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

nordisk/edda

crates/nornir-build-thing/src/data.rs

//! **The `data` group — what filesystem the appliance's `/data` is laid with,
//! and which writer arm the store appends through**
//! (`[build.installer.data]`, `fs = "fat" | "xfs"`, `io = "safe" | "uring"`).
//!
//! Follows the `target` group's shape ([`crate::target`]): declared in the
//! repo's build-thing impl, EMITTED to toml, consumed by three readers with a
//! typed consequence each —
//!
//! * the **kernel edge**: [`Data::kconfig_needs`] lands under the feature
//!   `data.fs` through [`crate::requires::DataFacts`], so `xfs` pulls
//!   `CONFIG_XFS_FS=y` (+ its CRC32C) into the donor kernel and switches
//!   OFF the parts one user with no access control never needs (POSIX ACLs,
//!   quota, realtime, online scrub); the why-index names `data.fs` as the
//!   requirer. `fat` names `CONFIG_VFAT_FS=y` — already the ESP's, so the
//!   default row changes nothing;
//! * the **installer PID 1**: [`crate::installer::DATA_FS_BOOT_PARAM`]
//!   (`korp.data.fs=xfs`) is baked beside `korp.target.firmware=` ONLY when
//!   the group is declared and not `fat` — an absent token already means
//!   FAT (every medium built before the group existed), so the default is
//!   byte-identical. `korp-installer::install` lays P3 with `korp-mkfs-xfs`
//!   when the token says so, with `fatfs` otherwise;
//! * the **appliance PID 1** (`korp-start`): dispatches its boot-time
//!   `/data` check on the partition's MAGIC, never on this token — a kept
//!   FAT `/data` on an older box (lane J's non-deleting reinstall) still
//!   boots under a medium that says xfs.
//!
//! Why xfs at all: FAT32 has one lock per FAT, no file over 4 GiB, and the
//! `dirsync` the power-cut fix needs makes every directory op a synchronous
//! write. A 100 k-user gunnar needs parallel writers (one allocation group
//! per CPU), extents, files past 4 GiB, and a journal for crash safety —
//! measured numbers in `korp-installer/.nornir/data-filesystem.md`.
//!
//! # `io` — the writer arm (lane S, 2026-08-30)
//!
//! `znippy-plugin-git` has two durable writer arms with ONE durability
//! contract (blob bytes durable before the journal row that references them):
//! `safe` = four blocking syscalls (`pwrite`, `fsync`, `write`, `fsync`) and
//! `uring` = the same four as ONE `io_uring_enter` with `IOSQE_IO_LINK`. The
//! arm is a MEDIUM fact because it needs the kernel: `io = "uring"` lands
//! `CONFIG_IO_URING=y` under the feature `data.io` on the kernel edge (the
//! tunnr donor ships `# CONFIG_IO_URING is not set`), and bakes
//! `korp.data.io=uring` so the app's PID 1 can select the arm — probing
//! `io_uring_setup` first and falling back to `safe` BY NAME (a row saying
//! so) when the kernel refuses. `safe` (the default, an absent token) asks
//! the kernel for nothing and bakes nothing: byte-identical to before.
//!
//! **Measured, and why the type default stays `Safe`** (lane S, oden
//! 2026-08-30, one installed image with the token flipped on its ESP, five
//! runs per arm): the arms are the same server at 1–8 clients — sequential
//! push-create 172 ms vs 174, 8-parallel 1145 vs 1175, a 50 MB blob 1848 vs
//! 1808 — inside a ±26 % noise floor taken from the three phases that never
//! touch the writer. The one signal is the deepest queue tested (200
//! push-creates from 64 clients): safe 5130–6149 ms against uring
//! 4433–4682 ms, 1.14× per accepted op, and ±3 % run spread against ±10 %.
//! Durability held: 133 of 133 acked pushes at the acked sha after three
//! `SIGKILL` power cuts. Kernel cost +45 056 B of bzImage.
//! So **gunnar declares `io = "uring"` in its own `[[build.installer]]`
//! rows** — the way `fs = "xfs"` was deployed — and [`DataIo::Safe`] stays
//! the type default, because korp, holger and salvage-korp declare installer
//! variants with NO `data` group: moving the default would bake
//! `korp.data.io=uring` into their media and pull `CONFIG_IO_URING=y` into
//! their donor kernels for a store they never open.

use serde::{Deserialize, Serialize};

/// The filesystem `/data` is laid with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DataFs {
    /// FAT32 through `fatfs`, mounted `dirsync`, fsck'd by `fatrepair` at
    /// boot. The pre-2026-08-29 `/data`; still what an undeclared row means.
    #[default]
    Fat,
    /// XFS v5 through `korp-mkfs-xfs`, mounted with the journal for crash
    /// safety (no `dirsync`), CRC-verified at boot.
    Xfs,
}

impl DataFs {
    /// Every arm, in declaration order — the CHOICES the `data.fs` val node
    /// offers, so `--opt data.fs=<…>` and `--list-options` read one roster.
    pub const ALL: [DataFs; 2] = [DataFs::Fat, DataFs::Xfs];

    pub fn as_str(self) -> &'static str {
        match self {
            DataFs::Fat => "fat",
            DataFs::Xfs => "xfs",
        }
    }

    /// The reading side of [`as_str`](Self::as_str): the cmdline token's value.
    pub fn parse(s: &str) -> Option<DataFs> {
        match s {
            "fat" => Some(DataFs::Fat),
            "xfs" => Some(DataFs::Xfs),
            _ => None,
        }
    }
}

/// The writer arm the store appends through — `[build.installer.data] io`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DataIo {
    /// `SafeWriter`: pwrite → fsync → write journal → fsync, four syscalls.
    /// The shipping default; an absent token.
    #[default]
    Safe,
    /// `UringWriter`: the same chain as one linked `io_uring_enter`.
    /// Needs `CONFIG_IO_URING=y`; the app falls back to `safe` by name
    /// when the kernel has no ring.
    Uring,
}

impl DataIo {
    /// Every arm, in declaration order — see [`DataFs::ALL`].
    pub const ALL: [DataIo; 2] = [DataIo::Safe, DataIo::Uring];

    pub fn as_str(self) -> &'static str {
        match self {
            DataIo::Safe => "safe",
            DataIo::Uring => "uring",
        }
    }

    /// The reading side of [`as_str`](Self::as_str): the cmdline token's value.
    pub fn parse(s: &str) -> Option<DataIo> {
        match s {
            "safe" => Some(DataIo::Safe),
            "uring" => Some(DataIo::Uring),
            _ => None,
        }
    }
}

/// `[build.installer.data]`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Data {
    #[serde(default)]
    pub fs: DataFs,
    /// The writer arm; `safe` unless declared. Emitted only when not the
    /// default, so every pre-`io` toml round-trips byte for byte.
    #[serde(default, skip_serializing_if = "DataIo::is_safe")]
    pub io: DataIo,
}

impl DataIo {
    /// For `skip_serializing_if`.
    pub fn is_safe(&self) -> bool {
        *self == DataIo::Safe
    }
}

impl Data {
    /// For `skip_serializing_if`: an undeclared group emits no row.
    pub fn is_default(&self) -> bool {
        *self == Data::default()
    }

    /// The fragment of the content id this group contributes — only when
    /// declared, so every pre-`data` medium keeps its id.
    pub fn canonical_fragment(&self) -> String {
        if self.is_default() {
            return String::new();
        }
        let mut s = format!("#data:fs={}", self.fs.as_str());
        // The arm only when it is not the default, so an `fs`-only row keeps
        // the id it had before `io` existed.
        if self.io != DataIo::Safe {
            s.push_str(&format!("#data:io={}", self.io.as_str()));
        }
        s
    }

    /// **What this `/data` needs of the kernel** — `(CONFIG_*, y|n, why)`,
    /// every reason opening with the feature word `data.fs` so the closure
    /// files the row under it. `fat` only restates the ESP's VFAT (already
    /// in the boot contract); `xfs` adds the filesystem and, by name, takes
    /// out what one user with no access control never exercises — each `=n`
    /// is a narrowing over the preset, never a contradiction of the contract.
    pub fn kconfig_needs(&self) -> Vec<(&'static str, bool, String)> {
        let mut v = self.fs_kconfig_needs();
        if self.io == DataIo::Uring {
            v.push((
                "CONFIG_IO_URING",
                true,
                "data.io = uring: the store's writer arm is one linked io_uring_enter (blob write → fsync → journal write → fsync); the app falls back to `safe` by name without it".into(),
            ));
        }
        v
    }

    /// The `fs` half of [`kconfig_needs`](Self::kconfig_needs).
    fn fs_kconfig_needs(&self) -> Vec<(&'static str, bool, String)> {
        match self.fs {
            DataFs::Fat => vec![("CONFIG_VFAT_FS", true, "data.fs = fat: /data is FAT32 (the ESP's driver, nothing new)".into())],
            DataFs::Xfs => {
                let on = "data.fs = xfs: /data is xfs v5 (korp-mkfs-xfs lays it; CRC32C is the v5 metadata checksum)";
                let off = "data.fs = xfs, one user, no access control: not exercised on /data";
                let mut v: Vec<(&'static str, bool, String)> = vec![
                    ("CONFIG_XFS_FS", true, on.into()),
                    ("CONFIG_LIBCRC32C", true, on.into()),
                    ("CONFIG_CRYPTO_CRC32C", true, on.into()),
                    // The ESP stays FAT whatever /data is.
                    ("CONFIG_VFAT_FS", true, "data.fs = xfs: the ESP is still FAT".into()),
                ];
                // The multi-disk set: N equal disks are ONE xfs over the
                // kernel's md RAID0, assembled by korp-start through sysfs —
                // a survey-time decision the recipe cannot know, so an xfs
                // /data always carries md. Autodetect stays off: the boot
                // side assembles by the superblocks it reads itself.
                let md = "data.fs = xfs: N equal disks are one xfs over md raid0 (korp-mkfs-xfs + korp-installer::dataset lay it, korp-start assembles it)";
                for s in ["CONFIG_MD", "CONFIG_BLK_DEV_MD", "CONFIG_MD_RAID0"] {
                    v.push((s, true, md.into()));
                }
                v.push(("CONFIG_MD_AUTODETECT", false, "data.fs = xfs: korp-start assembles the set from the superblocks it reads; no 0.90 autodetect".into()));
                for s in ["CONFIG_XFS_POSIX_ACL", "CONFIG_XFS_QUOTA", "CONFIG_XFS_RT", "CONFIG_XFS_ONLINE_SCRUB", "CONFIG_XFS_SUPPORT_V4", "CONFIG_XFS_SUPPORT_ASCII_CI", "CONFIG_FS_POSIX_ACL"] {
                    v.push((s, false, off.into()));
                }
                v
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_group_round_trips_and_an_undeclared_row_is_inert() {
        let d: Data = toml::from_str("fs = \"xfs\"").unwrap();
        assert_eq!(d.fs, DataFs::Xfs);
        assert!(!d.is_default());
        assert_eq!(d.canonical_fragment(), "#data:fs=xfs");
        let back = toml::to_string(&d).unwrap();
        assert!(back.contains("fs = \"xfs\""), "{back}");
        let empty: Data = toml::from_str("").unwrap();
        assert!(empty.is_default() && empty.canonical_fragment().is_empty());
        assert!(toml::from_str::<Data>("fs = \"ext4\"").is_err());
        assert!(toml::from_str::<Data>("filesystem = \"xfs\"").is_err(), "unknown keys are refused");
        assert_eq!(DataFs::parse("xfs"), Some(DataFs::Xfs));
        assert_eq!(DataFs::parse("ntfs"), None);
    }

    /// Seen red: `io` rides the same row; `uring` is emitted, adds ONE
    /// kernel row (`CONFIG_IO_URING=y`) under the feature word `data.io`, and
    /// moves the content id; `safe` is the default, emits nothing, asks the
    /// kernel for nothing and keeps the `fs`-only id — so every medium
    /// declared before `io` existed is byte-identical.
    #[test]
    fn io_uring_is_declared_beside_fs_and_safe_is_the_inert_default() {
        let d: Data = toml::from_str("fs = \"xfs\"\nio = \"uring\"").unwrap();
        assert_eq!(d.io, DataIo::Uring);
        assert_eq!(d.canonical_fragment(), "#data:fs=xfs#data:io=uring");
        let back = toml::to_string(&d).unwrap();
        assert!(back.contains("io = \"uring\""), "{back}");
        let rows = d.kconfig_needs();
        let uring: Vec<_> = rows.iter().filter(|(s, _, _)| *s == "CONFIG_IO_URING").collect();
        assert_eq!(uring.len(), 1);
        assert!(uring[0].1, "on");
        assert!(uring[0].2.starts_with("data.io"), "the requirer is the io feature: {}", uring[0].2);
        assert_eq!(rows.len(), Data { fs: DataFs::Xfs, io: DataIo::Safe }.kconfig_needs().len() + 1, "one row, and only that row");

        let safe: Data = toml::from_str("fs = \"xfs\"").unwrap();
        assert_eq!(safe.io, DataIo::Safe);
        assert_eq!(safe.canonical_fragment(), "#data:fs=xfs", "an fs-only row keeps its pre-io id");
        assert!(!toml::to_string(&safe).unwrap().contains("io ="), "safe emits nothing");
        assert!(safe.kconfig_needs().iter().all(|(s, _, _)| *s != "CONFIG_IO_URING"));
        let explicit: Data = toml::from_str("fs = \"xfs\"\nio = \"safe\"").unwrap();
        assert_eq!(explicit, safe);
        assert!(toml::from_str::<Data>("io = \"aio\"").is_err(), "a third arm is refused by name");
        assert_eq!(DataIo::parse("uring"), Some(DataIo::Uring));
        assert_eq!(DataIo::parse("fast"), None, "the non-durable arm is not a medium fact");
        // fat + uring is a legal pair: the arm is the store's, the fs is the disk's.
        let fu: Data = toml::from_str("io = \"uring\"").unwrap();
        assert!(!fu.is_default());
        assert_eq!(fu.canonical_fragment(), "#data:fs=fat#data:io=uring");
    }

    /// Seen red: every xfs row opens with the feature word, the positive
    /// rows name the filesystem + its checksum, the negative rows are the
    /// one-user narrowings — and `fat` asks for nothing the ESP does not.
    #[test]
    fn xfs_needs_the_filesystem_and_its_crc_and_switches_the_multi_user_parts_off() {
        let x = Data { fs: DataFs::Xfs, io: DataIo::Safe }.kconfig_needs();
        assert!(x.iter().all(|(_, _, why)| why.starts_with("data.fs")), "{x:?}");
        let on: Vec<&str> = x.iter().filter(|(_, on, _)| *on).map(|(s, _, _)| *s).collect();
        assert_eq!(on, ["CONFIG_XFS_FS", "CONFIG_LIBCRC32C", "CONFIG_CRYPTO_CRC32C", "CONFIG_VFAT_FS", "CONFIG_MD", "CONFIG_BLK_DEV_MD", "CONFIG_MD_RAID0"]);
        let off: Vec<&str> = x.iter().filter(|(_, on, _)| !*on).map(|(s, _, _)| *s).collect();
        assert!(off.contains(&"CONFIG_XFS_QUOTA") && off.contains(&"CONFIG_FS_POSIX_ACL") && off.contains(&"CONFIG_XFS_RT") && off.contains(&"CONFIG_MD_AUTODETECT"));
        assert!(!off.contains(&"CONFIG_XFS_FS"));
        let f = Data { fs: DataFs::Fat, io: DataIo::Safe }.kconfig_needs();
        assert_eq!(f.len(), 1);
        assert_eq!((f[0].0, f[0].1), ("CONFIG_VFAT_FS", true));
    }
}