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

nordisk/monetize

guests/lake/src/lib.rs

//! **The Lake guest** — the monetize product guest of the Lake (monetize v2.0 P1, 2026-10-24).
//!
//! The Lake is the prototype product for TESTING monetize (owner 2026-10-23). It is fake inside
//! (it sells nothing real, and every call it gets is printed as one plain line through the host's
//! `log` door) and real in its contract:
//!
//! * **parametric**: the buyer sets numbers ([`LakeOrder`]: the input store's size, how many
//!   ingestors and their plan, Apache Doris with `be` backends and `fe` frontends, a graph
//!   database or none), and the estate's shape and price follow them ([`plan_of`]);
//! * **pulled**: no grants, no endpoint. The ONE box never calls into a Lake estate; its boxes
//!   report and pull their orders (`[products.lake] instance = …`). The guest answers the six
//!   ops of the contract only (manifest, facts, plan, the guardian's three);
//! * **no actor tickets**: the Lake's front mints none (owner rule: the Lake stays thin);
//! * **its own roles**: `front`, `ingestor`, `be`, `fe`, `graph` — never front/main/twin. The
//!   first machine of a role carries the role's name, the next ones `<role>-<n>` (`be-2`);
//! * **no twin**: no election, no mirror;
//! * **grow = changing the numbers** (a new [`LakeOrder`], a new plan).
//!
//! What today's contract cannot say yet, and monetize v2.0 P3 adds (A24 §2.2, §4.3), is written
//! down in [`P3_WAITS`].

use std::collections::BTreeMap;

use monetize_product_guest::facts::{GrowRule, RoleFacts};
use monetize_product_guest::guardian::{EstateView, Finding, Remedy, Step, StepState};
use monetize_product_guest::wire::{LayPlanReq, LifecycleFacts, WireDataRole, WireWords};
use monetize_product_guest::{
    Backing, Enforcement, Guest, Host, Manifest, ProductError, TestAction, TestActionKind,
    WireMeter,
};

/// The product id: `[products.lake]` must name it.
pub const PRODUCT: &str = "lake";
/// The medium every Lake machine is laid from (one binary runs every role), and its keep medium.
pub const MEDIUM: &str = "lake";
pub const KEEP_MEDIUM: &str = "lake-keep";

/// The Lake's roles.
pub mod role {
    /// The console, the relay and the proof pages.
    pub const FRONT: &str = "front";
    /// The ingestors (n of them).
    pub const INGESTOR: &str = "ingestor";
    /// Apache Doris backends: the data (x of them).
    pub const BE: &str = "be";
    /// Apache Doris frontends (y of them, an odd number).
    pub const FE: &str = "fe";
    /// The graph database (none or one).
    pub const GRAPH: &str = "graph";
    /// The order an update takes them in: the ingestors, the frontends, the backends (the data)
    /// and the front LAST — the core's rule (`UpdatePlan.front_role` "drives the update and is
    /// upgraded last"; a front that never asked is keep-booted by the shop first instead).
    pub const UPDATE_ORDER: [&str; 4] = [INGESTOR, FE, BE, FRONT];
}

/// The plans of the machines whose plan the buyer does not choose.
pub mod plans {
    pub const FRONT: &str = "STARTER-1xCPU-2GB";
    pub const BE: &str = "STARTER-4xCPU-16GB";
    pub const FE: &str = "STARTER-2xCPU-4GB";
    pub const GRAPH: &str = "STARTER-4xCPU-16GB";
    /// The ingestors' plan when the order names none.
    pub const INGESTOR: &str = "STARTER-1xCPU-2GB";

    /// **The same machines at Scaleway** (its Instance types), for an estate laid in a Scaleway
    /// zone: the provider names its own plans, and the Lake is laid on either cloud.
    pub mod scaleway {
        pub const FRONT: &str = "DEV1-S";
        pub const BE: &str = "DEV1-XL";
        pub const FE: &str = "DEV1-M";
        pub const GRAPH: &str = "DEV1-XL";
        pub const INGESTOR: &str = "DEV1-S";
    }

    /// Is `zone` one of Scaleway's (`fr-par-1`, `nl-ams-2`, `pl-waw-3`)?
    pub fn is_scaleway_zone(zone: &str) -> bool {
        ["fr-par-", "nl-ams-", "pl-waw-"]
            .iter()
            .any(|p| zone.starts_with(p))
    }
}

/// **What monetize v2.0 P3 must add before the Lake is whole** (A24 §2.2 `Facts`, §4.3), said
/// once here and in the bench's receipt:
pub const P3_WAITS: &[&str] = &[
    "LANDED (P3): the order rides the wire (proto EstateSizes.order, the relay's `order`), checked against the Lake's facts.form at the ONE box; this bench still buys the default order",
    "LANDED (P3-FACTS): facts.primary_role = `fe`, the FE leader: the core's primary (the tenants, the entitlement orders) is read off the facts, not the first data role",
    "LANDED (P3-FACTS): facts.roles with RoleFacts.many and optional (`be`, `be-2`, …; `graph` laid or not); the update still expands `<role>-<n>` and leaves `graph` out of the update order",
    "the store as a plan member (Cloud.price/lay of an object store): the input store is the BE data disks today",
    "LANDED (P3-FACTS): a plan change that ADDS machines (be 1 → 2): the new numbers' plan, the machines it lays that are not laid, held, laid beside the estate, installed, heard and captured; taking machines away is refused by name",
    "LANDED (P3-FACTS): facts.grow (never_shrinks store_gib, may_add, may_remove): the core refuses a shrink of the store and a machine the rule does not allow, by name",
    "LANDED (A4-FACTS): new numbers that grow the store over the backends that stand grow each backend's data disk at the cloud, once paid; a backend is never taken away (facts.grow names no drain and may_remove none)",
];

/// **The buyer's numbers** — what a Lake order is. Every field has the smallest Lake as its
/// default ([`LakeOrder::default`]).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LakeOrder {
    /// The input store (S3), GiB: 100 to 100 000, in steps of 100.
    pub store_gib: u64,
    /// The most ingestors at once: 1 to 64.
    pub ingestors: u32,
    /// The ingestors' plan (their CPU and RAM), the provider's name (`STARTER-2xCPU-4GB`).
    pub ingestor_plan: String,
    /// Apache Doris backends: 1 to 32.
    pub be: u32,
    /// Apache Doris frontends: 1, 3 or 5.
    pub fe: u32,
    /// A graph database beside the lake.
    pub graph: bool,
}

impl Default for LakeOrder {
    fn default() -> Self {
        LakeOrder {
            store_gib: 100,
            ingestors: 1,
            ingestor_plan: plans::INGESTOR.into(),
            be: 1,
            fe: 1,
            graph: false,
        }
    }
}

impl LakeOrder {
    /// The order a lay request carries: `sizes.order` (null or absent: the default). Refused by
    /// name when it is not a Lake order or out of range.
    pub fn of(req: &LayPlanReq) -> Result<LakeOrder, String> {
        let o = match req.sizes.get("order") {
            None | Some(serde_json::Value::Null) => LakeOrder::default(),
            Some(v) => {
                serde_json::from_value(v.clone()).map_err(|e| format!("not a Lake order: {e}"))?
            }
        };
        o.check()?;
        Ok(o)
    }

    /// The ranges, refused by name.
    pub fn check(&self) -> Result<(), String> {
        if !(100..=100_000).contains(&self.store_gib) || self.store_gib % 100 != 0 {
            return Err(format!(
                "the input store is 100 to 100000 GiB, in steps of 100; {} is not",
                self.store_gib
            ));
        }
        if !(1..=64).contains(&self.ingestors) {
            return Err(format!(
                "a Lake has 1 to 64 ingestors; {} is not",
                self.ingestors
            ));
        }
        let p = self.ingestor_plan.trim();
        if p.is_empty() || p.len() > 64 || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
        {
            return Err(format!(
                "the ingestors' plan {:?} is not a plan name",
                self.ingestor_plan
            ));
        }
        if !(1..=32).contains(&self.be) {
            return Err(format!(
                "Apache Doris has 1 to 32 backends here; {} is not",
                self.be
            ));
        }
        if ![1, 3, 5].contains(&self.fe) {
            return Err(format!(
                "Apache Doris has 1, 3 or 5 frontends (an odd number elects a leader); {} is not",
                self.fe
            ));
        }
        Ok(())
    }

    /// How many machines the order lays: `1 + n + x + y (+1)`.
    pub fn machines(&self) -> u32 {
        1 + self.ingestors + self.be + self.fe + u32::from(self.graph)
    }

    /// GiB of each backend's data disk: the store spread over the backends, at least 50, in
    /// steps of 10.
    pub fn be_data_gib(&self) -> u64 {
        let each = self.store_gib.div_ceil(u64::from(self.be.max(1))).max(50);
        each.div_ceil(10) * 10
    }
}

/// The role name of the `n`th machine of `role` (1-based): the first carries the role's name.
pub fn member(role: &str, n: u32) -> String {
    if n <= 1 {
        role.to_string()
    } else {
        format!("{role}-{n}")
    }
}

/// **The plan of one Lake order** (pure): every machine with its role, plan and disks, the
/// config's labels and zone, `role = <role>` on each. `tier` is the system disks' tier (the
/// medium template's, else `standard`).
pub fn plan_of(
    order: &LakeOrder,
    company: &str,
    config: &serde_json::Value,
    tier: &str,
) -> serde_json::Value {
    let base_labels = config
        .get("labels")
        .cloned()
        .filter(|l| l.is_object())
        .unwrap_or_else(|| serde_json::json!({}));
    let mut machines = Vec::new();
    let mut machine = |role: String, plan: &str, data_gib: Option<u64>| {
        let hostname = format!("lake-{role}-{company}");
        let mut labels = base_labels.clone();
        labels["role"] = serde_json::json!(role);
        let mut disks = vec![
            serde_json::json!({ "title": format!("{hostname} system"), "gib": 20, "tier": tier, "labels": {} }),
        ];
        if let Some(gib) = data_gib {
            disks.push(serde_json::json!({ "title": format!("{hostname} data"), "gib": gib, "tier": "maxiops", "labels": { "volume": "data1" } }));
        }
        machines.push(serde_json::json!({
            "role": role, "hostname": hostname, "title": format!("Lake {role} {company}"),
            "plan": plan, "boot_order": "disk,cdrom", "labels": labels, "medium": "", "disks": disks
        }));
    };
    // The provider's own plan names: UpCloud's, or Scaleway's in a Scaleway zone (an order
    // that names no ingestors' plan takes the provider's default for it).
    let scw = plans::is_scaleway_zone(
        config
            .get("zone")
            .and_then(|z| z.as_str())
            .unwrap_or_default(),
    );
    let (front, be, fe, graph, default_ingestor) = if scw {
        (
            plans::scaleway::FRONT,
            plans::scaleway::BE,
            plans::scaleway::FE,
            plans::scaleway::GRAPH,
            plans::scaleway::INGESTOR,
        )
    } else {
        (
            plans::FRONT,
            plans::BE,
            plans::FE,
            plans::GRAPH,
            plans::INGESTOR,
        )
    };
    let ingestor = if order.ingestor_plan.trim() == plans::INGESTOR {
        default_ingestor
    } else {
        order.ingestor_plan.trim()
    };
    machine(role::FRONT.into(), front, None);
    for n in 1..=order.ingestors {
        machine(member(role::INGESTOR, n), ingestor, None);
    }
    for n in 1..=order.be {
        machine(member(role::BE, n), be, Some(order.be_data_gib()));
    }
    for n in 1..=order.fe {
        machine(member(role::FE, n), fe, None);
    }
    if order.graph {
        machine(role::GRAPH.into(), graph, Some(100));
    }
    serde_json::json!({
        "reference": format!("estate/{company}"),
        "company": company,
        "zone": config.get("zone").cloned().unwrap_or_default(),
        "machines": machines
    })
}

/// **The order kinds a Lake box runs** (core kinds only: no twin, so no repair verbs; a grow
/// is a new order, so no grow step): the entitlement, the account, its purge and the room.
pub const ORDER_KINDS: [&str; 4] = [
    monetize_product_guest::order_kind::ENTITLEMENT,
    monetize_product_guest::order_kind::ADD_PRINCIPAL,
    monetize_product_guest::order_kind::PURGE_TENANT,
    monetize_product_guest::order_kind::GROW_ROOM,
];

/// The Lake's facts (op 90): pure.
pub fn facts() -> LifecycleFacts {
    LifecycleFacts {
        kind: PRODUCT.into(),
        update_order: role::UPDATE_ORDER.iter().map(|r| r.to_string()).collect(),
        app_medium: MEDIUM.into(),
        front_medium: MEDIUM.into(),
        front_role: role::FRONT.into(),
        keep_medium: KEEP_MEDIUM.into(),
        front_keep_medium: KEEP_MEDIUM.into(),
        upgraded_by_shop: Vec::new(),
        medium_of: BTreeMap::new(),
        other_medium: MEDIUM.into(),
        // No backup at terminate (owner 2026-10-24).
        backup_roles: Vec::new(),
        words: WireWords {
            name: "lake".into(),
            title: "Lake".into(),
            account_home: String::new(),
        },
        // The names a Lake serves under its host: the host, www. and s3. (the input store).
        default_shape: serde_json::json!({ "names": ["", "www.", "s3."] }),
        // The backends hold the data; the primary is the FE leader ([`Facts::primary_role`]).
        data_roles: vec![WireDataRole {
            role: role::BE.into(),
            mirrors_primary: false,
            set_suffix: String::new(),
        }],
        domain_proof_name: "lake-proof".into(),
        live_slot: true,
        // The Lake mints no actor tickets (owner rule: the Lake stays thin).
        tickets: false,
        // The smallest Lake, as the shop's test button buys it.
        test_actions: vec![TestAction {
            id: "lake-mini".into(),
            label: "Test: the smallest Lake".into(),
            kind: TestActionKind::PrivateInstance {
                front_plan: String::new(),
                main_plan: String::new(),
                main_data_gib: 0,
            },
        }],
        // No grow lane (a grow is a new order), no restore; the guardian runs.
        guardian: true,
        // The buyer's numbers, as data: the shop checks an order against them (v2.0 P3).
        form: Some(order_form()),
        // ── What a Lake IS, as data (v2.0 P3-FACTS, A24 §2.2): its own roles, no twin, its
        // FE leader the primary, and what may change on a standing Lake.
        primary_role: role::FE.into(),
        roles: roles(),
        twin: None,
        grow: Some(grow_rule()),
        // Its own box kind, as nordisk-pairing pairs it (A28 H13: a box kind, never elected).
        box_kind: Some(PRODUCT.into()),
        // The order kinds its boxes run; any other is refused by name (A28 M23).
        order_kinds: ORDER_KINDS.iter().map(|k| k.to_string()).collect(),
        ..Default::default()
    }
}

/// **The Lake's roles** (P3-FACTS): the front once, the ingestors, backends and frontends laid
/// n times (`be`, `be-2`, …), the graph database laid or not.
pub fn roles() -> Vec<RoleFacts> {
    let r = |role: &str, many: bool, optional: bool| RoleFacts {
        role: role.into(),
        many,
        optional,
        ..RoleFacts::default()
    };
    vec![
        r(role::FRONT, false, false),
        r(role::INGESTOR, true, false),
        r(role::BE, true, false),
        r(role::FE, true, false),
        r(role::GRAPH, false, true),
    ]
}

/// **What may change on a standing Lake** (P3-FACTS): the input store never shrinks; a plan
/// change may lay more ingestors, backends and frontends, and the graph database; none is
/// taken away by a buy (a backend holds its data).
pub fn grow_rule() -> GrowRule {
    GrowRule {
        never_shrinks: vec!["store_gib".into()],
        may_add: [role::INGESTOR, role::BE, role::FE, role::GRAPH]
            .iter()
            .map(|r| r.to_string())
            .collect(),
        ..GrowRule::default()
    }
}

/// **The Lake's order form** (v2.0 P3, the parametric order): [`LakeOrder`]'s fields, ranges and
/// defaults, as data the shop checks an order against before it is priced or held.
pub fn order_form() -> monetize_product_guest::facts::OrderForm {
    use monetize_product_guest::facts::{FieldKind, OrderField, OrderForm};
    let d = LakeOrder::default();
    let f = |name: &str,
             kind: FieldKind,
             (min, max, step): (u64, u64, u64),
             choices: &[u64],
             default: serde_json::Value,
             words: &str| OrderField {
        name: name.into(),
        kind,
        min,
        max,
        step,
        choices: choices.to_vec(),
        default,
        words: words.into(),
    };
    OrderForm {
        fields: vec![
            f(
                "store_gib",
                FieldKind::Gib,
                (100, 100_000, 100),
                &[],
                d.store_gib.into(),
                "the input store (GiB)",
            ),
            f(
                "ingestors",
                FieldKind::Count,
                (1, 64, 0),
                &[],
                d.ingestors.into(),
                "the most ingestors at once",
            ),
            f(
                "ingestor_plan",
                FieldKind::Plan,
                (0, 0, 0),
                &[],
                d.ingestor_plan.into(),
                "the ingestors' plan",
            ),
            f(
                "be",
                FieldKind::Count,
                (1, 32, 0),
                &[],
                d.be.into(),
                "Apache Doris backends",
            ),
            f(
                "fe",
                FieldKind::Count,
                (0, 0, 0),
                &[1, 3, 5],
                d.fe.into(),
                "Apache Doris frontends",
            ),
            f(
                "graph",
                FieldKind::Bool,
                (0, 0, 0),
                &[],
                d.graph.into(),
                "a graph database",
            ),
        ],
    }
}

#[derive(Default)]
pub struct LakeGuest;

/// One plain line per call: `lake: guest op <n> <name> from monetize-server <what>`.
fn say(host: &dyn Host, op: u32, what: &str) {
    let name = monetize_product_guest::ops::name(op).unwrap_or("?");
    host.log(format!("lake: guest op {op} {name} from monetize-server {what}").trim_end());
}

impl Guest for LakeGuest {
    /// No grants: the Lake's guest opens no door. The bytes ingested into the store (enforced on
    /// the store) and the query seconds (a cap only, never reported).
    fn manifest(&self) -> Manifest {
        Manifest::new(PRODUCT, &[]).with_meters(vec![
            WireMeter::new(
                "ingested_bytes",
                "bytes ingested into the Lake's input store",
                Enforcement::Enforced,
            )
            .with_backing(Backing::DiskBytes),
            WireMeter::cap_only(
                "query_seconds",
                "the query seconds the estate was bought with",
                Enforcement::Measured,
            ),
        ])
    }

    fn lifecycle_facts(&self, host: &dyn Host) -> Result<LifecycleFacts, ProductError> {
        say(host, monetize_product_guest::ops::LIFECYCLE_FACTS, "");
        Ok(facts())
    }

    /// **The plan of one order** (op 91): the buyer's numbers ([`LakeOrder::of`]) laid as
    /// [`plan_of`] lays them. An order out of range is refused by name.
    fn lifecycle_lay_plan(
        &self,
        host: &dyn Host,
        req: &LayPlanReq,
    ) -> Result<serde_json::Value, ProductError> {
        let order = LakeOrder::of(req).map_err(ProductError::Refused)?;
        say(
            host,
            monetize_product_guest::ops::LIFECYCLE_LAY_PLAN,
            &format!(
                "company={} order={}",
                req.company,
                serde_json::to_string(&order).unwrap_or_default()
            ),
        );
        let tier = req
            .template_tiers
            .get(MEDIUM)
            .cloned()
            .unwrap_or_else(|| "standard".into());
        Ok(plan_of(&order, &req.company, &req.config, &tier))
    }

    /// One finding per silent machine (no report for the silent interval): a human looks; the
    /// Lake plans no automatic remedy yet.
    fn guardian_diagnose(&self, view: &EstateView) -> Result<Vec<Finding>, ProductError> {
        let mut out = Vec::new();
        for r in [role::FRONT, role::BE, role::FE] {
            let silent = view.get(r).is_none_or(|b| {
                view.now_unix_ms.saturating_sub(b.heard_unix_ms) > view.silent_after_ms
            });
            if silent {
                out.push(Finding {
                    code: "lake_silent".into(),
                    role: r.into(),
                    words: format!("the Lake's {r} of {} has not reported", view.instance),
                    evidence: vec![format!("now={}", view.now_unix_ms)],
                    remedies: vec![Remedy::NewServer],
                    auto: None,
                });
            }
        }
        Ok(out)
    }

    fn guardian_plan(
        &self,
        remedy: Remedy,
        view: &EstateView,
    ) -> Result<Result<Vec<Step>, String>, ProductError> {
        Ok(Err(format!(
            "the Lake plans no {remedy:?} for {}: a human lays the machine again",
            view.instance
        )))
    }

    fn guardian_observe(
        &self,
        role: &str,
        what: &str,
        _view: &EstateView,
    ) -> Result<StepState, ProductError> {
        Ok(StepState::Waiting(format!(
            "the Lake's {role} has not shown {what}"
        )))
    }
}

monetize_product_guest::export_product!(LakeGuest, grants = []);

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    /// A host that records what the guest says and grants nothing.
    #[derive(Default)]
    struct Recorder {
        lines: Mutex<Vec<String>>,
    }
    impl Host for Recorder {
        fn log(&self, line: &str) {
            self.lines.lock().unwrap().push(line.to_string());
        }
    }

    fn req(order: serde_json::Value) -> LayPlanReq {
        LayPlanReq {
            company: "acme".into(),
            config: serde_json::json!({"zone": "fi-hel2", "labels": {"shop": "one"}}),
            sizes: serde_json::json!({ "order": order }),
            ..Default::default()
        }
    }

    #[test]
    fn the_guest_opens_no_door_and_answers_the_six_ops_only() {
        let host = Recorder::default();
        let g = LakeGuest;
        let m = g.manifest();
        assert!(m.grants.is_empty(), "the Lake is pulled: no grants");
        assert_eq!(
            m.meters.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
            ["ingested_bytes", "query_seconds"],
            "the meter table rides in the manifest"
        );
        for (op, _) in monetize_product_guest::ops::ALL {
            let _ = monetize_product_guest::dispatch(&g, &host, *op, &[]);
        }
        let lines = host.lines.lock().unwrap().clone();
        assert!(
            lines.iter().all(|l| l.starts_with("lake: guest op ")),
            "{lines:#?}"
        );
        assert!(
            lines.iter().any(|l| l.contains("lifecycle.facts")),
            "{lines:#?}"
        );
        for (op, name) in monetize_product_guest::ops::RETIRED {
            let raw = monetize_product_guest::dispatch(&g, &host, *op, &[]);
            assert!(
                String::from_utf8_lossy(&raw)
                    .contains(monetize_product_guest::ops::UNKNOWN_OP_PHRASE),
                "op {op} ({name}) left the contract"
            );
        }
    }

    #[test]
    fn the_plan_follows_the_buyers_numbers() {
        let host = Recorder::default();
        let o = serde_json::json!({"store_gib": 1000, "ingestors": 2, "ingestor_plan": "STARTER-2xCPU-4GB", "be": 3, "fe": 1, "graph": true});
        let plan = LakeGuest.lifecycle_lay_plan(&host, &req(o)).unwrap();
        let m = plan["machines"].as_array().unwrap();
        assert_eq!(m.len(), 1 + 2 + 3 + 1 + 1, "1 + n + x + y (+1)");
        let roles: Vec<&str> = m.iter().map(|x| x["role"].as_str().unwrap()).collect();
        assert_eq!(
            roles,
            [
                "front",
                "ingestor",
                "ingestor-2",
                "be",
                "be-2",
                "be-3",
                "fe",
                "graph"
            ]
        );
        assert!(
            !roles.iter().any(|r| ["main", "twin"].contains(r)),
            "the Lake's roles are its own"
        );
        assert_eq!(
            m[1]["plan"], "STARTER-2xCPU-4GB",
            "the ingestors' plan is the buyer's"
        );
        assert_eq!(
            m[3]["disks"][1]["gib"], 340,
            "1000 GiB over 3 backends, rounded up to 10"
        );
        assert_eq!(m[3]["labels"]["role"], "be");
        assert_eq!(m[3]["labels"]["shop"], "one");
        assert_eq!(plan["zone"], "fi-hel2");
        // The smallest Lake when the order is absent.
        let small = LakeGuest
            .lifecycle_lay_plan(
                &host,
                &LayPlanReq {
                    company: "acme".into(),
                    ..Default::default()
                },
            )
            .unwrap();
        assert_eq!(small["machines"].as_array().unwrap().len(), 4);
        // Grow = changing the numbers: one more backend is one more machine.
        let o4 = serde_json::json!({"store_gib": 1000, "ingestors": 2, "ingestor_plan": "STARTER-2xCPU-4GB", "be": 4, "fe": 1, "graph": true});
        let grown = LakeGuest.lifecycle_lay_plan(&host, &req(o4)).unwrap();
        assert_eq!(grown["machines"].as_array().unwrap().len(), m.len() + 1);
    }

    #[test]
    fn in_a_scaleway_zone_the_plans_are_scaleways_names() {
        let host = Recorder::default();
        let scw = |order: serde_json::Value| LayPlanReq {
            company: "acme".into(),
            config: serde_json::json!({"zone": "fr-par-1"}),
            sizes: serde_json::json!({ "order": order }),
            ..Default::default()
        };
        let plan = LakeGuest
            .lifecycle_lay_plan(&host, &scw(serde_json::Value::Null))
            .unwrap();
        let plans: Vec<&str> = plan["machines"]
            .as_array()
            .unwrap()
            .iter()
            .map(|m| m["plan"].as_str().unwrap())
            .collect();
        assert_eq!(
            plans,
            ["DEV1-S", "DEV1-S", "DEV1-XL", "DEV1-M"],
            "front, ingestor, be, fe at Scaleway"
        );
        let named = LakeGuest
            .lifecycle_lay_plan(
                &host,
                &scw(serde_json::json!({"ingestor_plan": "DEV1-M", "graph": true})),
            )
            .unwrap();
        let m = named["machines"].as_array().unwrap();
        assert_eq!(
            (m[1]["plan"].as_str(), m.last().unwrap()["plan"].as_str()),
            (Some("DEV1-M"), Some("DEV1-XL")),
            "the buyer's ingestors' plan; the graph's at Scaleway"
        );
        let up = LakeGuest
            .lifecycle_lay_plan(&host, &req(serde_json::Value::Null))
            .unwrap();
        assert_eq!(
            up["machines"][0]["plan"],
            plans::FRONT,
            "UpCloud's names elsewhere"
        );
        assert!(
            plans::is_scaleway_zone("nl-ams-2")
                && !plans::is_scaleway_zone("fi-hel2")
                && !plans::is_scaleway_zone("se-sto1")
        );
    }

    #[test]
    fn an_order_out_of_range_is_refused_by_name() {
        let host = Recorder::default();
        for (bad, word) in [
            (serde_json::json!({"fe": 2}), "1, 3 or 5 frontends"),
            (serde_json::json!({"store_gib": 150}), "steps of 100"),
            (serde_json::json!({"be": 0}), "backends"),
            (serde_json::json!({"ingestors": 65}), "ingestors"),
            (serde_json::json!({"rooms": 1}), "not a Lake order"),
        ] {
            match LakeGuest.lifecycle_lay_plan(&host, &req(bad.clone())) {
                Err(ProductError::Refused(w)) => assert!(w.contains(word), "{bad}: {w}"),
                other => panic!("{bad}: {other:?}"),
            }
        }
    }

    #[test]
    fn the_facts_say_a_pulled_lake_with_no_twin_and_its_own_roles() {
        let f = facts();
        assert_eq!(f.kind, "lake");
        assert_eq!(f.front_role, "front");
        assert!(f.data_roles.iter().all(|d| !d.mirrors_primary), "no twin");
        assert!(f
            .update_order
            .iter()
            .all(|r| !["main", "twin"].contains(&r.as_str())));
        assert_eq!(
            f.update_order.last().map(String::as_str),
            Some("front"),
            "the front drives the update and is upgraded last (the core's rule)"
        );
        assert!(f.backup_roles.is_empty(), "no backup at terminate");
        assert!(!f.tickets, "the Lake mints no actor tickets");
        assert!(
            f.guardian && f.test_actions.iter().any(|t| t.id == "lake-mini"),
            "the guardian and the test button ride in the facts"
        );
    }

    /// ★ **The Lake's primary, roles, twin and grow rule are DATA in its facts** (P3-FACTS):
    /// the FE leader holds the tenants (never the first data role, `be`); its five roles with
    /// the three laid n times and the graph optional; no twin; the store never shrinks, and a
    /// plan change may lay more ingestors, backends, frontends and the graph, and takes none away.
    #[test]
    fn the_facts_name_the_lakes_primary_roles_no_twin_and_its_grow_rule() {
        let f = facts();
        assert_eq!(f.primary(), "fe");
        assert_eq!(f.role_names(), ["front", "ingestor", "be", "fe", "graph"]);
        assert_eq!(
            f.roles
                .iter()
                .filter(|r| r.many)
                .map(|r| r.role.as_str())
                .collect::<Vec<_>>(),
            ["ingestor", "be", "fe"]
        );
        assert!(f
            .roles
            .iter()
            .any(|r| r.role == "graph" && r.optional && !r.many));
        assert_eq!(f.twin_of(), None);
        let g = f.grow_rule();
        assert_eq!(g.never_shrinks, ["store_gib"]);
        assert_eq!(
            g.machines_refusal(&f.roles, &["be-2".into(), "graph".into()], &[]),
            None
        );
        assert!(
            g.machines_refusal(&f.roles, &[], &["be-2".into()])
                .is_some(),
            "a backend holds its data: never taken away by a buy"
        );
        // Every machine the plan lays is a member of one of the roles the facts name.
        let host = Recorder::default();
        let plan = LakeGuest
            .lifecycle_lay_plan(
                &host,
                &req(serde_json::json!({"ingestors": 2, "be": 3, "fe": 3, "graph": true})),
            )
            .unwrap();
        for m in plan["machines"].as_array().unwrap() {
            let role = m["role"].as_str().unwrap();
            assert!(
                monetize_product_guest::facts::RoleFacts::of(&f.roles, role).is_some(),
                "{role} is a role of the facts"
            );
        }
    }
}