diff --git a/ssa/go.mod b/ssa/go.mod index 3c7215285..a80701d1b 100644 --- a/ssa/go.mod +++ b/ssa/go.mod @@ -2,9 +2,15 @@ module github.com/fluxcd/pkg/ssa go 1.20 +// Fix CVE-2022-28948 +replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1 + require ( + github.com/evanphx/json-patch/v5 v5.6.0 github.com/google/go-cmp v0.5.9 github.com/onsi/gomega v1.27.10 + // TODO: unpin when https://github.com/wI2L/jsondiff/pull/14 has ended up in a release. + github.com/wI2L/jsondiff v0.4.1-0.20230626084051-c85fb8ce3cac golang.org/x/sync v0.3.0 k8s.io/api v0.27.4 k8s.io/apimachinery v0.27.4 @@ -15,9 +21,6 @@ require ( sigs.k8s.io/yaml v1.3.0 ) -// Fix CVE-2022-28948 -replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1 - require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect @@ -25,7 +28,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/logr v1.2.4 // indirect diff --git a/ssa/go.sum b/ssa/go.sum index 2f596bb81..8223e43cf 100644 --- a/ssa/go.sum +++ b/ssa/go.sum @@ -161,6 +161,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/wI2L/jsondiff v0.4.1-0.20230626084051-c85fb8ce3cac h1:X+MGDuQHQ2i4UoSsb2n4dESJoSCg7aTfvtk6Bj7nlcE= +github.com/wI2L/jsondiff v0.4.1-0.20230626084051-c85fb8ce3cac/go.mod h1:nR/vyy1efuDeAtMwc3AF6nZf/2LD1ID8GTyyJ+K8YB0= github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/ssa/jsondiff/diff.go b/ssa/jsondiff/diff.go new file mode 100644 index 000000000..63f76a8a4 --- /dev/null +++ b/ssa/jsondiff/diff.go @@ -0,0 +1,74 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ChangeType is the type of change detected by the server-side apply diff +// operation. +type ChangeType string + +const ( + // ChangeTypeCreate indicates that the resource does not exist + // and needs to be created. + ChangeTypeCreate ChangeType = "create" + // ChangeTypeUpdate indicates that the resource exists and needs + // to be updated. + ChangeTypeUpdate ChangeType = "update" + // ChangeTypeExclude indicates that the resource is excluded from + // the diff. + ChangeTypeExclude ChangeType = "exclude" + // ChangeTypeNone indicates that the resource exists and is + // identical to the dry-run object. + ChangeTypeNone ChangeType = "none" +) + +// Change is a change detected by the server-side apply diff operation. +type Change struct { + // Type of change detected. + Type ChangeType + + // GroupVersionKind of the resource the Patch applies to. + GroupVersionKind schema.GroupVersionKind + + // Namespace of the resource the Patch applies to. + Namespace string + + // Name of the resource the Patch applies to. + Name string + + // Patch with the changes detected for the resource. + Patch jsondiff.Patch +} + +// NewChangeForUnstructured creates a new Change for the given unstructured object. +func NewChangeForUnstructured(obj *unstructured.Unstructured, t ChangeType, p jsondiff.Patch) *Change { + return &Change{ + Type: t, + GroupVersionKind: obj.GetObjectKind().GroupVersionKind(), + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + Patch: p, + } +} + +// ChangeSet is a list of changes. +type ChangeSet []*Change diff --git a/ssa/jsondiff/mask.go b/ssa/jsondiff/mask.go new file mode 100644 index 000000000..d01bc1378 --- /dev/null +++ b/ssa/jsondiff/mask.go @@ -0,0 +1,88 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "github.com/wI2L/jsondiff" + "strings" +) + +const ( + sensitiveMaskDefault = "***" + sensitiveMaskBefore = "*** (before)" + sensitiveMaskAfter = "*** (after)" +) + +// MaskSecretPatchData masks the data and stringData fields of a Secret object +// in the given JSON patch. It replaces the values with a default mask value if +// the field is added or removed. Otherwise, it replaces the values with a +// before/after mask value if the field is modified. +func MaskSecretPatchData(patch jsondiff.Patch) jsondiff.Patch { + for i := range patch { + v := &patch[i] + oldMaskValue, newMaskValue := sensitiveMaskDefault, sensitiveMaskDefault + + if v.OldValue != nil && v.Value != nil { + oldMaskValue = sensitiveMaskBefore + newMaskValue = sensitiveMaskAfter + } + + switch { + case v.Path == "/data" || v.Path == "/stringData": + maskMap(v.OldValue, v.Value) + case strings.HasPrefix(v.Path, "/data/") || strings.HasPrefix(v.Path, "/stringData/"): + if v.OldValue != nil { + v.OldValue = oldMaskValue + } + if v.Value != nil { + v.Value = newMaskValue + } + } + } + return patch +} + +// maskMap replaces the values with a default mask value if a field is added or +// removed. Otherwise, it replaces the values with a before/after mask value if +// the field is modified. +func maskMap(from interface{}, to interface{}) { + fromMap, fromIsMap := from.(map[string]interface{}) + if !fromIsMap || fromMap == nil { + fromMap = make(map[string]interface{}) + } + + toMap, toIsMap := to.(map[string]interface{}) + if !toIsMap || toMap == nil { + toMap = make(map[string]interface{}) + } + + for k := range fromMap { + if _, ok := toMap[k]; ok { + if fromMap[k] != toMap[k] { + fromMap[k] = sensitiveMaskBefore + toMap[k] = sensitiveMaskAfter + continue + } + } + fromMap[k] = sensitiveMaskDefault + } + for k := range toMap { + if _, ok := fromMap[k]; !ok { + toMap[k] = sensitiveMaskDefault + } + } +} diff --git a/ssa/jsondiff/mask_test.go b/ssa/jsondiff/mask_test.go new file mode 100644 index 000000000..36ff72dfc --- /dev/null +++ b/ssa/jsondiff/mask_test.go @@ -0,0 +1,175 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "reflect" + "testing" + + "github.com/wI2L/jsondiff" +) + +func TestMaskSecretPatchData(t *testing.T) { + tests := []struct { + name string + patch jsondiff.Patch + want jsondiff.Patch + }{ + { + name: "masks replace data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data/foo", OldValue: "bar", Value: "baz"}, + {Type: jsondiff.OperationReplace, Path: "/data/bar", OldValue: "foo", Value: "baz"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data/foo", OldValue: sensitiveMaskBefore, Value: sensitiveMaskAfter}, + {Type: jsondiff.OperationReplace, Path: "/data/bar", OldValue: sensitiveMaskBefore, Value: sensitiveMaskAfter}, + }, + }, + { + name: "masks add data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/data/foo", Value: "baz"}, + {Type: jsondiff.OperationAdd, Path: "/data/bar", Value: "baz"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/data/foo", Value: sensitiveMaskDefault}, + {Type: jsondiff.OperationAdd, Path: "/data/bar", Value: sensitiveMaskDefault}, + }, + }, + { + name: "masks remove data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/data/foo", OldValue: "bar"}, + {Type: jsondiff.OperationRemove, Path: "/data/bar", OldValue: "foo"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/data/foo", OldValue: sensitiveMaskDefault}, + {Type: jsondiff.OperationRemove, Path: "/data/bar", OldValue: sensitiveMaskDefault}, + }, + }, + { + name: "masks rationalized replace data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data", OldValue: map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, Value: map[string]interface{}{ + "foo": "baz", + "bar": "baz", + }}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data", OldValue: map[string]interface{}{ + "foo": sensitiveMaskBefore, + "bar": sensitiveMaskBefore, + }, Value: map[string]interface{}{ + "foo": sensitiveMaskAfter, + "bar": sensitiveMaskAfter, + }, + }}, + }, + { + name: "masks rationalized add data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/data", Value: map[string]interface{}{ + "foo": "baz", + "bar": "baz", + }}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/data", Value: map[string]interface{}{ + "foo": sensitiveMaskDefault, + "bar": sensitiveMaskDefault, + }}, + }, + }, + { + name: "masks rationalized remove data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/data", OldValue: map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/data", OldValue: map[string]interface{}{ + "foo": sensitiveMaskDefault, + "bar": sensitiveMaskDefault, + }}, + }, + }, + { + name: "masks rationalized replace complex data values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data", OldValue: map[string]interface{}{ + // Changed key + "foo": "bar", + // Removed key + "bar": "baz", + }, Value: map[string]interface{}{ + "foo": "baz", + // Added key + "baz": "bar", + }}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data", OldValue: map[string]interface{}{ + "foo": sensitiveMaskBefore, + "bar": sensitiveMaskDefault, + }, Value: map[string]interface{}{ + "foo": sensitiveMaskAfter, + "baz": sensitiveMaskDefault, + }}, + }, + }, + { + name: "masks replace stringData values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/stringData/foo", OldValue: "bar", Value: "baz"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/stringData/foo", OldValue: sensitiveMaskBefore, Value: sensitiveMaskAfter}, + }, + }, + { + name: "masks add stringData values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/stringData/foo", Value: "baz"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/stringData/foo", Value: sensitiveMaskDefault}, + }, + }, + { + name: "masks remove stringData values", + patch: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/stringData/foo", OldValue: "bar"}, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/stringData/foo", OldValue: sensitiveMaskDefault}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MaskSecretPatchData(tt.patch); !reflect.DeepEqual(got, tt.want) { + t.Errorf("maskUnstructuredSecretData() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ssa/jsondiff/options.go b/ssa/jsondiff/options.go new file mode 100644 index 000000000..f7a1976e5 --- /dev/null +++ b/ssa/jsondiff/options.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +// ResourceOption is some configuration that modifies the diffing behavior for +// a single resource. +type ResourceOption interface { + ApplyToResource(options *ResourceOptions) +} + +// ListOption is some configuration that modifies the diffing behavior for +// a set of resources. +type ListOption interface { + ApplyToList(options *ListOptions) +} + +// ResourceOptions holds options for the server-side apply diff operation. +type ResourceOptions struct { + // FieldManager is the name of the user or component submitting + // the server-side apply request. + FieldOwner string + // IgnorePaths is a list of JSON pointers to ignore when comparing objects. + IgnorePaths []string + // MaskSecrets is a flag to mask secrets in the diff. + MaskSecrets bool +} + +// ApplyOptions applies the given options on these options, and then returns +// itself (for convenient chaining). +func (o *ResourceOptions) ApplyOptions(opts []ResourceOption) *ResourceOptions { + for _, opt := range opts { + opt.ApplyToResource(o) + } + return o +} + +// ListOptions holds options for the server-side apply diff operation. +type ListOptions struct { + // FieldManager is the name of the user or component submitting + // the server-side apply request. + FieldManager string + // ExclusionSelectors is a map of annotations or labels which mark a + // resource to be excluded from the server-side apply diff. + ExclusionSelectors map[string]string + // IgnorePathSelectors is a list of selectors that match resources + // to ignore JSON pointers in. + IgnorePathSelectors []IgnorePathSelector +} + +// ApplyOptions applies the given options on these options, and then returns +// itself (for convenient chaining). +func (o *ListOptions) ApplyOptions(opts []ListOption) *ListOptions { + for _, opt := range opts { + opt.ApplyToList(o) + } + return o +} + +// FieldOwner sets the field manager for the server-side apply request. +type FieldOwner string + +// ApplyToResource applies this configuration to the given options. +func (f FieldOwner) ApplyToResource(opts *ResourceOptions) { + opts.FieldOwner = string(f) +} + +// ApplyToList applies this configuration to the given options. +func (f FieldOwner) ApplyToList(opts *ListOptions) { + opts.FieldManager = string(f) +} + +// ExclusionSelector sets the annotations or labels which mark a resource to +// be excluded from the server-side apply diff. +type ExclusionSelector map[string]string + +// ApplyToList applies this configuration to the given options. +func (e ExclusionSelector) ApplyToList(opts *ListOptions) { + opts.ExclusionSelectors = e +} + +// IgnorePaths sets the JSON pointers to ignore when comparing objects. +type IgnorePaths []string + +// ApplyToResource applies this configuration to the given options. +func (i IgnorePaths) ApplyToResource(opts *ResourceOptions) { + opts.IgnorePaths = i +} + +// IgnorePathSelectors sets the JSON pointers to ignore when comparing objects. +type IgnorePathSelectors []IgnorePathSelector + +// ApplyToList applies this configuration to the given options. +func (i IgnorePathSelectors) ApplyToList(opts *ListOptions) { + opts.IgnorePathSelectors = i +} + +// MaskSecrets sets the flag to mask secrets in the diff. +type MaskSecrets bool + +// ApplyToResource applies this configuration to the given options. +func (m MaskSecrets) ApplyToResource(opts *ResourceOptions) { + opts.MaskSecrets = bool(m) +} diff --git a/ssa/jsondiff/patch.go b/ssa/jsondiff/patch.go new file mode 100644 index 000000000..812fac09f --- /dev/null +++ b/ssa/jsondiff/patch.go @@ -0,0 +1,83 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "encoding/json" + "fmt" + + jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// GenerateRemovePatch generates a JSON patch that removes the given JSON +// pointer paths. +func GenerateRemovePatch(paths ...string) jsondiff.Patch { + var patch jsondiff.Patch + for _, p := range paths { + patch = append(patch, jsondiff.Operation{ + Type: jsondiff.OperationRemove, + Path: p, + }) + } + return patch +} + +// ApplyPatchToUnstructured applies the given JSON patch to the given +// unstructured object. The patch is applied in-place. +// It permits the patch to contain "remove" operations that target non-existing +// paths. +func ApplyPatchToUnstructured(obj *unstructured.Unstructured, patch jsondiff.Patch) error { + if len(patch) == 0 { + return nil + } + + uJSON, err := obj.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to marshal unstructured object: %w", err) + } + + // Slightly awkward conversion from jsondiff.Patch to jsonpatch.Patch. + // This is necessary because the jsondiff library does not support applying + // patches, while the jsonpatch library does not support generating patches. + // To not expose this discrepancy to the user, we convert the jsondiff.Patch + // into a jsonpatch.Patch, and then apply it. + + patchJSON, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal JSON patch: %w", err) + } + + var patchApplier jsonpatch.Patch + if err = json.Unmarshal(patchJSON, &patchApplier); err != nil { + return fmt.Errorf("failed to transform jsondiff.Patch into jsonpatch.Patch: %w", err) + } + + if uJSON, err = patchApplier.ApplyWithOptions(uJSON, &jsonpatch.ApplyOptions{ + SupportNegativeIndices: true, + AccumulatedCopySizeLimit: 0, + AllowMissingPathOnRemove: true, + }); err != nil { + return err + } + + if err := obj.UnmarshalJSON(uJSON); err != nil { + return fmt.Errorf("failed to unmarshal patched JSON into unstructured object: %w", err) + } + return nil +} diff --git a/ssa/jsondiff/selector.go b/ssa/jsondiff/selector.go new file mode 100644 index 000000000..5e3c8fcf6 --- /dev/null +++ b/ssa/jsondiff/selector.go @@ -0,0 +1,228 @@ +/* +Copyright 2019 The Kubernetes Authors +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Much of the code in this file is derived from Kustomize: + +https://github.com/kubernetes-sigs/kustomize/blob/4b34ff3075c79b0d52493cdc60cf45e075f77372/api/types/selector.go +https://github.com/kubernetes-sigs/kustomize/blob/fb7ee2f4871d4ef054ecd9d2e1bc9b10cbfde4a9/kyaml/yaml/rnode.go#L1154-L1170 +*/ + +package jsondiff + +import ( + "regexp" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +// Selector is a struct that contains the information needed to select a +// Kubernetes resource. All fields are optional. +type Selector struct { + // Group defines a regular expression to filter resources by their + // API group. + Group string + + // Version defines a regular expression to filter resources by their + // API version. + Version string + + // Kind defines a regular expression to filter resources by their + // API kind. + Kind string + + // Name defines a regular expression to filter resources by their + // name. + Name string + + // Namespace defines a regular expression to filter resources by their + // namespace. + Namespace string + + // AnnotationSelector defines a selector to filter resources by their + // annotations in the format of a label selection expression. + // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + AnnotationSelector string + + // LabelSelector defines a selector to filter resources by their labels + // in the format of a label selection expression. + // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + LabelSelector string +} + +// SelectorRegex is a struct that contains the regular expressions needed to +// select a Kubernetes resource. +type SelectorRegex struct { + selector *Selector + groupRegex *regexp.Regexp + versionRegex *regexp.Regexp + kindRegex *regexp.Regexp + nameRegex *regexp.Regexp + namespaceRegex *regexp.Regexp + labelSelector labels.Selector + annotationSelector labels.Selector +} + +// NewSelectorRegex returns a pointer to a new SelectorRegex +// which uses the same condition as s. +func NewSelectorRegex(s *Selector) (sr *SelectorRegex, err error) { + if s == nil { + return nil, nil + } + + sr = &SelectorRegex{ + selector: s, + } + + sr.groupRegex, err = regexp.Compile(anchorRegex(s.Group)) + if err != nil { + return nil, err + } + sr.versionRegex, err = regexp.Compile(anchorRegex(s.Version)) + if err != nil { + return nil, err + } + sr.kindRegex, err = regexp.Compile(anchorRegex(s.Kind)) + if err != nil { + return nil, err + } + sr.nameRegex, err = regexp.Compile(anchorRegex(s.Name)) + if err != nil { + return nil, err + } + sr.namespaceRegex, err = regexp.Compile(anchorRegex(s.Namespace)) + if err != nil { + return nil, err + } + + if s.LabelSelector != "" { + sr.labelSelector, err = labels.Parse(s.LabelSelector) + if err != nil { + return nil, err + } + } + if s.AnnotationSelector != "" { + sr.annotationSelector, err = labels.Parse(s.AnnotationSelector) + if err != nil { + return nil, err + } + } + + return sr, nil +} + +// MatchUnstructured returns true if the unstructured object matches all the +// conditions in the selector. If the selector is nil, it returns true. +func (s *SelectorRegex) MatchUnstructured(obj *unstructured.Unstructured) bool { + if s == nil { + return true + } + + if !s.MatchNamespace(obj.GetNamespace()) { + return false + } + + if !s.MatchName(obj.GetName()) { + return false + } + + gvk := obj.GetObjectKind().GroupVersionKind() + if !s.MatchGVK(gvk.Group, gvk.Version, gvk.Kind) { + return false + } + + if !s.MatchLabelSelector(obj.GetLabels()) { + return false + } + + if !s.MatchAnnotationSelector(obj.GetAnnotations()) { + return false + } + + return true +} + +// MatchGVK returns true if the group, version and kind in selector are empty +// or the group, version and kind match the group, version and kind in selector. +// If the selector is nil, it returns true. +func (s *SelectorRegex) MatchGVK(group, version, kind string) bool { + if s == nil { + return true + } + + if len(s.selector.Group) > 0 { + if !s.groupRegex.MatchString(group) { + return false + } + } + if len(s.selector.Version) > 0 { + if !s.versionRegex.MatchString(version) { + return false + } + } + if len(s.selector.Kind) > 0 { + if !s.kindRegex.MatchString(kind) { + return false + } + } + return true +} + +// MatchName returns true if the name in selector is empty or the name matches +// the name in selector. If the selector is nil, it returns true. +func (s *SelectorRegex) MatchName(n string) bool { + if s == nil || s.selector.Name == "" { + return true + } + return s.nameRegex.MatchString(n) +} + +// MatchNamespace returns true if the namespace in selector is empty or the +// namespace matches the namespace in selector. If the selector is nil, it +// returns true. +func (s *SelectorRegex) MatchNamespace(ns string) bool { + if s == nil || s.selector.Namespace == "" { + return true + } + return s.namespaceRegex.MatchString(ns) +} + +// MatchAnnotationSelector returns true if the annotation selector in selector +// is empty or the annotation selector matches the annotations in selector. +// If the selector is nil, it returns true. +func (s *SelectorRegex) MatchAnnotationSelector(a map[string]string) bool { + if s == nil || s.selector.AnnotationSelector == "" { + return true + } + return s.annotationSelector.Matches(labels.Set(a)) +} + +// MatchLabelSelector returns true if the label selector in selector is empty +// or the label selector matches the labels in selector. If the selector is +// nil, it returns true. +func (s *SelectorRegex) MatchLabelSelector(l map[string]string) bool { + if s == nil || s.selector.LabelSelector == "" { + return true + } + return s.labelSelector.Matches(labels.Set(l)) +} + +func anchorRegex(pattern string) string { + if pattern == "" { + return pattern + } + return "^(?:" + pattern + ")$" +} diff --git a/ssa/jsondiff/selector_test.go b/ssa/jsondiff/selector_test.go new file mode 100644 index 000000000..8fff87690 --- /dev/null +++ b/ssa/jsondiff/selector_test.go @@ -0,0 +1,199 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestSelectorRegex_MatchUnstructured(t *testing.T) { + tests := []struct { + name string + selector *Selector + u *unstructured.Unstructured + want bool + }{ + { + name: "valid input", + selector: &Selector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "name-.*", + Namespace: "namespace-.*", + LabelSelector: "foo.bar/label in (a, b)", + AnnotationSelector: "foo.bar/annotation notin (c)", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "name-1", + "namespace": "namespace-1", + "labels": map[string]interface{}{ + "foo.bar/label": "a", + }, + "annotations": map[string]interface{}{ + "foo.bar/annotation": "d", + }, + }, + }, + }, + want: true, + }, + { + name: "nil selector", + selector: nil, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "anything", + "namespace": "anything", + }, + }, + }, + want: true, + }, + { + name: "mismatched namespace", + selector: &Selector{ + Namespace: "exact-namespace", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "namespace": "other-namespace", + }, + }, + }, + want: false, + }, + { + name: "mismatched name", + selector: &Selector{ + Name: "exact-name", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "other-name", + }, + }, + }, + want: false, + }, + { + name: "mismatched GVK", + selector: &Selector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + }, + }, + want: false, + }, + { + name: "mismatched label", + selector: &Selector{ + LabelSelector: "foo.bar/label in (a, b)", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo.bar/label": "c", + }, + }, + }, + }, + }, + { + name: "mismatched annotation", + selector: &Selector{ + AnnotationSelector: "foo.bar/annotation notin (c)", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo.bar/annotation": "c", + }, + }, + }, + }, + want: false, + }, + { + name: "combination of mismatches", + selector: &Selector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "name-.*", + Namespace: "namespace-.*", + LabelSelector: "foo.bar/label in (a, b)", + AnnotationSelector: "foo.bar/annotation notin (c)", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": map[string]interface{}{ + "name": "other-name-1", + "namespace": "other-namespace-1", + "labels": map[string]interface{}{ + "foo.bar/label": "c", + }, + "annotations": map[string]interface{}{ + "foo.bar/annotation": "c", + }, + }, + }, + }, + want: false, + }, + { + name: "empty input object", + selector: &Selector{}, + u: &unstructured.Unstructured{}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := NewSelectorRegex(tt.selector) + if err != nil { + t.Errorf("NewSelectorRegex() error = %v", err) + return + } + + if got := s.MatchUnstructured(tt.u); got != tt.want { + t.Errorf("MatchUnstructured(%v) = %v, want %v", tt.u.Object, got, tt.want) + } + }) + } +} diff --git a/ssa/jsondiff/suite_test.go b/ssa/jsondiff/suite_test.go new file mode 100644 index 000000000..5e05993a3 --- /dev/null +++ b/ssa/jsondiff/suite_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "context" + "fmt" + "os" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + "github.com/fluxcd/pkg/ssa" +) + +var ( + testEnv *envtest.Environment + + testClient client.Client +) + +func TestMain(m *testing.M) { + testEnv = &envtest.Environment{} + + fmt.Println("Starting the test environment") + if _, err := testEnv.Start(); err != nil { + panic(fmt.Sprintf("Failed to start the test environment: %v", err)) + } + + c, err := client.New(testEnv.Config, client.Options{}) + if err != nil { + panic(fmt.Sprintf("Failed to create the client: %v", err)) + } + testClient = c + + code := m.Run() + + fmt.Println("Stopping the test environment") + if err := testEnv.Stop(); err != nil { + panic(fmt.Sprintf("Failed to stop the test environment: %v", err)) + } + os.Exit(code) +} + +// CreateNamespace creates a namespace with the given generateName. +func CreateNamespace(ctx context.Context, generateName string) (*corev1.Namespace, error) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-", generateName), + }, + } + if err := testClient.Create(ctx, ns); err != nil { + return nil, err + } + return ns, nil +} + +// LoadResource loads an unstructured.Unstructured resource from a file. +func LoadResource(p string) (*unstructured.Unstructured, error) { + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + return ssa.ReadObject(f) +} diff --git a/ssa/jsondiff/testdata/deployment.yaml b/ssa/jsondiff/testdata/deployment.yaml new file mode 100644 index 000000000..9632fb178 --- /dev/null +++ b/ssa/jsondiff/testdata/deployment.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "podinfo" + annotations: + kubernetes.io/ingress.class: nginx-internal + labels: + app: podinfo +spec: + minReadySeconds: 3 + revisionHistoryLimit: 5 + progressDeadlineSeconds: 60 + strategy: + rollingUpdate: + maxUnavailable: 0 + type: RollingUpdate + selector: + matchLabels: + app: podinfo + template: + metadata: + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9797" + labels: + app: podinfo + spec: + containers: + - name: podinfod + image: ghcr.io/stefanprodan/podinfo:6.0.3 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 9898 + protocol: TCP + - name: http-metrics + containerPort: 9797 + protocol: TCP + - name: grpc + containerPort: 9999 + protocol: TCP + command: + - ./podinfo + - --port=9898 + - --port-metrics=9797 + - --grpc-port=9999 + - --grpc-service-name=podinfo + - --level=info + - --random-delay=false + - --random-error=false + env: + - name: PODINFO_UI_COLOR + value: "#34577c" + livenessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/healthz + initialDelaySeconds: 5 + timeoutSeconds: 5 + readinessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/readyz + initialDelaySeconds: 5 + timeoutSeconds: 5 + resources: + limits: + cpu: 2000m + memory: 512Mi + requests: + cpu: 100m + memory: 64Mi diff --git a/ssa/jsondiff/testdata/empty-configmap.yaml b/ssa/jsondiff/testdata/empty-configmap.yaml new file mode 100644 index 000000000..ba4703816 --- /dev/null +++ b/ssa/jsondiff/testdata/empty-configmap.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: "configmap-data" diff --git a/ssa/jsondiff/testdata/empty-secret.yaml b/ssa/jsondiff/testdata/empty-secret.yaml new file mode 100644 index 000000000..3972b0122 --- /dev/null +++ b/ssa/jsondiff/testdata/empty-secret.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: "secret-data" diff --git a/ssa/jsondiff/testdata/service.yaml b/ssa/jsondiff/testdata/service.yaml new file mode 100644 index 000000000..85688e4e2 --- /dev/null +++ b/ssa/jsondiff/testdata/service.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: "podinfo" +spec: + type: ClusterIP + selector: + app: "podinfo" + ports: + - name: http + port: 9898 + protocol: TCP + targetPort: http + - port: 9999 + targetPort: grpc + protocol: TCP + name: grpc diff --git a/ssa/jsondiff/unstructured.go b/ssa/jsondiff/unstructured.go new file mode 100644 index 000000000..dca0023e1 --- /dev/null +++ b/ssa/jsondiff/unstructured.go @@ -0,0 +1,220 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "context" + "fmt" + "github.com/fluxcd/pkg/ssa" + "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// IgnorePathSelector contains the information needed to ignore certain paths +// in a (set of) Kubernetes resource(s). +type IgnorePathSelector struct { + // Paths is a list of JSON pointers to ignore. + Paths []string + // Selector is a selector that matches the resources to ignore. + Selector *Selector +} + +// UnstructuredList performs a server-side apply dry-run and returns a ChangeSet +// containing the changes detected. It takes a list of Kubernetes resources +// and a list of options. The options can be used to ignore certain paths in +// certain resources, or to ignore certain resources altogether. +func UnstructuredList(ctx context.Context, c client.Client, objs []*unstructured.Unstructured, opts ...ListOption) (ChangeSet, error) { + o := &ListOptions{} + o.ApplyOptions(opts) + + var sm = make(map[*SelectorRegex][]string, len(o.IgnorePathSelectors)) + for _, ips := range o.IgnorePathSelectors { + sr, err := NewSelectorRegex(ips.Selector) + if err != nil { + return nil, fmt.Errorf("failed to create selector regex: %w", err) + } + sm[sr] = ips.Paths + } + + var resOpts []ResourceOption + for _, ro := range opts { + if r, ok := ro.(ResourceOption); ok { + resOpts = append(resOpts, r) + } + } + + var changeSet ChangeSet + for _, obj := range objs { + obj := obj + + if ssa.AnyInMetadata(obj, o.ExclusionSelectors) { + changeSet = append(changeSet, NewChangeForUnstructured(obj, ChangeTypeExclude, nil)) + continue + } + + var ignorePaths IgnorePaths + for sr, paths := range sm { + if sr.MatchUnstructured(obj) { + ignorePaths = append(ignorePaths, paths...) + } + } + + change, err := Unstructured(ctx, c, obj, append(resOpts, ignorePaths)...) + if err != nil { + return nil, err + } + changeSet = append(changeSet, change) + } + return changeSet, nil +} + +// Unstructured performs a server-side apply dry-run and returns the type of change +// detected, and a JSON patch with the changes. If the resource does not exist, +// it returns ChangeTypeCreate. If the resource exists and is identical to the +// dry-run object, it returns ChangeTypeNone. Otherwise, it returns +// ChangeTypeUpdate and a JSON patch with the changes. +func Unstructured(ctx context.Context, c client.Client, obj *unstructured.Unstructured, opts ...ResourceOption) (*Change, error) { + o := &ResourceOptions{} + o.ApplyOptions(opts) + + existingObj := obj.DeepCopy() + if err := c.Get(ctx, client.ObjectKeyFromObject(obj), existingObj); client.IgnoreNotFound(err) != nil { + return nil, err + } + + dryRunObj := obj.DeepCopy() + patchOpts := []client.PatchOption{ + client.DryRunAll, + client.ForceOwnership, + client.FieldOwner(o.FieldOwner), + } + if err := c.Patch(ctx, dryRunObj, client.Apply, patchOpts...); err != nil { + return nil, err + } + + if dryRunObj.GetResourceVersion() == "" { + return NewChangeForUnstructured(obj, ChangeTypeCreate, nil), nil + } + + // Remove any ignored JSON pointers from the dry-run and existing objects. + if len(o.IgnorePaths) > 0 { + patch := GenerateRemovePatch(o.IgnorePaths...) + if err := ApplyPatchToUnstructured(dryRunObj, patch); err != nil { + return nil, err + } + if err := ApplyPatchToUnstructured(existingObj, patch); err != nil { + return nil, err + } + } + + // Calculate the JSON patch between the dry-run and existing objects. + var patch jsondiff.Patch + metaPatch, err := diffUnstructuredMetadata(existingObj, dryRunObj, o.IgnorePaths...) + if err != nil { + return nil, err + } + patch = append(patch, metaPatch...) + + resPatch, err := diffUnstructured(existingObj, dryRunObj) + if err != nil { + return nil, err + } + patch = append(patch, resPatch...) + + if len(patch) == 0 { + return NewChangeForUnstructured(obj, ChangeTypeNone, nil), nil + } + + // Mask secrets if requested. + if o.MaskSecrets { + if gvk := obj.GroupVersionKind(); gvk.Group == "" && gvk.Kind == "Secret" { + patch = MaskSecretPatchData(patch) + } + } + return NewChangeForUnstructured(obj, ChangeTypeUpdate, patch), nil +} + +// diffUnstructuredMetadata returns a JSON patch with the differences between +// the labels and annotations metadata of the given objects. It ignores other +// fields, and only returns "replace" and "add" changes. +func diffUnstructuredMetadata(x, y *unstructured.Unstructured, ignorePath ...string) (jsondiff.Patch, error) { + xMeta, yMeta := copyAnnotationsAndLabels(x), copyAnnotationsAndLabels(y) + patch, err := jsondiff.Compare(xMeta, yMeta, jsondiff.Ignores(ignorePath...)) + if err != nil { + return nil, fmt.Errorf("unable to compare annotations and labels of objects: %w", err) + } + + var filteredPatch jsondiff.Patch + for _, change := range patch { + switch change.Type { + case jsondiff.OperationReplace, jsondiff.OperationAdd: + filteredPatch = append(filteredPatch, change) + default: + // Ignore other changes (like "remove") to avoid false positives due + // to core Kubernetes controllers adding labels to resources. + } + } + + return filteredPatch, nil +} + +// diffUnstructured returns a JSON patch with the differences between the given +// objects while ignoring "metadata" and "status" fields. +func diffUnstructured(x, y *unstructured.Unstructured) (jsondiff.Patch, error) { + xSpec, ySpec := removeMetadataAndStatus(x), removeMetadataAndStatus(y) + diffOpts := []jsondiff.Option{ + // Rationalize to minimize the number of changes. This ensures that + // multiple changes to a path are combined into a single "replace" + // change instead of multiple remove and add operations. + jsondiff.Rationalize(), + } + patch, err := jsondiff.Compare(xSpec.Object, ySpec.Object, diffOpts...) + if err != nil { + return nil, fmt.Errorf("unable to compare objects: %w", err) + } + return patch, nil +} + +// copyAnnotationsAndLabels returns a copy of the given object with only the +// metadata annotations and labels fields set. +func copyAnnotationsAndLabels(obj *unstructured.Unstructured) *unstructured.Unstructured { + c := &unstructured.Unstructured{ + Object: make(map[string]interface{}), + } + + annotations, ok, _ := unstructured.NestedFieldCopy(obj.Object, "metadata", "annotations") + if ok { + _ = unstructured.SetNestedField(c.Object, annotations, "metadata", "annotations") + } + + labels, ok, _ := unstructured.NestedFieldCopy(obj.Object, "metadata", "labels") + if ok { + _ = unstructured.SetNestedField(c.Object, labels, "metadata", "labels") + } + + return c +} + +// removeMetadataAndStatus returns a copy of the given object with the metadata +// and status fields removed. +func removeMetadataAndStatus(obj *unstructured.Unstructured) *unstructured.Unstructured { + c := obj.DeepCopy() + unstructured.RemoveNestedField(c.Object, "metadata") + unstructured.RemoveNestedField(c.Object, "status") + return c +} diff --git a/ssa/jsondiff/unstructured_test.go b/ssa/jsondiff/unstructured_test.go new file mode 100644 index 000000000..cadac1b48 --- /dev/null +++ b/ssa/jsondiff/unstructured_test.go @@ -0,0 +1,1230 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jsondiff + +import ( + "context" + "github.com/fluxcd/pkg/ssa" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const dummyFieldOwner = "dummy" + +func TestUnstructuredList(t *testing.T) { + tests := []struct { + name string + paths []string + mutateCluster func(*unstructured.Unstructured) + mutateDesired func(*unstructured.Unstructured) + opts []ListOption + want func(ns string) ChangeSet + wantErr bool + }{ + { + name: "resources do not exist", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateCluster: func(obj *unstructured.Unstructured) { + obj.Object = nil + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + }, + &Change{ + Type: ChangeTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + }, + } + }, + }, + { + name: "resources with multiple changes", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateDesired: func(obj *unstructured.Unstructured) { + if obj.GetKind() == "Deployment" { + _ = unstructured.SetNestedField(obj.Object, float64(2), "spec", "replicas") + } + if obj.GetKind() == "Service" { + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "annotations", "annotated") + } + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/spec/replicas", Value: float64(2), OldValue: float64(1)}, + }, + }, + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata", Value: map[string]interface{}{ + "annotations": map[string]interface{}{ + "annotated": "yes", + }, + }}, + }, + }, + } + }, + }, + { + name: "excludes resources with matching label", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateDesired: func(obj *unstructured.Unstructured) { + if obj.GetKind() != "Deployment" { + return + } + + labels := obj.GetLabels() + labels["exclude"] = "enabled" + obj.SetLabels(labels) + + _ = unstructured.SetNestedField(obj.Object, float64(2), "spec", "replicas") + }, + opts: []ListOption{ + ExclusionSelector{"exclude": "enabled"}, + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeExclude, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + }, + &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + }, + } + }, + }, + { + name: "excludes resources with matching annotation", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateDesired: func(obj *unstructured.Unstructured) { + if obj.GetKind() != "Service" { + return + } + + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations["exclude"] = "enabled" + obj.SetAnnotations(annotations) + + _ = unstructured.SetNestedField(obj.Object, "NodePort", "spec", "type") + }, + opts: []ListOption{ + ExclusionSelector{"exclude": "enabled"}, + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + }, + &Change{ + Type: ChangeTypeExclude, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + }, + } + }, + }, + { + name: "ignores JSON pointers for resources matching selector", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "change", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "change", "metadata", "labels", "labeled") + }, + opts: []ListOption{ + IgnorePathSelectors{ + { + Paths: []string{ + "/metadata/annotations", + }, + Selector: &Selector{ + Kind: "Service", + }, + }, + }, + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata/annotations/annotated", Value: "change"}, + {Type: jsondiff.OperationAdd, Path: "/metadata/labels/labeled", Value: "change"}, + }, + }, + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata", Value: map[string]interface{}{ + "labels": map[string]interface{}{ + "labeled": "change", + }, + }}, + }, + }, + } + }, + }, + { + name: "ignores paths for all resources without selector", + paths: []string{ + "testdata/deployment.yaml", + "testdata/service.yaml", + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "change", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "change", "metadata", "labels", "labeled") + }, + opts: []ListOption{ + IgnorePathSelectors{ + { + Paths: []string{ + "/metadata/annotations", + }, + }, + }, + }, + want: func(ns string) ChangeSet { + return ChangeSet{ + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata/labels/labeled", Value: "change"}, + }, + }, + &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Service", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata", Value: map[string]interface{}{ + "labels": map[string]interface{}{ + "labeled": "change", + }, + }}, + }, + }, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + ns, err := CreateNamespace(ctx, "test-unstructured-list") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = testClient.Delete(ctx, ns) }) + + var desired []*unstructured.Unstructured + for _, path := range tt.paths { + res, err := LoadResource(path) + if err != nil { + t.Fatal(err) + } + + cObj, dObj := res.DeepCopy(), res.DeepCopy() + cObj.SetNamespace(ns.Name) + if tt.mutateCluster != nil { + tt.mutateCluster(cObj) + } + if cObj.Object != nil { + if err := testClient.Patch(ctx, cObj, client.Apply, client.FieldOwner(dummyFieldOwner)); err != nil { + t.Fatal(err) + } + } + + dObj.SetNamespace(ns.Name) + if tt.mutateDesired != nil { + tt.mutateDesired(dObj) + } + if dObj != nil { + desired = append(desired, dObj) + } + } + + opts := []ListOption{ + FieldOwner(dummyFieldOwner), + } + opts = append(opts, tt.opts...) + change, err := UnstructuredList(ctx, testClient, desired, opts...) + if (err != nil) != tt.wantErr { + t.Errorf("UnstructuredList() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(tt.want(ns.Name), change, cmpopts.IgnoreUnexported(jsondiff.Operation{})); diff != "" { + t.Errorf("UnstructuredList() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestUnstructured(t *testing.T) { + tests := []struct { + name string + path string + mutateCluster func(*unstructured.Unstructured) + mutateDesired func(*unstructured.Unstructured) + opts []ResourceOption + want func(ns string) *Change + wantErr bool + }{ + { + name: "Deployment with added label and annotation", + path: "testdata/deployment.yaml", + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "labels", "labeled") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata/annotations/annotated", Value: "yes"}, + {Type: jsondiff.OperationAdd, Path: "/metadata/labels/labeled", Value: "yes"}, + }, + } + }, + }, + { + name: "Deployment with missing label and annotation", + path: "testdata/deployment.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "labels", "labeled") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + } + }, + }, + { + name: "Deployment with changed label and annotation", + path: "testdata/deployment.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "no", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "no", "metadata", "labels", "labeled") + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "labels", "labeled") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/annotations/annotated", Value: "yes", OldValue: "no"}, + {Type: jsondiff.OperationReplace, Path: "/metadata/labels/labeled", Value: "yes", OldValue: "no"}, + }, + } + }, + }, + { + name: "Deployment with ignored change path", + path: "testdata/deployment.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "no", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "no", "metadata", "labels", "labeled") + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "annotations", "annotated") + _ = unstructured.SetNestedField(obj.Object, "yes", "metadata", "labels", "labeled") + }, + opts: []ResourceOption{ + IgnorePaths{"/metadata/annotations/annotated"}, + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/labels/labeled", Value: "yes", OldValue: "no"}, + }, + } + }, + }, + { + name: "Deployment with added container", + path: "testdata/deployment.yaml", + mutateDesired: func(obj *unstructured.Unstructured) { + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + containers = append(containers, map[string]interface{}{ + "name": "nginx", + "image": "nginx:latest", + }) + _ = unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/spec/template/spec/containers/-", Value: map[string]interface{}{ + "name": "nginx", + "image": "nginx:latest", + "imagePullPolicy": "Always", + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "resources": map[string]interface{}{}, + }}, + }, + } + }, + }, + { + name: "Deployment with removed container", + path: "testdata/deployment.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + containers = append(containers, map[string]interface{}{ + "name": "nginx", + "image": "nginx:latest", + }) + _ = unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationRemove, Path: "/spec/template/spec/containers/1", OldValue: map[string]interface{}{ + "name": "nginx", + "image": "nginx:latest", + "imagePullPolicy": "Always", + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "resources": map[string]interface{}{}, + }}, + }, + } + }, + }, + { + name: "Deployment with changed container value", + path: "testdata/deployment.yaml", + mutateDesired: func(obj *unstructured.Unstructured) { + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + containers[0].(map[string]interface{})["image"] = "nginx:latest" + _ = unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers") + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/spec/template/spec/containers/0/image", Value: "nginx:latest", OldValue: "ghcr.io/stefanprodan/podinfo:6.0.3"}, + }, + } + }, + }, + { + name: "Deployment with changed container value and ignored path", + path: "testdata/deployment.yaml", + mutateDesired: func(obj *unstructured.Unstructured) { + containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + containers[0].(map[string]interface{})["image"] = "nginx:latest" + _ = unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers") + }, + opts: []ResourceOption{ + IgnorePaths{"/spec/template/spec/containers/0/image"}, + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + } + }, + }, + { + name: "Deployment without changes", + path: "testdata/deployment.yaml", + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + } + }, + }, + { + name: "Deployment does not exist", + path: "testdata/deployment.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + obj.Object = nil + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Namespace: ns, + Name: "podinfo", + } + }, + }, + { + name: "Secret without changes", + path: "testdata/empty-secret.yaml", + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + }, + Namespace: ns, + Name: "secret-data", + } + }, + }, + { + name: "Secret with added key and unmasked value", + path: "testdata/empty-secret.yaml", + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "bar", "stringData", "foo") + _ = ssa.SetNativeKindsDefaults([]*unstructured.Unstructured{obj}) + }, + opts: []ResourceOption{ + MaskSecrets(false), + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + }, + Namespace: ns, + Name: "secret-data", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/data", Value: map[string]interface{}{ + "foo": "YmFy", + }}, + }, + } + }, + }, + { + name: "Secret with changed and deleted key and masked value", + path: "testdata/empty-secret.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "bar", "stringData", "foo") + _ = unstructured.SetNestedField(obj.Object, "bar", "stringData", "bar") + _ = ssa.SetNativeKindsDefaults([]*unstructured.Unstructured{obj}) + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "baz", "stringData", "foo") + _ = ssa.SetNativeKindsDefaults([]*unstructured.Unstructured{obj}) + }, + opts: []ResourceOption{ + MaskSecrets(true), + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + }, + Namespace: ns, + Name: "secret-data", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data", OldValue: map[string]interface{}{ + "bar": sensitiveMaskDefault, + "foo": sensitiveMaskBefore, + }, Value: map[string]interface{}{ + "foo": sensitiveMaskAfter, + }}, + }, + } + }, + }, + { + name: "ConfigMap is not masked", + path: "testdata/empty-configmap.yaml", + mutateCluster: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "bar", "data", "foo") + }, + mutateDesired: func(obj *unstructured.Unstructured) { + _ = unstructured.SetNestedField(obj.Object, "baz", "data", "foo") + }, + opts: []ResourceOption{ + MaskSecrets(true), + }, + want: func(ns string) *Change { + return &Change{ + Type: ChangeTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: ns, + Name: "configmap-data", + Patch: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/data/foo", OldValue: "bar", Value: "baz"}, + }, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + ns, err := CreateNamespace(ctx, "test-resource") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = testClient.Delete(ctx, ns) }) + + res, err := LoadResource(tt.path) + if err != nil { + t.Fatal(err) + } + cluster, desired := res.DeepCopy(), res.DeepCopy() + + cluster.SetNamespace(ns.Name) + if tt.mutateCluster != nil { + tt.mutateCluster(cluster) + } + if cluster.Object != nil { + if err := testClient.Patch(ctx, cluster, client.Apply, client.FieldOwner(dummyFieldOwner)); err != nil { + t.Fatal(err) + } + } + + desired.SetNamespace(ns.Name) + if tt.mutateDesired != nil { + tt.mutateDesired(desired) + } + + opts := []ResourceOption{ + FieldOwner(dummyFieldOwner), + } + opts = append(opts, tt.opts...) + change, err := Unstructured(ctx, testClient, desired, opts...) + if (err != nil) != tt.wantErr { + t.Errorf("Unstructured() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(tt.want(ns.Name), change, cmpopts.IgnoreUnexported(jsondiff.Operation{})); diff != "" { + t.Errorf("Unstructured() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func Test_diffUnstructuredMetadata(t *testing.T) { + tests := []struct { + name string + x *unstructured.Unstructured + y *unstructured.Unstructured + ignorePaths []string + want jsondiff.Patch + wantErr bool + }{ + { + name: "label added", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata/labels/bar", Value: "foo"}, + }, + }, + { + name: "label removed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + want: nil, + }, + { + name: "label changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "baz", + }, + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/labels/foo", OldValue: "bar", Value: "baz"}, + }, + }, + { + name: "annotation added", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationAdd, Path: "/metadata/annotations/bar", Value: "foo"}, + }, + }, + { + name: "annotation removed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + want: nil, + }, + { + name: "annotation changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "baz", + }, + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/annotations/foo", OldValue: "bar", Value: "baz"}, + }, + }, + { + name: "label and annotation changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + "annotations": map[string]interface{}{ + "bar": "foo", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "baz", + }, + "annotations": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/annotations/bar", OldValue: "foo", Value: "baz"}, + {Type: jsondiff.OperationReplace, Path: "/metadata/labels/foo", OldValue: "bar", Value: "baz"}, + }, + }, + { + name: "label and annotation changed with ignore path", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + "annotations": map[string]interface{}{ + "bar": "foo", + }, + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "baz", + }, + "annotations": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + }, + ignorePaths: []string{"/metadata/annotations/bar"}, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/metadata/labels/foo", Value: "baz", OldValue: "bar"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := diffUnstructuredMetadata(tt.x, tt.y, tt.ignorePaths...) + if (err != nil) != tt.wantErr { + t.Errorf("diffResourceMetadata() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(jsondiff.Operation{})); diff != "" { + t.Errorf("diffResourceMetadata() got = %v", diff) + } + }) + } +} + +func Test_diffUnstructured(t *testing.T) { + tests := []struct { + name string + x *unstructured.Unstructured + y *unstructured.Unstructured + want jsondiff.Patch + wantErr bool + }{ + { + name: "no diff", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": float64(1), + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": float64(1), + }, + }, + }, + want: nil, + }, + { + name: "spec changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": float64(1), + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": float64(2), + }, + }, + }, + want: jsondiff.Patch{ + {Type: jsondiff.OperationReplace, Path: "/spec/replicas", OldValue: float64(1), Value: float64(2)}, + }, + }, + { + name: "metadata changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "foo", + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "bar", + }, + }, + }, + want: nil, + }, + { + name: "status changed", + x: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "observedGeneration": int64(1), + }, + }, + }, + y: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "observedGeneration": int64(2), + }, + }, + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := diffUnstructured(tt.x, tt.y) + if (err != nil) != tt.wantErr { + t.Errorf("diffResourceMetadata() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(jsondiff.Operation{})); diff != "" { + t.Errorf("diffResourceMetadata() got = %v", diff) + } + }) + } +} + +func Test_copyAnnotationsAndLabels(t *testing.T) { + tests := []struct { + name string + u *unstructured.Unstructured + want *unstructured.Unstructured + }{ + { + name: "copy annotations and labels", + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "annotation1": true, + "annotation2": "value", + }, + "labels": map[string]interface{}{ + "label1": false, + "label2": "value", + }, + }, + "spec": map[string]interface{}{ + "replicas": 1, + }, + }, + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "annotation1": true, + "annotation2": "value", + }, + "labels": map[string]interface{}{ + "label1": false, + "label2": "value", + }, + }, + }, + }, + }, + { + name: "copy annotations and labels with empty metadata", + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 1, + }, + }, + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + }, + { + name: "copy annotations and labels with empty annotations and labels", + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{}, + "labels": map[string]interface{}{}, + }, + }, + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{}, + "labels": map[string]interface{}{}, + }, + }, + }, + }, + { + name: "copy annotations and labels with nil annotations and labels", + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": nil, + "labels": nil, + }, + }, + }, + want: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": nil, + "labels": nil, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := copyAnnotationsAndLabels(tt.u); !reflect.DeepEqual(got, tt.want) { + t.Errorf("copyAnnotationsAndLabels() = %v, want %v", got, tt.want) + } + }) + } +}