gatehouse_runtime_protocol/lifecycle/journal/
storage.rs1use super::*;
4use crate::lifecycle::canonical;
5use std::ffi::CString;
6use std::fs::{self, File, OpenOptions};
7use std::io::{Read, Write};
8use std::os::fd::{AsRawFd, FromRawFd};
9use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13fn io<T>(r: std::io::Result<T>) -> Result<T> {
14 r.map_err(|_| Invalid("lifecycle journal I/O failure; retain state"))
15}
16fn private(m: &fs::Metadata, directory: bool) -> Result<()> {
17 if m.uid() != unsafe { libc::geteuid() }
19 || m.mode() & 0o777 != if directory { 0o700 } else { 0o600 }
20 || if directory {
21 !m.is_dir()
22 } else {
23 !m.is_file() || m.nlink() != 1
24 }
25 {
26 return Err(Invalid("lifecycle journal must be private"));
27 }
28 Ok(())
29}
30
31struct LockedDirectory {
34 file: File,
35 owner_pid: libc::pid_t,
36}
37impl std::ops::Deref for LockedDirectory {
38 type Target = File;
39 fn deref(&self) -> &File {
40 &self.file
41 }
42}
43impl Drop for LockedDirectory {
44 fn drop(&mut self) {
45 if unsafe { libc::getpid() } != self.owner_pid {
48 return;
49 }
50 loop {
51 let result = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
52 if result == 0
53 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
54 {
55 break;
56 }
57 }
58 }
59}
60
61pub struct Journal {
65 root: PathBuf,
66 directory: LockedDirectory,
67 record: Record,
68 bytes: Vec<u8>,
69 poisoned: bool,
70 ready: bool,
71 fresh_status: bool,
72}
73
74#[must_use]
86pub struct Dispatch<'a> {
87 journal: &'a mut Journal,
88}
89impl Dispatch<'_> {
90 pub fn request(&self) -> &Request {
91 &self
92 .journal
93 .record
94 .pending
95 .as_ref()
96 .expect("prepared token")
97 .request
98 }
99 pub fn observe(self, bytes: Option<&[u8]>) -> Result<()> {
101 let next = match self.journal.record.observe(bytes) {
102 Ok(next) => next,
103 Err(e) => {
104 self.journal.poisoned = true;
105 return Err(e);
106 }
107 };
108 let fresh = matches!(
109 next.attempts
110 .last()
111 .and_then(|a| a.response.as_ref())
112 .map(|r| (&r.operation, &r.outcome)),
113 Some((Operation::Status { .. }, Outcome::Status { .. }))
114 );
115 self.journal.commit(next)?;
116 self.journal.fresh_status = fresh;
117 Ok(())
118 }
119}
120impl Journal {
121 fn directory(root: &Path) -> Result<LockedDirectory> {
122 if !root.is_absolute() || io(fs::canonicalize(root))? != root {
123 return Err(Invalid("journal path must be canonical"));
124 }
125 for parent in root.ancestors().skip(1) {
128 let m = io(fs::symlink_metadata(parent))?;
129 if !m.is_dir()
130 || m.mode() & 0o022 != 0
131 || (m.uid() != 0 && m.uid() != unsafe { libc::geteuid() })
132 {
133 return Err(Invalid("unprotected journal ancestor"));
134 }
135 }
136 let directory = io(OpenOptions::new()
137 .read(true)
138 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
139 .open(root))?;
140 private(&io(directory.metadata())?, true)?;
141 if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
143 return Err(Invalid("lifecycle resource is locked"));
144 }
145 Ok(LockedDirectory {
146 file: directory,
147 owner_pid: unsafe { libc::getpid() },
148 })
149 }
150 fn at(directory: &File, name: &str, flags: i32) -> Result<Option<File>> {
151 let name = CString::new(name).map_err(|_| Invalid("invalid journal member"))?;
152 let fd = unsafe {
154 libc::openat(
155 directory.as_raw_fd(),
156 name.as_ptr(),
157 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
158 0o600,
159 )
160 };
161 if fd < 0 {
162 let err = std::io::Error::last_os_error();
163 if err.kind() == std::io::ErrorKind::NotFound && flags & libc::O_CREAT == 0 {
164 return Ok(None);
165 }
166 return Err(Invalid("cannot open lifecycle journal member"));
167 }
168 let f = unsafe { File::from_raw_fd(fd) };
170 private(&io(f.metadata())?, false)?;
171 Ok(Some(f))
172 }
173 fn read(directory: &File, name: &str) -> Result<Option<Vec<u8>>> {
174 let Some(f) = Self::at(directory, name, libc::O_RDONLY)? else {
175 return Ok(None);
176 };
177 let mut b = vec![];
178 io(f.take((crate::MAX_MESSAGE_BYTES + 1) as u64)
179 .read_to_end(&mut b))?;
180 if b.len() > crate::MAX_MESSAGE_BYTES {
181 return Err(Invalid("journal member too large"));
182 }
183 Ok(Some(b))
184 }
185 fn check(&self) -> Result<()> {
186 if self.poisoned {
187 return Err(Invalid("journal poisoned; reopen required"));
188 }
189 let now = io(fs::symlink_metadata(&self.root))?;
190 let held = io(self.directory.metadata())?;
191 private(&now, true)?;
192 if now.dev() != held.dev() || now.ino() != held.ino() {
193 return Err(Invalid("journal directory replaced"));
194 }
195 if Self::read(&self.directory, "lease.json")?.as_deref() != Some(self.bytes.as_slice()) {
196 return Err(Invalid("journal changed outside lock"));
197 }
198 if self.record.state.phase() != Phase::VerifiedAbsent
199 && Self::read(&self.directory, "active")?.as_deref()
200 != Some(self.record.marker().as_bytes())
201 {
202 return Err(Invalid("active lifecycle marker changed"));
203 }
204 Ok(())
205 }
206 fn encode(record: &Record) -> Result<Vec<u8>> {
207 record.validate()?;
208 canonical::encode(
209 &serde_json::to_value(record).map_err(|_| Invalid("journal encoding failed"))?,
210 )
211 }
212 fn rename(directory: &File, from: &str, to: &str) -> Result<()> {
213 let from = CString::new(from).unwrap();
214 let to = CString::new(to).unwrap();
215 if unsafe {
217 libc::renameat(
218 directory.as_raw_fd(),
219 from.as_ptr(),
220 directory.as_raw_fd(),
221 to.as_ptr(),
222 )
223 } != 0
224 {
225 return Err(Invalid("journal publish failed"));
226 }
227 Ok(())
228 }
229 fn atomic(directory: &File, name: &str, bytes: &[u8]) -> Result<()> {
230 Self::atomic_with(directory, name, bytes, |_| Ok(()))
231 }
232 fn atomic_with(
233 directory: &File,
234 name: &str,
235 bytes: &[u8],
236 mut stage: impl FnMut(u8) -> Result<()>,
237 ) -> Result<()> {
238 static NEXT: AtomicU64 = AtomicU64::new(0);
239 let temp = format!(
240 "next-{}-{}",
241 std::process::id(),
242 NEXT.fetch_add(1, Ordering::Relaxed)
243 );
244 let mut f = Self::at(
245 directory,
246 &temp,
247 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
248 )?
249 .ok_or(Invalid("missing journal temp"))?;
250 io(f.write_all(bytes))?;
251 stage(0)?;
252 io(f.sync_all())?;
253 stage(1)?;
254 Self::rename(directory, &temp, name)?;
255 stage(2)?;
256 io(directory.sync_all())?;
257 Ok(())
258 }
259 pub fn create(root: &Path, create: &Request) -> Result<Self> {
260 let directory = Self::directory(root)?;
261 let record = Record::new(create)?;
262 let bytes = Self::encode(&record)?;
263 for name in [
264 "lease.json",
265 "active",
266 "tombstone.json",
267 "reservation.json",
268 "reservation-tombstone.json",
269 ] {
270 if Self::read(&directory, name)?.is_some() {
271 return Err(Invalid("lifecycle resource already recorded"));
272 }
273 }
274 let mut marker = Self::at(
276 &directory,
277 "active",
278 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
279 )?
280 .ok_or(Invalid("missing active marker"))?;
281 io(marker.write_all(create.binding.operation_key.as_bytes()))?;
282 io(marker.sync_all())?;
283 io(directory.sync_all())?;
284 Self::atomic(&directory, "lease.json", &bytes)?;
285 Ok(Self {
286 root: root.into(),
287 directory,
288 record,
289 bytes,
290 poisoned: false,
291 ready: true,
292 fresh_status: false,
293 })
294 }
295 pub fn open(root: &Path, create: &Request) -> Result<Self> {
296 create.validate()?;
297 let directory = Self::directory(root)?;
298 Self::open_held(root, create, directory)
299 }
300 fn open_held(root: &Path, create: &Request, directory: LockedDirectory) -> Result<Self> {
301 let bytes = Self::read(&directory, "lease.json")?
302 .ok_or(Invalid("missing journal; no automatic adoption"))?;
303 let value = canonical::parse_canonical(&bytes)?;
304 let record: Record = serde_json::from_value(value)
305 .map_err(|_| Invalid("invalid lifecycle journal schema"))?;
306 if Self::encode(&record)? != bytes || record.create != *create {
307 return Err(Invalid("journal binding mismatch"));
308 }
309 let marker = Self::read(&directory, "active")?;
310 let tombstone = Self::read(&directory, "tombstone.json")?;
311 if let Some(t) = &tombstone {
312 if record.state.phase() != Phase::VerifiedAbsent || t != &bytes {
313 return Err(Invalid("journal tombstone mismatch"));
314 }
315 }
316 if let Some(m) = &marker {
317 if m != record.marker().as_bytes() {
318 return Err(Invalid("journal marker mismatch"));
319 }
320 } else if record.state.phase() != Phase::VerifiedAbsent
321 || tombstone.as_ref() != Some(&bytes)
322 {
323 return Err(Invalid("missing active marker without durable absence"));
324 }
325 let mut store = Self {
326 root: root.into(),
327 directory,
328 record,
329 bytes,
330 poisoned: false,
331 ready: false,
332 fresh_status: false,
333 };
334 let recovered = store.record.recover()?;
335 if recovered != store.record {
336 store.commit(recovered)?;
337 }
338 Ok(store)
339 }
340 pub fn retain_unknown(&mut self) -> Result<()> {
343 self.check()?;
344 let next = self.record.observe(None)?;
345 self.commit(next)
346 }
347 pub fn phase(&self) -> Phase {
348 self.record.state.phase()
349 }
350 pub fn revision(&self) -> u64 {
351 self.record.revision
352 }
353 pub fn target(&self) -> Result<Target> {
354 self.record.target()
355 }
356 fn commit(&mut self, next: Record) -> Result<()> {
357 self.commit_with(next, |_| Ok(()))
358 }
359 fn commit_with(&mut self, next: Record, stage: impl FnMut(u8) -> Result<()>) -> Result<()> {
360 self.check()?;
361 self.poisoned = true;
362 self.ready = false;
363 self.fresh_status = false;
364 let bytes = Self::encode(&next)?;
365 Self::atomic_with(&self.directory, "lease.json", &bytes, stage)?;
366 self.record = next;
367 self.bytes = bytes;
368 self.poisoned = false;
369 Ok(())
370 }
371 pub fn begin(&mut self, request: &Request, purpose: Purpose) -> Result<Dispatch<'_>> {
372 self.check()?;
373 let next = self.record.begin(request, purpose, self.fresh_status)?;
374 self.commit(next)?;
375 self.ready = true;
376 self.dispatch()
377 }
378 pub fn dispatch(&mut self) -> Result<Dispatch<'_>> {
382 self.check()?;
383 if !self.ready || self.record.pending.is_none() {
384 return Err(Invalid("no fresh durable dispatch authorization"));
385 }
386 self.ready = false;
387 Ok(Dispatch { journal: self })
388 }
389 fn persist_tombstone(&self) -> Result<()> {
390 match Self::read(&self.directory, "tombstone.json")? {
391 Some(b) if b != self.bytes => return Err(Invalid("conflicting tombstone")),
392 Some(_) => {}
393 None => Self::atomic(&self.directory, "tombstone.json", &self.bytes)?,
394 }
395 Ok(())
396 }
397 pub fn finish_release(&mut self) -> Result<()> {
401 self.check()?;
402 if self.record.state.phase() != Phase::VerifiedAbsent {
403 return Err(Invalid("absence evidence required before release"));
404 }
405 self.poisoned = true;
406 self.persist_tombstone()?;
407 let name = CString::new("active").unwrap();
408 if unsafe { libc::unlinkat(self.directory.as_raw_fd(), name.as_ptr(), 0) } != 0
410 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::NotFound
411 {
412 return Err(Invalid("cannot remove lifecycle marker"));
413 }
414 io(self.directory.sync_all())?;
415 self.poisoned = false;
416 Ok(())
417 }
418}
419
420#[cfg(test)]
421mod tests;
422
423mod reservation;
424pub use reservation::{Reservation, ResolutionDispatch};
425
426mod slot;
427pub use slot::{OperatorRecovery, ServiceSlot};