Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion api/v2/helmrelease_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"`

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions internal/action/render_digest.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 8 additions & 1 deletion internal/reconcile/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
17 changes: 17 additions & 0 deletions internal/reconcile/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
9 changes: 8 additions & 1 deletion internal/reconcile/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down