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

nordisk/edda

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