ironflow_engine/config/shell.rs
1//! [`ShellConfig`] — serializable configuration for a shell step.
2
3use ironflow_core::retry::RetryPolicy;
4use serde::{Deserialize, Serialize};
5
6use super::artifact::{ArtifactInput, ArtifactOutput, ArtifactRef};
7
8/// Serializable configuration for a shell step.
9///
10/// # Examples
11///
12/// ```
13/// use ironflow_engine::config::ShellConfig;
14///
15/// let config = ShellConfig::new("cargo build --release")
16/// .timeout_secs(300)
17/// .dir("/app")
18/// .output("target/report.html");
19/// ```
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ShellConfig {
22 /// The command line passed to `sh -c`, or the program to run when
23 /// [`args`](Self::args) is set.
24 pub command: String,
25 /// Arguments of [`command`](Self::command) when it runs without a shell.
26 ///
27 /// `Some` means exec mode: `command` is spawned directly with these
28 /// arguments, none of them is interpreted. `None` means `command` goes
29 /// through `sh -c`. Set by [`ShellConfig::exec`].
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub args: Option<Vec<String>>,
32 /// Timeout in seconds (default: 300).
33 pub timeout_secs: Option<u64>,
34 /// Working directory.
35 pub dir: Option<String>,
36 /// Environment variables to set.
37 pub env: Vec<(String, String)>,
38 /// If true, start with a clean environment.
39 pub clean_env: bool,
40 /// Files the step promises to produce, collected once it finishes.
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub outputs: Vec<ArtifactOutput>,
43 /// Artifacts of earlier steps to place in the working directory first.
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub inputs: Vec<ArtifactInput>,
46 /// When `true`, a failure of this step does not fail the run. The step is
47 /// still marked `Failed` but execution continues and the run finishes with
48 /// `RunStatus::Warning` instead of `Failed`.
49 #[serde(default)]
50 pub allow_failure: bool,
51 /// When `true`, a non-zero exit code is a normal output instead of an
52 /// error: the step is `Completed` and its output carries the real code.
53 /// Timeout, spawn and input-preparation failures stay errors.
54 #[serde(default)]
55 pub exit_code_as_output: bool,
56 /// Optional step-level retry policy. When set, a transient failure retries
57 /// the step locally with exponential backoff before propagating the error.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub retry: Option<RetryPolicy>,
60}
61
62impl ShellConfig {
63 /// Create a new shell config with the given command.
64 ///
65 /// The command is passed to `sh -c`, so pipes, redirects and globs work.
66 ///
67 /// # Security
68 ///
69 /// Never build the command from data the workflow does not control (its
70 /// input, a webhook payload, a human answer, an agent output): a quote or a
71 /// `;` in that data runs arbitrary commands on the worker. Use
72 /// [`ShellConfig::exec`] to pass such data as arguments, or
73 /// [`env`](Self::env) to hand it to a script as a variable.
74 ///
75 /// # Examples
76 ///
77 /// ```
78 /// use ironflow_engine::config::ShellConfig;
79 ///
80 /// let config = ShellConfig::new("echo hello");
81 /// assert_eq!(config.command, "echo hello");
82 /// ```
83 pub fn new(command: &str) -> Self {
84 Self {
85 command: command.to_string(),
86 args: None,
87 timeout_secs: None,
88 dir: None,
89 env: Vec::new(),
90 clean_env: false,
91 outputs: Vec::new(),
92 inputs: Vec::new(),
93 allow_failure: false,
94 exit_code_as_output: false,
95 retry: None,
96 }
97 }
98
99 /// Create a config that runs `program` directly, without a shell.
100 ///
101 /// Each argument reaches the program as is: quotes, `;`, `$(..)`,
102 /// backticks and globs are plain text. This is the way to run a command
103 /// built from untrusted data. The program is looked up in `PATH`.
104 ///
105 /// # Examples
106 ///
107 /// ```
108 /// use ironflow_engine::config::ShellConfig;
109 ///
110 /// let name = "Ada'; rm -rf / #";
111 /// let config = ShellConfig::exec("printf", &["Hello, %s!\n", name]);
112 /// assert_eq!(config.command, "printf");
113 /// assert_eq!(config.args.as_deref().map(<[String]>::len), Some(2));
114 /// ```
115 pub fn exec(program: &str, args: &[&str]) -> Self {
116 Self {
117 args: Some(args.iter().map(|arg| (*arg).to_string()).collect()),
118 ..Self::new(program)
119 }
120 }
121
122 /// Set the timeout in seconds.
123 pub fn timeout_secs(mut self, secs: u64) -> Self {
124 self.timeout_secs = Some(secs);
125 self
126 }
127
128 /// Set the working directory.
129 pub fn dir(mut self, dir: &str) -> Self {
130 self.dir = Some(dir.to_string());
131 self
132 }
133
134 /// Add an environment variable.
135 pub fn env(mut self, key: &str, value: &str) -> Self {
136 self.env.push((key.to_string(), value.to_string()));
137 self
138 }
139
140 /// Start with a clean environment (no inherited vars).
141 pub fn clean_env(mut self) -> Self {
142 self.clean_env = true;
143 self
144 }
145
146 /// Declare a file the step produces, typed from its name.
147 ///
148 /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
149 /// stored as an artifact named after the file. When the step succeeds and
150 /// the pattern matches nothing, the step fails.
151 ///
152 /// # Examples
153 ///
154 /// ```
155 /// use ironflow_engine::config::ShellConfig;
156 ///
157 /// let config = ShellConfig::new("cargo build").output("target/*.log");
158 /// assert_eq!(config.outputs.len(), 1);
159 /// ```
160 pub fn output(mut self, pattern: &str) -> Self {
161 self.outputs.push(ArtifactOutput::new(pattern));
162 self
163 }
164
165 /// Declare a produced file with an explicit MIME type.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// use ironflow_engine::config::ShellConfig;
171 ///
172 /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
173 /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
174 /// ```
175 pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
176 self.outputs
177 .push(ArtifactOutput::typed(pattern, content_type));
178 self
179 }
180
181 /// Consume an artifact produced by an earlier step of the same run.
182 ///
183 /// The handle comes from the producing step, see [`ArtifactRef`]. The
184 /// artifact is written into the working directory under its own name
185 /// before the command runs. Use [`input_at`](Self::input_at) to choose
186 /// another path.
187 ///
188 /// # Examples
189 ///
190 /// ```no_run
191 /// use ironflow_engine::config::ShellConfig;
192 /// use ironflow_engine::context::WorkflowContext;
193 /// use ironflow_engine::error::EngineError;
194 ///
195 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
196 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
197 /// let report = build.artifact("report.html")?;
198 /// let config = ShellConfig::new("./publish").input(&report);
199 /// assert_eq!(config.inputs[0].destination(), "report.html");
200 /// # Ok(())
201 /// # }
202 /// ```
203 pub fn input(mut self, artifact: &ArtifactRef) -> Self {
204 self.inputs.push(ArtifactInput::from(artifact));
205 self
206 }
207
208 /// Mark this step as allowed to fail without stopping the run.
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use ironflow_engine::config::ShellConfig;
214 ///
215 /// let config = ShellConfig::new("cargo clippy").allow_failure();
216 /// assert!(config.allow_failure);
217 /// ```
218 pub fn allow_failure(mut self) -> Self {
219 self.allow_failure = true;
220 self
221 }
222
223 /// Treat a non-zero exit code as data instead of a failure.
224 ///
225 /// The step is `Completed`: `StepOutput::is_success()` is `false` and
226 /// `exit_code()` returns the real code, so the handler can branch on it
227 /// (a conflicting `git merge`, red tests). The run is not degraded and
228 /// [`allow_failure`](Self::allow_failure) is not triggered. A non-zero
229 /// exit is no longer an error, so a retry policy does not retry it.
230 /// Timeout, spawn failure and input-preparation failure remain errors and
231 /// follow `allow_failure`.
232 ///
233 /// # Examples
234 ///
235 /// ```
236 /// use ironflow_engine::config::ShellConfig;
237 ///
238 /// let config = ShellConfig::new("git merge feature").exit_code_as_output();
239 /// assert!(config.exit_code_as_output);
240 /// ```
241 pub fn exit_code_as_output(mut self) -> Self {
242 self.exit_code_as_output = true;
243 self
244 }
245
246 /// Set a step-level retry policy.
247 ///
248 /// # Examples
249 ///
250 /// ```
251 /// use ironflow_core::retry::RetryPolicy;
252 /// use ironflow_engine::config::ShellConfig;
253 ///
254 /// let config = ShellConfig::new("curl http://api")
255 /// .retry_policy(RetryPolicy::new(3));
256 /// assert!(config.retry.is_some());
257 /// ```
258 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
259 self.retry = Some(policy);
260 self
261 }
262
263 /// Consume an artifact and write it to an explicit path.
264 ///
265 /// # Examples
266 ///
267 /// ```no_run
268 /// use ironflow_engine::config::ShellConfig;
269 /// use ironflow_engine::context::WorkflowContext;
270 /// use ironflow_engine::error::EngineError;
271 ///
272 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
273 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
274 /// let report = build.artifact("report.html")?;
275 /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
276 /// assert_eq!(config.inputs[0].destination(), "in/r.html");
277 /// # Ok(())
278 /// # }
279 /// ```
280 pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
281 self.inputs.push(ArtifactInput::from(artifact).at(dest));
282 self
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn builder() {
292 let config = ShellConfig::new("cargo test")
293 .timeout_secs(60)
294 .dir("/app")
295 .env("RUST_LOG", "debug")
296 .clean_env();
297
298 assert_eq!(config.command, "cargo test");
299 assert_eq!(config.timeout_secs, Some(60));
300 assert_eq!(config.dir, Some("/app".to_string()));
301 assert_eq!(
302 config.env,
303 vec![("RUST_LOG".to_string(), "debug".to_string())]
304 );
305 assert!(config.clean_env);
306 }
307
308 #[test]
309 fn a_fresh_config_declares_no_artifact() {
310 let config = ShellConfig::new("echo hi");
311 assert!(config.outputs.is_empty());
312 assert!(config.inputs.is_empty());
313 }
314
315 #[test]
316 fn outputs_and_inputs_accumulate_in_declaration_order() {
317 let config = ShellConfig::new("build")
318 .output("a.txt")
319 .output_typed("b", "text/csv")
320 .input(&ArtifactRef::new("prev", "c.txt"))
321 .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
322
323 assert_eq!(config.outputs[0].pattern, "a.txt");
324 assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
325 assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
326 assert_eq!(config.inputs[0].destination(), "c.txt");
327 assert_eq!(config.inputs[1].step, "prev");
328 assert_eq!(config.inputs[1].destination(), "in/d.txt");
329 }
330
331 #[test]
332 fn serde_omits_empty_artifact_declarations() {
333 let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
334 assert!(!json.contains("outputs"));
335 assert!(!json.contains("inputs"));
336 }
337
338 #[test]
339 fn a_config_predating_artifacts_still_deserializes() {
340 let config: ShellConfig = serde_json::from_str(
341 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
342 )
343 .expect("deserialize");
344
345 assert!(config.outputs.is_empty());
346 assert!(config.inputs.is_empty());
347 }
348
349 #[test]
350 fn a_config_predating_retry_still_deserializes() {
351 let config: ShellConfig = serde_json::from_str(
352 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
353 )
354 .expect("deserialize");
355
356 assert!(config.retry.is_none());
357 }
358
359 #[test]
360 fn a_config_predating_exit_code_as_output_still_deserializes() {
361 let config: ShellConfig = serde_json::from_str(
362 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
363 )
364 .expect("deserialize");
365
366 assert!(!config.exit_code_as_output);
367 }
368
369 #[test]
370 fn exit_code_as_output_roundtrip() {
371 assert!(!ShellConfig::new("x").exit_code_as_output);
372
373 let config = ShellConfig::new("git merge x").exit_code_as_output();
374 let json = serde_json::to_string(&config).expect("serialize");
375 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
376 assert!(back.exit_code_as_output);
377 }
378
379 #[test]
380 fn exec_keeps_the_program_and_each_argument_apart() {
381 let config = ShellConfig::exec("printf", &["%s\n", "a b; rm -rf /"]);
382
383 assert_eq!(config.command, "printf");
384 assert_eq!(
385 config.args,
386 Some(vec!["%s\n".to_string(), "a b; rm -rf /".to_string()])
387 );
388 }
389
390 #[test]
391 fn exec_with_no_argument_is_still_exec_mode() {
392 let config = ShellConfig::exec("true", &[]);
393 assert_eq!(config.args, Some(Vec::new()));
394 }
395
396 #[test]
397 fn new_runs_through_the_shell() {
398 assert!(ShellConfig::new("echo hi").args.is_none());
399 }
400
401 #[test]
402 fn exec_args_roundtrip_and_are_omitted_in_shell_mode() {
403 let shell = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
404 assert!(!shell.contains("args"));
405
406 let json =
407 serde_json::to_string(&ShellConfig::exec("git", &["log", "-1"])).expect("serialize");
408 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
409 assert_eq!(back.command, "git");
410 assert_eq!(back.args, Some(vec!["log".to_string(), "-1".to_string()]));
411 }
412
413 #[test]
414 fn a_config_predating_exec_still_deserializes_in_shell_mode() {
415 let config: ShellConfig = serde_json::from_str(
416 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
417 )
418 .expect("deserialize");
419
420 assert!(config.args.is_none());
421 }
422
423 #[test]
424 fn retry_policy_roundtrip() {
425 use ironflow_core::retry::RetryPolicy;
426
427 let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
428 let json = serde_json::to_string(&config).expect("serialize");
429 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
430 assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
431 }
432}