ic_backup/ops/persistence/command_lifetime_lock/
mod.rs1use super::file_lock::{self, FileLockError};
4use crate::model::command_custody::{CommandCustodyRecord, CommandCustodyRecordError};
5use std::{
6 fs::{self, File},
7 io,
8 path::{Path, PathBuf},
9 process::{Child, Command},
10 thread,
11 time::{Duration, Instant},
12};
13use thiserror::Error;
14
15const QUIESCENCE_GRACE: Duration = Duration::from_millis(250);
16pub const COMMAND_CUSTODY_DESCRIPTOR_ENV: &str = "IC_BACKUP_COMMAND_CUSTODY_FD";
18
19#[derive(Debug)]
24pub struct CommandLifetimeLock {
25 file: File,
26 path: PathBuf,
27 record: CommandCustodyRecord,
28 dispatched: bool,
29}
30
31impl CommandLifetimeLock {
32 pub fn acquire(
40 journal: &Path,
41 operation_sequence: u64,
42 ) -> Result<Self, CommandLifetimeLockError> {
43 let journal = resolve_journal(journal)?;
44 let path = lock_path(&journal, operation_sequence);
45 let file = file_lock::acquire(&path).map_err(|error| project_error(&path, error))?;
46 let (device, inode) = file_identity(&file.metadata()?)?;
47 let record = CommandCustodyRecord::new(journal, operation_sequence, device, inode)?;
48 Ok(Self {
49 file,
50 path,
51 record,
52 dispatched: false,
53 })
54 }
55
56 #[must_use]
58 pub fn record(&self) -> &CommandCustodyRecord {
59 &self.record
60 }
61
62 #[must_use]
64 pub fn path(&self) -> &Path {
65 &self.path
66 }
67
68 pub fn spawn(&mut self, mut command: Command) -> Result<Child, CommandLifetimeLockError> {
79 if self.dispatched {
80 return Err(CommandLifetimeLockError::AlreadyDispatched {
81 path: self.path.clone(),
82 });
83 }
84 require_identity(&self.record, &fs::symlink_metadata(&self.path)?, &self.path)?;
85 self.dispatched = true;
86 #[cfg(unix)]
87 {
88 use command_fds::CommandFdExt;
89 use std::os::fd::AsRawFd;
90 let descriptor =
91 rustix::io::fcntl_dupfd_cloexec(&self.file, 3).map_err(io::Error::from)?;
92 command.env(
93 COMMAND_CUSTODY_DESCRIPTOR_ENV,
94 descriptor.as_raw_fd().to_string(),
95 );
96 command.preserved_fds(vec![descriptor]);
97 let child = command.spawn();
98 drop(command);
99 child.map_err(CommandLifetimeLockError::from)
100 }
101 #[cfg(not(unix))]
102 {
103 let _ = command;
104 Err(io::Error::from(io::ErrorKind::Unsupported).into())
105 }
106 }
107
108 pub fn finish(self) -> Result<CommandQuiescenceGuard, CommandLifetimeLockError> {
117 let Self { file, record, .. } = self;
118 drop(file);
119 let deadline = Instant::now() + QUIESCENCE_GRACE;
120 loop {
121 match CommandQuiescenceGuard::acquire(&record) {
122 Err(CommandLifetimeLockError::InFlight { .. }) if Instant::now() < deadline => {
123 thread::sleep(Duration::from_millis(5));
124 }
125 result => return result,
126 }
127 }
128 }
129}
130
131#[derive(Debug)]
138pub struct CommandQuiescenceGuard {
139 file: File,
140 record: CommandCustodyRecord,
141}
142
143impl CommandQuiescenceGuard {
144 pub fn acquire(expected: &CommandCustodyRecord) -> Result<Self, CommandLifetimeLockError> {
149 let path = lock_path(expected.journal(), expected.operation_sequence());
150 let metadata = fs::symlink_metadata(&path)?;
154 if metadata.is_file() {
155 require_identity(expected, &metadata, &path)?;
156 }
157 let file =
158 file_lock::acquire_existing(&path).map_err(|error| project_error(&path, error))?;
159 require_identity(expected, &file.metadata()?, &path)?;
160 Ok(Self {
161 file,
162 record: expected.clone(),
163 })
164 }
165
166 #[must_use]
168 pub fn record(&self) -> &CommandCustodyRecord {
169 &self.record
170 }
171}
172
173impl Drop for CommandQuiescenceGuard {
174 fn drop(&mut self) {
175 #[cfg(unix)]
179 file_lock::unlock(&self.file);
180 }
181}
182
183fn resolve_journal(path: &Path) -> Result<PathBuf, CommandLifetimeLockError> {
184 let name = path
185 .file_name()
186 .ok_or_else(|| CommandLifetimeLockError::InvalidJournal {
187 path: path.to_path_buf(),
188 })?;
189 let parent = path
190 .parent()
191 .filter(|parent| !parent.as_os_str().is_empty())
192 .unwrap_or_else(|| Path::new("."));
193 let parent = parent.canonicalize()?;
194 if !parent.is_dir() {
195 return Err(io::Error::from(io::ErrorKind::NotADirectory).into());
196 }
197 let journal = parent.join(name);
198 match fs::symlink_metadata(&journal) {
199 Ok(metadata) if !metadata.is_file() => {
200 return Err(CommandLifetimeLockError::InvalidJournal { path: journal });
201 }
202 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error.into()),
203 _ => {}
204 }
205 Ok(journal)
206}
207
208fn lock_path(journal: &Path, operation_sequence: u64) -> PathBuf {
209 let mut path = journal.as_os_str().to_os_string();
210 path.push(format!(".command-{operation_sequence}.lock"));
211 PathBuf::from(path)
212}
213
214fn file_identity(metadata: &fs::Metadata) -> io::Result<(u64, u64)> {
215 if !metadata.is_file() {
216 return Err(io::Error::from(io::ErrorKind::InvalidInput));
217 }
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::MetadataExt;
221 Ok((metadata.dev(), metadata.ino()))
222 }
223 #[cfg(not(unix))]
224 {
225 let _ = metadata;
226 Err(io::Error::from(io::ErrorKind::Unsupported))
227 }
228}
229
230fn require_identity(
231 expected: &CommandCustodyRecord,
232 metadata: &fs::Metadata,
233 path: &Path,
234) -> Result<(), CommandLifetimeLockError> {
235 if !metadata.is_file() {
236 return Err(CommandLifetimeLockError::IdentityChanged {
237 path: path.to_path_buf(),
238 });
239 }
240 let (device, inode) = file_identity(metadata)?;
241 if expected.matches_file(device, inode) {
242 return Ok(());
243 }
244 Err(CommandLifetimeLockError::IdentityChanged {
245 path: path.to_path_buf(),
246 })
247}
248
249fn project_error(path: &Path, error: FileLockError) -> CommandLifetimeLockError {
250 match error {
251 FileLockError::Locked => CommandLifetimeLockError::InFlight {
252 path: path.to_path_buf(),
253 },
254 FileLockError::UnsafeEntry { kind } => CommandLifetimeLockError::UnsafeEntry {
255 path: path.to_path_buf(),
256 kind,
257 },
258 FileLockError::Io(error) => CommandLifetimeLockError::Io(error),
259 }
260}
261
262#[derive(Debug, Error)]
264pub enum CommandLifetimeLockError {
265 #[error("command custody remains in flight: {path:?}")]
267 InFlight {
268 path: PathBuf,
270 },
271 #[error("command custody identity changed: {path:?}")]
273 IdentityChanged {
274 path: PathBuf,
276 },
277 #[error("command spawn already attempted: {path:?}")]
279 AlreadyDispatched {
280 path: PathBuf,
282 },
283 #[error("unsafe command custody entry at {path:?}: {kind}")]
285 UnsafeEntry {
286 path: PathBuf,
288 kind: String,
290 },
291 #[error("invalid command journal location: {path:?}")]
293 InvalidJournal {
294 path: PathBuf,
296 },
297 #[error(transparent)]
299 Record(#[from] CommandCustodyRecordError),
300 #[error(transparent)]
302 Io(#[from] io::Error),
303}
304
305#[cfg(all(test, unix))]
306mod tests;