Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ This is a **multi-module** plugin with 19 submodules:
**plugin-script-groovy:**

- `io.kestra.plugin.scripts.groovy.Commands`
- `io.kestra.plugin.scripts.groovy.CommandsTrigger`
- `io.kestra.plugin.scripts.groovy.Eval`
- `io.kestra.plugin.scripts.groovy.FileTransform`
- `io.kestra.plugin.scripts.groovy.Script`
- `io.kestra.plugin.scripts.groovy.ScriptTrigger`
**plugin-script-jbang:**

- `io.kestra.plugin.scripts.jbang.Commands`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package io.kestra.plugin.scripts.groovy;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;

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.*;
import io.kestra.core.runners.RunContext;
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 jakarta.validation.constraints.NotNull;
import lombok.*;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
public abstract class AbstractGroovyTrigger extends AbstractTrigger
implements PollingTriggerInterface, TriggerOutput<AbstractGroovyTrigger.Output> {

protected static final String DEFAULT_IMAGE = "groovy";
private static final Pattern EXIT_CONDITION = Pattern.compile("^exit\\s+(\\d+)$", Pattern.CASE_INSENSITIVE);

@Schema(title = "Container image", description = "Image used by the underlying Groovy task on each poll. Defaults to groovy.")
@PluginProperty(group = "execution")
@Builder.Default
protected Property<String> containerImage = Property.ofValue(DEFAULT_IMAGE);

@Schema(title = "Condition to match", description = """
Evaluated after each run. 'exit N' compares a known process exit code (case insensitive).
Other nonempty values are regexes matched against structured vars emitted using
::{"outputs":{...}}::. Invalid or excessive regexes fall back to substring matching.
Raw stdout and stderr are not matched.
""")
@NotNull
@PluginProperty(group = "main")
protected Property<String> exitCondition;

@Schema(title = "Check interval", description = "Interval between fresh executions of the configured script or commands. Defaults to PT60S.")
@PluginProperty(group = "execution")
@Builder.Default
private final Duration interval = Duration.ofSeconds(60);

@Schema(title = "Edge trigger mode", description = """
When true (default), the first matching poll emits and consecutive matches are suppressed
until a nonmatching poll. The last match state is stored in the flow namespace KV store,
scoped to this flow and trigger. When false, each fresh matching run emits without accessing
or updating KV state. Re-enabling edge mode resumes the last stored edge-mode state.
This does not provide exactly-once delivery if an evaluation crashes.
""")
@PluginProperty(group = "advanced")
@Builder.Default
protected Property<Boolean> edge = Property.ofValue(true);

protected abstract ScriptOutput executeTask(RunContext runContext) throws Exception;

@Override
public Optional<Execution> evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception {
var runContext = conditionContext.getRunContext();
var rEdge = runContext.render(this.edge).as(Boolean.class).orElse(true);
var rCondition = runContext.render(this.exitCondition).as(String.class)
.orElseThrow(() -> new IllegalArgumentException("exitCondition is required; set 'exit N' or a regex against vars"));
validateCondition(rCondition);

Output output;
try {
output = runOnce(runContext, rCondition);
} catch (Exception e) {
runContext.logger().warn("Groovy trigger could not execute its task; check the container image and task runner", e);
return Optional.empty();
}

var matched = matchesCondition(output);
if (!rEdge) {
return matched
? Optional.of(TriggerService.generateExecution(this, conditionContext, context, output))
: Optional.empty();
}

var store = runContext.namespaceKv(context.getNamespace());
var key = edgeStateKey(context.getFlowId(), context.getTriggerId());
var lastMatched = store.getValue(key).map(value -> Boolean.parseBoolean(String.valueOf(value.value()))).orElse(false);
if (matched != lastMatched) {
store.put(key, new KVValueAndMetadata(null, matched));
}

if (!matched || lastMatched) {
return Optional.empty();
}
return Optional.of(TriggerService.generateExecution(this, conditionContext, context, output));
}

static String edgeStateKey(String flowId, String triggerId) {
return "groovy_edge_" + flowId.length() + "_" + flowId + "_" + triggerId.length() + "_" + triggerId;
}

private Output runOnce(RunContext runContext, String rCondition) throws Exception {
try {
var taskOutput = executeTask(runContext);
return new Output(
Instant.now(), rCondition,
taskOutput == null ? null : taskOutput.getExitCode(),
taskOutput == null ? null : taskOutput.getVars()
);
} catch (RunnableTaskException e) {
Integer exitCode = null;
for (var cause = e.getCause(); cause != null; cause = cause.getCause()) {
if (cause instanceof TaskException taskException) {
exitCode = taskException.getExitCode();
break;
}
}
if (exitCode == null) {
throw e;
}
var vars = e.getOutput() instanceof ScriptOutput taskOutput ? taskOutput.getVars() : null;
return new Output(Instant.now(), rCondition, exitCode, vars);
}
}

static void validateCondition(String condition) {
if (condition == null || condition.isBlank()) {
throw new IllegalArgumentException("exitCondition must not be empty; set 'exit N' or a regex against vars");
}
var matcher = EXIT_CONDITION.matcher(condition.trim());
if (matcher.matches()) {
try {
Integer.parseInt(matcher.group(1));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("exitCondition exit code must be between 0 and " + Integer.MAX_VALUE, e);
}
}
}

boolean matchesCondition(Output output) {
if (output == null || output.getCondition() == null || output.getCondition().isBlank()) {
return false;
}
var condition = output.getCondition().trim();
validateCondition(condition);
var matcher = EXIT_CONDITION.matcher(condition);
if (matcher.matches()) {
return output.getExitCode() != null && output.getExitCode() == Integer.parseInt(matcher.group(1));
}
return output.getVars() != null && !output.getVars().isEmpty()
&& ExitConditionRegex.find(condition, output.getVars().toString());
}

@Data
@AllArgsConstructor
public static class Output implements io.kestra.core.models.tasks.Output {
@Schema(title = "Poll timestamp", description = "Time the Groovy task finished for this poll.")
private Instant timestamp;

@Schema(title = "Rendered condition", description = "Rendered exitCondition for this poll.")
private String condition;

@Schema(title = "Process exit code", description = "Groovy task exit code, or null when unavailable. Unknown codes never match 'exit N'.")
private Integer exitCode;

@Schema(title = "Structured script vars", description = "Values emitted by the task using ::{\"outputs\":{...}}::, including values emitted before a process failure.")
private Map<String, Object> vars;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.kestra.plugin.scripts.groovy;

import java.util.List;

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.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.*;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
@Schema(
title = "Trigger a flow when Groovy commands match a condition",
description = "Runs its own Groovy commands on an interval through the Commands task and starts a flow when exitCondition matches. Defaults to the groovy container image and a 60-second interval."
)
@Plugin(
examples = {
@Example(title = "Trigger when a Groovy command exits with code 1.", full = true, code = """
id: groovy_commands_trigger
namespace: company.team

tasks:
- id: log
type: io.kestra.plugin.core.log.Log
message: "Triggered with exitCode={{ trigger.exitCode }}"

triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.CommandsTrigger
interval: PT60S
exitCondition: "exit 1"
commands:
- groovy -e "System.exit(1)"
""")
}
)
public class CommandsTrigger extends AbstractGroovyTrigger {

@Schema(
title = "Groovy commands",
description = "Executed on each poll with the same command rendering semantics as the Groovy Commands task, including runner variables such as workingDir. beforeCommands, env and taskRunner are not exposed by this trigger. Supply dependencies and configuration in the commands (for example @Grab in Groovy code, which may require network access) or container image."
)
@NotNull
@PluginProperty(group = "main")
protected Property<List<String>> commands;

@Override
protected ScriptOutput executeTask(RunContext runContext) throws Exception {
if (this.commands == null) {
throw new IllegalArgumentException("commands is required; supply the Groovy commands to run on each poll");
}
var rImage = runContext.render(this.containerImage).as(String.class).orElse(DEFAULT_IMAGE);
var task = Commands.builder()
.id(this.getId())
.type(Commands.class.getName())
.containerImage(Property.ofValue(rImage))
.commands(this.commands)
.build();
return task.run(TriggerRunContext.forEmbeddedTask(runContext, task));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.kestra.plugin.scripts.groovy;

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.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.*;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
@Schema(
title = "Trigger a flow when a Groovy script matches a condition",
description = "Runs its own Groovy script on an interval through the Script task and starts a flow when exitCondition matches. Defaults to the groovy container image and a 60-second interval."
)
@Plugin(
examples = {
@Example(title = "Trigger when structured vars report ready.", full = true, code = """
id: groovy_script_trigger
namespace: company.team

tasks:
- id: log
type: io.kestra.plugin.core.log.Log
message: "Triggered with exitCode={{ trigger.exitCode }} (vars={{ trigger.vars }})"

triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.ScriptTrigger
interval: PT60S
exitCondition: "ready"
script: |
println '::{"outputs":{"status":"ready"}}::'
""")
}
)
public class ScriptTrigger extends AbstractGroovyTrigger {

@Schema(
title = "Groovy script",
description = "Executed on each poll with the same script rendering semantics as the Groovy Script task, including runner variables such as workingDir. beforeCommands, env and taskRunner are not exposed by this trigger. Supply dependencies and configuration in the script (for example @Grab, which may require network access) or container image."
)
@NotNull
@PluginProperty(group = "main")
protected Property<String> script;

@Override
protected ScriptOutput executeTask(RunContext runContext) throws Exception {
if (this.script == null) {
throw new IllegalArgumentException("script is required; supply the Groovy script to run on each poll");
}
var rImage = runContext.render(this.containerImage).as(String.class).orElse(DEFAULT_IMAGE);
var task = Script.builder()
.id(this.getId())
.type(Script.class.getName())
.containerImage(Property.ofValue(rImage))
.script(this.script)
.build();
return task.run(TriggerRunContext.forEmbeddedTask(runContext, task));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,16 @@ Add Maven dependencies inline using Grape annotations: `@Grab('group:artifact:ve

## Container user

The working directory Kestra mounts is not necessarily owned by the image's default user (`groovy`, uid 1000, on `groovy:jdk21`), so a non-root process cannot always write the files that `outputFiles` collects. On the Docker task runner, `Commands` therefore runs the container as `root` unless you set `taskRunner.user` explicitly; set it if you need the container to keep its own default user, and make sure that user can write to the working directory. `Script` always runs as `root` on Docker and ignores `taskRunner.user`.
The working directory Kestra mounts is not necessarily owned by the image's default user (`groovy`, uid 1000, on `groovy:jdk21`), so a non-root process cannot always write the files that `outputFiles` collects. On the Docker task runner, `Commands` therefore runs the container as `root` unless you set `taskRunner.user` explicitly; set it if you need the container to keep its own default user, and make sure that user can write to the working directory. `Script` uses the same root default and respects an explicit `taskRunner.user`.

## Polling triggers

`ScriptTrigger` runs its own inline Groovy script on an interval; `CommandsTrigger` runs its own list of commands. Both delegate to the existing task runner tasks, with the `groovy` image and a 60-second interval by default.

The triggers expose `script` or `commands`, `containerImage`, `interval`, `exitCondition`, and `edge`; they do not expose `beforeCommands`, `env`, or `taskRunner`. Supply dependencies and configuration through the script/commands or the container image. Groovy `@Grab` is one way to load dependencies at runtime and may require network access; it does not guarantee offline availability. The underlying task runner renders script/command expressions such as `{{ workingDir }}` with its container working directory. This does not expose every option of the underlying tasks.

Set the required `exitCondition` to `exit N` to match a known process exit code, including nonzero exits. Alternatively, use a regex against structured vars emitted with `::{"outputs":{...}}::`. Invalid or excessive regexes fall back to substring matching. Raw stdout/stderr and comparison expressions such as `exitCode != 0` are not condition inputs.

Downstream tasks can read `trigger.timestamp`, `trigger.condition`, `trigger.exitCode`, and `trigger.vars`. Vars emitted before a process failure are preserved. Container startup, image, rendering, and other setup failures cannot satisfy a process exit condition.

With `edge: true` (the default), the first matching evaluation emits; further matches are suppressed until a nonmatch. The previous match is stored in the namespace KV store under `groovy_edge_<flowId length>_<flowId>_<triggerId length>_<triggerId>`, so it survives recreation of the trigger object. Only edge-mode state transitions write the key. With `edge: false`, evaluations do not access or update KV state; re-enabling edge mode resumes the last persisted edge-mode state. Setup/rendering failures preserve the previous state. Deleting that key resets the edge state. With `edge: false`, each evaluation runs the script/commands again and may emit. A fresh process run is a new observation; this is not an event listener or an exactly-once delivery guarantee across crashes.
Loading
Loading