crates/monetize-server/src/estate_order/adopt.rs
//! **The ONE box ADOPTS a kit-installed operator site** (owner 2026-10-17: Gunnar sites are
//! upgraded by the ONE box's monetize, not by kit scripts). gunnar.rs was installed by its
//! kit (private-gunnar-ops): the ONE box knows it only as a site that STANDS (its boxes call
//! home) and holds no run for it, so `EstateOrder.UpdateEstate` has nothing to update.
//!
//! `EstateOrder.AdoptEstate` (the operator's alone; `monetize estate adopt <site> …` through
//! the box's door) makes the run an update needs, and nothing else:
//!
//! * the declared operator site's run (`site_order`, its label as the cloud names an estate,
//! [`super::site::company_of`]) in the delivered phase a laid ready site has ([`phase::CAPTURED`]),
//! its `laid` the machines the operator names — each one READ BACK by its id through the
//! shop's cloud ([`monetize_cloud::Cloud::status`]: the provider lists only what
//! carries a monetize label, and a kit's machines carry none, so their addresses and disks
//! come from the kit's own record and only their standing is the provider's word);
//! * its client key minted through the custody path a lay uses ([`crate::custody::Custody::mint`],
//! the hand-off's `estate-client.key`), its public half on the run, and the custody then
//! forgotten — the shop keeps the public half alone, as after a lay's hand-off;
//! * the front's feed lines answered ONCE to the operator: `shop_door.toml` (the door, the
//! company, the shop's key, the client key's secret name) and `secret.shop_client_key` (the
//! seed, hex). Never logged, never in the run file.
//!
//! **What an adopted run never does** ([`EstateRun::adopted_at_ms`]): the shop did not lay its
//! machines and they carry no label of it, so nothing here lays, re-lays, fences, gives back
//! or bills them — the guardian keeps waiting for the operator's kit for a lost box
//! ([`crate::fleet::ShopWorld`]), `Recharge` and `Terminate` are refused by name, the twin does
//! not grow by itself, the site's join record keeps keying its boxes, and its `Upgrade`
//! is refused by name unless the operator opened an update on that box (every estate alike). It never lapses (a site).
use std::collections::BTreeMap;
use monetize_cloud::lay::{company_name_refusal, reference_of, LaidEstate, LaidMachine};
use monetize_cloud::{CloudError, ResourceStatus, Zone};
use monetize_pay::Money;
use super::lifecycle::{listed, Lifecycle};
use super::{phase, price, site, EstateRun, EstateSizes, Quote, StartRefusal, Transition};
use crate::state::AppState;
/// The note an adoption leaves in the run's history (what a console and the tests look for).
pub const ADOPTED: &str = "adopted";
/// **What the operator asks to adopt.**
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AdoptAsk {
/// The declared operator site's label (`gunnar-rs`).
pub estate: String,
/// The zone its machines stand in; empty: the shop's.
pub zone: String,
/// front, main and twin: the server ids (required) and what the kit recorded of them.
pub machines: Vec<LaidMachine>,
/// Role → the release it runs, when known; absent: unknown (an update updates it).
pub release_of: BTreeMap<String, String>,
/// Adopt an adopted site again: a NEW client key (the old one stops opening the shop).
pub again: bool,
/// Who asked (the operator's principal, or `operator`).
pub by: String,
}
/// **What an adoption answers** — once.
pub struct Adopted {
pub run: EstateRun,
/// **The front's feed lines**: `shop_door.toml` and `secret.shop_client_key`. A secret as
/// a whole: answered once, to the operator, never logged and never kept here.
pub feed: BTreeMap<String, String>,
pub words: String,
}
impl std::fmt::Debug for Adopted {
/// Never a value of the feed.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Adopted")
.field("company", &self.run.company)
.field("feed", &self.feed.keys().collect::<Vec<_>>())
.field("words", &self.words)
.finish()
}
}
/// The front's feed key its shop door is fed under (gunnar-front `recharge::FEED_SHOP_DOOR`,
/// monetize-shop-pairing `contract::FEED_SHOP_DOOR`).
pub const FEED_SHOP_DOOR: &str = "shop_door.toml";
/// The front's feed key of the client key's seed: `secret.<client_key_secret>`.
pub fn client_key_feed() -> String {
format!("secret.{}", crate::shop_cloud::CLIENT_KEY_SECRET)
}
/// Pure: **the front's `shop_door.toml`** — the shop's door, the company its calls name, the
/// shop's key, and the name of the fed secret its client key is (as a lay's pairing feeds it).
pub fn shop_door_toml(door: &str, company: &str, shop_pubkey: &str) -> Result<String, String> {
let mut t = toml::Table::new();
t.insert("door".into(), toml::Value::String(door.trim().to_string()));
t.insert("company".into(), toml::Value::String(company.to_string()));
t.insert(
"shop_pubkey".into(),
toml::Value::String(shop_pubkey.trim().to_string()),
);
t.insert(
"client_key_secret".into(),
toml::Value::String(crate::shop_cloud::CLIENT_KEY_SECRET.into()),
);
toml::to_string(&t).map_err(|e| e.to_string())
}
/// Pure: **why `machines` are not an estate of `life`'s product to adopt**, or `None`: its
/// roles ([`Lifecycle::roles`]; Gunnar's front, main and twin), each once, each with its
/// server id, no id twice, every address given an IPv4 one.
pub fn machines_refusal(life: &Lifecycle, machines: &[LaidMachine]) -> Option<String> {
let roles = life.roles();
for role in &roles {
match machines.iter().filter(|m| m.role == *role).count() {
0 => {
return Some(format!(
"no {role} named: an adopted estate is {}, each by its server id",
listed(&roles, "and")
))
}
1 => {}
_ => return Some(format!("the {role} is named twice")),
}
}
if let Some(m) = machines.iter().find(|m| !roles.contains(&m.role)) {
return Some(format!(
"{:?} is no role of an estate ({})",
m.role,
roles.join(", ")
));
}
for m in machines {
if m.server.trim().is_empty() {
return Some(format!("the {} names no server id", m.role));
}
if m.server.chars().any(|c| c.is_whitespace() || c == '/') {
return Some(format!(
"the {}'s server id {:?} is not one",
m.role, m.server
));
}
for (what, ip) in [("public", &m.public_ipv4), ("utility", &m.utility_ipv4)] {
if !ip.trim().is_empty() && ip.trim().parse::<std::net::Ipv4Addr>().is_err() {
return Some(format!(
"the {}'s {what} address {ip:?} is not an IPv4 address",
m.role
));
}
}
}
let mut ids: Vec<&str> = machines.iter().map(|m| m.server.trim()).collect();
ids.sort_unstable();
if ids.windows(2).any(|w| w[0] == w[1]) {
return Some("two roles name the same server".into());
}
None
}
/// Pure: **an adopted estate's quote** — what was laid, priced at nothing: the shop bought none
/// of it and bills none of it (its ledger row is the operator's own, never revenue). The sizes
/// are read off `life`'s front, its primary and the machine mirroring the primary (Gunnar's
/// front, main and twin).
pub fn quote_of(
life: &Lifecycle,
machines: &[LaidMachine],
currency: monetize_pay::Currency,
) -> Quote {
let zero = Money { minor: 0, currency };
let plan = |role: &str| {
machines
.iter()
.find(|m| m.role == role)
.map(|m| m.plan.clone())
.unwrap_or_default()
};
let primary = life.primary_role();
let mirror = life
.data_roles()
.into_iter()
.find(|r| r.mirrors_primary)
.map(|r| r.role)
.unwrap_or_default();
let main_data_gib = machines
.iter()
.find(|m| m.role == primary)
.and_then(|m| m.disks.iter().skip(1).map(|d| d.gib).min())
.unwrap_or(0);
Quote {
sizes: EstateSizes {
front_plan: plan(&life.update_plan().front_role),
main_plan: plan(&primary),
main_data_gib,
..Default::default()
},
twin_plan: plan(&mirror),
machines: machines
.iter()
.map(|m| price::MachineLine {
role: m.role.clone(),
plan: m.plan.clone(),
cores: 0,
memory_mib: 0,
disks_gib: m.disks.iter().map(|d| d.gib).collect(),
})
.collect(),
cost: monetize_cloud::Price {
minor_per_month: 0,
currency,
},
cost_in_sale: zero,
monthly: zero,
margin_bp: 0,
term_months: 0,
term_days: 0,
total: zero,
offered: Vec::new(),
}
}
/// **`EstateOrder.AdoptEstate`** — see the module. Refused by name, before anything is minted:
/// a site that is not a declared operator site or names no site, machines that are not front,
/// main and twin or that the provider does not hold, a site that already has a run here (an
/// adopted one: unless `again`), a shop with no custody or no door.
pub fn adopt(state: &AppState, ask: &AdoptAsk) -> Result<Adopted, StartRefusal> {
let base = state.estates.cfg.as_ref().ok_or_else(|| {
StartRefusal::NotSold(
"this monetize lays no estates ([estate_orders] is absent): nothing is adopted".into(),
)
})?;
let estate = ask.estate.trim();
let Some(admin) = state.registry.operator_admin_of(estate) else {
return Err(StartRefusal::NotSold(format!("{estate:?} is not a declared operator site ([estates.<label>] operator_site = true with its owner): only the operator's own sites are adopted")));
};
if state.registry.site_of(estate).is_none() {
return Err(StartRefusal::NotSold(format!(
"[estates.{estate}] names no site: an adopted site is the site's own apex"
)));
}
let company = site::company_of(estate);
if let Some(why) = company_name_refusal(&company) {
return Err(StartRefusal::NotSold(format!("the site {estate} cannot be adopted: as the cloud names an estate it is {company:?}, and {why}")));
}
let mut terms = super::terms_on(state, base, Some(estate)).map_err(StartRefusal::NotSold)?;
terms.site_itself = company.clone();
// The site's product's roles, words and sizes: its lifecycle's (Gunnar's front, main, twin).
let life = super::lifecycle::of(&terms).map_err(StartRefusal::NotSold)?;
let roles = life.roles();
if let Some(why) = machines_refusal(&life, &ask.machines) {
return Err(StartRefusal::BadName(why));
}
if let Some(role) = ask.release_of.keys().find(|r| !roles.contains(r)) {
return Err(StartRefusal::BadName(format!(
"{role:?} is no role of an estate: a release is named for {}",
listed(&roles, "or")
)));
}
let existing = state
.estates
.get(&company)
.filter(|r| !phase::is_gone(&r.phase));
match &existing {
Some(r) if r.adopted_at_ms > 0 && !ask.again => return Err(StartRefusal::Taken(format!("{estate} is adopted already ({}): `--again` adopts it again with a NEW client key — its front must then be fed the new one", super::iso_date(r.adopted_at_ms)))),
Some(r) if r.adopted_at_ms == 0 => return Err(StartRefusal::Taken(format!("{estate} has a run here already ({}, {}): this box laid it, nothing is adopted", r.reference, r.phase))),
_ => {}
}
let custody = state.estates.custody.as_ref().ok_or_else(|| {
StartRefusal::NotSold("this box holds no custody: no client key can be minted".into())
})?;
let door = terms.shop_door().map_err(StartRefusal::NotNow)?;
let product = terms.sold_product().map_err(StartRefusal::NotNow)?;
let shop_pubkey: String = state
.signer_for(product)
.public_key()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
// Each machine stands at the provider, by its own id.
let cloud = state.registry.find_cloud(&terms.cloud).ok_or_else(|| {
StartRefusal::NotSold(format!(
"the cloud {:?} is not linked here: the machines cannot be read back",
terms.cloud
))
})?;
for m in &ask.machines {
let id = monetize_cloud::ResourceId {
provider: cloud.name().into(),
id: m.server.trim().to_string(),
};
match monetize_cloud::ask::status(&cloud, &id) {
Ok(ResourceStatus::Active)
| Ok(ResourceStatus::Provisioning)
| Ok(ResourceStatus::Stopped) => {}
Ok(ResourceStatus::Gone) | Err(CloudError::UnknownResource(_)) => {
return Err(StartRefusal::NotFound(format!(
"the {} {} is not found at the provider ({}): nothing is adopted",
m.role, m.server, terms.cloud
)))
}
Err(e) => {
return Err(StartRefusal::NotNow(format!(
"the provider could not say whether the {} {} stands: {e}; nothing is adopted",
m.role, m.server
)))
}
}
}
let _pen = state.estates.pen_for_a_call(&terms, &company)?;
let now = state.now();
let dir = state
.estates
.run_dir(&company)
.ok_or_else(|| StartRefusal::NotSold("no estate-orders directory".into()))?;
// **The client key, minted through the lay's custody path**: a fresh seal (an earlier
// adoption's or a gone run's is forgotten first), its public half named, its seed read from
// the hand-off — then forgotten: the shop keeps the public half alone.
let minted = (|| -> Result<(String, String), String> {
custody.forget(&company)?;
custody.mint(&company)?;
let public = custody
.estate_client_public(&company)?
.ok_or("the custody holds no client key for the estate")?;
let handoff = custody.hand_off(&company, &dir.join("adopt-handoff"))?;
let seed =
std::fs::read_to_string(handoff.dir().join(crate::custody::handoff::ESTATE_CLIENT))
.map_err(|e| format!("the hand-off's client key: {e}"))?;
drop(handoff);
custody.forget(&company)?;
Ok((public, seed.trim().to_string()))
})();
let (public, seed) = match minted {
Ok(k) => k,
Err(e) => {
// What was minted is forgotten; a custody that cannot forget it is said (A28 M16),
// and the next adopt of the estate forgets it first.
let left = match custody.forget(&company) {
Ok(_) => String::new(),
Err(f) => format!("; the custody still holds what was minted for it ({f}), and the next adopt forgets it first"),
};
return Err(StartRefusal::NotNow(format!(
"the client key could not be minted: {e}; nothing is adopted{left}"
)));
}
};
let feed = BTreeMap::from([
(
FEED_SHOP_DOOR.to_string(),
shop_door_toml(&door, &company, &shop_pubkey).map_err(StartRefusal::NotNow)?,
),
(client_key_feed(), seed),
]);
let zone = if ask.zone.trim().is_empty() {
terms.zone.clone()
} else {
ask.zone.trim().to_string()
};
let mut machines: Vec<LaidMachine> = roles
.iter()
.filter_map(|r| ask.machines.iter().find(|m| m.role == *r).cloned())
.collect();
for m in &mut machines {
m.server = m.server.trim().to_string();
m.medium_clone.clear();
}
let laid = LaidEstate {
reference: reference_of(&company),
company: company.clone(),
zone: Zone(zone),
machines: machines.clone(),
narrate_from: String::new(),
shape: None,
};
// MAP-SEG 4: the machines join the labelled listing from now on — labels only, nothing
// else is written; a cloud that cannot label is said, and the inventory looks them up by uuid.
let labelled = match monetize_cloud::ask::label_adopted(&cloud, &laid) {
Ok(ids) => format!(
"; {} of its servers and disks labelled {}",
ids.len(),
laid.reference
),
Err(e) => format!("; NOT labelled ({e}): the inventory looks its machines up by uuid"),
};
let named = |m: &LaidMachine| {
format!(
"{} {}{}",
m.role,
m.server,
if m.public_ipv4.is_empty() {
String::new()
} else {
format!(" ({} / {})", m.public_ipv4, m.utility_ipv4)
}
)
};
let note = format!(
"{ADOPTED} by {}: the kit-installed site {estate} is this box's to update — {}; its client key minted (public {}…), its front to be fed it",
if ask.by.trim().is_empty() { "the operator" } else { ask.by.trim() },
machines.iter().map(named).collect::<Vec<_>>().join(", "),
&public[..public.len().min(12)]
) + &labelled;
let run = match existing {
Some(mut run) => {
run.laid = Some(laid);
run.client_key = public;
if !ask.release_of.is_empty() {
run.release_of = ask.release_of.clone();
}
run.adopted_at_ms = now.max(1);
run.secrets_forgotten_at_ms = now.max(1);
run.history.push(Transition {
phase: String::new(),
at_ms: now,
note: format!("{note} (again: the earlier client key opens nothing any more)"),
});
run
}
None => {
let currency = price::sale_currency(&terms).unwrap_or(monetize_pay::Currency::Eur);
let quote = quote_of(&life, &machines, currency);
let zero = Money { minor: 0, currency };
EstateRun {
company: company.clone(),
reference: reference_of(&company),
email: String::new(),
estate: estate.to_string(),
account: admin.clone(),
phase: phase::CAPTURED.into(),
ordered_at_ms: now,
held_at_ms: 0,
amount: zero,
quote,
domain: String::new(),
proof: String::new(),
dns_waiting_since_ms: 0,
dns_verified_at_ms: 0,
captured_at_ms: now,
paid_until_ms: 0,
handle: None,
checkout_url: String::new(),
laid: Some(laid),
dns: Vec::new(),
dns_writer: String::new(),
product: terms.product.trim().to_string(),
stopped_why: String::new(),
stopped_at_ms: 0,
receipt: None,
why: String::new(),
history: vec![Transition {
phase: phase::CAPTURED.into(),
at_ms: now,
note,
}],
// Nobody is mailed: the site ran before this box knew it.
told: true,
given_back: Vec::new(),
give_back_failures: 0,
give_back_retry_at_ms: 0,
peers: Vec::new(),
laid_cost: zero,
spend_cap: Money {
minor: terms.spend_cap_monthly_minor,
currency,
},
renewal: None,
renewals: Vec::new(),
reminded: Vec::new(),
delete_on_ms: 0,
suspended_at_ms: 0,
stopped: Vec::new(),
change: None,
changes: Vec::new(),
twin_floor: String::new(),
twin_changed_at_ms: 0,
sizes_read_at_ms: 0,
client_key: public,
growth: None,
cap_waiting_since_ms: 0,
cap_cleared_at_ms: 0,
queue_looked_at_ms: 0,
queue_told_at_ms: 0,
owed_told_at_ms: 0,
queue_why: String::new(),
failures: 0,
cancelled_at_ms: 0,
told_tried_at_ms: now,
// The custody was forgotten above: the shop keeps the public client key alone.
secrets_forgotten_at_ms: now.max(1),
// No release clone was laid: nothing to give back.
media_forgotten_at_ms: now.max(1),
release_of: ask.release_of.clone(),
material_taken_at_ms: 0,
sign_in_again_at_ms: 0,
sale_checked_at_ms: 0,
seam_looked_at_ms: 0,
test: false,
operator_test: super::is_operator_buy(state, estate, &admin),
site_order: true,
site_bearer_sha256: String::new(),
site_bearer_at_ms: 0,
site_bearer_until_ms: 0,
site_ticket_key: String::new(),
site_seed_key: String::new(),
update: None,
updates: Vec::new(),
live_update_off: false,
adopted_at_ms: now.max(1),
update_taken_at_ms: 0,
new_account: None,
reversals: Vec::new(),
reversed_at_ms: 0,
unreleased: Vec::new(),
}
}
};
super::save(state, &run).map_err(|e| {
StartRefusal::NotNow(format!("the run could not be written: {e}; adopt it again"))
})?;
let words = format!("{estate} is adopted as {} ({}): this box updates it from now on (`estate update {estate}`). Feed its FRONT the two lines answered here — {} and {} — then put its installer material in its primary's slot.", run.company, run.reference, FEED_SHOP_DOOR, client_key_feed());
// The log names the run, never the feed.
eprintln!(
"monetize-server: estate order {}: {}",
run.reference,
run.history
.last()
.map(|t| t.note.as_str())
.unwrap_or(ADOPTED)
);
Ok(Adopted { run, feed, words })
}
/// Whether `run` was adopted (a kit-installed site the shop did not lay).
pub fn is_adopted(run: &EstateRun) -> bool {
run.adopted_at_ms > 0
}
/// The refusal a verb that would lay, re-lay or give back an adopted estate's machines answers.
pub fn not_laid_here(run: &EstateRun, what: &str) -> String {
format!("{} was adopted from its kit's install, not laid by this box: its machines carry no label of this shop, so {what} is its kit's, never this box's", run.estate)
}
#[cfg(test)]
mod tests {
use super::*;
fn m(role: &str, server: &str) -> LaidMachine {
LaidMachine {
role: role.into(),
hostname: String::new(),
title: String::new(),
plan: "1xCPU-2GB".into(),
server: server.into(),
public_ipv4: "198.51.100.1".into(),
utility_ipv4: "10.0.0.1".into(),
disks: vec![],
medium_clone: String::new(),
}
}
#[test]
fn an_estate_to_adopt_is_front_main_and_twin_each_by_its_own_server() {
let ok = vec![m("front", "f"), m("main", "a"), m("twin", "t")];
assert_eq!(
machines_refusal(&crate::testing::lifecycles::gunnar(), &ok),
None
);
assert!(
machines_refusal(&crate::testing::lifecycles::gunnar(), &ok[..2])
.unwrap()
.contains("no twin")
);
assert!(machines_refusal(
&crate::testing::lifecycles::gunnar(),
&[m("front", "f"), m("main", "a"), m("twin", "a")]
)
.unwrap()
.contains("same server"));
assert!(machines_refusal(
&crate::testing::lifecycles::gunnar(),
&[m("front", "f"), m("main", ""), m("twin", "t")]
)
.unwrap()
.contains("no server id"));
assert!(machines_refusal(
&crate::testing::lifecycles::gunnar(),
&[
m("front", "f"),
m("main", "a"),
m("twin", "t"),
m("relay", "r")
]
)
.unwrap()
.contains("no role"));
let mut bad = ok.clone();
bad[0].public_ipv4 = "gunnar.rs".into();
assert!(
machines_refusal(&crate::testing::lifecycles::gunnar(), &bad)
.unwrap()
.contains("not an IPv4")
);
}
#[test]
fn the_shop_door_is_fed_as_a_lays_pairing_feeds_it() {
let t: toml::Table = toml::from_str(
&shop_door_toml("https://monetize.ignalina.dk", "gunnarrs", &"ab".repeat(32)).unwrap(),
)
.unwrap();
assert_eq!(t["door"].as_str(), Some("https://monetize.ignalina.dk"));
assert_eq!(t["company"].as_str(), Some("gunnarrs"));
assert_eq!(t["client_key_secret"].as_str(), Some("shop_client_key"));
assert_eq!(client_key_feed(), "secret.shop_client_key");
}
#[test]
fn an_adopted_estate_is_priced_at_nothing_and_quoted_as_it_stands() {
let mut main = m("main", "a");
main.disks = vec![
monetize_cloud::lay::LaidDisk {
title: "sys".into(),
id: "d0".into(),
gib: 40,
tier: "maxiops".into(),
address: "virtio:0".into(),
},
monetize_cloud::lay::LaidDisk {
title: "data".into(),
id: "d1".into(),
gib: 100,
tier: "maxiops".into(),
address: "virtio:1".into(),
},
];
let q = quote_of(
&crate::testing::lifecycles::gunnar(),
&[m("front", "f"), main, m("twin", "t")],
monetize_pay::Currency::Eur,
);
assert_eq!(
(q.monthly.minor, q.total.minor, q.cost.minor_per_month),
(0, 0, 0)
);
assert_eq!(
(q.sizes.main_data_gib, q.twin_plan.as_str()),
(100, "1xCPU-2GB")
);
}
}