From f3b19fd81e9a08590085ef3840184fcb16b3d63a Mon Sep 17 00:00:00 2001 From: Ronaldo Umana Date: Tue, 9 Dec 2025 00:41:47 -0500 Subject: [PATCH] Add parameter to render helmfile as go template without .gotmpl extension (#2312) * Add parameter to render helmfile as go template without gotmpl extension Signed-off-by: Ronaldo * Update pkg/envvar/const.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Ronaldo Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/index.md | 1 + pkg/app/app_test.go | 146 +++++++++++++++++++++++++++ pkg/app/desired_state_file_loader.go | 6 +- pkg/envvar/const.go | 1 + 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index b510eda2..42877ea3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -586,6 +586,7 @@ Helmfile uses some OS environment variables to override default behaviour: * `HELMFILE_CACHE_HOME` - specify directory to store cached files for remote operations * `HELMFILE_FILE_PATH` - specify the path to the helmfile.yaml file * `HELMFILE_INTERACTIVE` - enable interactive mode, expecting `true` lower case. The same as `--interactive` CLI flag +* `HELMFILE_RENDER_YAML` - force helmfile.yaml to be rendered as a Go template regardless of file extension, expecting `true` lower case. Useful for migrating from v0 to v1 without renaming files to `.gotmpl` ## CLI Reference diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 1987c64d..e129aa99 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -4455,3 +4455,149 @@ func TestGetArgs(t *testing.T) { require.Equalf(t, test.expected, strings.Join(receivedArgs, " "), "expected args %s, received args %s", test.expected, strings.Join(receivedArgs, " ")) } } + +func TestRenderYamlEnvVar(t *testing.T) { + testCases := []struct { + name string + envValue string + filename string + content string + shouldRender bool + expectErr bool + expectedOutput string + }{ + { + name: "default behavior - helmfile.yaml without .gotmpl is NOT rendered", + envValue: "", + filename: "helmfile.yaml", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: false, + expectErr: false, + expectedOutput: "", + }, + { + name: "default behavior - helmfile.yaml.gotmpl IS rendered", + envValue: "", + filename: "helmfile.yaml.gotmpl", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: true, + expectErr: false, + expectedOutput: "default-app", + }, + { + name: "HELMFILE_RENDER_YAML=true - helmfile.yaml IS rendered", + envValue: "true", + filename: "helmfile.yaml", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: true, + expectErr: false, + expectedOutput: "default-app", + }, + { + name: "HELMFILE_RENDER_YAML=true - helmfile.yaml.gotmpl IS rendered", + envValue: "true", + filename: "helmfile.yaml.gotmpl", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: true, + expectErr: false, + expectedOutput: "default-app", + }, + { + name: "HELMFILE_RENDER_YAML=false - helmfile.yaml is NOT rendered", + envValue: "false", + filename: "helmfile.yaml", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: false, + expectErr: false, + expectedOutput: "", + }, + { + name: "HELMFILE_RENDER_YAML=false - helmfile.yaml.gotmpl IS rendered (extension takes precedence)", + envValue: "false", + filename: "helmfile.yaml.gotmpl", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: true, + expectErr: false, + expectedOutput: "default-app", + }, + { + name: "HELMFILE_RENDER_YAML=TRUE (uppercase) - should NOT render (strict comparison)", + envValue: "TRUE", + filename: "helmfile.yaml", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: false, + expectErr: false, + expectedOutput: "", + }, + { + name: "HELMFILE_RENDER_YAML=1 - should NOT render (strict comparison)", + envValue: "1", + filename: "helmfile.yaml", + content: "releases:\n- name: {{ .Environment.Name }}-app\n chart: test/chart\n", + shouldRender: false, + expectErr: false, + expectedOutput: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.envValue != "" { + t.Setenv(envvar.RenderYaml, tc.envValue) + } + + files := map[string]string{ + fmt.Sprintf("/path/to/%s", tc.filename): tc.content, + } + + app := &App{ + OverrideHelmBinary: DefaultHelmBinary, + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Logger: newAppTestLogger(), + Namespace: "", + Env: "default", + FileOrDir: tc.filename, + } + + expectNoCallsToHelm(app) + app = appWithFs(app, files) + + err := app.ForEachState( + func(run *Run) (bool, []error) { + if tc.shouldRender { + // If rendering is expected, check that the template was rendered + require.NotNil(t, run.state) + require.NotEmpty(t, run.state.Releases) + // The rendered name should be "default-app" not "{{ .Environment.Name }}-app" + actualName := run.state.Releases[0].Name + require.Equal(t, tc.expectedOutput, actualName, "expected release name to be rendered as %s, got %s", tc.expectedOutput, actualName) + } else { + // If rendering is NOT expected, check that the template was NOT rendered + // In this case, the YAML parser will likely fail or the template syntax will remain + // We just verify that if there are releases, they contain the unrendered template + if run.state != nil && len(run.state.Releases) > 0 { + actualName := run.state.Releases[0].Name + require.Contains(t, actualName, "{{", "expected template syntax to remain unrendered, got %s", actualName) + } + } + return false, nil + }, + false, + SetFilter(true), + ) + + if tc.expectErr { + require.Error(t, err) + } else { + if err != nil && !tc.shouldRender { + // It's OK if there's an error when we don't expect rendering + // because the template syntax might cause YAML parsing issues + t.Logf("Expected error when not rendering template: %v", err) + } else if tc.shouldRender { + require.NoError(t, err, "unexpected error: %v", err) + } + } + }) + } +} diff --git a/pkg/app/desired_state_file_loader.go b/pkg/app/desired_state_file_loader.go index 0b243f3b..5b65d9a6 100644 --- a/pkg/app/desired_state_file_loader.go +++ b/pkg/app/desired_state_file_loader.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "os" "path/filepath" "slices" @@ -12,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/helmfile/helmfile/pkg/environment" + "github.com/helmfile/helmfile/pkg/envvar" "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/policy" @@ -216,7 +218,9 @@ func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, ba var rawContent []byte - if filepath.Ext(filename) == ".gotmpl" { + shouldRender := filepath.Ext(filename) == ".gotmpl" || os.Getenv(envvar.RenderYaml) == "true" + + if shouldRender { var yamlBuf *bytes.Buffer var err error diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index 9eb43bb6..b5df7100 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -15,6 +15,7 @@ const ( GoYamlV3 = "HELMFILE_GO_YAML_V3" CacheHome = "HELMFILE_CACHE_HOME" Interactive = "HELMFILE_INTERACTIVE" + RenderYaml = "HELMFILE_RENDER_YAML" // force helmfile.yaml to be rendered as template regardless of extension, expecting "true" lower case // AWSSDKLogLevel controls AWS SDK logging level // Valid values: "off" (default), "minimal", "standard", "verbose", or custom (e.g., "request,response")