From a8d88b419f97ea584515744109490c269c12e5f0 Mon Sep 17 00:00:00 2001 From: Chris Werner Rau Date: Fri, 25 Sep 2026 16:44:43 +0200 Subject: [PATCH] Add POC template-digest drift detection Sizes the design discussed for detecting drift caused by Helm's lookup function: a live re-render digest compared against the digest of the last real release, reusing the existing OutOfSync path instead of a new diffing mechanism. Tests intentionally omitted, this is only meant to gauge the size of the change. Signed-off-by: Chris Werner Rau Assisted-by: Claude Code/claude-sonnet-5 --- api/v2/helmrelease_types.go | 32 +++++++++++++++++++- internal/action/render_digest.go | 50 ++++++++++++++++++++++++++++++++ internal/reconcile/install.go | 9 +++++- internal/reconcile/state.go | 17 +++++++++++ internal/reconcile/upgrade.go | 9 +++++- 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 internal/action/render_digest.go diff --git a/api/v2/helmrelease_types.go b/api/v2/helmrelease_types.go index f9c74d67c..332c6e8b9 100644 --- a/api/v2/helmrelease_types.go +++ b/api/v2/helmrelease_types.go @@ -287,6 +287,17 @@ var ( // detect or respond to differences between the manifest in the Helm // storage and the resources currently existing in the cluster. DriftDetectionDisabled DriftDetectionMode = "disabled" + + // DriftDetectionEnabledWithRender instructs the controller to, in + // addition to DriftDetectionEnabled, perform a live re-render of the + // chart (evaluating e.g. the Helm `lookup` function against the + // current cluster state) on every reconciliation, and to trigger a + // Helm upgrade if the resulting manifest differs from the manifest + // observed at the most recent release action. + // + // PROOF OF CONCEPT: this mode exists to size the change, see + // https://github.com/fluxcd/helm-controller/issues/1583. + DriftDetectionEnabledWithRender DriftDetectionMode = "enabledWithRender" ) var ( @@ -321,7 +332,7 @@ 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 + // +kubebuilder:validation:Enum=enabled;warn;disabled;enabledWithRender // +optional Mode DriftDetectionMode `json:"mode,omitempty"` @@ -346,6 +357,13 @@ func (d DriftDetection) MustDetectChanges() bool { return d.GetMode() == DriftDetectionEnabled || d.GetMode() == DriftDetectionWarn } +// MustRenderForDrift returns true if the DriftDetectionMode requires a live +// re-render of the chart to detect drift, in addition to comparing the Helm +// storage manifest against the cluster state. +func (d DriftDetection) MustRenderForDrift() bool { + return d.GetMode() == DriftDetectionEnabledWithRender +} + // HelmChartTemplate defines the template from which the controller will // generate a v1.HelmChart object in the same namespace as the referenced // v1.Source. @@ -1298,6 +1316,18 @@ type HelmReleaseStatus struct { // +optional ObservedCommonMetadataDigest string `json:"observedCommonMetadataDigest,omitempty"` + // ObservedTemplateDigest is the digest of the manifest produced by the + // most recent Helm install or upgrade action. When + // spec.driftDetection.mode is set to DriftDetectionEnabledWithRender, + // it is compared against a live re-render of the chart on every + // reconciliation to detect changes that do not result in a change of + // Generation, such as those produced by the Helm `lookup` function. + // + // PROOF OF CONCEPT: added to size + // https://github.com/fluxcd/helm-controller/issues/1583. + // +optional + ObservedTemplateDigest string `json:"observedTemplateDigest,omitempty"` + // LastAttemptedGeneration is the last generation the controller attempted // to reconcile. // +optional diff --git a/internal/action/render_digest.go b/internal/action/render_digest.go new file mode 100644 index 000000000..9922b6289 --- /dev/null +++ b/internal/action/render_digest.go @@ -0,0 +1,50 @@ +/* +Copyright 2026 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. +*/ + +// PROOF OF CONCEPT: this file exists to size the change proposed in +// https://github.com/fluxcd/helm-controller/issues/1583. It is not meant to +// be merged as-is. +package action + +import ( + "context" + + helmaction "helm.sh/helm/v4/pkg/action" + helmchartutil "helm.sh/helm/v4/pkg/chart/common" + helmchart "helm.sh/helm/v4/pkg/chart/v2" + + v2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/helm-controller/internal/digest" +) + +// RenderTemplateDigest performs a server-side dry-run Helm upgrade to obtain +// a fresh render of the chart, including live evaluation of the Helm +// `lookup` function against the current cluster state, and returns the +// digest of the resulting release manifest. +// +// Only Release.Manifest is digested, hook manifests (Release.Hooks) are +// intentionally excluded, matching what Diff already compares the cluster +// state against. +func RenderTemplateDigest(ctx context.Context, config *helmaction.Configuration, obj *v2.HelmRelease, + chrt *helmchart.Chart, vals helmchartutil.Values) (string, error) { + rls, err := Upgrade(ctx, config, obj, chrt, vals, func(upgrade *helmaction.Upgrade) { + upgrade.DryRunStrategy = helmaction.DryRunServer + }) + if err != nil { + return "", err + } + return digest.Canonical.FromString(rls.Manifest).String(), nil +} diff --git a/internal/reconcile/install.go b/internal/reconcile/install.go index 58428604b..c023dc13a 100644 --- a/internal/reconcile/install.go +++ b/internal/reconcile/install.go @@ -91,7 +91,14 @@ func (r *Install) Reconcile(ctx context.Context, req *Request) error { conditions.Delete(req.Object, v2.RemediatedCondition) // Run the Helm install action. - _, err := action.Install(ctx, cfg, req.Object, req.Chart, req.Values) + rls, err := action.Install(ctx, cfg, req.Object, req.Chart, req.Values) + + // PROOF OF CONCEPT: record the digest of the manifest actually applied, + // for comparison against a live re-render on a later reconciliation. + // See https://github.com/fluxcd/helm-controller/issues/1583. + if err == nil { + req.Object.Status.ObservedTemplateDigest = digest.Canonical.FromString(rls.Manifest).String() + } // Record the action duration in status. req.Object.Status.LastAttemptedReleaseActionDuration = &metav1.Duration{Duration: time.Since(startTime)} diff --git a/internal/reconcile/state.go b/internal/reconcile/state.go index e78b6e2af..8c2737db4 100644 --- a/internal/reconcile/state.go +++ b/internal/reconcile/state.go @@ -188,6 +188,23 @@ func DetermineReleaseState(ctx context.Context, cfg *action.ConfigFactory, req * } } + // PROOF OF CONCEPT: detect changes a live re-render would produce + // (e.g. from the Helm `lookup` function) that a plain comparison + // against the stored release manifest can never see, since that + // comparison never re-renders the chart. Deliberately not gated on + // a Generation change, unlike the digests above: the entire point + // is to catch changes that happen without one. + // See https://github.com/fluxcd/helm-controller/issues/1583. + if req.Object.GetDriftDetection().MustRenderForDrift() { + templateDigest, err := action.RenderTemplateDigest(ctx, cfg.Build(nil), req.Object, req.Chart, req.Values) + if err != nil { + return ReleaseState{Status: ReleaseStatusUnknown}, fmt.Errorf("failed to render template for drift detection: %w", err) + } + if templateDigest != req.Object.Status.ObservedTemplateDigest { + return ReleaseState{Status: ReleaseStatusOutOfSync, Reason: "template digest changed"}, nil + } + } + // 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, disallowedFieldManagers, req.Object.GetDriftDetection().Ignore...) diff --git a/internal/reconcile/upgrade.go b/internal/reconcile/upgrade.go index d5e43f3ba..049ad19f3 100644 --- a/internal/reconcile/upgrade.go +++ b/internal/reconcile/upgrade.go @@ -82,7 +82,14 @@ func (r *Upgrade) Reconcile(ctx context.Context, req *Request) error { conditions.Delete(req.Object, v2.RemediatedCondition) // Run the Helm upgrade action. - _, err := action.Upgrade(ctx, cfg, req.Object, req.Chart, req.Values) + rls, err := action.Upgrade(ctx, cfg, req.Object, req.Chart, req.Values) + + // PROOF OF CONCEPT: record the digest of the manifest actually applied, + // for comparison against a live re-render on a later reconciliation. + // See https://github.com/fluxcd/helm-controller/issues/1583. + if err == nil { + req.Object.Status.ObservedTemplateDigest = digest.Canonical.FromString(rls.Manifest).String() + } // Record the action duration in status. req.Object.Status.LastAttemptedReleaseActionDuration = &metav1.Duration{Duration: time.Since(startTime)}