From dd9d3c0126b9e2e722b6f38c678fa3f891318e7a Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Fri, 27 Oct 2023 22:48:24 +0200 Subject: [PATCH] action: introduce `Diff` action The `Diff` action can be used to detect changes between the manifest from a Helm release and the current cluster state. Compared to the previous diff functionality, it allows for ignoring specific fields in a resource using the newly introduced ignore rules in the API. Signed-off-by: Hidde Beydals --- go.mod | 3 +- go.sum | 6 +- internal/action/diff.go | 123 ++++++++ internal/action/diff_test.go | 554 +++++++++++++++++++++++++++++++++++ 4 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 internal/action/diff.go create mode 100644 internal/action/diff_test.go diff --git a/go.mod b/go.mod index 174a31f6a..870582138 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/fluxcd/pkg/apis/kustomize v1.1.1 github.com/fluxcd/pkg/apis/meta v1.1.2 github.com/fluxcd/pkg/runtime v0.42.0 - github.com/fluxcd/pkg/ssa v0.32.0 + github.com/fluxcd/pkg/ssa v0.33.1-0.20231026140034-ee4c4a064707 github.com/fluxcd/pkg/testserver v0.4.0 github.com/fluxcd/source-controller/api v1.1.2 github.com/go-logr/logr v1.2.4 @@ -32,6 +32,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest/blake3 v0.0.0-20230815154656-802ce17c4f59 github.com/spf13/pflag v1.0.5 + github.com/wI2L/jsondiff v0.4.1-0.20230626084051-c85fb8ce3cac golang.org/x/text v0.13.0 gopkg.in/yaml.v2 v2.4.0 helm.sh/helm/v3 v3.12.3 diff --git a/go.sum b/go.sum index 4604cc11f..03bcacd48 100644 --- a/go.sum +++ b/go.sum @@ -176,8 +176,8 @@ github.com/fluxcd/pkg/apis/meta v1.1.2 h1:Unjo7hxadtB2dvGpeFqZZUdsjpRA08YYSBb7dF github.com/fluxcd/pkg/apis/meta v1.1.2/go.mod h1:BHQyRHCskGMEDf6kDGbgQ+cyiNpUHbLsCOsaMYM2maI= github.com/fluxcd/pkg/runtime v0.42.0 h1:a5DQ/f90YjoHBmiXZUpnp4bDSLORjInbmqP7K11L4uY= github.com/fluxcd/pkg/runtime v0.42.0/go.mod h1:p6A3xWVV8cKLLQW0N90GehKgGMMmbNYv+OSJ/0qB0vg= -github.com/fluxcd/pkg/ssa v0.32.0 h1:RBqs9DNrbJkFHjpfsiKilyean7gwqWFspSBTLOaBIHs= -github.com/fluxcd/pkg/ssa v0.32.0/go.mod h1:+Kf5euYAbvgJX645bo+IL7V/NlH0X7kGgFTr1W++I3c= +github.com/fluxcd/pkg/ssa v0.33.1-0.20231026140034-ee4c4a064707 h1:pW3qg+r+yEXUgVm3uU5ZrW9zAVFGgPTkKbbrmW1/O8k= +github.com/fluxcd/pkg/ssa v0.33.1-0.20231026140034-ee4c4a064707/go.mod h1:0SRSYkqJKuX2nSVzVOvzpw5ax31r4fpO1nKrv7oapDQ= github.com/fluxcd/pkg/testserver v0.4.0 h1:pDZ3gistqYhwlf3sAjn1Q8NzN4Qe6I1BEmHMHi46lMg= github.com/fluxcd/pkg/testserver v0.4.0/go.mod h1:gjOKX41okmrGYOa4oOF2fiLedDAfPo1XaG/EzrUUGBI= github.com/fluxcd/source-controller/api v1.1.2 h1:FfKDKVWnopo+Q2pOAxgHEjrtr4MP41L8aapR4mqBhBk= @@ -613,6 +613,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +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/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= diff --git a/internal/action/diff.go b/internal/action/diff.go new file mode 100644 index 000000000..bbe49774a --- /dev/null +++ b/internal/action/diff.go @@ -0,0 +1,123 @@ +/* +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 action + +import ( + "context" + "fmt" + "strings" + + helmaction "helm.sh/helm/v3/pkg/action" + helmrelease "helm.sh/helm/v3/pkg/release" + "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + "github.com/fluxcd/pkg/ssa" + "github.com/fluxcd/pkg/ssa/jsondiff" + + v2 "github.com/fluxcd/helm-controller/api/v2beta2" +) + +// Diff returns a jsondiff.DiffSet of the changes between the state of the +// cluster and the Helm release.Release manifest. +func Diff(ctx context.Context, config *helmaction.Configuration, rls *helmrelease.Release, fieldOwner string, ignore ...v2.IgnoreRule) (jsondiff.DiffSet, error) { + // Create a dry-run only client to use solely for diffing. + cfg, err := config.RESTClientGetter.ToRESTConfig() + if err != nil { + return nil, err + } + c, err := client.New(cfg, client.Options{DryRun: pointer.Bool(true)}) + if err != nil { + return nil, err + } + + // Read the release manifest and normalize the objects. + objects, err := ssa.ReadObjects(strings.NewReader(rls.Manifest)) + if err != nil { + return nil, fmt.Errorf("failed to read objects from release manifest: %w", err) + } + if err = ssa.NormalizeUnstructuredListWithScheme(objects, c.Scheme()); err != nil { + return nil, fmt.Errorf("failed to normalize release objects: %w", err) + } + + var ( + isNamespacedGVK = map[string]bool{} + errs []error + ) + for _, obj := range objects { + if obj.GetNamespace() == "" { + // Manifest does not contain the namespace of the release. + // Figure out if the object is namespaced if the namespace is not + // explicitly set, and configure the namespace accordingly. + objGVK := obj.GetObjectKind().GroupVersionKind().String() + if _, ok := isNamespacedGVK[objGVK]; !ok { + namespaced, err := apiutil.IsObjectNamespaced(obj, c.Scheme(), c.RESTMapper()) + if err != nil { + errs = append(errs, fmt.Errorf("failed to determine if %s is namespace scoped: %w", + obj.GetObjectKind().GroupVersionKind().Kind, err)) + continue + } + // Cache the result, so we don't have to do this for every object + isNamespacedGVK[objGVK] = namespaced + } + if isNamespacedGVK[objGVK] { + obj.SetNamespace(rls.Namespace) + } + } + } + + // Base configuration for the diffing of the object. + diffOpts := []jsondiff.ListOption{ + jsondiff.FieldOwner(fieldOwner), + jsondiff.ExclusionSelector{v2.DriftDetectionMetadataKey: v2.DriftDetectionDisabledValue}, + jsondiff.MaskSecrets(true), + jsondiff.Rationalize(true), + jsondiff.Graceful(true), + } + + // Add ignore rules to the diffing configuration. + var ignoreRules jsondiff.IgnoreRules + for _, rule := range ignore { + r := jsondiff.IgnoreRule{ + Paths: rule.Paths, + } + if rule.Target != nil { + r.Selector = &jsondiff.Selector{ + Group: rule.Target.Group, + Version: rule.Target.Version, + Kind: rule.Target.Kind, + Name: rule.Target.Name, + Namespace: rule.Target.Namespace, + AnnotationSelector: rule.Target.AnnotationSelector, + LabelSelector: rule.Target.LabelSelector, + } + } + ignoreRules = append(ignoreRules, r) + } + if len(ignoreRules) > 0 { + diffOpts = append(diffOpts, ignoreRules) + } + + // Actually diff the objects. + set, err := jsondiff.UnstructuredList(ctx, c, objects, diffOpts...) + if err != nil { + errs = append(errs, err) + } + return set, errors.Reduce(errors.Flatten(errors.NewAggregate(errs))) +} diff --git a/internal/action/diff_test.go b/internal/action/diff_test.go new file mode 100644 index 000000000..e3d71e3cb --- /dev/null +++ b/internal/action/diff_test.go @@ -0,0 +1,554 @@ +/* +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 action + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + extjsondiff "github.com/wI2L/jsondiff" + helmaction "helm.sh/helm/v3/pkg/action" + helmrelease "helm.sh/helm/v3/pkg/release" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + "github.com/fluxcd/pkg/apis/kustomize" + "github.com/fluxcd/pkg/ssa" + "github.com/fluxcd/pkg/ssa/jsondiff" + + v2 "github.com/fluxcd/helm-controller/api/v2beta2" + "github.com/fluxcd/helm-controller/internal/kube" +) + +func TestDiff(t *testing.T) { + // Normally, we would create e.g. a `suite_test.go` file with a `TestMain` + // function. But because this is the only test in this package which needs + // a test cluster, we create it here instead. + config, cleanup := newTestCluster(t) + t.Cleanup(func() { + t.Log("Stopping the test environment") + if err := cleanup(); err != nil { + t.Logf("Failed to stop the test environment: %v", err) + } + }) + + // Construct a REST client getter for Helm's action configuration. + getter := kube.NewMemoryRESTClientGetter(config) + + // Construct a client for to be able to mutate the cluster. + c, err := client.New(config, client.Options{}) + if err != nil { + t.Fatalf("Failed to create client for test environment: %v", err) + } + + const testOwner = "helm-controller" + + tests := []struct { + name string + manifest string + ignoreRules []v2.IgnoreRule + mutateCluster func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) + want func(namepace string) jsondiff.DiffSet + wantErr bool + }{ + { + name: "detects drift", + manifest: `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: changed +data: + key: value +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: deleted +data: + key: value +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: unchanged +data: + key: value`, + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + for _, obj := range objs { + if obj.GetName() == "deleted" { + continue + } + + obj := obj.DeepCopy() + obj.SetNamespace(namespace) + + if obj.GetName() == "changed" { + if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + } + + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "changed", + Patch: extjsondiff.Patch{ + { + Type: extjsondiff.OperationReplace, + OldValue: "changed", + Value: "value", + Path: "/data/key", + }, + }, + }, + { + Type: jsondiff.DiffTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "deleted", + }, + { + Type: jsondiff.DiffTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "unchanged", + }, + } + }, + }, + { + name: "empty release manifest", + manifest: "", + }, + { + name: "manifest with disabled annotation", + manifest: fmt.Sprintf(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: disabled + annotations: + %[1]s: %[2]s +data: + key: value`, v2.DriftDetectionMetadataKey, v2.DriftDetectionDisabledValue), + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + for _, obj := range objs { + obj := obj.DeepCopy() + obj.SetNamespace(namespace) + if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeExclude, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "disabled", + }, + } + }, + }, + { + name: "manifest with disabled annotation", + manifest: fmt.Sprintf(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: disabled + labels: + %[1]s: %[2]s +data: + key: value`, v2.DriftDetectionMetadataKey, v2.DriftDetectionDisabledValue), + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + for _, obj := range objs { + obj := obj.DeepCopy() + obj.SetNamespace(namespace) + if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeExclude, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "disabled", + }, + } + }, + }, + { + name: "adheres to ignore rules", + manifest: `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fully-ignored +data: + key: value +--- +apiVersion: v1 +kind: Secret +metadata: + name: partially-ignored +stringData: + key: value + otherKey: otherValue +--- +apiVersion: v1 +kind: Secret +metadata: + name: globally-ignored +stringData: + globalKey: globalValue +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: not-ignored +data: + key: value`, + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + for _, obj := range objs { + obj := obj.DeepCopy() + obj.SetNamespace(namespace) + + switch obj.GetName() { + case "fully-ignored", "not-ignored": + if err := unstructured.SetNestedField(obj.Object, "changed", "data", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + case "partially-ignored": + if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "otherKey"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + case "globally-ignored": + if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "globalKey"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + } + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + ignoreRules: []v2.IgnoreRule{ + {Target: &kustomize.Selector{Name: "fully-ignored"}, Paths: []string{""}}, + {Target: &kustomize.Selector{Name: "partially-ignored"}, Paths: []string{"/data/key"}}, + {Paths: []string{"/data/globalKey"}}, + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeExclude, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "fully-ignored", + }, + { + Type: jsondiff.DiffTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Secret", + }, + Namespace: namespace, + Name: "partially-ignored", + Patch: extjsondiff.Patch{ + { + Type: extjsondiff.OperationReplace, + Path: "/data/otherKey", + OldValue: "*** (before)", + Value: "*** (after)", + }, + }, + }, + { + Type: jsondiff.DiffTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Secret", + }, + Namespace: namespace, + Name: "globally-ignored", + }, + { + Type: jsondiff.DiffTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "not-ignored", + Patch: extjsondiff.Patch{ + { + Type: extjsondiff.OperationReplace, + Path: "/data/key", + OldValue: "changed", + Value: "value", + }, + }, + }, + } + }, + }, + { + name: "configures namespace", + manifest: `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: without-namespace +data: + key: value +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: with-namespace + namespace: diff-fixed-ns +data: + key: value`, + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + + otherNS := unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "diff-fixed-ns", + }, + }} + clusterObjs = append(clusterObjs, &otherNS) + + for _, obj := range objs { + obj := obj.DeepCopy() + if obj.GetNamespace() == "" { + obj.SetNamespace(namespace) + } + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: namespace, + Name: "without-namespace", + }, + { + Type: jsondiff.DiffTypeNone, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "ConfigMap", + }, + Namespace: "diff-fixed-ns", + Name: "with-namespace", + }, + } + }, + }, + { + name: "masks Secret data", + manifest: `--- +apiVersion: v1 +kind: Secret +metadata: + name: secret +stringData: + key: value`, + mutateCluster: func(objs []*unstructured.Unstructured, namespace string) ([]*unstructured.Unstructured, error) { + var clusterObjs []*unstructured.Unstructured + for _, obj := range objs { + obj := obj.DeepCopy() + obj.SetNamespace(namespace) + if err := unstructured.SetNestedField(obj.Object, "changed", "stringData", "key"); err != nil { + return nil, fmt.Errorf("failed to set nested field: %w", err) + } + clusterObjs = append(clusterObjs, obj) + } + return clusterObjs, nil + }, + want: func(namespace string) jsondiff.DiffSet { + return jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Secret", + }, + Namespace: namespace, + Name: "secret", + Patch: extjsondiff.Patch{ + { + Type: extjsondiff.OperationReplace, + Path: "/data/key", + OldValue: "*** (before)", + Value: "*** (after)", + }, + }, + }, + } + }, + }, + } + 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 := generateNamespace(ctx, c, "diff-action") + if err != nil { + t.Fatalf("Failed to generate namespace: %v", err) + } + t.Cleanup(func() { + if err := c.Delete(context.Background(), ns); client.IgnoreNotFound(err) != nil { + t.Logf("Failed to delete generated namespace: %v", err) + } + }) + + objs, err := ssa.ReadObjects(strings.NewReader(tt.manifest)) + if err != nil { + t.Fatalf("Failed to read release objects: %v", err) + } + + clusterObjs := objs + if tt.mutateCluster != nil { + if clusterObjs, err = tt.mutateCluster(objs, ns.Name); err != nil { + t.Fatalf("Failed to modify cluster resource: %v", err) + } + } + + t.Cleanup(func() { + for _, obj := range clusterObjs { + if err := c.Delete(context.Background(), obj); client.IgnoreNotFound(err) != nil { + t.Logf("Failed to delete object: %v", err) + } + } + }) + + for _, obj := range clusterObjs { + if err = ssa.NormalizeUnstructured(obj); err != nil { + t.Fatalf("Failed to normalize cluster manifest: %v", err) + } + if err := c.Create(ctx, obj, client.FieldOwner(testOwner)); err != nil { + t.Fatalf("Failed to create object: %v", err) + } + } + + rls := &helmrelease.Release{Namespace: ns.Name, Manifest: tt.manifest} + + got, err := Diff(ctx, &helmaction.Configuration{RESTClientGetter: getter}, rls, testOwner, tt.ignoreRules...) + if (err != nil) != tt.wantErr { + t.Errorf("Diff() error = %v, wantErr %v", err, tt.wantErr) + return + } + + var want jsondiff.DiffSet + if tt.want != nil { + want = tt.want(ns.Name) + } + if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(extjsondiff.Operation{})); diff != "" { + t.Errorf("Diff() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// newTestCluster creates a new test cluster and returns a rest.Config and a +// function to stop the test cluster. +func newTestCluster(t *testing.T) (*rest.Config, func() error) { + t.Helper() + + testEnv := &envtest.Environment{} + + t.Log("Starting the test environment") + if _, err := testEnv.Start(); err != nil { + t.Fatalf("Failed to start the test environment: %v", err) + } + + return testEnv.Config, testEnv.Stop +} + +// generateNamespace creates a new namespace with the given generateName and +// returns the namespace object. +func generateNamespace(ctx context.Context, c client.Client, generateName string) (*corev1.Namespace, error) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-", generateName), + }, + } + if err := c.Create(ctx, ns); err != nil { + return nil, err + } + return ns, nil +}