crates/monetize-server/src/estate_order/lifecycle.rs
//! **ONE lifecycle per product kind** (owner 2026-10-17): what a sold estate of a product IS —
//! the machines it is laid as, the order its boxes are updated in and the media each runs, the
//! boxes the shop upgrades in place, what a terminate keeps, and the sale check that lets its
//! money be captured. The core (`estate_order`) holds the money, the queue, the pen, the run
//! file, the cloud and the DNS; it asks THIS for every product-specific rule and hardcodes none.
//!
//! ```text
//! [estate_orders] product = "<id>" ──▶ [products.<id>] plugin (else the id, else its family
//! before the first '-': `gunnar-test2` is a gunnar)
//! ──▶ the lifecycle registered for that kind
//! ──▶ none: the start and the quote are REFUSED by name
//! ```
//!
//! There is no native lifecycle (v2.0 P2, T12 12.4): a product's own guest brings its lifecycle
//! ([`register`], from `guest_lifecycle::follow`); any other product is refused by name rather
//! than laid with someone else's rules. The tests sell on `crate::testing::lifecycles`.
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use monetize_cloud::lay::EstatePlan;
use super::{EstateOrderConfig, EstateSizes, Installer, LadderStep, SaleCheckAt, Shape};
use crate::facts::{Facts, GrowRule, RoleFacts, TwinFacts};
/// **What the product is called** in the words its buyer reads (`Your Gunnar is ready`, `Your
/// gunnar acme.gunnar.rs is paid until …`, `a private Gunnar is not sold here`).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ProductWords {
/// In a sentence's middle and in a line item (`gunnar`).
pub name: String,
/// As a name (`Gunnar`).
pub title: String,
/// Where a buyer's account of it lives, as the buyer knows it (`gunnar.rs`); empty: unnamed.
pub account_home: String,
}
impl ProductWords {
/// The words of a kind that names none: its kind, and the kind with a capital.
pub fn of_kind(kind: &str) -> ProductWords {
let mut c = kind.chars();
let title = c
.next()
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
.unwrap_or_default();
ProductWords {
name: kind.to_string(),
title,
account_home: String::new(),
}
}
/// Where no product's lifecycle can be named (a box that sells no estates).
pub fn neutral() -> ProductWords {
ProductWords {
name: "estate".into(),
title: "estate".into(),
account_home: String::new(),
}
}
}
/// **What a buyer's mails say of the product** beyond its name (lane M4): what it is, where it
/// serves, the buyer's first step and its pay page, each a line the product brings (its
/// lifecycle, native or guest). An empty line is not said. `{rp}` is the sign-in host,
/// `{account}` the buyer's account, `{pay_page}` the estate's pay page.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct MailWords {
/// What it is, after its name in the thanks (`an artifact repository`).
pub noun: String,
/// Where the buyer reaches what it serves (`git is at git.{rp}.`).
pub serves: String,
/// What an own domain serves (`git and the web`); empty: the web.
pub own_domain_serves: String,
/// The buyer's first step (`Your first repository, {account}/welcome, says how to start.`).
pub first_step: String,
/// Where the next period is paid (`Pay for its next period at {pay_page}.`).
pub pay_line: String,
}
impl MailWords {
/// `self` with every line `over` names put in its place.
pub fn overlaid(self, over: &MailWords) -> MailWords {
let pick = |own: String, o: &str| {
if o.trim().is_empty() {
own
} else {
o.to_string()
}
};
MailWords {
noun: pick(self.noun, &over.noun),
serves: pick(self.serves, &over.serves),
own_domain_serves: pick(self.own_domain_serves, &over.own_domain_serves),
first_step: pick(self.first_step, &over.first_step),
pay_line: pick(self.pay_line, &over.pay_line),
}
}
}
/// **A machine whose data disks are grown** (the data set bought at the shop): its role, whether
/// its data disk holds the PRIMARY's whole set (and so follows the primary's paid size), and the
/// suffix its data set is booked under (`<product><suffix>`). The first is the primary.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DataRole {
pub role: String,
/// Its one data disk holds the primary's whole set (Gunnar's twin).
pub mirrors_primary: bool,
/// `-twin`: its set is `<product>-twin`; empty: `<product>`.
pub set_suffix: String,
}
/// **How a sold estate is updated** to the release the shop sells now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatePlan {
/// The boxes in the order they are updated (the primary keeps serving while its log-holder
/// goes first; the box that drives the others last).
pub order: Vec<String>,
/// The medium kind the app boxes' release is read off (`release_id(cfg, app_medium)`).
pub app_medium: String,
/// The medium kind the driving box's (front's) release is read off.
pub front_medium: String,
/// The role that drives the update and is upgraded last, by itself or by the shop.
pub front_role: String,
/// The medium kind a box the shop upgrades is keep-booted from (`install_then=poweroff`).
pub keep_medium: String,
/// The medium kind the front is keep-booted from by the shop.
pub front_keep_medium: String,
/// The roles `SoldEstate.Upgrade` keep-boots (the front upgrades last, by itself).
pub upgraded_by_shop: Vec<String>,
/// **The boxes carry a live slot**: an update may stage the release's live app on them.
/// `false`: every box of every update goes through its keep medium (Gunnar's and Holger's
/// carry one; a product guest that declares none, as the toy).
pub live_slot: bool,
}
/// **How a product lays an estate**: the plan for `company` at `sizes` (its guest's op 91, or a
/// test's own rule), asked with the lifecycle itself so a plan can read its facts.
pub type LayPlan = Arc<
dyn Fn(&Lifecycle, &EstateOrderConfig, &str, &EstateSizes, &str) -> Result<EstatePlan, String>
+ Send
+ Sync,
>;
/// **A product kind's estate lifecycle** (MONETIZE-FIX step 3: no adapter trait, A24 §2.2): the
/// product's FACTS (op 90) and its lay plan (op 91), nothing else. Every rule the core asks is
/// read off the facts here; none of them moves money or writes the run.
#[derive(Clone)]
pub struct Lifecycle {
/// The product id it came with (`[products.<id>]`), for the words of a refusal.
pub product: String,
/// What the product says it IS (op 90).
pub facts: Facts,
lay: LayPlan,
}
impl std::fmt::Debug for Lifecycle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Lifecycle")
.field("product", &self.product)
.field("kind", &self.facts.kind)
.finish()
}
}
impl Lifecycle {
/// The lifecycle of `product` with `facts`, laid by `lay`.
pub fn new(product: impl Into<String>, facts: Facts, lay: LayPlan) -> Lifecycle {
Lifecycle {
product: product.into(),
facts,
lay,
}
}
/// The product kind this is (`gunnar`).
pub fn kind(&self) -> &str {
&self.facts.kind
}
/// **LAY**: the plan `lay_estate` is asked for — every machine with its role, plan, disks
/// and labels, `{company}` filled in. `Err` names why no plan could be made: nothing is laid.
pub fn lay_plan(
&self,
cfg: &EstateOrderConfig,
company: &str,
sizes: &EstateSizes,
twin_plan: &str,
) -> Result<EstatePlan, String> {
(self.lay)(self, cfg, company, sizes, twin_plan)
}
/// **UPDATE / UPGRADE**: the order, the media, the keep media and the boxes the shop
/// keep-boots ([`UpdatePlan`]).
pub fn update_plan(&self) -> UpdatePlan {
let f = &self.facts;
UpdatePlan {
order: f.update_order.clone(),
app_medium: f.app_medium.clone(),
front_medium: f.front_medium.clone(),
front_role: f.front_role.clone(),
keep_medium: f.keep_medium.clone(),
front_keep_medium: f.front_keep_medium.clone(),
upgraded_by_shop: f.upgraded_by_shop.clone(),
// The guest says (B4); a guest built before the field reads as a live slot.
live_slot: f.live_slot,
}
}
/// **MEDIA**: the medium kind a machine of `role` is laid, templated, seeded and released from.
pub fn medium_kind(&self, role: &str) -> String {
self.facts
.medium_of
.get(role)
.cloned()
.unwrap_or_else(|| self.facts.other_medium.clone())
}
/// The roles whose disks hold the estate's data.
pub fn backup_roles(&self) -> Vec<String> {
self.facts.backup_roles.clone()
}
/// **SALE CHECK**: before the order's money is captured, prove the sold instance serves what
/// the buyer is about to be sent to: the installer judges by the facts' proof (S4).
pub fn sale_check(
&self,
installer: &dyn Installer,
at: &SaleCheckAt<'_>,
) -> Result<String, String> {
installer.sale_check(at)
}
/// **WORDS**: what the buyer reads the product called. A product that names none: its kind.
pub fn words(&self) -> ProductWords {
let w = &self.facts.words;
if w.name.trim().is_empty() {
return ProductWords::of_kind(self.kind());
}
ProductWords {
name: w.name.clone(),
title: w.title.clone(),
account_home: w.account_home.clone(),
}
}
/// **MAIL WORDS**: the `mail_words` rider of its facts' `default_shape` (lane M4).
pub fn mail_words(&self) -> MailWords {
super::guest_lifecycle::mail_words_over(None, &self.facts.default_shape)
}
/// **SHAPE**: the machines an `[estate_orders]` that names no `shape` is priced at, from its
/// facts' `default_shape`. `None`: the config must name one.
pub fn default_shape(&self) -> Option<Shape> {
if self.facts.default_shape.is_null() {
return None;
}
// A JSON that carries only the riders (`ladder`, `mail_words`, `names`) names no shape.
if ["front", "main", "twin"]
.iter()
.all(|k| self.facts.default_shape.get(*k).is_none())
{
return None;
}
match serde_json::from_value(self.facts.default_shape.clone()) {
Ok(s) => Some(s),
Err(e) => {
eprintln!("monetize-server: lifecycle {}: [products.{}] its default shape is not one ({e}); the config must name the shape", self.kind(), self.product);
None
}
}
}
/// **SIZES**: the `ladder` rider of its facts' `default_shape`. `None`: the shop's ladder.
pub fn ladder(&self) -> Option<Vec<LadderStep>> {
let steps = self.facts.default_shape.get("ladder")?;
match serde_json::from_value::<Vec<LadderStep>>(steps.clone()) {
Ok(s) if !s.is_empty() => Some(s),
Ok(_) => None,
Err(e) => {
eprintln!("monetize-server: lifecycle {}: [products.{}] its ladder is not one ({e}); the shop's ladder sells it", self.kind(), self.product);
None
}
}
}
/// **GROW**: the machines whose data disks a bought data set grows, the primary first.
pub fn data_roles(&self) -> Vec<DataRole> {
self.facts
.data_roles
.iter()
.map(|r| DataRole {
role: r.role.clone(),
mirrors_primary: r.mirrors_primary,
set_suffix: r.set_suffix.clone(),
})
.collect()
}
/// **OWN DOMAIN**: the name of the TXT proof a customer's own domain carries.
pub fn proof_name(&self) -> String {
if self.facts.domain_proof_name.trim().is_empty() {
format!("{}-proof", self.kind())
} else {
self.facts.domain_proof_name.clone()
}
}
/// **NAMES**: the `names` rider of its facts' `default_shape` (`""`, `www.`, …); absent: the
/// host, `www.` and `git.`.
pub fn served_prefixes(&self) -> Vec<String> {
super::guest_lifecycle::names_over(None, &self.facts.default_shape)
}
/// **PRIMARY**: its facts' `primary_role`, else the first data role.
pub fn primary_role(&self) -> String {
self.facts.primary()
}
/// **FACTS**: what the product says it IS (op 90).
pub fn facts(&self) -> Facts {
self.facts.clone()
}
/// **ROLES**: its facts' `roles`, else those its lifecycle fields name.
pub fn roles(&self) -> Vec<String> {
self.facts.role_names()
}
/// **ROLE FACTS**: its facts' `roles`, else each named role laid once.
pub fn role_facts(&self) -> Vec<RoleFacts> {
if self.facts.roles.is_empty() {
return self
.roles()
.into_iter()
.map(|role| RoleFacts {
role,
..Default::default()
})
.collect();
}
self.facts.roles.clone()
}
/// **TWIN**: its facts' `twin`, else the data role that mirrors the primary.
pub fn twin(&self) -> Option<TwinFacts> {
self.facts.twin_of()
}
/// **GROW RULE**: its facts' `grow`, else main's data never shrinks.
pub fn grow(&self) -> GrowRule {
self.facts.grow_rule()
}
}
/// Pure: **every laid member of each role in `order`** (monetize v2.0 P1, the Lake): a role,
/// then its numbered members `<role>-<n>` as laid, by `n` (`be`, `be-2`, `be-3`). A product
/// that lays each role once (Gunnar, Holger) gets `order` back as it is.
pub fn members_in(order: &[String], laid: &[&str]) -> Vec<String> {
let mut out = Vec::new();
for role in order {
if laid.contains(&role.as_str()) && !out.contains(role) {
out.push(role.clone());
}
let mut more: Vec<(u32, &str)> = laid
.iter()
.filter_map(|l| {
l.strip_prefix(role.as_str())?
.strip_prefix('-')?
.parse::<u32>()
.ok()
.map(|n| (n, *l))
})
.collect();
more.sort();
for (_, l) in more {
if !out.iter().any(|o| o == l) {
out.push(l.to_string());
}
}
}
out
}
/// Pure: **`a, b and c`** (`joint` = `and`, `or`): the roles as a sentence names them.
pub fn listed(roles: &[String], joint: &str) -> String {
match roles {
[] => String::new(),
[one] => one.clone(),
[init @ .., last] => format!("{} {joint} {last}", init.join(", ")),
}
}
/// **The product a run's ledger rows, setup lines and fleet feed are on**: the kind of `cfg`'s
/// product's lifecycle (`gunnar` for every Gunnar site, `gunnar-test2` too), else — no estates
/// sold here, or a product with no lifecycle — `cfg`'s product id, else `estate`.
pub fn instance_product(cfg: Option<&EstateOrderConfig>) -> String {
match cfg {
Some(c) => match of(c) {
Ok(l) => l.kind().to_string(),
Err(_) => c
.sold_product()
.map(str::to_owned)
.unwrap_or_else(|_| ProductWords::neutral().name),
},
None => ProductWords::neutral().name,
}
}
/// **The product's name in a sentence's middle** (`gunnar`): [`words_of`]'s `name`.
pub fn name_of(cfg: &EstateOrderConfig) -> String {
words_of(Some(cfg)).name
}
/// **The buyer's words for `cfg`'s product**: its lifecycle's, else (no estates sold here)
/// [`ProductWords::neutral`].
pub fn words_of(cfg: Option<&EstateOrderConfig>) -> ProductWords {
cfg.and_then(|c| of(c).ok())
.map(|l| l.words())
.unwrap_or_else(ProductWords::neutral)
}
/// **The buyer's mail words for `cfg`'s product**: its lifecycle's, else none.
pub fn mail_words_of(cfg: Option<&EstateOrderConfig>) -> MailWords {
cfg.and_then(|c| of(c).ok())
.map(|l| l.mail_words())
.unwrap_or_default()
}
/// The lifecycles a plugin (a product's guest, or a test) brought, by kind.
static REGISTERED: RwLock<BTreeMap<String, Arc<Lifecycle>>> = RwLock::new(BTreeMap::new());
/// **A product plugin brings its lifecycle** for `kind`; a later one replaces it.
pub fn register(kind: &str, lifecycle: Arc<Lifecycle>) {
REGISTERED
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(kind.to_string(), lifecycle);
}
/// **A plugin's lifecycle goes** (its guest is gone, or no longer brings one): nothing answers
/// for its kind any more.
pub fn unregister(kind: &str) {
REGISTERED
.write()
.unwrap_or_else(|e| e.into_inner())
.remove(kind);
}
/// The lifecycle a plugin (or a test) REGISTERED for `kind`, if any; never a native one.
pub fn registered(kind: &str) -> Option<Arc<Lifecycle>> {
REGISTERED
.read()
.unwrap_or_else(|e| e.into_inner())
.get(kind)
.cloned()
}
/// The lifecycle of `kind`, if any: the one a plugin registered (there is no native one).
pub fn find(kind: &str) -> Option<Arc<Lifecycle>> {
registered(kind)
}
/// **The lifecycle `cfg`'s product is sold with**, or the refusal that names it: the kind its
/// `[products.<id>] plugin` names ([`EstateOrderConfig::lifecycle`]), else the product id, else
/// its family before the first `-` (a site's own product, `gunnar-test2`).
pub fn of(cfg: &EstateOrderConfig) -> Result<Arc<Lifecycle>, String> {
let product = cfg.sold_product()?;
let named = cfg.lifecycle.trim();
if !named.is_empty() {
return find(named).ok_or_else(|| refused(product, named));
}
if let Some(l) = find(product) {
return Ok(l);
}
match product.split_once('-') {
Some((family, _)) if !family.is_empty() => {
find(family).ok_or_else(|| refused(product, product))
}
_ => Err(refused(product, product)),
}
}
/// Pure: **a product id's family**: the part before its first `-` (`gunnar-test2` → `gunnar`,
/// `holger-rs` → `holger`), else the id itself — the rule [`of`] reads a site's own product by.
pub fn family_of(product: &str) -> &str {
let p = product.trim();
p.split_once('-')
.map(|(f, _)| f)
.filter(|f| !f.is_empty())
.unwrap_or(p)
}
fn refused(product: &str, kind: &str) -> String {
format!("[estate_orders] product {product:?} has no estate lifecycle (kind {kind:?}): nothing here lays, updates or ends such an estate, so none is sold")
}
/// **The plan the core lays**: [`Lifecycle::lay_plan`] of `cfg`'s product, refused by name
/// for a product with no lifecycle.
pub fn lay_plan_of(
cfg: &EstateOrderConfig,
company: &str,
sizes: &EstateSizes,
twin_plan: &str,
) -> Result<EstatePlan, String> {
plan_of(of(cfg)?.as_ref(), cfg, company, sizes, twin_plan)
}
/// Pure: **the product's shape, as the cloud checks a plan by it** (A4-FACTS): its role facts
/// ([`Lifecycle::role_facts`]) — which roles it lays, which n times, which may be left out,
/// which ports each opens to anyone.
pub fn shape_of(life: &Lifecycle) -> monetize_cloud::lay::PlanShape {
// The ports each role opens to anyone ride along as the product declares them (OX-WGDS5).
let open = |ports: Vec<monetize_product_guest::facts::PortFacts>| {
ports
.into_iter()
.map(|p| monetize_cloud::lay::OpenPort {
protocol: p.protocol,
port: p.port,
why: p.why,
})
.collect()
};
monetize_cloud::lay::PlanShape {
roles: life
.role_facts()
.into_iter()
.map(|r| monetize_cloud::lay::ShapeRole {
role: r.role,
many: r.many,
optional: r.optional,
open: open(r.open),
})
.collect(),
}
}
/// **`laid` as its firewall is written NOW** (OX-WGDS5): the record with the product's shape as
/// its facts say it now ([`shape_of`]), so every firewall written again — an update's, a plan
/// change's — opens each machine's declared ports and no other, also on an estate laid before
/// its product declared one. A product with no lifecycle here keeps the record's own shape.
pub fn shaped_now(
cfg: &EstateOrderConfig,
laid: &monetize_cloud::lay::LaidEstate,
) -> monetize_cloud::lay::LaidEstate {
let shape = of(cfg)
.map(|l| Some(shape_of(&l)))
.unwrap_or_else(|_| laid.shape.clone());
monetize_cloud::lay::LaidEstate {
shape,
..laid.clone()
}
}
/// **`life`'s plan, shaped** (A4-FACTS): [`Lifecycle::lay_plan`] with the product's own
/// shape on it ([`shape_of`]), so the cloud's check holds the plan to the product's roles and
/// never to Gunnar's three machines. Every plan the core prices, lays or lays beside an estate
/// is made here.
pub fn plan_of(
life: &Lifecycle,
cfg: &EstateOrderConfig,
company: &str,
sizes: &EstateSizes,
twin_plan: &str,
) -> Result<EstatePlan, String> {
let mut plan = life.lay_plan(cfg, company, sizes, twin_plan)?;
plan.shape = Some(shape_of(life));
Ok(plan)
}
#[cfg(test)]
mod tests {
#[test]
fn the_members_of_a_role_laid_n_times_follow_it_and_a_role_laid_once_is_itself() {
let o = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
super::members_in(&o(&["twin", "main", "front"]), &["front", "main", "twin"]),
o(&["twin", "main", "front"])
);
assert_eq!(
super::members_in(
&o(&["front", "ingestor", "fe", "be"]),
&[
"front",
"ingestor",
"be",
"be-10",
"be-2",
"fe",
"ingestor-2",
"graph",
"berry"
]
),
o(&[
"front",
"ingestor",
"ingestor-2",
"fe",
"be",
"be-2",
"be-10"
])
);
}
use super::*;
use monetize_cloud::lay::MachinePlan;
/// A product kind with an estate of ONE machine — not Gunnar's shape.
fn solo_plan(
_: &Lifecycle,
cfg: &EstateOrderConfig,
company: &str,
_sizes: &EstateSizes,
_twin_plan: &str,
) -> Result<EstatePlan, String> {
let m = MachinePlan {
role: "solo".into(),
hostname: format!("solo-{company}"),
title: String::new(),
plan: "STARTER-1xCPU-1GB".into(),
boot_order: String::new(),
labels: Default::default(),
interfaces: Vec::new(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: Vec::new(),
};
Ok(EstatePlan {
reference: monetize_cloud::lay::reference_of(company),
company: company.into(),
zone: monetize_cloud::Zone(cfg.zone.clone()),
machines: vec![m],
narrate_from: cfg.narrate_source().to_string(),
shape: None,
})
}
/// The facts of a one-machine kind `kind`.
fn solo_facts(kind: &str) -> Facts {
Facts {
kind: kind.into(),
update_order: vec!["solo".into()],
app_medium: "solo".into(),
front_role: "solo".into(),
keep_medium: "solo-keep".into(),
live_slot: false,
other_medium: "solo".into(),
backup_roles: vec!["solo".into()],
..Facts::default()
}
}
fn solo() -> Lifecycle {
Lifecycle::new("solo-kind", solo_facts("solo-kind"), Arc::new(solo_plan))
}
fn cfg(product: &str) -> EstateOrderConfig {
EstateOrderConfig {
product: product.into(),
apex: "example.com".into(),
vendor: "holdpay".into(),
shop_door: "https://shop.example.com".into(),
..EstateOrderConfig::default()
}
}
/// ★ **The core lays what the product's lifecycle says**: a kind that registered its own
/// lifecycle is laid as ITS machines, never as Gunnar's front, main and twin.
#[test]
fn the_core_lays_with_the_lifecycle_the_product_registered() {
register("solo-kind", Arc::new(solo()));
let plan = lay_plan_of(&cfg("solo-kind"), "acme", &EstateSizes::default(), "")
.expect("a registered kind is laid");
assert_eq!(
plan.machines
.iter()
.map(|m| m.role.as_str())
.collect::<Vec<_>>(),
["solo"],
"its own machines"
);
assert_eq!(of(&cfg("solo-kind")).unwrap().update_plan().order, ["solo"]);
assert_eq!(of(&cfg("solo-kind")).unwrap().backup_roles(), ["solo"]);
}
/// ★ **An estate's DNS names are its product's** (A17, the echo bench): a kind that serves no
/// git gets no `git.<host>`; Gunnar keeps its three names, Holger serves two (A28 M8).
#[test]
fn an_estates_names_are_the_ones_its_product_serves() {
let web = Lifecycle::new(
"web-kind",
Facts {
backup_roles: Vec::new(),
default_shape: serde_json::json!({ "names": ["", "www."] }),
..solo_facts("web-kind")
},
Arc::new(solo_plan),
);
register("web-kind", Arc::new(web));
assert_eq!(
cfg("web-kind").names_of("acme"),
["acme.example.com", "www.acme.example.com"],
"no git. for a product that serves none"
);
crate::testing::lifecycles::install();
assert_eq!(
cfg("gunnar").names_of("acme"),
[
"acme.example.com",
"www.acme.example.com",
"git.acme.example.com"
],
"gunnar keeps its three names"
);
assert_eq!(
cfg("holger").names_of("acme"),
[
"acme.example.com",
"www.acme.example.com",
"repo.acme.example.com"
],
"holger serves no git (M8), and its repository's name"
);
assert_eq!(
crate::testing::lifecycles::holger().served_prefixes(),
["", "www.", "repo."]
);
}
/// ★ HOL-REPO-NAME: **every name the product serves under the apex is the serving estate's
/// own** — a sold Holger estate called `repo` would be `repo.<apex>`, the operator site's
/// repository name: its records are refused by name, as `www`, `git` and `monetize` are.
#[test]
fn a_sold_estate_never_takes_a_served_name_of_the_serving_estate() {
crate::testing::lifecycles::install();
let c = EstateOrderConfig {
product: "holger".into(),
apex: "holger.rs".into(),
..EstateOrderConfig::default()
};
let e = c
.records_of("repo", "198.51.100.1")
.expect_err("repo.holger.rs is the site's own repository name");
assert!(
e.contains("repo.holger.rs") && e.contains("serving estate's own name"),
"{e}"
);
let ok = c
.records_of("acme", "198.51.100.1")
.expect("a buyer's names");
assert_eq!(
ok.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
[
"acme.holger.rs",
"www.acme.holger.rs",
"repo.acme.holger.rs"
]
);
}
/// ★ **A product with no lifecycle is refused by NAME** — the plan, and so the quote and the
/// lay, never fall back to Gunnar's rules.
#[test]
fn a_product_with_no_lifecycle_is_refused_by_name_never_given_gunnars() {
for p in ["acme", "zork-rs"] {
let e = lay_plan_of(&cfg(p), "acme", &EstateSizes::default(), "")
.err()
.unwrap_or_else(|| panic!("{p}: laid with someone else's rules"));
assert!(
e.contains(&format!("{p:?}")) && e.contains("lifecycle"),
"{p}: {e}"
);
assert!(of(&cfg(p)).is_err(), "{p}");
}
let Err(e) = of(&cfg("")) else {
panic!("no product, no lifecycle")
};
assert!(e.contains("[estate_orders] product"), "{e}");
}
/// ★ **A Gunnar-kind product is laid by the lifecycle registered for `gunnar`**: `gunnar`, a
/// site of it (`gunnar-test2`), and a product whose `[products.<id>] plugin` is gunnar.
#[test]
fn a_gunnar_kind_product_is_laid_exactly_as_before() {
crate::testing::lifecycles::install();
let c = cfg("gunnar-test2");
assert_eq!(of(&c).unwrap().kind(), "gunnar");
assert_eq!(
lay_plan_of(&c, "acme", &EstateSizes::default(), "").unwrap(),
plan_of(
&crate::testing::lifecycles::gunnar(),
&c,
"acme",
&EstateSizes::default(),
""
)
.unwrap()
);
let named = EstateOrderConfig {
lifecycle: "gunnar".into(),
..cfg("shop1")
};
assert_eq!(of(&named).unwrap().kind(), "gunnar", "named by its plugin");
let u = of(&cfg("gunnar")).unwrap().update_plan();
assert_eq!(
(
u.order.join(","),
u.app_medium.as_str(),
u.keep_medium.as_str()
),
("twin,main,front".to_string(), "gunnar", "gunnar-keep")
);
}
/// ★ **The kind comes from config**: `[estate_orders] product` → `[products.<id>] plugin`.
#[test]
fn the_start_reads_the_kind_off_the_products_plugin() {
register("solo-kind", Arc::new(solo()));
let dir =
std::env::temp_dir().join(format!("monetize-lifecycle-kind-{}", std::process::id()));
let text = format!(
"data_dir = {:?}\n[products.shop2]\nplugin = \"solo-kind\"\n[estate_orders]\ncurrency = \"EUR\"\nproduct = \"shop2\"\nshop_door = \"https://shop.example.com\"\n",
dir.display().to_string()
);
let config = crate::config::Config::from_toml(&text).unwrap();
let orders = super::super::EstateOrders::from_config_with(
&config,
&crate::registry::Linked::default(),
)
.expect("starts");
let c = orders.cfg.as_ref().unwrap();
assert_eq!(of(c).unwrap().kind(), "solo-kind");
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **The START refuses a product with no lifecycle by name**, before anything is held.
#[test]
fn the_start_refuses_a_product_with_no_lifecycle_by_name() {
let dir =
std::env::temp_dir().join(format!("monetize-lifecycle-start-{}", std::process::id()));
let mut state = crate::state::AppState::in_memory(crate::registry::Registry::new());
state.estates = super::super::EstateOrders::with(
cfg("acme"),
dir.join("estate-orders"),
Arc::new(super::super::NoInstaller),
Arc::new(super::super::NoDns),
crate::custody::Custody::open(dir.join("custody")).unwrap(),
);
let e = super::super::start_for(
&state,
"acme",
"buyer@example.com",
"",
"alice",
&EstateSizes::default(),
)
.expect_err("refused");
let words = e.to_string();
assert!(
words.contains("\"acme\"") && words.contains("lifecycle"),
"{words}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A product kind that names its own words, proof, shape and data machines.
fn own() -> Lifecycle {
let mut shape = serde_json::to_value(crate::testing::lifecycles::gunnar_shape()).unwrap();
shape["front"]["hostname"] = "solo-front-{company}".into();
let facts = Facts {
words: monetize_product_guest::wire::WireWords {
name: "solo".into(),
title: "Solo".into(),
account_home: "solo.example".into(),
},
default_shape: shape,
data_roles: vec![monetize_product_guest::wire::WireDataRole {
role: "solo".into(),
mirrors_primary: false,
set_suffix: "-data".into(),
}],
domain_proof_name: "solo-proof".into(),
..solo_facts("own-kind")
};
Lifecycle::new("own-kind", facts, Arc::new(solo_plan))
}
/// ★ **The type's default shape is a Mini of three machines that names no product** (v2.0
/// P3): the Gunnar-shaped test lifecycle's plans and disks, named by role alone.
#[test]
fn the_types_default_shape_is_a_mini_that_names_no_product() {
let (d, g) = (Shape::default(), crate::testing::lifecycles::gunnar_shape());
for (role, a, b) in [
("front", &d.front, &g.front),
("main", &d.main, &g.main),
("twin", &d.twin, &g.twin),
] {
assert_eq!(
(
&a.plan,
a.disks
.iter()
.map(|x| (x.gib, &x.tier, &x.volume))
.collect::<Vec<_>>()
),
(
&b.plan,
b.disks
.iter()
.map(|x| (x.gib, &x.tier, &x.volume))
.collect::<Vec<_>>()
),
"{role}: the Mini's plan and disks"
);
assert_eq!(a.hostname, format!("{role}-{{company}}"));
}
assert!(
!serde_json::to_string(&d)
.unwrap()
.to_lowercase()
.contains("gunnar"),
"the core's default names no product"
);
assert_eq!(EstateOrderConfig::default().shape, Shape::default());
}
/// ★ **A kind is spoken of, and proven, in its OWN words** — never Gunnar's: what the buyer
/// reads of an order, the start's refusals, the account sentence, the records an own domain
/// carries.
#[test]
fn a_kind_is_spoken_of_and_proven_in_its_own_words_never_gunnars() {
let w = own().words();
assert_eq!(super::super::account::not_proven(&w), "a private Solo is bought, read and changed by its solo.example account: sign in to that account and ask again");
assert_eq!(super::super::account::not_proven(&crate::testing::lifecycles::gunnar_words()), "a private Gunnar is bought, read and changed by its gunnar.rs account: sign in to that account and ask again", "Gunnar's sentence as before");
assert_eq!(super::super::no_install_path(&crate::testing::lifecycles::gunnar_words()), "this shop has no way to install a private Gunnar yet ([estate_orders] names no pairing, installer or install worker)");
assert!(super::super::no_install_path(&w).contains("a private Solo yet"));
assert!(super::super::reserved_account_as("kit-1", &[], &w)
.unwrap()
.contains("the new Solo keeps"));
assert!(
super::super::reserved_account_as("solo-ui", &["solo-ui".to_string()], &w)
.unwrap()
.contains("the new Solo keeps"),
"the product's own machine principal"
);
assert!(
super::super::reserved_account_as("gunnar-ui", &[], &w).is_none(),
"another product's machine names are no business of this one's"
);
let r = super::super::domain::required_as(
&own().proof_name(),
"x.example.org",
"acme",
"example.com",
"t",
);
assert_eq!(
(r[1].name.as_str(), r[1].value.as_str()),
("_solo-proof.x.example.org", "solo-proof=t")
);
assert_eq!(
crate::services::estate_order::not_sold_here(&w),
"A private Solo is not sold here right now."
);
assert_eq!(
crate::services::estate_order::not_sold_here(
&crate::testing::lifecycles::gunnar_words()
),
"A private Gunnar is not sold here right now."
);
for line in [
super::super::account::not_proven(&w),
super::super::no_install_path(&w),
] {
assert!(!line.to_lowercase().contains("gunnar"), "{line}");
}
}
/// ★ **A kind that names nothing is spoken of by its kind**, proves with `<kind>-proof`,
/// grows no data and has no shape of its own — never Gunnar's facts by default.
#[test]
fn a_kind_that_names_no_facts_gets_its_kinds_never_gunnars() {
assert_eq!(
solo().words(),
ProductWords {
name: "solo-kind".into(),
title: "Solo-kind".into(),
account_home: String::new()
}
);
assert_eq!(solo().proof_name(), "solo-kind-proof");
assert!(solo().data_roles().is_empty() && solo().default_shape().is_none());
assert_eq!(
words_of(None),
ProductWords::neutral(),
"no estates sold here: no product named"
);
}
/// ★ **An unset `[estate_orders.shape]` is the product's lifecycle's**; a named one is the
/// file's.
#[test]
fn an_unset_shape_is_the_lifecycles_and_a_named_one_the_files() {
register("own-kind", Arc::new(own()));
let dir =
std::env::temp_dir().join(format!("monetize-lifecycle-shape-{}", std::process::id()));
let base = format!("data_dir = {:?}\n[products.shop3]\nplugin = \"own-kind\"\n[estate_orders]\ncurrency = \"EUR\"\nproduct = \"shop3\"\nshop_door = \"https://shop.example.com\"\n", dir.display().to_string());
let orders = super::super::EstateOrders::from_config_with(
&crate::config::Config::from_toml(&base).unwrap(),
&crate::registry::Linked::default(),
)
.expect("starts");
assert_eq!(
orders.cfg.as_ref().unwrap().shape.front.hostname,
"solo-front-{company}",
"the lifecycle's shape"
);
let mut named = crate::testing::lifecycles::gunnar_shape();
named.front.hostname = "named-{company}".into();
let mut config = crate::config::Config::from_toml(&base).unwrap();
config.estate_orders.as_mut().unwrap().shape_given = Some(named);
let orders = super::super::EstateOrders::from_config_with(
&config,
&crate::registry::Linked::default(),
)
.expect("starts");
assert_eq!(
orders.cfg.as_ref().unwrap().shape.front.hostname,
"named-{company}",
"the file's shape"
);
let _ = std::fs::remove_dir_all(&dir);
}
}