From c964258bd1042a2301365705db9921c301b987de Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Thu, 23 Oct 2025 18:36:25 -0300 Subject: [PATCH 1/7] Add support for run.shell task Signed-off-by: Matheus Cruz --- impl/runner_test.go | 18 +++ impl/task_runner_do.go | 2 + impl/task_runner_run.go | 143 ++++++++++++++++++++++++ impl/task_runner_run_test.go | 159 +++++++++++++++++++++++++++ impl/testdata/runshell_echo.yaml | 25 +++++ impl/testdata/runshell_exitcode.yaml | 11 ++ model/runtime_expression.go | 5 + model/runtime_expression_test.go | 5 + model/task_run.go | 5 + 9 files changed, 373 insertions(+) create mode 100644 impl/task_runner_run.go create mode 100644 impl/task_runner_run_test.go create mode 100644 impl/testdata/runshell_echo.yaml create mode 100644 impl/testdata/runshell_exitcode.yaml diff --git a/impl/runner_test.go b/impl/runner_test.go index 5acdb6b..4baebe9 100644 --- a/impl/runner_test.go +++ b/impl/runner_test.go @@ -104,6 +104,24 @@ func runWorkflow(t *testing.T, workflowPath string, input, expectedOutput map[st return output, err } +func runWorkflowExpectString(t *testing.T, workflowPath string, input interface{}) (output interface{}, err error) { + // Read the workflow YAML from the testdata directory + yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + + // Parse the YAML workflow + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + + // Initialize the workflow runner + runner, err := NewDefaultRunner(workflow) + assert.NoError(t, err) + + // Run the workflow + output, err = runner.Run(input) + return output, err +} + func assertWorkflowRun(t *testing.T, expectedOutput map[string]interface{}, output interface{}) { if expectedOutput == nil { assert.Nil(t, output, "Expected nil Workflow run output") diff --git a/impl/task_runner_do.go b/impl/task_runner_do.go index 8b63bfc..48f2e83 100644 --- a/impl/task_runner_do.go +++ b/impl/task_runner_do.go @@ -40,6 +40,8 @@ func NewTaskRunner(taskName string, task model.Task, workflowDef *model.Workflow return NewCallHttpRunner(taskName, t) case *model.ForkTask: return NewForkTaskRunner(taskName, t, workflowDef) + case *model.RunTask: + return NewRunTaskRunner(taskName, t) default: return nil, fmt.Errorf("unsupported task type '%T' for task '%s'", t, taskName) } diff --git a/impl/task_runner_run.go b/impl/task_runner_run.go new file mode 100644 index 0000000..79eabbd --- /dev/null +++ b/impl/task_runner_run.go @@ -0,0 +1,143 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impl + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +type RunTaskRunner struct { + Task *model.RunTask + TaskName string +} + +func (d *RunTaskRunner) GetTaskName() string { + return d.TaskName +} + +// RunTaskRunnable defines the interface for running a subtask for RunTask. +type RunTaskRunnable interface { + RunTask(taskConfiguration *model.RunTaskConfiguration, support *TaskSupport, input interface{}) (output interface{}, err error) +} + +func NewRunTaskRunner(taskName string, task *model.RunTask) (*RunTaskRunner, error) { + + if task == nil { + return nil, model.NewErrValidation(fmt.Errorf("no set configuration provided for RunTask %s", taskName), taskName) + } + + return &RunTaskRunner{ + Task: task, + TaskName: taskName, + }, nil +} + +func (d *RunTaskRunner) Run(input interface{}, taskSupport TaskSupport) (output interface{}, err error) { + + if d.Task.Run.Shell != nil { + shellTask := NewRunTaskShell() + return shellTask.RunTask(d, input, taskSupport) + } + + return nil, fmt.Errorf("no set configuration provided for RunTask %s", d.TaskName) + +} + +// ProcessResult Describes the result of a process. +type ProcessResult struct { + Stdout string + Stderr string + Code int +} + +// NewProcessResult creates a new ProcessResult instance. +func NewProcessResult(stdout, stderr string, code int) *ProcessResult { + return &ProcessResult{ + Stdout: stdout, + Stderr: stderr, + Code: code, + } +} + +// RunTaskShell defines the shell configuration for RunTask. +// It implements the RunTask.shell definition. +type RunTaskShell struct { +} + +// NewRunTaskShell creates a new RunTaskShell instance. +func NewRunTaskShell() *RunTaskShell { + return &RunTaskShell{} +} + +func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, taskSupport TaskSupport) (interface{}, error) { + + shell := r.Task.Run.Shell + var cmdStr string + + if shell != nil { + cmdStr = shell.Command + } + + if cmdStr == "" { + return nil, model.NewErrValidation(fmt.Errorf("no command provided for RunTask %shellTask", r.TaskName), r.TaskName) + } + + evaluated, err := expr.TraverseAndEvaluate(cmdStr, input, taskSupport.GetContext()) + if err != nil { + return nil, err + } + + cmdEvaluated, ok := evaluated.(string) + if !ok { + return nil, model.NewErrRuntime(fmt.Errorf("expected evaluated command to be a string, but got a different type. Got: %v", evaluated), r.TaskName) + } + + cmd := exec.Command("sh", "-c", cmdEvaluated) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } + } else if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + stdoutStr := strings.TrimSpace(stdout.String()) + stderrStr := strings.TrimSpace(stderr.String()) + + switch r.Task.Run.Return { + case "all": + return NewProcessResult(stdoutStr, stderrStr, exitCode), nil + case "stderr": + return stdoutStr, nil + case "code": + return exitCode, nil + default: + return stdoutStr, nil + } +} diff --git a/impl/task_runner_run_test.go b/impl/task_runner_run_test.go new file mode 100644 index 0000000..f46fab5 --- /dev/null +++ b/impl/task_runner_run_test.go @@ -0,0 +1,159 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package impl + +import ( + "fmt" + "strings" + "testing" + + "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" +) + +func testingRunShell(t *testing.T, task model.RunTask, expected interface{}, input map[string]interface{}) { + + wfCtx, err := ctx.NewWorkflowContext(&model.Workflow{ + Input: &model.Input{ + From: &model.ObjectOrRuntimeExpr{Value: input}, + }, + }) + assert.NoError(t, err) + wfCtx.SetTaskReference("task_run_defined") + wfCtx.SetInput(input) + + runner, err := NewRunTaskRunner("runShell", &task) + assert.NoError(t, err) + + taskSupport := newTaskSupport(withRunnerCtx(wfCtx)) + + if input == nil { + input = map[string]interface{}{} + } + + output, err := runner.Run(input, taskSupport) + + assert.NoError(t, err) + + switch exp := expected.(type) { + + case int: + // expected an exit code + codeOut, ok := output.(int) + assert.True(t, ok, "output should be int (exit code), got %T", output) + assert.Equal(t, exp, codeOut) + case string: + var outStr string + switch v := output.(type) { + case string: + outStr = v + case []byte: + outStr = string(v) + case int: + outStr = fmt.Sprintf("%d", v) + default: + t.Fatalf("unexpected output type %T", output) + } + outStr = strings.TrimSpace(outStr) + assert.Equal(t, exp, outStr) + case ProcessResult: + resultOut, ok := output.(*ProcessResult) + assert.True(t, ok, "output should be ProcessResult, got %T", output) + assert.Equal(t, exp.Stdout, strings.TrimSpace(resultOut.Stdout)) + assert.Equal(t, exp.Stderr, strings.TrimSpace(resultOut.Stderr)) + assert.Equal(t, exp.Code, resultOut.Code) + default: + t.Fatalf("unsupported expected type %T", expected) + } +} + +func TestWithTestData(t *testing.T) { + + t.Run("Simple with echo", func(t *testing.T) { + workflowPath := "./testdata/runshell_echo.yaml" + + input := map[string]interface{}{} + expectedOutput := "Hello, anonymous" + output, err := runWorkflowExpectString(t, workflowPath, input) + assert.NoError(t, err) + assert.Equal(t, expectedOutput, output) + }) + + t.Run("Simple echo looking exit code", func(t *testing.T) { + workflowPath := "./testdata/runshell_exitcode.yaml" + input := map[string]interface{}{} + expectedOutput := 2 + output, err := runWorkflowExpectString(t, workflowPath, input) + assert.NoError(t, err) + assert.Equal(t, expectedOutput, output.(int)) + }) +} + +func TestRunTaskRunner(t *testing.T) { + tests := []struct { + name string + cmd string + ret string + expected interface{} + input map[string]interface{} + }{ + { + name: "echoLookCode", + cmd: "echo 'hello world'", + ret: "code", + expected: 0, + }, + { + name: "echoLookStdout", + cmd: "echo 'hello world'", + ret: "stdout", + expected: "hello world", + }, + { + name: "echoLookAll", + cmd: "echo 'hello world'", + ret: "all", + expected: *NewProcessResult( + "hello world", + "", + 0, + ), + }, + { + name: "echoJqExpression", + cmd: `${ "echo Hello, I love \(.project)" }`, + ret: "stdout", + expected: "Hello, I love ServerlessWorkflow", + input: map[string]interface{}{ + "project": "ServerlessWorkflow", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + task := model.RunTask{ + Run: model.RunTaskConfiguration{ + Shell: &model.Shell{ + Command: tc.cmd, + }, + Return: tc.ret, + }, + } + testingRunShell(t, task, tc.expected, tc.input) + }) + } +} diff --git a/impl/testdata/runshell_echo.yaml b/impl/testdata/runshell_echo.yaml new file mode 100644 index 0000000..b2c70cb --- /dev/null +++ b/impl/testdata/runshell_echo.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'echo "Hello, anonymous"' + return: all \ No newline at end of file diff --git a/impl/testdata/runshell_exitcode.yaml b/impl/testdata/runshell_exitcode.yaml new file mode 100644 index 0000000..7b38058 --- /dev/null +++ b/impl/testdata/runshell_exitcode.yaml @@ -0,0 +1,11 @@ +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'ls /nonexistent_directory' + return: code \ No newline at end of file diff --git a/model/runtime_expression.go b/model/runtime_expression.go index adef566..7962e1f 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -40,6 +40,11 @@ func IsStrictExpr(expression string) bool { return strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") } +// ContainsExpr returns true if the string contains `${` and `}` +func ContainsExpr(expression string) bool { + return strings.Contains(expression, "${") && strings.Contains(expression, "}") +} + // SanitizeExpr processes the expression to ensure it's ready for evaluation // It removes `${}` if present and replaces single quotes with double quotes func SanitizeExpr(expression string) string { diff --git a/model/runtime_expression_test.go b/model/runtime_expression_test.go index 770af70..835692b 100644 --- a/model/runtime_expression_test.go +++ b/model/runtime_expression_test.go @@ -110,6 +110,11 @@ func TestIsStrictExpr(t *testing.T) { expression: "${}", want: true, // Technically matches prefix+suffix }, + { + name: "With single quote", + expression: "echo 'hello, I love ${ .project }", + want: false, + }, } for _, tc := range tests { diff --git a/model/task_run.go b/model/task_run.go index b589cfa..dec6015 100644 --- a/model/task_run.go +++ b/model/task_run.go @@ -35,6 +35,7 @@ type RunTaskConfiguration struct { Script *Script `json:"script,omitempty"` Shell *Shell `json:"shell,omitempty"` Workflow *RunWorkflow `json:"workflow,omitempty"` + Return string `json:"return,omitempty"` } type Container struct { @@ -74,6 +75,7 @@ func (rtc *RunTaskConfiguration) UnmarshalJSON(data []byte) error { Script *Script `json:"script"` Shell *Shell `json:"shell"` Workflow *RunWorkflow `json:"workflow"` + Return string `json:"return"` }{} if err := json.Unmarshal(data, &temp); err != nil { @@ -105,6 +107,7 @@ func (rtc *RunTaskConfiguration) UnmarshalJSON(data []byte) error { } rtc.Await = temp.Await + rtc.Return = temp.Return return nil } @@ -116,12 +119,14 @@ func (rtc *RunTaskConfiguration) MarshalJSON() ([]byte, error) { Script *Script `json:"script,omitempty"` Shell *Shell `json:"shell,omitempty"` Workflow *RunWorkflow `json:"workflow,omitempty"` + Return string `json:"return,omitempty"` }{ Await: rtc.Await, Container: rtc.Container, Script: rtc.Script, Shell: rtc.Shell, Workflow: rtc.Workflow, + Return: rtc.Return, } return json.Marshal(temp) From 6aca436b549a06a1b069c24564dd4b2ee1fc7d41 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Thu, 23 Oct 2025 21:45:00 -0300 Subject: [PATCH 2/7] Add wait and more tests Signed-off-by: Matheus Cruz --- impl/task_runner_run.go | 34 ++++++++++++++-- impl/task_runner_run_test.go | 39 +++++++++++++++++++ .../runshell_echo_env_no_awaiting.yaml | 13 +++++++ impl/testdata/runshell_echo_jq.yaml | 11 ++++++ impl/testdata/runshell_echo_none.yaml | 11 ++++++ 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 impl/testdata/runshell_echo_env_no_awaiting.yaml create mode 100644 impl/testdata/runshell_echo_jq.yaml create mode 100644 impl/testdata/runshell_echo_none.yaml diff --git a/impl/task_runner_run.go b/impl/task_runner_run.go index 79eabbd..ffcc72b 100644 --- a/impl/task_runner_run.go +++ b/impl/task_runner_run.go @@ -18,6 +18,7 @@ import ( "bytes" "errors" "fmt" + "os" "os/exec" "strings" @@ -89,7 +90,7 @@ func NewRunTaskShell() *RunTaskShell { } func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, taskSupport TaskSupport) (interface{}, error) { - + await := r.Task.Run.Await shell := r.Task.Run.Shell var cmdStr string @@ -98,17 +99,40 @@ func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, task } if cmdStr == "" { - return nil, model.NewErrValidation(fmt.Errorf("no command provided for RunTask %shellTask", r.TaskName), r.TaskName) + return nil, model.NewErrValidation(fmt.Errorf("no command provided for RunTask shell: %s ", r.TaskName), r.TaskName) + } + + if shell.Environment != nil { + for key, value := range shell.Environment { + evaluated, ok := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) + if ok != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating environment variable value for RunTask shell: %s", r.TaskName), r.TaskName) + } + + err := os.Setenv(key, evaluated.(string)) + if err != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error setting environment variable for RunTask shell: %s", r.TaskName), r.TaskName) + } + } } evaluated, err := expr.TraverseAndEvaluate(cmdStr, input, taskSupport.GetContext()) if err != nil { - return nil, err + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating command for RunTask shell: %s", r.TaskName), r.TaskName) } cmdEvaluated, ok := evaluated.(string) if !ok { - return nil, model.NewErrRuntime(fmt.Errorf("expected evaluated command to be a string, but got a different type. Got: %v", evaluated), r.TaskName) + return nil, model.NewErrRuntime(fmt.Errorf("expected evaluated command to be a string, but got a different type"), r.TaskName) + } + + if await != nil && !*await { + go func() { + cmd := exec.Command("sh", "-c", cmdEvaluated) + _ = cmd.Start() + cmd.Wait() + }() + return input, nil } cmd := exec.Command("sh", "-c", cmdEvaluated) @@ -137,6 +161,8 @@ func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, task return stdoutStr, nil case "code": return exitCode, nil + case "none": + return nil, nil default: return stdoutStr, nil } diff --git a/impl/task_runner_run_test.go b/impl/task_runner_run_test.go index f46fab5..99d2de4 100644 --- a/impl/task_runner_run_test.go +++ b/impl/task_runner_run_test.go @@ -16,6 +16,7 @@ package impl import ( "fmt" + "os" "strings" "testing" @@ -100,6 +101,44 @@ func TestWithTestData(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expectedOutput, output.(int)) }) + + t.Run("JQ expression in command with 'all' return", func(t *testing.T) { + workflowPath := "./testdata/runshell_echo_jq.yaml" + input := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Matheus Cruz", + }, + } + output, err := runWorkflowExpectString(t, workflowPath, input) + + processResult := output.(*ProcessResult) + assert.NoError(t, err) + assert.Equal(t, "", processResult.Stderr) + assert.Equal(t, "Hello, Matheus Cruz", processResult.Stdout) + assert.Equal(t, 0, processResult.Code) + }) + + t.Run("Simple echo with 'none' return", func(t *testing.T) { + workflowPath := "./testdata/runshell_echo_none.yaml" + input := map[string]interface{}{} + output, err := runWorkflowExpectString(t, workflowPath, input) + + assert.NoError(t, err) + assert.Nil(t, output) + }) + + t.Run("Simple echo with env and await as 'false'", func(t *testing.T) { + workflowPath := "./testdata/runshell_echo_env_no_awaiting.yaml" + input := map[string]interface{}{ + "full_name": "John Doe", + } + output, err := runWorkflowExpectString(t, workflowPath, input) + + assert.NoError(t, err) + assert.Equal(t, output, input) + file, err := os.ReadFile("/tmp/hello.txt") + assert.Equal(t, "hello world not awaiting (John Doe)", strings.TrimSpace(string(file))) + }) } func TestRunTaskRunner(t *testing.T) { diff --git a/impl/testdata/runshell_echo_env_no_awaiting.yaml b/impl/testdata/runshell_echo_env_no_awaiting.yaml new file mode 100644 index 0000000..4642495 --- /dev/null +++ b/impl/testdata/runshell_echo_env_no_awaiting.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello.txt && cat /tmp/hello.txt + environment: + FULL_NAME: ${.full_name} + await: false \ No newline at end of file diff --git a/impl/testdata/runshell_echo_jq.yaml b/impl/testdata/runshell_echo_jq.yaml new file mode 100644 index 0000000..4e1b3ee --- /dev/null +++ b/impl/testdata/runshell_echo_jq.yaml @@ -0,0 +1,11 @@ +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: ${ "echo Hello, \(.user.name)" } + return: all \ No newline at end of file diff --git a/impl/testdata/runshell_echo_none.yaml b/impl/testdata/runshell_echo_none.yaml new file mode 100644 index 0000000..dde3289 --- /dev/null +++ b/impl/testdata/runshell_echo_none.yaml @@ -0,0 +1,11 @@ +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'echo "Serverless Workflow"' + return: none \ No newline at end of file From 86f704ae18b33b06aeb67b22fdc061c2d199ec04 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Sun, 26 Oct 2025 10:00:05 -0300 Subject: [PATCH 3/7] Clean up Signed-off-by: Matheus Cruz --- model/runtime_expression.go | 5 ----- model/runtime_expression_test.go | 5 ----- 2 files changed, 10 deletions(-) diff --git a/model/runtime_expression.go b/model/runtime_expression.go index 7962e1f..adef566 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -40,11 +40,6 @@ func IsStrictExpr(expression string) bool { return strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") } -// ContainsExpr returns true if the string contains `${` and `}` -func ContainsExpr(expression string) bool { - return strings.Contains(expression, "${") && strings.Contains(expression, "}") -} - // SanitizeExpr processes the expression to ensure it's ready for evaluation // It removes `${}` if present and replaces single quotes with double quotes func SanitizeExpr(expression string) string { diff --git a/model/runtime_expression_test.go b/model/runtime_expression_test.go index 835692b..770af70 100644 --- a/model/runtime_expression_test.go +++ b/model/runtime_expression_test.go @@ -110,11 +110,6 @@ func TestIsStrictExpr(t *testing.T) { expression: "${}", want: true, // Technically matches prefix+suffix }, - { - name: "With single quote", - expression: "echo 'hello, I love ${ .project }", - want: false, - }, } for _, tc := range tests { From d2a12dce539f15d18f58ac42fe83b9f65c9871e4 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Sun, 26 Oct 2025 23:48:42 -0300 Subject: [PATCH 4/7] Add more tests Signed-off-by: Matheus Cruz --- impl/runner_test.go | 6 +- impl/task_runner_run.go | 36 ++++- impl/task_runner_run_test.go | 126 ++++++++++++++++-- ...runshell_echo.yaml => run_shell_echo.yaml} | 0 .../run_shell_echo_env_no_awaiting.yaml | 27 ++++ impl/testdata/run_shell_echo_jq.yaml | 25 ++++ impl/testdata/run_shell_echo_none.yaml | 25 ++++ .../testdata/run_shell_echo_not_awaiting.yaml | 27 ++++ impl/testdata/run_shell_echo_with_args.yaml | 28 ++++ .../run_shell_echo_with_args_only_key.yaml | 31 +++++ impl/testdata/run_shell_echo_with_env.yaml | 28 ++++ impl/testdata/run_shell_exitcode.yaml | 25 ++++ impl/testdata/run_shell_ls_stderr.yaml | 25 ++++ impl/testdata/run_shell_missing_command.yaml | 27 ++++ impl/testdata/run_shell_touch_cat.yaml | 25 ++++ .../run_shell_with_args_key_value_jq.yaml | 28 ++++ .../runshell_echo_env_no_awaiting.yaml | 13 -- impl/testdata/runshell_echo_jq.yaml | 11 -- impl/testdata/runshell_echo_none.yaml | 11 -- impl/testdata/runshell_exitcode.yaml | 11 -- 20 files changed, 475 insertions(+), 60 deletions(-) rename impl/testdata/{runshell_echo.yaml => run_shell_echo.yaml} (100%) create mode 100644 impl/testdata/run_shell_echo_env_no_awaiting.yaml create mode 100644 impl/testdata/run_shell_echo_jq.yaml create mode 100644 impl/testdata/run_shell_echo_none.yaml create mode 100644 impl/testdata/run_shell_echo_not_awaiting.yaml create mode 100644 impl/testdata/run_shell_echo_with_args.yaml create mode 100644 impl/testdata/run_shell_echo_with_args_only_key.yaml create mode 100644 impl/testdata/run_shell_echo_with_env.yaml create mode 100644 impl/testdata/run_shell_exitcode.yaml create mode 100644 impl/testdata/run_shell_ls_stderr.yaml create mode 100644 impl/testdata/run_shell_missing_command.yaml create mode 100644 impl/testdata/run_shell_touch_cat.yaml create mode 100644 impl/testdata/run_shell_with_args_key_value_jq.yaml delete mode 100644 impl/testdata/runshell_echo_env_no_awaiting.yaml delete mode 100644 impl/testdata/runshell_echo_jq.yaml delete mode 100644 impl/testdata/runshell_echo_none.yaml delete mode 100644 impl/testdata/runshell_exitcode.yaml diff --git a/impl/runner_test.go b/impl/runner_test.go index 4baebe9..9f06ef9 100644 --- a/impl/runner_test.go +++ b/impl/runner_test.go @@ -73,20 +73,20 @@ func withRunnerCtx(workflowContext ctx.WorkflowContext) taskSupportOpts { // runWorkflowTest is a reusable test function for workflows func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { // Run the workflow - output, err := runWorkflow(t, workflowPath, input, expectedOutput) + output, err := runWorkflow(t, workflowPath, input) assert.NoError(t, err) assertWorkflowRun(t, expectedOutput, output) } func runWorkflowWithErr(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}, assertErr func(error)) { - output, err := runWorkflow(t, workflowPath, input, expectedOutput) + output, err := runWorkflow(t, workflowPath, input) assert.Error(t, err) assertErr(err) assertWorkflowRun(t, expectedOutput, output) } -func runWorkflow(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) (output interface{}, err error) { +func runWorkflow(t *testing.T, workflowPath string, input map[string]interface{}) (output interface{}, err error) { // Read the workflow YAML from the testdata directory yamlBytes, err := os.ReadFile(filepath.Clean(workflowPath)) assert.NoError(t, err, "Failed to read workflow YAML file") diff --git a/impl/task_runner_run.go b/impl/task_runner_run.go index ffcc72b..b80b789 100644 --- a/impl/task_runner_run.go +++ b/impl/task_runner_run.go @@ -126,16 +126,46 @@ func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, task return nil, model.NewErrRuntime(fmt.Errorf("expected evaluated command to be a string, but got a different type"), r.TaskName) } + var args []string + + args = append(args, "-c", cmdEvaluated) + + if shell.Arguments != nil { + for key, value := range shell.Arguments { + keyEval, ok := expr.TraverseAndEvaluate(key, input, taskSupport.GetContext()) + if ok != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument value for RunTask shell: %s", r.TaskName), r.TaskName) + } + + if value != nil { + valueEval, ok := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) + if ok != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument value for RunTask shell: %s", r.TaskName), r.TaskName) + } + args = append(args, fmt.Sprintf("%s=%s", keyEval, valueEval)) + } else { + args = append(args, fmt.Sprintf("%s", keyEval)) + } + } + } + + var fullCmd strings.Builder + fullCmd.WriteString(cmdEvaluated) + for i := 2; i < len(args); i++ { + fullCmd.WriteString(" ") + fullCmd.WriteString(args[i]) + } + if await != nil && !*await { go func() { - cmd := exec.Command("sh", "-c", cmdEvaluated) + cmd := exec.Command("sh", "-c", fullCmd.String()) _ = cmd.Start() cmd.Wait() }() return input, nil } - cmd := exec.Command("sh", "-c", cmdEvaluated) + cmd := exec.Command("sh", "-c", fullCmd.String()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -158,7 +188,7 @@ func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, task case "all": return NewProcessResult(stdoutStr, stderrStr, exitCode), nil case "stderr": - return stdoutStr, nil + return stderrStr, nil case "code": return exitCode, nil case "none": diff --git a/impl/task_runner_run_test.go b/impl/task_runner_run_test.go index 99d2de4..9e92b22 100644 --- a/impl/task_runner_run_test.go +++ b/impl/task_runner_run_test.go @@ -84,17 +84,22 @@ func testingRunShell(t *testing.T, task model.RunTask, expected interface{}, inp func TestWithTestData(t *testing.T) { t.Run("Simple with echo", func(t *testing.T) { - workflowPath := "./testdata/runshell_echo.yaml" + workflowPath := "./testdata/run_shell_echo.yaml" input := map[string]interface{}{} - expectedOutput := "Hello, anonymous" - output, err := runWorkflowExpectString(t, workflowPath, input) + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NotNilf(t, output, "output should not be nil") + assert.Equal(t, "Hello, anonymous", processResult.Stdout) + assert.Equal(t, "", processResult.Stderr) + assert.Equal(t, 0, processResult.Code) assert.NoError(t, err) - assert.Equal(t, expectedOutput, output) }) t.Run("Simple echo looking exit code", func(t *testing.T) { - workflowPath := "./testdata/runshell_exitcode.yaml" + workflowPath := "./testdata/run_shell_exitcode.yaml" input := map[string]interface{}{} expectedOutput := 2 output, err := runWorkflowExpectString(t, workflowPath, input) @@ -103,7 +108,7 @@ func TestWithTestData(t *testing.T) { }) t.Run("JQ expression in command with 'all' return", func(t *testing.T) { - workflowPath := "./testdata/runshell_echo_jq.yaml" + workflowPath := "./testdata/run_shell_echo_jq.yaml" input := map[string]interface{}{ "user": map[string]interface{}{ "name": "Matheus Cruz", @@ -119,7 +124,7 @@ func TestWithTestData(t *testing.T) { }) t.Run("Simple echo with 'none' return", func(t *testing.T) { - workflowPath := "./testdata/runshell_echo_none.yaml" + workflowPath := "./testdata/run_shell_echo_none.yaml" input := map[string]interface{}{} output, err := runWorkflowExpectString(t, workflowPath, input) @@ -128,7 +133,7 @@ func TestWithTestData(t *testing.T) { }) t.Run("Simple echo with env and await as 'false'", func(t *testing.T) { - workflowPath := "./testdata/runshell_echo_env_no_awaiting.yaml" + workflowPath := "./testdata/run_shell_echo_env_no_awaiting.yaml" input := map[string]interface{}{ "full_name": "John Doe", } @@ -139,6 +144,111 @@ func TestWithTestData(t *testing.T) { file, err := os.ReadFile("/tmp/hello.txt") assert.Equal(t, "hello world not awaiting (John Doe)", strings.TrimSpace(string(file))) }) + + t.Run("Simple echo not awaiting, function returns immediately", func(t *testing.T) { + workflowPath := "./testdata/run_shell_echo_not_awaiting.yaml" + input := map[string]interface{}{ + "full_name": "John Doe", + } + output, err := runWorkflow(t, workflowPath, input) + + assert.NoError(t, err) + assert.Equal(t, output, input) + }) + + t.Run("Simple ls getting output as stderr", func(t *testing.T) { + workflowPath := "./testdata/run_shell_ls_stderr.yaml" + input := map[string]interface{}{} + + output, err := runWorkflowExpectString(t, workflowPath, input) + + assert.NoError(t, err) + assert.True(t, strings.Contains(output.(string), "ls:")) + }) + + t.Run("Simple echo with args using JQ expression", func(t *testing.T) { + workflowPath := "./testdata/run_shell_with_args_key_value_jq.yaml" + input := map[string]interface{}{ + "user": "Alice", + "passwordKey": "--password", + } + + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NoError(t, err) + assert.True(t, strings.Contains(processResult.Stdout, "--user=Alice")) + assert.True(t, strings.Contains(processResult.Stdout, "--password=serverless")) + assert.Equal(t, 0, processResult.Code) + assert.Equal(t, "", processResult.Stderr) + }) + + t.Run("Simple echo with args", func(t *testing.T) { + workflowPath := "./testdata/run_shell_echo_with_args.yaml" + input := map[string]interface{}{} + + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NoError(t, err) + assert.True(t, strings.Contains(processResult.Stdout, "--user=john")) + assert.True(t, strings.Contains(processResult.Stdout, "--password=doe")) + assert.Equal(t, 0, processResult.Code) + assert.Equal(t, "", processResult.Stderr) + }) + + t.Run("Simple echo with args using only key", func(t *testing.T) { + workflowPath := "./testdata/run_shell_echo_with_args_only_key.yaml" + input := map[string]interface{}{ + "firstName": "Mary", + "lastName": "Jane", + } + + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NoError(t, err) + + // Go does not keep the order of map iteration + // TODO: improve the UnMarshal of args to keep the order + assert.True(t, strings.Contains(processResult.Stdout, "Mary")) + assert.True(t, strings.Contains(processResult.Stdout, "Jane")) + assert.Equal(t, 0, processResult.Code) + assert.Equal(t, "", processResult.Stderr) + }) + + t.Run("Simple echo with env and JQ", func(t *testing.T) { + workflowPath := "./testdata/run_shell_echo_with_env.yaml" + input := map[string]interface{}{ + "lastName": "Doe", + } + + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NoError(t, err) + assert.True(t, strings.Contains(processResult.Stdout, "Hello John Doe from env!")) + assert.Equal(t, 0, processResult.Code) + assert.Equal(t, "", processResult.Stderr) + }) + + t.Run("Execute touch and cat command", func(t *testing.T) { + workflowPath := "./testdata/run_shell_touch_cat.yaml" + input := map[string]interface{}{} + + output, err := runWorkflow(t, workflowPath, input) + + processResult := output.(*ProcessResult) + + assert.NoError(t, err) + assert.Equal(t, "hello world", strings.TrimSpace(processResult.Stdout)) + assert.Equal(t, 0, processResult.Code) + assert.Equal(t, "", processResult.Stderr) + }) } func TestRunTaskRunner(t *testing.T) { diff --git a/impl/testdata/runshell_echo.yaml b/impl/testdata/run_shell_echo.yaml similarity index 100% rename from impl/testdata/runshell_echo.yaml rename to impl/testdata/run_shell_echo.yaml diff --git a/impl/testdata/run_shell_echo_env_no_awaiting.yaml b/impl/testdata/run_shell_echo_env_no_awaiting.yaml new file mode 100644 index 0000000..721446f --- /dev/null +++ b/impl/testdata/run_shell_echo_env_no_awaiting.yaml @@ -0,0 +1,27 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello.txt && cat /tmp/hello.txt + environment: + FULL_NAME: ${.full_name} + await: false \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_jq.yaml b/impl/testdata/run_shell_echo_jq.yaml new file mode 100644 index 0000000..706e713 --- /dev/null +++ b/impl/testdata/run_shell_echo_jq.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: ${ "echo Hello, \(.user.name)" } + return: all \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_none.yaml b/impl/testdata/run_shell_echo_none.yaml new file mode 100644 index 0000000..9cf72d3 --- /dev/null +++ b/impl/testdata/run_shell_echo_none.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'echo "Serverless Workflow"' + return: none \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_not_awaiting.yaml b/impl/testdata/run_shell_echo_not_awaiting.yaml new file mode 100644 index 0000000..721446f --- /dev/null +++ b/impl/testdata/run_shell_echo_not_awaiting.yaml @@ -0,0 +1,27 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello.txt && cat /tmp/hello.txt + environment: + FULL_NAME: ${.full_name} + await: false \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_with_args.yaml b/impl/testdata/run_shell_echo_with_args.yaml new file mode 100644 index 0000000..d307752 --- /dev/null +++ b/impl/testdata/run_shell_echo_with_args.yaml @@ -0,0 +1,28 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + arguments: + '--user': 'john' + '--password': 'doe' + command: echo + return: all \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_with_args_only_key.yaml b/impl/testdata/run_shell_echo_with_args_only_key.yaml new file mode 100644 index 0000000..45ae332 --- /dev/null +++ b/impl/testdata/run_shell_echo_with_args_only_key.yaml @@ -0,0 +1,31 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + arguments: + 'Hello': + '${.firstName}': + '${.lastName}': + from: + 'args!': + command: echo + return: all \ No newline at end of file diff --git a/impl/testdata/run_shell_echo_with_env.yaml b/impl/testdata/run_shell_echo_with_env.yaml new file mode 100644 index 0000000..5ad6516 --- /dev/null +++ b/impl/testdata/run_shell_echo_with_env.yaml @@ -0,0 +1,28 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: echo "Hello $FIRST_NAME $LAST_NAME from env!" + environment: + FIRST_NAME: John + LAST_NAME: ${.lastName} + return: all \ No newline at end of file diff --git a/impl/testdata/run_shell_exitcode.yaml b/impl/testdata/run_shell_exitcode.yaml new file mode 100644 index 0000000..1c2fd54 --- /dev/null +++ b/impl/testdata/run_shell_exitcode.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'ls /nonexistent_directory' + return: code \ No newline at end of file diff --git a/impl/testdata/run_shell_ls_stderr.yaml b/impl/testdata/run_shell_ls_stderr.yaml new file mode 100644 index 0000000..a776ebc --- /dev/null +++ b/impl/testdata/run_shell_ls_stderr.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: 'ls /nonexistent_directory' + return: stderr \ No newline at end of file diff --git a/impl/testdata/run_shell_missing_command.yaml b/impl/testdata/run_shell_missing_command.yaml new file mode 100644 index 0000000..92f0b81 --- /dev/null +++ b/impl/testdata/run_shell_missing_command.yaml @@ -0,0 +1,27 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - missingShellCommand: + run: + shell: + command: '' + environment: + FIRST_NAME: John + LAST_NAME: ${.lastName} \ No newline at end of file diff --git a/impl/testdata/run_shell_touch_cat.yaml b/impl/testdata/run_shell_touch_cat.yaml new file mode 100644 index 0000000..65796f9 --- /dev/null +++ b/impl/testdata/run_shell_touch_cat.yaml @@ -0,0 +1,25 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + command: echo "hello world" > /tmp/hello.txt && cat /tmp/hello.txt + return: all \ No newline at end of file diff --git a/impl/testdata/run_shell_with_args_key_value_jq.yaml b/impl/testdata/run_shell_with_args_key_value_jq.yaml new file mode 100644 index 0000000..938bc58 --- /dev/null +++ b/impl/testdata/run_shell_with_args_key_value_jq.yaml @@ -0,0 +1,28 @@ +# Copyright 2025 The Serverless Workflow Specification Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + arguments: + '--user': '${.user}' + '${.passwordKey}': 'serverless' + command: echo + return: all \ No newline at end of file diff --git a/impl/testdata/runshell_echo_env_no_awaiting.yaml b/impl/testdata/runshell_echo_env_no_awaiting.yaml deleted file mode 100644 index 4642495..0000000 --- a/impl/testdata/runshell_echo_env_no_awaiting.yaml +++ /dev/null @@ -1,13 +0,0 @@ -document: - dsl: '1.0.1' - namespace: test - name: run-shell-example - version: '0.1.0' -do: - - runShell: - run: - shell: - command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello.txt && cat /tmp/hello.txt - environment: - FULL_NAME: ${.full_name} - await: false \ No newline at end of file diff --git a/impl/testdata/runshell_echo_jq.yaml b/impl/testdata/runshell_echo_jq.yaml deleted file mode 100644 index 4e1b3ee..0000000 --- a/impl/testdata/runshell_echo_jq.yaml +++ /dev/null @@ -1,11 +0,0 @@ -document: - dsl: '1.0.1' - namespace: test - name: run-shell-example - version: '0.1.0' -do: - - runShell: - run: - shell: - command: ${ "echo Hello, \(.user.name)" } - return: all \ No newline at end of file diff --git a/impl/testdata/runshell_echo_none.yaml b/impl/testdata/runshell_echo_none.yaml deleted file mode 100644 index dde3289..0000000 --- a/impl/testdata/runshell_echo_none.yaml +++ /dev/null @@ -1,11 +0,0 @@ -document: - dsl: '1.0.1' - namespace: test - name: run-shell-example - version: '0.1.0' -do: - - runShell: - run: - shell: - command: 'echo "Serverless Workflow"' - return: none \ No newline at end of file diff --git a/impl/testdata/runshell_exitcode.yaml b/impl/testdata/runshell_exitcode.yaml deleted file mode 100644 index 7b38058..0000000 --- a/impl/testdata/runshell_exitcode.yaml +++ /dev/null @@ -1,11 +0,0 @@ -document: - dsl: '1.0.1' - namespace: test - name: run-shell-example - version: '0.1.0' -do: - - runShell: - run: - shell: - command: 'ls /nonexistent_directory' - return: code \ No newline at end of file From 278b1e19351c0c905f905aca0e7c9e5501f8e116 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Sun, 26 Oct 2025 23:58:59 -0300 Subject: [PATCH 5/7] Move runShell code Signed-off-by: Matheus Cruz --- impl/task_runner_run.go | 125 ---------------- impl/task_runner_run_shell.go | 134 ++++++++++++++++++ ..._test.go => task_runner_run_shell_test.go} | 126 +--------------- .../run_shell_echo_env_no_awaiting.yaml | 2 +- 4 files changed, 142 insertions(+), 245 deletions(-) create mode 100644 impl/task_runner_run_shell.go rename impl/{task_runner_run_test.go => task_runner_run_shell_test.go} (66%) diff --git a/impl/task_runner_run.go b/impl/task_runner_run.go index b80b789..138dc6c 100644 --- a/impl/task_runner_run.go +++ b/impl/task_runner_run.go @@ -15,14 +15,8 @@ package impl import ( - "bytes" - "errors" "fmt" - "os" - "os/exec" - "strings" - "github.com/serverlessworkflow/sdk-go/v3/impl/expr" "github.com/serverlessworkflow/sdk-go/v3/model" ) @@ -78,122 +72,3 @@ func NewProcessResult(stdout, stderr string, code int) *ProcessResult { Code: code, } } - -// RunTaskShell defines the shell configuration for RunTask. -// It implements the RunTask.shell definition. -type RunTaskShell struct { -} - -// NewRunTaskShell creates a new RunTaskShell instance. -func NewRunTaskShell() *RunTaskShell { - return &RunTaskShell{} -} - -func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, taskSupport TaskSupport) (interface{}, error) { - await := r.Task.Run.Await - shell := r.Task.Run.Shell - var cmdStr string - - if shell != nil { - cmdStr = shell.Command - } - - if cmdStr == "" { - return nil, model.NewErrValidation(fmt.Errorf("no command provided for RunTask shell: %s ", r.TaskName), r.TaskName) - } - - if shell.Environment != nil { - for key, value := range shell.Environment { - evaluated, ok := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) - if ok != nil { - return nil, model.NewErrRuntime(fmt.Errorf("error evaluating environment variable value for RunTask shell: %s", r.TaskName), r.TaskName) - } - - err := os.Setenv(key, evaluated.(string)) - if err != nil { - return nil, model.NewErrRuntime(fmt.Errorf("error setting environment variable for RunTask shell: %s", r.TaskName), r.TaskName) - } - } - } - - evaluated, err := expr.TraverseAndEvaluate(cmdStr, input, taskSupport.GetContext()) - if err != nil { - return nil, model.NewErrRuntime(fmt.Errorf("error evaluating command for RunTask shell: %s", r.TaskName), r.TaskName) - } - - cmdEvaluated, ok := evaluated.(string) - if !ok { - return nil, model.NewErrRuntime(fmt.Errorf("expected evaluated command to be a string, but got a different type"), r.TaskName) - } - - var args []string - - args = append(args, "-c", cmdEvaluated) - - if shell.Arguments != nil { - for key, value := range shell.Arguments { - keyEval, ok := expr.TraverseAndEvaluate(key, input, taskSupport.GetContext()) - if ok != nil { - return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument value for RunTask shell: %s", r.TaskName), r.TaskName) - } - - if value != nil { - valueEval, ok := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) - if ok != nil { - return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument value for RunTask shell: %s", r.TaskName), r.TaskName) - } - args = append(args, fmt.Sprintf("%s=%s", keyEval, valueEval)) - } else { - args = append(args, fmt.Sprintf("%s", keyEval)) - } - } - } - - var fullCmd strings.Builder - fullCmd.WriteString(cmdEvaluated) - for i := 2; i < len(args); i++ { - fullCmd.WriteString(" ") - fullCmd.WriteString(args[i]) - } - - if await != nil && !*await { - go func() { - cmd := exec.Command("sh", "-c", fullCmd.String()) - _ = cmd.Start() - cmd.Wait() - }() - return input, nil - } - - cmd := exec.Command("sh", "-c", fullCmd.String()) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - exitCode := 0 - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } - } else if cmd.ProcessState != nil { - exitCode = cmd.ProcessState.ExitCode() - } - - stdoutStr := strings.TrimSpace(stdout.String()) - stderrStr := strings.TrimSpace(stderr.String()) - - switch r.Task.Run.Return { - case "all": - return NewProcessResult(stdoutStr, stderrStr, exitCode), nil - case "stderr": - return stderrStr, nil - case "code": - return exitCode, nil - case "none": - return nil, nil - default: - return stdoutStr, nil - } -} diff --git a/impl/task_runner_run_shell.go b/impl/task_runner_run_shell.go new file mode 100644 index 0000000..e361900 --- /dev/null +++ b/impl/task_runner_run_shell.go @@ -0,0 +1,134 @@ +package impl + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/serverlessworkflow/sdk-go/v3/impl/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +// RunTaskShell defines the shell configuration for RunTask. +// It implements the RunTask.shell definition. +type RunTaskShell struct { +} + +// NewRunTaskShell creates a new RunTaskShell instance. +func NewRunTaskShell() *RunTaskShell { + return &RunTaskShell{} +} + +func (shellTask *RunTaskShell) RunTask(r *RunTaskRunner, input interface{}, taskSupport TaskSupport) (interface{}, error) { + await := r.Task.Run.Await + shell := r.Task.Run.Shell + var cmdStr string + + if shell == nil { + return nil, model.NewErrValidation(fmt.Errorf("no shell configuration provided for RunTask %s", r.TaskName), r.TaskName) + } + + cmdStr = shell.Command + + if cmdStr == "" { + return nil, model.NewErrValidation(fmt.Errorf("no command provided for RunTask shell: %s ", r.TaskName), r.TaskName) + } + + if shell.Environment != nil { + for key, value := range shell.Environment { + evaluated, evalErr := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) + if evalErr != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating environment variable value for RunTask shell: %s", r.TaskName), r.TaskName) + } + + envVal := fmt.Sprint(evaluated) + if err := os.Setenv(key, envVal); err != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error setting environment variable for RunTask shell: %s", r.TaskName), r.TaskName) + } + } + } + + evaluated, err := expr.TraverseAndEvaluate(cmdStr, input, taskSupport.GetContext()) + if err != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating command for RunTask shell: %s", r.TaskName), r.TaskName) + } + + cmdEvaluated := fmt.Sprint(evaluated) + + var args []string + + args = append(args, "-c", cmdEvaluated) + + if shell.Arguments != nil { + for key, value := range shell.Arguments { + keyEval, evalErr := expr.TraverseAndEvaluate(key, input, taskSupport.GetContext()) + if evalErr != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument key for RunTask shell: %s", r.TaskName), r.TaskName) + } + + keyStr := fmt.Sprint(keyEval) + + if value != nil { + valueEval, evalErr := expr.TraverseAndEvaluate(value, input, taskSupport.GetContext()) + if evalErr != nil { + return nil, model.NewErrRuntime(fmt.Errorf("error evaluating argument value for RunTask shell: %s", r.TaskName), r.TaskName) + } + valueStr := fmt.Sprint(valueEval) + args = append(args, fmt.Sprintf("%s=%s", keyStr, valueStr)) + } else { + args = append(args, fmt.Sprintf("%s", keyStr)) + } + } + } + + var fullCmd strings.Builder + fullCmd.WriteString(cmdEvaluated) + for i := 2; i < len(args); i++ { + fullCmd.WriteString(" ") + fullCmd.WriteString(args[i]) + } + + if await != nil && !*await { + go func() { + cmd := exec.Command("sh", "-c", fullCmd.String()) + _ = cmd.Start() + _ = cmd.Wait() + }() + return input, nil + } + + cmd := exec.Command("sh", "-c", fullCmd.String()) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } + } else if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + stdoutStr := strings.TrimSpace(stdout.String()) + stderrStr := strings.TrimSpace(stderr.String()) + + switch r.Task.Run.Return { + case "all": + return NewProcessResult(stdoutStr, stderrStr, exitCode), nil + case "stderr": + return stderrStr, nil + case "code": + return exitCode, nil + case "none": + return nil, nil + default: + return stdoutStr, nil + } +} diff --git a/impl/task_runner_run_test.go b/impl/task_runner_run_shell_test.go similarity index 66% rename from impl/task_runner_run_test.go rename to impl/task_runner_run_shell_test.go index 9e92b22..cecfe97 100644 --- a/impl/task_runner_run_test.go +++ b/impl/task_runner_run_shell_test.go @@ -15,73 +15,14 @@ package impl import ( - "fmt" "os" "strings" "testing" - "github.com/serverlessworkflow/sdk-go/v3/impl/ctx" - "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/stretchr/testify/assert" ) -func testingRunShell(t *testing.T, task model.RunTask, expected interface{}, input map[string]interface{}) { - - wfCtx, err := ctx.NewWorkflowContext(&model.Workflow{ - Input: &model.Input{ - From: &model.ObjectOrRuntimeExpr{Value: input}, - }, - }) - assert.NoError(t, err) - wfCtx.SetTaskReference("task_run_defined") - wfCtx.SetInput(input) - - runner, err := NewRunTaskRunner("runShell", &task) - assert.NoError(t, err) - - taskSupport := newTaskSupport(withRunnerCtx(wfCtx)) - - if input == nil { - input = map[string]interface{}{} - } - - output, err := runner.Run(input, taskSupport) - - assert.NoError(t, err) - - switch exp := expected.(type) { - - case int: - // expected an exit code - codeOut, ok := output.(int) - assert.True(t, ok, "output should be int (exit code), got %T", output) - assert.Equal(t, exp, codeOut) - case string: - var outStr string - switch v := output.(type) { - case string: - outStr = v - case []byte: - outStr = string(v) - case int: - outStr = fmt.Sprintf("%d", v) - default: - t.Fatalf("unexpected output type %T", output) - } - outStr = strings.TrimSpace(outStr) - assert.Equal(t, exp, outStr) - case ProcessResult: - resultOut, ok := output.(*ProcessResult) - assert.True(t, ok, "output should be ProcessResult, got %T", output) - assert.Equal(t, exp.Stdout, strings.TrimSpace(resultOut.Stdout)) - assert.Equal(t, exp.Stderr, strings.TrimSpace(resultOut.Stderr)) - assert.Equal(t, exp.Code, resultOut.Code) - default: - t.Fatalf("unsupported expected type %T", expected) - } -} - -func TestWithTestData(t *testing.T) { +func TestRunShellWithTestData(t *testing.T) { t.Run("Simple with echo", func(t *testing.T) { workflowPath := "./testdata/run_shell_echo.yaml" @@ -141,11 +82,11 @@ func TestWithTestData(t *testing.T) { assert.NoError(t, err) assert.Equal(t, output, input) - file, err := os.ReadFile("/tmp/hello.txt") + file, err := os.ReadFile("/tmp/hello-world.txt") assert.Equal(t, "hello world not awaiting (John Doe)", strings.TrimSpace(string(file))) }) - t.Run("Simple echo not awaiting, function returns immediately", func(t *testing.T) { + t.Run("Simple echo not awaiting, function should returns immediately", func(t *testing.T) { workflowPath := "./testdata/run_shell_echo_not_awaiting.yaml" input := map[string]interface{}{ "full_name": "John Doe", @@ -156,7 +97,7 @@ func TestWithTestData(t *testing.T) { assert.Equal(t, output, input) }) - t.Run("Simple ls getting output as stderr", func(t *testing.T) { + t.Run("Simple 'ls' command getting output as stderr", func(t *testing.T) { workflowPath := "./testdata/run_shell_ls_stderr.yaml" input := map[string]interface{}{} @@ -192,6 +133,9 @@ func TestWithTestData(t *testing.T) { processResult := output.(*ProcessResult) + // Go does not keep the order of map iteration + // TODO: improve the UnMarshal of args to keep the order + assert.NoError(t, err) assert.True(t, strings.Contains(processResult.Stdout, "--user=john")) assert.True(t, strings.Contains(processResult.Stdout, "--password=doe")) @@ -250,59 +194,3 @@ func TestWithTestData(t *testing.T) { assert.Equal(t, "", processResult.Stderr) }) } - -func TestRunTaskRunner(t *testing.T) { - tests := []struct { - name string - cmd string - ret string - expected interface{} - input map[string]interface{} - }{ - { - name: "echoLookCode", - cmd: "echo 'hello world'", - ret: "code", - expected: 0, - }, - { - name: "echoLookStdout", - cmd: "echo 'hello world'", - ret: "stdout", - expected: "hello world", - }, - { - name: "echoLookAll", - cmd: "echo 'hello world'", - ret: "all", - expected: *NewProcessResult( - "hello world", - "", - 0, - ), - }, - { - name: "echoJqExpression", - cmd: `${ "echo Hello, I love \(.project)" }`, - ret: "stdout", - expected: "Hello, I love ServerlessWorkflow", - input: map[string]interface{}{ - "project": "ServerlessWorkflow", - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - task := model.RunTask{ - Run: model.RunTaskConfiguration{ - Shell: &model.Shell{ - Command: tc.cmd, - }, - Return: tc.ret, - }, - } - testingRunShell(t, task, tc.expected, tc.input) - }) - } -} diff --git a/impl/testdata/run_shell_echo_env_no_awaiting.yaml b/impl/testdata/run_shell_echo_env_no_awaiting.yaml index 721446f..757d701 100644 --- a/impl/testdata/run_shell_echo_env_no_awaiting.yaml +++ b/impl/testdata/run_shell_echo_env_no_awaiting.yaml @@ -21,7 +21,7 @@ do: - runShell: run: shell: - command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello.txt && cat /tmp/hello.txt + command: echo "hello world not awaiting ($FULL_NAME)" > /tmp/hello-world.txt && cat /tmp/hello-world.txt environment: FULL_NAME: ${.full_name} await: false \ No newline at end of file From 925df1ac3f8a052d0e088d2bff8712d3d973a2e6 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Mon, 27 Oct 2025 12:41:56 -0300 Subject: [PATCH 6/7] Add license header Signed-off-by: Matheus Cruz --- impl/task_runner_run_shell.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/impl/task_runner_run_shell.go b/impl/task_runner_run_shell.go index e361900..9c0c4ef 100644 --- a/impl/task_runner_run_shell.go +++ b/impl/task_runner_run_shell.go @@ -1,3 +1,17 @@ +// Copyright 2025 The Serverless Workflow Specification Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package impl import ( From 0f2b68ee64ed6f75b68899c6096f5234e5be91cc Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Mon, 3 Nov 2025 17:40:32 -0300 Subject: [PATCH 7/7] Change test to be sure about the order of args Signed-off-by: Matheus Cruz --- impl/task_runner_run_shell_test.go | 9 ++------- parser/parser.go | 1 + 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/impl/task_runner_run_shell_test.go b/impl/task_runner_run_shell_test.go index cecfe97..2d5aee8 100644 --- a/impl/task_runner_run_shell_test.go +++ b/impl/task_runner_run_shell_test.go @@ -15,7 +15,6 @@ package impl import ( - "os" "strings" "testing" @@ -82,8 +81,6 @@ func TestRunShellWithTestData(t *testing.T) { assert.NoError(t, err) assert.Equal(t, output, input) - file, err := os.ReadFile("/tmp/hello-world.txt") - assert.Equal(t, "hello world not awaiting (John Doe)", strings.TrimSpace(string(file))) }) t.Run("Simple echo not awaiting, function should returns immediately", func(t *testing.T) { @@ -119,8 +116,7 @@ func TestRunShellWithTestData(t *testing.T) { processResult := output.(*ProcessResult) assert.NoError(t, err) - assert.True(t, strings.Contains(processResult.Stdout, "--user=Alice")) - assert.True(t, strings.Contains(processResult.Stdout, "--password=serverless")) + assert.True(t, strings.Contains(processResult.Stdout, "--user=Alice --password=serverless")) assert.Equal(t, 0, processResult.Code) assert.Equal(t, "", processResult.Stderr) }) @@ -137,8 +133,7 @@ func TestRunShellWithTestData(t *testing.T) { // TODO: improve the UnMarshal of args to keep the order assert.NoError(t, err) - assert.True(t, strings.Contains(processResult.Stdout, "--user=john")) - assert.True(t, strings.Contains(processResult.Stdout, "--password=doe")) + assert.Equal(t, "--user=john --password=doe", processResult.Stdout) assert.Equal(t, 0, processResult.Code) assert.Equal(t, "", processResult.Stderr) }) diff --git a/parser/parser.go b/parser/parser.go index 3707132..e9f8770 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -37,6 +37,7 @@ var supportedExt = []string{extYAML, extYML, extJSON} // FromYAMLSource parses the given Serverless Workflow YAML source into the Workflow type. func FromYAMLSource(source []byte) (workflow *model.Workflow, err error) { var jsonBytes []byte + err = yaml.Unmarshal(source, &jsonBytes) if jsonBytes, err = yaml.YAMLToJSON(source); err != nil { return nil, err }