diff --git a/api/v2beta1/helmrelease_types.go b/api/v2beta1/helmrelease_types.go index 00d6a9d38..69743fca1 100644 --- a/api/v2beta1/helmrelease_types.go +++ b/api/v2beta1/helmrelease_types.go @@ -83,7 +83,7 @@ type HelmReleaseSpec struct { // a controller level fallback for when HelmReleaseSpec.ServiceAccountName // is empty. // +optional - KubeConfig *KubeConfig `json:"kubeConfig,omitempty"` + KubeConfig *meta.KubeConfigReference `json:"kubeConfig,omitempty"` // Suspend tells the controller to suspend reconciliation for this HelmRelease, // it does not apply to already started reconciliations. Defaults to false. @@ -215,21 +215,6 @@ func (in HelmReleaseSpec) GetUninstall() Uninstall { return *in.Uninstall } -// KubeConfig references a Kubernetes secret that contains a kubeconfig file. -type KubeConfig struct { - // SecretRef holds the name to a secret that contains a key with - // the kubeconfig file as the value. If no key is specified the key will - // default to 'value'. The secret must be in the same namespace as - // the HelmRelease. - // It is recommended that the kubeconfig is self-contained, and the secret - // is regularly updated if credentials such as a cloud-access-token expire. - // Cloud specific `cmd-path` auth helpers will not function without adding - // binaries and credentials to the Pod that is responsible for reconciling - // the HelmRelease. - // +required - SecretRef meta.SecretKeyReference `json:"secretRef,omitempty"` -} - // HelmChartTemplate defines the template from which the controller will // generate a v1beta2.HelmChart object in the same namespace as the referenced // v1beta2.Source. diff --git a/api/v2beta1/zz_generated.deepcopy.go b/api/v2beta1/zz_generated.deepcopy.go index 6c3f4c541..0d7b8cc7e 100644 --- a/api/v2beta1/zz_generated.deepcopy.go +++ b/api/v2beta1/zz_generated.deepcopy.go @@ -177,7 +177,7 @@ func (in *HelmReleaseSpec) DeepCopyInto(out *HelmReleaseSpec) { out.Interval = in.Interval if in.KubeConfig != nil { in, out := &in.KubeConfig, &out.KubeConfig - *out = new(KubeConfig) + *out = new(meta.KubeConfigReference) **out = **in } if in.DependsOn != nil { @@ -322,22 +322,6 @@ func (in *InstallRemediation) DeepCopy() *InstallRemediation { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubeConfig) DeepCopyInto(out *KubeConfig) { - *out = *in - out.SecretRef = in.SecretRef -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeConfig. -func (in *KubeConfig) DeepCopy() *KubeConfig { - if in == nil { - return nil - } - out := new(KubeConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Kustomize) DeepCopyInto(out *Kustomize) { *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 468d487bb..b926df4a5 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -274,14 +274,14 @@ spec: is empty. properties: secretRef: - description: SecretRef holds the name to a secret that contains - a key with the kubeconfig file as the value. If no key is specified - the key will default to 'value'. The secret must be in the same - namespace as the HelmRelease. It is recommended that the kubeconfig - is self-contained, and the secret is regularly updated if credentials - such as a cloud-access-token expire. Cloud specific `cmd-path` - auth helpers will not function without adding binaries and credentials - to the Pod that is responsible for reconciling the HelmRelease. + description: SecretRef holds the name of a secret that contains + a key with the kubeconfig file as the value. If no key is set, + the key will default to 'value'. It is recommended that the + kubeconfig is self-contained, and the secret is regularly updated + if credentials such as a cloud-access-token expire. Cloud specific + `cmd-path` auth helpers will not function without adding binaries + and credentials to the Pod that is responsible for reconciling + Kubernetes resources. properties: key: description: Key in the Secret, when not specified an implementation-specific @@ -293,6 +293,8 @@ spec: required: - name type: object + required: + - secretRef type: object maxHistory: description: MaxHistory is the number of revisions saved by Helm for diff --git a/controllers/helmrelease_controller.go b/controllers/helmrelease_controller.go index ccbc9e09b..52d788afe 100644 --- a/controllers/helmrelease_controller.go +++ b/controllers/helmrelease_controller.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/rest" kuberecorder "k8s.io/client-go/tools/record" "k8s.io/client-go/tools/reference" + "sigs.k8s.io/cli-utils/pkg/kstatus/polling" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -53,13 +54,15 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/acl" - fluxClient "github.com/fluxcd/pkg/runtime/client" + runtimeClient "github.com/fluxcd/pkg/runtime/client" "github.com/fluxcd/pkg/runtime/metrics" "github.com/fluxcd/pkg/runtime/predicates" "github.com/fluxcd/pkg/runtime/transform" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" v2 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/fluxcd/helm-controller/internal/diff" + "github.com/fluxcd/helm-controller/internal/features" "github.com/fluxcd/helm-controller/internal/kube" "github.com/fluxcd/helm-controller/internal/runner" "github.com/fluxcd/helm-controller/internal/util" @@ -83,8 +86,11 @@ type HelmReleaseReconciler struct { MetricsRecorder *metrics.Recorder DefaultServiceAccount string NoCrossNamespaceRef bool - ClientOpts fluxClient.Options - KubeConfigOpts fluxClient.KubeConfigOptions + ClientOpts runtimeClient.Options + KubeConfigOpts runtimeClient.KubeConfigOptions + StatusPoller *polling.StatusPoller + PollingOpts polling.Options + ControllerName string } func (r *HelmReleaseReconciler) SetupWithManager(mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error { @@ -103,7 +109,7 @@ func (r *HelmReleaseReconciler) SetupWithManager(mgr ctrl.Manager, opts HelmRele r.requeueDependency = opts.DependencyRequeueInterval // Configure the retryable http client used for fetching artifacts. - // By default it retries 10 times within a 3.5 minutes window. + // By default, it retries 10 times within a 3.5 minutes window. httpClient := retryablehttp.NewClient() httpClient.RetryWaitMin = 5 * time.Second httpClient.RetryWaitMax = 30 * time.Second @@ -319,6 +325,44 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, releaseRevision := util.ReleaseRevision(rel) valuesChecksum := util.ValuesChecksum(values) hr, hasNewState := v2.HelmReleaseAttempted(hr, revision, releaseRevision, valuesChecksum) + + // Run diff against current cluster state. + if !hasNewState { + if ok, _ := features.Enabled(features.DetectDrift); ok { + differ := diff.NewDiffer(runtimeClient.NewImpersonator( + r.Client, + r.StatusPoller, + r.PollingOpts, + hr.Spec.KubeConfig, + r.KubeConfigOpts, + r.DefaultServiceAccount, + hr.Spec.ServiceAccountName, + hr.GetNamespace(), + ), r.ControllerName) + + changeSet, drift, err := differ.Diff(ctx, rel) + if err != nil { + if changeSet == nil { + msg := "failed to diff release against cluster resources" + r.event(ctx, hr, rel.Chart.Metadata.Version, eventv1.EventSeverityError, err.Error()) + return v2.HelmReleaseNotReady(hr, "DiffFailed", fmt.Sprintf("%s: %s", msg, err.Error())), err + } + log.Error(err, "diff of release against cluster resources finished with error") + } + + msg := "no diff in cluster resources compared to release" + if drift { + hasNewState = true + msg = "diff in cluster resources compared to release" + } + if changeSet != nil { + msg = fmt.Sprintf("%s:\n\n%s", msg, changeSet.String()) + r.event(ctx, hr, rel.Chart.Metadata.Version, eventv1.EventSeverityInfo, msg) + } + log.Info(msg) + } + } + if hasNewState { hr = v2.HelmReleaseProgressing(hr) if updateStatusErr := r.patchStatus(ctx, &hr); updateStatusErr != nil { diff --git a/docs/api/helmrelease.md b/docs/api/helmrelease.md index b53cf1962..2f6277671 100644 --- a/docs/api/helmrelease.md +++ b/docs/api/helmrelease.md @@ -99,8 +99,8 @@ Kubernetes meta/v1.Duration
kubeConfig
kubeConfig
-(Appears on: -HelmReleaseSpec) -
-KubeConfig references a Kubernetes secret that contains a kubeconfig file.
-Field | -Description | -
---|---|
-secretRef - - -github.com/fluxcd/pkg/apis/meta.SecretKeyReference - - - |
-
- SecretRef holds the name to a secret that contains a key with
-the kubeconfig file as the value. If no key is specified the key will
-default to ‘value’. The secret must be in the same namespace as
-the HelmRelease.
-It is recommended that the kubeconfig is self-contained, and the secret
-is regularly updated if credentials such as a cloud-access-token expire.
-Cloud specific |
-
diff --git a/docs/spec/v2beta1/helmreleases.md b/docs/spec/v2beta1/helmreleases.md index e4bb3fca0..4d1c2ac3c 100644 --- a/docs/spec/v2beta1/helmreleases.md +++ b/docs/spec/v2beta1/helmreleases.md @@ -1270,6 +1270,92 @@ spec: crds: CreateReplace ``` +### Drift detection + +**Note:** This feature is experimental and can be enabled by setting `--feature-gates=DetectDrift=true`. + +When a HelmRelease is in-sync with the Helm release object in the storage, the controller will +compare the manifests from the Helm storage with the current state of the cluster using a +[server-side dry-run apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/). +If this comparison detects a drift (either due resource being created or modified during the +dry-run), the controller will perform an upgrade for the release, restoring the desired state. + +### Excluding resources from drift detection + +The drift detection feature can be configured to exclude certain resources from the comparison +by labeling or annotating them with `helm.toolkit.fluxcd.io/driftDetection: disabled`. Using +[post-renderers](#post-renderers), this can be applied to any resource rendered by Helm. + +```yaml +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: app + namespace: webapp +spec: + postRenderers: + - kustomize: + patches: + - target: + version: v1 + kind: Deployment + name: my-app + patch: | + - op: add + path: /metadata/annotations/helm.toolkit.fluxcd.io~1driftDetection + value: disabled +``` + +**Note:** For some charts, we have observed the drift detection feature can detect spurious +changes due to Helm not properly patching an object, which seems to be related to +[Helm#5915](https://github.com/helm/helm/issues/5915) and issues alike. In this case (and +when possible for your workload), configuring `.spec.upgrade.force` to `true` might be a +more fitting solution than ignoring the object in full. + +#### Drift exclusion example Prometheus Stack + +```yaml +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: kube-prometheus-stack +spec: + interval: 5m + chart: + spec: + version: "45.x" + chart: kube-prometheus-stack + sourceRef: + kind: HelmRepository + name: prometheus-community + interval: 60m + upgrade: + crds: CreateReplace + # Force recreation due to Helm not properly patching Deployment with e.g. added port, + # causing spurious drift detection + force: true + postRenderers: + - kustomize: + patches: + - target: + # Ignore these objects from Flux diff as they are mutated from chart hooks + kind: (ValidatingWebhookConfiguration|MutatingWebhookConfiguration) + name: kube-prometheus-stack-admission + patch: | + - op: add + path: /metadata/annotations/helm.toolkit.fluxcd.io~1driftDetection + value: disabled + - target: + # Ignore these objects from Flux diff as they are mutated at apply time but not + # at dry-run time + kind: PrometheusRule + patch: | + - op: add + path: /metadata/annotations/helm.toolkit.fluxcd.io~1driftDetection + value: disabled +``` + ## Status When the controller completes a reconciliation, it reports the result in the status sub-resource. diff --git a/go.mod b/go.mod index 698c99cdb..941cf7017 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,10 @@ require ( github.com/fluxcd/pkg/apis/kustomize v0.8.0 github.com/fluxcd/pkg/apis/meta v0.19.0 github.com/fluxcd/pkg/runtime v0.29.0 + github.com/fluxcd/pkg/ssa v0.24.1 github.com/fluxcd/source-controller/api v0.35.1 github.com/go-logr/logr v1.2.3 + github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-retryablehttp v0.7.2 github.com/onsi/gomega v1.26.0 github.com/spf13/pflag v1.0.5 @@ -23,6 +25,7 @@ require ( k8s.io/cli-runtime v0.26.1 k8s.io/client-go v0.26.1 k8s.io/utils v0.0.0-20230209194617-a36077c30491 + sigs.k8s.io/cli-utils v0.34.0 sigs.k8s.io/controller-runtime v0.14.4 sigs.k8s.io/kustomize/api v0.12.1 sigs.k8s.io/yaml v1.3.0 @@ -50,7 +53,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/containerd/containerd v1.6.18 // indirect + github.com/containerd/containerd v1.6.15 // indirect github.com/cyphar/filepath-securejoin v0.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v20.10.21+incompatible // indirect @@ -78,7 +81,6 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/gnostic v0.6.9 // indirect - github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.3.0 // indirect @@ -139,12 +141,12 @@ require ( go.uber.org/multierr v1.8.0 // indirect go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.5.0 // indirect - golang.org/x/net v0.5.0 // indirect + golang.org/x/net v0.7.0 // indirect golang.org/x/oauth2 v0.2.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.4.0 // indirect - golang.org/x/term v0.4.0 // indirect - golang.org/x/text v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/term v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect @@ -161,7 +163,6 @@ require ( k8s.io/kube-openapi v0.0.0-20221110221610-a28e98eb7c70 // indirect k8s.io/kubectl v0.26.0 // indirect oras.land/oras-go v1.2.2 // indirect - sigs.k8s.io/cli-utils v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect diff --git a/go.sum b/go.sum index 4066dfa33..75a021bf9 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/containerd/cgroups v1.0.4 h1:jN/mbWBEaz+T1pi5OFtnkQ+8qnmEbAr1Oo1FRm5B0dA= -github.com/containerd/containerd v1.6.18 h1:qZbsLvmyu+Vlty0/Ex5xc0z2YtKpIsb5n45mAMI+2Ns= -github.com/containerd/containerd v1.6.18/go.mod h1:1RdCUu95+gc2v9t3IL+zIlpClSmew7/0YS8O5eQZrOw= +github.com/containerd/containerd v1.6.15 h1:4wWexxzLNHNE46aIETc6ge4TofO550v+BlLoANrbses= +github.com/containerd/containerd v1.6.15/go.mod h1:U2NnBPIhzJDm59xF7xB2MMHnKtggpZ+phKg8o2TKj2c= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -168,6 +168,8 @@ github.com/fluxcd/pkg/apis/meta v0.19.0 h1:CX75e/eaRWZDTzNdMSWomY1InlssLKcS8GQDS github.com/fluxcd/pkg/apis/meta v0.19.0/go.mod h1:7b6prDPsViyAzoY7eRfSPS0/MbXpGGsOMvRq2QrTKa4= github.com/fluxcd/pkg/runtime v0.29.0 h1:/BDitj/y5shWqczECCiZFsEm9FH7do4VBgMHBiRiol0= github.com/fluxcd/pkg/runtime v0.29.0/go.mod h1:NrBONYHO5Piuzm6Y7QTS3cJRlgkgsDPn2EKB6gJ4BQw= +github.com/fluxcd/pkg/ssa v0.24.1 h1:0dn5FqyYdGa+VuDp5EJrkLbPq5xhhSAAkMgGUeMpOM0= +github.com/fluxcd/pkg/ssa v0.24.1/go.mod h1:nEOUOwGotBlNZkTkO6GHPlI0U0BmHTavFd1Jk+TzsGw= github.com/fluxcd/source-controller/api v0.35.1 h1:IHlbN7giz5kY4z9oWZ9QLNKtHAaxHdk9RbIurUPS1aI= github.com/fluxcd/source-controller/api v0.35.1/go.mod h1:TImPMy/MEwNpDu6qHsw9LlCznXaB8bSO8mnxBSFsX4Q= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -711,8 +713,8 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -808,14 +810,14 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -826,8 +828,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/cmp/simple_unstructured.go b/internal/cmp/simple_unstructured.go new file mode 100644 index 000000000..edae2a46d --- /dev/null +++ b/internal/cmp/simple_unstructured.go @@ -0,0 +1,110 @@ +/* +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 cmp + +import ( + "fmt" + "reflect" + "strings" + + "github.com/google/go-cmp/cmp" +) + +// SimpleUnstructuredReporter is a simple reporter for Unstructured objects +// that only records differences detected during comparison, in a diff-like +// format. +type SimpleUnstructuredReporter struct { + path cmp.Path + diffs []string +} + +// Report writes a diff entry if rs is not equal. In the format of: +// +// .spec.replicas +// -3 +// +1 +// +// .spec.template.spec.containers.[0].command.[6] +// ---deleted=true +// +// .spec.template.spec.containers.[0].env.[?->1] +// +map[name:ADDED] +func (r *SimpleUnstructuredReporter) Report(rs cmp.Result) { + if !rs.Equal() { + vx, vy := r.path.Last().Values() + isNonEmptyX := isValidAndNonEmpty(vx) + isNonEmptyY := isValidAndNonEmpty(vy) + + if !isNonEmptyX && !isNonEmptyY { + // Skip empty values. + return + } + + var sb strings.Builder + writePathString(r.path, &sb) + sb.WriteString("\n") + if isNonEmptyX { + sb.WriteString(fmt.Sprintf("-%v", vx)) + sb.WriteString("\n") + } + if isNonEmptyY { + sb.WriteString(fmt.Sprintf("+%v", vy)) + sb.WriteString("\n") + } + r.diffs = append(r.diffs, sb.String()) + } +} + +// String returns the diff entries joined together with newline, trimmed from +// spaces. +func (r *SimpleUnstructuredReporter) String() string { + return strings.TrimSpace(strings.Join(r.diffs, "\n")) +} + +func (r *SimpleUnstructuredReporter) PushStep(ps cmp.PathStep) { + r.path = append(r.path, ps) +} + +func (r *SimpleUnstructuredReporter) PopStep() { + r.path = r.path[:len(r.path)-1] +} + +// https://github.com/istio/istio/blob/2caf81c64a2213dde39174a0a36cae530dc52b69/operator/pkg/compare/compare.go#L79 +func isValidAndNonEmpty(v reflect.Value) bool { + if !v.IsValid() { + return false + } + switch v.Kind() { + case reflect.Interface: + return isValidAndNonEmpty(v.Elem()) + case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: + return v.Len() > 0 + } + return true +} + +func writePathString(path cmp.Path, sb *strings.Builder) { + for _, st := range path { + switch t := st.(type) { + case cmp.MapIndex: + sb.WriteString(fmt.Sprintf(".%v", t.Key())) + case cmp.SliceIndex: + sb.WriteString(fmt.Sprintf("%v", t.String())) + } + } + return +} diff --git a/internal/cmp/simple_unstructured_test.go b/internal/cmp/simple_unstructured_test.go new file mode 100644 index 000000000..6cba9fa11 --- /dev/null +++ b/internal/cmp/simple_unstructured_test.go @@ -0,0 +1,285 @@ +/* +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 cmp + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestSimpleYAMLReporter_Report(t *testing.T) { + tests := []struct { + name string + x string + y string + want string + }{ + { + name: "added simple value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: {}`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 1`, + want: `.spec.replicas ++1`, + }, + { + name: "removed simple value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 1`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: {}`, + want: `.spec.replicas +-1`, + }, + { + name: "changed simple value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 1`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 2`, + want: `.spec.replicas +-1 ++2`, + }, + { + name: "added map with value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: {}`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container`, + want: `.spec.template.spec.containers ++[map[name:container]]`, + }, + { + name: "removed map with value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: {}`, + want: `.spec.template.spec.containers +-[map[name:container]]`, + }, + { + name: "changed map with value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container2`, + want: `.spec.template.spec.containers[0].name +-container ++container2`, + }, + { + name: "added list item value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: []`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: + - name: env`, + want: `.spec.template.spec.containers[0].env[?->0] ++map[name:env]`, + }, + { + name: "removed list item value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: + - name: env`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: []`, + want: `.spec.template.spec.containers[0].env[0->?] +-map[name:env]`, + }, + { + name: "changed list item value", + x: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: + - name: env`, + y: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: container + env: + - name: env2`, + want: `.spec.template.spec.containers[0].env[0].name +-env ++env2`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + x, err := yamlToUnstructured(tt.x) + if err != nil { + t.Fatal("failed to parse x", err) + } + y, err := yamlToUnstructured(tt.y) + if err != nil { + t.Fatal("failed to parse y", err) + } + + r := SimpleUnstructuredReporter{} + _ = cmp.Diff(x, y, cmp.Reporter(&r)) + result := r.String() + g.Expect(result).To(Equal(tt.want), result) + }) + } +} + +func TestSimpleYAMLReporter_String(t *testing.T) { + tests := []struct { + name string + diffs []string + want string + }{ + {name: "trims space", diffs: []string{" at start", "in\nbetween", "at end\n"}, want: `at start +in +between +at end`}, + {name: "joins with newline", diffs: []string{"a", "b", "c"}, want: `a +b +c`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &SimpleUnstructuredReporter{ + diffs: tt.diffs, + } + if got := r.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func yamlToUnstructured(str string) (*unstructured.Unstructured, error) { + var obj map[string]interface{} + if err := yaml.Unmarshal([]byte(str), &obj); err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: obj}, nil +} diff --git a/internal/diff/differ.go b/internal/diff/differ.go new file mode 100644 index 000000000..9359fa3f6 --- /dev/null +++ b/internal/diff/differ.go @@ -0,0 +1,159 @@ +/* +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" + + "github.com/fluxcd/pkg/runtime/client" + "github.com/fluxcd/pkg/ssa" + "github.com/google/go-cmp/cmp" + "helm.sh/helm/v3/pkg/release" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/errors" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/fluxcd/pkg/runtime/logger" + + helmv1 "github.com/fluxcd/helm-controller/api/v2beta1" + intcmp "github.com/fluxcd/helm-controller/internal/cmp" + "github.com/fluxcd/helm-controller/internal/util" +) + +var ( + // MetadataKey is the label or annotation key used to disable the diffing + // of an object. + MetadataKey = helmv1.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 + r := intcmp.SimpleUnstructuredReporter{} + if diff := cmp.Diff( + unstructuredWithoutStatus(releaseObject).UnstructuredContent(), + unstructuredWithoutStatus(clusterObject).UnstructuredContent(), + cmp.Reporter(&r)); diff != "" { + ctrl.LoggerFrom(ctx).V(logger.DebugLevel).Info(entry.Subject + " diff:\n" + r.String()) + } + } + 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 +} + +func unstructuredWithoutStatus(obj *unstructured.Unstructured) *unstructured.Unstructured { + obj = obj.DeepCopy() + delete(obj.Object, "status") + return obj +} diff --git a/internal/diff/differ_test.go b/internal/diff/differ_test.go new file mode 100644 index 000000000..f3a9bdf7d --- /dev/null +++ b/internal/diff/differ_test.go @@ -0,0 +1,250 @@ +/* +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/cli-utils/pkg/kstatus/polling" + "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + 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/features/features.go b/internal/features/features.go index 4f14acc27..cae87fed0 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -24,15 +24,23 @@ const ( // CacheSecretsAndConfigMaps configures the caching of Secrets and ConfigMaps // by the controller-runtime client. // - // When enabled, it will cache both object types, resulting in increased memory usage - // and cluster-wide RBAC permissions (list and watch). + // When enabled, it will cache both object types, resulting in increased memory + // usage and cluster-wide RBAC permissions (list and watch). CacheSecretsAndConfigMaps = "CacheSecretsAndConfigMaps" + + // DetectDrift configures the detection of cluster state drift compared to + // the desired state as described in the manifest of the Helm release + // storage object. + DetectDrift = "DetectDrift" ) var features = map[string]bool{ // CacheSecretsAndConfigMaps // opt-in from v0.28 CacheSecretsAndConfigMaps: false, + // DetectClusterStateDrift + // opt-in from v0.31 + DetectDrift: false, } // FeatureGates contains a list of all supported feature gates and diff --git a/internal/util/object.go b/internal/util/object.go new file mode 100644 index 000000000..f6476fcdc --- /dev/null +++ b/internal/util/object.go @@ -0,0 +1,62 @@ +/* +Copyright 2023 The Flux authors +Copyright 2018 The Kubernetes 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. +*/ + +// TODO: Remove this when +// https://github.com/kubernetes-sigs/controller-runtime/blob/c783d2527a7da76332a2d8d563a6ca0b80c12122/pkg/client/apiutil/apimachinery.go#L76-L104 +// is included in a semver release. + +package util + +import ( + "errors" + "fmt" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// IsAPINamespaced returns true if the object is namespace scoped. +// For unstructured objects the gvk is found from the object itself. +func IsAPINamespaced(obj runtime.Object, scheme *runtime.Scheme, restmapper apimeta.RESTMapper) (bool, error) { + gvk, err := apiutil.GVKForObject(obj, scheme) + if err != nil { + return false, err + } + return IsAPINamespacedWithGVK(gvk, restmapper) +} + +// IsAPINamespacedWithGVK returns true if the object having the provided +// GVK is namespace scoped. +func IsAPINamespacedWithGVK(gk schema.GroupVersionKind, restmapper apimeta.RESTMapper) (bool, error) { + restmapping, err := restmapper.RESTMapping(schema.GroupKind{Group: gk.Group, Kind: gk.Kind}) + if err != nil { + return false, fmt.Errorf("failed to get restmapping: %w", err) + } + + scope := restmapping.Scope.Name() + + if scope == "" { + return false, errors.New("scope cannot be identified, empty scope returned") + } + + if scope != apimeta.RESTScopeNameRoot { + return true, nil + } + return false, nil +} diff --git a/main.go b/main.go index 3d4cbe9f8..04f9fa811 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + "sigs.k8s.io/cli-utils/pkg/kstatus/polling" ctrl "sigs.k8s.io/controller-runtime" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" @@ -165,6 +166,7 @@ func main() { os.Exit(1) } + pollingOpts := polling.Options{} if err = (&controllers.HelmReleaseReconciler{ Client: mgr.GetClient(), Config: mgr.GetConfig(), @@ -174,6 +176,9 @@ func main() { NoCrossNamespaceRef: aclOptions.NoCrossNamespaceRefs, ClientOpts: clientOptions, KubeConfigOpts: kubeConfigOpts, + PollingOpts: pollingOpts, + StatusPoller: polling.NewStatusPoller(mgr.GetClient(), mgr.GetRESTMapper(), pollingOpts), + ControllerName: controllerName, }).SetupWithManager(mgr, controllers.HelmReleaseReconcilerOptions{ MaxConcurrentReconciles: concurrent, DependencyRequeueInterval: requeueDependency,