From da245eea5afd6c1b1e300c51526b5f85d4c6892a Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Thu, 23 Oct 2025 20:50:30 -0300 Subject: [PATCH 1/4] Add support for RunTask.shell task Signed-off-by: Matheus Cruz --- .../impl/executors/ProcessResult.java | 18 ++ .../impl/executors/RunShellExecutor.java | 214 ++++++++++++++ ...erlessworkflow.impl.executors.RunnableTask | 3 +- .../impl/test/RunShellExecutorTest.java | 263 ++++++++++++++++++ .../run-shell/echo-exitcode.yaml | 11 + .../workflows-samples/run-shell/echo-jq.yaml | 11 + .../run-shell/echo-none.yaml | 11 + .../run-shell/echo-not-awaiting.yaml | 13 + .../run-shell/echo-stderr.yaml | 11 + .../echo-with-args-key-value-jq.yaml | 14 + .../run-shell/echo-with-args-key-value.yaml | 14 + .../run-shell/echo-with-args-only-key.yaml | 17 ++ .../run-shell/echo-with-env.yaml | 14 + .../workflows-samples/run-shell/echo.yaml | 11 + .../run-shell/missing-shell-command.yaml | 14 + .../run-shell/touch-cat.yaml | 15 + 16 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/executors/ProcessResult.java create mode 100644 impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java create mode 100644 impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-exitcode.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-jq.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-none.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-not-awaiting.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-stderr.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value-jq.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-only-key.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo-with-env.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/echo.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/missing-shell-command.yaml create mode 100644 impl/test/src/test/resources/workflows-samples/run-shell/touch-cat.yaml diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ProcessResult.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ProcessResult.java new file mode 100644 index 00000000..f04ded8c --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ProcessResult.java @@ -0,0 +1,18 @@ +/* + * Copyright 2020-Present 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 io.serverlessworkflow.impl.executors; + +public record ProcessResult(int code, String stdout, String stderr) {} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java new file mode 100644 index 00000000..269224d9 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java @@ -0,0 +1,214 @@ +/* + * Copyright 2020-Present 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 io.serverlessworkflow.impl.executors; + +import io.serverlessworkflow.api.types.RunShell; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.api.types.Shell; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowContext; +import io.serverlessworkflow.impl.WorkflowDefinition; +import io.serverlessworkflow.impl.WorkflowError; +import io.serverlessworkflow.impl.WorkflowException; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.WorkflowModelFactory; +import io.serverlessworkflow.impl.WorkflowUtils; +import io.serverlessworkflow.impl.expressions.ExpressionUtils; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public class RunShellExecutor implements RunnableTask { + + private ShellResultSupplier shellResultSupplier; + private ProcessBuilderSupplier processBuilderSupplier; + + @FunctionalInterface + private interface ShellResultSupplier { + WorkflowModel apply( + TaskContext taskContext, WorkflowModel input, ProcessBuilder processBuilder); + } + + @FunctionalInterface + private interface ProcessBuilderSupplier { + ProcessBuilder apply(WorkflowContext workflowContext, TaskContext taskContext); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + ProcessBuilder processBuilder = this.processBuilderSupplier.apply(workflowContext, taskContext); + WorkflowModel workflowModel = + this.shellResultSupplier.apply(taskContext, input, processBuilder); + return CompletableFuture.completedFuture(workflowModel); + } + + @Override + public void init(RunShell taskConfiguration, WorkflowDefinition definition) { + Shell shell = taskConfiguration.getShell(); + final String shellCommand = shell.getCommand(); + + if (shellCommand == null || shellCommand.isBlank()) { + throw new IllegalStateException("Missing shell command in RunShell task configuration"); + } + this.processBuilderSupplier = + (workflowContext, taskContext) -> { + WorkflowApplication application = definition.application(); + + String command = + ExpressionUtils.isExpr(shellCommand) + ? WorkflowUtils.buildStringResolver( + application, shellCommand, taskContext.input().asJavaObject()) + .apply(workflowContext, taskContext, taskContext.input()) + : shellCommand; + + StringBuilder commandBuilder = new StringBuilder(command); + + if (shell.getArguments() != null + && shell.getArguments().getAdditionalProperties() != null) { + for (Map.Entry entry : + shell.getArguments().getAdditionalProperties().entrySet()) { + + String argKey = + ExpressionUtils.isExpr(entry.getKey()) + ? WorkflowUtils.buildStringResolver( + application, entry.getKey(), taskContext.input().asJavaObject()) + .apply(workflowContext, taskContext, taskContext.input()) + : entry.getKey(); + + if (entry.getValue() == null) { + commandBuilder.append(" ").append(argKey); + continue; + } + + String argValue = + ExpressionUtils.isExpr(entry.getValue()) + ? WorkflowUtils.buildStringResolver( + application, + entry.getValue().toString(), + taskContext.input().asJavaObject()) + .apply(workflowContext, taskContext, taskContext.input()) + : entry.getValue().toString(); + + commandBuilder.append(" ").append(argKey).append("=").append(argValue); + } + } + + // TODO: support Windows cmd.exe + ProcessBuilder builder = new ProcessBuilder("sh", "-c", commandBuilder.toString()); + + if (shell.getEnvironment() != null + && shell.getEnvironment().getAdditionalProperties() != null) { + for (Map.Entry entry : + shell.getEnvironment().getAdditionalProperties().entrySet()) { + String value = + ExpressionUtils.isExpr(entry.getValue()) + ? WorkflowUtils.buildStringResolver( + application, + entry.getValue().toString(), + taskContext.input().asJavaObject()) + .apply(workflowContext, taskContext, taskContext.input()) + : entry.getValue().toString(); + + // configure environments + builder.environment().put(entry.getKey(), value); + } + } + + return builder; + }; + + this.shellResultSupplier = + (taskContext, input, processBuilder) -> { + try { + Process process = processBuilder.start(); + + if (taskConfiguration.isAwait()) { + return waitForResult(taskConfiguration, definition, process); + } else { + return input; + } + + } catch (IOException | InterruptedException e) { + throw new WorkflowException(WorkflowError.runtime(taskContext, e).build(), e); + } + }; + } + + private WorkflowModel waitForResult( + RunShell taskConfiguration, WorkflowDefinition definition, Process process) + throws IOException, InterruptedException { + + CompletableFuture futureStdout = + CompletableFuture.supplyAsync(() -> readInputStream(process.getInputStream())); + CompletableFuture futureStderr = + CompletableFuture.supplyAsync(() -> readInputStream(process.getErrorStream())); + + int exitCode = process.waitFor(); + + CompletableFuture allStd = CompletableFuture.allOf(futureStdout, futureStderr); + + allStd.join(); + + String stdout = futureStdout.join(); + String stderr = futureStderr.join(); + + RunTaskConfiguration.ProcessReturnType returnType = taskConfiguration.getReturn(); + + WorkflowModelFactory modelFactory = definition.application().modelFactory(); + + return switch (returnType) { + case ALL -> modelFactory.fromAny(new ProcessResult(exitCode, stdout.trim(), stderr.trim())); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(exitCode); + case STDOUT -> modelFactory.from(stdout.trim()); + case STDERR -> modelFactory.from(stderr.trim()); + }; + } + + @Override + public boolean accept(Class clazz) { + return RunShell.class.equals(clazz); + } + + /** + * Reads an InputStream and returns its content as a String. It keeps the original content using + * UTF-8 encoding. + * + * @param inputStream {@link InputStream} to be read + * @return {@link String} with the content of the InputStream + */ + public static String readInputStream(InputStream inputStream) { + StringWriter writer = new StringWriter(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + char[] buffer = new char[1024]; + int charsRead; + while ((charsRead = reader.read(buffer)) != -1) { + writer.write(buffer, 0, charsRead); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + return writer.toString(); + } +} diff --git a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask index a26105df..7d2be4f9 100644 --- a/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask +++ b/impl/core/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.RunnableTask @@ -1 +1,2 @@ -io.serverlessworkflow.impl.executors.RunWorkflowExecutor \ No newline at end of file +io.serverlessworkflow.impl.executors.RunWorkflowExecutor +io.serverlessworkflow.impl.executors.RunShellExecutor \ No newline at end of file diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java new file mode 100644 index 00000000..a19d5409 --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java @@ -0,0 +1,263 @@ +/* + * Copyright 2020-Present 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 io.serverlessworkflow.impl.test; + +import io.serverlessworkflow.api.WorkflowReader; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.impl.WorkflowApplication; +import io.serverlessworkflow.impl.WorkflowModel; +import io.serverlessworkflow.impl.executors.ProcessResult; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; + +public class RunShellExecutorTest { + + @Test + void testEcho() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("Hello, anonymous"); + }); + } + } + + @Test + void testEchoWithJqExpression() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo-jq.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = + appl.workflowDefinition(workflow) + .instance(new Input(new User("John Doe"))) + .start() + .join(); + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("Hello, John Doe"); + }); + } + } + + @Test + void testEchoWithEnvironment() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo-with-env.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = + appl.workflowDefinition(workflow).instance(Map.of("lastName", "Doe")).start().join(); + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("Hello John Doe from env!"); + }); + } + } + + @Test + void testTouchAndCat() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/touch-cat.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = + appl.workflowDefinition(workflow).instance(Map.of("lastName", "Doe")).start().join(); + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("hello world"); + }); + } + } + + @Test + void testMissingShellCommand() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-shell/missing-shell-command.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + SoftAssertions.assertSoftly( + softly -> { + softly + .assertThatThrownBy( + () -> { + appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + }) + .hasMessageContaining("Missing shell command in RunShell task configuration"); + }); + } + } + + @Test + void testAwaitBehavior() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-shell/echo-not-awaiting.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map inputMap = Map.of("full_name", "Matheus Cruz"); + + WorkflowModel outputModel = + appl.workflowDefinition(workflow).instance(inputMap).start().join(); + + String content = Files.readString(Path.of("/tmp/hello.txt")); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(content).contains("hello world not awaiting (Matheus Cruz)"); + softly.assertThat(outputModel.asMap().get()).isEqualTo(inputMap); + }); + } + } + + @Test + void testStderr() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo-stderr.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map inputMap = Map.of(); + + WorkflowModel outputModel = + appl.workflowDefinition(workflow).instance(inputMap).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(outputModel.asText()).isPresent(); + softly.assertThat(outputModel.asText().get()).isNotEmpty(); + softly.assertThat(outputModel.asText().get()).contains("ls: cannot access"); + softly.assertThat(outputModel.asText().get()).contains("No such file or directory"); + }); + } + } + + @Test + void testExitCode() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo-exitcode.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map inputMap = Map.of(); + + WorkflowModel outputModel = + appl.workflowDefinition(workflow).instance(inputMap).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(outputModel.asNumber()).isPresent(); + softly.assertThat(outputModel.asNumber().get()).isNotEqualTo(0); + }); + } + } + + @Test + void testNone() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-shell/echo-none.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map inputMap = Map.of(); + + WorkflowModel outputModel = + appl.workflowDefinition(workflow).instance(inputMap).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(outputModel.asJavaObject()).isNull(); + }); + } + } + + @Test + void testEchoWithArgsOnlyKey() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-shell/echo-with-args-only-key.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = + appl.workflowDefinition(workflow) + .instance(Map.of("firstName", "John", "lastName", "Doe")) + .start() + .join(); + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("Hello John Doe from args!"); + }); + } + } + + @Test + void testEchoWithArgsKeyValue() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-shell/echo-with-args-key-value.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("--user=john --password=doe"); + }); + } + } + + @Test + void testEchoWithArgsKeyValueJq() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-shell/echo-with-args-key-value.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = + appl.workflowDefinition(workflow) + .instance( + Map.of( + "user", "john", + "passwordKey", "--password")) + .start() + .join(); + + SoftAssertions.assertSoftly( + softly -> { + ProcessResult result = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(result.code()).isEqualTo(0); + softly.assertThat(result.stderr()).isEmpty(); + softly.assertThat(result.stdout()).contains("--user=john --password=doe"); + }); + } + } + + record Input(User user) {} + + record User(String name) {} +} diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-exitcode.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-exitcode.yaml new file mode 100644 index 00000000..7b380588 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-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/impl/test/src/test/resources/workflows-samples/run-shell/echo-jq.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-jq.yaml new file mode 100644 index 00000000..4e1b3ee9 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/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/test/src/test/resources/workflows-samples/run-shell/echo-none.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-none.yaml new file mode 100644 index 00000000..dde32896 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/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 diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-not-awaiting.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-not-awaiting.yaml new file mode 100644 index 00000000..46424957 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-not-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/test/src/test/resources/workflows-samples/run-shell/echo-stderr.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-stderr.yaml new file mode 100644 index 00000000..d2869be9 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-stderr.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: stderr \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value-jq.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value-jq.yaml new file mode 100644 index 00000000..a57b676d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value-jq.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.1' + namespace: test + name: run-shell-example + version: '0.1.0' +do: + - runShell: + run: + shell: + arguments: + '--user': '${.user}' + '${.passwordKey}': 'doe' + command: echo + return: all diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value.yaml new file mode 100644 index 00000000..916037c8 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-key-value.yaml @@ -0,0 +1,14 @@ +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 diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-only-key.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-only-key.yaml new file mode 100644 index 00000000..ab717b23 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-args-only-key.yaml @@ -0,0 +1,17 @@ +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 diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-env.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-env.yaml new file mode 100644 index 00000000..8361a328 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo-with-env.yaml @@ -0,0 +1,14 @@ +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 diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/echo.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/echo.yaml new file mode 100644 index 00000000..96dc715a --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/echo.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, anonymous"' + return: all \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/missing-shell-command.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/missing-shell-command.yaml new file mode 100644 index 00000000..81492510 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/missing-shell-command.yaml @@ -0,0 +1,14 @@ +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} + diff --git a/impl/test/src/test/resources/workflows-samples/run-shell/touch-cat.yaml b/impl/test/src/test/resources/workflows-samples/run-shell/touch-cat.yaml new file mode 100644 index 00000000..b7d82d0b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-shell/touch-cat.yaml @@ -0,0 +1,15 @@ +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 + environment: + FIRST_NAME: John + LAST_NAME: ${.lastName} + return: all + From 24048b977c8961614a3fda8491473e5a3085943c Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Fri, 24 Oct 2025 08:06:08 -0300 Subject: [PATCH 2/4] Apply pull request suggestions Signed-off-by: Matheus Cruz --- .../impl/executors/RunShellExecutor.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java index 269224d9..27fe4b11 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java @@ -33,9 +33,11 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.StringWriter; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; public class RunShellExecutor implements RunnableTask { @@ -148,7 +150,7 @@ public void init(RunShell taskConfiguration, WorkflowDefinition definition) { return input; } - } catch (IOException | InterruptedException e) { + } catch (IOException | InterruptedException | UncheckedIOException e) { throw new WorkflowException(WorkflowError.runtime(taskContext, e).build(), e); } }; @@ -159,9 +161,9 @@ private WorkflowModel waitForResult( throws IOException, InterruptedException { CompletableFuture futureStdout = - CompletableFuture.supplyAsync(() -> readInputStream(process.getInputStream())); + CompletableFuture.supplyAsync(inputStreamStringSupplier(process.getInputStream())); CompletableFuture futureStderr = - CompletableFuture.supplyAsync(() -> readInputStream(process.getErrorStream())); + CompletableFuture.supplyAsync(inputStreamStringSupplier(process.getErrorStream())); int exitCode = process.waitFor(); @@ -185,7 +187,17 @@ private WorkflowModel waitForResult( }; } - @Override + private static Supplier inputStreamStringSupplier(InputStream process) { + return () -> { + try { + return readInputStream(process); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }; + } + + @Override public boolean accept(Class clazz) { return RunShell.class.equals(clazz); } @@ -197,7 +209,7 @@ public boolean accept(Class clazz) { * @param inputStream {@link InputStream} to be read * @return {@link String} with the content of the InputStream */ - public static String readInputStream(InputStream inputStream) { + private static String readInputStream(InputStream inputStream) throws IOException { StringWriter writer = new StringWriter(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { @@ -206,8 +218,6 @@ public static String readInputStream(InputStream inputStream) { while ((charsRead = reader.read(buffer)) != -1) { writer.write(buffer, 0, charsRead); } - } catch (IOException e) { - throw new RuntimeException(e); } return writer.toString(); } From 4f6cc975ecafbc27df368ef0bc63be6236884788 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Fri, 24 Oct 2025 08:07:15 -0300 Subject: [PATCH 3/4] Apply pull request suggestions Signed-off-by: Matheus Cruz --- .../impl/executors/RunShellExecutor.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java index 27fe4b11..b783bc82 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java @@ -187,17 +187,17 @@ private WorkflowModel waitForResult( }; } - private static Supplier inputStreamStringSupplier(InputStream process) { - return () -> { - try { - return readInputStream(process); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }; - } + private static Supplier inputStreamStringSupplier(InputStream process) { + return () -> { + try { + return readInputStream(process); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }; + } - @Override + @Override public boolean accept(Class clazz) { return RunShell.class.equals(clazz); } From e211dc8f1b9174cbf5e05b88ca3581c79d32aace Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Fri, 24 Oct 2025 11:16:54 -0300 Subject: [PATCH 4/4] Apply pull request suggestions Signed-off-by: Matheus Cruz --- .../impl/executors/RunShellExecutor.java | 64 ++++--------------- .../impl/test/RunShellExecutorTest.java | 2 +- 2 files changed, 12 insertions(+), 54 deletions(-) diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java index b783bc82..73fdb697 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunShellExecutor.java @@ -28,16 +28,10 @@ import io.serverlessworkflow.impl.WorkflowModelFactory; import io.serverlessworkflow.impl.WorkflowUtils; import io.serverlessworkflow.impl.expressions.ExpressionUtils; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.StringWriter; -import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.function.Supplier; public class RunShellExecutor implements RunnableTask { @@ -59,9 +53,8 @@ private interface ProcessBuilderSupplier { public CompletableFuture apply( WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { ProcessBuilder processBuilder = this.processBuilderSupplier.apply(workflowContext, taskContext); - WorkflowModel workflowModel = - this.shellResultSupplier.apply(taskContext, input, processBuilder); - return CompletableFuture.completedFuture(workflowModel); + return CompletableFuture.completedFuture( + this.shellResultSupplier.apply(taskContext, input, processBuilder)); } @Override @@ -145,34 +138,29 @@ public void init(RunShell taskConfiguration, WorkflowDefinition definition) { Process process = processBuilder.start(); if (taskConfiguration.isAwait()) { - return waitForResult(taskConfiguration, definition, process); + return buildResultFromProcess(taskConfiguration, definition, process); } else { return input; } - } catch (IOException | InterruptedException | UncheckedIOException e) { + } catch (IOException | InterruptedException e) { throw new WorkflowException(WorkflowError.runtime(taskContext, e).build(), e); } }; } - private WorkflowModel waitForResult( + /** + * Builds the WorkflowModel result from the executed process. It waits for the process to finish + * and captures the exit code, stdout, and stderr based on the task configuration. + */ + private WorkflowModel buildResultFromProcess( RunShell taskConfiguration, WorkflowDefinition definition, Process process) throws IOException, InterruptedException { - CompletableFuture futureStdout = - CompletableFuture.supplyAsync(inputStreamStringSupplier(process.getInputStream())); - CompletableFuture futureStderr = - CompletableFuture.supplyAsync(inputStreamStringSupplier(process.getErrorStream())); - int exitCode = process.waitFor(); - CompletableFuture allStd = CompletableFuture.allOf(futureStdout, futureStderr); - - allStd.join(); - - String stdout = futureStdout.join(); - String stderr = futureStderr.join(); + String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); RunTaskConfiguration.ProcessReturnType returnType = taskConfiguration.getReturn(); @@ -187,38 +175,8 @@ private WorkflowModel waitForResult( }; } - private static Supplier inputStreamStringSupplier(InputStream process) { - return () -> { - try { - return readInputStream(process); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }; - } - @Override public boolean accept(Class clazz) { return RunShell.class.equals(clazz); } - - /** - * Reads an InputStream and returns its content as a String. It keeps the original content using - * UTF-8 encoding. - * - * @param inputStream {@link InputStream} to be read - * @return {@link String} with the content of the InputStream - */ - private static String readInputStream(InputStream inputStream) throws IOException { - StringWriter writer = new StringWriter(); - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { - char[] buffer = new char[1024]; - int charsRead; - while ((charsRead = reader.read(buffer)) != -1) { - writer.write(buffer, 0, charsRead); - } - } - return writer.toString(); - } } diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java index a19d5409..bd96d225 100644 --- a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunShellExecutorTest.java @@ -236,7 +236,7 @@ void testEchoWithArgsKeyValue() throws IOException { void testEchoWithArgsKeyValueJq() throws IOException { Workflow workflow = WorkflowReader.readWorkflowFromClasspath( - "workflows-samples/run-shell/echo-with-args-key-value.yaml"); + "workflows-samples/run-shell/echo-with-args-key-value-jq.yaml"); try (WorkflowApplication appl = WorkflowApplication.builder().build()) { WorkflowModel model = appl.workflowDefinition(workflow)