diff --git a/engine/shell.go b/engine/shell.go index eacadb7..94de712 100644 --- a/engine/shell.go +++ b/engine/shell.go @@ -8,11 +8,27 @@ import ( ) type ShellTemplate struct { - content []byte + content []byte + ignoreUnset bool } -func NewShellTemplate(template []byte) (Template, error) { - return ShellTemplate{template}, nil +type ShellTemplateOption = func(*ShellTemplate) error + +func ShellTemplateIgnoreUnset() ShellTemplateOption { + return func(t *ShellTemplate) error { + t.ignoreUnset = true + return nil + } +} + +func NewShellTemplate(template []byte, options ...ShellTemplateOption) (Template, error) { + tpl := ShellTemplate{template, false} + for _, option := range options { + if err := option(&tpl); err != nil { + return nil, err + } + } + return tpl, nil } func (t ShellTemplate) Render(data map[string]interface{}) (res []byte, err error) { @@ -31,14 +47,14 @@ func (t ShellTemplate) Render(data map[string]interface{}) (res []byte, err erro return nil, err } } - r, err := envsubst(string(t.content), data) + r, err := envsubst(string(t.content), data, t.ignoreUnset) if err != nil { return nil, err } return []byte(r), nil } -func envsubst(value string, env map[string]interface{}) (res string, err error) { +func envsubst(value string, env map[string]interface{}, ignoreUnset bool) (res string, err error) { defer func() { if r := recover(); r != nil { if _, ok := r.(runtime.Error); ok { @@ -47,23 +63,26 @@ func envsubst(value string, env map[string]interface{}) (res string, err error) err = r.(error) } }() - res = expandWithLineColumnInfo(value, func(key string, line int, col int) string { + res = expandWithLineColumnInfo(value, func(key string, line int, col int) (string, bool) { if key == "$" || key == "" { - return "$" + return "$", true } value, ok := env[key] if !ok || value == nil { + if ignoreUnset { + return "", false + } panic(fmt.Errorf("%d:%d: \"%s\" isn't set", line, col, key)) } if !yamlext.IsBasicType(value) { panic(fmt.Errorf("%d:%d: \"%s\" must be either a string, number or a boolean", line, col, key)) } - return fmt.Sprintf("%v", value) + return fmt.Sprintf("%v", value), true }) return } -func expandWithLineColumnInfo(s string, mapping func(string, int, int) string) string { +func expandWithLineColumnInfo(s string, mapping func(string, int, int) (string, bool)) string { buf := make([]byte, 0, 2*len(s)) i, l, n := 0, 0, 0 for j := 0; j < len(s); j++ { @@ -73,7 +92,11 @@ func expandWithLineColumnInfo(s string, mapping func(string, int, int) string) s } else if s[j] == '$' && j+1 < len(s) { buf = append(buf, s[i:j]...) name, w := getShellName(s[j+1:]) - buf = append(buf, mapping(name, l+1, j-n+1)...) + if v, ok := mapping(name, l+1, j-n+1); ok { + buf = append(buf, v...) + } else { + buf = append(buf, s[j:j+1+w]...) + } j += w i = j + 1 } diff --git a/engine/shell_test.go b/engine/shell_test.go index c0554ac..9c89c78 100644 --- a/engine/shell_test.go +++ b/engine/shell_test.go @@ -11,7 +11,7 @@ func init() { func TestShellTemplateRender(t *testing.T) { actual, err := ShellTemplate{ - []byte(`# kubetpl:syntax:$ + content: []byte(`# kubetpl:syntax:$ apiVersion: apps/v1beta1 kind: Deployment metadata: @@ -22,6 +22,7 @@ metadata: spec: replicas: $REPLICAS `), + ignoreUnset: false, }.Render(map[string]interface{}{ "NAME": "app", "NOT_USED": "value", @@ -48,11 +49,12 @@ spec: func TestShellTemplateRenderIncomplete(t *testing.T) { _, err := ShellTemplate{ - []byte(`apiVersion: apps/v1beta1 + content: []byte(`apiVersion: apps/v1beta1 kind: Deployment metadata: name: $NAME-deployment `), + ignoreUnset: false, }.Render(map[string]interface{}{ "NOT_USED": "value", }) @@ -64,3 +66,35 @@ metadata: t.Fatalf("actual: \n%s != expected: \n%s", err.Error(), expected) } } + +func TestShellTemplateRenderUnresolved(t *testing.T) { + actual, err := ShellTemplate{ + content: []byte(`apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: $NAME-deployment + annotations: + replicas-as-string: "${REPLICAS}" # $ $$ test +spec: + replicas: $REPLICAS +`), + ignoreUnset: true, + }.Render(map[string]interface{}{ + "NAME": "app", + }) + if err != nil { + t.Fatal(err) + } + expected := `apiVersion: apps/v1beta1 +kind: Deployment +metadata: + name: app-deployment + annotations: + replicas-as-string: "${REPLICAS}" # $ $ test +spec: + replicas: $REPLICAS +` + if string(actual) != expected { + t.Fatalf("actual: \n%s != expected: \n%s", string(actual), expected) + } +} diff --git a/kubetpl.go b/kubetpl.go index 43d8fec..1392b5b 100644 --- a/kubetpl.go +++ b/kubetpl.go @@ -53,7 +53,7 @@ func main() { } var syntax, chroot string var configFiles, configKeyValuePairs, freezeRefs, freezeList []string - var allowFsAccess, freeze bool + var allowFsAccess, ignoreUnset, freeze bool rootCmd := &cobra.Command{ Use: "kubetpl", Long: "Kubernetes templates made easy (https://github.com/shyiko/kubetpl).", @@ -133,6 +133,7 @@ func main() { freeze: freeze, freezeRefs: freezeRefs, freezeList: normalizedFreezeList, + ignoreUnset: ignoreUnset, }) if err != nil { log.Fatal(err) @@ -153,6 +154,7 @@ func main() { " kubetpl render template.yml -i staging.env -s KEY=VALUE", } renderCmd.Flags().BoolVarP(&freeze, "freeze", "z", false, "Freeze ConfigMap/Secret|s") + renderCmd.Flags().BoolVar(&ignoreUnset, "ignore-unset", false, "Keep $VAR/${VAR} if not set (e.g. \"echo 'kind: $A$B' | kubetpl r - -s A=X --syntax=$ --ignore-unset\" prints \"kind: X$B\")") renderCmd.Flags().StringArrayVar(&freezeRefs, "freeze-ref", nil, "External ConfigMap/Secret|s that should not be included in the output and yet references to which need to be '--freeze'd") renderCmd.Flags().StringSliceVar(&freezeList, "freeze-list", nil, @@ -265,6 +267,7 @@ type renderOpts struct { freeze bool freezeRefs []string freezeList []string + ignoreUnset bool } func render(templateFiles []string, data map[string]interface{}, opts renderOpts) ([]byte, error) { @@ -349,7 +352,7 @@ func renderTemplates(templateFiles []string, config map[string]interface{}, opts } func renderTemplate(templateFile string, config map[string]interface{}, opts renderOpts) ([]document, error) { - t, directives, err := newTemplate(templateFile, opts.format) + t, directives, err := newTemplate(templateFile, opts.format, opts.ignoreUnset) if err != nil { return nil, err } @@ -425,7 +428,7 @@ func dirnameAbs(path string) (string, error) { return filepath.Abs(filepath.Dir(path)) } -func newTemplate(file string, flavor string) (engine.Template, []directive, error) { +func newTemplate(file string, flavor string, ignoreUnset bool) (engine.Template, []directive, error) { content, err := readFile(file) if err != nil { return nil, nil, err @@ -455,7 +458,11 @@ func newTemplate(file string, flavor string) (engine.Template, []directive, erro var t engine.Template switch flavor { case "$": - t, err = engine.NewShellTemplate(content) + var opts []engine.ShellTemplateOption + if ignoreUnset { + opts = append(opts, engine.ShellTemplateIgnoreUnset()) + } + t, err = engine.NewShellTemplate(content, opts...) case "go-template": t, err = engine.NewGoTemplate(content, file) case "template-kind":