Skip to content

Commit

Permalink
feat: add templated conditionals to tasks (#139)
Browse files Browse the repository at this point in the history
## Description

Adds an `if` keyword to `task:` and `cmd:` that evaluates based on a go
template to true or false to facilitate conditional task execution.

## Related Issue

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Other (security config, docs update, etc)

## Checklist before merging

- [x] Test, docs, adr added or updated as needed
- [x] [Contributor Guide
Steps](https://github.com/defenseunicorns/maru-runner/blob/main/CONTRIBUTING.md)
followed

---------

Co-authored-by: Eric Wyles <23637493+ericwyles@users.noreply.github.com>
Co-authored-by: Wayne Starr <me@racer159.com>
  • Loading branch information
3 people authored Sep 19, 2024
1 parent e72da68 commit 87c56e4
Show file tree
Hide file tree
Showing 10 changed files with 285 additions and 30 deletions.
4 changes: 2 additions & 2 deletions .github/actions/zarf/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ runs:
steps:
- uses: defenseunicorns/setup-zarf@main
with:
# renovate: datasource=github-tags depName=defenseunicorns/zarf
version: v0.36.0
# renovate: datasource=github-tags depName=zarf-dev/zarf
version: v0.39.0
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ test-unit: ## Run unit tests


.PHONY: test-e2e
test-e2e: ## Run End to End (e2e) tests
cd src/test/e2e && go test -failfast -v -timeout 30m

test-e2e: build ## Run End to End (e2e) tests
cd src/test/e2e && go test -failfast -v -timeout 30m -count=1

schema: ## Update JSON schema for maru tasks
./hack/generate-schema.sh
Expand Down
30 changes: 20 additions & 10 deletions src/pkg/runner/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,32 @@ import (
"github.com/defenseunicorns/maru-runner/src/types"
)

func (r *Runner) performAction(action types.Action) error {
func (r *Runner) performAction(action types.Action, withs map[string]string, inputs map[string]types.InputParameter) error {

message.SLog.Debug(fmt.Sprintf("Evaluating action conditional %s", action.If))

action, _ = utils.TemplateTaskAction(action, withs, inputs, r.variableConfig.GetSetVariables())
if action.If == "false" && action.TaskReference != "" {
message.SLog.Info(fmt.Sprintf("Skipping action %s", action.TaskReference))
return nil
} else if action.If == "false" && action.Description != "" {
message.SLog.Info(fmt.Sprintf("Skipping action %s", action.Description))
return nil
} else if action.If == "false" && action.Cmd != "" {
cmdEscaped := helpers.Truncate(action.Cmd, 60, false)
message.SLog.Info(fmt.Sprintf("Skipping action %q", cmdEscaped))
return nil
}

if action.TaskReference != "" {
// todo: much of this logic is duplicated in Run, consider refactoring
referencedTask, err := r.getTask(action.TaskReference)
if err != nil {
return err
}

// template the withs with variables
for k, v := range action.With {
action.With[k] = utils.TemplateString(r.variableConfig.GetSetVariables(), v)
}

referencedTask.Actions, err = utils.TemplateTaskActionsWithInputs(referencedTask, action.With)
if err != nil {
return err
}

withEnv := []string{}
for name := range action.With {
withEnv = append(withEnv, utils.FormatEnvVar(name, action.With[name]))
Expand All @@ -50,14 +58,16 @@ func (r *Runner) performAction(action types.Action) error {
for _, a := range referencedTask.Actions {
a.Env = utils.MergeEnv(withEnv, a.Env)
}
if err := r.executeTask(referencedTask); err != nil {

if err := r.executeTask(referencedTask, action.With); err != nil {
return err
}
} else {
err := RunAction(action.BaseAction, r.envFilePath, r.variableConfig)
if err != nil {
return err
}

}
return nil
}
Expand Down
6 changes: 5 additions & 1 deletion src/pkg/runner/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func Test_validateActionableTaskCall(t *testing.T) {
name: "Valid task call with default value for missing input",
args: args{
inputTaskName: "testTask",

inputs: map[string]types.InputParameter{
"input1": {Required: true, Default: "defaultValue"},
"input2": {Required: true, Default: ""},
Expand Down Expand Up @@ -228,6 +229,8 @@ func TestRunner_performAction(t *testing.T) {
}
type args struct {
action types.Action
inputs map[string]types.InputParameter
withs map[string]string
}
tests := []struct {
name string
Expand All @@ -236,6 +239,7 @@ func TestRunner_performAction(t *testing.T) {
wantErr bool
}{
// TODO: Add more test cases
// https://github.com/defenseunicorns/maru-runner/issues/143
{
name: "failed action processing due to invalid command",
fields: fields{
Expand Down Expand Up @@ -294,7 +298,7 @@ func TestRunner_performAction(t *testing.T) {
envFilePath: tt.fields.envFilePath,
variableConfig: tt.fields.variableConfig,
}
err := r.performAction(tt.args.action)
err := r.performAction(tt.args.action, tt.args.withs, tt.args.inputs)
if (err != nil) != tt.wantErr {
t.Errorf("performAction() error = %v, wantErr %v", err, tt.wantErr)
}
Expand Down
7 changes: 4 additions & 3 deletions src/pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func Run(tasksFile types.TasksFile, taskName string, setVariables map[string]str
return err
}

err = runner.executeTask(task)
err = runner.executeTask(task, nil)
return err
}

Expand Down Expand Up @@ -267,7 +267,7 @@ func (r *Runner) getTask(taskName string) (types.Task, error) {
return types.Task{}, fmt.Errorf("task name %s not found", taskName)
}

func (r *Runner) executeTask(task types.Task) error {
func (r *Runner) executeTask(task types.Task, withs map[string]string) error {
defaultEnv := []string{}
for name, inputParam := range task.Inputs {
d := inputParam.Default
Expand All @@ -284,7 +284,8 @@ func (r *Runner) executeTask(task types.Task) error {

for _, action := range task.Actions {
action.Env = utils.MergeEnv(action.Env, defaultEnv)
if err := r.performAction(action); err != nil {

if err := r.performAction(action, withs, task.Inputs); err != nil {
return err
}
}
Expand Down
31 changes: 20 additions & 11 deletions src/pkg/utils/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,45 +16,54 @@ import (
goyaml "github.com/goccy/go-yaml"
)

// TemplateTaskActionsWithInputs templates a task's actions with the given inputs
func TemplateTaskActionsWithInputs(task types.Task, withs map[string]string) ([]types.Action, error) {
// TemplateTaskAction templates a task's actions with the given inputs and variables
func TemplateTaskAction[T any](action types.Action, withs map[string]string, inputs map[string]types.InputParameter, setVarMap variables.SetVariableMap[T]) (types.Action, error) {
data := map[string]map[string]string{
"inputs": {},
"inputs": {},
"variables": {},
}

// get inputs from "with" map
for name := range withs {
data["inputs"][name] = withs[name]
}

// get vars from "vms" map
for name := range setVarMap {
data["variables"][name] = setVarMap[name].Value
}

// use default if not populated in data
for name := range task.Inputs {
for name := range inputs {
if current, ok := data["inputs"][name]; !ok || current == "" {
data["inputs"][name] = task.Inputs[name].Default
data["inputs"][name] = inputs[name].Default
}
}

b, err := goyaml.Marshal(task.Actions)
b, err := goyaml.Marshal(action)
if err != nil {
return nil, err
return action, err
}

t, err := template.New("template task actions").Option("missingkey=error").Delims("${{", "}}").Parse(string(b))
if err != nil {
return nil, err
return action, err
}

var templated strings.Builder

if err := t.Execute(&templated, data); err != nil {
return nil, err
return action, err
}

result := templated.String()

var templatedActions []types.Action
var templatedAction types.Action
if err := goyaml.Unmarshal([]byte(result), &templatedAction); err != nil {
return action, err
}

return templatedActions, goyaml.Unmarshal([]byte(result), &templatedActions)
return templatedAction, nil
}

// TemplateString replaces ${...} with the value from the template map
Expand Down
98 changes: 97 additions & 1 deletion src/test/e2e/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func TestTaskRunner(t *testing.T) {
t.Parallel()
_, stdErr, err := e2e.Maru("run", "wait-fail", "--file", "src/test/tasks/tasks.yaml")
require.Error(t, err)
require.Contains(t, stdErr, "Waiting for")
require.Contains(t, stdErr, "timed out after 1 seconds")
})

t.Run("test successful call to zarf tools wait-for (requires Zarf on path)", func(t *testing.T) {
Expand Down Expand Up @@ -298,4 +298,100 @@ func TestTaskRunner(t *testing.T) {
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "defenseunicorns is a pretty ok company")
})

// Conditional Tests
t.Run("test calling a task with false conditional cmd comparing variables", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "false-conditional-with-var-cmd", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Skipping action false-conditional-with-var-cmd")
})

t.Run("test calling a task with true conditional cmd comparing variables", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-conditional-with-var-cmd", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "This should run because .variables.BAR = default-value")
})

t.Run("test calling a task with cmd no conditional", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "empty-conditional-cmd", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "This should run because there is no condition")
})

t.Run("test calling a task with false conditional comparing variables", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "false-conditional-task", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Skipping action included-task")
})

t.Run("test calling a task with true conditional comparing variables", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-conditional-task", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Task called successfully")
})

t.Run("test calling a task with no conditional comparing variables", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "empty-conditional-task", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Task called successfully")
})

t.Run("test calling a task with nested true conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-conditional-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "input val equals 5 and variable VAL1 equals 5")
})
t.Run("test calling a task with nested false conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "false-conditional-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Skipping action included-task-with-inputs")
})

t.Run("test calling a task with nested task true conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-conditional-nested-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Task called successfully")
})
t.Run("test calling a task with nested task false conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "false-conditional-nested-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Skipping action included-task")
})

t.Run("test calling a task with nested task calling a task with true conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-conditional-nested-nested-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "\"input val2 equals 5 and variable VAL1 equals 5\"")
})
t.Run("test calling a task with nested task calling a task with false conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "false-conditional-nested-nested-nested-task-comp-var-inputs", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "Skipping action \"echo \\\"input val2 equals 7 and variable VAL1 equals 5\\\"\"")
})

t.Run("test calling a task with nested task calling a task with old style var as input true conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-condition-var-as-input-original-syntax-nested-nested-with-comp", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "\"input val2 equals 5 and variable VAL1 equals 5\"")
})

t.Run("test calling a task with nested task calling a task with new style var as input true conditional comparing variables and inputs", func(t *testing.T) {
t.Parallel()
stdOut, stdErr, err := e2e.Maru("run", "true-condition-var-as-input-new-syntax-nested-nested-with-comp", "--file", "src/test/tasks/conditionals/tasks.yaml")
require.NoError(t, err, stdOut, stdErr)
require.Contains(t, stdErr, "\"input val2 equals 5 and variable VAL1 equals 5\"")
})
}
Loading

0 comments on commit 87c56e4

Please sign in to comment.