diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 00000000..12e3a9ea --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + "github.com/roboll/helmfile/helmexec" + "github.com/roboll/helmfile/pkg/app" + "github.com/roboll/helmfile/state" + "github.com/urfave/cli" + "go.uber.org/zap" + "os/exec" + "strings" + "syscall" +) + +func VisitAllDesiredStates(c *cli.Context, converge func(*state.HelmState, helmexec.Interface, app.Context) (bool, []error)) error { + a, fileOrDir, err := InitAppEntry(c, false) + if err != nil { + return err + } + + ctx := app.NewContext() + + convergeWithHelmBinary := func(st *state.HelmState, helm helmexec.Interface) (bool, []error) { + if c.GlobalString("helm-binary") != "" { + helm.SetHelmBinary(c.GlobalString("helm-binary")) + } + return converge(st, helm, ctx) + } + + err = a.VisitDesiredStates(fileOrDir, convergeWithHelmBinary) + + return toCliError(err) +} + +func InitAppEntry(c *cli.Context, reverse bool) (*app.App, string, error) { + if c.NArg() > 0 { + cli.ShowAppHelp(c) + return nil, "", fmt.Errorf("err: extraneous arguments: %s", strings.Join(c.Args(), ", ")) + } + + fileOrDir := c.GlobalString("file") + kubeContext := c.GlobalString("kube-context") + namespace := c.GlobalString("namespace") + selectors := c.GlobalStringSlice("selector") + logger := c.App.Metadata["logger"].(*zap.SugaredLogger) + + env := c.GlobalString("environment") + if env == "" { + env = state.DefaultEnv + } + + app := app.Init(&app.App{ + KubeContext: kubeContext, + Logger: logger, + Reverse: reverse, + Env: env, + Namespace: namespace, + Selectors: selectors, + }) + + return app, fileOrDir, nil +} + +func FindAndIterateOverDesiredStatesUsingFlagsWithReverse(c *cli.Context, reverse bool, converge func(*state.HelmState, helmexec.Interface, app.Context) []error) error { + a, fileOrDir, err := InitAppEntry(c, reverse) + if err != nil { + return err + } + + ctx := app.NewContext() + + convergeWithHelmBinary := func(st *state.HelmState, helm helmexec.Interface) []error { + if c.GlobalString("helm-binary") != "" { + helm.SetHelmBinary(c.GlobalString("helm-binary")) + } + return converge(st, helm, ctx) + } + + err = a.VisitDesiredStatesWithReleasesFiltered(fileOrDir, convergeWithHelmBinary) + + return toCliError(err) +} + +func toCliError(err error) error { + if err != nil { + switch e := err.(type) { + case *app.NoMatchingHelmfileError: + return cli.NewExitError(e.Error(), 2) + case *exec.ExitError: + // Propagate any non-zero exit status from the external command like `helm` that is failed under the hood + status := e.Sys().(syscall.WaitStatus) + return cli.NewExitError(e.Error(), status.ExitStatus()) + case *state.DiffError: + return cli.NewExitError(e.Error(), e.Code) + default: + return cli.NewExitError(e.Error(), 1) + } + } + return err +} diff --git a/cmd/deps.go b/cmd/deps.go new file mode 100644 index 00000000..5e8f1455 --- /dev/null +++ b/cmd/deps.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/roboll/helmfile/args" + "github.com/roboll/helmfile/helmexec" + "github.com/roboll/helmfile/pkg/app" + "github.com/roboll/helmfile/state" + "github.com/urfave/cli" +) + +func Deps(a *app.App) cli.Command { + return cli.Command{ + Name: "deps", + Usage: "update charts based on the contents of requirements.yaml", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "args", + Value: "", + Usage: "pass args to helm exec", + }, + }, + Action: func(c *cli.Context) error { + return VisitAllDesiredStates(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) (bool, []error) { + args := args.GetArgs(c.String("args"), state) + if len(args) > 0 { + helm.SetExtraArgs(args...) + } + + errs := state.UpdateDeps(helm) + + ok := len(errs) == 0 + + return ok, errs + }) + }, + } +} diff --git a/helmexec/exec.go b/helmexec/exec.go index eb92a6b9..642d70df 100644 --- a/helmexec/exec.go +++ b/helmexec/exec.go @@ -89,6 +89,13 @@ func (helm *execer) UpdateDeps(chart string) error { return err } +func (helm *execer) BuildDeps(chart string) error { + helm.logger.Infof("Building dependency %v", chart) + out, err := helm.exec("dependency", "build", chart) + helm.write(out) + return err +} + func (helm *execer) SyncRelease(name, chart string, flags ...string) error { helm.logger.Infof("Upgrading %v", chart) out, err := helm.exec(append([]string{"upgrade", "--install", "--reset-values", name, chart}, flags...)...) diff --git a/helmexec/exec_test.go b/helmexec/exec_test.go index 0c6efbf7..87a7f77e 100644 --- a/helmexec/exec_test.go +++ b/helmexec/exec_test.go @@ -159,6 +159,29 @@ exec: helm dependency update ./chart/foo --verify --kube-context dev } } +func Test_BuildDeps(t *testing.T) { + var buffer bytes.Buffer + logger := NewLogger(&buffer, "debug") + helm := MockExecer(logger, "dev") + helm.BuildDeps("./chart/foo") + expected := `Building dependency ./chart/foo +exec: helm dependency build ./chart/foo --kube-context dev +` + if buffer.String() != expected { + t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected) + } + + buffer.Reset() + helm.SetExtraArgs("--verify") + helm.BuildDeps("./chart/foo") + expected = `Building dependency ./chart/foo +exec: helm dependency build ./chart/foo --verify --kube-context dev +` + if buffer.String() != expected { + t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected) + } +} + func Test_DecryptSecret(t *testing.T) { var buffer bytes.Buffer logger := NewLogger(&buffer, "debug") diff --git a/helmexec/helmexec.go b/helmexec/helmexec.go index 821f38ea..96322383 100644 --- a/helmexec/helmexec.go +++ b/helmexec/helmexec.go @@ -7,6 +7,7 @@ type Interface interface { AddRepo(name, repository, certfile, keyfile, username, password string) error UpdateRepo() error + BuildDeps(chart string) error UpdateDeps(chart string) error SyncRelease(name, chart string, flags ...string) error DiffRelease(name, chart string, flags ...string) error diff --git a/main.go b/main.go index b0c25584..10056ca1 100644 --- a/main.go +++ b/main.go @@ -1,32 +1,17 @@ package main import ( - "bytes" "fmt" - "io/ioutil" - "log" - "os" - "os/exec" - "os/signal" - "path/filepath" - "sort" - "strings" - "syscall" - "github.com/roboll/helmfile/args" - "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/cmd" "github.com/roboll/helmfile/helmexec" + "github.com/roboll/helmfile/pkg/app" "github.com/roboll/helmfile/state" - "github.com/roboll/helmfile/tmpl" "github.com/urfave/cli" "go.uber.org/zap" "go.uber.org/zap/zapcore" -) - -const ( - DefaultHelmfile = "helmfile.yaml" - DeprecatedHelmfile = "charts.yaml" - DefaultHelmfileDirectory = "helmfile.d" + "os" + "strings" ) var Version string @@ -57,11 +42,11 @@ func configureLogging(c *cli.Context) error { func main() { - app := cli.NewApp() - app.Name = "helmfile" - app.Usage = "" - app.Version = Version - app.Flags = []cli.Flag{ + cliApp := cli.NewApp() + cliApp.Name = "helmfile" + cliApp.Usage = "" + cliApp.Version = Version + cliApp.Flags = []cli.Flag{ cli.StringFlag{ Name: "helm-binary, b", Usage: "path to helm binary", @@ -103,8 +88,8 @@ func main() { }, } - app.Before = configureLogging - app.Commands = []cli.Command{ + cliApp.Before = configureLogging + cliApp.Commands = []cli.Command{ { Name: "repos", Usage: "sync repositories from state file (helm repo add && helm repo update)", @@ -116,7 +101,7 @@ func main() { }, }, Action: func(c *cli.Context) error { - return visitAllDesiredStates(c, func(state *state.HelmState, helm helmexec.Interface, ctx context) (bool, []error) { + return cmd.VisitAllDesiredStates(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) (bool, []error) { args := args.GetArgs(c.String("args"), state) if len(args) > 0 { helm.SetExtraArgs(args...) @@ -150,7 +135,7 @@ func main() { }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ context) []error { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ app.Context) []error { return executeSyncCommand(c, state, helm) }) }, @@ -173,8 +158,8 @@ func main() { Usage: "DEPRECATED", }, cli.BoolFlag{ - Name: "skip-repo-update", - Usage: "skip running `helm repo update` on repositories declared in helmfile", + Name: "skip-deps", + Usage: "skip running `helm repo update` and `helm dependency build`", }, cli.BoolFlag{ Name: "detailed-exitcode", @@ -191,14 +176,17 @@ func main() { }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx context) []error { - if !c.Bool("skip-repo-update") { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) []error { + if !c.Bool("skip-deps") { if c.Bool("sync-repos") { logger.Warnf("--sync-repos has been removed and `helmfile diff` updates repositories by default. Provide `--skip-repo-update` to opt-out.") } if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { return errs } + if errs := state.BuildDeps(helm); errs != nil && len(errs) > 0 { + return errs + } } if errs := state.PrepareRelease(helm, "diff"); errs != nil && len(errs) > 0 { return errs @@ -227,20 +215,24 @@ func main() { Value: 0, Usage: "maximum number of concurrent downloads of release charts", }, + cli.BoolFlag{ + Name: "skip-deps", + Usage: "skip running `helm repo update` and `helm dependency build`", + }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx context) []error { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) []error { + if !c.Bool("skip-deps") { + if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { + return errs + } + if errs := state.BuildDeps(helm); errs != nil && len(errs) > 0 { + return errs + } + } if errs := state.PrepareRelease(helm, "template"); errs != nil && len(errs) > 0 { return errs } - if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { - return errs - } - - if errs := state.UpdateDeps(helm); errs != nil && len(errs) > 0 { - return errs - } - return executeTemplateCommand(c, state, helm) }) }, @@ -265,7 +257,7 @@ func main() { }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx context) []error { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) []error { values := c.StringSlice("values") args := args.GetArgs(c.String("args"), state) workers := c.Int("concurrency") @@ -297,18 +289,24 @@ func main() { Value: "", Usage: "pass args to helm exec", }, + cli.BoolFlag{ + Name: "skip-deps", + Usage: "skip running `helm repo update` and `helm dependency build`", + }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx context) []error { - if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { - return errs + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, ctx app.Context) []error { + if !c.Bool("skip-deps") { + if errs := ctx.SyncReposOnce(state, helm); errs != nil && len(errs) > 0 { + return errs + } + if errs := state.BuildDeps(helm); errs != nil && len(errs) > 0 { + return errs + } } if errs := state.PrepareRelease(helm, "sync"); errs != nil && len(errs) > 0 { return errs } - if errs := state.UpdateDeps(helm); errs != nil && len(errs) > 0 { - return errs - } return executeSyncCommand(c, state, helm) }) }, @@ -339,20 +337,27 @@ func main() { Name: "skip-repo-update", Usage: "skip running `helm repo update` on repositories declared in helmfile", }, + cli.BoolFlag{ + Name: "skip-deps", + Usage: "skip running `helm repo update` and `helm dependency build`", + }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(st *state.HelmState, helm helmexec.Interface, ctx context) []error { - if !c.Bool("skip-repo-update") { + return findAndIterateOverDesiredStatesUsingFlags(c, func(st *state.HelmState, helm helmexec.Interface, ctx app.Context) []error { + if !c.Bool("skip-deps") || !c.Bool("skip-repo-update") { + if !c.Bool("skip-repo-update") { + logger.Warn("--skip-repo-update has been deprecated. Provide --skip-deps instead.") + } if errs := ctx.SyncReposOnce(st, helm); errs != nil && len(errs) > 0 { return errs } + if errs := st.BuildDeps(helm); errs != nil && len(errs) > 0 { + return errs + } } if errs := st.PrepareRelease(helm, "apply"); errs != nil && len(errs) > 0 { return errs } - if errs := st.UpdateDeps(helm); errs != nil && len(errs) > 0 { - return errs - } releases, errs := executeDiffCommand(c, st, helm, true, c.Bool("suppress-secrets")) @@ -430,7 +435,7 @@ Do you really want to apply? }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ context) []error { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ app.Context) []error { workers := c.Int("concurrency") args := args.GetArgs(c.String("args"), state) @@ -457,7 +462,7 @@ Do you really want to apply? }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlagsWithReverse(c, true, func(state *state.HelmState, helm helmexec.Interface, _ context) []error { + return cmd.FindAndIterateOverDesiredStatesUsingFlagsWithReverse(c, true, func(state *state.HelmState, helm helmexec.Interface, _ app.Context) []error { purge := c.Bool("purge") args := args.GetArgs(c.String("args"), state) @@ -510,7 +515,7 @@ Do you really want to delete? }, }, Action: func(c *cli.Context) error { - return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ context) []error { + return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface, _ app.Context) []error { cleanup := c.Bool("cleanup") timeout := c.Int("timeout") concurrency := c.Int("concurrency") @@ -526,7 +531,7 @@ Do you really want to delete? }, } - err := app.Run(os.Args) + err := cliApp.Run(os.Args) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(3) @@ -566,568 +571,6 @@ func executeDiffCommand(c *cli.Context, st *state.HelmState, helm helmexec.Inter return st.DiffReleases(helm, values, workers, detailedExitCode, suppressSecrets, triggerCleanupEvents) } -type app struct { - kubeContext string - logger *zap.SugaredLogger - readFile func(string) ([]byte, error) - glob func(string) ([]string, error) - abs func(string) (string, error) - fileExistsAt func(string) bool - directoryExistsAt func(string) bool - reverse bool - env string - namespace string - selectors []string - - getwd func() (string, error) - chdir func(string) error -} - -func findAndIterateOverDesiredStatesUsingFlags(c *cli.Context, converge func(*state.HelmState, helmexec.Interface, context) []error) error { - return findAndIterateOverDesiredStatesUsingFlagsWithReverse(c, false, converge) -} - -func initAppEntry(c *cli.Context, reverse bool) (*app, string, error) { - if c.NArg() > 0 { - cli.ShowAppHelp(c) - return nil, "", fmt.Errorf("err: extraneous arguments: %s", strings.Join(c.Args(), ", ")) - } - - fileOrDir := c.GlobalString("file") - kubeContext := c.GlobalString("kube-context") - namespace := c.GlobalString("namespace") - selectors := c.GlobalStringSlice("selector") - logger := c.App.Metadata["logger"].(*zap.SugaredLogger) - - env := c.GlobalString("environment") - if env == "" { - env = state.DefaultEnv - } - - app := &app{ - readFile: ioutil.ReadFile, - glob: filepath.Glob, - abs: filepath.Abs, - getwd: os.Getwd, - chdir: os.Chdir, - fileExistsAt: fileExistsAt, - directoryExistsAt: directoryExistsAt, - kubeContext: kubeContext, - logger: logger, - reverse: reverse, - env: env, - namespace: namespace, - selectors: selectors, - } - - return app, fileOrDir, nil -} - -type context struct { - updatedRepos map[string]struct{} -} - -func (ctx context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) []error { - var errs []error - - allUpdated := true - for _, r := range st.Repositories { - _, exists := ctx.updatedRepos[r.Name] - allUpdated = allUpdated && exists - } - - if !allUpdated { - errs = st.SyncRepos(helm) - - for _, r := range st.Repositories { - ctx.updatedRepos[r.Name] = struct{}{} - } - } - - return errs -} - -func visitAllDesiredStates(c *cli.Context, converge func(*state.HelmState, helmexec.Interface, context) (bool, []error)) error { - app, fileOrDir, err := initAppEntry(c, false) - if err != nil { - return err - } - - ctx := context{ - updatedRepos: map[string]struct{}{}, - } - - convergeWithHelmBinary := func(st *state.HelmState, helm helmexec.Interface) (bool, []error) { - if c.GlobalString("helm-binary") != "" { - helm.SetHelmBinary(c.GlobalString("helm-binary")) - } - return converge(st, helm, ctx) - } - - err = app.VisitDesiredStates(fileOrDir, convergeWithHelmBinary) - - return toCliError(err) -} - -func toCliError(err error) error { - if err != nil { - switch e := err.(type) { - case *noMatchingHelmfileError: - return cli.NewExitError(e.Error(), 2) - case *exec.ExitError: - // Propagate any non-zero exit status from the external command like `helm` that is failed under the hood - status := e.Sys().(syscall.WaitStatus) - return cli.NewExitError(e.Error(), status.ExitStatus()) - case *state.DiffError: - return cli.NewExitError(e.Error(), e.Code) - default: - return cli.NewExitError(e.Error(), 1) - } - } - return err -} - -func findAndIterateOverDesiredStatesUsingFlagsWithReverse(c *cli.Context, reverse bool, converge func(*state.HelmState, helmexec.Interface, context) []error) error { - app, fileOrDir, err := initAppEntry(c, reverse) - if err != nil { - return err - } - - ctx := context{ - updatedRepos: map[string]struct{}{}, - } - - convergeWithHelmBinary := func(st *state.HelmState, helm helmexec.Interface) []error { - if c.GlobalString("helm-binary") != "" { - helm.SetHelmBinary(c.GlobalString("helm-binary")) - } - return converge(st, helm, ctx) - } - - err = app.VisitDesiredStatesWithReleasesFiltered(fileOrDir, convergeWithHelmBinary) - - return toCliError(err) -} - -type noMatchingHelmfileError struct { - selectors []string - env string -} - -func (e *noMatchingHelmfileError) Error() string { - return fmt.Sprintf( - "err: no releases found that matches specified selector(%s) and environment(%s), in any helmfile", - strings.Join(e.selectors, ", "), - e.env, - ) -} - -func prependLineNumbers(text string) string { - buf := bytes.NewBufferString("") - lines := strings.Split(text, "\n") - for i, line := range lines { - buf.WriteString(fmt.Sprintf("%2d: %s\n", i, line)) - } - return buf.String() -} - -type twoPassRenderer struct { - reader func(string) ([]byte, error) - env string - namespace string - filename string - logger *zap.SugaredLogger - abs func(string) (string, error) -} - -func (r *twoPassRenderer) renderEnvironment(content []byte) environment.Environment { - firstPassEnv := environment.Environment{Name: r.env, Values: map[string]interface{}(nil)} - tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} - firstPassRenderer := tmpl.NewFirstPassRenderer(filepath.Dir(r.filename), tmplData) - - // parse as much as we can, tolerate errors, this is a preparse - yamlBuf, err := firstPassRenderer.RenderTemplateContentToBuffer(content) - if err != nil && r.logger != nil { - r.logger.Debugf("first-pass rendering input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) - if yamlBuf == nil { // we have a template syntax error, let the second parse report - r.logger.Debugf("template syntax error: %v", err) - return firstPassEnv - } - } - c := state.NewCreator(r.logger, r.reader, r.abs) - c.Strict = false - // create preliminary state, as we may have an environment. Tolerate errors. - prestate, err := c.CreateFromYaml(yamlBuf.Bytes(), r.filename, r.env) - if err != nil && r.logger != nil { - switch err.(type) { - case *state.StateLoadError: - r.logger.Infof("could not deduce `environment:` block, configuring only .Environment.Name. error: %v", err) - } - r.logger.Debugf("error in first-pass rendering: result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) - } - if prestate != nil { - firstPassEnv = prestate.Env - } - return firstPassEnv -} - -func (r *twoPassRenderer) renderTemplate(content []byte) (*bytes.Buffer, error) { - // try a first pass render. This will always succeed, but can produce a limited env - firstPassEnv := r.renderEnvironment(content) - - tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} - secondPassRenderer := tmpl.NewFileRenderer(r.reader, filepath.Dir(r.filename), tmplData) - yamlBuf, err := secondPassRenderer.RenderTemplateContentToBuffer(content) - if err != nil { - if r.logger != nil { - r.logger.Debugf("second-pass rendering failed, input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) - } - return nil, err - } - if r.logger != nil { - r.logger.Debugf("second-pass rendering result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) - } - return yamlBuf, nil -} - -func (a *app) within(dir string, do func() error) error { - if dir == "." { - return do() - } - - prev, err := a.getwd() - if err != nil { - return fmt.Errorf("failed getting current working direcotyr: %v", err) - } - - absDir, err := a.abs(dir) - if err != nil { - return err - } - - a.logger.Debugf("changing working directory to \"%s\"", absDir) - - if err := a.chdir(absDir); err != nil { - return fmt.Errorf("failed changing working directory to \"%s\": %v", absDir, err) - } - - appErr := do() - - a.logger.Debugf("changing working directory back to \"%s\"", prev) - - if chdirBackErr := a.chdir(prev); chdirBackErr != nil { - if appErr != nil { - a.logger.Warnf("%v", appErr) - } - return fmt.Errorf("failed chaging working directory back to \"%s\": %v", prev, chdirBackErr) - } - - return appErr -} - -func (a *app) visitStateFiles(fileOrDir string, do func(string) error) error { - desiredStateFiles, err := a.findDesiredStateFiles(fileOrDir) - if err != nil { - return err - } - - for _, relPath := range desiredStateFiles { - var file string - var dir string - if a.directoryExistsAt(relPath) { - file = relPath - dir = relPath - } else { - file = filepath.Base(relPath) - dir = filepath.Dir(relPath) - } - - a.logger.Debugf("processing file \"%s\" in directory \"%s\"", file, dir) - - err := a.within(dir, func() error { - return do(file) - }) - if err != nil { - return err - } - } - - return nil -} - -func (a *app) VisitDesiredStates(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) error { - noMatchInHelmfiles := true - err := a.visitStateFiles(fileOrDir, func(f string) error { - content, err := a.readFile(f) - if err != nil { - return err - } - // render template, in two runs - r := &twoPassRenderer{ - reader: a.readFile, - env: a.env, - namespace: a.namespace, - filename: f, - logger: a.logger, - abs: a.abs, - } - yamlBuf, err := r.renderTemplate(content) - if err != nil { - return fmt.Errorf("error during %s parsing: %v", f, err) - } - - st, err := a.loadDesiredStateFromYaml( - yamlBuf.Bytes(), - f, - a.namespace, - a.env, - ) - - helm := helmexec.New(a.logger, a.kubeContext) - - if err != nil { - switch stateLoadErr := err.(type) { - // Addresses https://github.com/roboll/helmfile/issues/279 - case *state.StateLoadError: - switch stateLoadErr.Cause.(type) { - case *state.UndefinedEnvError: - return nil - default: - return err - } - default: - return err - } - } - - errs := []error{} - - if len(st.Helmfiles) > 0 { - noMatchInSubHelmfiles := true - for _, m := range st.Helmfiles { - if err := a.VisitDesiredStates(m, converge); err != nil { - switch err.(type) { - case *noMatchingHelmfileError: - - default: - return fmt.Errorf("failed processing %s: %v", m, err) - } - } else { - noMatchInSubHelmfiles = false - } - } - noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles - } else { - var err error - st, err = st.ExecuteTemplates() - if err != nil { - return fmt.Errorf("failed executing release templates in \"%s\": %v", f, err) - } - - var processed bool - processed, errs = converge(st, helm) - noMatchInHelmfiles = noMatchInHelmfiles && !processed - } - - return clean(st, errs) - }) - if err != nil { - return err - } - if noMatchInHelmfiles { - return &noMatchingHelmfileError{selectors: a.selectors, env: a.env} - } - return nil -} - -func (a *app) VisitDesiredStatesWithReleasesFiltered(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) []error) error { - selectors := a.selectors - - err := a.VisitDesiredStates(fileOrDir, func(st *state.HelmState, helm helmexec.Interface) (bool, []error) { - if len(selectors) > 0 { - err := st.FilterReleases(selectors) - if err != nil { - return false, []error{err} - } - } - - releaseNameCounts := map[string]int{} - for _, r := range st.Releases { - releaseNameCounts[r.Name]++ - } - for name, c := range releaseNameCounts { - if c > 1 { - return false, []error{fmt.Errorf("duplicate release \"%s\" found: there were %d releases named \"%s\" matching specified selector", name, c, name)} - } - } - - errs := converge(st, helm) - - processed := len(st.Releases) != 0 && len(errs) == 0 - - return processed, errs - }) - if err != nil { - return err - } - return nil -} - -func (a *app) findStateFilesInAbsPaths(specifiedPath string) ([]string, error) { - rels, err := a.findDesiredStateFiles(specifiedPath) - if err != nil { - return rels, err - } - - files := make([]string, len(rels)) - for i := range rels { - files[i], err = filepath.Abs(rels[i]) - if err != nil { - return []string{}, err - } - } - return files, nil -} - -func (a *app) findDesiredStateFiles(specifiedPath string) ([]string, error) { - var helmfileDir string - if specifiedPath != "" { - if a.fileExistsAt(specifiedPath) { - return []string{specifiedPath}, nil - } else if a.directoryExistsAt(specifiedPath) { - helmfileDir = specifiedPath - } else { - return []string{}, fmt.Errorf("specified state file %s is not found", specifiedPath) - } - } else { - var defaultFile string - if a.fileExistsAt(DefaultHelmfile) { - defaultFile = DefaultHelmfile - } else if a.fileExistsAt(DeprecatedHelmfile) { - log.Printf( - "warn: %s is being loaded: %s is deprecated in favor of %s. See https://github.com/roboll/helmfile/issues/25 for more information", - DeprecatedHelmfile, - DeprecatedHelmfile, - DefaultHelmfile, - ) - defaultFile = DeprecatedHelmfile - } - - if a.directoryExistsAt(DefaultHelmfileDirectory) { - if defaultFile != "" { - return []string{}, fmt.Errorf("configuration conlict error: you can have either %s or %s, but not both", defaultFile, DefaultHelmfileDirectory) - } - - helmfileDir = DefaultHelmfileDirectory - } else if defaultFile != "" { - return []string{defaultFile}, nil - } else { - return []string{}, fmt.Errorf("no state file found. It must be named %s/*.yaml, %s, or %s, or otherwise specified with the --file flag", DefaultHelmfileDirectory, DefaultHelmfile, DeprecatedHelmfile) - } - } - - files, err := a.glob(filepath.Join(helmfileDir, "*.yaml")) - if err != nil { - return []string{}, err - } - sort.Slice(files, func(i, j int) bool { - return files[i] < files[j] - }) - return files, nil -} - -func fileExistsAt(path string) bool { - fileInfo, err := os.Stat(path) - return err == nil && fileInfo.Mode().IsRegular() -} - -func directoryExistsAt(path string) bool { - fileInfo, err := os.Stat(path) - return err == nil && fileInfo.Mode().IsDir() -} - -func (a *app) loadDesiredStateFromYaml(yaml []byte, file string, namespace string, env string) (*state.HelmState, error) { - c := state.NewCreator(a.logger, a.readFile, a.abs) - st, err := c.CreateFromYaml(yaml, file, env) - if err != nil { - return nil, err - } - - helmfiles := []string{} - for _, globPattern := range st.Helmfiles { - helmfileRelativePattern := st.JoinBase(globPattern) - matches, err := a.glob(helmfileRelativePattern) - if err != nil { - return nil, fmt.Errorf("failed processing %s: %v", globPattern, err) - } - sort.Strings(matches) - - helmfiles = append(helmfiles, matches...) - } - st.Helmfiles = helmfiles - - if a.reverse { - rev := func(i, j int) bool { - return j < i - } - sort.Slice(st.Releases, rev) - sort.Slice(st.Helmfiles, rev) - } - - if a.kubeContext != "" { - if st.HelmDefaults.KubeContext != "" { - log.Printf("err: Cannot use option --kube-context and set attribute helmDefaults.kubeContext.") - os.Exit(1) - } - st.HelmDefaults.KubeContext = a.kubeContext - } - if namespace != "" { - if st.Namespace != "" { - log.Printf("err: Cannot use option --namespace and set attribute namespace.") - os.Exit(1) - } - st.Namespace = namespace - } - - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - go func() { - sig := <-sigs - - errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)} - _ = clean(st, errs) - // See http://tldp.org/LDP/abs/html/exitcodes.html - switch sig { - case syscall.SIGINT: - os.Exit(130) - case syscall.SIGTERM: - os.Exit(143) - } - }() - - return st, nil -} - -func clean(st *state.HelmState, errs []error) error { - if errs == nil { - errs = []error{} - } - - cleanErrs := st.Clean() - if cleanErrs != nil { - errs = append(errs, cleanErrs...) - } - - if errs != nil && len(errs) > 0 { - for _, err := range errs { - switch e := err.(type) { - case *state.ReleaseError: - fmt.Printf("err: release \"%s\" in \"%s\" failed: %v\n", e.Name, st.FilePath, e) - default: - fmt.Printf("err: %v\n", e) - } - } - return errs[0] - } - return nil +func findAndIterateOverDesiredStatesUsingFlags(c *cli.Context, converge func(*state.HelmState, helmexec.Interface, app.Context) []error) error { + return cmd.FindAndIterateOverDesiredStatesUsingFlagsWithReverse(c, false, converge) } diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 00000000..9896c859 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,386 @@ +package app + +import ( + "fmt" + "github.com/roboll/helmfile/helmexec" + "github.com/roboll/helmfile/state" + "go.uber.org/zap" + "io/ioutil" + "log" + "os" + "os/signal" + "path/filepath" + "sort" + "syscall" +) + +type App struct { + KubeContext string + Logger *zap.SugaredLogger + Reverse bool + Env string + Namespace string + Selectors []string + + readFile func(string) ([]byte, error) + glob func(string) ([]string, error) + abs func(string) (string, error) + fileExistsAt func(string) bool + directoryExistsAt func(string) bool + + getwd func() (string, error) + chdir func(string) error +} + +func Init(app *App) *App { + app.readFile = ioutil.ReadFile + app.glob = filepath.Glob + app.abs = filepath.Abs + app.getwd = os.Getwd + app.chdir = os.Chdir + app.fileExistsAt = fileExistsAt + app.directoryExistsAt = directoryExistsAt + return app +} + +func (a *App) within(dir string, do func() error) error { + if dir == "." { + return do() + } + + prev, err := a.getwd() + if err != nil { + return fmt.Errorf("failed getting current working direcotyr: %v", err) + } + + absDir, err := a.abs(dir) + if err != nil { + return err + } + + a.Logger.Debugf("changing working directory to \"%s\"", absDir) + + if err := a.chdir(absDir); err != nil { + return fmt.Errorf("failed changing working directory to \"%s\": %v", absDir, err) + } + + appErr := do() + + a.Logger.Debugf("changing working directory back to \"%s\"", prev) + + if chdirBackErr := a.chdir(prev); chdirBackErr != nil { + if appErr != nil { + a.Logger.Warnf("%v", appErr) + } + return fmt.Errorf("failed chaging working directory back to \"%s\": %v", prev, chdirBackErr) + } + + return appErr +} + +func (a *App) visitStateFiles(fileOrDir string, do func(string) error) error { + desiredStateFiles, err := a.findDesiredStateFiles(fileOrDir) + if err != nil { + return err + } + + for _, relPath := range desiredStateFiles { + var file string + var dir string + if a.directoryExistsAt(relPath) { + file = relPath + dir = relPath + } else { + file = filepath.Base(relPath) + dir = filepath.Dir(relPath) + } + + a.Logger.Debugf("processing file \"%s\" in directory \"%s\"", file, dir) + + err := a.within(dir, func() error { + return do(file) + }) + if err != nil { + return err + } + } + + return nil +} + +func (a *App) VisitDesiredStates(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) error { + noMatchInHelmfiles := true + err := a.visitStateFiles(fileOrDir, func(f string) error { + content, err := a.readFile(f) + if err != nil { + return err + } + // render template, in two runs + r := &twoPassRenderer{ + reader: a.readFile, + env: a.Env, + namespace: a.Namespace, + filename: f, + logger: a.Logger, + abs: a.abs, + } + yamlBuf, err := r.renderTemplate(content) + if err != nil { + return fmt.Errorf("error during %s parsing: %v", f, err) + } + + st, err := a.loadDesiredStateFromYaml( + yamlBuf.Bytes(), + f, + a.Namespace, + a.Env, + ) + + helm := helmexec.New(a.Logger, a.KubeContext) + + if err != nil { + switch stateLoadErr := err.(type) { + // Addresses https://github.com/roboll/helmfile/issues/279 + case *state.StateLoadError: + switch stateLoadErr.Cause.(type) { + case *state.UndefinedEnvError: + return nil + default: + return err + } + default: + return err + } + } + + errs := []error{} + + if len(st.Helmfiles) > 0 { + noMatchInSubHelmfiles := true + for _, m := range st.Helmfiles { + if err := a.VisitDesiredStates(m, converge); err != nil { + switch err.(type) { + case *NoMatchingHelmfileError: + + default: + return fmt.Errorf("failed processing %s: %v", m, err) + } + } else { + noMatchInSubHelmfiles = false + } + } + noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles + } else { + var err error + st, err = st.ExecuteTemplates() + if err != nil { + return fmt.Errorf("failed executing release templates in \"%s\": %v", f, err) + } + + var processed bool + processed, errs = converge(st, helm) + noMatchInHelmfiles = noMatchInHelmfiles && !processed + } + + return clean(st, errs) + }) + if err != nil { + return err + } + if noMatchInHelmfiles { + return &NoMatchingHelmfileError{selectors: a.Selectors, env: a.Env} + } + return nil +} + +func (a *App) VisitDesiredStatesWithReleasesFiltered(fileOrDir string, converge func(*state.HelmState, helmexec.Interface) []error) error { + selectors := a.Selectors + + err := a.VisitDesiredStates(fileOrDir, func(st *state.HelmState, helm helmexec.Interface) (bool, []error) { + if len(selectors) > 0 { + err := st.FilterReleases(selectors) + if err != nil { + return false, []error{err} + } + } + + releaseNameCounts := map[string]int{} + for _, r := range st.Releases { + releaseNameCounts[r.Name]++ + } + for name, c := range releaseNameCounts { + if c > 1 { + return false, []error{fmt.Errorf("duplicate release \"%s\" found: there were %d releases named \"%s\" matching specified selector", name, c, name)} + } + } + + errs := converge(st, helm) + + processed := len(st.Releases) != 0 && len(errs) == 0 + + return processed, errs + }) + if err != nil { + return err + } + return nil +} + +func (a *App) findStateFilesInAbsPaths(specifiedPath string) ([]string, error) { + rels, err := a.findDesiredStateFiles(specifiedPath) + if err != nil { + return rels, err + } + + files := make([]string, len(rels)) + for i := range rels { + files[i], err = filepath.Abs(rels[i]) + if err != nil { + return []string{}, err + } + } + return files, nil +} + +func (a *App) findDesiredStateFiles(specifiedPath string) ([]string, error) { + var helmfileDir string + if specifiedPath != "" { + if a.fileExistsAt(specifiedPath) { + return []string{specifiedPath}, nil + } else if a.directoryExistsAt(specifiedPath) { + helmfileDir = specifiedPath + } else { + return []string{}, fmt.Errorf("specified state file %s is not found", specifiedPath) + } + } else { + var defaultFile string + if a.fileExistsAt(DefaultHelmfile) { + defaultFile = DefaultHelmfile + } else if a.fileExistsAt(DeprecatedHelmfile) { + log.Printf( + "warn: %s is being loaded: %s is deprecated in favor of %s. See https://github.com/roboll/helmfile/issues/25 for more information", + DeprecatedHelmfile, + DeprecatedHelmfile, + DefaultHelmfile, + ) + defaultFile = DeprecatedHelmfile + } + + if a.directoryExistsAt(DefaultHelmfileDirectory) { + if defaultFile != "" { + return []string{}, fmt.Errorf("configuration conlict error: you can have either %s or %s, but not both", defaultFile, DefaultHelmfileDirectory) + } + + helmfileDir = DefaultHelmfileDirectory + } else if defaultFile != "" { + return []string{defaultFile}, nil + } else { + return []string{}, fmt.Errorf("no state file found. It must be named %s/*.yaml, %s, or %s, or otherwise specified with the --file flag", DefaultHelmfileDirectory, DefaultHelmfile, DeprecatedHelmfile) + } + } + + files, err := a.glob(filepath.Join(helmfileDir, "*.yaml")) + if err != nil { + return []string{}, err + } + sort.Slice(files, func(i, j int) bool { + return files[i] < files[j] + }) + return files, nil +} + +func fileExistsAt(path string) bool { + fileInfo, err := os.Stat(path) + return err == nil && fileInfo.Mode().IsRegular() +} + +func directoryExistsAt(path string) bool { + fileInfo, err := os.Stat(path) + return err == nil && fileInfo.Mode().IsDir() +} + +func (a *App) loadDesiredStateFromYaml(yaml []byte, file string, namespace string, env string) (*state.HelmState, error) { + c := state.NewCreator(a.Logger, a.readFile, a.abs) + st, err := c.CreateFromYaml(yaml, file, env) + if err != nil { + return nil, err + } + + helmfiles := []string{} + for _, globPattern := range st.Helmfiles { + helmfileRelativePattern := st.JoinBase(globPattern) + matches, err := a.glob(helmfileRelativePattern) + if err != nil { + return nil, fmt.Errorf("failed processing %s: %v", globPattern, err) + } + sort.Strings(matches) + + helmfiles = append(helmfiles, matches...) + } + st.Helmfiles = helmfiles + + if a.Reverse { + rev := func(i, j int) bool { + return j < i + } + sort.Slice(st.Releases, rev) + sort.Slice(st.Helmfiles, rev) + } + + if a.KubeContext != "" { + if st.HelmDefaults.KubeContext != "" { + log.Printf("err: Cannot use option --kube-context and set attribute helmDefaults.kubeContext.") + os.Exit(1) + } + st.HelmDefaults.KubeContext = a.KubeContext + } + if namespace != "" { + if st.Namespace != "" { + log.Printf("err: Cannot use option --namespace and set attribute namespace.") + os.Exit(1) + } + st.Namespace = namespace + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigs + + errs := []error{fmt.Errorf("Received [%s] to shutdown ", sig)} + _ = clean(st, errs) + // See http://tldp.org/LDP/abs/html/exitcodes.html + switch sig { + case syscall.SIGINT: + os.Exit(130) + case syscall.SIGTERM: + os.Exit(143) + } + }() + + return st, nil +} + +func clean(st *state.HelmState, errs []error) error { + if errs == nil { + errs = []error{} + } + + cleanErrs := st.Clean() + if cleanErrs != nil { + errs = append(errs, cleanErrs...) + } + + if errs != nil && len(errs) > 0 { + for _, err := range errs { + switch e := err.(type) { + case *state.ReleaseError: + fmt.Printf("err: release \"%s\" in \"%s\" failed: %v\n", e.Name, st.FilePath, e) + default: + fmt.Printf("err: %v\n", e) + } + } + return errs[0] + } + return nil +} diff --git a/app_test.go b/pkg/app/app_test.go similarity index 89% rename from app_test.go rename to pkg/app/app_test.go index 3869ab80..ac220c5c 100644 --- a/app_test.go +++ b/pkg/app/app_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" @@ -18,12 +18,12 @@ type testFs struct { files map[string]string } -func appWithFs(app *app, files map[string]string) *app { +func appWithFs(app *App, files map[string]string) *App { fs := newTestFs(files) return injectFs(app, fs) } -func injectFs(app *app, fs *testFs) *app { +func injectFs(app *App, fs *testFs) *App { app.readFile = fs.readFile app.glob = fs.glob app.abs = fs.abs @@ -159,12 +159,12 @@ releases: } for _, testcase := range testcases { - app := appWithFs(&app{ - kubeContext: "default", - logger: helmexec.NewLogger(os.Stderr, "debug"), - selectors: []string{fmt.Sprintf("name=%s", testcase.name)}, - namespace: "", - env: "default", + app := appWithFs(&App{ + KubeContext: "default", + Logger: helmexec.NewLogger(os.Stderr, "debug"), + Selectors: []string{fmt.Sprintf("name=%s", testcase.name)}, + Namespace: "", + Env: "default", }, files) err := app.VisitDesiredStatesWithReleasesFiltered( "helmfile.yaml", noop, @@ -210,12 +210,12 @@ releases: } for _, testcase := range testcases { - app := appWithFs(&app{ - kubeContext: "default", - logger: helmexec.NewLogger(os.Stderr, "debug"), - namespace: "", - selectors: []string{}, - env: testcase.name, + app := appWithFs(&App{ + KubeContext: "default", + Logger: helmexec.NewLogger(os.Stderr, "debug"), + Namespace: "", + Selectors: []string{}, + Env: testcase.name, }, files) err := app.VisitDesiredStatesWithReleasesFiltered( "helmfile.yaml", noop, @@ -285,12 +285,12 @@ releases: return []error{} } - app := appWithFs(&app{ - kubeContext: "default", - logger: helmexec.NewLogger(os.Stderr, "debug"), - namespace: "", - selectors: []string{testcase.label}, - env: "default", + app := appWithFs(&App{ + KubeContext: "default", + Logger: helmexec.NewLogger(os.Stderr, "debug"), + Namespace: "", + Selectors: []string{testcase.label}, + Env: "default", }, files) err := app.VisitDesiredStatesWithReleasesFiltered( @@ -356,13 +356,13 @@ releases: } return []error{} } - app := appWithFs(&app{ - kubeContext: "default", - logger: helmexec.NewLogger(os.Stderr, "debug"), - reverse: testcase.reverse, - namespace: "", - selectors: []string{}, - env: "default", + app := appWithFs(&App{ + KubeContext: "default", + Logger: helmexec.NewLogger(os.Stderr, "debug"), + Reverse: testcase.reverse, + Namespace: "", + Selectors: []string{}, + Env: "default", }, files) err := app.VisitDesiredStatesWithReleasesFiltered( "helmfile.yaml", collectReleases, @@ -389,12 +389,12 @@ func TestLoadDesiredStateFromYaml_DuplicateReleaseName(t *testing.T) { labels: stage: post `) - app := &app{ + app := &App{ readFile: ioutil.ReadFile, glob: filepath.Glob, abs: filepath.Abs, - kubeContext: "default", - logger: logger, + KubeContext: "default", + Logger: helmexec.NewLogger(os.Stderr, "debug"), } _, err := app.loadDesiredStateFromYaml(yamlContent, yamlFile, "default", "default") if err != nil { diff --git a/pkg/app/constants.go b/pkg/app/constants.go new file mode 100644 index 00000000..a237394f --- /dev/null +++ b/pkg/app/constants.go @@ -0,0 +1,7 @@ +package app + +const ( + DefaultHelmfile = "helmfile.yaml" + DeprecatedHelmfile = "charts.yaml" + DefaultHelmfileDirectory = "helmfile.d" +) diff --git a/pkg/app/context.go b/pkg/app/context.go new file mode 100644 index 00000000..6ae381cd --- /dev/null +++ b/pkg/app/context.go @@ -0,0 +1,33 @@ +package app + +import "github.com/roboll/helmfile/state" + +type Context struct { + updatedRepos map[string]struct{} +} + +func NewContext() Context { + return Context{ + updatedRepos: map[string]struct{}{}, + } +} + +func (ctx Context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) []error { + var errs []error + + allUpdated := true + for _, r := range st.Repositories { + _, exists := ctx.updatedRepos[r.Name] + allUpdated = allUpdated && exists + } + + if !allUpdated { + errs = st.SyncRepos(helm) + + for _, r := range st.Repositories { + ctx.updatedRepos[r.Name] = struct{}{} + } + } + + return errs +} diff --git a/pkg/app/errors.go b/pkg/app/errors.go new file mode 100644 index 00000000..2e24a3e8 --- /dev/null +++ b/pkg/app/errors.go @@ -0,0 +1,19 @@ +package app + +import ( + "fmt" + "strings" +) + +type NoMatchingHelmfileError struct { + selectors []string + env string +} + +func (e *NoMatchingHelmfileError) Error() string { + return fmt.Sprintf( + "err: no releases found that matches specified selector(%s) and environment(%s), in any helmfile", + strings.Join(e.selectors, ", "), + e.env, + ) +} diff --git a/pkg/app/two_pass_renderer.go b/pkg/app/two_pass_renderer.go new file mode 100644 index 00000000..4bfaec50 --- /dev/null +++ b/pkg/app/two_pass_renderer.go @@ -0,0 +1,80 @@ +package app + +import ( + "bytes" + "fmt" + "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/state" + "github.com/roboll/helmfile/tmpl" + "go.uber.org/zap" + "path/filepath" + "strings" +) + +func prependLineNumbers(text string) string { + buf := bytes.NewBufferString("") + lines := strings.Split(text, "\n") + for i, line := range lines { + buf.WriteString(fmt.Sprintf("%2d: %s\n", i, line)) + } + return buf.String() +} + +type twoPassRenderer struct { + reader func(string) ([]byte, error) + env string + namespace string + filename string + logger *zap.SugaredLogger + abs func(string) (string, error) +} + +func (r *twoPassRenderer) renderEnvironment(content []byte) environment.Environment { + firstPassEnv := environment.Environment{Name: r.env, Values: map[string]interface{}(nil)} + tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} + firstPassRenderer := tmpl.NewFirstPassRenderer(filepath.Dir(r.filename), tmplData) + + // parse as much as we can, tolerate errors, this is a preparse + yamlBuf, err := firstPassRenderer.RenderTemplateContentToBuffer(content) + if err != nil && r.logger != nil { + r.logger.Debugf("first-pass rendering input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) + if yamlBuf == nil { // we have a template syntax error, let the second parse report + r.logger.Debugf("template syntax error: %v", err) + return firstPassEnv + } + } + c := state.NewCreator(r.logger, r.reader, r.abs) + c.Strict = false + // create preliminary state, as we may have an environment. Tolerate errors. + prestate, err := c.CreateFromYaml(yamlBuf.Bytes(), r.filename, r.env) + if err != nil && r.logger != nil { + switch err.(type) { + case *state.StateLoadError: + r.logger.Infof("could not deduce `environment:` block, configuring only .Environment.Name. error: %v", err) + } + r.logger.Debugf("error in first-pass rendering: result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) + } + if prestate != nil { + firstPassEnv = prestate.Env + } + return firstPassEnv +} + +func (r *twoPassRenderer) renderTemplate(content []byte) (*bytes.Buffer, error) { + // try a first pass render. This will always succeed, but can produce a limited env + firstPassEnv := r.renderEnvironment(content) + + tmplData := state.EnvironmentTemplateData{Environment: firstPassEnv, Namespace: r.namespace} + secondPassRenderer := tmpl.NewFileRenderer(r.reader, filepath.Dir(r.filename), tmplData) + yamlBuf, err := secondPassRenderer.RenderTemplateContentToBuffer(content) + if err != nil { + if r.logger != nil { + r.logger.Debugf("second-pass rendering failed, input of \"%s\":\n%s", r.filename, prependLineNumbers(string(content))) + } + return nil, err + } + if r.logger != nil { + r.logger.Debugf("second-pass rendering result of \"%s\":\n%s", r.filename, prependLineNumbers(yamlBuf.String())) + } + return yamlBuf, nil +} diff --git a/main_test.go b/pkg/app/two_pass_renderer_test.go similarity index 99% rename from main_test.go rename to pkg/app/two_pass_renderer_test.go index 10280219..917b6a41 100644 --- a/main_test.go +++ b/pkg/app/two_pass_renderer_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" diff --git a/state/state.go b/state/state.go index 6697f572..aa68316e 100644 --- a/state/state.go +++ b/state/state.go @@ -830,6 +830,23 @@ func (st *HelmState) UpdateDeps(helm helmexec.Interface) []error { return nil } +// BuildDeps wrapper for building dependencies on the releases +func (st *HelmState) BuildDeps(helm helmexec.Interface) []error { + errs := []error{} + + for _, release := range st.Releases { + if isLocalChart(release.Chart) { + if err := helm.BuildDeps(normalizeChart(st.basePath, release.Chart)); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) != 0 { + return errs + } + return nil +} + // JoinBase returns an absolute path in the form basePath/relative func (st *HelmState) JoinBase(relPath string) string { return filepath.Join(st.basePath, relPath) diff --git a/state/state_test.go b/state/state_test.go index 46cdeb7a..914baaf9 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -524,6 +524,14 @@ func (helm *mockHelmExec) UpdateDeps(chart string) error { return nil } +func (helm *mockHelmExec) BuildDeps(chart string) error { + if strings.Contains(chart, "error") { + return errors.New("error") + } + helm.charts = append(helm.charts, chart) + return nil +} + func (helm *mockHelmExec) SetExtraArgs(args ...string) { return } diff --git a/test/integration/run.sh b/test/integration/run.sh index e16ad880..d49ca07b 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -55,14 +55,32 @@ trap "{ $kubectl delete namespace ${test_ns}; }" EXIT # remove namespace wheneve # TEST CASES---------------------------------------------------------------------------------------------------------- test_start "happypath - simple rollout of httpbin chart" + +info "Diffing ${dir}/happypath.yaml" +helmfile -f ${dir}/happypath.yaml diff --detailed-exitcode +code=$? +[ ${code} -eq 2 ] || fail "unexpected exit code returned by helmfile diff: ${code}" + +info "Templating ${dir}/happypath.yaml" +helmfile -f ${dir}/happypath.yaml template +code=$? +[ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile template: ${code}" + info "Syncing ${dir}/happypath.yaml" ${helmfile} -f ${dir}/happypath.yaml sync wait_deploy_ready httpbin-httpbin retry 5 "curl --fail $(minikube service --url --namespace=${test_ns} httpbin-httpbin)/status/200" [ ${retry_result} -eq 0 ] || fail "httpbin failed to return 200 OK" + +info "Applying ${dir}/happypath.yaml" +helmfile -f ${dir}/happypath.yaml apply +code=$? +[ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile apply: ${code}" + info "Deleting release" ${helmfile} -f ${dir}/happypath.yaml delete ${helm} status --namespace=${test_ns} httpbin &> /dev/null && fail "release should not exist anymore after a delete" + test_pass "happypath"