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:
parent
4da13b44ee
commit
b9de22b256
104
README.md
104
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
13
main.go
13
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue