Skip to content

Commit

Permalink
Introduce variable map self-interpolation and command variable interp…
Browse files Browse the repository at this point in the history
…olation.

A variable in the variables section can now reference another variable and will get populated before an actual task’s content will get populated. Additionally to self-referencing variables `$()` will run a command and capture the output into a variable.
  • Loading branch information
Jens Petersohn committed Sep 2, 2020
1 parent 2a554e0 commit 5976aaa
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 56 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
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
}
55 changes: 55 additions & 0 deletions interpolation/interpolation_test.go
Original file line number Diff line number Diff line change
@@ -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])
}

0 comments on commit 5976aaa

Please sign in to comment.