diff --git a/api/v2beta2/helmrelease_types.go b/api/v2beta2/helmrelease_types.go index 12cdd4173..19d49937f 100644 --- a/api/v2beta2/helmrelease_types.go +++ b/api/v2beta2/helmrelease_types.go @@ -158,6 +158,12 @@ type HelmReleaseSpec struct { // +optional PersistentClient *bool `json:"persistentClient,omitempty"` + // DriftDetection holds the configuration for detecting and handling + // differences between the manifest in the Helm storage and the resources + // currently existing in the cluster. + // +optional + DriftDetection *DriftDetection `json:"driftDetection,omitempty"` + // Install holds the configuration for Helm install actions for this HelmRelease. // +optional Install *Install `json:"install,omitempty"` @@ -192,6 +198,91 @@ type HelmReleaseSpec struct { PostRenderers []PostRenderer `json:"postRenderers,omitempty"` } +// DriftDetectionMode represents the modes in which a controller can detect and +// handle differences between the manifest in the Helm storage and the resources +// currently existing in the cluster. +type DriftDetectionMode string + +var ( + // DriftDetectionEnabled instructs the controller to actively detect any + // changes between the manifest in the Helm storage and the resources + // currently existing in the cluster. + // If any differences are detected, the controller will automatically + // correct the cluster state by performing a Helm upgrade. + DriftDetectionEnabled DriftDetectionMode = "enabled" + + // DriftDetectionWarn instructs the controller to actively detect any + // changes between the manifest in the Helm storage and the resources + // currently existing in the cluster. + // If any differences are detected, the controller will emit a warning + // without automatically correcting the cluster state. + DriftDetectionWarn DriftDetectionMode = "warn" + + // DriftDetectionDisabled instructs the controller to skip detection of + // differences entirely. + // This is the default behavior, and the controller will not actively + // detect or respond to differences between the manifest in the Helm + // storage and the resources currently existing in the cluster. + DriftDetectionDisabled DriftDetectionMode = "disabled" +) + +var ( + // DriftDetectionMetadataKey is the label or annotation key used to disable + // the diffing of an object. + DriftDetectionMetadataKey = GroupVersion.Group + "/driftDetection" + // DriftDetectionDisabledValue is the value used to disable the diffing of + // an object using DriftDetectionMetadataKey. + DriftDetectionDisabledValue = "disabled" +) + +// IgnoreRule defines a rule to selectively disregard specific changes during +// the drift detection process. +type IgnoreRule struct { + // Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + // consideration in a Kubernetes object. + // +required + Paths []string `json:"paths"` + + // Target is a selector for specifying Kubernetes objects to which this + // rule applies. + // If Target is not set, the Paths will be ignored for all Kubernetes + // objects within the manifest of the Helm release. + // +optional + Target *kustomize.Selector `json:"target,omitempty"` +} + +// DriftDetection defines the strategy for performing differential analysis and +// provides a way to define rules for ignoring specific changes during this +// process. +type DriftDetection struct { + // Mode defines how differences should be handled between the Helm manifest + // and the manifest currently applied to the cluster. + // If not explicitly set, it defaults to DiffModeDisabled. + // +kubebuilder:validation:Enum=enabled;warn;disabled + // +optional + Mode DriftDetectionMode `json:"mode,omitempty"` + + // Ignore contains a list of rules for specifying which changes to ignore + // during diffing. + // +optional + Ignore []IgnoreRule `json:"ignore,omitempty"` +} + +// GetMode returns the DiffMode set on the Diff, or DiffModeDisabled if not +// set. +func (d DriftDetection) GetMode() DriftDetectionMode { + if d.Mode == "" { + return DriftDetectionDisabled + } + return d.Mode +} + +// MustDetectChanges returns true if the DiffMode is set to DiffModeEnabled or +// DiffModeWarn. +func (d DriftDetection) MustDetectChanges() bool { + return d.GetMode() == DriftDetectionEnabled || d.GetMode() == DriftDetectionWarn +} + // HelmChartTemplate defines the template from which the controller will // generate a v1beta2.HelmChart object in the same namespace as the referenced // v1.Source. @@ -970,6 +1061,16 @@ type HelmRelease struct { Status HelmReleaseStatus `json:"status,omitempty"` } +// GetDriftDetection returns the configuration for detecting and handling +// differences between the manifest in the Helm storage and the resources +// currently existing in the cluster. +func (in *HelmRelease) GetDriftDetection() DriftDetection { + if in.Spec.DriftDetection == nil { + return DriftDetection{} + } + return *in.Spec.DriftDetection +} + // GetInstall returns the configuration for Helm install actions for the // HelmRelease. func (in *HelmRelease) GetInstall() Install { diff --git a/api/v2beta2/zz_generated.deepcopy.go b/api/v2beta2/zz_generated.deepcopy.go index a2f3b8f65..f58cdc02f 100644 --- a/api/v2beta2/zz_generated.deepcopy.go +++ b/api/v2beta2/zz_generated.deepcopy.go @@ -44,6 +44,28 @@ func (in *CrossNamespaceObjectReference) DeepCopy() *CrossNamespaceObjectReferen return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DriftDetection) DeepCopyInto(out *DriftDetection) { + *out = *in + if in.Ignore != nil { + in, out := &in.Ignore, &out.Ignore + *out = make([]IgnoreRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DriftDetection. +func (in *DriftDetection) DeepCopy() *DriftDetection { + if in == nil { + return nil + } + out := new(DriftDetection) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Filter) DeepCopyInto(out *Filter) { *out = *in @@ -249,6 +271,11 @@ func (in *HelmReleaseSpec) DeepCopyInto(out *HelmReleaseSpec) { *out = new(bool) **out = **in } + if in.DriftDetection != nil { + in, out := &in.DriftDetection, &out.DriftDetection + *out = new(DriftDetection) + (*in).DeepCopyInto(*out) + } if in.Install != nil { in, out := &in.Install, &out.Install *out = new(Install) @@ -337,6 +364,31 @@ func (in *HelmReleaseStatus) DeepCopy() *HelmReleaseStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IgnoreRule) DeepCopyInto(out *IgnoreRule) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(kustomize.Selector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IgnoreRule. +func (in *IgnoreRule) DeepCopy() *IgnoreRule { + if in == nil { + return nil + } + out := new(IgnoreRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Install) DeepCopyInto(out *Install) { *out = *in diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index 7a17c4441..757d39530 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -1221,6 +1221,80 @@ spec: - name type: object type: array + driftDetection: + description: DriftDetection holds the configuration for detecting + and handling differences between the manifest in the Helm storage + and the resources currently existing in the cluster. + properties: + ignore: + description: Ignore contains a list of rules for specifying which + changes to ignore during diffing. + items: + description: IgnoreRule defines a rule to selectively disregard + specific changes during the drift detection process. + properties: + paths: + description: Paths is a list of JSON Pointer (RFC 6901) + paths to be excluded from consideration in a Kubernetes + object. + items: + type: string + type: array + target: + description: Target is a selector for specifying Kubernetes + objects to which this rule applies. If Target is not set, + the Paths will be ignored for all Kubernetes objects within + the manifest of the Helm release. + properties: + annotationSelector: + description: AnnotationSelector is a string that follows + the label selection expression https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: Group is the API group to select resources + from. Together with Version and Kind it is capable + of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: Kind of the API Group to select resources + from. Together with Group and Version it is capable + of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: LabelSelector is a string that follows + the label selection expression https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: Version of the API Group to select resources + from. Together with Group and Kind it is capable of + unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - paths + type: object + type: array + mode: + description: Mode defines how differences should be handled between + the Helm manifest and the manifest currently applied to the + cluster. If not explicitly set, it defaults to DiffModeDisabled. + enum: + - enabled + - warn + - disabled + type: string + type: object install: description: Install holds the configuration for Helm install actions for this HelmRelease. diff --git a/docs/api/v2beta2/helm.md b/docs/api/v2beta2/helm.md index bd2677ab2..49216b3a8 100644 --- a/docs/api/v2beta2/helm.md +++ b/docs/api/v2beta2/helm.md @@ -246,6 +246,22 @@ available by e.g. post-install hooks.

+driftDetection
+ + +DriftDetection + + + + +(Optional) +

DriftDetection holds the configuration for detecting and handling +differences between the manifest in the Helm storage and the resources +currently existing in the cluster.

+ + + + install
@@ -453,6 +469,69 @@ string +

DriftDetection +

+

+(Appears on: +HelmReleaseSpec) +

+

DriftDetection defines the strategy for performing differential analysis and +provides a way to define rules for ignoring specific changes during this +process.

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+mode
+ + +DriftDetectionMode + + +
+(Optional) +

Mode defines how differences should be handled between the Helm manifest +and the manifest currently applied to the cluster. +If not explicitly set, it defaults to DiffModeDisabled.

+
+ignore
+ + +[]IgnoreRule + + +
+(Optional) +

Ignore contains a list of rules for specifying which changes to ignore +during diffing.

+
+
+
+

DriftDetectionMode +(string alias)

+

+(Appears on: +DriftDetection) +

+

DriftDetectionMode represents the modes in which a controller can detect and +handle differences between the manifest in the Helm storage and the resources +currently existing in the cluster.

Filter

@@ -1098,6 +1177,22 @@ available by e.g. post-install hooks.

+driftDetection
+ + +DriftDetection + + + + +(Optional) +

DriftDetection holds the configuration for detecting and handling +differences between the manifest in the Helm storage and the resources +currently existing in the cluster.

+ + + + install
@@ -1450,6 +1545,57 @@ github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus +

IgnoreRule +

+

+(Appears on: +DriftDetection) +

+

IgnoreRule defines a rule to selectively disregard specific changes during +the drift detection process.

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+paths
+ +[]string + +
+

Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from +consideration in a Kubernetes object.

+
+target
+ + +github.com/fluxcd/pkg/apis/kustomize.Selector + + +
+(Optional) +

Target is a selector for specifying Kubernetes objects to which this +rule applies. +If Target is not set, the Paths will be ignored for all Kubernetes +objects within the manifest of the Helm release.

+
+
+

Install

diff --git a/go.mod b/go.mod index bc788da16..1d7dddf98 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ replace ( ) require ( - github.com/fluxcd/cli-utils v0.36.0-flux.1 github.com/fluxcd/helm-controller/api v0.36.2 github.com/fluxcd/pkg/apis/acl v0.1.0 github.com/fluxcd/pkg/apis/event v0.6.0 @@ -33,6 +32,7 @@ require ( github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98 github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98 github.com/spf13/pflag v1.0.5 + github.com/wI2L/jsondiff v0.4.1-0.20230626084051-c85fb8ce3cac golang.org/x/text v0.14.0 helm.sh/helm/v3 v3.13.2 k8s.io/api v0.28.4 @@ -77,6 +77,7 @@ require ( github.com/evanphx/json-patch/v5 v5.7.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.13.0 // indirect + github.com/fluxcd/cli-utils v0.36.0-flux.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect diff --git a/go.sum b/go.sum index eec871cf6..5604a3a98 100644 --- a/go.sum +++ b/go.sum @@ -369,6 +369,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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 +} diff --git a/internal/diff/differ.go b/internal/diff/differ.go deleted file mode 100644 index d97d15774..000000000 --- a/internal/diff/differ.go +++ /dev/null @@ -1,146 +0,0 @@ -/* -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 diff - -import ( - "context" - "fmt" - "strings" - - "helm.sh/helm/v3/pkg/release" - "k8s.io/apimachinery/pkg/util/errors" - ctrl "sigs.k8s.io/controller-runtime" - - "github.com/fluxcd/pkg/runtime/client" - "github.com/fluxcd/pkg/runtime/logger" - "github.com/fluxcd/pkg/ssa" - - v2 "github.com/fluxcd/helm-controller/api/v2beta2" - "github.com/fluxcd/helm-controller/internal/util" -) - -var ( - // MetadataKey is the label or annotation key used to disable the diffing - // of an object. - MetadataKey = v2.GroupVersion.Group + "/driftDetection" - // MetadataDisabledValue is the value used to disable the diffing of an - // object using MetadataKey. - MetadataDisabledValue = "disabled" -) - -type Differ struct { - impersonator *client.Impersonator - controllerName string -} - -func NewDiffer(impersonator *client.Impersonator, controllerName string) *Differ { - return &Differ{ - impersonator: impersonator, - controllerName: controllerName, - } -} - -// Manager returns a new ssa.ResourceManager constructed using the client.Impersonator. -func (d *Differ) Manager(ctx context.Context) (*ssa.ResourceManager, error) { - c, poller, err := d.impersonator.GetClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get client to configure resource manager: %w", err) - } - owner := ssa.Owner{ - Field: d.controllerName, - } - return ssa.NewResourceManager(c, poller, owner), nil -} - -func (d *Differ) Diff(ctx context.Context, rel *release.Release) (*ssa.ChangeSet, bool, error) { - objects, err := ssa.ReadObjects(strings.NewReader(rel.Manifest)) - if err != nil { - return nil, false, fmt.Errorf("failed to read objects from release manifest: %w", err) - } - - if err := ssa.SetNativeKindsDefaults(objects); err != nil { - return nil, false, fmt.Errorf("failed to set native kind defaults on release objects: %w", err) - } - - resourceManager, err := d.Manager(ctx) - if err != nil { - return nil, false, err - } - - var ( - changeSet = ssa.NewChangeSet() - isNamespacedGVK = map[string]bool{} - diff 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 := util.IsAPINamespaced(obj, resourceManager.Client().Scheme(), resourceManager.Client().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(rel.Namespace) - } - } - - entry, releaseObject, clusterObject, err := resourceManager.Diff(ctx, obj, ssa.DiffOptions{ - Exclusions: map[string]string{ - MetadataKey: MetadataDisabledValue, - }, - }) - if err != nil { - errs = append(errs, err) - } - - if entry == nil { - continue - } - - switch entry.Action { - case ssa.CreatedAction, ssa.ConfiguredAction: - diff = true - changeSet.Add(*entry) - - if entry.Action == ssa.ConfiguredAction { - // TODO: remove this once we have a better way to log the diff - // for example using a custom dyff reporter, or a flux CLI command - if d, equal := Unstructured(releaseObject, clusterObject, WithoutStatus()); !equal { - ctrl.LoggerFrom(ctx).V(logger.DebugLevel).Info(entry.Subject + " diff:\n" + d) - } - } - case ssa.SkippedAction: - changeSet.Add(*entry) - } - } - - err = errors.Reduce(errors.Flatten(errors.NewAggregate(errs))) - if len(changeSet.Entries) == 0 { - return nil, diff, err - } - return changeSet, diff, err -} diff --git a/internal/diff/differ_test.go b/internal/diff/differ_test.go deleted file mode 100644 index bc9e53b3f..000000000 --- a/internal/diff/differ_test.go +++ /dev/null @@ -1,250 +0,0 @@ -/* -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 diff - -import ( - "context" - "fmt" - "testing" - - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - "github.com/fluxcd/cli-utils/pkg/kstatus/polling" - "github.com/fluxcd/cli-utils/pkg/object" - runtimeClient "github.com/fluxcd/pkg/runtime/client" - "github.com/fluxcd/pkg/ssa" - - "helm.sh/helm/v3/pkg/release" -) - -func TestDiffer_Diff(t *testing.T) { - scheme, mapper := testSchemeWithMapper() - - // We do not test all the possible scenarios here, as the ssa package is - // already tested in depth. We only test the integration with the ssa package. - tests := []struct { - name string - client client.Client - rel *release.Release - want *ssa.ChangeSet - wantDrift bool - wantErr string - }{ - { - name: "manifest read error", - client: fake.NewClientBuilder().Build(), - rel: &release.Release{ - Manifest: "invalid", - }, - wantErr: "failed to read objects from release manifest", - }, - { - name: "error on failure to determine namespace scope", - client: fake.NewClientBuilder().Build(), - rel: &release.Release{ - Namespace: "release", - Manifest: `apiVersion: v1 -kind: Secret -metadata: - name: test -stringData: - foo: bar -`, - }, - wantErr: "failed to determine if Secret is namespace scoped", - }, - { - name: "detects changes", - client: fake.NewClientBuilder(). - WithScheme(scheme). - WithRESTMapper(mapper). - Build(), - rel: &release.Release{ - Namespace: "release", - Manifest: `--- -apiVersion: v1 -kind: Secret -metadata: - name: test -stringData: - foo: bar ---- -apiVersion: v1 -kind: Secret -metadata: - name: test-ns - namespace: other -stringData: - foo: bar -`, - }, - want: &ssa.ChangeSet{ - Entries: []ssa.ChangeSetEntry{ - { - ObjMetadata: object.ObjMetadata{ - Namespace: "release", - Name: "test", - GroupKind: schema.GroupKind{ - Kind: "Secret", - }, - }, - GroupVersion: "v1", - Subject: "Secret/release/test", - Action: ssa.CreatedAction, - }, - { - ObjMetadata: object.ObjMetadata{ - Namespace: "other", - Name: "test-ns", - GroupKind: schema.GroupKind{ - Kind: "Secret", - }, - }, - GroupVersion: "v1", - Subject: "Secret/other/test-ns", - Action: ssa.CreatedAction, - }, - }, - }, - wantDrift: true, - }, - { - name: "ignores exclusions", - client: fake.NewClientBuilder(). - WithScheme(scheme). - WithRESTMapper(mapper). - Build(), - rel: &release.Release{ - Namespace: "release", - Manifest: fmt.Sprintf(`--- -apiVersion: v1 -kind: Secret -metadata: - name: test - labels: - %[1]s: %[2]s -stringData: - foo: bar ---- -apiVersion: v1 -kind: Secret -metadata: - name: test2 -stringData: - foo: bar -`, MetadataKey, MetadataDisabledValue), - }, - want: &ssa.ChangeSet{ - Entries: []ssa.ChangeSetEntry{ - { - ObjMetadata: object.ObjMetadata{ - Namespace: "release", - Name: "test", - GroupKind: schema.GroupKind{ - Kind: "Secret", - }, - }, - GroupVersion: "v1", - Subject: "Secret/release/test", - Action: ssa.SkippedAction, - }, - { - ObjMetadata: object.ObjMetadata{ - Namespace: "release", - Name: "test2", - GroupKind: schema.GroupKind{ - Kind: "Secret", - }, - }, - GroupVersion: "v1", - Subject: "Secret/release/test2", - Action: ssa.CreatedAction, - }, - }, - }, - wantDrift: true, - }, - { - name: "ignores exclusions (without diff)", - client: fake.NewClientBuilder(). - WithScheme(scheme). - WithRESTMapper(mapper). - Build(), - rel: &release.Release{ - Namespace: "release", - Manifest: fmt.Sprintf(`--- -apiVersion: v1 -kind: Secret -metadata: - name: test - labels: - %[1]s: %[2]s -stringData: - foo: bar`, MetadataKey, MetadataDisabledValue), - }, - want: &ssa.ChangeSet{ - Entries: []ssa.ChangeSetEntry{ - { - ObjMetadata: object.ObjMetadata{ - Namespace: "release", - Name: "test", - GroupKind: schema.GroupKind{ - Kind: "Secret", - }, - }, - GroupVersion: "v1", - Subject: "Secret/release/test", - Action: ssa.SkippedAction, - }, - }, - }, - wantDrift: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - - d := NewDiffer(runtimeClient.NewImpersonator(tt.client, nil, polling.Options{}, nil, runtimeClient.KubeConfigOptions{}, "", "", ""), "test-controller") - got, drift, err := d.Diff(context.TODO(), tt.rel) - - if tt.wantErr != "" { - g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) - } else { - g.Expect(err).NotTo(HaveOccurred()) - } - - g.Expect(got).To(Equal(tt.want)) - g.Expect(drift).To(Equal(tt.wantDrift)) - }) - } -} - -func testSchemeWithMapper() (*runtime.Scheme, meta.RESTMapper) { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{corev1.SchemeGroupVersion}) - mapper.Add(corev1.SchemeGroupVersion.WithKind("Secret"), meta.RESTScopeNamespace) - return scheme, mapper -} diff --git a/internal/diff/summarize.go b/internal/diff/summarize.go new file mode 100644 index 000000000..c553120c7 --- /dev/null +++ b/internal/diff/summarize.go @@ -0,0 +1,164 @@ +/* +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 diff + +import ( + "fmt" + "strings" + + extjsondiff "github.com/wI2L/jsondiff" + + "github.com/fluxcd/pkg/ssa/jsondiff" +) + +// DefaultDiffTypes is the default set of jsondiff.DiffType types to include in +// summaries. +var DefaultDiffTypes = []jsondiff.DiffType{ + jsondiff.DiffTypeCreate, + jsondiff.DiffTypeUpdate, + jsondiff.DiffTypeExclude, +} + +// SummarizeDiffSet returns a summary of the given DiffSet, including only +// the given jsondiff.DiffType types. If no types are given, the +// DefaultDiffTypes set is used. +// +// The summary is a string with one line per Diff, in the format: +// `Kind/namespace/name:

` +// +// Where summary is one of: +// +// - unchanged +// - removed +// - excluded +// - changed (x added, y changed, z removed) +// +// For example: +// +// Deployment/default/hello-world: changed (1 added, 1 changed, 1 removed) +// Deployment/default/hello-world2: removed +// Deployment/default/hello-world3: excluded +// Deployment/default/hello-world4: unchanged +func SummarizeDiffSet(set jsondiff.DiffSet, include ...jsondiff.DiffType) string { + if include == nil { + include = DefaultDiffTypes + } + + var summary strings.Builder + for _, diff := range set { + if diff == nil || !typeInSlice(diff.Type, include) { + continue + } + + switch diff.Type { + case jsondiff.DiffTypeNone: + writeResourceName(diff, &summary) + summary.WriteString(" unchanged\n") + case jsondiff.DiffTypeCreate: + writeResourceName(diff, &summary) + summary.WriteString(" removed\n") + case jsondiff.DiffTypeExclude: + writeResourceName(diff, &summary) + summary.WriteString(" excluded\n") + case jsondiff.DiffTypeUpdate: + writeResourceName(diff, &summary) + added, changed, removed := summarizeUpdate(diff) + summary.WriteString(fmt.Sprintf(" changed (%d additions, %d changes, %d removals)\n", added, changed, removed)) + } + } + return strings.TrimSpace(summary.String()) +} + +// SummarizeDiffSetBrief returns a brief summary of the given DiffSet. +// +// The summary is a string in the format: +// +// removed: x, changed: y, excluded: z, unchanged: w +// +// For example: +// +// removed: 1, changed: 3, excluded: 1, unchanged: 2 +func SummarizeDiffSetBrief(set jsondiff.DiffSet, include ...jsondiff.DiffType) string { + var removed, changed, excluded, unchanged int + for _, diff := range set { + switch diff.Type { + case jsondiff.DiffTypeCreate: + removed++ + case jsondiff.DiffTypeUpdate: + changed++ + case jsondiff.DiffTypeExclude: + excluded++ + case jsondiff.DiffTypeNone: + unchanged++ + } + } + + if include == nil { + include = DefaultDiffTypes + } + + var summary strings.Builder + for _, t := range include { + switch t { + case jsondiff.DiffTypeCreate: + summary.WriteString(fmt.Sprintf("removed: %d, ", removed)) + case jsondiff.DiffTypeUpdate: + summary.WriteString(fmt.Sprintf("changed: %d, ", changed)) + case jsondiff.DiffTypeExclude: + summary.WriteString(fmt.Sprintf("excluded: %d, ", excluded)) + case jsondiff.DiffTypeNone: + summary.WriteString(fmt.Sprintf("unchanged: %d, ", unchanged)) + } + } + return strings.TrimSuffix(summary.String(), ", ") +} + +// writeResourceName writes the resource name in the format +// `kind/namespace/name` to the given strings.Builder. +func writeResourceName(diff *jsondiff.Diff, summary *strings.Builder) { + summary.WriteString(diff.GroupVersionKind.Kind) + summary.WriteString("/") + summary.WriteString(diff.Namespace) + summary.WriteString("/") + summary.WriteString(diff.Name) +} + +// SummarizeUpdate returns the number of added, changed and removed fields +// in the given update patch. +func summarizeUpdate(diff *jsondiff.Diff) (added, changed, removed int) { + for _, p := range diff.Patch { + switch p.Type { + case extjsondiff.OperationAdd: + added++ + case extjsondiff.OperationReplace: + changed++ + case extjsondiff.OperationRemove: + removed++ + } + } + return +} + +// typeInSlice returns true if the given jsondiff.DiffType is in the slice. +func typeInSlice(t jsondiff.DiffType, slice []jsondiff.DiffType) bool { + for _, s := range slice { + if t == s { + return true + } + } + return false +} diff --git a/internal/diff/summarize_test.go b/internal/diff/summarize_test.go new file mode 100644 index 000000000..8b9024379 --- /dev/null +++ b/internal/diff/summarize_test.go @@ -0,0 +1,190 @@ +/* +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 diff + +import ( + "testing" + + extjsondiff "github.com/wI2L/jsondiff" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/fluxcd/pkg/ssa/jsondiff" +) + +func TestSummarizeDiffSet(t *testing.T) { + diffSet := jsondiff.DiffSet{ + &jsondiff.Diff{ + GroupVersionKind: schema.GroupVersionKind{ + Kind: "ConfigMap", + }, + Namespace: "namespace-1", + Name: "config", + Type: jsondiff.DiffTypeNone, + }, + &jsondiff.Diff{ + GroupVersionKind: schema.GroupVersionKind{ + Kind: "Secret", + }, + Namespace: "namespace-x", + Name: "naughty", + Type: jsondiff.DiffTypeCreate, + }, + &jsondiff.Diff{ + GroupVersionKind: schema.GroupVersionKind{ + Kind: "StatefulSet", + }, + Namespace: "default", + Name: "hello-world", + Type: jsondiff.DiffTypeExclude, + }, + &jsondiff.Diff{ + GroupVersionKind: schema.GroupVersionKind{ + Kind: "Deployment", + }, + Namespace: "tenant-y", + Name: "touched-me", + Type: jsondiff.DiffTypeUpdate, + Patch: extjsondiff.Patch{ + {Type: extjsondiff.OperationAdd}, + {Type: extjsondiff.OperationReplace}, + {Type: extjsondiff.OperationReplace}, + {Type: extjsondiff.OperationReplace}, + {Type: extjsondiff.OperationRemove}, + {Type: extjsondiff.OperationRemove}, + }, + }, + } + + tests := []struct { + name string + include []jsondiff.DiffType + want string + }{ + { + name: "default", + include: nil, + want: `Secret/namespace-x/naughty removed +StatefulSet/default/hello-world excluded +Deployment/tenant-y/touched-me changed (1 additions, 3 changes, 2 removals)`, + }, + { + name: "include unchanged", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeNone, + }, + want: "ConfigMap/namespace-1/config unchanged", + }, + { + name: "include removed", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeCreate, + }, + want: "Secret/namespace-x/naughty removed", + }, + { + name: "include excluded", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeExclude, + }, + want: "StatefulSet/default/hello-world excluded", + }, + { + name: "include changed", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeUpdate, + }, + want: "Deployment/tenant-y/touched-me changed (1 additions, 3 changes, 2 removals)", + }, + { + name: "include multiple types", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeNone, + jsondiff.DiffTypeUpdate, + }, + want: `ConfigMap/namespace-1/config unchanged +Deployment/tenant-y/touched-me changed (1 additions, 3 changes, 2 removals)`, + }, + { + name: "empty set", + include: []jsondiff.DiffType{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SummarizeDiffSet(diffSet, tt.include...) + if got != tt.want { + t.Errorf("SummarizeDiffSet() =\n\n%v\n\nwant\n\n%v", got, tt.want) + } + }) + } +} + +func TestSummarizeDiffSetBrief(t *testing.T) { + diffSet := jsondiff.DiffSet{ + &jsondiff.Diff{Type: jsondiff.DiffTypeCreate}, + &jsondiff.Diff{Type: jsondiff.DiffTypeUpdate}, + &jsondiff.Diff{Type: jsondiff.DiffTypeExclude}, + &jsondiff.Diff{Type: jsondiff.DiffTypeNone}, + &jsondiff.Diff{Type: jsondiff.DiffTypeNone}, + } + + tests := []struct { + name string + include []jsondiff.DiffType + want string + }{ + { + name: "default include", + include: nil, + want: "removed: 1, changed: 1, excluded: 1", + }, + { + name: "include create and update", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeCreate, + jsondiff.DiffTypeUpdate, + }, + want: "removed: 1, changed: 1", + }, + { + name: "include all types", + include: []jsondiff.DiffType{ + jsondiff.DiffTypeCreate, + jsondiff.DiffTypeUpdate, + jsondiff.DiffTypeExclude, + jsondiff.DiffTypeNone, + }, + want: "removed: 1, changed: 1, excluded: 1, unchanged: 2", + }, + { + name: "include none", + include: []jsondiff.DiffType{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SummarizeDiffSetBrief(diffSet, tt.include...) + if got != tt.want { + t.Errorf("SummarizeDiffSetBrief() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/reconcile/atomic_release.go b/internal/reconcile/atomic_release.go index 3d1924220..5bebadc54 100644 --- a/internal/reconcile/atomic_release.go +++ b/internal/reconcile/atomic_release.go @@ -23,15 +23,19 @@ import ( "strings" "time" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/logger" "github.com/fluxcd/pkg/runtime/patch" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" + "github.com/fluxcd/pkg/ssa/jsondiff" v2 "github.com/fluxcd/helm-controller/api/v2beta2" "github.com/fluxcd/helm-controller/internal/action" + "github.com/fluxcd/helm-controller/internal/diff" interrors "github.com/fluxcd/helm-controller/internal/errors" ) @@ -172,7 +176,7 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error { default: // Determine the next action to run based on the current state. log.V(logger.DebugLevel).Info("determining current state of Helm release") - state, err := DetermineReleaseState(r.configFactory, req) + state, err := DetermineReleaseState(ctx, r.configFactory, req) if err != nil { conditions.MarkFalse(req.Object, meta.ReadyCondition, "StateError", fmt.Sprintf("Could not determine release state: %s", err.Error())) return fmt.Errorf("cannot determine release state: %w", err) @@ -313,6 +317,24 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state } return NewUpgrade(r.configFactory, r.eventRecorder), nil + case ReleaseStatusDrifted: + log.Info(msgWithReason("detected changes in cluster state", diff.SummarizeDiffSetBrief(state.Diff))) + for _, change := range state.Diff { + if change.Type == jsondiff.DiffTypeCreate || change.Type == jsondiff.DiffTypeUpdate { + log.V(logger.DebugLevel).Info(fmt.Sprintf("observed change in cluster state"), "diff", change) + } + } + + r.eventRecorder.Eventf(req.Object, corev1.EventTypeWarning, "DriftDetected", + "Cluster state of release %s has drifted from the desired state:\n%s", + req.Object.Status.History.Latest().FullReleaseName(), diff.SummarizeDiffSet(state.Diff), + ) + + if req.Object.GetDriftDetection().GetMode() == v2.DriftDetectionEnabled { + return NewUpgrade(r.configFactory, r.eventRecorder), nil + } + + return nil, nil case ReleaseStatusUntested: log.Info(msgWithReason("release has not been tested", state.Reason)) return NewTest(r.configFactory, r.eventRecorder), nil diff --git a/internal/reconcile/atomic_release_test.go b/internal/reconcile/atomic_release_test.go index c7b146c45..30673d682 100644 --- a/internal/reconcile/atomic_release_test.go +++ b/internal/reconcile/atomic_release_test.go @@ -18,16 +18,20 @@ package reconcile import ( "context" + "fmt" "testing" "time" . "github.com/onsi/gomega" + extjsondiff "github.com/wI2L/jsondiff" helmchart "helm.sh/helm/v3/pkg/chart" helmrelease "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/releaseutil" helmstorage "helm.sh/helm/v3/pkg/storage" helmdriver "helm.sh/helm/v3/pkg/storage/driver" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/record" "k8s.io/utils/pointer" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -35,6 +39,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" + "github.com/fluxcd/pkg/ssa/jsondiff" v2 "github.com/fluxcd/helm-controller/api/v2beta2" "github.com/fluxcd/helm-controller/internal/action" @@ -199,7 +204,7 @@ func TestAtomicRelease_Reconcile(t *testing.T) { g.Expect(obj.Status.InstallFailures).To(BeZero()) g.Expect(obj.Status.UpgradeFailures).To(BeZero()) - endState, err := DetermineReleaseState(cfg, req) + endState, err := DetermineReleaseState(ctx, cfg, req) g.Expect(err).ToNot(HaveOccurred()) g.Expect(endState).To(Equal(ReleaseState{Status: ReleaseStatusInSync})) }) @@ -1010,13 +1015,14 @@ func TestAtomicRelease_Reconcile_Scenarios(t *testing.T) { func TestAtomicRelease_actionForState(t *testing.T) { tests := []struct { - name string - releases []*helmrelease.Release - spec func(spec *v2.HelmReleaseSpec) - status func(releases []*helmrelease.Release) v2.HelmReleaseStatus - state ReleaseState - want ActionReconciler - wantErr error + name string + releases []*helmrelease.Release + spec func(spec *v2.HelmReleaseSpec) + status func(releases []*helmrelease.Release) v2.HelmReleaseStatus + state ReleaseState + want ActionReconciler + wantEvent *corev1.Event + wantErr error }{ { name: "in-sync release does not trigger any action", @@ -1055,6 +1061,97 @@ func TestAtomicRelease_actionForState(t *testing.T) { state: ReleaseState{Status: ReleaseStatusUnmanaged}, want: &Upgrade{}, }, + { + name: "drifted release triggers upgrade if enabled", + state: ReleaseState{Status: ReleaseStatusDrifted, Diff: jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }, + Name: "mock", + Namespace: "something", + }, + }}, + spec: func(spec *v2.HelmReleaseSpec) { + spec.DriftDetection = &v2.DriftDetection{ + Mode: v2.DriftDetectionEnabled, + } + }, + status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus { + return v2.HelmReleaseStatus{ + History: v2.Snapshots{ + { + Name: mockReleaseName, + Namespace: mockReleaseNamespace, + Version: 1, + }, + }, + } + }, + want: &Upgrade{}, + wantEvent: &corev1.Event{ + Reason: "DriftDetected", + Type: corev1.EventTypeWarning, + Message: fmt.Sprintf( + "Cluster state of release %s has drifted from the desired state:\n%s", + mockReleaseNamespace+"/"+mockReleaseName+".v1", + "Deployment/something/mock removed", + ), + }, + }, + { + name: "drifted release only triggers event if mode is warn", + spec: func(spec *v2.HelmReleaseSpec) { + spec.DriftDetection = &v2.DriftDetection{ + Mode: v2.DriftDetectionDisabled, + } + }, + status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus { + return v2.HelmReleaseStatus{ + History: v2.Snapshots{ + { + Name: mockReleaseName, + Namespace: mockReleaseNamespace, + Version: 1, + }, + }, + } + }, + state: ReleaseState{Status: ReleaseStatusDrifted, Diff: jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeUpdate, + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }, + Name: "mock", + Namespace: "something", + Patch: extjsondiff.Patch{ + { + Type: extjsondiff.OperationReplace, + Path: "/spec/replicas", + OldValue: 1, + Value: 2, + }, + }, + }, + }}, + want: nil, + wantErr: nil, + wantEvent: &corev1.Event{ + Reason: "DriftDetected", + Type: corev1.EventTypeWarning, + Message: fmt.Sprintf( + "Cluster state of release %s has drifted from the desired state:\n%s", + mockReleaseNamespace+"/"+mockReleaseName+".v1", + "Deployment/something/mock changed (0 additions, 1 changes, 0 removals)", + ), + }, + }, { name: "out-of-sync release triggers upgrade", state: ReleaseState{ @@ -1276,7 +1373,8 @@ func TestAtomicRelease_actionForState(t *testing.T) { } } - r := &AtomicRelease{configFactory: cfg} + recorder := testutil.NewFakeRecorder(1, false) + r := &AtomicRelease{configFactory: cfg, eventRecorder: recorder} got, err := r.actionForState(context.TODO(), &Request{Object: obj}, tt.state) if tt.wantErr != nil { @@ -1291,6 +1389,12 @@ func TestAtomicRelease_actionForState(t *testing.T) { want = BeNil() } g.Expect(got).To(want) + + if tt.wantEvent != nil { + g.Expect(recorder.GetEvents()).To(ConsistOf([]corev1.Event{*tt.wantEvent})) + } else { + g.Expect(recorder.GetEvents()).To(BeEmpty()) + } }) } } diff --git a/internal/reconcile/state.go b/internal/reconcile/state.go index ca9b04331..0392dc2a9 100644 --- a/internal/reconcile/state.go +++ b/internal/reconcile/state.go @@ -17,10 +17,14 @@ limitations under the License. package reconcile import ( + "context" "errors" "fmt" + "github.com/fluxcd/pkg/ssa/jsondiff" + "helm.sh/helm/v3/pkg/kube" helmrelease "helm.sh/helm/v3/pkg/release" + ctrl "sigs.k8s.io/controller-runtime" "github.com/fluxcd/helm-controller/internal/action" interrors "github.com/fluxcd/helm-controller/internal/errors" @@ -48,6 +52,10 @@ const ( // ReleaseStatusOutOfSync indicates that the release is present in the Helm // storage, but is not in sync with the v2beta2.HelmRelease object. ReleaseStatusOutOfSync ReleaseStatus = "OutOfSync" + // ReleaseStatusDrifted indicates that the release is present in the Helm + // storage, but the cluster state has drifted from the manifest in the + // storage. + ReleaseStatusDrifted ReleaseStatus = "Drifted" // ReleaseStatusLocked indicates that the release is present in the Helm // storage, but is locked. ReleaseStatusLocked ReleaseStatus = "Locked" @@ -69,12 +77,15 @@ type ReleaseState struct { Status ReleaseStatus // Reason for the Status. Reason string + // Diff contains any differences between the Helm storage manifest and the + // cluster state when Status equals ReleaseStatusDrifted. + Diff jsondiff.DiffSet } // DetermineReleaseState determines the state of the Helm release as compared // to the v2beta2.HelmRelease object. It returns a ReleaseState that indicates // the status of the release, and an error if the state could not be determined. -func DetermineReleaseState(cfg *action.ConfigFactory, req *Request) (ReleaseState, error) { +func DetermineReleaseState(ctx context.Context, cfg *action.ConfigFactory, req *Request) (ReleaseState, error) { rls, err := action.LastRelease(cfg.Build(nil), req.Object.GetReleaseName()) if err != nil { if errors.Is(err, action.ErrReleaseNotFound) { @@ -146,6 +157,21 @@ func DetermineReleaseState(cfg *action.ConfigFactory, req *Request) (ReleaseStat } } + // Confirm the cluster state matches the desired config. + if diffOpts := req.Object.GetDriftDetection(); diffOpts.MustDetectChanges() { + diffSet, err := action.Diff(ctx, cfg.Build(nil), rls, kube.ManagedFieldsManager, req.Object.GetDriftDetection().Ignore...) + hasChanges := diffSet.HasChanges() + if err != nil { + if !hasChanges { + return ReleaseState{Status: ReleaseStatusUnknown}, fmt.Errorf("unable to determine cluster state: %w", err) + } + ctrl.LoggerFrom(ctx).Error(err, "diff of release against cluster state completed with error") + } + if hasChanges { + return ReleaseState{Status: ReleaseStatusDrifted, Diff: diffSet}, nil + } + } + return ReleaseState{Status: ReleaseStatusInSync}, nil default: return ReleaseState{Status: ReleaseStatusUnknown}, fmt.Errorf("unable to determine state for release with status '%s'", rls.Info.Status) diff --git a/internal/reconcile/state_test.go b/internal/reconcile/state_test.go index a4a3b9c64..62154f684 100644 --- a/internal/reconcile/state_test.go +++ b/internal/reconcile/state_test.go @@ -17,6 +17,8 @@ limitations under the License. package reconcile import ( + "context" + "strings" "testing" . "github.com/onsi/gomega" @@ -25,6 +27,10 @@ import ( helmrelease "helm.sh/helm/v3/pkg/release" helmstorage "helm.sh/helm/v3/pkg/storage" helmdriver "helm.sh/helm/v3/pkg/storage/driver" + "k8s.io/apimachinery/pkg/runtime/schema" + + "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/action" @@ -477,7 +483,7 @@ func Test_DetermineReleaseState(t *testing.T) { } } - got, err := DetermineReleaseState(cfg, &Request{ + got, err := DetermineReleaseState(context.TODO(), cfg, &Request{ Object: obj, Chart: tt.chart, Values: tt.values, @@ -494,3 +500,147 @@ func Test_DetermineReleaseState(t *testing.T) { }) } } + +func TestDetermineReleaseState_DriftDetection(t *testing.T) { + tests := []struct { + name string + driftMode v2.DriftDetectionMode + applyManifest bool + want func(namespace string) ReleaseState + }{ + { + name: "with drift and detection mode enabled", + driftMode: v2.DriftDetectionEnabled, + want: func(namespace string) ReleaseState { + return ReleaseState{ + Status: ReleaseStatusDrifted, + Diff: jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Kind: "Secret", + Version: "v1", + }, + Namespace: namespace, + Name: "fixture", + }, + }, + } + }, + }, + { + name: "without drift and detection mode enabled", + driftMode: v2.DriftDetectionEnabled, + applyManifest: true, + want: func(_ string) ReleaseState { + return ReleaseState{Status: ReleaseStatusInSync} + }, + }, + { + name: "with drift and detection mode warn", + driftMode: v2.DriftDetectionWarn, + want: func(namespace string) ReleaseState { + return ReleaseState{ + Status: ReleaseStatusDrifted, + Diff: jsondiff.DiffSet{ + { + Type: jsondiff.DiffTypeCreate, + GroupVersionKind: schema.GroupVersionKind{ + Kind: "Secret", + Version: "v1", + }, + Namespace: namespace, + Name: "fixture", + }, + }, + } + }, + }, + { + name: "without drift and detection mode warn", + applyManifest: true, + driftMode: v2.DriftDetectionWarn, + want: func(_ string) ReleaseState { + return ReleaseState{Status: ReleaseStatusInSync} + }, + }, + { + name: "drift detection mode disabled", + driftMode: v2.DriftDetectionDisabled, + want: func(_ string) ReleaseState { + return ReleaseState{Status: ReleaseStatusInSync} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + namedNS, err := testEnv.CreateNamespace(context.TODO(), mockReleaseNamespace) + g.Expect(err).NotTo(HaveOccurred()) + t.Cleanup(func() { + _ = testEnv.Delete(context.TODO(), namedNS) + }) + releaseNamespace := namedNS.Name + + chart := testutil.BuildChart() + + rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{ + Name: mockReleaseName, + Namespace: releaseNamespace, + Version: 1, + Status: helmrelease.StatusDeployed, + Chart: chart, + }) + + if tt.applyManifest { + objs, err := ssa.ReadObjects(strings.NewReader(rls.Manifest)) + g.Expect(err).ToNot(HaveOccurred()) + + for _, obj := range objs { + g.Expect(ssa.NormalizeUnstructured(obj)).To(Succeed()) + obj.SetNamespace(releaseNamespace) + g.Expect(testEnv.Create(context.Background(), obj)).To(Succeed()) + } + } + + obj := &v2.HelmRelease{ + Spec: v2.HelmReleaseSpec{ + ReleaseName: mockReleaseName, + TargetNamespace: releaseNamespace, + StorageNamespace: releaseNamespace, + DriftDetection: &v2.DriftDetection{ + Mode: tt.driftMode, + }, + }, + Status: v2.HelmReleaseStatus{ + History: v2.Snapshots{ + release.ObservedToSnapshot(release.ObserveRelease(rls)), + }, + }, + } + + getter, err := RESTClientGetterFromManager(testEnv.Manager, obj.GetReleaseNamespace()) + g.Expect(err).ToNot(HaveOccurred()) + + cfg, err := action.NewConfigFactory(getter, + action.WithStorage(action.DefaultStorageDriver, obj.GetStorageNamespace()), + ) + g.Expect(err).ToNot(HaveOccurred()) + + store := helmstorage.Init(cfg.Driver) + g.Expect(store.Create(rls)).To(Succeed()) + + got, err := DetermineReleaseState(context.TODO(), cfg, &Request{ + Object: obj, + Chart: testutil.BuildChart(), + Values: rls.Config, + }) + g.Expect(err).ToNot(HaveOccurred()) + + want := tt.want(releaseNamespace) + g.Expect(got).To(Equal(want)) + }) + } +}