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}