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/tests/more_machines.rs

//! **A plan change that ADDS machines** (monetize v2.0 P3-FACTS; A24 §4.3 `Cloud.change(laid,
//! new)`; the Lake's P3_WAITS row 4), and the grow rule, the primary and the roles read off the
//! product's facts, never Gunnar's shape (A24 §2.2, A28 H11).
//!
//! The product here is neither Gunnar nor Holger nor the Lake: a parametric TEST product, the
//! herd — a `front` and `n` nodes (`node`, `node-2`, …), its order `{nodes, disk_gib, gate}`.
//! Its facts say what may change on a standing herd: more nodes (never fewer), the nodes' disks
//! never shrink, and a `gate` is never added by a buy.
//!
//! ```text
//!   ChangePlan {nodes: 1 → 2} ─▶ the herd's plan for 2 ─▶ node-2 is not laid: an ADD
//!     ─▶ priced whole (the provider's price now + the margin), the difference HELD
//!     ─▶ paid ─▶ resizing: node-2 laid beside the estate (marked added=change-1),
//!        installed by the estate's installer (InstallRequest.role = node-2), its key named
//!     ─▶ node-2 calls home ─▶ the hold CAPTURED ─▶ done; the run's price is the new one
//! ```

use super::*;
use crate::facts::{FieldKind, GrowRule, OrderField, OrderForm, RoleFacts};

const PLAN: &str = "STARTER-1xCPU-2GB";

fn order_of(sizes: &EstateSizes) -> (u64, u64, bool) {
    let o = &sizes.order;
    (
        o.get("nodes")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(1),
        o.get("disk_gib")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(10),
        o.get("gate")
            .and_then(serde_json::Value::as_bool)
            .unwrap_or(false),
    )
}

/// **The herd's plan**: a front and `nodes` nodes, each node with a data disk of `disk_gib`, and
/// a `gate` when the order asks for one.
fn herd_plan(
    _: &lifecycle::Lifecycle,
    cfg: &EstateOrderConfig,
    company: &str,
    sizes: &EstateSizes,
    _twin: &str,
) -> Result<monetize_cloud::lay::EstatePlan, String> {
    use monetize_cloud::lay::{
        default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan, UserData,
    };
    let (nodes, disk_gib, gate) = order_of(sizes);
    let front_plan = if sizes.front_plan.trim().is_empty() {
        PLAN.to_string()
    } else {
        sizes.front_plan.trim().to_string()
    };
    let m = |role: String, data: Option<u64>| {
        let hostname = format!("herd-{role}-{company}");
        let mut disks = vec![DiskPlan {
            title: format!("{hostname} system"),
            gib: 10,
            tier: "standard".into(),
            labels: BTreeMap::new(),
        }];
        disks.extend(data.map(|gib| DiskPlan {
            title: format!("{hostname} data"),
            gib,
            tier: "maxiops".into(),
            labels: BTreeMap::new(),
        }));
        MachinePlan {
            role: role.clone(),
            hostname: hostname.clone(),
            title: format!("herd {role} {company}"),
            plan: if role == "front" {
                front_plan.clone()
            } else {
                PLAN.into()
            },
            boot_order: "disk,cdrom".into(),
            labels: BTreeMap::from([("role".to_string(), role)]),
            interfaces: default_interfaces(),
            medium: String::new(),
            medium_object: None,
            template: String::new(),
            user_data: UserData::default(),
            disks,
        }
    };
    let mut machines = vec![m("front".into(), None)];
    for n in 1..=nodes {
        machines.push(m(
            if n == 1 {
                "node".into()
            } else {
                format!("node-{n}")
            },
            Some(disk_gib),
        ));
    }
    if gate {
        machines.push(m("gate".into(), None));
    }
    Ok(EstatePlan {
        reference: reference_of(company),
        company: company.into(),
        zone: monetize_cloud::Zone(cfg.zone.clone()),
        machines,
        narrate_from: String::new(),
        shape: None,
    })
}

/// **The herd** (its facts: what it is, as data).
fn herd() -> lifecycle::Lifecycle {
    let f =
        |name: &str, kind: FieldKind, min: u64, max: u64, step: u64, default: serde_json::Value| {
            OrderField {
                name: name.into(),
                kind,
                min,
                max,
                step,
                choices: Vec::new(),
                default,
                words: name.into(),
            }
        };
    let u = lifecycle::UpdatePlan {
        order: vec!["node".into(), "front".into()],
        app_medium: "herd".into(),
        front_medium: "herd".into(),
        front_role: "front".into(),
        keep_medium: "herd-keep".into(),
        front_keep_medium: "herd-keep".into(),
        upgraded_by_shop: Vec::new(),
        live_slot: false,
    };
    let facts = crate::facts::Facts {
        data_roles: vec![monetize_product_guest::wire::WireDataRole {
            role: "node".into(),
            mirrors_primary: false,
            set_suffix: String::new(),
        }],
        roles: vec![
            RoleFacts {
                role: "front".into(),
                ..RoleFacts::default()
            },
            RoleFacts {
                role: "node".into(),
                many: true,
                ..RoleFacts::default()
            },
            RoleFacts {
                role: "gate".into(),
                optional: true,
                ..RoleFacts::default()
            },
        ],
        grow: Some(GrowRule {
            never_shrinks: vec!["disk_gib".into()],
            may_add: vec!["node".into()],
            ..GrowRule::default()
        }),
        form: Some(OrderForm {
            fields: vec![
                f("nodes", FieldKind::Count, 1, 8, 0, 1.into()),
                f("disk_gib", FieldKind::Gib, 10, 100, 10, 10.into()),
                f("gate", FieldKind::Bool, 0, 0, 0, false.into()),
            ],
        }),
        ..crate::testing::lifecycles::facts_of("herd", u, "herd")
    };
    lifecycle::Lifecycle::new("herd", facts, Arc::new(herd_plan))
}

fn herd_cfg() -> EstateOrderConfig {
    lifecycle::register("herd", Arc::new(herd()));
    EstateOrderConfig {
        lifecycle: "herd".into(),
        ..cfg()
    }
}

fn sizes(order: serde_json::Value) -> EstateSizes {
    EstateSizes {
        order,
        ..EstateSizes::default()
    }
}

fn roles_laid(r: &Rig) -> Vec<String> {
    r.state
        .estates
        .get("acme")
        .and_then(|run| run.laid)
        .map(|l| l.machines.iter().map(|m| m.role.clone()).collect())
        .unwrap_or_default()
}

/// A herd of one node, delivered and paid.
fn one_node(name: &str) -> Rig {
    let r = rig_with(name, herd_cfg(), "PASS");
    start_for(
        &r.state,
        "acme",
        "buyer@example.com",
        "",
        "alice",
        &sizes(serde_json::json!({"nodes": 1})),
    )
    .expect("ordered");
    r.pay.authorize(&handle_of(&r, "acme"));
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    assert_eq!(run.phase, phase::CAPTURED, "{:#?}", run.history);
    assert_eq!(roles_laid(&r), ["front", "node"]);
    r
}

/// ★ **A plan change that adds a machine lays it, pairs it, hears it and bills it** (P3-FACTS):
/// the herd's nodes 1 → 2 is priced whole, the difference for the days left held; once paid,
/// `node-2` is laid at the cloud beside the estate and installed by its installer (the request
/// names `node-2`), and only once `node-2` itself called home is the hold captured. The run then
/// holds three machines at the new price, and the buyer is mailed what was laid.
#[test]
fn a_plan_change_that_adds_a_node_lays_it_pairs_it_hears_it_and_bills_it() {
    let r = one_node("more-machines");
    let before = r.state.estates.get("acme").unwrap();
    let lays_before = r.cloud.lays().len();
    let c = change::change(&r.state, "acme", &sizes(serde_json::json!({"nodes": 2})))
        .unwrap_or_else(|e| panic!("a plan change of the numbers is taken: {e:?}"));
    assert_eq!(
        c.adds.iter().map(|a| a.role.as_str()).collect::<Vec<_>>(),
        ["node-2"],
        "{c:#?}"
    );
    assert!(
        c.steps.is_empty(),
        "nothing that stands is resized: {:?}",
        c.steps
    );
    assert_eq!(c.status, change::status::WAITING_FOR_PAYMENT);
    assert!(
        c.quote.monthly.minor > before.quote.monthly.minor,
        "the new machine is priced in: {} > {}",
        c.quote.monthly.minor,
        before.quote.monthly.minor
    );
    assert_eq!(
        c.amount,
        change::prorated(
            before.quote.monthly,
            c.quote.monthly,
            r.state.now(),
            before.paid_until_ms
        ),
        "the difference for the days left is held"
    );
    assert!(
        c.words.contains("a new node-2 (STARTER-1xCPU-2GB)"),
        "{}",
        c.words
    );
    assert_eq!(
        r.cloud.lays().len(),
        lays_before,
        "nothing is laid before the money is held"
    );

    // Paid; the new box is laid and installed, but it has not called home yet: no capture.
    r.kit.silent.store(true, Ordering::SeqCst);
    r.pay.authorize(&c.handle.as_ref().expect("a hold").id);
    super::super::drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    let open = run
        .change
        .as_ref()
        .expect("the change waits for the new box");
    assert_eq!(open.status, change::status::RESIZING, "{open:#?}");
    assert!(
        open.why.contains("waiting for the new node-2 to call home"),
        "{}",
        open.why
    );
    assert_eq!(
        roles_laid(&r),
        ["front", "node", "node-2"],
        "node-2 is laid beside the estate"
    );
    assert_eq!(
        r.cloud.lays().len(),
        lays_before + 1,
        "one lay: the new machine alone"
    );
    assert!(
        r.kit
            .roles
            .lock()
            .unwrap()
            .iter()
            .any(|role| role == "node-2"),
        "the installer laid node-2: {:?}",
        r.kit.roles.lock().unwrap()
    );
    assert!(
        open.adds[0].laid_at_ms > 0 && !open.adds[0].server.is_empty(),
        "{:?}",
        open.adds
    );
    assert!(
        r.cloud
            .firewalled
            .lock()
            .unwrap()
            .iter()
            .any(|f| f == "estate/acme"),
        "the estate's firewall is written again with node-2 in it"
    );
    assert_eq!(
        r.pay.captures(),
        1,
        "the order's capture only: the change waits for node-2"
    );

    // node-2 calls home: the hold is captured, the change is done.
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    assert!(run.change.is_none(), "{:#?}", run.change);
    let done = run.changes.last().expect("the change is kept");
    assert_eq!(done.status, change::status::DONE, "{done:#?}");
    assert_eq!(r.pay.captures(), 2, "the change's hold captured");
    assert_eq!(
        run.quote.monthly, c.quote.monthly,
        "the bill follows: the renewal is priced for three machines"
    );
    assert_eq!(run.quote.sizes.order["nodes"], 2);
    assert_eq!(
        r.cloud.live_under("estate/acme"),
        3 + 1 + 2 + 2,
        "3 servers, the front's disk and two disks per node, each labelled with the estate"
    );
    assert_eq!(r.mails.count("estate.changed"), 1);
    assert!(
        r.mails
            .last()
            .body
            .contains("a new node-2 runs on STARTER-1xCPU-2GB"),
        "{}",
        r.mails.last().body
    );
}

/// ★ **What the product's grow rule keeps is refused by name, nothing held** (P3-FACTS): a
/// node's disk never shrinks (the herd's `never_shrinks`, not Gunnar's `main_data_gib`); a node
/// is never taken away and a gate never added by a buy (`may_add`, `may_remove`). Each refusal
/// holds no money.
#[test]
fn the_grow_rule_of_the_products_facts_refuses_by_name_and_holds_nothing() {
    let r = rig_with("herd-rule", herd_cfg(), "PASS");
    start_for(
        &r.state,
        "acme",
        "buyer@example.com",
        "",
        "alice",
        &sizes(serde_json::json!({"nodes": 2, "disk_gib": 20})),
    )
    .expect("ordered");
    r.pay.authorize(&handle_of(&r, "acme"));
    drive(&r.state, "acme");
    assert_eq!(r.state.estates.get("acme").unwrap().phase, phase::CAPTURED);
    let held = r.pay.0.requests.lock().unwrap().len();
    for (order, words) in [
        (
            serde_json::json!({"nodes": 2, "disk_gib": 10}),
            "data never shrinks: disk_gib is 20 now, and 10 was asked",
        ),
        (
            serde_json::json!({"nodes": 1, "disk_gib": 20}),
            "takes no node-2 away",
        ),
        (
            serde_json::json!({"nodes": 2, "disk_gib": 20, "gate": true}),
            "lays no new gate: this product adds machines of node only",
        ),
    ] {
        let e =
            change::change(&r.state, "acme", &sizes(order.clone())).expect_err(&order.to_string());
        let said = format!("{e:?}");
        assert!(said.contains(words), "{order}: {said}");
        assert!(
            said.contains("nothing was held") || said.contains("never shrinks"),
            "{order}: {said}"
        );
    }
    assert_eq!(
        r.pay.0.requests.lock().unwrap().len(),
        held,
        "no money moved for a refusal"
    );
    assert!(r.state.estates.get("acme").unwrap().change.is_none());
}

/// ★ **The parts a change speaks of are the product's facts** (P3-FACTS): the herd's primary is
/// its node and it has no twin, so a change resizes the node LAST and never grows a twin;
/// Gunnar's shape keeps main and its twin.
#[test]
fn the_primary_and_the_twin_of_a_change_are_the_products() {
    crate::testing::lifecycles::install();
    assert_eq!(
        change::Parts::of(&herd_cfg()),
        change::Parts {
            primary: "node".into(),
            twin: None
        }
    );
    assert_eq!(
        change::Parts::of(&cfg()),
        change::Parts {
            primary: "main".into(),
            twin: Some("twin".into())
        }
    );
    assert_eq!(herd().twin(), None);
    assert_eq!(
        lifecycle::of(&cfg()).unwrap().twin(),
        Some(crate::facts::TwinFacts {
            role: "twin".into(),
            mirrors: "main".into()
        })
    );
    assert_eq!(
        lifecycle::of(&cfg()).unwrap().grow(),
        GrowRule::sizes(),
        "Gunnar's: main's data never shrinks, no machine added"
    );
}

/// ★ **A product with no twin changes plans without one** (P3-FACTS): the herd's front is
/// resized alone — the change's steps are the machines that stand, never a fixed front, twin
/// and main (before, it was refused: "the estate has no twin").
#[test]
fn a_twinless_products_plan_change_resizes_what_stands_and_asks_for_no_twin() {
    let r = one_node("twinless");
    let c = change::change(
        &r.state,
        "acme",
        &EstateSizes {
            front_plan: "STARTER-4xCPU-8GB".into(),
            ..EstateSizes::default()
        },
    )
    .unwrap_or_else(|e| panic!("{e:?}"));
    assert_eq!(
        c.steps
            .iter()
            .map(|s| (s.role.as_str(), s.to.as_str()))
            .collect::<Vec<_>>(),
        [("front", "STARTER-4xCPU-8GB")]
    );
    assert!(c.adds.is_empty());
    let run = r.state.estates.get("acme").unwrap();
    let mut grown = run.clone();
    grown
        .laid
        .as_mut()
        .unwrap()
        .machines
        .iter_mut()
        .for_each(|m| m.plan = "STARTER-1xCPU-1GB".into());
    let q = change::steps_for(
        &grown,
        &[],
        &c.quote,
        &change::Parts {
            primary: "node".into(),
            twin: None,
        },
    )
    .unwrap();
    assert_eq!(
        q.iter().map(|s| s.role.as_str()).collect::<Vec<_>>(),
        ["front", "node"],
        "the primary LAST"
    );
}