Skip to main content

pipeline_service/runners/
shell.rs

1// Shell Runner
2// Executes script, bash, pwsh, and powershell steps
3
4use crate::parser::models::{StepResult, StepStatus, Value};
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command;
12
13/// Shell types supported by the runner
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Shell {
16    /// Default shell (sh on Unix, cmd on Windows)
17    Default,
18    /// Bash shell
19    Bash,
20    /// PowerShell Core (cross-platform)
21    Pwsh,
22    /// Windows PowerShell (Windows only, falls back to pwsh)
23    PowerShell,
24}
25
26impl Shell {
27    /// Get the shell executable and arguments
28    fn get_command(&self) -> (&'static str, &'static [&'static str]) {
29        match self {
30            Shell::Default => {
31                if cfg!(target_os = "windows") {
32                    ("cmd", &["/C"])
33                } else {
34                    ("sh", &["-c"])
35                }
36            }
37            Shell::Bash => ("bash", &["-c"]),
38            Shell::Pwsh => ("pwsh", &["-NoLogo", "-NoProfile", "-Command"]),
39            Shell::PowerShell => {
40                if cfg!(target_os = "windows") {
41                    ("powershell.exe", &["-NoLogo", "-NoProfile", "-Command"])
42                } else {
43                    // Fall back to pwsh on non-Windows
44                    ("pwsh", &["-NoLogo", "-NoProfile", "-Command"])
45                }
46            }
47        }
48    }
49}
50
51/// Configuration for shell execution
52#[derive(Debug, Clone, Default)]
53pub struct ShellConfig {
54    /// Working directory for the script
55    pub working_dir: Option<String>,
56    /// Fail if there's output to stderr
57    pub fail_on_stderr: bool,
58    /// Error action preference (for PowerShell)
59    pub error_action_preference: Option<String>,
60    /// Timeout in seconds (None = no timeout)
61    pub timeout: Option<Duration>,
62}
63
64/// Output collected during script execution
65#[derive(Debug, Clone, Default)]
66pub struct ShellOutput {
67    /// Standard output
68    pub stdout: String,
69    /// Standard error
70    pub stderr: String,
71    /// Exit code (if available)
72    pub exit_code: Option<i32>,
73    /// Outputs extracted from logging commands
74    pub outputs: HashMap<String, String>,
75    /// Variables set via logging commands
76    pub variables: HashMap<String, Value>,
77}
78
79/// Callback for handling output lines in real-time
80pub type OutputCallback = Box<dyn Fn(&str, bool) + Send + Sync>;
81
82/// Shell runner for executing scripts
83pub struct ShellRunner {
84    /// Default shell to use
85    default_shell: Shell,
86}
87
88impl ShellRunner {
89    /// Create a new shell runner with the default shell
90    pub fn new() -> Self {
91        Self {
92            default_shell: Shell::Default,
93        }
94    }
95
96    /// Create a shell runner with a specific default shell
97    pub fn with_default_shell(shell: Shell) -> Self {
98        Self {
99            default_shell: shell,
100        }
101    }
102
103    /// Execute a script using the default shell
104    pub async fn run_script(
105        &self,
106        script: &str,
107        env: &HashMap<String, String>,
108        working_dir: &Path,
109        config: &ShellConfig,
110    ) -> ShellOutput {
111        self.run_with_shell(self.default_shell, script, env, working_dir, config)
112            .await
113    }
114
115    /// Execute a bash script
116    pub async fn run_bash(
117        &self,
118        script: &str,
119        env: &HashMap<String, String>,
120        working_dir: &Path,
121        config: &ShellConfig,
122    ) -> ShellOutput {
123        self.run_with_shell(Shell::Bash, script, env, working_dir, config)
124            .await
125    }
126
127    /// Execute a PowerShell Core (pwsh) script
128    pub async fn run_pwsh(
129        &self,
130        script: &str,
131        env: &HashMap<String, String>,
132        working_dir: &Path,
133        config: &ShellConfig,
134    ) -> ShellOutput {
135        // Wrap script with error action preference if specified
136        let script = if let Some(pref) = &config.error_action_preference {
137            format!("$ErrorActionPreference = '{}'\n{}", pref, script)
138        } else {
139            script.to_string()
140        };
141
142        self.run_with_shell(Shell::Pwsh, &script, env, working_dir, config)
143            .await
144    }
145
146    /// Execute a Windows PowerShell script
147    pub async fn run_powershell(
148        &self,
149        script: &str,
150        env: &HashMap<String, String>,
151        working_dir: &Path,
152        config: &ShellConfig,
153    ) -> ShellOutput {
154        // Wrap script with error action preference if specified
155        let script = if let Some(pref) = &config.error_action_preference {
156            format!("$ErrorActionPreference = '{}'\n{}", pref, script)
157        } else {
158            script.to_string()
159        };
160
161        self.run_with_shell(Shell::PowerShell, &script, env, working_dir, config)
162            .await
163    }
164
165    /// Execute a script with a specific shell
166    async fn run_with_shell(
167        &self,
168        shell: Shell,
169        script: &str,
170        env: &HashMap<String, String>,
171        working_dir: &Path,
172        config: &ShellConfig,
173    ) -> ShellOutput {
174        let (shell_cmd, shell_args) = shell.get_command();
175
176        // Determine working directory
177        let work_dir = config
178            .working_dir
179            .as_ref()
180            .map(Path::new)
181            .unwrap_or(working_dir);
182
183        let mut cmd = Command::new(shell_cmd);
184        cmd.args(shell_args);
185        cmd.arg(script);
186        cmd.current_dir(work_dir);
187        cmd.envs(env);
188        cmd.stdout(Stdio::piped());
189        cmd.stderr(Stdio::piped());
190
191        // Spawn the process
192        let mut child = match cmd.spawn() {
193            Ok(child) => child,
194            Err(e) => {
195                return ShellOutput {
196                    stdout: String::new(),
197                    stderr: format!("Failed to spawn shell process '{}': {}", shell_cmd, e),
198                    exit_code: None,
199                    outputs: HashMap::new(),
200                    variables: HashMap::new(),
201                };
202            }
203        };
204
205        let stdout = match child.stdout.take() {
206            Some(s) => s,
207            None => {
208                return ShellOutput {
209                    stdout: String::new(),
210                    stderr: "Internal error: stdout was not piped".to_string(),
211                    exit_code: None,
212                    outputs: HashMap::new(),
213                    variables: HashMap::new(),
214                };
215            }
216        };
217        let stderr = match child.stderr.take() {
218            Some(s) => s,
219            None => {
220                return ShellOutput {
221                    stdout: String::new(),
222                    stderr: "Internal error: stderr was not piped".to_string(),
223                    exit_code: None,
224                    outputs: HashMap::new(),
225                    variables: HashMap::new(),
226                };
227            }
228        };
229
230        // Read output streams concurrently
231        let stdout_reader = BufReader::new(stdout);
232        let stderr_reader = BufReader::new(stderr);
233
234        let stdout_handle = tokio::spawn(async move {
235            let mut lines = stdout_reader.lines();
236            let mut output = String::new();
237            while let Ok(Some(line)) = lines.next_line().await {
238                if !output.is_empty() {
239                    output.push('\n');
240                }
241                output.push_str(&line);
242            }
243            output
244        });
245
246        let stderr_handle = tokio::spawn(async move {
247            let mut lines = stderr_reader.lines();
248            let mut output = String::new();
249            while let Ok(Some(line)) = lines.next_line().await {
250                if !output.is_empty() {
251                    output.push('\n');
252                }
253                output.push_str(&line);
254            }
255            output
256        });
257
258        // Wait for completion with optional timeout
259        let wait_result = if let Some(timeout) = config.timeout {
260            match tokio::time::timeout(timeout, child.wait()).await {
261                Ok(result) => result,
262                Err(_) => {
263                    // Timeout - kill the process
264                    let _ = child.kill().await;
265                    return ShellOutput {
266                        stdout: stdout_handle.await.unwrap_or_default(),
267                        stderr: format!("Process timed out after {:?}", timeout),
268                        exit_code: None,
269                        outputs: HashMap::new(),
270                        variables: HashMap::new(),
271                    };
272                }
273            }
274        } else {
275            child.wait().await
276        };
277
278        let exit_code = wait_result.ok().and_then(|s| s.code());
279        let stdout = stdout_handle.await.unwrap_or_default();
280        let stderr = stderr_handle.await.unwrap_or_default();
281
282        // Parse logging commands from stdout
283        let (outputs, variables) = parse_logging_commands(&stdout);
284
285        ShellOutput {
286            stdout,
287            stderr,
288            exit_code,
289            outputs,
290            variables,
291        }
292    }
293
294    /// Execute a script with real-time output streaming
295    pub async fn run_script_streaming(
296        &self,
297        script: &str,
298        env: &HashMap<String, String>,
299        working_dir: &Path,
300        config: &ShellConfig,
301        on_output: OutputCallback,
302    ) -> ShellOutput {
303        self.run_with_shell_streaming(
304            self.default_shell,
305            script,
306            env,
307            working_dir,
308            config,
309            on_output,
310        )
311        .await
312    }
313
314    /// Execute a script with real-time output streaming using a specific shell
315    async fn run_with_shell_streaming(
316        &self,
317        shell: Shell,
318        script: &str,
319        env: &HashMap<String, String>,
320        working_dir: &Path,
321        config: &ShellConfig,
322        on_output: OutputCallback,
323    ) -> ShellOutput {
324        let (shell_cmd, shell_args) = shell.get_command();
325
326        let work_dir = config
327            .working_dir
328            .as_ref()
329            .map(Path::new)
330            .unwrap_or(working_dir);
331
332        let mut cmd = Command::new(shell_cmd);
333        cmd.args(shell_args);
334        cmd.arg(script);
335        cmd.current_dir(work_dir);
336        cmd.envs(env);
337        cmd.stdout(Stdio::piped());
338        cmd.stderr(Stdio::piped());
339
340        let mut child = match cmd.spawn() {
341            Ok(child) => child,
342            Err(e) => {
343                return ShellOutput {
344                    stdout: String::new(),
345                    stderr: format!("Failed to spawn shell process '{}': {}", shell_cmd, e),
346                    exit_code: None,
347                    outputs: HashMap::new(),
348                    variables: HashMap::new(),
349                };
350            }
351        };
352
353        let stdout = match child.stdout.take() {
354            Some(s) => s,
355            None => {
356                return ShellOutput {
357                    stdout: String::new(),
358                    stderr: "Internal error: stdout was not piped".to_string(),
359                    exit_code: None,
360                    outputs: HashMap::new(),
361                    variables: HashMap::new(),
362                };
363            }
364        };
365        let stderr = match child.stderr.take() {
366            Some(s) => s,
367            None => {
368                return ShellOutput {
369                    stdout: String::new(),
370                    stderr: "Internal error: stderr was not piped".to_string(),
371                    exit_code: None,
372                    outputs: HashMap::new(),
373                    variables: HashMap::new(),
374                };
375            }
376        };
377
378        let stdout_reader = BufReader::new(stdout);
379        let stderr_reader = BufReader::new(stderr);
380
381        let on_output = std::sync::Arc::new(on_output);
382        let on_output_stdout = on_output.clone();
383        let on_output_stderr = on_output;
384
385        // Stream stdout
386        let stdout_handle = tokio::spawn(async move {
387            let mut lines = stdout_reader.lines();
388            let mut output = String::new();
389            while let Ok(Some(line)) = lines.next_line().await {
390                on_output_stdout(&line, false);
391                if !output.is_empty() {
392                    output.push('\n');
393                }
394                output.push_str(&line);
395            }
396            output
397        });
398
399        // Stream stderr
400        let stderr_handle = tokio::spawn(async move {
401            let mut lines = stderr_reader.lines();
402            let mut output = String::new();
403            while let Ok(Some(line)) = lines.next_line().await {
404                on_output_stderr(&line, true);
405                if !output.is_empty() {
406                    output.push('\n');
407                }
408                output.push_str(&line);
409            }
410            output
411        });
412
413        let wait_result = if let Some(timeout) = config.timeout {
414            match tokio::time::timeout(timeout, child.wait()).await {
415                Ok(result) => result,
416                Err(_) => {
417                    let _ = child.kill().await;
418                    return ShellOutput {
419                        stdout: stdout_handle.await.unwrap_or_default(),
420                        stderr: format!("Process timed out after {:?}", timeout),
421                        exit_code: None,
422                        outputs: HashMap::new(),
423                        variables: HashMap::new(),
424                    };
425                }
426            }
427        } else {
428            child.wait().await
429        };
430
431        let exit_code = wait_result.ok().and_then(|s| s.code());
432        let stdout = stdout_handle.await.unwrap_or_default();
433        let stderr = stderr_handle.await.unwrap_or_default();
434
435        let (outputs, variables) = parse_logging_commands(&stdout);
436
437        ShellOutput {
438            stdout,
439            stderr,
440            exit_code,
441            outputs,
442            variables,
443        }
444    }
445
446    /// Convert shell output to a step result
447    pub fn to_step_result(
448        &self,
449        output: ShellOutput,
450        step_name: Option<String>,
451        display_name: Option<String>,
452        fail_on_stderr: bool,
453        duration: Duration,
454    ) -> StepResult {
455        let status = if output.exit_code.map(|c| c != 0).unwrap_or(true)
456            || (fail_on_stderr && !output.stderr.is_empty())
457        {
458            StepStatus::Failed
459        } else {
460            StepStatus::Succeeded
461        };
462
463        StepResult {
464            step_name,
465            display_name,
466            status,
467            output: output.stdout,
468            error: if output.stderr.is_empty() {
469                None
470            } else {
471                Some(output.stderr)
472            },
473            duration,
474            exit_code: output.exit_code,
475            outputs: output.outputs,
476        }
477    }
478}
479
480impl Default for ShellRunner {
481    fn default() -> Self {
482        Self::new()
483    }
484}
485
486/// Parse Azure DevOps logging commands from output
487fn parse_logging_commands(output: &str) -> (HashMap<String, String>, HashMap<String, Value>) {
488    let mut outputs = HashMap::new();
489    let mut variables = HashMap::new();
490
491    for line in output.lines() {
492        // ##vso[task.setvariable variable=name;isoutput=true;issecret=false]value
493        if let Some(rest) = line.strip_prefix("##vso[task.setvariable") {
494            if let Some((props, value)) = rest.split_once(']') {
495                let mut var_name = None;
496                let mut is_output = false;
497                let mut is_secret = false;
498
499                for prop in props.split(';') {
500                    let prop = prop.trim();
501                    if let Some(name) = prop.strip_prefix("variable=") {
502                        var_name = Some(name.to_string());
503                    } else if prop.eq_ignore_ascii_case("isoutput=true") {
504                        is_output = true;
505                    } else if prop.eq_ignore_ascii_case("issecret=true") {
506                        is_secret = true;
507                    }
508                }
509
510                if let Some(name) = var_name {
511                    if is_output {
512                        outputs.insert(name.clone(), value.to_string());
513                    }
514                    if !is_secret {
515                        variables.insert(name, Value::String(value.to_string()));
516                    }
517                }
518            }
519        }
520        // ##vso[task.setVariable variable=name]value (alternate format)
521        else if let Some(rest) = line.strip_prefix("##vso[task.setVariable") {
522            if let Some((props, value)) = rest.split_once(']') {
523                let mut var_name = None;
524                let mut is_output = false;
525                let mut is_secret = false;
526
527                for prop in props.split(';') {
528                    let prop = prop.trim();
529                    if let Some(name) = prop.strip_prefix("variable=") {
530                        var_name = Some(name.to_string());
531                    } else if prop.eq_ignore_ascii_case("isoutput=true") {
532                        is_output = true;
533                    } else if prop.eq_ignore_ascii_case("issecret=true") {
534                        is_secret = true;
535                    }
536                }
537
538                if let Some(name) = var_name {
539                    if is_output {
540                        outputs.insert(name.clone(), value.to_string());
541                    }
542                    if !is_secret {
543                        variables.insert(name, Value::String(value.to_string()));
544                    }
545                }
546            }
547        }
548        // ##vso[task.prependpath]path
549        else if let Some(rest) = line.strip_prefix("##vso[task.prependpath]") {
550            // Store prepend path requests
551            let existing = variables
552                .entry("_PREPEND_PATH".to_string())
553                .or_insert_with(|| Value::Array(vec![]));
554            if let Value::Array(arr) = existing {
555                arr.push(Value::String(rest.to_string()));
556            }
557        }
558        // ##vso[task.uploadfile]path
559        else if let Some(rest) = line.strip_prefix("##vso[task.uploadfile]") {
560            let existing = variables
561                .entry("_UPLOAD_FILES".to_string())
562                .or_insert_with(|| Value::Array(vec![]));
563            if let Value::Array(arr) = existing {
564                arr.push(Value::String(rest.to_string()));
565            }
566        }
567        // ##vso[artifact.upload containerfolder=folder;artifactname=name]path
568        // Skip for now - artifact handling
569        // ##vso[build.addbuildtag]tag
570        else if let Some(rest) = line.strip_prefix("##vso[build.addbuildtag]") {
571            let existing = variables
572                .entry("_BUILD_TAGS".to_string())
573                .or_insert_with(|| Value::Array(vec![]));
574            if let Value::Array(arr) = existing {
575                arr.push(Value::String(rest.to_string()));
576            }
577        }
578        // ##vso[task.complete result=Succeeded;]message
579        else if let Some(rest) = line.strip_prefix("##vso[task.complete") {
580            if let Some((props, _message)) = rest.split_once(']') {
581                for prop in props.split(';') {
582                    let prop = prop.trim();
583                    if let Some(result) = prop.strip_prefix("result=") {
584                        variables.insert(
585                            "_TASK_RESULT".to_string(),
586                            Value::String(result.to_string()),
587                        );
588                    }
589                }
590            }
591        }
592    }
593
594    (outputs, variables)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[tokio::test]
602    async fn test_shell_runner_echo() {
603        let runner = ShellRunner::new();
604        let env = HashMap::new();
605        let working_dir = std::env::current_dir().unwrap();
606        let config = ShellConfig::default();
607
608        let output = runner
609            .run_script("echo hello", &env, &working_dir, &config)
610            .await;
611
612        assert_eq!(output.exit_code, Some(0));
613        assert!(output.stdout.contains("hello"));
614        assert!(output.stderr.is_empty());
615    }
616
617    #[tokio::test]
618    async fn test_shell_runner_with_env() {
619        let runner = ShellRunner::new();
620        let mut env = HashMap::new();
621        env.insert("MY_VAR".to_string(), "test_value".to_string());
622        let working_dir = std::env::current_dir().unwrap();
623        let config = ShellConfig::default();
624
625        let script = if cfg!(target_os = "windows") {
626            "echo %MY_VAR%"
627        } else {
628            "echo $MY_VAR"
629        };
630
631        let output = runner.run_script(script, &env, &working_dir, &config).await;
632
633        assert_eq!(output.exit_code, Some(0));
634        assert!(output.stdout.contains("test_value"));
635    }
636
637    #[tokio::test]
638    async fn test_shell_runner_bash() {
639        let runner = ShellRunner::new();
640        let env = HashMap::new();
641        let working_dir = std::env::current_dir().unwrap();
642        let config = ShellConfig::default();
643
644        let output = runner
645            .run_bash("echo 'bash test'", &env, &working_dir, &config)
646            .await;
647
648        // This test might fail if bash is not installed
649        if output.exit_code == Some(0) {
650            assert!(output.stdout.contains("bash test"));
651        }
652    }
653
654    #[tokio::test]
655    async fn test_shell_runner_exit_code() {
656        let runner = ShellRunner::new();
657        let env = HashMap::new();
658        let working_dir = std::env::current_dir().unwrap();
659        let config = ShellConfig::default();
660
661        let output = runner
662            .run_script("exit 42", &env, &working_dir, &config)
663            .await;
664
665        assert_eq!(output.exit_code, Some(42));
666    }
667
668    #[tokio::test]
669    async fn test_shell_runner_stderr() {
670        let runner = ShellRunner::new();
671        let env = HashMap::new();
672        let working_dir = std::env::current_dir().unwrap();
673        let config = ShellConfig::default();
674
675        let output = runner
676            .run_script("echo error >&2", &env, &working_dir, &config)
677            .await;
678
679        assert_eq!(output.exit_code, Some(0));
680        assert!(output.stderr.contains("error"));
681    }
682
683    #[test]
684    fn test_parse_logging_commands_setvariable() {
685        let output = r#"
686Starting build
687##vso[task.setvariable variable=version]1.0.0
688##vso[task.setvariable variable=output;isoutput=true]result_value
689Build complete
690"#;
691
692        let (outputs, variables) = parse_logging_commands(output);
693
694        assert_eq!(
695            variables.get("version"),
696            Some(&Value::String("1.0.0".to_string()))
697        );
698        assert_eq!(outputs.get("output"), Some(&"result_value".to_string()));
699        assert_eq!(
700            variables.get("output"),
701            Some(&Value::String("result_value".to_string()))
702        );
703    }
704
705    #[test]
706    fn test_parse_logging_commands_secret() {
707        let output = "##vso[task.setvariable variable=password;issecret=true]secretvalue";
708
709        let (outputs, variables) = parse_logging_commands(output);
710
711        // Secrets should not be stored in variables
712        assert!(!variables.contains_key("password"));
713        assert!(!outputs.contains_key("password"));
714    }
715
716    #[test]
717    fn test_parse_logging_commands_build_tag() {
718        let output = r#"
719##vso[build.addbuildtag]release
720##vso[build.addbuildtag]v1.0
721"#;
722
723        let (_outputs, variables) = parse_logging_commands(output);
724
725        let tags = variables.get("_BUILD_TAGS").unwrap();
726        if let Value::Array(arr) = tags {
727            assert_eq!(arr.len(), 2);
728            assert_eq!(arr[0], Value::String("release".to_string()));
729            assert_eq!(arr[1], Value::String("v1.0".to_string()));
730        } else {
731            panic!("Expected array");
732        }
733    }
734
735    #[test]
736    fn test_to_step_result_success() {
737        let runner = ShellRunner::new();
738        let output = ShellOutput {
739            stdout: "Success".to_string(),
740            stderr: String::new(),
741            exit_code: Some(0),
742            outputs: HashMap::new(),
743            variables: HashMap::new(),
744        };
745
746        let result = runner.to_step_result(
747            output,
748            Some("test_step".to_string()),
749            Some("Test Step".to_string()),
750            false,
751            Duration::from_secs(1),
752        );
753
754        assert_eq!(result.status, StepStatus::Succeeded);
755        assert_eq!(result.output, "Success");
756        assert!(result.error.is_none());
757        assert_eq!(result.exit_code, Some(0));
758    }
759
760    #[test]
761    fn test_to_step_result_fail_on_stderr() {
762        let runner = ShellRunner::new();
763        let output = ShellOutput {
764            stdout: "Output".to_string(),
765            stderr: "Warning message".to_string(),
766            exit_code: Some(0),
767            outputs: HashMap::new(),
768            variables: HashMap::new(),
769        };
770
771        let result = runner.to_step_result(output, None, None, true, Duration::from_secs(1));
772
773        assert_eq!(result.status, StepStatus::Failed);
774        assert_eq!(result.error, Some("Warning message".to_string()));
775    }
776}