feat: `prepare` and `cleanup` release event hooks (#349)

Resolves #295
Resolves #330
Resolves #329 (Supports templating of only `releases[].hooks[].command` and `args` right now
Resolves #324
This commit is contained in:
KUOKA Yusuke 2018-09-21 10:35:12 +09:00 committed by GitHub
parent 4da13b44ee
commit b9de22b256
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 433 additions and 4 deletions

104
README.md
View File

@ -593,6 +593,110 @@ mysetting: |
The possibility is endless. Try importing values from your golang app, bash script, jsonnet, or anything! 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 ## 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: helmfile itself doesn't have an ability to load env files. But you can write some bash script to achieve the goal:

101
event/bus.go Normal file
View File

@ -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
}

113
event/bus_test.go Normal file
View File

@ -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)
}
}
}
}

View File

@ -15,10 +15,13 @@ type Runner interface {
} }
// ShellRunner implemention for shell commands // ShellRunner implemention for shell commands
type ShellRunner struct{} type ShellRunner struct {
Dir string
}
// Execute a shell command // Execute a shell command
func (shell ShellRunner) Execute(cmd string, args []string) ([]byte, error) { func (shell ShellRunner) Execute(cmd string, args []string) ([]byte, error) {
preparedCmd := exec.Command(cmd, args...) preparedCmd := exec.Command(cmd, args...)
preparedCmd.Dir = shell.Dir
return preparedCmd.CombinedOutput() return preparedCmd.CombinedOutput()
} }

13
main.go
View File

@ -209,6 +209,10 @@ func main() {
}, },
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
return findAndIterateOverDesiredStatesUsingFlags(c, func(state *state.HelmState, helm helmexec.Interface) []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) return executeTemplateCommand(c, state, helm)
}) })
}, },
@ -240,6 +244,9 @@ func main() {
if errs := state.SyncRepos(helm); errs != nil && len(errs) > 0 { if errs := state.SyncRepos(helm); errs != nil && len(errs) > 0 {
return errs return errs
} }
if errs := state.PrepareRelease(helm, "lint"); errs != nil && len(errs) > 0 {
return errs
}
return state.LintReleases(helm, values, args, workers) return state.LintReleases(helm, values, args, workers)
}) })
}, },
@ -268,6 +275,9 @@ func main() {
if errs := state.SyncRepos(helm); errs != nil && len(errs) > 0 { if errs := state.SyncRepos(helm); errs != nil && len(errs) > 0 {
return errs 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 { if errs := state.UpdateDeps(helm); errs != nil && len(errs) > 0 {
return errs return errs
} }
@ -313,6 +323,9 @@ func main() {
return errs 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 { if errs := st.UpdateDeps(helm); errs != nil && len(errs) > 0 {
return errs return errs
} }

View File

@ -19,6 +19,7 @@ import (
"syscall" "syscall"
"github.com/roboll/helmfile/environment" "github.com/roboll/helmfile/environment"
"github.com/roboll/helmfile/event"
"github.com/roboll/helmfile/valuesfile" "github.com/roboll/helmfile/valuesfile"
"go.uber.org/zap" "go.uber.org/zap"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
@ -42,6 +43,8 @@ type HelmState struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
readFile func(string) ([]byte, error) readFile func(string) ([]byte, error)
runner helmexec.Runner
} }
// HelmSpec to defines helmDefault values // HelmSpec to defines helmDefault values
@ -91,6 +94,9 @@ type ReleaseSpec struct {
// Installed, when set to true, `delete --purge` the release // Installed, when set to true, `delete --purge` the release
Installed *bool `yaml:"installed"` 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 is the name of this release
Name string `yaml:"name"` Name string `yaml:"name"`
Namespace string `yaml:"namespace"` Namespace string `yaml:"namespace"`
@ -175,6 +181,7 @@ func (state *HelmState) prepareSyncReleases(helm helmexec.Interface, additionalV
go func() { go func() {
for release := range jobs { for release := range jobs {
state.applyDefaultsTo(release) state.applyDefaultsTo(release)
flags, flagsErr := state.flagsForUpgrade(helm, release) flags, flagsErr := state.flagsForUpgrade(helm, release)
if flagsErr != nil { if flagsErr != nil {
results <- syncPrepareResult{errors: []*ReleaseError{&ReleaseError{release, flagsErr}}} results <- syncPrepareResult{errors: []*ReleaseError{&ReleaseError{release, flagsErr}}}
@ -282,6 +289,10 @@ func (state *HelmState) SyncReleases(helm helmexec.Interface, additionalValues [
} else { } else {
results <- syncResult{} 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 // 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)) temp := make(map[string]string, len(state.Releases))
type downloadResults struct { type downloadResults struct {
releaseName string releaseName string
@ -387,7 +398,7 @@ func (state *HelmState) TemplateReleases(helm helmexec.Interface, additionalValu
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
temp, errs := state.downloadCharts(helm, dir, workerLimit) temp, errs := state.downloadCharts(helm, dir, workerLimit, "template")
if errs != nil { if errs != nil {
errs = append(errs, err) errs = append(errs, err)
@ -420,6 +431,10 @@ func (state *HelmState) TemplateReleases(helm helmexec.Interface, additionalValu
errs = append(errs, err) errs = append(errs, err)
} }
} }
if _, err := state.triggerCleanupEvent(&release, "template"); err != nil {
state.logger.Warnf("warn: %v\n", err)
}
} }
if len(errs) != 0 { if len(errs) != 0 {
@ -440,7 +455,7 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
temp, errs := state.downloadCharts(helm, dir, workerLimit) temp, errs := state.downloadCharts(helm, dir, workerLimit, "lint")
if errs != nil { if errs != nil {
errs = append(errs, err) errs = append(errs, err)
return errs return errs
@ -472,6 +487,10 @@ func (state *HelmState) LintReleases(helm helmexec.Interface, additionalValues [
errs = append(errs, err) errs = append(errs, err)
} }
} }
if _, err := state.triggerCleanupEvent(&release, "lint"); err != nil {
state.logger.Warnf("warn: %v\n", err)
}
} }
if len(errs) != 0 { if len(errs) != 0 {
@ -617,6 +636,10 @@ func (state *HelmState) DiffReleases(helm helmexec.Interface, additionalValues [
// diff succeeded, found no changes // diff succeeded, found no changes
results <- diffResult{} 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 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 // UpdateDeps wrapper for updating dependencies on the releases
func (state *HelmState) UpdateDeps(helm helmexec.Interface) []error { func (state *HelmState) UpdateDeps(helm helmexec.Interface) []error {
errs := []error{} errs := []error{}

30
tmpl/text.go Normal file
View File

@ -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
}