diff --git a/CHANGELOG.md b/CHANGELOG.md index 419bab63..5869b1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### Added + +- Add support for `conditionTemplate` and allow `condition` to be set directly to `true` or `false`. + ## [1.4.1] - 2026-03-03 ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index 305689b3..084cea4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,7 +173,7 @@ releases: foo: bar chart: roboll/vault-secret-manager # the chart being installed to create this release, referenced by `repository/chart` syntax version: ~1.24.1 # the semver of the chart. range constraint is supported - condition: vault.enabled # The values lookup key for filtering releases. Corresponds to the boolean value of `vault.enabled`, where `vault` is an arbitrary value + condition: vault.enabled # Filters releases by the boolean value at `vault.enabled`. Can also be set directly to true or false. missingFileHandler: Warn # set to either "Error" or "Warn". "Error" instructs helmfile to fail when unable to find a values or secrets file. When "Warn", it prints the file and continues. missingFileHandlerConfig: # Ignores missing git branch error so that the Debug/Info/Warn handler can treat a missing branch as non-error. @@ -445,6 +445,12 @@ The following `helmDefaults` fields are also available but not shown in the exam ### Additional release fields +#### Condition + +`condition` controls whether a release is enabled. An empty condition enables the release. A direct `true` or `false` value is treated as a literal boolean and bypasses values lookup. Any other condition must be a values lookup path ending in `.enabled`, such as `vault.enabled`. + +`conditionTemplate` is evaluated before `condition` is checked. It must render to a boolean value; when both `condition` and `conditionTemplate` are set, the rendered `conditionTemplate` value replaces `condition`. Like other `*Template` fields, `conditionTemplate` is not evaluated by the `list` command. + The following per-release fields are also available: | Field | Type | Default | Description | @@ -457,6 +463,7 @@ The following per-release fields are also available: | `verifyTemplate` | string | | Templated verify flag (e.g., `{{ .Values.verify \| default "false" }}`) | | `waitTemplate` | string | | Templated wait flag | | `installedTemplate` | string | | Templated installed flag | +| `conditionTemplate` | string | | Templated condition flag. Must render to a boolean. When set with `condition`, the rendered value replaces `condition` | | `adopt` | list | | List of resources to adopt (passes `--adopt` to Helm) | | `forceGoGetter` | bool | false | Force go-getter URL parsing for the chart field. Useful when go-getter URL parsing fails unexpectedly | | `forceNamespace` | string | | Force namespace on all K8s resources rendered by the chart, even when the template doesn't use `{{ .Namespace }}`. Use with caution | diff --git a/docs/writing-helmfile.md b/docs/writing-helmfile.md index a9e0d9ce..5ebbe076 100644 --- a/docs/writing-helmfile.md +++ b/docs/writing-helmfile.md @@ -118,12 +118,15 @@ Release Templating supports the following parts of release definition: - basic fields: `name`, `namespace`, `chart`, `version` -- boolean fields: `installed`, `wait`, `waitForJobs`, `verify` by the means of additional text - fields designed for templating only: `installedTemplate`, `waitTemplate`, `verifyTemplate` +- boolean fields: `installed`, `wait`, `waitForJobs`, `verify`, `condition` by the means of additional text + fields designed for templating only: `installedTemplate`, `waitTemplate`, `verifyTemplate`, `conditionTemplate`. + `conditionTemplate` must render to a boolean. When set with `condition`, the rendered value replaces `condition`. + `condition` accepts direct `true`/`false` values or a values lookup path ending in `.enabled`, for example `condition: vault.enabled`. # ... installedTemplate: '{{`{{ eq .Release.Namespace "kube-system" }}`}}' waitTemplate: '{{`{{ eq .Release.Labels.tag "safe" | not }}`}}' + conditionTemplate: '{{`{{ eq .Release.Namespace "kube-system" }}`}}' # ... - `set` block values: diff --git a/pkg/state/release.go b/pkg/state/release.go index 8f395215..d6ea716f 100644 --- a/pkg/state/release.go +++ b/pkg/state/release.go @@ -58,7 +58,7 @@ func (r ReleaseSpec) ExecuteTemplateExpressions(renderer *tmpl.FileRenderer) (*R ts := *result.WaitTemplate resultTmpl, err := renderer.RenderTemplateContentToString([]byte(ts)) if err != nil { - return nil, fmt.Errorf("failed executing template expressions in release \"%s\".version = \"%s\": %v", r.Name, ts, err) + return nil, fmt.Errorf("failed executing template expressions in release \"%s\".waitTemplate = \"%s\": %v", r.Name, ts, err) } result.WaitTemplate = &resultTmpl } @@ -67,16 +67,25 @@ func (r ReleaseSpec) ExecuteTemplateExpressions(renderer *tmpl.FileRenderer) (*R ts := *result.InstalledTemplate resultTmpl, err := renderer.RenderTemplateContentToString([]byte(ts)) if err != nil { - return nil, fmt.Errorf("failed executing template expressions in release \"%s\".version = \"%s\": %v", r.Name, ts, err) + return nil, fmt.Errorf("failed executing template expressions in release \"%s\".installedTemplate = \"%s\": %v", r.Name, ts, err) } result.InstalledTemplate = &resultTmpl } + if result.ConditionTemplate != nil { + ts := *result.ConditionTemplate + resultTmpl, err := renderer.RenderTemplateContentToString([]byte(ts)) + if err != nil { + return nil, fmt.Errorf("failed executing template expressions in release \"%s\".conditionTemplate = \"%s\": %v", r.Name, ts, err) + } + result.ConditionTemplate = &resultTmpl + } + if result.VerifyTemplate != nil { ts := *result.VerifyTemplate resultTmpl, err := renderer.RenderTemplateContentToString([]byte(ts)) if err != nil { - return nil, fmt.Errorf("failed executing template expressions in release \"%s\".version = \"%s\": %v", r.Name, ts, err) + return nil, fmt.Errorf("failed executing template expressions in release \"%s\".verifyTemplate = \"%s\": %v", r.Name, ts, err) } result.VerifyTemplate = &resultTmpl } diff --git a/pkg/state/state.go b/pkg/state/state.go index 5400ff74..eb5a7bfd 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -448,6 +448,7 @@ type ReleaseSpec struct { VerifyTemplate *string `yaml:"verifyTemplate,omitempty"` WaitTemplate *string `yaml:"waitTemplate,omitempty"` InstalledTemplate *string `yaml:"installedTemplate,omitempty"` + ConditionTemplate *string `yaml:"conditionTemplate,omitempty"` // These settings requires helm-x integration to work Dependencies []Dependency `yaml:"dependencies,omitempty"` @@ -3351,7 +3352,8 @@ func markExcludedReleases(releases []ReleaseSpec, selectors []string, values map // ConditionEnabled checks if a release condition is enabled based on the provided values. // It takes a ReleaseSpec and a map of values as input. // If the condition is not specified, it returns true. -// If the condition is specified but not in the form 'foo.enabled', it returns an error. +// If the condition is a boolean literal (true/false), it returns the corresponding boolean value. +// If the condition is specified but not a boolean literal or in the form 'foo.enabled', it returns an error. // If the condition is specified and the corresponding value is found in the values map, // it checks if the 'enabled' field is set to true. If so, it returns true. // Otherwise, it returns false. @@ -3361,6 +3363,14 @@ func ConditionEnabled(r ReleaseSpec, values map[string]any) (bool, error) { if len(r.Condition) == 0 { return true, nil } + + switch strings.ToLower(r.Condition) { + case "true": + return true, nil + case "false": + return false, nil + } + iValues := values keys := strings.Split(r.Condition, ".") if keys[len(keys)-1] != "enabled" { diff --git a/pkg/state/state_exec_tmpl.go b/pkg/state/state_exec_tmpl.go index db32af84..1a08903a 100644 --- a/pkg/state/state_exec_tmpl.go +++ b/pkg/state/state_exec_tmpl.go @@ -58,6 +58,15 @@ func updateBoolTemplatedValues(r *ReleaseSpec) error { } } + if r.ConditionTemplate != nil { + if condition, err := getBoolRefFromStringTemplate(*r.ConditionTemplate); err != nil { + return fmt.Errorf("conditionTemplate: %v", err) + } else { + r.ConditionTemplate = nil + r.Condition = fmt.Sprintf("%t", *condition) + } + } + if r.WaitTemplate != nil { if wait, err := getBoolRefFromStringTemplate(*r.WaitTemplate); err != nil { return fmt.Errorf("waitTemplate: %v", err) diff --git a/pkg/state/state_exec_tmpl_test.go b/pkg/state/state_exec_tmpl_test.go index 01956a0f..e065ce2a 100644 --- a/pkg/state/state_exec_tmpl_test.go +++ b/pkg/state/state_exec_tmpl_test.go @@ -71,6 +71,7 @@ func TestHelmState_executeTemplates(t *testing.T) { Name: "app-dev", Namespace: "dev", Labels: map[string]string{"id": "app"}, + ConditionTemplate: func(i string) *string { return &i }(`{{ eq .Release.Labels.id "app" | ternary "true" "false" }}`), InstalledTemplate: func(i string) *string { return &i }(`{{ eq .Release.Labels.id "app" | ternary "true" "false" }}`), VerifyTemplate: func(i string) *string { return &i }(`{{ true }}`), Verify: func(i bool) *bool { return &i }(false), @@ -81,11 +82,43 @@ func TestHelmState_executeTemplates(t *testing.T) { Name: "app-dev", Namespace: "dev", Labels: map[string]string{"id": "app"}, + Condition: "true", Installed: func(i bool) *bool { return &i }(true), Verify: func(i bool) *bool { return &i }(true), Wait: func(i bool) *bool { return &i }(false), }, }, + { + name: "Condition template renders false", + input: ReleaseSpec{ + Chart: "test-chart", + Name: "app-dev", + Namespace: "dev", + ConditionTemplate: func(i string) *string { return &i }(`{{ false }}`), + }, + want: ReleaseSpec{ + Chart: "test-chart", + Name: "app-dev", + Namespace: "dev", + Condition: "false", + }, + }, + { + name: "Condition template takes precedence over condition", + input: ReleaseSpec{ + Chart: "test-chart", + Name: "app-dev", + Namespace: "dev", + Condition: "foo.enabled", + ConditionTemplate: func(i string) *string { return &i }(`{{ true }}`), + }, + want: ReleaseSpec{ + Chart: "test-chart", + Name: "app-dev", + Namespace: "dev", + Condition: "true", + }, + }, { name: "Has template in set-values", input: ReleaseSpec{ @@ -236,6 +269,12 @@ func TestHelmState_executeTemplates(t *testing.T) { boolPtrToString(tt.want.Wait), boolPtrToString(actual.Wait), ) } + if !reflect.DeepEqual(actual.Condition, tt.want.Condition) { + t.Errorf("expected Condition %+v, got %+v", tt.want.Condition, actual.Condition) + } + if actual.ConditionTemplate != nil { + t.Errorf("expected ConditionTemplate to be nil, got %q", *actual.ConditionTemplate) + } }) } } @@ -256,7 +295,7 @@ func TestHelmState_recursiveRefsTemplates(t *testing.T) { }, }, { - name: "Has unresolvable boolean templates", + name: "Has unresolvable wait templates", input: ReleaseSpec{ Name: "app-dev", Chart: "test-charts/app", @@ -265,6 +304,16 @@ func TestHelmState_recursiveRefsTemplates(t *testing.T) { WaitTemplate: func(i string) *string { return &i }("hi"), }, }, + { + name: "Has unresolvable condition template", + input: ReleaseSpec{ + Name: "app-dev", + Chart: "test-charts/app", + Verify: nil, + Namespace: "dev", + ConditionTemplate: func(i string) *string { return &i }(`{{ "maybe" }}`), + }, + }, } for i := range tests { diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index f990fcce..62ecdc1a 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -18,6 +18,7 @@ import ( "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/testhelper" + "github.com/helmfile/helmfile/pkg/yaml" ) var logger = helmexec.NewLogger(io.Discard, "warn") @@ -3730,6 +3731,28 @@ func TestConditionEnabled(t *testing.T) { condition: "", want: true, }, + { + name: "condition true literal enabled", + condition: "true", + values: map[string]any{}, + want: true, + }, + { + name: "condition false literal disabled", + condition: "false", + values: map[string]any{}, + want: false, + }, + { + name: "condition true literal takes precedence over values lookup", + condition: "true", + values: map[string]any{ + "foo": map[string]any{ + "enabled": false, + }, + }, + want: true, + }, } for i := range tests { tt := tests[i] @@ -3751,6 +3774,16 @@ func TestConditionEnabled(t *testing.T) { } } +func TestReleaseSpecConditionUnmarshalsBoolLiteral(t *testing.T) { + var got ReleaseSpec + require.NoError(t, yaml.Unmarshal([]byte("condition: true"), &got)) + assert.Equal(t, "true", got.Condition) + + enabled, err := ConditionEnabled(got, map[string]any{}) + require.NoError(t, err) + assert.True(t, enabled) +} + func TestHelmState_NoReleaseMatched(t *testing.T) { releases := []ReleaseSpec{ { diff --git a/pkg/state/temp_test.go b/pkg/state/temp_test.go index 312340f6..7aeb40d7 100644 --- a/pkg/state/temp_test.go +++ b/pkg/state/temp_test.go @@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) { run(testcase{ subject: "baseline", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, - want: "foo-values-6fcd6fc479", + want: "foo-values-6765d87f7c", }) run(testcase{ subject: "different bytes content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: []byte(`{"k":"v"}`), - want: "foo-values-6556658b7b", + want: "foo-values-6d7db5f8b", }) run(testcase{ subject: "different map content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: map[string]any{"k": "v"}, - want: "foo-values-fd86f44b", + want: "foo-values-5c55f66dd", }) run(testcase{ subject: "different chart", release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"}, - want: "foo-values-58bdc49774", + want: "foo-values-7665888bf4", }) run(testcase{ subject: "different name", release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"}, - want: "bar-values-856b998888", + want: "bar-values-bc6f974bd", }) run(testcase{ subject: "specific ns", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"}, - want: "myns-foo-values-7bdfd95fbd", + want: "myns-foo-values-76f8c7c596", }) for id, n := range ids { diff --git a/skills/helmfile/SKILL.md b/skills/helmfile/SKILL.md index 6c172bae..60156318 100644 --- a/skills/helmfile/SKILL.md +++ b/skills/helmfile/SKILL.md @@ -95,7 +95,7 @@ repositories: | `set`/`setString` | list | | Override specific values | | `secrets` | list | | Encrypted values files (requires helm-secrets plugin) | | `installed` | bool | | Set false to uninstall on sync | -| `condition` | string | | Values lookup key for filtering releases | +| `condition` | string | | Direct `true`/`false` or values lookup key ending in `.enabled` for filtering releases | | `wait` | bool | false | Wait for resources to be ready | | `waitForJobs` | bool | false | Wait until all Jobs have completed | | `timeout` | int | 300 | Operation timeout in seconds | @@ -112,6 +112,7 @@ repositories: | `verifyTemplate` | string | | Templated verify flag | | `waitTemplate` | string | | Templated wait flag | | `installedTemplate` | string | | Templated installed flag | +| `conditionTemplate` | string | | Templated condition flag. Must render to boolean. When set with `condition`, the rendered value replaces `condition` | | `adopt` | list | | Resources to adopt (passes `--adopt` to Helm) | | `forceGoGetter` | bool | false | Force go-getter URL parsing for chart field | | `forceNamespace` | string | | Force namespace on all K8s resources |