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