crates/monetize-server/src/testing/flock.rs
//! **The flock: a parametric TEST product of Gunnar's shape** (monetize v2.0 A4-FACTS).
//!
//! Not Gunnar, not Holger, not the Lake: a product the plan-change tests sell, its rules all
//! DATA in its facts, so the core is proven to read them and nothing else. A flock is Gunnar's
//! three machines (a front, a main that is the primary, a twin that mirrors it), with the twin
//! OPTIONAL, and `n` nodes beside them (`node`, `node-2`, …), each with a data disk:
//!
//! ```text
//! order {twin, nodes, node_gib}
//! twin = false ─▶ front, main (a Gunnar-shaped estate with no twin)
//! twin = true ─▶ front, main, twin (the default: Gunnar's three)
//! nodes = 2 ─▶ … node, node-2, each node_gib of data
//! facts.roles front, main, twin (optional), node (many, optional)
//! facts.twin twin mirrors main
//! facts.grow never_shrinks main_data_gib, node_gib; may_add twin, node; may_remove node;
//! drain = flock_drain; moves_data node (the `keeps` variant: none)
//! ```
//!
//! Two kinds are registered ([`install`]): `flock`, whose estate moves a node's data off it before
//! it goes, and `flock-keeps`, whose estate cannot (a node is then never taken away: refused by
//! name). Only tests call [`install`]; nothing here is linked into a shipped path.
use std::sync::{Arc, OnceLock};
use monetize_cloud::lay::{default_interfaces, DiskPlan, EstatePlan, MachinePlan};
use crate::estate_order::lifecycle::{self, Lifecycle};
use crate::estate_order::{self as eo, EstateOrderConfig, EstateSizes};
use crate::facts::{Facts, FieldKind, GrowRule, OrderField, OrderForm, RoleFacts};
/// The kind whose estate moves a node's data off it by its drain.
pub const KIND: &str = "flock";
/// The kind whose estate cannot move a node's data: a node is never taken away.
pub const KEEPS: &str = "flock-keeps";
/// The order a flock box is sent before it is taken away (one of its own kinds).
pub const DRAIN: &str = "flock_drain";
/// Each node's plan.
pub const NODE_PLAN: &str = "STARTER-1xCPU-2GB";
/// The flock's numbers: `(twin, nodes, node_gib)`, defaulted (a twin, no node, 10 GiB).
pub fn order_of(sizes: &EstateSizes) -> (bool, u64, u64) {
let o = &sizes.order;
(
o.get("twin").and_then(serde_json::Value::as_bool).unwrap_or(true),
o.get("nodes").and_then(serde_json::Value::as_u64).unwrap_or(0),
o.get("node_gib").and_then(serde_json::Value::as_u64).unwrap_or(10),
)
}
/// The name of the `n`th node (1-based): the first carries the role's name.
pub fn node(n: u64) -> String {
if n <= 1 {
"node".into()
} else {
format!("node-{n}")
}
}
/// **A flock's plan**: Gunnar's three machines (the twin left out on `twin = false`), and its
/// nodes beside them, each with a data disk.
fn flock_plan(life: &Lifecycle, cfg: &EstateOrderConfig, company: &str, sizes: &EstateSizes, twin_plan: &str) -> Result<EstatePlan, String> {
let (twin, nodes, node_gib) = order_of(sizes);
let mut plan = eo::plan_for_as(cfg, company, sizes, twin_plan, life);
if !twin {
plan.machines.retain(|m| m.role != "twin");
}
for n in 1..=nodes {
let role = node(n);
let hostname = format!("flock-{role}-{company}");
let mut labels = cfg.labels.clone();
labels.insert("role".into(), role.clone());
let disk = |title: String, gib: u64, tier: &str, volume: &str| {
let mut l = labels.clone();
l.insert("volume".into(), volume.into());
DiskPlan { title, gib, tier: tier.into(), labels: l }
};
plan.machines.push(MachinePlan {
role: role.clone(),
hostname: hostname.clone(),
title: format!("flock {role} {company}"),
plan: NODE_PLAN.into(),
boot_order: "disk,cdrom".into(),
labels: labels.clone(),
interfaces: default_interfaces(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: vec![disk(format!("{hostname} system"), 10, "standard", "system"), disk(format!("{hostname} data"), node_gib, "maxiops", "data1")],
});
}
Ok(plan)
}
/// **A flock's facts** (see the module doc); `moves_data`: its estate moves a node's data off it.
pub fn flock_facts(kind: &str, moves_data: bool) -> Facts {
use monetize_product_guest::wire::{WireDataRole, WireWords};
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 r = |role: &str, many: bool, optional: bool| RoleFacts { role: role.into(), many, optional, ..RoleFacts::default() };
Facts {
kind: kind.into(),
update_order: ["twin", "main", "front"].iter().map(|r| r.to_string()).collect(),
app_medium: "flock".into(),
front_medium: "flock-front".into(),
front_role: "front".into(),
keep_medium: "flock-keep".into(),
front_keep_medium: "flock-front-keep".into(),
live_slot: false,
medium_of: [("front".to_string(), "flock-front".to_string())].into(),
other_medium: "flock".into(),
words: WireWords { name: "flock".into(), title: "Flock".into(), account_home: String::new() },
default_shape: serde_json::to_value(crate::testing::lifecycles::gunnar_shape()).expect("a shape is JSON"),
data_roles: vec![WireDataRole { role: "main".into(), mirrors_primary: false, set_suffix: String::new() }, WireDataRole { role: "twin".into(), mirrors_primary: true, set_suffix: "-twin".into() }],
roles: vec![r("front", false, false), r("main", false, false), r("twin", false, true), r("node", true, true)],
grow: Some(GrowRule {
never_shrinks: vec!["main_data_gib".into(), "node_gib".into()],
may_add: vec!["twin".into(), "node".into()],
may_remove: vec!["node".into()],
drain: DRAIN.into(),
moves_data: if moves_data { vec!["node".into()] } else { Vec::new() },
}),
form: Some(OrderForm { fields: vec![f("twin", FieldKind::Bool, 0, 0, 0, true.into()), f("nodes", FieldKind::Count, 0, 4, 0, 0.into()), f("node_gib", FieldKind::Gib, 10, 100, 10, 10.into())] }),
order_kinds: [monetize_product::pull::order::ENTITLEMENT, DRAIN].iter().map(|k| k.to_string()).collect(),
..Facts::default()
}
}
/// The one flock of each kind a test binary registers.
pub fn flock() -> Arc<Lifecycle> {
static ONE: OnceLock<Arc<Lifecycle>> = OnceLock::new();
ONE.get_or_init(|| Arc::new(Lifecycle::new(KIND, flock_facts(KIND, true), Arc::new(flock_plan)))).clone()
}
/// The flock whose estate cannot move a node's data.
pub fn keeps() -> Arc<Lifecycle> {
static ONE: OnceLock<Arc<Lifecycle>> = OnceLock::new();
ONE.get_or_init(|| Arc::new(Lifecycle::new(KEEPS, flock_facts(KEEPS, false), Arc::new(flock_plan)))).clone()
}
/// **Register both flocks** (idempotent).
pub fn install() {
for (kind, life) in [(KIND, flock()), (KEEPS, keeps())] {
if lifecycle::registered(kind).is_none() {
lifecycle::register(kind, life);
}
}
}
/// The flock's numbers as an order (`EstateSizes.order`).
pub fn sizes(twin: bool, nodes: u64, node_gib: u64) -> EstateSizes {
EstateSizes { order: serde_json::json!({"twin": twin, "nodes": nodes, "node_gib": node_gib}), ..EstateSizes::default() }
}
/// The roles a laid record holds, in its order.
pub fn roles_of(laid: &monetize_cloud::lay::LaidEstate) -> Vec<String> {
laid.machines.iter().map(|m| m.role.clone()).collect()
}