Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: prepare and cleanup release event hooks #349

Merged
merged 1 commit into from
Sep 21, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a technical reason you didn't go with this the structure proposed in #330?:

 hooks:
    preChartLoad: # register as many commands as you want to this hook
    - command: {{` ./chartify -e {{ .Environment.Name }} {{ .Chart }} `}}

I see that you would want to register the same command for a specific hook, but that seems to be over generic (I believe it is more common to have differing calls on each event, than the same call on multiple events).

YAML anchors could come handy if you have the same command on multiple events.

The preChartLoad schema is verifiable at load time.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Indeed my intention was to make it even more generic with (hopefully) comparable complexity to the third option in my proposal.

I believe it is more common to have differing calls on each event, than the same call on multiple events

I think so too. My intention here was to provide the most DRY way to support this use-case:

To be able to use the same script to implement multiple hooks the name of the hook should also be passed along.

See #295 (comment) for more context.

The preChartLoad schema is verifiable at load time.

Good point! I was thinking to add validation for unsupported events. That is, events: ["foo"] results in an error like unsupported event to hook: foo.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I wasn't sure if "eventsare the only possible hook target in helmfile. Googling around, the termhook` seems to be used for NOT only for expressing extension points associated to events. For example, something like "action hook" can be considered:

hooks:
- actions: ["lint"]
   command: ...

An event hook reacts on specific event and doesn't have ability to return data from the hook to helmfile.

An action hook, on the other hand, could be used for passing data from the hook to helmfile, in an output format helmfile expects for the specific action. In the above example, the lint action hook can return a specifically formatted json to add lint results that are integrated into the original output of helmfile lint.

Explicitly wording hook targets as events prevents ambiguity and creates future possibility like this.

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:
Expand Down
101 changes: 101 additions & 0 deletions event/bus.go
Original file line number Diff line number Diff line change
@@ -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 changes: 113 additions & 0 deletions event/bus_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
5 changes: 4 additions & 1 deletion helmexec/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 13 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,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)
})
},
Expand Down Expand Up @@ -247,6 +251,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)
})
},
Expand Down Expand Up @@ -275,6 +282,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
}
Expand Down Expand Up @@ -320,6 +330,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
}
Expand Down
Loading