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