crates/appliance-pid1/src/stop_record.rs
//! **The last stop, kept where it survives the stop** (Gunnar's `stop_record`, dcf1d82f
//! 2026-10-01, moved here so both products keep it ONE way).
//!
//! The real test2 run 9: vm1 stopped cleanly, but its power-off steps were lost (the
//! narration and the disk log die with the read-only remount of /data), and UpCloud
//! reported `stopped` 46 s after the soft stop, so nobody could tell the guest's power-off
//! from the provider's reporting. After the quiesce and just before the power goes, ONE
//! line is written on the ESP (its own filesystem, flipped read-write for that write); the
//! next boot says it at INFO and removes it.
use std::path::{Path, PathBuf};
/// Where korp-start leaves the ESP mounted (read-only).
pub const ESP_MOUNT: &str = "/boot";
/// The words the next boot says the record in: what an installer's kit looks for.
pub const SAID: &str = "the previous stop (kept on the ESP):";
/// **Where `product`'s record lives on the ESP**: `/boot/<product>-last-stop.txt`
/// (Gunnar's stays `/boot/gunnar-last-stop.txt`).
pub fn record_for(product: &str) -> PathBuf {
Path::new(ESP_MOUNT).join(format!("{product}-last-stop.txt"))
}
/// **The record**: `power_off_epoch_ms=<ms> <the stop's line>`, one line.
pub fn render(power_off_epoch_ms: u128, line: &str) -> String {
format!("power_off_epoch_ms={power_off_epoch_ms} {}\n", line.replace(['\r', '\n'], " "))
}
/// The power-off time out of a record (or out of the line the next boot said it in).
pub fn power_off_epoch_ms(text: &str) -> Option<u128> {
text.split("power_off_epoch_ms=").nth(1)?.split_whitespace().next()?.parse().ok()
}
/// **Keep the stop said as `line` at `record`, stamped now** — the last write before the
/// power, with `esp_mount` flipped read-write for exactly that write.
pub fn keep_at(record: &Path, esp_mount: &Path, line: &str) -> bool {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
write_esp_file(esp_mount, record, render(now, line).as_bytes())
}
/// [`keep_at`] for `product` on the real ESP.
pub fn keep(product: &str, line: &str) -> bool {
keep_at(&record_for(product), Path::new(ESP_MOUNT), line)
}
/// **At boot: the previous stop's line to say** (`<SAID> <record>`), the record removed.
/// `None` when there is none.
pub fn take_previous_at(record: &Path, esp_mount: &Path) -> Option<String> {
let text = std::fs::read_to_string(record).ok()?;
remove_esp_file(esp_mount, record);
Some(format!("{SAID} {}", text.trim()))
}
/// [`take_previous_at`] for `product` on the real ESP, said through `say`.
pub fn say_previous(product: &str, say: &dyn Fn(&str)) {
if let Some(line) = take_previous_at(&record_for(product), Path::new(ESP_MOUNT)) {
say(&line);
}
}
/// **Write one small file on the ESP**, `esp_mount` flipped read-write for exactly that
/// write and straight back (nothing may hold a writer open across a remount to
/// read-only). `false` when it could not be written.
pub fn write_esp_file(esp_mount: &Path, path: &Path, bytes: &[u8]) -> bool {
use std::io::Write as _;
with_esp_writable(esp_mount, || std::fs::File::create(path).and_then(|mut f| f.write_all(bytes).and_then(|_| f.sync_all())).is_ok())
}
/// **Remove one small file from the ESP**, flipped read-write the same way.
pub fn remove_esp_file(esp_mount: &Path, path: &Path) -> bool {
with_esp_writable(esp_mount, || std::fs::remove_file(path).is_ok())
}
/// **Run `f` with the ESP writable** (lane D14): a read-only `esp_mount` is flipped read-write
/// for exactly `f` and straight back; one already read-write is left as it is (never turned
/// read-only behind somebody's back). What `f` answers is answered.
pub fn with_esp_writable<T>(esp_mount: &Path, f: impl FnOnce() -> T) -> T {
with_esp_writable_by(esp_mount, &is_readonly, &remount, f)
}
/// [`with_esp_writable`] over its two seams: whether the mount is read-only, and the remount.
pub fn with_esp_writable_by<T>(esp_mount: &Path, readonly: &dyn Fn(&Path) -> bool, remount: &dyn Fn(&Path, bool) -> std::io::Result<()>, f: impl FnOnce() -> T) -> T {
let flipped = readonly(esp_mount) && remount(esp_mount, false).is_ok();
let out = f();
if flipped {
let _ = remount(esp_mount, true);
}
out
}
/// Whether the filesystem at `path` is mounted read-only (`statvfs`, `ST_RDONLY`).
fn is_readonly(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt as _;
let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { return false };
// SAFETY: statvfs(2) on a NUL-terminated path into a zeroed struct it fills.
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(c.as_ptr(), &mut st) };
rc == 0 && (st.f_flag & libc::ST_RDONLY) != 0
}
/// Flip a mount point between read-only and read-write in place (`MS_REMOUNT`, the
/// flags korp-start mounts the ESP with). Refused on a path that is not a mount point.
fn remount(mountpoint: &Path, readonly: bool) -> std::io::Result<()> {
use std::os::unix::ffi::OsStrExt as _;
let target = std::ffi::CString::new(mountpoint.as_os_str().as_bytes()).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let mut flags = libc::MS_REMOUNT | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC;
if readonly {
flags |= libc::MS_RDONLY;
}
// SAFETY: mount(2) MS_REMOUNT on a NUL-terminated path; no source, type or data.
let rc = unsafe { libc::mount(std::ptr::null(), target.as_ptr(), std::ptr::null(), flags, std::ptr::null()) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// One line, the power-off time first and readable back, the stop's own words after it.
#[test]
fn a_stop_record_is_one_line_whose_power_off_time_reads_back() {
let r = render(1_790_850_910_705, "front: stopped cleanly on ACPI power button — power-off: sync(2) done in 3 ms; 2 writer(s) released;\n/data remounted read-only in 41 ms");
assert_eq!(r.matches('\n').count(), 1, "{r}");
assert!(r.starts_with("power_off_epoch_ms=1790850910705 front: stopped cleanly"), "{r}");
assert_eq!(power_off_epoch_ms(&r), Some(1_790_850_910_705));
assert_eq!(power_off_epoch_ms(&format!("INFO {SAID} {r}")), Some(1_790_850_910_705), "read back out of the next boot's line too");
}
/// Gunnar's record keeps its path; each product has its own name on the ESP.
#[test]
fn a_read_only_esp_is_flipped_read_write_for_the_write_and_back_and_a_writable_one_is_left_alone() {
use std::cell::RefCell;
let esp = Path::new("/boot");
for (ro, want) in [(true, vec![false, true]), (false, vec![])] {
let flips = RefCell::new(Vec::new());
let during = RefCell::new(None);
let out = with_esp_writable_by(esp, &|_| ro, &|_, readonly| { flips.borrow_mut().push(readonly); Ok(()) }, || {
*during.borrow_mut() = Some(flips.borrow().clone());
7
});
assert_eq!(out, 7);
assert_eq!(*flips.borrow(), want, "read-only {ro}: the flips");
if ro {
assert_eq!(during.borrow().clone(), Some(vec![false]), "the write runs while the ESP is read-write");
}
}
// A remount that fails: the write still runs, nothing is flipped back.
let flips = RefCell::new(Vec::new());
assert!(with_esp_writable_by(esp, &|_| true, &|_, readonly| { flips.borrow_mut().push(readonly); Err(std::io::Error::other("EPERM")) }, || true));
assert_eq!(*flips.borrow(), vec![false], "not flipped, so not flipped back");
}
#[test]
fn each_product_has_its_record_on_the_esp() {
assert_eq!(record_for("gunnar"), PathBuf::from("/boot/gunnar-last-stop.txt"));
assert_eq!(record_for("holger"), PathBuf::from("/boot/holger-last-stop.txt"));
}
/// ★ **Kept before the power, said once at the next boot, then gone.** (An ESP that is
/// not a mount point here: the flip is refused and the write still lands.)
#[test]
fn a_kept_stop_is_said_once_at_the_next_boot_and_removed() {
let esp = tempfile::tempdir().unwrap();
let rec = esp.path().join("holger-last-stop.txt");
assert!(keep_at(&rec, esp.path(), "holger: stopped cleanly — power-off: sync(2) done in 3 ms"));
let said = take_previous_at(&rec, esp.path()).expect("the record is there");
assert!(said.starts_with(SAID), "{said}");
assert!(said.contains("holger: stopped cleanly"), "{said}");
assert!(power_off_epoch_ms(&said).is_some(), "{said}");
assert!(!rec.exists(), "removed once said");
assert_eq!(take_previous_at(&rec, esp.path()), None, "said once, not twice");
}
}