diff --git a/api/filters/imagetag/doc.go b/api/filters/imagetag/doc.go new file mode 100644 index 000000000..d919491dd --- /dev/null +++ b/api/filters/imagetag/doc.go @@ -0,0 +1,12 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +// Package imagetag contains two kio.Filter implementations to cover the +// functionality of the kustomize imagetag transformer. +// +// Filter updates fields based on a FieldSpec and an ImageTag. +// +// LegacyFilter doesn't use a FieldSpec, and instead only updates image +// references if the field is name image and it is underneath a field called +// either containers or initContainers. +package imagetag diff --git a/api/filters/imagetag/example_test.go b/api/filters/imagetag/example_test.go new file mode 100644 index 000000000..7c0c8da06 --- /dev/null +++ b/api/filters/imagetag/example_test.go @@ -0,0 +1,126 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "bytes" + "log" + "os" + + "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/kustomize/kyaml/kio" +) + +func ExampleFilter() { + err := kio.Pipeline{ + Inputs: []kio.Reader{&kio.ByteReader{Reader: bytes.NewBufferString(` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - name: FooBar + image: nginx +--- +apiVersion: example.com/v1 +kind: Bar +metadata: + name: instance +spec: + containers: + - name: BarFoo + image: nginx:1.2.1 +`)}}, + Filters: []kio.Filter{Filter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + Digest: "12345", + }, + FsSlice: []types.FieldSpec{ + { + Path: "spec/containers[]/image", + }, + }, + }}, + Outputs: []kio.Writer{kio.ByteWriter{Writer: os.Stdout}}, + }.Execute() + if err != nil { + log.Fatal(err) + } + + // Output: + // apiVersion: example.com/v1 + // kind: Foo + // metadata: + // name: instance + // spec: + // containers: + // - name: FooBar + // image: apache@12345 + // --- + // apiVersion: example.com/v1 + // kind: Bar + // metadata: + // name: instance + // spec: + // containers: + // - name: BarFoo + // image: apache@12345 +} + +func ExampleLegacyFilter() { + err := kio.Pipeline{ + Inputs: []kio.Reader{&kio.ByteReader{Reader: bytes.NewBufferString(` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - name: FooBar + image: nginx +--- +apiVersion: example.com/v1 +kind: Bar +metadata: + name: instance +spec: + containers: + - name: BarFoo + image: nginx:1.2.1 +`)}}, + Filters: []kio.Filter{LegacyFilter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + Digest: "12345", + }, + }}, + Outputs: []kio.Writer{kio.ByteWriter{Writer: os.Stdout}}, + }.Execute() + if err != nil { + log.Fatal(err) + } + + // Output: + // apiVersion: example.com/v1 + // kind: Foo + // metadata: + // name: instance + // spec: + // containers: + // - name: FooBar + // image: apache@12345 + // --- + // apiVersion: example.com/v1 + // kind: Bar + // metadata: + // name: instance + // spec: + // containers: + // - name: BarFoo + // image: apache@12345 +} diff --git a/api/filters/imagetag/imagetag.go b/api/filters/imagetag/imagetag.go new file mode 100644 index 000000000..eb78a2af5 --- /dev/null +++ b/api/filters/imagetag/imagetag.go @@ -0,0 +1,44 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "sigs.k8s.io/kustomize/api/filters/fsslice" + "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +type Filter struct { + // imageTag is the tag we want to apply to the inputs + ImageTag types.Image `json:"imageTag,omitempty" yaml:"imageTag,omitempty"` + + // FsSlice contains the FieldSpecs to locate the namespace field + FsSlice types.FsSlice `json:"fieldSpecs,omitempty" yaml:"fieldSpecs,omitempty"` +} + +var _ kio.Filter = Filter{} + +func (f Filter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error) { + _, err := kio.FilterAll(yaml.FilterFunc(f.filter)).Filter(nodes) + return nodes, err +} + +func (f Filter) filter(node *yaml.RNode) (*yaml.RNode, error) { + if err := node.PipeE(fsslice.Filter{ + FsSlice: f.FsSlice, + SetValue: updateImageTagFn(f.ImageTag), + }); err != nil { + return nil, err + } + return node, nil +} + +func updateImageTagFn(imageTag types.Image) fsslice.SetFn { + return func(node *yaml.RNode) error { + return node.PipeE(imageTagUpdater{ + ImageTag: imageTag, + }) + } +} diff --git a/api/filters/imagetag/imagetag_test.go b/api/filters/imagetag/imagetag_test.go new file mode 100644 index 000000000..2b71fb2ce --- /dev/null +++ b/api/filters/imagetag/imagetag_test.go @@ -0,0 +1,101 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + filtertest "sigs.k8s.io/kustomize/api/testutils/filtertest" + "sigs.k8s.io/kustomize/api/types" +) + +func TestImageTagUpdater_Filter(t *testing.T) { + testCases := map[string]struct { + input string + expectedOutput string + filter Filter + fsSlice types.FsSlice + }{ + "update with digest": { + input: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + image: nginx:1.2.1 +`, + expectedOutput: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + image: apache@12345 +`, + filter: Filter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + Digest: "12345", + }, + }, + fsSlice: []types.FieldSpec{ + { + Path: "spec/image", + }, + }, + }, + "multiple matches in sequence": { + input: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: nginx:1.2.1 + - image: not_nginx@54321 + - image: nginx:1.2.1 +`, + expectedOutput: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: apache:3.2.1 + - image: not_nginx@54321 + - image: apache:3.2.1 +`, + filter: Filter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + NewTag: "3.2.1", + }, + }, + fsSlice: []types.FieldSpec{ + { + Path: "spec/containers/image", + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + filter := tc.filter + filter.FsSlice = tc.fsSlice + if !assert.Equal(t, + strings.TrimSpace(tc.expectedOutput), + strings.TrimSpace(filtertest.RunFilter(t, tc.input, filter))) { + t.FailNow() + } + }) + } +} diff --git a/api/filters/imagetag/legacy.go b/api/filters/imagetag/legacy.go new file mode 100644 index 000000000..5014e9c82 --- /dev/null +++ b/api/filters/imagetag/legacy.go @@ -0,0 +1,113 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// LegacyFilter is an implementation of the kio.Filter interface +// that scans through the provided kyaml data structure and updates +// any values of any image fields that is inside a sequence under +// a field called either containers or initContainers. The field is only +// update if it has a value that matches and image reference and the name +// of the image is a match with the provided ImageTag. +type LegacyFilter struct { + ImageTag types.Image `json:"imageTag,omitempty" yaml:"imageTag,omitempty"` +} + +var _ kio.Filter = LegacyFilter{} + +func (lf LegacyFilter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error) { + return kio.FilterAll(yaml.FilterFunc(lf.filter)).Filter(nodes) +} + +func (lf LegacyFilter) filter(node *yaml.RNode) (*yaml.RNode, error) { + meta, err := node.GetMeta() + if err != nil { + return nil, err + } + + // We do not make any changes if the type of the resource + // is CustomResourceDefinition. + if meta.Kind == `CustomResourceDefinition` { + return node, nil + } + + fff := findFieldsFilter{ + fields: []string{"containers", "initContainers"}, + fieldCallback: checkImageTagsFn(lf.ImageTag), + } + if err := node.PipeE(fff); err != nil { + return nil, err + } + return node, nil +} + +type fieldCallback func(node *yaml.RNode) error + +// findFieldsFilter is an implementation of the kio.Filter +// interface. It will walk the data structure and look for fields +// that matches the provided list of field names. For each match, +// the value of the field will be passed in as a parameter to the +// provided fieldCallback. +// TODO: move this to kyaml/filterutils +type findFieldsFilter struct { + fields []string + + fieldCallback fieldCallback +} + +func (f findFieldsFilter) Filter(obj *yaml.RNode) (*yaml.RNode, error) { + return obj, f.walk(obj) +} + +func (f findFieldsFilter) walk(node *yaml.RNode) error { + switch node.YNode().Kind { + case yaml.MappingNode: + return node.VisitFields(func(n *yaml.MapNode) error { + err := f.walk(n.Value) + if err != nil { + return err + } + key := n.Key.YNode().Value + if contains(f.fields, key) { + return f.fieldCallback(n.Value) + } + return nil + }) + case yaml.SequenceNode: + return node.VisitElements(func(n *yaml.RNode) error { + return f.walk(n) + }) + } + return nil +} + +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} + +func checkImageTagsFn(imageTag types.Image) fieldCallback { + return func(node *yaml.RNode) error { + if node.YNode().Kind != yaml.SequenceNode { + return nil + } + + return node.VisitElements(func(n *yaml.RNode) error { + // Look up any fields on the provided node that is named + // image. + return n.PipeE(yaml.Get("image"), imageTagUpdater{ + ImageTag: imageTag, + }) + }) + } +} diff --git a/api/filters/imagetag/legacy_test.go b/api/filters/imagetag/legacy_test.go new file mode 100644 index 000000000..28796b060 --- /dev/null +++ b/api/filters/imagetag/legacy_test.go @@ -0,0 +1,136 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + filtertest "sigs.k8s.io/kustomize/api/testutils/filtertest" + "sigs.k8s.io/kustomize/api/types" +) + +func TestLegacyImageTag_Filter(t *testing.T) { + testCases := map[string]struct { + input string + expectedOutput string + filter LegacyFilter + }{ + "updates multiple images inside containers": { + input: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: nginx:1.2.1 + - image: nginx:2.1.2 +`, + expectedOutput: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: apache@12345 + - image: apache@12345 +`, + filter: LegacyFilter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + Digest: "12345", + }, + }, + }, + "updates inside both containers and initContainers": { + input: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: nginx:1.2.1 + - image: tomcat:1.2.3 + initContainers: + - image: nginx:1.2.1 + - image: apache:1.2.3 +`, + expectedOutput: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: apache:3.2.1 + - image: tomcat:1.2.3 + initContainers: + - image: apache:3.2.1 + - image: apache:1.2.3 +`, + filter: LegacyFilter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + NewTag: "3.2.1", + }, + }, + }, + "updates on multiple depths": { + input: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: nginx:1.2.1 + - image: tomcat:1.2.3 + template: + spec: + initContainers: + - image: nginx:1.2.1 + - image: apache:1.2.3 +`, + expectedOutput: ` +apiVersion: example.com/v1 +kind: Foo +metadata: + name: instance +spec: + containers: + - image: apache:3.2.1 + - image: tomcat:1.2.3 + template: + spec: + initContainers: + - image: apache:3.2.1 + - image: apache:1.2.3 +`, + filter: LegacyFilter{ + ImageTag: types.Image{ + Name: "nginx", + NewName: "apache", + NewTag: "3.2.1", + }, + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, func(t *testing.T) { + filter := tc.filter + if !assert.Equal(t, + strings.TrimSpace(tc.expectedOutput), + strings.TrimSpace(filtertest.RunFilter(t, tc.input, filter))) { + t.FailNow() + } + }) + } +} diff --git a/api/filters/imagetag/updater.go b/api/filters/imagetag/updater.go new file mode 100644 index 000000000..1c3637cde --- /dev/null +++ b/api/filters/imagetag/updater.go @@ -0,0 +1,43 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package imagetag + +import ( + "sigs.k8s.io/kustomize/api/image" + "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// imageTagUpdater is an implementation of the kio.Filter interface +// that will update the value of the yaml node based on the provided +// ImageTag if the current value matches the format of an image reference. +type imageTagUpdater struct { + Kind string `yaml:"kind,omitempty"` + ImageTag types.Image `yaml:"imageTag,omitempty"` +} + +func (u imageTagUpdater) Filter(rn *yaml.RNode) (*yaml.RNode, error) { + if err := yaml.ErrorIfInvalid(rn, yaml.ScalarNode); err != nil { + return nil, err + } + + value := rn.YNode().Value + + if !image.IsImageMatched(value, u.ImageTag.Name) { + return rn, nil + } + + name, tag := image.Split(value) + if u.ImageTag.NewName != "" { + name = u.ImageTag.NewName + } + if u.ImageTag.NewTag != "" { + tag = ":" + u.ImageTag.NewTag + } + if u.ImageTag.Digest != "" { + tag = "@" + u.ImageTag.Digest + } + + return rn.Pipe(yaml.FieldSetter{StringValue: name + tag}) +} diff --git a/api/image/image.go b/api/image/image.go new file mode 100644 index 000000000..059999062 --- /dev/null +++ b/api/image/image.go @@ -0,0 +1,50 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "regexp" + "strings" +) + +// IsImageMatched returns true if the value of t is identical to the +// image name in the full image name and tag as given by s. +func IsImageMatched(s, t string) bool { + // Tag values are limited to [a-zA-Z0-9_.{}-]. + // Some tools like Bazel rules_k8s allow tag patterns with {} characters. + // More info: https://github.com/bazelbuild/rules_k8s/pull/423 + pattern, _ := regexp.Compile("^" + t + "(@sha256)?(:[a-zA-Z0-9_.{}-]*)?$") + return pattern.MatchString(s) +} + +// Split separates and returns the name and tag parts +// from the image string using either colon `:` or at `@` separators. +// Note that the returned tag keeps its separator. +func Split(imageName string) (name string, tag string) { + // check if image name contains a domain + // if domain is present, ignore domain and check for `:` + ic := -1 + if slashIndex := strings.Index(imageName, "/"); slashIndex < 0 { + ic = strings.LastIndex(imageName, ":") + } else { + lastIc := strings.LastIndex(imageName[slashIndex:], ":") + // set ic only if `:` is present + if lastIc > 0 { + ic = slashIndex + lastIc + } + } + ia := strings.LastIndex(imageName, "@") + if ic < 0 && ia < 0 { + return imageName, "" + } + + i := ic + if ia > 0 { + i = ia + } + + name = imageName[:i] + tag = imageName[i:] + return +} diff --git a/api/image/image_test.go b/api/image/image_test.go new file mode 100644 index 000000000..c3526490e --- /dev/null +++ b/api/image/image_test.go @@ -0,0 +1,80 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsImageMatched(t *testing.T) { + testCases := []struct { + testName string + value string + name string + isMatched bool + }{ + { + testName: "identical", + value: "nginx", + name: "nginx", + isMatched: true, + }, + { + testName: "name is match", + value: "nginx:12345", + name: "nginx", + isMatched: true, + }, + { + testName: "name is not a match", + value: "apache:12345", + name: "nginx", + isMatched: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + assert.Equal(t, tc.isMatched, IsImageMatched(tc.value, tc.name)) + }) + } +} + +func TestSplit(t *testing.T) { + testCases := []struct { + testName string + value string + name string + tag string + }{ + { + testName: "no tag", + value: "nginx", + name: "nginx", + tag: "", + }, + { + testName: "with tag", + value: "nginx:1.2.3", + name: "nginx", + tag: ":1.2.3", + }, + { + testName: "with digest", + value: "nginx@12345", + name: "nginx", + tag: "@12345", + }, + } + + for _, tc := range testCases { + t.Run(tc.testName, func(t *testing.T) { + name, tag := Split(tc.value) + assert.Equal(t, tc.name, name) + assert.Equal(t, tc.tag, tag) + }) + } +}