diff --git a/api/v2/helmrelease_types.go b/api/v2/helmrelease_types.go index 78f228d7f..73cea73a4 100644 --- a/api/v2/helmrelease_types.go +++ b/api/v2/helmrelease_types.go @@ -185,6 +185,15 @@ type HelmReleaseSpec struct { // +optional PostRenderers []PostRenderer `json:"postRenderers,omitempty"` + // PostRenderStrategy defines the strategy for sending hooks to post-renderers. + // Valid values are 'nohooks' (hooks not sent to post-renderers, Helm 3 behavior), + // 'combined' (hooks and templates sent together, Helm 4 default), and 'separate' + // (hooks and templates sent in separate streams, Helm 4.2 opt-in). + // Defaults to 'combined', or 'nohooks' when the UseHelm3Defaults feature gate is enabled. + // +kubebuilder:validation:Enum=nohooks;combined;separate + // +optional + PostRenderStrategy PostRenderStrategy `json:"postRenderStrategy,omitempty"` + // WaitStrategy defines Helm's wait strategy for waiting for applied // resources to become ready. // +optional @@ -235,6 +244,23 @@ type PostRenderer struct { Kustomize *Kustomize `json:"kustomize,omitempty"` } +// PostRenderStrategy represents the strategy for sending hooks to post-renderers. +type PostRenderStrategy string + +const ( + // PostRenderStrategyNoHooks is the Helm 3 behavior where hooks are not sent + // to post-renderers. + PostRenderStrategyNoHooks PostRenderStrategy = "nohooks" + + // PostRenderStrategyCombined is the Helm 4 default behavior where both hooks + // and templates are sent to post-renderers in the same stream. + PostRenderStrategyCombined PostRenderStrategy = "combined" + + // PostRenderStrategySeparate is the Helm 4.2 opt-in behavior where hooks and + // templates are sent to post-renderers in separate streams. + PostRenderStrategySeparate PostRenderStrategy = "separate" +) + // 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. @@ -471,6 +497,11 @@ func (in *HelmRelease) GetWaitStrategy() WaitStrategyName { return "" } +// GetPostRenderStrategy returns the post-render strategy for the Helm actions. +func (in *HelmRelease) GetPostRenderStrategy() PostRenderStrategy { + return in.Spec.PostRenderStrategy +} + // Remediation defines a consistent interface for InstallRemediation and // UpgradeRemediation. // +kubebuilder:object:generate=false diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index c185e17e9..bf06f7d30 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -621,6 +621,18 @@ spec: If not set, it defaults to true. type: boolean + postRenderStrategy: + description: |- + PostRenderStrategy defines the strategy for sending hooks to post-renderers. + Valid values are 'nohooks' (hooks not sent to post-renderers, Helm 3 behavior), + 'combined' (hooks and templates sent together, Helm 4 default), and 'separate' + (hooks and templates sent in separate streams, Helm 4.2 opt-in). + Defaults to 'combined', or 'nohooks' when the UseHelm3Defaults feature gate is enabled. + enum: + - nohooks + - combined + - separate + type: string postRenderers: description: |- PostRenderers holds an array of Helm PostRenderers, which will be applied in order diff --git a/docs/api/v2/helm.md b/docs/api/v2/helm.md index f49945c25..639745c4f 100644 --- a/docs/api/v2/helm.md +++ b/docs/api/v2/helm.md @@ -407,6 +407,24 @@ of their definition.

+postRenderStrategy
+ + +PostRenderStrategy + + + + +(Optional) +

PostRenderStrategy defines the strategy for sending hooks to post-renderers. +Valid values are ‘nohooks’ (hooks not sent to post-renderers, Helm 3 behavior), +‘combined’ (hooks and templates sent together, Helm 4 default), and ‘separate’ +(hooks and templates sent in separate streams, Helm 4.2 opt-in). +Defaults to ‘combined’, or ‘nohooks’ when the UseHelm3Defaults feature gate is enabled.

+ + + + waitStrategy
@@ -1575,6 +1593,24 @@ of their definition.

+postRenderStrategy
+ +
+PostRenderStrategy + + + + +(Optional) +

PostRenderStrategy defines the strategy for sending hooks to post-renderers. +Valid values are ‘nohooks’ (hooks not sent to post-renderers, Helm 3 behavior), +‘combined’ (hooks and templates sent together, Helm 4 default), and ‘separate’ +(hooks and templates sent in separate streams, Helm 4.2 opt-in). +Defaults to ‘combined’, or ‘nohooks’ when the UseHelm3Defaults feature gate is enabled.

+ + + + waitStrategy
@@ -2370,6 +2406,13 @@ patch, but this operator is simpler to specify.

+

PostRenderStrategy +(string alias)

+

+(Appears on: +HelmReleaseSpec) +

+

PostRenderStrategy represents the strategy for sending hooks to post-renderers.

PostRenderer

diff --git a/docs/spec/v2/helmreleases.md b/docs/spec/v2/helmreleases.md index ad2228adc..4c967685f 100644 --- a/docs/spec/v2/helmreleases.md +++ b/docs/spec/v2/helmreleases.md @@ -526,6 +526,22 @@ spec: replicaCount: 2 ``` +### Post-render strategy + +`.spec.postRenderStrategy` is an optional field to configure the strategy for sending +hooks to post-renderers. Valid values are: + +- `nohooks`: Hooks are not sent to post-renderers (Helm 3 behavior). +- `combined`: Hooks and templates are sent together to post-renderers in the same stream (Helm 4 default). +- `separate`: Hooks and templates are sent to post-renderers in separate streams (Helm 4.2 opt-in). + +Defaults to `combined`, or `nohooks` when the `UseHelm3Defaults` feature gate is enabled. + +```yaml +spec: + postRenderStrategy: combined +``` + ### Install configuration `.spec.install` is an optional field to specify the configuration for the diff --git a/internal/action/install.go b/internal/action/install.go index f6a9c5e59..ed3fcbd31 100644 --- a/internal/action/install.go +++ b/internal/action/install.go @@ -20,7 +20,6 @@ import ( "context" "fmt" - "helm.sh/helm/v4/pkg/action" helmaction "helm.sh/helm/v4/pkg/action" helmchartutil "helm.sh/helm/v4/pkg/chart/common" helmchart "helm.sh/helm/v4/pkg/chart/v2" @@ -109,7 +108,7 @@ func newInstall(config *helmaction.Configuration, obj *v2.HelmRelease, opts []In } install.PostRenderer = postrender.BuildPostRenderers(obj) - install.PostRenderStrategy = action.PostRenderStrategyNoHooks + install.PostRenderStrategy = toHelmPostRenderStrategy(obj.GetPostRenderStrategy()) for _, opt := range opts { opt(install) diff --git a/internal/action/install_test.go b/internal/action/install_test.go index 492c02de6..ac878c50b 100644 --- a/internal/action/install_test.go +++ b/internal/action/install_test.go @@ -21,6 +21,7 @@ import ( "time" . "github.com/onsi/gomega" + "helm.sh/helm/v4/pkg/action" helmaction "helm.sh/helm/v4/pkg/action" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -208,4 +209,100 @@ func Test_newInstall(t *testing.T) { g.Expect(got).ToNot(BeNil()) g.Expect(got.ServerSideApply).To(BeFalse()) }) + + t.Run("post render strategy defaults to combined with Helm4 defaults", func(t *testing.T) { + g := NewWithT(t) + + // Save and restore UseHelm3Defaults + oldUseHelm3Defaults := UseHelm3Defaults + t.Cleanup(func() { UseHelm3Defaults = oldUseHelm3Defaults }) + UseHelm3Defaults = false + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "install", + Namespace: "install-ns", + }, + Spec: v2.HelmReleaseSpec{}, + } + + got := newInstall(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyCombined)) + }) + + t.Run("post render strategy defaults to nohooks with UseHelm3Defaults", func(t *testing.T) { + g := NewWithT(t) + + // Save and restore UseHelm3Defaults + oldUseHelm3Defaults := UseHelm3Defaults + t.Cleanup(func() { UseHelm3Defaults = oldUseHelm3Defaults }) + UseHelm3Defaults = true + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "install", + Namespace: "install-ns", + }, + Spec: v2.HelmReleaseSpec{}, + } + + got := newInstall(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyNoHooks)) + }) + + t.Run("post render strategy combined", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "install", + Namespace: "install-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategyCombined, + }, + } + + got := newInstall(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyCombined)) + }) + + t.Run("post render strategy separate", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "install", + Namespace: "install-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategySeparate, + }, + } + + got := newInstall(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategySeparate)) + }) + + t.Run("post render strategy nohooks", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "install", + Namespace: "install-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategyNoHooks, + }, + } + + got := newInstall(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyNoHooks)) + }) } diff --git a/internal/action/post_render.go b/internal/action/post_render.go new file mode 100644 index 000000000..79f45a9b7 --- /dev/null +++ b/internal/action/post_render.go @@ -0,0 +1,36 @@ +/* +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. +*/ + +package action + +import ( + "helm.sh/helm/v4/pkg/action" + + v2 "github.com/fluxcd/helm-controller/api/v2" +) + +// toHelmPostRenderStrategy converts the API PostRenderStrategy to the Helm SDK value. +// If the strategy is not set, it defaults to PostRenderStrategyCombined (Helm 4 default), +// or PostRenderStrategyNoHooks when UseHelm3Defaults is enabled. +func toHelmPostRenderStrategy(strategy v2.PostRenderStrategy) action.PostRenderStrategy { + if strategy == "" { + if UseHelm3Defaults { + return action.PostRenderStrategyNoHooks + } + return action.PostRenderStrategyCombined + } + return action.PostRenderStrategy(strategy) +} diff --git a/internal/action/upgrade.go b/internal/action/upgrade.go index 38ed1ca97..b3af809ac 100644 --- a/internal/action/upgrade.go +++ b/internal/action/upgrade.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" - "helm.sh/helm/v4/pkg/action" helmaction "helm.sh/helm/v4/pkg/action" helmchartutil "helm.sh/helm/v4/pkg/chart/common" helmchart "helm.sh/helm/v4/pkg/chart/v2" @@ -127,7 +126,7 @@ func newUpgrade(config *helmaction.Configuration, obj *v2.HelmRelease, opts []Up } upgrade.PostRenderer = postrender.BuildPostRenderers(obj) - upgrade.PostRenderStrategy = action.PostRenderStrategyNoHooks + upgrade.PostRenderStrategy = toHelmPostRenderStrategy(obj.GetPostRenderStrategy()) for _, opt := range opts { opt(upgrade) diff --git a/internal/action/upgrade_test.go b/internal/action/upgrade_test.go index 3e23b7d7b..68f52865f 100644 --- a/internal/action/upgrade_test.go +++ b/internal/action/upgrade_test.go @@ -21,6 +21,7 @@ import ( "time" . "github.com/onsi/gomega" + "helm.sh/helm/v4/pkg/action" helmaction "helm.sh/helm/v4/pkg/action" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -169,4 +170,100 @@ func Test_newUpgrade(t *testing.T) { g.Expect(got).ToNot(BeNil()) g.Expect(got.ServerSideApply).To(Equal("false")) }) + + t.Run("post render strategy defaults to combined with Helm4 defaults", func(t *testing.T) { + g := NewWithT(t) + + // Save and restore UseHelm3Defaults + oldUseHelm3Defaults := UseHelm3Defaults + t.Cleanup(func() { UseHelm3Defaults = oldUseHelm3Defaults }) + UseHelm3Defaults = false + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "upgrade", + Namespace: "upgrade-ns", + }, + Spec: v2.HelmReleaseSpec{}, + } + + got := newUpgrade(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyCombined)) + }) + + t.Run("post render strategy defaults to nohooks with UseHelm3Defaults", func(t *testing.T) { + g := NewWithT(t) + + // Save and restore UseHelm3Defaults + oldUseHelm3Defaults := UseHelm3Defaults + t.Cleanup(func() { UseHelm3Defaults = oldUseHelm3Defaults }) + UseHelm3Defaults = true + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "upgrade", + Namespace: "upgrade-ns", + }, + Spec: v2.HelmReleaseSpec{}, + } + + got := newUpgrade(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyNoHooks)) + }) + + t.Run("post render strategy combined", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "upgrade", + Namespace: "upgrade-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategyCombined, + }, + } + + got := newUpgrade(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyCombined)) + }) + + t.Run("post render strategy separate", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "upgrade", + Namespace: "upgrade-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategySeparate, + }, + } + + got := newUpgrade(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategySeparate)) + }) + + t.Run("post render strategy nohooks", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "upgrade", + Namespace: "upgrade-ns", + }, + Spec: v2.HelmReleaseSpec{ + PostRenderStrategy: v2.PostRenderStrategyNoHooks, + }, + } + + got := newUpgrade(&helmaction.Configuration{}, obj, nil) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.PostRenderStrategy).To(Equal(action.PostRenderStrategyNoHooks)) + }) }