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