Fix values templating bug where mergeOverwrite mutated global values
Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									3f5d4110f6
								
							
						
					
					
						commit
						052a2a8219
					
				|  | @ -396,7 +396,11 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp | ||||||
| 			return nil, clean, fmt.Errorf("rendering set value entry for release %s: %v", release.Name, err) | 			return nil, clean, fmt.Errorf("rendering set value entry for release %s: %v", release.Name, err) | ||||||
| 		} | 		} | ||||||
| 		c.Opts.SetFlags = setFlags | 		c.Opts.SetFlags = setFlags | ||||||
| 		c.Opts.TemplateData = st.newReleaseTemplateData(release) | 		templateData, err := st.newReleaseTemplateData(release) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, clean, fmt.Errorf("creating template data for release %s: %v", release.Name, err) | ||||||
|  | 		} | ||||||
|  | 		c.Opts.TemplateData = templateData | ||||||
| 		c.Opts.TemplateFuncs = st.newReleaseTemplateFuncMap(dir) | 		c.Opts.TemplateFuncs = st.newReleaseTemplateFuncMap(dir) | ||||||
| 
 | 
 | ||||||
| 		return c, clean, nil | 		return c, clean, nil | ||||||
|  |  | ||||||
|  | @ -0,0 +1,130 @@ | ||||||
|  | package state | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/helmfile/helmfile/pkg/filesystem" | ||||||
|  | 	"github.com/stretchr/testify/require" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // TestIssue2182_ValuesTemplatingBugFix is an integration test that reproduces
 | ||||||
|  | // the exact scenario described in https://github.com/helmfile/helmfile/issues/2182
 | ||||||
|  | // and verifies that our fix works correctly.
 | ||||||
|  | func TestIssue2182_ValuesTemplatingBugFix(t *testing.T) { | ||||||
|  | 	// Simulate the exact scenario from the issue:
 | ||||||
|  | 	// environments:
 | ||||||
|  | 	//   default:
 | ||||||
|  | 	//     values:
 | ||||||
|  | 	//       - values.yaml
 | ||||||
|  | 	// ---
 | ||||||
|  | 	// releases:
 | ||||||
|  | 	//   - name: foo
 | ||||||
|  | 	//     chart: charts/foo
 | ||||||
|  | 	//     valuesTemplate:
 | ||||||
|  | 	//       - {{ .Values | get "foo" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}
 | ||||||
|  | 	//   - name: bar
 | ||||||
|  | 	//     chart: charts/bar
 | ||||||
|  | 	//     valuesTemplate:
 | ||||||
|  | 	//       - {{ .Values | get "bar" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}
 | ||||||
|  | 
 | ||||||
|  | 	// Create test filesystem
 | ||||||
|  | 	fs := &filesystem.FileSystem{ | ||||||
|  | 		Glob: func(pattern string) ([]string, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 		ReadFile: func(filename string) ([]byte, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Simulate values.yaml content
 | ||||||
|  | 	valuesYaml := map[string]any{ | ||||||
|  | 		"global":    "shared-config", | ||||||
|  | 		"commonKey": "commonValue", | ||||||
|  | 		"foo": map[string]any{ | ||||||
|  | 			"enabled":     true, | ||||||
|  | 			"replicaCount": 2, | ||||||
|  | 			"image":       "foo:1.0.0", | ||||||
|  | 		}, | ||||||
|  | 		"bar": map[string]any{ | ||||||
|  | 			"enabled":     true, | ||||||
|  | 			"replicaCount": 1, | ||||||
|  | 			"image":       "bar:2.0.0", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	st := &HelmState{ | ||||||
|  | 		fs:             fs, | ||||||
|  | 		basePath:       "/tmp", | ||||||
|  | 		FilePath:       "helmfile.yaml", | ||||||
|  | 		RenderedValues: valuesYaml, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Define the releases as they would appear in helmfile.yaml
 | ||||||
|  | 	fooRelease := &ReleaseSpec{ | ||||||
|  | 		Name:  "foo", | ||||||
|  | 		Chart: "charts/foo", | ||||||
|  | 		ValuesTemplate: []any{ | ||||||
|  | 			`{{ .Values | get "foo" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}`, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	barRelease := &ReleaseSpec{ | ||||||
|  | 		Name:  "bar", | ||||||
|  | 		Chart: "charts/bar", | ||||||
|  | 		ValuesTemplate: []any{ | ||||||
|  | 			`{{ .Values | get "bar" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}`, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Simulate ExecuteTemplates processing releases in order: foo then bar
 | ||||||
|  | 	releases1 := []ReleaseSpec{*fooRelease, *barRelease} | ||||||
|  | 	st.Releases = releases1 | ||||||
|  | 
 | ||||||
|  | 	result1, err := st.ExecuteTemplates() | ||||||
|  | 	require.NoError(t, err, "ExecuteTemplates should succeed with foo then bar") | ||||||
|  | 
 | ||||||
|  | 	// Simulate ExecuteTemplates processing releases in reverse order: bar then foo  
 | ||||||
|  | 	releases2 := []ReleaseSpec{*barRelease, *fooRelease} | ||||||
|  | 	st.Releases = releases2 | ||||||
|  | 
 | ||||||
|  | 	result2, err := st.ExecuteTemplates() | ||||||
|  | 	require.NoError(t, err, "ExecuteTemplates should succeed with bar then foo") | ||||||
|  | 
 | ||||||
|  | 	// Extract the processed releases from both executions
 | ||||||
|  | 	fooRelease1 := result1.Releases[0] // foo from first execution (foo, bar)
 | ||||||
|  | 	barRelease1 := result1.Releases[1] // bar from first execution (foo, bar)
 | ||||||
|  | 
 | ||||||
|  | 	barRelease2 := result2.Releases[0] // bar from second execution (bar, foo)
 | ||||||
|  | 	fooRelease2 := result2.Releases[1] // foo from second execution (bar, foo)
 | ||||||
|  | 
 | ||||||
|  | 	// The critical assertion: Order should not matter!
 | ||||||
|  | 	// Before the fix, the second release would see modified values from the first release
 | ||||||
|  | 	require.Equal(t, fooRelease1.Values, fooRelease2.Values,  | ||||||
|  | 		"foo release values should be identical regardless of processing order") | ||||||
|  | 	require.Equal(t, barRelease1.Values, barRelease2.Values, | ||||||
|  | 		"bar release values should be identical regardless of processing order") | ||||||
|  | 
 | ||||||
|  | 	// Verify that each release gets the expected merged values
 | ||||||
|  | 	// foo release should have foo-specific values merged into the root
 | ||||||
|  | 	fooVals1 := fooRelease1.Values[0] | ||||||
|  | 	require.Contains(t, fooVals1, "enabled") | ||||||
|  | 	require.Contains(t, fooVals1, "replicaCount")  | ||||||
|  | 	require.Contains(t, fooVals1, "image") | ||||||
|  | 	require.Contains(t, fooVals1, "global")    // Should preserve global values
 | ||||||
|  | 	require.Contains(t, fooVals1, "commonKey") // Should preserve common values
 | ||||||
|  | 
 | ||||||
|  | 	// bar release should have bar-specific values merged into the root  
 | ||||||
|  | 	barVals1 := barRelease1.Values[0] | ||||||
|  | 	require.Contains(t, barVals1, "enabled") | ||||||
|  | 	require.Contains(t, barVals1, "replicaCount") | ||||||
|  | 	require.Contains(t, barVals1, "image")  | ||||||
|  | 	require.Contains(t, barVals1, "global")    // Should preserve global values
 | ||||||
|  | 	require.Contains(t, barVals1, "commonKey") // Should preserve common values
 | ||||||
|  | 
 | ||||||
|  | 	// Verify that the original values were not mutated
 | ||||||
|  | 	originalVals := st.Values() | ||||||
|  | 	require.Equal(t, valuesYaml, originalVals, "original values should remain unchanged") | ||||||
|  | 
 | ||||||
|  | 	t.Log("✅ Fix verified: Release order no longer affects values templating results") | ||||||
|  | } | ||||||
|  | @ -3167,11 +3167,14 @@ func (st *HelmState) flagsForLint(helm helmexec.Interface, release *ReleaseSpec, | ||||||
| 	return st.appendHelmXFlags(flags, release), files, nil | 	return st.appendHelmXFlags(flags, release), files, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (st *HelmState) newReleaseTemplateData(release *ReleaseSpec) releaseTemplateData { | func (st *HelmState) newReleaseTemplateData(release *ReleaseSpec) (releaseTemplateData, error) { | ||||||
| 	vals := st.Values() | 	vals := st.Values() | ||||||
| 	templateData := st.createReleaseTemplateData(release, vals) | 	templateData, err := st.createReleaseTemplateData(release, vals) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return releaseTemplateData{}, err | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	return templateData | 	return templateData, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (st *HelmState) newReleaseTemplateFuncMap(dir string) template.FuncMap { | func (st *HelmState) newReleaseTemplateFuncMap(dir string) template.FuncMap { | ||||||
|  | @ -3181,7 +3184,10 @@ func (st *HelmState) newReleaseTemplateFuncMap(dir string) template.FuncMap { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (st *HelmState) RenderReleaseValuesFileToBytes(release *ReleaseSpec, path string) ([]byte, error) { | func (st *HelmState) RenderReleaseValuesFileToBytes(release *ReleaseSpec, path string) ([]byte, error) { | ||||||
| 	templateData := st.newReleaseTemplateData(release) | 	templateData, err := st.newReleaseTemplateData(release) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to create template data for release %q: %v", release.Name, err) | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	r := tmpl.NewFileRenderer(st.fs, filepath.Dir(path), templateData) | 	r := tmpl.NewFileRenderer(st.fs, filepath.Dir(path), templateData) | ||||||
| 	rawBytes, err := r.RenderToBytes(path) | 	rawBytes, err := r.RenderToBytes(path) | ||||||
|  |  | ||||||
|  | @ -20,13 +20,39 @@ func (st *HelmState) Values() map[string]any { | ||||||
| 	return st.RenderedValues | 	return st.RenderedValues | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (st *HelmState) createReleaseTemplateData(release *ReleaseSpec, vals map[string]any) releaseTemplateData { | // deepCopyValues creates a deep copy of a values map using YAML marshal/unmarshal.
 | ||||||
|  | // This ensures that template operations don't mutate the original values.
 | ||||||
|  | func deepCopyValues(vals map[string]any) (map[string]any, error) { | ||||||
|  | 	if vals == nil { | ||||||
|  | 		return nil, nil | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	serialized, err := yaml.Marshal(vals) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to deep copy values: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var deserialized map[string]any | ||||||
|  | 	if err := yaml.Unmarshal(serialized, &deserialized); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to deep copy values: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return deserialized, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (st *HelmState) createReleaseTemplateData(release *ReleaseSpec, vals map[string]any) (releaseTemplateData, error) { | ||||||
|  | 	// Create a deep copy of values to prevent template mutations from affecting global state
 | ||||||
|  | 	valuesCopy, err := deepCopyValues(vals) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return releaseTemplateData{}, fmt.Errorf("failed to copy values for release %q: %v", release.Name, err) | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
| 	tmplData := releaseTemplateData{ | 	tmplData := releaseTemplateData{ | ||||||
| 		Environment: st.Env, | 		Environment: st.Env, | ||||||
| 		KubeContext: st.OverrideKubeContext, | 		KubeContext: st.OverrideKubeContext, | ||||||
| 		Namespace:   st.OverrideNamespace, | 		Namespace:   st.OverrideNamespace, | ||||||
| 		Chart:       st.OverrideChart, | 		Chart:       st.OverrideChart, | ||||||
| 		Values:      vals, | 		Values:      valuesCopy, | ||||||
| 		Release: releaseTemplateDataRelease{ | 		Release: releaseTemplateDataRelease{ | ||||||
| 			Name:         release.Name, | 			Name:         release.Name, | ||||||
| 			Chart:        release.Chart, | 			Chart:        release.Chart, | ||||||
|  | @ -37,7 +63,7 @@ func (st *HelmState) createReleaseTemplateData(release *ReleaseSpec, vals map[st | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| 	tmplData.StateValues = &tmplData.Values | 	tmplData.StateValues = &tmplData.Values | ||||||
| 	return tmplData | 	return tmplData, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func getBoolRefFromStringTemplate(templateRef string) (*bool, error) { | func getBoolRefFromStringTemplate(templateRef string) (*bool, error) { | ||||||
|  | @ -112,7 +138,10 @@ func (st *HelmState) ExecuteTemplates() (*HelmState, error) { | ||||||
| 
 | 
 | ||||||
| 		successFlag := false | 		successFlag := false | ||||||
| 		for it, prev := 0, release; it < 6; it++ { | 		for it, prev := 0, release; it < 6; it++ { | ||||||
| 			tmplData := st.createReleaseTemplateData(prev, vals) | 			tmplData, err := st.createReleaseTemplateData(prev, vals) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return nil, fmt.Errorf("failed creating template data for release \"%s\".\"%s\": %v", st.FilePath, release.Name, err) | ||||||
|  | 			} | ||||||
| 			renderer := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData) | 			renderer := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData) | ||||||
| 			r, err := release.ExecuteTemplateExpressions(renderer) | 			r, err := release.ExecuteTemplateExpressions(renderer) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,76 @@ | ||||||
|  | package state | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/helmfile/helmfile/pkg/filesystem" | ||||||
|  | 	"github.com/stretchr/testify/require" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // TestValuesMutationFix reproduces and tests the fix for the issue described in
 | ||||||
|  | // https://github.com/helmfile/helmfile/issues/2182
 | ||||||
|  | // where mergeOverwrite modifies the global .Values object instead of creating a local copy
 | ||||||
|  | func TestValuesMutationFix(t *testing.T) { | ||||||
|  | 	// Create test filesystem with no files
 | ||||||
|  | 	fs := &filesystem.FileSystem{ | ||||||
|  | 		Glob: func(pattern string) ([]string, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 		ReadFile: func(filename string) ([]byte, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	st := &HelmState{ | ||||||
|  | 		fs:       fs, | ||||||
|  | 		basePath: "/tmp", | ||||||
|  | 		RenderedValues: map[string]any{ | ||||||
|  | 			"common": "value", | ||||||
|  | 			"foo": map[string]any{ | ||||||
|  | 				"specific": "foo-value", | ||||||
|  | 			}, | ||||||
|  | 			"bar": map[string]any{ | ||||||
|  | 				"specific": "bar-value", | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	release := &ReleaseSpec{ | ||||||
|  | 		Name: "test-release", | ||||||
|  | 		Chart: "test/chart", | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Create template data twice to simulate two different releases
 | ||||||
|  | 	vals1 := st.Values() | ||||||
|  | 	tmplData1, err := st.createReleaseTemplateData(release, vals1) | ||||||
|  | 	require.NoError(t, err, "first createReleaseTemplateData should not fail") | ||||||
|  | 
 | ||||||
|  | 	vals2 := st.Values() | ||||||
|  | 	tmplData2, err := st.createReleaseTemplateData(release, vals2) | ||||||
|  | 	require.NoError(t, err, "second createReleaseTemplateData should not fail") | ||||||
|  | 
 | ||||||
|  | 	// Verify that both template data have the same initial values
 | ||||||
|  | 	require.Equal(t, tmplData1.Values, tmplData2.Values, "both template data should start with identical values") | ||||||
|  | 
 | ||||||
|  | 	// Simulate mergeOverwrite operation on first template data
 | ||||||
|  | 	// This should not affect the second template data after our fix
 | ||||||
|  | 	fooSection, ok := tmplData1.Values["foo"].(map[string]any) | ||||||
|  | 	require.True(t, ok, "foo section should be a map") | ||||||
|  | 	 | ||||||
|  | 	// Manually perform what mergeOverwrite would do - add values from foo section to the root
 | ||||||
|  | 	for k, v := range fooSection { | ||||||
|  | 		tmplData1.Values[k] = v | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Verify that the modification only affected tmplData1, not tmplData2
 | ||||||
|  | 	_, hasSpecificInTmpl1 := tmplData1.Values["specific"] | ||||||
|  | 	_, hasSpecificInTmpl2 := tmplData2.Values["specific"] | ||||||
|  | 
 | ||||||
|  | 	require.True(t, hasSpecificInTmpl1, "tmplData1 should have the merged 'specific' key") | ||||||
|  | 	require.False(t, hasSpecificInTmpl2, "tmplData2 should NOT have the merged 'specific' key (values should be isolated)") | ||||||
|  | 
 | ||||||
|  | 	// Also verify that the original values are not affected
 | ||||||
|  | 	originalVals := st.Values() | ||||||
|  | 	_, hasSpecificInOriginal := originalVals["specific"] | ||||||
|  | 	require.False(t, hasSpecificInOriginal, "original Values should NOT be affected") | ||||||
|  | } | ||||||
|  | @ -0,0 +1,102 @@ | ||||||
|  | package state | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/helmfile/helmfile/pkg/filesystem" | ||||||
|  | 	"github.com/helmfile/helmfile/pkg/tmpl" | ||||||
|  | 	"github.com/stretchr/testify/require" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // TestValuesTemplateIsolation tests the fix for the helmfile values templating bug
 | ||||||
|  | // where changing the order of releases resulted in different values being used
 | ||||||
|  | func TestValuesTemplateIsolation(t *testing.T) { | ||||||
|  | 	// Create test filesystem
 | ||||||
|  | 	fs := &filesystem.FileSystem{ | ||||||
|  | 		Glob: func(pattern string) ([]string, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 		ReadFile: func(filename string) ([]byte, error) { | ||||||
|  | 			return nil, nil | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Create test environment values
 | ||||||
|  | 	envValues := map[string]any{ | ||||||
|  | 		"common": "shared-value", | ||||||
|  | 		"foo": map[string]any{ | ||||||
|  | 			"name": "foo-chart", | ||||||
|  | 		}, | ||||||
|  | 		"bar": map[string]any{ | ||||||
|  | 			"name": "bar-chart", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	st := &HelmState{ | ||||||
|  | 		fs:             fs, | ||||||
|  | 		basePath:       "/tmp", | ||||||
|  | 		RenderedValues: envValues, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Create two releases that use valuesTemplate with mergeOverwrite
 | ||||||
|  | 	fooRelease := &ReleaseSpec{ | ||||||
|  | 		Name:  "foo", | ||||||
|  | 		Chart: "charts/foo", | ||||||
|  | 		ValuesTemplate: []any{ | ||||||
|  | 			"{{ .Values | get \"foo\" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	barRelease := &ReleaseSpec{ | ||||||
|  | 		Name:  "bar", | ||||||
|  | 		Chart: "charts/bar", | ||||||
|  | 		ValuesTemplate: []any{ | ||||||
|  | 			"{{ .Values | get \"bar\" (dict) | mergeOverwrite .Values | toYaml | nindent 8 }}", | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Test: process foo first, then bar
 | ||||||
|  | 	vals1 := st.Values() | ||||||
|  | 	tmplData1, err := st.createReleaseTemplateData(fooRelease, vals1) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 	 | ||||||
|  | 	renderer1 := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData1) | ||||||
|  | 	processedFooFirst, err := fooRelease.ExecuteTemplateExpressions(renderer1) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 	vals2 := st.Values() | ||||||
|  | 	tmplData2, err := st.createReleaseTemplateData(barRelease, vals2) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 	 | ||||||
|  | 	renderer2 := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData2) | ||||||
|  | 	processedBarSecond, err := barRelease.ExecuteTemplateExpressions(renderer2) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 	// Test: process bar first, then foo (reverse order)
 | ||||||
|  | 	vals3 := st.Values() | ||||||
|  | 	tmplData3, err := st.createReleaseTemplateData(barRelease, vals3) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 	 | ||||||
|  | 	renderer3 := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData3) | ||||||
|  | 	processedBarFirst, err := barRelease.ExecuteTemplateExpressions(renderer3) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 	vals4 := st.Values() | ||||||
|  | 	tmplData4, err := st.createReleaseTemplateData(fooRelease, vals4) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 	 | ||||||
|  | 	renderer4 := tmpl.NewFileRenderer(st.fs, st.basePath, tmplData4) | ||||||
|  | 	processedFooSecond, err := fooRelease.ExecuteTemplateExpressions(renderer4) | ||||||
|  | 	require.NoError(t, err) | ||||||
|  | 
 | ||||||
|  | 	// Verify that the order doesn't matter - results should be consistent
 | ||||||
|  | 	require.Equal(t, processedFooFirst.Values, processedFooSecond.Values,  | ||||||
|  | 		"foo release should produce same values regardless of processing order") | ||||||
|  | 	require.Equal(t, processedBarSecond.Values, processedBarFirst.Values, | ||||||
|  | 		"bar release should produce same values regardless of processing order") | ||||||
|  | 
 | ||||||
|  | 	// Also verify that the original values are not modified
 | ||||||
|  | 	originalVals := st.Values() | ||||||
|  | 	require.Equal(t, envValues, originalVals,  | ||||||
|  | 		"original values should remain unchanged") | ||||||
|  | } | ||||||
|  | @ -0,0 +1,57 @@ | ||||||
|  | package tmpl | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/helmfile/helmfile/pkg/filesystem" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // TestValuesIsolation reproduces the issue described in https://github.com/helmfile/helmfile/issues/2182
 | ||||||
|  | // where mergeOverwrite modifies the global .Values object instead of creating a local copy
 | ||||||
|  | // This test demonstrates that the issue is fixed when using the state layer (which provides isolation)
 | ||||||
|  | func TestValuesIsolation(t *testing.T) { | ||||||
|  | 	ctx := &Context{ | ||||||
|  | 		fs: &filesystem.FileSystem{ | ||||||
|  | 			Glob: func(pattern string) ([]string, error) { | ||||||
|  | 				return nil, nil | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Template that simulates the problematic helmfile.yaml content
 | ||||||
|  | 	// NOTE: This test shows that the template layer itself doesn't provide isolation,
 | ||||||
|  | 	// but the fix is implemented at the state layer where values are copied before templating
 | ||||||
|  | 	template := ` | ||||||
|  | {{- $originalValues := .Values }} | ||||||
|  | {{- $fooValues := .Values | get "foo" (dict) | mergeOverwrite .Values }} | ||||||
|  | {{- $barValues := .Values | get "bar" (dict) | mergeOverwrite .Values }} | ||||||
|  | First render (should use original + foo): {{ $fooValues | toYaml }} | ||||||
|  | Second render (should use original + bar): {{ $barValues | toYaml }} | ||||||
|  | Final Values (should be original): {{ .Values | toYaml }} | ||||||
|  | Original Values (should be same as final): {{ $originalValues | toYaml }} | ||||||
|  | ` | ||||||
|  | 
 | ||||||
|  | 	data := map[string]any{ | ||||||
|  | 		"Values": map[string]any{ | ||||||
|  | 			"common": "value", | ||||||
|  | 			"foo": map[string]any{ | ||||||
|  | 				"specific": "foo-value", | ||||||
|  | 			}, | ||||||
|  | 			"bar": map[string]any{ | ||||||
|  | 				"specific": "bar-value", | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	buf, err := ctx.RenderTemplateToBuffer(template, data) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatalf("unexpected error: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	result := buf.String() | ||||||
|  | 	t.Logf("Template result:\n%s", result) | ||||||
|  | 
 | ||||||
|  | 	// This test still demonstrates the raw template behavior (without our fix at state layer),
 | ||||||
|  | 	// but the actual bug is fixed at the state layer where we copy values before templating.
 | ||||||
|  | 	// See TestValuesTemplateIsolation in pkg/state for the proper integration test.
 | ||||||
|  | } | ||||||
		Loading…
	
		Reference in New Issue