|
| 1 | +package io.kestra.plugin.scripts.perl; |
| 2 | + |
| 3 | +import java.time.Duration; |
| 4 | +import java.time.Instant; |
| 5 | +import java.util.List; |
| 6 | +import java.util.Map; |
| 7 | +import java.util.Optional; |
| 8 | +import java.util.concurrent.CompletableFuture; |
| 9 | +import java.util.concurrent.TimeUnit; |
| 10 | +import java.util.concurrent.TimeoutException; |
| 11 | +import java.util.regex.Matcher; |
| 12 | +import java.util.regex.Pattern; |
| 13 | + |
| 14 | +import io.kestra.core.models.annotations.Example; |
| 15 | +import io.kestra.core.models.annotations.Plugin; |
| 16 | +import io.kestra.core.models.conditions.ConditionContext; |
| 17 | +import io.kestra.core.models.executions.Execution; |
| 18 | +import io.kestra.core.models.property.Property; |
| 19 | +import io.kestra.core.models.tasks.RunnableTaskException; |
| 20 | +import io.kestra.core.models.tasks.runners.TaskException; |
| 21 | +import io.kestra.core.models.triggers.AbstractTrigger; |
| 22 | +import io.kestra.core.models.triggers.PollingTriggerInterface; |
| 23 | +import io.kestra.core.models.triggers.TriggerContext; |
| 24 | +import io.kestra.core.models.triggers.TriggerOutput; |
| 25 | +import io.kestra.core.models.triggers.TriggerService; |
| 26 | +import io.kestra.core.runners.RunContext; |
| 27 | +import io.kestra.core.storages.kv.KVStore; |
| 28 | +import io.kestra.core.storages.kv.KVValueAndMetadata; |
| 29 | +import io.kestra.plugin.scripts.exec.TriggerRunContext; |
| 30 | +import io.kestra.plugin.scripts.exec.scripts.models.ScriptOutput; |
| 31 | + |
| 32 | +import io.swagger.v3.oas.annotations.media.Schema; |
| 33 | +import jakarta.validation.constraints.NotNull; |
| 34 | +import lombok.AllArgsConstructor; |
| 35 | +import lombok.Builder; |
| 36 | +import lombok.Data; |
| 37 | +import lombok.EqualsAndHashCode; |
| 38 | +import lombok.Getter; |
| 39 | +import lombok.NoArgsConstructor; |
| 40 | +import lombok.ToString; |
| 41 | +import lombok.experimental.SuperBuilder; |
| 42 | +import io.kestra.core.models.annotations.PluginProperty; |
| 43 | + |
| 44 | +@SuperBuilder |
| 45 | +@ToString |
| 46 | +@EqualsAndHashCode |
| 47 | +@Getter |
| 48 | +@NoArgsConstructor |
| 49 | +@Schema( |
| 50 | + title = "Trigger a flow when Perl commands match a condition", |
| 51 | + description = "Polls and triggers a flow by running Perl commands within a script container." |
| 52 | +) |
| 53 | +@Plugin( |
| 54 | + examples = { |
| 55 | + @Example( |
| 56 | + title = "Trigger when the command explicitly exits with code 1.", |
| 57 | + full = true, |
| 58 | + code = """ |
| 59 | + id: commands_trigger |
| 60 | + namespace: company.team |
| 61 | +
|
| 62 | + triggers: |
| 63 | + - id: commands_failure |
| 64 | + type: io.kestra.plugin.scripts.perl.CommandsTrigger |
| 65 | + interval: PT10S |
| 66 | + exitCondition: "exit 1" |
| 67 | + edge: true |
| 68 | + containerImage: perl |
| 69 | + commands: |
| 70 | + - perl -e 'exit 1' |
| 71 | +
|
| 72 | + tasks: |
| 73 | + - id: log |
| 74 | + type: io.kestra.plugin.core.log.Log |
| 75 | + message: "Triggered with exitCode={{ trigger.exitCode }} (condition={{ trigger.condition }})" |
| 76 | + """ |
| 77 | + ) |
| 78 | + } |
| 79 | +) |
| 80 | +// TODO: extract shared trigger logic (evaluate, matchesCondition, extractFailure, Output) |
| 81 | +// into an AbstractScriptTrigger in plugin-script to reduce duplication across Shell, Node, Ruby, etc. |
| 82 | +public class CommandsTrigger extends AbstractTrigger |
| 83 | + implements PollingTriggerInterface, TriggerOutput<CommandsTrigger.Output> { |
| 84 | + |
| 85 | + private static final String DEFAULT_IMAGE = "perl"; |
| 86 | + private static final Pattern EXIT_CONDITION_PATTERN = Pattern.compile("^\\s*exit\\s+(\\d+)\\s*$", Pattern.CASE_INSENSITIVE); |
| 87 | + |
| 88 | + @Schema( |
| 89 | + title = "Docker image used to execute the commands", |
| 90 | + description = """ |
| 91 | + Container image used by the underlying Commands task to run Perl commands. |
| 92 | + Defaults to 'perl'. |
| 93 | + """ |
| 94 | + ) |
| 95 | + @Builder.Default |
| 96 | + @PluginProperty(group = "execution") |
| 97 | + protected Property<String> containerImage = Property.ofValue(DEFAULT_IMAGE); |
| 98 | + |
| 99 | + @Schema( |
| 100 | + title = "Perl commands to execute", |
| 101 | + description = "Commands executed on each poll (same semantics as the Perl Commands task)." |
| 102 | + ) |
| 103 | + @NotNull |
| 104 | + @PluginProperty(group = "main") |
| 105 | + protected Property<List<String>> commands; |
| 106 | + |
| 107 | + @Schema( |
| 108 | + title = "Condition to match", |
| 109 | + description = """ |
| 110 | + Condition evaluated after each commands execution. The trigger emits an event only when this condition matches. |
| 111 | +
|
| 112 | + Supported forms: |
| 113 | + - 'exit N' (example: 'exit 1'): matches when the process exit code equals N. |
| 114 | + - Any other string: treated as a regex (or substring if regex is invalid) matched against: |
| 115 | + - the task 'vars' (when commands emit ::{"outputs":...}::), |
| 116 | + - and error logs when the task fails (TaskException). |
| 117 | + """ |
| 118 | + ) |
| 119 | + @NotNull |
| 120 | + @PluginProperty(group = "main") |
| 121 | + protected Property<String> exitCondition; |
| 122 | + |
| 123 | + @Schema( |
| 124 | + title = "Check interval", |
| 125 | + description = "Interval between polling evaluations." |
| 126 | + ) |
| 127 | + @Builder.Default |
| 128 | + @PluginProperty(group = "execution") |
| 129 | + private final Duration interval = Duration.ofSeconds(60); |
| 130 | + |
| 131 | + @Schema( |
| 132 | + title = "Edge trigger mode", |
| 133 | + description = """ |
| 134 | + If true, the trigger emits only on a transition from 'not matching' to 'matching' (anti-spam). |
| 135 | + The previous result is kept in the namespace KV store, keyed by flow and trigger id. |
| 136 | + If false, the trigger emits on every poll where the condition matches. |
| 137 | + """ |
| 138 | + ) |
| 139 | + @Builder.Default |
| 140 | + @PluginProperty(group = "advanced") |
| 141 | + protected Property<Boolean> edge = Property.ofValue(true); |
| 142 | + |
| 143 | + @Override |
| 144 | + public Optional<Execution> evaluate(ConditionContext conditionContext, TriggerContext context) throws Exception { |
| 145 | + RunContext runContext = conditionContext.getRunContext(); |
| 146 | + boolean renderedEdge = runContext.render(this.edge).as(Boolean.class).orElse(true); |
| 147 | + |
| 148 | + Output out; |
| 149 | + try { |
| 150 | + out = runOnce(runContext); |
| 151 | + } catch (Exception e) { |
| 152 | + runContext.logger().warn("Trigger evaluation failed, returning empty result to avoid blocking the scheduler", e); |
| 153 | + return Optional.empty(); |
| 154 | + } |
| 155 | + |
| 156 | + boolean matched = matchesCondition(out); |
| 157 | + |
| 158 | + boolean emit = shouldEmit(runContext, context, renderedEdge, matched); |
| 159 | + |
| 160 | + if (!emit) { |
| 161 | + return Optional.empty(); |
| 162 | + } |
| 163 | + |
| 164 | + return Optional.of(TriggerService.generateExecution(this, conditionContext, context, out)); |
| 165 | + } |
| 166 | + |
| 167 | + boolean shouldEmit(RunContext runContext, TriggerContext context, boolean edge, boolean matched) throws Exception { |
| 168 | + if (!edge) { |
| 169 | + return matched; |
| 170 | + } |
| 171 | + |
| 172 | + // A polling trigger is rebuilt from the flow definition (and serialized to a worker) on |
| 173 | + // every poll, so the previous result cannot live in a field. It is kept in the namespace |
| 174 | + // KV store instead and advanced on every poll. |
| 175 | + KVStore kvStore = runContext.namespaceKv(context.getNamespace()); |
| 176 | + String key = edgeStateKey(context); |
| 177 | + |
| 178 | + boolean previouslyMatched = kvStore.getValue(key) |
| 179 | + .map(value -> Boolean.parseBoolean(String.valueOf(value.value()))) |
| 180 | + .orElse(false); |
| 181 | + kvStore.put(key, new KVValueAndMetadata(null, matched)); |
| 182 | + |
| 183 | + return matched && !previouslyMatched; |
| 184 | + } |
| 185 | + |
| 186 | + // Length prefixed so that the pairs ("a-b", "c") and ("a", "b-c") can never share a key. |
| 187 | + // Flow and trigger ids only use characters that are valid in a KV key. |
| 188 | + static String edgeStateKey(TriggerContext context) { |
| 189 | + return "trigger-edge-" + context.getFlowId().length() + "-" + context.getFlowId() + "-" + context.getTriggerId(); |
| 190 | + } |
| 191 | + |
| 192 | + private Output runOnce(RunContext runContext) throws Exception { |
| 193 | + Commands task = Commands.builder() |
| 194 | + .id(this.getId()) |
| 195 | + .type(Commands.class.getName()) |
| 196 | + .containerImage(this.containerImage) |
| 197 | + .commands(this.commands) |
| 198 | + .build(); |
| 199 | + |
| 200 | + String renderedCondition = runContext.render(this.exitCondition).as(String.class).orElse(""); |
| 201 | + |
| 202 | + try { |
| 203 | + ScriptOutput taskOutput = task.run(TriggerRunContext.forEmbeddedTask(runContext, task)); |
| 204 | + Integer exitCode = safeExitCode(taskOutput); |
| 205 | + Map<String, Object> vars = safeVars(taskOutput); |
| 206 | + |
| 207 | + return new Output(Instant.now(), renderedCondition, exitCode, vars); |
| 208 | + } catch (RunnableTaskException e) { |
| 209 | + ExtractedFailure failure = extractFailure(e); |
| 210 | + return new Output(Instant.now(), renderedCondition, failure.exitCode, null); |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + boolean matchesCondition(Output out) { |
| 215 | + String cond = out.getCondition() == null ? "" : out.getCondition().trim(); |
| 216 | + |
| 217 | + Matcher exitMatcher = EXIT_CONDITION_PATTERN.matcher(cond); |
| 218 | + if (exitMatcher.matches()) { |
| 219 | + int expected = Integer.parseInt(exitMatcher.group(1)); |
| 220 | + return out.getExitCode() != null && out.getExitCode() == expected; |
| 221 | + } |
| 222 | + |
| 223 | + String haystack = buildHaystack(out); |
| 224 | + if (haystack.isEmpty() || cond.isEmpty()) { |
| 225 | + return false; |
| 226 | + } |
| 227 | + |
| 228 | + try { |
| 229 | + // Guard against catastrophic backtracking (ReDoS) from user-supplied patterns |
| 230 | + var pattern = Pattern.compile(cond); |
| 231 | + var future = CompletableFuture.supplyAsync( |
| 232 | + () -> pattern.matcher(haystack).find() |
| 233 | + ); |
| 234 | + return future.get(5, TimeUnit.SECONDS); |
| 235 | + } catch (TimeoutException te) { |
| 236 | + return haystack.contains(cond); |
| 237 | + } catch (Exception e) { |
| 238 | + return haystack.contains(cond); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + private String buildHaystack(Output out) { |
| 243 | + if (out.getVars() == null || out.getVars().isEmpty()) { |
| 244 | + return ""; |
| 245 | + } |
| 246 | + // Map.toString() produces {key=value, ...} — intentional for substring/regex matching. |
| 247 | + return out.getVars().toString(); |
| 248 | + } |
| 249 | + |
| 250 | + private Integer safeExitCode(ScriptOutput taskOutput) { |
| 251 | + try { |
| 252 | + return taskOutput.getExitCode(); |
| 253 | + } catch (Exception ignored) { |
| 254 | + return null; |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + private Map<String, Object> safeVars(ScriptOutput taskOutput) { |
| 259 | + try { |
| 260 | + return taskOutput.getVars(); |
| 261 | + } catch (Exception ignored) { |
| 262 | + return null; |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + private record ExtractedFailure(Integer exitCode) { |
| 267 | + } |
| 268 | + |
| 269 | + private ExtractedFailure extractFailure(RunnableTaskException e) { |
| 270 | + Integer exitCode = null; |
| 271 | + |
| 272 | + Throwable cur = e.getCause(); |
| 273 | + while (cur != null) { |
| 274 | + if (cur instanceof TaskException te) { |
| 275 | + exitCode = te.getExitCode(); |
| 276 | + break; |
| 277 | + } |
| 278 | + cur = cur.getCause(); |
| 279 | + } |
| 280 | + |
| 281 | + return new ExtractedFailure(exitCode); |
| 282 | + } |
| 283 | + |
| 284 | + @Data |
| 285 | + @AllArgsConstructor |
| 286 | + public static class Output implements io.kestra.core.models.tasks.Output { |
| 287 | + @Schema(title = "Timestamp of the event that fired the trigger") |
| 288 | + private Instant timestamp; |
| 289 | + |
| 290 | + @Schema( |
| 291 | + title = "Rendered condition", |
| 292 | + description = "Rendered value of the exitCondition property for this poll." |
| 293 | + ) |
| 294 | + private String condition; |
| 295 | + |
| 296 | + @Schema( |
| 297 | + title = "Commands exit code", |
| 298 | + description = "Exit code returned by the Perl process (may be null if not available)." |
| 299 | + ) |
| 300 | + private Integer exitCode; |
| 301 | + |
| 302 | + @Schema( |
| 303 | + title = "Commands vars", |
| 304 | + description = "Vars produced by the task (e.g. via ::{\"outputs\":{...}}:: convention)." |
| 305 | + ) |
| 306 | + private Map<String, Object> vars; |
| 307 | + } |
| 308 | +} |
0 commit comments