diff --git a/.github/actions/zarf/action.yaml b/.github/actions/zarf/action.yaml index 21255d7..ec7eb84 100644 --- a/.github/actions/zarf/action.yaml +++ b/.github/actions/zarf/action.yaml @@ -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 diff --git a/Makefile b/Makefile index 8407399..f0915e5 100644 --- a/Makefile +++ b/Makefile @@ -49,11 +49,13 @@ build-cli-mac-apple: ## Build the CLI for Mac Apple .PHONY: test-unit test-unit: ## Run unit tests - cd src/pkg && go test ./... -failfast -v -timeout 30m + go test -failfast -v -timeout 30m $$(go list ./... | grep -v '^github.com/defenseunicorns/maru-runner/src/test/e2e') + .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 diff --git a/src/pkg/runner/actions.go b/src/pkg/runner/actions.go index 6500958..529025c 100644 --- a/src/pkg/runner/actions.go +++ b/src/pkg/runner/actions.go @@ -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])) @@ -51,7 +59,8 @@ 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 { @@ -59,6 +68,7 @@ func (r *Runner) performAction(action types.Action) error { if err != nil { return err } + } return nil } diff --git a/src/pkg/runner/actions_test.go b/src/pkg/runner/actions_test.go index 3304a2f..c007e5f 100644 --- a/src/pkg/runner/actions_test.go +++ b/src/pkg/runner/actions_test.go @@ -5,6 +5,7 @@ package runner import ( "reflect" + "slices" "testing" "github.com/defenseunicorns/maru-runner/src/config" @@ -200,6 +201,7 @@ func Test_validateActionableTaskCall(t *testing.T) { args: args{ execContext: "internal", inputTaskName: "testTask", + inputs: map[string]types.InputParameter{ "input1": {Required: true, Default: "defaultValue"}, "input2": {Required: true, Default: ""}, @@ -229,6 +231,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 @@ -237,6 +241,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{ @@ -295,7 +300,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) } @@ -500,7 +505,8 @@ func TestRunner_GetBaseActionCfg(t *testing.T) { } got := GetBaseActionCfg(tt.args.cfg, tt.args.a, tt.args.vars) - + slices.Sort(got.Env) + slices.Sort(tt.want) require.Equal(t, tt.want, got.Env, "The returned Env array did not match what was wanted") }) } diff --git a/src/pkg/runner/runner.go b/src/pkg/runner/runner.go index 98a5ad4..a7c5d87 100644 --- a/src/pkg/runner/runner.go +++ b/src/pkg/runner/runner.go @@ -65,7 +65,9 @@ func Run(tasksFile types.TasksFile, taskName string, setVariables map[string]str if err = runner.checkForTaskLoops(task, runner.TasksFile, setVariables); err != nil { return err } - err = runner.executeTask(task) + + err = runner.executeTask(task, nil) + return err } @@ -266,7 +268,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 @@ -283,7 +285,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 } } diff --git a/src/pkg/utils/template.go b/src/pkg/utils/template.go index dda1707..fc14437 100644 --- a/src/pkg/utils/template.go +++ b/src/pkg/utils/template.go @@ -16,10 +16,11 @@ 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 @@ -27,34 +28,42 @@ func TemplateTaskActionsWithInputs(task types.Task, withs map[string]string) ([] 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 diff --git a/src/test/e2e/runner_test.go b/src/test/e2e/runner_test.go index d91daca..e24acf8 100644 --- a/src/test/e2e/runner_test.go +++ b/src/test/e2e/runner_test.go @@ -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) { @@ -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\"") + }) } diff --git a/src/test/tasks/conditionals/tasks.yaml b/src/test/tasks/conditionals/tasks.yaml new file mode 100644 index 0000000..47fa3b1 --- /dev/null +++ b/src/test/tasks/conditionals/tasks.yaml @@ -0,0 +1,129 @@ +variables: + - name: FOO + default: default-value + - name: BAR + default: default-value + - name: VAL1 + default: "5" + - name: VAL2 + default: "10" + +tasks: + + - name: false-conditional-with-var-cmd + actions: + - cmd: echo "This should not run because .variables.BAR != default-value" + description: false-conditional-with-var-cmd + if: ${{ eq .variables.BAR "default-value1" }} + + - name: true-conditional-with-var-cmd + actions: + - cmd: echo "This should run because .variables.BAR = default-value" + description: true-conditional-with-var-cmd + if: ${{ eq .variables.BAR "default-value" }} + + - name: empty-conditional-cmd + actions: + - cmd: echo "This should run because there is no condition" + description: empty-conditional-cmd + + - name: empty-conditional-task + actions: + - task: included-task + + - name: true-conditional-task + actions: + - task: included-task + if: ${{ eq .variables.BAR "default-value" }} + + - name: false-conditional-task + actions: + - task: included-task + if: ${{ eq .variables.BAR "default-value1" }} + + - name: true-conditional-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs + with: + val: "5" + + - name: false-conditional-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs + with: + val: "7" + + - name: true-conditional-nested-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs-and-nested-task + with: + val: "5" + + - name: false-conditional-nested-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs-and-nested-task + with: + val: "7" + + - name: true-conditional-nested-nested-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs-and-nested-nested-task + with: + val: "5" + + - name: false-conditional-nested-nested-nested-task-comp-var-inputs + actions: + - task: included-task-with-inputs-and-nested-nested-task + with: + val: "7" + + - name: true-condition-var-as-input-original-syntax-nested-nested-with-comp + actions: + - task: included-task-with-inputs-and-nested-nested-task + with: + val: ${VAL1} + + - name: true-condition-var-as-input-new-syntax-nested-nested-with-comp + actions: + - task: included-task-with-inputs-and-nested-nested-task + with: + val: ${{ .variables.VAL1 }} + + - name: included-task + actions: + - cmd: echo "Task called successfully" + + - name: included-task-with-inputs + inputs: + val: + description: has no default + actions: + - cmd: echo "input val equals ${{ .inputs.val }} and variable VAL1 equals ${{ .variables.VAL1 }}" + description: "included-task-with-inputs" + if: ${{ eq .inputs.val .variables.VAL1 }} + + - name: included-task-with-inputs-and-nested-task + inputs: + val: + description: has no default + actions: + - task: included-task + if: ${{ eq .inputs.val .variables.VAL1 }} + + + - name: included-task-with-inputs-and-nested-nested-task + inputs: + val: + description: has no default + actions: + - task: included-task-nested + with: + val2: ${{ .inputs.val }} + + - name: included-task-nested + inputs: + val2: + description: has no default + actions: + - cmd: echo "input val2 equals ${{ .inputs.val2 }} and variable VAL1 equals ${{ .variables.VAL1 }}" + if: ${{ eq .inputs.val2 .variables.VAL1 }} diff --git a/src/types/tasks.go b/src/types/tasks.go index c6c0b28..b97f30c 100644 --- a/src/types/tasks.go +++ b/src/types/tasks.go @@ -37,6 +37,7 @@ type Action struct { *BaseAction[variables.ExtraVariableInfo] `json:",inline"` TaskReference string `json:"task,omitempty" jsonschema:"description=The task to run, mutually exclusive with cmd and wait"` With map[string]string `json:"with,omitempty" jsonschema:"description=Input parameters to pass to the task,type=object"` + If string `json:"if,omitempty" jsonschema:"description=Conditional to determine if the action should run"` } // TaskReference references the name of a task diff --git a/tasks.schema.json b/tasks.schema.json index 0eedb91..2a1c2a1 100644 --- a/tasks.schema.json +++ b/tasks.schema.json @@ -61,6 +61,10 @@ }, "type": "object", "description": "Input parameters to pass to the task" + }, + "if": { + "type": "string", + "description": "Conditional to determine if the action should run" } }, "additionalProperties": false,