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

nordisk/gunnar

crates/gunnar-monetize-guest/src/lifecycle.rs

//! **Gunnar's estate lifecycle, from the guest** (owner 2026-10-17): what a sold Gunnar IS —
//! a front (caddy, gunnar-ui, the relay), main (the forge) and a twin (the log-holder) — served
//! to the ONE monetize through `lifecycle.facts` (op 90) and `lifecycle.lay_plan` (op 91), so the
//! shop's core holds no Gunnar rule of its own.
//!
//! These are the rules monetize-server's native `estate_order::lifecycle::Gunnar` has, byte for
//! byte (`tests/lifecycle_parity.rs` asserts it against the server itself): updated twin, main,
//! front; main and twin keep-booted from `gunnar-keep` by the shop, the front from `front-keep`;
//! the front laid from the `front` media, main and twin from `gunnar`; main's disks kept at a
//! terminate; the twin follows main (its plan chosen from main's, its data disk holding main's
//! whole data set).

use std::collections::BTreeMap;

use monetize_cloud::lay::{
    default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan, NetInterface,
};
use monetize_cloud::Zone;
use monetize_product_guest::facts::{
    Billing, Facts, FirstDoorProof, GrowRule, ProofAsk, ProofCheck, ProofExpect, ProofMethod,
    RoleFacts, SaleProof, TwinFacts,
};
use monetize_product_guest::wire::{LayPlanReq, WireDataRole, WireWords};

/// The product kind.
pub const KIND: &str = "gunnar";

/// Pure: the medium kind a machine of `role` is laid, seeded and released from.
pub fn medium_kind(role: &str) -> &'static str {
    if role == "front" {
        "front"
    } else {
        "gunnar"
    }
}

/// Pure: **the facts** — the update plan, each role's medium, what a terminate keeps, and what
/// the shop's pairing speaks for a sold Gunnar (its contract, its sale proof; v2.0 P3: the ONE
/// box's core holds no Gunnar contract of its own).
pub fn facts() -> Facts {
    let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
    Facts {
        kind: KIND.into(),
        update_order: s(&["twin", "main", "front"]),
        app_medium: "gunnar".into(),
        front_medium: "front".into(),
        front_role: "front".into(),
        keep_medium: "gunnar-keep".into(),
        front_keep_medium: "front-keep".into(),
        upgraded_by_shop: s(&["main", "twin"]),
        // Gunnar takes a release live into its other app slot (as monetize's native lifecycle says).
        live_slot: true,
        medium_of: BTreeMap::from([("front".to_string(), "front".to_string())]),
        other_medium: "gunnar".into(),
        backup_roles: s(&["main"]),
        words: WireWords {
            name: "gunnar".into(),
            title: "Gunnar".into(),
            account_home: "gunnar.rs".into(),
        },
        default_shape: carried(default_shape()),
        data_roles: vec![
            WireDataRole {
                role: "main".into(),
                mirrors_primary: false,
                set_suffix: String::new(),
            },
            WireDataRole {
                role: "twin".into(),
                mirrors_primary: true,
                set_suffix: "-twin".into(),
            },
        ],
        domain_proof_name: "gunnar-proof".into(),
        // Gunnar's main mints the actor tickets; the ONE box checks them on the host under the
        // appliance key it holds (op 40, merged; whether a key is pinned is the host's business).
        tickets: true,
        test_actions: crate::test_actions(),
        // Gunnar's estate guardian (op 60's guardian lane, merged).
        guardian: true,
        sold_variant: Some("gunnar-twin-monetize".into()),
        site_variant: Some("gunnar-twin-one".into()),
        box_kind: Some(KIND.into()),
        free_feed: Some("plugin.gunnar.free".into()),
        welcome: Some(true),
        welcome_readme: Some(WELCOME_README.into()),
        // The console's and the guest's own principals on the forge: never a buyer's account.
        reserved_accounts: vec!["gunnar-ui".into(), "gunnar-guest".into()],
        // The sold console serves its own pay page (`/billing/{tenant}`).
        billing: Some(Billing::Console),
        // gunnar-front `console::FEED_ADMIN_TOKEN`: the front alone holds the console's bearer.
        console_token_feed: Some("console_admin_token".into()),
        // Gunnar's off-site copy is the operator's pull of the front's `/backup/stream`.
        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 and
        // the public site's switch again and refuses a refeed with any other key,
        // `public_repositories` among them, so the ONE box feeds a Gunnar site's boxes none
        // (OX-WIRED 2026-10-28).
        public_repositories: Some(false),
        // GRS-PUBLIC (live bug 2026-10-28): a Gunnar main takes the site's public-site switch in
        // a refeed (`console_public_site`) and stores it as its console's switch, so a site laid
        // before its `public_site` declaration (gunnar.rs) is told by `site refeed`.
        public_site: Some(true),
        proof: Some(sale_proof()),
        // ── What a Gunnar estate IS, as data (monetize v2.0 P3-FACTS, A24 §2.2): the core
        // decides none of it by Gunnar's shape any more.
        // Main holds the tenants: the ticket key, the backups, the report the shop meters by.
        primary_role: "main".into(),
        roles: ["front", "main", "twin"]
            .iter()
            .map(|r| RoleFacts {
                role: r.to_string(),
                ..RoleFacts::default()
            })
            .collect(),
        // The twin mirrors main and is grown by its load (the election: the weakest of two).
        twin: Some(TwinFacts {
            role: "twin".into(),
            mirrors: "main".into(),
        }),
        // Main's data never shrinks; a plan change resizes the three machines, adds none.
        grow: Some(GrowRule::sizes()),
        ..Facts::default()
    }
}

/// **The buyer's welcome README** the primary makes `<account>/welcome` from (the shop fills in
/// `{host}`, `{account}` and `{repo}`): thanks, the login, the clone line, a passkey, inviting
/// people (no-dwarf step 7, the owner 2026-10-08).
pub const 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";

/// gunnar-ui-server's public routes the sale check reads (its router; gunnar-front's
/// `tests/shop_pairing_contract.rs` holds them to it).
pub mod doors {
    pub const LOGIN_PATH: &str = "/login";
    /// A word only the console's login page carries: its passkey sign-in.
    pub const LOGIN_PAGE_MARK: &str = "/auth/passkey/login/start";
    pub const AUTH_STATUS_PATH: &str = "/auth/status";
    pub const REGISTER_START_PATH: &str = "/auth/passkey/register/start";
    /// The repository list, read with the console's break-glass bearer.
    pub const REPOS_PATH: &str = "/api/repos";
    /// The most repositories one page lists (`?limit=`).
    pub const REPOS_MAX: usize = 2000;
    /// The buyer's first repository, made by the primary from the welcome README.
    pub const WELCOME_REPO: &str = "welcome";
}

/// Pure: **what proves a sale of Gunnar** (S4, A28 H12), checked over the sold console's PUBLIC
/// `https://<host>` before the money is captured: the login page answers (its passkey sign-in on
/// it); the first-login door offers passkeys and is unspent, and the ready mail's own link
/// (`/auth/passkey/register/start?invite=…&user=<account>`) gets a challenge (nothing is spent:
/// the invite is redeemed only at `finish`); and the console lists, to its bearer, exactly ONE
/// repository named `welcome`, the buyer's.
pub fn sale_proof() -> SaleProof {
    use doors::*;
    let get = |path: String, bearer: bool| ProofAsk {
        method: ProofMethod::Get,
        path,
        body: None,
        bearer,
    };
    let is = |field: &str, value: bool| ProofExpect::Equals {
        field: field.into(),
        value: serde_json::Value::Bool(value),
    };
    let start = get(
        format!("{REGISTER_START_PATH}?invite={{invite}}&user={{account}}"),
        false,
    );
    let challenge = "/publicKey/challenge";
    SaleProof {
        checks: vec![
            ProofCheck {
                what: "the login page".into(),
                ask: get(LOGIN_PATH.into(), false),
                expect: vec![ProofExpect::Contains {
                    text: LOGIN_PAGE_MARK.into(),
                }],
            },
            ProofCheck {
                what: "the first-login door".into(),
                ask: get(AUTH_STATUS_PATH.into(), false),
                expect: vec![is("/passkey_enabled", true), is("/bootstrap_spent", false)],
            },
            ProofCheck {
                what: "the first-login door".into(),
                ask: start.clone(),
                expect: vec![ProofExpect::Text {
                    field: challenge.into(),
                }],
            },
            ProofCheck {
                what: "the welcome repository".into(),
                ask: get(format!("{REPOS_PATH}?limit={REPOS_MAX}"), true),
                expect: vec![
                    is("/truncated", false),
                    ProofExpect::ExactlyOne {
                        list: "/items".into(),
                        name_field: "name".into(),
                        name: WELCOME_REPO.into(),
                        id_field: "path".into(),
                        id: format!("{{account}}/{WELCOME_REPO}"),
                    },
                ],
            },
        ],
        needs_account: true,
        first_door: Some(FirstDoorProof {
            status: get(AUTH_STATUS_PATH.into(), false),
            spent: "/bootstrap_spent".into(),
            enabled: "/passkey_enabled".into(),
            start,
            challenge: challenge.into(),
        }),
    }
}

/// Pure: **what the buyer's mails say of Gunnar** beyond its name (monetize's `MailWords`): git
/// is at `git.{rp}`, the first repository is `{account}/welcome`, no pay line. `{rp}` is the
/// sign-in host, `{account}` the buyer's account. The ONE box links no Gunnar rule, so the guest
/// says them itself (monetize v2.0 P2 12.1, A28 H5).
pub fn mail_words() -> serde_json::Value {
    serde_json::json!({
        "noun": "",
        "serves": "git is at git.{rp}.",
        "own_domain_serves": "git and the web",
        "first_step": "Your first repository, {account}/welcome, says how to start.",
        "pay_line": "",
    })
}

/// The names an estate of Gunnar serves under its host: the host, `www.` and `git.` (A17).
pub const NAMES: [&str; 3] = ["", "www.", "git."];

/// **The served names its front serves on :443 as a redirect only** (HOL-REPO-NAME, kit parity
/// h-front-sans): the forge is ssh (`ssh://git@git.<site>:2222`), so `git.<site>` is on the
/// front's certificate (https there verifies) and every path is sent to the site; the shop's
/// pairing feeds it as `web_callback_only`. Rides in the shape as `names` does.
pub const REDIRECT_NAMES: [&str; 1] = ["git."];

/// Pure: **the shape with what rides in it** — the mail words (`mail_words`), the served
/// names (`names`) and the redirect-only ones (`redirect_names`): the facts wire has no field for either, and a reader of the shape ignores a
/// key it does not name (as Holger's ladder rides).
pub fn carried(mut shape: serde_json::Value) -> serde_json::Value {
    if let Some(o) = shape.as_object_mut() {
        o.insert("mail_words".into(), mail_words());
        o.insert("names".into(), serde_json::json!(NAMES));
        o.insert("redirect_names".into(), serde_json::json!(REDIRECT_NAMES));
    }
    shape
}

/// Pure: **the shape an order that names no size is laid at: Mini** (owner, 2026-10-13) — a
/// 2-core 4 GB front, a 4-core 8 GB main, a 1-core 4 GB twin; the kit's own declaration
/// (`private-gunnar-ops/terraform`), templates over `{company}`.
pub fn default_shape() -> serde_json::Value {
    let d = |title: &str, gib: u64, tier: &str, volume: &str| serde_json::json!({ "title": title, "gib": gib, "tier": tier, "volume": volume });
    let ifs = serde_json::to_value(default_interfaces()).unwrap_or_default();
    let m = |hostname: &str,
             title: &str,
             plan: &str,
             role_label: &str,
             disks: Vec<serde_json::Value>| {
        serde_json::json!({ "hostname": hostname, "title": title, "plan": plan, "boot_order": "disk,cdrom", "role_label": role_label, "disks": disks, "interfaces": ifs })
    };
    serde_json::json!({
        "front": m("gunnar-front-{company}", "gunnar front — caddy, gunnar-ui, the relay-{company}", "STARTER-2xCPU-4GB", "front", vec![d("gunnar-front-{company} system", 40, "standard", "system")]),
        "main": m("gunnar-appliance-{company}", "gunnar appliance — the forge, no shell-{company}", "STARTER-4xCPU-8GB", "appliance", vec![
            d("gunnar-appliance-{company} system", 50, "standard", "system"),
            d("gunnar-appliance-{company} data 1", 10, "maxiops", "data1"),
            d("gunnar-appliance-{company} data 2", 10, "maxiops", "data2"),
            d("gunnar-appliance-{company} data 3", 10, "maxiops", "data3"),
            d("gunnar-appliance-{company} data 4", 10, "maxiops", "data4"),
        ]),
        "twin": m("gunnar-twin-{company}", "gunnar twin — the log-holder, no shell-{company}", "STARTER-1xCPU-4GB", "twin", vec![
            d("gunnar-twin-{company} system", 20, "standard", "system"),
            d("gunnar-twin-{company} data", 20, "hdd", "twin"),
        ]),
    })
}

#[derive(serde::Deserialize)]
struct Shape {
    front: MachineShape,
    main: MachineShape,
    twin: MachineShape,
}

#[derive(Clone, serde::Deserialize)]
struct MachineShape {
    hostname: String,
    title: String,
    plan: String,
    #[serde(default = "disk_cdrom")]
    boot_order: String,
    role_label: String,
    disks: Vec<DiskShape>,
    #[serde(default = "default_interfaces")]
    interfaces: Vec<NetInterface>,
}

#[derive(Clone, serde::Deserialize)]
struct DiskShape {
    title: String,
    gib: u64,
    tier: String,
    volume: String,
}

#[derive(Default, serde::Deserialize)]
struct Sizes {
    #[serde(default)]
    front_plan: String,
    #[serde(default)]
    main_plan: String,
    #[serde(default)]
    main_data_gib: u64,
}

fn disk_cdrom() -> String {
    "disk,cdrom".into()
}

/// Pure: **the plan an estate is laid as** — 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, a system disk cloned from a template in its tier.
pub fn lay_plan(req: &LayPlanReq) -> Result<EstatePlan, String> {
    let shape: Shape = serde_json::from_value(req.config.get("shape").cloned().unwrap_or_default())
        .map_err(|e| format!("the config's shape: {e}"))?;
    let labels: BTreeMap<String, String> = serde_json::from_value(
        req.config
            .get("labels")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("the config's labels: {e}"))?;
    let zone = req
        .config
        .get("zone")
        .and_then(|z| z.as_str())
        .unwrap_or_default()
        .to_string();
    let sizes: Sizes = if req.sizes.is_null() {
        Sizes::default()
    } else {
        serde_json::from_value(req.sizes.clone()).map_err(|e| format!("the sizes: {e}"))?
    };
    let company = req.company.as_str();
    let fill = |t: &str| t.replace("{company}", company);
    let or = |asked: &str, shape: &str| {
        if asked.trim().is_empty() {
            shape.to_string()
        } else {
            asked.trim().to_string()
        }
    };
    let mut front = shape.front.clone();
    front.plan = or(&sizes.front_plan, &front.plan);
    let mut main = shape.main.clone();
    main.plan = or(&sizes.main_plan, &main.plan);
    if sizes.main_data_gib > 0 {
        main.disks
            .iter_mut()
            .skip(1)
            .for_each(|d| d.gib = sizes.main_data_gib);
    }
    let mut twin = shape.twin.clone();
    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(), m.role_label.clone());
        MachinePlan {
            role: role.into(),
            hostname: fill(&m.hostname),
            title: fill(&m.title),
            plan: m.plan.clone(),
            boot_order: m.boot_order.clone(),
            labels: l.clone(),
            interfaces: m.interfaces.clone(),
            medium: String::new(),
            medium_object: None,
            template: String::new(),
            user_data: Default::default(),
            disks: m
                .disks
                .iter()
                .map(|d| {
                    let mut dl = l.clone();
                    dl.insert("volume".into(), d.volume.clone());
                    let tier = match req.template_tiers.get(medium_kind(role)) {
                        Some(t) if d.volume == "system" => t.clone(),
                        _ => d.tier.clone(),
                    };
                    DiskPlan {
                        title: fill(&d.title),
                        gib: d.gib,
                        tier,
                        labels: dl,
                    }
                })
                .collect(),
        }
    };
    Ok(EstatePlan {
        reference: reference_of(company),
        company: company.into(),
        zone: Zone(zone),
        machines: vec![
            machine("front", &front),
            machine("main", &main),
            machine("twin", &twin),
        ],
        narrate_from: String::new(),
        shape: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **The facts carry Gunnar's mail words and names** (monetize v2.0 P2 12.1, A28 H5): the
    /// ONE box reads them off the shape with no native Gunnar under them; the shape itself is
    /// unchanged.
    #[test]
    fn the_shape_carries_gunnars_mail_words_and_names() {
        let s = facts().default_shape;
        assert_eq!(s["mail_words"]["serves"], "git is at git.{rp}.");
        assert_eq!(
            s["mail_words"]["first_step"],
            "Your first repository, {account}/welcome, says how to start."
        );
        assert_eq!(
            (
                s["mail_words"]["noun"].as_str(),
                s["mail_words"]["pay_line"].as_str()
            ),
            (Some(""), Some(""))
        );
        assert_eq!(s["names"], serde_json::json!(["", "www.", "git."]));
        // HOL-REPO-NAME (kit parity, h-front-sans): the forge is ssh, so `git.<site>` is on the
        // front's certificate as a redirect-only name (the shop feeds it `web_callback_only`).
        assert_eq!(s["redirect_names"], serde_json::json!(["git."]));
        let mut bare = s.clone();
        bare.as_object_mut()
            .unwrap()
            .retain(|k, _| k != "mail_words" && k != "names" && k != "redirect_names");
        assert_eq!(bare, default_shape(), "the shape itself is the one it was");
    }

    /// ★ **What ops 40, 54 and 60 answered rides in the facts** (monetize v2.0 P2 T13): Gunnar's
    /// main mints tickets, its two test buttons, its guardian.
    #[test]
    fn the_facts_carry_the_tickets_the_test_buttons_and_the_guardian() {
        let f = facts();
        assert!(f.tickets && f.guardian);
        assert_eq!(
            f.test_actions
                .iter()
                .map(|t| t.id.as_str())
                .collect::<Vec<_>>(),
            ["hosted-10gb", "private-small"]
        );
    }

    /// ★ **A Gunnar box takes no `public_repositories`, and the facts say so** (OX-WIRED
    /// 2026-10-28): its refeed takes the mail relay's keys and the public site's switch alone and refuses any other key whole
    /// (gunnar-repair `order::kind::REFEED_KEYS`), so the ONE box must feed a Gunnar site's boxes
    /// none, at lay or in a refeed. It reads that off these facts (monetize `Facts::public_repositories`).
    #[test]
    fn the_facts_say_a_gunnar_box_takes_no_public_repositories_as_its_refeed_refuses_them() {
        assert!(
            !gunnar_repair::order::kind::REFEED_KEYS.contains(&"public_repositories"),
            "a Gunnar box's refeed keys: {:?}",
            gunnar_repair::order::kind::REFEED_KEYS
        );
        let f = facts();
        assert_eq!(f.public_repositories, Some(false), "the facts say it");
        assert!(!f.takes_public_repositories());
    }

    /// ★ **GRS-PUBLIC (live bug 2026-10-28): a Gunnar main takes the site's public-site switch
    /// in a refeed, and the facts say so.** gunnar.rs was laid before its `public_site`
    /// declaration and its console was never told (https://gunnar.rs/ asked every reader to log
    /// in): the ONE box feeds the declaration again as `console_public_site` (monetize
    /// `site refeed --only public_site`) only to a product whose facts say it takes the key, and
    /// a Gunnar main stores it as its console's switch (gunnar-repair `order::PUBLIC_SITE_KEY`).
    #[test]
    fn the_facts_say_a_gunnar_main_takes_the_public_site_switch_as_its_refeed_does() {
        assert!(
            gunnar_repair::order::kind::REFEED_KEYS
                .contains(&gunnar_repair::order::PUBLIC_SITE_KEY),
            "{:?}",
            gunnar_repair::order::kind::REFEED_KEYS
        );
        // monetize-embed `fleet::refeed::PUBLIC_SITE`, the word the ONE box sends.
        assert_eq!(
            gunnar_repair::order::PUBLIC_SITE_KEY,
            "console_public_site",
            "the ONE box's word and Gunnar's are one"
        );
        let f = facts();
        assert_eq!(f.public_site, Some(true), "the facts say it");
        assert!(f.takes_public_site());
    }
}