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