ic_build/artifacts/staging/mod.rs
1//! Invocation-owned artifact staging without cache publication or reuse.
2//!
3//! Callers own the parent directory, recipes and publication boundary. This
4//! module allocates only its own child and removes it after the last owner is
5//! dropped. Explicit retention transfers cleanup to the caller.
6//! It never prunes existing artifacts or publishes a product release.
7
8use std::{
9 fs, io,
10 path::{Path, PathBuf},
11 sync::atomic::{AtomicU64, Ordering},
12};
13
14use ic_host_fs::{durable::write_bytes, read::read_file};
15
16use super::cache_fs::remove_path_if_present;
17
18const STAGE_ATTEMPTS: usize = 64;
19static STAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21/// Temporary artifact directory retained until its owner is closed or dropped.
22///
23/// The caller keeps custody of the parent and allocated child against unrelated
24/// writers. Drop performs best-effort cleanup of this invocation-owned child;
25/// use [`Self::close`] when cleanup failures must reach the caller. No shared
26/// cache entry is created, reused or removed.
27#[derive(Debug)]
28pub struct ArtifactStagingDirectory {
29 root: PathBuf,
30 armed: bool,
31}
32
33impl ArtifactStagingDirectory {
34 /// Allocate a distinct staging child beneath an existing caller-owned directory.
35 ///
36 /// The parent is not created. Existing children are never reused or removed.
37 ///
38 /// # Errors
39 /// Returns the filesystem error for a missing or unwritable parent, or
40 /// `AlreadyExists` when every allocation attempt collides.
41 pub fn new(parent: &Path) -> io::Result<Self> {
42 for _ in 0..STAGE_ATTEMPTS {
43 let sequence = STAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
44 let root = parent.join(format!(
45 ".ic-build-artifact-stage-{}-{sequence}",
46 std::process::id()
47 ));
48 match fs::create_dir(&root) {
49 Ok(()) => return Ok(Self { root, armed: true }),
50 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
51 Err(source) => return Err(source),
52 }
53 }
54 Err(io::Error::new(
55 io::ErrorKind::AlreadyExists,
56 format!(
57 "failed to allocate artifact staging beneath {}",
58 parent.display()
59 ),
60 ))
61 }
62
63 /// Borrow the owned staging directory through artifact production and consumption.
64 #[must_use]
65 pub fn path(&self) -> &Path {
66 &self.root
67 }
68
69 /// Remove this owned staging child, reporting cleanup failure without a hidden retry.
70 ///
71 /// On failure, the caller owns recovery of the remaining staging child.
72 /// It does not remove the parent or any other artifact directory.
73 ///
74 /// # Errors
75 /// Returns the filesystem error if removing the owned child fails.
76 pub fn close(mut self) -> io::Result<()> {
77 self.armed = false;
78 remove_path_if_present(&self.root)
79 }
80}
81
82impl Drop for ArtifactStagingDirectory {
83 fn drop(&mut self) {
84 if self.armed {
85 let _ = remove_path_if_present(&self.root);
86 }
87 }
88}
89
90/// Owned copy of one completed producer output, independent of later source replacement.
91///
92/// Keep this record alive through every use of [`Self::path`]. The copy is
93/// temporary rather than a reusable cache entry; ordinary Drop removes it.
94/// [`Self::retain`] explicitly transfers cleanup to the caller.
95#[derive(Debug)]
96pub struct CapturedArtifact {
97 staging: ArtifactStagingDirectory,
98 path: PathBuf,
99}
100
101impl CapturedArtifact {
102 /// Capture an output synchronously before another producer can overwrite it.
103 ///
104 /// The caller keeps the source stable during capture and selects the maximum
105 /// readable byte count. Read/publication failures retain their typed causes
106 /// inside the returned I/O error. Existing public destinations are untouched.
107 ///
108 /// # Errors
109 /// Returns the source read/byte-limit, staging allocation or durable-write
110 /// error. An unsuccessful capture performs best-effort staging cleanup.
111 pub fn capture(source: &Path, parent: &Path, maximum_bytes: usize) -> io::Result<Self> {
112 let bytes = read_file(source, maximum_bytes).map_err(io::Error::from)?;
113 let staging = ArtifactStagingDirectory::new(parent)?;
114 let path = staging.path().join("input.artifact");
115 write_bytes(&path, &bytes).map_err(io::Error::other)?;
116 Ok(Self { staging, path })
117 }
118
119 /// Borrow the exact captured bytes while this record owns their lifetime.
120 #[must_use]
121 pub fn path(&self) -> &Path {
122 &self.path
123 }
124
125 /// Transfer the captured file and its staging child to caller-owned retention.
126 ///
127 /// Unlike close or Drop, this keeps the exact captured bytes after this owner
128 /// is consumed. The caller owns later cleanup of the returned path's parent.
129 /// Keep custody of those paths; this grants no cache or release admission.
130 #[must_use]
131 pub fn retain(mut self) -> PathBuf {
132 self.staging.armed = false;
133 self.path
134 }
135
136 /// Remove the captured output and its owned staging child, reporting cleanup failure.
137 ///
138 /// # Errors
139 /// Returns the filesystem error if removing the owned child fails.
140 pub fn close(self) -> io::Result<()> {
141 self.staging.close()
142 }
143}
144
145#[cfg(test)]
146mod tests;