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