gunnar_server/slow.rs
1//! **Where each kind of data goes, and what gunnar may claim about it.**
2//!
3//! ```text
4//! 17:40:44 layout /boot/korp/layout.conf INTEGRITY ONLY · off-stripe: p3 /dev/vda3
5//! 17:40:44 layout /boot/korp/layout.conf INTEGRITY ONLY · SAME DEVICE as /data — not off-stripe
6//! 17:40:44 layout (no config) nothing says what these are on — not confirmed off-stripe
7//! ```
8//!
9//! # The machine this is written for
10//!
11//! The appliance `korp-installer` lays down has two storage areas, and they are
12//! not "fast" and "slow" so much as **one failure domain and another**:
13//!
14//! * `/data` — the MaxIOPS volumes as one XFS over an md RAID0. Fast, and
15//! **zero redundancy**: one member disk dies and all of it is gone.
16//! * `/serialdat` — a standard-tier area, usually the boot disk's third
17//! partition. Its value is NOT speed. It is another spindle (every IO there
18//! is one the stripe does not serve) and it is a **different failure
19//! domain**.
20//!
21//! # gunnar adds NO placement mechanism
22//!
23//! The installer already solved placement, and not with paths in this code: it
24//! lays `crash`, `log`, `gc` and `quarantine` PHYSICALLY on `/serialdat` and
25//! makes `/data/<name>` a **symlink** to each. So `dir.log.path` is `/data/log`
26//! and writing there already lands on the other spindle without gunnar knowing.
27//! `meta` — the roster, principals and enrolled keys — is a second COPY, on
28//! `/serialdat/meta` only, with no `/data` twin. `repos` stays on the stripe.
29//!
30//! This module reads `dir.<name>.path` out of [`crate::layout_config`] and
31//! writes exactly there. It does not compute a path, does not prefer one root
32//! over another and does not check what it was told: two things deciding the
33//! same thing is how they start disagreeing.
34//!
35//! # The one rule: never branch on whether the slow area is real
36//!
37//! `korp-installer` guarantees every directory resolves — on a machine with no
38//! room for a real `/serialdat` it lays a directory under `/data` and
39//! bind-mounts it there anyway. So gunnar has ONE code path: it writes the path
40//! it was given, always.
41//!
42//! What the config records instead is, per directory,
43//! `dir.<name>.off_stripe_required` and `dir.<name>.off_stripe_satisfied`.
44//! Reading them changes **nothing** about where gunnar writes. It changes what
45//! gunnar is allowed to CLAIM. The settled principle, and the reason this
46//! module is shaped the way it is:
47//!
48//! > **Branch on what you claim, never on what you do.**
49//!
50//! # Why a dishonest claim is worse than no claim
51//!
52//! Three of the things placed here — the metadata mirror, the crash and
53//! diagnostic records, the audit trail — are the only copies that survive
54//! losing one data disk. When the installer had no second device, they are on
55//! the same device as the thing they protect, and a health check that answers
56//! "mirror OK" for a mirror that dies with its original is worse than no mirror
57//! at all, because somebody will plan a recovery around it.
58//!
59//! So [`Claim`] has three values and none of them is a boolean:
60//! `off-stripe` / `same-device` / `absent`. `SameDevice` is reported as
61//! **present, same-device, not off-stripe** — never "OK", never "missing".
62//! `Absent` (no config: nothing has said either way) is likewise never
63//! promoted, because an unread file is not evidence of a separate disk.
64//!
65//! # Without a config
66//!
67//! Every appliance installed before the config existed has none. gunnar must
68//! start on it, so each directory falls back to `<data root>/<name>` — the
69//! documented default, said out loud — and the claim is `absent`. That is a
70//! fallback, never a discovery mechanism: nothing probes for a `/serialdat` to
71//! use instead.
72//!
73//! On a box laid by the installer but whose config could not be read, four of
74//! those fallbacks are the installer's own symlinks and land on the other
75//! spindle anyway. gunnar still does not claim it, because gunnar was not told.
76//!
77//! # The table
78//!
79//! | data | `dir.<name>` | needs off-stripe | gunnar writes it |
80//! |---|---|---|---|
81//! | git objects, packfiles, LFS, refs | [`REPOS_DIR`] | no | yes — unchanged |
82//! | metadata mirror (roster, principals, keys) | [`META_DIR`] | **yes** | yes |
83//! | crash + diagnostics | [`CRASH_DIR`] | **yes** | yes |
84//! | audit / console log | [`LOG_DIR`] | **yes** | yes |
85//! | git gc / repack scratch | [`GC_DIR`] | no | **not yet** |
86//! | push quarantine | [`QUARANTINE_DIR`] | no | **not yet** |
87//!
88//! The last two are said plainly rather than implied by a directory that
89//! exists, and [`PLACEMENT`] carries the `wired` flag the admin view prints:
90//!
91//! * gc/repack scratch is the storage engine's, not gunnar's — `Repos.Gc`
92//! seals logs and compacts the archive inside the engine, which chooses its
93//! own temporaries. Pointing it here is an engine change, not a placement
94//! one.
95//! * push quarantine would move the `incoming-*.pack` landing spot
96//! (`crate::storage`) off the stripe, which turns the final rename into a
97//! whole-pack cross-device COPY on the hot push path. That is a trade to
98//! measure, not to make blind.
99//!
100//! `typeset` and `generations` are in the installer's tree and are NOT gunnar's
101//! — the first is gunnar-ui's, the second korp-installer's — so nothing here
102//! names them.
103
104use std::collections::BTreeMap;
105use std::path::{Path, PathBuf};
106use std::sync::OnceLock;
107
108use anyhow::{Context, Result};
109
110use crate::layout_config::LayoutConfig;
111
112/// `korp_installer::layout::USERS_DIR` — the per-user namespace directory,
113/// spelled the same on both roots: `/data/repos/<user>` and, mirrored,
114/// `/serialdat/repos/<user>`.
115pub const REPOS_DIR: &str = "repos";
116/// The metadata mirror: the replication roster, principals, enrolled keys.
117pub const META_DIR: &str = "meta";
118/// Crash records and diagnostics.
119pub const CRASH_DIR: &str = "crash";
120/// The audit trail and the durable console log.
121pub const LOG_DIR: &str = "log";
122/// gc / repack scratch.
123pub const GC_DIR: &str = "gc";
124/// Push quarantine.
125pub const QUARANTINE_DIR: &str = "quarantine";
126
127/// **A path as a reader must see it**: the configured path, and where it REALLY
128/// is when that differs — `/data/crash (really /serialdat/crash)`. The
129/// installer lays the off-stripe directories as symlinks under `/data`, so the
130/// bare configured path read as "on the data stripe" beside the words
131/// "off-stripe" (owner, gunnar.rs 2026-10-14). Read off the filesystem, never
132/// computed; a path that cannot be resolved is shown as configured.
133pub fn shown_path(configured: &Path) -> String {
134 match std::fs::canonicalize(configured) {
135 Ok(real) if real != configured => {
136 format!("{} (really {})", configured.display(), real.display())
137 }
138 _ => configured.display().to_string(),
139 }
140}
141
142/// The tracing target every row from this module carries.
143pub const TARGET_SLOW: &str = "gunnar::slow";
144
145// ───────────────────────────────────────────────────────────────────────────
146// the placement table, as data
147// ───────────────────────────────────────────────────────────────────────────
148
149/// One kind of data, and whether gunnar actually puts anything there.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct Placement {
152 /// What it holds, in the admin's words.
153 pub what: &'static str,
154 /// The `dir.<name>` the config spells it under.
155 pub dir: &'static str,
156 /// Is this one of the copies that must be on another failure domain?
157 pub requires_off_stripe: bool,
158 /// **Does gunnar write here today?** False is reported as false; a
159 /// directory that exists is not a feature.
160 pub wired: bool,
161 /// When `wired` is false, why not — printed beside the row.
162 pub note: &'static str,
163}
164
165/// **The placement table gunnar is actually configured with** — layer 3 of the
166/// admin view, and the specification the tests assert against.
167///
168/// The `requires_off_stripe` column is gunnar's own statement of what it needs.
169/// Whether that need is MET is never decided here: it comes from the config's
170/// `dir.<name>.off_stripe_satisfied`, which is the installer's verdict about
171/// the machine it partitioned.
172pub const PLACEMENT: &[Placement] = &[
173 Placement {
174 what: "git objects, packfiles, LFS, refs",
175 dir: REPOS_DIR,
176 requires_off_stripe: false,
177 wired: true,
178 note: "",
179 },
180 Placement {
181 what: "metadata mirror (roster, principals, enrolled keys)",
182 dir: META_DIR,
183 requires_off_stripe: true,
184 wired: true,
185 note: "",
186 },
187 Placement {
188 what: "crash + diagnostics",
189 dir: CRASH_DIR,
190 requires_off_stripe: true,
191 wired: true,
192 note: "",
193 },
194 Placement {
195 what: "audit / console log",
196 dir: LOG_DIR,
197 requires_off_stripe: true,
198 wired: true,
199 note: "",
200 },
201 Placement {
202 what: "git gc / repack scratch",
203 dir: GC_DIR,
204 requires_off_stripe: false,
205 wired: false,
206 note: "the storage engine chooses its own temporaries; pointing them here is an \
207 engine change, not a placement one",
208 },
209 Placement {
210 what: "push quarantine",
211 dir: QUARANTINE_DIR,
212 requires_off_stripe: false,
213 wired: false,
214 note: "moving the incoming-*.pack landing spot off the stripe turns the final rename \
215 into a whole-pack cross-device copy on the push path; measure before trading",
216 },
217];
218
219// ───────────────────────────────────────────────────────────────────────────
220// the claim
221// ───────────────────────────────────────────────────────────────────────────
222
223/// **What gunnar is allowed to say** about the copies that need another failure
224/// domain.
225///
226/// Three values, and deliberately not a `bool` with a comment: the middle one
227/// is the whole point. A mirror that is present and dies with its original is
228/// not "OK" and is not "missing", and the two words a boolean offers are both
229/// lies about it.
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub enum Claim {
232 /// The installer says this is a different failure domain. The copy
233 /// survives losing a data disk.
234 OffStripe,
235 /// The installer says it is not. Present, written, readable — and on the
236 /// same device as what it protects.
237 SameDevice,
238 /// Nothing has said either way. Not confirmed off-stripe, and never
239 /// reported as if it were.
240 Absent,
241}
242
243impl Claim {
244 /// The one word a status column carries. Never `ok`.
245 pub fn word(self) -> &'static str {
246 match self {
247 Claim::OffStripe => "off-stripe",
248 Claim::SameDevice => "same-device",
249 Claim::Absent => "absent",
250 }
251 }
252
253 /// Is the off-stripe requirement satisfied? Only [`Claim::OffStripe`].
254 pub fn satisfied(self) -> bool {
255 matches!(self, Claim::OffStripe)
256 }
257
258 /// Does this deserve a warning row rather than an informational one?
259 pub fn warns(self) -> bool {
260 !self.satisfied()
261 }
262
263 /// `yes`/`no`/absent, as the config's `off_stripe_satisfied` writes it.
264 pub fn from_satisfied(satisfied: Option<bool>) -> Claim {
265 match satisfied {
266 Some(true) => Claim::OffStripe,
267 Some(false) => Claim::SameDevice,
268 None => Claim::Absent,
269 }
270 }
271}
272
273// ───────────────────────────────────────────────────────────────────────────
274// where the paths came from
275// ───────────────────────────────────────────────────────────────────────────
276
277/// **How gunnar learned where to write.** Reported, because "we were told" and
278/// "we fell back to the documented default" are different facts, and an admin
279/// looking at this page is often looking at it because something broke.
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub enum Source {
282 /// The layout config named every path. The normal case on a box installed
283 /// today.
284 Config,
285 /// There is no config (or it did not verify). Every directory is
286 /// `<data root>/<name>`, the documented default — a FALLBACK, not a
287 /// discovery.
288 Fallback,
289}
290
291impl Source {
292 pub fn word(self) -> &'static str {
293 match self {
294 Source::Config => "named by the layout config",
295 Source::Fallback => {
296 "no layout config: the documented default under the data root, and nothing \
297 here went looking for a slow area"
298 }
299 }
300 }
301}
302
303// ───────────────────────────────────────────────────────────────────────────
304// the resolved layout
305// ───────────────────────────────────────────────────────────────────────────
306
307/// The resolved directories, how they were learned, and what may be claimed
308/// about each.
309#[derive(Clone, Debug)]
310pub struct SlowRoot {
311 data_root: PathBuf,
312 source: Source,
313 dirs: BTreeMap<&'static str, PathBuf>,
314 claims: BTreeMap<&'static str, Claim>,
315 /// `serialdat.mountpoint` as the config gives it, or empty. Reported, not
316 /// used to build a path: every path comes from `dir.<name>.path`.
317 serialdat: String,
318 /// `serialdat.class` — `p3` / `device` / `fallback` — or empty.
319 class: String,
320 /// `serialdat.dev`, or empty.
321 device: String,
322}
323
324impl SlowRoot {
325 /// Resolve every directory from the config, or fall back under the data
326 /// root when there is none.
327 pub fn resolve(data_root: &Path, config: Option<&LayoutConfig>) -> SlowRoot {
328 let source = if config.is_some() {
329 Source::Config
330 } else {
331 Source::Fallback
332 };
333 let mut dirs = BTreeMap::new();
334 let mut claims = BTreeMap::new();
335 for p in PLACEMENT {
336 // **Obeyed, not computed.** The config's path is taken verbatim;
337 // the fallback is the documented default and nothing else.
338 let path = config
339 .and_then(|c| c.dir_path(p.dir))
340 .unwrap_or_else(|| data_root.join(p.dir));
341 dirs.insert(p.dir, path);
342 // The verdict is the INSTALLER's, read out of the config. gunnar
343 // does not re-derive it from anything it can see.
344 let claim = if p.requires_off_stripe {
345 Claim::from_satisfied(config.and_then(|c| c.dir_off_stripe_satisfied(p.dir)))
346 } else {
347 Claim::Absent
348 };
349 claims.insert(p.dir, claim);
350 }
351 let field = |key: &str| {
352 config
353 .and_then(|c| c.get(key))
354 .unwrap_or_default()
355 .to_owned()
356 };
357 SlowRoot {
358 data_root: data_root.to_path_buf(),
359 source,
360 dirs,
361 claims,
362 serialdat: field("serialdat.mountpoint"),
363 class: field("serialdat.class"),
364 device: field("serialdat.dev"),
365 }
366 }
367
368 /// How gunnar learned where to write.
369 pub fn source(&self) -> Source {
370 self.source
371 }
372
373 /// `serialdat.mountpoint`, or empty when nothing said. Reported only.
374 pub fn serialdat(&self) -> &str {
375 &self.serialdat
376 }
377
378 /// `serialdat.class` — `p3` / `device` / `fallback` — or empty when
379 /// nothing said. A class that was not read is not reported as one.
380 pub fn class(&self) -> &str {
381 &self.class
382 }
383
384 /// `serialdat.dev`, or empty.
385 pub fn device(&self) -> &str {
386 &self.device
387 }
388
389 /// The path for one `dir.<name>`, exactly as configured. Never computed
390 /// relative to anything else.
391 pub fn path_of(&self, name: &str) -> PathBuf {
392 self.dirs
393 .get(name)
394 .cloned()
395 .unwrap_or_else(|| self.data_root.join(name))
396 }
397
398 /// **The one verdict**, over the copies that need another failure domain.
399 ///
400 /// The three of them are laid by one installer decision, so they agree by
401 /// construction; if they ever did not, the honest summary is the WORST of
402 /// them, never the best.
403 pub fn claim(&self) -> Claim {
404 let mut worst = Claim::OffStripe;
405 let mut seen = false;
406 for p in PLACEMENT.iter().filter(|p| p.requires_off_stripe) {
407 seen = true;
408 let c = self.claim_of(p.dir);
409 worst = match (worst, c) {
410 (Claim::SameDevice, _) | (_, Claim::SameDevice) => Claim::SameDevice,
411 (Claim::Absent, _) | (_, Claim::Absent) => Claim::Absent,
412 _ => Claim::OffStripe,
413 };
414 }
415 if seen {
416 worst
417 } else {
418 Claim::Absent
419 }
420 }
421
422 /// The verdict for one directory.
423 pub fn claim_of(&self, name: &str) -> Claim {
424 self.claims.get(name).copied().unwrap_or(Claim::Absent)
425 }
426
427 /// One of the directories, created on demand.
428 ///
429 /// Creation is here and not at resolve time on purpose: a slow area that
430 /// has gone read-only must fail the write that needed it, with the path in
431 /// the message, rather than fail the boot of a server whose git objects are
432 /// all still on a working disk.
433 pub fn dir(&self, name: &str) -> Result<PathBuf> {
434 let dir = self.path_of(name);
435 std::fs::create_dir_all(&dir)
436 .with_context(|| format!("creating the configured directory {}", dir.display()))?;
437 Ok(dir)
438 }
439
440 /// The metadata mirror: roster, principals, enrolled keys. Needs off-stripe.
441 pub fn meta_dir(&self) -> Result<PathBuf> {
442 self.dir(META_DIR)
443 }
444
445 /// Crash records and diagnostics. Needs off-stripe.
446 pub fn crash_dir(&self) -> Result<PathBuf> {
447 self.dir(CRASH_DIR)
448 }
449
450 /// The audit trail and the durable console log. Needs off-stripe.
451 pub fn log_dir(&self) -> Result<PathBuf> {
452 self.dir(LOG_DIR)
453 }
454
455 /// gc / repack scratch. Does not need off-stripe; nothing writes here yet.
456 pub fn gc_dir(&self) -> Result<PathBuf> {
457 self.dir(GC_DIR)
458 }
459
460 /// Push quarantine. Does not need off-stripe; nothing writes here yet.
461 pub fn quarantine_dir(&self) -> Result<PathBuf> {
462 self.dir(QUARANTINE_DIR)
463 }
464
465 /// **The honest sentence about one off-stripe-requiring copy.**
466 ///
467 /// Never "OK" and never "missing" for a copy that is present on the same
468 /// device: the whole failure this module exists to prevent is a health
469 /// check that answers `mirror OK` for a mirror that dies with its original.
470 pub fn health_line(&self, what: &str, dir: &str) -> String {
471 let where_ = shown_path(&self.path_of(dir));
472 match self.claim_of(dir) {
473 Claim::OffStripe => format!(
474 "{what}: present at {}, off-stripe ({}) — survives losing a data disk",
475 where_,
476 self.backing_phrase()
477 ),
478 Claim::SameDevice => format!(
479 "{what}: present at {}, SAME DEVICE as the data it protects ({}) — NOT \
480 off-stripe; it does not survive losing a data disk",
481 where_,
482 self.backing_phrase()
483 ),
484 Claim::Absent => format!(
485 "{what}: present at {}, and nothing says what it is on ({}) — NOT confirmed \
486 off-stripe; treat it as same-device until the layout config says otherwise",
487 where_,
488 self.backing_phrase()
489 ),
490 }
491 }
492
493 /// The three off-stripe-requiring copies, each said honestly.
494 pub fn health(&self) -> Vec<String> {
495 PLACEMENT
496 .iter()
497 .filter(|p| p.requires_off_stripe)
498 .map(|p| self.health_line(p.what, p.dir))
499 .collect()
500 }
501
502 /// What the config says is under `/serialdat`, or that nothing does.
503 fn backing_phrase(&self) -> String {
504 match (self.class.as_str(), self.device.as_str()) {
505 ("", _) => "no layout config".to_owned(),
506 (class, "") => class.to_owned(),
507 (class, dev) => format!("{class} {dev}"),
508 }
509 }
510
511 /// The console row: the same shape `crate::disktest` uses, one line, and a
512 /// `warn` flag so the dashboard colours it.
513 pub fn row(&self) -> (String, bool) {
514 let claim = self.claim();
515 let text = match claim {
516 Claim::OffStripe => format!("off-stripe: {}", self.backing_phrase()),
517 Claim::SameDevice => format!(
518 "SAME DEVICE as /data \u{2014} not off-stripe ({})",
519 self.backing_phrase()
520 ),
521 Claim::Absent => format!(
522 "nothing says what these are on \u{2014} not confirmed off-stripe ({})",
523 self.backing_phrase()
524 ),
525 };
526 (text, claim.warns())
527 }
528
529 /// Say the row as the ONE tracing line the log tap turns into the `layout`
530 /// row (`dashboard::describe`), exactly as `disktest` does.
531 ///
532 /// `authenticity` is [`crate::layout_config::Authenticity::word`] — the
533 /// SAME two words the installer's own console prints, so an admin
534 /// comparing the two screens is not left wondering whether they mean the
535 /// same thing.
536 pub fn announce(&self, config_path: &str, authenticity: &str) {
537 let (text, warn) = self.row();
538 let text = if authenticity.is_empty() {
539 text
540 } else {
541 format!("{authenticity} \u{b7} {text}")
542 };
543 if warn {
544 tracing::warn!(
545 target: TARGET_SLOW,
546 event = "layout",
547 path = config_path,
548 claim = self.claim().word(),
549 source = self.source.word(),
550 "{text}"
551 );
552 } else {
553 tracing::info!(
554 target: TARGET_SLOW,
555 event = "layout",
556 path = config_path,
557 claim = self.claim().word(),
558 source = self.source.word(),
559 "{text}"
560 );
561 }
562 }
563}
564
565// ───────────────────────────────────────────────────────────────────────────
566// crash records
567// ───────────────────────────────────────────────────────────────────────────
568
569/// Where [`record_crash`] writes, registered once the layout is known.
570///
571/// A process-global because its reader is the **panic hook**, which is
572/// installed in `main` before a data root has been parsed and which takes no
573/// arguments. Nothing else reads it, and a hook that finds it unset simply
574/// writes no file — the panic still reaches the console either way.
575static CRASH_DIR_REGISTERED: OnceLock<PathBuf> = OnceLock::new();
576
577/// Register the crash directory for the panic hook. First caller wins.
578pub fn register_crash_dir(dir: PathBuf) {
579 let _ = CRASH_DIR_REGISTERED.set(dir);
580}
581
582/// Write one crash record where the config said crash records go, best effort,
583/// and answer where it went.
584///
585/// Best effort on purpose: this runs inside a panic hook, where a failure has
586/// nowhere to be reported and an unwrap would replace a readable panic message
587/// with an unreadable double one.
588pub fn record_crash(text: &str) -> Option<PathBuf> {
589 let dir = CRASH_DIR_REGISTERED.get()?;
590 let stamp = std::time::SystemTime::now()
591 .duration_since(std::time::UNIX_EPOCH)
592 .map(|d| d.as_millis())
593 .unwrap_or_default();
594 let path = dir.join(format!("panic-{stamp}.txt"));
595 std::fs::create_dir_all(dir).ok()?;
596 std::fs::write(&path, text.as_bytes()).ok()?;
597 Some(path)
598}
599
600// ───────────────────────────────────────────────────────────────────────────
601// adopting a file that used to live somewhere else
602// ───────────────────────────────────────────────────────────────────────────
603
604/// Move `old` to `new` once, and answer which path to use.
605///
606/// `rename` is not enough: the two paths are on DIFFERENT DEVICES whenever the
607/// configured directory is a symlink onto the other spindle, which is the case
608/// this exists for, and `rename` answers `EXDEV` there. So it is copy, fsync,
609/// then unlink — and if any step fails the answer is `old`, unchanged and
610/// untouched. A server that could not move its audit trail keeps writing the
611/// audit trail.
612///
613/// Does nothing when `new` already exists: the move happens once, on the first
614/// boot of a build that reads the layout config.
615pub fn adopt_file(old: &Path, new: &Path) -> PathBuf {
616 if new.exists() || !old.exists() {
617 return new.to_path_buf();
618 }
619 let Some(parent) = new.parent() else {
620 return old.to_path_buf();
621 };
622 if std::fs::create_dir_all(parent).is_err() {
623 return old.to_path_buf();
624 }
625 // Through a `.adopting` sibling and a rename WITHIN the destination
626 // directory, so a cut in the middle of the copy can never leave a half file
627 // under the real name — the same discipline `crate::durable` writes control
628 // files with, and for the same reason.
629 let temp = new.with_extension("adopting");
630 let copied = std::fs::copy(old, &temp).is_ok()
631 && std::fs::File::open(&temp)
632 .and_then(|f| f.sync_all())
633 .is_ok()
634 && std::fs::rename(&temp, new).is_ok();
635 if !copied {
636 let _ = std::fs::remove_file(&temp);
637 return old.to_path_buf();
638 }
639 if let Ok(dir) = std::fs::File::open(parent) {
640 let _ = dir.sync_all();
641 }
642 // The original goes only after the copy is durable under its final name.
643 // A failure to unlink leaves a stale copy behind, which is untidy and
644 // harmless; a failure the other way round loses the trail.
645 if let Err(err) = std::fs::remove_file(old) {
646 tracing::warn!(
647 target: TARGET_SLOW,
648 old = %old.display(),
649 new = %new.display(),
650 %err,
651 "the file was copied to its configured directory but the old copy could not be \
652 removed; the one in use is the new one"
653 );
654 }
655 new.to_path_buf()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::layout_config::{Authenticity, LayoutConfig};
662
663 /// A config body in the installer's shape, with the three dirs that matter
664 /// and a chosen `off_stripe_satisfied`.
665 fn config(satisfied: bool, class: &str, dev: &str) -> LayoutConfig {
666 let yn = if satisfied { "yes" } else { "no" };
667 let mut text = format!(
668 "korp-layout-config 1\n\
669 data.mountpoint=/data\n\
670 serialdat.mountpoint=/serialdat\n\
671 serialdat.class={class}\n\
672 serialdat.dev={dev}\n\
673 serialdat.off_stripe={yn}\n\
674 dir.users_dir=repos\n\
675 dir.repos.path=/data/repos\n\
676 dir.repos.off_stripe_required=no\n\
677 dir.repos.off_stripe_satisfied=yes\n\
678 dir.meta.path=/serialdat/meta\n\
679 dir.meta.off_stripe_required=yes\n\
680 dir.meta.off_stripe_satisfied={yn}\n"
681 );
682 for d in [CRASH_DIR, LOG_DIR] {
683 text.push_str(&format!(
684 "dir.{d}.path=/data/{d}\n\
685 dir.{d}.off_stripe_required=yes\n\
686 dir.{d}.off_stripe_satisfied={yn}\n"
687 ));
688 }
689 for d in [GC_DIR, QUARANTINE_DIR] {
690 text.push_str(&format!(
691 "dir.{d}.path=/data/{d}\n\
692 dir.{d}.off_stripe_required=no\n\
693 dir.{d}.off_stripe_satisfied=yes\n"
694 ));
695 }
696 LayoutConfig::parse(
697 PathBuf::from("/boot/korp/layout.conf"),
698 &text,
699 Authenticity::IntegrityOnly,
700 )
701 .expect("the fixture parses")
702 }
703
704 /// ★ Owner 2026-10-14: "present at /data/crash, off-stripe (p3 /dev/vda3)"
705 /// while /data is the stripe. True — /data/crash is the installer's symlink
706 /// onto /serialdat — and unreadable. The line names the real place.
707 #[cfg(unix)]
708 #[test]
709 fn a_symlinked_directory_is_shown_with_where_it_really_is() {
710 let t = tempfile::tempdir().unwrap();
711 let root = t.path().canonicalize().unwrap();
712 let (data, slow) = (root.join("data"), root.join("serialdat"));
713 std::fs::create_dir_all(&data).unwrap();
714 std::fs::create_dir_all(slow.join("crash")).unwrap();
715 std::os::unix::fs::symlink(slow.join("crash"), data.join("crash")).unwrap();
716 assert_eq!(
717 shown_path(&data.join("crash")),
718 format!(
719 "{} (really {})",
720 data.join("crash").display(),
721 slow.join("crash").display()
722 )
723 );
724 // A plain directory, and one that does not exist, are shown as configured.
725 assert_eq!(shown_path(&data), data.display().to_string());
726 assert_eq!(shown_path(Path::new("/nowhere/at/all")), "/nowhere/at/all");
727 }
728
729 // ── obeying the config ─────────────────────────────────────────────────
730
731 /// **The config's paths are taken verbatim.** `/data/log` is a symlink the
732 /// installer laid onto the other spindle; gunnar writes there and adds no
733 /// second placement mechanism on top of it.
734 #[test]
735 fn every_path_comes_from_the_config_unchanged() {
736 let c = config(true, "p3", "/dev/vda3");
737 let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
738 assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
739 assert_eq!(s.path_of(CRASH_DIR), PathBuf::from("/data/crash"));
740 assert_eq!(s.path_of(REPOS_DIR), PathBuf::from("/data/repos"));
741 // The metadata mirror is a second COPY and has no /data twin.
742 assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
743 assert_eq!(s.source(), Source::Config);
744 }
745
746 /// A path gunnar was not told about is not invented from `/serialdat`: it
747 /// is the documented default under the data root.
748 #[test]
749 fn a_config_missing_one_dir_falls_back_for_that_one_only() {
750 let text = "korp-layout-config 1\ndir.meta.path=/serialdat/meta\n\
751 dir.meta.off_stripe_required=yes\ndir.meta.off_stripe_satisfied=yes\n";
752 let c = LayoutConfig::parse(PathBuf::from("/x"), text, Authenticity::IntegrityOnly)
753 .expect("parses");
754 let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
755 assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
756 assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
757 }
758
759 // ── the claim, in each of the three states ─────────────────────────────
760
761 #[test]
762 fn a_satisfied_config_claims_off_stripe_and_does_not_warn() {
763 let c = config(true, "p3", "/dev/vda3");
764 let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
765 assert_eq!(s.claim(), Claim::OffStripe);
766 assert!(s.claim().satisfied());
767 let (text, warn) = s.row();
768 assert!(!warn, "a real spindle is not a warning: {text}");
769 assert!(text.contains("off-stripe: p3 /dev/vda3"), "{text}");
770 }
771
772 /// ★ **Never "OK", never "missing".** The one assertion that is the whole
773 /// point: a mirror on the same device as its original must be reported as
774 /// present AND as not off-stripe, in the same sentence.
775 #[test]
776 fn a_same_device_layout_is_never_reported_as_satisfied() {
777 let c = config(false, "fallback", "/data");
778 let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
779 assert_eq!(s.claim(), Claim::SameDevice);
780 assert!(
781 !s.claim().satisfied(),
782 "a mirror that dies with its original satisfies nothing"
783 );
784 assert!(s.claim().warns());
785 assert_eq!(s.claim().word(), "same-device");
786
787 let lines = s.health();
788 assert_eq!(lines.len(), 3, "three copies need off-stripe");
789 for line in lines {
790 let lower = line.to_lowercase();
791 assert!(
792 lower.contains("present"),
793 "it IS there, and saying `missing` sends someone looking for a bug: {line}"
794 );
795 assert!(
796 lower.contains("not off-stripe"),
797 "the sentence has to carry the limitation: {line}"
798 );
799 assert!(
800 !lower.contains(" ok"),
801 "`OK` for a same-device mirror is the lie this module exists to stop: {line}"
802 );
803 }
804 // And the row that reaches the console is a WARN.
805 let (text, warn) = s.row();
806 assert!(warn, "{text}");
807 assert!(text.contains("SAME DEVICE"), "{text}");
808 }
809
810 /// **No config: nothing has said either way**, and that is its own answer.
811 #[test]
812 fn without_a_config_everything_is_absent_and_nothing_is_inferred() {
813 let s = SlowRoot::resolve(Path::new("/var/lib/gunnar"), None);
814 assert_eq!(s.source(), Source::Fallback);
815 assert_eq!(s.claim(), Claim::Absent);
816 assert_eq!(s.claim().word(), "absent");
817 assert!(!s.claim().satisfied());
818 // The documented default, and NOT a `/serialdat` gunnar went looking
819 // for.
820 assert_eq!(s.path_of(META_DIR), PathBuf::from("/var/lib/gunnar/meta"));
821 assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/var/lib/gunnar/log"));
822 // Nothing that was not read is reported.
823 assert_eq!(s.serialdat(), "");
824 assert_eq!(s.class(), "");
825 assert_eq!(s.device(), "");
826 for line in s.health() {
827 assert!(line.contains("present"), "{line}");
828 assert!(
829 line.to_lowercase().contains("not confirmed off-stripe"),
830 "{line}"
831 );
832 }
833 }
834
835 /// The summary of three verdicts is the WORST of them, never the best.
836 #[test]
837 fn a_disagreeing_config_summarises_downwards() {
838 let mut text = String::from("korp-layout-config 1\n");
839 for (d, yn) in [(META_DIR, "yes"), (CRASH_DIR, "yes"), (LOG_DIR, "no")] {
840 text.push_str(&format!(
841 "dir.{d}.path=/data/{d}\ndir.{d}.off_stripe_required=yes\n\
842 dir.{d}.off_stripe_satisfied={yn}\n"
843 ));
844 }
845 let c = LayoutConfig::parse(PathBuf::from("/x"), &text, Authenticity::IntegrityOnly)
846 .expect("parses");
847 let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
848 assert_eq!(s.claim_of(META_DIR), Claim::OffStripe);
849 assert_eq!(s.claim_of(LOG_DIR), Claim::SameDevice);
850 assert_eq!(
851 s.claim(),
852 Claim::SameDevice,
853 "one copy that cannot survive a disk makes the summary same-device"
854 );
855 }
856
857 // ── the placement table ────────────────────────────────────────────────
858
859 #[test]
860 fn the_placement_table_holds() {
861 // Exactly one row does not require another failure domain AND is git's
862 // objects; exactly three require one.
863 let need: Vec<_> = PLACEMENT.iter().filter(|p| p.requires_off_stripe).collect();
864 assert_eq!(
865 need.iter().map(|p| p.dir).collect::<Vec<_>>(),
866 [META_DIR, CRASH_DIR, LOG_DIR]
867 );
868 assert!(
869 need.iter().all(|p| p.wired),
870 "a copy that must survive a disk and is not written is not a placement"
871 );
872 assert_eq!(PLACEMENT[0].dir, REPOS_DIR);
873 assert!(!PLACEMENT[0].requires_off_stripe);
874
875 // The two that are NOT wired say so, and say why.
876 let unwired: Vec<_> = PLACEMENT.iter().filter(|p| !p.wired).collect();
877 assert_eq!(
878 unwired.iter().map(|p| p.dir).collect::<Vec<_>>(),
879 [GC_DIR, QUARANTINE_DIR]
880 );
881 assert!(
882 unwired.iter().all(|p| !p.note.is_empty()),
883 "an unwired row without a reason is a directory pretending to be a feature"
884 );
885 // `typeset` and `generations` are somebody else's and are not here.
886 assert!(PLACEMENT
887 .iter()
888 .all(|p| p.dir != "typeset" && p.dir != "generations"));
889 }
890
891 /// The SAME paths whatever the verdict says: only the claim moves.
892 #[test]
893 fn the_layout_is_identical_in_every_state() {
894 let paths = |c: Option<&LayoutConfig>| {
895 let s = SlowRoot::resolve(Path::new("/data"), c);
896 PLACEMENT
897 .iter()
898 .map(|p| s.path_of(p.dir))
899 .collect::<Vec<_>>()
900 };
901 let satisfied = config(true, "p3", "/dev/vda3");
902 let not = config(false, "fallback", "/data");
903 assert_eq!(
904 paths(Some(&satisfied)),
905 paths(Some(¬)),
906 "the verdict must not move one byte of the layout"
907 );
908 }
909
910 // ── directories ────────────────────────────────────────────────────────
911
912 #[test]
913 fn every_directory_is_created_on_demand_under_the_configured_path() {
914 let dir = tempfile::tempdir().expect("a temp dir");
915 let s = SlowRoot::resolve(dir.path(), None);
916 for d in [
917 s.meta_dir().expect("meta"),
918 s.crash_dir().expect("crash"),
919 s.log_dir().expect("log"),
920 s.gc_dir().expect("gc"),
921 s.quarantine_dir().expect("quarantine"),
922 ] {
923 assert!(d.is_dir(), "{} was not created", d.display());
924 assert!(d.starts_with(dir.path()), "{}", d.display());
925 }
926 }
927
928 // ── adopting ───────────────────────────────────────────────────────────
929
930 #[test]
931 fn adopting_moves_the_file_once_and_leaves_nothing_behind() {
932 let dir = tempfile::tempdir().expect("a temp dir");
933 let old = dir.path().join("control/audit-chain.jsonl");
934 std::fs::create_dir_all(old.parent().unwrap()).expect("mkdir");
935 std::fs::write(&old, b"{\"seq\":0}\n").expect("write");
936 let new = dir.path().join("log/audit-chain.jsonl");
937
938 assert_eq!(adopt_file(&old, &new), new);
939 assert_eq!(
940 std::fs::read(&new).expect("the moved file"),
941 b"{\"seq\":0}\n"
942 );
943 assert!(!old.exists(), "the old copy is gone");
944 assert!(!new.with_extension("adopting").exists());
945
946 // Idempotent: a second boot does not re-adopt and does not clobber.
947 std::fs::write(&new, b"{\"seq\":1}\n").expect("append");
948 assert_eq!(adopt_file(&old, &new), new);
949 assert_eq!(std::fs::read(&new).expect("untouched"), b"{\"seq\":1}\n");
950 }
951
952 #[test]
953 fn adopting_nothing_still_names_the_new_path() {
954 let dir = tempfile::tempdir().expect("a temp dir");
955 let old = dir.path().join("control/audit-chain.jsonl");
956 let new = dir.path().join("log/audit-chain.jsonl");
957 assert_eq!(
958 adopt_file(&old, &new),
959 new,
960 "a fresh box starts in the right place"
961 );
962 }
963
964 /// A destination that cannot be made leaves the file exactly where it is
965 /// and answers with it.
966 #[test]
967 fn adopting_into_an_impossible_place_keeps_the_original() {
968 let dir = tempfile::tempdir().expect("a temp dir");
969 let old = dir.path().join("audit-chain.jsonl");
970 std::fs::write(&old, b"x").expect("write");
971 let blocker = dir.path().join("blocker");
972 std::fs::write(&blocker, b"not a directory").expect("write");
973 let new = blocker.join("log/audit-chain.jsonl");
974 assert_eq!(adopt_file(&old, &new), old);
975 assert!(old.exists(), "the original is untouched");
976 }
977}