-
Notifications
You must be signed in to change notification settings - Fork 49
Add support for Run.script (Javascript) #962
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RunScript> { | ||
|
|
||
| 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<String, String> envs = new HashMap<>(); | ||
| if (script.getEnvironment() != null) { | ||
| for (Map.Entry<String, Object> 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(); | ||
|
Comment on lines
+121
to
+128
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an example of functor I mention in the general comment. |
||
| envs.put(entry.getKey(), value); | ||
| } | ||
| } | ||
|
|
||
| Map<String, Object> args = new HashMap<>(); | ||
| if (script.getArguments() != null) { | ||
| for (Map.Entry<String, Object> 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)); | ||
| }; | ||
| } | ||
|
Comment on lines
+184
to
+213
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thing that rather than an external context, GraalVM supports embedded call
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me elaborate a bit more on this. The spec states this |
||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * 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<String, String> 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<WorkflowModel> apply( | ||
| WorkflowContext workflowContext, TaskContext taskContext, WorkflowModel input) { | ||
| return CompletableFuture.supplyAsync( | ||
| () -> this.scriptExecutor.apply(workflowContext, taskContext)); | ||
| } | ||
|
|
||
| @Override | ||
| public boolean accept(Class<? extends RunTaskConfiguration> clazz) { | ||
| return RunScript.class.equals(clazz); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| io.serverlessworkflow.impl.executors.RunWorkflowExecutor | ||
| io.serverlessworkflow.impl.executors.RunShellExecutor | ||
| io.serverlessworkflow.impl.executors.RunShellExecutor | ||
| io.serverlessworkflow.impl.executors.RunScriptExecutor |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ | |
| <version.jakarta.ws.rs>4.0.0</version.jakarta.ws.rs> | ||
| <version.net.thisptr>1.6.0</version.net.thisptr> | ||
| <version.org.glassfish.jersey>3.1.11</version.org.glassfish.jersey> | ||
| <version.org.graalvm.plyglot>23.1.1</version.org.graalvm.plyglot> | ||
| </properties> | ||
| <dependencyManagement> | ||
| <dependencies> | ||
|
|
@@ -92,6 +93,17 @@ | |
| <artifactId>serverlessworkflow-impl-openapi</artifactId> | ||
| <version>${project.version}</version> | ||
| </dependency> | ||
| <dependency> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think graalvm is a the right choice for javascript library, however, as indicated in the general comment, this dependecy should be in an specific javascript module, not in the core one |
||
| <groupId>org.graalvm.polyglot</groupId> | ||
| <artifactId>polyglot</artifactId> | ||
| <version>${version.org.graalvm.plyglot}</version> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.graalvm.polyglot</groupId> | ||
| <artifactId>js</artifactId> | ||
| <version>${version.org.graalvm.plyglot}</version> | ||
| <type>pom</type> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>net.thisptr</groupId> | ||
| <artifactId>jackson-jq</artifactId> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is also another example of "functor" that should be defined at init and later caller at runtime.
You basically generate a WorkflowValueResolver, so the if are invoked during it, that either give you the source inline or use the workflow and task context to load the resource containing the string