Skip to main content

opal/pipeline/
artifacts.rs

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        let mut collected = Vec::new();
101        for relative in &job.artifacts.paths {
102            for (src, matched_relative) in
103                resolve_declared_artifact_sources(workspace, mounts, container_root, relative)?
104            {
105                let dest = self.job_artifact_host_path(&job.name, &matched_relative);
106                copy_declared_path(&src, &dest, &matched_relative, exclude.as_ref())?;
107                collected.push(matched_relative);
108            }
109        }
110        collected.sort();
111        collected.dedup();
112        self.write_declared_manifest(&job.name, &collected)
113    }
114
115    pub fn dependency_mount_specs(
116        &self,
117        job_name: &str,
118        job: Option<&JobSpec>,
119        outcome: Option<ArtifactSourceOutcome>,
120        optional: bool,
121    ) -> Vec<(PathBuf, PathBuf)> {
122        let Some(dep_job) = job else {
123            return Vec::new();
124        };
125        let mut specs = Vec::new();
126        let declared = self.read_declared_manifest(job_name);
127        let declared_paths: Vec<PathBuf> = if declared.is_empty() {
128            dep_job.artifacts.paths.clone()
129        } else {
130            declared
131        };
132        for relative in &declared_paths {
133            let host = self.job_artifact_host_path(job_name, relative);
134            if !host.exists() {
135                if !optional {
136                    warn!(job = job_name, path = %relative.display(), "artifact missing");
137                }
138                continue;
139            }
140            if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
141                continue;
142            }
143            specs.push((host, relative.clone()));
144        }
145        if dep_job.artifacts.untracked {
146            for relative in self.read_untracked_manifest(job_name) {
147                let host = self.job_artifact_host_path(job_name, &relative);
148                if !host.exists() {
149                    continue;
150                }
151                if !dep_job.artifacts.when.includes(outcome) && !artifact_path_has_content(&host) {
152                    continue;
153                }
154                specs.push((host, relative));
155            }
156        }
157
158        specs
159    }
160
161    pub fn job_artifact_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
162        self.job_artifacts_root(job_name)
163            .join(artifact_relative_path(artifact))
164    }
165
166    pub fn collect_dotenv_report(
167        &self,
168        job: &JobSpec,
169        workspace: &Path,
170        mounts: &[VolumeMount],
171        container_root: &Path,
172    ) -> Result<()> {
173        let Some(relative) = &job.artifacts.report_dotenv else {
174            return Ok(());
175        };
176        let src = resolve_artifact_source(workspace, mounts, container_root, relative);
177        if !src.exists() {
178            return Ok(());
179        }
180        let dest = self.job_artifact_host_path(&job.name, relative);
181        if let Some(parent) = dest.parent() {
182            fs::create_dir_all(parent)
183                .with_context(|| format!("failed to create {}", parent.display()))?;
184        }
185        fs::copy(&src, &dest)
186            .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
187        Ok(())
188    }
189
190    pub fn job_artifacts_root(&self, job_name: &str) -> PathBuf {
191        self.root.join(job_name_slug(job_name)).join("artifacts")
192    }
193
194    pub fn job_dependency_root(&self, job_name: &str) -> PathBuf {
195        self.root.join(job_name_slug(job_name)).join("dependencies")
196    }
197
198    pub fn job_dependency_host_path(&self, job_name: &str, artifact: &Path) -> PathBuf {
199        self.job_dependency_root(job_name)
200            .join(artifact_relative_path(artifact))
201    }
202
203    pub fn collect_untracked(&self, job: &JobSpec, workspace: &Path) -> Result<()> {
204        if !job.artifacts.untracked {
205            return Ok(());
206        }
207
208        let exclude = build_exclude_matcher(&job.artifacts.exclude)?;
209        let explicit_paths = &job.artifacts.paths;
210        let mut collected = Vec::new();
211        for relative in git::untracked_files(workspace)? {
212            let relative = PathBuf::from(relative);
213            if path_is_covered_by_explicit_artifacts(&relative, explicit_paths) {
214                continue;
215            }
216            if should_exclude(&relative, exclude.as_ref()) {
217                continue;
218            }
219
220            let src = workspace.join(&relative);
221            if !src.exists() {
222                continue;
223            }
224            copy_untracked_entry(
225                workspace,
226                &src,
227                self.job_artifact_host_path(&job.name, &relative),
228                &relative,
229                exclude.as_ref(),
230                &mut collected,
231            )?;
232        }
233
234        collected.sort();
235        collected.dedup();
236        self.write_untracked_manifest(&job.name, &collected)
237    }
238
239    fn write_declared_manifest(&self, job_name: &str, paths: &[PathBuf]) -> Result<()> {
240        let manifest = self.job_declared_manifest_path(job_name);
241        if let Some(parent) = manifest.parent() {
242            fs::create_dir_all(parent)
243                .with_context(|| format!("failed to create {}", parent.display()))?;
244        }
245        let content = if paths.is_empty() {
246            String::new()
247        } else {
248            let mut body = paths
249                .iter()
250                .map(|path| path.to_string_lossy().to_string())
251                .collect::<Vec<_>>()
252                .join("\n");
253            body.push('\n');
254            body
255        };
256        fs::write(&manifest, content)
257            .with_context(|| format!("failed to write {}", manifest.display()))
258    }
259
260    fn read_declared_manifest(&self, job_name: &str) -> Vec<PathBuf> {
261        let manifest = self.job_declared_manifest_path(job_name);
262        let Ok(contents) = fs::read_to_string(&manifest) else {
263            return Vec::new();
264        };
265        contents
266            .lines()
267            .filter(|line| !line.trim().is_empty())
268            .map(PathBuf::from)
269            .collect()
270    }
271
272    fn write_untracked_manifest(&self, job_name: &str, paths: &[PathBuf]) -> Result<()> {
273        let manifest = self.job_untracked_manifest_path(job_name);
274        if let Some(parent) = manifest.parent() {
275            fs::create_dir_all(parent)
276                .with_context(|| format!("failed to create {}", parent.display()))?;
277        }
278        let content = if paths.is_empty() {
279            String::new()
280        } else {
281            let mut body = paths
282                .iter()
283                .map(|path| path.to_string_lossy().to_string())
284                .collect::<Vec<_>>()
285                .join("\n");
286            body.push('\n');
287            body
288        };
289        fs::write(&manifest, content)
290            .with_context(|| format!("failed to write {}", manifest.display()))
291    }
292
293    fn read_untracked_manifest(&self, job_name: &str) -> Vec<PathBuf> {
294        let manifest = self.job_untracked_manifest_path(job_name);
295        let Ok(contents) = fs::read_to_string(&manifest) else {
296            return Vec::new();
297        };
298        contents
299            .lines()
300            .filter(|line| !line.trim().is_empty())
301            .map(PathBuf::from)
302            .collect()
303    }
304
305    fn job_untracked_manifest_path(&self, job_name: &str) -> PathBuf {
306        self.root
307            .join(job_name_slug(job_name))
308            .join("untracked-manifest.txt")
309    }
310
311    fn job_declared_manifest_path(&self, job_name: &str) -> PathBuf {
312        self.root
313            .join(job_name_slug(job_name))
314            .join("declared-manifest.txt")
315    }
316}
317
318#[derive(Debug, Clone, Copy)]
319pub enum ArtifactPathKind {
320    File,
321    Directory,
322}
323
324fn artifact_relative_path(artifact: &Path) -> PathBuf {
325    use std::path::Component;
326
327    let mut rel = PathBuf::new();
328    for component in artifact.components() {
329        match component {
330            Component::RootDir | Component::CurDir => continue,
331            Component::ParentDir => continue,
332            Component::Prefix(prefix) => rel.push(prefix.as_os_str()),
333            Component::Normal(seg) => rel.push(seg),
334        }
335    }
336
337    if rel.as_os_str().is_empty() {
338        rel.push("artifact");
339    }
340    rel
341}
342
343fn resolve_artifact_source(
344    workspace: &Path,
345    mounts: &[VolumeMount],
346    container_root: &Path,
347    relative: &Path,
348) -> PathBuf {
349    let container_path = container_root.join(relative);
350    mounts
351        .iter()
352        .filter_map(|mount| resolve_mount_source(mount, &container_path))
353        .max_by_key(|(depth, _)| *depth)
354        .map(|(_, path)| path)
355        .unwrap_or_else(|| workspace.join(relative))
356}
357
358fn resolve_declared_artifact_sources(
359    workspace: &Path,
360    mounts: &[VolumeMount],
361    container_root: &Path,
362    relative: &Path,
363) -> Result<Vec<(PathBuf, PathBuf)>> {
364    if !path_contains_glob(relative) {
365        let src = resolve_artifact_source(workspace, mounts, container_root, relative);
366        if src.exists() {
367            return Ok(vec![(src, relative.to_path_buf())]);
368        }
369        return Ok(Vec::new());
370    }
371
372    let base_relative = glob_search_base(relative);
373    let base_source = resolve_artifact_source(workspace, mounts, container_root, &base_relative);
374    if !base_source.exists() {
375        return Ok(Vec::new());
376    }
377
378    let matcher = Glob::new(&relative.to_string_lossy())
379        .with_context(|| format!("invalid artifact glob {}", relative.display()))?
380        .compile_matcher();
381    let mut matches = Vec::new();
382    for entry in walkdir::WalkDir::new(&base_source)
383        .into_iter()
384        .filter_map(Result::ok)
385    {
386        let path = entry.path();
387        let stripped = path.strip_prefix(&base_source).unwrap_or(path);
388        let candidate = if stripped.as_os_str().is_empty() {
389            base_relative.clone()
390        } else {
391            base_relative.join(stripped)
392        };
393        if matcher.is_match(&candidate) {
394            matches.push((path.to_path_buf(), candidate));
395        }
396    }
397    matches.sort_by(|a, b| a.1.cmp(&b.1));
398    matches.dedup_by(|a, b| a.1 == b.1);
399    Ok(matches)
400}
401
402fn path_contains_glob(path: &Path) -> bool {
403    let text = path.to_string_lossy();
404    text.contains('*') || text.contains('?') || text.contains('[') || text.contains('{')
405}
406
407fn glob_search_base(path: &Path) -> PathBuf {
408    let mut base = PathBuf::new();
409    for component in path.components() {
410        let text = component.as_os_str().to_string_lossy();
411        if text.contains('*') || text.contains('?') || text.contains('[') || text.contains('{') {
412            break;
413        }
414        base.push(component.as_os_str());
415    }
416    if base.as_os_str().is_empty() {
417        PathBuf::from(".")
418    } else {
419        base
420    }
421}
422
423fn resolve_mount_source(mount: &VolumeMount, container_path: &Path) -> Option<(usize, PathBuf)> {
424    if container_path == mount.container {
425        return Some((mount.container.components().count(), mount.host.clone()));
426    }
427    if mount.host.is_dir() && container_path.starts_with(&mount.container) {
428        let relative = container_path.strip_prefix(&mount.container).ok()?;
429        return Some((
430            mount.container.components().count(),
431            mount.host.join(relative),
432        ));
433    }
434    None
435}
436
437fn copy_declared_path(
438    src: &Path,
439    dest: &Path,
440    relative: &Path,
441    exclude: Option<&GlobSet>,
442) -> Result<()> {
443    let metadata =
444        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
445    if metadata.is_dir() {
446        fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
447        for entry in
448            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
449        {
450            let entry = entry?;
451            let child_src = entry.path();
452            let child_rel = relative.join(entry.file_name());
453            let child_dest = dest.join(entry.file_name());
454            copy_declared_path(&child_src, &child_dest, &child_rel, exclude)?;
455        }
456        return Ok(());
457    }
458    if exclude.is_some_and(|glob| glob.is_match(relative)) {
459        return Ok(());
460    }
461    if let Some(parent) = dest.parent() {
462        fs::create_dir_all(parent)
463            .with_context(|| format!("failed to create {}", parent.display()))?;
464    }
465    fs::copy(src, dest)
466        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
467    Ok(())
468}
469
470fn artifact_kind(path: &Path) -> ArtifactPathKind {
471    let text = path.to_string_lossy();
472    if text.ends_with(std::path::MAIN_SEPARATOR) {
473        ArtifactPathKind::Directory
474    } else {
475        ArtifactPathKind::File
476    }
477}
478
479fn artifact_path_has_content(path: &Path) -> bool {
480    match fs::metadata(path) {
481        Ok(metadata) if metadata.is_file() => true,
482        Ok(metadata) if metadata.is_dir() => fs::read_dir(path)
483            .ok()
484            .and_then(|mut entries| entries.next())
485            .is_some(),
486        _ => false,
487    }
488}
489
490fn build_exclude_matcher(patterns: &[String]) -> Result<Option<GlobSet>> {
491    if patterns.is_empty() {
492        return Ok(None);
493    }
494
495    let mut builder = GlobSetBuilder::new();
496    for pattern in patterns {
497        builder.add(
498            Glob::new(pattern)
499                .with_context(|| format!("invalid artifacts.exclude pattern '{pattern}'"))?,
500        );
501    }
502    Ok(Some(builder.build()?))
503}
504
505fn should_exclude(path: &Path, exclude: Option<&GlobSet>) -> bool {
506    exclude.is_some_and(|glob| glob.is_match(path))
507}
508
509fn path_is_covered_by_explicit_artifacts(path: &Path, explicit_paths: &[PathBuf]) -> bool {
510    explicit_paths.iter().any(|artifact| {
511        if path_contains_glob(artifact) {
512            return Glob::new(&artifact.to_string_lossy())
513                .map(|glob| glob.compile_matcher().is_match(path))
514                .unwrap_or(false);
515        }
516        match artifact_kind(artifact) {
517            ArtifactPathKind::Directory => {
518                let base = artifact_relative_path(artifact);
519                path == base || path.starts_with(&base)
520            }
521            ArtifactPathKind::File => path == artifact_relative_path(artifact),
522        }
523    })
524}
525
526fn copy_untracked_entry(
527    workspace: &Path,
528    src: &Path,
529    dest: PathBuf,
530    relative: &Path,
531    exclude: Option<&GlobSet>,
532    collected: &mut Vec<PathBuf>,
533) -> Result<()> {
534    let metadata =
535        fs::symlink_metadata(src).with_context(|| format!("failed to stat {}", src.display()))?;
536    if metadata.is_dir() {
537        for entry in
538            fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
539        {
540            let entry = entry?;
541            let child_src = entry.path();
542            let child_relative = match child_src.strip_prefix(workspace) {
543                Ok(rel) => rel.to_path_buf(),
544                Err(_) => continue,
545            };
546            let child_dest = dest.join(entry.file_name());
547            copy_untracked_entry(
548                workspace,
549                &child_src,
550                child_dest,
551                &child_relative,
552                exclude,
553                collected,
554            )?;
555        }
556        return Ok(());
557    }
558
559    if should_exclude(relative, exclude) {
560        return Ok(());
561    }
562    if let Some(parent) = dest.parent() {
563        fs::create_dir_all(parent)
564            .with_context(|| format!("failed to create {}", parent.display()))?;
565    }
566    fs::copy(src, &dest)
567        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
568    collected.push(relative.to_path_buf());
569    Ok(())
570}
571
572#[derive(Clone, Debug)]
573pub struct ExternalArtifactsManager {
574    inner: Arc<ExternalArtifactsInner>,
575}
576
577#[derive(Debug)]
578struct ExternalArtifactsInner {
579    root: PathBuf,
580    base_url: String,
581    token: String,
582    cache: Mutex<HashMap<String, PathBuf>>,
583}
584
585impl ExternalArtifactsManager {
586    pub fn new(root: PathBuf, base_url: String, token: String) -> Self {
587        let inner = ExternalArtifactsInner {
588            root,
589            base_url,
590            token,
591            cache: Mutex::new(HashMap::new()),
592        };
593        Self {
594            inner: Arc::new(inner),
595        }
596    }
597
598    pub fn ensure_artifacts(&self, project: &str, job: &str, reference: &str) -> Result<PathBuf> {
599        let key = format!("{project}::{reference}::{job}");
600        if let Ok(cache) = self.inner.cache.lock()
601            && let Some(path) = cache.get(&key)
602            && path.exists()
603        {
604            return Ok(path.clone());
605        }
606
607        let target = self.external_root(project, job, reference);
608        if target.exists() {
609            fs::remove_dir_all(&target)
610                .with_context(|| format!("failed to clear {}", target.display()))?;
611        }
612        fs::create_dir_all(&target)
613            .with_context(|| format!("failed to create {}", target.display()))?;
614        let archive_path = target.join("artifacts.zip");
615        self.download_artifacts(project, job, reference, &archive_path)?;
616        self.extract_artifacts(&archive_path, &target)?;
617        let _ = fs::remove_file(&archive_path);
618
619        if let Ok(mut cache) = self.inner.cache.lock() {
620            cache.insert(key, target.clone());
621        }
622
623        Ok(target)
624    }
625
626    fn external_root(&self, project: &str, job: &str, reference: &str) -> PathBuf {
627        let project_slug = project_slug(project);
628        let reference_slug = sanitize_reference(reference);
629        self.inner
630            .root
631            .join("external")
632            .join(project_slug)
633            .join(reference_slug)
634            .join(job_name_slug(job))
635    }
636
637    fn download_artifacts(
638        &self,
639        project: &str,
640        job: &str,
641        reference: &str,
642        dest: &Path,
643    ) -> Result<()> {
644        let base = self.inner.base_url.trim_end_matches('/');
645        let project_id = percent_encode(project);
646        let ref_id = percent_encode(reference);
647        let job_name = percent_encode(job);
648        let url = format!(
649            "{base}/api/v4/projects/{project_id}/jobs/artifacts/{ref_id}/download?job={job_name}"
650        );
651        let status = Command::new("curl")
652            .arg("--fail")
653            .arg("-sS")
654            .arg("-L")
655            .arg("-H")
656            .arg(format!("PRIVATE-TOKEN: {}", self.inner.token))
657            .arg("-o")
658            .arg(dest)
659            .arg(&url)
660            .status()
661            .with_context(|| "failed to invoke curl to download artifacts")?;
662        if !status.success() {
663            return Err(anyhow!(
664                "curl failed to download artifacts from {} (status {})",
665                url,
666                status.code().unwrap_or(-1)
667            ));
668        }
669        Ok(())
670    }
671
672    fn extract_artifacts(&self, archive: &Path, dest: &Path) -> Result<()> {
673        let unzip_status = Command::new("unzip")
674            .arg("-q")
675            .arg("-o")
676            .arg(archive)
677            .arg("-d")
678            .arg(dest)
679            .status();
680        match unzip_status {
681            Ok(status) if status.success() => return Ok(()),
682            Ok(_) | Err(_) => {
683                // fallback to python's zipfile
684                let script =
685                    "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])";
686                let status = Command::new("python3")
687                    .arg("-c")
688                    .arg(script)
689                    .arg(archive)
690                    .arg(dest)
691                    .status()
692                    .with_context(|| "failed to invoke python3 to extract artifacts")?;
693                if status.success() {
694                    return Ok(());
695                }
696            }
697        }
698        Err(anyhow!(
699            "unable to extract artifacts archive {}",
700            archive.display()
701        ))
702    }
703}
704
705fn percent_encode(value: &str) -> String {
706    let mut encoded = String::new();
707    for byte in value.bytes() {
708        match byte {
709            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
710                encoded.push(byte as char)
711            }
712            _ => encoded.push_str(&format!("%{:02X}", byte)),
713        }
714    }
715    encoded
716}
717
718fn sanitize_reference(reference: &str) -> String {
719    let mut slug = String::new();
720    for ch in reference.chars() {
721        if ch.is_ascii_alphanumeric() {
722            slug.push(ch.to_ascii_lowercase());
723        } else if matches!(ch, '-' | '_' | '.') {
724            slug.push(ch);
725        } else {
726            slug.push('-');
727        }
728    }
729    if slug.is_empty() {
730        slug.push_str("ref");
731    }
732    slug
733}
734
735#[cfg(test)]
736mod tests {
737    use super::{
738        ArtifactManager, ArtifactPathKind, artifact_kind, artifact_path_has_content,
739        path_is_covered_by_explicit_artifacts, resolve_artifact_source,
740    };
741    use crate::model::{
742        ArtifactSourceOutcome, ArtifactSpec, ArtifactWhenSpec, JobSpec, RetryPolicySpec,
743    };
744    use crate::pipeline::VolumeMount;
745    use std::collections::HashMap;
746    use std::fs;
747    use std::path::{Path, PathBuf};
748    use std::time::{SystemTime, UNIX_EPOCH};
749
750    #[test]
751    fn artifact_path_kind_treats_trailing_separator_as_directory() {
752        assert!(matches!(
753            artifact_kind(std::path::Path::new("tests-temp/build/")),
754            ArtifactPathKind::Directory
755        ));
756    }
757
758    #[test]
759    fn artifact_path_has_content_requires_directory_entries() {
760        let root = temp_path("artifact-content");
761        fs::create_dir_all(&root).expect("create dir");
762        assert!(!artifact_path_has_content(&root));
763        fs::write(root.join("marker.txt"), "ok").expect("write marker");
764        assert!(artifact_path_has_content(&root));
765        let _ = fs::remove_dir_all(root);
766    }
767
768    #[test]
769    fn dependency_mount_specs_allow_populated_artifacts_without_recorded_outcome() {
770        let root = temp_path("artifact-presence");
771        let manager = ArtifactManager::new(root.clone());
772        let relative = PathBuf::from("tests-temp/build/");
773        let job = job(
774            "build",
775            vec![relative.clone()],
776            Vec::new(),
777            false,
778            ArtifactWhenSpec::OnSuccess,
779        );
780        let host = manager.job_artifact_host_path("build", &relative);
781        fs::create_dir_all(&host).expect("create artifact dir");
782        fs::write(host.join("linux-release.txt"), "release").expect("write artifact");
783
784        let specs = manager.dependency_mount_specs("build", Some(&job), None, false);
785
786        assert_eq!(specs.len(), 1);
787        assert_eq!(specs[0].0, host);
788
789        let _ = fs::remove_dir_all(root);
790    }
791
792    #[test]
793    fn artifact_when_matches_expected_outcomes() {
794        assert!(ArtifactWhenSpec::Always.includes(None));
795        assert!(ArtifactWhenSpec::Always.includes(Some(ArtifactSourceOutcome::Success)));
796        assert!(ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Success)));
797        assert!(!ArtifactWhenSpec::OnSuccess.includes(Some(ArtifactSourceOutcome::Failed)));
798        assert!(ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Failed)));
799        assert!(!ArtifactWhenSpec::OnFailure.includes(Some(ArtifactSourceOutcome::Skipped)));
800        assert!(!ArtifactWhenSpec::OnFailure.includes(None));
801    }
802
803    #[test]
804    fn collect_untracked_includes_ignored_workspace_files() {
805        let root = temp_path("artifact-untracked");
806        let workspace = root.join("workspace");
807        fs::create_dir_all(&workspace).expect("create workspace");
808        let repo = git2::Repository::init(&workspace).expect("init repo");
809        fs::write(workspace.join("README.md"), "opal\n").expect("write readme");
810        fs::write(workspace.join(".gitignore"), "tests-temp/\n").expect("write ignore");
811        let mut index = repo.index().expect("open index");
812        index
813            .add_path(Path::new("README.md"))
814            .expect("add readme to index");
815        index
816            .add_path(Path::new(".gitignore"))
817            .expect("add ignore to index");
818        let tree_id = index.write_tree().expect("write tree");
819        let tree = repo.find_tree(tree_id).expect("find tree");
820        let sig = git2::Signature::now("Opal Tests", "opal@example.com").expect("signature");
821        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
822            .expect("commit");
823
824        fs::create_dir_all(workspace.join("tests-temp")).expect("create ignored dir");
825        fs::write(workspace.join("tests-temp/generated.txt"), "hello").expect("write ignored");
826        fs::write(workspace.join("scratch.txt"), "hi").expect("write untracked");
827
828        let manager = ArtifactManager::new(root.join("artifacts"));
829        let job = job(
830            "build",
831            Vec::new(),
832            vec!["tests-temp/**/*.log".into()],
833            true,
834            ArtifactWhenSpec::OnSuccess,
835        );
836
837        manager
838            .prepare_targets(&job)
839            .expect("prepare artifact targets");
840        manager
841            .collect_untracked(&job, &workspace)
842            .expect("collect untracked artifacts");
843
844        let manifest = manager.read_untracked_manifest("build");
845        assert!(manifest.iter().any(|path| path == Path::new("scratch.txt")));
846        assert!(
847            manifest
848                .iter()
849                .any(|path| path == Path::new("tests-temp/generated.txt"))
850        );
851        assert!(
852            manager
853                .job_artifact_host_path("build", Path::new("scratch.txt"))
854                .exists()
855        );
856        assert!(
857            manager
858                .job_artifact_host_path("build", Path::new("tests-temp/generated.txt"))
859                .exists()
860        );
861
862        let _ = fs::remove_dir_all(root);
863    }
864
865    #[test]
866    fn resolve_artifact_source_prefers_more_specific_mount_over_workspace_root() {
867        let root = temp_path("artifact-mounted-source");
868        let workspace = root.join("workspace");
869        let cache_host = root.join("cache-target");
870        fs::create_dir_all(workspace.join("target/release")).expect("create workspace target");
871        fs::create_dir_all(cache_host.join("release")).expect("create cache target");
872        fs::write(cache_host.join("release/opal"), "binary").expect("write cached binary");
873
874        let mounts = vec![
875            VolumeMount {
876                host: workspace.clone(),
877                container: PathBuf::from("/builds/opal"),
878                read_only: false,
879            },
880            VolumeMount {
881                host: cache_host.clone(),
882                container: PathBuf::from("/builds/opal/target"),
883                read_only: false,
884            },
885        ];
886
887        let resolved = resolve_artifact_source(
888            &workspace,
889            &mounts,
890            Path::new("/builds/opal"),
891            Path::new("target/release/opal"),
892        );
893
894        assert_eq!(resolved, cache_host.join("release/opal"));
895
896        let _ = fs::remove_dir_all(root);
897    }
898
899    #[test]
900    fn collect_declared_supports_globbed_artifact_paths() {
901        let root = temp_path("artifact-glob-collect");
902        let workspace = root.join("workspace");
903        fs::create_dir_all(workspace.join("releases")).expect("create releases dir");
904        fs::write(
905            workspace.join("releases/opal-aarch64.tar.gz"),
906            "archive-aarch64",
907        )
908        .expect("write archive");
909        fs::write(
910            workspace.join("releases/opal-amd64.tar.gz"),
911            "archive-amd64",
912        )
913        .expect("write archive");
914        fs::write(workspace.join("releases/notes.txt"), "notes").expect("write notes");
915
916        let manager = ArtifactManager::new(root.join("artifacts"));
917        let job = job(
918            "build",
919            vec![PathBuf::from("releases/*.tar.gz")],
920            Vec::new(),
921            false,
922            ArtifactWhenSpec::OnSuccess,
923        );
924
925        manager
926            .collect_declared(&job, &workspace, &[], Path::new("/builds/opal"))
927            .expect("collect declared artifacts");
928
929        let declared = manager.read_declared_manifest("build");
930        assert_eq!(
931            declared,
932            vec![
933                PathBuf::from("releases/opal-aarch64.tar.gz"),
934                PathBuf::from("releases/opal-amd64.tar.gz")
935            ]
936        );
937        assert!(
938            manager
939                .job_artifact_host_path("build", Path::new("releases/opal-aarch64.tar.gz"))
940                .exists()
941        );
942        assert!(
943            manager
944                .job_artifact_host_path("build", Path::new("releases/opal-amd64.tar.gz"))
945                .exists()
946        );
947        assert!(
948            !manager
949                .job_artifact_host_path("build", Path::new("releases/notes.txt"))
950                .exists()
951        );
952
953        let specs = manager.dependency_mount_specs("build", Some(&job), None, false);
954        let mounted: Vec<PathBuf> = specs.into_iter().map(|(_, relative)| relative).collect();
955        assert_eq!(mounted, declared);
956
957        let _ = fs::remove_dir_all(root);
958    }
959
960    #[test]
961    fn path_is_covered_by_explicit_artifacts_matches_directory_and_file_paths() {
962        assert!(path_is_covered_by_explicit_artifacts(
963            Path::new("tests-temp/build/linux.txt"),
964            &[PathBuf::from("tests-temp/build/")]
965        ));
966        assert!(path_is_covered_by_explicit_artifacts(
967            Path::new("output/report.txt"),
968            &[PathBuf::from("output/report.txt")]
969        ));
970        assert!(!path_is_covered_by_explicit_artifacts(
971            Path::new("other.txt"),
972            &[PathBuf::from("output/report.txt")]
973        ));
974        assert!(path_is_covered_by_explicit_artifacts(
975            Path::new("releases/opal-aarch64.tar.gz"),
976            &[PathBuf::from("releases/*.tar.gz")]
977        ));
978    }
979
980    fn job(
981        name: &str,
982        paths: Vec<PathBuf>,
983        exclude: Vec<String>,
984        untracked: bool,
985        when: ArtifactWhenSpec,
986    ) -> JobSpec {
987        JobSpec {
988            name: name.into(),
989            stage: "build".into(),
990            commands: vec!["echo ok".into()],
991            needs: Vec::new(),
992            explicit_needs: false,
993            dependencies: Vec::new(),
994            before_script: None,
995            after_script: None,
996            inherit_default_before_script: true,
997            inherit_default_after_script: true,
998            inherit_default_image: true,
999            inherit_default_cache: true,
1000            inherit_default_services: true,
1001            inherit_default_timeout: true,
1002            inherit_default_retry: true,
1003            inherit_default_interruptible: true,
1004            when: None,
1005            rules: Vec::new(),
1006            only: Vec::new(),
1007            except: Vec::new(),
1008            artifacts: ArtifactSpec {
1009                name: None,
1010                paths,
1011                exclude,
1012                untracked,
1013                when,
1014                expire_in: None,
1015                report_dotenv: None,
1016            },
1017            cache: Vec::new(),
1018            image: None,
1019            variables: HashMap::new(),
1020            services: Vec::new(),
1021            timeout: None,
1022            retry: RetryPolicySpec::default(),
1023            interruptible: false,
1024            resource_group: None,
1025            parallel: None,
1026            tags: Vec::new(),
1027            environment: None,
1028        }
1029    }
1030
1031    fn temp_path(prefix: &str) -> PathBuf {
1032        let nanos = SystemTime::now()
1033            .duration_since(UNIX_EPOCH)
1034            .expect("system time before epoch")
1035            .as_nanos();
1036        std::env::temp_dir().join(format!("opal-{prefix}-{nanos}"))
1037    }
1038}