crates/gunnar-server/src/slow.rs
//! **Where each kind of data goes, and what gunnar may claim about it.**
//!
//! ```text
//! 17:40:44 layout /boot/korp/layout.conf INTEGRITY ONLY · off-stripe: p3 /dev/vda3
//! 17:40:44 layout /boot/korp/layout.conf INTEGRITY ONLY · SAME DEVICE as /data — not off-stripe
//! 17:40:44 layout (no config) nothing says what these are on — not confirmed off-stripe
//! ```
//!
//! # The machine this is written for
//!
//! The appliance `korp-installer` lays down has two storage areas, and they are
//! not "fast" and "slow" so much as **one failure domain and another**:
//!
//! * `/data` — the MaxIOPS volumes as one XFS over an md RAID0. Fast, and
//! **zero redundancy**: one member disk dies and all of it is gone.
//! * `/serialdat` — a standard-tier area, usually the boot disk's third
//! partition. Its value is NOT speed. It is another spindle (every IO there
//! is one the stripe does not serve) and it is a **different failure
//! domain**.
//!
//! # gunnar adds NO placement mechanism
//!
//! The installer already solved placement, and not with paths in this code: it
//! lays `crash`, `log`, `gc` and `quarantine` PHYSICALLY on `/serialdat` and
//! makes `/data/<name>` a **symlink** to each. So `dir.log.path` is `/data/log`
//! and writing there already lands on the other spindle without gunnar knowing.
//! `meta` — the roster, principals and enrolled keys — is a second COPY, on
//! `/serialdat/meta` only, with no `/data` twin. `repos` stays on the stripe.
//!
//! This module reads `dir.<name>.path` out of [`crate::layout_config`] and
//! writes exactly there. It does not compute a path, does not prefer one root
//! over another and does not check what it was told: two things deciding the
//! same thing is how they start disagreeing.
//!
//! # The one rule: never branch on whether the slow area is real
//!
//! `korp-installer` guarantees every directory resolves — on a machine with no
//! room for a real `/serialdat` it lays a directory under `/data` and
//! bind-mounts it there anyway. So gunnar has ONE code path: it writes the path
//! it was given, always.
//!
//! What the config records instead is, per directory,
//! `dir.<name>.off_stripe_required` and `dir.<name>.off_stripe_satisfied`.
//! Reading them changes **nothing** about where gunnar writes. It changes what
//! gunnar is allowed to CLAIM. The settled principle, and the reason this
//! module is shaped the way it is:
//!
//! > **Branch on what you claim, never on what you do.**
//!
//! # Why a dishonest claim is worse than no claim
//!
//! Three of the things placed here — the metadata mirror, the crash and
//! diagnostic records, the audit trail — are the only copies that survive
//! losing one data disk. When the installer had no second device, they are on
//! the same device as the thing they protect, and a health check that answers
//! "mirror OK" for a mirror that dies with its original is worse than no mirror
//! at all, because somebody will plan a recovery around it.
//!
//! So [`Claim`] has three values and none of them is a boolean:
//! `off-stripe` / `same-device` / `absent`. `SameDevice` is reported as
//! **present, same-device, not off-stripe** — never "OK", never "missing".
//! `Absent` (no config: nothing has said either way) is likewise never
//! promoted, because an unread file is not evidence of a separate disk.
//!
//! # Without a config
//!
//! Every appliance installed before the config existed has none. gunnar must
//! start on it, so each directory falls back to `<data root>/<name>` — the
//! documented default, said out loud — and the claim is `absent`. That is a
//! fallback, never a discovery mechanism: nothing probes for a `/serialdat` to
//! use instead.
//!
//! On a box laid by the installer but whose config could not be read, four of
//! those fallbacks are the installer's own symlinks and land on the other
//! spindle anyway. gunnar still does not claim it, because gunnar was not told.
//!
//! # The table
//!
//! | data | `dir.<name>` | needs off-stripe | gunnar writes it |
//! |---|---|---|---|
//! | git objects, packfiles, LFS, refs | [`REPOS_DIR`] | no | yes — unchanged |
//! | metadata mirror (roster, principals, keys) | [`META_DIR`] | **yes** | yes |
//! | crash + diagnostics | [`CRASH_DIR`] | **yes** | yes |
//! | audit / console log | [`LOG_DIR`] | **yes** | yes |
//! | git gc / repack scratch | [`GC_DIR`] | no | **not yet** |
//! | push quarantine | [`QUARANTINE_DIR`] | no | **not yet** |
//!
//! The last two are said plainly rather than implied by a directory that
//! exists, and [`PLACEMENT`] carries the `wired` flag the admin view prints:
//!
//! * gc/repack scratch is the storage engine's, not gunnar's — `Repos.Gc`
//! seals logs and compacts the archive inside the engine, which chooses its
//! own temporaries. Pointing it here is an engine change, not a placement
//! one.
//! * push quarantine would move the `incoming-*.pack` landing spot
//! (`crate::storage`) off the stripe, which turns the final rename into a
//! whole-pack cross-device COPY on the hot push path. That is a trade to
//! measure, not to make blind.
//!
//! `typeset` and `generations` are in the installer's tree and are NOT gunnar's
//! — the first is gunnar-ui's, the second korp-installer's — so nothing here
//! names them.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result};
use crate::layout_config::LayoutConfig;
/// `korp_installer::layout::USERS_DIR` — the per-user namespace directory,
/// spelled the same on both roots: `/data/repos/<user>` and, mirrored,
/// `/serialdat/repos/<user>`.
pub const REPOS_DIR: &str = "repos";
/// The metadata mirror: the replication roster, principals, enrolled keys.
pub const META_DIR: &str = "meta";
/// Crash records and diagnostics.
pub const CRASH_DIR: &str = "crash";
/// The audit trail and the durable console log.
pub const LOG_DIR: &str = "log";
/// gc / repack scratch.
pub const GC_DIR: &str = "gc";
/// Push quarantine.
pub const QUARANTINE_DIR: &str = "quarantine";
/// **A path as a reader must see it**: the configured path, and where it REALLY
/// is when that differs — `/data/crash (really /serialdat/crash)`. The
/// installer lays the off-stripe directories as symlinks under `/data`, so the
/// bare configured path read as "on the data stripe" beside the words
/// "off-stripe" (owner, gunnar.rs 2026-10-14). Read off the filesystem, never
/// computed; a path that cannot be resolved is shown as configured.
pub fn shown_path(configured: &Path) -> String {
match std::fs::canonicalize(configured) {
Ok(real) if real != configured => {
format!("{} (really {})", configured.display(), real.display())
}
_ => configured.display().to_string(),
}
}
/// The tracing target every row from this module carries.
pub const TARGET_SLOW: &str = "gunnar::slow";
// ───────────────────────────────────────────────────────────────────────────
// the placement table, as data
// ───────────────────────────────────────────────────────────────────────────
/// One kind of data, and whether gunnar actually puts anything there.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Placement {
/// What it holds, in the admin's words.
pub what: &'static str,
/// The `dir.<name>` the config spells it under.
pub dir: &'static str,
/// Is this one of the copies that must be on another failure domain?
pub requires_off_stripe: bool,
/// **Does gunnar write here today?** False is reported as false; a
/// directory that exists is not a feature.
pub wired: bool,
/// When `wired` is false, why not — printed beside the row.
pub note: &'static str,
}
/// **The placement table gunnar is actually configured with** — layer 3 of the
/// admin view, and the specification the tests assert against.
///
/// The `requires_off_stripe` column is gunnar's own statement of what it needs.
/// Whether that need is MET is never decided here: it comes from the config's
/// `dir.<name>.off_stripe_satisfied`, which is the installer's verdict about
/// the machine it partitioned.
pub const PLACEMENT: &[Placement] = &[
Placement {
what: "git objects, packfiles, LFS, refs",
dir: REPOS_DIR,
requires_off_stripe: false,
wired: true,
note: "",
},
Placement {
what: "metadata mirror (roster, principals, enrolled keys)",
dir: META_DIR,
requires_off_stripe: true,
wired: true,
note: "",
},
Placement {
what: "crash + diagnostics",
dir: CRASH_DIR,
requires_off_stripe: true,
wired: true,
note: "",
},
Placement {
what: "audit / console log",
dir: LOG_DIR,
requires_off_stripe: true,
wired: true,
note: "",
},
Placement {
what: "git gc / repack scratch",
dir: GC_DIR,
requires_off_stripe: false,
wired: false,
note: "the storage engine chooses its own temporaries; pointing them here is an \
engine change, not a placement one",
},
Placement {
what: "push quarantine",
dir: QUARANTINE_DIR,
requires_off_stripe: false,
wired: false,
note: "moving the incoming-*.pack landing spot off the stripe turns the final rename \
into a whole-pack cross-device copy on the push path; measure before trading",
},
];
// ───────────────────────────────────────────────────────────────────────────
// the claim
// ───────────────────────────────────────────────────────────────────────────
/// **What gunnar is allowed to say** about the copies that need another failure
/// domain.
///
/// Three values, and deliberately not a `bool` with a comment: the middle one
/// is the whole point. A mirror that is present and dies with its original is
/// not "OK" and is not "missing", and the two words a boolean offers are both
/// lies about it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Claim {
/// The installer says this is a different failure domain. The copy
/// survives losing a data disk.
OffStripe,
/// The installer says it is not. Present, written, readable — and on the
/// same device as what it protects.
SameDevice,
/// Nothing has said either way. Not confirmed off-stripe, and never
/// reported as if it were.
Absent,
}
impl Claim {
/// The one word a status column carries. Never `ok`.
pub fn word(self) -> &'static str {
match self {
Claim::OffStripe => "off-stripe",
Claim::SameDevice => "same-device",
Claim::Absent => "absent",
}
}
/// Is the off-stripe requirement satisfied? Only [`Claim::OffStripe`].
pub fn satisfied(self) -> bool {
matches!(self, Claim::OffStripe)
}
/// Does this deserve a warning row rather than an informational one?
pub fn warns(self) -> bool {
!self.satisfied()
}
/// `yes`/`no`/absent, as the config's `off_stripe_satisfied` writes it.
pub fn from_satisfied(satisfied: Option<bool>) -> Claim {
match satisfied {
Some(true) => Claim::OffStripe,
Some(false) => Claim::SameDevice,
None => Claim::Absent,
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// where the paths came from
// ───────────────────────────────────────────────────────────────────────────
/// **How gunnar learned where to write.** Reported, because "we were told" and
/// "we fell back to the documented default" are different facts, and an admin
/// looking at this page is often looking at it because something broke.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The layout config named every path. The normal case on a box installed
/// today.
Config,
/// There is no config (or it did not verify). Every directory is
/// `<data root>/<name>`, the documented default — a FALLBACK, not a
/// discovery.
Fallback,
}
impl Source {
pub fn word(self) -> &'static str {
match self {
Source::Config => "named by the layout config",
Source::Fallback => {
"no layout config: the documented default under the data root, and nothing \
here went looking for a slow area"
}
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// the resolved layout
// ───────────────────────────────────────────────────────────────────────────
/// The resolved directories, how they were learned, and what may be claimed
/// about each.
#[derive(Clone, Debug)]
pub struct SlowRoot {
data_root: PathBuf,
source: Source,
dirs: BTreeMap<&'static str, PathBuf>,
claims: BTreeMap<&'static str, Claim>,
/// `serialdat.mountpoint` as the config gives it, or empty. Reported, not
/// used to build a path: every path comes from `dir.<name>.path`.
serialdat: String,
/// `serialdat.class` — `p3` / `device` / `fallback` — or empty.
class: String,
/// `serialdat.dev`, or empty.
device: String,
}
impl SlowRoot {
/// Resolve every directory from the config, or fall back under the data
/// root when there is none.
pub fn resolve(data_root: &Path, config: Option<&LayoutConfig>) -> SlowRoot {
let source = if config.is_some() {
Source::Config
} else {
Source::Fallback
};
let mut dirs = BTreeMap::new();
let mut claims = BTreeMap::new();
for p in PLACEMENT {
// **Obeyed, not computed.** The config's path is taken verbatim;
// the fallback is the documented default and nothing else.
let path = config
.and_then(|c| c.dir_path(p.dir))
.unwrap_or_else(|| data_root.join(p.dir));
dirs.insert(p.dir, path);
// The verdict is the INSTALLER's, read out of the config. gunnar
// does not re-derive it from anything it can see.
let claim = if p.requires_off_stripe {
Claim::from_satisfied(config.and_then(|c| c.dir_off_stripe_satisfied(p.dir)))
} else {
Claim::Absent
};
claims.insert(p.dir, claim);
}
let field = |key: &str| {
config
.and_then(|c| c.get(key))
.unwrap_or_default()
.to_owned()
};
SlowRoot {
data_root: data_root.to_path_buf(),
source,
dirs,
claims,
serialdat: field("serialdat.mountpoint"),
class: field("serialdat.class"),
device: field("serialdat.dev"),
}
}
/// How gunnar learned where to write.
pub fn source(&self) -> Source {
self.source
}
/// `serialdat.mountpoint`, or empty when nothing said. Reported only.
pub fn serialdat(&self) -> &str {
&self.serialdat
}
/// `serialdat.class` — `p3` / `device` / `fallback` — or empty when
/// nothing said. A class that was not read is not reported as one.
pub fn class(&self) -> &str {
&self.class
}
/// `serialdat.dev`, or empty.
pub fn device(&self) -> &str {
&self.device
}
/// The path for one `dir.<name>`, exactly as configured. Never computed
/// relative to anything else.
pub fn path_of(&self, name: &str) -> PathBuf {
self.dirs
.get(name)
.cloned()
.unwrap_or_else(|| self.data_root.join(name))
}
/// **The one verdict**, over the copies that need another failure domain.
///
/// The three of them are laid by one installer decision, so they agree by
/// construction; if they ever did not, the honest summary is the WORST of
/// them, never the best.
pub fn claim(&self) -> Claim {
let mut worst = Claim::OffStripe;
let mut seen = false;
for p in PLACEMENT.iter().filter(|p| p.requires_off_stripe) {
seen = true;
let c = self.claim_of(p.dir);
worst = match (worst, c) {
(Claim::SameDevice, _) | (_, Claim::SameDevice) => Claim::SameDevice,
(Claim::Absent, _) | (_, Claim::Absent) => Claim::Absent,
_ => Claim::OffStripe,
};
}
if seen {
worst
} else {
Claim::Absent
}
}
/// The verdict for one directory.
pub fn claim_of(&self, name: &str) -> Claim {
self.claims.get(name).copied().unwrap_or(Claim::Absent)
}
/// One of the directories, created on demand.
///
/// Creation is here and not at resolve time on purpose: a slow area that
/// has gone read-only must fail the write that needed it, with the path in
/// the message, rather than fail the boot of a server whose git objects are
/// all still on a working disk.
pub fn dir(&self, name: &str) -> Result<PathBuf> {
let dir = self.path_of(name);
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating the configured directory {}", dir.display()))?;
Ok(dir)
}
/// The metadata mirror: roster, principals, enrolled keys. Needs off-stripe.
pub fn meta_dir(&self) -> Result<PathBuf> {
self.dir(META_DIR)
}
/// Crash records and diagnostics. Needs off-stripe.
pub fn crash_dir(&self) -> Result<PathBuf> {
self.dir(CRASH_DIR)
}
/// The audit trail and the durable console log. Needs off-stripe.
pub fn log_dir(&self) -> Result<PathBuf> {
self.dir(LOG_DIR)
}
/// gc / repack scratch. Does not need off-stripe; nothing writes here yet.
pub fn gc_dir(&self) -> Result<PathBuf> {
self.dir(GC_DIR)
}
/// Push quarantine. Does not need off-stripe; nothing writes here yet.
pub fn quarantine_dir(&self) -> Result<PathBuf> {
self.dir(QUARANTINE_DIR)
}
/// **The honest sentence about one off-stripe-requiring copy.**
///
/// Never "OK" and never "missing" for a copy that is present on the same
/// device: the whole failure this module exists to prevent is a health
/// check that answers `mirror OK` for a mirror that dies with its original.
pub fn health_line(&self, what: &str, dir: &str) -> String {
let where_ = shown_path(&self.path_of(dir));
match self.claim_of(dir) {
Claim::OffStripe => format!(
"{what}: present at {}, off-stripe ({}) — survives losing a data disk",
where_,
self.backing_phrase()
),
Claim::SameDevice => format!(
"{what}: present at {}, SAME DEVICE as the data it protects ({}) — NOT \
off-stripe; it does not survive losing a data disk",
where_,
self.backing_phrase()
),
Claim::Absent => format!(
"{what}: present at {}, and nothing says what it is on ({}) — NOT confirmed \
off-stripe; treat it as same-device until the layout config says otherwise",
where_,
self.backing_phrase()
),
}
}
/// The three off-stripe-requiring copies, each said honestly.
pub fn health(&self) -> Vec<String> {
PLACEMENT
.iter()
.filter(|p| p.requires_off_stripe)
.map(|p| self.health_line(p.what, p.dir))
.collect()
}
/// What the config says is under `/serialdat`, or that nothing does.
fn backing_phrase(&self) -> String {
match (self.class.as_str(), self.device.as_str()) {
("", _) => "no layout config".to_owned(),
(class, "") => class.to_owned(),
(class, dev) => format!("{class} {dev}"),
}
}
/// The console row: the same shape `crate::disktest` uses, one line, and a
/// `warn` flag so the dashboard colours it.
pub fn row(&self) -> (String, bool) {
let claim = self.claim();
let text = match claim {
Claim::OffStripe => format!("off-stripe: {}", self.backing_phrase()),
Claim::SameDevice => format!(
"SAME DEVICE as /data \u{2014} not off-stripe ({})",
self.backing_phrase()
),
Claim::Absent => format!(
"nothing says what these are on \u{2014} not confirmed off-stripe ({})",
self.backing_phrase()
),
};
(text, claim.warns())
}
/// Say the row as the ONE tracing line the log tap turns into the `layout`
/// row (`dashboard::describe`), exactly as `disktest` does.
///
/// `authenticity` is [`crate::layout_config::Authenticity::word`] — the
/// SAME two words the installer's own console prints, so an admin
/// comparing the two screens is not left wondering whether they mean the
/// same thing.
pub fn announce(&self, config_path: &str, authenticity: &str) {
let (text, warn) = self.row();
let text = if authenticity.is_empty() {
text
} else {
format!("{authenticity} \u{b7} {text}")
};
if warn {
tracing::warn!(
target: TARGET_SLOW,
event = "layout",
path = config_path,
claim = self.claim().word(),
source = self.source.word(),
"{text}"
);
} else {
tracing::info!(
target: TARGET_SLOW,
event = "layout",
path = config_path,
claim = self.claim().word(),
source = self.source.word(),
"{text}"
);
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// crash records
// ───────────────────────────────────────────────────────────────────────────
/// Where [`record_crash`] writes, registered once the layout is known.
///
/// A process-global because its reader is the **panic hook**, which is
/// installed in `main` before a data root has been parsed and which takes no
/// arguments. Nothing else reads it, and a hook that finds it unset simply
/// writes no file — the panic still reaches the console either way.
static CRASH_DIR_REGISTERED: OnceLock<PathBuf> = OnceLock::new();
/// Register the crash directory for the panic hook. First caller wins.
pub fn register_crash_dir(dir: PathBuf) {
let _ = CRASH_DIR_REGISTERED.set(dir);
}
/// Write one crash record where the config said crash records go, best effort,
/// and answer where it went.
///
/// Best effort on purpose: this runs inside a panic hook, where a failure has
/// nowhere to be reported and an unwrap would replace a readable panic message
/// with an unreadable double one.
pub fn record_crash(text: &str) -> Option<PathBuf> {
let dir = CRASH_DIR_REGISTERED.get()?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default();
let path = dir.join(format!("panic-{stamp}.txt"));
std::fs::create_dir_all(dir).ok()?;
std::fs::write(&path, text.as_bytes()).ok()?;
Some(path)
}
// ───────────────────────────────────────────────────────────────────────────
// adopting a file that used to live somewhere else
// ───────────────────────────────────────────────────────────────────────────
/// Move `old` to `new` once, and answer which path to use.
///
/// `rename` is not enough: the two paths are on DIFFERENT DEVICES whenever the
/// configured directory is a symlink onto the other spindle, which is the case
/// this exists for, and `rename` answers `EXDEV` there. So it is copy, fsync,
/// then unlink — and if any step fails the answer is `old`, unchanged and
/// untouched. A server that could not move its audit trail keeps writing the
/// audit trail.
///
/// Does nothing when `new` already exists: the move happens once, on the first
/// boot of a build that reads the layout config.
pub fn adopt_file(old: &Path, new: &Path) -> PathBuf {
if new.exists() || !old.exists() {
return new.to_path_buf();
}
let Some(parent) = new.parent() else {
return old.to_path_buf();
};
if std::fs::create_dir_all(parent).is_err() {
return old.to_path_buf();
}
// Through a `.adopting` sibling and a rename WITHIN the destination
// directory, so a cut in the middle of the copy can never leave a half file
// under the real name — the same discipline `crate::durable` writes control
// files with, and for the same reason.
let temp = new.with_extension("adopting");
let copied = std::fs::copy(old, &temp).is_ok()
&& std::fs::File::open(&temp)
.and_then(|f| f.sync_all())
.is_ok()
&& std::fs::rename(&temp, new).is_ok();
if !copied {
let _ = std::fs::remove_file(&temp);
return old.to_path_buf();
}
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
// The original goes only after the copy is durable under its final name.
// A failure to unlink leaves a stale copy behind, which is untidy and
// harmless; a failure the other way round loses the trail.
if let Err(err) = std::fs::remove_file(old) {
tracing::warn!(
target: TARGET_SLOW,
old = %old.display(),
new = %new.display(),
%err,
"the file was copied to its configured directory but the old copy could not be \
removed; the one in use is the new one"
);
}
new.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout_config::{Authenticity, LayoutConfig};
/// A config body in the installer's shape, with the three dirs that matter
/// and a chosen `off_stripe_satisfied`.
fn config(satisfied: bool, class: &str, dev: &str) -> LayoutConfig {
let yn = if satisfied { "yes" } else { "no" };
let mut text = format!(
"korp-layout-config 1\n\
data.mountpoint=/data\n\
serialdat.mountpoint=/serialdat\n\
serialdat.class={class}\n\
serialdat.dev={dev}\n\
serialdat.off_stripe={yn}\n\
dir.users_dir=repos\n\
dir.repos.path=/data/repos\n\
dir.repos.off_stripe_required=no\n\
dir.repos.off_stripe_satisfied=yes\n\
dir.meta.path=/serialdat/meta\n\
dir.meta.off_stripe_required=yes\n\
dir.meta.off_stripe_satisfied={yn}\n"
);
for d in [CRASH_DIR, LOG_DIR] {
text.push_str(&format!(
"dir.{d}.path=/data/{d}\n\
dir.{d}.off_stripe_required=yes\n\
dir.{d}.off_stripe_satisfied={yn}\n"
));
}
for d in [GC_DIR, QUARANTINE_DIR] {
text.push_str(&format!(
"dir.{d}.path=/data/{d}\n\
dir.{d}.off_stripe_required=no\n\
dir.{d}.off_stripe_satisfied=yes\n"
));
}
LayoutConfig::parse(
PathBuf::from("/boot/korp/layout.conf"),
&text,
Authenticity::IntegrityOnly,
)
.expect("the fixture parses")
}
/// ★ Owner 2026-10-14: "present at /data/crash, off-stripe (p3 /dev/vda3)"
/// while /data is the stripe. True — /data/crash is the installer's symlink
/// onto /serialdat — and unreadable. The line names the real place.
#[cfg(unix)]
#[test]
fn a_symlinked_directory_is_shown_with_where_it_really_is() {
let t = tempfile::tempdir().unwrap();
let root = t.path().canonicalize().unwrap();
let (data, slow) = (root.join("data"), root.join("serialdat"));
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(slow.join("crash")).unwrap();
std::os::unix::fs::symlink(slow.join("crash"), data.join("crash")).unwrap();
assert_eq!(
shown_path(&data.join("crash")),
format!(
"{} (really {})",
data.join("crash").display(),
slow.join("crash").display()
)
);
// A plain directory, and one that does not exist, are shown as configured.
assert_eq!(shown_path(&data), data.display().to_string());
assert_eq!(shown_path(Path::new("/nowhere/at/all")), "/nowhere/at/all");
}
// ── obeying the config ─────────────────────────────────────────────────
/// **The config's paths are taken verbatim.** `/data/log` is a symlink the
/// installer laid onto the other spindle; gunnar writes there and adds no
/// second placement mechanism on top of it.
#[test]
fn every_path_comes_from_the_config_unchanged() {
let c = config(true, "p3", "/dev/vda3");
let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
assert_eq!(s.path_of(CRASH_DIR), PathBuf::from("/data/crash"));
assert_eq!(s.path_of(REPOS_DIR), PathBuf::from("/data/repos"));
// The metadata mirror is a second COPY and has no /data twin.
assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
assert_eq!(s.source(), Source::Config);
}
/// A path gunnar was not told about is not invented from `/serialdat`: it
/// is the documented default under the data root.
#[test]
fn a_config_missing_one_dir_falls_back_for_that_one_only() {
let text = "korp-layout-config 1\ndir.meta.path=/serialdat/meta\n\
dir.meta.off_stripe_required=yes\ndir.meta.off_stripe_satisfied=yes\n";
let c = LayoutConfig::parse(PathBuf::from("/x"), text, Authenticity::IntegrityOnly)
.expect("parses");
let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
}
// ── the claim, in each of the three states ─────────────────────────────
#[test]
fn a_satisfied_config_claims_off_stripe_and_does_not_warn() {
let c = config(true, "p3", "/dev/vda3");
let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
assert_eq!(s.claim(), Claim::OffStripe);
assert!(s.claim().satisfied());
let (text, warn) = s.row();
assert!(!warn, "a real spindle is not a warning: {text}");
assert!(text.contains("off-stripe: p3 /dev/vda3"), "{text}");
}
/// ★ **Never "OK", never "missing".** The one assertion that is the whole
/// point: a mirror on the same device as its original must be reported as
/// present AND as not off-stripe, in the same sentence.
#[test]
fn a_same_device_layout_is_never_reported_as_satisfied() {
let c = config(false, "fallback", "/data");
let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
assert_eq!(s.claim(), Claim::SameDevice);
assert!(
!s.claim().satisfied(),
"a mirror that dies with its original satisfies nothing"
);
assert!(s.claim().warns());
assert_eq!(s.claim().word(), "same-device");
let lines = s.health();
assert_eq!(lines.len(), 3, "three copies need off-stripe");
for line in lines {
let lower = line.to_lowercase();
assert!(
lower.contains("present"),
"it IS there, and saying `missing` sends someone looking for a bug: {line}"
);
assert!(
lower.contains("not off-stripe"),
"the sentence has to carry the limitation: {line}"
);
assert!(
!lower.contains(" ok"),
"`OK` for a same-device mirror is the lie this module exists to stop: {line}"
);
}
// And the row that reaches the console is a WARN.
let (text, warn) = s.row();
assert!(warn, "{text}");
assert!(text.contains("SAME DEVICE"), "{text}");
}
/// **No config: nothing has said either way**, and that is its own answer.
#[test]
fn without_a_config_everything_is_absent_and_nothing_is_inferred() {
let s = SlowRoot::resolve(Path::new("/var/lib/gunnar"), None);
assert_eq!(s.source(), Source::Fallback);
assert_eq!(s.claim(), Claim::Absent);
assert_eq!(s.claim().word(), "absent");
assert!(!s.claim().satisfied());
// The documented default, and NOT a `/serialdat` gunnar went looking
// for.
assert_eq!(s.path_of(META_DIR), PathBuf::from("/var/lib/gunnar/meta"));
assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/var/lib/gunnar/log"));
// Nothing that was not read is reported.
assert_eq!(s.serialdat(), "");
assert_eq!(s.class(), "");
assert_eq!(s.device(), "");
for line in s.health() {
assert!(line.contains("present"), "{line}");
assert!(
line.to_lowercase().contains("not confirmed off-stripe"),
"{line}"
);
}
}
/// The summary of three verdicts is the WORST of them, never the best.
#[test]
fn a_disagreeing_config_summarises_downwards() {
let mut text = String::from("korp-layout-config 1\n");
for (d, yn) in [(META_DIR, "yes"), (CRASH_DIR, "yes"), (LOG_DIR, "no")] {
text.push_str(&format!(
"dir.{d}.path=/data/{d}\ndir.{d}.off_stripe_required=yes\n\
dir.{d}.off_stripe_satisfied={yn}\n"
));
}
let c = LayoutConfig::parse(PathBuf::from("/x"), &text, Authenticity::IntegrityOnly)
.expect("parses");
let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
assert_eq!(s.claim_of(META_DIR), Claim::OffStripe);
assert_eq!(s.claim_of(LOG_DIR), Claim::SameDevice);
assert_eq!(
s.claim(),
Claim::SameDevice,
"one copy that cannot survive a disk makes the summary same-device"
);
}
// ── the placement table ────────────────────────────────────────────────
#[test]
fn the_placement_table_holds() {
// Exactly one row does not require another failure domain AND is git's
// objects; exactly three require one.
let need: Vec<_> = PLACEMENT.iter().filter(|p| p.requires_off_stripe).collect();
assert_eq!(
need.iter().map(|p| p.dir).collect::<Vec<_>>(),
[META_DIR, CRASH_DIR, LOG_DIR]
);
assert!(
need.iter().all(|p| p.wired),
"a copy that must survive a disk and is not written is not a placement"
);
assert_eq!(PLACEMENT[0].dir, REPOS_DIR);
assert!(!PLACEMENT[0].requires_off_stripe);
// The two that are NOT wired say so, and say why.
let unwired: Vec<_> = PLACEMENT.iter().filter(|p| !p.wired).collect();
assert_eq!(
unwired.iter().map(|p| p.dir).collect::<Vec<_>>(),
[GC_DIR, QUARANTINE_DIR]
);
assert!(
unwired.iter().all(|p| !p.note.is_empty()),
"an unwired row without a reason is a directory pretending to be a feature"
);
// `typeset` and `generations` are somebody else's and are not here.
assert!(PLACEMENT
.iter()
.all(|p| p.dir != "typeset" && p.dir != "generations"));
}
/// The SAME paths whatever the verdict says: only the claim moves.
#[test]
fn the_layout_is_identical_in_every_state() {
let paths = |c: Option<&LayoutConfig>| {
let s = SlowRoot::resolve(Path::new("/data"), c);
PLACEMENT
.iter()
.map(|p| s.path_of(p.dir))
.collect::<Vec<_>>()
};
let satisfied = config(true, "p3", "/dev/vda3");
let not = config(false, "fallback", "/data");
assert_eq!(
paths(Some(&satisfied)),
paths(Some(¬)),
"the verdict must not move one byte of the layout"
);
}
// ── directories ────────────────────────────────────────────────────────
#[test]
fn every_directory_is_created_on_demand_under_the_configured_path() {
let dir = tempfile::tempdir().expect("a temp dir");
let s = SlowRoot::resolve(dir.path(), None);
for d in [
s.meta_dir().expect("meta"),
s.crash_dir().expect("crash"),
s.log_dir().expect("log"),
s.gc_dir().expect("gc"),
s.quarantine_dir().expect("quarantine"),
] {
assert!(d.is_dir(), "{} was not created", d.display());
assert!(d.starts_with(dir.path()), "{}", d.display());
}
}
// ── adopting ───────────────────────────────────────────────────────────
#[test]
fn adopting_moves_the_file_once_and_leaves_nothing_behind() {
let dir = tempfile::tempdir().expect("a temp dir");
let old = dir.path().join("control/audit-chain.jsonl");
std::fs::create_dir_all(old.parent().unwrap()).expect("mkdir");
std::fs::write(&old, b"{\"seq\":0}\n").expect("write");
let new = dir.path().join("log/audit-chain.jsonl");
assert_eq!(adopt_file(&old, &new), new);
assert_eq!(
std::fs::read(&new).expect("the moved file"),
b"{\"seq\":0}\n"
);
assert!(!old.exists(), "the old copy is gone");
assert!(!new.with_extension("adopting").exists());
// Idempotent: a second boot does not re-adopt and does not clobber.
std::fs::write(&new, b"{\"seq\":1}\n").expect("append");
assert_eq!(adopt_file(&old, &new), new);
assert_eq!(std::fs::read(&new).expect("untouched"), b"{\"seq\":1}\n");
}
#[test]
fn adopting_nothing_still_names_the_new_path() {
let dir = tempfile::tempdir().expect("a temp dir");
let old = dir.path().join("control/audit-chain.jsonl");
let new = dir.path().join("log/audit-chain.jsonl");
assert_eq!(
adopt_file(&old, &new),
new,
"a fresh box starts in the right place"
);
}
/// A destination that cannot be made leaves the file exactly where it is
/// and answers with it.
#[test]
fn adopting_into_an_impossible_place_keeps_the_original() {
let dir = tempfile::tempdir().expect("a temp dir");
let old = dir.path().join("audit-chain.jsonl");
std::fs::write(&old, b"x").expect("write");
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"not a directory").expect("write");
let new = blocker.join("log/audit-chain.jsonl");
assert_eq!(adopt_file(&old, &new), old);
assert!(old.exists(), "the original is untouched");
}
}