helmfile/pkg/state/create_test.go

1384 lines
39 KiB
Go

package state
import (
"fmt"
"path/filepath"
"reflect"
"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"
)
func createFromYaml(content []byte, file string, env string, logger *zap.SugaredLogger) (*HelmState, error) {
c := &StateCreator{
logger: logger,
fs: filesystem.DefaultFileSystem(),
Strict: true,
}
return c.ParseAndLoad(content, filepath.Dir(file), file, env, false, true, true, nil, nil)
}
func TestReadFromYaml(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases:
- name: myrelease
namespace: mynamespace
chart: mychart
`)
state, err := createFromYaml(yamlContent, yamlFile, DefaultEnv, logger)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if state.Releases[0].Name != "myrelease" {
t.Errorf("unexpected release name: expected=myrelease actual=%s", state.Releases[0].Name)
}
if state.Releases[0].Namespace != "mynamespace" {
t.Errorf("unexpected chart namespace: expected=mynamespace actual=%s", state.Releases[0].Chart)
}
if state.Releases[0].Chart != "mychart" {
t.Errorf("unexpected chart name: expected=mychart actual=%s", state.Releases[0].Chart)
}
}
func TestReadFromYaml_NonexistentEnv(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases:
- name: myrelease
namespace: mynamespace
chart: mychart
`)
_, err := createFromYaml(yamlContent, yamlFile, "production", logger)
// This does not produce an error because the environment existence check if done
// outside of the ParseAndLoad function since
// https://github.com/helmfile/helmfile/pull/885
require.NoError(t, err)
}
type stateTestEnv struct {
Files map[string]string
WorkDir string
}
func (testEnv stateTestEnv) MustLoadState(t *testing.T, file, envName string) *HelmState {
return testEnv.MustLoadStateWithEnableLiveOutput(t, file, envName, false)
}
func (testEnv stateTestEnv) MustLoadStateWithEnableLiveOutput(t *testing.T, file, envName string, enableLiveOutput bool) *HelmState {
t.Helper()
testFs := testhelper.NewTestFs(testEnv.Files)
if testFs.Cwd == "" {
testFs.Cwd = "/"
}
yamlContent, ok := testEnv.Files[file]
if !ok {
t.Fatalf("no file named %q registered", file)
}
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
state, err := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, enableLiveOutput, "").
ParseAndLoad([]byte(yamlContent), filepath.Dir(file), file, envName, true, true, true, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return state
}
func TestReadFromYaml_NonDefaultEnv(t *testing.T) {
yamlFile := "/example/path/to/helmfile.yaml"
yamlContent := []byte(`environments:
production:
values:
- foo.yaml
- bar.yaml.gotmpl
releases:
- name: myrelease
namespace: mynamespace
chart: mychart
values:
- values.yaml.gotmpl
`)
fooYamlFile := "/example/path/to/foo.yaml"
fooYamlContent := []byte(`foo: foo
# As this file doesn't have an file extension ".gotmpl", this template expression should not be evaluated
baz: "{{ readFile \"baz.txt\" }}"`)
barYamlFile := "/example/path/to/bar.yaml.gotmpl"
barYamlContent := []byte(`foo: FOO
bar: {{ readFile "bar.txt" }}
env: {{ .Environment.Name }}
`)
barTextFile := "/example/path/to/bar.txt"
barTextContent := []byte("BAR")
expected := map[string]any{
"foo": "FOO",
"bar": "BAR",
// As the file doesn't have an file extension ".gotmpl", this template expression should not be evaluated
"baz": "{{ readFile \"baz.txt\" }}",
"env": "production",
}
valuesFile := "/example/path/to/values.yaml.gotmpl"
valuesContent := []byte(`env: {{ .Environment.Name }}
releaseName: {{ .Release.Name }}
releaseNamespace: {{ .Release.Namespace }}
`)
expectedValues := `env: production
releaseName: myrelease
releaseNamespace: mynamespace
`
testFs := testhelper.NewTestFs(map[string]string{
fooYamlFile: string(fooYamlContent),
barYamlFile: string(barYamlContent),
barTextFile: string(barTextContent),
valuesFile: string(valuesContent),
})
testFs.Cwd = "/example/path/to"
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
env := environment.Environment{
Name: "production",
}
state, err := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "").
ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "production", true, true, true, &env, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
actual := state.Env.Values
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected environment values: expected=%v, actual=%v", expected, actual)
}
release := state.Releases[0]
state.ApplyOverrides(&release)
actualValuesData, err := state.RenderReleaseValuesFileToBytes(&release, valuesFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actualValues := string(actualValuesData)
if !reflect.DeepEqual(expectedValues, actualValues) {
t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues)
}
}
func TestReadFromYaml_OverrideNamespace(t *testing.T) {
yamlFile := "/example/path/to/helmfile.yaml"
yamlContent := []byte(`environments:
production:
values:
- foo.yaml
- bar.yaml.gotmpl
# A.k.a helmfile apply --namespace myns
namespace: myns
releases:
- name: myrelease
namespace: mynamespace
chart: mychart
values:
- values.yaml.gotmpl
`)
fooYamlFile := "/example/path/to/foo.yaml"
fooYamlContent := []byte(`foo: foo
# As this file doesn't have an file extension ".gotmpl", this template expression should not be evaluated
baz: "{{ readFile \"baz.txt\" }}"`)
barYamlFile := "/example/path/to/bar.yaml.gotmpl"
barYamlContent := []byte(`foo: FOO
bar: {{ readFile "bar.txt" }}
`)
barTextFile := "/example/path/to/bar.txt"
barTextContent := []byte("BAR")
expected := map[string]any{
"foo": "FOO",
"bar": "BAR",
// As the file doesn't have an file extension ".gotmpl", this template expression should not be evaluated
"baz": "{{ readFile \"baz.txt\" }}",
}
valuesFile := "/example/path/to/values.yaml.gotmpl"
valuesContent := []byte(`env: {{ .Environment.Name }}
releaseName: {{ .Release.Name }}
releaseNamespace: {{ .Release.Namespace }}
overrideNamespace: {{ .Namespace }}
`)
expectedValues := `env: production
releaseName: myrelease
releaseNamespace: myns
overrideNamespace: myns
`
testFs := testhelper.NewTestFs(map[string]string{
fooYamlFile: string(fooYamlContent),
barYamlFile: string(barYamlContent),
barTextFile: string(barTextContent),
valuesFile: string(valuesContent),
})
testFs.Cwd = "/example/path/to"
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
state, err := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "").
ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "production", true, true, true, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
actual := state.Env.Values
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected environment values: expected=%v, actual=%v", expected, actual)
}
release := state.Releases[0]
state.ApplyOverrides(&release)
actualValuesData, err := state.RenderReleaseValuesFileToBytes(&release, valuesFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actualValues := string(actualValuesData)
if !reflect.DeepEqual(expectedValues, actualValues) {
t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues)
}
}
func TestReadFromYaml_StrictUnmarshalling(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases:
- name: myrelease
namespace: mynamespace
releases: mychart
`)
_, err := createFromYaml(yamlContent, yamlFile, DefaultEnv, logger)
if err == nil {
t.Error("expected an error for wrong key 'releases' which is not in struct")
}
}
func TestReadFromYaml_ConflictingReleasesConfig(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`charts:
- name: myrelease1
chart: mychart1
releases:
- name: myrelease2
chart: mychart2
`)
_, err := createFromYaml(yamlContent, yamlFile, DefaultEnv, logger)
if err == nil {
t.Error("expected error")
}
}
func TestReadFromYaml_FilterReleasesOnLabels(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases:
- name: myrelease1
chart: mychart1
labels:
tier: frontend
foo: bar
- name: myrelease2
chart: mychart2
labels:
tier: frontend
- name: myrelease3
chart: mychart3
labels:
tier: backend
`)
cases := []struct {
filter LabelFilter
results []bool
}{
{LabelFilter{positiveLabels: [][]string{{"tier", "frontend"}}},
[]bool{true, true, false}},
{LabelFilter{positiveLabels: [][]string{{"tier", "frontend"}, {"foo", "bar"}}},
[]bool{true, false, false}},
{LabelFilter{negativeLabels: [][]string{{"tier", "frontend"}}},
[]bool{false, false, true}},
{LabelFilter{positiveLabels: [][]string{{"tier", "frontend"}}, negativeLabels: [][]string{{"foo", "bar"}}},
[]bool{false, true, false}},
}
state, err := createFromYaml(yamlContent, yamlFile, DefaultEnv, logger)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
for idx, c := range cases {
for idx2, expected := range c.results {
if f := c.filter.Match(state.Releases[idx2]); f != expected {
t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f)
}
}
}
}
func TestReadFromYaml_FilterNegatives(t *testing.T) {
yamlFile := "example/path/to/yaml/file"
yamlContent := []byte(`releases:
- name: myrelease1
chart: mychart1
labels:
stage: pre
foo: bar
- name: myrelease2
chart: mychart2
labels:
stage: post
- name: myrelease3
chart: mychart3
`)
cases := []struct {
filter LabelFilter
results []bool
}{
{LabelFilter{positiveLabels: [][]string{{"stage", "pre"}}},
[]bool{true, false, false}},
{LabelFilter{positiveLabels: [][]string{{"stage", "post"}}},
[]bool{false, true, false}},
{LabelFilter{negativeLabels: [][]string{{"stage", "pre"}, {"stage", "post"}}},
[]bool{false, false, true}},
{LabelFilter{negativeLabels: [][]string{{"foo", "bar"}}},
[]bool{false, true, true}},
}
state, err := createFromYaml(yamlContent, yamlFile, DefaultEnv, logger)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
for idx, c := range cases {
for idx2, expected := range c.results {
if f := c.filter.Match(state.Releases[idx2]); f != expected {
t.Errorf("[case: %d][outcome: %d] Unexpected outcome wanted %t, got %t", idx, idx2, expected, f)
}
}
}
}
func TestReadFromYaml_Helmfiles_Selectors(t *testing.T) {
tests := []struct {
path string
content []byte
wantErr bool
helmfiles []SubHelmfileSpec
}{
{
path: "working/selector",
content: []byte(`helmfiles:
- simple/helmfile.yaml
- path: path/prefix/selector.yaml
selectors:
- name=zorba
- foo=bar
- path: path/prefix/empty/selector.yaml
selectors: []
- path: path/prefix/inherits/selector.yaml
selectorsInherited: true
`),
wantErr: false,
helmfiles: []SubHelmfileSpec{{Path: "simple/helmfile.yaml", Selectors: nil, SelectorsInherited: false},
{Path: "path/prefix/selector.yaml", Selectors: []string{"name=zorba", "foo=bar"}, SelectorsInherited: false},
{Path: "path/prefix/empty/selector.yaml", Selectors: []string{}, SelectorsInherited: false},
{Path: "path/prefix/inherits/selector.yaml", Selectors: nil, SelectorsInherited: true},
},
},
{
path: "failing2/selector",
content: []byte(`helmfiles:
- path: failing2/helmfile.yaml
wrongkey:
`),
wantErr: true,
},
{
path: "failing3/selector",
content: []byte(`helmfiles:
- path: failing3/helmfile.yaml
selectors: foo
`),
wantErr: true,
},
{
path: "failing4/selector",
content: []byte(`helmfiles:
- path: failing4/helmfile.yaml
selectors:
`),
wantErr: true,
},
{
path: "failing4/selector",
content: []byte(`helmfiles:
- path: failing4/helmfile.yaml
selectors:
- colon: not-authorized
`),
wantErr: true,
},
{
path: "failing6/selector",
content: []byte(`helmfiles:
- selectors:
- whatever
`),
wantErr: true,
},
{
path: "failing7/selector",
content: []byte(`helmfiles:
- path: foo/bar
selectors:
- foo=bar
selectorsInherited: true
`),
wantErr: true,
},
}
for _, test := range tests {
st, err := createFromYaml(test.content, test.path, DefaultEnv, logger)
if err != nil {
if test.wantErr {
continue
} else {
t.Error("unexpected error:", err)
}
}
require.Equalf(t, test.helmfiles, st.Helmfiles, "for path %s", test.path)
}
}
func TestReadFromYaml_EnvironmentContext(t *testing.T) {
yamlFile := "/example/path/to/helmfile.yaml"
yamlContent := []byte(`environments:
production:
values: []
kubeContext: myCtx
releases:
- name: myrelease
namespace: mynamespace
chart: mychart
values:
- values.yaml.gotmpl
`)
valuesFile := "/example/path/to/values.yaml.gotmpl"
valuesContent := []byte(`envName: {{ .Environment.Name }}
envContext: {{ .Environment.KubeContext }}
releaseName: {{ .Release.Name }}
releaseContext: {{ .Release.KubeContext }}
`)
expectedValues := `envName: production
envContext: myCtx
releaseName: myrelease
releaseContext:
`
testFs := testhelper.NewTestFs(map[string]string{
valuesFile: string(valuesContent),
})
testFs.Cwd = "/example/path/to"
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
state, err := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "").
ParseAndLoad(yamlContent, filepath.Dir(yamlFile), yamlFile, "production", true, true, true, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
release := state.Releases[0]
state.ApplyOverrides(&release)
actualValuesData, err := state.RenderReleaseValuesFileToBytes(&release, valuesFile)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actualValues := string(actualValuesData)
if !reflect.DeepEqual(expectedValues, actualValues) {
t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues)
}
}
// TestHelmBinaryInBases tests that helmBinary and kustomizeBinary settings
// from bases are properly merged with later values overriding earlier ones
func TestHelmBinaryInBases(t *testing.T) {
tests := []struct {
name string
files map[string]string
mainFile string
expectedHelmBinary string
expectedKustomizeBinary string
}{
{
name: "helmBinary in second base should be used",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/env.yaml
---
bases:
- ./bases/repos.yaml
---
bases:
- ./bases/releases.yaml
`,
"/path/to/bases/env.yaml": `environments:
default:
values:
- key: value1
`,
"/path/to/bases/repos.yaml": `repositories:
- name: stable
url: https://charts.helm.sh/stable
helmBinary: /path/to/custom/helm
`,
"/path/to/bases/releases.yaml": `releases:
- name: myapp
chart: stable/nginx
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: "/path/to/custom/helm",
expectedKustomizeBinary: DefaultKustomizeBinary,
},
{
name: "helmBinary in main file after bases should override",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/env.yaml
---
bases:
- ./bases/repos.yaml
---
bases:
- ./bases/releases.yaml
helmBinary: /path/to/main/helm
`,
"/path/to/bases/env.yaml": `environments:
default:
values:
- key: value1
`,
"/path/to/bases/repos.yaml": `repositories:
- name: stable
url: https://charts.helm.sh/stable
helmBinary: /path/to/base/helm
`,
"/path/to/bases/releases.yaml": `releases:
- name: myapp
chart: stable/nginx
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: "/path/to/main/helm",
expectedKustomizeBinary: DefaultKustomizeBinary,
},
{
name: "helmBinary in main file between bases should override earlier bases",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/env.yaml
---
bases:
- ./bases/repos.yaml
helmBinary: /path/to/middle/helm
---
bases:
- ./bases/releases.yaml
`,
"/path/to/bases/env.yaml": `environments:
default:
values:
- key: value1
`,
"/path/to/bases/repos.yaml": `repositories:
- name: stable
url: https://charts.helm.sh/stable
helmBinary: /path/to/base/helm
`,
"/path/to/bases/releases.yaml": `releases:
- name: myapp
chart: stable/nginx
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: "/path/to/middle/helm",
expectedKustomizeBinary: DefaultKustomizeBinary,
},
{
name: "kustomizeBinary in base should be used",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/base.yaml
`,
"/path/to/bases/base.yaml": `kustomizeBinary: /path/to/custom/kustomize
releases:
- name: myapp
chart: mychart
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: DefaultHelmBinary,
expectedKustomizeBinary: "/path/to/custom/kustomize",
},
{
name: "both helmBinary and kustomizeBinary in different bases",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/helm.yaml
---
bases:
- ./bases/kustomize.yaml
`,
"/path/to/bases/helm.yaml": `helmBinary: /path/to/custom/helm
`,
"/path/to/bases/kustomize.yaml": `kustomizeBinary: /path/to/custom/kustomize
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: "/path/to/custom/helm",
expectedKustomizeBinary: "/path/to/custom/kustomize",
},
{
name: "later base overrides earlier base for helmBinary",
files: map[string]string{
"/path/to/helmfile.yaml": `bases:
- ./bases/first.yaml
---
bases:
- ./bases/second.yaml
`,
"/path/to/bases/first.yaml": `helmBinary: /path/to/first/helm
`,
"/path/to/bases/second.yaml": `helmBinary: /path/to/second/helm
`,
},
mainFile: "/path/to/helmfile.yaml",
expectedHelmBinary: "/path/to/second/helm",
expectedKustomizeBinary: DefaultKustomizeBinary,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testFs := testhelper.NewTestFs(tt.files)
if testFs.Cwd == "" {
testFs.Cwd = "/"
}
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
creator := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "")
// Set up LoadFile for recursive base loading
creator.LoadFile = func(inheritedEnv, overrodeEnv *environment.Environment, baseDir, file string, evaluateBases bool) (*HelmState, error) {
path := filepath.Join(baseDir, file)
content, ok := tt.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
// When loading base files, we don't apply defaults - they should only
// be applied to the main file after all parts/bases are merged.
// So applyDefaults = evaluateBases (true for main, false for bases).
applyDefaults := evaluateBases
return creator.ParseAndLoad([]byte(content), filepath.Dir(path), path, DefaultEnv, true, evaluateBases, applyDefaults, inheritedEnv, overrodeEnv)
}
yamlContent, ok := tt.files[tt.mainFile]
if !ok {
t.Fatalf("no file named %q registered", tt.mainFile)
}
state, err := creator.ParseAndLoad([]byte(yamlContent), filepath.Dir(tt.mainFile), tt.mainFile, DefaultEnv, true, true, true, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state.DefaultHelmBinary != tt.expectedHelmBinary {
t.Errorf("helmBinary mismatch: expected=%s, actual=%s",
tt.expectedHelmBinary, state.DefaultHelmBinary)
}
if state.DefaultKustomizeBinary != tt.expectedKustomizeBinary {
t.Errorf("kustomizeBinary mismatch: expected=%s, actual=%s",
tt.expectedKustomizeBinary, state.DefaultKustomizeBinary)
}
})
}
}
// TestHelmBinaryInMultiDocumentYAML tests that helmBinary is preserved when
// processing multi-document YAML files (files with --- separators).
// This is a regression test for issue #2319.
func TestHelmBinaryInMultiDocumentYAML(t *testing.T) {
logger := zaptest.NewLogger(t).Sugar()
testFs := testhelper.NewTestFs(map[string]string{})
testFs.Cwd = "/"
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
creator := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "")
// Simulate a multi-document YAML where the first document sets helmBinary
// and the second document has no helmBinary
doc1 := `helmBinary: /custom/helm
helmDefaults:
timeout: 300
`
doc2 := `releases:
- name: myapp
chart: stable/nginx
`
// Simulate what load() in desired_state_file_loader.go does:
// 1. Process each part with applyDefaults=false
state1, err := creator.ParseAndLoad([]byte(doc1), "/", "/helmfile.yaml", DefaultEnv, true, true, false, nil, nil)
require.NoError(t, err)
state2, err := creator.ParseAndLoad([]byte(doc2), "/", "/helmfile.yaml", DefaultEnv, true, true, false, nil, nil)
require.NoError(t, err)
// 2. Merge parts (simulating what mergo.Merge does with mergo.WithOverride)
// Since state2 has empty DefaultHelmBinary, mergo.WithOverride should preserve state1's value
require.NoError(t, mergo.Merge(state1, state2, mergo.WithAppendSlice, mergo.WithOverride))
// 3. Apply defaults after merge
creator.ApplyDefaultsAndOverrides(state1)
// Verify that helmBinary from first document is preserved
assert.Equal(t, "/custom/helm", state1.DefaultHelmBinary,
"helmBinary from first document should be preserved after merge and applyDefaults")
}
// TestEnvironmentMergingWithBases tests that environment values from multiple bases
// are properly merged rather than replaced. This is a regression test for issue #2273.
func TestEnvironmentMergingWithBases(t *testing.T) {
tests := []struct {
name string
files map[string]string
mainFile string
environment string
expectedError bool
checkValues func(t *testing.T, state *HelmState)
}{
{
name: "environment values should merge from multiple bases",
files: map[string]string{
"/path/one.yaml": `environments:
sandbox:
values:
- example:
enabled: true
`,
"/path/two.yaml": `environments:
sandbox: {}
`,
"/path/helmfile.yaml": `bases:
- one.yaml
- two.yaml
---
repositories:
- name: examples
url: https://helm.github.io/examples
releases:
- name: example
chart: examples/hello-world
`,
},
mainFile: "/path/helmfile.yaml",
environment: "sandbox",
checkValues: func(t *testing.T, state *HelmState) {
// Check that the environment has the values from the first base
envSpec, ok := state.Environments["sandbox"]
require.True(t, ok, "sandbox environment should exist")
require.NotNil(t, envSpec.Values, "environment values should not be nil")
require.Greater(t, len(envSpec.Values), 0, "environment should have values from first base")
// Check that RenderedValues has the example.enabled value
require.NotNil(t, state.RenderedValues, "rendered values should not be nil")
exampleVal, ok := state.RenderedValues["example"]
require.True(t, ok, "example key should exist in rendered values")
exampleMap, ok := exampleVal.(map[string]any)
require.True(t, ok, "example should be a map")
enabled, ok := exampleMap["enabled"]
require.True(t, ok, "enabled key should exist")
require.Equal(t, true, enabled, "enabled should be true")
},
},
{
name: "environment values should merge when second base adds values",
files: map[string]string{
"/path/one.yaml": `environments:
sandbox:
values:
- example:
enabled: true
`,
"/path/two.yaml": `environments:
sandbox:
values:
- another:
setting: value
`,
"/path/helmfile.yaml": `bases:
- one.yaml
- two.yaml
---
repositories:
- name: examples
url: https://helm.github.io/examples
releases:
- name: example
chart: examples/hello-world
`,
},
mainFile: "/path/helmfile.yaml",
environment: "sandbox",
checkValues: func(t *testing.T, state *HelmState) {
// Check that both values from both bases are present
require.NotNil(t, state.RenderedValues, "rendered values should not be nil")
exampleVal, ok := state.RenderedValues["example"]
require.True(t, ok, "example key should exist in rendered values")
exampleMap, ok := exampleVal.(map[string]any)
require.True(t, ok, "example should be a map")
enabled, ok := exampleMap["enabled"]
require.True(t, ok, "enabled key should exist")
require.Equal(t, true, enabled, "enabled should be true")
anotherVal, ok := state.RenderedValues["another"]
require.True(t, ok, "another key should exist in rendered values")
anotherMap, ok := anotherVal.(map[string]any)
require.True(t, ok, "another should be a map")
setting, ok := anotherMap["setting"]
require.True(t, ok, "setting key should exist")
require.Equal(t, "value", setting, "setting should be 'value'")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creator := &StateCreator{
logger: logger,
fs: &filesystem.FileSystem{
ReadFile: func(filename string) ([]byte, error) {
content, ok := tt.files[filename]
if !ok {
return nil, fmt.Errorf("file not found: %s", filename)
}
return []byte(content), nil
},
},
valsRuntime: valsRuntime,
Strict: true,
}
creator.LoadFile = func(inheritedEnv, overrodeEnv *environment.Environment, baseDir, file string, evaluateBases bool) (*HelmState, error) {
path := filepath.Join(baseDir, file)
content, ok := tt.files[path]
if !ok {
return nil, fmt.Errorf("file not found: %s", path)
}
// When loading base files, we don't apply defaults - they should only
// be applied to the main file after all parts/bases are merged.
// So applyDefaults = evaluateBases (true for main, false for bases).
applyDefaults := evaluateBases
return creator.ParseAndLoad([]byte(content), filepath.Dir(path), path, tt.environment, true, evaluateBases, applyDefaults, inheritedEnv, overrodeEnv)
}
yamlContent, ok := tt.files[tt.mainFile]
if !ok {
t.Fatalf("no file named %q registered", tt.mainFile)
}
state, err := creator.ParseAndLoad([]byte(yamlContent), filepath.Dir(tt.mainFile), tt.mainFile, tt.environment, true, true, true, nil, nil)
if tt.expectedError {
require.Error(t, err, "expected an error but got none")
return
}
require.NoError(t, err, "unexpected error: %v", err)
if tt.checkValues != nil {
tt.checkValues(t, state)
}
})
}
}
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")
}