From 4143609f78fe77cc0b1eea559d4242507e3760a4 Mon Sep 17 00:00:00 2001 From: e-esakman Date: Mon, 12 Oct 2026 22:16:05 +0530 Subject: [PATCH 1/3] feat(zx): add Script and Commands tasks --- plugin-script-zx/build.gradle | 22 ++++ .../io/kestra/plugin/scripts/zx/Commands.java | 93 +++++++++++++ .../io/kestra/plugin/scripts/zx/Script.java | 122 ++++++++++++++++++ .../plugin/scripts/zx/package-info.java | 8 ++ .../icons/io.kestra.plugin.scripts.zx.svg | 10 ++ .../src/main/resources/icons/plugin-icon.svg | 10 ++ .../src/main/resources/metadata/index.yaml | 7 + .../plugin/scripts/zx/CommandsTest.java | 61 +++++++++ .../kestra/plugin/scripts/zx/RunnerTest.java | 22 ++++ .../kestra/plugin/scripts/zx/ScriptTest.java | 114 ++++++++++++++++ .../src/test/resources/allure.properties | 1 + .../src/test/resources/application.yml | 9 ++ .../src/test/resources/logback.xml | 11 ++ .../test/resources/sanity-checks/all_zx.yaml | 18 +++ settings.gradle | 1 + 15 files changed, 509 insertions(+) create mode 100644 plugin-script-zx/build.gradle create mode 100644 plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java create mode 100644 plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Script.java create mode 100644 plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/package-info.java create mode 100644 plugin-script-zx/src/main/resources/icons/io.kestra.plugin.scripts.zx.svg create mode 100644 plugin-script-zx/src/main/resources/icons/plugin-icon.svg create mode 100644 plugin-script-zx/src/main/resources/metadata/index.yaml create mode 100644 plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/CommandsTest.java create mode 100644 plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/RunnerTest.java create mode 100644 plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/ScriptTest.java create mode 100644 plugin-script-zx/src/test/resources/allure.properties create mode 100644 plugin-script-zx/src/test/resources/application.yml create mode 100644 plugin-script-zx/src/test/resources/logback.xml create mode 100644 plugin-script-zx/src/test/resources/sanity-checks/all_zx.yaml diff --git a/plugin-script-zx/build.gradle b/plugin-script-zx/build.gradle new file mode 100644 index 00000000..0160aee1 --- /dev/null +++ b/plugin-script-zx/build.gradle @@ -0,0 +1,22 @@ +project.description = 'Execute zx scripts as part of Kestra data workflows.' + +jar { + manifest { + attributes( + "X-Kestra-Name": project.name, + "X-Kestra-Title": "zx", + "X-Kestra-Group": project.group + ".scripts.zx", + "X-Kestra-Description": project.description, + "X-Kestra-Version": project.version + ) + } +} + +dependencies { + implementation project(':plugin-script') + + testImplementation project(path: ':plugin-script', configuration: 'testOutput') + + testImplementation group: "io.kestra", name: "scheduler", version: kestraVersion + testImplementation group: "io.kestra", name: "worker", version: kestraVersion +} diff --git a/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java new file mode 100644 index 00000000..1e981009 --- /dev/null +++ b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java @@ -0,0 +1,93 @@ +package io.kestra.plugin.scripts.zx; + +import java.util.List; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.annotations.PluginProperty; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.models.tasks.runners.TargetOS; +import io.kestra.core.runners.RunContext; +import io.kestra.plugin.scripts.exec.AbstractExecScript; +import io.kestra.plugin.scripts.exec.scripts.models.DockerOptions; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Execute zx files and commands", + description = "Runs JavaScript shell automation with zx. The default image includes zx at /zx/build/cli.js; custom images and Process runners require Node.js, zx and a supported shell." +) +@Plugin( + examples = { + @Example( + full = true, + title = "Run shell commands with zx", + code = """ + id: zx_commands + namespace: company.team + + tasks: + - id: zx + type: io.kestra.plugin.scripts.zx.Commands + inputFiles: + main.mjs: | + await $`echo Hello Kestra`; + commands: + - node /zx/build/cli.js main.mjs + """ + ) + } +) +public class Commands extends AbstractExecScript implements RunnableTask { + private static final String DEFAULT_IMAGE = "ghcr.io/google/zx:8.5.0"; + + @Schema( + title = "Container image for zx runtime", + description = "Docker image used to run the commands; defaults to 'ghcr.io/google/zx:8.5.0'. Custom images must provide Node.js, zx and a supported shell." + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); + + @Schema( + title = "Commands to execute", + description = "List of shell commands, such as `node /zx/build/cli.js main.mjs` in the default image, executed in order inside the container; combine with beforeCommands for npm install and inputFiles/namespaceFiles to stage code." + ) + @NotNull + @PluginProperty(group = "main") + protected Property> commands; + + @Override + protected DockerOptions injectDefaults(RunContext runContext, DockerOptions original) throws IllegalVariableEvaluationException { + var builder = original.toBuilder(); + if (original.getImage() == null) { + builder.image(runContext.render(this.getContainerImage()).as(String.class).orElse(null)); + } + + return builder.build(); + } + + @Override + public ScriptOutput run(RunContext runContext) throws Exception { + var rTargetOS = runContext.render(this.targetOS).as(TargetOS.class).orElse(null); + + return this.commands(runContext) + .withInterpreter(this.interpreter) + .withBeforeCommands(beforeCommands) + .withBeforeCommandsWithOptions(true) + .withCommands(commands) + .withTargetOS(rTargetOS) + .run(); + } +} diff --git a/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Script.java b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Script.java new file mode 100644 index 00000000..6f03fbe6 --- /dev/null +++ b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Script.java @@ -0,0 +1,122 @@ +package io.kestra.plugin.scripts.zx; + +import java.util.List; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.annotations.PluginProperty; +import io.kestra.core.models.enums.MonacoLanguages; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.models.tasks.runners.TargetOS; +import io.kestra.core.runners.FilesService; +import io.kestra.core.runners.RunContext; +import io.kestra.plugin.scripts.exec.AbstractExecScript; +import io.kestra.plugin.scripts.exec.scripts.models.DockerOptions; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Run inline zx script", + description = "Runs JavaScript shell automation with zx. The default image includes zx; custom images and Process runners require Node.js, zx and a supported shell." +) +@Plugin( + examples = { + @Example( + full = true, + title = "Run shell commands with zx", + code = """ + id: zx_script + namespace: company.team + + tasks: + - id: zx + type: io.kestra.plugin.scripts.zx.Script + script: | + const name = 'Kestra'; + await $`echo Hello ${name}`; + """ + ) + } +) +public class Script extends AbstractExecScript implements RunnableTask { + private static final String DEFAULT_IMAGE = "ghcr.io/google/zx:8.5.0"; + private static final String DEFAULT_EXECUTABLE = "node /zx/build/cli.js"; + + @Schema( + title = "Container image for zx runtime", + description = "Docker image used to run the script; defaults to 'ghcr.io/google/zx:8.5.0'. Custom images must provide Node.js, zx and a supported shell." + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); + + @Schema( + title = "Inline zx script", + description = "JavaScript source as a multi-line string; saved to a temporary .mjs file and executed with the configured zx command. For existing files, use the Commands task." + ) + @NotNull + @PluginProperty(language = MonacoLanguages.JAVASCRIPT, group = "main") + protected Property script; + + @Schema( + title = "Command used to launch zx", + description = "Defaults to `node /zx/build/cli.js` for the bundled CLI in the default image. Set to `zx` when using a custom image or Process runner with zx installed on PATH." + ) + @Builder.Default + @NotNull + @PluginProperty(group = "execution") + protected Property executable = Property.ofValue(DEFAULT_EXECUTABLE); + + @Override + protected DockerOptions injectDefaults(RunContext runContext, DockerOptions original) throws IllegalVariableEvaluationException { + var builder = original.toBuilder(); + if (original.getImage() == null) { + builder.image(runContext.render(this.getContainerImage()).as(String.class).orElse(null)); + } + + return builder.build(); + } + + @Override + public ScriptOutput run(RunContext runContext) throws Exception { + var commands = this.commands(runContext); + + var inputFiles = FilesService.inputFiles(runContext, commands.getTaskRunner().additionalVars(runContext, commands), this.getInputFiles()); + var relativeScriptPath = runContext.workingDir().path().relativize(runContext.workingDir().createTempFile(".mjs")); + inputFiles.put( + relativeScriptPath.toString(), + commands.render(runContext, this.script) + ); + commands = commands.withInputFiles(inputFiles); + + var rTargetOS = runContext.render(this.targetOS).as(TargetOS.class).orElse(null); + var rExecutable = runContext.render(this.executable).as(String.class).orElse(DEFAULT_EXECUTABLE); + return commands + .withInterpreter(this.interpreter) + .withBeforeCommands(beforeCommands) + .withBeforeCommandsWithOptions(true) + .withCommands( + Property.ofValue( + List.of( + String.join( + " ", rExecutable, + commands.getTaskRunner().toAbsolutePath(runContext, commands, relativeScriptPath.toString(), rTargetOS) + ) + ) + ) + ) + .withTargetOS(rTargetOS) + .run(); + } +} diff --git a/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/package-info.java b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/package-info.java new file mode 100644 index 00000000..920a6105 --- /dev/null +++ b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/package-info.java @@ -0,0 +1,8 @@ +@PluginSubGroup( + title = "zx", + description = "Tasks for running JavaScript shell automation with zx.", + categories = { PluginSubGroup.PluginCategory.CORE } +) +package io.kestra.plugin.scripts.zx; + +import io.kestra.core.models.annotations.PluginSubGroup; diff --git a/plugin-script-zx/src/main/resources/icons/io.kestra.plugin.scripts.zx.svg b/plugin-script-zx/src/main/resources/icons/io.kestra.plugin.scripts.zx.svg new file mode 100644 index 00000000..f1948b65 --- /dev/null +++ b/plugin-script-zx/src/main/resources/icons/io.kestra.plugin.scripts.zx.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/plugin-script-zx/src/main/resources/icons/plugin-icon.svg b/plugin-script-zx/src/main/resources/icons/plugin-icon.svg new file mode 100644 index 00000000..f1948b65 --- /dev/null +++ b/plugin-script-zx/src/main/resources/icons/plugin-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/plugin-script-zx/src/main/resources/metadata/index.yaml b/plugin-script-zx/src/main/resources/metadata/index.yaml new file mode 100644 index 00000000..82dce4cf --- /dev/null +++ b/plugin-script-zx/src/main/resources/metadata/index.yaml @@ -0,0 +1,7 @@ +group: io.kestra.plugin.scripts.zx +name: "plugin-zx" +title: "zx" +description: "Tasks that run zx scripts." +body: "Run JavaScript shell automation inline or from existing files using zx, capturing logs and output files for downstream tasks. The default container includes Node.js, zx, and Bash." +createdBy: "Kestra Core Team" +managedBy: "Kestra Core Team" diff --git a/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/CommandsTest.java b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/CommandsTest.java new file mode 100644 index 00000000..fa809d28 --- /dev/null +++ b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/CommandsTest.java @@ -0,0 +1,61 @@ +package io.kestra.plugin.scripts.zx; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableMap; + +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.LogEntry; +import io.kestra.core.models.property.Property; +import io.kestra.core.queues.QueueFactoryInterface; +import io.kestra.core.queues.QueueInterface; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.utils.TestsUtils; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import reactor.core.publisher.Flux; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@KestraTest +class CommandsTest { + @Inject + RunContextFactory runContextFactory; + + @Inject + @Named(QueueFactoryInterface.WORKERTASKLOG_NAMED) + private QueueInterface logQueue; + + @Test + void executesStagedScript() throws Exception { + List logs = new CopyOnWriteArrayList<>(); + Flux receive = TestsUtils.receive(logQueue, l -> logs.add(l.getLeft())); + + Commands zxCommands = Commands.builder() + .id("zx-commands-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .inputFiles(Map.of("main.mjs", "$.verbose = false; console.log((await $`echo hello there!`).stdout.trim());")) + .commands(Property.ofValue(List.of("node /zx/build/cli.js main.mjs"))) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, zxCommands, ImmutableMap.of()); + ScriptOutput run = zxCommands.run(runContext); + + assertThat(run.getExitCode(), is(0)); + assertThat(run.getStdOutLineCount(), is(1)); + assertThat(run.getStdErrLineCount(), is(0)); + + TestsUtils.awaitLog(logs, log -> log.getMessage() != null && log.getMessage().contains("hello there!")); + receive.blockLast(); + assertThat(List.copyOf(logs).stream().filter(logEntry -> logEntry.getMessage() != null && logEntry.getMessage().contains("hello there!")).count(), is(1L)); + } +} diff --git a/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/RunnerTest.java b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/RunnerTest.java new file mode 100644 index 00000000..1501f963 --- /dev/null +++ b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/RunnerTest.java @@ -0,0 +1,22 @@ +package io.kestra.plugin.scripts.zx; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.junit.annotations.ExecuteFlow; +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.flows.State; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +@KestraTest(startRunner = true) +class RunnerTest { + @Test + @ExecuteFlow(value = "sanity-checks/all_zx.yaml", timeout = "PT5M") + void all_zx(Execution execution) { + assertThat(execution.getTaskRunList(), hasSize(2)); + assertThat(execution.getState().getCurrent(), is(State.Type.SUCCESS)); + } +} diff --git a/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/ScriptTest.java b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/ScriptTest.java new file mode 100644 index 00000000..c4f466ad --- /dev/null +++ b/plugin-script-zx/src/test/java/io/kestra/plugin/scripts/zx/ScriptTest.java @@ -0,0 +1,114 @@ +package io.kestra.plugin.scripts.zx; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableMap; + +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.LogEntry; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTaskException; +import io.kestra.core.queues.QueueFactoryInterface; +import io.kestra.core.queues.QueueInterface; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.storages.StorageInterface; +import io.kestra.core.tenant.TenantService; +import io.kestra.core.utils.TestsUtils; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; +import io.kestra.plugin.scripts.runner.docker.Docker; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import reactor.core.publisher.Flux; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@KestraTest +class ScriptTest { + @Inject + RunContextFactory runContextFactory; + + @Inject + StorageInterface storageInterface; + + @Inject + @Named(QueueFactoryInterface.WORKERTASKLOG_NAMED) + private QueueInterface logQueue; + + @Test + void script() throws Exception { + List logs = new CopyOnWriteArrayList<>(); + Flux receive = TestsUtils.receive(logQueue, l -> logs.add(l.getLeft())); + + Script zxScript = Script.builder() + .id("zx-script-" + UUID.randomUUID()) + .type(Script.class.getName()) + .script(Property.ofValue("$.verbose = false; const name = 'hello there!'; console.log((await $`echo ${name}`).stdout.trim());")) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, zxScript, ImmutableMap.of()); + ScriptOutput run = zxScript.run(runContext); + + assertThat(run.getExitCode(), is(0)); + assertThat(run.getStdOutLineCount(), is(1)); + assertThat(run.getStdErrLineCount(), is(0)); + + TestsUtils.awaitLog(logs, log -> log.getMessage() != null && log.getMessage().contains("hello there!")); + receive.blockLast(); + assertThat(List.copyOf(logs).stream().filter(logEntry -> logEntry.getMessage() != null && logEntry.getMessage().contains("hello there!")).count(), is(1L)); + } + + @Test + void failedShellCommandFailsTask() { + Script task = Script.builder() + .id("zx-failure-" + UUID.randomUUID()) + .type(Script.class.getName()) + .script(Property.ofValue("await $`exit 7`;")) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, task, ImmutableMap.of()); + RunnableTaskException failure = assertThrows(RunnableTaskException.class, () -> task.run(runContext)); + // zx reports the child exit code in its error, but the CLI itself exits with 1. + assertThat(((ScriptOutput) failure.getOutput()).getExitCode(), is(1)); + } + + @Test + void rendersScriptAndPreservesFilesAndEnvironment() throws Exception { + Script task = Script.builder() + .id("zx-files-" + UUID.randomUUID()) + .type(Script.class.getName()) + .taskRunner(Docker.builder().type(Docker.class.getName()).networkMode("none").build()) + .executable(Property.ofExpression("{{ inputs.zxCommand }}")) + .inputFiles(Map.of("input.txt", "hello")) + .env(Property.ofValue(Map.of("ZX_TEST_SUFFIX", "world"))) + .beforeCommands(Property.ofValue(List.of("mkdir -p result"))) + .outputFiles(Property.ofValue(List.of("result/output.txt"))) + .script(Property.ofExpression(""" + import { readFile, writeFile } from 'node:fs/promises'; + import { networkInterfaces } from 'node:os'; + if (Object.values(networkInterfaces()).flat().some(address => !address.internal)) { + throw new Error('Configured isolated network was not preserved'); + } + const input = await readFile('input.txt', 'utf8'); + await writeFile('result/output.txt', `${input} ${process.env.ZX_TEST_SUFFIX} {{ inputs.marker }}`); + """)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, task, ImmutableMap.of("marker", "rendered", "zxCommand", "node /zx/build/cli.js")); + ScriptOutput output = task.run(runContext); + + assertThat(output.getExitCode(), is(0)); + try (var stream = storageInterface.get(TenantService.MAIN_TENANT, null, output.getOutputFiles().get("result/output.txt"))) { + assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8), is("hello world rendered")); + } + } +} diff --git a/plugin-script-zx/src/test/resources/allure.properties b/plugin-script-zx/src/test/resources/allure.properties new file mode 100644 index 00000000..4873f6d0 --- /dev/null +++ b/plugin-script-zx/src/test/resources/allure.properties @@ -0,0 +1 @@ +allure.results.directory=build/allure-results diff --git a/plugin-script-zx/src/test/resources/application.yml b/plugin-script-zx/src/test/resources/application.yml new file mode 100644 index 00000000..ffbcc1b0 --- /dev/null +++ b/plugin-script-zx/src/test/resources/application.yml @@ -0,0 +1,9 @@ +kestra: + storage: + type: local + local: + base-path: /tmp/unittest + queue: + type: memory + repository: + type: memory diff --git a/plugin-script-zx/src/test/resources/logback.xml b/plugin-script-zx/src/test/resources/logback.xml new file mode 100644 index 00000000..803c82e3 --- /dev/null +++ b/plugin-script-zx/src/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/plugin-script-zx/src/test/resources/sanity-checks/all_zx.yaml b/plugin-script-zx/src/test/resources/sanity-checks/all_zx.yaml new file mode 100644 index 00000000..b800827b --- /dev/null +++ b/plugin-script-zx/src/test/resources/sanity-checks/all_zx.yaml @@ -0,0 +1,18 @@ +id: all_zx +namespace: io.kestra.tests + +tasks: + - id: inline + type: io.kestra.plugin.scripts.zx.Script + script: | + $.verbose = false; + console.log((await $`echo inline-zx`).stdout.trim()); + + - id: existing + type: io.kestra.plugin.scripts.zx.Commands + inputFiles: + main.mjs: | + $.verbose = false; + console.log((await $`echo existing-zx`).stdout.trim()); + commands: + - node /zx/build/cli.js main.mjs diff --git a/settings.gradle b/settings.gradle index 1618a8e8..1f40cc5f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,3 +24,4 @@ include 'plugin-script-powershell' include 'plugin-script-python' include 'plugin-script-r' include 'plugin-script-ruby' +include 'plugin-script-zx' From c9c8725db0373cf4e936ccc6508c431030ea0a04 Mon Sep 17 00:00:00 2001 From: e-esakman Date: Mon, 12 Oct 2026 22:16:07 +0530 Subject: [PATCH 2/3] docs(zx): add plugin documentation --- AGENTS.md | 8 +++++++- README.md | 1 + .../doc/io.kestra.plugin.scripts.zx.md | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 plugin-script-zx/src/main/resources/doc/io.kestra.plugin.scripts.zx.md diff --git a/AGENTS.md b/AGENTS.md index 5501f271..e913aa12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ ### Architecture -This is a **multi-module** plugin with 19 submodules: +This is a **multi-module** plugin with 20 submodules: - `plugin-script` - `plugin-script-bun` @@ -36,6 +36,7 @@ This is a **multi-module** plugin with 19 submodules: - `plugin-script-r` - `plugin-script-ruby` - `plugin-script-shell` +- `plugin-script-zx` ### Key Plugin Classes @@ -137,6 +138,11 @@ This is a **multi-module** plugin with 19 submodules: - `io.kestra.plugin.scripts.shell.Script` - `io.kestra.plugin.scripts.shell.ScriptTrigger` +**plugin-script-zx:** + +- `io.kestra.plugin.scripts.zx.Commands` +- `io.kestra.plugin.scripts.zx.Script` + ### Project Structure ``` diff --git a/README.md b/README.md index 1a274db6..a25d053a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ * [for R](https://kestra.io/plugins/plugin-script-r/) * [for Ruby](https://kestra.io/plugins/plugin-script-ruby/) * [for Shell](https://kestra.io/plugins/plugin-script-shell/). + * [for zx](https://kestra.io/plugins/plugin-script-zx/). ## License diff --git a/plugin-script-zx/src/main/resources/doc/io.kestra.plugin.scripts.zx.md b/plugin-script-zx/src/main/resources/doc/io.kestra.plugin.scripts.zx.md new file mode 100644 index 00000000..1f660a2f --- /dev/null +++ b/plugin-script-zx/src/main/resources/doc/io.kestra.plugin.scripts.zx.md @@ -0,0 +1,17 @@ +# How to use the zx plugin + +Run JavaScript shell automation with zx, combining JavaScript logic with shell commands through the `$` helper. + +## Common properties + +`containerImage` defaults to `ghcr.io/google/zx:8.5.0`, which includes Node.js, zx, and Bash. `taskRunner` defaults to Docker. + +The default image is AMD64. ARM64 workers need emulation or an ARM64-compatible custom image. + +For a custom image or Process runner with zx installed on PATH, set `executable: zx` on `Script`. Process execution requires Node.js, zx, and a supported shell on the worker. + +## Tasks + +`Script` runs inline JavaScript from the `script` property and supports top-level `await`. `Commands` runs existing files staged through `inputFiles` or [namespace files](https://kestra.io/docs/concepts/namespace-files); in the default image, use `node /zx/build/cli.js main.mjs`. + +Use `beforeCommands` to install additional packages, `env` for environment variables, and `outputFiles` to persist generated files. See the individual task pages for complete flow examples. From 8b48c6a4f79b2d5c041366dd405c73a40b232579 Mon Sep 17 00:00:00 2001 From: e-esakman Date: Tue, 13 Oct 2026 22:50:07 +0530 Subject: [PATCH 3/3] test(zx): cover Commands failure , output files & format schema descriptions --- .../io/kestra/plugin/scripts/zx/Commands.java | 16 +++++-- .../io/kestra/plugin/scripts/zx/Script.java | 20 ++++++-- .../plugin/scripts/zx/CommandsTest.java | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java index 1e981009..7f6befdf 100644 --- a/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java +++ b/plugin-script-zx/src/main/java/io/kestra/plugin/scripts/zx/Commands.java @@ -26,7 +26,10 @@ @NoArgsConstructor @Schema( title = "Execute zx files and commands", - description = "Runs JavaScript shell automation with zx. The default image includes zx at /zx/build/cli.js; custom images and Process runners require Node.js, zx and a supported shell." + description = """ + Runs JavaScript shell automation with zx. The default image includes zx at /zx/build/cli.js; custom + images and Process runners require Node.js, zx and a supported shell. + """ ) @Plugin( examples = { @@ -54,7 +57,10 @@ public class Commands extends AbstractExecScript implements RunnableTask logQueue; + @Test + void failedShellCommandFailsTask() { + Commands task = Commands.builder() + .id("zx-commands-failure-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .inputFiles(Map.of("main.mjs", "await $`exit 7`;")) + .commands(Property.ofValue(List.of("node /zx/build/cli.js main.mjs"))) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, task, ImmutableMap.of()); + RunnableTaskException failure = assertThrows(RunnableTaskException.class, () -> task.run(runContext)); + // zx reports the child exit code in its error, but the CLI itself exits with 1. + assertThat(((ScriptOutput) failure.getOutput()).getExitCode(), is(1)); + } + + @Test + void preservesPreparationOutputFilesAndEnvironment() throws Exception { + Commands task = Commands.builder() + .id("zx-commands-files-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .inputFiles(Map.of("main.mjs", """ + import { readFile, writeFile } from 'node:fs/promises'; + const input = await readFile('result/input.txt', 'utf8'); + await writeFile('result/output.txt', `${input} ${process.env.ZX_TEST_SUFFIX}`); + """)) + .env(Property.ofValue(Map.of("ZX_TEST_SUFFIX", "world"))) + .beforeCommands(Property.ofValue(List.of("mkdir -p result", "printf hello > result/input.txt"))) + .outputFiles(Property.ofValue(List.of("result/output.txt"))) + .commands(Property.ofValue(List.of("node /zx/build/cli.js main.mjs"))) + .build(); + + RunContext runContext = TestsUtils.mockRunContext(runContextFactory, task, ImmutableMap.of()); + ScriptOutput output = task.run(runContext); + + assertThat(output.getExitCode(), is(0)); + try (var stream = storageInterface.get(TenantService.MAIN_TENANT, null, output.getOutputFiles().get("result/output.txt"))) { + assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8), is("hello world")); + } + } + @Test void executesStagedScript() throws Exception { List logs = new CopyOnWriteArrayList<>();