Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}