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