Skip to main content

pipeline_service/runners/
task.rs

1// Task Runner
2// Executes Azure DevOps tasks (Bash@3, PowerShell@2, etc.)
3
4use crate::parser::models::{StepResult, StepStatus};
5use crate::runners::shell::{ShellConfig, ShellRunner};
6use crate::tasks::cache::{CachedTask, TaskCache, TaskCacheError};
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant};
11use thiserror::Error;
12
13/// Errors that can occur when running tasks
14#[derive(Debug, Error)]
15pub enum TaskRunnerError {
16    #[error("Task cache error: {0}")]
17    CacheError(#[from] TaskCacheError),
18
19    #[error("Task execution failed: {0}")]
20    ExecutionFailed(String),
21
22    #[error("Unsupported task execution type: {0}")]
23    UnsupportedExecution(String),
24
25    #[error("Missing required input: {0}")]
26    MissingInput(String),
27
28    #[error("IO error: {0}")]
29    IoError(#[from] std::io::Error),
30}
31
32/// Task runner for executing Azure DevOps tasks
33pub struct TaskRunner {
34    /// Task cache
35    cache: TaskCache,
36    /// Shell runner for executing scripts
37    shell_runner: ShellRunner,
38    /// Path to Node.js executable (for JS tasks)
39    node_path: Option<PathBuf>,
40    /// Path to PowerShell executable (for PS tasks)
41    powershell_path: Option<PathBuf>,
42}
43
44impl TaskRunner {
45    /// Create a new task runner with the specified cache directory
46    pub fn new(cache_dir: PathBuf) -> Self {
47        Self {
48            cache: TaskCache::with_cache_dir(cache_dir),
49            shell_runner: ShellRunner::new(),
50            node_path: find_node_path(),
51            powershell_path: find_powershell_path(),
52        }
53    }
54
55    /// Create a task runner with a custom task cache
56    pub fn with_cache(cache: TaskCache) -> Self {
57        Self {
58            cache,
59            shell_runner: ShellRunner::new(),
60            node_path: find_node_path(),
61            powershell_path: find_powershell_path(),
62        }
63    }
64
65    /// Set the Node.js path
66    pub fn with_node_path(mut self, path: impl AsRef<Path>) -> Self {
67        self.node_path = Some(path.as_ref().to_path_buf());
68        self
69    }
70
71    /// Set the PowerShell path
72    pub fn with_powershell_path(mut self, path: impl AsRef<Path>) -> Self {
73        self.powershell_path = Some(path.as_ref().to_path_buf());
74        self
75    }
76
77    /// Get the task cache
78    pub fn cache(&self) -> &TaskCache {
79        &self.cache
80    }
81
82    /// Execute a task
83    pub async fn execute_task(
84        &self,
85        task_ref: &str,
86        inputs: &HashMap<String, String>,
87        env: &HashMap<String, String>,
88        working_dir: &Path,
89    ) -> Result<StepResult, TaskRunnerError> {
90        let start = Instant::now();
91
92        // Get the task from cache (downloading if necessary)
93        let task = self.cache.get_task(task_ref).await?;
94
95        // Validate required inputs
96        self.validate_inputs(&task, inputs)?;
97
98        // Merge inputs with defaults
99        let merged_inputs = self.merge_inputs(&task, inputs);
100
101        // Execute based on task type
102        let result = self
103            .execute_task_impl(&task, &merged_inputs, env, working_dir)
104            .await;
105
106        let duration = start.elapsed();
107
108        match result {
109            Ok(mut step_result) => {
110                step_result.duration = duration;
111                Ok(step_result)
112            }
113            Err(e) => Ok(StepResult {
114                step_name: None,
115                display_name: task.manifest.friendly_name.clone(),
116                status: StepStatus::Failed,
117                output: String::new(),
118                error: Some(e.to_string()),
119                duration,
120                exit_code: None,
121                outputs: HashMap::new(),
122            }),
123        }
124    }
125
126    /// Validate that all required inputs are provided
127    fn validate_inputs(
128        &self,
129        task: &CachedTask,
130        inputs: &HashMap<String, String>,
131    ) -> Result<(), TaskRunnerError> {
132        for input in &task.manifest.inputs {
133            if input.required.unwrap_or(false) {
134                let name = &input.name;
135
136                // Check if input is provided or has default
137                if !inputs.contains_key(name) && input.default_value.is_none() {
138                    // Check aliases
139                    let has_alias = input
140                        .aliases
141                        .as_ref()
142                        .map(|aliases| aliases.iter().any(|a| inputs.contains_key(a)))
143                        .unwrap_or(false);
144
145                    if !has_alias {
146                        // Check visibility rules - if not visible, not required
147                        if let Some(rule) = &input.visible_rule {
148                            // Simple visibility rule parsing
149                            // Format: "inputName = value" or "inputName != value"
150                            if let Some((check_input, _)) = rule.split_once('=') {
151                                let check_input = check_input.trim().trim_end_matches('!').trim();
152                                if !inputs.contains_key(check_input) {
153                                    continue; // Skip validation if condition input not provided
154                                }
155                            }
156                        }
157
158                        return Err(TaskRunnerError::MissingInput(name.clone()));
159                    }
160                }
161            }
162        }
163
164        Ok(())
165    }
166
167    /// Merge provided inputs with defaults
168    fn merge_inputs(
169        &self,
170        task: &CachedTask,
171        inputs: &HashMap<String, String>,
172    ) -> HashMap<String, String> {
173        let mut merged = task.manifest.default_values();
174
175        // Override with provided inputs
176        for (key, value) in inputs {
177            merged.insert(key.clone(), value.clone());
178        }
179
180        merged
181    }
182
183    /// Execute the task implementation
184    async fn execute_task_impl(
185        &self,
186        task: &CachedTask,
187        inputs: &HashMap<String, String>,
188        env: &HashMap<String, String>,
189        working_dir: &Path,
190    ) -> Result<StepResult, TaskRunnerError> {
191        // Check for built-in tasks that we handle specially
192        match task.name.as_str() {
193            "Bash" => self.execute_bash_task(inputs, env, working_dir).await,
194            "PowerShell" => self.execute_powershell_task(inputs, env, working_dir).await,
195            "CmdLine" => self.execute_cmdline_task(inputs, env, working_dir).await,
196            _ => {
197                // For other tasks, try to execute based on manifest
198                self.execute_generic_task(task, inputs, env, working_dir)
199                    .await
200            }
201        }
202    }
203
204    /// Execute the Bash task
205    async fn execute_bash_task(
206        &self,
207        inputs: &HashMap<String, String>,
208        env: &HashMap<String, String>,
209        working_dir: &Path,
210    ) -> Result<StepResult, TaskRunnerError> {
211        let target_type = inputs
212            .get("targetType")
213            .map(|s| s.as_str())
214            .unwrap_or("inline");
215
216        let script = match target_type {
217            "inline" => inputs
218                .get("script")
219                .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?
220                .clone(),
221            "filePath" => {
222                let file_path = inputs
223                    .get("filePath")
224                    .ok_or_else(|| TaskRunnerError::MissingInput("filePath".to_string()))?;
225
226                // Read script from file
227                let script_path = if Path::new(file_path).is_absolute() {
228                    PathBuf::from(file_path)
229                } else {
230                    working_dir.join(file_path)
231                };
232
233                std::fs::read_to_string(&script_path)?
234            }
235            _ => {
236                return Err(TaskRunnerError::ExecutionFailed(format!(
237                    "Unknown targetType: {}",
238                    target_type
239                )))
240            }
241        };
242
243        let config = ShellConfig {
244            working_dir: inputs.get("workingDirectory").cloned(),
245            fail_on_stderr: inputs
246                .get("failOnStderr")
247                .map(|s| s == "true")
248                .unwrap_or(false),
249            ..Default::default()
250        };
251
252        let output = self
253            .shell_runner
254            .run_bash(&script, env, working_dir, &config)
255            .await;
256
257        Ok(self.shell_runner.to_step_result(
258            output,
259            None,
260            Some("Bash".to_string()),
261            config.fail_on_stderr,
262            Duration::ZERO, // Will be set by caller
263        ))
264    }
265
266    /// Execute the PowerShell task
267    async fn execute_powershell_task(
268        &self,
269        inputs: &HashMap<String, String>,
270        env: &HashMap<String, String>,
271        working_dir: &Path,
272    ) -> Result<StepResult, TaskRunnerError> {
273        let target_type = inputs
274            .get("targetType")
275            .map(|s| s.as_str())
276            .unwrap_or("inline");
277        let use_pwsh = inputs.get("pwsh").map(|s| s == "true").unwrap_or(false);
278
279        let script = match target_type {
280            "inline" => inputs
281                .get("script")
282                .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?
283                .clone(),
284            "filePath" => {
285                let file_path = inputs
286                    .get("filePath")
287                    .ok_or_else(|| TaskRunnerError::MissingInput("filePath".to_string()))?;
288
289                let script_path = if Path::new(file_path).is_absolute() {
290                    PathBuf::from(file_path)
291                } else {
292                    working_dir.join(file_path)
293                };
294
295                std::fs::read_to_string(&script_path)?
296            }
297            _ => {
298                return Err(TaskRunnerError::ExecutionFailed(format!(
299                    "Unknown targetType: {}",
300                    target_type
301                )))
302            }
303        };
304
305        let config = ShellConfig {
306            working_dir: inputs.get("workingDirectory").cloned(),
307            fail_on_stderr: inputs
308                .get("failOnStderr")
309                .map(|s| s == "true")
310                .unwrap_or(false),
311            error_action_preference: inputs.get("errorActionPreference").cloned(),
312            ..Default::default()
313        };
314
315        let output = if use_pwsh {
316            self.shell_runner
317                .run_pwsh(&script, env, working_dir, &config)
318                .await
319        } else {
320            self.shell_runner
321                .run_powershell(&script, env, working_dir, &config)
322                .await
323        };
324
325        Ok(self.shell_runner.to_step_result(
326            output,
327            None,
328            Some("PowerShell".to_string()),
329            config.fail_on_stderr,
330            Duration::ZERO,
331        ))
332    }
333
334    /// Execute the CmdLine task
335    async fn execute_cmdline_task(
336        &self,
337        inputs: &HashMap<String, String>,
338        env: &HashMap<String, String>,
339        working_dir: &Path,
340    ) -> Result<StepResult, TaskRunnerError> {
341        let script = inputs
342            .get("script")
343            .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?;
344
345        let config = ShellConfig {
346            working_dir: inputs.get("workingDirectory").cloned(),
347            fail_on_stderr: inputs
348                .get("failOnStderr")
349                .map(|s| s == "true")
350                .unwrap_or(false),
351            ..Default::default()
352        };
353
354        let output = self
355            .shell_runner
356            .run_script(script, env, working_dir, &config)
357            .await;
358
359        Ok(self.shell_runner.to_step_result(
360            output,
361            None,
362            Some("Command Line".to_string()),
363            config.fail_on_stderr,
364            Duration::ZERO,
365        ))
366    }
367
368    /// Execute a generic task using its manifest
369    async fn execute_generic_task(
370        &self,
371        task: &CachedTask,
372        inputs: &HashMap<String, String>,
373        env: &HashMap<String, String>,
374        working_dir: &Path,
375    ) -> Result<StepResult, TaskRunnerError> {
376        let exec = task.manifest.primary_execution().ok_or_else(|| {
377            TaskRunnerError::UnsupportedExecution("No execution defined".to_string())
378        })?;
379
380        let target_path = task.path.join(&exec.target);
381
382        if !target_path.exists() {
383            return Err(TaskRunnerError::ExecutionFailed(format!(
384                "Task target not found: {}",
385                target_path.display()
386            )));
387        }
388
389        // Determine execution type from manifest
390        if task.manifest.is_node_task() {
391            self.execute_node_task(&target_path, task, inputs, env, working_dir)
392                .await
393        } else if task.manifest.is_powershell_task() {
394            self.execute_ps_task(&target_path, task, inputs, env, working_dir)
395                .await
396        } else {
397            Err(TaskRunnerError::UnsupportedExecution(format!(
398                "Unknown execution type for task: {}",
399                task.name
400            )))
401        }
402    }
403
404    /// Execute a Node.js-based task
405    async fn execute_node_task(
406        &self,
407        target: &Path,
408        task: &CachedTask,
409        inputs: &HashMap<String, String>,
410        env: &HashMap<String, String>,
411        working_dir: &Path,
412    ) -> Result<StepResult, TaskRunnerError> {
413        let node_path = self
414            .node_path
415            .as_ref()
416            .ok_or_else(|| TaskRunnerError::ExecutionFailed("Node.js not found".to_string()))?;
417
418        // Set up task environment
419        let mut task_env = env.clone();
420
421        // Add inputs as environment variables (INPUT_<name>)
422        for (key, value) in inputs {
423            let env_key = format!("INPUT_{}", key.to_uppercase().replace([' ', '.'], "_"));
424            task_env.insert(env_key, value.clone());
425        }
426
427        // Add task library variables
428        task_env.insert(
429            "AGENT_TEMPDIRECTORY".to_string(),
430            std::env::temp_dir().to_string_lossy().to_string(),
431        );
432        task_env.insert(
433            "AGENT_WORKFOLDER".to_string(),
434            working_dir.to_string_lossy().to_string(),
435        );
436        task_env.insert(
437            "SYSTEM_DEFAULTWORKINGDIRECTORY".to_string(),
438            working_dir.to_string_lossy().to_string(),
439        );
440
441        // Build the command
442        let script = format!("{} {}", node_path.display(), target.display());
443
444        let config = ShellConfig {
445            working_dir: Some(task.path.to_string_lossy().to_string()),
446            ..Default::default()
447        };
448
449        let output = self
450            .shell_runner
451            .run_script(&script, &task_env, working_dir, &config)
452            .await;
453
454        Ok(self.shell_runner.to_step_result(
455            output,
456            None,
457            task.manifest.friendly_name.clone(),
458            false,
459            Duration::ZERO,
460        ))
461    }
462
463    /// Execute a PowerShell-based task
464    async fn execute_ps_task(
465        &self,
466        target: &Path,
467        task: &CachedTask,
468        inputs: &HashMap<String, String>,
469        env: &HashMap<String, String>,
470        working_dir: &Path,
471    ) -> Result<StepResult, TaskRunnerError> {
472        // Set up task environment
473        let mut task_env = env.clone();
474
475        // Add inputs as environment variables
476        for (key, value) in inputs {
477            let env_key = format!("INPUT_{}", key.to_uppercase().replace([' ', '.'], "_"));
478            task_env.insert(env_key, value.clone());
479        }
480
481        // Add task library variables
482        task_env.insert(
483            "AGENT_TEMPDIRECTORY".to_string(),
484            std::env::temp_dir().to_string_lossy().to_string(),
485        );
486        task_env.insert(
487            "SYSTEM_DEFAULTWORKINGDIRECTORY".to_string(),
488            working_dir.to_string_lossy().to_string(),
489        );
490
491        // Build PowerShell command to execute the script
492        let script = format!("& '{}' ", target.display());
493
494        let config = ShellConfig {
495            working_dir: Some(task.path.to_string_lossy().to_string()),
496            ..Default::default()
497        };
498
499        let output = self
500            .shell_runner
501            .run_pwsh(&script, &task_env, working_dir, &config)
502            .await;
503
504        Ok(self.shell_runner.to_step_result(
505            output,
506            None,
507            task.manifest.friendly_name.clone(),
508            false,
509            Duration::ZERO,
510        ))
511    }
512}
513
514/// Find the Node.js executable path
515fn find_node_path() -> Option<PathBuf> {
516    // Try common locations
517    let candidates = if cfg!(target_os = "windows") {
518        vec![
519            "node.exe",
520            "C:\\Program Files\\nodejs\\node.exe",
521            "C:\\Program Files (x86)\\nodejs\\node.exe",
522        ]
523    } else {
524        vec![
525            "node",
526            "/usr/bin/node",
527            "/usr/local/bin/node",
528            "/opt/homebrew/bin/node",
529        ]
530    };
531
532    for candidate in candidates {
533        let path = PathBuf::from(candidate);
534        if path.exists() || which::which(candidate).is_ok() {
535            return Some(path);
536        }
537    }
538
539    // Try using which
540    which::which("node").ok()
541}
542
543/// Find the PowerShell executable path
544fn find_powershell_path() -> Option<PathBuf> {
545    // Try pwsh first (PowerShell Core), then fallback to powershell.exe on Windows
546    let candidates = if cfg!(target_os = "windows") {
547        vec![
548            "pwsh.exe",
549            "powershell.exe",
550            "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
551            "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
552        ]
553    } else {
554        vec![
555            "pwsh",
556            "/usr/bin/pwsh",
557            "/usr/local/bin/pwsh",
558            "/opt/microsoft/powershell/7/pwsh",
559        ]
560    };
561
562    for candidate in candidates {
563        let path = PathBuf::from(candidate);
564        if path.exists() || which::which(candidate).is_ok() {
565            return Some(path);
566        }
567    }
568
569    which::which("pwsh").ok()
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use tempfile::TempDir;
576
577    fn create_test_runner() -> (TaskRunner, TempDir) {
578        let temp_dir = TempDir::new().unwrap();
579        let runner = TaskRunner::new(temp_dir.path().to_path_buf());
580        (runner, temp_dir)
581    }
582
583    #[tokio::test]
584    async fn test_execute_bash_task_inline() {
585        let (runner, _temp_dir) = create_test_runner();
586        let mut inputs = HashMap::new();
587        inputs.insert("targetType".to_string(), "inline".to_string());
588        inputs.insert(
589            "script".to_string(),
590            "echo 'Hello from Bash task'".to_string(),
591        );
592
593        let env = HashMap::new();
594        let working_dir = std::env::current_dir().unwrap();
595
596        let result = runner.execute_bash_task(&inputs, &env, &working_dir).await;
597
598        // Bash might not be available on all systems
599        if let Ok(step_result) = result {
600            if step_result.status == StepStatus::Succeeded {
601                assert!(step_result.output.contains("Hello from Bash task"));
602            }
603        }
604    }
605
606    #[tokio::test]
607    async fn test_execute_cmdline_task() {
608        let (runner, _temp_dir) = create_test_runner();
609        let mut inputs = HashMap::new();
610        inputs.insert("script".to_string(), "echo Hello from CmdLine".to_string());
611
612        let env = HashMap::new();
613        let working_dir = std::env::current_dir().unwrap();
614
615        let result = runner
616            .execute_cmdline_task(&inputs, &env, &working_dir)
617            .await
618            .unwrap();
619
620        assert_eq!(result.status, StepStatus::Succeeded);
621        assert!(result.output.contains("Hello from CmdLine"));
622    }
623
624    #[test]
625    fn test_merge_inputs() {
626        let (runner, _temp_dir) = create_test_runner();
627
628        // Create a minimal cached task for testing
629        let task = CachedTask {
630            name: "Test".to_string(),
631            version: "1".to_string(),
632            path: PathBuf::from("/tmp/test"),
633            manifest: crate::tasks::manifest::TaskManifest {
634                id: "test".to_string(),
635                name: "Test".to_string(),
636                friendly_name: None,
637                description: None,
638                help_url: None,
639                help_mark_down: None,
640                category: None,
641                visibility: None,
642                runs_on: None,
643                author: None,
644                version: crate::tasks::manifest::TaskVersion {
645                    major: 1,
646                    minor: 0,
647                    patch: 0,
648                },
649                minimum_agent_version: None,
650                instance_name_format: None,
651                groups: None,
652                inputs: vec![crate::tasks::manifest::TaskInput {
653                    name: "input1".to_string(),
654                    input_type: None,
655                    label: None,
656                    default_value: Some("default_value".to_string()),
657                    required: None,
658                    help_mark_down: None,
659                    group_name: None,
660                    visible_rule: None,
661                    options: None,
662                    properties: None,
663                    validation: None,
664                    aliases: None,
665                }],
666                output_variables: None,
667                execution: None,
668                pre_job_execution: None,
669                post_job_execution: None,
670                data_source_bindings: None,
671                messages: None,
672                restrictions: None,
673                demands: None,
674            },
675        };
676
677        let mut inputs = HashMap::new();
678        inputs.insert("input2".to_string(), "custom_value".to_string());
679
680        let merged = runner.merge_inputs(&task, &inputs);
681
682        assert_eq!(merged.get("input1"), Some(&"default_value".to_string()));
683        assert_eq!(merged.get("input2"), Some(&"custom_value".to_string()));
684    }
685}