feat: `get` and `getOrNil` template funcs to allow defaulting in templates (#370)

* feat: `get` and `getOrNil` template funcs to allow defaulting in templates

Ref #357

* Add docs about missing keys and default values in templates
This commit is contained in:
KUOKA Yusuke 2018-09-28 11:44:49 +09:00 committed by GitHub
parent 6f0dc6e069
commit 770c3daa5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 239 additions and 1 deletions

View File

@ -443,7 +443,7 @@ releaseName: prod
`values.yaml.gotmpl` `values.yaml.gotmpl`
```yaml ```yaml
domain: {{ .Environment.Values.domain | default "dev.example.com" }} domain: {{ .Environment.Values | getOrNil "my.domain" | default "dev.example.com" }}
``` ```
`helmfile sync` installs `myapp` with the value `domain=dev.example.com`, `helmfile sync` installs `myapp` with the value `domain=dev.example.com`,
@ -704,6 +704,13 @@ Run `helmfile --environment staging sync` and see it results in helmfile running
Voilà! You can mix helm releases that are backed by remote charts, local charts, and even kustomize overlays. Voilà! You can mix helm releases that are backed by remote charts, local charts, and even kustomize overlays.
## Guides
Use the [Helmfile Best Practices Guide](/docs/writing-helmfile.md) to write advanced helmfiles that features:
- Default values
- Layering
## 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:

View File

@ -2,6 +2,34 @@
This guide covers the Helmfiles considered patterns for writing advanced helmfiles. It focuses on how helmfile should be structured and executed. This guide covers the Helmfiles considered patterns for writing advanced helmfiles. It focuses on how helmfile should be structured and executed.
## Missing keys and Default values
helmfile tries its best to inform users for noticing potential mistakes.
One example of how helmfile achieves it is that, `helmfile` fails when you tried to access missing keys in environment values.
That is, the following example let `helmfile` fail when you have no `eventApi.replicas` defined in environment values.
```
{{ .Environment.Values.eventApi.replicas | default 1 }}
```
In case it isn't a mistake and you do want to allow missing keys, use the `getOrNil` template function:
```
{{ .Environment.Values | getOrNil "eventApi.replicas" }}
```
This result in printing `<no value` in your template, that may or may not result in a failure.
If you want a kind of default values that is used when a missing key was referenced, use `default` like:
```
{{ .Environment.Values | getOrNil "eventApi.replicas" | default 1 }}
```
Now, you get `1` when there is no `eventApi.replicas` defined in environment values.
## Layering ## Layering
You may occasionally end up with many helmfiles that shares common parts like which repositories to use, and whichi release to be bundled by default. You may occasionally end up with many helmfiles that shares common parts like which repositories to use, and whichi release to be bundled by default.

View File

@ -22,6 +22,8 @@ func (c *Context) createFuncMap() template.FuncMap {
"fromYaml": FromYaml, "fromYaml": FromYaml,
"setValueAtPath": SetValueAtPath, "setValueAtPath": SetValueAtPath,
"requiredEnv": RequiredEnv, "requiredEnv": RequiredEnv,
"get": get,
"getOrNil": getOrNil,
} }
if c.preRender { if c.preRender {
// disable potential side-effect template calls // disable potential side-effect template calls

60
tmpl/get_or_nil.go Normal file
View File

@ -0,0 +1,60 @@
package tmpl
import (
"fmt"
"reflect"
"strings"
)
type noValueError struct {
msg string
}
func (e *noValueError) Error() string {
return e.msg
}
func get(path string, obj interface{}) (interface{}, error) {
if path == "" {
return obj, nil
}
keys := strings.Split(path, ".")
switch typedObj := obj.(type) {
case map[string]interface{}:
v, ok := typedObj[keys[0]]
if !ok {
return nil, &noValueError{fmt.Sprintf("no value exist for key \"%s\" in %v", keys[0], typedObj)}
}
return get(strings.Join(keys[1:], "."), v)
case map[interface{}]interface{}:
v, ok := typedObj[keys[0]]
if !ok {
return nil, &noValueError{fmt.Sprintf("no value exist for key \"%s\" in %v", keys[0], typedObj)}
}
return get(strings.Join(keys[1:], "."), v)
default:
maybeStruct := reflect.ValueOf(typedObj)
if maybeStruct.NumField() < 1 {
return nil, &noValueError{fmt.Sprintf("unexpected type(%v) of value for key \"%s\": it must be either map[string]interface{} or any struct", reflect.TypeOf(obj), keys[0])}
}
f := maybeStruct.FieldByName(keys[0])
if !f.IsValid() {
return nil, &noValueError{fmt.Sprintf("no field named \"%s\" exist in %v", keys[0], typedObj)}
}
v := f.Interface()
return get(strings.Join(keys[1:], "."), v)
}
}
func getOrNil(path string, o interface{}) (interface{}, error) {
v, err := get(path, o)
if err != nil {
switch err.(type) {
case *noValueError:
return nil, nil
default:
return nil, err
}
}
return v, nil
}

91
tmpl/get_or_nil_test.go Normal file
View File

@ -0,0 +1,91 @@
package tmpl
import (
"testing"
)
func TestGetStruct(t *testing.T) {
type Foo struct{ Bar string }
obj := struct{ Foo }{Foo{Bar: "Bar"}}
v1, err := get("Foo.Bar", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v1 != "Bar" {
t.Errorf("unexpected value for path Foo.Bar in %v: expected=Bar, actual=%v", obj, v1)
}
_, err = get("Foo.baz", obj)
if err == nil {
t.Errorf("expected error but was not occurred")
}
}
func TestGetMap(t *testing.T) {
obj := map[string]interface{}{"Foo": map[string]interface{}{"Bar": "Bar"}}
v1, err := get("Foo.Bar", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v1 != "Bar" {
t.Errorf("unexpected value for path Foo.Bar in %v: expected=Bar, actual=%v", obj, v1)
}
_, err = get("Foo.baz", obj)
if err == nil {
t.Errorf("expected error but was not occurred")
}
}
func TestGetOrNilStruct(t *testing.T) {
type Foo struct{ Bar string }
obj := struct{ Foo }{Foo{Bar: "Bar"}}
v1, err := getOrNil("Foo.Bar", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v1 != "Bar" {
t.Errorf("unexpected value for path Foo.Bar in %v: expected=Bar, actual=%v", obj, v1)
}
v2, err := getOrNil("Foo.baz", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v2 != nil {
t.Errorf("unexpected value for path Foo.baz in %v: expected=nil, actual=%v", obj, v2)
}
}
func TestGetOrNilMap(t *testing.T) {
obj := map[string]interface{}{"Foo": map[string]interface{}{"Bar": "Bar"}}
v1, err := getOrNil("Foo.Bar", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v1 != "Bar" {
t.Errorf("unexpected value for path Foo.Bar in %v: expected=Bar, actual=%v", obj, v1)
}
v2, err := getOrNil("Foo.baz", obj)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if v2 != nil {
t.Errorf("unexpected value for path Foo.baz in %v: expected=nil, actual=%v", obj, v2)
}
}

View File

@ -60,6 +60,56 @@ func TestRenderTemplate_WithData(t *testing.T) {
} }
} }
func TestRenderTemplate_AccessingMissingKeyWithGetOrNil(t *testing.T) {
valuesYamlContent := `foo:
bar: {{ . | getOrNil "foo.bar" }}
`
expected := `foo:
bar: <no value>
`
expectedFilename := "values.yaml"
data := map[string]interface{}{}
ctx := &Context{readFile: func(filename string) ([]byte, error) {
if filename != expectedFilename {
return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedFilename, filename)
}
return []byte(valuesYamlContent), nil
}}
buf, err := ctx.RenderTemplateToBuffer(valuesYamlContent, data)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := buf.String()
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func TestRenderTemplate_Defaulting(t *testing.T) {
valuesYamlContent := `foo:
bar: {{ . | getOrNil "foo.bar" | default "DEFAULT" }}
`
expected := `foo:
bar: DEFAULT
`
expectedFilename := "values.yaml"
data := map[string]interface{}{}
ctx := &Context{readFile: func(filename string) ([]byte, error) {
if filename != expectedFilename {
return nil, fmt.Errorf("unexpected filename: expected=%v, actual=%s", expectedFilename, filename)
}
return []byte(valuesYamlContent), nil
}}
buf, err := ctx.RenderTemplateToBuffer(valuesYamlContent, data)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
actual := buf.String()
if !reflect.DeepEqual(actual, expected) {
t.Errorf("unexpected result: expected=%v, actual=%v", expected, actual)
}
}
func renderTemplateToString(s string) (string, error) { func renderTemplateToString(s string) (string, error) {
ctx := &Context{readFile: func(filename string) ([]byte, error) { ctx := &Context{readFile: func(filename string) ([]byte, error) {
return nil, fmt.Errorf("unexpected call to readFile: filename=%s", filename) return nil, fmt.Errorf("unexpected call to readFile: filename=%s", filename)