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