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

nordisk/monetize

crates/monetize-server/src/estate_order/tests/holger_lifecycle.rs

//! **Holger's estate lifecycle** (lane L7, 2026-10-17): the same rules as Gunnar's — the lay plan
//! (front, main, twin from the shape), the facts (each role's medium and seed kind, the boxes the
//! shop keep-boots, what a terminate keeps), the front-first update while the front never asked,
//! the keep medium — and, since L20 (2026-10-18), Gunnar's live slot too: an update stages the
//! release's live apps (`holger`, `holger-front`) on twin, main and front, and the keep media
//! (`holger-keep`, the front's `holger-front-keep`) stay the fallback.

use super::lifecycle::of;
use super::*;

/// A release for Holger: its front and holger media, both keep media, and live apps for both
/// kinds.
fn holger_release(release: &str) -> EstateOrderConfig {
    let mut c = EstateOrderConfig {
        lifecycle: "holger".into(),
        ..cfg()
    };
    c.media = [
        ("front", "front"),
        ("holger-front", "holger-front"),
        ("holger", "holger"),
        ("holger-keep", "holger-keep"),
        ("holger-front-keep", "holger-front-keep"),
    ]
    .into_iter()
    .map(|(k, v)| (k.to_string(), format!("{release}-{v}")))
    .collect();
    for kind in ["holger", "holger-front"] {
        c.live_apps.insert(
            kind.into(),
            update::LiveRecord {
                base: "b".repeat(64),
                app: "a".repeat(64),
                app_len: 9,
                sig: "5".repeat(128),
                ..Default::default()
            },
        );
        c.media_objects.insert(
            format!("{kind}{}", update::LIVE_SUFFIX),
            monetize_cloud::lay::MediaObject {
                url: format!("https://objsto.example/release/{kind}/{release}.live-app.znippy"),
                sha256: "c".repeat(64),
                bytes: 9,
            },
        );
    }
    c
}

/// ★ **Holger has its own lifecycle, Gunnar's rules with Holger's names**: a holger product (and a
/// site of it, `holger-rs`) is laid front, main and twin from the shape; main and twin are holger
/// boxes (`holger` medium, `seed.holger.toml`), the front Holger's own (`holger-front`: holger-web-ui,
/// `seed.holger-front.toml`, never Gunnar's `front`); updated twin, main,
/// front; main and twin keep-booted from `holger-keep`, the front from `holger-front-keep`; main's disks
/// kept at a terminate; a LIVE SLOT, as Gunnar's (L20). Gunnar keeps its live slot.
#[test]
fn holger_is_laid_and_updated_by_its_own_lifecycle_with_a_live_slot() {
    // Its own lifecycles: under nextest (one process per test) no other test registered them.
    crate::testing::lifecycles::install();
    let c = |p: &str| EstateOrderConfig {
        product: p.into(),
        ..cfg()
    };
    for p in ["holger", "holger-rs"] {
        assert_eq!(
            of(&c(p)).unwrap_or_else(|e| panic!("{p}: {e}")).kind(),
            "holger",
            "{p}"
        );
    }
    let life = of(&c("holger")).unwrap();
    let plan = life
        .lay_plan(&c("holger"), "acme", &EstateSizes::default(), "")
        .unwrap();
    assert_eq!(
        plan,
        plan_for(&c("holger"), "acme", &EstateSizes::default(), ""),
        "the shape's machines"
    );
    assert_eq!(
        plan.machines
            .iter()
            .map(|m| m.role.as_str())
            .collect::<Vec<_>>(),
        ["front", "main", "twin"]
    );
    assert_eq!(
        ["front", "main", "twin"].map(|r| life.medium_kind(r)),
        ["holger-front", "holger", "holger"].map(String::from)
    );
    let u = life.update_plan();
    assert_eq!(u.order, ["twin", "main", "front"]);
    assert_eq!(
        (
            u.app_medium.as_str(),
            u.front_medium.as_str(),
            u.front_role.as_str()
        ),
        ("holger", "holger-front", "front")
    );
    assert_eq!(
        (u.keep_medium.as_str(), u.front_keep_medium.as_str()),
        ("holger-keep", "holger-front-keep")
    );
    assert_eq!(u.upgraded_by_shop, ["main", "twin"]);
    assert!(
        u.live_slot,
        "Holger's boxes carry a live slot (L20), as Gunnar's"
    );
    assert_eq!(life.backup_roles(), ["main"]);
    assert_eq!(
        life.data_roles(),
        g_data_roles(),
        "main is the primary, the twin mirrors it"
    );
    assert_eq!(
        (
            life.words().name.as_str(),
            life.words().title.as_str(),
            life.proof_name().as_str()
        ),
        ("holger", "Holger", "holger-proof")
    );
    let shape = life
        .default_shape()
        .expect("Holger prices at its own shape");
    assert_eq!(
        [&shape.front, &shape.main, &shape.twin].map(|m| m.hostname.as_str()),
        [
            "holger-web-ui-{company}",
            "holger-server-{company}",
            "holger-twin-{company}"
        ]
    );
    // ★ Holger's OWN shape (owner 2026-10-18): Gunnar's pattern, nothing of Gunnar's content.
    let gunnar = crate::testing::lifecycles::gunnar_shape();
    for (h, g) in [
        (&shape.front, &gunnar.front),
        (&shape.main, &gunnar.main),
        (&shape.twin, &gunnar.twin),
    ] {
        let words = format!("{h:?}");
        assert!(
            !words.to_lowercase().contains("gunnar"),
            "no Gunnar name in Holger's shape: {words}"
        );
        assert_ne!(
            h, g,
            "Holger's shape is not Gunnar's with the names swapped"
        );
    }
    assert!(
        shape.front.title.contains("holger-web-ui")
            && shape.main.title.contains("holger-server")
            && shape.twin.title.contains("twin mode"),
        "{shape:?}"
    );
    assert_eq!(
        [&shape.front, &shape.main, &shape.twin].map(|m| m.role_label.as_str()),
        ["front", "primary", "twin"]
    );
    assert_eq!(
        shape
            .twin
            .disks
            .iter()
            .map(|d| d.volume.as_str())
            .collect::<Vec<_>>(),
        ["system", "twin"],
        "the twin's data disk holds main's set"
    );
    assert!(
        shape
            .main
            .disks
            .iter()
            .skip(1)
            .all(|d| d.volume.starts_with("data")),
        "{:?}",
        shape.main.disks
    );
    // The election makes main the primary: the twin is the weaker box (nordisk-pairing's
    // election walks cores, then memory; the lean estate's 1-core twin has less memory).
    let size = |plan: &str| {
        let cores = plan
            .split('-')
            .find_map(|p| p.strip_suffix("xCPU"))
            .and_then(|n| n.parse::<u32>().ok())
            .unwrap();
        let gb = plan
            .rsplit('-')
            .next()
            .and_then(|p| p.strip_suffix("GB"))
            .and_then(|n| n.parse::<u32>().ok())
            .unwrap();
        (cores, gb)
    };
    assert!(
        size(&shape.twin.plan) < size(&shape.main.plan),
        "{} vs {}",
        shape.twin.plan,
        shape.main.plan
    );
    // The seeds and media follow the kind.
    let mut plan = plan;
    let seeds = [
        ("seed.front.toml", "G-F"),
        ("seed.holger-front.toml", "F"),
        ("seed.holger.toml", "H"),
        ("seed.gunnar.toml", "G"),
    ]
    .map(|(n, t)| (n.to_string(), t.to_string()));
    give_seeds_as(&mut plan, &seeds, life.as_ref());
    assert_eq!(
        plan.machines
            .iter()
            .map(|m| m.user_data.0.as_str())
            .collect::<Vec<_>>(),
        ["F", "H", "H"]
    );
    // Gunnar is untouched.
    let g = of(&c("gunnar")).unwrap();
    assert!(
        g.update_plan().live_slot,
        "Gunnar's boxes keep their live slot"
    );
    assert_eq!(g.update_plan().keep_medium, KEEP_MEDIUM);
}

/// ★ **A delivered Holger estate is laid at its release and updated front first, then twin and
/// main LIVE** (L20) — the release's live apps for `holger` and `holger-front` are handed out,
/// a live report is taken, and every box then runs the release. With live updates OFF for the
/// estate the same release goes through the keep medium only: a live report is refused.
#[test]
fn a_holger_estate_is_updated_front_first_then_twin_and_main_live() {
    let r = delivered("holger-update", holger_release("02"));
    let key = client_of(&r, "acme");
    let run = r.state.estates.get("acme").unwrap();
    assert_eq!(
        run.release_of.get("main").map(String::as_str),
        Some("02-holger"),
        "main runs the holger release"
    );
    assert_eq!(
        run.release_of.get("twin").map(String::as_str),
        Some("02-holger")
    );
    assert_eq!(
        run.release_of.get("front").map(String::as_str),
        Some("02-holger-front"),
        "the front runs Holger's own front release, never Gunnar's"
    );
    laid_older(&r);
    // The front never asked: the one button goes front first, from holger-front-keep.
    let u = update::start(&r.state, "acme", "", "operator").expect("opened");
    assert_eq!(
        (u.state.as_str(), u.steps[0].role.as_str(), u.live),
        (update::state::FRONT_KEEP, "front", true),
        "{}",
        u.line()
    );
    assert_eq!(u.release, "02-holger");
    update::front_keep(&r.state, "acme");
    let t = take(&r, &key).unwrap();
    assert!(t.open && t.role == "twin" && t.live, "{t:?}");
    let (app, front) = (
        t.app.expect("the holger live app"),
        t.front.expect("the holger-front live app"),
    );
    assert!(
        app.url.contains("/release/holger/02") && front.url.contains("/release/holger-front/02"),
        "Holger's own live apps: {} {}",
        app.url,
        front.url
    );
    report(&r, &key, "twin", &update::Outcome::Done("live".into()), "").unwrap();
    report(&r, &key, "main", &update::Outcome::Done("live".into()), "").unwrap();
    let run = r.state.estates.get("acme").unwrap();
    assert!(run.update.is_none(), "done");
    assert_eq!(
        run.release_of.get("main").map(String::as_str),
        Some("02-holger")
    );
    assert!(
        update::status(&r.state, "acme").unwrap().live_on,
        "a Holger estate says live is on"
    );
    // Live OFF for this estate (a military customer): keep media only.
    update::set_live(&r.state, "acme", false, "operator").unwrap();
    assert!(!update::status(&r.state, "acme").unwrap().live_on);
    laid_older(&r);
    let u = update::start(&r.state, "acme", "", "operator").expect("opened");
    assert!(!u.live, "{}", u.line());
    let t = take(&r, &key).unwrap();
    assert!(
        t.open && !t.live && t.app.is_none() && t.front.is_none(),
        "no live app while live is off: {t:?}"
    );
    assert!(
        matches!(report(&r, &key, &t.role, &update::Outcome::Done("live".into()), "").unwrap_err(), sold::SoldRefusal::Refused(w) if w.contains("OFF"))
    );
}

/// ★ **A Holger release with no `holger-keep` is updated live only**: its live apps open the
/// update; with live updates OFF there is no way to update it, refused by name. The Gunnar keep
/// medium is no Holger keep medium.
#[test]
fn a_holger_release_without_holger_keep_is_updated_live_only() {
    let mut c = holger_release("02");
    c.media.remove("holger-keep");
    c.media.insert(KEEP_MEDIUM.into(), "02-gunnar-keep".into());
    let r = delivered("holger-no-keep", c);
    laid_older(&r);
    update::set_live(&r.state, "acme", false, "operator").unwrap();
    let e = update::start_front_first(&r.state, "acme", "", "operator").unwrap_err();
    assert!(
        matches!(&e, sold::SoldRefusal::Refused(w) if w.contains("in-place upgrade")),
        "{e:?}"
    );
    assert!(
        r.state.estates.get("acme").unwrap().update.is_none(),
        "nothing opened"
    );
    update::set_live(&r.state, "acme", true, "operator").unwrap();
    let u = update::start_front_first(&r.state, "acme", "", "operator").expect("live opens it");
    assert!(u.live, "{}", u.line());
}

fn g_data_roles() -> Vec<super::lifecycle::DataRole> {
    crate::testing::lifecycles::gunnar().data_roles()
}

/// ★ **B5: a Holger estate is on Holger's rows** — its ledger `Instance` rows are booked on the
/// `holger` product (its lifecycle's kind), never on `gunnar`; its primary and roles are its
/// lifecycle's. Gunnar's stay on `gunnar` (every Gunnar test, unchanged).
#[test]
fn a_holger_estate_is_booked_on_holger_and_its_roles_are_its_lifecycles() {
    let r = delivered("holger-rows", holger_release("02"));
    assert_eq!(instance_product(&r.state), "holger");
    let rows = r.state.ledger.instances("holger").unwrap();
    assert!(
        rows.iter().any(|l| l.company == "acme"),
        "acme's row is on holger: {rows:?}"
    );
    assert!(
        r.state.ledger.instances("gunnar").unwrap().is_empty(),
        "nothing of a Holger estate is booked on gunnar"
    );
    let life = of(&EstateOrderConfig {
        lifecycle: "holger".into(),
        ..cfg()
    })
    .unwrap();
    assert_eq!(life.roles(), ["front", "main", "twin"]);
    assert_eq!(life.primary_role(), "main");
    assert_eq!(
        lifecycle::instance_product(Some(&cfg())),
        "gunnar",
        "Gunnar's rows stay on gunnar"
    );
    assert_eq!(
        lifecycle::instance_product(None),
        "estate",
        "no estates sold here: no product's name"
    );
}

/// A product of two machines (`door` and `vault`, the primary) — neither Gunnar's nor Holger's.
fn pair() -> lifecycle::Lifecycle {
    let u = lifecycle::UpdatePlan {
        order: vec!["vault".into(), "door".into()],
        app_medium: "pair".into(),
        front_medium: "pair".into(),
        front_role: "door".into(),
        keep_medium: "pair-keep".into(),
        front_keep_medium: "pair-keep".into(),
        upgraded_by_shop: vec!["vault".into()],
        live_slot: false,
    };
    let facts = crate::facts::Facts {
        backup_roles: vec!["vault".into()],
        data_roles: vec![monetize_product_guest::wire::WireDataRole {
            role: "vault".into(),
            mirrors_primary: false,
            set_suffix: String::new(),
        }],
        ..crate::testing::lifecycles::facts_of("pair", u, "pair")
    };
    lifecycle::Lifecycle::new(
        "pair",
        facts,
        Arc::new(
            |_: &lifecycle::Lifecycle, _: &EstateOrderConfig, _: &str, _: &EstateSizes, _: &str| {
                Err("not laid in this test".to_string())
            },
        ),
    )
}

/// ★ **B5: adoption reads the roles off the lifecycle** — a product of `door` and `vault` is
/// adopted as door and vault (refused when one is missing, or for a Gunnar role), and its quote's
/// sizes are read off its front and its primary.
#[test]
fn an_adopted_estate_is_the_roles_its_lifecycle_names() {
    use monetize_cloud::lay::{LaidDisk, LaidMachine};
    let m = |role: &str, server: &str, plan: &str| LaidMachine {
        role: role.into(),
        hostname: String::new(),
        title: String::new(),
        plan: plan.into(),
        server: server.into(),
        public_ipv4: String::new(),
        utility_ipv4: String::new(),
        disks: vec![],
        medium_clone: String::new(),
    };
    assert_eq!(pair().roles(), ["door", "vault"]);
    assert_eq!(pair().primary_role(), "vault");
    let mut vault = m("vault", "v", "4xCPU");
    vault.disks = vec![
        LaidDisk {
            title: "sys".into(),
            id: "d0".into(),
            gib: 20,
            tier: "maxiops".into(),
            address: "virtio:0".into(),
        },
        LaidDisk {
            title: "data".into(),
            id: "d1".into(),
            gib: 70,
            tier: "maxiops".into(),
            address: "virtio:1".into(),
        },
    ];
    let ok = vec![m("door", "d", "1xCPU"), vault];
    assert_eq!(adopt::machines_refusal(&pair(), &ok), None);
    let why = adopt::machines_refusal(&pair(), &ok[..1]).unwrap();
    assert!(
        why.contains("no vault named: an adopted estate is door and vault"),
        "{why}"
    );
    let why = adopt::machines_refusal(&pair(), &[ok[0].clone(), ok[1].clone(), m("twin", "t", "")])
        .unwrap();
    assert!(
        why.contains("\"twin\" is no role of an estate (door, vault)"),
        "{why}"
    );
    let q = adopt::quote_of(&pair(), &ok, monetize_pay::Currency::Eur);
    assert_eq!(
        (
            q.sizes.front_plan.as_str(),
            q.sizes.main_plan.as_str(),
            q.sizes.main_data_gib,
            q.twin_plan.as_str()
        ),
        ("1xCPU", "4xCPU", 70, "")
    );
    assert_eq!(
        lifecycle::listed(&["a".into(), "b".into(), "c".into()], "or"),
        "a, b or c"
    );
}

/// ★ **A Holger estate's firewall is laid and written for all three boxes, as Gunnar's** (lane
/// E1, 2026-10-19): the laid machines are exactly the roles the cloud writes an estate's firewall
/// for (`monetize_cloud::lay::ROLES`: the front's relay rule names main's and the twin's utility
/// addresses, theirs the front's), each laid with a utility address the rules can name; and an
/// update writes the Holger estate's firewall again before its first step, as it does Gunnar's.
/// No rule is Holger's own: once every Holger estate link rides the WireGuard relay, the relay's
/// rules are the whole box-to-box surface.
#[test]
fn a_holger_estate_has_its_firewall_written_for_front_main_and_twin_as_gunnars() {
    let r = delivered("holger-firewall", holger_release("02"));
    let laid = r.state.estates.get("acme").unwrap().laid.expect("laid");
    assert_eq!(
        laid.machines
            .iter()
            .map(|m| m.role.as_str())
            .collect::<Vec<_>>(),
        monetize_cloud::lay::ROLES,
        "the firewall's roles, every one laid"
    );
    for m in &laid.machines {
        assert!(
            m.utility_ipv4.parse::<std::net::Ipv4Addr>().is_ok(),
            "{} has a utility address the relay rules name: {:?}",
            m.role,
            m.utility_ipv4
        );
    }
    let g = delivered("gunnar-firewall", release_cfg("02", true));
    let gl = g.state.estates.get("acme").unwrap().laid.expect("laid");
    assert_eq!(
        gl.machines
            .iter()
            .map(|m| m.role.as_str())
            .collect::<Vec<_>>(),
        laid.machines
            .iter()
            .map(|m| m.role.as_str())
            .collect::<Vec<_>>(),
        "Holger's roles are Gunnar's"
    );
    laid_older(&r);
    update::start(&r.state, "acme", "", "operator").expect("opened");
    assert_eq!(
        *r.cloud.firewalled.lock().unwrap(),
        vec![laid.reference.clone()],
        "the Holger estate's firewall written again before the first step"
    );
}

/// ★ **A front opens only the ports its product declares, laid and on every update** (lane
/// OX-WGDS5, 2026-10-29): every front opened udp/51830 (monetize-impl 19b4917), Gunnar's too. Now
/// the product says it in its role facts: Holger's front declares udp/51830 (its downstream
/// interface), Gunnar's nothing. The lay carries the shape with it; an estate laid before the
/// declaration (its record has no shape) gets it from the update's firewall write, the normal
/// update path, never a manual step. Written again, nothing changes: the same declaration.
#[test]
fn a_front_opens_only_its_products_declared_ports_laid_and_on_every_update() {
    use crate::testing::lifecycles::HOLGER_DOWNSTREAM_UDP;
    let front_opens = |shape: &Option<monetize_cloud::lay::PlanShape>| {
        shape
            .as_ref()
            .map(|s| {
                s.open_of("front")
                    .iter()
                    .map(|p| (p.protocol.clone(), p.port))
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default()
    };
    let wg = vec![("udp".to_string(), HOLGER_DOWNSTREAM_UDP)];
    let r = delivered("holger-open-ports", holger_release("02"));
    let g = delivered("gunnar-open-ports", release_cfg("02", true));
    let laid_shape = |r: &Rig| {
        r.cloud
            .shapes
            .lock()
            .unwrap()
            .first()
            .map(|(_, s)| s.clone())
            .expect("the lay's shape")
    };
    assert_eq!(
        front_opens(&laid_shape(&r)),
        wg,
        "Holger's front is laid opening its downstream port"
    );
    assert!(
        laid_shape(&r)
            .as_ref()
            .is_some_and(|s| s.open_of("main").is_empty() && s.open_of("twin").is_empty()),
        "main and the twin open nothing more"
    );
    assert_eq!(
        front_opens(&laid_shape(&g)),
        Vec::<(String, u16)>::new(),
        "Gunnar's front declares nothing extra"
    );
    // Laid before the declaration: the record keeps no shape. The update writes it as the product says now.
    for rig in [&r, &g] {
        let cfg = rig.state.estates.cfg.clone().unwrap();
        {
            let _pen = rig.state.estates.pen_for_a_call(&cfg, "acme").unwrap();
            let mut run = rig.state.estates.get("acme").unwrap();
            run.laid.as_mut().unwrap().shape = None;
            save(&rig.state, &run).unwrap();
        }
        laid_older(rig);
        rig.cloud.shapes.lock().unwrap().clear();
    }
    update::start(&r.state, "acme", "", "operator").expect("opened");
    update::start(&g.state, "acme", "", "operator").expect("opened");
    let written = |r: &Rig| {
        r.cloud
            .shapes
            .lock()
            .unwrap()
            .iter()
            .map(|(_, s)| front_opens(s))
            .collect::<Vec<_>>()
    };
    assert_eq!(
        written(&r),
        vec![wg.clone()],
        "the update writes Holger's front opening udp/{HOLGER_DOWNSTREAM_UDP}"
    );
    assert_eq!(
        written(&g),
        vec![Vec::<(String, u16)>::new()],
        "and Gunnar's front opening nothing extra"
    );
}