Skip to content

Commit

Permalink
Merge pull request #45 from jenpet/feature/variable_interpolation
Browse files Browse the repository at this point in the history
Feature/variable interpolation
  • Loading branch information
yields authored Sep 3, 2020
2 parents 2a554e0 + eb1f1bb commit bb4ca22
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 62 deletions.
14 changes: 14 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 26 additions & 6 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"text/template"

"github.com/fatih/color"
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
70 changes: 16 additions & 54 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
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"
)

// 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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
12 changes: 10 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}

Expand All @@ -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)
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
122 changes: 122 additions & 0 deletions interpolation/interpolation.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit bb4ca22

Please sign in to comment.