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 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}