diff --git a/impl/core/pom.xml b/impl/core/pom.xml index 0ee45fe9..13e8d364 100644 --- a/impl/core/pom.xml +++ b/impl/core/pom.xml @@ -24,5 +24,14 @@ de.huxhorn.sulky de.huxhorn.sulky.ulid + + org.graalvm.polyglot + polyglot + + + org.graalvm.polyglot + js + pom + diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java new file mode 100644 index 00000000..ffb174e9 --- /dev/null +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/RunScriptExecutor.java @@ -0,0 +1,259 @@ +/* + * 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.RunScript; +import io.serverlessworkflow.api.types.RunTaskConfiguration; +import io.serverlessworkflow.api.types.Script; +import io.serverlessworkflow.api.types.ScriptUnion; +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 io.serverlessworkflow.impl.resources.ResourceLoaderUtils; +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Value; + +public class RunScriptExecutor implements RunnableTask { + + enum ScriptLanguage { + JS("js"), + PYTHON("python"); + + private final String lang; + + ScriptLanguage(String lang) { + this.lang = lang; + } + + public String getLang() { + return lang; + } + + public static boolean isSupported(String lang) { + for (ScriptLanguage l : ScriptLanguage.values()) { + if (l.getLang().equalsIgnoreCase(lang)) { + return true; + } + } + return false; + } + } + + @FunctionalInterface + private interface ScriptExecutor { + WorkflowModel apply(WorkflowContext workflowContext, TaskContext taskContext); + } + + private ScriptExecutor scriptExecutor; + + @Override + public void init(RunScript taskConfiguration, WorkflowDefinition definition) { + ScriptUnion scriptUnion = taskConfiguration.getScript(); + Script script = scriptUnion.get(); + String language = script.getLanguage(); + boolean isAwait = taskConfiguration.isAwait(); + + WorkflowApplication application = definition.application(); + if (language == null || !ScriptLanguage.isSupported(language)) { + throw new IllegalArgumentException( + "Unsupported script language: " + + language + + ". Supported languages are: " + + Arrays.toString( + Arrays.stream(ScriptLanguage.values()).map(ScriptLanguage::getLang).toArray())); + } + + String lowerLang = language.toLowerCase(); + + scriptExecutor = + (workflowContext, taskContext) -> { + String source; + if (scriptUnion.getInlineScript() != null) { + source = scriptUnion.getInlineScript().getCode(); + } else if (scriptUnion.getExternalScript() == null) { + throw new WorkflowException( + WorkflowError.runtime( + taskContext, new IllegalStateException("No script source defined.")) + .build()); + } else { + source = + definition + .resourceLoader() + .load( + scriptUnion.getExternalScript().getSource(), + ResourceLoaderUtils::readString, + workflowContext, + taskContext, + taskContext.input()); + } + + Map envs = new HashMap<>(); + if (script.getEnvironment() != null) { + for (Map.Entry entry : + script.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(); + envs.put(entry.getKey(), value); + } + } + + Map args = new HashMap<>(); + if (script.getArguments() != null) { + for (Map.Entry entry : + script.getArguments().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(); + args.put(entry.getKey(), value); + } + } + + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + try (Context context = + Context.newBuilder(lowerLang) + .err(stderr) + .out(stdout) + .environment(envs) + .useSystemExit(true) + .option( + "engine.WarnInterpreterOnly", + "false") // disabling it due to warnings in stderr + .build()) { + + ExecutorService executorService = application.executorService(); + + args.forEach( + (arg, val) -> { + context.getBindings(lowerLang).putMember(arg, val); + }); + + // configure process.env for js environment variables + if (lowerLang.equalsIgnoreCase(ScriptLanguage.JS.lang)) { + configureProcessEnv(context, envs); + } + + if (!isAwait) { + executorService.submit( + () -> { + context.eval(lowerLang, source); + }); + return application.modelFactory().fromAny(taskContext.input()); + } + + context.eval(lowerLang, source); + + WorkflowModelFactory modelFactory = application.modelFactory(); + + // GraalVM does not provide exit code, assuming 0 for successful execution + int statusCode = 0; + + return switch (taskConfiguration.getReturn()) { + case ALL -> + modelFactory.fromAny( + new ProcessResult(statusCode, stdout.toString(), stderr.toString())); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(statusCode); + case STDOUT -> modelFactory.from(stdout.toString().trim()); + case STDERR -> modelFactory.from(stderr.toString().trim()); + }; + } catch (PolyglotException e) { + if (e.getExitStatus() != 0 || e.isSyntaxError()) { + throw new WorkflowException(WorkflowError.runtime(taskContext, e).build()); + } else { + WorkflowModelFactory modelFactory = definition.application().modelFactory(); + return switch (taskConfiguration.getReturn()) { + case ALL -> + modelFactory.fromAny( + new ProcessResult( + e.getExitStatus(), stdout.toString().trim(), buildStderr(e, stderr))); + case NONE -> modelFactory.fromNull(); + case CODE -> modelFactory.from(e.getExitStatus()); + case STDOUT -> modelFactory.from(stdout.toString().trim()); + case STDERR -> modelFactory.from(buildStderr(e, stderr)); + }; + } + } + }; + } + + /** + * Gets the stderr message from the PolyglotException or the stderr stream. + * + * @param e the {@link PolyglotException} thrown during script execution + * @param stderr the stderr stream + * @return the stderr message + */ + private String buildStderr(PolyglotException e, ByteArrayOutputStream stderr) { + String err = stderr.toString(); + return err.isBlank() ? e.getMessage() : err.trim(); + } + + /** + * Configures the process.env object in the JavaScript context with the provided environment + * variables. + * + * @param context the GraalVM context + * @param envs the environment variables to set + */ + private void configureProcessEnv(Context context, Map envs) { + String js = ScriptLanguage.JS.lang; + Value bindings = context.getBindings(js); + Value process = context.eval(js, "({ env: {} })"); + + for (var entry : envs.entrySet()) { + process.getMember("env").putMember(entry.getKey(), entry.getValue()); + } + bindings.putMember("process", process); + } + + @Override + public CompletableFuture apply( + WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { + return CompletableFuture.supplyAsync( + () -> this.scriptExecutor.apply(workflowContext, taskContext)); + } + + @Override + public boolean accept(Class clazz) { + return RunScript.class.equals(clazz); + } +} 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 7d2be4f9..ea1bb37e 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,2 +1,3 @@ io.serverlessworkflow.impl.executors.RunWorkflowExecutor -io.serverlessworkflow.impl.executors.RunShellExecutor \ No newline at end of file +io.serverlessworkflow.impl.executors.RunShellExecutor +io.serverlessworkflow.impl.executors.RunScriptExecutor \ No newline at end of file diff --git a/impl/pom.xml b/impl/pom.xml index 00d900f9..f75b4d6a 100644 --- a/impl/pom.xml +++ b/impl/pom.xml @@ -14,6 +14,7 @@ 4.0.0 1.6.0 3.1.11 + 23.1.1 @@ -92,6 +93,17 @@ serverlessworkflow-impl-openapi ${project.version} + + org.graalvm.polyglot + polyglot + ${version.org.graalvm.plyglot} + + + org.graalvm.polyglot + js + ${version.org.graalvm.plyglot} + pom + net.thisptr jackson-jq diff --git a/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java new file mode 100644 index 00000000..24832ebf --- /dev/null +++ b/impl/test/src/test/java/io/serverlessworkflow/impl/test/RunScriptJavaScriptTest.java @@ -0,0 +1,186 @@ +/* + * 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.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class RunScriptJavaScriptTest { + + private MockWebServer fileServer; + + @BeforeEach + void setUp() throws IOException { + fileServer = new MockWebServer(); + fileServer.start(8886); + } + + @AfterEach + void tearDown() throws IOException { + fileServer.shutdown(); + } + + @Test + void testConsoleLog() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath("workflows-samples/run-script/console-log.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testConsoleLogWithArgs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-args.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Hello, world!"); + }); + } + } + + @Test + void testConsoleLogWithEnvs() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-envs.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly + .assertThat(model.asText().get()) + .isEqualTo("Running JavaScript code using Serverless Workflow!"); + }); + } + } + + @Test + void testConsoleLogWithExternalSource() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-external-source.yaml"); + + fileServer.enqueue( + new MockResponse() + .setBody( + """ + console.log("hello from script"); + """) + .setHeader("Content-Type", "application/javascript") + .setResponseCode(200)); + + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("hello from script"); + }); + } + } + + @Test + void testFunctionThrowingError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(model.asText()).isPresent(); + softly.assertThat(model.asText().get()).isEqualTo("Error: This is a test error"); + }); + } + } + + @Test + void testFunctionThrowingErrorAndReturnAll() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-throw-all.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + WorkflowModel model = appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + + SoftAssertions.assertSoftly( + softly -> { + ProcessResult r = model.as(ProcessResult.class).orElseThrow(); + softly.assertThat(r.stderr()).isEqualTo("Error: This is a test error"); + softly.assertThat(r.stdout()).isEqualTo("logged before the 'throw' statement"); + softly.assertThat(r.code()).isEqualTo(0); + }); + } + } + + @Test + void testFunctionWithSyntaxError() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/function-with-syntax-error.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Assertions.assertThatThrownBy( + () -> { + appl.workflowDefinition(workflow).instance(Map.of()).start().join(); + }) + .hasMessageContaining("SyntaxError"); + } + } + + @Test + void testConsoleLogNotAwaiting() throws IOException { + Workflow workflow = + WorkflowReader.readWorkflowFromClasspath( + "workflows-samples/run-script/console-log-not-awaiting.yaml"); + try (WorkflowApplication appl = WorkflowApplication.builder().build()) { + Map input = Map.of("hello", "world"); + + WorkflowModel model = appl.workflowDefinition(workflow).instance(input).start().join(); + + Map output = model.asMap().orElseThrow(); + + Assertions.assertThat(output).isEqualTo(input); + } + } +} diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml new file mode 100644 index 00000000..c51681ea --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-args.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + arguments: + greetings: Hello, world! + code: > + console.log(greetings) \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml new file mode 100644 index 00000000..36684b2b --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-envs.yaml @@ -0,0 +1,14 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log(`Running ${process.env.LANGUAGE} code using Serverless Workflow!`); + environment: + LANGUAGE: JavaScript \ No newline at end of file diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml new file mode 100644 index 00000000..5ab9ba4d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-external-source.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + source: + endpoint: + uri: http://localhost:8886/script.js diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml new file mode 100644 index 00000000..c67adb64 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log-not-awaiting.yaml @@ -0,0 +1,13 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); + await: false diff --git a/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml new file mode 100644 index 00000000..79c5013a --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/console-log.yaml @@ -0,0 +1,12 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + console.log("hello from script"); diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml new file mode 100644 index 00000000..75bd074d --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-syntax-error.yaml @@ -0,0 +1,19 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + // there is no reserved word func in JavaScript, it should be function + func hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml new file mode 100644 index 00000000..c6e6cf3f --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw-all.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("logged before the 'throw' statement"); + throw new Error("This is a test error"); + } + + hello(); + return: all diff --git a/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml new file mode 100644 index 00000000..89236129 --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/function-with-throw.yaml @@ -0,0 +1,18 @@ +document: + dsl: '1.0.2' + namespace: test + name: run-script-example + version: '0.1.0' +do: + - runScript: + run: + script: + language: js + code: > + function hello() { + console.log("hello from script"); + throw new Error("This is a test error"); + } + + hello(); + return: stderr diff --git a/impl/test/src/test/resources/workflows-samples/run-script/script.js b/impl/test/src/test/resources/workflows-samples/run-script/script.js new file mode 100644 index 00000000..f7b8bd8c --- /dev/null +++ b/impl/test/src/test/resources/workflows-samples/run-script/script.js @@ -0,0 +1 @@ +console.log("hello from script"); \ No newline at end of file