Skip to main content

opal/execution_plan/
build.rs

1use crate::compiler::CompiledPipeline;
2use crate::execution_plan::{ExecutableJob, ExecutionPlan};
3use crate::model::JobSpec;
4use anyhow::{Result, anyhow};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8pub fn build_execution_plan<F>(compiled: CompiledPipeline, mut log_info: F) -> Result<ExecutionPlan>
9where
10    F: FnMut(&JobSpec) -> (PathBuf, String),
11{
12    let CompiledPipeline {
13        ordered,
14        jobs,
15        dependents,
16        order_index,
17        variants,
18    } = compiled;
19    let mut nodes = HashMap::new();
20    for name in &ordered {
21        let compiled = jobs
22            .get(name)
23            .cloned()
24            .ok_or_else(|| anyhow!("compiled job '{}' missing from output", name))?;
25        let (log_path, log_hash) = log_info(&compiled.job);
26        nodes.insert(
27            name.clone(),
28            ExecutableJob {
29                instance: compiled,
30                log_path,
31                log_hash,
32            },
33        );
34    }
35    Ok(ExecutionPlan {
36        ordered,
37        nodes,
38        dependents,
39        order_index,
40        variants,
41    })
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use crate::compiler::{JobInstance, JobVariantInfo, compile_pipeline};
48    use crate::model::{
49        ArtifactSpec, DependencySourceSpec, JobDependencySpec, JobSpec, PipelineSpec,
50        RetryPolicySpec,
51    };
52    use crate::pipeline::rules::RuleContext;
53    use crate::pipeline::rules::RuleEvaluation;
54    use std::collections::HashMap;
55    use std::path::Path;
56
57    #[test]
58    fn build_execution_plan_assigns_log_targets_and_preserves_runtime_metadata() {
59        let compiled = CompiledPipeline {
60            ordered: vec!["build".into()],
61            jobs: HashMap::from([(
62                "build".into(),
63                JobInstance {
64                    job: job("build"),
65                    stage_name: "compile".into(),
66                    dependencies: vec!["setup".into()],
67                    rule: RuleEvaluation {
68                        allow_failure: true,
69                        ..RuleEvaluation::default()
70                    },
71                    timeout: Some(std::time::Duration::from_secs(30)),
72                    retry: RetryPolicySpec {
73                        max: 2,
74                        when: vec!["runner_system_failure".into()],
75                        exit_codes: Vec::new(),
76                    },
77                    interruptible: true,
78                    resource_group: Some("builder".into()),
79                },
80            )]),
81            dependents: HashMap::from([("setup".into(), vec!["build".into()])]),
82            order_index: HashMap::from([("build".into(), 0)]),
83            variants: HashMap::new(),
84        };
85
86        let plan = build_execution_plan(compiled, |job| {
87            (
88                PathBuf::from(format!("/tmp/{}.log", job.name)),
89                format!("hash-{}", job.name),
90            )
91        })
92        .expect("execution plan builds");
93
94        let executable = plan.nodes.get("build").expect("job exists");
95        assert_eq!(plan.ordered, vec!["build".to_string()]);
96        assert_eq!(executable.instance.stage_name, "compile");
97        assert_eq!(executable.instance.dependencies, vec!["setup".to_string()]);
98        assert_eq!(executable.log_path, PathBuf::from("/tmp/build.log"));
99        assert_eq!(executable.log_hash, "hash-build");
100        assert!(executable.instance.rule.allow_failure);
101        assert_eq!(
102            executable.instance.timeout,
103            Some(std::time::Duration::from_secs(30))
104        );
105        assert_eq!(executable.instance.retry.max, 2);
106        assert!(executable.instance.interruptible);
107        assert_eq!(
108            executable.instance.resource_group.as_deref(),
109            Some("builder")
110        );
111        assert_eq!(plan.dependents["setup"], vec!["build".to_string()]);
112    }
113
114    #[test]
115    fn build_execution_plan_preserves_variant_lookup_for_dependencies() {
116        let compiled = CompiledPipeline {
117            ordered: vec!["build: [linux, release]".into()],
118            jobs: HashMap::from([(
119                "build: [linux, release]".into(),
120                JobInstance {
121                    job: job("build: [linux, release]"),
122                    stage_name: "build".into(),
123                    dependencies: Vec::new(),
124                    rule: RuleEvaluation::default(),
125                    timeout: None,
126                    retry: RetryPolicySpec::default(),
127                    interruptible: false,
128                    resource_group: None,
129                },
130            )]),
131            dependents: HashMap::new(),
132            order_index: HashMap::from([("build: [linux, release]".into(), 0)]),
133            variants: HashMap::from([(
134                "build".into(),
135                vec![JobVariantInfo {
136                    name: "build: [linux, release]".into(),
137                    labels: HashMap::from([
138                        ("OS".into(), "linux".into()),
139                        ("MODE".into(), "release".into()),
140                    ]),
141                    ordered_values: vec!["linux".into(), "release".into()],
142                }],
143            )]),
144        };
145
146        let plan = build_execution_plan(compiled, |job| {
147            (
148                PathBuf::from(format!("/tmp/{}.log", job.name)),
149                "hash".into(),
150            )
151        })
152        .expect("execution plan builds");
153        let dep = JobDependencySpec {
154            job: "build".into(),
155            needs_artifacts: false,
156            optional: false,
157            source: DependencySourceSpec::Local,
158            parallel: None,
159            inline_variant: Some(vec!["linux".into(), "release".into()]),
160        };
161
162        assert_eq!(
163            plan.variants_for_dependency(&dep),
164            vec!["build: [linux, release]".to_string()]
165        );
166    }
167
168    #[test]
169    fn build_execution_plan_errors_when_order_references_missing_job() {
170        let compiled = CompiledPipeline {
171            ordered: vec!["missing".into()],
172            jobs: HashMap::new(),
173            dependents: HashMap::new(),
174            order_index: HashMap::new(),
175            variants: HashMap::new(),
176        };
177
178        let err = build_execution_plan(compiled, |_job| {
179            (PathBuf::from("/tmp/unused.log"), "unused".into())
180        })
181        .expect_err("missing job should error");
182
183        assert!(err.to_string().contains("compiled job 'missing' missing"));
184    }
185
186    #[test]
187    fn build_execution_plan_resolves_matrix_needs_to_variant_names() {
188        let pipeline = PipelineSpec::from_path(Path::new(
189            "pipelines/tests/needs-and-artifacts.gitlab-ci.yml",
190        ))
191        .expect("pipeline loads");
192        let ctx = RuleContext::from_env(
193            Path::new("."),
194            HashMap::from([
195                ("CI_PIPELINE_SOURCE".into(), "push".into()),
196                ("CI_COMMIT_BRANCH".into(), "main".into()),
197                ("CI_COMMIT_REF_NAME".into(), "main".into()),
198                ("CI_COMMIT_REF_SLUG".into(), "main".into()),
199            ]),
200            false,
201        );
202        let compiled = compile_pipeline(&pipeline, Some(&ctx)).expect("pipeline compiles");
203        let plan = build_execution_plan(compiled, |_job| (PathBuf::new(), String::new()))
204            .expect("execution plan builds");
205
206        assert!(plan.nodes.contains_key("build-matrix: [linux, release]"));
207        let package = plan.nodes.get("package-linux").expect("package job exists");
208        assert!(
209            package
210                .instance
211                .job
212                .dependencies
213                .iter()
214                .any(|dep| dep == "build-matrix: [linux, release]")
215        );
216        assert!(
217            package
218                .instance
219                .dependencies
220                .iter()
221                .any(|dep| dep == "build-matrix: [linux, release]")
222        );
223        let matrix_need = package
224            .instance
225            .job
226            .needs
227            .iter()
228            .find(|need| need.job == "build-matrix")
229            .expect("matrix dependency present");
230        let variants = plan.variants_for_dependency(matrix_need);
231        assert_eq!(variants, vec!["build-matrix: [linux, release]".to_string()]);
232    }
233
234    #[test]
235    fn build_execution_plan_preserves_inline_variant_metadata() {
236        let pipeline = PipelineSpec::from_path(Path::new(
237            "pipelines/tests/needs-and-artifacts.gitlab-ci.yml",
238        ))
239        .expect("pipeline loads");
240        let ctx = RuleContext::from_env(
241            Path::new("."),
242            HashMap::from([
243                ("CI_PIPELINE_SOURCE".into(), "push".into()),
244                ("CI_COMMIT_BRANCH".into(), "main".into()),
245                ("CI_COMMIT_REF_NAME".into(), "main".into()),
246                ("CI_COMMIT_REF_SLUG".into(), "main".into()),
247            ]),
248            false,
249        );
250        let compiled = compile_pipeline(&pipeline, Some(&ctx)).expect("pipeline compiles");
251        let plan = build_execution_plan(compiled, |_job| (PathBuf::new(), String::new()))
252            .expect("execution plan builds");
253
254        let package = plan.nodes.get("package-linux").expect("package job exists");
255        let matrix_need = package
256            .instance
257            .job
258            .needs
259            .iter()
260            .find(|need| need.job == "build-matrix")
261            .expect("matrix dependency present");
262        assert_eq!(
263            matrix_need.inline_variant,
264            Some(vec!["linux".to_string(), "release".to_string()])
265        );
266    }
267
268    #[test]
269    fn selected_jobs_include_upstream_dependencies() {
270        let pipeline = PipelineSpec::from_path(Path::new(
271            "pipelines/tests/needs-and-artifacts.gitlab-ci.yml",
272        ))
273        .expect("pipeline loads");
274        let ctx = RuleContext::from_env(
275            Path::new("."),
276            HashMap::from([
277                ("CI_COMMIT_BRANCH".into(), "main".into()),
278                ("CI_PIPELINE_SOURCE".into(), "push".into()),
279                ("CI_COMMIT_REF_NAME".into(), "main".into()),
280            ]),
281            false,
282        );
283        let compiled = compile_pipeline(&pipeline, Some(&ctx)).expect("pipeline compiles");
284        let plan = build_execution_plan(compiled, |_job| (PathBuf::new(), String::new()))
285            .expect("execution plan builds")
286            .select_jobs(&["package-linux".into()])
287            .expect("selection succeeds");
288
289        assert!(plan.nodes.contains_key("package-linux"));
290        assert!(plan.nodes.contains_key("prepare-artifacts"));
291        assert!(plan.nodes.contains_key("build-matrix: [linux, release]"));
292        assert!(!plan.nodes.contains_key("smoke-tests"));
293    }
294
295    #[test]
296    fn selecting_base_name_includes_all_variants() {
297        let pipeline = PipelineSpec::from_path(Path::new(
298            "pipelines/tests/control-flow-parity.gitlab-ci.yml",
299        ))
300        .expect("pipeline loads");
301        let ctx = RuleContext::from_env(
302            Path::new("."),
303            HashMap::from([
304                ("CI_COMMIT_BRANCH".into(), "main".into()),
305                ("CI_PIPELINE_SOURCE".into(), "push".into()),
306                ("CI_COMMIT_REF_NAME".into(), "main".into()),
307            ]),
308            false,
309        );
310        let compiled = compile_pipeline(&pipeline, Some(&ctx)).expect("pipeline compiles");
311        let plan = build_execution_plan(compiled, |_job| (PathBuf::new(), String::new()))
312            .expect("execution plan builds")
313            .select_jobs(&["parallel-fanout".into()])
314            .expect("selection succeeds");
315
316        assert!(plan.nodes.contains_key("parallel-fanout: [1]"));
317        assert!(plan.nodes.contains_key("parallel-fanout: [2]"));
318        assert_eq!(plan.nodes.len(), 2);
319    }
320
321    fn job(name: &str) -> JobSpec {
322        JobSpec {
323            name: name.into(),
324            stage: "build".into(),
325            commands: vec!["true".into()],
326            needs: Vec::new(),
327            explicit_needs: false,
328            dependencies: Vec::new(),
329            before_script: None,
330            after_script: None,
331            inherit_default_before_script: true,
332            inherit_default_after_script: true,
333            inherit_default_image: true,
334            inherit_default_cache: true,
335            inherit_default_services: true,
336            inherit_default_timeout: true,
337            inherit_default_retry: true,
338            inherit_default_interruptible: true,
339            when: None,
340            rules: Vec::new(),
341            only: Vec::new(),
342            except: Vec::new(),
343            artifacts: ArtifactSpec::default(),
344            cache: Vec::new(),
345            image: None,
346            variables: HashMap::new(),
347            services: Vec::new(),
348            timeout: None,
349            retry: RetryPolicySpec::default(),
350            interruptible: false,
351            resource_group: None,
352            parallel: None,
353            tags: Vec::new(),
354            environment: None,
355        }
356    }
357}