1use crate::git;
2use crate::model::{EnvironmentSpec, JobSpec};
3use crate::naming::job_name_slug;
4use crate::secrets::SecretsStore;
5use anyhow::{Context, Result};
6use globset::{Glob, GlobSetBuilder};
7use std::collections::HashMap;
8use std::env;
9use std::path::Path;
10
11pub fn build_include_lookup(
12 workdir: &Path,
13 host_env: &HashMap<String, String>,
14) -> HashMap<String, String> {
15 let mut lookup = host_env.clone();
16 for (key, value) in inferred_ci_env(workdir, host_env) {
17 lookup.entry(key).or_insert(value);
18 }
19 lookup
20}
21
22pub fn collect_env_vars(patterns: &[String]) -> Result<Vec<(String, String)>> {
23 if patterns.is_empty() {
24 return Ok(Vec::new());
25 }
26
27 let mut builder = GlobSetBuilder::new();
28 for pattern in patterns {
29 let glob =
30 Glob::new(pattern).with_context(|| format!("invalid --env pattern '{pattern}'"))?;
31 builder.add(glob);
32 }
33 let matcher = builder.build()?;
34
35 let vars = env::vars()
36 .filter(|(key, _)| matcher.is_match(key))
37 .collect();
38 Ok(vars)
39}
40
41#[allow(clippy::too_many_arguments)]
42pub fn build_job_env(
43 base_env: &[(String, String)],
44 default_vars: &HashMap<String, String>,
45 job: &JobSpec,
46 secrets: &SecretsStore,
47 host_workdir: &Path,
48 container_workdir: &Path,
49 container_root: &Path,
50 run_id: &str,
51 host_env: &HashMap<String, String>,
52) -> Vec<(String, String)> {
53 let mut env = Vec::new();
54 let mut push = |key: &str, value: &str| {
55 if let Some(existing) = env.iter_mut().find(|(k, _)| k == key) {
56 existing.1 = value.to_string();
57 } else {
58 env.push((key.to_string(), value.to_string()));
59 }
60 };
61
62 for (key, value) in base_env {
63 push(key, value);
64 }
65 for (key, value) in default_vars {
66 push(key, value);
67 }
68 for (key, value) in &job.variables {
69 push(key, value);
70 }
71
72 push("CI", "true");
73 push("GITLAB_CI", "true");
74 push("CI_JOB_NAME", &job.name);
75 push("CI_JOB_NAME_SLUG", &job_name_slug(&job.name));
76 push("CI_JOB_STAGE", &job.stage);
77 push("CI_PROJECT_DIR", &container_workdir.display().to_string());
78 push("CI_BUILDS_DIR", &container_root.display().to_string());
79 push("CI_PIPELINE_ID", run_id);
80 push("OPAL_IN_OPAL", "1");
81
82 for (key, value) in inferred_ci_env(host_workdir, host_env) {
83 push(&key, &value);
84 }
85
86 if let Some(timeout) = job.timeout {
87 push("CI_JOB_TIMEOUT", &timeout.as_secs().to_string());
88 }
89
90 if secrets.has_secrets() {
91 secrets.extend_env(&mut env);
92 }
93
94 expand_env_list(&mut env[..], host_env);
95
96 env
97}
98
99pub fn expand_env_list(env: &mut [(String, String)], host_env: &HashMap<String, String>) {
100 let mut lookup: HashMap<String, String> = HashMap::with_capacity(host_env.len() + env.len());
101 for (key, value) in host_env {
102 lookup.insert(key.clone(), value.clone());
103 }
104 for (key, value) in env.iter() {
105 lookup.entry(key.clone()).or_insert_with(|| value.clone());
106 }
107 for (key, value) in env.iter_mut() {
108 let expanded = expand_value(value, &lookup);
109 *value = expanded.clone();
110 lookup.insert(key.clone(), expanded);
111 }
112}
113
114pub fn expand_environment(
115 environment: &EnvironmentSpec,
116 lookup: &HashMap<String, String>,
117) -> EnvironmentSpec {
118 EnvironmentSpec {
119 name: expand_value(&environment.name, lookup),
120 url: environment
121 .url
122 .as_ref()
123 .map(|value| expand_value(value, lookup)),
124 on_stop: environment
125 .on_stop
126 .as_ref()
127 .map(|value| expand_value(value, lookup)),
128 auto_stop_in: environment.auto_stop_in,
129 action: environment.action,
130 }
131}
132
133pub fn expand_value(value: &str, lookup: &HashMap<String, String>) -> String {
134 let chars: Vec<char> = value.chars().collect();
135 let mut idx = 0;
136 let mut output = String::new();
137 while idx < chars.len() {
138 let ch = chars[idx];
139 if ch == '$' && idx + 1 < chars.len() {
140 match chars[idx + 1] {
141 '$' => {
142 output.push('$');
143 idx += 2;
144 continue;
145 }
146 '{' => {
147 let mut end = idx + 2;
148 while end < chars.len() && chars[end] != '}' {
149 end += 1;
150 }
151 if end < chars.len() {
152 let expr: String = chars[idx + 2..end].iter().collect();
153 if let Some((name, default)) = expr.split_once(":-") {
154 if let Some(val) = lookup.get(name).filter(|val| !val.is_empty()) {
155 output.push_str(val);
156 } else {
157 output.push_str(&expand_value(default, lookup));
158 }
159 } else if let Some(val) = lookup.get(&expr) {
160 output.push_str(val);
161 }
162 idx = end + 1;
163 continue;
164 }
165 }
166 c if is_var_char(c) => {
167 let mut end = idx + 1;
168 while end < chars.len() && is_var_char(chars[end]) {
169 end += 1;
170 }
171 let name: String = chars[idx + 1..end].iter().collect();
172 if let Some(val) = lookup.get(&name) {
173 output.push_str(val);
174 }
175 idx = end;
176 continue;
177 }
178 _ => {}
179 }
180 }
181 output.push(ch);
182 idx += 1;
183 }
184 output
185}
186
187fn is_var_char(ch: char) -> bool {
188 ch == '_' || ch.is_ascii_alphanumeric()
189}
190
191fn inferred_ci_env(workdir: &Path, host_env: &HashMap<String, String>) -> Vec<(String, String)> {
192 let mut inferred = Vec::new();
193
194 insert_inferred_env(
195 &mut inferred,
196 "CI_PIPELINE_SOURCE",
197 host_env,
198 Some(|| Ok("push".to_string())),
199 );
200 if let Some(branch) = host_env
201 .get("CI_COMMIT_BRANCH")
202 .filter(|value| !value.is_empty())
203 {
204 inferred.push(("CI_COMMIT_BRANCH".into(), branch.clone()));
205 } else if host_env
206 .get("CI_COMMIT_TAG")
207 .filter(|value| !value.is_empty())
208 .or_else(|| {
209 host_env
210 .get("GIT_COMMIT_TAG")
211 .filter(|value| !value.is_empty())
212 })
213 .is_none()
214 && let Ok(branch) = git::current_branch(workdir)
215 && !branch.is_empty()
216 {
217 inferred.push(("CI_COMMIT_BRANCH".into(), branch));
218 }
219 if let Some(tag) = host_env
220 .get("CI_COMMIT_TAG")
221 .filter(|value| !value.is_empty())
222 .or_else(|| {
223 host_env
224 .get("GIT_COMMIT_TAG")
225 .filter(|value| !value.is_empty())
226 })
227 {
228 inferred.push(("CI_COMMIT_TAG".into(), tag.clone()));
229 } else if let Ok(tag) = git::current_tag(workdir)
230 && !tag.is_empty()
231 {
232 inferred.push(("CI_COMMIT_TAG".into(), tag));
233 }
234 insert_inferred_env(
235 &mut inferred,
236 "CI_DEFAULT_BRANCH",
237 host_env,
238 Some(|| git::default_branch(workdir)),
239 );
240
241 if host_env
242 .get("CI_COMMIT_REF_NAME")
243 .is_none_or(|value| value.is_empty())
244 {
245 if let Some(tag) = host_env
246 .get("CI_COMMIT_TAG")
247 .filter(|value| !value.is_empty())
248 .cloned()
249 .or_else(|| {
250 inferred
251 .iter()
252 .find(|(key, _)| key == "CI_COMMIT_TAG")
253 .map(|(_, value)| value.clone())
254 })
255 {
256 inferred.push(("CI_COMMIT_REF_NAME".into(), tag));
257 } else if let Some(branch) = host_env
258 .get("CI_COMMIT_BRANCH")
259 .filter(|value| !value.is_empty())
260 .cloned()
261 .or_else(|| {
262 inferred
263 .iter()
264 .find(|(key, _)| key == "CI_COMMIT_BRANCH")
265 .map(|(_, value)| value.clone())
266 })
267 {
268 inferred.push(("CI_COMMIT_REF_NAME".into(), branch));
269 }
270 }
271 if host_env
272 .get("CI_COMMIT_REF_SLUG")
273 .is_none_or(|value| value.is_empty())
274 && let Some(ref_name) = host_env
275 .get("CI_COMMIT_REF_NAME")
276 .filter(|value| !value.is_empty())
277 .cloned()
278 .or_else(|| {
279 inferred
280 .iter()
281 .find(|(key, _)| key == "CI_COMMIT_REF_NAME")
282 .map(|(_, value)| value.clone())
283 })
284 {
285 let slug = job_name_slug(&ref_name);
286 if !slug.is_empty() {
287 inferred.push(("CI_COMMIT_REF_SLUG".into(), slug));
288 }
289 }
290
291 inferred
292}
293
294fn insert_inferred_env<F>(
295 env: &mut Vec<(String, String)>,
296 key: &str,
297 host_env: &HashMap<String, String>,
298 fallback: Option<F>,
299) where
300 F: FnOnce() -> Result<String>,
301{
302 if let Some(value) = host_env.get(key).filter(|value| !value.is_empty()) {
303 env.push((key.to_string(), value.clone()));
304 return;
305 }
306 if let Some(fallback) = fallback
307 && let Ok(value) = fallback()
308 && !value.is_empty()
309 {
310 env.push((key.to_string(), value));
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::git::test_support::init_repo_with_commit_and_tag;
318 use crate::model::{
319 ArtifactSpec, EnvironmentActionSpec, EnvironmentSpec, JobSpec, RetryPolicySpec,
320 };
321 use crate::secrets::SecretsStore;
322 use anyhow::Result;
323 use std::collections::HashMap;
324 use std::fs;
325 use std::path::PathBuf;
326 use std::time::{SystemTime, UNIX_EPOCH};
327
328 #[test]
329 fn expands_env_references() {
330 let job = JobSpec {
331 name: "lint".into(),
332 stage: "test".into(),
333 commands: Vec::new(),
334 needs: Vec::new(),
335 explicit_needs: false,
336 dependencies: Vec::new(),
337 before_script: None,
338 after_script: None,
339 inherit_default_before_script: true,
340 inherit_default_after_script: true,
341 inherit_default_image: true,
342 inherit_default_cache: true,
343 inherit_default_services: true,
344 inherit_default_timeout: true,
345 inherit_default_retry: true,
346 inherit_default_interruptible: true,
347 when: None,
348 rules: Vec::new(),
349 only: Vec::new(),
350 except: Vec::new(),
351 artifacts: ArtifactSpec::default(),
352 cache: Vec::new(),
353 image: None,
354 variables: HashMap::from([("CARGO_HOME".into(), "$CI_PROJECT_DIR/.cargo".into())]),
355 services: Vec::new(),
356 timeout: None,
357 retry: RetryPolicySpec::default(),
358 interruptible: false,
359 resource_group: None,
360 parallel: None,
361 tags: Vec::new(),
362 environment: None,
363 };
364 let env = build_job_env(
365 &[],
366 &HashMap::new(),
367 &job,
368 &SecretsStore::default(),
369 Path::new("/workspace"),
370 Path::new("/workspace"),
371 Path::new("/builds"),
372 "1",
373 &HashMap::from([("CI_PROJECT_DIR".into(), "/workspace".into())]),
374 );
375 let map: HashMap<_, _> = env.into_iter().collect();
376 assert_eq!(
377 map.get("CI_JOB_NAME_SLUG").map(String::as_str),
378 Some("lint")
379 );
380 assert_eq!(
381 map.get("CI_PROJECT_DIR").map(String::as_str),
382 Some("/workspace")
383 );
384 assert_eq!(
385 map.get("CARGO_HOME").map(String::as_str),
386 Some("/workspace/.cargo")
387 );
388 }
389
390 #[test]
391 fn expands_shell_style_default_fallbacks() {
392 let lookup = HashMap::from([
393 ("CI_COMMIT_REF_SLUG".into(), "main".into()),
394 ("CI_ENVIRONMENT_SLUG".into(), "review-main".into()),
395 ]);
396
397 assert_eq!(
398 expand_value("review/${CI_COMMIT_REF_SLUG:-local}", &lookup),
399 "review/main"
400 );
401 assert_eq!(
402 expand_value(
403 "https://${CI_ENVIRONMENT_SLUG:-fallback}.example.com",
404 &lookup
405 ),
406 "https://review-main.example.com"
407 );
408 assert_eq!(
409 expand_value("review/${MISSING_VAR:-local}", &lookup),
410 "review/local"
411 );
412 }
413
414 #[test]
415 fn expands_environment_metadata() {
416 let environment = EnvironmentSpec {
417 name: "review/${CI_COMMIT_REF_SLUG:-local}".into(),
418 url: Some("https://${CI_ENVIRONMENT_SLUG:-fallback}.example.com".into()),
419 on_stop: Some("stop-${CI_COMMIT_REF_SLUG:-local}".into()),
420 auto_stop_in: None,
421 action: EnvironmentActionSpec::Start,
422 };
423 let expanded = expand_environment(
424 &environment,
425 &HashMap::from([
426 ("CI_COMMIT_REF_SLUG".into(), "main".into()),
427 ("CI_ENVIRONMENT_SLUG".into(), "review-main".into()),
428 ]),
429 );
430
431 assert_eq!(expanded.name, "review/main");
432 assert_eq!(
433 expanded.url.as_deref(),
434 Some("https://review-main.example.com")
435 );
436 assert_eq!(expanded.on_stop.as_deref(), Some("stop-main"));
437 }
438
439 #[test]
440 fn infers_tagged_ref_vars_for_job_environment() -> Result<()> {
441 let dir = init_repo_with_commit_and_tag("v1.2.3")?;
442
443 let job = JobSpec {
444 name: "release-artifacts".into(),
445 stage: "release".into(),
446 commands: Vec::new(),
447 needs: Vec::new(),
448 explicit_needs: false,
449 dependencies: Vec::new(),
450 before_script: None,
451 after_script: None,
452 inherit_default_before_script: true,
453 inherit_default_after_script: true,
454 inherit_default_image: true,
455 inherit_default_cache: true,
456 inherit_default_services: true,
457 inherit_default_timeout: true,
458 inherit_default_retry: true,
459 inherit_default_interruptible: true,
460 when: None,
461 rules: Vec::new(),
462 only: Vec::new(),
463 except: Vec::new(),
464 artifacts: ArtifactSpec::default(),
465 cache: Vec::new(),
466 image: None,
467 variables: HashMap::new(),
468 services: Vec::new(),
469 timeout: None,
470 retry: RetryPolicySpec::default(),
471 interruptible: false,
472 resource_group: None,
473 parallel: None,
474 tags: Vec::new(),
475 environment: None,
476 };
477
478 let env = build_job_env(
479 &[],
480 &HashMap::new(),
481 &job,
482 &SecretsStore::default(),
483 dir.path(),
484 Path::new("/workspace"),
485 Path::new("/builds"),
486 "1",
487 &HashMap::new(),
488 );
489 let map: HashMap<_, _> = env.into_iter().collect();
490 assert_eq!(map.get("CI_COMMIT_TAG").map(String::as_str), Some("v1.2.3"));
491 assert_eq!(
492 map.get("CI_COMMIT_REF_NAME").map(String::as_str),
493 Some("v1.2.3")
494 );
495 Ok(())
496 }
497
498 #[test]
499 fn tagged_job_environment_does_not_infer_branch() -> Result<()> {
500 let dir = init_repo_with_commit_and_tag("v1.2.3")?;
501
502 let job = JobSpec {
503 name: "release-artifacts".into(),
504 stage: "release".into(),
505 commands: Vec::new(),
506 needs: Vec::new(),
507 explicit_needs: false,
508 dependencies: Vec::new(),
509 before_script: None,
510 after_script: None,
511 inherit_default_before_script: true,
512 inherit_default_after_script: true,
513 inherit_default_image: true,
514 inherit_default_cache: true,
515 inherit_default_services: true,
516 inherit_default_timeout: true,
517 inherit_default_retry: true,
518 inherit_default_interruptible: true,
519 when: None,
520 rules: Vec::new(),
521 only: Vec::new(),
522 except: Vec::new(),
523 artifacts: ArtifactSpec::default(),
524 cache: Vec::new(),
525 image: None,
526 variables: HashMap::new(),
527 services: Vec::new(),
528 timeout: None,
529 retry: RetryPolicySpec::default(),
530 interruptible: false,
531 resource_group: None,
532 parallel: None,
533 tags: Vec::new(),
534 environment: None,
535 };
536
537 let env = build_job_env(
538 &[],
539 &HashMap::new(),
540 &job,
541 &SecretsStore::default(),
542 dir.path(),
543 Path::new("/workspace"),
544 Path::new("/builds"),
545 "1",
546 &HashMap::from([
547 ("CI_COMMIT_TAG".into(), "v1.2.3".into()),
548 ("CI_COMMIT_REF_NAME".into(), "v1.2.3".into()),
549 ]),
550 );
551 let map: HashMap<_, _> = env.into_iter().collect();
552 assert_eq!(map.get("CI_COMMIT_TAG").map(String::as_str), Some("v1.2.3"));
553 assert!(!map.contains_key("CI_COMMIT_BRANCH"));
554 Ok(())
555 }
556
557 #[test]
558 fn secret_file_env_uses_absolute_container_path() -> Result<()> {
559 let temp_root = temp_path("env-secret-file");
560 let secrets_root = temp_root.join(".opal").join("env");
561 fs::create_dir_all(&secrets_root)?;
562 fs::write(secrets_root.join("API_TOKEN"), "super-secret")?;
563 let secrets = SecretsStore::load(&temp_root)?;
564 let job = JobSpec {
565 name: "lint".into(),
566 stage: "test".into(),
567 commands: Vec::new(),
568 needs: Vec::new(),
569 explicit_needs: false,
570 dependencies: Vec::new(),
571 before_script: None,
572 after_script: None,
573 inherit_default_before_script: true,
574 inherit_default_after_script: true,
575 inherit_default_image: true,
576 inherit_default_cache: true,
577 inherit_default_services: true,
578 inherit_default_timeout: true,
579 inherit_default_retry: true,
580 inherit_default_interruptible: true,
581 when: None,
582 rules: Vec::new(),
583 only: Vec::new(),
584 except: Vec::new(),
585 artifacts: ArtifactSpec::default(),
586 cache: Vec::new(),
587 image: None,
588 variables: HashMap::new(),
589 services: Vec::new(),
590 timeout: None,
591 retry: RetryPolicySpec::default(),
592 interruptible: false,
593 resource_group: None,
594 parallel: None,
595 tags: Vec::new(),
596 environment: None,
597 };
598
599 let env = build_job_env(
600 &[],
601 &HashMap::new(),
602 &job,
603 &secrets,
604 &temp_root,
605 Path::new("/builds/workspace"),
606 Path::new("/builds"),
607 "1",
608 &HashMap::new(),
609 );
610 let map: HashMap<_, _> = env.into_iter().collect();
611 assert_eq!(
612 map.get("API_TOKEN_FILE").map(String::as_str),
613 Some("/opal/secrets/API_TOKEN")
614 );
615
616 let _ = fs::remove_dir_all(temp_root);
617 Ok(())
618 }
619
620 #[test]
621 fn maps_git_commit_tag_into_ci_tag_variables() {
622 let job = JobSpec {
623 name: "release-artifacts".into(),
624 stage: "release".into(),
625 commands: Vec::new(),
626 needs: Vec::new(),
627 explicit_needs: false,
628 dependencies: Vec::new(),
629 before_script: None,
630 after_script: None,
631 inherit_default_before_script: true,
632 inherit_default_after_script: true,
633 inherit_default_image: true,
634 inherit_default_cache: true,
635 inherit_default_services: true,
636 inherit_default_timeout: true,
637 inherit_default_retry: true,
638 inherit_default_interruptible: true,
639 when: None,
640 rules: Vec::new(),
641 only: Vec::new(),
642 except: Vec::new(),
643 artifacts: ArtifactSpec::default(),
644 cache: Vec::new(),
645 image: None,
646 variables: HashMap::new(),
647 services: Vec::new(),
648 timeout: None,
649 retry: RetryPolicySpec::default(),
650 interruptible: false,
651 resource_group: None,
652 parallel: None,
653 tags: Vec::new(),
654 environment: None,
655 };
656
657 let env = build_job_env(
658 &[],
659 &HashMap::new(),
660 &job,
661 &SecretsStore::default(),
662 Path::new("/workspace"),
663 Path::new("/workspace"),
664 Path::new("/builds"),
665 "1",
666 &HashMap::from([("GIT_COMMIT_TAG".into(), "v9.9.9".into())]),
667 );
668 let map: HashMap<_, _> = env.into_iter().collect();
669 assert_eq!(map.get("CI_COMMIT_TAG").map(String::as_str), Some("v9.9.9"));
670 assert_eq!(
671 map.get("CI_COMMIT_REF_NAME").map(String::as_str),
672 Some("v9.9.9")
673 );
674 assert_eq!(
675 map.get("CI_COMMIT_REF_SLUG").map(String::as_str),
676 Some("v999")
677 );
678 assert!(!map.contains_key("CI_COMMIT_BRANCH"));
679 }
680
681 #[test]
682 fn build_job_env_includes_legacy_dotopal_secrets() -> Result<()> {
683 let temp_root = temp_path("env-legacy-secret-file");
684 let dotopal = temp_root.join(".opal");
685 fs::create_dir_all(&dotopal)?;
686 fs::write(dotopal.join("QUAY_USERNAME"), "robot-user")?;
687 let secrets = SecretsStore::load(&temp_root)?;
688 let job = JobSpec {
689 name: "container-release".into(),
690 stage: "publish".into(),
691 commands: Vec::new(),
692 needs: Vec::new(),
693 explicit_needs: false,
694 dependencies: Vec::new(),
695 before_script: None,
696 after_script: None,
697 inherit_default_before_script: true,
698 inherit_default_after_script: true,
699 inherit_default_image: true,
700 inherit_default_cache: true,
701 inherit_default_services: true,
702 inherit_default_timeout: true,
703 inherit_default_retry: true,
704 inherit_default_interruptible: true,
705 when: None,
706 rules: Vec::new(),
707 only: Vec::new(),
708 except: Vec::new(),
709 artifacts: ArtifactSpec::default(),
710 cache: Vec::new(),
711 image: None,
712 variables: HashMap::new(),
713 services: Vec::new(),
714 timeout: None,
715 retry: RetryPolicySpec::default(),
716 interruptible: false,
717 resource_group: None,
718 parallel: None,
719 tags: Vec::new(),
720 environment: None,
721 };
722
723 let env = build_job_env(
724 &[],
725 &HashMap::new(),
726 &job,
727 &secrets,
728 &temp_root,
729 Path::new("/builds/workspace"),
730 Path::new("/builds"),
731 "1",
732 &HashMap::new(),
733 );
734 let map: HashMap<_, _> = env.into_iter().collect();
735 assert_eq!(
736 map.get("QUAY_USERNAME").map(String::as_str),
737 Some("robot-user")
738 );
739
740 let _ = fs::remove_dir_all(temp_root);
741 Ok(())
742 }
743
744 fn temp_path(prefix: &str) -> PathBuf {
745 let nanos = SystemTime::now()
746 .duration_since(UNIX_EPOCH)
747 .expect("system time before epoch")
748 .as_nanos();
749 std::env::temp_dir().join(format!("opal-{prefix}-{nanos}"))
750 }
751}