diff --git a/cmd/root.go b/cmd/root.go index d702718a..111760d6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -105,6 +105,7 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) { NewStatusCmd(globalImpl), NewShowDAGCmd(globalImpl), NewPrintEnvCmd(globalImpl), + NewUnittestCmd(globalImpl), extension.NewVersionCobraCmd( versionOpts..., ), diff --git a/cmd/unittest.go b/cmd/unittest.go new file mode 100644 index 00000000..7689349f --- /dev/null +++ b/cmd/unittest.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/helmfile/helmfile/pkg/app" + "github.com/helmfile/helmfile/pkg/config" +) + +func NewUnittestCmd(globalCfg *config.GlobalImpl) *cobra.Command { + unittestOptions := config.NewUnittestOptions() + + cmd := &cobra.Command{ + Use: "unittest", + Short: "Run unit tests for charts", + Long: "Run helm-unittest on releases defined in state file", + 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.StringArrayVar(&unittestOptions.Values, "values", nil, "additional value files to be merged into the helm command --values flag") + f.IntVar(&unittestOptions.Concurrency, "concurrency", 0, "maximum number of concurrent helm processes to run, 0 is unlimited") + f.BoolVar(&unittestOptions.FailFast, "fail-fast", false, "fail fast on the first test failure") + f.BoolVar(&unittestOptions.Color, "color", false, "output with color") + f.BoolVar(&unittestOptions.DebugPlugin, "debug-plugin", false, "output plugin debug information") + f.StringArrayVar(&unittestOptions.UnittestArgs, "unittest-args", nil, "additional arguments to pass to helm unittest") + 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`) + 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/pkg/app/app.go b/pkg/app/app.go index 798e46a1..c614a0e7 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -344,6 +344,46 @@ 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 + + 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.IncludeNeeds(), + }, 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 +1949,43 @@ 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, true, func(st *state.HelmState) []error { + helm := r.helm + + args := GetArgs(c.Args(), st) + + helm.SetExtraArgs() + + if len(args) > 0 { + helm.SetExtraArgs(args...) + } + + opts := &state.UnittestOpts{ + Color: c.Color(), + DebugPlugin: c.DebugPlugin(), + FailFast: c.FailFast(), + UnittestArgs: c.UnittestArgs(), + } + unittestErrs := st.UninttestReleases(helm, c.Values(), c.Concurrency(), true, 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..ba326996 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2643,6 +2643,10 @@ func (helm *mockHelmExec) TemplateRelease(name, chart string, flags ...string) e return nil } +func (helm *mockHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + return nil +} + func (helm *mockHelmExec) ChartPull(chart string, path string, flags ...string) error { return nil } diff --git a/pkg/app/config.go b/pkg/app/config.go index ea22c3d5..7dcf089a 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -128,6 +128,25 @@ type SyncConfigProvider interface { valuesControlMode } +type UnittestConfigProvider interface { + Args() string + Values() []string + FailFast() bool + Color() bool + DebugPlugin() bool + UnittestArgs() []string + SkipNeeds() bool + IncludeNeeds() bool + IncludeTransitiveNeeds() bool + EnforceNeedsAreInstalled() bool + SkipDeps() bool + SkipRefresh() bool + SkipCleanup() bool + + concurrencyConfig + DAGConfig +} + type DiffConfigProvider interface { Args() string PostRenderer() string diff --git a/pkg/app/init.go b/pkg/app/init.go index 4dea2127..a13a5950 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -24,6 +24,7 @@ const ( 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" + HelmUnittestVersion = "v0.5.1" HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility ) @@ -54,6 +55,11 @@ var ( version: HelmGitRecommendedVersion, repo: "https://github.com/aslafy-z/helm-git.git", }, + { + name: "unittest", + version: HelmUnittestVersion, + repo: "https://github.com/helm-unittest/helm-unittest.git", + }, } ) diff --git a/pkg/config/unittest.go b/pkg/config/unittest.go new file mode 100644 index 00000000..6e40047a --- /dev/null +++ b/pkg/config/unittest.go @@ -0,0 +1,85 @@ +package config + +type UnittestOptions struct { + Values []string + FailFast bool + Color bool + DebugPlugin bool + UnittestArgs []string + Concurrency int + SkipNeeds bool + IncludeNeeds bool + IncludeTransitiveNeeds bool +} + +func NewUnittestOptions() *UnittestOptions { + return &UnittestOptions{} +} + +type UnittestImpl struct { + *GlobalImpl + *UnittestOptions +} + +func NewUnittestImpl(g *GlobalImpl, t *UnittestOptions) *UnittestImpl { + return &UnittestImpl{ + GlobalImpl: g, + UnittestOptions: t, + } +} + +func (t *UnittestImpl) Values() []string { + return t.UnittestOptions.Values +} + +func (t *UnittestImpl) Concurrency() int { + return t.UnittestOptions.Concurrency +} + +func (t *UnittestImpl) FailFast() bool { + return t.UnittestOptions.FailFast +} + +func (t *UnittestImpl) Color() bool { + return t.UnittestOptions.Color +} + +func (t *UnittestImpl) DebugPlugin() bool { + return t.UnittestOptions.DebugPlugin +} + +func (t *UnittestImpl) UnittestArgs() []string { + return t.UnittestOptions.UnittestArgs +} + +func (t *UnittestImpl) Args() string { + return "" +} + +func (t *UnittestImpl) SkipNeeds() bool { + return t.UnittestOptions.SkipNeeds +} + +func (t *UnittestImpl) IncludeNeeds() bool { + return t.UnittestOptions.IncludeNeeds +} + +func (t *UnittestImpl) IncludeTransitiveNeeds() bool { + return t.UnittestOptions.IncludeTransitiveNeeds +} + +func (t *UnittestImpl) SkipDeps() bool { + return false +} + +func (t *UnittestImpl) SkipRefresh() bool { + return false +} + +func (t *UnittestImpl) SkipCleanup() bool { + return false +} + +func (t *UnittestImpl) EnforceNeedsAreInstalled() bool { + return false +} diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index 7ffea2d1..24108430 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -224,6 +224,9 @@ func (helm *Helm) TemplateRelease(name, chart string, flags ...string) error { helm.Templated = append(helm.Templated, Release{Name: name, Flags: flags}) return nil } +func (helm *Helm) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + return nil +} func (helm *Helm) ChartPull(chart string, path string, flags ...string) error { return nil } diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 34a6e5dc..6e7d8f44 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -649,6 +649,17 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string) return err } +func (helm *execer) UnittestRelease(context HelmContext, name, chart string, flags ...string) error { + helm.logger.Infof("Running unittest for release=%v, chart=%v", name, redactedURL(chart)) + preArgs := make([]string, 0) + env := make(map[string]string) + var overrideEnableLiveOutput *bool = nil + + out, err := helm.exec(append(append(preArgs, "unittest", chart), flags...), env, overrideEnableLiveOutput) + helm.write(context.Writer, out) + return err +} + func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error { diffMsg := fmt.Sprintf("Comparing release=%v, chart=%v, namespace=%v\n", name, redactedURL(chart), namespace) if context.Writer != nil && !suppressDiff { diff --git a/pkg/helmexec/helmexec.go b/pkg/helmexec/helmexec.go index f7291ac0..4210f2f7 100644 --- a/pkg/helmexec/helmexec.go +++ b/pkg/helmexec/helmexec.go @@ -24,6 +24,7 @@ type Interface interface { SyncRelease(context HelmContext, name, chart, namespace string, flags ...string) error DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error TemplateRelease(name, chart string, flags ...string) error + UnittestRelease(context HelmContext, name, chart string, flags ...string) error Fetch(chart string, flags ...string) error ChartPull(chart string, path string, flags ...string) error ChartExport(chart string, path string) error diff --git a/pkg/state/state.go b/pkg/state/state.go index ae699477..f0cdea6f 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -442,6 +442,9 @@ type ReleaseSpec struct { SyncReleaseLabels *bool `yaml:"syncReleaseLabels,omitempty"` // TakeOwnership is true if the release should take ownership of the resources TakeOwnership *bool `yaml:"takeOwnership,omitempty"` + + // UnitTests is a list of paths to unittest directories for this release + UnitTests []string `yaml:"unitTests,omitempty"` } func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error { @@ -2166,6 +2169,95 @@ func (st *HelmState) LintReleases(helm helmexec.Interface, additionalValues []st return nil } +type UnittestOpts struct { + Color bool + DebugPlugin bool + FailFast bool + UnittestArgs []string +} + +func (o *UnittestOpts) Apply(opts *UnittestOpts) { + *opts = *o +} + +type UnittestOpt interface{ Apply(*UnittestOpts) } + +// UninttestReleases wrapper for executing helm unittest on the releases +func (st *HelmState) UninttestReleases(helm helmexec.Interface, additionalValues []string, workerLimit int, triggerCleanupEvents bool, opt ...UnittestOpt) []error { + opts := &UnittestOpts{} + for _, o := range opt { + o.Apply(opts) + } + + helm.SetExtraArgs() + + errs := []error{} + + for i := range st.Releases { + release := st.Releases[i] + + if !release.Desired() { + continue + } + + if len(release.UnitTests) == 0 { + continue + } + + flags, files, err := st.flagsForUnittest(helm, &release, 0) + + defer st.removeFiles(files) + + if err != nil { + errs = append(errs, err) + } + + for _, value := range additionalValues { + valfile, err := filepath.Abs(value) + if err != nil { + errs = append(errs, err) + } + + if _, err := os.Stat(valfile); os.IsNotExist(err) { + errs = append(errs, err) + } + flags = append(flags, "--values", valfile) + } + + flags = append(flags, opts.UnittestArgs...) + + if opts.Color { + flags = append(flags, "--color") + } + + if opts.DebugPlugin { + flags = append(flags, "--debug-plugin") + } + + if opts.FailFast { + flags = append(flags, "--fail-fast") + } + + if len(errs) == 0 { + if err := helm.UnittestRelease(st.createHelmContext(&release, 0), release.Name, release.ChartPathOrName(), flags...); err != nil { + errs = append(errs, err) + } + } + + if triggerCleanupEvents { + if _, err := st.TriggerCleanupEvent(&release, "unittest"); err != nil { + st.logger.Warnf("warn: %v\n", err) + } + } + } + + if len(errs) != 0 { + return errs + } + + return nil +} + type diffResult struct { release *ReleaseSpec err *ReleaseError @@ -3601,6 +3693,10 @@ func (st *HelmState) flagsForLint(helm helmexec.Interface, release *ReleaseSpec, return st.appendHelmXFlags(flags, release), files, nil } +func (st *HelmState) flagsForUnittest(helm helmexec.Interface, release *ReleaseSpec, workerIndex int) ([]string, []string, error) { + return st.namespaceAndValuesFlags(helm, release, workerIndex) +} + func (st *HelmState) newReleaseTemplateData(release *ReleaseSpec) releaseTemplateData { vals := st.Values() templateData := st.createReleaseTemplateData(release, vals) diff --git a/pkg/testutil/mocks.go b/pkg/testutil/mocks.go index e8a2942e..5209963a 100644 --- a/pkg/testutil/mocks.go +++ b/pkg/testutil/mocks.go @@ -61,6 +61,10 @@ func (helm *noCallHelmExec) TemplateRelease(name, chart string, flags ...string) helm.doPanic() return nil } +func (helm *noCallHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + helm.doPanic() + return nil +} func (helm *noCallHelmExec) ChartPull(chart string, path string, flags ...string) error { helm.doPanic() return nil