From 6856c6e9793cc8b0b2386c9d89e490c1f81cb8c7 Mon Sep 17 00:00:00 2001 From: Johan Lyheden <344326+jlyheden@users.noreply.github.com> Date: Thu, 14 Jun 2018 15:35:09 +0200 Subject: [PATCH] Add helmfile lint support (#162) The use case is to have a list of helmfile releases version controlled together with all settings and have a CI pipeline that will lint all releases with settings before running sync. The new functionality was mostly copy pasted from the Diff implementation with some extra handling for fetching remote charts. Notes: * Added release name to chart path to avoid potential race condition when fetching the chart --- README.md | 5 ++ helmexec/exec.go | 12 +++++ helmexec/exec_test.go | 20 +++++++ helmexec/helmexec.go | 2 + main.go | 33 ++++++++++++ state/state.go | 120 ++++++++++++++++++++++++++++++++++++++++++ state/state_test.go | 6 +++ 7 files changed, 198 insertions(+) diff --git a/README.md b/README.md index 33b611fd..567ebead 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ COMMANDS: repos sync repositories from state file (helm repo add && helm repo update) charts sync charts from state file (helm upgrade --install) diff diff charts from state file against env (helm diff) + lint lint charts from state file (helm lint) sync sync all resources from state file (repos, charts and local chart deps) status retrieve status of releases in state file delete delete charts from state file (helm delete) @@ -211,6 +212,10 @@ The `helmfile test` sub-command runs a `helm test` against specified releases in Use `--cleanup` to delete pods upon completion. +### lint + +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. + ## Paths Overview Using manifest files in conjunction with command line argument can be a bit confusing. diff --git a/helmexec/exec.go b/helmexec/exec.go index a5f5bc9e..32159849 100644 --- a/helmexec/exec.go +++ b/helmexec/exec.go @@ -82,6 +82,18 @@ func (helm *execer) DiffRelease(name, chart string, flags ...string) error { return err } +func (helm *execer) Lint(chart string, flags ...string) error { + out, err := helm.exec(append([]string{"lint", chart}, flags...)...) + helm.write(out) + return err +} + +func (helm *execer) Fetch(chart string, flags ...string) error { + out, err := helm.exec(append([]string{"fetch", chart}, flags...)...) + helm.write(out) + return err +} + func (helm *execer) DeleteRelease(name string, flags ...string) error { out, err := helm.exec(append([]string{"delete", name}, flags...)...) helm.write(out) diff --git a/helmexec/exec_test.go b/helmexec/exec_test.go index f19f4a46..bcb52415 100644 --- a/helmexec/exec_test.go +++ b/helmexec/exec_test.go @@ -238,3 +238,23 @@ func Test_exec(t *testing.T) { t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected) } } + +func Test_Lint(t *testing.T) { + var buffer bytes.Buffer + helm := MockExecer(&buffer, "dev") + helm.Lint("path/to/chart", "--values", "file.yml") + expected := "exec: helm lint path/to/chart --values file.yml --kube-context dev\n" + if buffer.String() != expected { + t.Errorf("helmexec.Lint()\nactual = %v\nexpect = %v", buffer.String(), expected) + } +} + +func Test_Fetch(t *testing.T) { + var buffer bytes.Buffer + helm := MockExecer(&buffer, "dev") + helm.Fetch("chart", "--version", "1.2.3", "--untar", "--untardir", "/tmp/dir") + expected := "exec: helm fetch chart --version 1.2.3 --untar --untardir /tmp/dir --kube-context dev\n" + if buffer.String() != expected { + t.Errorf("helmexec.Lint()\nactual = %v\nexpect = %v", buffer.String(), expected) + } +} diff --git a/helmexec/helmexec.go b/helmexec/helmexec.go index 38e5ab50..f570b62b 100644 --- a/helmexec/helmexec.go +++ b/helmexec/helmexec.go @@ -9,6 +9,8 @@ type Interface interface { UpdateDeps(chart string) error SyncRelease(name, chart string, flags ...string) error DiffRelease(name, chart string, flags ...string) error + Fetch(chart string, flags ...string) error + Lint(chart string, flags ...string) error ReleaseStatus(name string) error DeleteRelease(name string, flags ...string) error TestRelease(name string, flags ...string) error diff --git a/main.go b/main.go index 6911aaaa..dacc6af7 100644 --- a/main.go +++ b/main.go @@ -155,6 +155,39 @@ func main() { }) }, }, + { + Name: "lint", + Usage: "lint charts from state file (helm lint)", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "args", + Value: "", + Usage: "pass args to helm exec", + }, + cli.StringSliceFlag{ + Name: "values", + Usage: "additional value files to be merged into the command", + }, + cli.IntFlag{ + Name: "concurrency", + Value: 0, + Usage: "maximum number of concurrent helm processes to run, 0 is unlimited", + }, + }, + Action: func(c *cli.Context) error { + return eachDesiredStateDo(c, func(state *state.HelmState, helm helmexec.Interface) []error { + args := c.String("args") + if len(args) > 0 { + helm.SetExtraArgs(strings.Split(args, " ")...) + } + + values := c.StringSlice("values") + workers := c.Int("concurrency") + + return state.LintReleases(helm, values, workers) + }) + }, + }, { Name: "sync", Usage: "sync all resources from state file (repos, charts and local chart deps)", diff --git a/state/state.go b/state/state.go index f13f218a..3ab1619f 100644 --- a/state/state.go +++ b/state/state.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "os" + "path" "path/filepath" "strconv" "strings" @@ -315,6 +316,120 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues [ return nil } +// LintReleases wrapper for executing helm lint on the releases +func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues []string, workerLimit int) []error { + var wgRelease sync.WaitGroup + var wgError sync.WaitGroup + errs := []error{} + jobQueue := make(chan *ReleaseSpec, len(state.Releases)) + errQueue := make(chan error) + + if workerLimit < 1 { + workerLimit = len(state.Releases) + } + + wgRelease.Add(len(state.Releases)) + + // Create tmp directory and bail immediately if it fails + dir, err := ioutil.TempDir("", "") + if err != nil { + errs = append(errs, err) + return errs + } + defer os.RemoveAll(dir) + + for w := 1; w <= workerLimit; w++ { + go func() { + for release := range jobQueue { + errs := []error{} + flags, err := flagsForRelease(helm, state.BaseChartPath, release) + 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) + } + + chartPath := "" + if isLocalChart(release.Chart) { + chartPath = normalizeChart(state.BaseChartPath, release.Chart) + } else { + fetchFlags := []string{} + if release.Version != "" { + chartPath = path.Join(dir, release.Name, release.Version, release.Chart) + fetchFlags = append(fetchFlags, "--version", release.Version) + } else { + chartPath = path.Join(dir, release.Name, "latest", release.Chart) + } + + // only fetch chart if it is not already fetched + if _, err := os.Stat(chartPath); os.IsNotExist(err) { + fetchFlags = append(fetchFlags, "--untar", "--untardir", chartPath) + if err := helm.Fetch(release.Chart, fetchFlags...); err != nil { + errs = append(errs, err) + } + } + chartPath = path.Join(chartPath, chartNameWithoutRepository(release.Chart)) + } + + // strip version from the slice returned from flagsForRelease + realFlags := []string{} + isVersion := false + for _, v := range flags { + if v == "--version" { + isVersion = true + } else if isVersion { + isVersion = false + } else { + realFlags = append(realFlags, v) + } + } + + if len(errs) == 0 { + if err := helm.Lint(chartPath, realFlags...); err != nil { + errs = append(errs, err) + } + } + for _, err := range errs { + errQueue <- err + } + wgRelease.Done() + } + }() + } + wgError.Add(1) + go func() { + for err := range errQueue { + errs = append(errs, err) + } + wgError.Done() + }() + + for i := 0; i < len(state.Releases); i++ { + jobQueue <- &state.Releases[i] + } + + close(jobQueue) + wgRelease.Wait() + + close(errQueue) + wgError.Wait() + + if len(errs) != 0 { + return errs + } + + return nil +} + func (state *HelmState) ReleaseStatuses(helm helmexec.Interface, workerLimit int) []error { var errs []error jobQueue := make(chan ReleaseSpec) @@ -505,6 +620,11 @@ func isLocalChart(chart string) bool { return err == nil } +func chartNameWithoutRepository(chart string) string { + chartSplit := strings.Split(chart, "/") + return chartSplit[len(chartSplit)-1] +} + func flagsForRelease(helm helmexec.Interface, basePath string, release *ReleaseSpec) ([]string, error) { flags := []string{} if release.Version != "" { diff --git a/state/state_test.go b/state/state_test.go index 97d435b5..8f28d96f 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -559,6 +559,12 @@ func (helm *mockHelmExec) TestRelease(name string, flags ...string) error { helm.releases = append(helm.releases, mockRelease{name: name, flags: flags}) return nil } +func (helm *mockHelmExec) Fetch(chart string, flags ...string) error { + return nil +} +func (helm *mockHelmExec) Lint(chart string, flags ...string) error { + return nil +} func TestHelmState_SyncRepos(t *testing.T) { tests := []struct {