From 0129681222636caa2bae4345ed8066c5d9511f5c Mon Sep 17 00:00:00 2001 From: Aditya Menon Date: Mon, 16 Feb 2026 07:15:10 +0530 Subject: [PATCH] feat: add `helmfile unittest` command for helm-unittest integration (#2400) Adds a new `helmfile unittest` command that integrates the helm-unittest plugin, allowing users to define unit test paths per release and run them via helmfile. Closes #2376 Signed-off-by: Aditya Menon --- cmd/root.go | 1 + cmd/unittest.go | 46 ++++ docs/index.md | 64 +++++ pkg/app/app.go | 80 ++++++ pkg/app/app_test.go | 13 + pkg/app/app_unittest_test.go | 256 ++++++++++++++++++ pkg/app/config.go | 17 ++ pkg/app/init.go | 20 +- .../testdata/app_unittest_test/bad_selector | 6 + .../testdata/app_unittest_test/include-needs | 17 ++ .../release_without_unittests_is_skipped | 11 + pkg/app/testdata/app_unittest_test/skip-needs | 13 + .../unittest_all_releases_with_unittests | 17 ++ .../app_unittest_test/with_dedicated_flags | 11 + .../with_dedicated_flags_helm4 | 13 + pkg/config/unittest.go | 101 +++++++ pkg/exectest/helm.go | 8 + pkg/helmexec/exec.go | 26 ++ pkg/helmexec/helmexec.go | 1 + pkg/state/state.go | 155 +++++++++++ pkg/state/temp_test.go | 12 +- pkg/testutil/mocks.go | 4 + test/integration/run.sh | 1 + test/integration/test-cases/unittest.sh | 25 ++ .../unittest/input/charts/test-app/Chart.yaml | 5 + .../charts/test-app/templates/deployment.yaml | 21 ++ .../charts/test-app/templates/service.yaml | 11 + .../test-app/tests/deployment_test.yaml | 20 ++ .../input/charts/test-app/values.yaml | 9 + .../test-cases/unittest/input/helmfile.yaml | 10 + 30 files changed, 981 insertions(+), 13 deletions(-) create mode 100644 cmd/unittest.go create mode 100644 pkg/app/app_unittest_test.go create mode 100644 pkg/app/testdata/app_unittest_test/bad_selector create mode 100644 pkg/app/testdata/app_unittest_test/include-needs create mode 100644 pkg/app/testdata/app_unittest_test/release_without_unittests_is_skipped create mode 100644 pkg/app/testdata/app_unittest_test/skip-needs create mode 100644 pkg/app/testdata/app_unittest_test/unittest_all_releases_with_unittests create mode 100644 pkg/app/testdata/app_unittest_test/with_dedicated_flags create mode 100644 pkg/app/testdata/app_unittest_test/with_dedicated_flags_helm4 create mode 100644 pkg/config/unittest.go create mode 100644 test/integration/test-cases/unittest.sh create mode 100644 test/integration/test-cases/unittest/input/charts/test-app/Chart.yaml create mode 100644 test/integration/test-cases/unittest/input/charts/test-app/templates/deployment.yaml create mode 100644 test/integration/test-cases/unittest/input/charts/test-app/templates/service.yaml create mode 100644 test/integration/test-cases/unittest/input/charts/test-app/tests/deployment_test.yaml create mode 100644 test/integration/test-cases/unittest/input/charts/test-app/values.yaml create mode 100644 test/integration/test-cases/unittest/input/helmfile.yaml diff --git a/cmd/root.go b/cmd/root.go index d702718a..33253819 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -99,6 +99,7 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { NewLintCmd(globalImpl), NewWriteValuesCmd(globalImpl), NewTestCmd(globalImpl), + NewUnittestCmd(globalImpl), NewTemplateCmd(globalImpl), NewSyncCmd(globalImpl), NewDiffCmd(globalImpl), diff --git a/cmd/unittest.go b/cmd/unittest.go new file mode 100644 index 00000000..ebead91d --- /dev/null +++ b/cmd/unittest.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/helmfile/helmfile/pkg/app" + "github.com/helmfile/helmfile/pkg/config" +) + +// NewUnittestCmd returns unittest subcmd +func NewUnittestCmd(globalCfg *config.GlobalImpl) *cobra.Command { + unittestOptions := config.NewUnittestOptions() + + cmd := &cobra.Command{ + Use: "unittest", + Short: "Unit test charts from state file using helm-unittest plugin", + RunE: func(cmd *cobra.Command, args []string) error { + unittestImpl := config.NewUnittestImpl(globalCfg, unittestOptions) + err := config.NewCLIConfigImpl(unittestImpl.GlobalImpl) + if err != nil { + return err + } + + if err := unittestImpl.ValidateConfig(); err != nil { + return err + } + + a := app.New(unittestImpl) + return toCLIError(unittestImpl.GlobalImpl, a.Unittest(unittestImpl)) + }, + } + + f := cmd.Flags() + f.IntVar(&unittestOptions.Concurrency, "concurrency", 0, "maximum number of concurrent helm processes to run, 0 is unlimited") + f.StringVar(&globalCfg.GlobalOptions.Args, "args", "", "pass args to helm exec") + f.StringArrayVar(&unittestOptions.Set, "set", nil, "additional values to be merged into the helm command --set flag") + f.StringArrayVar(&unittestOptions.Values, "values", nil, "additional value files to be merged into the helm command --values flag") + f.BoolVar(&unittestOptions.FailFast, "fail-fast", false, "fail fast on the first test failure") + f.BoolVar(&unittestOptions.Color, "color", false, "enforce colored output even when stdout is not a tty (ignored on Helm 4 due to flag parsing issues)") + f.BoolVar(&unittestOptions.DebugPlugin, "debug-plugin", false, "enable verbose output from the helm-unittest plugin") + f.BoolVar(&unittestOptions.SkipNeeds, "skip-needs", true, `do not automatically include releases from the target release's "needs" when --selector/-l flag is provided. Does nothing when --selector/-l flag is not provided. Defaults to true when --include-needs or --include-transitive-needs is not provided`) + f.BoolVar(&unittestOptions.IncludeNeeds, "include-needs", false, `automatically include releases from the target release's "needs" when --selector/-l flag is provided. Does nothing when --selector/-l flag is not provided`) + f.BoolVar(&unittestOptions.IncludeTransitiveNeeds, "include-transitive-needs", false, `like --include-needs, but also includes transitive needs (needs of needs). Does nothing when --selector/-l flag is not provided. Overrides exclusions of other selectors and conditions.`) + + return cmd +} diff --git a/docs/index.md b/docs/index.md index 8e60f201..bfd34447 100644 --- a/docs/index.md +++ b/docs/index.md @@ -380,6 +380,11 @@ releases: - "version" # syncReleaseLabels is a list of labels to be added to the release when syncing. syncReleaseLabels: false + # unitTests is a list of test file or directory paths for helm-unittest integration. + # When specified, `helmfile unittest` will run `helm unittest` with the merged values and these test paths. + # Requires the helm-unittest plugin: https://github.com/helm-unittest/helm-unittest + unitTests: + - tests/vault # Local chart example @@ -619,6 +624,7 @@ Available Commands: sync Sync releases defined in state file template Template releases defined in state file test Test charts from state file (helm test) + unittest Unit test charts from state file using helm-unittest plugin version Print the CLI version write-values Write values files for releases. Similar to `helmfile template`, write values files instead of manifests. @@ -787,6 +793,64 @@ Use `--cleanup` to delete pods upon completion. The `helmfile lint` sub-command runs a `helm lint` across all of the charts/releases defined in the manifest. Non local charts will be fetched into a temporary folder which will be deleted once the task is completed. +### unittest + +The `helmfile unittest` sub-command runs `helm unittest` (from the [helm-unittest plugin](https://github.com/helm-unittest/helm-unittest)) on releases that have `unitTests` defined. It automatically generates the final merged values files for each release and passes them to `helm unittest`. + +This requires the `helm-unittest` plugin to be installed. You can install it with: + +```bash +helm plugin install https://github.com/helm-unittest/helm-unittest +``` + +Releases without `unitTests` defined are skipped. Non-local charts will be fetched into a temporary folder which will be deleted once the task is completed. + +Example helmfile configuration: + +```yaml +releases: + - name: my-app + chart: ./charts/my-app + values: + - values.yaml + unitTests: + - tests +``` + +The `unitTests` paths are relative to the chart directory and follow helm-unittest conventions. +If a path does not contain glob characters, it is treated as a directory and `/*_test.yaml` is appended automatically. +You can also specify explicit glob patterns (e.g., `tests/**/*_test.yaml`). + +Running `helmfile unittest` will: + +1. Merge all values files defined for the release +2. Run `helm unittest ./charts/my-app --values --file tests/*_test.yaml` + +You can pass additional flags: + +```bash +# Run with additional values +helmfile unittest --values extra-values.yaml + +# Run with --set overrides +helmfile unittest --set key=value + +# Target specific releases +helmfile unittest --selector name=my-app + +# Fail fast on first test failure +helmfile unittest --fail-fast + +# Enable colored output (Helm 3 only; ignored on Helm 4 due to flag parsing issues) +helmfile unittest --color + +# Enable verbose plugin output +helmfile unittest --debug-plugin + +# Pass extra arguments to helm unittest +helmfile unittest --args "--strict" +``` + ### fetch The `helmfile fetch` sub-command downloads or copies local charts to a local directory for debug purpose. The local directory diff --git a/pkg/app/app.go b/pkg/app/app.go index 798e46a1..0bc85958 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -344,6 +344,47 @@ func (a *App) Lint(c LintConfigProvider) error { return nil } +func (a *App) Unittest(c UnittestConfigProvider) error { + var deferredUnittestErrors []error + + err := a.ForEachState(func(run *Run) (ok bool, errs []error) { + var unittestErrs []error + + // helm unittest needs local charts, so force download + prepErr := run.withPreparedCharts("unittest", state.ChartPrepareOptions{ + ForceDownload: true, + SkipRepos: c.SkipRefresh() || c.SkipDeps(), + SkipRefresh: c.SkipRefresh(), + SkipDeps: c.SkipDeps(), + SkipCleanup: c.SkipCleanup(), + Concurrency: c.Concurrency(), + IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), + }, func() { + ok, unittestErrs, errs = a.unittest(run, c) + }) + + if prepErr != nil { + errs = append(errs, prepErr) + } + + if len(unittestErrs) > 0 { + deferredUnittestErrors = append(deferredUnittestErrors, unittestErrs...) + } + + return + }, c.IncludeTransitiveNeeds()) + + if err != nil { + return err + } + + if len(deferredUnittestErrors) > 0 { + return &MultiError{Errors: deferredUnittestErrors} + } + + return nil +} + func (a *App) Fetch(c FetchConfigProvider) error { return a.ForEachState(func(run *Run) (ok bool, errs []error) { prepErr := run.withPreparedCharts("pull", state.ChartPrepareOptions{ @@ -1909,6 +1950,45 @@ func (a *App) lint(r *Run, c LintConfigProvider) (bool, []error, []error) { return ok, deferredLintErrs, errs } +func (a *App) unittest(r *Run, c UnittestConfigProvider) (bool, []error, []error) { + var deferredUnittestErrs []error + + ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error { + helm := r.helm + + args := GetArgs(c.Args(), st) + + // Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `unittest` + helm.SetExtraArgs() + + if len(args) > 0 { + helm.SetExtraArgs(args...) + } + + opts := &state.UnittestOpts{ + Set: c.Set(), + SkipCleanup: c.SkipCleanup(), + FailFast: c.FailFast(), + Color: c.Color(), + DebugPlugin: c.DebugPlugin(), + } + unittestErrs := st.UnittestReleases(helm, c.Values(), args, c.Concurrency(), opts) + if len(unittestErrs) == 1 { + if err, ok := unittestErrs[0].(helmexec.ExitError); ok { + if err.Code > 0 { + deferredUnittestErrs = append(deferredUnittestErrs, err) + + return nil + } + } + } + + return unittestErrs + }) + + return ok, deferredUnittestErrs, errs +} + func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) { st := r.state helm := r.helm diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index fce32b84..7ec1999c 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2354,6 +2354,8 @@ type applyConfig struct { suppressDiff bool noColor bool color bool + failFast bool + debugPlugin bool context int diffOutput string concurrency int @@ -2476,6 +2478,14 @@ func (a applyConfig) Color() bool { return a.color } +func (a applyConfig) FailFast() bool { + return a.failFast +} + +func (a applyConfig) DebugPlugin() bool { + return a.debugPlugin +} + func (a applyConfig) NoColor() bool { return a.noColor } @@ -2710,6 +2720,9 @@ func (helm *mockHelmExec) Fetch(chart string, flags ...string) error { func (helm *mockHelmExec) Lint(name, chart string, flags ...string) error { return nil } +func (helm *mockHelmExec) Unittest(name, chart string, flags ...string) error { + return nil +} func (helm *mockHelmExec) IsHelm3() bool { return !exectest.IsHelm4Enabled() } diff --git a/pkg/app/app_unittest_test.go b/pkg/app/app_unittest_test.go new file mode 100644 index 00000000..f5977048 --- /dev/null +++ b/pkg/app/app_unittest_test.go @@ -0,0 +1,256 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/helmfile/vals" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/helmfile/helmfile/pkg/exectest" + ffs "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/helmexec" +) + +func TestUnittest(t *testing.T) { + type fields struct { + skipNeeds bool + includeNeeds bool + includeTransitiveNeeds bool + failFast bool + color bool + debugPlugin bool + } + + type testcase struct { + fields fields + ns string + error string + selectors []string + unittested []exectest.Release + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + wantUnittests := tc.unittested + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + Helm4: exectest.IsHelm4Enabled(), + Helm3: !exectest.IsHelm4Enabled(), + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + bs := runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Errorf("unexpected error creating vals runtime: %v", err) + } + + files := map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: logging + chart: incubator/raw + namespace: kube-system + unitTests: + - tests/logging + +- name: kubernetes-external-secrets + chart: incubator/raw + namespace: kube-system + needs: + - kube-system/logging + unitTests: + - tests/secrets + +- name: external-secrets + chart: incubator/raw + namespace: default + labels: + app: test + needs: + - kube-system/kubernetes-external-secrets + unitTests: + - tests/external + +- name: my-release + chart: incubator/raw + namespace: default + labels: + app: test + needs: + - default/external-secrets + unitTests: + - tests/myrelease + +- name: no-tests + chart: incubator/raw + namespace: default +`, + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, files) + + if tc.ns != "" { + app.Namespace = tc.ns + } + + if tc.selectors != nil { + app.Selectors = tc.selectors + } + + unittestErr := app.Unittest(applyConfig{ + concurrency: 1, + logger: logger, + skipNeeds: tc.fields.skipNeeds, + includeNeeds: tc.fields.includeNeeds, + includeTransitiveNeeds: tc.fields.includeTransitiveNeeds, + failFast: tc.fields.failFast, + color: tc.fields.color, + debugPlugin: tc.fields.debugPlugin, + }) + + var gotErr string + if unittestErr != nil { + gotErr = unittestErr.Error() + } + + if d := cmp.Diff(tc.error, gotErr); d != "" { + t.Fatalf("unexpected error: want (-), got (+): %s", d) + } + + require.Equal(t, wantUnittests, helm.Unittested) + }) + + testNameComponents := strings.Split(t.Name(), "/") + testBaseName := strings.ToLower( + strings.ReplaceAll( + testNameComponents[len(testNameComponents)-1], + " ", + "_", + ), + ) + wantLogFileDir := filepath.Join("testdata", "app_unittest_test") + snapshotName := testBaseName + if exectest.IsHelm4Enabled() { + if _, err := os.Stat(filepath.Join(wantLogFileDir, testBaseName+"_helm4")); err == nil { + snapshotName = testBaseName + "_helm4" + } + } + wantLogFile := filepath.Join(wantLogFileDir, snapshotName) + wantLogData, err := os.ReadFile(wantLogFile) + updateLogFile := err != nil + wantLog := string(wantLogData) + gotLog := bs.String() + if updateLogFile { + if err := os.MkdirAll(wantLogFileDir, 0755); err != nil { + t.Fatalf("unable to create directory %q: %v", wantLogFileDir, err) + } + if err := os.WriteFile(wantLogFile, bs.Bytes(), 0644); err != nil { + t.Fatalf("unable to update unittest log snapshot: %v", err) + } + } + + assert.Equal(t, wantLog, gotLog) + } + + t.Run("unittest all releases with unitTests", func(t *testing.T) { + check(t, testcase{ + unittested: []exectest.Release{ + {Name: "logging", Flags: []string{"--namespace", "kube-system", "--file", "tests/logging/*_test.yaml"}}, + {Name: "kubernetes-external-secrets", Flags: []string{"--namespace", "kube-system", "--file", "tests/secrets/*_test.yaml"}}, + {Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}}, + {Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}}, + }, + }) + }) + + t.Run("with dedicated flags", func(t *testing.T) { + // --color is skipped on Helm 4 due to flag parsing issues + expectedFlags := []string{"--namespace", "kube-system", "--failfast"} + if !exectest.IsHelm4Enabled() { + expectedFlags = append(expectedFlags, "--color") + } + expectedFlags = append(expectedFlags, "--debugPlugin", "--file", "tests/logging/*_test.yaml") + + check(t, testcase{ + fields: fields{ + failFast: true, + color: true, + debugPlugin: true, + }, + selectors: []string{"name=logging"}, + unittested: []exectest.Release{ + {Name: "logging", Flags: expectedFlags}, + }, + }) + }) + + t.Run("skip-needs", func(t *testing.T) { + check(t, testcase{ + fields: fields{ + skipNeeds: true, + }, + selectors: []string{"app=test"}, + unittested: []exectest.Release{ + {Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}}, + {Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}}, + }, + }) + }) + + t.Run("include-needs", func(t *testing.T) { + check(t, testcase{ + fields: fields{ + skipNeeds: false, + includeNeeds: true, + }, + selectors: []string{"app=test"}, + unittested: []exectest.Release{ + {Name: "logging", Flags: []string{"--namespace", "kube-system", "--file", "tests/logging/*_test.yaml"}}, + {Name: "kubernetes-external-secrets", Flags: []string{"--namespace", "kube-system", "--file", "tests/secrets/*_test.yaml"}}, + {Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}}, + {Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}}, + }, + }) + }) + + t.Run("release without unitTests is skipped", func(t *testing.T) { + check(t, testcase{ + selectors: []string{"name=no-tests"}, + unittested: nil, + }) + }) + + t.Run("bad selector", func(t *testing.T) { + check(t, testcase{ + selectors: []string{"app=test_non_existent"}, + unittested: nil, + error: "err: no releases found that matches specified selector(app=test_non_existent) and environment(default), in any helmfile", + }) + }) +} diff --git a/pkg/app/config.go b/pkg/app/config.go index ea22c3d5..8465f1dd 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -207,6 +207,23 @@ type LintConfigProvider interface { concurrencyConfig } +type UnittestConfigProvider interface { + Args() string + + Values() []string + Set() []string + FailFast() bool + Color() bool + DebugPlugin() bool + SkipDeps() bool + SkipRefresh() bool + SkipCleanup() bool + + DAGConfig + + concurrencyConfig +} + type FetchConfigProvider interface { SkipDeps() bool SkipRefresh() bool diff --git a/pkg/app/init.go b/pkg/app/init.go index 2b47e765..ffb1680f 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -18,13 +18,14 @@ import ( ) const ( - HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x) - HelmDiffRecommendedVersion = "v3.14.1" - HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4 - HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture) - HelmGitRecommendedVersion = "v1.3.0" - HelmS3RecommendedVersion = "v0.16.3" - HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility + HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x) + HelmDiffRecommendedVersion = "v3.14.1" + HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4 + HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture) + HelmGitRecommendedVersion = "v1.3.0" + HelmS3RecommendedVersion = "v0.16.3" + HelmUnittestRecommendedVersion = "v1.0.3" + HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility ) var ( @@ -54,6 +55,11 @@ var ( version: HelmGitRecommendedVersion, repo: "https://github.com/aslafy-z/helm-git.git", }, + { + name: "unittest", + version: HelmUnittestRecommendedVersion, + repo: "https://github.com/helm-unittest/helm-unittest", + }, } ) diff --git a/pkg/app/testdata/app_unittest_test/bad_selector b/pkg/app/testdata/app_unittest_test/bad_selector new file mode 100644 index 00000000..56247803 --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/bad_selector @@ -0,0 +1,6 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +0 release(s) matching app=test_non_existent found in helmfile.yaml + +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/include-needs b/pkg/app/testdata/app_unittest_test/include-needs new file mode 100644 index 00000000..8a7e8f63 --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/include-needs @@ -0,0 +1,17 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +2 release(s) matching app=test found in helmfile.yaml + +processing 4 groups of releases in this order: +GROUP RELEASES +1 default/kube-system/logging +2 default/kube-system/kubernetes-external-secrets +3 default/default/external-secrets +4 default/default/my-release + +processing releases in group 1/4: default/kube-system/logging +processing releases in group 2/4: default/kube-system/kubernetes-external-secrets +processing releases in group 3/4: default/default/external-secrets +processing releases in group 4/4: default/default/my-release +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/release_without_unittests_is_skipped b/pkg/app/testdata/app_unittest_test/release_without_unittests_is_skipped new file mode 100644 index 00000000..150c9b0a --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/release_without_unittests_is_skipped @@ -0,0 +1,11 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +1 release(s) matching name=no-tests found in helmfile.yaml + +processing 1 groups of releases in this order: +GROUP RELEASES +1 default/default/no-tests + +processing releases in group 1/1: default/default/no-tests +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/skip-needs b/pkg/app/testdata/app_unittest_test/skip-needs new file mode 100644 index 00000000..d127107d --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/skip-needs @@ -0,0 +1,13 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +2 release(s) matching app=test found in helmfile.yaml + +processing 2 groups of releases in this order: +GROUP RELEASES +1 default/default/external-secrets +2 default/default/my-release + +processing releases in group 1/2: default/default/external-secrets +processing releases in group 2/2: default/default/my-release +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/unittest_all_releases_with_unittests b/pkg/app/testdata/app_unittest_test/unittest_all_releases_with_unittests new file mode 100644 index 00000000..e6c3ffb8 --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/unittest_all_releases_with_unittests @@ -0,0 +1,17 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +5 release(s) found in helmfile.yaml + +processing 4 groups of releases in this order: +GROUP RELEASES +1 default/kube-system/logging, default/default/no-tests +2 default/kube-system/kubernetes-external-secrets +3 default/default/external-secrets +4 default/default/my-release + +processing releases in group 1/4: default/kube-system/logging, default/default/no-tests +processing releases in group 2/4: default/kube-system/kubernetes-external-secrets +processing releases in group 3/4: default/default/external-secrets +processing releases in group 4/4: default/default/my-release +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/with_dedicated_flags b/pkg/app/testdata/app_unittest_test/with_dedicated_flags new file mode 100644 index 00000000..6420827b --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/with_dedicated_flags @@ -0,0 +1,11 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +1 release(s) matching name=logging found in helmfile.yaml + +processing 1 groups of releases in this order: +GROUP RELEASES +1 default/kube-system/logging + +processing releases in group 1/1: default/kube-system/logging +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_unittest_test/with_dedicated_flags_helm4 b/pkg/app/testdata/app_unittest_test/with_dedicated_flags_helm4 new file mode 100644 index 00000000..9745fb35 --- /dev/null +++ b/pkg/app/testdata/app_unittest_test/with_dedicated_flags_helm4 @@ -0,0 +1,13 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[] map[]} +1 release(s) matching name=logging found in helmfile.yaml + +processing 1 groups of releases in this order: +GROUP RELEASES +1 default/kube-system/logging + +processing releases in group 1/1: default/kube-system/logging +warn: --color flag is not supported with Helm 4 due to flag parsing issues, ignoring + +changing working directory back to "/path/to" diff --git a/pkg/config/unittest.go b/pkg/config/unittest.go new file mode 100644 index 00000000..89d02375 --- /dev/null +++ b/pkg/config/unittest.go @@ -0,0 +1,101 @@ +package config + +// UnittestOptions is the options for the unittest command +type UnittestOptions struct { + // Concurrency is the maximum number of concurrent helm processes to run, 0 is unlimited + Concurrency int + // Set is the set flags to pass to helm unittest + Set []string + // Values is the values flags to pass to helm unittest + Values []string + // FailFast causes helm-unittest to quit immediately when a test fails + FailFast bool + // Color enforces colored output even when stdout is not a tty + Color bool + // DebugPlugin enables verbose output from the helm-unittest plugin + DebugPlugin bool + // SkipNeeds is the skip needs flag + SkipNeeds bool + // IncludeNeeds is the include needs flag + IncludeNeeds bool + // IncludeTransitiveNeeds is the include transitive needs flag + IncludeTransitiveNeeds bool +} + +// NewUnittestOptions creates a new UnittestOptions +func NewUnittestOptions() *UnittestOptions { + return &UnittestOptions{} +} + +// UnittestImpl is impl for UnittestOptions +type UnittestImpl struct { + *GlobalImpl + *UnittestOptions +} + +// NewUnittestImpl creates a new UnittestImpl +func NewUnittestImpl(g *GlobalImpl, u *UnittestOptions) *UnittestImpl { + return &UnittestImpl{ + GlobalImpl: g, + UnittestOptions: u, + } +} + +// Concurrency returns the concurrency +func (u *UnittestImpl) Concurrency() int { + return u.UnittestOptions.Concurrency +} + +// Set returns the Set +func (u *UnittestImpl) Set() []string { + return u.UnittestOptions.Set +} + +// Values returns the Values +func (u *UnittestImpl) Values() []string { + return u.UnittestOptions.Values +} + +// FailFast returns the fail fast flag +func (u *UnittestImpl) FailFast() bool { + return u.UnittestOptions.FailFast +} + +// Color returns the color flag +func (u *UnittestImpl) Color() bool { + return u.UnittestOptions.Color +} + +// DebugPlugin returns the debug plugin flag +func (u *UnittestImpl) DebugPlugin() bool { + return u.UnittestOptions.DebugPlugin +} + +// SkipCleanup returns the skip clean up +func (u *UnittestImpl) SkipCleanup() bool { + return false +} + +// IncludeNeeds returns the include needs +func (u *UnittestImpl) IncludeNeeds() bool { + return u.UnittestOptions.IncludeNeeds || u.IncludeTransitiveNeeds() +} + +// IncludeTransitiveNeeds returns the include transitive needs +func (u *UnittestImpl) IncludeTransitiveNeeds() bool { + return u.UnittestOptions.IncludeTransitiveNeeds +} + +// SkipNeeds returns the skip needs +func (u *UnittestImpl) SkipNeeds() bool { + if !u.IncludeNeeds() { + return u.UnittestOptions.SkipNeeds + } + + return false +} + +// EnforceNeedsAreInstalled returns false for unittest +func (u *UnittestImpl) EnforceNeedsAreInstalled() bool { + return false +} diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index 7ffea2d1..2531f6f1 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -37,6 +37,7 @@ type Helm struct { Releases []Release Deleted []Release Linted []Release + Unittested []Release Templated []Release Lists map[ListKey]string Diffs map[DiffKey]error @@ -210,6 +211,13 @@ func (helm *Helm) TestRelease(context helmexec.HelmContext, name string, flags . func (helm *Helm) Fetch(chart string, flags ...string) error { return nil } +func (helm *Helm) Unittest(name, chart string, flags ...string) error { + if strings.Contains(name, "error") { + return errors.New("error") + } + helm.Unittested = append(helm.Unittested, Release{Name: name, Flags: flags}) + return nil +} func (helm *Helm) Lint(name, chart string, flags ...string) error { if strings.Contains(name, "error") { return errors.New("error") diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 85507636..5c35d1aa 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -51,6 +51,8 @@ type execer struct { decryptedSecretMutex sync.Mutex decryptedSecrets map[string]*decryptedSecret writeTempFile func([]byte) (string, error) + unittestPluginOnce sync.Once + unittestPluginErr error } func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger { @@ -744,6 +746,30 @@ func (helm *execer) Lint(name, chart string, flags ...string) error { return err } +func (helm *execer) Unittest(name, chart string, flags ...string) error { + // Check if the helm-unittest plugin is installed (cached across invocations) + helm.unittestPluginOnce.Do(func() { + var pluginsDir string + if helm.IsHelm3() { + pluginsDir = cliv3.New().PluginsDirectory + } else { + pluginsDir = cliv4.New().PluginsDirectory + } + _, err := GetPluginVersion("unittest", pluginsDir) + if err != nil { + helm.unittestPluginErr = fmt.Errorf("helm-unittest plugin is required for `helmfile unittest`. Install it with: helm plugin install https://github.com/helm-unittest/helm-unittest: %w", err) + } + }) + if helm.unittestPluginErr != nil { + return helm.unittestPluginErr + } + + helm.logger.Infof("Unit testing release=%v, chart=%v", name, chart) + out, err := helm.exec(append([]string{"unittest", chart}, flags...), map[string]string{}, nil) + helm.write(nil, out) + return err +} + func (helm *execer) Fetch(chart string, flags ...string) error { helm.logger.Infof("Fetching %v", redactedURL(chart)) out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{}, nil) diff --git a/pkg/helmexec/helmexec.go b/pkg/helmexec/helmexec.go index f7291ac0..2b1457c5 100644 --- a/pkg/helmexec/helmexec.go +++ b/pkg/helmexec/helmexec.go @@ -28,6 +28,7 @@ type Interface interface { ChartPull(chart string, path string, flags ...string) error ChartExport(chart string, path string) error Lint(name, chart string, flags ...string) error + Unittest(name, chart string, flags ...string) error ReleaseStatus(context HelmContext, name string, flags ...string) error DeleteRelease(context HelmContext, name string, flags ...string) error TestRelease(context HelmContext, name string, flags ...string) error diff --git a/pkg/state/state.go b/pkg/state/state.go index 9c862dd6..1c1d61e8 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -335,6 +335,10 @@ type ReleaseSpec struct { // Hooks is a list of extension points paired with operations, that are executed in specific points of the lifecycle of releases defined in helmfile Hooks []event.Hook `yaml:"hooks,omitempty"` + // UnitTests is a list of test file or directory paths for helm-unittest integration. + // When specified, `helmfile unittest` will run `helm unittest` with the merged values and these test paths. + UnitTests []string `yaml:"unitTests,omitempty"` + // Name is the name of this release Name string `yaml:"name,omitempty"` Namespace string `yaml:"namespace,omitempty"` @@ -2180,6 +2184,157 @@ func (st *HelmState) LintReleases(helm helmexec.Interface, additionalValues []st return nil } +// UnittestOpts is the options for the unittest command +type UnittestOpts struct { + Set []string + SkipCleanup bool + FailFast bool + Color bool + DebugPlugin bool +} + +// UnittestOpt is a functional option for UnittestOpts +type UnittestOpt interface { + Apply(*UnittestOpts) +} + +// Apply implements UnittestOpt +func (o *UnittestOpts) Apply(opts *UnittestOpts) { + *opts = *o +} + +// UnittestReleases runs helm unittest on each release that has unitTests defined. +// The workerLimit parameter is currently unused but kept for API consistency with +// similar methods (e.g., LintReleases). +func (st *HelmState) UnittestReleases(helm helmexec.Interface, additionalValues []string, args []string, _ int, opt ...UnittestOpt) []error { + opts := &UnittestOpts{} + for _, o := range opt { + o.Apply(opts) + } + + helm.SetExtraArgs() + + errs := []error{} + + if len(args) > 0 { + helm.SetExtraArgs(args...) + } + + for i := range st.Releases { + release := st.Releases[i] + + if !release.Desired() { + continue + } + + if len(release.UnitTests) == 0 { + continue + } + + flags, files, err := st.flagsForLint(helm, &release, 0) + + if !opts.SkipCleanup { + defer st.removeFiles(files) + } + + releaseErr := false + if err != nil { + errs = append(errs, err) + releaseErr = true + } + for _, value := range additionalValues { + valfile, err := filepath.Abs(value) + if err != nil { + errs = append(errs, err) + releaseErr = true + break + } + + // Check for any stat error (not just IsNotExist) to also catch + // permission denied, I/O errors, etc. before passing to helm. + // This intentionally differs from LintReleases which only checks IsNotExist. + if _, err := os.Stat(valfile); err != nil { + errs = append(errs, err) + releaseErr = true + break + } + flags = append(flags, "--values", valfile) + } + + if releaseErr { + continue + } + + if opts.Set != nil { + for _, s := range opts.Set { + flags = append(flags, "--set", s) + } + } + + if opts.FailFast { + flags = append(flags, "--failfast") + } + + if opts.Color { + // In Helm 4, --color is parsed by Helm itself before reaching the plugin. + // See https://github.com/helmfile/helmfile/issues/2280 for details. + // Skip the flag with a warning since helm-unittest does not currently + // support an env var alternative for colored output. + if helm.IsHelm4() { + st.logger.Warnf("warn: --color flag is not supported with Helm 4 due to flag parsing issues, ignoring\n") + } else { + flags = append(flags, "--color") + } + } + + if opts.DebugPlugin { + flags = append(flags, "--debugPlugin") + } + + // Add unit test file/directory paths as glob patterns for --file flag. + // Paths are relative to the chart directory (matching helm-unittest conventions). + // If the path has no glob characters and does not look like a YAML file, + // treat it as a directory and append a glob suffix. + // Validate and add unit test file/directory paths. + // Reject absolute paths and paths that escape the chart directory via "..". + for _, testPath := range release.UnitTests { + cleanPath := filepath.Clean(testPath) + if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) { + errs = append(errs, fmt.Errorf("release %q: unitTests path %q must be a relative path within the chart directory", release.Name, testPath)) + releaseErr = true + break + } + if !strings.ContainsAny(testPath, "*?[") { + lowerPath := strings.ToLower(testPath) + if !strings.HasSuffix(lowerPath, ".yaml") && !strings.HasSuffix(lowerPath, ".yml") { + testPath = strings.TrimRight(testPath, "/") + "/*_test.yaml" + } + } + flags = append(flags, "--file", testPath) + } + + if len(errs) == 0 || !opts.FailFast { + if err := helm.Unittest(release.Name, release.ChartPathOrName(), flags...); err != nil { + errs = append(errs, err) + } + } + + if _, err := st.TriggerCleanupEvent(&release, "unittest"); err != nil { + st.logger.Warnf("warn: %v\n", err) + } + + if opts.FailFast && len(errs) > 0 { + break + } + } + + if len(errs) != 0 { + return errs + } + + return nil +} + type diffResult struct { release *ReleaseSpec err *ReleaseError diff --git a/pkg/state/temp_test.go b/pkg/state/temp_test.go index 811fd084..f8a35579 100644 --- a/pkg/state/temp_test.go +++ b/pkg/state/temp_test.go @@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) { run(testcase{ subject: "baseline", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, - want: "foo-values-66f7fd6f7b", + want: "foo-values-6884949b8b", }) run(testcase{ subject: "different bytes content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: []byte(`{"k":"v"}`), - want: "foo-values-6664979cd7", + want: "foo-values-58f57b794f", }) run(testcase{ subject: "different map content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: map[string]any{"k": "v"}, - want: "foo-values-78897dfd49", + want: "foo-values-6b6b884cc9", }) run(testcase{ subject: "different chart", release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"}, - want: "foo-values-64b7846cb7", + want: "foo-values-85494c4677", }) run(testcase{ subject: "different name", release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"}, - want: "bar-values-576cb7ddc7", + want: "bar-values-9d65c65f", }) run(testcase{ subject: "specific ns", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"}, - want: "myns-foo-values-6c567f54c", + want: "myns-foo-values-84b69bb989", }) for id, n := range ids { diff --git a/pkg/testutil/mocks.go b/pkg/testutil/mocks.go index e8a2942e..453a931e 100644 --- a/pkg/testutil/mocks.go +++ b/pkg/testutil/mocks.go @@ -142,6 +142,10 @@ func (helm *noCallHelmExec) Lint(name, chart string, flags ...string) error { helm.doPanic() return nil } +func (helm *noCallHelmExec) Unittest(name, chart string, flags ...string) error { + helm.doPanic() + return nil +} func (helm *noCallHelmExec) IsHelm3() bool { helm.doPanic() return false diff --git a/test/integration/run.sh b/test/integration/run.sh index 39e33a4c..ddcd58f6 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -129,6 +129,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes . ${dir}/test-cases/issue-2309-kube-context-template.sh . ${dir}/test-cases/issue-2355.sh . ${dir}/test-cases/issue-2103.sh +. ${dir}/test-cases/unittest.sh # ALL DONE ----------------------------------------------------------------------------------------------------------- diff --git a/test/integration/test-cases/unittest.sh b/test/integration/test-cases/unittest.sh new file mode 100644 index 00000000..e44cc2c1 --- /dev/null +++ b/test/integration/test-cases/unittest.sh @@ -0,0 +1,25 @@ +unittest_input_dir="${cases_dir}/unittest/input" +helmfile_real="$(pwd)/${helmfile}" +HELM_UNITTEST_VERSION="${HELM_UNITTEST_VERSION:-1.0.3}" + +# Ensure helm-unittest plugin is installed (matching plugin install pattern from run.sh) +info "Ensuring helm-unittest plugin v${HELM_UNITTEST_VERSION} is installed" +${helm} plugin ls | grep "^unittest" || ${helm} plugin install https://github.com/helm-unittest/helm-unittest --version v${HELM_UNITTEST_VERSION} ${PLUGIN_INSTALL_FLAGS} || fail "Could not install helm-unittest plugin" + +test_start "helmfile unittest - runs unit tests on releases with unitTests defined" +cd "${unittest_input_dir}" +${helmfile_real} unittest || fail "helmfile unittest should succeed" +cd - +test_pass "helmfile unittest - runs unit tests on releases with unitTests defined" + +test_start "helmfile unittest - with selector targeting release without unitTests" +cd "${unittest_input_dir}" +${helmfile_real} -l name=no-tests-app unittest || fail "helmfile unittest should succeed for releases without unitTests (skips them)" +cd - +test_pass "helmfile unittest - with selector targeting release without unitTests" + +test_start "helmfile unittest - with selector targeting release with unitTests" +cd "${unittest_input_dir}" +${helmfile_real} -l name=test-app unittest || fail "helmfile unittest should succeed for releases with unitTests" +cd - +test_pass "helmfile unittest - with selector targeting release with unitTests" diff --git a/test/integration/test-cases/unittest/input/charts/test-app/Chart.yaml b/test/integration/test-cases/unittest/input/charts/test-app/Chart.yaml new file mode 100644 index 00000000..c9dd197b --- /dev/null +++ b/test/integration/test-cases/unittest/input/charts/test-app/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: test-app +description: A test chart for helmfile unittest integration test +type: application +version: 0.1.0 diff --git a/test/integration/test-cases/unittest/input/charts/test-app/templates/deployment.yaml b/test/integration/test-cases/unittest/input/charts/test-app/templates/deployment.yaml new file mode 100644 index 00000000..9ad1b495 --- /dev/null +++ b/test/integration/test-cases/unittest/input/charts/test-app/templates/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} + labels: + app: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }} + template: + metadata: + labels: + app: {{ .Release.Name }} + spec: + containers: + - name: {{ .Release.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + ports: + - containerPort: {{ .Values.service.port }} diff --git a/test/integration/test-cases/unittest/input/charts/test-app/templates/service.yaml b/test/integration/test-cases/unittest/input/charts/test-app/templates/service.yaml new file mode 100644 index 00000000..84eb43ca --- /dev/null +++ b/test/integration/test-cases/unittest/input/charts/test-app/templates/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} + selector: + app: {{ .Release.Name }} diff --git a/test/integration/test-cases/unittest/input/charts/test-app/tests/deployment_test.yaml b/test/integration/test-cases/unittest/input/charts/test-app/tests/deployment_test.yaml new file mode 100644 index 00000000..300c65b4 --- /dev/null +++ b/test/integration/test-cases/unittest/input/charts/test-app/tests/deployment_test.yaml @@ -0,0 +1,20 @@ +suite: test deployment +templates: + - templates/deployment.yaml +tests: + - it: should create deployment with correct replicas + set: + replicaCount: 3 + asserts: + - equal: + path: spec.replicas + value: 3 + + - it: should use the correct image + set: + image.repository: nginx + image.tag: "1.21" + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "nginx:1.21" diff --git a/test/integration/test-cases/unittest/input/charts/test-app/values.yaml b/test/integration/test-cases/unittest/input/charts/test-app/values.yaml new file mode 100644 index 00000000..d7c19a6f --- /dev/null +++ b/test/integration/test-cases/unittest/input/charts/test-app/values.yaml @@ -0,0 +1,9 @@ +replicaCount: 1 + +image: + repository: nginx + tag: latest + +service: + type: ClusterIP + port: 80 diff --git a/test/integration/test-cases/unittest/input/helmfile.yaml b/test/integration/test-cases/unittest/input/helmfile.yaml new file mode 100644 index 00000000..f78a05ef --- /dev/null +++ b/test/integration/test-cases/unittest/input/helmfile.yaml @@ -0,0 +1,10 @@ +releases: + - name: test-app + chart: ./charts/test-app + values: + - ./charts/test-app/values.yaml + unitTests: + - tests + + - name: no-tests-app + chart: ./charts/test-app