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

nordisk/holger

crates/holger-monetize-guest/src/estate.rs

//! **Holger's estate, laid by the guest** (lane L27, 2026-10-19): the ONE box links no product,
//! so Holger reaches it as Gunnar does, a signed guest. Like Gunnar's guest it answers
//! `lifecycle.lay_plan` (op 91) itself and names its own shape and size ladder in
//! `lifecycle.facts` (op 90); the core holds no Holger rule.
//!
//! The rules are monetize's `estate_order::lifecycle::Holger` (Gunnar's pattern, Holger's boxes),
//! byte for byte (monetize-server `tests/the_signed_holger_guest_lays_holgers_three_boxes.rs`
//! asserts it against the server itself): the front is holger-web-ui (the `holger-front`
//! medium), main is holger-server (the primary, one data disk the shop grows) and the twin is
//! holger-server in twin mode (its data disk holds main's whole set).
//!
//! The plan is answered as JSON in monetize-cloud's `EstatePlan` form, built here: this module
//! takes no monetize crate beyond the guest SDK.
//!
//! **The ladder** travels inside the facts' `default_shape` JSON (key `ladder`, monetize's
//! `LadderStep` form): the `LifecycleFacts` wire has no ladder field, and a key a shape does not
//! name is ignored by every reader of the shape.

use monetize_product_guest::wire::LayPlanReq;
use serde_json::{json, Map, Value};

/// The medium kind a machine of `role` is laid from: the front from `holger-front`, main and
/// the twin from `holger`.
pub fn medium_kind(role: &str) -> &'static str {
    if role == "front" {
        "holger-front"
    } else {
        "holger"
    }
}

/// A public and a utility IPv4: what every estate machine is laid with (monetize-cloud's
/// `default_interfaces`).
fn default_interfaces() -> Value {
    json!([{ "kind": "public", "family": "IPv4" }, { "kind": "utility", "family": "IPv4" }])
}

/// A utility IPv4 alone: a box behind its estate's front (monetize-cloud's
/// `behind_the_front_interfaces`, the lean estate, owner 2026-10-23).
fn behind_the_front_interfaces() -> Value {
    json!([{ "kind": "utility", "family": "IPv4" }])
}

/// **Holger's twin floor**, MiB (monetize's `Shape::twin_floor_mib`): holger-server in twin mode
/// runs in a 1 GB box (monetize's default floor, 4 GiB, is gunnar-server's).
pub const TWIN_FLOOR_MIB: u64 = 1024;

/// **Holger's size ladder**, Mini to Gigantic (monetize `lifecycle::holger::ladder`, lane L18):
/// Mini is [`default_shape`]; main has ONE data disk, so a step's data is the whole set.
pub fn ladder() -> Value {
    let step = |name: &str, front: &str, main: &str, gib: u64| json!({ "name": name, "front_plan": front, "main_plan": main, "main_data_gib": gib });
    json!([
        step("Mini", "STARTER-1xCPU-1GB", "STARTER-1xCPU-2GB", 20),
        step("Small", "STARTER-1xCPU-2GB", "STARTER-4xCPU-8GB", 120),
        step("Medium", "STARTER-2xCPU-4GB", "8xCPU-32GB", 240),
        step("Large", "STARTER-2xCPU-4GB", "12xCPU-48GB", 480),
        step("Huge", "STARTER-4xCPU-8GB", "16xCPU-64GB", 1000),
        step("Gigantic", "STARTER-4xCPU-8GB", "20xCPU-96GB", 2000),
    ])
}

/// **Holger's own shape** (monetize `lifecycle::holger::shape`), **the lean estate** (owner
/// 2026-10-23, about 20 € a month): the front holds the estate's ONE public IPv4 (its hub: the
/// console, the WireGuard relay); main and the twin a utility address only. A 1-core 1 GB front,
/// a 1-core 2 GB main with one 20 GB data disk, a 1-core 1 GB twin (the weaker box: less memory,
/// the election's second axis, so the installer's election makes it the twin), 10 GB system
/// disks. Every disk is UpCloud's `hdd` tier, the cheapest for live servers (owner 2026-10-23).
/// Templates over `{company}`. Carries the ladder and Holger's twin floor.
pub fn default_shape() -> Value {
    let d = |title: &str, gib: u64, tier: &str, volume: &str| json!({ "title": title, "gib": gib, "tier": tier, "volume": volume });
    let m = |hostname: &str,
             title: &str,
             plan: &str,
             role_label: &str,
             disks: Vec<Value>,
             interfaces: Value| {
        json!({ "hostname": hostname, "title": title, "plan": plan, "boot_order": "disk,cdrom", "role_label": role_label, "disks": disks, "interfaces": interfaces })
    };
    json!({
        "front": m("holger-web-ui-{company}", "holger-web-ui — Holger's front: the console, the relay-{company}", "STARTER-1xCPU-1GB", "front", vec![d("holger-web-ui-{company} system", 10, "hdd", "system")], default_interfaces()),
        "main": m("holger-server-{company}", "holger-server — the primary, the repositories-{company}", "STARTER-1xCPU-2GB", "primary", vec![
            d("holger-server-{company} system", 10, "hdd", "system"),
            d("holger-server-{company} data 1", 20, "hdd", "data1"),
        ], behind_the_front_interfaces()),
        "twin": m("holger-twin-{company}", "holger-server in twin mode — follows the primary-{company}", "STARTER-1xCPU-1GB", "twin", vec![
            d("holger-twin-{company} system", 10, "hdd", "system"),
            d("holger-twin-{company} data", 20, "hdd", "twin"),
        ], behind_the_front_interfaces()),
        "twin_floor_mib": TWIN_FLOOR_MIB,
        "ladder": ladder(),
        "mail_words": mail_words(),
        "names": NAMES,
        "repo_names": REPO_NAMES,
    })
}

/// **The names a Holger estate serves under its host**: the console's host, `www.` and
/// `repo.`, no git (monetize v2.0 P2 12.1, A28 M8: until now the ONE box gave a Holger estate
/// Gunnar's three). `repo.<site>` is where every client is sent (HOL-REPO-NAME, the docs'
/// `https://repo.<site>/…`): the ONE box writes its record with the others. Carried in the shape
/// as the ladder is.
pub const NAMES: [&str; 3] = ["", "www.", "repo."];

/// **The served names its front routes to the primary's repository** (HOL-REPO-NAME): the shop's
/// pairing feeds them as `repo_hosts` (edda `estate-front::route`), so each is in `web_hosts` and
/// on the front's certificate. Carried in the shape as `names` is.
pub const REPO_NAMES: [&str; 1] = ["repo."];

/// **What Holger's buyer reads of it** (monetize `lifecycle::holger::mail_words`, lane M4): an
/// artifact repository, served from its console's host (`{rp}`), and the pay page its next
/// period is paid at (`{pay_page}`). Carried in the shape as the ladder is (the published
/// facts wire has no field for it).
pub fn mail_words() -> Value {
    json!({
        "noun": "an artifact repository",
        "serves": "Your artifact repository is served from https://{rp}.",
        "own_domain_serves": "your artifact repository and the web",
        // Lane P1: what the shop's first-login link makes the buyer (monetize's own words).
        "first_step": "Your passkey makes {account} its first admin: you set up its repositories, approve what is uploaded, and invite the people who upload.",
        "pay_line": "Pay for its next period at {pay_page}.",
    })
}

/// One disk of a machine's shape.
struct DiskShape {
    title: String,
    gib: u64,
    tier: String,
    volume: String,
}

/// One machine of the shape (monetize's `MachineShape`).
struct MachineShape {
    hostname: String,
    title: String,
    plan: String,
    boot_order: String,
    role_label: String,
    disks: Vec<DiskShape>,
    interfaces: Value,
}

fn text(v: &Value, key: &str, at: &str) -> Result<String, String> {
    v.get(key)
        .and_then(Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| format!("the config's shape: {at} names no `{key}`"))
}

fn machine_shape(shape: &Value, role: &str) -> Result<MachineShape, String> {
    let m = shape
        .get(role)
        .filter(|m| m.is_object())
        .ok_or_else(|| format!("the config's shape: no `{role}` machine"))?;
    let disks = m
        .get("disks")
        .and_then(Value::as_array)
        .ok_or_else(|| format!("the config's shape: {role} names no `disks`"))?
        .iter()
        .map(|d| {
            let at = format!("a disk of {role}");
            Ok(DiskShape {
                title: text(d, "title", &at)?,
                gib: d
                    .get("gib")
                    .and_then(Value::as_u64)
                    .ok_or_else(|| format!("the config's shape: {at} names no `gib`"))?,
                tier: text(d, "tier", &at)?,
                volume: text(d, "volume", &at)?,
            })
        })
        .collect::<Result<Vec<_>, String>>()?;
    Ok(MachineShape {
        hostname: text(m, "hostname", role)?,
        title: text(m, "title", role)?,
        plan: text(m, "plan", role)?,
        boot_order: m
            .get("boot_order")
            .and_then(Value::as_str)
            .unwrap_or("disk,cdrom")
            .to_string(),
        role_label: text(m, "role_label", role)?,
        disks,
        interfaces: m
            .get("interfaces")
            .filter(|i| !i.is_null())
            .cloned()
            .unwrap_or_else(default_interfaces),
    })
}

fn labels_of(v: Option<&Value>) -> Result<Map<String, Value>, String> {
    match v {
        None | Some(Value::Null) => Ok(Map::new()),
        Some(Value::Object(o)) if o.values().all(Value::is_string) => Ok(o.clone()),
        Some(other) => Err(format!(
            "the config's labels: not a table of strings: {other}"
        )),
    }
}

/// Pure: **the plan a Holger estate is laid as**, in monetize-cloud's `EstatePlan` form — the
/// config's shape with `{company}` filled in, every resource labelled with its role (and a disk
/// with its volume), the buyer's sizes over the shape's, the twin following main (its plan
/// chosen from main's, its data disk holding main's whole set), a system disk cloned from a
/// template laid in the template's tier. The medium, seed and template are the sale's, set
/// where the lay is called (empty here, as the native plan leaves them).
pub fn lay_plan(req: &LayPlanReq) -> Result<Value, String> {
    let shape = req.config.get("shape").cloned().unwrap_or_default();
    let labels = labels_of(req.config.get("labels"))?;
    let zone = req
        .config
        .get("zone")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let size = |k: &str| {
        req.sizes
            .get(k)
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim()
            .to_string()
    };
    let (front_plan, main_plan) = (size("front_plan"), size("main_plan"));
    let main_data_gib = req
        .sizes
        .get("main_data_gib")
        .and_then(Value::as_u64)
        .unwrap_or(0);
    let company = req.company.as_str();
    let fill = |t: &str| t.replace("{company}", company);
    let mut front = machine_shape(&shape, "front")?;
    if !front_plan.is_empty() {
        front.plan = front_plan;
    }
    let mut main = machine_shape(&shape, "main")?;
    if !main_plan.is_empty() {
        main.plan = main_plan;
    }
    if main_data_gib > 0 {
        main.disks
            .iter_mut()
            .skip(1)
            .for_each(|d| d.gib = main_data_gib);
    }
    let mut twin = machine_shape(&shape, "twin")?;
    if !req.twin_plan.is_empty() {
        twin.plan = req.twin_plan.clone();
    }
    let main_data: u64 = main.disks.iter().skip(1).map(|d| d.gib).sum();
    if let Some(d) = twin.disks.get_mut(1) {
        d.gib = d.gib.max(main_data);
    }
    let machine = |role: &str, m: &MachineShape| {
        let mut l = labels.clone();
        l.insert("role".into(), Value::String(m.role_label.clone()));
        let disks: Vec<Value> = m
            .disks
            .iter()
            .map(|d| {
                let mut dl = l.clone();
                dl.insert("volume".into(), Value::String(d.volume.clone()));
                let tier = match req.template_tiers.get(medium_kind(role)) {
                    Some(t) if d.volume == "system" => t.clone(),
                    _ => d.tier.clone(),
                };
                json!({ "title": fill(&d.title), "gib": d.gib, "tier": tier, "labels": dl })
            })
            .collect();
        json!({
            "role": role,
            "hostname": fill(&m.hostname),
            "title": fill(&m.title),
            "plan": m.plan,
            "boot_order": m.boot_order,
            "labels": l,
            "disks": disks,
            "interfaces": m.interfaces,
            "medium": "",
        })
    };
    Ok(json!({
        "reference": format!("estate/{company}"),
        "company": company,
        "zone": zone,
        "machines": [machine("front", &front), machine("main", &main), machine("twin", &twin)],
    }))
}

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

    /// ★ The guest lays Holger's three boxes from its own shape: holger-web-ui, holger-server
    /// and the twin, never a Gunnar name; the twin's data disk holds main's set.
    #[test]
    fn the_guest_lays_holgers_front_main_and_twin() {
        let req = LayPlanReq {
            company: "acme".into(),
            config: json!({ "shape": default_shape(), "labels": {}, "zone": "fi-hel2" }),
            sizes: json!({ "main_data_gib": 240 }),
            ..Default::default()
        };
        let plan = lay_plan(&req).expect("laid");
        let ms = plan["machines"].as_array().unwrap();
        assert_eq!(
            ms.iter()
                .map(|m| m["hostname"].as_str().unwrap())
                .collect::<Vec<_>>(),
            [
                "holger-web-ui-acme",
                "holger-server-acme",
                "holger-twin-acme"
            ]
        );
        assert_eq!(
            ms.iter()
                .map(|m| m["plan"].as_str().unwrap())
                .collect::<Vec<_>>(),
            [
                "STARTER-1xCPU-1GB",
                "STARTER-1xCPU-2GB",
                "STARTER-1xCPU-1GB"
            ]
        );
        assert_eq!(
            ms[1]["disks"].as_array().unwrap().len(),
            2,
            "main has ONE data disk"
        );
        assert_eq!(
            ms[2]["disks"][1]["gib"], 240,
            "the twin holds main's whole set"
        );
        assert!(!plan.to_string().contains("gunnar"), "{plan}");
        assert_eq!(plan["reference"], "estate/acme");
        assert_eq!(default_shape()["ladder"].as_array().unwrap().len(), 6);
    }

    /// ★ Owner 2026-10-23: every disk of a fresh Holger estate is laid on UpCloud's `hdd` tier,
    /// the cheapest for live servers: the front's system, main's system and data, the twin's
    /// system and data. Only new lays and grown disks follow it; laid disks keep their tier.
    #[test]
    fn every_disk_of_a_fresh_holger_shape_is_hdd() {
        let shape = default_shape();
        let tiers: Vec<(String, String)> = ["front", "main", "twin"]
            .iter()
            .flat_map(|r| {
                shape[r]["disks"].as_array().unwrap().iter().map(move |d| {
                    (
                        format!("{r} {}", d["volume"].as_str().unwrap()),
                        d["tier"].as_str().unwrap().to_string(),
                    )
                })
            })
            .collect();
        assert_eq!(tiers.len(), 5, "{tiers:?}");
        assert!(tiers.iter().all(|(_, t)| t == "hdd"), "{tiers:?}");
        let req = LayPlanReq {
            company: "acme".into(),
            config: json!({ "shape": shape, "labels": {}, "zone": "fi-hel2" }),
            sizes: json!({ "main_data_gib": 240 }),
            ..Default::default()
        };
        let plan = lay_plan(&req).expect("laid");
        let laid: Vec<&str> = plan["machines"]
            .as_array()
            .unwrap()
            .iter()
            .flat_map(|m| {
                m["disks"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .map(|d| d["tier"].as_str().unwrap())
            })
            .collect();
        assert_eq!(laid, ["hdd"; 5], "{plan}");
    }

    /// ★ **The lean estate** (owner 2026-10-23, monetize `lifecycle::holger::shape`): one public
    /// IPv4, on the front (the estate's hub); main and the twin on the utility network only; a
    /// 1-core 1 GB front, a 1-core 2 GB main, a 1-core 1 GB twin (the weaker box: less memory),
    /// 10 GB system disks; Holger's twin floor is 1 GB (monetize's is Gunnar's 4 GiB).
    #[test]
    fn the_guest_lays_the_lean_estate_with_one_public_ipv4_on_the_front() {
        let shape = default_shape();
        let public = |r: &str| {
            shape[r]["interfaces"]
                .as_array()
                .unwrap()
                .iter()
                .filter(|i| i["kind"] == "public" && i["family"] == "IPv4")
                .count()
        };
        assert_eq!((public("front"), public("main"), public("twin")), (1, 0, 0));
        for r in ["main", "twin"] {
            assert!(
                shape[r]["interfaces"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .any(|i| i["kind"] == "utility"),
                "{r} keeps its utility address"
            );
        }
        assert_eq!(
            ["front", "main", "twin"].map(|r| shape[r]["plan"].as_str().unwrap().to_string()),
            [
                "STARTER-1xCPU-1GB",
                "STARTER-1xCPU-2GB",
                "STARTER-1xCPU-1GB"
            ]
        );
        assert_eq!(
            ["front", "main", "twin"].map(|r| shape[r]["disks"][0]["gib"].as_u64().unwrap()),
            [10, 10, 10]
        );
        assert_eq!(shape["twin_floor_mib"], 1024);
        assert_eq!(
            (
                shape["ladder"][0]["front_plan"].as_str(),
                shape["ladder"][0]["main_plan"].as_str()
            ),
            (Some("STARTER-1xCPU-1GB"), Some("STARTER-1xCPU-2GB")),
            "Mini is the shape"
        );
        let req = LayPlanReq {
            company: "acme".into(),
            config: json!({ "shape": shape, "labels": {}, "zone": "se-sto1" }),
            twin_plan: "STARTER-1xCPU-1GB".into(),
            ..Default::default()
        };
        let plan = lay_plan(&req).expect("laid");
        let laid: Vec<usize> = plan["machines"]
            .as_array()
            .unwrap()
            .iter()
            .map(|m| {
                m["interfaces"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .filter(|i| i["kind"] == "public")
                    .count()
            })
            .collect();
        assert_eq!(laid, [1, 0, 0], "{plan}");
    }

    /// ★ A28 M8: a Holger estate is served under its host and `www.`, never `git.`; and
    /// (HOL-REPO-NAME) under `repo.`, the name every client is sent to (`https://repo.<site>/…`):
    /// the ONE box writes its record, and its front routes it to the primary's repository.
    #[test]
    fn the_guest_names_holgers_served_names_and_no_git() {
        assert_eq!(default_shape()["names"], json!(["", "www.", "repo."]));
        assert_eq!(
            default_shape()["repo_names"],
            json!(["repo."]),
            "the front routes repo.<site> to the primary"
        );
        assert!(
            default_shape().get("redirect_names").is_none(),
            "no redirect-only name"
        );
    }

    /// ★ Lane M4: the buyer's mails speak of Holger in Holger's words — an artifact repository,
    /// served from its console's host, its pay page — carried in the shape as the ladder is.
    #[test]
    fn the_guest_names_holgers_mail_words() {
        let w = &default_shape()["mail_words"];
        assert_eq!(w["noun"], "an artifact repository", "{w}");
        assert!(
            w["serves"].as_str().unwrap().contains("{rp}") && !w.to_string().contains("git"),
            "{w}"
        );
        assert!(
            w["pay_line"].as_str().unwrap().contains("{pay_page}"),
            "{w}"
        );
        assert!(!w.to_string().to_lowercase().contains("gunnar"), "{w}");
    }
}