Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-cloud/src/cloud.rs

//! **`Cloud` — the cloud trait (A24 §2.5, nine ops).**
//!
//! A cloud lays whole estates. What a cloud does is nine verbs, one per action a
//! provider takes: say what it is, list what it sells, price something, lay an
//! estate, change one, boot a box, write the firewall, give things back, and list
//! what it holds. Each verb that has more than one kind takes the kind as an enum
//! and answers one arm per kind ([`Change`] → [`Changed`], [`Boot`] → [`Booted`],
//! [`Listing`] → [`Listed`]); [`ask`] decodes the one arm each question expects.
//!
//! | op | its kinds |
//! |---|---|
//! | `manifest` | the name, the zones, the setting NAMES, whether it may grow a data set now |
//! | `catalog` | the plans sold in a zone, and the price of one public IPv4 address |
//! | `price` | an estate plan, a data set's growth, one resource ([`Priced`]) |
//! | `lay` | a whole estate |
//! | `change` | a server's plan, a data set grown or settled, release media given back, a resource ensured, one machine retired, an adopted estate labelled ([`Change`]) |
//! | `boot` | a keep boot, a power change, a restore from backup ([`Boot`]) |
//! | `firewall` | every machine's rules, written whole |
//! | `destroy` | everything under some references, or one resource ([`Give`]) |
//! | `inventory` | the account, a data set, one resource's standing, what still stands under some references, the room it serves, whether it would lay a plan now ([`Listing`]) |
//!
//! The spend cap, the pool ledger and the journal's FILE stay on the host
//! ([`crate::cap`], [`crate::pool`]); a cloud sees a [`Journal`] and writes its steps
//! through it. A guest's wire keeps its op numbers (`monetize-cloud-guest`'s `ops`);
//! the host answers these nine from them, so a signed guest built before keeps serving.

use std::collections::BTreeMap;

use crate::grow::{DataSet, GrowOutcome, Journal, SetSurvey};
use crate::lay::{EstatePlan, KeepBoot, LaidEstate, LaidMachine, PlanSpec, RestoreFromBackup, Restored};
use crate::{CloudError, Ensured, Inventory, Owned, OwnedKind, Price, Reclaimed, Resource, ResourceId, ResourceStatus, Room, Zone};

/// **What a cloud is**: its name (the `[clouds.<name>]` it is configured under),
/// the zones it lays in, the NAMES of the settings it reads (never a value;
/// the credential is the host's), and whether it may grow a data set as configured
/// right now (`grow_refused: None`; `Some(why)` names what is off — the operator's
/// switch, read from the same settings a growth refuses on).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CloudManifest {
    pub provider: String,
    pub zones: Vec<Zone>,
    pub settings: Vec<String>,
    #[serde(default)]
    pub grow_refused: Option<String>,
}

/// **What a cloud sells in one zone**: its plans with their monthly price, and the
/// price of one public IPv4 address when the provider bills it apart (`None`: it
/// does not, or prices no address of its own).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Catalog {
    pub plans: Vec<PlanSpec>,
    pub ipv4: Option<Price>,
}

impl Catalog {
    pub fn plan(&self, name: &str) -> Option<&PlanSpec> {
        self.plans.iter().find(|p| p.name == name)
    }
}

/// **What `price` is asked**: what it adds a month at the provider, from its own list. Pure.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Priced<'a> {
    /// Laying a whole estate: every server at its plan, every disk at its size, every
    /// public IPv4 address.
    Plan(&'a EstatePlan),
    /// Enlarging every member of a data set to its target: `price(target) − price(current)`
    /// summed over the members still below it, at the provider's own sizes.
    Grow(&'a DataSet),
    /// One resource (a disk, a server, a resize).
    Resource(&'a Resource),
}

/// **A change to what a cloud holds**, journaled where it buys or rewrites; resumable:
/// a member already at its target is left alone.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Change {
    /// A server's plan (cores and memory): stopped, changed, started; its disks
    /// untouched. `delta` is what the change adds a month, admitted by the host's
    /// spend cap before the call.
    Plan { server: ResourceId, plan: String, delta: Price },
    /// A data set grown to its target: per member detach → resize → wait → re-attach
    /// at the same address → verify, every step journaled. Idempotent on the target.
    /// `estate`: a SOLD estate's set, run on the cloud's estate lane where it has one.
    Grow { set: DataSet, estate: bool },
    /// From any journal state, every member of the set brought to an attached state —
    /// never resized further, never deleted; closes the open epoch.
    Settle { set: DataSet },
    /// The release media a lay attached, ejected and given back (the ones the journal
    /// recorded only), and each laid machine's metadata service turned off.
    ForgetMedia { laid: LaidEstate },
    /// **One resource made available for `reference`**: `OnHand` when the pool already
    /// covers it, otherwise bought. Idempotent on `reference` + the resource, so a retried
    /// order never buys twice. `room` is what the product measured: `OnHand` must not buy.
    Ensure { reference: String, resource: Resource, room: Option<Room> },
    /// **One laid machine given back WHOLE** — the guardian's fence: whatever of its
    /// server, disks and release clone still stands, server first. Never capped.
    Retire { reference: String, machine: LaidMachine },
    /// **An ADOPTED estate's machines labelled as this shop's** (MAP-SEG 4): each machine of
    /// `laid` that stands — its server and every disk it names — gets `monetize_ref =
    /// laid.reference` and an `adopt:<role>:…` `monetize_res`; labels it already carries are
    /// kept. Writes labels and nothing else; never capped.
    LabelAdopted { laid: LaidEstate },
}

/// **What `change` answers**, one arm per [`Change`] arm.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Changed {
    /// A plan changed.
    Done,
    /// A set grown or settled.
    Grown(GrowOutcome),
    /// The media given back, by title.
    Forgotten(Vec<String>),
    /// A resource on hand or bought.
    Ensured(Ensured),
    /// What the provider accepted to give back (empty when nothing stood).
    Retired(Vec<ResourceId>),
    /// The ids labelled.
    Labelled(Vec<String>),
}

/// **What `boot` is asked.**
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Boot {
    /// One laid machine booted ONCE from a keep medium (an upgrade, F4): the medium
    /// rewrites the system and powers the box off; the box comes back on its own disk.
    /// Boxed, as is [`Boot::Restore`]: both are hundreds of bytes and `Power` is a few.
    Keep(Box<KeepBoot>),
    /// Stopped (`on = false`, its disks kept) or started. Buys nothing.
    Power { server: ResourceId, on: bool },
    /// A lost front restored from its newest provider backup (S8).
    Restore(Box<RestoreFromBackup>),
}

/// **What `boot` answers**, one arm per [`Boot`] arm.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Booted {
    Machine(LaidMachine),
    Powered,
    /// `None`: no backup stands and nothing was changed.
    Restored(Option<Restored>),
}

/// **What `destroy` gives back.** Never capped: it is the call that lowers the bill.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Give<'a> {
    /// Everything bought under any of these references: servers, disks, media clones
    /// and backups. An empty slice matches NOTHING.
    References(&'a [String]),
    /// One resource (a release clone, a member of a fence, a hosted disk). One the
    /// provider no longer has is given back already.
    Resource(&'a ResourceId),
}

/// **What `inventory` is asked.** Every kind is a read: nothing is bought.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Listing<'a> {
    /// Everything at the provider with a `monetize_ref`, and a count of what has none.
    Account,
    /// A data set as the provider sees it now: every member's size, state, attachment.
    Set(&'a DataSet),
    /// One resource's standing.
    Status(&'a ResourceId),
    /// What still stands under these references, asked of the account again (the
    /// proof after a give-back). The box's own data set is spared; empty matches nothing.
    Under(&'a [String]),
    /// **The room this cloud serves within** — the box's own iron's wall: whether it
    /// could serve `delta` now (told what the product measured), the bytes it can still
    /// serve, its declared ceilings. A cloud that buys has no wall of its own.
    Room { delta: &'a [Resource], room: Option<Room> },
    /// **Would it lay this plan now?** PURE: nothing is reserved, nothing is bought. A
    /// provider's own admission (a quota) and the host's spend cap answer it; `Err` says
    /// why, an over-cap refusal TYPED ([`CloudError::OverCap`]) so the estate job waits in
    /// its queue instead of failing. A cloud with nothing to check admits it.
    Admits(&'a EstatePlan),
}

/// **The room a cloud serves within** ([`Listing::Room`]).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RoomLeft {
    /// `Err(reason)` names what is short, in the words a purchase would refuse with.
    pub serve: Result<(), String>,
    /// `None`: no wall of its own (a cloud that buys).
    pub left_bytes: Option<u64>,
    /// Declared ceilings on product-scope meters, by meter name. Empty: none declared.
    pub ceilings: BTreeMap<String, u64>,
}

impl RoomLeft {
    /// A cloud with no wall of its own: it can serve, has no byte figure, declares nothing.
    pub fn unwalled() -> RoomLeft {
        RoomLeft { serve: Ok(()), left_bytes: None, ceilings: BTreeMap::new() }
    }
}

/// **What `inventory` answers**, one arm per [`Listing`] arm.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Listed {
    Account(Inventory),
    Set(SetSurvey),
    Status(ResourceStatus),
    Under(Vec<ResourceId>),
    Room(RoomLeft),
    Admitted,
}

/// **A cloud, in nine verbs.** Every op is idempotent on what it names: a second
/// `lay` of the same plan ADOPTS what the first one bought, a second `destroy` of
/// the same references gives back nothing and fails nothing. An account that cannot
/// be read is an error, never an empty answer.
pub trait Cloud: Send + Sync {
    /// The name, the zones, the setting NAMES, the grow switch. Pure.
    fn manifest(&self) -> CloudManifest;
    /// The plans sold in `zone`, priced, and the address price. Pure (a price list read).
    fn catalog(&self, zone: &Zone) -> Result<Catalog, CloudError>;
    /// What `what` adds a month, from the provider's own list. Pure.
    fn price(&self, what: &Priced<'_>) -> Result<Price, CloudError>;
    /// Every disk, then every server with its disks, each labelled with the plan's
    /// reference and journaled (intent before, the id confirmed after).
    fn lay(&self, plan: &EstatePlan, journal: &Journal) -> Result<LaidEstate, CloudError>;
    /// One change; journaled where it buys or rewrites; resumable.
    fn change(&self, change: &Change, journal: &Journal) -> Result<Changed, CloudError>;
    /// A keep boot, a power change or a restore; journaled where it buys or rewrites.
    fn boot(&self, boot: &Boot, journal: &Journal) -> Result<Booted, CloudError>;
    /// Every machine's rules written again, whole, against the addresses it has now.
    fn firewall(&self, laid: &LaidEstate) -> Result<(), CloudError>;
    /// Give `what` back. Never capped.
    fn destroy(&self, what: &Give<'_>) -> Result<Reclaimed, CloudError>;
    /// A read of what the cloud holds.
    fn inventory(&self, what: &Listing<'_>) -> Result<Listed, CloudError>;
}

/// A journal that records nothing — for a verb that has none to write to (a power
/// change, a hosted ensure), when it is answered through [`Cloud::boot`] or [`Cloud::change`].
pub fn no_journal() -> Journal {
    Journal::with_sink(Vec::new(), Box::new(|_| Ok(())))
}

/// The answer arm did not match the asked arm: a provider's bug, said by name.
pub fn wrong_answer(verb: &str, got: &impl std::fmt::Debug) -> CloudError {
    CloudError::Refused(format!("{verb}: the cloud answered {got:?} to a {verb}"))
}

/// The `Booted` arm did not match the `Boot` arm: a provider's bug, said by name.
pub fn wrong_boot_answer(verb: &str, got: &Booted) -> CloudError {
    wrong_answer(verb, got)
}

/// **What of `inventory` stands under `references`**, the box's own data set spared, the
/// rows' ids. What a cloud whose listing is its proof answers [`Listing::Under`] with.
pub fn under(inventory: Inventory, references: &[String]) -> Vec<ResourceId> {
    inventory
        .owned
        .into_iter()
        .filter(|row| row.kind != OwnedKind::Dataset)
        .filter(|row| row.reference.as_deref().is_some_and(|r| references.iter().any(|want| want == r)))
        .map(|row| row.id)
        .collect()
}

/// **One laid machine given back by rows** — what a cloud whose give-back is its own
/// per-resource destroy answers [`Change::Retire`] with: what [stands](ask::machine_standing),
/// each given back through `destroy`; a resource it will not let go of fails the retire BY
/// NAME after the rest is asked for, so a retry finds only what is left.
pub fn retire_by_rows(c: &dyn Cloud, reference: &str, machine: &LaidMachine) -> Result<Vec<ResourceId>, CloudError> {
    let standing = ask::machine_standing(c, reference, machine)?;
    let mut failures = Vec::new();
    for id in &standing {
        match ask::destroy_one(c, id) {
            Ok(()) | Err(CloudError::UnknownResource(_)) => {}
            Err(e) => failures.push(format!("{}: {e}", id.id)),
        }
    }
    if !failures.is_empty() {
        return Err(CloudError::Refused(format!("the {} box of {reference} is not given back: {}", machine.role, failures.join("; "))));
    }
    Ok(standing)
}

/// **The questions the host asks a cloud, each one op and the one arm it expects.**
/// Written once here, so a caller reads the answer it asked for and a wrong arm is a
/// named refusal, never another answer.
pub mod ask {
    use super::*;

    /// Everything at the provider with a `monetize_ref` ([`Listing::Account`]).
    pub fn account(c: &dyn Cloud) -> Result<Inventory, CloudError> {
        match c.inventory(&Listing::Account)? {
            Listed::Account(i) => Ok(i),
            other => Err(wrong_answer("inventory", &other)),
        }
    }
    /// A data set as the provider sees it now ([`Listing::Set`]).
    pub fn survey(c: &dyn Cloud, set: &DataSet) -> Result<SetSurvey, CloudError> {
        match c.inventory(&Listing::Set(set))? {
            Listed::Set(s) => Ok(s),
            other => Err(wrong_answer("survey_set", &other)),
        }
    }
    /// One resource's standing ([`Listing::Status`]).
    pub fn status(c: &dyn Cloud, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
        match c.inventory(&Listing::Status(id))? {
            Listed::Status(s) => Ok(s),
            other => Err(wrong_answer("status", &other)),
        }
    }
    /// What still stands under `references` ([`Listing::Under`]); empty matches nothing.
    pub fn orphans_for(c: &dyn Cloud, references: &[String]) -> Result<Vec<ResourceId>, CloudError> {
        match c.inventory(&Listing::Under(references))? {
            Listed::Under(ids) => Ok(ids),
            other => Err(wrong_answer("orphans_for", &other)),
        }
    }
    /// The room this cloud serves within ([`Listing::Room`]).
    pub fn room(c: &dyn Cloud, delta: &[Resource], room: Option<Room>) -> Result<RoomLeft, CloudError> {
        match c.inventory(&Listing::Room { delta, room })? {
            Listed::Room(r) => Ok(r),
            other => Err(wrong_answer("room", &other)),
        }
    }
    /// Whether this cloud could serve `delta` now ([`Listing::Room`]'s `serve`); a read that
    /// fails is the refusal, in its words.
    pub fn can_serve(c: &dyn Cloud, delta: &[Resource], room_: Option<Room>) -> Result<(), String> {
        room(c, delta, room_).map_err(|e| e.to_string())?.serve
    }
    /// The bytes this cloud can still serve within its own wall; `None`: no wall of its own.
    pub fn room_left_bytes(c: &dyn Cloud) -> Option<u64> {
        room(c, &[], None).ok().and_then(|r| r.left_bytes)
    }
    /// Its declared ceilings on product-scope meters; empty: none (or none it could say).
    pub fn ceilings(c: &dyn Cloud) -> BTreeMap<String, u64> {
        room(c, &[], None).map(|r| r.ceilings).unwrap_or_default()
    }
    /// **May this cloud grow a data set as configured now?** The manifest's switch.
    pub fn grow_gate(c: &dyn Cloud) -> Result<(), CloudError> {
        match c.manifest().grow_refused {
            None => Ok(()),
            Some(why) => Err(CloudError::Refused(why)),
        }
    }
    /// **Would it lay `plan` now?** ([`Listing::Admits`]); an over-cap refusal is TYPED.
    pub fn can_lay(c: &dyn Cloud, plan: &EstatePlan) -> Result<(), CloudError> {
        match c.inventory(&Listing::Admits(plan))? {
            Listed::Admitted => Ok(()),
            other => Err(wrong_answer("can_lay", &other)),
        }
    }
    /// The plans sold in `zone` ([`Cloud::catalog`]).
    pub fn plan_catalog(c: &dyn Cloud, zone: &Zone) -> Result<Vec<PlanSpec>, CloudError> {
        Ok(c.catalog(zone)?.plans)
    }
    /// One public IPv4 address a month in `zone`, when billed apart ([`Cloud::catalog`]).
    pub fn price_ipv4(c: &dyn Cloud, zone: &Zone) -> Result<Option<Price>, CloudError> {
        Ok(c.catalog(zone)?.ipv4)
    }
    /// One resource made available for `reference` ([`Change::Ensure`]).
    pub fn ensure(c: &dyn Cloud, reference: &str, resource: &Resource, room: Option<Room>) -> Result<Ensured, CloudError> {
        match c.change(&Change::Ensure { reference: reference.to_string(), resource: resource.clone(), room }, &no_journal())? {
            Changed::Ensured(e) => Ok(e),
            other => Err(wrong_answer("ensure", &other)),
        }
    }
    /// A server's plan changed ([`Change::Plan`]).
    pub fn change_plan(c: &dyn Cloud, server: &ResourceId, plan: &str, delta: Price, journal: &Journal) -> Result<(), CloudError> {
        match c.change(&Change::Plan { server: server.clone(), plan: plan.to_string(), delta }, journal)? {
            Changed::Done => Ok(()),
            other => Err(wrong_answer("change_plan", &other)),
        }
    }
    /// A data set grown ([`Change::Grow`]); `estate`: a sold estate's.
    pub fn grow(c: &dyn Cloud, set: &DataSet, estate: bool, journal: &Journal) -> Result<GrowOutcome, CloudError> {
        match c.change(&Change::Grow { set: set.clone(), estate }, journal)? {
            Changed::Grown(g) => Ok(g),
            other => Err(wrong_answer("grow_set", &other)),
        }
    }
    /// A data set settled from any journal state ([`Change::Settle`]).
    pub fn settle(c: &dyn Cloud, set: &DataSet, journal: &Journal) -> Result<GrowOutcome, CloudError> {
        match c.change(&Change::Settle { set: set.clone() }, journal)? {
            Changed::Grown(g) => Ok(g),
            other => Err(wrong_answer("resume_grow", &other)),
        }
    }
    /// The release media a lay attached, given back ([`Change::ForgetMedia`]).
    pub fn forget_media(c: &dyn Cloud, laid: &LaidEstate, journal: &Journal) -> Result<Vec<String>, CloudError> {
        match c.change(&Change::ForgetMedia { laid: laid.clone() }, journal)? {
            Changed::Forgotten(t) => Ok(t),
            other => Err(wrong_answer("forget_media", &other)),
        }
    }
    /// An adopted estate's machines labelled as this shop's ([`Change::LabelAdopted`]).
    pub fn label_adopted(c: &dyn Cloud, laid: &LaidEstate) -> Result<Vec<String>, CloudError> {
        match c.change(&Change::LabelAdopted { laid: laid.clone() }, &no_journal())? {
            Changed::Labelled(ids) => Ok(ids),
            other => Err(wrong_answer("label_adopted", &other)),
        }
    }
    /// One server stopped or started ([`Boot::Power`]). Buys nothing.
    pub fn set_power(c: &dyn Cloud, server: &ResourceId, on: bool) -> Result<(), CloudError> {
        match c.boot(&Boot::Power { server: server.clone(), on }, &no_journal())? {
            Booted::Powered => Ok(()),
            other => Err(wrong_boot_answer("set_power", &other)),
        }
    }
    /// One laid machine booted once from a keep medium ([`Boot::Keep`]).
    pub fn keep_boot(c: &dyn Cloud, keep: &KeepBoot, journal: &Journal) -> Result<LaidMachine, CloudError> {
        match c.boot(&Boot::Keep(Box::new(keep.clone())), journal)? {
            Booted::Machine(m) => Ok(m),
            other => Err(wrong_boot_answer("keep_boot", &other)),
        }
    }
    /// A lost front restored from its newest backup ([`Boot::Restore`]).
    pub fn restore_from_backup(c: &dyn Cloud, restore: &RestoreFromBackup, journal: &Journal) -> Result<Option<Restored>, CloudError> {
        match c.boot(&Boot::Restore(Box::new(restore.clone())), journal)? {
            Booted::Restored(r) => Ok(r),
            other => Err(wrong_boot_answer("restore_from_backup", &other)),
        }
    }
    /// Everything under `references` given back ([`Give::References`]).
    pub fn release_for(c: &dyn Cloud, references: &[String]) -> Result<Reclaimed, CloudError> {
        c.destroy(&Give::References(references))
    }
    /// One resource given back ([`Give::Resource`]); one the provider no longer has is
    /// given back already.
    pub fn destroy_one(c: &dyn Cloud, id: &ResourceId) -> Result<(), CloudError> {
        let r = c.destroy(&Give::Resource(id))?;
        match r.failures.first() {
            Some(why) => Err(CloudError::Refused(why.clone())),
            None => Ok(()),
        }
    }

    /// **What of one laid machine still stands at the provider** (5.4 guardian): the rows
    /// of the account under `reference` that the laid record names, in the record's order,
    /// the server first. Empty is "all of it is gone". Read from the provider's own
    /// listing; a listing that fails is an error. An empty `reference` is refused.
    pub fn machine_standing(c: &dyn Cloud, reference: &str, machine: &LaidMachine) -> Result<Vec<ResourceId>, CloudError> {
        if reference.trim().is_empty() {
            return Err(CloudError::Refused("a machine is looked up under its estate's reference; an empty one matches nothing".into()));
        }
        let rows: Vec<Owned> = account(c)?.owned.into_iter().filter(|r| r.kind != OwnedKind::Dataset && r.reference.as_deref() == Some(reference)).collect();
        let mut out: Vec<ResourceId> = Vec::new();
        for laid in machine.laid_ids() {
            for r in rows.iter().filter(|r| crate::lay::same_resource(&r.id.id, laid)) {
                if !out.contains(&r.id) {
                    out.push(r.id.clone());
                }
            }
        }
        Ok(out)
    }

    /// **One laid machine given back WHOLE** — the guardian's fence (5.4, gap-2): whatever of
    /// it stands, server first ([`Change::Retire`]). Never capped.
    pub fn retire_machine(c: &dyn Cloud, reference: &str, machine: &LaidMachine) -> Result<Vec<ResourceId>, CloudError> {
        match c.change(&Change::Retire { reference: reference.to_string(), machine: machine.clone() }, &no_journal())? {
            Changed::Retired(ids) => Ok(ids),
            other => Err(wrong_answer("retire_machine", &other)),
        }
    }

}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lay::{DiskPlan, MachinePlan};
    use std::sync::Mutex;

    /// A cloud that remembers what it laid, in memory: the nine ops and nothing else.
    #[derive(Default)]
    struct Toy {
        laid: Mutex<Vec<Owned>>,
        powered: Mutex<Vec<(String, bool)>>,
        changed: Mutex<Vec<(String, String)>>,
    }

    fn eur(minor: u64) -> Price {
        Price { minor_per_month: minor, currency: monetize_pay::Currency::Eur }
    }

    fn plan_spec(name: &str, cores: u32, minor: u64) -> PlanSpec {
        PlanSpec { name: name.into(), cores, memory_mib: 1024 * cores as u64, price: eur(minor) }
    }

    impl Cloud for Toy {
        fn manifest(&self) -> CloudManifest {
            CloudManifest { provider: "toy".into(), zones: vec![Zone("toy-1".into())], settings: vec!["TOY_TOKEN".into()], grow_refused: Some("toy grows nothing".into()) }
        }
        fn catalog(&self, zone: &Zone) -> Result<Catalog, CloudError> {
            if zone.0 != "toy-1" {
                return Err(CloudError::Refused(format!("toy: no zone {}", zone.0)));
            }
            Ok(Catalog { plans: vec![plan_spec("S", 1, 500), plan_spec("M", 2, 1000)], ipv4: Some(eur(300)) })
        }
        fn price(&self, what: &Priced<'_>) -> Result<Price, CloudError> {
            let Priced::Plan(plan) = what else { return Err(CloudError::Refused("toy prices estates only".into())) };
            let c = self.catalog(&plan.zone)?;
            let mut minor = 0;
            for m in &plan.machines {
                minor += c.plan(&m.plan).ok_or_else(|| CloudError::Refused(format!("toy: no plan {}", m.plan)))?.price.minor_per_month;
                minor += m.disks.iter().map(|d| d.gib * 10).sum::<u64>();
            }
            Ok(eur(minor))
        }
        fn lay(&self, plan: &EstatePlan, _journal: &Journal) -> Result<LaidEstate, CloudError> {
            let mut laid = self.laid.lock().unwrap();
            let mut machines = Vec::new();
            for m in &plan.machines {
                let server = format!("srv-{}-{}", plan.company, m.role);
                if !laid.iter().any(|o| o.id.id == server) {
                    laid.push(Owned { id: ResourceId { provider: "toy".into(), id: server.clone() }, kind: OwnedKind::Server, gib: 0, zone: plan.zone.0.clone(), attached_to: None, reference: Some(plan.reference.clone()), monthly_minor: None, currency: monetize_pay::Currency::Eur, provenance: "toy".into(), resource: None, backup: None });
                }
                machines.push(LaidMachine { role: m.role.clone(), hostname: m.hostname.clone(), title: m.title.clone(), plan: m.plan.clone(), server, public_ipv4: "10.0.0.1".into(), utility_ipv4: "10.1.0.1".into(), disks: vec![], medium_clone: String::new() });
            }
            Ok(LaidEstate { reference: plan.reference.clone(), company: plan.company.clone(), zone: plan.zone.clone(), machines, narrate_from: String::new(), shape: plan.shape.clone() })
        }
        fn change(&self, change: &Change, _journal: &Journal) -> Result<Changed, CloudError> {
            match change {
                Change::Plan { server, plan, .. } => {
                    self.changed.lock().unwrap().push((server.id.clone(), plan.clone()));
                    Ok(Changed::Done)
                }
                Change::Retire { reference, machine } => Ok(Changed::Retired(retire_by_rows(self, reference, machine)?)),
                Change::ForgetMedia { .. } => Ok(Changed::Forgotten(vec![])),
                Change::Ensure { .. } => Ok(Changed::Ensured(Ensured::OnHand { pool_free_after: 0 })),
                Change::Grow { .. } | Change::Settle { .. } => Err(CloudError::Refused("toy grows nothing".into())),
                Change::LabelAdopted { .. } => Ok(Changed::Labelled(vec![])),
            }
        }
        fn boot(&self, boot: &Boot, _journal: &Journal) -> Result<Booted, CloudError> {
            match boot {
                Boot::Power { server, on } => {
                    self.powered.lock().unwrap().push((server.id.clone(), *on));
                    Ok(Booted::Powered)
                }
                Boot::Keep(k) => Ok(Booted::Machine(k.machine.clone())),
                Boot::Restore(_) => Ok(Booted::Restored(None)),
            }
        }
        fn firewall(&self, _laid: &LaidEstate) -> Result<(), CloudError> {
            Ok(())
        }
        fn destroy(&self, what: &Give<'_>) -> Result<Reclaimed, CloudError> {
            let mut laid = self.laid.lock().unwrap();
            let mut out = Reclaimed::default();
            laid.retain(|o| {
                let hit = match what {
                    Give::References(references) => o.reference.as_deref().is_some_and(|r| references.iter().any(|w| w == r)),
                    Give::Resource(id) => o.id == **id,
                };
                if hit {
                    out.destroyed.push(o.id.clone());
                }
                !hit
            });
            Ok(out)
        }
        fn inventory(&self, what: &Listing<'_>) -> Result<Listed, CloudError> {
            let account = Inventory { owned: self.laid.lock().unwrap().clone(), foreign: 0, currency: monetize_pay::Currency::Eur };
            Ok(match what {
                Listing::Account => Listed::Account(account),
                Listing::Under(refs) => Listed::Under(under(account, refs)),
                Listing::Status(id) => Listed::Status(if account.owned.iter().any(|o| o.id == **id) { ResourceStatus::Active } else { ResourceStatus::Gone }),
                Listing::Room { .. } => Listed::Room(RoomLeft::unwalled()),
                Listing::Admits(_) => Listed::Admitted,
                Listing::Set(_) => return Err(CloudError::Refused("toy grows nothing".into())),
            })
        }
    }

    fn plan() -> EstatePlan {
        let m = |role: &str, plan: &str| MachinePlan {
            role: role.into(),
            hostname: format!("toy-{role}"),
            title: role.into(),
            plan: plan.into(),
            boot_order: "disk".into(),
            labels: BTreeMap::new(),
            interfaces: crate::lay::default_interfaces(),
            medium: String::new(),
            template: String::new(),
            medium_object: None,
            user_data: Default::default(),
            disks: vec![DiskPlan { title: format!("{role} disk"), gib: 10, tier: "standard".into(), labels: BTreeMap::new() }],
        };
        EstatePlan { reference: "estate/acme".into(), company: "acme".into(), zone: Zone("toy-1".into()), machines: vec![m("front", "S"), m("main", "M")], narrate_from: String::new(), shape: None }
    }

    /// ★ The host's questions each read the one arm they asked for, from the nine ops.
    #[test]
    fn the_hosts_questions_are_answered_from_the_nine_ops() {
        let toy = Toy::default();
        let c: &dyn Cloud = &toy;
        let zone = Zone("toy-1".into());
        assert_eq!(ask::plan_catalog(c, &zone).unwrap().len(), 2);
        assert_eq!(ask::price_ipv4(c, &zone).unwrap().unwrap().minor_per_month, 300);
        assert_eq!(c.price(&Priced::Plan(&plan())).unwrap().minor_per_month, 500 + 100 + 1000 + 100);
        let laid = c.lay(&plan(), &no_journal()).unwrap();
        assert_eq!(laid.machines.len(), 2);
        assert_eq!(ask::account(c).unwrap().owned.len(), 2);
        let main = ResourceId { provider: "toy".into(), id: laid.machine("main").unwrap().server.clone() };
        ask::change_plan(c, &main, "S", eur(0), &no_journal()).unwrap();
        assert_eq!(toy.changed.lock().unwrap().as_slice(), &[(main.id.clone(), "S".to_string())]);
        ask::set_power(c, &main, false).unwrap();
        ask::set_power(c, &main, true).unwrap();
        assert_eq!(toy.powered.lock().unwrap().len(), 2);
        c.firewall(&laid).unwrap();
        assert_eq!(ask::status(c, &main).unwrap(), ResourceStatus::Active);
        assert_eq!(ask::orphans_for(c, &["estate/acme".to_string()]).unwrap().len(), 2, "what stands under the reference");
        assert_eq!(ask::machine_standing(c, "estate/acme", laid.machine("main").unwrap()).unwrap(), vec![main.clone()]);
        assert_eq!(ask::retire_machine(c, "estate/acme", laid.machine("main").unwrap()).unwrap(), vec![main.clone()], "one machine given back whole");
        assert_eq!(ask::status(c, &main).unwrap(), ResourceStatus::Gone);
        let r = ask::release_for(c, &["estate/acme".to_string()]).unwrap();
        assert_eq!(r.destroyed.len(), 1);
        assert!(ask::orphans_for(c, &["estate/acme".to_string()]).unwrap().is_empty());
        assert!(ask::release_for(c, &["estate/acme".to_string()]).unwrap().destroyed.is_empty(), "a second destroy gives back nothing and fails nothing");
        assert_eq!(ask::room(c, &[], None).unwrap(), RoomLeft::unwalled(), "a cloud that buys has no wall of its own");
        assert!(matches!(ask::ensure(c, "r", &Resource::Disk { server: crate::ServerId("s".into()), gib: 1 }, None).unwrap(), Ensured::OnHand { .. }));
        assert!(ask::forget_media(c, &laid, &no_journal()).unwrap().is_empty());
    }

    /// ★ An answer of the wrong arm is a refusal by name, never read as another answer.
    #[test]
    fn an_answer_of_the_wrong_arm_is_a_named_refusal() {
        struct Odd;
        impl Cloud for Odd {
            fn manifest(&self) -> CloudManifest {
                CloudManifest { provider: "odd".into(), zones: vec![], settings: vec![], grow_refused: None }
            }
            fn catalog(&self, _: &Zone) -> Result<Catalog, CloudError> {
                Ok(Catalog { plans: vec![], ipv4: None })
            }
            fn price(&self, _: &Priced<'_>) -> Result<Price, CloudError> {
                Err(CloudError::Refused("odd".into()))
            }
            fn lay(&self, _: &EstatePlan, _: &Journal) -> Result<LaidEstate, CloudError> {
                Err(CloudError::Refused("odd".into()))
            }
            fn change(&self, _: &Change, _: &Journal) -> Result<Changed, CloudError> {
                Ok(Changed::Done)
            }
            fn boot(&self, _: &Boot, _: &Journal) -> Result<Booted, CloudError> {
                Ok(Booted::Powered)
            }
            fn firewall(&self, _: &LaidEstate) -> Result<(), CloudError> {
                Ok(())
            }
            fn destroy(&self, _: &Give<'_>) -> Result<Reclaimed, CloudError> {
                Ok(Reclaimed::default())
            }
            fn inventory(&self, _: &Listing<'_>) -> Result<Listed, CloudError> {
                Ok(Listed::Under(vec![]))
            }
        }
        let keep = KeepBoot { reference: "r".into(), zone: Zone("z".into()), machine: LaidMachine { role: "main".into(), hostname: String::new(), title: String::new(), plan: String::new(), server: "s".into(), public_ipv4: String::new(), utility_ipv4: String::new(), disks: vec![], medium_clone: String::new() }, medium: "m".into(), medium_object: None };
        let e = ask::keep_boot(&Odd, &keep, &no_journal()).unwrap_err();
        assert!(e.to_string().contains("keep_boot: the cloud answered Powered"), "{e}");
        let set = crate::grow::DataSet { product: "p".into(), server: crate::ServerId("s".into()), members: vec![], target_gib: 1 };
        assert!(ask::grow(&Odd, &set, false, &no_journal()).unwrap_err().to_string().contains("grow_set: the cloud answered Done"));
        assert!(ask::account(&Odd).unwrap_err().to_string().contains("inventory: the cloud answered Under"));
        assert!(ask::survey(&Odd, &set).is_err() && ask::status(&Odd, &ResourceId { provider: "odd".into(), id: "x".into() }).is_err());
        assert!(ask::room(&Odd, &[], None).is_err() && ask::ensure(&Odd, "r", &Resource::Disk { server: crate::ServerId("s".into()), gib: 1 }, None).is_err());
    }
}