1use crate::git;
2use crate::model::{ArtifactSourceOutcome, JobSpec};
3use crate::naming::{job_name_slug, project_slug};
4use crate::pipeline::VolumeMount;
5use anyhow::{Context, Result, anyhow};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::{Arc, Mutex};
12use tracing::warn;
13
14#[derive(Debug, Clone)]
15pub struct ArtifactManager {
16 root: PathBuf,
17}
18
19impl ArtifactManager {
20 pub fn new(root: PathBuf) -> Self {
21 Self { root }
22 }
23
24 pub fn prepare_targets(&self, job: &JobSpec) -> Result<()> {
25 if job.artifacts.paths.is_empty()
26 && !job.artifacts.untracked
27 && job.artifacts.report_dotenv.is_none()
28 {
29 return Ok(());
30 }
31 let root = self.job_artifacts_root(&job.name);
32 fs::create_dir_all(&root)
33 .with_context(|| format!("failed to prepare artifacts for {}", job.name))?;
34
35 for relative in &job.artifacts.paths {
36 let host = self.job_artifact_host_path(&job.name, relative);
37 match artifact_kind(relative) {
38 ArtifactPathKind::Directory => {
39 fs::create_dir_all(&host).with_context(|| {
40 format!("failed to prepare artifact directory {}", host.display())
41 })?;
42 }
43 ArtifactPathKind::File => {
44 if let Some(parent) = host.parent() {
45 fs::create_dir_all(parent).with_context(|| {
46 format!("failed to prepare artifact parent {}", parent.display())
47 })?;
48 }
49 }
50 }
51 }
52
53 if let Some(relative) = &job.artifacts.report_dotenv {
54 let host = self.job_artifact_host_path(&job.name, relative);
55 if let Some(parent) = host.parent() {
56 fs::create_dir_all(parent).with_context(|| {
57 format!(
58 "failed to prepare dotenv artifact parent {}",
59 parent.display()
60 )
61 })?;
62 }
63 }
64
65 Ok(())
66 }
67
68 pub fn job_mount_specs(&self, job: &JobSpec) -> Vec<(PathBuf, PathBuf)> {
69 use std::collections::HashSet;
70
71 let mut specs = Vec::new();
72 let mut seen = HashSet::new();
73 for relative in &job.artifacts.paths {
74 let rel_path = artifact_relative_path(relative);
75 let mount_rel = match artifact_kind(relative) {
76 ArtifactPathKind::Directory => rel_path.clone(),
77 ArtifactPathKind::File => match rel_path.parent() {
78 Some(parent) if parent != Path::new("") && parent != Path::new(".") => {
79 parent.to_path_buf()
80 }
81 _ => continue,
82 },
83 };
84 if seen.insert(mount_rel.clone()) {
85 let host = self.job_artifacts_root(&job.name).join(&mount_rel);
86 specs.push((host, mount_rel));
87 }
88 }
89 specs
90 }
91
92 pub fn collect_declared(
93 &self,
94 job: &JobSpec,
95 workspace: &Path,
96 mounts: &[VolumeMount],
97 container_root: &Path,
98 ) -> Result<()> {
99 let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
100 for relative in &job.artifacts.paths {
101 let src = resolve_artifact_source(workspace, mounts, container_root, relative);
102 if !src.exists() {
103 continue;
104 }
105 let dest = self.job_artifact_host_path(&job.name, relative);
106 copy_declared_path(&src, &dest, relative, exclude.as_ref())?;
107 }
108 Ok(())
109 }
110
111 pub fn dependency_mount_specs(
112 &self,
113 job_name: &str,
114 job: Option<&JobSpec>,
115 outcome: Option<ArtifactSourceOutcome>,
116 optional: bool,
117 ) -> Vec<(PathBuf, PathBuf)> {
118 let Some(dep_job) = job else {
119 return Vec::new();
120 };
121 let mut specs = Vec::new();
122 for relative in &dep_job.artifacts.paths {
123 let host = self.job_artifact_host_path(job_name, relative);
124 if !host.exists() {
125 if !optional {
126 warn!(job = job_name, path = %relative.display(), "artifact missing");
127 }
128 continue;
129 }
130 if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
131 continue;
132 }
133 specs.push((host, relative.clone()));
134 }
135 if dep_job.artifacts.untracked {
136 for relative in self.read_untracked_manifest(job_name) {
137 let host = self.job_artifact_host_path(job_name, &relative);
138 if !host.exists() {
139 continue;
140 }
141 if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
142 continue;
143 }
144 specs.push((host, relative));
145 }
146 }
147
148 specs
149 }
150
151 pub fn job_artifact_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
152 self.job_artifacts_root(job_name)
153 .join(artifact_relative_path(artifact))
154 }
155
156 pub fn collect_dotenv_report(
157 &self,
158 job: &JobSpec,
159 workspace: &Path,
160 mounts: &[VolumeMount],
161 container_root: &Path,
162 ) -> Result<()> {
163 let Some(relative) = &job.artifacts.report_dotenv else {
164 return Ok(());
165 };
166 let src = resolve_artifact_source(workspace, mounts, container_root, relative);
167 if !src.exists() {
168 return Ok(());
169 }
170 let dest = self.job_artifact_host_path(&job.name, relative);
171 if let Some(parent) = dest.parent() {
172 fs::create_dir_all(parent)
173 .with_context(|| format!("failed to create {}", parent.display()))?;
174 }
175 fs::copy(&src, &dest)
176 .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
177 Ok(())
178 }
179
180 pub fn job_artifacts_root(&self, job_name: &str) -> PathBuf {
181 self.root.join(job_name_slug(job_name)).join("artifacts")
182 }
183
184 pub fn job_dependency_root(&self, job_name: &str) -> PathBuf {
185 self.root.join(job_name_slug(job_name)).join("dependencies")
186 }
187
188 pub fn job_dependency_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
189 self.job_dependency_root(job_name)
190 .join(artifact_relative_path(artifact))
191 }
192
193 pub fn collect_untracked(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
194 if !job.artifacts.untracked {
195 return Ok(());
196 }
197
198 let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
199 let explicit_paths = &job.artifacts.paths;
200 let mut collected = Vec::new();
201 for relative in git::untracked_files(workspace)? {
202 let relative = PathBuf::from(relative);
203 if path_is_covered_by_explicit_artifacts(&relative, explicit_paths) {
204 continue;
205 }
206 if should_exclude(&relative, exclude.as_ref()) {
207 continue;
208 }
209
210 let src = workspace.join(&relative);
211 if !src.exists() {
212 continue;
213 }
214 copy_untracked_entry(
215 workspace,
216 &src,
217 self.job_artifact_host_path(&job.name, &relative),
218 &relative,
219 exclude.as_ref(),
220 &mut collected,
221 )?;
222 }
223
224 collected.sort();
225 collected.dedup();
226 self.write_untracked_manifest(&job.name, &collected)
227 }
228
229 fn write_untracked_manifest(&self, job_name: &str, paths: &[PathBuf]) -> Result<()> {
230 let manifest = self.job_untracked_manifest_path(job_name);
231 if let Some(parent) = manifest.parent() {
232 fs::create_dir_all(parent)
233 .with_context(|| format!("failed to create {}", parent.display()))?;
234 }
235 let content = if paths.is_empty() {
236 String::new()
237 } else {
238 let mut body = paths
239 .iter()
240 .map(|path| path.to_string_lossy().to_string())
241 .collect::<Vec<_>>()
242 .join("\n");
243 body.push('\n');
244 body
245 };
246 fs::write(&manifest, content)
247 .with_context(|| format!("failed to write {}", manifest.display()))
248 }
249
250 fn read_untracked_manifest(&self, job_name: &str) -> Vec<PathBuf> {
251 let manifest = self.job_untracked_manifest_path(job_name);
252 let Ok(contents) = fs::read_to_string(&manifest) else {
253 return Vec::new();
254 };
255 contents
256 .lines()
257 .filter(|line| !line.trim().is_empty())
258 .map(PathBuf::from)
259 .collect()
260 }
261
262 fn job_untracked_manifest_path(&self, job_name: &str) -> PathBuf {
263 self.root
264 .join(job_name_slug(job_name))
265 .join("untracked-manifest.txt")
266 }
267}
268
269#[derive(Debug, Clone, Copy)]
270pub enum ArtifactPathKind {
271 File,
272 Directory,
273}
274
275fn artifact_relative_path(artifact: &Path) -> PathBuf {
276 use std::path::Component;
277
278 let mut rel = PathBuf::new();
279 for component in artifact.components() {
280 match component {
281 Component::RootDir | Component::CurDir => continue,
282 Component::ParentDir => continue,
283 Component::Prefix(prefix) => rel.push(prefix.as_os_str()),
284 Component::Normal(seg) => rel.push(seg),
285 }
286 }
287
288 if rel.as_os_str().is_empty() {
289 rel.push("artifact");
290 }
291 rel
292}
293
294fn resolve_artifact_source(
295 workspace: &Path,
296 mounts: &[VolumeMount],
297 container_root: &Path,
298 relative: &Path,
299) -> PathBuf {
300 let container_path = container_root.join(relative);
301 mounts
302 .iter()
303 .filter_map(|mount| resolve_mount_source(mount, &container_path))
304 .max_by_key(|(depth, _)| *depth)
305 .map(|(_, path)| path)
306 .unwrap_or_else(|| workspace.join(relative))
307}
308
309fn resolve_mount_source(mount: &VolumeMount, container_path: &Path) -> Option<(usize, PathBuf)> {
310 if container_path == mount.container {
311 return Some((mount.container.components().count(), mount.host.clone()));
312 }
313 if mount.host.is_dir() && container_path.starts_with(&mount.container) {
314 let relative = container_path.strip_prefix(&mount.container).ok()?;
315 return Some((
316 mount.container.components().count(),
317 mount.host.join(relative),
318 ));
319 }
320 None
321}
322
323fn copy_declared_path(
324 src: &Path,
325 dest: &Path,
326 relative: &Path,
327 exclude: Option<&GlobSet>,
328) -> Result<()> {
329 let metadata =
330 fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
331 if metadata.is_dir() {
332 fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
333 for entry in
334 fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
335 {
336 let entry = entry?;
337 let child_src = entry.path();
338 let child_rel = relative.join(entry.file_name());
339 let child_dest = dest.join(entry.file_name());
340 copy_declared_path(&child_src, &child_dest, &child_rel, exclude)?;
341 }
342 return Ok(());
343 }
344 if exclude.is_some_and(|glob| glob.is_match(relative)) {
345 return Ok(());
346 }
347 if let Some(parent) = dest.parent() {
348 fs::create_dir_all(parent)
349 .with_context(|| format!("failed to create {}", parent.display()))?;
350 }
351 fs::copy(src, dest)
352 .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
353 Ok(())
354}
355
356fn artifact_kind(path: &Path) -> ArtifactPathKind {
357 let text = path.to_string_lossy();
358 if text.ends_with(std::path::MAIN_SEPARATOR) {
359 ArtifactPathKind::Directory
360 } else {
361 ArtifactPathKind::File
362 }
363}
364
365fn artifact_path_has_content(path: &Path) -> bool {
366 match fs::metadata(path) {
367 Ok(metadata) if metadata.is_file() => true,
368 Ok(metadata) if metadata.is_dir() => fs::read_dir(path)
369 .ok()
370 .and_then(|mut entries| entries.next())
371 .is_some(),
372 _ => false,
373 }
374}
375
376fn build_exclude_matcher(patterns: &[String]) -> Result<Option<GlobSet>> {
377 if patterns.is_empty() {
378 return Ok(None);
379 }
380
381 let mut builder = GlobSetBuilder::new();
382 for pattern in patterns {
383 builder.add(
384 Glob::new(pattern)
385 .with_context(|| format!("invalid artifacts.exclude pattern '{pattern}'"))?,
386 );
387 }
388 Ok(Some(builder.build()?))
389}
390
391fn should_exclude(path: &Path, exclude: Option<&GlobSet>) -> bool {
392 exclude.is_some_and(|glob| glob.is_match(path))
393}
394
395fn path_is_covered_by_explicit_artifacts(path: &Path, explicit_paths: &[PathBuf]) -> bool {
396 explicit_paths
397 .iter()
398 .any(|artifact| match artifact_kind(artifact) {
399 ArtifactPathKind::Directory => {
400 let base = artifact_relative_path(artifact);
401 path == base || path.starts_with(&base)
402 }
403 ArtifactPathKind::File => path == artifact_relative_path(artifact),
404 })
405}
406
407fn copy_untracked_entry(
408 workspace: &Path,
409 src: &Path,
410 dest: PathBuf,
411 relative: &Path,
412 exclude: Option<&GlobSet>,
413 collected: &mut Vec<PathBuf>,
414) -> Result<()> {
415 let metadata =
416 fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
417 if metadata.is_dir() {
418 for entry in
419 fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
420 {
421 let entry = entry?;
422 let child_src = entry.path();
423 let child_relative = match child_src.strip_prefix(workspace) {
424 Ok(rel) => rel.to_path_buf(),
425 Err(_) => continue,
426 };
427 let child_dest = dest.join(entry.file_name());
428 copy_untracked_entry(
429 workspace,
430 &child_src,
431 child_dest,
432 &child_relative,
433 exclude,
434 collected,
435 )?;
436 }
437 return Ok(());
438 }
439
440 if should_exclude(relative, exclude) {
441 return Ok(());
442 }
443 if let Some(parent) = dest.parent() {
444 fs::create_dir_all(parent)
445 .with_context(|| format!("failed to create {}", parent.display()))?;
446 }
447 fs::copy(src, &dest)
448 .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
449 collected.push(relative.to_path_buf());
450 Ok(())
451}
452
453#[derive(Clone, Debug)]
454pub struct ExternalArtifactsManager {
455 inner: Arc<ExternalArtifactsInner>,
456}
457
458#[derive(Debug)]
459struct ExternalArtifactsInner {
460 root: PathBuf,
461 base_url: String,
462 token: String,
463 cache: Mutex<HashMap<String, PathBuf>>,
464}
465
466impl ExternalArtifactsManager {
467 pub fn new(root: PathBuf, base_url: String, token: String) -> Self {
468 let inner = ExternalArtifactsInner {
469 root,
470 base_url,
471 token,
472 cache: Mutex::new(HashMap::new()),
473 };
474 Self {
475 inner: Arc::new(inner),
476 }
477 }
478
479 pub fn ensure_artifacts(&self, project: &str, job: &str, reference: &str) -> Result<PathBuf> {
480 let key = format!("{project}::{reference}::{job}");
481 if let Ok(cache) = self.inner.cache.lock()
482 && let Some(path) = cache.get(&key)
483 && path.exists()
484 {
485 return Ok(path.clone());
486 }
487
488 let target = self.external_root(project, job, reference);
489 if target.exists() {
490 fs::remove_dir_all(&target)
491 .with_context(|| format!("failed to clear {}", target.display()))?;
492 }
493 fs::create_dir_all(&target)
494 .with_context(|| format!("failed to create {}", target.display()))?;
495 let archive_path = target.join("artifacts.zip");
496 self.download_artifacts(project, job, reference, &archive_path)?;
497 self.extract_artifacts(&archive_path, &target)?;
498 let _ = fs::remove_file(&archive_path);
499
500 if let Ok(mut cache) = self.inner.cache.lock() {
501 cache.insert(key, target.clone());
502 }
503
504 Ok(target)
505 }
506
507 fn external_root(&self, project: &str, job: &str, reference: &str) -> PathBuf {
508 let project_slug = project_slug(project);
509 let reference_slug = sanitize_reference(reference);
510 self.inner
511 .root
512 .join("external")
513 .join(project_slug)
514 .join(reference_slug)
515 .join(job_name_slug(job))
516 }
517
518 fn download_artifacts(
519 &self,
520 project: &str,
521 job: &str,
522 reference: &str,
523 dest: &Path,
524 ) -> Result<()> {
525 let base = self.inner.base_url.trim_end_matches('/');
526 let project_id = percent_encode(project);
527 let ref_id = percent_encode(reference);
528 let job_name = percent_encode(job);
529 let url = format!(
530 "{base}/api/v4/projects/{project_id}/jobs/artifacts/{ref_id}/download?job={job_name}"
531 );
532 let status = Command::new("curl")
533 .arg("--fail")
534 .arg("-sS")
535 .arg("-L")
536 .arg("-H")
537 .arg(format!("PRIVATE-TOKEN: {}", self.inner.token))
538 .arg("-o")
539 .arg(dest)
540 .arg(&url)
541 .status()
542 .with_context(|| "failed to invoke curl to download artifacts")?;
543 if !status.success() {
544 return Err(anyhow!(
545 "curl failed to download artifacts from {} (status {})",
546 url,
547 status.code().unwrap_or(-1)
548 ));
549 }
550 Ok(())
551 }
552
553 fn extract_artifacts(&self, archive: &Path, dest: &Path) -> Result<()> {
554 let unzip_status = Command::new("unzip")
555 .arg("-q")
556 .arg("-o")
557 .arg(archive)
558 .arg("-d")
559 .arg(dest)
560 .status();
561 match unzip_status {
562 Ok(status) if status.success() => return Ok(()),
563 Ok(_) | Err(_) => {
564 let script =
566 "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])";
567 let status = Command::new("python3")
568 .arg("-c")
569 .arg(script)
570 .arg(archive)
571 .arg(dest)
572 .status()
573 .with_context(|| "failed to invoke python3 to extract artifacts")?;
574 if status.success() {
575 return Ok(());
576 }
577 }
578 }
579 Err(anyhow!(
580 "unable to extract artifacts archive {}",
581 archive.display()
582 ))
583 }
584}
585
586fn percent_encode(value: &str) -> String {
587 let mut encoded = String::new();
588 for byte in value.bytes() {
589 match byte {
590 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
591 encoded.push(byte as char)
592 }
593 _ => encoded.push_str(&format!("%{:02X}", byte)),
594 }
595 }
596 encoded
597}
598
599fn sanitize_reference(reference: &str) -> String {
600 let mut slug = String::new();
601 for ch in reference.chars() {
602 if ch.is_ascii_alphanumeric() {
603 slug.push(ch.to_ascii_lowercase());
604 } else if matches!(ch, '-' | '_' | '.') {
605 slug.push(ch);
606 } else {
607 slug.push('-');
608 }
609 }
610 if slug.is_empty() {
611 slug.push_str("ref");
612 }
613 slug
614}
615
616#[cfg(test)]
617mod tests {
618 use super::{
619 ArtifactManager, ArtifactPathKind, artifact_kind, artifact_path_has_content,
620 path_is_covered_by_explicit_artifacts, resolve_artifact_source,
621 };
622 use crate::model::{
623 ArtifactSourceOutcome, ArtifactSpec, ArtifactWhenSpec, JobSpec, RetryPolicySpec,
624 };
625 use crate::pipeline::VolumeMount;
626 use std::collections::HashMap;
627 use std::fs;
628 use std::path::{Path, PathBuf};
629 use std::time::{SystemTime, UNIX_EPOCH};
630
631 #[test]
632 fn artifact_path_kind_treats_trailing_separator_as_directory() {
633 assert!(matches!(
634 artifact_kind(std::path::Path::new("tests-temp/build/")),
635 ArtifactPathKind::Directory
636 ));
637 }
638
639 #[test]
640 fn artifact_path_has_content_requires_directory_entries() {
641 let root = temp_path("artifact-content");
642 fs::create_dir_all(&root).expect("create dir");
643 assert!(!artifact_path_has_content(&root));
644 fs::write(root.join("marker.txt"), "ok").expect("write marker");
645 assert!(artifact_path_has_content(&root));
646 let _ = fs::remove_dir_all(root);
647 }
648
649 #[test]
650 fn dependency_mount_specs_allow_populated_artifacts_without_recorded_outcome() {
651 let root = temp_path("artifact-presence");
652 let manager = ArtifactManager::new(root.clone());
653 let relative = PathBuf::from("tests-temp/build/");
654 let job = job(
655 "build",
656 vec![relative.clone()],
657 Vec::new(),
658 false,
659 ArtifactWhenSpec::OnSuccess,
660 );
661 let host = manager.job_artifact_host_path("build", &relative);
662 fs::create_dir_all(&host).expect("create artifact dir");
663 fs::write(host.join("linux-release.txt"), "release").expect("write artifact");
664
665 let specs = manager.dependency_mount_specs("build", Some(&job), None, false);
666
667 assert_eq!(specs.len(), 1);
668 assert_eq!(specs[0].0, host);
669
670 let _ = fs::remove_dir_all(root);
671 }
672
673 #[test]
674 fn artifact_when_matches_expected_outcomes() {
675 assert!(ArtifactWhenSpec::Always.includes(None));
676 assert!(ArtifactWhenSpec::Always.includes(Some(ArtifactSourceOutcome::Success)));
677 assert!(ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Success)));
678 assert!(!ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Failed)));
679 assert!(ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Failed)));
680 assert!(!ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Skipped)));
681 assert!(!ArtifactWhenSpec::OnFailure.includes(None));
682 }
683
684 #[test]
685 fn collect_untracked_includes_ignored_workspace_files() {
686 let root = temp_path("artifact-untracked");
687 let workspace = root.join("workspace");
688 fs::create_dir_all(&workspace).expect("create workspace");
689 let repo = git2::Repository::init(&workspace).expect("init repo");
690 fs::write(workspace.join("README.md"), "opal\n").expect("write readme");
691 fs::write(workspace.join(".gitignore"), "tests-temp/\n").expect("write ignore");
692 let mut index = repo.index().expect("open index");
693 index
694 .add_path(Path::new("README.md"))
695 .expect("add readme to index");
696 index
697 .add_path(Path::new(".gitignore"))
698 .expect("add ignore to index");
699 let tree_id = index.write_tree().expect("write tree");
700 let tree = repo.find_tree(tree_id).expect("find tree");
701 let sig = git2::Signature::now("Opal Tests", "opal@example.com").expect("signature");
702 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
703 .expect("commit");
704
705 fs::create_dir_all(workspace.join("tests-temp")).expect("create ignored dir");
706 fs::write(workspace.join("tests-temp/generated.txt"), "hello").expect("write ignored");
707 fs::write(workspace.join("scratch.txt"), "hi").expect("write untracked");
708
709 let manager = ArtifactManager::new(root.join("artifacts"));
710 let job = job(
711 "build",
712 Vec::new(),
713 vec!["tests-temp/**/*.log".into()],
714 true,
715 ArtifactWhenSpec::OnSuccess,
716 );
717
718 manager
719 .prepare_targets(&job)
720 .expect("prepare artifact targets");
721 manager
722 .collect_untracked(&job, &workspace)
723 .expect("collect untracked artifacts");
724
725 let manifest = manager.read_untracked_manifest("build");
726 assert!(manifest.iter().any(|path| path == Path::new("scratch.txt")));
727 assert!(
728 manifest
729 .iter()
730 .any(|path| path == Path::new("tests-temp/generated.txt"))
731 );
732 assert!(
733 manager
734 .job_artifact_host_path("build", Path::new("scratch.txt"))
735 .exists()
736 );
737 assert!(
738 manager
739 .job_artifact_host_path("build", Path::new("tests-temp/generated.txt"))
740 .exists()
741 );
742
743 let _ = fs::remove_dir_all(root);
744 }
745
746 #[test]
747 fn resolve_artifact_source_prefers_more_specific_mount_over_workspace_root() {
748 let root = temp_path("artifact-mounted-source");
749 let workspace = root.join("workspace");
750 let cache_host = root.join("cache-target");
751 fs::create_dir_all(workspace.join("target/release")).expect("create workspace target");
752 fs::create_dir_all(cache_host.join("release")).expect("create cache target");
753 fs::write(cache_host.join("release/opal"), "binary").expect("write cached binary");
754
755 let mounts = vec![
756 VolumeMount {
757 host: workspace.clone(),
758 container: PathBuf::from("/builds/opal"),
759 read_only: false,
760 },
761 VolumeMount {
762 host: cache_host.clone(),
763 container: PathBuf::from("/builds/opal/target"),
764 read_only: false,
765 },
766 ];
767
768 let resolved = resolve_artifact_source(
769 &workspace,
770 &mounts,
771 Path::new("/builds/opal"),
772 Path::new("target/release/opal"),
773 );
774
775 assert_eq!(resolved, cache_host.join("release/opal"));
776
777 let _ = fs::remove_dir_all(root);
778 }
779
780 #[test]
781 fn path_is_covered_by_explicit_artifacts_matches_directory_and_file_paths() {
782 assert!(path_is_covered_by_explicit_artifacts(
783 Path::new("tests-temp/build/linux.txt"),
784 &[PathBuf::from("tests-temp/build/")]
785 ));
786 assert!(path_is_covered_by_explicit_artifacts(
787 Path::new("output/report.txt"),
788 &[PathBuf::from("output/report.txt")]
789 ));
790 assert!(!path_is_covered_by_explicit_artifacts(
791 Path::new("other.txt"),
792 &[PathBuf::from("output/report.txt")]
793 ));
794 }
795
796 fn job(
797 name: &str,
798 paths: Vec<PathBuf>,
799 exclude: Vec<String>,
800 untracked: bool,
801 when: ArtifactWhenSpec,
802 ) -> JobSpec {
803 JobSpec {
804 name: name.into(),
805 stage: "build".into(),
806 commands: vec!["echo ok".into()],
807 needs: Vec::new(),
808 explicit_needs: false,
809 dependencies: Vec::new(),
810 before_script: None,
811 after_script: None,
812 inherit_default_before_script: true,
813 inherit_default_after_script: true,
814 inherit_default_image: true,
815 inherit_default_cache: true,
816 inherit_default_services: true,
817 inherit_default_timeout: true,
818 inherit_default_retry: true,
819 inherit_default_interruptible: true,
820 when: None,
821 rules: Vec::new(),
822 only: Vec::new(),
823 except: Vec::new(),
824 artifacts: ArtifactSpec {
825 name: None,
826 paths,
827 exclude,
828 untracked,
829 when,
830 expire_in: None,
831 report_dotenv: None,
832 },
833 cache: Vec::new(),
834 image: None,
835 variables: HashMap::new(),
836 services: Vec::new(),
837 timeout: None,
838 retry: RetryPolicySpec::default(),
839 interruptible: false,
840 resource_group: None,
841 parallel: None,
842 tags: Vec::new(),
843 environment: None,
844 }
845 }
846
847 fn temp_path(prefix: &str) -> PathBuf {
848 let nanos = SystemTime::now()
849 .duration_since(UNIX_EPOCH)
850 .expect("system time before epoch")
851 .as_nanos();
852 std::env::temp_dir().join(format!("opal-{prefix}-{nanos}"))
853 }
854}