ironflow_core/operations/
shell.rs1use std::fmt;
30use std::future::{Future, IntoFuture};
31use std::pin::Pin;
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34use tokio::process::Command;
35use tracing::{debug, error, warn};
36
37use crate::error::OperationError;
38#[cfg(feature = "prometheus")]
39use crate::metric_names;
40use crate::utils::truncate_output;
41
42enum ShellMode {
44 Shell(String),
46 Exec { program: String, args: Vec<String> },
49}
50
51impl fmt::Display for ShellMode {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Self::Shell(cmd) => f.write_str(cmd),
55 Self::Exec { program, args } => {
56 write!(f, "{program}")?;
57 for arg in args {
58 write!(f, " {arg}")?;
59 }
60 Ok(())
61 }
62 }
63 }
64}
65
66const DEFAULT_SHELL_TIMEOUT: Duration = Duration::from_secs(300);
68
69#[must_use = "a Shell command does nothing until .run() or .await is called"]
102pub struct Shell {
103 mode: ShellMode,
104 timeout: Duration,
105 dir: Option<String>,
106 env_vars: Vec<(String, String)>,
107 inherit_env: bool,
108 dry_run: Option<bool>,
109}
110
111impl Shell {
112 pub fn new(command: &str) -> Self {
136 Self {
137 mode: ShellMode::Shell(command.to_string()),
138 timeout: DEFAULT_SHELL_TIMEOUT,
139 dir: None,
140 env_vars: Vec::new(),
141 inherit_env: true,
142 dry_run: None,
143 }
144 }
145
146 pub fn exec(program: &str, args: &[&str]) -> Self {
166 Self {
167 mode: ShellMode::Exec {
168 program: program.to_string(),
169 args: args.iter().map(|a| (*a).to_string()).collect(),
170 },
171 timeout: DEFAULT_SHELL_TIMEOUT,
172 dir: None,
173 env_vars: Vec::new(),
174 inherit_env: true,
175 dry_run: None,
176 }
177 }
178
179 pub fn timeout(mut self, timeout: Duration) -> Self {
184 self.timeout = timeout;
185 self
186 }
187
188 pub fn dir(mut self, dir: &str) -> Self {
190 self.dir = Some(dir.to_string());
191 self
192 }
193
194 pub fn env(mut self, key: &str, value: &str) -> Self {
198 self.env_vars.push((key.to_string(), value.to_string()));
199 self
200 }
201
202 pub fn clean_env(mut self) -> Self {
205 self.inherit_env = false;
206 self
207 }
208
209 pub fn dry_run(mut self, enabled: bool) -> Self {
218 self.dry_run = Some(enabled);
219 self
220 }
221
222 #[tracing::instrument(name = "shell", skip_all, fields(command = %self.mode))]
231 pub async fn run(self) -> Result<ShellOutput, OperationError> {
232 let command_display = self.mode.to_string();
233
234 if crate::dry_run::effective_dry_run(self.dry_run) {
235 debug!(command = %command_display, "[dry-run] shell command skipped");
236 return Ok(ShellOutput {
237 stdout: String::new(),
238 stderr: String::new(),
239 exit_code: 0,
240 duration_ms: 0,
241 });
242 }
243
244 debug!(command = %command_display, "executing shell command");
245
246 let start = Instant::now();
247
248 let mut cmd = match &self.mode {
249 ShellMode::Shell(command) => {
250 let mut c = Command::new("sh");
251 c.arg("-c").arg(command);
252 c
253 }
254 ShellMode::Exec { program, args } => {
255 let mut c = Command::new(program);
256 c.args(args);
257 c
258 }
259 };
260
261 cmd.stdout(Stdio::piped())
262 .stderr(Stdio::piped())
263 .kill_on_drop(true);
264
265 if !self.inherit_env {
266 cmd.env_clear();
267 }
268
269 if let Some(ref dir) = self.dir {
270 cmd.current_dir(dir);
271 }
272
273 for (key, value) in &self.env_vars {
274 cmd.env(key, value);
275 }
276
277 let child = cmd.spawn().map_err(|e| OperationError::Shell {
278 exit_code: -1,
279 stderr: format!("failed to spawn shell: {e}"),
280 })?;
281
282 let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
283 Ok(result) => result.map_err(|e| OperationError::Shell {
284 exit_code: -1,
285 stderr: format!("failed to wait for shell: {e}"),
286 })?,
287 Err(_) => {
288 return Err(OperationError::Timeout {
289 step: command_display,
290 limit: self.timeout,
291 });
292 }
293 };
294
295 let duration_ms = start.elapsed().as_millis() as u64;
296 let stdout = truncate_output(&output.stdout, "shell stdout");
297 let stderr = truncate_output(&output.stderr, "shell stderr");
298
299 let exit_code = output.status.code().unwrap_or_else(|| {
300 #[cfg(unix)]
301 {
302 use std::os::unix::process::ExitStatusExt;
303 if let Some(signal) = output.status.signal() {
304 warn!(signal, "process killed by signal");
305 return -signal;
306 }
307 }
308 -1
309 });
310
311 #[cfg(feature = "prometheus")]
312 metrics::histogram!(metric_names::SHELL_DURATION_SECONDS)
313 .record(duration_ms as f64 / 1000.0);
314
315 if !output.status.success() {
316 error!(exit_code, stderr = %stderr, "shell command failed");
317 #[cfg(feature = "prometheus")]
318 metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_ERROR)
319 .increment(1);
320 return Err(OperationError::Shell { exit_code, stderr });
321 }
322
323 debug!(
324 exit_code,
325 stdout_len = stdout.len(),
326 duration_ms,
327 "shell command completed"
328 );
329
330 #[cfg(feature = "prometheus")]
331 metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_SUCCESS)
332 .increment(1);
333
334 Ok(ShellOutput {
335 stdout,
336 stderr,
337 exit_code,
338 duration_ms,
339 })
340 }
341}
342
343impl IntoFuture for Shell {
344 type Output = Result<ShellOutput, OperationError>;
345 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
346
347 fn into_future(self) -> Self::IntoFuture {
348 Box::pin(self.run())
349 }
350}
351
352#[derive(Debug)]
356pub struct ShellOutput {
357 stdout: String,
358 stderr: String,
359 exit_code: i32,
360 duration_ms: u64,
361}
362
363impl ShellOutput {
364 pub fn stdout(&self) -> &str {
367 &self.stdout
368 }
369
370 pub fn stderr(&self) -> &str {
373 &self.stderr
374 }
375
376 pub fn exit_code(&self) -> i32 {
378 self.exit_code
379 }
380
381 pub fn duration_ms(&self) -> u64 {
383 self.duration_ms
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::dry_run::{DryRunGuard, set_dry_run};
391 use serial_test::serial;
392 use std::time::Duration;
393
394 #[tokio::test]
395 async fn test_shell_new_creates_with_correct_command() {
396 let shell = Shell::new("echo hello");
397 assert_eq!(shell.timeout, DEFAULT_SHELL_TIMEOUT);
398 assert!(shell.inherit_env);
399 assert!(shell.dir.is_none());
400 assert!(shell.env_vars.is_empty());
401 }
402
403 #[tokio::test]
404 async fn test_shell_exec_creates_with_program_and_args() {
405 let shell = Shell::exec("echo", &["hello", "world"]);
406 assert_eq!(shell.timeout, DEFAULT_SHELL_TIMEOUT);
407 assert!(shell.inherit_env);
408 assert!(shell.dir.is_none());
409 assert!(shell.env_vars.is_empty());
410 }
411
412 #[tokio::test]
413 async fn test_timeout_builder_returns_self() {
414 let custom_timeout = Duration::from_secs(10);
415 let shell = Shell::new("echo hello").timeout(custom_timeout);
416 assert_eq!(shell.timeout, custom_timeout);
417 }
418
419 #[tokio::test]
420 async fn test_timeout_is_enforced() {
421 let short_timeout = Duration::from_millis(100);
422 let result = Shell::new("sleep 10")
423 .dry_run(false)
424 .timeout(short_timeout)
425 .await;
426
427 assert!(result.is_err());
428 match result {
429 Err(OperationError::Timeout { step, limit }) => {
430 assert_eq!(limit, short_timeout);
431 assert!(step.contains("sleep"));
432 }
433 _ => panic!("expected Timeout error"),
434 }
435 }
436
437 #[tokio::test]
438 async fn test_dir_builder_returns_self() {
439 let shell = Shell::new("pwd").dir("/tmp");
440 assert_eq!(shell.dir, Some("/tmp".to_string()));
441 }
442
443 #[tokio::test]
444 async fn test_dir_is_respected() {
445 let output = Shell::new("pwd")
446 .dry_run(false)
447 .dir("/tmp")
448 .await
449 .expect("failed to run pwd in /tmp");
450
451 let pwd_output = output.stdout().trim();
453 assert!(pwd_output.ends_with("/tmp") || pwd_output.ends_with("private/tmp"));
454 }
455
456 #[tokio::test]
457 async fn test_env_builder_returns_self() {
458 let shell = Shell::new("echo $TEST_VAR").env("TEST_VAR", "hello");
459 assert_eq!(shell.env_vars.len(), 1);
460 assert_eq!(
461 shell.env_vars[0],
462 ("TEST_VAR".to_string(), "hello".to_string())
463 );
464 }
465
466 #[tokio::test]
467 async fn test_env_is_visible_to_command() {
468 let output = Shell::new("echo $TEST_VAR")
469 .dry_run(false)
470 .env("TEST_VAR", "custom_value")
471 .await
472 .expect("failed to run echo with env var");
473
474 assert_eq!(output.stdout().trim(), "custom_value");
475 }
476
477 #[tokio::test]
478 async fn test_multiple_env_vars() {
479 let output = Shell::new("echo $VAR1:$VAR2")
480 .dry_run(false)
481 .env("VAR1", "foo")
482 .env("VAR2", "bar")
483 .await
484 .expect("failed to run echo with multiple env vars");
485
486 assert_eq!(output.stdout().trim(), "foo:bar");
487 }
488
489 #[tokio::test]
490 async fn test_clean_env_clears_inherited_environment() {
491 let output = Shell::exec("/bin/echo", &["hello"])
493 .dry_run(false)
494 .clean_env()
495 .await
496 .expect("failed to run with clean env");
497
498 assert_eq!(output.stdout().trim(), "hello");
499 }
500
501 #[tokio::test]
502 async fn test_clean_env_with_custom_var_only() {
503 let output = Shell::exec("/bin/sh", &["-c", "echo $CUSTOM_VAR"])
505 .dry_run(false)
506 .clean_env()
507 .env("CUSTOM_VAR", "value")
508 .await
509 .expect("failed to run with clean env and custom var");
510
511 assert_eq!(output.stdout().trim(), "value");
512 }
513
514 #[tokio::test]
515 async fn test_dry_run_true_skips_execution() {
516 let output = Shell::new("echo test")
517 .dry_run(true)
518 .await
519 .expect("dry run should not fail");
520
521 assert_eq!(output.stdout(), "");
522 assert_eq!(output.stderr(), "");
523 assert_eq!(output.exit_code(), 0);
524 assert_eq!(output.duration_ms(), 0);
525 }
526
527 #[tokio::test]
528 async fn test_dry_run_false_executes_command() {
529 let output = Shell::new("echo hello")
530 .dry_run(false)
531 .await
532 .expect("dry run false should execute");
533
534 assert_eq!(output.stdout(), "hello");
535 }
536
537 #[tokio::test]
538 #[serial]
539 async fn test_global_dry_run_affects_operations() {
540 set_dry_run(false);
541 {
542 let _guard = DryRunGuard::new(true);
543 let output = Shell::new("echo test")
544 .await
545 .expect("dry run should not fail");
546
547 assert_eq!(output.stdout(), "");
548 assert_eq!(output.duration_ms(), 0);
549 }
550 set_dry_run(false);
551 }
552
553 #[tokio::test]
554 #[serial]
555 async fn test_per_operation_dry_run_overrides_global() {
556 set_dry_run(false);
557 {
558 let _guard = DryRunGuard::new(true);
559 let output = Shell::new("echo hello")
560 .dry_run(false)
561 .await
562 .expect("per-operation dry_run(false) should override global");
563
564 assert_eq!(output.stdout(), "hello");
566 }
567 set_dry_run(false);
568 }
569
570 #[tokio::test]
571 async fn test_run_captures_stdout_stderr_exit_code() {
572 let output = Shell::new("echo stdout && echo stderr >&2; exit 0")
573 .dry_run(false)
574 .await
575 .expect("should not fail with exit 0");
576
577 assert_eq!(output.stdout().trim(), "stdout");
578 assert!(output.stderr().contains("stderr"));
579 assert_eq!(output.exit_code(), 0);
580 }
581
582 #[tokio::test]
583 async fn test_failed_command_returns_error() {
584 let result = Shell::new("exit 42").dry_run(false).await;
585
586 assert!(result.is_err());
587 match result {
588 Err(OperationError::Shell {
589 exit_code,
590 stderr: _,
591 }) => {
592 assert_eq!(exit_code, 42);
593 }
594 _ => panic!("expected Shell error"),
595 }
596 }
597
598 #[tokio::test]
599 async fn test_non_zero_exit_code_captured() {
600 let result = Shell::new("sh -c 'exit 7'").dry_run(false).await;
601
602 assert!(result.is_err());
603 if let Err(OperationError::Shell { exit_code, .. }) = result {
604 assert_eq!(exit_code, 7);
605 } else {
606 panic!("expected Shell error with exit_code 7");
607 }
608 }
609
610 #[tokio::test]
611 async fn test_shell_exec_without_shell_interpretation() {
612 let output = Shell::exec("echo", &["hello | world"])
614 .dry_run(false)
615 .await
616 .expect("exec should not interpret pipe");
617
618 assert_eq!(output.stdout().trim(), "hello | world");
619 }
620
621 #[tokio::test]
622 async fn test_shell_new_with_shell_interpretation() {
623 let output = Shell::new("echo hello | wc -w")
625 .dry_run(false)
626 .await
627 .expect("should interpret pipe");
628
629 assert_eq!(output.stdout().trim(), "1");
630 }
631
632 #[tokio::test]
633 async fn test_empty_command_string() {
634 let output = Shell::new("")
636 .dry_run(false)
637 .await
638 .expect("empty command should succeed");
639
640 assert_eq!(output.stdout(), "");
641 assert_eq!(output.exit_code(), 0);
642 }
643
644 #[tokio::test]
645 async fn test_unicode_in_stdout() {
646 let output = Shell::new("echo '你好世界'")
647 .dry_run(false)
648 .await
649 .expect("should handle unicode");
650
651 assert!(output.stdout().contains("你好"));
652 }
653
654 #[tokio::test]
655 async fn test_unicode_in_stderr() {
656 let result = Shell::new("echo '错误日志' >&2; exit 1")
657 .dry_run(false)
658 .await;
659
660 assert!(result.is_err());
661 if let Err(OperationError::Shell { stderr, .. }) = result {
662 assert!(stderr.contains("错误"));
663 }
664 }
665
666 #[tokio::test]
667 async fn test_large_output_is_truncated() {
668 let large_count = 1000; let cmd = format!(
673 "for i in $(seq 1 {}); do echo \"line $i\"; done",
674 large_count
675 );
676 let output = Shell::new(&cmd)
677 .dry_run(false)
678 .await
679 .expect("should handle large output");
680
681 assert_eq!(output.exit_code(), 0);
683 assert!(!output.stdout().is_empty());
684 }
685
686 #[tokio::test]
687 async fn test_duration_is_recorded() {
688 let output = Shell::new("sleep 0.1")
689 .dry_run(false)
690 .await
691 .expect("should complete");
692
693 assert!(output.duration_ms() >= 100);
694 assert!(output.duration_ms() < 2000); }
696
697 #[tokio::test]
698 async fn test_into_future_trait() {
699 let output = Shell::new("echo into_future")
701 .dry_run(false)
702 .await
703 .expect("should work");
704 assert_eq!(output.stdout(), "into_future");
705 }
706
707 #[tokio::test]
708 async fn test_multiple_builder_calls_chain() {
709 let output = Shell::new("echo test")
710 .dry_run(false)
711 .timeout(Duration::from_secs(30))
712 .env("MY_VAR", "value")
713 .dir("/tmp")
714 .await
715 .expect("chained builders should work");
716
717 assert_eq!(output.stdout(), "test");
718 }
719
720 #[tokio::test]
721 async fn test_shell_output_accessors() {
722 let output = Shell::new("echo hello && echo world >&2")
723 .dry_run(false)
724 .await
725 .expect("should succeed");
726
727 let stdout = output.stdout();
728 let stderr = output.stderr();
729 let exit_code = output.exit_code();
730 assert_eq!(stdout, "hello");
731 assert!(stderr.contains("world"));
732 assert_eq!(exit_code, 0);
733 }
734
735 #[tokio::test]
736 async fn test_spawning_nonexistent_program_fails() {
737 let result = Shell::exec("/nonexistent/program/path", &[])
738 .dry_run(false)
739 .await;
740
741 assert!(result.is_err());
742 match result {
743 Err(OperationError::Shell {
744 exit_code,
745 stderr: _,
746 }) => {
747 assert_eq!(exit_code, -1);
748 }
749 _ => panic!("expected Shell error"),
750 }
751 }
752
753 #[tokio::test]
754 async fn test_output_is_trimmed() {
755 let output = Shell::new("echo 'hello\n\n'")
756 .dry_run(false)
757 .await
758 .expect("should succeed");
759
760 assert_eq!(output.stdout(), "hello");
762 }
763
764 #[tokio::test]
765 async fn test_complex_shell_features() {
766 let output = Shell::new("echo first && echo second | head -1")
767 .dry_run(false)
768 .await
769 .expect("complex shell should work");
770
771 assert!(output.stdout().contains("first"));
772 assert!(output.stdout().contains("second"));
773 }
774
775 #[tokio::test]
776 async fn test_stderr_on_success_is_captured() {
777 let output = Shell::new("echo success && echo warnings >&2")
778 .dry_run(false)
779 .await
780 .expect("should succeed despite stderr");
781
782 assert_eq!(output.stdout().trim(), "success");
783 assert!(output.stderr().contains("warnings"));
784 assert_eq!(output.exit_code(), 0);
785 }
786
787 #[tokio::test]
788 async fn test_must_use_attribute_on_shell() {
789 let _shell = Shell::new("echo test");
791 }
792
793 #[tokio::test]
794 async fn test_shell_mode_display_for_new() {
795 let shell = Shell::new("echo test");
796 let mode_str = shell.mode.to_string();
797 assert_eq!(mode_str, "echo test");
798 }
799
800 #[tokio::test]
801 async fn test_shell_mode_display_for_exec() {
802 let shell = Shell::exec("echo", &["hello", "world"]);
803 let mode_str = shell.mode.to_string();
804 assert!(mode_str.contains("echo"));
805 assert!(mode_str.contains("hello"));
806 assert!(mode_str.contains("world"));
807 }
808}