crates/appliance-grow/src/capacity.rs
//! **"Can this data set ever hold more than it does, and by whose hand?"** —
//! the growth half of a product's capacity door, moved AS IS out of
//! gunnar-server's `capacity.rs` (`decide_growth`, gunnar 18a44e3) so every
//! appliance answers the SAME ladder over the SAME measured facts.
//!
//! # Two facts, not one number
//!
//! A single free-bytes answer is the dangerous one: a caller reads a small
//! number as "buy another disk", and on an appliance that is money converted
//! into nothing. So a product's capacity door returns its bytes AND,
//! independently, a [`SetGrowth`] — reported on the yes arm as well, because
//! "yes, and this is the last capacity this box will ever have" is a different
//! sale from "yes, and there is more where that came from".
//!
//! # The ladder, over MEASURED facts
//!
//! * the journal on the box ([`GrowthFacts::grow`]) — the boot stack CAN grow
//! the set at a restart;
//! * this build's engine ([`GrowthFacts::runtime_engine`]) — the server links
//! the runtime half (`crate::GrowMachine` over `crate::md`);
//! * the kernel's PCI hot-plug ([`GrowthFacts::hotplug`]) — a member detached
//! and re-attached while the server runs is visible;
//! * PID 1 ([`GrowthFacts::is_pid1`]) — no shell beside the server, so nothing
//! on the machine can grow a filesystem out of band.
//!
//! SEALED is only the bottom rung, reached when **this box keeps no grow
//! journal**. Nothing here may claim "no such path in any build": that is a
//! statement about the binary, and the binary is measured one field away.
//! On that rung a disk bought and attached to the box is **billed and
//! invisible**, and the reason line says so in those words.
use std::path::Path;
use crate::probe::DiskKind;
/// **Can this set grow, and by whose hand** — gunnar's `SetGrowth` enum, the
/// same five answers and the same wire numbers (`GROWTH_UNKNOWN = 1` …
/// `GROWTH_AT_RUNTIME = 5`, `0` unspecified), so every product's proto maps
/// 1:1 and monetize's `Growth` reads them all the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetGrowth {
/// The data root is not a mount point of its own, or `/proc/mounts` is
/// unreadable — UNKNOWN, which is not a yes.
Unknown = 1,
/// PID 1, no journal: a disk attached is UNUSED AND INVISIBLE.
Sealed = 2,
/// Not PID 1: an operator with a shell can extend the set out of band.
OperatorOnly = 3,
/// The boot stack grows it into enlarged members at the next restart.
AtRestart = 4,
/// The server grows it WITHOUT a restart (`GrowStart`).
AtRuntime = 5,
}
impl SetGrowth {
/// The wire number (`SetGrowth` in gunnar's and holger's protos).
pub fn wire(self) -> i32 {
self as i32
}
/// The proto spelling.
pub fn as_str(self) -> &'static str {
match self {
SetGrowth::Unknown => "GROWTH_UNKNOWN",
SetGrowth::Sealed => "GROWTH_SEALED",
SetGrowth::OperatorOnly => "GROWTH_OPERATOR_ONLY",
SetGrowth::AtRestart => "GROWTH_AT_RESTART",
SetGrowth::AtRuntime => "GROWTH_AT_RUNTIME",
}
}
}
/// **What the machine says about the set under the data root.** Measured
/// facts and nothing inferred; [`decide_growth`] turns them into a verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrowthFacts {
/// The mount and the array under it, as [`crate::probe::disk_kind`] reads
/// them. `None` when the data root is not a mount point of its own, or
/// `/proc/mounts` is unreadable — which is the honest UNKNOWN, not a
/// growable answer.
pub kind: Option<DiskKind>,
/// **This process is PID 1.** On the appliance the server IS init: there
/// is no shell beside it, so nothing on the machine can grow a filesystem
/// out of band. On a developer box or in a container beside an init this
/// is false and an operator's hands still exist.
pub is_pid1: bool,
/// **The boot stack's grow journal** (`/serialdat/grow-journal`), when
/// this box's `korp-start` keeps one — the measured fact that the boot
/// stack CAN grow the set at a restart. `None` on an image older than the
/// engine, where the appliance is as sealed as it ever was.
pub grow: Option<crate::journal::Summary>,
/// **This server links the runtime grow engine** (the product's
/// `grow-runtime` feature): with the journal on the box, the set grows
/// WITHOUT a restart through `GrowStart`. A build fact, read once.
pub runtime_engine: bool,
/// **The running kernel has PCI hot-plug** ([`crate::probe::pci_hotplug`]).
pub hotplug: bool,
}
impl GrowthFacts {
/// Read them off the running machine; `runtime_engine` is the caller's
/// build fact (`cfg!(feature = "grow-runtime")` in the product).
pub fn measure(root: &Path, runtime_engine: bool) -> GrowthFacts {
GrowthFacts {
kind: crate::probe::disk_kind(root),
is_pid1: std::process::id() == 1,
grow: crate::journal::Summary::measure(),
runtime_engine,
hotplug: crate::probe::pci_hotplug().present,
}
}
}
/// **The growth verdict and the clause the reason line carries for it.**
/// `product` is the word the sentences name (`gunnar`, `holger`).
pub fn decide_growth(facts: &GrowthFacts, root: &Path, product: &str) -> (SetGrowth, String) {
let root = root.display();
match (&facts.kind, facts.is_pid1) {
(None, _) => (
SetGrowth::Unknown,
format!(
"{product} cannot tell what {root} sits on — it is not a mount point of its own, \
so whether this set can grow is UNKNOWN, which is not a yes"
),
),
// The boot stack carries the grow engine (its journal is on this
// box) AND this server links it: the set grows WITHOUT a restart
// (`GrowStart`: drain, unmount, wait for larger members, grow, mount,
// reopen — DATA-SET-GROWTH-FLOW.md §3). The restart path below still
// holds as the fallback for a crash mid-way.
(Some(kind), true) if facts.grow.is_some() && facts.runtime_engine && facts.hotplug => {
let journal = facts.grow.as_ref().expect("guarded");
let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
(
SetGrowth::AtRuntime,
format!(
"AND THE SET GROWS WITHOUT A RESTART: {root} is a {kind} set, this {product} server \
links the grow engine, the kernel has PCI hot-plug and the journal is on this box — `GrowStart` drains \
the box, unmounts, waits for ALL {n} members to come back larger at one equal \
size, grows in place and serves again in minutes; a crash mid-way is finished \
at the next boot. Grow journal: {}",
journal.detail()
),
)
}
// The boot stack carries the grow engine (its journal is on this
// box): the set grows at the NEXT RESTART into enlarged member
// volumes. Still no hot-plug, still no shell — an attached disk is
// as wasted as under SEALED, and that is said in the same breath.
(Some(kind), true) if facts.grow.is_some() => {
let journal = facts.grow.as_ref().expect("guarded");
let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
(
SetGrowth::AtRestart,
format!(
"AND THE SET GROWS AT RESTART: {root} is a {kind} set and this box's boot stack \
(korp-start) grows it into enlarged member volumes at the next boot — enlarge \
ALL {n} members to one equal size, restart, and the next measurement reads the \
larger set. It does NOT grow while {product} runs{}, and a disk ATTACHED to this \
machine is still invisible to it. Grow journal: {}",
if facts.runtime_engine && !facts.hotplug {
" (the grow engine is linked, but the running kernel has NO PCI hot-plug: a member detached and re-attached while the server runs is invisible, so a runtime growth is refused by name)"
} else {
""
},
journal.detail()
),
)
}
(Some(kind), true) => {
// The `lay_set` clause is claimed only where an md set was
// actually measured. A single-disk PID-1 box is just as sealed —
// no growth path, no shell — but it was not laid by
// `korp-installer`'s array pass, and saying so would be inventing
// a fact about a machine nobody looked at.
let laid = if kind.set.is_some() { " laid once at install (korp-installer `lay_set`, InstallMode::Fresh only)" } else { "" };
// The MEASURED reason this box cannot grow is that it keeps no
// grow journal — its boot stack predates the engine. Whether THIS
// binary links the runtime half is a separate fact, and it is
// measured (`runtime_engine`), so the line names it. "so it refuses
// by name" is a FACT: `GrowMachine::start` refuses on the SAME file
// `facts.grow` measures here, so this sentence and that door cannot
// disagree.
let engine = if facts.runtime_engine {
" (this server links the runtime grow engine, but there is no grow journal on this box for it to continue, so it refuses by name)"
} else {
""
};
(
SetGrowth::Sealed,
format!(
"AND THE SET CANNOT GROW WHILE {} RUNS: {root} is a {kind} set{laid}, \
this box's boot stack keeps no grow journal{engine}, and {product} is PID 1 \
with no shell — so a disk attached to this machine would be UNUSED AND \
INVISIBLE. Serving more needs a fresh install onto a larger set, not \
another disk",
product.to_uppercase()
),
)
}
(Some(kind), false) => (
SetGrowth::OperatorOnly,
format!(
"{product} will not grow the set itself here — the grow engine runs only where \
{product} is PID 1 over a set its own boot stack laid, and this process is not \
PID 1 — but that is also why an operator with a shell can extend {root} \
({kind}) out of band, and the next measurement will see it"
),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::SetFacts;
const GIB: u64 = 1024 * 1024 * 1024;
/// A live appliance, as `/proc/mounts` and the md sysfs read on it:
/// `/data` is a 4×75 GiB raid0 xfs set and the server is PID 1.
fn appliance() -> GrowthFacts {
GrowthFacts {
kind: Some(DiskKind {
fs: "xfs".into(),
set: Some(SetFacts { level: "raid0".into(), members: 4, member_bytes: 75 * GIB, chunk_bytes: 512 * 1024 }),
}),
is_pid1: true,
grow: None,
runtime_engine: false,
hotplug: false,
}
}
fn growing_appliance(text: &str) -> GrowthFacts {
GrowthFacts { grow: Some(crate::journal::Summary::of(text, "/serialdat/grow-journal")), ..appliance() }
}
/// **The runtime arm** (ruling 1): the journal on the box AND this server
/// linking the engine AND hot-plug is `AT_RUNTIME`; the journal without the
/// engine is the restart arm, the engine without the journal is SEALED
/// (measured, not declared).
#[test]
fn the_engine_in_the_server_with_the_journal_on_the_box_grows_at_runtime() {
let both = GrowthFacts { runtime_engine: true, hotplug: true, ..growing_appliance("") };
let (v, line) = decide_growth(&both, Path::new("/data"), "holger");
assert_eq!(v, SetGrowth::AtRuntime);
assert!(line.contains("WITHOUT A RESTART") && line.contains("GrowStart") && line.contains("ALL 4 members") && line.contains("holger"), "{line}");
let engine_only = GrowthFacts { runtime_engine: true, hotplug: true, ..appliance() };
assert_eq!(decide_growth(&engine_only, Path::new("/data"), "holger").0, SetGrowth::Sealed, "no journal: the engine alone proves nothing");
assert_eq!(decide_growth(&growing_appliance(""), Path::new("/data"), "holger").0, SetGrowth::AtRestart, "journal alone: the restart arm");
let no_hp = GrowthFacts { runtime_engine: true, hotplug: false, ..growing_appliance("") };
let (v, line) = decide_growth(&no_hp, Path::new("/data"), "holger");
assert_eq!(v, SetGrowth::AtRestart);
assert!(line.contains("NO PCI hot-plug") && line.contains("refused by name"), "{line}");
}
/// Three answers, never two: an unmeasured set is not a growable one, and
/// a sealed set is not merely "the server will not do it".
#[test]
fn the_growth_verdict_separates_a_sealed_appliance_from_a_box_with_a_shell() {
let (sealed, sealed_line) = decide_growth(&appliance(), Path::new("/data"), "holger");
let (operator, operator_line) = decide_growth(&GrowthFacts { is_pid1: false, ..appliance() }, Path::new("/data"), "holger");
let (unknown, unknown_line) = decide_growth(&GrowthFacts { kind: None, ..appliance() }, Path::new("/tmp/x"), "holger");
assert_eq!((sealed, operator, unknown), (SetGrowth::Sealed, SetGrowth::OperatorOnly, SetGrowth::Unknown));
assert!(sealed_line.contains("UNUSED AND INVISIBLE") && sealed_line.contains("fresh install") && sealed_line.contains("4\u{d7}75G raid0 xfs"), "{sealed_line}");
assert!(!operator_line.contains("UNUSED AND INVISIBLE") && operator_line.contains("an operator with a shell"), "{operator_line}");
assert!(unknown_line.contains("UNKNOWN, which is not a yes"), "{unknown_line}");
// The wire numbers are gunnar's, so a product's proto maps 1:1.
assert_eq!((SetGrowth::Unknown.wire(), SetGrowth::Sealed.wire(), SetGrowth::AtRuntime.wire()), (1, 2, 5));
// A partial resize carries the journal's own sentence.
let partial = "epoch=0 target_sectors=134215680 member=0 step=PARTIAL at=1\nepoch=0 target_sectors=134215680 member=1 step=PARTIAL at=1\n";
let (v, line) = decide_growth(&growing_appliance(partial), Path::new("/data"), "holger");
assert_eq!(v, SetGrowth::AtRestart);
assert!(line.contains("PARTIAL RESIZE: 2 member(s) [0, 1]"), "{line}");
}
}