Skip to main content

raps_cli/commands/
pipeline.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Pipeline execution commands
5//!
6//! Run multiple CLI commands from a YAML or JSON pipeline file.
7
8use anyhow::{Context, Result};
9use clap::Subcommand;
10use colored::Colorize;
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13use std::process::Command;
14
15use crate::output::OutputFormat;
16// use raps_kernel::output::OutputFormat;
17
18#[derive(Debug, Subcommand)]
19pub enum PipelineCommands {
20    /// Run a pipeline from a YAML or JSON file (use `-` for stdin, parsed as YAML)
21    Run {
22        /// Path to pipeline file (use `-` for stdin)
23        file: PathBuf,
24
25        /// Continue on error
26        #[arg(short, long)]
27        continue_on_error: bool,
28
29        /// Dry run (show commands without executing)
30        #[arg(short, long)]
31        dry_run: bool,
32    },
33
34    /// Validate a pipeline file
35    Validate {
36        /// Path to pipeline file
37        file: PathBuf,
38    },
39
40    /// Generate a sample pipeline file
41    Sample {
42        /// Output file path
43        #[arg(long = "out-file", default_value = "pipeline.yaml")]
44        out_file: PathBuf,
45    },
46}
47
48/// Pipeline definition
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct Pipeline {
51    /// Pipeline name
52    pub name: String,
53    /// Pipeline description
54    #[serde(default)]
55    pub description: Option<String>,
56    /// Variables for substitution
57    #[serde(default)]
58    pub variables: std::collections::HashMap<String, String>,
59    /// Pipeline steps
60    pub steps: Vec<PipelineStep>,
61}
62
63/// Single step in a pipeline
64#[derive(Debug, Clone, Deserialize, Serialize)]
65pub struct PipelineStep {
66    /// Step name
67    pub name: String,
68    /// Command to execute (raps subcommand, e.g., "bucket list")
69    pub command: String,
70    /// Continue on failure
71    #[serde(default)]
72    pub continue_on_error: bool,
73    /// Condition to check before running
74    #[serde(default)]
75    pub condition: Option<String>,
76}
77
78impl PipelineCommands {
79    pub async fn execute(self, output_format: OutputFormat) -> Result<()> {
80        match self {
81            PipelineCommands::Run {
82                file,
83                continue_on_error,
84                dry_run,
85            } => run_pipeline(&file, continue_on_error, dry_run, output_format).await,
86            PipelineCommands::Validate { file } => validate_pipeline(&file, output_format),
87            PipelineCommands::Sample { out_file } => generate_sample(&out_file, output_format),
88        }
89    }
90}
91
92fn load_pipeline(file: &PathBuf) -> Result<Pipeline> {
93    let content = if file.as_os_str() == "-" {
94        use std::io::Read;
95        let mut buf = String::new();
96        std::io::stdin()
97            .lock()
98            .read_to_string(&mut buf)
99            .context("Failed to read pipeline from stdin")?;
100        buf
101    } else {
102        std::fs::read_to_string(file)
103            .with_context(|| format!("Failed to read pipeline file: {}", file.display()))?
104    };
105
106    // Stdin defaults to YAML; files use extension to determine format
107    let is_yaml = file.as_os_str() == "-"
108        || file
109            .extension()
110            .map(|e| e == "yaml" || e == "yml")
111            .unwrap_or(false);
112
113    let pipeline: Pipeline = if is_yaml {
114        serde_yaml::from_str(&content)
115            .with_context(|| format!("Failed to parse YAML pipeline: {}", file.display()))?
116    } else {
117        serde_json::from_str(&content)
118            .with_context(|| format!("Failed to parse JSON pipeline: {}", file.display()))?
119    };
120
121    Ok(pipeline)
122}
123
124async fn run_pipeline(
125    file: &PathBuf,
126    global_continue_on_error: bool,
127    dry_run: bool,
128    output_format: OutputFormat,
129) -> Result<()> {
130    let pipeline = load_pipeline(file)?;
131
132    if output_format.supports_colors() {
133        println!("\n{} {}", "Pipeline:".bold(), pipeline.name.cyan());
134        if let Some(ref desc) = pipeline.description {
135            println!("  {}", desc.dimmed());
136        }
137        println!("{}", "─".repeat(60));
138    }
139
140    let mut passed = 0;
141    let mut failed = 0;
142    let mut skipped = 0;
143
144    for (i, step) in pipeline.steps.iter().enumerate() {
145        let step_num = i + 1;
146
147        if output_format.supports_colors() {
148            println!(
149                "\n[{}/{}] {}",
150                step_num,
151                pipeline.steps.len(),
152                step.name.bold()
153            );
154            println!("  {} {}", "Command:".dimmed(), step.command.cyan());
155        }
156
157        // Check condition if specified
158        if let Some(ref condition) = step.condition {
159            // Simple condition parsing (e.g., "exit_code == 0")
160            if !evaluate_condition(condition) {
161                if output_format.supports_colors() {
162                    println!("  {} Condition not met, skipping", "→".yellow());
163                }
164                skipped += 1;
165                continue;
166            }
167        }
168
169        if dry_run {
170            if output_format.supports_colors() {
171                println!("  {} Would execute: raps {}", "→".dimmed(), step.command);
172            }
173            passed += 1;
174            continue;
175        }
176
177        // Validate and substitute variables in command
178        let mut command = step.command.clone();
179        for (key, value) in &pipeline.variables {
180            // Reject shell metacharacters in variable values
181            const SHELL_META: &[char] = &['|', '&', ';', '$', '`', '(', ')', '{', '}', '<', '>'];
182            if value.contains(SHELL_META) {
183                anyhow::bail!("Pipeline variable '{}' contains shell metacharacters", key);
184            }
185            command = command.replace(&format!("${{{}}}", key), value);
186            command = command.replace(&format!("${}", key), value);
187        }
188
189        // Execute the command
190        let result = execute_raps_command(&command);
191
192        match result {
193            Ok(0) => {
194                if output_format.supports_colors() {
195                    println!("  {} Success", "✓".green().bold());
196                }
197                passed += 1;
198            }
199            Ok(exit_code) => {
200                if output_format.supports_colors() {
201                    println!("  {} Failed (exit code: {})", "✗".red().bold(), exit_code);
202                }
203                failed += 1;
204
205                if !step.continue_on_error && !global_continue_on_error {
206                    anyhow::bail!(
207                        "Pipeline aborted at step '{}' (exit code: {})",
208                        step.name,
209                        exit_code
210                    );
211                }
212            }
213            Err(e) => {
214                if output_format.supports_colors() {
215                    println!("  {} Error: {}", "✗".red().bold(), e);
216                }
217                failed += 1;
218
219                if !step.continue_on_error && !global_continue_on_error {
220                    anyhow::bail!("Pipeline aborted at step '{}': {e}", step.name);
221                }
222            }
223        }
224    }
225
226    // Summary
227    if output_format.supports_colors() {
228        println!("\n{}", "─".repeat(60));
229        println!("{}", "Pipeline Summary:".bold());
230        println!(
231            "  {} {} passed, {} {} failed, {} {} skipped",
232            "✓".green(),
233            passed,
234            "✗".red(),
235            failed,
236            "→".yellow(),
237            skipped
238        );
239    }
240
241    #[derive(Serialize)]
242    struct PipelineResult {
243        success: bool,
244        passed: usize,
245        failed: usize,
246        skipped: usize,
247    }
248
249    // If we reach here, all failures were from continue_on_error steps
250    // (hard failures bail immediately above), so the pipeline succeeded.
251    let result = PipelineResult {
252        success: true,
253        passed,
254        failed,
255        skipped,
256    };
257
258    if !matches!(output_format, OutputFormat::Table) {
259        output_format.write(&result)?;
260    }
261
262    Ok(())
263}
264
265fn execute_raps_command(command: &str) -> Result<i32> {
266    // Get the current executable path
267    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
268
269    // Split command into args (shell-aware quoting)
270    let args = shlex::split(command)
271        .ok_or_else(|| anyhow::anyhow!("Invalid quoting in pipeline command: {}", command))?;
272
273    // Execute raps with the given arguments
274    let output = Command::new(&exe_path)
275        .args(&args)
276        .output()
277        .context("Failed to execute command")?;
278
279    // Print stdout/stderr
280    if !output.stdout.is_empty() {
281        print!("{}", String::from_utf8_lossy(&output.stdout));
282    }
283    if !output.stderr.is_empty() {
284        eprint!("{}", String::from_utf8_lossy(&output.stderr));
285    }
286
287    Ok(output.status.code().unwrap_or(-1))
288}
289
290fn evaluate_condition(condition: &str) -> bool {
291    // Simple condition evaluation
292    // For now, just check if it's truthy
293    let trimmed = condition.trim().to_lowercase();
294    !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
295}
296
297fn validate_pipeline(file: &PathBuf, output_format: OutputFormat) -> Result<()> {
298    let pipeline = load_pipeline(file)?;
299
300    #[derive(Serialize)]
301    struct ValidationResult {
302        valid: bool,
303        name: String,
304        steps_count: usize,
305        warnings: Vec<String>,
306    }
307
308    let mut warnings = Vec::new();
309
310    // Check for potential issues
311    for (i, step) in pipeline.steps.iter().enumerate() {
312        if step.command.is_empty() {
313            warnings.push(format!("Step {} '{}' has empty command", i + 1, step.name));
314        }
315    }
316
317    let result = ValidationResult {
318        valid: warnings.is_empty(),
319        name: pipeline.name.clone(),
320        steps_count: pipeline.steps.len(),
321        warnings: warnings.clone(),
322    };
323
324    match output_format {
325        OutputFormat::Table => {
326            if warnings.is_empty() {
327                println!(
328                    "{} Pipeline '{}' is valid!",
329                    "✓".green().bold(),
330                    pipeline.name
331                );
332                println!("  {} {} steps", "Steps:".bold(), result.steps_count);
333            } else {
334                println!("{} Pipeline has warnings:", "!".yellow().bold());
335                for warning in &warnings {
336                    println!("  {} {}", "•".yellow(), warning);
337                }
338            }
339        }
340        _ => {
341            output_format.write(&result)?;
342        }
343    }
344
345    Ok(())
346}
347
348fn generate_sample(output: &PathBuf, output_format: OutputFormat) -> Result<()> {
349    let ts = std::time::SystemTime::now()
350        .duration_since(std::time::UNIX_EPOCH)
351        .unwrap_or_default()
352        .as_millis();
353    let bucket_name = format!("raps-sample-{ts}");
354    let sample = Pipeline {
355        name: "Sample Pipeline".to_string(),
356        description: Some("Example pipeline demonstrating raps automation".to_string()),
357        variables: [
358            ("BUCKET".to_string(), bucket_name),
359            ("PROJECT_ID".to_string(), "12345".to_string()),
360        ]
361        .into_iter()
362        .collect(),
363        steps: vec![
364            PipelineStep {
365                name: "List buckets".to_string(),
366                command: "bucket list".to_string(),
367                continue_on_error: false,
368                condition: None,
369            },
370            PipelineStep {
371                name: "Create bucket".to_string(),
372                command: "bucket create -k ${BUCKET} -p transient -r US".to_string(),
373                continue_on_error: true,
374                condition: None,
375            },
376            PipelineStep {
377                name: "List objects".to_string(),
378                command: "object list ${BUCKET}".to_string(),
379                continue_on_error: false,
380                condition: None,
381            },
382            PipelineStep {
383                name: "Delete bucket".to_string(),
384                command: "bucket delete ${BUCKET} -y".to_string(),
385                continue_on_error: true,
386                condition: None,
387            },
388        ],
389    };
390
391    let content = if output.extension().map(|e| e == "json").unwrap_or(false) {
392        serde_json::to_string_pretty(&sample)?
393    } else {
394        serde_yaml::to_string(&sample)?
395    };
396
397    std::fs::write(output, &content)
398        .with_context(|| format!("Failed to write sample pipeline to {}", output.display()))?;
399
400    match output_format {
401        OutputFormat::Table => {
402            println!(
403                "{} Sample pipeline written to {}",
404                "✓".green().bold(),
405                output.display().to_string().cyan()
406            );
407        }
408        _ => {
409            #[derive(Serialize)]
410            struct SampleOutput {
411                success: bool,
412                path: String,
413            }
414            output_format.write(&SampleOutput {
415                success: true,
416                path: output.display().to_string(),
417            })?;
418        }
419    }
420
421    Ok(())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_pipeline_deserialization_yaml() {
430        let yaml = r#"
431name: Test Pipeline
432description: A test pipeline
433variables:
434  BUCKET: test-bucket
435steps:
436  - name: Step 1
437    command: bucket list
438  - name: Step 2
439    command: object list ${BUCKET}
440    continue_on_error: true
441"#;
442
443        let pipeline: Pipeline = serde_yaml::from_str(yaml).unwrap();
444        assert_eq!(pipeline.name, "Test Pipeline");
445        assert_eq!(pipeline.steps.len(), 2);
446        assert_eq!(
447            pipeline.variables.get("BUCKET"),
448            Some(&"test-bucket".to_string())
449        );
450        assert!(!pipeline.steps[0].continue_on_error);
451        assert!(pipeline.steps[1].continue_on_error);
452    }
453
454    #[test]
455    fn test_pipeline_deserialization_json() {
456        let json = r#"{
457            "name": "Test Pipeline",
458            "steps": [
459                {"name": "Step 1", "command": "bucket list"}
460            ]
461        }"#;
462
463        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
464        assert_eq!(pipeline.name, "Test Pipeline");
465        assert_eq!(pipeline.steps.len(), 1);
466    }
467
468    #[test]
469    fn test_evaluate_condition_truthy() {
470        assert!(evaluate_condition("true"));
471        assert!(evaluate_condition("1"));
472        assert!(evaluate_condition("yes"));
473        assert!(evaluate_condition("anything"));
474    }
475
476    #[test]
477    fn test_evaluate_condition_falsy() {
478        assert!(!evaluate_condition("false"));
479        assert!(!evaluate_condition("0"));
480        assert!(!evaluate_condition(""));
481        assert!(!evaluate_condition("   "));
482    }
483
484    #[test]
485    fn test_pipeline_step_defaults() {
486        let yaml = r#"
487name: Test
488command: bucket list
489"#;
490        let step: PipelineStep = serde_yaml::from_str(yaml).unwrap();
491        assert!(!step.continue_on_error);
492        assert!(step.condition.is_none());
493    }
494}