From 0ecbe8429bd56f634f094f43a4dc77dc57fcb4b0 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Fri, 12 Apr 2024 08:36:18 +0800 Subject: [PATCH] add helm-unittest integration Signed-off-by: yxxhero --- pkg/app/app.go | 23 ++++ pkg/app/app_test.go | 4 + pkg/app/config.go | 9 ++ pkg/app/init.go | 6 + pkg/app/run.go | 26 ++++ pkg/exectest/helm.go | 4 + pkg/helmexec/exec.go | 17 +++ pkg/helmexec/helmexec.go | 1 + pkg/state/state.go | 287 +++++++++++++++++++++++++++++++++++++++ pkg/testutil/mocks.go | 5 + 10 files changed, 382 insertions(+) diff --git a/pkg/app/app.go b/pkg/app/app.go index 917d06fe..964e2f01 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -1584,6 +1584,29 @@ Do you really want to delete? return true, errs } +func (a *App) unittest(r *Run, c UnittestConfigProvider) (bool, []error) { + ok, errs := a.withNeeds(r, c, true, func(st *state.HelmState) []error { + helm := r.helm + + opts := &state.UnittestOpts{ + Color: c.Color(), + DebugPlugin: c.DebugPlugin(), + FailFast: c.FailFast(), + UnittestArgs: c.UnittestArgs(), + } + + filtered := &Run{ + state: st, + helm: helm, + ctx: r.ctx, + Ask: r.Ask, + } + return filtered.unittest(true, c, opts) + }) + + return ok, errs +} + func (a *App) diff(r *Run, c DiffConfigProvider) (*string, bool, bool, []error) { var ( infoMsg *string diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 08d8af28..178037bb 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2510,6 +2510,10 @@ func (helm *mockHelmExec) SyncRelease(context helmexec.HelmContext, name, chart func (helm *mockHelmExec) DiffRelease(context helmexec.HelmContext, name, chart string, suppressDiff bool, flags ...string) error { return nil } + +func (helm *mockHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + return nil +} func (helm *mockHelmExec) ReleaseStatus(context helmexec.HelmContext, release string, flags ...string) error { return nil } diff --git a/pkg/app/config.go b/pkg/app/config.go index 9697eeda..d5fc7dbe 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -121,6 +121,15 @@ type SyncConfigProvider interface { valuesControlMode } +type UnittestConfigProvider interface { + Color() bool + DebugPlugin() bool + Values() []string + FailFast() bool + UnittestArgs() []string + concurrencyConfig + DAGConfig +} type DiffConfigProvider interface { Args() string PostRenderer() string diff --git a/pkg/app/init.go b/pkg/app/init.go index da6d043f..20464b0e 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -23,6 +23,7 @@ const ( HelmSecretsRecommendedVersion = "v4.6.0" HelmGitRecommendedVersion = "v0.15.1" HelmS3RecommendedVersion = "v0.16.0" + HelmUninttestVersion = "v0.4.4" HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" ) @@ -53,6 +54,11 @@ var ( version: HelmGitRecommendedVersion, repo: "https://github.com/aslafy-z/helm-git.git", }, + { + name: "helm-unittest", + version: HelmUninttestVersion, + repo: "https://github.com/helm-unittest/helm-unittest.git", + }, } ) diff --git a/pkg/app/run.go b/pkg/app/run.go index d3eb1d6f..5e81d002 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -213,3 +213,29 @@ func (r *Run) diff(triggerCleanupEvent bool, detailedExitCode bool, c DiffConfig return &infoMsg, releasesToBeUpdated, releasesToBeDeleted, nil } + +func (r *Run) unittest(triggerCleanupEvent bool, c UnittestConfigProvider, unittestOpts *state.UnittestOpts) []error { + st := r.state + helm := r.helm + + planningErrs := st.UninttestReleases(helm, c.Values(), c.Concurrency(), triggerCleanupEvent, unittestOpts) + + fatalErrs := []error{} + + for _, e := range planningErrs { + switch err := e.(type) { + case *state.ReleaseError: + if err.Code != 2 { + fatalErrs = append(fatalErrs, e) + } + default: + fatalErrs = append(fatalErrs, e) + } + } + + if len(fatalErrs) > 0 { + return fatalErrs + } + + return nil +} diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index 8dec332c..46384cf8 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -117,6 +117,10 @@ func (helm *Helm) SyncRelease(context helmexec.HelmContext, name, chart string, return nil } +func (helm *Helm) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + return nil +} + func (helm *Helm) DiffRelease(context helmexec.HelmContext, name, chart string, suppressDiff bool, flags ...string) error { if helm.DiffMutex != nil { helm.DiffMutex.Lock() diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 3d76e049..01d1e447 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -434,6 +434,23 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string) return err } +func (helm *execer) UnittestRelease(context HelmContext, name, chart string, flags ...string) error { + if context.Writer != nil { + fmt.Fprintf(context.Writer, "Unittestting release=%v, chart=%v\n", name, redactedURL(chart)) + } else { + helm.logger.Infof("Comparing 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) + // Do our best to write STDOUT only when diff existed + // Unfortunately, this works only when you run helmfile with `--detailed-exitcode` + helm.write(context.Writer, out) + return err +} + func (helm *execer) DiffRelease(context HelmContext, name, chart string, suppressDiff bool, flags ...string) error { if context.Writer != nil { fmt.Fprintf(context.Writer, "Comparing release=%v, chart=%v\n", name, redactedURL(chart)) diff --git a/pkg/helmexec/helmexec.go b/pkg/helmexec/helmexec.go index dcd66902..72caf60d 100644 --- a/pkg/helmexec/helmexec.go +++ b/pkg/helmexec/helmexec.go @@ -23,6 +23,7 @@ type Interface interface { UpdateDeps(chart string) error SyncRelease(context HelmContext, name, chart string, flags ...string) error DiffRelease(context HelmContext, name, chart string, suppressDiff bool, flags ...string) error + UnittestRelease(context HelmContext, name, chart string, flags ...string) error TemplateRelease(name, chart string, flags ...string) error Fetch(chart string, flags ...string) error ChartPull(chart string, path string, flags ...string) error diff --git a/pkg/state/state.go b/pkg/state/state.go index 6829def0..eb2bc4ca 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -387,6 +387,9 @@ type ReleaseSpec struct { DeleteWait *bool `yaml:"deleteWait,omitempty"` // Timeout is the time in seconds to wait for helmfile delete command (default 300) DeleteTimeout *int `yaml:"deleteTimeout,omitempty"` + + // https://github.com/helmfile/helmfile/discussions/1445, add integration with helm-unittest + UnitTests []string `yaml:"unitTests,omitempty"` } func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error { @@ -1727,6 +1730,19 @@ type diffPrepareResult struct { suppressDiff bool } +type unittestResult struct { + release *ReleaseSpec + err *ReleaseError + buf *bytes.Buffer +} + +type unittestPrepareResult struct { + release *ReleaseSpec + flags []string + errors []*ReleaseError + files []string +} + // commonDiffFlags returns common flags for helm diff, not in release-specific context func (st *HelmState) commonDiffFlags(detailedExitCode bool, stripTrailingCR bool, includeTests bool, suppress []string, suppressSecrets bool, showSecrets bool, noHooks bool, opt *DiffOpts) []string { var flags []string @@ -1785,6 +1801,123 @@ func (st *HelmState) commonDiffFlags(detailedExitCode bool, stripTrailingCR bool return flags } +func (st *HelmState) commonUnittestFlags(opt *UnittestOpts) []string { + var flags []string + + if opt.Color { + flags = append(flags, "--color") + } + + if opt.DebugPlugin { + flags = append(flags, "--debug-plugin") + } + + if opt.FailFast { + flags = append(flags, "--fail-fast") + } + + return flags +} + +func (st *HelmState) prepareUnittestReleases(helm helmexec.Interface, additionalValues []string, concurrency int, opts ...UnittestOpt) ([]unittestPrepareResult, []error) { + opt := &UnittestOpts{} + for _, o := range opts { + o.Apply(opt) + } + + releases := []*ReleaseSpec{} + for i := range st.Releases { + if !st.Releases[i].Desired() { + continue + } + if st.Releases[i].Installed != nil && !*(st.Releases[i].Installed) { + continue + } + releases = append(releases, &st.Releases[i]) + } + + numReleases := len(releases) + jobs := make(chan *ReleaseSpec, numReleases) + results := make(chan unittestPrepareResult, numReleases) + resultsMap := map[string]unittestPrepareResult{} + commonUnittestFlags := st.commonUnittestFlags(opt) + + rs := []unittestPrepareResult{} + errs := []error{} + + mut := sync.Mutex{} + + st.scatterGather( + concurrency, + numReleases, + func() { + for i := 0; i < numReleases; i++ { + jobs <- releases[i] + } + close(jobs) + }, + func(workerIndex int) { + for release := range jobs { + errs := []error{} + + st.ApplyOverrides(release) + + mut.Lock() + + flags, files, err := st.flagsForUnittest(helm, release, workerIndex) + mut.Unlock() + 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, commonUnittestFlags...) + + if len(errs) > 0 { + rsErrs := make([]*ReleaseError, len(errs)) + for i, e := range errs { + rsErrs[i] = newReleaseFailedError(release, e) + } + results <- unittestPrepareResult{errors: rsErrs, flags: files} + } else { + results <- unittestPrepareResult{release: release, flags: flags, files: files, errors: []*ReleaseError{}} + } + } + }, + func() { + for i := 0; i < numReleases; i++ { + res := <-results + if res.errors != nil && len(res.errors) > 0 { + for _, e := range res.errors { + errs = append(errs, e) + } + } else if res.release != nil { + resultsMap[ReleaseToID(res.release)] = res + } + } + }, + ) + + for _, r := range releases { + if p, ok := resultsMap[ReleaseToID(r)]; ok { + rs = append(rs, p) + } + } + + return rs, errs +} + func (st *HelmState) prepareDiffReleases(helm helmexec.Interface, additionalValues []string, concurrency int, detailedExitCode bool, stripTrailingCR bool, includeTests bool, suppress []string, suppressSecrets bool, showSecrets bool, noHooks bool, opts ...DiffOpt) ([]diffPrepareResult, []error) { opt := &DiffOpts{} for _, o := range opts { @@ -1947,6 +2080,102 @@ func (st *HelmState) createHelmContextWithWriter(spec *ReleaseSpec, w io.Writer) return ctx } +type UnittestOpts struct { + Color bool + DebugPlugin bool + FailFast bool + UnittestArgs []string +} + +func (o *UnittestOpts) Apply(opts *UnittestOpts) { + *opts = *o +} + +type UnittestOpt interface{ Apply(*UnittestOpts) } + +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) + } + + preps, prepErrs := st.prepareUnittestReleases(helm, additionalValues, workerLimit, opts) + + defer func() { + for _, p := range preps { + st.removeFiles(p.files) + } + }() + + if len(prepErrs) > 0 { + return prepErrs + } + + jobQueue := make(chan *unittestPrepareResult, len(preps)) + results := make(chan unittestResult, len(preps)) + + outputs := map[string]*bytes.Buffer{} + errs := []error{} + + st.scatterGather( + workerLimit, + len(preps), + func() { + for i := 0; i < len(preps); i++ { + jobQueue <- &preps[i] + } + close(jobQueue) + }, + func(workerIndex int) { + for prep := range jobQueue { + flags := prep.flags + release := prep.release + buf := &bytes.Buffer{} + + if err := helm.UnittestRelease(st.createHelmContextWithWriter(release, buf), release.Name, normalizeChart(st.basePath, release.ChartPathOrName()), flags...); err != nil { + switch e := err.(type) { + case helmexec.ExitError: + // Propagate any non-zero exit status from the external command like `helm` that is failed under the hood + results <- unittestResult{release, &ReleaseError{release, err, e.ExitStatus()}, buf} + default: + results <- unittestResult{release, &ReleaseError{release, err, 0}, buf} + } + } else { + // diff succeeded, found no changes + results <- unittestResult{release, nil, buf} + } + + if triggerCleanupEvents { + if _, err := st.TriggerCleanupEvent(prep.release, "diff"); err != nil { + st.logger.Warnf("warn: %v\n", err) + } + } + } + }, + func() { + for i := 0; i < len(preps); i++ { + res := <-results + if res.err != nil { + errs = append(errs, res.err) + } + + outputs[ReleaseToID(res.release)] = res.buf + } + }, + ) + + for _, p := range preps { + id := ReleaseToID(p.release) + if stdout, ok := outputs[id]; ok { + fmt.Print(stdout.String()) + } else { + panic(fmt.Sprintf("missing output for release %s", id)) + } + } + + return errs +} + type DiffOpts struct { Context int Output string @@ -2708,6 +2937,10 @@ func (st *HelmState) flagsForTemplate(helm helmexec.Interface, release *ReleaseS return append(flags, common...), files, nil } +func (st *HelmState) flagsForUnittest(helm helmexec.Interface, release *ReleaseSpec, workerIndex int) ([]string, []string, error) { + return st.valuesFlags(helm, release, workerIndex) +} + func (st *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec, disableValidation bool, workerIndex int, opt *DiffOpts) ([]string, []string, error) { settings := cli.New() flags := st.chartVersionFlags(release) @@ -3157,6 +3390,60 @@ func (st *HelmState) generateValuesFiles(helm helmexec.Interface, release *Relea return files, nil } +func (st *HelmState) valuesFlags(helm helmexec.Interface, release *ReleaseSpec, workerIndex int) ([]string, []string, error) { + flags := []string{} + var files []string + + generatedFiles, err := st.generateValuesFiles(helm, release, workerIndex) + if err != nil { + return nil, files, err + } + + files = generatedFiles + + for _, f := range generatedFiles { + flags = append(flags, "--values", f) + } + + if len(release.SetValues) > 0 { + setFlags, err := st.setFlags(release.SetValues) + if err != nil { + return nil, files, fmt.Errorf("Failed to render set value entry in %s for release %s: %v", st.FilePath, release.Name, err) + } + + flags = append(flags, setFlags...) + } + + /*********** + * START 'env' section for backwards compatibility + ***********/ + // The 'env' section is not really necessary any longer, as 'set' would now provide the same functionality + if len(release.EnvValues) > 0 { + val := []string{} + envValErrs := []string{} + for _, set := range release.EnvValues { + value, isSet := os.LookupEnv(set.Value) + if isSet { + val = append(val, fmt.Sprintf("%s=%s", escape(set.Name), escape(value))) + } else { + errMsg := fmt.Sprintf("\t%s", set.Value) + envValErrs = append(envValErrs, errMsg) + } + } + if len(envValErrs) != 0 { + joinedEnvVals := strings.Join(envValErrs, "\n") + errMsg := fmt.Sprintf("Environment Variables not found. Please make sure they are set and try again:\n%s", joinedEnvVals) + return nil, files, errors.New(errMsg) + } + flags = append(flags, "--set", strings.Join(val, ",")) + } + /************** + * END 'env' section for backwards compatibility + **************/ + + return flags, files, nil +} + func (st *HelmState) namespaceAndValuesFlags(helm helmexec.Interface, release *ReleaseSpec, workerIndex int) ([]string, []string, error) { flags := []string{} if release.Namespace != "" { diff --git a/pkg/testutil/mocks.go b/pkg/testutil/mocks.go index dd796175..f9c61bdc 100644 --- a/pkg/testutil/mocks.go +++ b/pkg/testutil/mocks.go @@ -97,6 +97,11 @@ func (helm *noCallHelmExec) DiffRelease(context helmexec.HelmContext, name, char helm.doPanic() return nil } + +func (helm *noCallHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + helm.doPanic() + return nil +} func (helm *noCallHelmExec) ReleaseStatus(context helmexec.HelmContext, release string, flags ...string) error { helm.doPanic() return nil