crates/monetize-server/src/testing/lifecycles.rs
//! **Gunnar- and Holger-shaped TEST lifecycles** (monetize v2.0 P2 step 2, item 12.0; A28 H6).
//!
//! The estate_order suite and the tests around it sell a `gunnar` (and a `holger`) and used to
//! reach the NATIVE lifecycles through [`lifecycle::find`]'s fall-back. The natives go in P2
//! (A25 2.3): the product's own guest brings its lifecycle on the ONE box. The tests keep the
//! rules they were written against here instead, registered for the two kinds the way a
//! guest's lifecycle is registered ([`install`]):
//!
//! ```text
//! a test's rig ──▶ install() ──▶ lifecycle::register("gunnar", gunnar())
//! lifecycle::register("holger", holger())
//! find("gunnar") ──▶ the TEST lifecycle (never the native fall-back)
//! ```
//!
//! The facts are the Gunnar and Holger rules word for word, as the natives had them until they
//! went (T12 12.4; pinned to them fact for fact while both existed). The signed guests send the
//! same: `gunnar-monetize-guest/tests/lifecycle_parity.rs` and monetize-server's
//! `tests/the_signed_holger_guest_lays_holgers_three_boxes.rs` hold the guests to them. Nothing
//! here is linked into a shipped path: only tests call [`install`].
use std::sync::{Arc, OnceLock};
use monetize_cloud::lay::EstatePlan;
use crate::estate_order::lifecycle::{self, Lifecycle, MailWords, ProductWords};
use crate::estate_order::{self as eo, DiskShape, EstateOrderConfig, EstateSizes, LadderStep, MachineShape, Shape};
use crate::facts::{Billing, Facts, FirstDoorProof, ProofAsk, ProofCheck, ProofExpect, ProofMethod, SaleProof};
use monetize_product_guest::wire::{WireDataRole, WireWords};
/// The medium kind a sold Gunnar box is upgraded IN PLACE from (F4): Gunnar's release medium
/// composed `install_then=poweroff`, as Gunnar's guest names it.
pub const GUNNAR_KEEP_MEDIUM: &str = "gunnar-keep";
fn strings(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
fn disk(title: &str, gib: u64, tier: &str, volume: &str) -> DiskShape {
DiskShape { title: title.into(), gib, tier: tier.into(), volume: volume.into() }
}
/// **Gunnar's estate as the kit declares it**, at Mini: a 2-core 4 GB front, a 4-core 8 GB main
/// with four 10 GB data disks, a 1-core 4 GB twin.
pub fn gunnar_shape() -> Shape {
let boot = || "disk,cdrom".to_string();
Shape {
front: MachineShape {
hostname: "gunnar-front-{company}".into(),
title: "gunnar front — caddy, gunnar-ui, the relay-{company}".into(),
plan: "STARTER-2xCPU-4GB".into(),
boot_order: boot(),
role_label: "front".into(),
disks: vec![disk("gunnar-front-{company} system", 40, "standard", "system")],
interfaces: monetize_cloud::lay::default_interfaces(),
},
main: MachineShape {
hostname: "gunnar-appliance-{company}".into(),
title: "gunnar appliance — the forge, no shell-{company}".into(),
plan: "STARTER-4xCPU-8GB".into(),
boot_order: boot(),
role_label: "appliance".into(),
disks: vec![
disk("gunnar-appliance-{company} system", 50, "standard", "system"),
disk("gunnar-appliance-{company} data 1", 10, "maxiops", "data1"),
disk("gunnar-appliance-{company} data 2", 10, "maxiops", "data2"),
disk("gunnar-appliance-{company} data 3", 10, "maxiops", "data3"),
disk("gunnar-appliance-{company} data 4", 10, "maxiops", "data4"),
],
interfaces: monetize_cloud::lay::default_interfaces(),
},
twin: MachineShape {
hostname: "gunnar-twin-{company}".into(),
title: "gunnar twin — the log-holder, no shell-{company}".into(),
plan: "STARTER-1xCPU-4GB".into(),
boot_order: boot(),
role_label: "twin".into(),
disks: vec![disk("gunnar-twin-{company} system", 20, "standard", "system"), disk("gunnar-twin-{company} data", 20, "hdd", "twin")],
interfaces: monetize_cloud::lay::default_interfaces(),
},
twin_floor_mib: None,
}
}
/// The lines Gunnar's mails say: git, the welcome repository, no pay line.
pub fn gunnar_mail_words() -> MailWords {
MailWords {
noun: String::new(),
serves: "git is at git.{rp}.".into(),
own_domain_serves: "git and the web".into(),
first_step: "Your first repository, {account}/welcome, says how to start.".into(),
pay_line: String::new(),
}
}
/// Gunnar's words: `gunnar`, `Gunnar`, its accounts on `gunnar.rs`.
pub fn gunnar_words() -> ProductWords {
ProductWords { name: "gunnar".into(), title: "Gunnar".into(), account_home: "gunnar.rs".into() }
}
/// Main is the primary; the twin's one data disk holds main's whole set (`<product>-twin`).
fn data_roles() -> Vec<WireDataRole> {
vec![
WireDataRole { role: "main".into(), mirrors_primary: false, set_suffix: String::new() },
WireDataRole { role: "twin".into(), mirrors_primary: true, set_suffix: "-twin".into() },
]
}
/// The machines of a Gunnar- or Holger-shaped test estate: `shape` with each role's medium kind
/// (MONETIZE-FIX step 3: the rule the native lifecycles had, now the test's plan).
fn native_plan(life: &Lifecycle, cfg: &EstateOrderConfig, company: &str, sizes: &EstateSizes, twin_plan: &str) -> Result<EstatePlan, String> {
Ok(eo::plan_for_as(cfg, company, sizes, twin_plan, life))
}
/// `shape` as a guest's `default_shape` carries it, with `riders` beside its machines.
fn shape_with(shape: &Shape, riders: serde_json::Value) -> serde_json::Value {
let mut v = serde_json::to_value(shape).expect("a shape is JSON");
if let (Some(o), Some(r)) = (v.as_object_mut(), riders.as_object()) {
o.extend(r.clone());
}
v
}
/// The medium kinds of a Gunnar-shaped estate: `front` for the front, `app` for the rest.
fn media(front: &str, app: &str) -> std::collections::BTreeMap<String, String> {
[("front".to_string(), front.to_string()), ("main".to_string(), app.to_string()), ("twin".to_string(), app.to_string())].into()
}
/// **What Holger's buyer reads of it**: an artifact repository, served from its console's
/// host, and the pay page its next period is paid at.
pub fn holger_mail_words() -> MailWords {
MailWords {
noun: "an artifact repository".into(),
serves: "Your artifact repository is served from https://{rp}.".into(),
own_domain_serves: "your artifact repository and the web".into(),
first_step: "Your passkey makes {account} its first admin: you set up its repositories, approve what is uploaded, and invite the people who upload.".into(),
pay_line: "Pay for its next period at {pay_page}.".into(),
}
}
/// **Holger's own size ladder**, Mini to Gigantic: more main and more data on main's one data
/// disk each step; the front grows twice.
pub fn holger_ladder() -> Vec<LadderStep> {
vec![
LadderStep::new("Mini", "STARTER-1xCPU-1GB", "STARTER-1xCPU-2GB", 20),
LadderStep::new("Small", "STARTER-1xCPU-2GB", "STARTER-4xCPU-8GB", 120),
LadderStep::new("Medium", "STARTER-2xCPU-4GB", "8xCPU-32GB", 240),
LadderStep::new("Large", "STARTER-2xCPU-4GB", "12xCPU-48GB", 480),
LadderStep::new("Huge", "STARTER-4xCPU-8GB", "16xCPU-64GB", 1000),
LadderStep::new("Gigantic", "STARTER-4xCPU-8GB", "20xCPU-96GB", 2000),
]
}
/// Holger's twin floor: holger-server in twin mode runs in a 1 GB box.
pub const HOLGER_TWIN_FLOOR_MIB: u64 = 1024;
/// **Holger's lean estate**: a 1-core 1 GB front holds the one public IPv4; a 1-core 2 GB main
/// (one 20 GB data disk) and a 1-core 1 GB twin sit behind it on a utility address.
pub fn holger_shape() -> Shape {
let boot = || "disk,cdrom".to_string();
Shape {
front: MachineShape {
hostname: "holger-web-ui-{company}".into(),
title: "holger-web-ui — Holger's front: the console, the relay-{company}".into(),
plan: "STARTER-1xCPU-1GB".into(),
boot_order: boot(),
role_label: "front".into(),
disks: vec![disk("holger-web-ui-{company} system", 10, "hdd", "system")],
interfaces: monetize_cloud::lay::default_interfaces(),
},
main: MachineShape {
hostname: "holger-server-{company}".into(),
title: "holger-server — the primary, the repositories-{company}".into(),
plan: "STARTER-1xCPU-2GB".into(),
boot_order: boot(),
role_label: "primary".into(),
disks: vec![disk("holger-server-{company} system", 10, "hdd", "system"), disk("holger-server-{company} data 1", 20, "hdd", "data1")],
interfaces: monetize_cloud::lay::behind_the_front_interfaces(),
},
twin: MachineShape {
hostname: "holger-twin-{company}".into(),
title: "holger-server in twin mode — follows the primary-{company}".into(),
plan: "STARTER-1xCPU-1GB".into(),
boot_order: boot(),
role_label: "twin".into(),
disks: vec![disk("holger-twin-{company} system", 10, "hdd", "system"), disk("holger-twin-{company} data", 20, "hdd", "twin")],
interfaces: monetize_cloud::lay::behind_the_front_interfaces(),
},
twin_floor_mib: Some(HOLGER_TWIN_FLOOR_MIB),
}
}
/// The port Holger's front opens to anyone for its downstreams (holger `via::HUB_UDP_PORT`).
pub const HOLGER_DOWNSTREAM_UDP: u16 = 51830;
/// **The facts of a test kind** laid as `update` says, every medium `medium`: the rule a test
/// fake's few methods had (MONETIZE-FIX step 3: a lifecycle IS its facts).
pub fn facts_of(kind: &str, update: lifecycle::UpdatePlan, medium: &str) -> Facts {
Facts {
kind: kind.into(),
update_order: update.order,
app_medium: update.app_medium,
front_medium: update.front_medium,
front_role: update.front_role,
keep_medium: update.keep_medium,
front_keep_medium: update.front_keep_medium,
upgraded_by_shop: update.upgraded_by_shop,
live_slot: update.live_slot,
other_medium: medium.into(),
..Facts::default()
}
}
/// **A plan at the config's shape**, each role laid from its medium kind: the lay rule of the
/// Gunnar-shaped test lifecycles and fakes.
pub fn shaped_plan() -> lifecycle::LayPlan {
Arc::new(native_plan)
}
/// **Gunnar's welcome README**, as Gunnar's guest declares it (`{host}`, `{account}`, `{repo}`).
pub const GUNNAR_WELCOME_README: &str = "# Welcome to your own Gunnar\n\n\
Thank you for buying **{host}**. It is yours: the forge, the console and every byte on it.\n\n\
## Start here\n\n\
1. **Sign in** at <https://{host}/login> with the passkey you registered from the link in our mail.\n\
2. **Add a second passkey** (a spare key, or your phone) and an SSH key under *You → Keys & passkeys*.\n\
3. **Clone this repository**:\n\n ```\n git clone ssh://git@git.{host}:2222/{account}/{repo}.git\n ```\n\n\
4. **Invite the people who work with you** under *Account → People*.\n\n\
This repository is yours to keep, rename or delete.\n";
fn get(path: &str, bearer: bool) -> ProofAsk {
ProofAsk { method: ProofMethod::Get, path: path.into(), body: None, bearer }
}
fn is(field: &str, value: bool) -> ProofExpect {
ProofExpect::Equals { field: field.into(), value: serde_json::Value::Bool(value) }
}
/// **A sale proof of the console's shape**: its login page (and the mark only it carries), its
/// first-login door (passkeys offered, unspent, a challenge for the mail's invite) and `repos`.
fn console_proof(login_mark: &str, status: &str, enabled: &str, start: ProofAsk, repos: ProofCheck) -> SaleProof {
let challenge = "/publicKey/challenge";
SaleProof {
checks: vec![
ProofCheck { what: "the login page".into(), ask: get("/login", false), expect: vec![ProofExpect::Contains { text: login_mark.into() }] },
ProofCheck { what: "the first-login door".into(), ask: get(status, false), expect: vec![is(enabled, true), is("/bootstrap_spent", false)] },
ProofCheck { what: "the first-login door".into(), ask: start.clone(), expect: vec![ProofExpect::Text { field: challenge.into() }] },
repos,
],
needs_account: true,
first_door: Some(FirstDoorProof { status: get(status, false), spent: "/bootstrap_spent".into(), enabled: enabled.into(), start, challenge: challenge.into() }),
}
}
/// **Gunnar's sale proof**, as Gunnar's guest declares it: gunnar-ui-server's `/login`,
/// `/auth/status`, the mail's own `/auth/passkey/register/start?invite=…&user=…`, and exactly
/// ONE `<account>/welcome` in `/api/repos` read with the console's bearer.
pub fn gunnar_proof() -> SaleProof {
console_proof(
"/auth/passkey/login/start",
"/auth/status",
"/passkey_enabled",
get("/auth/passkey/register/start?invite={invite}&user={account}", false),
ProofCheck {
what: "the welcome repository".into(),
ask: get("/api/repos?limit=2000", true),
expect: vec![is("/truncated", false), ProofExpect::ExactlyOne { list: "/items".into(), name_field: "name".into(), name: "welcome".into(), id_field: "path".into(), id: "{account}/welcome".into() }],
},
)
}
/// **Holger's sale proof**, as Holger's guest declares it: the thin console's `/login` (loading
/// `/login.js`), Holger's own `/-/auth/passkey/config` and `POST /-/auth/passkey/register/start`,
/// and at least one repository on `/-/releases`, read with the console's bearer.
pub fn holger_proof() -> SaleProof {
console_proof(
"/login.js",
"/-/auth/passkey/config",
"/enabled",
ProofAsk { method: ProofMethod::Post, path: "/-/auth/passkey/register/start".into(), body: Some(serde_json::json!({ "invite": "{invite}", "display": "{account}" })), bearer: false },
ProofCheck { what: "the repository list".into(), ask: get("/-/releases", true), expect: vec![ProofExpect::NotEmpty { list: "/repositories".into(), name_field: "name".into() }] },
)
}
/// **Gunnar's facts**, as Gunnar's guest answers op 90 (the contract and the proof included).
pub fn gunnar_facts() -> Facts {
let w = gunnar_words();
{
Facts {
kind: "gunnar".into(),
update_order: strings(&eo::update::ORDER),
app_medium: "gunnar".into(),
front_medium: "front".into(),
front_role: "front".into(),
keep_medium: GUNNAR_KEEP_MEDIUM.into(),
front_keep_medium: eo::update::FRONT_KEEP_MEDIUM.into(),
upgraded_by_shop: strings(&["main", "twin"]),
live_slot: true,
medium_of: media("front", "gunnar"),
other_medium: "gunnar".into(),
backup_roles: vec!["main".into()],
words: WireWords { name: w.name, title: w.title, account_home: w.account_home },
data_roles: data_roles(),
domain_proof_name: "gunnar-proof".into(),
sold_variant: Some("gunnar-twin-monetize".into()),
site_variant: Some("gunnar-twin-one".into()),
box_kind: Some("gunnar".into()),
free_feed: Some("plugin.gunnar.free".into()),
welcome: Some(true),
welcome_readme: Some(GUNNAR_WELCOME_README.into()),
reserved_accounts: vec!["gunnar-ui".into(), "gunnar-guest".into()],
billing: Some(Billing::Console),
console_token_feed: Some("console_admin_token".into()),
offsite: Some(false),
boxes_hold_console_token: Some(false),
passkey_rp_feed: None,
// gunnar-repair `order::kind::REFEED_KEYS`: a Gunnar box takes the mail relay's keys
// again and refuses a refeed with any other, `public_repositories` among them.
public_repositories: Some(false),
// GRS-PUBLIC: a Gunnar main takes the site's public-site switch in a refeed and
// stores it as its console's switch (gunnar-repair `order::PUBLIC_SITE_KEY`).
public_site: Some(true),
proof: Some(gunnar_proof()),
// HOL-REPO-NAME: Gunnar's guest says its names in its shape, as the riders ride
// (`names`), and that `git.` is served on :443 as a redirect only (its forge is ssh).
default_shape: shape_with(&gunnar_shape(), serde_json::json!({ "names": ["", "www.", "git."], "redirect_names": ["git."], "mail_words": gunnar_mail_words() })),
..Facts::default()
}
}
}
/// **Holger's facts**, as Holger's guest answers op 90.
pub fn holger_facts() -> Facts {
// Each role once, the front opening udp/51830 to anyone, as Holger's guest declares it
// (its downstream interface, holger `via::HUB_UDP_PORT`).
let open = |role: &str| if role == "front" { vec![crate::facts::PortFacts { protocol: "udp".into(), port: HOLGER_DOWNSTREAM_UDP, why: "a WireGuard downstream's handshake".into() }] } else { Vec::new() };
{
Facts {
kind: "holger".into(),
update_order: strings(&eo::update::ORDER),
app_medium: "holger".into(),
front_medium: "holger-front".into(),
front_role: "front".into(),
keep_medium: "holger-keep".into(),
front_keep_medium: format!("holger-{}", eo::update::FRONT_KEEP_MEDIUM),
upgraded_by_shop: strings(&["main", "twin"]),
live_slot: true,
medium_of: media("holger-front", "holger"),
other_medium: "holger".into(),
backup_roles: vec!["main".into()],
words: WireWords { name: "holger".into(), title: "Holger".into(), account_home: String::new() },
data_roles: data_roles(),
domain_proof_name: "holger-proof".into(),
roles: ["front", "main", "twin"].iter().map(|r| crate::facts::RoleFacts { open: open(r), role: r.to_string(), ..Default::default() }).collect(),
sold_variant: Some("holger-twin-monetize".into()),
site_variant: Some("holger-twin-one".into()),
box_kind: Some("holger".into()),
free_feed: Some("plugin.holger.free".into()),
welcome: Some(false),
billing: Some(Billing::SitePayPage),
console_token_feed: Some("holger_console_token".into()),
offsite: Some(true),
boxes_hold_console_token: Some(true),
passkey_rp_feed: Some("passkey_rp_id".into()),
// HOL-PUBSITE-FEED: Holger's main and twin take the site's public-site switch in a
// refeed and keep it as the console's setting (holger-fleet `refeed_key::PUBLIC_SITE`).
public_site: Some(true),
proof: Some(holger_proof()),
// HOL-REPO-NAME: Holger's guest says its names in its shape, and that `repo.` is
// routed to its primary's repository (the front's `repo_hosts`).
default_shape: shape_with(&holger_shape(), serde_json::json!({ "names": ["", "www.", "repo."], "repo_names": ["repo."], "ladder": holger_ladder(), "mail_words": holger_mail_words() })),
// MONETIZE-FIX: Holger's own order kinds, as its guest declares them (a sample).
orders: vec![
crate::facts::OrderKindFacts {
kind: "downstream_enrol".into(),
role: "main".into(),
words: "enrol a downstream".into(),
params: vec![
crate::facts::OrderParamFacts { name: "name".into(), words: "the downstream's name".into(), required: true },
crate::facts::OrderParamFacts { name: "key".into(), words: String::new(), required: false },
],
file: String::new(),
},
crate::facts::OrderKindFacts {
kind: "bundle_put".into(),
role: String::new(),
words: "an air-gap bundle into the repository".into(),
params: vec![crate::facts::OrderParamFacts { name: "bundle".into(), words: "the bundle file".into(), required: true }],
file: "bundle".into(),
},
],
..Facts::default()
}
}
}
/// The one TEST Gunnar lifecycle a test binary registers (so a test can tell it is the one
/// [`lifecycle::find`] answers with).
pub fn gunnar() -> Arc<Lifecycle> {
static ONE: OnceLock<Arc<Lifecycle>> = OnceLock::new();
ONE.get_or_init(|| Arc::new(Lifecycle::new("gunnar", gunnar_facts(), Arc::new(native_plan)))).clone()
}
/// The one TEST Holger lifecycle a test binary registers.
pub fn holger() -> Arc<Lifecycle> {
static ONE: OnceLock<Arc<Lifecycle>> = OnceLock::new();
ONE.get_or_init(|| Arc::new(Lifecycle::new("holger", holger_facts(), Arc::new(native_plan)))).clone()
}
/// **Register the TEST lifecycles** for `gunnar` and `holger` (idempotent; every rig calls it).
/// A kind a guest's lifecycle was registered for by this binary is left to the guest.
pub fn install() {
for (kind, life) in [("gunnar", gunnar()), ("holger", holger())] {
if lifecycle::registered(kind).is_none() {
lifecycle::register(kind, life);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **The rig's lifecycle IS the test one**: once installed, `find` answers the TEST
/// Gunnar and Holger, never a native fall-back.
#[test]
fn find_answers_the_test_lifecycles_once_they_are_installed() {
install();
for (kind, life) in [("gunnar", gunnar()), ("holger", holger())] {
let found = lifecycle::find(kind).expect("registered");
assert!(Arc::ptr_eq(&found, &life), "{kind}: find answered another lifecycle");
}
}
/// The facts the tests are written against, pinned (each a rule the suite reads).
#[test]
fn the_test_lifecycles_carry_gunnars_and_holgers_rules() {
let g = gunnar();
assert_eq!(g.update_plan().order, ["twin", "main", "front"]);
assert_eq!((g.medium_kind("front"), g.medium_kind("main"), g.medium_kind("twin")), ("front".into(), "gunnar".into(), "gunnar".into()));
assert_eq!(g.update_plan().keep_medium, "gunnar-keep");
assert_eq!(g.default_shape().unwrap().main.disks.len(), 5);
assert_eq!(g.served_prefixes(), ["", "www.", "git."]);
assert_eq!(g.mail_words().serves, "git is at git.{rp}.");
let h = holger();
assert_eq!((h.medium_kind("front"), h.medium_kind("main")), ("holger-front".into(), "holger".into()));
assert_eq!((h.update_plan().keep_medium.as_str(), h.update_plan().front_keep_medium.as_str()), ("holger-keep", "holger-front-keep"));
assert_eq!(h.served_prefixes(), ["", "www.", "repo."]);
assert_eq!(h.ladder().unwrap()[0].name, "Mini");
assert_eq!(h.default_shape().unwrap().twin_floor_mib, Some(HOLGER_TWIN_FLOOR_MIB));
}
}