1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12use tokio::process::Command;
13
14use super::template::{CommandTemplate, SubstitutionError, TemplateError};
15use crate::activity::ActivityFailure;
16use crate::command_transcript::CommandTranscript;
17use crate::context::ActivityContext;
18use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
22pub struct ShellOutcome {
23 pub exit_code: i32,
25 pub stdout: String,
28 pub stderr: String,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum FailureMode {
37 #[default]
43 Retryable,
44 Terminal,
48}
49
50pub(super) const INHERITED_VARIABLE: &str = "PATH";
64
65#[derive(Debug, Clone)]
67pub struct ShellAction {
68 template: CommandTemplate,
69 failure_mode: FailureMode,
70 environment: BTreeMap<String, String>,
71 working_directory: Option<std::path::PathBuf>,
72}
73
74impl ShellAction {
75 pub fn new(command: &str) -> Result<Self, TemplateError> {
83 Ok(Self {
84 template: CommandTemplate::parse(command)?,
85 failure_mode: FailureMode::default(),
86 environment: BTreeMap::new(),
87 working_directory: None,
88 })
89 }
90
91 #[must_use]
93 pub const fn with_failure_mode(mut self, failure_mode: FailureMode) -> Self {
94 self.failure_mode = failure_mode;
95 self
96 }
97
98 #[must_use]
104 pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
105 self.environment = environment;
106 self
107 }
108
109 #[must_use]
118 pub fn with_working_directory(mut self, directory: impl Into<std::path::PathBuf>) -> Self {
119 self.working_directory = Some(directory.into());
120 self
121 }
122
123 #[must_use]
128 pub fn referenced_parameters(&self) -> Vec<String> {
129 self.template.referenced_parameters()
130 }
131
132 pub async fn run(
152 &self,
153 arguments: &BTreeMap<String, serde_json::Value>,
154 context: &ActivityContext,
155 ) -> Result<ShellOutcome, ActivityFailure> {
156 let argv = self
157 .template
158 .render(arguments)
159 .map_err(|error| substitution_failure(&error))?;
160 let (program, rest) = argv
161 .split_first()
162 .ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;
166
167 let mut command = Command::new(program);
168 command.args(rest);
169 command.stdin(std::process::Stdio::null());
175 command.env_clear();
179 if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
180 command.env(INHERITED_VARIABLE, path);
181 }
182 for (name, value) in &self.environment {
183 command.env(name, value);
184 }
185 if let Some(directory) = &self.working_directory {
186 command.current_dir(directory);
187 }
188
189 let transcript = CommandTranscript::new(context);
190 match run_cancellable_command(command, context.cancelled(), &transcript).await {
191 Ok(CancellableCommandOutput::Completed(output)) => {
192 let outcome = ShellOutcome {
193 exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
194 stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
195 stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
196 };
197 if output.status.success() {
198 Ok(outcome)
199 } else {
200 Err(self.exit_failure(program, &outcome))
201 }
202 }
203 Ok(CancellableCommandOutput::Cancelled) => Err(ActivityFailure::terminal(format!(
204 "the declared command `{program}` was cancelled and its process group was terminated"
205 ))),
206 Err(error) => Err(spawn_failure(program, &error)),
207 }
208 }
209
210 fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
212 let message = if outcome.stderr.is_empty() {
216 format!(
217 "the declared command `{program}` exited {} with no standard error output",
218 outcome.exit_code
219 )
220 } else {
221 format!(
222 "the declared command `{program}` exited {}: {}",
223 outcome.exit_code, outcome.stderr
224 )
225 };
226 match self.failure_mode {
227 FailureMode::Retryable => ActivityFailure::retryable(message),
228 FailureMode::Terminal => ActivityFailure::terminal(message),
229 }
230 }
231}
232
233const EXIT_CODE_SIGNALLED: i32 = 137;
238
239fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
242 ActivityFailure::terminal(error.to_string())
243}
244
245fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
247 ActivityFailure::terminal(format!(
252 "the declared command `{program}` could not be run to completion: {error}"
253 ))
254}
255
256pub(super) fn trim_trailing_newline(text: &str) -> String {
263 text.strip_suffix('\n')
264 .map_or(text, |trimmed| {
265 trimmed.strip_suffix('\r').unwrap_or(trimmed)
266 })
267 .to_owned()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{FailureMode, ShellAction, trim_trailing_newline};
273 use crate::activity::Classification;
274 use crate::context::ActivityContext;
275 use aion_core::{ActivityId, RunId, WorkflowId};
276 use std::collections::BTreeMap;
277
278 fn context() -> (ActivityContext, crate::context::ActivityCancellationHandle) {
279 ActivityContext::new(
280 WorkflowId::new_v4(),
281 RunId::new_v4(),
282 ActivityId::from_sequence_position(1),
283 1,
284 )
285 }
286
287 fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
288 pairs
289 .iter()
290 .map(|(name, value)| ((*name).to_owned(), value.clone()))
291 .collect()
292 }
293
294 type TestResult = Result<(), Box<dyn std::error::Error>>;
298
299 #[tokio::test]
300 async fn a_succeeding_command_returns_its_output() -> TestResult {
301 let action = ShellAction::new("echo hello")?;
302 let (context, _handle) = context();
303 let outcome = action.run(&BTreeMap::new(), &context).await?;
304 assert_eq!(outcome.exit_code, 0);
305 assert_eq!(outcome.stdout, "hello");
306 assert_eq!(outcome.stderr, "");
307 Ok(())
308 }
309
310 #[tokio::test]
311 async fn a_parameter_value_reaches_the_program_as_one_argument() -> TestResult {
312 let action = ShellAction::new("echo {{greeting}}")?;
313 let (context, _handle) = context();
314 let outcome = action
315 .run(
316 &arguments(&[("greeting", serde_json::json!("hello there world"))]),
317 &context,
318 )
319 .await?;
320 assert_eq!(outcome.stdout, "hello there world");
321 Ok(())
322 }
323
324 #[tokio::test]
325 async fn a_hostile_value_is_inert_because_no_shell_ever_sees_it() -> TestResult {
326 let action = ShellAction::new("echo {{value}}")?;
329 let (context, _handle) = context();
330 let outcome = action
331 .run(
332 &arguments(&[("value", serde_json::json!("hi; echo PWNED"))]),
333 &context,
334 )
335 .await?;
336 assert_eq!(outcome.stdout, "hi; echo PWNED");
337 assert!(
338 !outcome.stdout.contains("PWNED\n"),
339 "the injected command must never have run"
340 );
341 Ok(())
342 }
343
344 #[tokio::test]
345 async fn a_nonzero_exit_is_retryable_by_default_and_carries_stderr() -> TestResult {
346 let action = ShellAction::new("sh -c 'echo trouble >&2; exit 3'")?;
347 let (context, _handle) = context();
348 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
349 return Err("a non-zero exit must fail the activity".into());
350 };
351 assert_eq!(failure.classification(), &Classification::Retryable);
352 assert!(
353 failure.message().contains("trouble"),
354 "the failure must carry the command's own words: {}",
355 failure.message()
356 );
357 assert!(failure.message().contains('3'), "the exit code is reported");
358 Ok(())
359 }
360
361 #[tokio::test]
362 async fn a_nonzero_exit_is_terminal_when_the_action_says_so() -> TestResult {
363 let action = ShellAction::new("sh -c 'exit 1'")?.with_failure_mode(FailureMode::Terminal);
364 let (context, _handle) = context();
365 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
366 return Err("a non-zero exit must fail the activity".into());
367 };
368 assert_eq!(failure.classification(), &Classification::Terminal);
369 Ok(())
370 }
371
372 #[tokio::test]
373 async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
374 let action = ShellAction::new("echo {{absent}}")?;
375 let (context, _handle) = context();
376 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
377 return Err("a missing parameter must fail the activity".into());
378 };
379 assert_eq!(failure.classification(), &Classification::Terminal);
380 assert!(failure.message().contains("absent"));
381 Ok(())
382 }
383
384 #[tokio::test]
385 async fn an_absent_program_fails_terminally() -> TestResult {
386 let action = ShellAction::new("aion-no-such-program-exists-anywhere")?;
387 let (context, _handle) = context();
388 let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
389 return Err("an absent program must fail the activity".into());
390 };
391 assert_eq!(failure.classification(), &Classification::Terminal);
392 Ok(())
393 }
394
395 #[tokio::test]
396 async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
397 let action = ShellAction::new("sleep 30")?;
398 let (context, handle) = context();
399 let run = tokio::spawn(async move {
400 let (context, _keep) = (context, ());
401 action.run(&BTreeMap::new(), &context).await
402 });
403 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
406 handle.cancel();
407 let Err(failure) = run.await? else {
408 return Err("a cancelled command must fail the activity".into());
409 };
410 assert_eq!(failure.classification(), &Classification::Terminal);
411 assert!(failure.message().contains("cancelled"));
412 Ok(())
413 }
414
415 #[tokio::test]
416 async fn the_hosts_environment_does_not_cross_into_a_declared_command() -> TestResult {
417 let Some(present) = std::env::vars_os()
427 .filter_map(|(name, _)| name.into_string().ok())
428 .find(|name| name != "PATH" && !name.is_empty() && !name.contains('='))
429 else {
430 tracing::info!(
431 "skipping: the host has no environment variable besides PATH to prove \
432 non-inheritance with"
433 );
434 return Ok(());
435 };
436 let action = ShellAction::new(&format!("sh -c 'echo \"[${{{present}:-absent}}]\"'"))?;
437 let (context, _handle) = context();
438 let outcome = action.run(&BTreeMap::new(), &context).await?;
439 assert_eq!(
440 outcome.stdout, "[absent]",
441 "the host's `{present}` leaked into a declared command"
442 );
443 Ok(())
444 }
445
446 #[tokio::test]
447 async fn an_operator_supplied_variable_does_reach_the_command() -> TestResult {
448 let mut environment = BTreeMap::new();
450 environment.insert("DECLARED_GREETING".to_owned(), "supplied".to_owned());
451 let action = ShellAction::new("sh -c 'echo \"[$DECLARED_GREETING]\"'")?
452 .with_environment(environment);
453 let (context, _handle) = context();
454 let outcome = action.run(&BTreeMap::new(), &context).await?;
455 assert_eq!(outcome.stdout, "[supplied]");
456 Ok(())
457 }
458
459 #[tokio::test]
460 async fn a_command_runs_in_its_declared_working_directory() -> TestResult {
461 let action = ShellAction::new("pwd")?.with_working_directory("/");
462 let (context, _handle) = context();
463 let outcome = action.run(&BTreeMap::new(), &context).await?;
464 assert_eq!(outcome.stdout, "/");
465 Ok(())
466 }
467
468 #[tokio::test]
469 async fn a_command_reading_stdin_gets_end_of_file_rather_than_the_hosts() -> TestResult {
470 let action = ShellAction::new("cat")?;
473 let (context, _handle) = context();
474 let outcome = action.run(&BTreeMap::new(), &context).await?;
475 assert_eq!(outcome.exit_code, 0);
476 assert_eq!(outcome.stdout, "");
477 Ok(())
478 }
479
480 #[tokio::test]
481 async fn awkward_values_survive_as_exactly_one_argument_each() -> TestResult {
482 for (value, expected) in [
486 ("two\nlines", "[two\nlines]"),
487 ("", "[]"),
488 (" leading and trailing ", "[ leading and trailing ]"),
489 ("tab\there", "[tab\there]"),
490 ("quote\"inside", "[quote\"inside]"),
491 ("single'inside", "[single'inside]"),
492 ("back\\slash", "[back\\slash]"),
493 ] {
494 let action = ShellAction::new("printf [%s] {{value}}")?;
495 let (context, _handle) = context();
496 let outcome = action
497 .run(&arguments(&[("value", serde_json::json!(value))]), &context)
498 .await?;
499 assert_eq!(
500 outcome.stdout, expected,
501 "value {value:?} did not arrive as exactly one argument"
502 );
503 }
504 Ok(())
505 }
506
507 #[tokio::test]
508 async fn a_value_that_looks_like_a_flag_is_still_passed_as_a_value() -> TestResult {
509 let exposed = ShellAction::new("printf %s {{value}}")?;
515 let (exposed_context, _exposed_handle) = context();
516 let outcome = exposed
517 .run(
518 &arguments(&[("value", serde_json::json!("-n"))]),
519 &exposed_context,
520 )
521 .await;
522 assert!(
525 outcome.is_ok(),
526 "the executor must deliver the value and let the program parse it"
527 );
528
529 let guarded = ShellAction::new("printf -- [%s] {{value}}")?;
532 let (guarded_context, _guarded_handle) = context();
533 let guarded_outcome = guarded
534 .run(
535 &arguments(&[("value", serde_json::json!("-n"))]),
536 &guarded_context,
537 )
538 .await?;
539 assert_eq!(guarded_outcome.stdout, "[-n]");
540 Ok(())
541 }
542
543 #[tokio::test]
553 async fn a_declared_command_streams_both_streams_before_it_exits() -> TestResult {
554 use aion_core::{RunId, WorkflowId};
555
556 let (sender, mut events) = tokio::sync::mpsc::unbounded_channel();
557 let (context, cancellation) = ActivityContext::with_transcript(
558 WorkflowId::new_v4(),
559 RunId::new_v4(),
560 ActivityId::from_sequence_position(9),
561 3,
562 sender,
563 );
564 let action = ShellAction::new("sh -c 'echo working; echo warning >&2; sleep 30'")?;
565 let no_arguments = BTreeMap::new();
566 let run = action.run(&no_arguments, &context);
567 tokio::pin!(run);
568
569 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
570 let mut observed: Vec<(String, String)> = Vec::new();
571 while !observed.iter().any(|(role, _)| role.contains("stdout"))
572 || !observed.iter().any(|(role, _)| role.contains("stderr"))
573 {
574 tokio::select! {
575 biased;
576 outcome = &mut run => {
577 drop(outcome);
578 return Err(format!(
579 "the command completed before its output reached the transcript: \
580 {observed:?}"
581 )
582 .into());
583 }
584 event = events.recv() => {
585 let event = event.ok_or("the transcript seam closed mid-command")?;
586 assert_eq!(event.attempt, 3, "the event carries the attempt it belongs to");
587 assert_eq!(
588 event.activity_id,
589 ActivityId::from_sequence_position(9),
590 "the event is keyed to the activity that is running"
591 );
592 let aion_core::ActivityEventKind::Message { text, .. } = event.kind else {
593 return Err("an output line must be a Message".into());
594 };
595 observed.push((event.agent_role, text));
596 }
597 () = tokio::time::sleep_until(deadline) => {
598 return Err(format!(
599 "the running command's output never reached the transcript: {observed:?}"
600 )
601 .into());
602 }
603 }
604 }
605
606 assert!(
607 observed.contains(&("command stdout".to_owned(), "working".to_owned())),
608 "stdout must arrive labelled by its stream: {observed:?}"
609 );
610 assert!(
611 observed.contains(&("command stderr".to_owned(), "warning".to_owned())),
612 "stderr must arrive labelled by its stream: {observed:?}"
613 );
614
615 cancellation.cancel();
616 let Err(failure) = run.await else {
617 return Err("a cancelled command must fail the activity".into());
618 };
619 assert_eq!(failure.classification(), &Classification::Terminal);
620 Ok(())
621 }
622
623 #[test]
624 fn only_one_trailing_newline_is_trimmed() {
625 assert_eq!(trim_trailing_newline("hello\n"), "hello");
626 assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
627 assert_eq!(trim_trailing_newline("hello\n\n"), "hello\n");
628 assert_eq!(trim_trailing_newline("hello"), "hello");
629 assert_eq!(trim_trailing_newline(""), "");
630 }
631}