diff --git a/README.md b/README.md index eb13a0de..8e5d0743 100644 --- a/README.md +++ b/README.md @@ -593,6 +593,110 @@ mysetting: | The possibility is endless. Try importing values from your golang app, bash script, jsonnet, or anything! +## Hooks + +A helmfile hook is a per-release extension point that is composed of: + +- `events` +- `command` +- `args` + +helmfile triggers various `events` while it is running. +Once `events` are triggered, associated `hooks` are executed, by running the `command` with `args`. + +Currently supported `events` are: + +- `prepare` +- `cleanup` + +Hooks associated to `prepare` events are triggered after each release in your helmfile is loaded from YAML, before executed. + +Hooks associated to `cleanup` events are triggered after each release is processed. + +The following is an example hook that just prints the contextual information provided to hook: + +``` +releases: +- name: myapp + chart: mychart + # *snip* + hooks: + - events: ["prepare", "cleanup"] + command: "echo" + args: ["{{`{{.Environment.Name}}`}}", "{{`{{.Release.Name}}`}}", "{{`{{.HelmfileCommand}}`}}\ +"] +``` + +Let's say you ran `helmfile --environment prod sync`, the above hook results in executing: + +``` +echo {{Environment.Name}} {{.Release.Name}} {{.HelmfileCommand}} +``` + +Whereas the template expressions are executed thus the command becomes: + +``` +echo prod myapp sync +``` + +Now, replace `echo` with any command you like, and rewrite `args` that actually conforms to the command, so that you can integrate any command that does: + +- templating +- linting +- testing + +For templating, imagine that you created a hook that generates a helm chart on-the-fly by running an external tool like ksonnet, kustomize, or your own template engine. +It will allow you to write your helm releases with any language you like, while still leveraging goodies provided by helm. + +### Helmfile + Kustomize + +Do you prefer `kustomize` to write and organize your Kubernetes apps, but still want to leverage helm's useful features +like rollback, history, and so on? This section is for you! + +The combination of `hooks` and [helmify-kustomize](https://gist.github.com/mumoshu/f9d0bd98e0eb77f636f79fc2fb130690) +enables you to integrate [kustomize](https://github.com/kubernetes-sigs/kustomize) into helmfle. + +That is, you can use `kustommize` to build a local helm chart from a kustomize overlay. + +Let's assume you have a kustomize project named `foo-kustomize` like this: + +``` +foo-kustomize/ +├── base +│   ├── configMap.yaml +│   ├── deployment.yaml +│   ├── kustomization.yaml +│   └── service.yaml +└── overlays + ├── default + │   ├── kustomization.yaml + │   └── map.yaml + ├── production + │   ├── deployment.yaml + │   └── kustomization.yaml + └── staging + ├── kustomization.yaml + └── map.yaml + +5 directories, 10 files +``` + +Write `helmfile.yaml`: + +```yaml +- name: kustomize + chart: ./foo + hooks: + - events: ["prepare", "cleanup"] + command: "../helmify" + args: ["{{`{{if eq .Event.Name \"prepare\"}}build{{else}}clean{{end}}`}}", "{{`{{.Release.Ch\ +art}}`}}", "{{`{{.Environment.Name}}`}}"] +``` + +Run `helmfile --environment staging sync` and see it results in helmfile running `kustomize build foo-kustomize/overlays/staging > foo/templates/all.yaml`. + +Voilà! You can mix helm releases that are backed by remote charts, local charts, and even kustomize overlays. + ## Using env files helmfile itself doesn't have an ability to load env files. But you can write some bash script to achieve the goal: diff --git a/event/bus.go b/event/bus.go new file mode 100644 index 00000000..39f907a8 --- /dev/null +++ b/event/bus.go @@ -0,0 +1,101 @@ +package event + +import ( + "fmt" + "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/helmexec" + "github.com/roboll/helmfile/tmpl" + "go.uber.org/zap" + "os" +) + +type Hook struct { + Name string `yaml:"name"` + Events []string `yaml:"events"` + Command string `yaml:"command"` + Args []string `yaml:"args"` +} + +type event struct { + Name string +} + +type Bus struct { + Runner helmexec.Runner + Hooks []Hook + + BasePath string + StateFilePath string + Namespace string + + Env environment.Environment + + ReadFile func(string) ([]byte, error) + Logger *zap.SugaredLogger +} + +func (bus *Bus) Trigger(evt string, context map[string]interface{}) (bool, error) { + if bus.Runner == nil { + bus.Runner = helmexec.ShellRunner{ + Dir: bus.BasePath, + } + } + + executed := false + + for _, hook := range bus.Hooks { + contained := false + for _, e := range hook.Events { + contained = contained || e == evt + } + if !contained { + continue + } + + var err error + + name := hook.Name + if name == "" { + name = hook.Command + } + + fmt.Fprintf(os.Stderr, "%s: basePath=%s\n", bus.StateFilePath, bus.BasePath) + + data := map[string]interface{}{ + "Environment": bus.Env, + "Namespace": bus.Namespace, + "Event": event{ + Name: evt, + }, + } + for k, v := range context { + data[k] = v + } + render := tmpl.NewTextRenderer(bus.ReadFile, bus.BasePath, data) + + bus.Logger.Debugf("hook[%s]: triggered by event \"%s\"\n", name, evt) + + command, err := render.RenderTemplateText(hook.Command) + if err != nil { + return false, fmt.Errorf("hook[%s]: %v", name, err) + } + + args := make([]string, len(hook.Args)) + for i, raw := range hook.Args { + args[i], err = render.RenderTemplateText(raw) + if err != nil { + return false, fmt.Errorf("hook[%s]: %v", name, err) + } + } + + bytes, err := bus.Runner.Execute(command, args) + bus.Logger.Debugf("hook[%s]: %s\n", name, string(bytes)) + if err != nil { + return false, fmt.Errorf("hook[%s]: command `%s` failed: %v", name, command, err) + } + + executed = true + } + + return executed, nil +} diff --git a/event/bus_test.go b/event/bus_test.go new file mode 100644 index 00000000..46f9ca96 --- /dev/null +++ b/event/bus_test.go @@ -0,0 +1,113 @@ +package event + +import ( + "fmt" + "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/helmexec" + "os" + "testing" +) + +var logger = helmexec.NewLogger(os.Stdout, "warn") + +type runner struct { +} + +func (r *runner) Execute(cmd string, args []string) ([]byte, error) { + if cmd == "ng" { + return nil, fmt.Errorf("cmd failed due to invalid cmd: %s", cmd) + } + for _, a := range args { + if a == "ng" { + return nil, fmt.Errorf("cmd failed due to invalid arg: %s", a) + } + } + return []byte(""), nil +} + +func TestTrigger(t *testing.T) { + cases := []struct { + name string + hook *Hook + triggeredEvt string + expectedResult bool + expectedErr string + }{ + { + "okhook1", + &Hook{"okhook1", []string{"foo"}, "ok", []string{}}, + "foo", + true, + "", + }, + { + "missinghook1", + &Hook{"okhook1", []string{"foo"}, "ok", []string{}}, + "bar", + false, + "", + }, + { + "nohook1", + nil, + "bar", + false, + "", + }, + { + "nghook1", + &Hook{"nghook1", []string{"foo"}, "ng", []string{}}, + "foo", + false, + "hook[nghook1]: command `ng` failed: cmd failed due to invalid cmd: ng", + }, + { + "nghook2", + &Hook{"nghook2", []string{"foo"}, "ok", []string{"ng"}}, + "foo", + false, + "hook[nghook2]: command `ok` failed: cmd failed due to invalid arg: ng", + }, + } + readFile := func(filename string) ([]byte, error) { + return nil, fmt.Errorf("unexpected call to readFile: %s", filename) + } + for _, c := range cases { + hooks := []Hook{} + if c.hook != nil { + hooks = append(hooks, *c.hook) + } + bus := &Bus{ + Hooks: hooks, + StateFilePath: "path/to/helmfile.yaml", + BasePath: "path/to", + Namespace: "myns", + Env: environment.Environment{Name: "prod"}, + Logger: logger, + ReadFile: readFile, + } + + bus.Runner = &runner{} + data := map[string]interface{}{ + "Release": "myrel", + "HelmfileCommand": "mycmd", + } + ok, err := bus.Trigger(c.triggeredEvt, data) + + if ok != c.expectedResult { + t.Errorf("unexpected result for case \"%s\": expected=%v, actual=%v", c.name, c.expectedResult, ok) + } + + if c.expectedErr != "" { + if err == nil { + t.Error("error expected, but not occurred") + } else if err.Error() != c.expectedErr { + t.Errorf("unexpected error for case \"%s\": expected=%s, actual=%v", c.name, c.expectedErr, err) + } + } else { + if err != nil { + t.Errorf("unexpected error for case \"%s\": %v", c.name, err) + } + } + } +} diff --git a/helmexec/runner.go b/helmexec/runner.go index 9364bdc7..eaccf117 100644 --- a/helmexec/runner.go +++ b/helmexec/runner.go @@ -15,10 +15,13 @@ type Runner interface { } // ShellRunner implemention for shell commands -type ShellRunner struct{} +type ShellRunner struct { + Dir string +} // Execute a shell command func (shell ShellRunner) Execute(cmd string, args []string) ([]byte, error) { preparedCmd := exec.Command(cmd, args...) + preparedCmd.Dir = shell.Dir return preparedCmd.CombinedOutput() } diff --git a/main.go b/main.go index 3fc535fb..84157a15 100644 --- a/main.go +++ b/main.go @@ -209,6 +209,10 @@ func main() { }, Action: func(c *cli.Context) error { return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface) []error { + if errs := state.PrepareRelease(helm, "template"); errs != nil && len(errs) > 0 { + return errs + } + return executeTemplateCommand(c, state, helm) }) }, @@ -240,6 +244,9 @@ func main() { if errs := state.SyncRepos(helm); errs != nil && len(errs) > 0 { return errs } + if errs := state.PrepareRelease(helm, "lint"); errs != nil && len(errs) > 0 { + return errs + } return state.LintReleases(helm, values, args, workers) }) }, @@ -268,6 +275,9 @@ func main() { if errs := state.SyncRepos(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 } @@ -313,6 +323,9 @@ func main() { 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 } diff --git a/state/state.go b/state/state.go index 8f4befa1..e0a3cf45 100644 --- a/state/state.go +++ b/state/state.go @@ -19,6 +19,7 @@ import ( "syscall" "github.com/roboll/helmfile/environment" + "github.com/roboll/helmfile/event" "github.com/roboll/helmfile/valuesfile" "go.uber.org/zap" "gopkg.in/yaml.v2" @@ -42,6 +43,8 @@ type HelmState struct { logger *zap.SugaredLogger readFile func(string) ([]byte, error) + + runner helmexec.Runner } // HelmSpec to defines helmDefault values @@ -91,6 +94,9 @@ type ReleaseSpec struct { // Installed, when set to true, `delete --purge` the release Installed *bool `yaml:"installed"` + // Hooks is a list of extension points paired with operations, that are executed in specific points of the lifecycle of releases defined in helmfile + Hooks []event.Hook `yaml:"hooks"` + // Name is the name of this release Name string `yaml:"name"` Namespace string `yaml:"namespace"` @@ -175,6 +181,7 @@ func (state *HelmState) prepareSyncReleases(helm helmexec.Interface, additionalV go func() { for release := range jobs { state.applyDefaultsTo(release) + flags, flagsErr := state.flagsForUpgrade(helm, release) if flagsErr != nil { results <- syncPrepareResult{errors: []*ReleaseError{&ReleaseError{release, flagsErr}}} @@ -282,6 +289,10 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues [ } else { results <- syncResult{} } + + if _, err := state.triggerCleanupEvent(prep.release, "sync"); err != nil { + state.logger.Warnf("warn: %v\n", err) + } } }() } @@ -312,7 +323,7 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues [ } // downloadCharts will download and untar charts for Lint and Template -func (state *HelmState) downloadCharts(helm helmexec.Interface, dir string, workerLimit int) (map[string]string, []error) { +func (state *HelmState) downloadCharts(helm helmexec.Interface, dir string, workerLimit int, helmfileCommand string) (map[string]string, []error) { temp := make(map[string]string, len(state.Releases)) type downloadResults struct { releaseName string @@ -387,7 +398,7 @@ func (state *HelmState) TemplateReleases(helm helmexec.Interface, additionalValu } defer os.RemoveAll(dir) - temp, errs := state.downloadCharts(helm, dir, workerLimit) + temp, errs := state.downloadCharts(helm, dir, workerLimit, "template") if errs != nil { errs = append(errs, err) @@ -420,6 +431,10 @@ func (state *HelmState) TemplateReleases(helm helmexec.Interface, additionalValu errs = append(errs, err) } } + + if _, err := state.triggerCleanupEvent(&release, "template"); err != nil { + state.logger.Warnf("warn: %v\n", err) + } } if len(errs) != 0 { @@ -440,7 +455,7 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [ } defer os.RemoveAll(dir) - temp, errs := state.downloadCharts(helm, dir, workerLimit) + temp, errs := state.downloadCharts(helm, dir, workerLimit, "lint") if errs != nil { errs = append(errs, err) return errs @@ -472,6 +487,10 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [ errs = append(errs, err) } } + + if _, err := state.triggerCleanupEvent(&release, "lint"); err != nil { + state.logger.Warnf("warn: %v\n", err) + } } if len(errs) != 0 { @@ -617,6 +636,10 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues [ // diff succeeded, found no changes results <- diffResult{} } + + if _, err := state.triggerCleanupEvent(prep.release, "diff"); err != nil { + state.logger.Warnf("warn: %v\n", err) + } } }() } @@ -799,6 +822,48 @@ func (state *HelmState) FilterReleases(labels []string) error { return nil } +func (state *HelmState) PrepareRelease(helm helmexec.Interface, helmfileCommand string) []error { + errs := []error{} + + for _, release := range state.Releases { + if isLocalChart(release.Chart) { + if _, err := state.triggerPrepareEvent(&release, helmfileCommand); err != nil { + errs = append(errs, &ReleaseError{&release, err}) + continue + } + } + } + if len(errs) != 0 { + return errs + } + return nil +} + +func (state *HelmState) triggerPrepareEvent(r *ReleaseSpec, helmfileCommand string) (bool, error) { + return state.triggerReleaseEvent("prepare", r, helmfileCommand) +} + +func (state *HelmState) triggerCleanupEvent(r *ReleaseSpec, helmfileCommand string) (bool, error) { + return state.triggerReleaseEvent("cleanup", r, helmfileCommand) +} + +func (state *HelmState) triggerReleaseEvent(evt string, r *ReleaseSpec, helmfileCmd string) (bool, error) { + bus := &event.Bus{ + Hooks: r.Hooks, + StateFilePath: state.FilePath, + BasePath: state.basePath, + Namespace: state.Namespace, + Env: state.Env, + Logger: state.logger, + ReadFile: state.readFile, + } + data := map[string]interface{}{ + "Release": r, + "HelmfileCommand": helmfileCmd, + } + return bus.Trigger(evt, data) +} + // UpdateDeps wrapper for updating dependencies on the releases func (state *HelmState) UpdateDeps(helm helmexec.Interface) []error { errs := []error{} diff --git a/tmpl/text.go b/tmpl/text.go new file mode 100644 index 00000000..4415a414 --- /dev/null +++ b/tmpl/text.go @@ -0,0 +1,30 @@ +package tmpl + +type templateTextRenderer struct { + ReadText func(string) ([]byte, error) + Context *Context + Data interface{} +} + +type TextRenderer interface { + RenderTemplateText(text string) (string, error) +} + +func NewTextRenderer(readFile func(filename string) ([]byte, error), basePath string, data interface{}) *templateTextRenderer { + return &templateTextRenderer{ + ReadText: readFile, + Context: &Context{ + basePath: basePath, + readFile: readFile, + }, + Data: data, + } +} + +func (r *templateTextRenderer) RenderTemplateText(text string) (string, error) { + buf, err := r.Context.RenderTemplateToBuffer(text, r.Data) + if err != nil { + return "", err + } + return buf.String(), nil +}