Skip to main content

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    fmt, fs, io,
10    path::{Path, PathBuf},
11    sync::atomic::{AtomicU64, Ordering},
12};
13
14use ic_host_artifacts::artifact::{ArtifactError, CopyError, copy_reader};
15use ic_host_fs::{
16    durable::{NamedWriteError, PublicationMode, WriteOptions, write_with},
17    read::open_regular_file,
18};
19
20use super::cache_fs::remove_path_if_present;
21
22const STAGE_ATTEMPTS: usize = 64;
23static STAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
24
25#[derive(Debug)]
26pub(super) struct OwnedDirectory {
27    path: PathBuf,
28    armed: bool,
29}
30
31impl OwnedDirectory {
32    pub(super) const fn new(path: PathBuf) -> Self {
33        Self { path, armed: true }
34    }
35
36    pub(super) fn path(&self) -> &Path {
37        &self.path
38    }
39
40    pub(super) const fn is_armed(&self) -> bool {
41        self.armed
42    }
43
44    pub(super) const fn disarm(&mut self) {
45        self.armed = false;
46    }
47
48    pub(super) fn preserve(mut self) {
49        self.disarm();
50    }
51
52    pub(super) fn try_cleanup(&mut self) -> io::Result<()> {
53        remove_path_if_present(&self.path)?;
54        self.disarm();
55        Ok(())
56    }
57
58    pub(super) fn cleanup(mut self) -> io::Result<()> {
59        self.try_cleanup()
60    }
61}
62
63impl Drop for OwnedDirectory {
64    fn drop(&mut self) {
65        if self.armed {
66            let _ = remove_path_if_present(&self.path);
67            self.disarm();
68        }
69    }
70}
71
72/// Temporary artifact directory retained until its owner is closed or dropped.
73///
74/// The caller keeps custody of the parent and allocated child against unrelated
75/// writers. Drop performs best-effort cleanup of this invocation-owned child;
76/// use [`Self::close`] when cleanup failures must reach the caller. No shared
77/// cache entry is created, reused or removed.
78#[derive(Debug)]
79pub struct ArtifactStagingDirectory {
80    directory: OwnedDirectory,
81}
82
83impl ArtifactStagingDirectory {
84    /// Allocate a distinct staging child beneath an existing caller-owned directory.
85    ///
86    /// The parent is not created. Existing children are never reused or removed.
87    ///
88    /// # Errors
89    /// Returns the filesystem error for a missing or unwritable parent, or
90    /// `AlreadyExists` when every allocation attempt collides.
91    pub fn new(parent: &Path) -> io::Result<Self> {
92        for _ in 0..STAGE_ATTEMPTS {
93            let sequence = STAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
94            let root = parent.join(format!(
95                ".ic-build-artifact-stage-{}-{sequence}",
96                std::process::id()
97            ));
98            match fs::create_dir(&root) {
99                Ok(()) => {
100                    return Ok(Self {
101                        directory: OwnedDirectory::new(root),
102                    });
103                }
104                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
105                Err(source) => return Err(source),
106            }
107        }
108        Err(io::Error::new(
109            io::ErrorKind::AlreadyExists,
110            format!(
111                "failed to allocate artifact staging beneath {}",
112                parent.display()
113            ),
114        ))
115    }
116
117    /// Borrow the owned staging directory through artifact production and consumption.
118    #[must_use]
119    pub fn path(&self) -> &Path {
120        self.directory.path()
121    }
122
123    /// Remove this owned staging child, reporting cleanup failure without a hidden retry.
124    ///
125    /// On failure, the caller owns recovery of the remaining staging child.
126    /// It does not remove the parent or any other artifact directory.
127    ///
128    /// # Errors
129    /// Returns the filesystem error if removing the owned child fails.
130    pub fn close(mut self) -> io::Result<()> {
131        self.directory.disarm();
132        remove_path_if_present(self.directory.path())
133    }
134}
135
136/// Owned copy of one completed producer output, independent of later source replacement.
137///
138/// Keep this record alive through every use of [`Self::path`]. The copy is
139/// temporary rather than a reusable cache entry; ordinary Drop removes it.
140/// [`Self::retain`] explicitly transfers cleanup to the caller.
141#[derive(Debug)]
142pub struct CapturedArtifact {
143    staging: ArtifactStagingDirectory,
144    path: PathBuf,
145}
146
147/// Finalization failed after capture custody was transferred to the caller.
148///
149/// The exact bytes remain at [`Self::retained_path`]. If rename succeeded but
150/// cleanup failed, that path is the destination; otherwise it is the staging file.
151#[derive(Debug)]
152pub struct CaptureRetentionError {
153    /// Filesystem operation that failed.
154    pub source: io::Error,
155    /// Actual retained file path after the failed operation.
156    pub retained_path: PathBuf,
157    /// Owned staging child still requiring caller disposition.
158    pub staging_directory: PathBuf,
159}
160
161impl fmt::Display for CaptureRetentionError {
162    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(
164            formatter,
165            "capture retained at {} (staging {}): {}",
166            self.retained_path.display(),
167            self.staging_directory.display(),
168            self.source
169        )
170    }
171}
172
173impl std::error::Error for CaptureRetentionError {
174    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
175        Some(&self.source)
176    }
177}
178
179impl CapturedArtifact {
180    /// Capture an output synchronously before another producer can overwrite it.
181    ///
182    /// The caller keeps the source stable during capture and selects the maximum
183    /// readable byte count. Read/publication failures retain their typed causes
184    /// inside the returned I/O error. Existing public destinations are untouched.
185    /// Bytes stream through bounded scratch rather than a complete in-memory copy.
186    ///
187    /// # Errors
188    /// Returns the source read/byte-limit, staging allocation or durable-write
189    /// error. An unsuccessful capture performs best-effort staging cleanup.
190    pub fn capture(source: &Path, parent: &Path, maximum_bytes: usize) -> io::Result<Self> {
191        let source = open_regular_file(source).map_err(io::Error::from)?;
192        let limit = maximum_bytes as u64;
193        if source.metadata()?.len() > limit {
194            return Err(ArtifactError::LimitExceeded { limit }.into());
195        }
196        let staging = ArtifactStagingDirectory::new(parent)?;
197        let path = staging.path().join("input.artifact");
198        write_with(
199            &path,
200            WriteOptions {
201                mode: PublicationMode::Replace,
202                permissions: 0o666,
203            },
204            |file| copy_reader(source, file, limit).map(|_| ()),
205        )
206        .map_err(|error| match error {
207            NamedWriteError::Producer {
208                source: CopyError::Input(source),
209                cleanup_error: None,
210            } => io::Error::from(source),
211            other => io::Error::other(other),
212        })?;
213        Ok(Self { staging, path })
214    }
215
216    /// Borrow the exact captured bytes while this record owns their lifetime.
217    #[must_use]
218    pub fn path(&self) -> &Path {
219        &self.path
220    }
221
222    /// Transfer the captured file and its staging child to caller-owned retention.
223    ///
224    /// Unlike close or Drop, this keeps the exact captured bytes after this owner
225    /// is consumed. The caller owns later cleanup of the returned path's parent.
226    /// Keep custody of those paths; this grants no cache or release admission.
227    #[must_use]
228    pub fn retain(mut self) -> PathBuf {
229        self.staging.directory.disarm();
230        self.path
231    }
232
233    /// Retain the exact capture at a caller-selected destination.
234    ///
235    /// Uses [`fs::rename`] semantics, including replacement of an existing
236    /// destination when supported. The caller owns destination admission and
237    /// later custody. Only the empty owned staging child is removed on success.
238    ///
239    /// # Errors
240    /// Rename or cleanup failure reports the actual retained bytes and staging
241    /// child. Neither failure deletes evidence or silently retries publication.
242    pub fn retain_to(self, destination: &Path) -> Result<PathBuf, CaptureRetentionError> {
243        let staging = self.staging.path().to_owned();
244        let retained = self.retain();
245        fs::rename(&retained, destination).map_err(|source| CaptureRetentionError {
246            source,
247            retained_path: retained.clone(),
248            staging_directory: staging.clone(),
249        })?;
250        fs::remove_dir(&staging).map_err(|source| CaptureRetentionError {
251            source,
252            retained_path: destination.to_owned(),
253            staging_directory: staging,
254        })?;
255        Ok(destination.to_owned())
256    }
257
258    /// Remove the captured output and its owned staging child, reporting cleanup failure.
259    ///
260    /// # Errors
261    /// Returns the filesystem error if removing the owned child fails.
262    pub fn close(self) -> io::Result<()> {
263        self.staging.close()
264    }
265}
266
267#[cfg(test)]
268mod tests;