From 38e27ee4391733df96c45d8562c7e1e4c6977997 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:31:46 +0800 Subject: [PATCH] fix: include release values in .Values for patch template rendering (#2556) * fix: include release values in .Values for jsonPatches/strategicMergePatches/transformers gotmpl rendering Before this fix, .Values in patch template files only contained environment values, not the release's own values. This meant references like {{ .Values.ingress.enabled }} would fail when ingress.enabled was set in the release's values: file rather than environment values. Now patch gotmpl files see .Values as merged(environment values, release values), matching user expectations that values defined in the release should be accessible in conditional patches. Fixes #1904 Signed-off-by: yxxhero * test: add more tests for resolveReleaseValues, renderValuesFileToBytesWithData, and generateTemporaryReleaseValuesFilesWithData Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/5da5c9d8-7464-4146-84b5-1433ed6193f3 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * test: simplify newTestHelmStateWithFiles by removing empty cleanup func Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/5da5c9d8-7464-4146-84b5-1433ed6193f3 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: remove always-constant basePath param from newTestHelmStateWithFiles to fix unparam lint error Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/b4a669cb-692c-4ca6-a68b-1b04a062b989 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: address c66017c review comments - error messages, defer-in-loop, map normalization, test cleanup Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/44988dd8-1c67-465b-995c-80525a24eb93 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * refactor: extract generateTemporaryReleaseValuesFilesCore to eliminate duplication; fix temp dir leaks in tests Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/b254ddda-aa95-4e2f-8dd9-1ce4c40eedb6 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: remove rendered content from debug log; extract prepareReleaseValuesEntries shared helper Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/321c6ba5-f835-4afd-be5e-ee790bc6b4a5 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: compute mergedReleaseTemplateData lazily in PrepareChartify Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/100b7974-3268-4dc8-be21-12bd82aa2dbb Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: normalize nested YAML keys in resolveReleaseValues via CastKeysToStrings Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/d0129d85-9c7d-4a31-966e-fc0b05b74867 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: use %w for error wrapping in release values resolution Signed-off-by: yxxhero --------- Signed-off-by: yxxhero Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- pkg/state/create_test.go | 449 +++++++++++++++++++++++++++++++++++++++ pkg/state/helmx.go | 25 ++- pkg/state/state.go | 232 ++++++++++++++++---- 3 files changed, 657 insertions(+), 49 deletions(-) diff --git a/pkg/state/create_test.go b/pkg/state/create_test.go index 0d28753c..caffc040 100644 --- a/pkg/state/create_test.go +++ b/pkg/state/create_test.go @@ -7,12 +7,14 @@ import ( "testing" "dario.cat/mergo" + "github.com/helmfile/vals" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "go.uber.org/zap/zaptest" "github.com/helmfile/helmfile/pkg/environment" + "github.com/helmfile/helmfile/pkg/envvar" "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/remote" "github.com/helmfile/helmfile/pkg/testhelper" @@ -1025,3 +1027,450 @@ func TestMergeEnvironments_PreservesMergeStrategy(t *testing.T) { }) } } + +func TestMergedReleaseTemplateData_IncludesReleaseValues(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + testValsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + require.NoError(t, err) + + yamlFile := "/example/path/to/helmfile.yaml" + yamlContent := []byte(`environments: + default: + values: + - env.yaml + +releases: +- name: myrelease + chart: mychart + values: + - values.yaml +`) + + envYamlFile := "/example/path/to/env.yaml" + envYamlContent := []byte(`envKey: envValue`) + + valuesFile := "/example/path/to/values.yaml" + valuesContent := []byte(`ingress: + enabled: true + host: example.com`) + + testFs := testhelper.NewTestFs(map[string]string{ + envYamlFile: string(envYamlContent), + valuesFile: string(valuesContent), + }) + testFs.Cwd = "/example/path/to" + + r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem()) + env := environment.Environment{ + Name: "default", + } + state, err := NewCreator(logger, testFs.ToFileSystem(), testValsRuntime, nil, "", "", r, false, ""). + ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "default", true, true, true, &env, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + release := state.Releases[0] + + templateData, err := state.mergedReleaseTemplateData(&release) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ingress, ok := templateData.Values["ingress"] + if !ok { + t.Fatalf("expected .Values to contain 'ingress' key from release values") + } + ingressMap, ok := ingress.(map[string]any) + if !ok { + t.Fatalf("expected ingress to be a map, got %T", ingress) + } + if ingressMap["enabled"] != true { + t.Errorf("expected ingress.enabled to be true, got %v", ingressMap["enabled"]) + } + if ingressMap["host"] != "example.com" { + t.Errorf("expected ingress.host to be 'example.com', got %v", ingressMap["host"]) + } + + if templateData.Values["envKey"] != "envValue" { + t.Errorf("expected envKey to be 'envValue', got %v", templateData.Values["envKey"]) + } +} + +func TestMergedReleaseTemplateData_ReleaseValuesOverrideEnvValues(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + testValsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + require.NoError(t, err) + + yamlFile := "/example/path/to/helmfile.yaml" + yamlContent := []byte(`environments: + default: + values: + - env.yaml + +releases: +- name: myrelease + chart: mychart + values: + - values.yaml +`) + + envYamlFile := "/example/path/to/env.yaml" + envYamlContent := []byte(`replicaCount: 1`) + + valuesFile := "/example/path/to/values.yaml" + valuesContent := []byte(`replicaCount: 3`) + + testFs := testhelper.NewTestFs(map[string]string{ + envYamlFile: string(envYamlContent), + valuesFile: string(valuesContent), + }) + testFs.Cwd = "/example/path/to" + + r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem()) + env := environment.Environment{ + Name: "default", + } + state, err := NewCreator(logger, testFs.ToFileSystem(), testValsRuntime, nil, "", "", r, false, ""). + ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "default", true, true, true, &env, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + release := state.Releases[0] + + templateData, err := state.mergedReleaseTemplateData(&release) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if templateData.Values["replicaCount"] != 3 { + t.Errorf("expected replicaCount to be 3 (release value overriding env), got %v", templateData.Values["replicaCount"]) + } +} + +func TestMergedReleaseTemplateData_InlineValues(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + testValsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + require.NoError(t, err) + + yamlFile := "/example/path/to/helmfile.yaml" + yamlContent := []byte(`releases: +- name: myrelease + chart: mychart + values: + - ingress: + enabled: true + host: example.com +`) + + testFs := testhelper.NewTestFs(map[string]string{}) + testFs.Cwd = "/example/path/to" + + r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem()) + state, err := NewCreator(logger, testFs.ToFileSystem(), testValsRuntime, nil, "", "", r, false, ""). + ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "default", true, true, true, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + release := state.Releases[0] + + templateData, err := state.mergedReleaseTemplateData(&release) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ingress, ok := templateData.Values["ingress"] + if !ok { + t.Fatalf("expected .Values to contain 'ingress' key from inline release values") + } + ingressMap, ok := ingress.(map[string]any) + if !ok { + t.Fatalf("expected ingress to be a map, got %T", ingress) + } + if ingressMap["enabled"] != true { + t.Errorf("expected ingress.enabled to be true, got %v", ingressMap["enabled"]) + } +} + +func newTestHelmStateWithFiles(t *testing.T, files map[string]string) *HelmState { + t.Helper() + const basePath = "/project" + logger := zaptest.NewLogger(t).Sugar() + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + require.NoError(t, err) + + testFs := testhelper.NewTestFs(files) + testFs.Cwd = basePath + + return &HelmState{ + logger: logger, + fs: testFs.ToFileSystem(), + valsRuntime: valsRuntime, + basePath: basePath, + FilePath: basePath + "/helmfile.yaml", + RenderedValues: map[string]any{}, + } +} + +func TestResolveReleaseValues_Empty(t *testing.T) { + st := newTestHelmStateWithFiles(t, map[string]string{}) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + result, err := st.resolveReleaseValues(release) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestResolveReleaseValues_FromFile(t *testing.T) { + valuesFile := "/project/values.yaml" + valuesContent := `replicaCount: 2 +image: + repository: nginx + tag: "1.21" +` + st := newTestHelmStateWithFiles(t, map[string]string{ + valuesFile: valuesContent, + }) + + release := &ReleaseSpec{ + Name: "myrelease", + Chart: "mychart", + Values: []any{ + "values.yaml", + }, + } + result, err := st.resolveReleaseValues(release) + require.NoError(t, err) + assert.Equal(t, 2, result["replicaCount"]) + imageMap, ok := result["image"].(map[string]any) + require.True(t, ok, "expected image to be a map") + assert.Equal(t, "nginx", imageMap["repository"]) +} + +func TestResolveReleaseValues_InlineMap(t *testing.T) { + st := newTestHelmStateWithFiles(t, map[string]string{}) + + release := &ReleaseSpec{ + Name: "myrelease", + Chart: "mychart", + Values: []any{ + map[string]any{ + "replicaCount": 5, + "service": map[string]any{ + "type": "ClusterIP", + "port": 80, + }, + }, + }, + } + result, err := st.resolveReleaseValues(release) + require.NoError(t, err) + assert.Equal(t, 5, result["replicaCount"]) + serviceMap, ok := result["service"].(map[string]any) + require.True(t, ok, "expected service to be a map") + assert.Equal(t, "ClusterIP", serviceMap["type"]) +} + +func TestResolveReleaseValues_MultipleSourcesMerged(t *testing.T) { + baseValuesFile := "/project/base.yaml" + baseValuesContent := `replicaCount: 1 +service: + type: ClusterIP +` + overrideValuesFile := "/project/override.yaml" + overrideValuesContent := `replicaCount: 3 +ingress: + enabled: true +` + st := newTestHelmStateWithFiles(t, map[string]string{ + baseValuesFile: baseValuesContent, + overrideValuesFile: overrideValuesContent, + }) + + release := &ReleaseSpec{ + Name: "myrelease", + Chart: "mychart", + Values: []any{ + "base.yaml", + "override.yaml", + }, + } + result, err := st.resolveReleaseValues(release) + require.NoError(t, err) + // override.yaml value wins + assert.Equal(t, 3, result["replicaCount"]) + // from base.yaml + serviceMap, ok := result["service"].(map[string]any) + require.True(t, ok, "expected service to be a map") + assert.Equal(t, "ClusterIP", serviceMap["type"]) + // from override.yaml + ingressMap, ok := result["ingress"].(map[string]any) + require.True(t, ok, "expected ingress to be a map") + assert.Equal(t, true, ingressMap["enabled"]) +} + +func TestResolveReleaseValues_FileAndInlineMerged(t *testing.T) { + valuesFile := "/project/values.yaml" + valuesContent := `replicaCount: 1 +` + st := newTestHelmStateWithFiles(t, map[string]string{ + valuesFile: valuesContent, + }) + + release := &ReleaseSpec{ + Name: "myrelease", + Chart: "mychart", + Values: []any{ + "values.yaml", + map[string]any{ + "extraEnv": "production", + }, + }, + } + result, err := st.resolveReleaseValues(release) + require.NoError(t, err) + assert.Equal(t, 1, result["replicaCount"]) + assert.Equal(t, "production", result["extraEnv"]) +} + +func TestRenderValuesFileToBytesWithData_PlainYAML(t *testing.T) { + valuesFile := "/project/values.yaml" + valuesContent := `replicaCount: 2 +image: + repository: nginx +` + st := newTestHelmStateWithFiles(t, map[string]string{ + valuesFile: valuesContent, + }) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + tmplData := st.createReleaseTemplateData(release, map[string]any{}) + + result, err := st.renderValuesFileToBytesWithData(valuesFile, tmplData) + require.NoError(t, err) + assert.Contains(t, string(result), "replicaCount: 2") + assert.Contains(t, string(result), "repository: nginx") +} + +func TestRenderValuesFileToBytesWithData_WithValuesTemplate(t *testing.T) { + valuesFile := "/project/values.yaml.gotmpl" + valuesContent := `replicaCount: {{ .Values.replicaCount }} +enabled: {{ .Values.ingress.enabled }} +` + st := newTestHelmStateWithFiles(t, map[string]string{ + valuesFile: valuesContent, + }) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + tmplData := st.createReleaseTemplateData(release, map[string]any{ + "replicaCount": 3, + "ingress": map[string]any{ + "enabled": true, + }, + }) + + result, err := st.renderValuesFileToBytesWithData(valuesFile, tmplData) + require.NoError(t, err) + assert.Contains(t, string(result), "replicaCount: 3") + assert.Contains(t, string(result), "enabled: true") +} + +func TestRenderValuesFileToBytesWithData_WithReleaseTemplate(t *testing.T) { + valuesFile := "/project/values.yaml.gotmpl" + valuesContent := `releaseName: {{ .Release.Name }} +releaseNamespace: {{ .Release.Namespace }} +` + st := newTestHelmStateWithFiles(t, map[string]string{ + valuesFile: valuesContent, + }) + + release := &ReleaseSpec{Name: "myapp", Chart: "mychart", Namespace: "production"} + tmplData := st.createReleaseTemplateData(release, map[string]any{}) + + result, err := st.renderValuesFileToBytesWithData(valuesFile, tmplData) + require.NoError(t, err) + assert.Contains(t, string(result), "releaseName: myapp") + assert.Contains(t, string(result), "releaseNamespace: production") +} + +func TestGenerateTemporaryReleaseValuesFilesWithData_StringPath(t *testing.T) { + t.Setenv(envvar.TempDir, t.TempDir()) + + patchFile := "/project/patch.yaml.gotmpl" + patchContent := `enabled: {{ .Values.ingress.enabled }} +host: {{ .Values.ingress.host }} +` + st := newTestHelmStateWithFiles(t, map[string]string{ + patchFile: patchContent, + }) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + tmplData := st.createReleaseTemplateData(release, map[string]any{ + "ingress": map[string]any{ + "enabled": true, + "host": "example.com", + }, + }) + + generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData( + release, + []any{"patch.yaml.gotmpl"}, + func() (releaseTemplateData, error) { return tmplData, nil }, + ) + require.NoError(t, err) + require.Len(t, generatedFiles, 1) + + // The temp files are created on the real OS filesystem via os.Create, so we read them with os.ReadFile + content, err := filesystem.DefaultFileSystem().ReadFile(generatedFiles[0]) + require.NoError(t, err) + assert.Contains(t, string(content), "enabled: true") + assert.Contains(t, string(content), "host: example.com") +} + +func TestGenerateTemporaryReleaseValuesFilesWithData_InlineMap(t *testing.T) { + t.Setenv(envvar.TempDir, t.TempDir()) + + st := newTestHelmStateWithFiles(t, map[string]string{}) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + tmplData := st.createReleaseTemplateData(release, map[string]any{}) + + inlineValues := map[string]any{ + "replicaCount": 5, + "service": map[string]any{ + "type": "NodePort", + }, + } + + generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData( + release, + []any{inlineValues}, + func() (releaseTemplateData, error) { return tmplData, nil }, + ) + require.NoError(t, err) + require.Len(t, generatedFiles, 1) + + // The temp files are created on the real OS filesystem via os.Create, so we read them with os.ReadFile + content, err := filesystem.DefaultFileSystem().ReadFile(generatedFiles[0]) + require.NoError(t, err) + assert.Contains(t, string(content), "replicaCount: 5") + assert.Contains(t, string(content), "NodePort") +} + +func TestGenerateTemporaryReleaseValuesFilesWithData_UnknownTypeError(t *testing.T) { + st := newTestHelmStateWithFiles(t, map[string]string{}) + + release := &ReleaseSpec{Name: "myrelease", Chart: "mychart"} + tmplData := st.createReleaseTemplateData(release, map[string]any{}) + + // Passing an unsupported type (int) should return an error + _, err := st.generateTemporaryReleaseValuesFilesWithData( + release, + []any{42}, + func() (releaseTemplateData, error) { return tmplData, nil }, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected type of value") +} diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index d8edd80a..96045515 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -474,9 +474,28 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp shouldRun = true } + // patchTemplateData is computed lazily on first use: only when an actual string patch/transformer + // file path is rendered. Inline-map entries don't require template data at all, so we avoid + // unnecessary I/O for releases whose patches are all inline maps or that have no string entries. + var ( + cachedPatchTemplateData releaseTemplateData + cachedPatchTemplateDataErr error + cachedPatchTemplateDataSet bool + ) + getPatchTemplateData := func() (releaseTemplateData, error) { + if !cachedPatchTemplateDataSet { + cachedPatchTemplateDataSet = true + cachedPatchTemplateData, cachedPatchTemplateDataErr = st.mergedReleaseTemplateData(release) + if cachedPatchTemplateDataErr != nil { + cachedPatchTemplateDataErr = fmt.Errorf("failed to compute merged release values for patch rendering: %w", cachedPatchTemplateDataErr) + } + } + return cachedPatchTemplateData, cachedPatchTemplateDataErr + } + jsonPatches := release.JSONPatches if len(jsonPatches) > 0 { - generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, jsonPatches) + generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData(release, jsonPatches, getPatchTemplateData) if err != nil { return nil, clean, err } @@ -490,7 +509,7 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp strategicMergePatches := release.StrategicMergePatches if len(strategicMergePatches) > 0 { - generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, strategicMergePatches) + generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData(release, strategicMergePatches, getPatchTemplateData) if err != nil { return nil, clean, err } @@ -504,7 +523,7 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp transformers := release.Transformers if len(transformers) > 0 { - generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, transformers) + generatedFiles, err := st.generateTemporaryReleaseValuesFilesWithData(release, transformers, getPatchTemplateData) if err != nil { return nil, clean, err } diff --git a/pkg/state/state.go b/pkg/state/state.go index c4e7c5d2..cbc907d9 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -4150,15 +4150,101 @@ func (st *HelmState) newReleaseTemplateData(release *ReleaseSpec) releaseTemplat return templateData } -func (st *HelmState) newReleaseTemplateFuncMap(dir string) template.FuncMap { - r := tmpl.NewFileRenderer(st.fs, dir, nil) - - return r.Context.CreateFuncMap() +func (st *HelmState) mergedReleaseTemplateData(release *ReleaseSpec) (releaseTemplateData, error) { + releaseValues, err := st.resolveReleaseValues(release) + if err != nil { + return releaseTemplateData{}, err + } + mergedVals := maputil.MergeMaps(st.Values(), releaseValues) + return st.createReleaseTemplateData(release, mergedVals), nil } -func (st *HelmState) RenderReleaseValuesFileToBytes(release *ReleaseSpec, path string) ([]byte, error) { - templateData := st.newReleaseTemplateData(release) +// prepareReleaseValuesEntries normalizes release.Values path entries (applying ValuesPathPrefix) +// and evaluates any vals ref+ secrets, returning the fully-rendered values slice ready for processing. +func (st *HelmState) prepareReleaseValuesEntries(release *ReleaseSpec) ([]any, error) { + values := []any{} + for _, v := range release.Values { + switch typedValue := v.(type) { + case string: + path := st.storage().normalizePath(release.ValuesPathPrefix + typedValue) + values = append(values, path) + default: + values = append(values, typedValue) + } + } + valuesMapSecretsRendered, err := st.valsRuntime.Eval(map[string]any{"values": values}) + if err != nil { + return nil, err + } + + valuesSecretsRendered, ok := valuesMapSecretsRendered["values"].([]any) + if !ok { + return nil, fmt.Errorf("failed to render values in %s for release %s: type %T isn't supported", st.FilePath, release.Name, valuesMapSecretsRendered["values"]) + } + + return valuesSecretsRendered, nil +} + +func (st *HelmState) resolveReleaseValues(release *ReleaseSpec) (map[string]any, error) { + merged := map[string]any{} + + valuesSecretsRendered, err := st.prepareReleaseValuesEntries(release) + if err != nil { + return nil, err + } + + for _, v := range valuesSecretsRendered { + switch typedValue := v.(type) { + case string: + paths, skip, err := st.storage().resolveFile(st.getReleaseMissingFileHandler(release), "values", typedValue, st.getReleaseMissingFileHandlerConfig(release).resolveFileOptions()...) + if err != nil { + return nil, err + } + if skip { + continue + } + if len(paths) > 1 { + return nil, fmt.Errorf("glob patterns in release values are not supported for template data resolution: value=%q, resolvedPaths=%v", typedValue, paths) + } + path := paths[0] + + yamlBytes, err := st.RenderReleaseValuesFileToBytes(release, path) + if err != nil { + return nil, fmt.Errorf("failed to render values file \"%s\": %w", typedValue, err) + } + + var rawVals map[string]any + if err := yaml.Unmarshal(yamlBytes, &rawVals); err != nil { + return nil, fmt.Errorf("failed to parse values file \"%s\": %w", typedValue, err) + } + + // Normalize nested keys: yaml v2 may produce map[any]any for nested maps. + // CastKeysToStrings recurses through both map[any]any and map[string]any so it is + // safe to call even when yaml v3 is in use and keys are already strings. + normalizedVals, err := maputil.CastKeysToStrings(rawVals) + if err != nil { + return nil, fmt.Errorf("failed to normalize keys in values file \"%s\": %w", typedValue, err) + } + + merged = maputil.MergeMaps(merged, normalizedVals) + case map[any]any: + strMap, err := maputil.CastKeysToStrings(typedValue) + if err != nil { + return nil, err + } + merged = maputil.MergeMaps(merged, strMap) + case map[string]any: + merged = maputil.MergeMaps(merged, typedValue) + default: + return nil, fmt.Errorf("unexpected type of value in release values: value=%v, type=%T", typedValue, typedValue) + } + } + + return merged, nil +} + +func (st *HelmState) renderValuesFileToBytesWithData(path string, templateData releaseTemplateData) ([]byte, error) { r := tmpl.NewFileRenderer(st.fs, filepath.Dir(path), templateData) rawBytes, err := r.RenderToBytes(path) if err != nil { @@ -4189,6 +4275,27 @@ func (st *HelmState) RenderReleaseValuesFileToBytes(release *ReleaseSpec, path s return rawBytes, nil } +func (st *HelmState) generateTemporaryReleaseValuesFilesWithData(release *ReleaseSpec, values []any, getTemplateData func() (releaseTemplateData, error)) ([]string, error) { + return st.generateTemporaryReleaseValuesFilesCore(release, values, func(path string) ([]byte, error) { + templateData, err := getTemplateData() + if err != nil { + return nil, err + } + return st.renderValuesFileToBytesWithData(path, templateData) + }) +} + +func (st *HelmState) newReleaseTemplateFuncMap(dir string) template.FuncMap { + r := tmpl.NewFileRenderer(st.fs, dir, nil) + + return r.Context.CreateFuncMap() +} + +func (st *HelmState) RenderReleaseValuesFileToBytes(release *ReleaseSpec, path string) ([]byte, error) { + templateData := st.newReleaseTemplateData(release) + return st.renderValuesFileToBytesWithData(path, templateData) +} + func (st *HelmState) storage() *Storage { return &Storage{ FilePath: st.FilePath, @@ -4314,6 +4421,14 @@ func (st *HelmState) getMissingFileHandler() *string { } func (st *HelmState) generateTemporaryReleaseValuesFiles(release *ReleaseSpec, values []any) ([]string, error) { + return st.generateTemporaryReleaseValuesFilesCore(release, values, func(path string) ([]byte, error) { + return st.RenderReleaseValuesFileToBytes(release, path) + }) +} + +// generateTemporaryReleaseValuesFilesCore is the shared implementation for generating temporary values files. +// renderStringValue is called for each string value entry after the file path has been resolved. +func (st *HelmState) generateTemporaryReleaseValuesFilesCore(release *ReleaseSpec, values []any, renderStringValue func(path string) ([]byte, error)) ([]string, error) { generatedFiles := []string{} for _, value := range values { @@ -4332,45 +4447,86 @@ func (st *HelmState) generateTemporaryReleaseValuesFiles(release *ReleaseSpec, v } path := paths[0] - yamlBytes, err := st.RenderReleaseValuesFileToBytes(release, path) + yamlBytes, err := renderStringValue(path) if err != nil { return generatedFiles, fmt.Errorf("failed to render values files \"%s\": %v", typedValue, err) } - valfile, err := createTempValuesFile(release, yamlBytes) + if err := func() error { + valfile, err := createTempValuesFile(release, yamlBytes) + if err != nil { + return err + } + defer func() { + _ = valfile.Close() + }() + + if _, err := valfile.Write(yamlBytes); err != nil { + return fmt.Errorf("failed to write %s: %v", valfile.Name(), err) + } + + st.logger.Debugf("Successfully generated the value file from %s to %s", path, valfile.Name()) + + generatedFiles = append(generatedFiles, valfile.Name()) + + return nil + }(); err != nil { + return generatedFiles, err + } + case map[any]any: + strMap, err := maputil.CastKeysToStrings(typedValue) if err != nil { return generatedFiles, err } - defer func() { - _ = valfile.Close() - }() + if err := func() error { + valfile, err := createTempValuesFile(release, strMap) + if err != nil { + return err + } + defer func() { + _ = valfile.Close() + }() - if _, err := valfile.Write(yamlBytes); err != nil { - return generatedFiles, fmt.Errorf("failed to write %s: %v", valfile.Name(), err) - } + encoder := yaml.NewEncoder(valfile) + defer func() { + _ = encoder.Close() + }() - st.logger.Debugf("Successfully generated the value file at %s. produced:\n%s", path, string(yamlBytes)) + if err := encoder.Encode(strMap); err != nil { + return err + } - generatedFiles = append(generatedFiles, valfile.Name()) - case map[any]any, map[string]any: - valfile, err := createTempValuesFile(release, typedValue) - if err != nil { + generatedFiles = append(generatedFiles, valfile.Name()) + + return nil + }(); err != nil { return generatedFiles, err } - defer func() { - _ = valfile.Close() - }() + case map[string]any: + if err := func() error { + valfile, err := createTempValuesFile(release, typedValue) + if err != nil { + return err + } + defer func() { + _ = valfile.Close() + }() - encoder := yaml.NewEncoder(valfile) - defer func() { - _ = encoder.Close() - }() + encoder := yaml.NewEncoder(valfile) + defer func() { + _ = encoder.Close() + }() - if err := encoder.Encode(typedValue); err != nil { + if err := encoder.Encode(typedValue); err != nil { + return err + } + + generatedFiles = append(generatedFiles, valfile.Name()) + + return nil + }(); err != nil { return generatedFiles, err } - - generatedFiles = append(generatedFiles, valfile.Name()) default: return generatedFiles, fmt.Errorf("unexpected type of value: value=%v, type=%T", typedValue, typedValue) } @@ -4379,27 +4535,11 @@ func (st *HelmState) generateTemporaryReleaseValuesFiles(release *ReleaseSpec, v } func (st *HelmState) generateVanillaValuesFiles(release *ReleaseSpec) ([]string, error) { - values := []any{} - for _, v := range release.Values { - switch typedValue := v.(type) { - case string: - path := st.storage().normalizePath(release.ValuesPathPrefix + typedValue) - values = append(values, path) - default: - values = append(values, v) - } - } - - valuesMapSecretsRendered, err := st.valsRuntime.Eval(map[string]any{"values": values}) + valuesSecretsRendered, err := st.prepareReleaseValuesEntries(release) if err != nil { return nil, err } - valuesSecretsRendered, ok := valuesMapSecretsRendered["values"].([]any) - if !ok { - return nil, fmt.Errorf("Failed to render values in %s for release %s: type %T isn't supported", st.FilePath, release.Name, valuesMapSecretsRendered["values"]) - } - generatedFiles, err := st.generateTemporaryReleaseValuesFiles(release, valuesSecretsRendered) if err != nil { return nil, err