Skip to main content

cuenv_buildkite/
emitter.rs

1//! Buildkite Pipeline Emitter
2//!
3//! Transforms cuenv IR into Buildkite pipeline YAML format.
4
5// Pipeline emission involves complex step generation with many options
6#![allow(clippy::too_many_lines)]
7
8use crate::schema::{AgentRules, BlockStep, CommandStep, CommandValue, DependsOn, Pipeline, Step};
9use cuenv_ci::emitter::{Emitter, EmitterError, EmitterResult};
10use cuenv_ci::ir::{BuildStage, IntermediateRepresentation, OutputType, Task};
11use std::collections::HashMap;
12
13/// Buildkite pipeline emitter
14///
15/// Transforms cuenv IR into Buildkite pipeline YAML that can be uploaded
16/// via `buildkite-agent pipeline upload`.
17///
18/// # IR to Buildkite Mapping
19///
20/// | IR Field | Buildkite YAML |
21/// |----------|----------------|
22/// | `task.id` | `key` |
23/// | `task.command` | `command` |
24/// | `task.env` | `env` |
25/// | `task.secrets` | `env` (variable references) |
26/// | `task.depends_on` | `depends_on` |
27/// | `task.resources.tags` | `agents: { queue: "tag" }` |
28/// | `task.concurrency_group` | `concurrency_group` + `concurrency: 1` |
29/// | `task.manual_approval` | `block` step before task |
30/// | `task.outputs` (orchestrator) | `artifact_paths` |
31#[derive(Debug, Clone, Default)]
32pub struct BuildkiteEmitter {
33    /// Add emoji prefixes to labels
34    pub use_emojis: bool,
35    /// Default queue for agents
36    pub default_queue: Option<String>,
37}
38
39impl BuildkiteEmitter {
40    /// Create a new Buildkite emitter
41    #[must_use]
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Enable emoji prefixes in step labels
47    #[must_use]
48    pub const fn with_emojis(mut self) -> Self {
49        self.use_emojis = true;
50        self
51    }
52
53    /// Set a default queue for all steps
54    #[must_use]
55    pub fn with_default_queue(mut self, queue: impl Into<String>) -> Self {
56        self.default_queue = Some(queue.into());
57        self
58    }
59
60    /// Convert IR tasks to Buildkite pipeline
61    fn build_pipeline(&self, ir: &IntermediateRepresentation) -> Pipeline {
62        let mut steps: Vec<Step> = Vec::new();
63
64        // Emit bootstrap phase tasks (e.g., install-nix)
65        for task in ir.sorted_phase_tasks(BuildStage::Bootstrap) {
66            steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
67        }
68
69        // Emit setup phase tasks (e.g., cachix, setup-cuenv, 1password)
70        for task in ir.sorted_phase_tasks(BuildStage::Setup) {
71            steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
72        }
73
74        // Collect all bootstrap + setup task IDs for dependencies
75        let setup_keys: Vec<String> = ir
76            .sorted_phase_tasks(BuildStage::Bootstrap)
77            .iter()
78            .chain(ir.sorted_phase_tasks(BuildStage::Setup).iter())
79            .map(|t| t.id.clone())
80            .collect();
81
82        // Build approval keys map for tasks that need manual approval
83        let approval_keys: HashMap<String, String> = ir
84            .regular_tasks()
85            .filter(|task| task.manual_approval)
86            .map(|task| (task.id.clone(), format!("{}-approval", task.id)))
87            .collect();
88
89        // Build task steps: block steps for approvals, then command steps
90        for task in ir.regular_tasks() {
91            // Add block step if task requires manual approval
92            if let Some(approval_key) = approval_keys.get(&task.id) {
93                steps.push(Step::Block(self.build_block_step(task, approval_key)));
94            }
95
96            // Add command step for the task
97            steps.push(Step::Command(Box::new(self.build_command_step(
98                task,
99                ir,
100                &approval_keys,
101                &setup_keys,
102            ))));
103        }
104
105        Pipeline {
106            steps,
107            env: HashMap::new(),
108        }
109    }
110
111    /// Convert a phase task (bootstrap/setup/success/failure) to a Buildkite command step
112    fn phase_task_to_step(&self, task: &Task) -> CommandStep {
113        let label = task.label.as_ref().map(|l| self.format_label(l, false));
114
115        // Build command - use shell wrapper if needed
116        let command = if task.shell {
117            Some(CommandValue::Single(task.command.join(" ")))
118        } else {
119            Some(CommandValue::Array(task.command.clone()))
120        };
121
122        // Build dependencies
123        let depends_on: Vec<DependsOn> = task
124            .depends_on
125            .iter()
126            .map(|dep| DependsOn::Key(dep.clone()))
127            .collect();
128
129        CommandStep {
130            label,
131            key: Some(task.id.clone()),
132            command,
133            env: task
134                .env
135                .iter()
136                .map(|(k, v)| (k.clone(), v.clone()))
137                .collect(),
138            agents: None,
139            artifact_paths: vec![],
140            depends_on,
141            concurrency_group: None,
142            concurrency: None,
143            retry: None,
144            timeout_in_minutes: None,
145            soft_fail: None,
146        }
147    }
148
149    /// Build a command step from an IR task
150    fn build_command_step(
151        &self,
152        task: &Task,
153        ir: &IntermediateRepresentation,
154        approval_keys: &HashMap<String, String>,
155        setup_keys: &[String],
156    ) -> CommandStep {
157        let label = self.format_label(&task.id, task.deployment);
158
159        // Build the command - Nix setup is handled by stage tasks
160        let base_command = ir.pipeline.environment.as_ref().map_or_else(
161            || format!("cuenv task {}", task.id),
162            |env| format!("cuenv task {} -e {}", task.id, env),
163        );
164
165        // Wrap with nix develop if task has a runtime
166        let command = if let Some(runtime_id) = &task.runtime {
167            if let Some(runtime) = ir.runtimes.iter().find(|r| r.id == *runtime_id) {
168                // Source nix profile and run in nix develop
169                Some(CommandValue::Single(format!(
170                    ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh && nix develop {}#{} --command {}",
171                    runtime.flake, runtime.output, base_command
172                )))
173            } else {
174                Some(CommandValue::Single(base_command))
175            }
176        } else {
177            Some(CommandValue::Single(base_command))
178        };
179
180        // Environment variables - secrets are handled by stage tasks
181        // Convert from BTreeMap to HashMap for Buildkite schema
182        let env: HashMap<String, String> = task
183            .env
184            .iter()
185            .map(|(k, v)| (k.clone(), v.clone()))
186            .collect();
187
188        // Build agent rules from resource tags
189        let agents = task
190            .resources
191            .as_ref()
192            .and_then(|r| AgentRules::from_tags(r.tags.clone()))
193            .or_else(|| self.default_queue.as_ref().map(AgentRules::with_queue));
194
195        // Build artifact paths from orchestrator outputs
196        let artifact_paths: Vec<String> = task
197            .outputs
198            .iter()
199            .filter(|o| o.output_type == OutputType::Orchestrator)
200            .map(|o| o.path.clone())
201            .collect();
202
203        // Build dependencies: task depends on setup stages + explicit dependencies
204        let mut depends_on: Vec<DependsOn> = Vec::new();
205
206        // Add setup stage dependencies if task has a runtime or needs 1Password
207        if task.runtime.is_some() || ir.pipeline.requires_onepassword {
208            for setup_key in setup_keys {
209                depends_on.push(DependsOn::Key(setup_key.clone()));
210            }
211        }
212
213        // Add explicit task dependencies
214        for dep in &task.depends_on {
215            if let Some(approval_key) = approval_keys.get(dep) {
216                depends_on.push(DependsOn::Key(approval_key.clone()));
217            } else {
218                depends_on.push(DependsOn::Key(dep.clone()));
219            }
220        }
221
222        // If this task has manual approval, depend on its own approval step
223        if let Some(approval_key) = approval_keys.get(&task.id) {
224            depends_on.push(DependsOn::Key(approval_key.clone()));
225        }
226
227        // Handle concurrency
228        let (concurrency_group, concurrency) = task
229            .concurrency_group
230            .as_ref()
231            .map_or((None, None), |group| (Some(group.clone()), Some(1)));
232
233        CommandStep {
234            label: Some(label),
235            key: Some(task.id.clone()),
236            command,
237            env,
238            agents,
239            artifact_paths,
240            depends_on,
241            concurrency_group,
242            concurrency,
243            retry: None,
244            timeout_in_minutes: None,
245            soft_fail: None,
246        }
247    }
248
249    /// Build a block step for manual approval
250    fn build_block_step(&self, task: &Task, approval_key: &str) -> BlockStep {
251        let label = if self.use_emojis {
252            format!(":hand: Approve {}", task.id)
253        } else {
254            format!("Approve {}", task.id)
255        };
256
257        let mut depends_on: Vec<DependsOn> = task
258            .depends_on
259            .iter()
260            .map(|dep| DependsOn::Key(dep.clone()))
261            .collect();
262
263        // Block step inherits the task's dependencies
264        if depends_on.is_empty() {
265            depends_on = Vec::new();
266        }
267
268        BlockStep {
269            block: label,
270            key: Some(approval_key.to_string()),
271            depends_on,
272            prompt: Some(format!("Approve execution of {}", task.id)),
273            fields: Vec::new(),
274        }
275    }
276
277    /// Format a step label with optional emoji
278    fn format_label(&self, task_id: &str, is_deployment: bool) -> String {
279        if self.use_emojis {
280            let emoji = if is_deployment { ":rocket:" } else { ":gear:" };
281            format!("{emoji} {task_id}")
282        } else {
283            task_id.to_string()
284        }
285    }
286}
287
288impl Emitter for BuildkiteEmitter {
289    /// Emit a thin mode Buildkite pipeline.
290    ///
291    /// Thin mode generates a bootstrap pipeline that:
292    /// 1. Installs Nix
293    /// 2. Builds cuenv
294    /// 3. Calls `cuenv ci --pipeline <name>` for orchestration
295    fn emit_thin(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
296        let pipeline_name = &ir.pipeline.name;
297
298        // Build a bootstrap pipeline that delegates to cuenv
299        let mut steps = Vec::new();
300
301        // Bootstrap phase tasks (e.g., install-nix)
302        for task in ir.sorted_phase_tasks(BuildStage::Bootstrap) {
303            steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
304        }
305
306        // Setup phase tasks (e.g., cachix, setup-cuenv)
307        for task in ir.sorted_phase_tasks(BuildStage::Setup) {
308            steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
309        }
310
311        // Main execution step: cuenv ci --pipeline <name>
312        let cuenv_command = format!("cuenv ci --pipeline {pipeline_name}");
313        let main_step = CommandStep {
314            label: Some(self.format_label(&format!("Run pipeline: {pipeline_name}"), false)),
315            key: Some("cuenv-ci".to_string()),
316            command: Some(CommandValue::Single(cuenv_command)),
317            env: HashMap::new(),
318            agents: self.default_queue.as_ref().map(AgentRules::with_queue),
319            artifact_paths: vec![],
320            depends_on: ir
321                .sorted_phase_tasks(BuildStage::Setup)
322                .last()
323                .map(|t| vec![DependsOn::Key(t.id.clone())])
324                .unwrap_or_default(),
325            concurrency_group: None,
326            concurrency: None,
327            retry: None,
328            timeout_in_minutes: None,
329            soft_fail: None,
330        };
331        steps.push(Step::Command(Box::new(main_step)));
332
333        let pipeline = Pipeline {
334            steps,
335            env: HashMap::new(),
336        };
337
338        serde_yaml::to_string(&pipeline).map_err(|e| EmitterError::Serialization(e.to_string()))
339    }
340
341    /// Emit an expanded mode Buildkite pipeline.
342    ///
343    /// Expanded mode generates a full pipeline where each task becomes a separate step
344    /// with dependencies managed by Buildkite.
345    fn emit_expanded(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
346        let pipeline = self.build_pipeline(ir);
347        serde_yaml::to_string(&pipeline).map_err(|e| EmitterError::Serialization(e.to_string()))
348    }
349
350    fn format_name(&self) -> &'static str {
351        "buildkite"
352    }
353
354    fn file_extension(&self) -> &'static str {
355        "yml"
356    }
357
358    fn description(&self) -> &'static str {
359        "Buildkite pipeline YAML emitter"
360    }
361
362    fn validate(&self, ir: &IntermediateRepresentation) -> EmitterResult<()> {
363        // Validate that all task IDs are valid Buildkite keys
364        for task in &ir.tasks {
365            if task.id.contains(' ') {
366                return Err(EmitterError::InvalidIR(format!(
367                    "Task ID '{}' contains spaces, which are not allowed in Buildkite keys",
368                    task.id
369                )));
370            }
371        }
372
373        // Validate dependencies exist
374        let task_ids: std::collections::HashSet<_> = ir.tasks.iter().map(|t| &t.id).collect();
375        for task in &ir.tasks {
376            for dep in &task.depends_on {
377                if !task_ids.contains(dep) {
378                    return Err(EmitterError::InvalidIR(format!(
379                        "Task '{}' depends on non-existent task '{}'",
380                        task.id, dep
381                    )));
382                }
383            }
384        }
385
386        Ok(())
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use cuenv_ci::ir::{
394        CachePolicy, OutputDeclaration, PipelineMetadata, PurityMode, ResourceRequirements,
395        Runtime, SecretConfig,
396    };
397    use cuenv_core::ci::PipelineMode;
398    use std::collections::BTreeMap;
399
400    /// Create an IR for testing expanded mode behavior.
401    /// Uses PipelineMode::Expanded explicitly since tests check multi-job output.
402    fn make_ir(tasks: Vec<Task>) -> IntermediateRepresentation {
403        IntermediateRepresentation {
404            version: "1.4".to_string(),
405            pipeline: PipelineMetadata {
406                name: "test-pipeline".to_string(),
407                mode: PipelineMode::Expanded,
408                environment: None,
409                requires_onepassword: false,
410                project_name: None,
411                trigger: None,
412                pipeline_tasks: vec![],
413                pipeline_task_defs: vec![],
414            },
415            runtimes: vec![],
416            tasks,
417        }
418    }
419
420    fn make_task(id: &str, command: &[&str]) -> Task {
421        Task {
422            id: id.to_string(),
423            runtime: None,
424            command: command.iter().map(|s| (*s).to_string()).collect(),
425            shell: false,
426            env: BTreeMap::new(),
427            secrets: BTreeMap::new(),
428            resources: None,
429            concurrency_group: None,
430            inputs: vec![],
431            outputs: vec![],
432            depends_on: vec![],
433            cache_policy: CachePolicy::Normal,
434            deployment: false,
435            manual_approval: false,
436            matrix: None,
437            artifact_downloads: vec![],
438            params: BTreeMap::new(),
439            phase: None,
440            label: None,
441            priority: None,
442            contributor: None,
443            condition: None,
444            provider_hints: None,
445        }
446    }
447
448    #[test]
449    fn test_simple_pipeline() {
450        let emitter = BuildkiteEmitter::new();
451        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
452
453        let yaml = emitter.emit(&ir).unwrap();
454
455        assert!(yaml.contains("steps:"));
456        assert!(yaml.contains("key: build"));
457        // Commands are wrapped with cuenv task
458        assert!(yaml.contains("cuenv task build"));
459    }
460
461    #[test]
462    fn test_with_dependencies() {
463        let emitter = BuildkiteEmitter::new();
464        let mut test_task = make_task("test", &["cargo", "test"]);
465        test_task.depends_on = vec!["build".to_string()];
466
467        let ir = make_ir(vec![make_task("build", &["cargo", "build"]), test_task]);
468
469        let yaml = emitter.emit(&ir).unwrap();
470
471        assert!(yaml.contains("depends_on:"));
472        assert!(yaml.contains("- build"));
473    }
474
475    #[test]
476    fn test_with_manual_approval() {
477        let emitter = BuildkiteEmitter::new().with_emojis();
478        let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
479        deploy_task.manual_approval = true;
480        deploy_task.deployment = true;
481
482        let ir = make_ir(vec![deploy_task]);
483
484        let yaml = emitter.emit(&ir).unwrap();
485
486        assert!(yaml.contains("block:"));
487        assert!(yaml.contains("Approve deploy"));
488        assert!(yaml.contains("deploy-approval"));
489    }
490
491    #[test]
492    fn test_with_concurrency_group() {
493        let emitter = BuildkiteEmitter::new();
494        let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
495        deploy_task.concurrency_group = Some("production".to_string());
496
497        let ir = make_ir(vec![deploy_task]);
498
499        let yaml = emitter.emit(&ir).unwrap();
500
501        assert!(yaml.contains("concurrency_group: production"));
502        assert!(yaml.contains("concurrency: 1"));
503    }
504
505    #[test]
506    fn test_with_agent_queue() {
507        let emitter = BuildkiteEmitter::new();
508        let mut task = make_task("build", &["cargo", "build"]);
509        task.resources = Some(ResourceRequirements {
510            cpu: None,
511            memory: None,
512            tags: vec!["linux-x86".to_string()],
513        });
514
515        let ir = make_ir(vec![task]);
516
517        let yaml = emitter.emit(&ir).unwrap();
518
519        assert!(yaml.contains("agents:"));
520        assert!(yaml.contains("queue: linux-x86"));
521    }
522
523    #[test]
524    fn test_with_secrets() {
525        let emitter = BuildkiteEmitter::new();
526        let mut task = make_task("deploy", &["./deploy.sh"]);
527        task.secrets.insert(
528            "API_KEY".to_string(),
529            SecretConfig {
530                source: "BUILDKITE_SECRET_API_KEY".to_string(),
531                cache_key: false,
532            },
533        );
534
535        let ir = make_ir(vec![task]);
536
537        let yaml = emitter.emit(&ir).unwrap();
538
539        // Secrets are now handled by cuenv task internally, not mapped to env vars
540        assert!(yaml.contains("cuenv task deploy"));
541    }
542
543    #[test]
544    fn test_with_artifacts() {
545        let emitter = BuildkiteEmitter::new();
546        let mut task = make_task("build", &["cargo", "build"]);
547        task.outputs = vec![OutputDeclaration {
548            path: "target/release/binary".to_string(),
549            output_type: OutputType::Orchestrator,
550        }];
551
552        let ir = make_ir(vec![task]);
553
554        let yaml = emitter.emit(&ir).unwrap();
555
556        assert!(yaml.contains("artifact_paths:"));
557        assert!(yaml.contains("target/release/binary"));
558    }
559
560    #[test]
561    fn test_default_queue() {
562        let emitter = BuildkiteEmitter::new().with_default_queue("default");
563        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
564
565        let yaml = emitter.emit(&ir).unwrap();
566
567        assert!(yaml.contains("agents:"));
568        assert!(yaml.contains("queue: default"));
569    }
570
571    #[test]
572    fn test_emojis() {
573        let emitter = BuildkiteEmitter::new().with_emojis();
574        let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
575
576        let yaml = emitter.emit(&ir).unwrap();
577
578        assert!(yaml.contains(":gear:"));
579    }
580
581    #[test]
582    fn test_validation_invalid_id() {
583        let emitter = BuildkiteEmitter::new();
584        let ir = make_ir(vec![make_task("invalid task", &["echo"])]);
585
586        let result = emitter.validate(&ir);
587        assert!(result.is_err());
588    }
589
590    #[test]
591    fn test_validation_missing_dependency() {
592        let emitter = BuildkiteEmitter::new();
593        let mut task = make_task("test", &["cargo", "test"]);
594        task.depends_on = vec!["nonexistent".to_string()];
595
596        let ir = make_ir(vec![task]);
597
598        let result = emitter.validate(&ir);
599        assert!(result.is_err());
600    }
601
602    #[test]
603    fn test_format_name() {
604        let emitter = BuildkiteEmitter::new();
605        assert_eq!(emitter.format_name(), "buildkite");
606        assert_eq!(emitter.file_extension(), "yml");
607    }
608
609    /// Helper to create a phase task for testing
610    fn make_phase_task(id: &str, command: &[&str], phase: BuildStage, priority: i32) -> Task {
611        Task {
612            id: id.to_string(),
613            runtime: None,
614            command: command.iter().map(|s| (*s).to_string()).collect(),
615            shell: command.len() == 1, // Single command = shell mode
616            env: BTreeMap::new(),
617            secrets: BTreeMap::new(),
618            resources: None,
619            concurrency_group: None,
620            inputs: vec![],
621            outputs: vec![],
622            depends_on: vec![],
623            cache_policy: CachePolicy::Disabled,
624            deployment: false,
625            manual_approval: false,
626            matrix: None,
627            artifact_downloads: vec![],
628            params: BTreeMap::new(),
629            phase: Some(phase),
630            label: None,
631            priority: Some(priority),
632            contributor: None,
633            condition: None,
634            provider_hints: None,
635        }
636    }
637
638    #[test]
639    fn test_with_nix_runtime() {
640        let emitter = BuildkiteEmitter::new();
641
642        // Create a task that references a Nix runtime
643        let mut task = make_task("build", &["cargo", "build"]);
644        task.runtime = Some("nix-rust".to_string());
645
646        // Create phase tasks that would be contributed by NixContributor
647        let mut bootstrap_task = make_phase_task(
648            "install-nix",
649            &[
650                "curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --no-confirm --init none",
651            ],
652            BuildStage::Bootstrap,
653            0,
654        );
655        bootstrap_task.label = Some("Install Nix".to_string());
656        bootstrap_task.contributor = Some("nix".to_string());
657
658        let mut setup_task = make_phase_task(
659            "setup-cuenv",
660            &[
661                ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh && nix build .#cuenv --accept-flake-config",
662            ],
663            BuildStage::Setup,
664            10,
665        );
666        setup_task.label = Some("Setup cuenv".to_string());
667        setup_task.contributor = Some("cuenv".to_string());
668        setup_task.depends_on = vec!["install-nix".to_string()];
669
670        // Create IR with runtime definition and phase tasks
671        let mut ir = make_ir(vec![bootstrap_task, setup_task, task]);
672        ir.runtimes.push(Runtime {
673            id: "nix-rust".to_string(),
674            flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
675            output: "devShells.x86_64-linux.default".to_string(),
676            system: "x86_64-linux".to_string(),
677            digest: "sha256:abc123".to_string(),
678            purity: PurityMode::Strict,
679        });
680
681        let yaml = emitter.emit(&ir).unwrap();
682
683        // Should contain Nix bootstrap from phase tasks
684        assert!(yaml.contains("install.determinate.systems/nix"));
685        assert!(yaml.contains("nix-daemon.sh"));
686
687        // Should contain nix develop command with flake reference
688        assert!(yaml.contains("nix develop"));
689        assert!(yaml.contains("github:NixOS/nixpkgs/nixos-unstable"));
690        assert!(yaml.contains("devShells.x86_64-linux.default"));
691
692        // Should wrap cuenv task
693        assert!(yaml.contains("cuenv task build"));
694    }
695
696    #[test]
697    fn test_without_runtime_no_nix_setup() {
698        let emitter = BuildkiteEmitter::new();
699
700        // Task without runtime
701        let task = make_task("build", &["cargo", "build"]);
702        let ir = make_ir(vec![task]);
703
704        let yaml = emitter.emit(&ir).unwrap();
705
706        // Should NOT contain Nix bootstrap
707        assert!(!yaml.contains("install.determinate.systems/nix"));
708        assert!(!yaml.contains("nix develop"));
709
710        // Should just have plain cuenv task
711        assert!(yaml.contains("cuenv task build"));
712    }
713}