package app import ( "bytes" "fmt" "reflect" "strings" "testing" "github.com/helmfile/vals" ffs "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/testhelper" "github.com/helmfile/helmfile/pkg/testutil" ) // TestSequentialHelmfilesNoChdirCalled verifies that sequential processing // does NOT call os.Chdir(), which was the root cause of issue #2409. func TestSequentialHelmfilesNoChdirCalled(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/01-first.yaml": ` releases: - name: first-release chart: stable/chart-a namespace: default `, "/path/to/helmfile.d/02-second.yaml": ` releases: - name: second-release chart: stable/chart-b namespace: default `, } testFs := testhelper.NewTestFs(files) valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := &App{ OverrideHelmBinary: DefaultHelmBinary, OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: newAppTestLogger(), valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, } app = injectFs(app, testFs) expectNoCallsToHelm(app) err = app.ForEachState( Noop, false, SetFilter(true), ) if err != nil { t.Fatalf("unexpected error: %v", err) } if testFs.ChdirCalls != 0 { t.Errorf("expected 0 Chdir calls in sequential mode, got %d", testFs.ChdirCalls) } } // TestSequentialHelmfilesProcessesAllFiles verifies all files in helmfile.d // are processed when using sequential mode. func TestSequentialHelmfilesProcessesAllFiles(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/001-app.yaml": ` releases: - name: app1 chart: stable/app1 namespace: default `, "/path/to/helmfile.d/002-db.yaml": ` releases: - name: db1 chart: stable/postgresql namespace: default `, "/path/to/helmfile.d/003-cache.yaml": ` releases: - name: cache1 chart: stable/redis namespace: default `, } var buffer bytes.Buffer syncWriter := testhelper.NewSyncWriter(&buffer) logger := helmexec.NewLogger(syncWriter, "debug") valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: logger, valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, }, files) expectNoCallsToHelm(app) out, err := testutil.CaptureStdout(func() { err := app.ListReleases(configImpl{ skipCharts: false, output: "json", }) if err != nil { t.Logf("ListReleases error: %v", err) } }) if err != nil { t.Fatalf("unexpected error capturing output: %v", err) } if !bytes.Contains([]byte(out), []byte("app1")) { t.Errorf("app1 release not found in output:\n%s", out) } if !bytes.Contains([]byte(out), []byte("db1")) { t.Errorf("db1 release not found in output:\n%s", out) } if !bytes.Contains([]byte(out), []byte("cache1")) { t.Errorf("cache1 release not found in output:\n%s", out) } } // TestSequentialHelmfilesAlphabeticalOrder verifies sequential mode processes // files in alphabetical order. func TestSequentialHelmfilesAlphabeticalOrder(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/z-last.yaml": ` releases: - name: zulu-release chart: stable/chart-z namespace: ns-z `, "/path/to/helmfile.d/a-first.yaml": ` releases: - name: alpha-release chart: stable/chart-a namespace: ns-a `, "/path/to/helmfile.d/m-middle.yaml": ` releases: - name: mike-release chart: stable/chart-m namespace: ns-m `, } valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: newAppTestLogger(), valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, }, files) expectNoCallsToHelm(app) var actualOrder []string noop := func(run *Run) (bool, []error) { actualOrder = append(actualOrder, run.state.FilePath) return false, []error{} } err = app.ForEachState( noop, false, SetFilter(true), ) if err != nil { t.Fatalf("unexpected error: %v", err) } expectedOrder := []string{"/path/to/helmfile.d/a-first.yaml", "/path/to/helmfile.d/m-middle.yaml", "/path/to/helmfile.d/z-last.yaml"} if !reflect.DeepEqual(actualOrder, expectedOrder) { t.Errorf("unexpected order of processed state files: expected=%v, actual=%v", expectedOrder, actualOrder) } } // TestSequentialHelmfilesMatchesParallelResults verifies that sequential and // parallel modes produce the same set of releases. func TestSequentialHelmfilesMatchesParallelResults(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/01-app.yaml": ` releases: - name: app-release chart: stable/app namespace: default `, "/path/to/helmfile.d/02-db.yaml": ` releases: - name: db-release chart: stable/postgresql namespace: default `, } valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } // Run in parallel mode (default) parallelOut, err := testutil.CaptureStdout(func() { var buffer bytes.Buffer syncWriter := testhelper.NewSyncWriter(&buffer) logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: logger, valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: false, }, files) expectNoCallsToHelm(app) if err := app.ListReleases(configImpl{skipCharts: false, output: "json"}); err != nil { t.Logf("parallel ListReleases error: %v", err) } }) if err != nil { t.Fatalf("unexpected error capturing parallel output: %v", err) } // Run in sequential mode sequentialOut, err := testutil.CaptureStdout(func() { var buffer bytes.Buffer syncWriter := testhelper.NewSyncWriter(&buffer) logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: logger, valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, }, files) expectNoCallsToHelm(app) if err := app.ListReleases(configImpl{skipCharts: false, output: "json"}); err != nil { t.Logf("sequential ListReleases error: %v", err) } }) if err != nil { t.Fatalf("unexpected error capturing sequential output: %v", err) } // Both modes should contain the same releases for _, name := range []string{"app-release", "db-release"} { if !bytes.Contains([]byte(parallelOut), []byte(name)) { t.Errorf("parallel output missing release %q:\n%s", name, parallelOut) } if !bytes.Contains([]byte(sequentialOut), []byte(name)) { t.Errorf("sequential output missing release %q:\n%s", name, sequentialOut) } } } // TestSequentialHelmfilesWithUndefinedEnv verifies that files with undefined // environments are skipped gracefully in sequential mode. func TestSequentialHelmfilesWithUndefinedEnv(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/01-has-prod.yaml": ` environments: prod: {} --- releases: - name: prod-release chart: stable/prod namespace: default `, "/path/to/helmfile.d/02-no-prod.yaml": ` environments: staging: {} --- releases: - name: staging-release chart: stable/staging namespace: default `, } valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "prod", Logger: newAppTestLogger(), valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, }, files) expectNoCallsToHelm(app) out, err := testutil.CaptureStdout(func() { err := app.ListReleases(configImpl{ skipCharts: false, output: "json", }) if err != nil { t.Logf("ListReleases error: %v", err) } }) if err != nil { t.Fatalf("unexpected error capturing output: %v", err) } // The prod-release should be present if !bytes.Contains([]byte(out), []byte("prod-release")) { t.Errorf("prod-release not found in output:\n%s", out) } // The staging-release should NOT be present (env "prod" not defined in that file) if bytes.Contains([]byte(out), []byte("staging-release")) { t.Errorf("staging-release should have been skipped but was found in output:\n%s", out) } } // TestSequentialHelmfilesConvergeErrorPropagated verifies that errors returned // from the converge function are properly propagated in sequential mode. func TestSequentialHelmfilesConvergeErrorPropagated(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/01-first.yaml": ` releases: - name: first-release chart: stable/chart-a namespace: default `, "/path/to/helmfile.d/02-second.yaml": ` releases: - name: second-release chart: stable/chart-b namespace: default `, } valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, fs: ffs.DefaultFileSystem(), OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: newAppTestLogger(), valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, }, files) expectNoCallsToHelm(app) convergeErr := fmt.Errorf("simulated converge failure") failingConverge := func(_ *Run) (bool, []error) { return false, []error{convergeErr} } err = app.ForEachState( failingConverge, false, SetFilter(true), ) if err == nil { t.Fatal("expected error from ForEachState, got nil") } if !bytes.Contains([]byte(err.Error()), []byte("simulated converge failure")) { t.Errorf("expected error to contain 'simulated converge failure', got: %v", err) } } // TestSequentialHelmfilesValuesFileResolution verifies that relative values file // paths (e.g., ../config/myapp/values.yaml) are resolved correctly in sequential // mode. This is a regression test for issue #2424 where a relative baseDir caused // values paths to be resolved incorrectly. func TestSequentialHelmfilesValuesFileResolution(t *testing.T) { files := map[string]string{ "/path/to/helmfile.d/01-app.yaml": ` releases: - name: myapp chart: stable/myapp namespace: default values: - ../config/myapp/values.yaml `, "/path/to/helmfile.d/02-other.yaml": ` releases: - name: other chart: stable/other namespace: default `, "/path/to/config/myapp/values.yaml": ` replicaCount: 3 `, } testFs := testhelper.NewTestFs(files) valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { t.Fatalf("unexpected error creating vals runtime: %v", err) } app := &App{ OverrideHelmBinary: DefaultHelmBinary, OverrideKubeContext: "default", DisableKubeVersionAutoDetection: true, Env: "default", Logger: newAppTestLogger(), valsRuntime: valsRuntime, FileOrDir: "/path/to/helmfile.d", SequentialHelmfiles: true, } app = injectFs(app, testFs) expectNoCallsToHelm(app) var stateFilePaths []string captureState := func(run *Run) (bool, []error) { stateFilePaths = append(stateFilePaths, run.state.FilePath) return false, []error{} } err = app.ForEachState( captureState, false, SetFilter(true), ) if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify that state FilePaths are absolute (which means basePath is also // absolute, ensuring relative values paths like ../config/myapp/values.yaml // resolve correctly). Before the fix, a relative baseDir was passed causing // values path resolution to fail. for _, fp := range stateFilePaths { if !strings.HasPrefix(fp, "/") { t.Errorf("expected absolute FilePath, got relative: %s", fp) } } // Verify the values file was found and can be read (proving correct path resolution). // With an absolute basePath, ../config/myapp/values.yaml resolves to // /path/to/config/myapp/values.yaml which exists in our test filesystem. valuesPath := "/path/to/config/myapp/values.yaml" content, err := testFs.ToFileSystem().ReadFile(valuesPath) if err != nil { t.Fatalf("values file should be readable at %s: %v", valuesPath, err) } if !strings.Contains(string(content), "replicaCount") { t.Errorf("values file content mismatch: %s", string(content)) } }