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