diff --git a/Readme.md b/Readme.md index b308567..407d641 100644 --- a/Readme.md +++ b/Readme.md @@ -220,6 +220,20 @@ variables: stage: bastion-stage ``` +The variables section does also interpolate itself with its own data via `{{ .var }}` and allows shell like command +expressions via `$(echo true)` to be executed first providing the output result as a variable. Note that variables are +interpolated first and then command expressions are evaluated. This will allow you to reduce repetitive variable definitions and declarations. + +````bash +hash: + summary: echos the git {{ .branch }} branch's git hash + command: echo {{ .branch }} {{ .githash }} + +variables: + branch: master + githash: $(git rev-parse --short {{ .branch }}) +```` + Along with your own custom variables, robo defines the following variables: ```bash diff --git a/cli/cli.go b/cli/cli.go index afb3b6d..b077c2a 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "text/template" "github.com/fatih/color" @@ -30,11 +31,9 @@ var list = ` // Variables template. var variables = ` -{{- range $parent, $v := .Variables}} -{{- range $k, $v := $v }} - {{cyan "%s.%s" $parent $k }}: {{$v}} -{{- end}} -{{end}} +{{- range $k, $v := . }} +{{cyan "%s" $k }}: {{$v}} +{{- end }} ` // Help template. @@ -62,7 +61,8 @@ func ListVariables(c *config.Config) { tmpl = t(c.Templates.Variables) } - tmpl.Execute(os.Stdout, c) + flattened := flatten("", reflect.ValueOf(c.Variables)) + tmpl.Execute(os.Stdout, flattened) } // List outputs the tasks defined. @@ -118,3 +118,23 @@ func Fatalf(msg string, args ...interface{}) { func t(s string) *template.Template { return template.Must(template.New("").Funcs(helpers).Parse(s)) } + +// flatten reduces a given map into a flattened map of strings having the path to a variable as a key +// and the actual value as a value. Resulting in ".path.to.key: value" +func flatten(key string, v reflect.Value) map[string]string { + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + v = v.Elem() + } + m := map[string]string{} + switch v.Kind() { + case reflect.Map: + for _, k := range v.MapKeys() { + for k, v := range flatten(key+"."+fmt.Sprintf("%v", k), v.MapIndex(k)) { + m[k] = v + } + } + default: + m[key] = v.String() + } + return m +} \ No newline at end of file diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..040fb32 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "github.com/bmizerany/assert" + "reflect" + "testing" +) + +func TestFlatten(t *testing.T) { + vars := map[string]interface{}{ + "root": "foo", + "one": + map[interface{}]interface{}{ + "two": "bar", + "three": map[interface{}]interface{}{ + "hello": "world", + }, + }, + } + + flattened := flatten("", reflect.ValueOf(vars)) + + assert.Equal(t, 3, len(flattened)) + assert.Equal(t, "foo", flattened[".root"]) + assert.Equal(t, "bar", flattened[".one.two"]) + assert.Equal(t, "world", flattened[".one.three.hello"]) +} \ No newline at end of file diff --git a/config/config.go b/config/config.go index 4796da0..fdbafa7 100644 --- a/config/config.go +++ b/config/config.go @@ -1,13 +1,12 @@ package config import ( - "bytes" + "fmt" + "github.com/tj/robo/interpolation" + "gopkg.in/yaml.v2" "io/ioutil" "os/user" "path" - "text/template" - - "gopkg.in/yaml.v2" "github.com/tj/robo/task" ) @@ -15,10 +14,10 @@ import ( // Config represents the main YAML configuration // loaded for Robo tasks. type Config struct { - File string - Tasks map[string]*task.Task `yaml:",inline"` - Variables map[string]interface{} - Templates struct { + File string + Tasks map[string]*task.Task `yaml:",inline"` + Variables map[string]interface{} + Templates struct { List string Help string Variables string @@ -28,24 +27,15 @@ type Config struct { // Eval evaluates the config by interpolating // all templates using the variables. func (c *Config) Eval() error { - for _, task := range c.Tasks { - err := interpolate( - c.Variables, - &task.Command, - &task.Summary, - &task.Script, - &task.Exec, - ) - if err != nil { - return err - } + var err error + err = interpolation.Vars(&c.Variables) + if err != nil { + return fmt.Errorf("failed interpolating variables. Error: %v", err) + } - for i, item := range task.Env { - if err := interpolate(c.Variables, &item); err != nil { - return err - } - task.Env[i] = item - } + err = interpolation.Tasks(c.Tasks, c.Variables) + if err != nil { + return fmt.Errorf("failed interpolating tasks. Error: %v", err) } return nil } @@ -112,32 +102,4 @@ func NewString(s string) (*Config, error) { } return c, nil -} - -// Apply interpolation against the given strings. -func interpolate(v interface{}, s ...*string) error { - for _, p := range s { - ret, err := eval(*p, v) - if err != nil { - return err - } - *p = ret - } - return nil -} - -// Evaluate template against `v`. -func eval(s string, v interface{}) (string, error) { - t, err := template.New("task").Parse(s) - if err != nil { - return "", err - } - - var b bytes.Buffer - err = t.Execute(&b, v) - if err != nil { - return "", err - } - - return string(b.Bytes()), nil -} +} \ No newline at end of file diff --git a/config/config_test.go b/config/config_test.go index 282c4ef..2a01ecd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -31,9 +31,13 @@ templates: list: testing variables: + region: + euw: europe-west hosts: - prod: bastion-prod stage: bastion-stage + prod: bastion-prod + dns: "{{ .region.euw }}.{{ .hosts.prod }}" + command: "$(true && echo $?)" ` func TestNewString(t *testing.T) { @@ -55,6 +59,10 @@ func TestNewString(t *testing.T) { assert.Equal(t, []string{"H=bastion-prod"}, c.Tasks["prod"].Env) + // test variables section interpolation + assert.Equal(t, "europe-west.bastion-prod", c.Variables["dns"]) + assert.Equal(t, 0, c.Variables["command"]) + assert.Equal(t, `testing`, c.Templates.List) } @@ -74,4 +82,4 @@ func TestNew(t *testing.T) { c, err := config.New(file) assert.Equal(t, nil, err) assert.Equal(t, file, c.File) -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 1f63e44..351363b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.9 // indirect github.com/mattn/go-shellwords v1.0.6 + github.com/mitchellh/gox v1.0.1 // indirect github.com/tj/docopt v1.0.0 github.com/tj/kingpin v2.5.0+incompatible gopkg.in/yaml.v2 v2.2.2 diff --git a/go.sum b/go.sum index 8d6df0b..6de014b 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4Yn github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/hashicorp/go-version v1.0.0 h1:21MVWPKDphxa7ineQQTrCU5brh7OuVVAzGOCnnCPtE8= +github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -20,6 +22,10 @@ github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-shellwords v1.0.6 h1:9Jok5pILi5S1MnDirGVTufYGtksUs/V2BWUP3ZkeUUI= github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mitchellh/gox v1.0.1 h1:x0jD3dcHk9a9xPSDN6YEL4xL6Qz0dvNYm8yZqui5chI= +github.com/mitchellh/gox v1.0.1/go.mod h1:ED6BioOGXMswlXa2zxfh/xdd5QhwYliBFn9V18Ap4z4= +github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/tj/docopt v1.0.0 h1:echGqiJYxqwI1PQAppd8PrBM2e6E4G46NQugrTpL/wU= github.com/tj/docopt v1.0.0/go.mod h1:UWdJekySvYOgmpTJtkPaWS4fvSKYba+U6+E2iKJCV/I= github.com/tj/kingpin v2.5.0+incompatible h1:nZWdCABGeebLFX5Ha/rYqxgEQpSXYWh5N9Dx2sGR0Bs= diff --git a/interpolation/interpolation.go b/interpolation/interpolation.go new file mode 100644 index 0000000..0eefd1a --- /dev/null +++ b/interpolation/interpolation.go @@ -0,0 +1,122 @@ +// Package interpolation provides methods to interpolate user defined variables and robo tasks. +package interpolation + +import ( + "bytes" + "fmt" + "github.com/tj/robo/task" + "gopkg.in/yaml.v2" + "os" + "os/exec" + "regexp" + "strings" + "text/template" +) + +var commandPattern = regexp.MustCompile("\\$\\((.+)\\)") + +// Vars interpolates a given map of interfaces (strings or submaps) with itself +// returning it mit populated template values. +func Vars(vars *map[string]interface{}) error { + b, err := yaml.Marshal(*vars) + if err != nil { + return err + } + s := string(b) + + err = interpolate("variables", *vars, &s) + if err != nil { + return err + } + + err = interpolateVariableCommands(&s) + if err != nil { + return fmt.Errorf("failed replacing variable placeholder with command result") + } + + err = yaml.Unmarshal([]byte(s), vars) + if err != nil { + return err + } + return err +} + +func interpolateVariableCommands(s *string) error { + // find all commands + matches := commandPattern.FindAllStringSubmatch(*s, -1) + for _, match := range matches { + if len(match) != 2 { + continue + } + cmdOut, err := captureCommandOutput(match[1]) + if err != nil { + return fmt.Errorf("error while executing command. Error: %s", err) + } + *s = strings.ReplaceAll(*s, match[0], cmdOut) + } + return nil +} + +// captureCommandOutput executes a command and captures the output which usually gets prompted to stdout. +func captureCommandOutput(args string) (string, error) { + var cmd *exec.Cmd + // try to use the user's default shell. If it is not set via env var fall back to `sh`. + if defaultShell, ok := os.LookupEnv("SHELL"); ok { + cmd = exec.Command(defaultShell, "-c", args) + } else { + cmd = exec.Command("sh", "-c", args) + } + var b bytes.Buffer + cmd.Stdin = os.Stdin + cmd.Stdout = &b + cmd.Stderr = os.Stderr + err := cmd.Run() + return strings.TrimSuffix(string(b.Bytes()), "\n"), err +} + +// Tasks interpolates a given task with a set of data replacing placeholders +// in the command, summary, script, exec and envs property. +func Tasks(tasks map[string]*task.Task, data map[string]interface{}) error { + for _, task := range tasks { + // interpolate the tasks main fields + err := interpolate( + "task", + data, + &task.Command, + &task.Summary, + &task.Script, + &task.Exec, + ) + if err != nil { + return err + } + + // interpolate a tasks environment data + for i, item := range task.Env { + if err := interpolate("env-var", data, &item); err != nil { + return err + } + task.Env[i] = item + } + } + return nil +} + +// interpolate populates a given slice of templates with actual values provided +// in the data parameter. +func interpolate(name string, data interface{}, temps ...*string) error { + for _, temp := range temps { + t, err := template.New(name).Parse(*temp) + if err != nil { + return err + } + + var b bytes.Buffer + err = t.Execute(&b, data) + if err != nil { + return err + } + *temp = string(b.Bytes()) + } + return nil +} \ No newline at end of file diff --git a/interpolation/interpolation_test.go b/interpolation/interpolation_test.go new file mode 100644 index 0000000..a7b8d51 --- /dev/null +++ b/interpolation/interpolation_test.go @@ -0,0 +1,55 @@ +package interpolation + +import ( + "github.com/bmizerany/assert" + "github.com/tj/robo/task" + "testing" +) + +func TestVars_whenValueReferencesOtherKey_shouldReplaceAccordingly(t *testing.T) { + vars := map[string]interface{}{ + "foo": "Hello", + "bar": "{{ .foo }} World!", + } + err := Vars(&vars) + + assert.Equal(t, nil, err) + assert.Equal(t, "Hello", vars["foo"]) + assert.Equal(t, "Hello World!", vars["bar"]) +} + +func TestVars_whenValueIsCommand_shouldReplaceWithCommandResult(t *testing.T) { + vars := map[string]interface{}{ + "foo": "$(echo Hello)", + "bar": + map[string]interface{}{ + "sub": "$(echo World!)", + }, + } + + err := Vars(&vars) + + assert.Equal(t, nil, err) + assert.Equal(t, "Hello", vars["foo"]) + assert.Equal(t, "World!", vars["bar"].(map[interface{}]interface{})["sub"]) +} + +func TestTasks(t *testing.T) { + tk := task.Task{ + Summary: "This task handles {{ .foo }} World!", + Command: "echo {{ .foo }} World!", + Script: "/path/to/{{ .foo }}.sh", + Exec: "{{ .foo }} World!", + Env: []string{"bar={{ .foo }} World!"}, + } + + vars := map[string]interface{}{"foo": "Hello"} + + err := Tasks(map[string]*task.Task{"tk": &tk}, vars) + assert.Equal(t, nil, err) + assert.Equal(t, "This task handles Hello World!", tk.Summary) + assert.Equal(t, "echo Hello World!", tk.Command) + assert.Equal(t, "/path/to/Hello.sh", tk.Script) + assert.Equal(t, "Hello World!", tk.Exec) + assert.Equal(t, "bar=Hello World!", tk.Env[0]) +}