diff --git a/main.go b/main.go index 3398a61a..f91a3b24 100644 --- a/main.go +++ b/main.go @@ -200,6 +200,10 @@ func main() { Name: "values", Usage: "additional value files to be merged into the command", }, + cli.StringFlag{ + Name: "output-dir", + Usage: "output directory to pass to helm template (helm template --output-dir)", + }, cli.IntFlag{ Name: "concurrency", Value: 0, @@ -440,6 +444,10 @@ func (c configImpl) Args() string { return c.c.String("args") } +func (c configImpl) OutputDir() string { + return c.c.String("output-dir") +} + func (c configImpl) Concurrency() int { return c.c.Int("concurrency") } diff --git a/pkg/app/app.go b/pkg/app/app.go index 6f05e459..72c7a88c 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -45,6 +45,8 @@ type App struct { chdir func(string) error remote *remote.Remote + + helmExecer helmexec.Interface } func New(conf ConfigProvider) *App { @@ -59,6 +61,9 @@ func New(conf ConfigProvider) *App { FileOrDir: conf.FileOrDir(), ValuesFiles: conf.ValuesFiles(), Set: conf.Set(), + helmExecer: helmexec.New(conf.Logger(), conf.KubeContext(), &helmexec.ShellRunner{ + Logger: conf.Logger(), + }), }) } @@ -274,7 +279,7 @@ func (a *App) visitStates(fileOrDir string, defOpts LoadOpts, converge func(*sta ctx := context{a, st} - helm := helmexec.New(a.Logger, a.KubeContext) + helm := a.helmExecer if err != nil { switch stateLoadErr := err.(type) { diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 77fd5e84..2c132a9d 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -1,6 +1,7 @@ package app import ( + "bytes" "fmt" "github.com/roboll/helmfile/pkg/helmexec" "github.com/roboll/helmfile/pkg/state" @@ -8,8 +9,10 @@ import ( "os" "path/filepath" "reflect" + "regexp" "testing" + "go.uber.org/zap" "gotest.tools/env" ) @@ -1738,3 +1741,158 @@ services: } } } + +type configImpl struct { +} + +func (c configImpl) Values() []string { + return []string{} +} + +func (c configImpl) Args() string { + return "some args" +} + +func (c configImpl) SkipDeps() bool { + return true +} + +func (c configImpl) OutputDir() string { + return "output/subdir" +} + +func (c configImpl) Concurrency() int { + return 1 +} + +// Mocking the command-line runner + +type mockRunner struct { + output []byte + err error +} + +func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string) ([]byte, error) { + return []byte{}, nil +} + +func MockExecer(logger *zap.SugaredLogger, kubeContext string) helmexec.Interface { + execer := helmexec.New(logger, kubeContext, &mockRunner{}) + return execer +} + +// mocking helmexec.Interface + +type listKey struct { + filter string + flags string +} + +type mockHelmExec struct { + templated []mockTemplates + + updateDepsCallbacks map[string]func(string) error +} + +type mockTemplates struct { + flags []string +} + +func (helm *mockHelmExec) TemplateRelease(chart string, flags ...string) error { + helm.templated = append(helm.templated, mockTemplates{flags: flags}) + return nil +} + +func (helm *mockHelmExec) UpdateDeps(chart string) error { + return nil +} + +func (helm *mockHelmExec) BuildDeps(chart string) error { + return nil +} + +func (helm *mockHelmExec) SetExtraArgs(args ...string) { + return +} +func (helm *mockHelmExec) SetHelmBinary(bin string) { + return +} +func (helm *mockHelmExec) AddRepo(name, repository, certfile, keyfile, username, password string) error { + return nil +} +func (helm *mockHelmExec) UpdateRepo() error { + return nil +} +func (helm *mockHelmExec) SyncRelease(context helmexec.HelmContext, name, chart string, flags ...string) error { + return nil +} +func (helm *mockHelmExec) DiffRelease(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 +} +func (helm *mockHelmExec) DeleteRelease(context helmexec.HelmContext, name string, flags ...string) error { + return nil +} +func (helm *mockHelmExec) List(context helmexec.HelmContext, filter string, flags ...string) (string, error) { + return "", nil +} +func (helm *mockHelmExec) DecryptSecret(context helmexec.HelmContext, name string, flags ...string) (string, error) { + return "", nil +} +func (helm *mockHelmExec) TestRelease(context helmexec.HelmContext, name string, flags ...string) error { + 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 TestTemplate_SingleStateFile(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: myrelease1 + chart: mychart1 +- name: myrelease2 + chart: mychart1 +`, + } + + var helm = &mockHelmExec{} + var wantReleases = []mockTemplates{ + {[]string{"--name", "myrelease1", "--output-dir", "output/subdir/helmfile-[a-z0-9]{8}-myrelease1"}}, + {[]string{"--name", "myrelease2", "--output-dir", "output/subdir/helmfile-[a-z0-9]{8}-myrelease2"}}, + } + + var buffer bytes.Buffer + logger := helmexec.NewLogger(&buffer, "debug") + + app := appWithFs(&App{ + glob: filepath.Glob, + abs: filepath.Abs, + KubeContext: "default", + Env: "default", + Logger: logger, + helmExecer: helm, + }, files) + app.Template(configImpl{}) + + for i := range wantReleases { + for j := range wantReleases[i].flags { + if j == 3 { + matched, _ := regexp.Match(wantReleases[i].flags[j], []byte(helm.templated[i].flags[j])) + if !matched { + t.Errorf("HelmState.TemplateReleases() = [%v], want %v", helm.templated[i].flags[j], wantReleases[i].flags[j]) + } + } else if wantReleases[i].flags[j] != helm.templated[i].flags[j] { + t.Errorf("HelmState.TemplateReleases() = [%v], want %v", helm.templated[i].flags[j], wantReleases[i].flags[j]) + } + } + + } + +} diff --git a/pkg/app/config.go b/pkg/app/config.go index 654b6428..a6e1b479 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -109,6 +109,7 @@ type TemplateConfigProvider interface { Values() []string SkipDeps() bool + OutputDir() string concurrencyConfig } diff --git a/pkg/app/run.go b/pkg/app/run.go index dc2f8058..a3c7b61e 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -270,7 +270,7 @@ func (r *Run) Template(c TemplateConfigProvider) []error { } args := argparser.GetArgs(c.Args(), state) - return state.TemplateReleases(helm, c.Values(), args, c.Concurrency()) + return state.TemplateReleases(helm, c.OutputDir(), c.Values(), args, c.Concurrency()) } func (r *Run) Test(c TestConfigProvider) []error { diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 62b8c681..52a1d596 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -44,14 +44,12 @@ func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger { } // New for running helm commands -func New(logger *zap.SugaredLogger, kubeContext string) *execer { +func New(logger *zap.SugaredLogger, kubeContext string, runner Runner) *execer { return &execer{ helmBinary: command, logger: logger, kubeContext: kubeContext, - runner: &ShellRunner{ - logger: logger, - }, + runner: runner, } } diff --git a/pkg/helmexec/exec_test.go b/pkg/helmexec/exec_test.go index d18c5cfd..5fab7d60 100644 --- a/pkg/helmexec/exec_test.go +++ b/pkg/helmexec/exec_test.go @@ -24,8 +24,7 @@ func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string } func MockExecer(logger *zap.SugaredLogger, kubeContext string) *execer { - execer := New(logger, kubeContext) - execer.runner = &mockRunner{} + execer := New(logger, kubeContext, &mockRunner{}) return execer } @@ -34,7 +33,9 @@ func MockExecer(logger *zap.SugaredLogger, kubeContext string) *execer { func TestNewHelmExec(t *testing.T) { buffer := bytes.NewBufferString("something") logger := NewLogger(buffer, "debug") - helm := New(logger, "dev") + helm := New(logger, "dev", &ShellRunner{ + Logger: logger, + }) if helm.kubeContext != "dev" { t.Error("helmexec.New() - kubeContext") } @@ -47,7 +48,11 @@ func TestNewHelmExec(t *testing.T) { } func Test_SetExtraArgs(t *testing.T) { - helm := New(NewLogger(os.Stdout, "info"), "dev") + buffer := bytes.NewBufferString("something") + logger := NewLogger(buffer, "debug") + helm := New(NewLogger(os.Stdout, "info"), "dev", &ShellRunner{ + Logger: logger, + }) helm.SetExtraArgs() if len(helm.extra) != 0 { t.Error("helmexec.SetExtraArgs() - passing no arguments should not change extra field") @@ -63,7 +68,11 @@ func Test_SetExtraArgs(t *testing.T) { } func Test_SetHelmBinary(t *testing.T) { - helm := New(NewLogger(os.Stdout, "info"), "dev") + buffer := bytes.NewBufferString("something") + logger := NewLogger(buffer, "debug") + helm := New(NewLogger(os.Stdout, "info"), "dev", &ShellRunner{ + Logger: logger, + }) if helm.helmBinary != "helm" { t.Error("helmexec.command - default command is not helm") } @@ -478,3 +487,16 @@ func Test_mergeEnv(t *testing.T) { t.Errorf("mergeEnv()\nactual = %v\nexpect = %v", actual, expected) } } + +func Test_Template(t *testing.T) { + var buffer bytes.Buffer + logger := NewLogger(&buffer, "debug") + helm := MockExecer(logger, "dev") + helm.TemplateRelease("path/to/chart", "--values", "file.yml") + expected := `exec: helm template path/to/chart --values file.yml --kube-context dev +exec: helm template path/to/chart --values file.yml --kube-context dev: +` + if buffer.String() != expected { + t.Errorf("helmexec.Template()\nactual = %v\nexpect = %v", buffer.String(), expected) + } +} diff --git a/pkg/helmexec/runner.go b/pkg/helmexec/runner.go index de2fe595..2892afc3 100644 --- a/pkg/helmexec/runner.go +++ b/pkg/helmexec/runner.go @@ -25,7 +25,7 @@ type Runner interface { type ShellRunner struct { Dir string - logger *zap.SugaredLogger + Logger *zap.SugaredLogger } // Execute a shell command @@ -33,7 +33,7 @@ func (shell ShellRunner) Execute(cmd string, args []string, env map[string]strin preparedCmd := exec.Command(cmd, args...) preparedCmd.Dir = shell.Dir preparedCmd.Env = mergeEnv(os.Environ(), env) - return combinedOutput(preparedCmd, shell.logger) + return combinedOutput(preparedCmd, shell.Logger) } func combinedOutput(c *exec.Cmd, logger *zap.SugaredLogger) ([]byte, error) { diff --git a/pkg/state/create.go b/pkg/state/create.go index 7447be6b..c63ec813 100644 --- a/pkg/state/create.go +++ b/pkg/state/create.go @@ -185,7 +185,9 @@ func (st *HelmState) loadEnvValues(name string, ctxEnv *environment.Environment, } if len(envSpec.Secrets) > 0 { - helm := helmexec.New(st.logger, "") + helm := helmexec.New(st.logger, "", &helmexec.ShellRunner{ + Logger: st.logger, + }) var envSecretFiles []string for _, urlOrPath := range envSpec.Secrets { diff --git a/pkg/state/state.go b/pkg/state/state.go index 1654fd48..e92b878e 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -1,8 +1,11 @@ package state import ( + "crypto/sha1" + "encoding/hex" "errors" "fmt" + "io" "io/ioutil" "os" "path" @@ -534,7 +537,7 @@ func (st *HelmState) downloadCharts(helm helmexec.Interface, dir string, concurr } // TemplateReleases wrapper for executing helm template on the releases -func (st *HelmState) TemplateReleases(helm helmexec.Interface, additionalValues []string, args []string, workerLimit int) []error { +func (st *HelmState) TemplateReleases(helm helmexec.Interface, outputDir string, additionalValues []string, args []string, workerLimit int) []error { // Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint` helm.SetExtraArgs() @@ -569,6 +572,7 @@ func (st *HelmState) TemplateReleases(helm helmexec.Interface, additionalValues if err != nil { errs = append(errs, err) } + for _, value := range additionalValues { valfile, err := filepath.Abs(value) if err != nil { @@ -581,6 +585,17 @@ func (st *HelmState) TemplateReleases(helm helmexec.Interface, additionalValues flags = append(flags, "--values", valfile) } + if len(outputDir) > 0 { + releaseOutputDir, err := st.GenerateOutputDir(outputDir, release) + if err != nil { + errs = append(errs, err) + } + + flags = append(flags, "--output-dir", releaseOutputDir) + st.logger.Debugf("Generating templates to : %s\n", releaseOutputDir) + os.Mkdir(releaseOutputDir, 0755) + } + if len(errs) == 0 { if err := helm.TemplateRelease(temp[release.Name], flags...); err != nil { errs = append(errs, err) @@ -1560,3 +1575,28 @@ func (hf *SubHelmfileSpec) UnmarshalYAML(unmarshal func(interface{}) error) erro } return nil } + +func (st *HelmState) GenerateOutputDir(outputDir string, release ReleaseSpec) (string, error) { + // get absolute path of state file to generate a hash + // use this hash to write helm output in a specific directory by state file and release name + // ie. in a directory named stateFileName-stateFileHash-releaseName + stateAbsPath, err := filepath.Abs(st.FilePath) + if err != nil { + return stateAbsPath, err + } + + hasher := sha1.New() + io.WriteString(hasher, stateAbsPath) + + var stateFileExtension = filepath.Ext(st.FilePath) + var stateFileName = st.FilePath[0 : len(st.FilePath)-len(stateFileExtension)] + + var sb strings.Builder + sb.WriteString(stateFileName) + sb.WriteString("-") + sb.WriteString(hex.EncodeToString(hasher.Sum(nil))[:8]) + sb.WriteString("-") + sb.WriteString(release.Name) + + return path.Join(outputDir, sb.String()), nil +} diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 8c039c73..4400773f 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -524,7 +524,9 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { Releases: []ReleaseSpec{*tt.release}, HelmDefaults: tt.defaults, } - helm := helmexec.New(logger, "default") + helm := helmexec.New(logger, "default", &helmexec.ShellRunner{ + Logger: logger, + }) args, err := state.flagsForUpgrade(helm, tt.release, 0) if err != nil { t.Errorf("unexpected error flagsForUpgade: %v", err)