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;