Repository navigation
feat(groovy): add script and commands triggers - #459
Conversation
jymaire
left a comment
There was a problem hiding this comment.
Thanks a lot for this contribution, @sushanttiwari-14, and for the detailed QA write-up! The trigger model lines up well with the other language triggers: it uses the shared ExitConditionRegex helper, length-prefixed KV edge state, vars are kept on nonzero exits, and the condition tests cover case, whitespace, null, blank and invalid regex.
I found one functional issue, plus a few minor points:
- [MEDIUM]
ScriptTriggerandCommandsTriggerrenderscript/commandsbefore handing them to the task. That drops the task runner variables, so expressions like{{ workingDir }}that work in the Groovy tasks make the trigger fail silently. See the inline comments for a fix. - [LOW] The edge state is rewritten in KV on every poll. Writing only when it changes would avoid that.
- [LOW] The docs don't say that
beforeCommands/envare not available on the trigger. - [LOW]
AGENTS.mdlists each module's plugin classes, and the other trigger PRs (#447, #453, #454) added their triggers there. Could you addio.kestra.plugin.scripts.groovy.CommandsTriggerandio.kestra.plugin.scripts.groovy.ScriptTriggerunderplugin-script-groovy?
I ran GroovyTriggerTest and GroovyTriggerConditionTest locally at 945e8df, and all tests passed.
|
Pushed the review fixes in 70c47e2 and the additional invalid-input/literal-Property coverage in 78c3c1c. AGENTS.md now lists both Groovy trigger classes. The PR description includes complete workingDir reproducers and fresh, unedited UI evidence from the exact built JAR. All 70 trigger tests and all 85 Groovy tests pass, along with formatting, documentation lint, and the JAR build. I investigated the original Ruby failure in run 37071337432, attempt 2: The full local repository check remains incomplete: its latest attempt encountered missing host |
jymaire
left a comment
There was a problem hiding this comment.
Kestra Plugin Code Review
Business Requirements — met
Issue #425 asks for Groovy script and commands polling triggers with an exit-code or vars condition and edge mode. Both are delivered, consistent with the Lua trigger model.
Previous Review Follow-up — 4 of 4 addressed
Baseline: 945e8df (jymaire review 5412275190), compared with head 78c3c1c.
- ✅ [MEDIUM] Pre-rendering of script/commands (
ScriptTrigger.java,CommandsTrigger.java):this.script/this.commandsare now passed unchanged to the embeddedScript/Commandstask, so{{ workingDir }}renders in the runner context.workingDirRendersInRunnerContextpasses with real Docker (3/3). - ✅ [LOW] KV write on every poll (
AbstractGroovyTrigger.java:86-91):store.putonly runs whenmatched != lastMatched.writesOnlyTransitionsEvenWithEdgeDisabledverifies the write counts. - ✅ [LOW] Docs missing the unavailable beforeCommands/env/taskRunner: stated in both property
@Schemadescriptions and inio.kestra.plugin.scripts.groovy.md. - ✅ AGENTS.md: both trigger classes are listed under the groovy module.
The four earlier threads are resolved.
Kestra Guidelines — 1 finding
- 🟡
AbstractGroovyTrigger.java:86[Consistency] edge: false still reads and writes the KV store
Security (OWASP Top 10:2025 + KPS) — 0 findings
✅ No issues found. No secrets, shell construction or template injection beyond the standard task rendering; the regex condition goes through ExitConditionRegex (timeout plus substring fallback).
Performance — 1 finding
- 🟡
AbstractGroovyTrigger.java:88[Performance] Extra KV read per poll when edge is disabled (same point as above)
Compatibility with Kestra 1.3.x and 2.0.x: no core API changed in 2.0 is used (TriggerRunContext.forEmbeddedTask, namespaceKv, KVValueAndMetadata are the same as in the Lua trigger). GroovyTrigger* tests: 70/70 pass locally (1 worker, no daemon). CI (check / main) was still pending at review time.
Verdict: APPROVE (recommendation only; no remaining blocking finding, waiting for CI)
Skip KV access when edge mode is disabled and resume the stored baseline when enabled. Accept Boolean string representations and cover state transitions, outages, and mode changes.
Keep real Axios installation and HTTP execution without relying on Google connectivity. Bound fixture execution and cover HTTP rejection and dependency installation failures.
|
Addressed both new KV findings in dad6fef: edge-disabled polling bypasses KV, and persisted Boolean/string values are handled consistently. Regression tests cover KV outages, state representations, mode changes and rearming; the docs describe the retained baseline when edge mode is re-enabled. Fixed the latest CI failure in 1ee9aa7: the Node dependency test now makes a real Axios request to a bounded loopback HTTP fixture instead of Google. It still verifies npm installation, HTTP execution and structured output, with HTTP and installation failure coverage. The earlier Ruby log-count failure and this Node connection timeout were different failures; the Ruby cause remains unconfirmed. Validation: 99/99 Groovy and 17/17 Node tests pass, along with documentation, JAR and relevant formatting checks. The full local check executed 244 tests: 243 passed, and one existing JBang test failed because its Kotlin compiler could not be downloaded or installed. The PR description records this limit and the evidence provenance. The new CI run awaits maintainer approval. Please approve it when ready. No new reviewer threads have been resolved. |
jymaire
left a comment
There was a problem hiding this comment.
Kestra Plugin Code Review
Business Requirements — met
Groovy script and commands polling triggers with exit-code or vars condition and edge mode are delivered; the latest commits only refine edge-mode KV handling and tests.
Previous Review Follow-up — 2 of 2 addressed
Baseline: 78c3c1c, new head: 1ee9aa7.
- ✅ [Consistency] edge: false still reads/writes KV (
AbstractGroovyTrigger.java:87-91):evaluatenow returns the fresh match beforenamespaceKvis touched;edgeDisabledDoesNotAccessKvverifies it with a throwing KV andnever(). - ✅ [Performance] strict Boolean check on the stored value (
AbstractGroovyTrigger.java:96): nowBoolean.parseBoolean(String.valueOf(value.value()));persistedBooleanAndStringStateControlsEdgescovers Boolean and string states.
Both threads resolved after verifying the code on the current head.
Kestra Guidelines — 1 finding
- 🟢
GroovyTriggerTest.java:305[Guidelines] Fully qualified names in tests
Security (OWASP Top 10:2025 + KPS) — 0 findings
✅ No issues found. The delta to plugin-script-node is a test-only change (loopback HTTP fixture instead of google.com); no build, CI or .github files are touched.
Performance — 0 findings
✅ No issues found. Edge-disabled polls now skip the KV round trip entirely.
Compatibility with Kestra 1.3.x and 2.0.x (kestraVersion 1.3.39): no core API changed in 2.0 is used in the delta. CI (Main) was waiting for fork approval and has been approved; results are pending.
Verdict: APPROVE (recommendation only; no remaining blocking finding, waiting for CI)
QA report: Groovy
|
| # | Scenario | v1.3.42 | v2.0.5 |
|---|---|---|---|
| 1 | ScriptTrigger @Example, unmodified (defaults: PT60S, no image, no runner settings) |
✅ fired, vars={status: ready} |
✅ fired, vars={status: ready} |
| 2 | CommandsTrigger @Example, unmodified (exit 1) |
✅ fired, exitCode=1 |
✅ fired, exitCode=1 |
| 3 | No fire when the condition does not match (regex miss + exit 3 vs exit 2), several minutes of polling |
✅ 0 executions | ✅ 0 executions |
| 4 | edge: true: KV toggle off→on→(stay on)→restart→off→on |
✅ exactly 2 executions, one per false→true edge | ✅ exactly 2 executions |
| 5 | edge: false: fires on every matching poll, no KV access |
✅ 19 executions at PT5S, no KV key | ✅ 27 executions, no KV key (GET → 404) |
| 6 | {{ workingDir }} rendering in a ScriptTrigger script (Docker runner) |
✅ absolute runner dir, probe file written and read back | ✅ |
| 7 | {{ workingDir }} rendering in CommandsTrigger commands |
✅ | ✅ |
| 8 | vars emitted before a non-zero exit (exit 5) still match a regex condition | ✅ exitCode=5, vars={progress: partial} |
✅ |
| 9 | Invalid inputs: missing required props, bad interval, unsupported taskRunner |
✅ rejected at save | ✅ rejected at save |
| 10 | Invalid inputs found at runtime: edge: notabool, exitCondition: '', exit 99999999999, commands as a string, missing image |
✅ never fires, clear error (see LOW-1) | ✅ same |
| 11 | Non-regression: Groovy Eval, Script, Commands tasks |
✅ SUCCESS | ✅ SUCCESS |
| 12 | Docs for both triggers in the UI (properties, examples) | ✅ | ✅ |
The #460 trap (null targetOS forwarded from the trigger to the task) does not occur here. Both examples fire with all defaults on both versions.
Note on 2.0.5: the PT60S examples first fired about 61 s after the flow was created. On 1.3.42 the first poll ran about 4 s after creation. This comes from the core scheduler and does not depend on the plugin.
Flow 1: groovy_script_trigger (plugin example, ✅ SUCCESS)
Flow YAML
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"}}::'Gantt
| Task | Status | Duration |
|---|---|---|
log |
SUCCESS | 0.02s |
| Total | SUCCESS | 0.08s |
Trigger variables: condition: ready, exitCode: 0, vars: {status: ready}, timestamp set. There was one execution on each version. Edge mode (the default) did not re-fire on later polls.
Flow 2: groovy_commands_trigger (plugin example, ✅ SUCCESS)
Flow YAML
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)"Gantt (docs/example screenshot)
| Task | Status | Duration |
|---|---|---|
log |
SUCCESS | 0.02s |
| Total | SUCCESS | 0.07s |
Trigger variables: condition: exit 1, exitCode: 1, vars: {}.
Flow 3: groovy_nofire (✅ never fired, as expected)
Flow YAML
id: groovy_nofire
namespace: qa.groovy
tasks:
- id: log
type: io.kestra.plugin.core.log.Log
message: "SHOULD NOT FIRE {{ trigger.vars }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.ScriptTrigger
interval: PT5S
exitCondition: "ready"
script: |
println '::{"outputs":{"status":"waiting"}}::'
- id: poll_cmd
type: io.kestra.plugin.scripts.groovy.CommandsTrigger
interval: PT5S
exitCondition: "exit 3"
commands:
- groovy -e "System.exit(2)"Result: 0 executions on either version. No KV key was written, because state is only stored when it changes.
Flow 4: groovy_edge_true (✅ SUCCESS, edge semantics)
Flow YAML
id: groovy_edge_true
namespace: qa.groovy
tasks:
- id: out
type: io.kestra.plugin.core.debug.Return
format: "state={{ trigger.vars.state }} exitCode={{ trigger.exitCode }} condition={{ trigger.condition }} ts={{ trigger.timestamp }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.ScriptTrigger
interval: PT5S
exitCondition: "state=on"
script: |
println '::{"outputs":{"state":"{{ kv('qa_toggle', errorOnMissing=false) ?? 'off' }}"}}::'Sequence (same on both versions): the namespace KV qa_toggle was missing, so no fire. Setting it to on produced 1 execution. It stayed on for about 30 s with no new execution. docker restart of Kestra followed, and polls resumed with no re-fire (the edge key groovy_edge_16_groovy_edge_true_4_poll = BOOLEAN true survived). Setting off stored the edge key as false (revision 2). Setting on again produced 1 more execution. Total: 2 executions.
Gantt (executions, gantt)
| Task | Status | Duration |
|---|---|---|
out |
SUCCESS | 0.03s |
| Total | SUCCESS | 0.06s |
Outputs: out.value = state=on exitCode=0 condition=state=on ts=2026-10-09T12:30:45.128188Z. Trigger variables are shown in the execution overview (screenshot).
Flow 5: groovy_edge_false (✅ SUCCESS, fires on every matching poll)
Flow YAML
id: groovy_edge_false
namespace: qa.groovy
tasks:
- id: out
type: io.kestra.plugin.core.debug.Return
format: "exitCode={{ trigger.exitCode }} vars={{ trigger.vars }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.CommandsTrigger
interval: PT5S
edge: false
exitCondition: "exit 0"
commands:
- groovy -e "println 'tick'"Result: one SUCCESS execution per poll. That gave 19 before disabling on 1.3.42 and 27 on 2.0.5, and the trigger kept firing across the restart. The KV store has no groovy_edge_17_groovy_edge_false_4_poll key on either version (KV screenshot, executions).
Flow 6: groovy_workdir (✅ SUCCESS, runner rendering in the script)
Flow YAML
id: groovy_workdir
namespace: qa.groovy
tasks:
- id: out
type: io.kestra.plugin.core.debug.Return
format: "script path={{ trigger.vars.path }} usable={{ trigger.vars.usable }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.ScriptTrigger
interval: PT10S
containerImage: groovy:latest
exitCondition: "usable"
script: |
def dir = new File('{{ workingDir }}')
assert dir.isAbsolute() && dir.isDirectory()
def probe = new File(dir, 'probe.txt')
probe.text = 'usable'
println '::{"outputs":{"path":"' + dir.absolutePath + '","usable":"' + probe.text + '","flow":"{{ flow.id }}"}}::'Outputs (screenshot): script path=<tmpDir>/5Ik6fOVmExSqSwLJmsu6nj usable=usable. {{ workingDir }} and {{ flow.id }} rendered inside the Docker container.
Flow 7: groovy_workdir_cmd (✅ SUCCESS, runner rendering in commands)
Flow YAML
id: groovy_workdir_cmd
namespace: qa.groovy
tasks:
- id: out
type: io.kestra.plugin.core.debug.Return
format: "commands vars={{ trigger.vars }} exit={{ trigger.exitCode }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.CommandsTrigger
interval: PT10S
exitCondition: "exit 0"
commands:
- echo 'println "::{\"outputs\":{\"wd\":\"" + new File(".").canonicalPath + "\"}}::"' > {{ workingDir }}/probe.groovy
- groovy {{ workingDir }}/probe.groovyOutputs (screenshot): commands vars={"wd":"<tmpDir>/6y2Y3cNERcvZ8xkA1adNBM"} exit=0.
Flow 8: groovy_vars_on_failure (✅ SUCCESS)
Flow YAML
id: groovy_vars_on_failure
namespace: qa.groovy
tasks:
- id: out
type: io.kestra.plugin.core.debug.Return
format: "exit={{ trigger.exitCode }} vars={{ trigger.vars }}"
triggers:
- id: poll
type: io.kestra.plugin.scripts.groovy.ScriptTrigger
interval: PT10S
exitCondition: "partial"
script: |
println '::{"outputs":{"progress":"partial"}}::'
System.exit(5)Outputs (screenshot): exit=5 vars={"progress":"partial"}. The vars emitted before the process failure are kept and matched.
Flow 9: invalid inputs (✅ rejected)
Each check used a one-trigger flow (groovy_invalid / inv_*, namespace qa.groovy, a single Log task, interval: PT5S).
| Input | Result (both versions) |
|---|---|
no exitCondition |
save rejected: triggers[0].exitCondition: must not be null |
ScriptTrigger without script |
save rejected: triggers[0].script: must not be null |
CommandsTrigger without commands |
save rejected: triggers[0].commands: must not be null |
interval: abc |
save rejected: Text cannot be parsed to a Duration |
taskRunner: on the trigger |
save rejected: Unrecognized field "taskRunner" (matches the docs: not exposed) |
edge: notabool |
saves, as with any Property<Boolean>. Every poll fails with ERROR Unrecognized token 'notabool', never fires |
exitCondition: '' |
ERROR exitCondition must not be empty; set 'exit N' or a regex against vars, never fires |
exitCondition: exit 99999999999 |
ERROR exitCondition exit code must be between 0 and 2147483647, never fires (screenshot) |
exitCondition: 'a[b' (invalid regex) |
WARN not a valid regex; falling back to a substring match, fires on substring (documented) |
commands: groovy -v (string, not list) |
WARN only, never fires. See LOW-1 |
containerImage: <missing image> |
WARN only, never fires. See LOW-1 |
Flow 10: groovy_tasks_nonreg (✅ SUCCESS, non-regression)
Flow YAML
id: groovy_tasks_nonreg
namespace: qa.groovy
tasks:
- id: eval
type: io.kestra.plugin.scripts.groovy.Eval
outputs:
- total
script: |
total = (1..10).sum()
logger.info("Eval total {}", total)
- id: script
type: io.kestra.plugin.scripts.groovy.Script
script: |
println "hello from {{ flow.id }} in " + new File('{{ workingDir }}').absolutePath
println '::{"outputs":{"answer":42}}::'
- id: commands
type: io.kestra.plugin.scripts.groovy.Commands
commands:
- groovy -e 'println "::{\"outputs\":{\"v\":\"" + GroovySystem.version + "\"}}::"'
- id: show
type: io.kestra.plugin.core.debug.Return
format: "eval={{ outputs.eval.outputs.total }} script={{ outputs.script.vars.answer }} commands={{ outputs.commands.vars.v }}"Gantt (screenshot, v2.0.5)
| Task | Status | Duration |
|---|---|---|
eval |
SUCCESS | 0.67s |
script |
SUCCESS | 1.84s |
commands |
SUCCESS | 1.69s |
show |
SUCCESS | 0.02s |
| Total | SUCCESS | 4.39s |
Outputs: show.value = eval=55 script=42 commands=5.1.3 on both versions.
LOW notes (non-blocking)
- LOW-1: the cause of a swallowed task failure is hidden at the default log level.
AbstractGroovyTrigger.java:82catches every task exception and logswarn("Groovy trigger could not execute its task; check the container image and task runner", e). Kestra writes the attached throwable as a separate TRACE (1.3) or DEBUG (2.0) entry. At the default INFO filter, users only see the generic line, and it points to a task runner the trigger does not expose. Example:commands: groovy -v(string) hidesJsonParseException: Unrecognized token 'groovy'. Includinge.getMessage()in the WARN text would make it actionable (screenshot). - LOW-2 (maintainer decision): because that catch returns
Optional.empty(),failOnTriggerErrorhas no effect on task-level failures (image pull errors, badcommands). Errors in condition validation and in renderingedgedo propagate. - Triggers always use the default Docker runner.
taskRunneris intentionally not exposed, so the Process runner cannot be tested for the triggers.
Timeout notes
No scenario hit the 30 s timeout. On 2.0.5 the unmodified PT60S examples fired about 61 s after creation because the core scheduler waits one interval before the first poll; 1.3.42 polls immediately.
jymaire
left a comment
There was a problem hiding this comment.
All good for me, thanks for the contribution!
What changes are being made and why?
Add Groovy
ScriptTriggerandCommandsTriggerpolling triggers. They start a flow when a fresh process matches an exit-code or structured-variable condition, following the maintainer-confirmed model. Closes #425.Script and commands properties reach their embedded tasks unchanged, so rendering can use runner variables such as
{{ workingDir }}. Edge mode stores boolean state only when it changes and accepts Boolean/string values from KV. Withedge: false, polling bypasses KV entirely; re-enabling resumes the last persisted edge-mode baseline. Documentation describes supported options, dependency setup, and unavailablebeforeCommands,env, andtaskRunnerproperties. Both trigger classes are listed in AGENTS.md.How the changes have been QAed?
Built with Java 21, Kestra dependencies 1.3.39, and plugin version 1.13.1-SNAPSHOT.
workingDir, then pass after the fix: script expressions, expressions in serialized command-list elements, and an expression returning the whole command list. Coverage also checks KV outages with edge disabled, persisted Boolean/string/null/missing/unexpected values, mode changes and rearming, enabled KV failure propagation, invalid inputs, and literal/expression Property parity with the underlying tasks.78c3c1c0loaded its exact built JAR into Kestrav1.3.39-no-plugins, with one Groovy JAR. Both flows below verified an absolute usable runner directory, wrote/read a probe file, emitted structured values, and completed their downstream Return tasks. Both flows were disabled after QA. JAR SHA-256:46591e32017920283f444364a73d029b0e13aadc6fe3b2343fc50b24ec66870a.78c3c1c0passed on the reviewed merge, its unmodified base and the feature tree. Its stdout-log-count failure remains unconfirmed. The latest failed remote run did not execute Ruby tests, so no Ruby pass is claimed for that run.NodeTest.requirementsafter Axios requestedhttp://google.com: IPv4 timed out and IPv6 was unreachable. The dependency test now uses a real HTTP server inside the Node process, bound to loopback on an ephemeral port, with readiness, response assertions, proxy disabled, bounded timeouts and cleanup. Regression tests cover HTTP rejection and preventing script execution after dependency installation failure. Axios, Node production code and CI configuration are unchanged; npm registry access is still required.check shadowJar --parallel --max-workers=2 --console=plainexecuted 244 tests, with 243 passing and one existing JBang Kotlin test failing because kotlinc 2.3.10 could not be downloaded or installed. Groovy passed 99/99 in this run; Node passed 17/17 separately afterward. The precise compiler installation failure is unconfirmed.270288548f89b1abf747ed5c378c173dbae8756dd1caebc8a3b381a5beccfad5. The UI captures below verify the rendering fix at78c3c1c0; the latest edge-state changes are covered by the updated tests../gradlew :plugin-script-groovy:test --tests '*Trigger*' --console=plain ./gradlew :plugin-script-groovy:spotlessCheck :plugin-script-groovy:lintPluginDocs --console=plain ./gradlew :plugin-script-groovy:check :plugin-script-groovy:shadowJar --console=plain ./gradlew :plugin-script-node:check --max-workers=2 --console=plain ./gradlew check shadowJar --parallel --max-workers=2 --console=plain git diff --checkRendering UI evidence at
78c3c1c0shows successful trigger values and downstream outputs. Screenshots 01–02 are historical and predate the rendering fix; screenshots 03–06 were captured with that commit’s JAR. No new UI capture is claimed for the later edge-state changes.Evidence and QA flows.
ScriptTrigger flow
CommandsTrigger flow
Setup Instructions
Build with Java 21 and the checked-in wrapper. Docker is required. Load the new Groovy shadow JAR into a compatible Kestra instance without another Groovy JAR, then create the two flows above. Disable the polling flows after QA. No API keys are required.
Dependencies/configuration belong in script/commands or the image;
@Grabmay require network access. Edge persistence and execution delivery are not transactional and do not guarantee exactly-once delivery across crashes.Contributor Checklist ✅