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