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}