Skip to main content

ic_backup/ops/persistence/command_lifetime_lock/
mod.rs

1//! Backup-owned journal binding over Host Process's inherited descriptor custody.
2
3use crate::model::command_custody::{CommandCustodyRecord, CommandCustodyRecordError};
4#[cfg(unix)]
5use ic_host_process::custody::{CommandCustody, CustodyError, FileIdentity, QuiescenceGuard};
6#[cfg(unix)]
7use std::time::Duration;
8use std::{
9    fs, io,
10    path::{Path, PathBuf},
11    process::{Child, Command},
12};
13use thiserror::Error;
14
15/// Child environment variable containing its inherited custody descriptor number.
16pub const COMMAND_CUSTODY_DESCRIPTOR_ENV: &str = "IC_BACKUP_COMMAND_CUSTODY_FD";
17
18/// Exclusive custody bound to one exact journal operation.
19///
20/// Persist the record and dispatch intent before spawning; paid-call authority,
21/// argv, output bounds, deadlines and reaping remain integration-owned.
22#[derive(Debug)]
23pub struct CommandLifetimeLock {
24    #[cfg(unix)]
25    custody: CommandCustody,
26    path: PathBuf,
27    record: CommandCustodyRecord,
28}
29
30impl CommandLifetimeLock {
31    /// Acquire the operation's regular no-follow sidecar without waiting.
32    /// # Errors
33    /// Rejects unsafe journals, contention, invalid identity and native I/O failures.
34    pub fn acquire(
35        journal: &Path,
36        operation_sequence: u64,
37    ) -> Result<Self, CommandLifetimeLockError> {
38        let journal = resolve_journal(journal)?;
39        let path = lock_path(&journal, operation_sequence);
40        #[cfg(unix)]
41        {
42            let custody =
43                CommandCustody::acquire(&path).map_err(|error| project_error(&path, error))?;
44            let identity = custody.identity();
45            let record = CommandCustodyRecord::new(
46                journal,
47                operation_sequence,
48                identity.device,
49                identity.inode,
50            )?;
51            Ok(Self {
52                custody,
53                path,
54                record,
55            })
56        }
57        #[cfg(not(unix))]
58        {
59            let _ = path;
60            Err(io::Error::from(io::ErrorKind::Unsupported).into())
61        }
62    }
63
64    /// Read exact Backup-owned evidence to retain before dispatch.
65    #[must_use]
66    pub fn record(&self) -> &CommandCustodyRecord {
67        &self.record
68    }
69
70    /// Read the retained sidecar path; dropping custody never removes it.
71    #[must_use]
72    pub fn path(&self) -> &Path {
73        &self.path
74    }
75
76    /// Attempt one command with Host-owned descriptor inheritance and no retry.
77    ///
78    /// A failed spawn consumes the single allowance. Descendants must preserve
79    /// the inherited descriptor; callers retain admission, deadlines and reaping.
80    /// # Errors
81    /// Rejects repeated dispatch, replaced sidecars and native spawn failures.
82    pub fn spawn(&mut self, command: Command) -> Result<Child, CommandLifetimeLockError> {
83        #[cfg(unix)]
84        {
85            self.custody
86                .spawn(command, COMMAND_CUSTODY_DESCRIPTOR_ENV)
87                .map_err(|error| project_error(&self.path, error))
88        }
89        #[cfg(not(unix))]
90        {
91            let _ = command;
92            Err(io::Error::from(io::ErrorKind::Unsupported).into())
93        }
94    }
95
96    /// Close owner custody and probe exact quiescence with Backup's bounded grace.
97    ///
98    /// Only contention is retried for 250 ms at 5 ms intervals. Failure preserves
99    /// the sidecar and establishes no remote effect outcome or retry authority.
100    /// # Errors
101    /// Rejects active inherited holders, changed identities and native I/O failures.
102    pub fn finish(self) -> Result<CommandQuiescenceGuard, CommandLifetimeLockError> {
103        #[cfg(unix)]
104        {
105            let guard = self
106                .custody
107                .finish(Duration::from_millis(250), Duration::from_millis(5))
108                .map_err(|error| project_error(&self.path, error))?;
109            Ok(CommandQuiescenceGuard {
110                _guard: guard,
111                record: self.record,
112            })
113        }
114        #[cfg(not(unix))]
115        {
116            Err(io::Error::from(io::ErrorKind::Unsupported).into())
117        }
118    }
119}
120
121/// Fresh exclusive quiescence bound to Backup's retained original journal record.
122///
123/// Host owns descriptor exclusion and explicit unlock on drop. This guard cannot
124/// spawn; terminal settlement and reference-release admission remain separate.
125#[derive(Debug)]
126pub struct CommandQuiescenceGuard {
127    #[cfg(unix)]
128    _guard: QuiescenceGuard,
129    record: CommandCustodyRecord,
130}
131
132impl CommandQuiescenceGuard {
133    /// Admit the exact retained existing sidecar without creating it.
134    /// # Errors
135    /// Rejects active holders, replaced/missing/unsafe sidecars and I/O failures.
136    pub fn acquire(expected: &CommandCustodyRecord) -> Result<Self, CommandLifetimeLockError> {
137        #[cfg(unix)]
138        {
139            let path = lock_path(expected.journal(), expected.operation_sequence());
140            let identity = FileIdentity {
141                device: expected.device(),
142                inode: expected.inode(),
143            };
144            let guard = QuiescenceGuard::acquire(&path, identity)
145                .map_err(|error| project_error(&path, error))?;
146            Ok(Self {
147                _guard: guard,
148                record: expected.clone(),
149            })
150        }
151        #[cfg(not(unix))]
152        {
153            let _ = expected;
154            Err(io::Error::from(io::ErrorKind::Unsupported).into())
155        }
156    }
157
158    /// Read the exact retained evidence freshly admitted by this guard.
159    #[must_use]
160    pub fn record(&self) -> &CommandCustodyRecord {
161        &self.record
162    }
163}
164
165fn resolve_journal(path: &Path) -> Result<PathBuf, CommandLifetimeLockError> {
166    let name = path
167        .file_name()
168        .ok_or_else(|| CommandLifetimeLockError::InvalidJournal {
169            path: path.to_path_buf(),
170        })?;
171    let parent = path
172        .parent()
173        .filter(|parent| !parent.as_os_str().is_empty())
174        .unwrap_or_else(|| Path::new("."));
175    let parent = parent.canonicalize()?;
176    if !parent.is_dir() {
177        return Err(io::Error::from(io::ErrorKind::NotADirectory).into());
178    }
179    let journal = parent.join(name);
180    match fs::symlink_metadata(&journal) {
181        Ok(metadata) if !metadata.is_file() => {
182            return Err(CommandLifetimeLockError::InvalidJournal { path: journal });
183        }
184        Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error.into()),
185        _ => {}
186    }
187    Ok(journal)
188}
189
190fn lock_path(journal: &Path, operation_sequence: u64) -> PathBuf {
191    let mut path = journal.as_os_str().to_os_string();
192    path.push(format!(".command-{operation_sequence}.lock"));
193    PathBuf::from(path)
194}
195
196#[cfg(unix)]
197fn project_error(path: &Path, error: CustodyError) -> CommandLifetimeLockError {
198    use ic_host_fs::durable::RegularFileLockError;
199    match error {
200        CustodyError::InFlight { path } => CommandLifetimeLockError::InFlight { path },
201        CustodyError::IdentityChanged { path } => {
202            CommandLifetimeLockError::IdentityChanged { path }
203        }
204        CustodyError::AlreadyDispatched { path } => {
205            CommandLifetimeLockError::AlreadyDispatched { path }
206        }
207        CustodyError::Io(error) => CommandLifetimeLockError::Io(error),
208        CustodyError::Lock(RegularFileLockError::NotRegular) => {
209            let kind = match fs::symlink_metadata(path) {
210                Ok(metadata) if metadata.file_type().is_symlink() => "Symlink",
211                Ok(metadata) if metadata.is_dir() => "Directory",
212                _ => "Special",
213            };
214            CommandLifetimeLockError::UnsafeEntry {
215                path: path.to_path_buf(),
216                kind: kind.into(),
217            }
218        }
219        CustodyError::Lock(error) => CommandLifetimeLockError::Io(error.into()),
220    }
221}
222
223/// Typed custody admission, dispatch or quiescence failure.
224#[derive(Debug, Error)]
225pub enum CommandLifetimeLockError {
226    /// An owner or inherited command still holds custody.
227    #[error("command custody remains in flight: {path:?}")]
228    InFlight {
229        /// Retained sidecar path.
230        path: PathBuf,
231    },
232    /// The retained sidecar is no longer the expected regular file.
233    #[error("command custody identity changed: {path:?}")]
234    IdentityChanged {
235        /// Changed sidecar path.
236        path: PathBuf,
237    },
238    /// One guard already attempted its single command spawn.
239    #[error("command spawn already attempted: {path:?}")]
240    AlreadyDispatched {
241        /// Retained sidecar path.
242        path: PathBuf,
243    },
244    /// The sidecar has an unsafe entry type.
245    #[error("unsafe command custody entry at {path:?}: {kind}")]
246    UnsafeEntry {
247        /// Sidecar path.
248        path: PathBuf,
249        /// Observed entry kind.
250        kind: String,
251    },
252    /// The selected journal location is unsafe or incomplete.
253    #[error("invalid command journal location: {path:?}")]
254    InvalidJournal {
255        /// Rejected location.
256        path: PathBuf,
257    },
258    /// Retained custody evidence fails model admission.
259    #[error(transparent)]
260    Record(#[from] CommandCustodyRecordError),
261    /// A filesystem or process operation failed.
262    #[error(transparent)]
263    Io(#[from] io::Error),
264}
265
266#[cfg(all(test, unix))]
267mod tests;