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/estate_order/guest_lifecycle.rs

//! **A product guest's own estate lifecycle** (owner 2026-10-17): a product whose signed guest
//! answers `lifecycle.facts` (op 90) brings the rules its estates are laid, updated and ended
//! by; the core asks its facts and its plan ([`super::lifecycle::Lifecycle`]).
//!
//! ```text
//!   [products.<id>] guest = "<id>.wasm"   ──▶ loaded, signed, its manifest gated (as ever)
//!     lifecycle.facts    → the update plan (live slot or keep-only), each role's medium, the roles a terminate keeps
//!     lifecycle.lay_plan → the machines an estate is laid as (the config's shape, labels,
//!                          zone and each medium's template tier handed in)
//!   ──▶ registered for its kind (`gunnar`): the only lifecycle of that kind
//!   a guest that brings none (an older one, a product with no estates): none
//!   a guest that REFUSES lay_plan: refused by name (no fall-back onto a native lifecycle)
//! ```
//!
//! The registration follows the guest live ([`follow`], on every watcher look and at start): a
//! replaced module's facts are read again, a removed one's lifecycle goes.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use monetize_cloud::lay::EstatePlan;
use monetize_product_guest::facts::Facts;
use monetize_product_guest::wire::LayPlanReq;

use super::lifecycle::{self, Lifecycle};
use super::{EstateOrderConfig, EstateSizes};

/// Pure: **the request a lay sends the guest** — the config's shape, labels and zone, and the
/// tier each of the guest's medium kinds' templates is in (a system disk cloned from a template
/// is laid in the template's tier).
pub fn request(
    facts: &Facts,
    cfg: &EstateOrderConfig,
    company: &str,
    sizes: &EstateSizes,
    twin_plan: &str,
) -> LayPlanReq {
    let mut kinds: Vec<&String> = facts
        .medium_of
        .values()
        .chain(std::iter::once(&facts.other_medium))
        .collect();
    kinds.sort();
    kinds.dedup();
    let template_tiers = kinds
        .into_iter()
        .filter_map(|k| super::template_of(cfg, k).map(|t| (k.clone(), t.tier().to_string())))
        .collect();
    LayPlanReq {
        config: serde_json::json!({ "shape": cfg.shape, "labels": cfg.labels, "zone": cfg.zone }),
        company: company.to_string(),
        sizes: serde_json::to_value(sizes).unwrap_or_default(),
        twin_plan: twin_plan.to_string(),
        template_tiers,
    }
}

/// **The lifecycle a product guest brings**: its facts (op 90), read once per module, and its
/// plan asked of the guest at each lay (op 91). **A guest that refuses to make one** is refused
/// by name: the product lays its own estates or none. A host-side failure is said as such.
pub fn of_guest(
    product: &str,
    guest: Arc<monetize_product_wasm::GuestProduct>,
    facts: Facts,
) -> Lifecycle {
    let id = product.to_string();
    let lay: lifecycle::LayPlan = Arc::new(
        move |life: &Lifecycle,
              cfg: &EstateOrderConfig,
              company: &str,
              sizes: &EstateSizes,
              twin_plan: &str|
              -> Result<EstatePlan, String> {
            let req = request(&life.facts, cfg, company, sizes, twin_plan);
            let plan = match guest.lifecycle_lay_plan(&req) {
                Ok(plan) => plan,
                Err(monetize_product::ProductError::Refused(why)) => {
                    return Err(format!(
                        "[products.{id}] its lifecycle's lay plan: product refused: {why}"
                    ))
                }
                Err(e) => return Err(format!("[products.{id}] its lifecycle's lay plan: {e}")),
            };
            let mut plan: EstatePlan = serde_json::from_value(plan).map_err(|e| {
                format!("[products.{id}] its lifecycle answered a plan that is not one: {e}")
            })?;
            // DEV-NARRATE's source is the shop's to say (lane N2), never a product guest's.
            plan.narrate_from = cfg.narrate_source().to_string();
            Ok(plan)
        },
    );
    Lifecycle::new(product, facts, lay)
}

/// Pure: **the mail words a guest brings** — its kind's native ones (`base`; none: nothing
/// said) with each line the guest names in `given`'s `mail_words` in its place. Lines it does
/// not name keep the native words, so a guest that names none speaks as its kind always did.
pub fn mail_words_over(
    base: Option<lifecycle::MailWords>,
    given: &serde_json::Value,
) -> lifecycle::MailWords {
    let base = base.unwrap_or_default();
    match given
        .get("mail_words")
        .map(|w| serde_json::from_value::<lifecycle::MailWords>(w.clone()))
    {
        Some(Ok(w)) => base.overlaid(&w),
        Some(Err(e)) => {
            eprintln!("monetize-server: lifecycle: a guest's mail words are not mail words ({e}); its kind's own are said");
            base
        }
        None => base,
    }
}

/// Pure: **the names a guest serves under its host** — the prefixes its `given` facts'
/// `names` key lists (`["", "www."]`), else its kind's native ones (`base`), else Gunnar's three.
/// A guest that names its own needs no native under it (v2.0 P2 12.1, A28 H5/M8).
pub fn names_over(base: Option<Vec<String>>, given: &serde_json::Value) -> Vec<String> {
    match given
        .get("names")
        .map(|n| serde_json::from_value::<Vec<String>>(n.clone()))
    {
        Some(Ok(v)) if !v.is_empty() => v,
        Some(Err(e)) => {
            eprintln!("monetize-server: lifecycle: a guest's names are not a list of prefixes ({e}); its kind's own are served");
            base.unwrap_or_else(|| vec![String::new(), "www.".into(), "git.".into()])
        }
        _ => base.unwrap_or_else(|| vec![String::new(), "www.".into(), "git.".into()]),
    }
}

/// The rider of a guest's `default_shape` that names **the served prefixes its front routes to
/// its primary's repository** (HOL-REPO-NAME: Holger's `["repo."]`, the front's `repo_hosts`).
pub const RIDER_REPO_NAMES: &str = "repo_names";
/// The rider of a guest's `default_shape` that names **the served prefixes its front serves on
/// :443 as a redirect only** (HOL-REPO-NAME: Gunnar's `["git."]`, whose forge is ssh: on the
/// certificate, every path sent to the site; the front's `web_callback_only`).
pub const RIDER_REDIRECT_NAMES: &str = "redirect_names";

/// Pure: **the prefixes `given`'s `rider` lists**, as the ladder and `names` ride — `None` when
/// the guest names no such rider (a guest built before it), or names one that is not a list of
/// prefixes (said, and read as absent).
pub fn prefixes_named(rider: &str, given: &serde_json::Value) -> Option<Vec<String>> {
    match given
        .get(rider)
        .map(|n| serde_json::from_value::<Vec<String>>(n.clone()))
    {
        Some(Ok(v)) => Some(
            v.into_iter()
                .map(|p| p.trim().to_ascii_lowercase())
                .collect(),
        ),
        Some(Err(e)) => {
            eprintln!("monetize-server: lifecycle: a guest's {rider} is not a list of prefixes ({e}); read as not named");
            None
        }
        None => None,
    }
}

/// What [`follow`] registered: kind → (product, the module's digest).
static FOLLOWED: Mutex<BTreeMap<String, (String, String)>> = Mutex::new(BTreeMap::new());

/// **Every product guest's lifecycle, followed**: a guest that brings one is registered for its
/// kind (again when its module changed), one that no longer does — or is gone — is
/// unregistered. Answers the lines to log.
pub fn follow(registry: &crate::registry::Registry) -> Vec<String> {
    let mut lines = Vec::new();
    let mut followed = FOLLOWED.lock().unwrap_or_else(|e| e.into_inner());
    let mut seen = Vec::new();
    for id in registry.guest_ids() {
        let Some(guest) = registry.guest(&id) else {
            continue;
        };
        let digest = guest.digest_hex().to_string();
        if let Some((kind, _)) = followed
            .iter()
            .find(|(_, (p, d))| *p == id && *d == digest)
            .map(|(k, v)| (k.clone(), v.clone()))
        {
            seen.push(kind);
            continue;
        }
        match guest.lifecycle_facts() {
            Ok(Some(facts)) if !facts.kind.trim().is_empty() => {
                let kind = facts.kind.clone();
                lifecycle::register(&kind, Arc::new(of_guest(&id, guest.clone(), facts)));
                lines.push(format!("lifecycle {kind}: taken from the guest of [products.{id}] (blake3 {digest:.12})"));
                followed.insert(kind.clone(), (id.clone(), digest));
                seen.push(kind);
            }
            Ok(_) => {}
            Err(e) => {
                // Refused: what was registered for this guest stands.
                seen.extend(
                    followed
                        .iter()
                        .filter(|(_, (p, _))| *p == id)
                        .map(|(k, _)| k.clone()),
                );
                lines.push(format!("lifecycle REFUSED [products.{id}] its guest's lifecycle.facts: {e} — what it brought before still stands"));
            }
        }
    }
    let gone: Vec<String> = followed
        .keys()
        .filter(|k| !seen.contains(k))
        .cloned()
        .collect();
    for kind in gone {
        let (id, _) = followed.remove(&kind).unwrap_or_default();
        lifecycle::unregister(&kind);
        lines.push(format!(
            "lifecycle {kind}: the guest of [products.{id}] brings it no more"
        ));
    }
    lines
}