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/fewer_machines.rs

//! ★ **A plan change that TAKES MACHINES AWAY** (monetize v2.0 A4-FACTS, item 1).
//!
//! The flock ([`crate::testing::flock`]) lays `n` nodes beside Gunnar's three machines; its facts
//! say a node may be taken away (`may_remove node`), how a box is emptied before it goes (its own
//! order kind, `drain = flock_drain`) and that its estate moves a node's data to the boxes that
//! stay (`moves_data node`). The `flock-keeps` kind moves none: a node there is never taken away.
//!
//! ```text
//!   ChangePlan {nodes: 2 → 1} ─▶ node-2 is laid and the new plan has none: a REMOVE
//!     ─▶ priced whole: cheaper, so nothing is held (the period is prepaid), queued
//!     ─▶ node-2 is sent its drain (a signed order it pulls) ─▶ it acks: emptied, its data moved
//!     ─▶ node-2 given back at the cloud, server and disks ─▶ read gone from the listing
//!     ─▶ the guardian forgets its key, the estate's DNS and firewall name it no more
//!     ─▶ done: the run's price is the new one, so the renewal bills one node less
//! ```

use super::*;
use crate::testing::flock;

fn cfg_of(kind: &str) -> EstateOrderConfig {
    flock::install();
    EstateOrderConfig {
        lifecycle: kind.into(),
        ..cfg()
    }
}

fn roles_laid(r: &Rig) -> Vec<String> {
    r.state
        .estates
        .get("acme")
        .and_then(|run| run.laid)
        .map(|l| flock::roles_of(&l))
        .unwrap_or_default()
}

/// A flock of `kind` with two nodes, delivered and paid.
fn two_nodes(name: &str, kind: &str) -> Rig {
    let r = rig_with(name, cfg_of(kind), "PASS");
    start_for(
        &r.state,
        "acme",
        "buyer@example.com",
        "",
        "alice",
        &flock::sizes(true, 2, 20),
    )
    .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", "main", "twin", "node", "node-2"]);
    r
}

/// The seqs of the drain orders queued for `role`, unacked.
fn drains(r: &Rig, role: &str) -> Vec<u64> {
    r.state
        .fleet
        .guardian()
        .unwrap()
        .store
        .queued("acme")
        .into_iter()
        .filter(|o| o.role == role && o.kind == flock::DRAIN)
        .map(|o| o.seq)
        .collect()
}

/// `role` acks its drain (`ok`, in `words`), as a box does in its next report.
fn acks_drain(r: &Rig, role: &str, ok: bool, words: &str) {
    let seq = drains(r, role)
        .into_iter()
        .next()
        .unwrap_or_else(|| panic!("no drain queued for {role}"));
    let report = monetize_embed::fleet::Report {
        acked: vec![monetize_embed::fleet::OrderAck {
            seq,
            ok,
            detail: words.into(),
            receipt_json: String::new(),
        }],
        ..Default::default()
    };
    r.state
        .fleet
        .guardian()
        .unwrap()
        .store
        .record_report("acme", role, r.state.now(), &report)
        .unwrap();
}

/// ★ **A node taken away is drained, given back at the cloud, forgotten, and billed no more.**
#[test]
fn a_plan_change_that_takes_a_node_away_drains_it_gives_it_back_and_bills_one_node_less() {
    let r = two_nodes("fewer-machines", flock::KIND);
    let before = r.state.estates.get("acme").unwrap();
    let gone = before
        .laid
        .as_ref()
        .unwrap()
        .machine("node-2")
        .cloned()
        .unwrap();
    // The estate names node-2 in its DNS (a record at its address) and its WireGuard peers.
    {
        let _pen = r.state.estates.pen("acme");
        let mut run = before.clone();
        run.dns.push(monetize_cloud::dns::DnsRecord {
            name: "node-2.acme.gunnar.rs".into(),
            kind: "A".into(),
            value: gone.public_ipv4.clone(),
            proxied: false,
        });
        run.peers.push(crate::estate_order::WgPeer {
            name: "node-2".into(),
            overlay: "10.44.0.9".into(),
            public_key: "PUBnode2=".into(),
            ..Default::default()
        });
        super::super::save(&r.state, &run).unwrap();
        FileDns(r.dir.join("dns.json")).write(&run.dns).unwrap();
    }
    let held = r.pay.0.requests.lock().unwrap().len();

    let c = change::change(&r.state, "acme", &flock::sizes(true, 1, 20))
        .unwrap_or_else(|e| panic!("a node is taken away by a plan change: {e:?}"));
    assert_eq!(
        c.removes
            .iter()
            .map(|x| x.role.as_str())
            .collect::<Vec<_>>(),
        ["node-2"],
        "{c:#?}"
    );
    assert!(c.adds.is_empty() && c.steps.is_empty(), "{c:#?}");
    assert!(
        c.quote.monthly.minor < before.quote.monthly.minor,
        "one node less is cheaper: {} < {}",
        c.quote.monthly.minor,
        before.quote.monthly.minor
    );
    assert_eq!(
        (c.status.as_str(), c.amount.minor),
        (change::status::QUEUED, 0),
        "cheaper: nothing is held, the period is prepaid"
    );
    assert_eq!(
        r.pay.0.requests.lock().unwrap().len(),
        held,
        "no money moves"
    );
    assert!(
        c.words.contains("node-2 is emptied and taken away"),
        "{}",
        c.words
    );

    // The drain is a signed order node-2 pulls; nothing is given back before it acks.
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    let open = run.change.as_ref().expect("the change waits for the drain");
    assert!(
        open.why.contains("waiting for node-2 to be emptied"),
        "{}",
        open.why
    );
    assert_eq!(drains(&r, "node-2").len(), 1, "one drain, for node-2");
    assert!(
        r.cloud.destroys().is_empty(),
        "nothing is given back before the box is emptied"
    );
    drive(&r.state, "acme");
    assert_eq!(drains(&r, "node-2").len(), 1, "the drain is queued once");

    // node-2 acks: emptied, its data moved to the node that stays. It is given back whole.
    acks_drain(&r, "node-2", true, "emptied: its data is on node");
    drive(&r.state, "acme");
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    assert!(
        run.change.is_none(),
        "{:#?}\n{:#?}",
        run.change,
        run.history
    );
    let done = run.changes.last().unwrap();
    assert_eq!(done.status, change::status::DONE, "{done:#?}");
    assert_eq!(
        roles_laid(&r),
        ["front", "main", "twin", "node"],
        "node-2 is gone from the estate"
    );
    let destroyed: Vec<String> = r.cloud.destroys().into_iter().map(|i| i.id).collect();
    for id in gone.laid_ids() {
        assert!(
            destroyed.iter().any(|d| d == id),
            "{id} of node-2 is given back: {destroyed:?}"
        );
    }
    assert_eq!(
        r.cloud.live_under("estate/acme"),
        4 + 1 + 5 + 2 + 2,
        "4 servers; the front's disk, main's five, the twin's two and node's two"
    );
    let g = r.state.fleet.guardian().unwrap();
    assert!(
        g.store.key_of("acme", "node-2").is_none()
            && g.store.box_record("acme", "node-2").is_none(),
        "the guardian forgets node-2's key"
    );
    assert!(
        r.cloud
            .firewalled
            .lock()
            .unwrap()
            .iter()
            .any(|f| f == "estate/acme"),
        "the firewall is written again without node-2"
    );
    assert!(
        run.dns.iter().all(|d| d.value != gone.public_ipv4),
        "no record names node-2: {:?}",
        run.dns
    );
    assert!(
        FileDns(r.dir.join("dns.json"))
            .listed()
            .unwrap()
            .iter()
            .all(|d| d.name != "node-2.acme.gunnar.rs"),
        "its name is gone from the zone"
    );
    assert!(
        run.peers.iter().all(|p| p.name != "node-2"),
        "no WireGuard peer is node-2"
    );
    assert_eq!(
        run.quote.monthly, c.quote.monthly,
        "the bill follows: the renewal prices one node"
    );
    assert_eq!(run.quote.sizes.order["nodes"], 1);
    assert!(
        r.mails.last().body.contains("node-2 is taken away"),
        "{}",
        r.mails.last().body
    );
}

/// ★ **A box whose data the estate cannot move is never taken away** (`flock-keeps`: its facts
/// move no node's data): refused by name, nothing held, nothing queued.
#[test]
fn a_node_whose_data_cannot_move_is_refused_by_name_and_nothing_is_held() {
    let r = two_nodes("fewer-keeps", flock::KEEPS);
    let held = r.pay.0.requests.lock().unwrap().len();
    let e = change::change(&r.state, "acme", &flock::sizes(true, 1, 20)).expect_err("refused");
    let said = format!("{e:?}");
    assert!(said.contains("node-2 holds the estate's data, and this product does not move the data of a node off a box"), "{said}");
    assert!(said.contains("nothing was held"), "{said}");
    assert_eq!(r.pay.0.requests.lock().unwrap().len(), held);
    assert!(drains(&r, "node-2").is_empty(), "no drain is sent");
    assert!(r.state.estates.get("acme").unwrap().change.is_none());
    assert_eq!(roles_laid(&r), ["front", "main", "twin", "node", "node-2"]);
}

/// ★ **A box that will not be emptied stays**: its drain acked NOT ok fails the change by its
/// words; nothing is given back and the guardian keeps its key.
#[test]
fn a_box_that_refuses_its_drain_stays_and_the_change_fails_by_its_words() {
    let r = two_nodes("fewer-refused", flock::KIND);
    change::change(&r.state, "acme", &flock::sizes(true, 1, 20)).unwrap();
    drive(&r.state, "acme");
    acks_drain(
        &r,
        "node-2",
        false,
        "the node that stays has no room for node-2's data",
    );
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    let failed = run.changes.last().expect("the change ended");
    assert_eq!(failed.status, change::status::FAILED);
    assert!(failed.why.contains("node-2 was not emptied: the node that stays has no room for node-2's data; nothing was taken away"), "{}", failed.why);
    assert!(r.cloud.destroys().is_empty());
    assert_eq!(roles_laid(&r), ["front", "main", "twin", "node", "node-2"]);
    assert!(
        r.state
            .fleet
            .guardian()
            .unwrap()
            .store
            .box_record("acme", "node-2")
            .is_some(),
        "node-2 is still known"
    );
    assert_eq!(run.quote.sizes.order["nodes"], 2, "the price stands");
}

/// ★ P4 (lane mon-shared) RED->GREEN: a node given back that the cloud still lists is asked
/// again, and when that ask fails the change's words carry the cloud's answer (it was dropped).
#[test]
fn a_node_still_listed_whose_second_give_back_fails_says_the_clouds_answer() {
    let r = two_nodes("fewer-linger", flock::KIND);
    r.cloud.linger_deletes();
    change::change(&r.state, "acme", &flock::sizes(true, 1, 20)).unwrap();
    drive(&r.state, "acme");
    acks_drain(&r, "node-2", true, "emptied: its data is on node");
    drive(&r.state, "acme");
    *r.cloud.fail_destroy.lock().unwrap() = true;
    drive(&r.state, "acme");
    let run = r.state.estates.get("acme").unwrap();
    let open = run.change.as_ref().expect("the change waits for the cloud");
    assert!(
        open.why.contains("is being given back at the cloud"),
        "{}",
        open.why
    );
    assert!(
        open.why.contains("asked again, the cloud answered"),
        "the second ask's failure is said: {}",
        open.why
    );
}