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