guests/lake/src/lib.rs
//! **The Lake guest** — the monetize product guest of the Lake (monetize v2.0 P1, 2026-10-24).
//!
//! The Lake is the prototype product for TESTING monetize (owner 2026-10-23). It is fake inside
//! (it sells nothing real, and every call it gets is printed as one plain line through the host's
//! `log` door) and real in its contract:
//!
//! * **parametric**: the buyer sets numbers ([`LakeOrder`]: the input store's size, how many
//! ingestors and their plan, Apache Doris with `be` backends and `fe` frontends, a graph
//! database or none), and the estate's shape and price follow them ([`plan_of`]);
//! * **pulled**: no grants, no endpoint. The ONE box never calls into a Lake estate; its boxes
//! report and pull their orders (`[products.lake] instance = …`). The guest answers the six
//! ops of the contract only (manifest, facts, plan, the guardian's three);
//! * **no actor tickets**: the Lake's front mints none (owner rule: the Lake stays thin);
//! * **its own roles**: `front`, `ingestor`, `be`, `fe`, `graph` — never front/main/twin. The
//! first machine of a role carries the role's name, the next ones `<role>-<n>` (`be-2`);
//! * **no twin**: no election, no mirror;
//! * **grow = changing the numbers** (a new [`LakeOrder`], a new plan).
//!
//! What today's contract cannot say yet, and monetize v2.0 P3 adds (A24 §2.2, §4.3), is written
//! down in [`P3_WAITS`].
use std::collections::BTreeMap;
use monetize_product_guest::facts::{GrowRule, RoleFacts};
use monetize_product_guest::guardian::{EstateView, Finding, Remedy, Step, StepState};
use monetize_product_guest::wire::{LayPlanReq, LifecycleFacts, WireDataRole, WireWords};
use monetize_product_guest::{
Backing, Enforcement, Guest, Host, Manifest, ProductError, TestAction, TestActionKind,
WireMeter,
};
/// The product id: `[products.lake]` must name it.
pub const PRODUCT: &str = "lake";
/// The medium every Lake machine is laid from (one binary runs every role), and its keep medium.
pub const MEDIUM: &str = "lake";
pub const KEEP_MEDIUM: &str = "lake-keep";
/// The Lake's roles.
pub mod role {
/// The console, the relay and the proof pages.
pub const FRONT: &str = "front";
/// The ingestors (n of them).
pub const INGESTOR: &str = "ingestor";
/// Apache Doris backends: the data (x of them).
pub const BE: &str = "be";
/// Apache Doris frontends (y of them, an odd number).
pub const FE: &str = "fe";
/// The graph database (none or one).
pub const GRAPH: &str = "graph";
/// The order an update takes them in: the ingestors, the frontends, the backends (the data)
/// and the front LAST — the core's rule (`UpdatePlan.front_role` "drives the update and is
/// upgraded last"; a front that never asked is keep-booted by the shop first instead).
pub const UPDATE_ORDER: [&str; 4] = [INGESTOR, FE, BE, FRONT];
}
/// The plans of the machines whose plan the buyer does not choose.
pub mod plans {
pub const FRONT: &str = "STARTER-1xCPU-2GB";
pub const BE: &str = "STARTER-4xCPU-16GB";
pub const FE: &str = "STARTER-2xCPU-4GB";
pub const GRAPH: &str = "STARTER-4xCPU-16GB";
/// The ingestors' plan when the order names none.
pub const INGESTOR: &str = "STARTER-1xCPU-2GB";
/// **The same machines at Scaleway** (its Instance types), for an estate laid in a Scaleway
/// zone: the provider names its own plans, and the Lake is laid on either cloud.
pub mod scaleway {
pub const FRONT: &str = "DEV1-S";
pub const BE: &str = "DEV1-XL";
pub const FE: &str = "DEV1-M";
pub const GRAPH: &str = "DEV1-XL";
pub const INGESTOR: &str = "DEV1-S";
}
/// Is `zone` one of Scaleway's (`fr-par-1`, `nl-ams-2`, `pl-waw-3`)?
pub fn is_scaleway_zone(zone: &str) -> bool {
["fr-par-", "nl-ams-", "pl-waw-"]
.iter()
.any(|p| zone.starts_with(p))
}
}
/// **What monetize v2.0 P3 must add before the Lake is whole** (A24 §2.2 `Facts`, §4.3), said
/// once here and in the bench's receipt:
pub const P3_WAITS: &[&str] = &[
"LANDED (P3): the order rides the wire (proto EstateSizes.order, the relay's `order`), checked against the Lake's facts.form at the ONE box; this bench still buys the default order",
"LANDED (P3-FACTS): facts.primary_role = `fe`, the FE leader: the core's primary (the tenants, the entitlement orders) is read off the facts, not the first data role",
"LANDED (P3-FACTS): facts.roles with RoleFacts.many and optional (`be`, `be-2`, …; `graph` laid or not); the update still expands `<role>-<n>` and leaves `graph` out of the update order",
"the store as a plan member (Cloud.price/lay of an object store): the input store is the BE data disks today",
"LANDED (P3-FACTS): a plan change that ADDS machines (be 1 → 2): the new numbers' plan, the machines it lays that are not laid, held, laid beside the estate, installed, heard and captured; taking machines away is refused by name",
"LANDED (P3-FACTS): facts.grow (never_shrinks store_gib, may_add, may_remove): the core refuses a shrink of the store and a machine the rule does not allow, by name",
"LANDED (A4-FACTS): new numbers that grow the store over the backends that stand grow each backend's data disk at the cloud, once paid; a backend is never taken away (facts.grow names no drain and may_remove none)",
];
/// **The buyer's numbers** — what a Lake order is. Every field has the smallest Lake as its
/// default ([`LakeOrder::default`]).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LakeOrder {
/// The input store (S3), GiB: 100 to 100 000, in steps of 100.
pub store_gib: u64,
/// The most ingestors at once: 1 to 64.
pub ingestors: u32,
/// The ingestors' plan (their CPU and RAM), the provider's name (`STARTER-2xCPU-4GB`).
pub ingestor_plan: String,
/// Apache Doris backends: 1 to 32.
pub be: u32,
/// Apache Doris frontends: 1, 3 or 5.
pub fe: u32,
/// A graph database beside the lake.
pub graph: bool,
}
impl Default for LakeOrder {
fn default() -> Self {
LakeOrder {
store_gib: 100,
ingestors: 1,
ingestor_plan: plans::INGESTOR.into(),
be: 1,
fe: 1,
graph: false,
}
}
}
impl LakeOrder {
/// The order a lay request carries: `sizes.order` (null or absent: the default). Refused by
/// name when it is not a Lake order or out of range.
pub fn of(req: &LayPlanReq) -> Result<LakeOrder, String> {
let o = match req.sizes.get("order") {
None | Some(serde_json::Value::Null) => LakeOrder::default(),
Some(v) => {
serde_json::from_value(v.clone()).map_err(|e| format!("not a Lake order: {e}"))?
}
};
o.check()?;
Ok(o)
}
/// The ranges, refused by name.
pub fn check(&self) -> Result<(), String> {
if !(100..=100_000).contains(&self.store_gib) || self.store_gib % 100 != 0 {
return Err(format!(
"the input store is 100 to 100000 GiB, in steps of 100; {} is not",
self.store_gib
));
}
if !(1..=64).contains(&self.ingestors) {
return Err(format!(
"a Lake has 1 to 64 ingestors; {} is not",
self.ingestors
));
}
let p = self.ingestor_plan.trim();
if p.is_empty() || p.len() > 64 || !p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(format!(
"the ingestors' plan {:?} is not a plan name",
self.ingestor_plan
));
}
if !(1..=32).contains(&self.be) {
return Err(format!(
"Apache Doris has 1 to 32 backends here; {} is not",
self.be
));
}
if ![1, 3, 5].contains(&self.fe) {
return Err(format!(
"Apache Doris has 1, 3 or 5 frontends (an odd number elects a leader); {} is not",
self.fe
));
}
Ok(())
}
/// How many machines the order lays: `1 + n + x + y (+1)`.
pub fn machines(&self) -> u32 {
1 + self.ingestors + self.be + self.fe + u32::from(self.graph)
}
/// GiB of each backend's data disk: the store spread over the backends, at least 50, in
/// steps of 10.
pub fn be_data_gib(&self) -> u64 {
let each = self.store_gib.div_ceil(u64::from(self.be.max(1))).max(50);
each.div_ceil(10) * 10
}
}
/// The role name of the `n`th machine of `role` (1-based): the first carries the role's name.
pub fn member(role: &str, n: u32) -> String {
if n <= 1 {
role.to_string()
} else {
format!("{role}-{n}")
}
}
/// **The plan of one Lake order** (pure): every machine with its role, plan and disks, the
/// config's labels and zone, `role = <role>` on each. `tier` is the system disks' tier (the
/// medium template's, else `standard`).
pub fn plan_of(
order: &LakeOrder,
company: &str,
config: &serde_json::Value,
tier: &str,
) -> serde_json::Value {
let base_labels = config
.get("labels")
.cloned()
.filter(|l| l.is_object())
.unwrap_or_else(|| serde_json::json!({}));
let mut machines = Vec::new();
let mut machine = |role: String, plan: &str, data_gib: Option<u64>| {
let hostname = format!("lake-{role}-{company}");
let mut labels = base_labels.clone();
labels["role"] = serde_json::json!(role);
let mut disks = vec![
serde_json::json!({ "title": format!("{hostname} system"), "gib": 20, "tier": tier, "labels": {} }),
];
if let Some(gib) = data_gib {
disks.push(serde_json::json!({ "title": format!("{hostname} data"), "gib": gib, "tier": "maxiops", "labels": { "volume": "data1" } }));
}
machines.push(serde_json::json!({
"role": role, "hostname": hostname, "title": format!("Lake {role} {company}"),
"plan": plan, "boot_order": "disk,cdrom", "labels": labels, "medium": "", "disks": disks
}));
};
// The provider's own plan names: UpCloud's, or Scaleway's in a Scaleway zone (an order
// that names no ingestors' plan takes the provider's default for it).
let scw = plans::is_scaleway_zone(
config
.get("zone")
.and_then(|z| z.as_str())
.unwrap_or_default(),
);
let (front, be, fe, graph, default_ingestor) = if scw {
(
plans::scaleway::FRONT,
plans::scaleway::BE,
plans::scaleway::FE,
plans::scaleway::GRAPH,
plans::scaleway::INGESTOR,
)
} else {
(
plans::FRONT,
plans::BE,
plans::FE,
plans::GRAPH,
plans::INGESTOR,
)
};
let ingestor = if order.ingestor_plan.trim() == plans::INGESTOR {
default_ingestor
} else {
order.ingestor_plan.trim()
};
machine(role::FRONT.into(), front, None);
for n in 1..=order.ingestors {
machine(member(role::INGESTOR, n), ingestor, None);
}
for n in 1..=order.be {
machine(member(role::BE, n), be, Some(order.be_data_gib()));
}
for n in 1..=order.fe {
machine(member(role::FE, n), fe, None);
}
if order.graph {
machine(role::GRAPH.into(), graph, Some(100));
}
serde_json::json!({
"reference": format!("estate/{company}"),
"company": company,
"zone": config.get("zone").cloned().unwrap_or_default(),
"machines": machines
})
}
/// **The order kinds a Lake box runs** (core kinds only: no twin, so no repair verbs; a grow
/// is a new order, so no grow step): the entitlement, the account, its purge and the room.
pub const ORDER_KINDS: [&str; 4] = [
monetize_product_guest::order_kind::ENTITLEMENT,
monetize_product_guest::order_kind::ADD_PRINCIPAL,
monetize_product_guest::order_kind::PURGE_TENANT,
monetize_product_guest::order_kind::GROW_ROOM,
];
/// The Lake's facts (op 90): pure.
pub fn facts() -> LifecycleFacts {
LifecycleFacts {
kind: PRODUCT.into(),
update_order: role::UPDATE_ORDER.iter().map(|r| r.to_string()).collect(),
app_medium: MEDIUM.into(),
front_medium: MEDIUM.into(),
front_role: role::FRONT.into(),
keep_medium: KEEP_MEDIUM.into(),
front_keep_medium: KEEP_MEDIUM.into(),
upgraded_by_shop: Vec::new(),
medium_of: BTreeMap::new(),
other_medium: MEDIUM.into(),
// No backup at terminate (owner 2026-10-24).
backup_roles: Vec::new(),
words: WireWords {
name: "lake".into(),
title: "Lake".into(),
account_home: String::new(),
},
// The names a Lake serves under its host: the host, www. and s3. (the input store).
default_shape: serde_json::json!({ "names": ["", "www.", "s3."] }),
// The backends hold the data; the primary is the FE leader ([`Facts::primary_role`]).
data_roles: vec![WireDataRole {
role: role::BE.into(),
mirrors_primary: false,
set_suffix: String::new(),
}],
domain_proof_name: "lake-proof".into(),
live_slot: true,
// The Lake mints no actor tickets (owner rule: the Lake stays thin).
tickets: false,
// The smallest Lake, as the shop's test button buys it.
test_actions: vec![TestAction {
id: "lake-mini".into(),
label: "Test: the smallest Lake".into(),
kind: TestActionKind::PrivateInstance {
front_plan: String::new(),
main_plan: String::new(),
main_data_gib: 0,
},
}],
// No grow lane (a grow is a new order), no restore; the guardian runs.
guardian: true,
// The buyer's numbers, as data: the shop checks an order against them (v2.0 P3).
form: Some(order_form()),
// ── What a Lake IS, as data (v2.0 P3-FACTS, A24 §2.2): its own roles, no twin, its
// FE leader the primary, and what may change on a standing Lake.
primary_role: role::FE.into(),
roles: roles(),
twin: None,
grow: Some(grow_rule()),
// Its own box kind, as nordisk-pairing pairs it (A28 H13: a box kind, never elected).
box_kind: Some(PRODUCT.into()),
// The order kinds its boxes run; any other is refused by name (A28 M23).
order_kinds: ORDER_KINDS.iter().map(|k| k.to_string()).collect(),
..Default::default()
}
}
/// **The Lake's roles** (P3-FACTS): the front once, the ingestors, backends and frontends laid
/// n times (`be`, `be-2`, …), the graph database laid or not.
pub fn roles() -> Vec<RoleFacts> {
let r = |role: &str, many: bool, optional: bool| RoleFacts {
role: role.into(),
many,
optional,
..RoleFacts::default()
};
vec![
r(role::FRONT, false, false),
r(role::INGESTOR, true, false),
r(role::BE, true, false),
r(role::FE, true, false),
r(role::GRAPH, false, true),
]
}
/// **What may change on a standing Lake** (P3-FACTS): the input store never shrinks; a plan
/// change may lay more ingestors, backends and frontends, and the graph database; none is
/// taken away by a buy (a backend holds its data).
pub fn grow_rule() -> GrowRule {
GrowRule {
never_shrinks: vec!["store_gib".into()],
may_add: [role::INGESTOR, role::BE, role::FE, role::GRAPH]
.iter()
.map(|r| r.to_string())
.collect(),
..GrowRule::default()
}
}
/// **The Lake's order form** (v2.0 P3, the parametric order): [`LakeOrder`]'s fields, ranges and
/// defaults, as data the shop checks an order against before it is priced or held.
pub fn order_form() -> monetize_product_guest::facts::OrderForm {
use monetize_product_guest::facts::{FieldKind, OrderField, OrderForm};
let d = LakeOrder::default();
let f = |name: &str,
kind: FieldKind,
(min, max, step): (u64, u64, u64),
choices: &[u64],
default: serde_json::Value,
words: &str| OrderField {
name: name.into(),
kind,
min,
max,
step,
choices: choices.to_vec(),
default,
words: words.into(),
};
OrderForm {
fields: vec![
f(
"store_gib",
FieldKind::Gib,
(100, 100_000, 100),
&[],
d.store_gib.into(),
"the input store (GiB)",
),
f(
"ingestors",
FieldKind::Count,
(1, 64, 0),
&[],
d.ingestors.into(),
"the most ingestors at once",
),
f(
"ingestor_plan",
FieldKind::Plan,
(0, 0, 0),
&[],
d.ingestor_plan.into(),
"the ingestors' plan",
),
f(
"be",
FieldKind::Count,
(1, 32, 0),
&[],
d.be.into(),
"Apache Doris backends",
),
f(
"fe",
FieldKind::Count,
(0, 0, 0),
&[1, 3, 5],
d.fe.into(),
"Apache Doris frontends",
),
f(
"graph",
FieldKind::Bool,
(0, 0, 0),
&[],
d.graph.into(),
"a graph database",
),
],
}
}
#[derive(Default)]
pub struct LakeGuest;
/// One plain line per call: `lake: guest op <n> <name> from monetize-server <what>`.
fn say(host: &dyn Host, op: u32, what: &str) {
let name = monetize_product_guest::ops::name(op).unwrap_or("?");
host.log(format!("lake: guest op {op} {name} from monetize-server {what}").trim_end());
}
impl Guest for LakeGuest {
/// No grants: the Lake's guest opens no door. The bytes ingested into the store (enforced on
/// the store) and the query seconds (a cap only, never reported).
fn manifest(&self) -> Manifest {
Manifest::new(PRODUCT, &[]).with_meters(vec![
WireMeter::new(
"ingested_bytes",
"bytes ingested into the Lake's input store",
Enforcement::Enforced,
)
.with_backing(Backing::DiskBytes),
WireMeter::cap_only(
"query_seconds",
"the query seconds the estate was bought with",
Enforcement::Measured,
),
])
}
fn lifecycle_facts(&self, host: &dyn Host) -> Result<LifecycleFacts, ProductError> {
say(host, monetize_product_guest::ops::LIFECYCLE_FACTS, "");
Ok(facts())
}
/// **The plan of one order** (op 91): the buyer's numbers ([`LakeOrder::of`]) laid as
/// [`plan_of`] lays them. An order out of range is refused by name.
fn lifecycle_lay_plan(
&self,
host: &dyn Host,
req: &LayPlanReq,
) -> Result<serde_json::Value, ProductError> {
let order = LakeOrder::of(req).map_err(ProductError::Refused)?;
say(
host,
monetize_product_guest::ops::LIFECYCLE_LAY_PLAN,
&format!(
"company={} order={}",
req.company,
serde_json::to_string(&order).unwrap_or_default()
),
);
let tier = req
.template_tiers
.get(MEDIUM)
.cloned()
.unwrap_or_else(|| "standard".into());
Ok(plan_of(&order, &req.company, &req.config, &tier))
}
/// One finding per silent machine (no report for the silent interval): a human looks; the
/// Lake plans no automatic remedy yet.
fn guardian_diagnose(&self, view: &EstateView) -> Result<Vec<Finding>, ProductError> {
let mut out = Vec::new();
for r in [role::FRONT, role::BE, role::FE] {
let silent = view.get(r).is_none_or(|b| {
view.now_unix_ms.saturating_sub(b.heard_unix_ms) > view.silent_after_ms
});
if silent {
out.push(Finding {
code: "lake_silent".into(),
role: r.into(),
words: format!("the Lake's {r} of {} has not reported", view.instance),
evidence: vec![format!("now={}", view.now_unix_ms)],
remedies: vec![Remedy::NewServer],
auto: None,
});
}
}
Ok(out)
}
fn guardian_plan(
&self,
remedy: Remedy,
view: &EstateView,
) -> Result<Result<Vec<Step>, String>, ProductError> {
Ok(Err(format!(
"the Lake plans no {remedy:?} for {}: a human lays the machine again",
view.instance
)))
}
fn guardian_observe(
&self,
role: &str,
what: &str,
_view: &EstateView,
) -> Result<StepState, ProductError> {
Ok(StepState::Waiting(format!(
"the Lake's {role} has not shown {what}"
)))
}
}
monetize_product_guest::export_product!(LakeGuest, grants = []);
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A host that records what the guest says and grants nothing.
#[derive(Default)]
struct Recorder {
lines: Mutex<Vec<String>>,
}
impl Host for Recorder {
fn log(&self, line: &str) {
self.lines.lock().unwrap().push(line.to_string());
}
}
fn req(order: serde_json::Value) -> LayPlanReq {
LayPlanReq {
company: "acme".into(),
config: serde_json::json!({"zone": "fi-hel2", "labels": {"shop": "one"}}),
sizes: serde_json::json!({ "order": order }),
..Default::default()
}
}
#[test]
fn the_guest_opens_no_door_and_answers_the_six_ops_only() {
let host = Recorder::default();
let g = LakeGuest;
let m = g.manifest();
assert!(m.grants.is_empty(), "the Lake is pulled: no grants");
assert_eq!(
m.meters.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
["ingested_bytes", "query_seconds"],
"the meter table rides in the manifest"
);
for (op, _) in monetize_product_guest::ops::ALL {
let _ = monetize_product_guest::dispatch(&g, &host, *op, &[]);
}
let lines = host.lines.lock().unwrap().clone();
assert!(
lines.iter().all(|l| l.starts_with("lake: guest op ")),
"{lines:#?}"
);
assert!(
lines.iter().any(|l| l.contains("lifecycle.facts")),
"{lines:#?}"
);
for (op, name) in monetize_product_guest::ops::RETIRED {
let raw = monetize_product_guest::dispatch(&g, &host, *op, &[]);
assert!(
String::from_utf8_lossy(&raw)
.contains(monetize_product_guest::ops::UNKNOWN_OP_PHRASE),
"op {op} ({name}) left the contract"
);
}
}
#[test]
fn the_plan_follows_the_buyers_numbers() {
let host = Recorder::default();
let o = serde_json::json!({"store_gib": 1000, "ingestors": 2, "ingestor_plan": "STARTER-2xCPU-4GB", "be": 3, "fe": 1, "graph": true});
let plan = LakeGuest.lifecycle_lay_plan(&host, &req(o)).unwrap();
let m = plan["machines"].as_array().unwrap();
assert_eq!(m.len(), 1 + 2 + 3 + 1 + 1, "1 + n + x + y (+1)");
let roles: Vec<&str> = m.iter().map(|x| x["role"].as_str().unwrap()).collect();
assert_eq!(
roles,
[
"front",
"ingestor",
"ingestor-2",
"be",
"be-2",
"be-3",
"fe",
"graph"
]
);
assert!(
!roles.iter().any(|r| ["main", "twin"].contains(r)),
"the Lake's roles are its own"
);
assert_eq!(
m[1]["plan"], "STARTER-2xCPU-4GB",
"the ingestors' plan is the buyer's"
);
assert_eq!(
m[3]["disks"][1]["gib"], 340,
"1000 GiB over 3 backends, rounded up to 10"
);
assert_eq!(m[3]["labels"]["role"], "be");
assert_eq!(m[3]["labels"]["shop"], "one");
assert_eq!(plan["zone"], "fi-hel2");
// The smallest Lake when the order is absent.
let small = LakeGuest
.lifecycle_lay_plan(
&host,
&LayPlanReq {
company: "acme".into(),
..Default::default()
},
)
.unwrap();
assert_eq!(small["machines"].as_array().unwrap().len(), 4);
// Grow = changing the numbers: one more backend is one more machine.
let o4 = serde_json::json!({"store_gib": 1000, "ingestors": 2, "ingestor_plan": "STARTER-2xCPU-4GB", "be": 4, "fe": 1, "graph": true});
let grown = LakeGuest.lifecycle_lay_plan(&host, &req(o4)).unwrap();
assert_eq!(grown["machines"].as_array().unwrap().len(), m.len() + 1);
}
#[test]
fn in_a_scaleway_zone_the_plans_are_scaleways_names() {
let host = Recorder::default();
let scw = |order: serde_json::Value| LayPlanReq {
company: "acme".into(),
config: serde_json::json!({"zone": "fr-par-1"}),
sizes: serde_json::json!({ "order": order }),
..Default::default()
};
let plan = LakeGuest
.lifecycle_lay_plan(&host, &scw(serde_json::Value::Null))
.unwrap();
let plans: Vec<&str> = plan["machines"]
.as_array()
.unwrap()
.iter()
.map(|m| m["plan"].as_str().unwrap())
.collect();
assert_eq!(
plans,
["DEV1-S", "DEV1-S", "DEV1-XL", "DEV1-M"],
"front, ingestor, be, fe at Scaleway"
);
let named = LakeGuest
.lifecycle_lay_plan(
&host,
&scw(serde_json::json!({"ingestor_plan": "DEV1-M", "graph": true})),
)
.unwrap();
let m = named["machines"].as_array().unwrap();
assert_eq!(
(m[1]["plan"].as_str(), m.last().unwrap()["plan"].as_str()),
(Some("DEV1-M"), Some("DEV1-XL")),
"the buyer's ingestors' plan; the graph's at Scaleway"
);
let up = LakeGuest
.lifecycle_lay_plan(&host, &req(serde_json::Value::Null))
.unwrap();
assert_eq!(
up["machines"][0]["plan"],
plans::FRONT,
"UpCloud's names elsewhere"
);
assert!(
plans::is_scaleway_zone("nl-ams-2")
&& !plans::is_scaleway_zone("fi-hel2")
&& !plans::is_scaleway_zone("se-sto1")
);
}
#[test]
fn an_order_out_of_range_is_refused_by_name() {
let host = Recorder::default();
for (bad, word) in [
(serde_json::json!({"fe": 2}), "1, 3 or 5 frontends"),
(serde_json::json!({"store_gib": 150}), "steps of 100"),
(serde_json::json!({"be": 0}), "backends"),
(serde_json::json!({"ingestors": 65}), "ingestors"),
(serde_json::json!({"rooms": 1}), "not a Lake order"),
] {
match LakeGuest.lifecycle_lay_plan(&host, &req(bad.clone())) {
Err(ProductError::Refused(w)) => assert!(w.contains(word), "{bad}: {w}"),
other => panic!("{bad}: {other:?}"),
}
}
}
#[test]
fn the_facts_say_a_pulled_lake_with_no_twin_and_its_own_roles() {
let f = facts();
assert_eq!(f.kind, "lake");
assert_eq!(f.front_role, "front");
assert!(f.data_roles.iter().all(|d| !d.mirrors_primary), "no twin");
assert!(f
.update_order
.iter()
.all(|r| !["main", "twin"].contains(&r.as_str())));
assert_eq!(
f.update_order.last().map(String::as_str),
Some("front"),
"the front drives the update and is upgraded last (the core's rule)"
);
assert!(f.backup_roles.is_empty(), "no backup at terminate");
assert!(!f.tickets, "the Lake mints no actor tickets");
assert!(
f.guardian && f.test_actions.iter().any(|t| t.id == "lake-mini"),
"the guardian and the test button ride in the facts"
);
}
/// ★ **The Lake's primary, roles, twin and grow rule are DATA in its facts** (P3-FACTS):
/// the FE leader holds the tenants (never the first data role, `be`); its five roles with
/// the three laid n times and the graph optional; no twin; the store never shrinks, and a
/// plan change may lay more ingestors, backends, frontends and the graph, and takes none away.
#[test]
fn the_facts_name_the_lakes_primary_roles_no_twin_and_its_grow_rule() {
let f = facts();
assert_eq!(f.primary(), "fe");
assert_eq!(f.role_names(), ["front", "ingestor", "be", "fe", "graph"]);
assert_eq!(
f.roles
.iter()
.filter(|r| r.many)
.map(|r| r.role.as_str())
.collect::<Vec<_>>(),
["ingestor", "be", "fe"]
);
assert!(f
.roles
.iter()
.any(|r| r.role == "graph" && r.optional && !r.many));
assert_eq!(f.twin_of(), None);
let g = f.grow_rule();
assert_eq!(g.never_shrinks, ["store_gib"]);
assert_eq!(
g.machines_refusal(&f.roles, &["be-2".into(), "graph".into()], &[]),
None
);
assert!(
g.machines_refusal(&f.roles, &[], &["be-2".into()])
.is_some(),
"a backend holds its data: never taken away by a buy"
);
// Every machine the plan lays is a member of one of the roles the facts name.
let host = Recorder::default();
let plan = LakeGuest
.lifecycle_lay_plan(
&host,
&req(serde_json::json!({"ingestors": 2, "be": 3, "fe": 3, "graph": true})),
)
.unwrap();
for m in plan["machines"].as_array().unwrap() {
let role = m["role"].as_str().unwrap();
assert!(
monetize_product_guest::facts::RoleFacts::of(&f.roles, role).is_some(),
"{role} is a role of the facts"
);
}
}
}