From c326db62829959d0079b7d005b018f501c4a3503 Mon Sep 17 00:00:00 2001 From: Gauri Khedekar Date: Sun, 4 Oct 2026 00:01:36 +0530 Subject: [PATCH 1/4] feat(dotnet): add F# script execution support --- README.md | 2 +- plugin-script-dotnet/build.gradle | 4 +- .../kestra/plugin/scripts/dotnet/FSharp.java | 160 +++++++++++++++ .../plugin/scripts/dotnet/package-info.java | 2 +- .../doc/io.kestra.plugin.scripts.dotnet.md | 48 +++-- .../plugin/scripts/dotnet/FSharpTest.java | 183 ++++++++++++++++++ 6 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java diff --git a/README.md b/README.md index 1a274db6..4859d9d7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ * The documentation for specific sub-plugins: * [for Bun](https://kestra.io/plugins/plugin-script-bun/) * [for Deno](https://kestra.io/plugins/plugin-script-deno/) - * [for .NET (C#)](https://kestra.io/plugins/plugin-script-dotnet/) + * [for .NET (C# and F#)](https://kestra.io/plugins/plugin-script-dotnet/) * [for Go](https://kestra.io/plugins/plugin-script-go/) * [for Groovy](https://kestra.io/plugins/plugin-script-groovy/) * [for JBang](https://kestra.io/plugins/plugin-script-jbang/) diff --git a/plugin-script-dotnet/build.gradle b/plugin-script-dotnet/build.gradle index 447762f0..abc562da 100644 --- a/plugin-script-dotnet/build.gradle +++ b/plugin-script-dotnet/build.gradle @@ -1,10 +1,10 @@ -project.description = 'Execute .NET C# scripts as part of Kestra data workflows.' +project.description = 'Execute .NET C# and F# scripts as part of Kestra data workflows.' jar { manifest { attributes( "X-Kestra-Name": project.name, - "X-Kestra-Title": ".NET (C#)", + "X-Kestra-Title": ".NET (C# and F#)", "X-Kestra-Group": project.group + ".scripts.dotnet", "X-Kestra-Description": project.description, "X-Kestra-Version": project.version diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java new file mode 100644 index 00000000..d74ba4e8 --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java @@ -0,0 +1,160 @@ +package io.kestra.plugin.scripts.dotnet; + +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 an inline F# script", + description = """ + Executes a multi-line F# script (`.fsx`) inside a .NET SDK container using `dotnet fsi`. + The script is written to a temporary `.fsx` file and executed with F# Interactive. + NuGet package references via `#r "nuget:PackageName,Version"` are supported. + """ +) +@Plugin( + examples = { + @Example( + title = "Run a simple F# Hello World script.", + full = true, + code = """ + id: fsharp_hello_world + namespace: company.team + + tasks: + - id: hello_fsharp + type: io.kestra.plugin.scripts.dotnet.FSharp + script: | + printfn "Hello from Kestra!" + """ + ), + @Example( + title = "Run an inline F# script with a NuGet dependency.", + full = true, + code = """ + id: fsharp_nuget + namespace: company.team + + tasks: + - id: hello_fsharp + type: io.kestra.plugin.scripts.dotnet.FSharp + script: | + #r "nuget: Newtonsoft.Json, 13.0.3" + + open Newtonsoft.Json + + let data = {| message = "Hello from Kestra" |} + printfn "%s" (JsonConvert.SerializeObject(data)) + """ + ) + } +) +public class FSharp extends AbstractExecScript implements RunnableTask { + private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; + + @Schema( + title = "Container image for the .NET runtime", + description = "Docker image used to run the F# script. Defaults to `mcr.microsoft.com/dotnet/sdk:10.0`." + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); + + @Schema( + title = "Inline F# script to execute", + description = """ + F# script body in `.fsx` format. + The script is written to a temporary `.fsx` file and executed with `dotnet fsi`. + NuGet packages can be referenced with `#r "nuget:PackageName,Version"`. + """ + ) + @NotNull + @PluginProperty(language = MonacoLanguages.FSHARP, group = "main") + protected Property script; + + @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(DEFAULT_IMAGE) + ); + } + + 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(".fsx")); + + inputFiles.put( + relativeScriptPath.toString(), + commands.render(runContext, this.script) + ); + + commands = commands.withInputFiles(inputFiles); + + var os = runContext.render(this.targetOS) + .as(TargetOS.class) + .orElse(null); + + return commands + .withInterpreter(this.interpreter) + .withBeforeCommands(this.beforeCommands) + .withBeforeCommandsWithOptions(true) + .withCommands( + Property.ofValue( + List.of( + "dotnet fsi " + + commands.getTaskRunner().toAbsolutePath( + runContext, + commands, + "./" + relativeScriptPath, + os + ) + ) + ) + ) + .withTargetOS(os) + .run(); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/package-info.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/package-info.java index dfa4f164..f9a96445 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/package-info.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/package-info.java @@ -1,5 +1,5 @@ @PluginSubGroup( - description = "This subgroup of plugins contains tasks for running .NET C# scripts.", + description = "This subgroup of plugins contains tasks for running .NET C# and F# scripts.", categories = { PluginSubGroup.PluginCategory.DATA, PluginSubGroup.PluginCategory.CORE } ) package io.kestra.plugin.scripts.dotnet; diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md index dd09e44a..ef0c7701 100644 --- a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md +++ b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md @@ -1,6 +1,6 @@ -# How to use the .NET (C#) plugin +# How to use the .NET (C# and F#) plugin -Run C# scripts and dotnet CLI commands from Kestra workflows using [dotnet-script](https://github.com/dotnet-script/dotnet-script) inside a .NET SDK container. +Run C# and F# scripts and dotnet CLI commands from Kestra workflows using .NET runtimes inside a .NET SDK container. ## Authentication @@ -10,21 +10,27 @@ This plugin has no authentication properties. Use environment variables for secr ### Script -Runs an inline C# script defined in the `script` property. The script is written to a temporary `.csx` file and executed with `dotnet-script`. +Runs an inline C# or F# script defined in the `script` property. -`dotnet-script` is installed automatically via `dotnet tool install -g dotnet-script` before each run. To avoid the installation overhead, use a custom `containerImage` that already includes `dotnet-script`. +For C#, the script is written to a temporary `.csx` file and executed with `dotnet-script`. + +For F#, the script is written to a temporary `.fsx` file and executed with `dotnet fsi`. + +`dotnet-script` is installed automatically via `dotnet tool install -g dotnet-script` before each C# run. F# scripts use `dotnet fsi`, which is available in the default .NET SDK image. To avoid the C# tool installation overhead, use a custom `containerImage` that already includes `dotnet-script`. NuGet package references work out of the box — place `#r "nuget:PackageName,Version"` directives at the top of your script. The first run with a new package reference triggers a NuGet restore which may take 30–60 seconds. Required properties: -- `script` — inline C# script body in `.csx` format + +* `script` — inline C# script body in `.csx` format, or inline F# script body in `.fsx` format depending on the task used Optional: -- `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` -- `beforeCommands` — shell commands to run before the script (e.g., set environment variables) -- `inputFiles` — additional files to stage alongside the script -- `outputFiles` — glob patterns for files to capture into Kestra internal storage -- `taskRunner` — override the execution environment (default: Docker) + +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +* `beforeCommands` — shell commands to run before the script (e.g., set environment variables) +* `inputFiles` — additional files to stage alongside the script +* `outputFiles` — glob patterns for files to capture into Kestra internal storage +* `taskRunner` — override the execution environment (default: Docker) ### Commands @@ -39,11 +45,13 @@ beforeCommands: ``` Required properties: -- `commands` — list of shell commands to execute in order + +* `commands` — list of shell commands to execute in order Optional: -- `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` -- `beforeCommands`, `inputFiles`, `namespaceFiles`, `outputFiles`, `taskRunner` — same as `Script` + +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +* `beforeCommands`, `inputFiles`, `namespaceFiles`, `outputFiles`, `taskRunner` — same as `Script` ## Triggers @@ -52,16 +60,18 @@ Optional: Polls on an interval by running an inline .NET script the same way the `Script` task does, and starts an execution when `exitCondition` matches. The script runs in a fresh container on every poll, so keep it quick. Required properties: -- `script`: inline .NET script body -- `exitCondition`: either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` + +* `script`: inline .NET script body +* `exitCondition`: either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` Optional: -- `interval`: time between polls, defaults to `PT60S` -- `edge`: defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll -- `containerImage`: defaults to `mcr.microsoft.com/dotnet/sdk:10.0` + +* `interval`: time between polls, defaults to `PT60S` +* `edge`: defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll +* `containerImage`: defaults to `mcr.microsoft.com/dotnet/sdk:10.0` The trigger outputs are available as `{{ trigger.timestamp }}`, `{{ trigger.condition }}`, `{{ trigger.exitCode }}` and `{{ trigger.vars }}`. A failed run has no vars, so only an `exit N` condition can match a failure. ### CommandsTrigger -Same behavior as `ScriptTrigger`, but runs a list of shell commands the way the `Commands` task does. Required properties are `commands` and `exitCondition`; `interval`, `edge` and `containerImage` work as above. +Same behavior as `ScriptTrigger`, but runs a list of shell commands the way the `Commands` task does. Required properties are `commands` and `exitCondition`; `interval`, `edge` and `containerImage` work as above. \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java new file mode 100644 index 00000000..b8298fbf --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java @@ -0,0 +1,183 @@ +package io.kestra.plugin.scripts.dotnet; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Assertions; +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.models.tasks.runners.TargetOS; +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; +import static org.hamcrest.Matchers.startsWith; + +@KestraTest +class FSharpTest { + @Inject + RunContextFactory runContextFactory; + + @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())); + + FSharp fsharpScript = FSharp.builder() + .id("fsharp-script-" + UUID.randomUUID()) + .type(FSharp.class.getName()) + .script(Property.ofValue("printfn \"Hello from Kestra!\"")) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpScript, + ImmutableMap.of() + ); + + ScriptOutput run = fsharpScript.run(runContext); + + assertThat(run.getExitCode(), is(0)); + + TestsUtils.awaitLog( + logs, + log -> log.getMessage() != null + && log.getMessage().contains("Hello from Kestra!") + ); + + receive.blockLast(); + + assertThat( + List.copyOf(logs).stream() + .filter(logEntry -> + logEntry.getMessage() != null + && logEntry.getMessage().contains("Hello from Kestra!") + ) + .count(), + is(1L) + ); + } + + @Test + void scriptWithNugetDependency() throws Exception { + List logs = new CopyOnWriteArrayList<>(); + Flux receive = TestsUtils.receive(logQueue, l -> logs.add(l.getLeft())); + + FSharp fsharpScript = FSharp.builder() + .id("fsharp-nuget-" + UUID.randomUUID()) + .type(FSharp.class.getName()) + .script(Property.ofValue(""" + #r "nuget: Newtonsoft.Json, 13.0.3" + + open Newtonsoft.Json + + let data = {| message = "Hello from NuGet" |} + printfn "%s" (JsonConvert.SerializeObject(data)) + """)) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpScript, + ImmutableMap.of() + ); + + ScriptOutput run = fsharpScript.run(runContext); + + assertThat(run.getExitCode(), is(0)); + + TestsUtils.awaitLog( + logs, + log -> log.getMessage() != null + && log.getMessage().contains("Hello from NuGet") + ); + + receive.blockLast(); + + assertThat( + List.copyOf(logs).stream() + .filter(logEntry -> + logEntry.getMessage() != null + && logEntry.getMessage().contains("Hello from NuGet") + ) + .count(), + is(1L) + ); + } + + @Test + void scriptWithOutputFile() throws Exception { + FSharp fsharpScript = FSharp.builder() + .id("fsharp-output-" + UUID.randomUUID()) + .type(FSharp.class.getName()) + .script(Property.ofValue(""" + System.IO.File.WriteAllText( + "result.txt", + "hello from F#" + ) + """)) + .outputFiles(Property.ofValue(List.of("result.txt"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpScript, + ImmutableMap.of() + ); + + ScriptOutput run = fsharpScript.run(runContext); + + assertThat(run.getExitCode(), is(0)); + assertThat(run.getOutputFiles().containsKey("result.txt"), is(true)); + assertThat( + run.getOutputFiles().get("result.txt").toString(), + startsWith("kestra://") + ); + } + + @Test + void scriptFailure() { + FSharp fsharpScript = FSharp.builder() + .id("fsharp-fail-" + UUID.randomUUID()) + .type(FSharp.class.getName()) + .script(Property.ofValue(""" + failwith "deliberate failure" + """)) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpScript, + ImmutableMap.of() + ); + + Assertions.assertThrows( + RunnableTaskException.class, + () -> fsharpScript.run(runContext) + ); + } +} \ No newline at end of file From 58423f170eae509249cdbac8b01385d4fd0b6942 Mon Sep 17 00:00:00 2001 From: Gauri Khedekar Date: Mon, 5 Oct 2026 20:03:30 +0530 Subject: [PATCH 2/4] feat(dotnet): add F# script support --- AGENTS.md | 34 +- .../scripts/{dotnet => csharp}/Commands.java | 33 +- .../scripts/csharp/CommandsTrigger.java | 133 +++++++ .../scripts/{dotnet => csharp}/Script.java | 80 ++-- .../plugin/scripts/csharp/ScriptTrigger.java | 129 ++++++ .../plugin/scripts/csharp/package-info.java | 11 + .../dotnet/AbstractDotnetCommands.java | 69 ++++ .../scripts/dotnet/AbstractDotnetScript.java | 63 +++ .../scripts/dotnet/AbstractDotnetTrigger.java | 368 ++++++++++++++++++ .../scripts/dotnet/CommandsTrigger.java | 303 -------------- .../plugin/scripts/dotnet/ScriptTrigger.java | 306 --------------- .../plugin/scripts/fsharp/Commands.java | 102 +++++ .../scripts/fsharp/CommandsTrigger.java | 131 +++++++ .../FSharp.java => fsharp/Script.java} | 64 ++- .../plugin/scripts/fsharp/ScriptTrigger.java | 141 +++++++ .../plugin/scripts/fsharp/package-info.java | 11 + .../doc/io.kestra.plugin.scripts.csharp.md | 98 +++++ .../doc/io.kestra.plugin.scripts.dotnet.md | 77 ---- .../doc/io.kestra.plugin.scripts.fsharp.md | 141 +++++++ .../resources/doc/io.kestra.plugin.scripts.md | 23 ++ .../icons/io.kestra.plugin.scripts.csharp.svg | 1 + .../icons/io.kestra.plugin.scripts.fsharp.svg | 7 + .../src/main/resources/metadata/csharp.yaml | 7 + .../src/main/resources/metadata/fsharp.yaml | 7 + .../src/main/resources/metadata/index.yaml | 8 +- .../{dotnet => csharp}/CommandsTest.java | 6 +- .../CommandsTriggerTest.java | 7 +- .../{dotnet => csharp}/RunnerTest.java | 2 +- .../{dotnet => csharp}/ScriptTest.java | 7 +- .../ScriptTriggerEvaluateTest.java | 4 +- .../dotnet/BackwardCompatibilityTest.java | 30 ++ .../plugin/scripts/dotnet/EdgeStateTest.java | 4 + .../dotnet/ExitConditionRegexWiringTest.java | 1 + .../scripts/dotnet/ScriptTriggerTest.java | 2 + .../plugin/scripts/fsharp/CommandsTest.java | 126 ++++++ .../scripts/fsharp/CommandsTriggerTest.java | 319 +++++++++++++++ .../ScriptTest.java} | 20 +- .../fsharp/ScriptTriggerEvaluateTest.java | 183 +++++++++ .../scripts/fsharp/ScriptTriggerTest.java | 224 +++++++++++ .../resources/sanity-checks/all_dotnet.yaml | 8 +- 40 files changed, 2479 insertions(+), 811 deletions(-) rename plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/{dotnet => csharp}/Commands.java (77%) create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/CommandsTrigger.java rename plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/{dotnet => csharp}/Script.java (68%) create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/ScriptTrigger.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/package-info.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetCommands.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetScript.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java delete mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/CommandsTrigger.java delete mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/ScriptTrigger.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Commands.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/CommandsTrigger.java rename plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/{dotnet/FSharp.java => fsharp/Script.java} (74%) create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/ScriptTrigger.java create mode 100644 plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/package-info.java create mode 100644 plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.csharp.md delete mode 100644 plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md create mode 100644 plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md create mode 100644 plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.md create mode 100644 plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.csharp.svg create mode 100644 plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.fsharp.svg create mode 100644 plugin-script-dotnet/src/main/resources/metadata/csharp.yaml create mode 100644 plugin-script-dotnet/src/main/resources/metadata/fsharp.yaml rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet => csharp}/CommandsTest.java (92%) rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet => csharp}/CommandsTriggerTest.java (94%) rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet => csharp}/RunnerTest.java (94%) rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet => csharp}/ScriptTest.java (93%) rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet => csharp}/ScriptTriggerEvaluateTest.java (95%) create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTest.java create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java rename plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/{dotnet/FSharpTest.java => fsharp/ScriptTest.java} (92%) create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java create mode 100644 plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerTest.java diff --git a/AGENTS.md b/AGENTS.md index 4601ab03..d50e1e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,50 +45,69 @@ This is a **multi-module** plugin with 19 submodules: - `io.kestra.plugin.scripts.bun.CommandsTrigger` - `io.kestra.plugin.scripts.bun.Script` - `io.kestra.plugin.scripts.bun.ScriptTrigger` + **plugin-script-deno:** - `io.kestra.plugin.scripts.deno.Commands` - `io.kestra.plugin.scripts.deno.Script` + **plugin-script-dotnet:** -- `io.kestra.plugin.scripts.dotnet.Commands` -- `io.kestra.plugin.scripts.dotnet.CommandsTrigger` -- `io.kestra.plugin.scripts.dotnet.Script` -- `io.kestra.plugin.scripts.dotnet.ScriptTrigger` +**C#:** + +- `io.kestra.plugin.scripts.csharp.Commands` +- `io.kestra.plugin.scripts.csharp.CommandsTrigger` +- `io.kestra.plugin.scripts.csharp.Script` +- `io.kestra.plugin.scripts.csharp.ScriptTrigger` + +**F#:** + +- `io.kestra.plugin.scripts.fsharp.Commands` +- `io.kestra.plugin.scripts.fsharp.CommandsTrigger` +- `io.kestra.plugin.scripts.fsharp.Script` +- `io.kestra.plugin.scripts.fsharp.ScriptTrigger` + **plugin-script-go:** - `io.kestra.plugin.scripts.go.Commands` - `io.kestra.plugin.scripts.go.Script` + **plugin-script-groovy:** - `io.kestra.plugin.scripts.groovy.Commands` - `io.kestra.plugin.scripts.groovy.Eval` - `io.kestra.plugin.scripts.groovy.FileTransform` - `io.kestra.plugin.scripts.groovy.Script` + **plugin-script-jbang:** - `io.kestra.plugin.scripts.jbang.Commands` - `io.kestra.plugin.scripts.jbang.CommandsTrigger` - `io.kestra.plugin.scripts.jbang.Script` - `io.kestra.plugin.scripts.jbang.ScriptTrigger` + **plugin-script-julia:** - `io.kestra.plugin.scripts.julia.Commands` - `io.kestra.plugin.scripts.julia.Script` + **plugin-script-jython:** - `io.kestra.plugin.scripts.jython.Eval` - `io.kestra.plugin.scripts.jython.FileTransform` + **plugin-script-lua:** - `io.kestra.plugin.scripts.lua.Commands` - `io.kestra.plugin.scripts.lua.CommandsTrigger` - `io.kestra.plugin.scripts.lua.Script` - `io.kestra.plugin.scripts.lua.ScriptTrigger` + **plugin-script-nashorn:** - `io.kestra.plugin.scripts.nashorn.Eval` - `io.kestra.plugin.scripts.nashorn.FileTransform` + **plugin-script-node:** - `io.kestra.core.tasks.scripts.Node` @@ -96,37 +115,44 @@ This is a **multi-module** plugin with 19 submodules: - `io.kestra.plugin.scripts.node.CommandsTrigger` - `io.kestra.plugin.scripts.node.Script` - `io.kestra.plugin.scripts.node.ScriptTrigger` + **plugin-script-perl:** - `io.kestra.plugin.scripts.perl.Commands` - `io.kestra.plugin.scripts.perl.CommandsTrigger` - `io.kestra.plugin.scripts.perl.Script` - `io.kestra.plugin.scripts.perl.ScriptTrigger` + **plugin-script-php:** - `io.kestra.plugin.scripts.php.Commands` - `io.kestra.plugin.scripts.php.Script` + **plugin-script-powershell:** - `io.kestra.plugin.scripts.powershell.Commands` - `io.kestra.plugin.scripts.powershell.CommandsTrigger` - `io.kestra.plugin.scripts.powershell.Script` - `io.kestra.plugin.scripts.powershell.ScriptTrigger` + **plugin-script-python:** - `io.kestra.core.tasks.scripts.Python` - `io.kestra.plugin.scripts.python.Commands` - `io.kestra.plugin.scripts.python.Script` + **plugin-script-r:** - `io.kestra.plugin.scripts.r.Commands` - `io.kestra.plugin.scripts.r.CommandsTrigger` - `io.kestra.plugin.scripts.r.Script` - `io.kestra.plugin.scripts.r.ScriptTrigger` + **plugin-script-ruby:** - `io.kestra.plugin.scripts.ruby.Commands` - `io.kestra.plugin.scripts.ruby.Script` + **plugin-script-shell:** - `io.kestra.core.tasks.scripts.Bash` diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Commands.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java similarity index 77% rename from plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Commands.java rename to plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java index 0b2008d9..ffb4650a 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Commands.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java @@ -1,9 +1,8 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; -import java.util.Collections; import java.util.List; -import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetCommands; import io.kestra.core.models.annotations.Example; import io.kestra.core.models.annotations.Plugin; import io.kestra.core.models.annotations.PluginProperty; @@ -11,8 +10,6 @@ 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; @@ -21,8 +18,8 @@ import lombok.experimental.SuperBuilder; @SuperBuilder -@ToString -@EqualsAndHashCode +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) @Getter @NoArgsConstructor @Schema( @@ -45,7 +42,7 @@ tasks: - id: run_dotnet - type: io.kestra.plugin.scripts.dotnet.Commands + type: io.kestra.plugin.scripts.csharp.Commands inputFiles: analyze.csx: | Console.WriteLine("Analyzing data..."); @@ -66,15 +63,15 @@ tasks: - id: build - type: io.kestra.plugin.scripts.dotnet.Commands + type: io.kestra.plugin.scripts.csharp.Commands commands: - dotnet --version """ ) - } + }, + aliases = "io.kestra.plugin.scripts.dotnet.Commands" ) -public class Commands extends AbstractExecScript implements RunnableTask { - private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; +public class Commands extends AbstractDotnetCommands { @Schema( title = "Container image for the .NET runtime", @@ -96,18 +93,6 @@ public class Commands extends AbstractExecScript implements RunnableTask> 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(DEFAULT_IMAGE)); - } - if (original.getEntryPoint() == null) { - builder.entryPoint(Collections.emptyList()); - } - return builder.build(); - } - @Override public ScriptOutput run(RunContext runContext) throws Exception { var os = runContext.render(this.targetOS).as(TargetOS.class).orElse(null); diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/CommandsTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/CommandsTrigger.java new file mode 100644 index 00000000..a1d95ed3 --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/CommandsTrigger.java @@ -0,0 +1,133 @@ +package io.kestra.plugin.scripts.csharp; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +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.runners.RunContext; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetTrigger; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger a flow when C# commands match a condition", + description = """ + Polls by running .NET commands in a script container and emits when + the configured exit condition matches. + + Edge mode and condition evaluation are handled by the shared + .NET trigger implementation. + """ +) +@Plugin( + examples = { + @Example( + title = "Trigger when commands fail with an implicit error (exit 1).", + full = true, + code = """ + id: commands_trigger + namespace: company.team + + triggers: + - id: commands_failure + type: io.kestra.plugin.scripts.csharp.CommandsTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: mcr.microsoft.com/dotnet/sdk:10.0 + commands: + - dotnet run --project missing + + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" + """ + ) + }, + aliases = "io.kestra.plugin.scripts.dotnet.CommandsTrigger" +) +public class CommandsTrigger + extends AbstractDotnetTrigger { + + @Schema( + title = ".NET commands to execute", + description = "Commands executed on each poll (same semantics as the .NET Commands task)." + ) + @NotNull + @PluginProperty(group = "main") + protected Property> commands; + + @Override + protected ScriptOutput runTask( + RunContext runContext + ) throws Exception { + + Commands task = Commands.builder() + .id(this.getId()) + .type(Commands.class.getName()) + .containerImage(this.containerImage) + .targetOS(this.targetOS) + .commands(this.commands) + .build(); + + return task.run( + TriggerRunContext.forEmbeddedTask( + runContext, + task + ) + ); + } + + @Override + protected Output createOutput( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + return new Output( + timestamp, + condition, + exitCode, + vars + ); + } + + @EqualsAndHashCode(callSuper = true) + public static class Output + extends AbstractDotnetTrigger.Output { + + public Output( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + super( + timestamp, + condition, + exitCode, + vars + ); + } + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Script.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java similarity index 68% rename from plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Script.java rename to plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java index 27a06f75..0f23d648 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/Script.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java @@ -1,24 +1,20 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Map; 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.models.property.Property; 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.dotnet.AbstractDotnetScript; import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; -import io.kestra.plugin.scripts.exec.scripts.runners.CommandsWrapper; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -26,8 +22,8 @@ import lombok.experimental.SuperBuilder; @SuperBuilder -@ToString -@EqualsAndHashCode +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) @Getter @NoArgsConstructor @Schema( @@ -52,7 +48,7 @@ tasks: - id: hello_csharp - type: io.kestra.plugin.scripts.dotnet.Script + type: io.kestra.plugin.scripts.csharp.Script script: | Console.WriteLine("Hello from Kestra!"); """ @@ -66,7 +62,7 @@ tasks: - id: hello_csharp - type: io.kestra.plugin.scripts.dotnet.Script + type: io.kestra.plugin.scripts.csharp.Script script: | #r "nuget:Newtonsoft.Json,13.0.3" using Newtonsoft.Json; @@ -87,29 +83,21 @@ tasks: - id: write_file - type: io.kestra.plugin.scripts.dotnet.Script + type: io.kestra.plugin.scripts.csharp.Script script: | File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from dotnet-script!"); """ ) - } + }, + aliases = "io.kestra.plugin.scripts.dotnet.Script" ) -public class Script extends AbstractExecScript implements RunnableTask { - private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; +public class Script extends AbstractDotnetScript { private static final List DOTNET_SCRIPT_INSTALL = List.of( "dotnet tool install -g dotnet-script --ignore-failed-sources || true", "export PATH=\"$PATH:$HOME/.dotnet/tools\"" ); - @Schema( - title = "Container image for the .NET runtime", - description = "Docker image used to run the script. Defaults to `mcr.microsoft.com/dotnet/sdk:10.0`. Use a custom image pre-built with `dotnet-script` to avoid tool installation on each run." - ) - @Builder.Default - @PluginProperty(group = "execution") - protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); - @Schema( title = "Inline C# script to execute", description = """ @@ -121,28 +109,30 @@ public class Script extends AbstractExecScript implements RunnableTask script; - @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(DEFAULT_IMAGE)); - } - 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()); - Path relativeScriptPath = runContext.workingDir().path().relativize(runContext.workingDir().createTempFile(".csx")); + var inputFiles = FilesService.inputFiles( + runContext, + commands.getTaskRunner().additionalVars(runContext, commands), + this.getInputFiles() + ); + + Path relativeScriptPath = runContext.workingDir().path() + .relativize(runContext.workingDir().createTempFile(".csx")); + inputFiles.put( relativeScriptPath.toString(), commands.render(runContext, this.script) ); + commands = commands.withInputFiles(inputFiles); - var os = runContext.render(this.targetOS).as(TargetOS.class).orElse(null); + var os = runContext.render(this.targetOS) + .as(TargetOS.class) + .orElse(null); + return commands .withInterpreter(this.interpreter) .withBeforeCommands(buildBeforeCommands(runContext)) @@ -150,7 +140,13 @@ public ScriptOutput run(RunContext runContext) throws Exception { .withCommands( Property.ofValue( List.of( - "dotnet-script " + commands.getTaskRunner().toAbsolutePath(runContext, commands, "./" + relativeScriptPath, os) + "dotnet-script " + + commands.getTaskRunner().toAbsolutePath( + runContext, + commands, + "./" + relativeScriptPath, + os + ) ) ) ) @@ -158,10 +154,16 @@ public ScriptOutput run(RunContext runContext) throws Exception { .run(); } - private Property> buildBeforeCommands(RunContext runContext) throws IllegalVariableEvaluationException { - var userBefore = runContext.render(this.getBeforeCommands()).asList(String.class); + private Property> buildBeforeCommands( + RunContext runContext + ) throws IllegalVariableEvaluationException { + + var userBefore = + runContext.render(this.getBeforeCommands()).asList(String.class); + var combined = new ArrayList<>(DOTNET_SCRIPT_INSTALL); combined.addAll(userBefore); + return Property.ofValue(combined); } -} +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/ScriptTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/ScriptTrigger.java new file mode 100644 index 00000000..6d27e90a --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/ScriptTrigger.java @@ -0,0 +1,129 @@ +package io.kestra.plugin.scripts.csharp; + +import java.time.Instant; +import java.util.Map; + +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.runners.RunContext; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetTrigger; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; +import lombok.NoArgsConstructor; + +@SuperBuilder +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger on C# script condition", + description = """ + Polls by running an inline C# script in a .NET SDK container + and emits when exitCondition matches. + + The common polling, edge-state, condition matching, and failure + handling behavior is provided by AbstractDotnetTrigger. + """ +) +@Plugin( + aliases = "io.kestra.plugin.scripts.dotnet.ScriptTrigger", + examples = { + @Example( + title = "Trigger when the C# script fails with an implicit error (exit 1).", + full = true, + code = """ + id: script_trigger + namespace: company.team + + triggers: + - id: script_failure + type: io.kestra.plugin.scripts.csharp.ScriptTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: mcr.microsoft.com/dotnet/sdk:10.0 + script: | + System.Environment.Exit(1); + + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" + """ + ) + } +) +public class ScriptTrigger + extends AbstractDotnetTrigger { + + @Schema( + title = "Inline C# script", + description = """ + Multi-line C# script executed on each poll. + The script is executed using the C# Script task. + """ + ) + @NotNull + @PluginProperty(group = "main") + protected Property script; + + @Override + protected ScriptOutput runTask( + RunContext runContext + ) throws Exception { + + Script task = Script.builder() + .id(this.getId()) + .type(Script.class.getName()) + .containerImage(this.containerImage) + .targetOS(this.targetOS) + .script(this.script) + .build(); + + return task.run( + TriggerRunContext.forEmbeddedTask( + runContext, + task + ) + ); + } + + @Override + protected Output createOutput( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + return new Output( + timestamp, + condition, + exitCode, + vars + ); + } + + @EqualsAndHashCode(callSuper = true) + public static class Output + extends AbstractDotnetTrigger.Output { + + public Output( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + super(timestamp, condition, exitCode, vars); + } + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/package-info.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/package-info.java new file mode 100644 index 00000000..34c6282b --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/package-info.java @@ -0,0 +1,11 @@ +@PluginSubGroup( + title = "C#", + description = "This subgroup of plugins contains tasks for running C# scripts.", + categories = { + PluginSubGroup.PluginCategory.DATA, + PluginSubGroup.PluginCategory.CORE + } +) +package io.kestra.plugin.scripts.csharp; + +import io.kestra.core.models.annotations.PluginSubGroup; \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetCommands.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetCommands.java new file mode 100644 index 00000000..1f8e2a6b --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetCommands.java @@ -0,0 +1,69 @@ +package io.kestra.plugin.scripts.dotnet; + +import java.util.Collections; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.runners.RunContext; +import io.kestra.plugin.scripts.exec.AbstractExecScript; +import io.kestra.core.models.annotations.PluginProperty; +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 lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +public abstract class AbstractDotnetCommands + extends AbstractExecScript + implements RunnableTask { + + protected static final String DEFAULT_IMAGE = + "mcr.microsoft.com/dotnet/sdk:10.0"; + + @Schema( + title = "Container image for the .NET runtime", + description = """ + Docker image used to run the commands. + Defaults to 'mcr.microsoft.com/dotnet/sdk:10.0'. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = + Property.ofValue(DEFAULT_IMAGE); + + @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(DEFAULT_IMAGE) + ); + } + + if (original.getEntryPoint() == null) { + builder.entryPoint(Collections.emptyList()); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetScript.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetScript.java new file mode 100644 index 00000000..dd5583fd --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetScript.java @@ -0,0 +1,63 @@ +package io.kestra.plugin.scripts.dotnet; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +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.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 lombok.Getter; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +public abstract class AbstractDotnetScript + extends AbstractExecScript + implements RunnableTask { + + protected static final String DEFAULT_IMAGE = + "mcr.microsoft.com/dotnet/sdk:10.0"; + + @Schema( + title = "Container image for the .NET runtime", + description = """ + Docker image used to run the script. + Defaults to 'mcr.microsoft.com/dotnet/sdk:10.0'. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = + Property.ofValue(DEFAULT_IMAGE); + + @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(DEFAULT_IMAGE) + ); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java new file mode 100644 index 00000000..27b28a6f --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java @@ -0,0 +1,368 @@ +package io.kestra.plugin.scripts.dotnet; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.kestra.core.models.tasks.runners.TargetOS; +import io.kestra.core.models.annotations.PluginProperty; +import io.kestra.core.models.conditions.ConditionContext; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTaskException; +import io.kestra.core.models.tasks.runners.TaskException; +import io.kestra.core.models.triggers.AbstractTrigger; +import io.kestra.core.models.triggers.PollingTriggerInterface; +import io.kestra.core.models.triggers.TriggerContext; +import io.kestra.core.models.triggers.TriggerOutput; +import io.kestra.core.models.triggers.TriggerService; +import io.kestra.core.runners.RunContext; +import io.kestra.core.storages.kv.KVStore; +import io.kestra.core.storages.kv.KVValueAndMetadata; +import io.kestra.plugin.scripts.exec.ExitConditionRegex; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@Getter +@NoArgsConstructor +public abstract class AbstractDotnetTrigger + extends AbstractTrigger + implements PollingTriggerInterface, TriggerOutput { + + private static final String DEFAULT_IMAGE = + "mcr.microsoft.com/dotnet/sdk:10.0"; + + private static final Pattern EXIT_CONDITION_PATTERN = + Pattern.compile( + "^\\s*exit\\s+(\\d+)\\s*$", + Pattern.CASE_INSENSITIVE + ); + + @Schema( + title = "Container image for task execution", + description = """ + Container image used to execute the underlying .NET task. + Defaults to 'mcr.microsoft.com/dotnet/sdk:10.0'. + """ + ) + @Builder.Default + @PluginProperty(group = "execution") + protected Property containerImage = + Property.ofValue(DEFAULT_IMAGE); + @Schema( + title = "Target operating system", + description = "Operating system used by the underlying .NET task." + ) + @PluginProperty(group = "execution") + protected Property targetOS; + + @Schema( + title = "Condition to match", + description = """ + Condition evaluated after each polling execution. + + Supported forms: + - 'exit N' matches the task exit code. + - Any other string is treated as a regex, with substring fallback, + against values emitted by the task. + + A failed task has no output vars, so only an 'exit N' + condition can match a failure. + """ + ) + protected Property exitCondition; + + @Schema( + title = "Check interval", + description = "Interval between polling evaluations." + ) + @Builder.Default + @PluginProperty(group = "execution") + private final Duration interval = Duration.ofSeconds(60); + + @Schema( + title = "Edge trigger mode", + description = """ + When true (default), emit only on a transition from + not matching to matching. + The previous result is stored in the namespace KV store + using the flow and trigger identity. + + When false, emit on every matching poll. + """ + ) + @Builder.Default + @PluginProperty(group = "advanced") + protected Property edge = Property.ofValue(true); + + @Override + public Optional evaluate( + ConditionContext conditionContext, + TriggerContext context + ) throws Exception { + + RunContext runContext = conditionContext.getRunContext(); + + boolean renderedEdge = runContext + .render(this.edge) + .as(Boolean.class) + .orElse(true); + + O out; + + try { + out = runOnce(runContext); + } catch (Exception e) { + runContext.logger().warn( + "Trigger evaluation failed, returning empty result to avoid blocking the scheduler", + e + ); + return Optional.empty(); + } + + boolean matched = matchesCondition(out); + + boolean emit = shouldEmit( + runContext, + context, + renderedEdge, + matched + ); + + if (!emit) { + return Optional.empty(); + } + + return Optional.of( + TriggerService.generateExecution( + this, + conditionContext, + context, + out + ) + ); + } + + private O runOnce(RunContext runContext) throws Exception { + + String renderedCondition = runContext + .render(this.exitCondition) + .as(String.class) + .filter(condition -> !condition.isBlank()) + .orElseThrow( + () -> new IllegalArgumentException( + "exitCondition must render to a non-empty value" + ) + ); + + try { + ScriptOutput taskOutput = runTask(runContext); + + Integer exitCode = safeExitCode(taskOutput); + Map vars = safeVars(taskOutput); + + return createOutput( + Instant.now(), + renderedCondition, + exitCode, + vars + ); + } catch (RunnableTaskException e) { + Integer exitCode = extractFailureExitCode(e); + + return createOutput( + Instant.now(), + renderedCondition, + exitCode, + null + ); + } + } + + /** + * Concrete triggers only need to decide which .NET task to execute. + */ + protected abstract ScriptOutput runTask( + RunContext runContext + ) throws Exception; + + /** + * Creates the concrete trigger output type. + */ + protected abstract O createOutput( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ); + + protected boolean shouldEmit( + RunContext runContext, + TriggerContext context, + boolean edge, + boolean matched + ) throws Exception { + + if (!edge) { + return matched; + } + + // A polling trigger is rebuilt from the flow definition on every poll, + // so the previous result cannot live in a field. + // Store it in the namespace KV store instead. + KVStore kvStore = + runContext.namespaceKv(context.getNamespace()); + + String key = edgeStateKey(context); + + boolean previouslyMatched = kvStore + .getValue(key) + .map(value -> + Boolean.parseBoolean( + String.valueOf(value.value()) + ) + ) + .orElse(false); + + // Avoid rewriting the same state on every poll. + if (matched != previouslyMatched) { + kvStore.put( + key, + new KVValueAndMetadata(null, matched) + ); + } + + return matched && !previouslyMatched; + } + + protected static String edgeStateKey( + TriggerContext context + ) { + // Length-prefixed flow ID prevents collisions such as: + // ("a-b", "c") vs ("a", "b-c") + return "trigger-edge-" + + context.getFlowId().length() + + "-" + + context.getFlowId() + + "-" + + context.getTriggerId(); + } + + protected boolean matchesCondition(O out) { + + String condition = out.getCondition() == null + ? "" + : out.getCondition().trim(); + + Matcher exitMatcher = + EXIT_CONDITION_PATTERN.matcher(condition); + + if (exitMatcher.matches()) { + int expected = + Integer.parseInt(exitMatcher.group(1)); + + return out.getExitCode() != null + && out.getExitCode() == expected; + } + + String haystack = buildHaystack(out); + + if (haystack.isEmpty() || condition.isEmpty()) { + return false; + } + + return ExitConditionRegex.find( + condition, + haystack + ); + } + + protected String buildHaystack(O out) { + + if (out.getVars() == null || out.getVars().isEmpty()) { + return ""; + } + + return out.getVars().toString(); + } + + protected Integer safeExitCode( + ScriptOutput taskOutput + ) { + try { + return taskOutput.getExitCode(); + } catch (Exception ignored) { + return null; + } + } + + protected Map safeVars( + ScriptOutput taskOutput + ) { + try { + return taskOutput.getVars(); + } catch (Exception ignored) { + return null; + } + } + + private Integer extractFailureExitCode( + RunnableTaskException e + ) { + + Throwable current = e.getCause(); + + while (current != null) { + if (current instanceof TaskException taskException) { + return taskException.getExitCode(); + } + + current = current.getCause(); + } + + return null; + } + + @Data + @AllArgsConstructor + public static class Output + implements io.kestra.core.models.tasks.Output { + + @Schema( + title = "Timestamp of the event that fired the trigger" + ) + private Instant timestamp; + + @Schema( + title = "Rendered condition", + description = + "Rendered value of the exitCondition property for this poll." + ) + private String condition; + + @Schema( + title = "Task exit code", + description = + "Exit code returned by the underlying .NET task." + ) + private Integer exitCode; + + @Schema( + title = "Task vars", + description = + "Vars produced by the underlying .NET task." + ) + private Map vars; + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/CommandsTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/CommandsTrigger.java deleted file mode 100644 index 0f7d4798..00000000 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/CommandsTrigger.java +++ /dev/null @@ -1,303 +0,0 @@ -package io.kestra.plugin.scripts.dotnet; - -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -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.conditions.ConditionContext; -import io.kestra.core.models.executions.Execution; -import io.kestra.core.models.property.Property; -import io.kestra.core.models.tasks.RunnableTaskException; -import io.kestra.core.models.tasks.runners.TaskException; -import io.kestra.core.models.triggers.AbstractTrigger; -import io.kestra.core.models.triggers.PollingTriggerInterface; -import io.kestra.core.models.triggers.TriggerContext; -import io.kestra.core.models.triggers.TriggerOutput; -import io.kestra.core.models.triggers.TriggerService; -import io.kestra.core.runners.RunContext; -import io.kestra.core.storages.kv.KVStore; -import io.kestra.core.storages.kv.KVValueAndMetadata; -import io.kestra.plugin.scripts.exec.ExitConditionRegex; -import io.kestra.plugin.scripts.exec.TriggerRunContext; -import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@ToString -@EqualsAndHashCode -@Getter -@NoArgsConstructor -@Schema( - title = "Trigger a flow when .NET commands match a condition", - description = """ - Polls by running .NET commands in a script container (default image mcr.microsoft.com/dotnet/sdk:10.0) and emits when exitCondition matches. \ - Edge mode (the default) emits only on a transition from not matching to matching, remembering the previous result in the namespace KV store. \ - Polls every 60s by default. Accepts 'exit N' or a regex (fallback substring) matched against emitted vars; a failed run has no vars, so only 'exit N' can match a failure. - """ -) -@Plugin( - examples = { - @Example( - title = "Trigger when commands fail with an implicit error (exit 1).", - full = true, - code = """ - id: commands_trigger - namespace: company.team - - triggers: - - id: commands_failure - type: io.kestra.plugin.scripts.dotnet.CommandsTrigger - interval: PT60S - exitCondition: "exit 1" - edge: true - containerImage: mcr.microsoft.com/dotnet/sdk:10.0 - commands: - - dotnet run --project missing - - tasks: - - id: log - type: io.kestra.plugin.core.log.Log - message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" - """ - ) - } -) -public class CommandsTrigger extends AbstractTrigger - implements PollingTriggerInterface, TriggerOutput { - - private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; - private static final Pattern EXIT_CONDITION_PATTERN = Pattern.compile("^\\s*exit\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE); - - @Schema( - title = "Docker image used to execute the commands", - description = """ - Container image used by the underlying Commands task to run .NET commands. - Defaults to 'mcr.microsoft.com/dotnet/sdk:10.0'. - """ - ) - @Builder.Default - @PluginProperty(group = "execution") - protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); - - @Schema( - title = ".NET commands to execute", - description = "Commands executed on each poll (same semantics as the .NET Commands task)." - ) - @NotNull - @PluginProperty(group = "main") - protected Property> commands; - - @Schema( - title = "Condition to match", - description = """ - Condition evaluated after each commands execution. The trigger emits an event only when this condition matches. - - Supported forms: - - 'exit N' (example: 'exit 1'): matches when the process exit code equals N. - - Any other string: treated as a regex (or substring if regex is invalid) matched against the - task's 'vars' (when commands emit ::{"outputs":...}::). On a failed run no vars are available - to match against, so only an 'exit N' condition can match a failure. - """ - ) - @NotNull - @PluginProperty(group = "main") - protected Property exitCondition; - - @Schema( - title = "Check interval", - description = "Interval between polling evaluations." - ) - @Builder.Default - @PluginProperty(group = "execution") - private final Duration interval = Duration.ofSeconds(60); - - @Schema( - title = "Edge trigger mode", - description = """ - If true (default), the trigger emits only on a transition from 'not matching' to 'matching' \ - (anti-spam). The previous result is kept in the namespace KV store, keyed by flow and trigger id. \ - If false, the trigger emits on every poll where the condition matches. - """ - ) - @Builder.Default - @PluginProperty(group = "advanced") - protected Property edge = Property.ofValue(true); - - @Override - public Optional evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception { - RunContext runContext = conditionContext.getRunContext(); - boolean renderedEdge = runContext.render(this.edge).as(Boolean.class).orElse(true); - - Output out; - try { - out = runOnce(runContext); - } catch (Exception e) { - runContext.logger().warn("Trigger evaluation failed, returning empty result to avoid blocking the scheduler", e); - return Optional.empty(); - } - - boolean matched = matchesCondition(out); - - boolean emit = shouldEmit(runContext, context, renderedEdge, matched); - - if (!emit) { - return Optional.empty(); - } - - return Optional.of(TriggerService.generateExecution(this, conditionContext, context, out)); - } - - boolean shouldEmit(RunContext runContext, TriggerContext context, boolean edge, boolean matched) throws Exception { - if (!edge) { - return matched; - } - - // A polling trigger is rebuilt from the flow definition (and serialized to a worker) on - // every poll, so the previous result cannot live in a field. It is kept in the namespace - // KV store instead and advanced on every poll. - KVStore kvStore = runContext.namespaceKv(context.getNamespace()); - String key = edgeStateKey(context); - - boolean previouslyMatched = kvStore.getValue(key) - .map(value -> Boolean.parseBoolean(String.valueOf(value.value()))) - .orElse(false); - - // Skip the write on a poll that repeats the same result, so a condition that stays true - // for hours does not rewrite the same value every interval. - if (matched != previouslyMatched) { - kvStore.put(key, new KVValueAndMetadata(null, matched)); - } - - return matched && !previouslyMatched; - } - - // Length prefixed so that the pairs ("a-b", "c") and ("a", "b-c") can never share a key. - // Flow and trigger ids only use characters that are valid in a KV key. - static String edgeStateKey(TriggerContext context) { - return "trigger-edge-" + context.getFlowId().length() + "-" + context.getFlowId() + "-" + context.getTriggerId(); - } - - private Output runOnce(RunContext runContext) throws Exception { - Commands task = Commands.builder() - .id(this.getId()) - .type(Commands.class.getName()) - .containerImage(this.containerImage) - .commands(this.commands) - .build(); - - String renderedCondition = runContext.render(this.exitCondition).as(String.class) - .filter(condition -> !condition.isBlank()) - .orElseThrow(() -> new IllegalArgumentException("exitCondition must render to a non-empty value")); - - try { - ScriptOutput taskOutput = task.run(TriggerRunContext.forEmbeddedTask(runContext, task)); - Integer exitCode = safeExitCode(taskOutput); - Map vars = safeVars(taskOutput); - - return new Output(Instant.now(), renderedCondition, exitCode, vars); - } catch (RunnableTaskException e) { - ExtractedFailure failure = extractFailure(e); - return new Output(Instant.now(), renderedCondition, failure.exitCode, null); - } - } - - boolean matchesCondition(Output out) { - String cond = out.getCondition() == null ? "" : out.getCondition().trim(); - - Matcher exitMatcher = EXIT_CONDITION_PATTERN.matcher(cond); - if (exitMatcher.matches()) { - int expected = Integer.parseInt(exitMatcher.group(1)); - return out.getExitCode() != null && out.getExitCode() == expected; - } - - String haystack = buildHaystack(out); - if (haystack.isEmpty() || cond.isEmpty()) { - return false; - } - - return ExitConditionRegex.find(cond, haystack); - } - - private String buildHaystack(Output out) { - if (out.getVars() == null || out.getVars().isEmpty()) { - return ""; - } - return out.getVars().toString(); - } - - private Integer safeExitCode(ScriptOutput taskOutput) { - try { - return taskOutput.getExitCode(); - } catch (Exception ignored) { - return null; - } - } - - private Map safeVars(ScriptOutput taskOutput) { - try { - return taskOutput.getVars(); - } catch (Exception ignored) { - return null; - } - } - - private record ExtractedFailure(Integer exitCode) { - } - - private ExtractedFailure extractFailure(RunnableTaskException e) { - Integer exitCode = null; - - Throwable cur = e.getCause(); - while (cur != null) { - if (cur instanceof TaskException te) { - exitCode = te.getExitCode(); - break; - } - cur = cur.getCause(); - } - - return new ExtractedFailure(exitCode); - } - - @Data - @AllArgsConstructor - public static class Output implements io.kestra.core.models.tasks.Output { - @Schema(title = "Timestamp of the event that fired the trigger") - private Instant timestamp; - - @Schema( - title = "Rendered condition", - description = "Rendered value of the exitCondition property for this poll." - ) - private String condition; - - @Schema( - title = "Commands exit code", - description = "Exit code returned by the .NET process (may be null if not available)." - ) - private Integer exitCode; - - @Schema( - title = "Commands vars", - description = "Vars produced by the task (e.g. via ::{\"outputs\":{...}}:: convention)." - ) - private Map vars; - } -} diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/ScriptTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/ScriptTrigger.java deleted file mode 100644 index f43e855e..00000000 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/ScriptTrigger.java +++ /dev/null @@ -1,306 +0,0 @@ -package io.kestra.plugin.scripts.dotnet; - -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -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.conditions.ConditionContext; -import io.kestra.core.models.executions.Execution; -import io.kestra.core.models.property.Property; -import io.kestra.core.models.tasks.RunnableTaskException; -import io.kestra.core.models.tasks.runners.TaskException; -import io.kestra.core.models.triggers.AbstractTrigger; -import io.kestra.core.models.triggers.PollingTriggerInterface; -import io.kestra.core.models.triggers.TriggerContext; -import io.kestra.core.models.triggers.TriggerOutput; -import io.kestra.core.models.triggers.TriggerService; -import io.kestra.core.runners.RunContext; -import io.kestra.core.storages.kv.KVStore; -import io.kestra.core.storages.kv.KVValueAndMetadata; -import io.kestra.plugin.scripts.exec.ExitConditionRegex; -import io.kestra.plugin.scripts.exec.TriggerRunContext; -import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@ToString -@EqualsAndHashCode -@Getter -@NoArgsConstructor -@Schema( - title = "Trigger on .NET script condition", - description = """ - Polls by running an inline .NET script in a container (default image mcr.microsoft.com/dotnet/sdk:10.0) and emits when exitCondition matches. \ - Edge mode (the default) emits only on a transition from not matching to matching, remembering the previous result in the namespace KV store. \ - Polls every 60s by default. Accepts 'exit N' or a regex (fallback substring) matched against emitted vars; a failed run has no vars, so only 'exit N' can match a failure. - """ -) -@Plugin( - examples = { - @Example( - title = "Trigger when the script fails with an implicit error (exit 1).", - full = true, - code = """ - id: script_trigger - namespace: company.team - - triggers: - - id: script_failure - type: io.kestra.plugin.scripts.dotnet.ScriptTrigger - interval: PT60S - exitCondition: "exit 1" - edge: true - containerImage: mcr.microsoft.com/dotnet/sdk:10.0 - script: | - System.Environment.Exit(1); - - tasks: - - id: log - type: io.kestra.plugin.core.log.Log - message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" - """ - ) - } -) -public class ScriptTrigger extends AbstractTrigger - implements PollingTriggerInterface, TriggerOutput { - - private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; - private static final Pattern EXIT_CONDITION_PATTERN = Pattern.compile("^\\s*exit\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE); - - @Schema( - title = "Container image for script execution", - description = """ - Image used by the Script task to run the inline .NET script; defaults to 'mcr.microsoft.com/dotnet/sdk:10.0'. - Provide an image that includes the .NET SDK. - """ - ) - @Builder.Default - @PluginProperty(group = "execution") - protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); - - @Schema( - title = "Inline .NET script", - description = """ - Multi-line script executed on each poll, with the same semantics as the .NET Script task. - """ - ) - @NotNull - @PluginProperty(group = "main") - protected Property script; - - @Schema( - title = "Condition to match", - description = """ - Rendered condition evaluated after each execution; the trigger emits only when it matches. - 'exit N' compares the exit code, otherwise the string is used as a regex (or substring fallback) \ - against emitted vars (from ::{"outputs":...}::). On a failed run no vars are available to match \ - against, so only an 'exit N' condition can match a failure. - """ - ) - @NotNull - @PluginProperty(group = "main") - protected Property exitCondition; - - @Schema( - title = "Check interval", - description = """ - Interval between polls; default PT60S. The scheduler uses this to schedule the next evaluation. - """ - ) - @Builder.Default - @PluginProperty(group = "execution") - private final Duration interval = Duration.ofSeconds(60); - - @Schema( - title = "Edge trigger mode", - description = """ - When true (default), emit only on a transition from not matching to matching, so a condition that \ - stays true does not fire on every poll. The previous result is kept in the namespace KV store, keyed \ - by flow and trigger id. When false, emit on every poll that matches. - """ - ) - @Builder.Default - @PluginProperty(group = "advanced") - protected Property edge = Property.ofValue(true); - - @Override - public Optional evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception { - RunContext runContext = conditionContext.getRunContext(); - boolean renderedEdge = runContext.render(this.edge).as(Boolean.class).orElse(true); - - Output out; - try { - out = runOnce(runContext); - } catch (Exception e) { - runContext.logger().warn("Trigger evaluation failed, returning empty result to avoid blocking the scheduler", e); - return Optional.empty(); - } - - boolean matched = matchesCondition(out); - - boolean emit = shouldEmit(runContext, context, renderedEdge, matched); - - if (!emit) { - return Optional.empty(); - } - - return Optional.of(TriggerService.generateExecution(this, conditionContext, context, out)); - } - - boolean shouldEmit(RunContext runContext, TriggerContext context, boolean edge, boolean matched) throws Exception { - if (!edge) { - return matched; - } - - // A polling trigger is rebuilt from the flow definition (and serialized to a worker) on - // every poll, so the previous result cannot live in a field. It is kept in the namespace - // KV store instead and advanced on every poll. - KVStore kvStore = runContext.namespaceKv(context.getNamespace()); - String key = edgeStateKey(context); - - boolean previouslyMatched = kvStore.getValue(key) - .map(value -> Boolean.parseBoolean(String.valueOf(value.value()))) - .orElse(false); - - // Skip the write on a poll that repeats the same result, so a condition that stays true - // for hours does not rewrite the same value every interval. - if (matched != previouslyMatched) { - kvStore.put(key, new KVValueAndMetadata(null, matched)); - } - - return matched && !previouslyMatched; - } - - // Length prefixed so that the pairs ("a-b", "c") and ("a", "b-c") can never share a key. - // Flow and trigger ids only use characters that are valid in a KV key. - static String edgeStateKey(TriggerContext context) { - return "trigger-edge-" + context.getFlowId().length() + "-" + context.getFlowId() + "-" + context.getTriggerId(); - } - - private Output runOnce(RunContext runContext) throws Exception { - Script task = Script.builder() - .id(this.getId()) - .type(Script.class.getName()) - .containerImage(this.containerImage) - .script(this.script) - .build(); - - String renderedExitCondition = runContext.render(this.exitCondition).as(String.class) - .filter(condition -> !condition.isBlank()) - .orElseThrow(() -> new IllegalArgumentException("exitCondition must render to a non-empty value")); - - try { - ScriptOutput taskOutput = task.run(TriggerRunContext.forEmbeddedTask(runContext, task)); - Integer exitCode = safeExitCode(taskOutput); - Map vars = safeVars(taskOutput); - - return new Output(Instant.now(), renderedExitCondition, exitCode, vars); - } catch (RunnableTaskException e) { - ExtractedFailure failure = extractFailure(e); - return new Output(Instant.now(), renderedExitCondition, failure.exitCode, null); - } - } - - boolean matchesCondition(Output out) { - String cond = out.getCondition() == null ? "" : out.getCondition().trim(); - - Matcher exitMatcher = EXIT_CONDITION_PATTERN.matcher(cond); - if (exitMatcher.matches()) { - int expected = Integer.parseInt(exitMatcher.group(1)); - return out.getExitCode() != null && out.getExitCode() == expected; - } - - String haystack = buildHaystack(out); - if (haystack.isEmpty() || cond.isEmpty()) { - return false; - } - - return ExitConditionRegex.find(cond, haystack); - } - - private String buildHaystack(Output out) { - if (out.getVars() == null || out.getVars().isEmpty()) { - return ""; - } - return out.getVars().toString(); - } - - private Integer safeExitCode(ScriptOutput taskOutput) { - try { - return taskOutput.getExitCode(); - } catch (Exception ignored) { - return null; - } - } - - private Map safeVars(ScriptOutput taskOutput) { - try { - return taskOutput.getVars(); - } catch (Exception ignored) { - return null; - } - } - - private record ExtractedFailure(Integer exitCode) { - } - - private ExtractedFailure extractFailure(RunnableTaskException e) { - Integer exitCode = null; - - Throwable cur = e.getCause(); - while (cur != null) { - if (cur instanceof TaskException te) { - exitCode = te.getExitCode(); - break; - } - cur = cur.getCause(); - } - - return new ExtractedFailure(exitCode); - } - - @Data - @AllArgsConstructor - public static class Output implements io.kestra.core.models.tasks.Output { - @Schema(title = "Timestamp of the event that fired the trigger") - private Instant timestamp; - - @Schema( - title = "Rendered condition", - description = "Rendered value of the exitCondition property for this poll." - ) - private String condition; - - @Schema( - title = "Script exit code", - description = "Exit code returned by the .NET process (may be null if not available)." - ) - private Integer exitCode; - - @Schema( - title = "Script vars", - description = """ - Vars produced by the task (e.g. via ::{"outputs":{...}}:: convention). This is the main structured - way to evaluate non-exit conditions on successful runs. - """ - ) - private Map vars; - } -} diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Commands.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Commands.java new file mode 100644 index 00000000..ddc3fe3f --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Commands.java @@ -0,0 +1,102 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.util.Collections; +import java.util.List; + +import io.kestra.plugin.scripts.dotnet.AbstractDotnetCommands; +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 = "Run commands inside a .NET SDK container", + description = """ + Executes arbitrary shell commands sequentially inside a .NET SDK container + (`mcr.microsoft.com/dotnet/sdk:10.0` by default). + + Use this task when you need to run `dotnet` CLI commands directly or execute + an existing F# `.fsx` script file with `dotnet fsi`. + """ +) +@Plugin( + examples = { + @Example( + full = true, + title = "Run an F# script file using dotnet fsi.", + code = """ + id: fsharp_commands + namespace: company.team + + tasks: + - id: run_fsharp + type: io.kestra.plugin.scripts.fsharp.Commands + inputFiles: + analyze.fsx: | + printfn "Analyzing data..." + printfn "Hello from F# commands!" + commands: + - dotnet fsi analyze.fsx + """ + ), + @Example( + full = true, + title = "Run a .NET CLI command.", + code = """ + id: dotnet_version + namespace: company.team + + tasks: + - id: version + type: io.kestra.plugin.scripts.fsharp.Commands + commands: + - dotnet --version + """ + ) + } +) +public class Commands extends AbstractDotnetCommands { + + @Schema( + title = "Shell commands to execute", + description = """ + List of shell commands executed in order inside the .NET SDK container. + Use `dotnet fsi` to execute existing F# `.fsx` files. + """ + ) + @NotNull + @PluginProperty(group = "main") + protected Property> commands; + + @Override + public ScriptOutput run(RunContext runContext) throws Exception { + var os = runContext.render(this.targetOS) + .as(TargetOS.class) + .orElse(null); + + return this.commands(runContext) + .withInterpreter(this.interpreter) + .withBeforeCommands(this.beforeCommands) + .withBeforeCommandsWithOptions(true) + .withCommands(this.commands) + .withTargetOS(os) + .run(); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/CommandsTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/CommandsTrigger.java new file mode 100644 index 00000000..d36e78f2 --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/CommandsTrigger.java @@ -0,0 +1,131 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +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.runners.RunContext; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetTrigger; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger a flow when F# commands match a condition", + description = """ + Polls by running F# commands in a .NET SDK container and emits when + the configured exit condition matches. + + Edge mode and condition evaluation are handled by the shared + .NET trigger implementation. + """ +) +@Plugin( + examples = { + @Example( + title = "Trigger when F# commands fail with an implicit error (exit 1).", + full = true, + code = """ + id: fsharp_commands_trigger + namespace: company.team + + triggers: + - id: fsharp_commands_failure + type: io.kestra.plugin.scripts.fsharp.CommandsTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: mcr.microsoft.com/dotnet/sdk:10.0 + commands: + - dotnet fsi --exec "System.Environment.Exit(1)" + + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" + """ + ) + } +) +public class CommandsTrigger + extends AbstractDotnetTrigger { + + @Schema( + title = "F# commands to execute", + description = "Commands executed on each poll (same semantics as the F# Commands task)." + ) + @NotNull + @PluginProperty(group = "main") + protected Property> commands; + + @Override + protected ScriptOutput runTask( + RunContext runContext + ) throws Exception { + + Commands task = Commands.builder() + .id(this.getId()) + .type(Commands.class.getName()) + .containerImage(this.containerImage) + .targetOS(this.targetOS) + .commands(this.commands) + .build(); + + return task.run( + TriggerRunContext.forEmbeddedTask( + runContext, + task + ) + ); + } + + @Override + protected Output createOutput( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + return new Output( + timestamp, + condition, + exitCode, + vars + ); + } + + @EqualsAndHashCode(callSuper = true) + public static class Output + extends AbstractDotnetTrigger.Output { + + public Output( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + super( + timestamp, + condition, + exitCode, + vars + ); + } + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java similarity index 74% rename from plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java rename to plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java index d74ba4e8..44646a1d 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/FSharp.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java @@ -1,8 +1,8 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.fsharp; import java.util.List; -import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetScript; import io.kestra.core.models.annotations.Example; import io.kestra.core.models.annotations.Plugin; import io.kestra.core.models.annotations.PluginProperty; @@ -12,8 +12,6 @@ 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; @@ -22,8 +20,8 @@ import lombok.experimental.SuperBuilder; @SuperBuilder -@ToString -@EqualsAndHashCode +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) @Getter @NoArgsConstructor @Schema( @@ -37,6 +35,7 @@ @Plugin( examples = { @Example( + // Hello World title = "Run a simple F# Hello World script.", full = true, code = """ @@ -45,12 +44,13 @@ tasks: - id: hello_fsharp - type: io.kestra.plugin.scripts.dotnet.FSharp + type: io.kestra.plugin.scripts.fsharp.Script script: | printfn "Hello from Kestra!" """ ), @Example( + // NuGet dependency title = "Run an inline F# script with a NuGet dependency.", full = true, code = """ @@ -59,7 +59,7 @@ tasks: - id: hello_fsharp - type: io.kestra.plugin.scripts.dotnet.FSharp + type: io.kestra.plugin.scripts.fsharp.Script script: | #r "nuget: Newtonsoft.Json, 13.0.3" @@ -68,19 +68,29 @@ let data = {| message = "Hello from Kestra" |} printfn "%s" (JsonConvert.SerializeObject(data)) """ + ), + @Example( + // output files + full = true, + title = """ + Generate output files from an F# script. Files written to `{{ outputDir }}` are persisted \ + in Kestra's internal storage and accessible to downstream tasks via \ + `{{ outputs.yourTaskId.outputFiles['yourFileName.txt'] }}`. + """, + code = """ + id: fsharp_generate_files + namespace: company.team + + tasks: + - id: write_file + type: io.kestra.plugin.scripts.fsharp.Script + script: | + System.IO.File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from F#!") + """ ) } ) -public class FSharp extends AbstractExecScript implements RunnableTask { - private static final String DEFAULT_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0"; - - @Schema( - title = "Container image for the .NET runtime", - description = "Docker image used to run the F# script. Defaults to `mcr.microsoft.com/dotnet/sdk:10.0`." - ) - @Builder.Default - @PluginProperty(group = "execution") - protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); +public class Script extends AbstractDotnetScript { @Schema( title = "Inline F# script to execute", @@ -94,24 +104,6 @@ public class FSharp extends AbstractExecScript implements RunnableTask script; - @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(DEFAULT_IMAGE) - ); - } - - return builder.build(); - } - @Override public ScriptOutput run(RunContext runContext) throws Exception { var commands = this.commands(runContext); diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/ScriptTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/ScriptTrigger.java new file mode 100644 index 00000000..771fbc01 --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/ScriptTrigger.java @@ -0,0 +1,141 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.time.Instant; +import java.util.Map; + +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.runners.RunContext; +import io.kestra.plugin.scripts.dotnet.AbstractDotnetTrigger; +import io.kestra.plugin.scripts.exec.TriggerRunContext; +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@Getter +@NoArgsConstructor +@Schema( + title = "Trigger on F# script condition", + description = """ + Polls by running an inline F# script in a .NET SDK container + and emits when the configured exit condition matches. + + F# scripts use the `.fsx` format and are executed with `dotnet fsi`. + + Edge mode and condition evaluation are handled by the shared + .NET trigger implementation. + """ +) +@Plugin( + examples = { + @Example( + title = "Trigger when the F# script fails with exit code 1.", + full = true, + code = """ + id: fsharp_script_trigger + namespace: company.team + + triggers: + - id: fsharp_script_failure + type: io.kestra.plugin.scripts.fsharp.ScriptTrigger + interval: PT60S + exitCondition: "exit 1" + edge: true + containerImage: mcr.microsoft.com/dotnet/sdk:10.0 + script: | + System.Environment.Exit(1) + + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" + """ + ) + } +) +public class ScriptTrigger + extends AbstractDotnetTrigger { + + @Schema( + title = "Inline F# script", + description = """ + Multi-line F# script executed on each poll. + + The script uses `.fsx` format and is executed with `dotnet fsi`. + """ + ) + @NotNull + @PluginProperty( + language = MonacoLanguages.FSHARP, + group = "main" + ) + protected Property script; + + @Override + protected ScriptOutput runTask( + RunContext runContext + ) throws Exception { + + Script task = Script.builder() + .id(this.getId()) + .type(Script.class.getName()) + .containerImage(this.containerImage) + .targetOS(this.targetOS) + .script(this.script) + .build(); + + return task.run( + TriggerRunContext.forEmbeddedTask( + runContext, + task + ) + ); + } + + @Override + protected Output createOutput( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + return new Output( + timestamp, + condition, + exitCode, + vars + ); + } + + @EqualsAndHashCode(callSuper = true) + public static class Output + extends AbstractDotnetTrigger.Output { + + public Output( + Instant timestamp, + String condition, + Integer exitCode, + Map vars + ) { + super( + timestamp, + condition, + exitCode, + vars + ); + } + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/package-info.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/package-info.java new file mode 100644 index 00000000..9e40a095 --- /dev/null +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/package-info.java @@ -0,0 +1,11 @@ +@PluginSubGroup( + title = "F#", + description = "This subgroup of plugins contains tasks for running F# scripts.", + categories = { + PluginSubGroup.PluginCategory.DATA, + PluginSubGroup.PluginCategory.CORE + } +) +package io.kestra.plugin.scripts.fsharp; + +import io.kestra.core.models.annotations.PluginSubGroup; \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.csharp.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.csharp.md new file mode 100644 index 00000000..26926f3b --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.csharp.md @@ -0,0 +1,98 @@ +# How to use the .NET (C#) plugin + +Run C# scripts and dotnet CLI commands from Kestra workflows using [dotnet-script](https://github.com/dotnet-script/dotnet-script) inside a .NET SDK container. + +## Authentication + +This plugin has no authentication properties. Use environment variables for secrets (e.g., connection strings, API keys) passed via `env` on the task, or use `{{ secret('SECRET_NAME') }}` in your script body. + +## Tasks + +### Script + +Runs an inline C# script defined in the `script` property. The script is written to a temporary `.csx` file and executed with `dotnet-script`. + +The task type is: + +`io.kestra.plugin.scripts.csharp.Script` + +`dotnet-script` is installed automatically via `dotnet tool install -g dotnet-script` before each run. To avoid the installation overhead, use a custom `containerImage` that already includes `dotnet-script`. + +NuGet package references work out of the box — place `#r "nuget:PackageName,Version"` directives at the top of your script. The first run with a new package reference triggers a NuGet restore which may take 30–60 seconds. + +Required properties: + +- `script` — inline C# script body in `.csx` format + +Optional: + +- `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +- `beforeCommands` — shell commands to run before the script (e.g., set environment variables) +- `inputFiles` — additional files to stage alongside the script +- `outputFiles` — glob patterns for files to capture into Kestra internal storage +- `taskRunner` — override the execution environment (default: Docker) + +### Commands + +Runs arbitrary shell commands sequentially inside a .NET SDK container. Use this task when script files live in namespace files or are cloned from a Git repository, or when you need raw `dotnet` CLI access (e.g., `dotnet build`, `dotnet test`). + +The task type is: + +`io.kestra.plugin.scripts.csharp.Commands` + +`dotnet-script` is **not** pre-installed in the default image. Add the following to `beforeCommands` when running `.csx` files: + +```yaml +beforeCommands: + - dotnet tool install -g dotnet-script --ignore-failed-sources || true + - export PATH="$PATH:$HOME/.dotnet/tools" +``` + +Required properties: + +- `commands` — list of shell commands to execute in order + +Optional: + +- `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +- `beforeCommands`, `inputFiles`, `namespaceFiles`, `outputFiles`, `taskRunner` — same as `Script` + +## Triggers + +### ScriptTrigger + +Polls on an interval by running an inline C# script the same way the `Script` task does, and starts an execution when `exitCondition` matches. The script runs in a fresh container on every poll, so keep it quick. + +The trigger type is: + +`io.kestra.plugin.scripts.csharp.ScriptTrigger` + +Required properties: + +- `script`: inline C# script body +- `exitCondition`: either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` + +Optional: + +- `interval`: time between polls, defaults to `PT60S` +- `edge`: defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll +- `containerImage`: defaults to `mcr.microsoft.com/dotnet/sdk:10.0` + +The trigger outputs are available as `{{ trigger.timestamp }}`, `{{ trigger.condition }}`, `{{ trigger.exitCode }}` and `{{ trigger.vars }}`. A failed run has no vars, so only an `exit N` condition can match a failure. + +### CommandsTrigger + +Same behavior as `ScriptTrigger`, but runs a list of shell commands the way the `Commands` task does. The trigger type is: + +`io.kestra.plugin.scripts.csharp.CommandsTrigger` + +Required properties: + +- `commands`: list of shell commands to execute +- `exitCondition`: condition evaluated after each poll + +Optional: + +- `interval`: time between polls, defaults to `PT60S` +- `edge`: defaults to `true` +- `containerImage`: defaults to `mcr.microsoft.com/dotnet/sdk:10.0` \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md deleted file mode 100644 index ef0c7701..00000000 --- a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.dotnet.md +++ /dev/null @@ -1,77 +0,0 @@ -# How to use the .NET (C# and F#) plugin - -Run C# and F# scripts and dotnet CLI commands from Kestra workflows using .NET runtimes inside a .NET SDK container. - -## Authentication - -This plugin has no authentication properties. Use environment variables for secrets (e.g., connection strings, API keys) passed via `env` on the task, or use `{{ secret('SECRET_NAME') }}` in your script body. - -## Tasks - -### Script - -Runs an inline C# or F# script defined in the `script` property. - -For C#, the script is written to a temporary `.csx` file and executed with `dotnet-script`. - -For F#, the script is written to a temporary `.fsx` file and executed with `dotnet fsi`. - -`dotnet-script` is installed automatically via `dotnet tool install -g dotnet-script` before each C# run. F# scripts use `dotnet fsi`, which is available in the default .NET SDK image. To avoid the C# tool installation overhead, use a custom `containerImage` that already includes `dotnet-script`. - -NuGet package references work out of the box — place `#r "nuget:PackageName,Version"` directives at the top of your script. The first run with a new package reference triggers a NuGet restore which may take 30–60 seconds. - -Required properties: - -* `script` — inline C# script body in `.csx` format, or inline F# script body in `.fsx` format depending on the task used - -Optional: - -* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` -* `beforeCommands` — shell commands to run before the script (e.g., set environment variables) -* `inputFiles` — additional files to stage alongside the script -* `outputFiles` — glob patterns for files to capture into Kestra internal storage -* `taskRunner` — override the execution environment (default: Docker) - -### Commands - -Runs arbitrary shell commands sequentially inside a .NET SDK container. Use this task when script files live in namespace files or are cloned from a Git repository, or when you need raw `dotnet` CLI access (e.g., `dotnet build`, `dotnet test`). - -`dotnet-script` is **not** pre-installed in the default image. Add the following to `beforeCommands` when running `.csx` files: - -```yaml -beforeCommands: - - dotnet tool install -g dotnet-script --ignore-failed-sources || true - - export PATH="$PATH:$HOME/.dotnet/tools" -``` - -Required properties: - -* `commands` — list of shell commands to execute in order - -Optional: - -* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` -* `beforeCommands`, `inputFiles`, `namespaceFiles`, `outputFiles`, `taskRunner` — same as `Script` - -## Triggers - -### ScriptTrigger - -Polls on an interval by running an inline .NET script the same way the `Script` task does, and starts an execution when `exitCondition` matches. The script runs in a fresh container on every poll, so keep it quick. - -Required properties: - -* `script`: inline .NET script body -* `exitCondition`: either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` - -Optional: - -* `interval`: time between polls, defaults to `PT60S` -* `edge`: defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll -* `containerImage`: defaults to `mcr.microsoft.com/dotnet/sdk:10.0` - -The trigger outputs are available as `{{ trigger.timestamp }}`, `{{ trigger.condition }}`, `{{ trigger.exitCode }}` and `{{ trigger.vars }}`. A failed run has no vars, so only an `exit N` condition can match a failure. - -### CommandsTrigger - -Same behavior as `ScriptTrigger`, but runs a list of shell commands the way the `Commands` task does. Required properties are `commands` and `exitCondition`; `interval`, `edge` and `containerImage` work as above. \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md new file mode 100644 index 00000000..4bad76c4 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md @@ -0,0 +1,141 @@ +# How to use the F# plugin + +Run F# scripts and .NET CLI commands from Kestra workflows using F# Interactive (`dotnet fsi`) inside a .NET SDK container. + +## Authentication + +This plugin has no authentication properties. Use environment variables for secrets +(e.g., connection strings, API keys) passed via `env` on the task, or use `{{ secret('SECRET_NAME') }}` in your script body. + +## Tasks + +### Script + +Runs an inline F# script defined in the `script` property. The script is written to a temporary `.fsx` file and executed with `dotnet fsi`. + +NuGet package references work out of the box — place `#r "nuget:PackageName,Version"` directives at the top of your script. The first run with a new package reference triggers a NuGet restore which may take 30–60 seconds. + +The task type is: + +`io.kestra.plugin.scripts.fsharp.Script` + +Required properties: + +* `script` — inline F# script body in `.fsx` format + +Optional: + +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +* `beforeCommands` — shell commands to run before the script +* `inputFiles` — additional files to stage alongside the script +* `outputFiles` — glob patterns for files to capture into Kestra internal storage +* `taskRunner` — override the execution environment (default: Docker) + +#### Hello World + +```yaml +id: fsharp_hello_world +namespace: company.team + +tasks: + - id: hello_fsharp + type: io.kestra.plugin.scripts.fsharp.Script + script: | + printfn "Hello from F# and Kestra!" +``` + +#### NuGet dependency + +```yaml +id: fsharp_nuget +namespace: company.team + +tasks: + - id: hello_fsharp + type: io.kestra.plugin.scripts.fsharp.Script + script: | + #r "nuget:Newtonsoft.Json,13.0.3" + + open Newtonsoft.Json + + let data = {| message = "Hello from Kestra" |} + printfn "%s" (JsonConvert.SerializeObject(data)) +``` + +#### Generate output files + +Files written to `{{ outputDir }}` are persisted in Kestra's internal storage and are available to downstream tasks through the task output. + +```yaml +id: fsharp_generate_files +namespace: company.team + +tasks: + - id: write_file + type: io.kestra.plugin.scripts.fsharp.Script + script: | + System.IO.File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from F#!") +``` + +### Commands + +Runs arbitrary .NET CLI or shell commands sequentially inside a .NET SDK container. + +The task type is: + +`io.kestra.plugin.scripts.fsharp.Commands` + +Required properties: + +* `commands` — list of commands to execute in order + +Optional: + +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` +* `beforeCommands` +* `inputFiles` +* `namespaceFiles` +* `outputFiles` +* `taskRunner` + +## Triggers + +### ScriptTrigger + +Polls on an interval by running an inline F# script in a fresh container using `dotnet fsi`, and starts an execution when `exitCondition` matches. + +The trigger type is: + +`io.kestra.plugin.scripts.fsharp.ScriptTrigger` + +Required properties: + +* `script` — inline F# script body in `.fsx` format +* `exitCondition` — either `exit N`, which matches when the script exits with code N, or a regex (with substring fallback) matched against the vars the script emits with `::{"outputs":{...}}::` + +Optional: + +* `interval` — time between polls, defaults to `PT60S` +* `edge` — defaults to `true`, so the trigger fires only when the condition changes from not matching to matching. The previous result is kept in the namespace KV store under a key starting with `trigger-edge-`. Set to `false` to fire on every matching poll +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` + +The trigger outputs are available as `{{ trigger.timestamp }}`, `{{ trigger.condition }}`, `{{ trigger.exitCode }}` and `{{ trigger.vars }}`. A failed run has no vars, so only an `exit N` condition can match a failure. + +### CommandsTrigger + +Runs a list of .NET CLI or shell commands the way the `Commands` task does and starts an execution when `exitCondition` matches. + +The trigger type is: + +`io.kestra.plugin.scripts.fsharp.CommandsTrigger` + +Required properties: + +* `commands` — list of commands to execute +* `exitCondition` — condition evaluated after each poll + +Optional: + +* `interval` — time between polls, defaults to `PT60S` +* `edge` — defaults to `true` +* `containerImage` — defaults to `mcr.microsoft.com/dotnet/sdk:10.0` \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.md new file mode 100644 index 00000000..d4308a64 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.md @@ -0,0 +1,23 @@ +# How to use the .NET (C# and F#) plugin + +The .NET plugin provides tasks and triggers for running C# and F# code inside Kestra workflows. + +C# tasks and triggers are available under the `io.kestra.plugin.scripts.csharp` package. + +F# tasks and triggers are available under the `io.kestra.plugin.scripts.fsharp` package. + +Both languages run inside the existing `plugin-script-dotnet` module. + +C# inline scripts use `dotnet-script` and are stored as `.csx` files before execution. + +F# inline scripts use F# Interactive through `dotnet fsi` and are stored as `.fsx` files before execution. + +Both languages can use the default .NET SDK container or a custom `containerImage`. + +Each package also provides `Commands` and `CommandsTrigger` tasks for executing .NET-related commands. + +Existing C# workflows using the old `io.kestra.plugin.scripts.dotnet.*` type names remain supported through compatibility aliases. + +For C# tasks, commands, and triggers, see the [C# plugin documentation](io.kestra.plugin.scripts.csharp.md). + +For F# tasks, commands, and triggers, see the [F# plugin documentation](io.kestra.plugin.scripts.fsharp.md). \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.csharp.svg b/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.csharp.svg new file mode 100644 index 00000000..5f58f928 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.csharp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.fsharp.svg b/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.fsharp.svg new file mode 100644 index 00000000..11b97893 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/icons/io.kestra.plugin.scripts.fsharp.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/metadata/csharp.yaml b/plugin-script-dotnet/src/main/resources/metadata/csharp.yaml new file mode 100644 index 00000000..8c0e91f6 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/metadata/csharp.yaml @@ -0,0 +1,7 @@ +group: io.kestra.plugin.scripts.csharp +name: "plugin-csharp" +title: "C#" +description: "Tasks and triggers that run C# scripts and .NET commands." +body: "Run C# scripts using dotnet-script or execute .NET CLI commands inside a .NET SDK container, with support for triggers, inputs, outputs, and workflow variables." +createdBy: "Kestra Core Team" +managedBy: "Kestra Core Team" \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/metadata/fsharp.yaml b/plugin-script-dotnet/src/main/resources/metadata/fsharp.yaml new file mode 100644 index 00000000..c8ba5211 --- /dev/null +++ b/plugin-script-dotnet/src/main/resources/metadata/fsharp.yaml @@ -0,0 +1,7 @@ +group: io.kestra.plugin.scripts.fsharp +name: "plugin-fsharp" +title: "F#" +description: "Tasks and triggers that run F# scripts and commands." +body: "Run F# scripts using F# Interactive through dotnet fsi or execute F#-related commands inside a .NET SDK container, with support for triggers, inputs, outputs, and workflow variables." +createdBy: "Kestra Core Team" +managedBy: "Kestra Core Team" \ No newline at end of file diff --git a/plugin-script-dotnet/src/main/resources/metadata/index.yaml b/plugin-script-dotnet/src/main/resources/metadata/index.yaml index a6c15391..5af56e12 100644 --- a/plugin-script-dotnet/src/main/resources/metadata/index.yaml +++ b/plugin-script-dotnet/src/main/resources/metadata/index.yaml @@ -1,7 +1,7 @@ group: io.kestra.plugin.scripts.dotnet name: "plugin-dotnet" -title: ".NET (C#)" -description: "Tasks that run .NET C# scripts using dotnet-script." -body: "Provide C# code inline as .csx scripts or run arbitrary dotnet CLI commands inside a .NET SDK container, capturing stdout/stderr and any output files for subsequent steps." +title: ".NET (C# and F#)" +description: "Tasks that run .NET C# and F# scripts and commands." +body: "Run C# scripts using dotnet-script, F# scripts using dotnet fsi, or arbitrary .NET CLI commands inside a .NET SDK container, with support for triggers, inputs, outputs, and workflow variables." createdBy: "Kestra Core Team" -managedBy: "Kestra Core Team" +managedBy: "Kestra Core Team" \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTest.java similarity index 92% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTest.java index cb44703d..af6db14a 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTest.java @@ -1,4 +1,4 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import java.util.List; import java.util.UUID; @@ -9,6 +9,7 @@ import com.google.common.collect.ImmutableMap; +import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.LogEntry; import io.kestra.core.models.property.Property; @@ -45,6 +46,7 @@ void task() throws Exception { .id("dotnet-commands-" + UUID.randomUUID()) .type(Commands.class.getName()) .commands(Property.ofValue(List.of("dotnet --version"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetCommands, ImmutableMap.of()); @@ -64,6 +66,7 @@ void shouldExitOnFirstError() { .type(Commands.class.getName()) .commands(Property.ofValue(List.of("dotnet nonexistent-command", "echo \"This should not run\""))) .failFast(Property.ofValue(true)) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetCommands, ImmutableMap.of()); @@ -77,6 +80,7 @@ void shouldNotExitOnFirstError() throws Exception { .type(Commands.class.getName()) .commands(Property.ofValue(List.of("dotnet nonexistent-command", "echo \"This should still run\""))) .failFast(Property.ofValue(false)) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetCommands, ImmutableMap.of()); diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java similarity index 94% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTriggerTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java index 4d0a4f97..cd8e4d20 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/CommandsTriggerTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java @@ -1,4 +1,4 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import java.util.List; import java.util.Map; @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; +import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.property.Property; @@ -34,6 +35,7 @@ void commandsTrigger_shouldTriggerOnImplicitFailureExit1() throws Exception { .edge(Property.ofValue(true)) .containerImage(Property.ofValue("mcr.microsoft.com/dotnet/sdk:10.0")) .commands(Property.ofValue(List.of("exit 1"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = TestsUtils.mockTrigger(runContextFactory, trigger); @@ -57,6 +59,7 @@ void commandsTrigger_shouldTriggerOnStdoutMatchUsingStructuredOutputs() throws E .edge(Property.ofValue(true)) .containerImage(Property.ofValue("mcr.microsoft.com/dotnet/sdk:10.0")) .commands(Property.ofValue(List.of("echo '::{\"outputs\":{\"listing\":\"toto\"}}::'"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = TestsUtils.mockTrigger(runContextFactory, trigger); @@ -81,6 +84,7 @@ void commandsTrigger_edgeModeShouldSuppressSecondEmission() throws Exception { .edge(Property.ofValue(true)) .containerImage(Property.ofValue("mcr.microsoft.com/dotnet/sdk:10.0")) .commands(Property.ofValue(List.of("exit 1"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = TestsUtils.mockTrigger(runContextFactory, trigger); @@ -103,6 +107,7 @@ void commandsTrigger_shouldMatchRegexAgainstStructuredOutputs() throws Exception .edge(Property.ofValue(true)) .containerImage(Property.ofValue("mcr.microsoft.com/dotnet/sdk:10.0")) .commands(Property.ofValue(List.of("echo '::{\"outputs\":{\"status\":\"status=ready\"}}::'"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = TestsUtils.mockTrigger(runContextFactory, trigger); diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/RunnerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/RunnerTest.java similarity index 94% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/RunnerTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/RunnerTest.java index 4ef9cbe4..7bcfd84e 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/RunnerTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/RunnerTest.java @@ -1,4 +1,4 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import org.junit.jupiter.api.Test; diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTest.java similarity index 93% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTest.java index 56ce2cbe..0059e74d 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTest.java @@ -1,4 +1,4 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import java.util.List; import java.util.UUID; @@ -9,6 +9,7 @@ import com.google.common.collect.ImmutableMap; +import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.LogEntry; import io.kestra.core.models.property.Property; @@ -46,6 +47,7 @@ void script() throws Exception { .id("dotnet-script-" + UUID.randomUUID()) .type(Script.class.getName()) .script(Property.ofValue("Console.WriteLine(\"Hello from Kestra!\");")) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetScript, ImmutableMap.of()); @@ -77,6 +79,7 @@ void scriptWithNugetDependency() throws Exception { var data = new { message = "Hello from NuGet" }; Console.WriteLine(JsonConvert.SerializeObject(data)); """)) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetScript, ImmutableMap.of()); @@ -101,6 +104,7 @@ void scriptWithOutputFile() throws Exception { .type(Script.class.getName()) .script(Property.ofValue("File.WriteAllText(\"result.txt\", \"hello from dotnet\");")) .outputFiles(Property.ofValue(List.of("result.txt"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetScript, ImmutableMap.of()); @@ -117,6 +121,7 @@ void scriptFailure() { .id("dotnet-fail-" + UUID.randomUUID()) .type(Script.class.getName()) .script(Property.ofValue("throw new Exception(\"deliberate failure\");")) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); RunContext runContext = TestsUtils.mockRunContext(runContextFactory, dotnetScript, ImmutableMap.of()); diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerEvaluateTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java similarity index 95% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerEvaluateTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java index 6e7e76f5..f2ea6069 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerEvaluateTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java @@ -1,10 +1,11 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.csharp; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; +import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.property.Property; @@ -37,6 +38,7 @@ private static ScriptTrigger trigger(String prefix, String script) { .edge(Property.ofValue(true)) .containerImage(Property.ofValue(IMAGE)) .script(Property.ofValue(script)) + .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); } diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java new file mode 100644 index 00000000..a78bfcba --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java @@ -0,0 +1,30 @@ +package io.kestra.plugin.scripts.dotnet; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.models.flows.Flow; +import io.kestra.core.serializers.YamlParser; +import io.kestra.plugin.scripts.csharp.Script; + +import static org.assertj.core.api.Assertions.assertThat; + +class BackwardCompatibilityTest { + + @Test + void oldDotnetScriptTypeDeserializesToCSharpScript() { + String source = """ + id: backward-compatibility + namespace: company.team + tasks: + - id: script + type: io.kestra.plugin.scripts.dotnet.Script + script: | + System.Console.WriteLine("Hello"); + """; + + Flow flow = YamlParser.parse(source, Flow.class); + + assertThat(flow.getTasks()).hasSize(1); + assertThat(flow.getTasks().get(0).getClass()).isEqualTo(Script.class); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/EdgeStateTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/EdgeStateTest.java index 7929b1ca..2e2b75f2 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/EdgeStateTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/EdgeStateTest.java @@ -5,6 +5,10 @@ import org.junit.jupiter.api.Test; +import io.kestra.plugin.scripts.csharp.CommandsTrigger; +import io.kestra.plugin.scripts.csharp.ScriptTrigger; +import io.kestra.plugin.scripts.csharp.CommandsTrigger; +import io.kestra.plugin.scripts.csharp.ScriptTrigger; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.property.Property; import io.kestra.core.models.triggers.Trigger; diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ExitConditionRegexWiringTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ExitConditionRegexWiringTest.java index 511418a7..706eb285 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ExitConditionRegexWiringTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ExitConditionRegexWiringTest.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.Map; +import io.kestra.plugin.scripts.csharp.CommandsTrigger; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.mockStatic; diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerTest.java index c26c404c..cc90c81b 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/ScriptTriggerTest.java @@ -5,7 +5,9 @@ import java.time.Instant; import java.util.Map; +import io.kestra.plugin.scripts.csharp.ScriptTrigger; import org.junit.jupiter.api.Test; +import io.kestra.plugin.scripts.csharp.ScriptTrigger; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTest.java new file mode 100644 index 00000000..77af235d --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTest.java @@ -0,0 +1,126 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableMap; + +import io.kestra.core.models.tasks.runners.TargetOS; +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.models.tasks.runners.TargetOS; +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.*; + +@KestraTest +class CommandsTest { + + @Inject + RunContextFactory runContextFactory; + + @Inject + @Named(QueueFactoryInterface.WORKERTASKLOG_NAMED) + private QueueInterface logQueue; + + @Test + void task() throws Exception { + List logs = new CopyOnWriteArrayList<>(); + Flux receive = TestsUtils.receive( + logQueue, + l -> logs.add(l.getLeft()) + ); + + Commands fsharpCommands = Commands.builder() + .id("fsharp-commands-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .commands(Property.ofValue(List.of("dotnet --version"))) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpCommands, + ImmutableMap.of() + ); + + ScriptOutput run = fsharpCommands.run(runContext); + + assertThat(run.getExitCode(), is(0)); + assertThat(run.getStdOutLineCount(), greaterThanOrEqualTo(1)); + + TestsUtils.awaitLog( + logs, + log -> log.getMessage() != null + && log.getMessage().matches("\\d+\\.\\d+\\.\\d+.*") + ); + + receive.blockLast(); + } + + @Test + void shouldExitOnFirstError() { + Commands fsharpCommands = Commands.builder() + .id("fsharp-should-exit-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .commands(Property.ofValue(List.of( + "dotnet nonexistent-command", + "echo \"This should not run\"" + ))) + .failFast(Property.ofValue(true)) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpCommands, + ImmutableMap.of() + ); + + Assertions.assertThrows( + RunnableTaskException.class, + () -> fsharpCommands.run(runContext) + ); + } + + @Test + void shouldNotExitOnFirstError() throws Exception { + Commands fsharpCommands = Commands.builder() + .id("fsharp-should-not-exit-" + UUID.randomUUID()) + .type(Commands.class.getName()) + .commands(Property.ofValue(List.of( + "dotnet nonexistent-command", + "echo \"This should still run\"" + ))) + .failFast(Property.ofValue(false)) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + RunContext runContext = TestsUtils.mockRunContext( + runContextFactory, + fsharpCommands, + ImmutableMap.of() + ); + + ScriptOutput run = fsharpCommands.run(runContext); + + assertThat(run.getExitCode(), is(0)); + assertThat(run.getStdOutLineCount(), greaterThanOrEqualTo(1)); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java new file mode 100644 index 00000000..4d7d04ba --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java @@ -0,0 +1,319 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.models.tasks.runners.TargetOS; +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.Execution; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.serializers.JacksonMapper; +import io.kestra.core.utils.IdUtils; +import io.kestra.core.utils.TestsUtils; + +import jakarta.inject.Inject; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +@KestraTest +class CommandsTriggerTest { + + @Inject + private RunContextFactory runContextFactory; + + @Test + void fsharpCommandsTrigger_shouldTriggerOnImplicitFailureExit1() + throws Exception { + + CommandsTrigger trigger = + CommandsTrigger.builder() + .id("fsharp-commands-trigger-" + IdUtils.create()) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage( + Property.ofValue( + "mcr.microsoft.com/dotnet/sdk:10.0" + ) + ) + .commands( + Property.ofValue( + List.of("exit 1") + ) + ) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional execution = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + execution.isPresent(), + is(true) + ); + + Map triggerVars = + execution.get() + .getTrigger() + .getVariables(); + + assertThat( + "condition should be present", + triggerVars.get("condition"), + is("exit 1") + ); + + assertThat( + "exitCode should be present", + triggerVars.get("exitCode"), + notNullValue() + ); + + assertThat( + "exitCode should be 1", + triggerVars.get("exitCode"), + is(1) + ); + + assertThat( + "timestamp should be present", + triggerVars.get("timestamp"), + notNullValue() + ); + } + + @Test + void fsharpCommandsTrigger_shouldTriggerOnStdoutMatchUsingStructuredOutputs() + throws Exception { + + CommandsTrigger trigger = + CommandsTrigger.builder() + .id( + "fsharp-commands-stdout-match-trigger-" + + IdUtils.create() + ) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("toto")) + .edge(Property.ofValue(true)) + .containerImage( + Property.ofValue( + "mcr.microsoft.com/dotnet/sdk:10.0" + ) + ) + .commands( + Property.ofValue( + List.of( + "echo '::{\"outputs\":{\"listing\":\"toto\"}}::'" + ) + ) + ) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional execution = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + execution.isPresent(), + is(true) + ); + + Map triggerVars = + execution.get() + .getTrigger() + .getVariables(); + + assertThat( + "condition should be present", + triggerVars.get("condition"), + is("toto") + ); + + assertThat( + "exitCode should be present", + triggerVars.get("exitCode"), + notNullValue() + ); + + assertThat( + "exitCode should be 0", + triggerVars.get("exitCode"), + is(0) + ); + + assertThat( + "timestamp should be present", + triggerVars.get("timestamp"), + notNullValue() + ); + + assertThat( + "vars should be present", + triggerVars.get("vars"), + notNullValue() + ); + } + + @Test + void fsharpCommandsTrigger_edgeModeShouldSuppressSecondEmission() + throws Exception { + + CommandsTrigger trigger = + CommandsTrigger.builder() + .id( + "fsharp-commands-edge-trigger-" + + IdUtils.create() + ) + .type(CommandsTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage( + Property.ofValue( + "mcr.microsoft.com/dotnet/sdk:10.0" + ) + ) + .commands( + Property.ofValue( + List.of("exit 1") + ) + ) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional first = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + "First evaluation should fire", + first.isPresent(), + is(true) + ); + + // The second poll runs on a copy that went through + // the worker's serialize/deserialize round trip. + CommandsTrigger nextPoll = + JacksonMapper.ofJson() + .readValue( + JacksonMapper.ofJson() + .writeValueAsString(trigger), + CommandsTrigger.class + ); + + context = + TestsUtils.mockTrigger( + runContextFactory, + nextPoll + ); + + Optional second = + nextPoll.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + "Edge mode should suppress repeated emission", + second.isPresent(), + is(false) + ); + } + + @Test + void fsharpCommandsTrigger_shouldMatchRegexAgainstStructuredOutputs() + throws Exception { + + CommandsTrigger trigger = + CommandsTrigger.builder() + .id( + "fsharp-commands-regex-trigger-" + + IdUtils.create() + ) + .type(CommandsTrigger.class.getName()) + .exitCondition( + Property.ofValue("status=\\w+") + ) + .edge(Property.ofValue(true)) + .containerImage( + Property.ofValue( + "mcr.microsoft.com/dotnet/sdk:10.0" + ) + ) + .commands( + Property.ofValue( + List.of( + "echo '::{\"outputs\":{\"status\":\"status=ready\"}}::'" + ) + ) + ) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional execution = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + "Regex condition should match", + execution.isPresent(), + is(true) + ); + + Map triggerVars = + execution.get() + .getTrigger() + .getVariables(); + + assertThat( + "exitCode should be 0", + triggerVars.get("exitCode"), + is(0) + ); + + assertThat( + "vars should be present", + triggerVars.get("vars"), + notNullValue() + ); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTest.java similarity index 92% rename from plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java rename to plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTest.java index b8298fbf..b750750b 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/FSharpTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTest.java @@ -1,4 +1,4 @@ -package io.kestra.plugin.scripts.dotnet; +package io.kestra.plugin.scripts.fsharp; import java.util.List; import java.util.UUID; @@ -30,7 +30,7 @@ import static org.hamcrest.Matchers.startsWith; @KestraTest -class FSharpTest { +class ScriptTest { @Inject RunContextFactory runContextFactory; @@ -43,9 +43,9 @@ void script() throws Exception { List logs = new CopyOnWriteArrayList<>(); Flux receive = TestsUtils.receive(logQueue, l -> logs.add(l.getLeft())); - FSharp fsharpScript = FSharp.builder() + Script fsharpScript = Script.builder() .id("fsharp-script-" + UUID.randomUUID()) - .type(FSharp.class.getName()) + .type(Script.class.getName()) .script(Property.ofValue("printfn \"Hello from Kestra!\"")) .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); @@ -84,9 +84,9 @@ void scriptWithNugetDependency() throws Exception { List logs = new CopyOnWriteArrayList<>(); Flux receive = TestsUtils.receive(logQueue, l -> logs.add(l.getLeft())); - FSharp fsharpScript = FSharp.builder() + Script fsharpScript = Script.builder() .id("fsharp-nuget-" + UUID.randomUUID()) - .type(FSharp.class.getName()) + .type(Script.class.getName()) .script(Property.ofValue(""" #r "nuget: Newtonsoft.Json, 13.0.3" @@ -129,9 +129,9 @@ void scriptWithNugetDependency() throws Exception { @Test void scriptWithOutputFile() throws Exception { - FSharp fsharpScript = FSharp.builder() + Script fsharpScript = Script.builder() .id("fsharp-output-" + UUID.randomUUID()) - .type(FSharp.class.getName()) + .type(Script.class.getName()) .script(Property.ofValue(""" System.IO.File.WriteAllText( "result.txt", @@ -160,9 +160,9 @@ void scriptWithOutputFile() throws Exception { @Test void scriptFailure() { - FSharp fsharpScript = FSharp.builder() + Script fsharpScript = Script.builder() .id("fsharp-fail-" + UUID.randomUUID()) - .type(FSharp.class.getName()) + .type(Script.class.getName()) .script(Property.ofValue(""" failwith "deliberate failure" """)) diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java new file mode 100644 index 00000000..08b0a509 --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java @@ -0,0 +1,183 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.kestra.core.models.tasks.runners.TargetOS; +import io.kestra.core.junit.annotations.KestraTest; +import io.kestra.core.models.executions.Execution; +import io.kestra.plugin.scripts.fsharp.ScriptTrigger; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.core.serializers.JacksonMapper; +import io.kestra.core.utils.IdUtils; +import io.kestra.core.utils.TestsUtils; + +import jakarta.inject.Inject; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Runs the real FSharpScriptTrigger#evaluate end to end. + */ +@KestraTest +class ScriptTriggerEvaluateTest { + + private static final String IMAGE = + "mcr.microsoft.com/dotnet/sdk:10.0"; + + @Inject + private RunContextFactory runContextFactory; + + private static ScriptTrigger trigger( + String prefix, + String script + ) { + return ScriptTrigger.builder() + .id(prefix + "-" + IdUtils.create()) + .type(ScriptTrigger.class.getName()) + .exitCondition(Property.ofValue("exit 1")) + .edge(Property.ofValue(true)) + .containerImage(Property.ofValue(IMAGE)) + .script(Property.ofValue(script)) + .targetOS(Property.ofValue(TargetOS.LINUX)) + .build(); + } + + @Test + void scriptTrigger_shouldEmitWhenExitCodeMatches() + throws Exception { + + ScriptTrigger trigger = + trigger( + "fsharp-script-exit1", + "System.Environment.Exit(1)" + ); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional execution = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + execution.isPresent(), + is(true) + ); + + Map triggerVars = + execution.get() + .getTrigger() + .getVariables(); + + assertThat( + triggerVars.get("condition"), + is("exit 1") + ); + + assertThat( + triggerVars.get("exitCode"), + is(1) + ); + + assertThat( + triggerVars.get("timestamp"), + notNullValue() + ); + } + + @Test + void scriptTrigger_shouldStayQuietWhenConditionDoesNotMatch() + throws Exception { + + ScriptTrigger trigger = + trigger( + "fsharp-script-exit0", + "printfn \"ok\"" + ); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional execution = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + execution.isPresent(), + is(false) + ); + } + + @Test + void scriptTrigger_edgeModeShouldSuppressSecondEmissionAcrossFreshInstances() + throws Exception { + + ScriptTrigger trigger = + trigger( + "fsharp-script-edge", + "System.Environment.Exit(1)" + ); + + var context = + TestsUtils.mockTrigger( + runContextFactory, + trigger + ); + + Optional first = + trigger.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + "first poll should fire", + first.isPresent(), + is(true) + ); + + // Simulate the serialize/deserialize round trip + // used between trigger polls. + ScriptTrigger nextPoll = + JacksonMapper.ofJson() + .readValue( + JacksonMapper.ofJson() + .writeValueAsString(trigger), + ScriptTrigger.class + ); + + context = + TestsUtils.mockTrigger( + runContextFactory, + nextPoll + ); + + Optional second = + nextPoll.evaluate( + context.getKey(), + context.getValue() + ); + + assertThat( + "edge mode should suppress the repeat", + second.isPresent(), + is(false) + ); + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerTest.java new file mode 100644 index 00000000..58079bbc --- /dev/null +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerTest.java @@ -0,0 +1,224 @@ +package io.kestra.plugin.scripts.fsharp; + +import java.time.Instant; +import java.util.Map; + +import io.kestra.plugin.scripts.dotnet.AbstractDotnetTrigger; +import io.kestra.plugin.scripts.exec.ExitConditionRegex; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +class ScriptTriggerTest { + + /* + * matchesCondition() is protected in AbstractDotnetTrigger because it is + * shared implementation detail, not part of the public plugin API. + * + * We access the inherited protected method through reflection so that + * this test does not create a subclass of a Kestra plugin. + * + * This is important because Kestra's PluginScanner scans plugin classes + * from the test classpath. A test subclass such as: + * + * TestableScriptTrigger extends ScriptTrigger + * + * can be detected as another plugin class and cause plugin scanning to + * fail because the test class does not have the required public no-arg + * constructor. + */ + private static boolean matches( + ScriptTrigger trigger, + ScriptTrigger.Output output + ) throws Exception { + var method = AbstractDotnetTrigger.class.getDeclaredMethod( + "matchesCondition", + AbstractDotnetTrigger.Output.class + ); + + method.setAccessible(true); + + return (boolean) method.invoke(trigger, output); + } + + // Use the real F# ScriptTrigger. + // Do NOT create a TestableScriptTrigger subclass here. + private final ScriptTrigger trigger = new ScriptTrigger(); + + private ScriptTrigger.Output output( + String condition, + Integer exitCode, + Map vars + ) { + return new ScriptTrigger.Output( + Instant.now(), + condition, + exitCode, + vars + ); + } + + @ParameterizedTest + @CsvSource( + { + "exit 0, 0, true", + "exit 1, 1, true", + "EXIT 1, 1, true", + "exit 0, 1, false", + "exit 1, 127, false", + "exit 42, 42, true", + } + ) + void exitCodeCondition( + String condition, + int exitCode, + boolean expected + ) throws Exception { + assertThat( + matches(trigger, output(condition, exitCode, null)), + is(expected) + ); + } + + @Test + void exitCondition_nullExitCode_doesNotMatch() throws Exception { + assertThat( + matches( + trigger, + output("exit 1", null, null) + ), + is(false) + ); + } + + @Test + void substringMatch_inVars() throws Exception { + assertThat( + matches( + trigger, + output( + "toto", + 0, + Map.of("listing", "toto") + ) + ), + is(true) + ); + } + + @Test + void noMatch_whenSubstringAbsentFromVars() throws Exception { + assertThat( + matches( + trigger, + output( + "toto", + 0, + Map.of("listing", "something_else") + ) + ), + is(false) + ); + } + + @Test + void regexMatch_inVars() throws Exception { + assertThat( + matches( + trigger, + output( + "status=\\w+", + 0, + Map.of("status", "status=ready") + ) + ), + is(true) + ); + } + + @Test + void noMatch_emptyHaystack() throws Exception { + assertThat( + matches( + trigger, + output("something", 0, null) + ), + is(false) + ); + } + + @Test + void noMatch_emptyCondition() throws Exception { + assertThat( + matches( + trigger, + output( + "", + 0, + Map.of("k", "v") + ) + ), + is(false) + ); + } + + @Test + void nullCondition_doesNotMatch() throws Exception { + assertThat( + matches( + trigger, + output( + null, + 0, + Map.of("k", "v") + ) + ), + is(false) + ); + } + + @Test + void regexCondition_delegatesToHelperAndReturnsItsResult() throws Exception { + String condition = "status=\\w+"; + + ScriptTrigger.Output output = output( + " " + condition + " ", + 0, + Map.of("status", "status=ready") + ); + + try (var helper = mockStatic(ExitConditionRegex.class)) { + helper.when( + () -> ExitConditionRegex.find( + condition, + "{status=status=ready}" + ) + ).thenReturn(true, false); + + assertThat( + matches(trigger, output), + is(true) + ); + + assertThat( + matches(trigger, output), + is(false) + ); + + helper.verify( + () -> ExitConditionRegex.find( + condition, + "{status=status=ready}" + ), + times(2) + ); + + helper.verifyNoMoreInteractions(); + } + } +} \ No newline at end of file diff --git a/plugin-script-dotnet/src/test/resources/sanity-checks/all_dotnet.yaml b/plugin-script-dotnet/src/test/resources/sanity-checks/all_dotnet.yaml index 1b0f0fb2..844279bd 100644 --- a/plugin-script-dotnet/src/test/resources/sanity-checks/all_dotnet.yaml +++ b/plugin-script-dotnet/src/test/resources/sanity-checks/all_dotnet.yaml @@ -3,11 +3,13 @@ namespace: sanitychecks.plugin-scripts tasks: - id: dotnet_inline_script - type: io.kestra.plugin.scripts.dotnet.Script + type: io.kestra.plugin.scripts.csharp.Script + targetOS: LINUX script: | Console.WriteLine("Hello from dotnet-script!"); - id: dotnet_commands - type: io.kestra.plugin.scripts.dotnet.Commands + type: io.kestra.plugin.scripts.csharp.Commands + targetOS: LINUX commands: - - dotnet --version + - dotnet --version \ No newline at end of file From 6313f6e143601961af99880dc71f82c4ffa0d145 Mon Sep 17 00:00:00 2001 From: Gauri Khedekar Date: Fri, 9 Oct 2026 15:43:23 +0530 Subject: [PATCH 3/4] test(dotnet): cover all C# compatibility aliases --- .../kestra/plugin/scripts/fsharp/Script.java | 3 - .../dotnet/BackwardCompatibilityTest.java | 73 ++++++++++++++++++- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java index 44646a1d..18b39074 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java @@ -35,7 +35,6 @@ @Plugin( examples = { @Example( - // Hello World title = "Run a simple F# Hello World script.", full = true, code = """ @@ -50,7 +49,6 @@ """ ), @Example( - // NuGet dependency title = "Run an inline F# script with a NuGet dependency.", full = true, code = """ @@ -70,7 +68,6 @@ """ ), @Example( - // output files full = true, title = """ Generate output files from an F# script. Files written to `{{ outputDir }}` are persisted \ diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java index a78bfcba..ccf147da 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/dotnet/BackwardCompatibilityTest.java @@ -4,7 +4,10 @@ import io.kestra.core.models.flows.Flow; import io.kestra.core.serializers.YamlParser; +import io.kestra.plugin.scripts.csharp.Commands; +import io.kestra.plugin.scripts.csharp.CommandsTrigger; import io.kestra.plugin.scripts.csharp.Script; +import io.kestra.plugin.scripts.csharp.ScriptTrigger; import static org.assertj.core.api.Assertions.assertThat; @@ -12,7 +15,7 @@ class BackwardCompatibilityTest { @Test void oldDotnetScriptTypeDeserializesToCSharpScript() { - String source = """ + Flow flow = parseFlow(""" id: backward-compatibility namespace: company.team tasks: @@ -20,11 +23,73 @@ void oldDotnetScriptTypeDeserializesToCSharpScript() { type: io.kestra.plugin.scripts.dotnet.Script script: | System.Console.WriteLine("Hello"); - """; - - Flow flow = YamlParser.parse(source, Flow.class); + """); assertThat(flow.getTasks()).hasSize(1); assertThat(flow.getTasks().get(0).getClass()).isEqualTo(Script.class); } + + @Test + void oldDotnetCommandsTypeDeserializesToCSharpCommands() { + Flow flow = parseFlow(""" + id: backward-compatibility + namespace: company.team + tasks: + - id: commands + type: io.kestra.plugin.scripts.dotnet.Commands + commands: + - dotnet --version + """); + + assertThat(flow.getTasks()).hasSize(1); + assertThat(flow.getTasks().get(0).getClass()).isEqualTo(Commands.class); + } + + @Test + void oldDotnetScriptTriggerTypeDeserializesToCSharpScriptTrigger() { + Flow flow = parseFlow(""" + id: backward-compatibility + namespace: company.team + triggers: + - id: script-trigger + type: io.kestra.plugin.scripts.dotnet.ScriptTrigger + interval: PT1M + exitCondition: "exit 1" + script: | + System.Environment.Exit(1); + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: compatibility-test + """); + + assertThat(flow.getTriggers()).hasSize(1); + assertThat(flow.getTriggers().get(0).getClass()).isEqualTo(ScriptTrigger.class); + } + + @Test + void oldDotnetCommandsTriggerTypeDeserializesToCSharpCommandsTrigger() { + Flow flow = parseFlow(""" + id: backward-compatibility + namespace: company.team + triggers: + - id: commands-trigger + type: io.kestra.plugin.scripts.dotnet.CommandsTrigger + interval: PT1M + exitCondition: "exit 1" + commands: + - exit 1 + tasks: + - id: log + type: io.kestra.plugin.core.log.Log + message: compatibility-test + """); + + assertThat(flow.getTriggers()).hasSize(1); + assertThat(flow.getTriggers().get(0).getClass()).isEqualTo(CommandsTrigger.class); + } + + private static Flow parseFlow(String source) { + return YamlParser.parse(source, Flow.class); + } } \ No newline at end of file From 8bf825477f28eb9b7eab439c136712b8387a7926 Mon Sep 17 00:00:00 2001 From: GauriKhedekar Date: Fri, 9 Oct 2026 20:47:23 +0530 Subject: [PATCH 4/4] fix(dotnet): address review feedback --- .../plugin/scripts/csharp/Commands.java | 8 ----- .../kestra/plugin/scripts/csharp/Script.java | 29 ++++++++++--------- .../scripts/dotnet/AbstractDotnetTrigger.java | 4 ++- .../kestra/plugin/scripts/fsharp/Script.java | 10 +++++-- .../doc/io.kestra.plugin.scripts.fsharp.md | 8 +++-- .../scripts/csharp/CommandsTriggerTest.java | 1 - .../csharp/ScriptTriggerEvaluateTest.java | 2 -- .../scripts/fsharp/CommandsTriggerTest.java | 1 - .../fsharp/ScriptTriggerEvaluateTest.java | 2 -- 9 files changed, 32 insertions(+), 33 deletions(-) diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java index ffb4650a..0777ee2c 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Commands.java @@ -73,14 +73,6 @@ ) public class Commands extends AbstractDotnetCommands { - @Schema( - title = "Container image for the .NET runtime", - description = "Docker image used to run the commands. Defaults to `mcr.microsoft.com/dotnet/sdk:10.0`. Use a custom image pre-installed with `dotnet-script` or other tools to skip manual setup in `beforeCommands`." - ) - @Builder.Default - @PluginProperty(group = "execution") - protected Property containerImage = Property.ofValue(DEFAULT_IMAGE); - @Schema( title = "Shell commands to execute", description = """ diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java index 0f23d648..72fc30a1 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/csharp/Script.java @@ -70,23 +70,26 @@ Console.WriteLine(JsonConvert.SerializeObject(data)); """ ), + @Example( full = true, title = """ - Generate output files from a C# script. Files written to `{{ outputDir }}` are persisted \ - in Kestra's internal storage and accessible to downstream tasks via \ - `{{ outputs.yourTaskId.outputFiles['yourFileName.txt'] }}`. - """, + Generate output files from a C# script. Files declared in `outputFiles` are persisted \ + in Kestra's internal storage and accessible to downstream tasks via \ + `{{ outputs.write_file.outputFiles['hello.txt'] }}`. + """, code = """ - id: dotnet_generate_files - namespace: company.team - - tasks: - - id: write_file - type: io.kestra.plugin.scripts.csharp.Script - script: | - File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from dotnet-script!"); - """ + id: dotnet_generate_files + namespace: company.team + + tasks: + - id: write_file + type: io.kestra.plugin.scripts.csharp.Script + outputFiles: + - hello.txt + script: | + File.WriteAllText("hello.txt", "Hello from dotnet-script!"); + """ ) }, aliases = "io.kestra.plugin.scripts.dotnet.Script" diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java index 27b28a6f..9def022b 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/dotnet/AbstractDotnetTrigger.java @@ -64,8 +64,10 @@ public abstract class AbstractDotnetTrigger targetOS; + protected Property targetOS = + Property.ofValue(TargetOS.AUTO); @Schema( title = "Condition to match", diff --git a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java index 18b39074..dbcfd0c4 100644 --- a/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java +++ b/plugin-script-dotnet/src/main/java/io/kestra/plugin/scripts/fsharp/Script.java @@ -67,12 +67,13 @@ printfn "%s" (JsonConvert.SerializeObject(data)) """ ), + @Example( full = true, title = """ - Generate output files from an F# script. Files written to `{{ outputDir }}` are persisted \ + Generate output files from an F# script. Files declared in `outputFiles` are persisted \ in Kestra's internal storage and accessible to downstream tasks via \ - `{{ outputs.yourTaskId.outputFiles['yourFileName.txt'] }}`. + `{{ outputs.write_file.outputFiles['hello.txt'] }}`. """, code = """ id: fsharp_generate_files @@ -81,8 +82,11 @@ tasks: - id: write_file type: io.kestra.plugin.scripts.fsharp.Script + outputFiles: + - hello.txt script: | - System.IO.File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from F#!") + System.IO.File.WriteAllText("hello.txt", "Hello from F#!") + printfn "Created hello.txt successfully." """ ) } diff --git a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md index 4bad76c4..55a5de0e 100644 --- a/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md +++ b/plugin-script-dotnet/src/main/resources/doc/io.kestra.plugin.scripts.fsharp.md @@ -62,9 +62,10 @@ tasks: printfn "%s" (JsonConvert.SerializeObject(data)) ``` + #### Generate output files -Files written to `{{ outputDir }}` are persisted in Kestra's internal storage and are available to downstream tasks through the task output. +Files declared in `outputFiles` are persisted in Kestra's internal storage and available to downstream tasks through the task output. ```yaml id: fsharp_generate_files @@ -73,8 +74,11 @@ namespace: company.team tasks: - id: write_file type: io.kestra.plugin.scripts.fsharp.Script + outputFiles: + - hello.txt script: | - System.IO.File.WriteAllText("{{ outputDir }}/hello.txt", "Hello from F#!") + System.IO.File.WriteAllText("hello.txt", "Hello from F#!") + printfn "Created hello.txt successfully." ``` ### Commands diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java index cd8e4d20..474e2500 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/CommandsTriggerTest.java @@ -35,7 +35,6 @@ void commandsTrigger_shouldTriggerOnImplicitFailureExit1() throws Exception { .edge(Property.ofValue(true)) .containerImage(Property.ofValue("mcr.microsoft.com/dotnet/sdk:10.0")) .commands(Property.ofValue(List.of("exit 1"))) - .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = TestsUtils.mockTrigger(runContextFactory, trigger); diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java index f2ea6069..5fd049e0 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/csharp/ScriptTriggerEvaluateTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test; -import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.property.Property; @@ -38,7 +37,6 @@ private static ScriptTrigger trigger(String prefix, String script) { .edge(Property.ofValue(true)) .containerImage(Property.ofValue(IMAGE)) .script(Property.ofValue(script)) - .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); } diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java index 4d7d04ba..ea487681 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/CommandsTriggerTest.java @@ -47,7 +47,6 @@ void fsharpCommandsTrigger_shouldTriggerOnImplicitFailureExit1() List.of("exit 1") ) ) - .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); var context = diff --git a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java index 08b0a509..3e0a7a25 100644 --- a/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java +++ b/plugin-script-dotnet/src/test/java/io/kestra/plugin/scripts/fsharp/ScriptTriggerEvaluateTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test; -import io.kestra.core.models.tasks.runners.TargetOS; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.executions.Execution; import io.kestra.plugin.scripts.fsharp.ScriptTrigger; @@ -44,7 +43,6 @@ private static ScriptTrigger trigger( .edge(Property.ofValue(true)) .containerImage(Property.ofValue(IMAGE)) .script(Property.ofValue(script)) - .targetOS(Property.ofValue(TargetOS.LINUX)) .build(); }