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