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