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/sandbox/mod.rs

//! **`sandbox` — fake iron at real prices. The rehearsal cloud.**
//!
//! Testability wants a fake payment handler and a half-fake cloud provider.
//! `monetize_pay::sandbox` is the fake payment handler; this is its twin on the
//! cloud seam, and half fake is exactly what it is: **the prices are real, the
//! iron is not.**
//!
//! # What is real
//!
//! `Priced::Resource` answers from a real provider's own price list when
//! the build links one and hands it in as a [`PriceList`] — the vetra build's
//! UpCloud `PriceBook` (parser, 1 h cache, credits/hour → monthly cents), the
//! same one the real provider uses, when a credential is in the environment. So
//! the `SpendCap` arithmetic and the lifetime ceiling are exercised against the
//! numbers an invoice would carry. This open crate holds no provider that could
//! fill the slot: without a measured list it falls back to a **declared** price
//! table from the config, documented as *declared, not measured*, and refuses
//! to start on a table with a zero in it: a storage price of zero would make
//! the cap untestable, and a silent zero is the one thing a rehearsal must
//! never produce.
//!
//! # What is fake
//!
//! `Change::Ensure` provisions **nothing**. It records a
//! [`FakeResource`] with an id shaped `sandbox:<kind>:<n>`, idempotent on
//! `reference + fingerprint` exactly the way UpCloud's label search is — this is the
//! double-charge seam, and an increase under a new reference must not re-provision
//! what the tenant already holds. It honours a declared pool (`pool_gib` per server
//! name): `OnHand` when the pool covers the request, `Provisioned` otherwise, with
//! the bought size rounded up to `disk_step_gib` and the surplus becoming pool, as
//! the real one does. `destroy` is idempotent, `status` reports `Gone` after it and
//! `Active` before, and an id it never minted is an error, never `Gone`.
//!
//! # It cannot reach a real cloud, by construction
//!
//! The only handle to the network this type can hold is a [`PriceList`], whose
//! whole surface is price reads. There is no provider client here, and so no
//! way to form a `POST /1.3/storage`, an attach or a `DELETE` — not "we do not
//! call it", but "the request cannot be built". `monetize-cloud-impl`'s
//! `sandbox_measured` tests show it on a recorded mock across a full purchase →
//! increase → destroy cycle: one `GET /1.3/price`, nothing else.
//!
//! # Every fake resource says so, forever
//!
//! Rule 2 still holds: every resource is attached to one reference, stamped with
//! [`LABEL_REF`] and [`LABEL_RES`] like the real provider's. On top of that it
//! carries [`LABEL_PROVENANCE`] `= sandbox` and [`LABEL_PRICE`] naming the price
//! source, and `sandbox` is in the id itself — so a tenant provisioned on play iron
//! is distinguishable from a real one in every ledger row that names the resource.
//!
//! # State is persisted after every mutation
//!
//! To one JSON file (`MONETIZE_SANDBOX_CLOUD_STATE`; the registry defaults it to
//! `<data_dir>/sandbox-cloud.json`), rewritten atomically. The payment sandbox
//! shipped without this and MEASURED on gunnar.rs 2026-09-04 a restart that forgot
//! every charge, which made refunds fail. A cloud that forgot its iron would make
//! every `destroy` after a restart `UnknownResource` — and would let a resumed
//! attempt buy the same disk twice, which is the exact bug this seam exists to
//! prevent. `None` keeps it in memory, which is right for a test and wrong for a
//! deployment.

use crate::factory::{CloudBuild, CloudFactory};
use crate::pool::{self, Grant, LedgerData};
use crate::Cloud;
use crate::{
    fingerprint, CloudError, Ensured, Inventory, Owned, OwnedKind, Price, Resource, ResourceId,
    ResourceStatus, Room, ServerId, Zone, LABEL_REF, LABEL_RES,
};
use monetize_pay::Currency;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

pub const PROVIDER: &str = "sandbox";

/// Environment names [`SandboxConfig::from_env`] reads. The one writer of the
/// list; the server's install forwards exactly these into the unit's 0600
/// `EnvironmentFile=`. A provider credential the measured price path needs is
/// that provider's `CONFIG_ENVS` to name — the build that links it registers a
/// `sandbox` factory whose list includes them.
pub const CONFIG_ENVS: &[&str] = &["MONETIZE_SANDBOX_CLOUD_STATE"];

/// How a server links the rehearsal cloud with DECLARED prices. A build that
/// links a real provider registers its own `sandbox` factory on top (see
/// [`config_from`] and [`build_with`]) whose prices are measured.
pub const FACTORY: CloudFactory = CloudFactory {
    name: PROVIDER,
    config_envs: CONFIG_ENVS,
    spends: true,
    build,
};

fn build(ctx: &CloudBuild<'_>) -> Result<Box<dyn Cloud>, String> {
    build_with(config_from(ctx)?)
}

/// **A real provider's price list, as the sandbox reads it.** Implemented by
/// the private UpCloud `PriceBook`; this crate only knows the shape. Every
/// method is a price READ — the trait cannot express a request that creates
/// anything, which is what keeps the sandbox a sandbox.
pub trait PriceList: Send + Sync {
    /// Whose list: stamped into [`LABEL_PRICE`] as `measured:<provider>:<zone>`.
    fn provider(&self) -> &'static str;
    fn currency(&self) -> Currency;
    /// A disk of `gib` in `zone`, per month.
    fn disk(&self, zone: &str, gib: u64) -> Result<Price, CloudError>;
    /// A server `plan` in `zone`, per month.
    fn plan(&self, zone: &str, plan: &str) -> Result<Price, CloudError>;
    /// `from` → `to`, never below zero; an empty `from` charges the whole of `to`.
    fn delta(&self, zone: &str, from: &str, to: &str) -> Result<Price, CloudError>;
}

/// **The `[clouds.sandbox]` block as a config** — the one reader of its keys
/// (`zone`, `pool_gib`, `disk_step_gib`, `disk_tier`, `prices = { … }`), with
/// the deployment defaults: state beside the ledger, a declared table taking
/// the cap's currency when it names none. A build that links a real provider
/// calls this, sets [`SandboxConfig::measured`], and hands the result to
/// [`build_with`] — one constructor path, whichever price source (one implementation).
pub fn config_from(ctx: &CloudBuild<'_>) -> Result<SandboxConfig, String> {
    let s = ctx.settings;
    let mut sb = SandboxConfig::from_env();
    // A DEPLOYED sandbox keeps its iron beside the ledger — the payment
    // sandbox's restart bug, guarded before it can happen here.
    // `MONETIZE_SANDBOX_CLOUD_STATE` still wins if an operator set one.
    sb.state_path
        .get_or_insert_with(|| ctx.data_dir.join("sandbox-cloud.json"));
    if let Some(v) = s.get::<String>("zone").map_err(|e| ctx.err(e))? {
        sb.zone = Zone(v);
    }
    if let Some(v) = s.get::<u64>("pool_gib").map_err(|e| ctx.err(e))? {
        sb.pool_gib = v;
    }
    if let Some(v) = s.get::<u64>("disk_step_gib").map_err(|e| ctx.err(e))? {
        sb.disk_step_gib = v;
    }
    if let Some(v) = s.get::<String>("disk_tier").map_err(|e| ctx.err(e))? {
        sb.disk_tier = v;
    }
    /// The config-file shape of [`DeclaredPrices`]: the currency may be left
    /// to the cap's.
    #[derive(serde::Deserialize)]
    struct Declared {
        disk_minor_per_gib_month: u64,
        #[serde(default)]
        plan_minor_per_month: BTreeMap<String, u64>,
        currency: Option<Currency>,
    }
    if let Some(p) = s.get::<Declared>("prices").map_err(|e| ctx.err(e))? {
        let currency = p
            .currency
            .or(ctx.ceiling.map(|c| c.currency))
            .ok_or_else(|| {
                ctx.err("`prices` names no currency and there is no cap to take one from")
            })?;
        sb.declared = Some(DeclaredPrices {
            disk_minor_per_gib_month: p.disk_minor_per_gib_month,
            plan_minor_per_month: p.plan_minor_per_month,
            currency,
        });
    }
    Ok(sb)
}

/// The second half of the one constructor path: refuse what [`Sandbox::new`]
/// refuses, say on stderr what kind of rehearsal this is, box it. The registry
/// wraps the result in its spend ceiling — the cap is checked before this is
/// reached, because the whole point of the sandbox is to exercise that
/// arithmetic against the numbers an invoice would carry.
pub fn build_with(cfg: SandboxConfig) -> Result<Box<dyn Cloud>, String> {
    let inner = Sandbox::new(cfg).map_err(|e| format!("[clouds.sandbox]: {e}"))?;
    eprintln!(
        "monetize-server: [clouds.sandbox] is the REHEARSAL cloud — fake iron, prices {} (state {})",
        inner.price_source().label(),
        inner.state_path().map(|p| p.display().to_string()).unwrap_or_default()
    );
    Ok(Box::new(inner))
}

/// Label on every fake resource, value always [`PROVIDER`]: the mark the console
/// shows so play iron is never mistaken for bought iron.
pub const LABEL_PROVENANCE: &str = "monetize_provenance";
/// Label naming where the resource's price came from: [`PriceSource::label`].
pub const LABEL_PRICE: &str = "monetize_price";

/// A price table written down by an operator. **Declared, not measured**: these
/// are what the config says, not what any provider quoted. Every number must be
/// non-zero — see [`DeclaredPrices::check`].
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DeclaredPrices {
    /// Per GiB of disk, per month, in `currency` minor units.
    pub disk_minor_per_gib_month: u64,
    /// Per server plan name, per month.
    #[serde(default)]
    pub plan_minor_per_month: BTreeMap<String, u64>,
    pub currency: Currency,
}

impl DeclaredPrices {
    /// Refuse a table with a zero in it. A disk priced at zero makes every cap
    /// vacuous — the ceiling can never be crossed, so it is never tested — and a
    /// plan priced at zero is the same lie for compute. Called at construction so a
    /// sandbox that could not exercise the cap cannot be built.
    pub fn check(&self) -> Result<(), CloudError> {
        if self.disk_minor_per_gib_month == 0 {
            return Err(CloudError::Refused(
                "sandbox cloud: the declared price table prices disk at 0. A zero price makes the spend cap \
                 untestable, which defeats the rehearsal; declare a real number (UpCloud maxiops in se-sto1 \
                 was 0.031 credits/GB/h ≈ 23 cents/GiB/month on 2026-08-30), or compile `upcloud` and set \
                 UPCLOUD_TOKEN so the price is measured instead of declared."
                    .into(),
            ));
        }
        if let Some((plan, _)) = self.plan_minor_per_month.iter().find(|(_, v)| **v == 0) {
            return Err(CloudError::Refused(format!(
                "sandbox cloud: the declared price table prices plan {plan} at 0; a zero price makes the spend cap untestable"
            )));
        }
        Ok(())
    }
}

#[derive(Clone)]
pub struct SandboxConfig {
    /// The zone prices are read for. `se-sto1`, like the appliance.
    pub zone: Zone,
    /// Where the fake iron is remembered. `None` is in-memory: right for a test,
    /// wrong for a deployment (see the module doc).
    pub state_path: Option<PathBuf>,
    /// GiB every server name is declared to have free before anything is bought.
    /// `0` means every disk is `Provisioned` — the shape that exercises the cap.
    pub pool_gib: u64,
    /// Disks are "bought" in multiples of this many GiB; the surplus becomes pool.
    /// Mirrors `upcloud::Config::disk_step_gib` so the charged size is the size the
    /// real provider would have charged.
    pub disk_step_gib: u64,
    /// The storage tier a disk is priced in when prices are measured
    /// (`storage_<tier>` in the price list).
    pub disk_tier: String,
    /// The fallback table. Used only when prices cannot be measured.
    pub declared: Option<DeclaredPrices>,
    /// A MEASURED price source — a real provider's own list, handed in by the
    /// build that links that provider. Present ⇒ prices are measured and
    /// `declared` is not consulted. This crate holds nothing that could fill it.
    pub measured: Option<Arc<dyn PriceList>>,
}

impl std::fmt::Debug for SandboxConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SandboxConfig")
            .field("zone", &self.zone)
            .field("state_path", &self.state_path)
            .field("pool_gib", &self.pool_gib)
            .field("disk_step_gib", &self.disk_step_gib)
            .field("disk_tier", &self.disk_tier)
            .field("declared", &self.declared)
            .field("measured", &self.measured.as_ref().map(|m| m.provider()))
            .finish()
    }
}

impl SandboxConfig {
    /// Defaults for everything but the price source: zone `se-sto1`, no pool, a
    /// 10 GiB step, `maxiops`, in memory. A config with no price source is refused
    /// by [`Sandbox::new`], not here — this is a starting point, not a valid whole.
    pub fn new() -> SandboxConfig {
        SandboxConfig {
            zone: Zone("se-sto1".to_string()),
            state_path: None,
            pool_gib: 0,
            disk_step_gib: 10,
            disk_tier: "maxiops".to_string(),
            declared: None,
            measured: None,
        }
    }

    /// `MONETIZE_SANDBOX_CLOUD_STATE` (optional; absent ⇒ in memory). A measured
    /// price source is the linking build's to add — see [`config_from`].
    pub fn from_env() -> SandboxConfig {
        Self::from_env_with(|k| std::env::var(k).ok())
    }

    /// [`SandboxConfig::from_env`] with the lookup handed in — pure, so a test can
    /// stand in any environment without touching the process's own.
    pub fn from_env_with(lookup: impl Fn(&str) -> Option<String>) -> SandboxConfig {
        let mut cfg = SandboxConfig::new();
        cfg.state_path = lookup("MONETIZE_SANDBOX_CLOUD_STATE")
            .map(PathBuf::from)
            .filter(|p| !p.as_os_str().is_empty());
        cfg
    }
}

impl Default for SandboxConfig {
    fn default() -> Self {
        Self::new()
    }
}

/// Where a price came from. Stamped on every fake resource as [`LABEL_PRICE`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PriceSource {
    /// Read from a real provider's price list.
    Measured {
        provider: &'static str,
        zone: String,
    },
    /// Written down in the config. Not measured.
    Declared,
}

impl PriceSource {
    pub fn label(&self) -> String {
        match self {
            PriceSource::Measured { provider, zone } => format!("measured:{provider}:{zone}"),
            PriceSource::Declared => "declared".to_string(),
        }
    }
}

enum Prices {
    Measured(Arc<dyn PriceList>),
    Declared(DeclaredPrices),
}

/// What one ensure is about, as every resource kind records it: the ledger key, the
/// estate's reference, the fingerprint and the resource asked for.
#[derive(Clone, Copy)]
struct Ask<'a> {
    key: &'a str,
    reference: &'a str,
    fp: &'a str,
    resource: &'a Resource,
}

/// One piece of fake iron, as the sandbox remembers it. Everything the console
/// needs to say "this is play iron, bought for this order, at this price".
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FakeResource {
    /// `sandbox:<kind>:<n>`.
    pub id: String,
    /// The order it is attached to (rule 2). Also [`LABEL_REF`] in `labels`.
    pub reference: String,
    /// [`crate::fingerprint`] of `resource`. Also [`LABEL_RES`] in `labels`.
    pub fingerprint: String,
    pub resource: Resource,
    /// For a disk, the size "bought": the request rounded up to the step. What the
    /// price was computed on.
    pub size_gib: u64,
    /// For a plan change, the plan the server ran before it — what `destroy` puts back.
    pub from_plan: Option<String>,
    pub cost: Price,
    pub labels: BTreeMap<String, String>,
    pub status: ResourceStatus,
    pub created_unix_ms: u64,
    pub destroyed_unix_ms: Option<u64>,
}

/// What one `ensure` or `destroy` was asked, in order. In memory only: it is a
/// test's view, not state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Call {
    Ensure {
        reference: String,
        fingerprint: String,
    },
    Destroy(ResourceId),
    /// A keep boot of a laid machine (nothing boots: recorded, journaled).
    KeepBoot {
        server: String,
        medium: String,
    },
    /// The estate's firewall asked for (play iron has none: recorded).
    Firewall {
        reference: String,
        machines: usize,
    },
}

#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Stored {
    version: u32,
    /// The next `<n>` in `sandbox:<kind>:<n>`. Never reused, even after destroy.
    next: u64,
    resources: BTreeMap<String, FakeResource>,
    /// The pool arithmetic, shared with `upcloud` by construction.
    pool: LedgerData,
    /// server name → the plan it runs, as far as this sandbox has been told: a
    /// fake server's plan at creation, then every plan change it applied.
    plans: BTreeMap<String, String>,
}

/// A cloud provider with no iron and no socket but a price GET.
pub struct Sandbox {
    cfg: SandboxConfig,
    zones: Vec<Zone>,
    prices: Prices,
    /// Held for the whole of `ensure`/`destroy`: the pool arithmetic must not interleave.
    state: Mutex<Stored>,
    calls: Mutex<Vec<Call>>,
    clock: Box<dyn Fn() -> u64 + Send + Sync>,
}

impl std::fmt::Debug for Sandbox {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Sandbox")
            .field("zone", &self.cfg.zone)
            .field("price_source", &self.price_source())
            .field(
                "resources",
                &self.state.lock().map(|s| s.resources.len()).unwrap_or(0),
            )
            .finish_non_exhaustive()
    }
}

fn now_unix_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

impl Sandbox {
    /// Refuses, in this order: an empty zone; no price source at all; a declared
    /// table with a zero in it; a state file that exists but does not parse (a
    /// sandbox that guessed over a corrupt file could sell the same gigabyte twice,
    /// which is the payment sandbox's restart bug in a worse coat).
    pub fn new(cfg: SandboxConfig) -> Result<Sandbox, CloudError> {
        if cfg.zone.0.trim().is_empty() {
            return Err(CloudError::Refused("sandbox cloud: zone is empty".into()));
        }
        let prices = Self::price_source_for(&cfg)?;
        let stored = match cfg.state_path.as_deref() {
            Some(path) => Self::load(path)?,
            None => Stored {
                version: 1,
                ..Default::default()
            },
        };
        Ok(Sandbox {
            zones: vec![cfg.zone.clone()],
            prices,
            state: Mutex::new(stored),
            calls: Mutex::new(Vec::new()),
            clock: Box::new(now_unix_ms),
            cfg,
        })
    }

    fn price_source_for(cfg: &SandboxConfig) -> Result<Prices, CloudError> {
        if let Some(list) = &cfg.measured {
            return Ok(Prices::Measured(list.clone()));
        }
        match &cfg.declared {
            Some(d) => {
                d.check()?;
                Ok(Prices::Declared(d.clone()))
            }
            None => Err(CloudError::Refused(
                "sandbox cloud: no price source. Either run a build that links a real provider with its credential \
                 set (the vetra build with UPCLOUD_TOKEN) so prices are MEASURED from the real price list, or declare \
                 a table in the config (`prices = { disk_minor_per_gib_month = 23, currency = \"Eur\" }`) — a \
                 rehearsal with no prices cannot exercise the spend cap and would not be a rehearsal."
                    .into(),
            )),
        }
    }

    fn load(path: &Path) -> Result<Stored, CloudError> {
        match std::fs::read(path) {
            Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
                CloudError::Unavailable(format!(
                    "sandbox cloud: state {} does not parse ({e}); refusing to start rather than forget the iron it owns",
                    path.display()
                ))
            }),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Stored { version: 1, ..Default::default() }),
            Err(e) => Err(CloudError::Unavailable(format!("sandbox cloud: state {}: {e}", path.display()))),
        }
    }

    /// Write the state, if it has a home. Called after every mutation.
    fn persist(&self, st: &Stored) -> Result<(), CloudError> {
        let Some(path) = self.cfg.state_path.as_deref() else {
            return Ok(());
        };
        let bytes = serde_json::to_vec_pretty(st)
            .map_err(|e| CloudError::Unavailable(format!("sandbox cloud: state encode: {e}")))?;
        pool::write_atomically(path, &bytes, "sandbox cloud state")
    }

    /// A fixed clock, so a test can assert the recorded timestamps.
    pub fn with_clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
        self.clock = Box::new(clock);
        self
    }

    pub fn price_source(&self) -> PriceSource {
        match &self.prices {
            Prices::Measured(list) => PriceSource::Measured {
                provider: list.provider(),
                zone: self.cfg.zone.0.clone(),
            },
            Prices::Declared(_) => PriceSource::Declared,
        }
    }

    pub fn state_path(&self) -> Option<&Path> {
        self.cfg.state_path.as_deref()
    }

    /// Every resource ever minted, `Gone` ones included, in id order.
    pub fn resources(&self) -> Vec<FakeResource> {
        self.state
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .resources
            .values()
            .cloned()
            .collect()
    }

    /// GiB of fake disk currently `Active` — the money-shaped number: what the
    /// provider would be invoicing for.
    pub fn live_gib(&self) -> u64 {
        self.state
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .resources
            .values()
            .filter(|r| r.status == ResourceStatus::Active)
            .map(|r| r.size_gib)
            .sum()
    }

    pub fn calls(&self) -> Vec<Call> {
        self.calls.lock().unwrap_or_else(|e| e.into_inner()).clone()
    }

    // ── prices ───────────────────────────────────────────────────────────

    /// The currency the price source quotes in: the book's when measured, the
    /// table's when declared.
    fn currency(&self) -> Currency {
        match &self.prices {
            Prices::Measured(book) => book.currency(),
            Prices::Declared(d) => d.currency,
        }
    }

    /// A declared table is zone-blind: an operator wrote one number down.
    fn price_disk(&self, zone: &str, gib: u64) -> Result<Price, CloudError> {
        let _ = zone;
        match &self.prices {
            Prices::Measured(book) => book.disk(zone, gib),
            Prices::Declared(d) => Ok(Price {
                minor_per_month: d.disk_minor_per_gib_month.saturating_mul(gib),
                currency: d.currency,
            }),
        }
    }

    fn price_plan(&self, zone: &str, plan: &str) -> Result<Price, CloudError> {
        let _ = zone;
        match &self.prices {
            Prices::Measured(book) => book.plan(zone, plan),
            Prices::Declared(d) => d
                .plan_minor_per_month
                .get(plan)
                .map(|m| Price {
                    minor_per_month: *m,
                    currency: d.currency,
                })
                .ok_or_else(|| {
                    CloudError::Refused(format!("sandbox cloud: no declared price for plan {plan}"))
                }),
        }
    }

    /// `from` → `to`, never below zero; an empty `from` charges the whole of `to`.
    fn price_delta(&self, zone: &str, from: &str, to: &str) -> Result<Price, CloudError> {
        match &self.prices {
            Prices::Measured(book) => book.delta(zone, from, to),
            Prices::Declared(_) => {
                let to = self.price_plan(zone, to)?;
                let from = if from.is_empty() {
                    0
                } else {
                    self.price_plan(zone, from)?.minor_per_month
                };
                Ok(Price {
                    minor_per_month: to.minor_per_month.saturating_sub(from),
                    currency: to.currency,
                })
            }
        }
    }

    // ── ids and labels ───────────────────────────────────────────────────

    fn mint(st: &mut Stored, kind: &str) -> String {
        st.next += 1;
        format!("{PROVIDER}:{kind}:{}", st.next)
    }

    fn labels(&self, reference: &str, fp: &str) -> BTreeMap<String, String> {
        BTreeMap::from([
            (LABEL_REF.to_string(), reference.to_string()),
            (LABEL_RES.to_string(), fp.to_string()),
            (LABEL_PROVENANCE.to_string(), PROVIDER.to_string()),
            (LABEL_PRICE.to_string(), self.price_source().label()),
        ])
    }

    fn rid(id: &str) -> ResourceId {
        ResourceId {
            provider: PROVIDER.into(),
            id: id.to_string(),
        }
    }

    fn record(
        &self,
        st: &mut Stored,
        ask: &Ask<'_>,
        kind: &str,
        size_gib: u64,
        from_plan: Option<String>,
        cost: Price,
    ) -> Result<Ensured, CloudError> {
        let Ask {
            key,
            reference,
            fp,
            resource,
        } = *ask;
        let id = Self::mint(st, kind);
        let fake = FakeResource {
            id: id.clone(),
            reference: reference.to_string(),
            fingerprint: fp.to_string(),
            resource: resource.clone(),
            size_gib,
            from_plan,
            cost,
            labels: self.labels(reference, fp),
            status: ResourceStatus::Active,
            created_unix_ms: (self.clock)(),
            destroyed_unix_ms: None,
        };
        st.resources.insert(id.clone(), fake);
        let (server, gib) = match resource {
            Resource::Disk { server, gib } => (Some(server.0.clone()), *gib),
            _ => (None, 0),
        };
        st.pool.record(
            key,
            Grant::Provisioned {
                id: id.clone(),
                cost,
                server,
                gib,
            },
        );
        self.persist(st)?;
        Ok(Ensured::Provisioned {
            id: Self::rid(&id),
            cost,
        })
    }

    fn ensure_room(
        &self,
        reference: &str,
        resource: &Resource,
        room: Option<Room>,
    ) -> Result<Ensured, CloudError> {
        if reference.is_empty() || reference.len() > 255 {
            return Err(CloudError::Refused(
                "sandbox cloud: reference must be 1–255 chars (it is a label value)".into(),
            ));
        }

        let fp = fingerprint(resource);
        self.calls
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .push(Call::Ensure {
                reference: reference.to_string(),
                fingerprint: fp.clone(),
            });
        let key = pool::Ledger::key(reference, &fp);
        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
        // This pair was finished before: answer from the ledger.
        match st.pool.grant(&key) {
            Some(Grant::OnHand { free_after, .. }) => {
                return Ok(Ensured::OnHand {
                    pool_free_after: *free_after,
                })
            }
            Some(Grant::Provisioned { id, cost, .. }) => {
                return Ok(Ensured::Provisioned {
                    id: Self::rid(id),
                    cost: *cost,
                })
            }
            _ => {}
        }
        // The label search: an ACTIVE resource carrying both labels is adopted,
        // never minted again — the seam UpCloud covers with `GET …?label=`.
        let found = st
            .resources
            .values()
            .find(|r| {
                r.status == ResourceStatus::Active
                    && r.reference == reference
                    && r.fingerprint == fp
            })
            .map(|r| (r.id.clone(), r.cost));
        if let Some((id, cost)) = found {
            let (server, gib) = match resource {
                Resource::Disk { server, gib } => (Some(server.0.clone()), *gib),
                _ => (None, 0),
            };
            st.pool.record(
                &key,
                Grant::Provisioned {
                    id: id.clone(),
                    cost,
                    server,
                    gib,
                },
            );
            self.persist(&st)?;
            return Ok(Ensured::Provisioned {
                id: Self::rid(&id),
                cost,
            });
        }
        let ask = Ask {
            key: &key,
            reference,
            fp: &fp,
            resource,
        };
        match resource {
            Resource::Disk { server, gib } => self.ensure_disk(&mut st, &ask, server, *gib, room),
            Resource::Server { plan, zone, .. } => self.ensure_server(&mut st, &ask, plan, zone),
            Resource::Compute { server, plan } => self.ensure_compute(&mut st, &ask, server, plan),
        }
    }

    // ── ensure, per resource ─────────────────────────────────────────────

    /// The pool: declared + every active fake disk on `server` (its bought size,
    /// so a rounded-up purchase's surplus is pool for the next tenant) − sold.
    ///
    /// **Told the product's measurement, the pool steps aside** — the same rule
    /// UpCloud follows, rehearsed here without money: `Room::OnHand` records a
    /// grant and mints nothing; `Room::Buy` mints without asking the declared
    /// pool, which has never seen the product's disk. `None` is a caller that
    /// passed no measurement, and the declared pool decides as it always has.
    fn ensure_disk(
        &self,
        st: &mut Stored,
        ask: &Ask<'_>,
        server: &ServerId,
        gib: u64,
        room: Option<Room>,
    ) -> Result<Ensured, CloudError> {
        let key = ask.key;
        if gib == 0 {
            return Err(CloudError::Refused("sandbox cloud: a disk of 0 GiB".into()));
        }
        match room {
            Some(Room::OnHand) => {
                st.pool.record(
                    key,
                    Grant::OnHand {
                        server: server.0.clone(),
                        gib,
                        free_after: 0,
                    },
                );
                self.persist(st)?;
                return Ok(Ensured::OnHand { pool_free_after: 0 });
            }
            Some(Room::Buy) => {
                let step = self.cfg.disk_step_gib.max(1);
                let size = gib.max(step).div_ceil(step) * step;
                let cost = self.price_disk(&self.cfg.zone.0, size)?;
                return self.record(st, ask, "disk", size, None, cost);
            }
            None => {}
        }
        let provisioned: u64 = self.cfg.pool_gib
            + st.resources
                .values()
                .filter(|r| {
                    r.status == ResourceStatus::Active
                        && matches!(&r.resource, Resource::Disk { server: s, .. } if s == server)
                })
                .map(|r| r.size_gib)
                .sum::<u64>();
        let free = provisioned.saturating_sub(st.pool.sold(&server.0));
        if free >= gib {
            let free_after = free - gib;
            st.pool.record(
                key,
                Grant::OnHand {
                    server: server.0.clone(),
                    gib,
                    free_after,
                },
            );
            self.persist(st)?;
            return Ok(Ensured::OnHand {
                pool_free_after: free_after,
            });
        }
        let step = self.cfg.disk_step_gib.max(1);
        let size = gib.max(step).div_ceil(step) * step;
        // The price is read BEFORE anything is written: a price list that cannot
        // be reached leaves no half-minted resource behind.
        let cost = self.price_disk(&self.cfg.zone.0, size)?;
        self.record(st, ask, "disk", size, None, cost)
    }

    fn ensure_server(
        &self,
        st: &mut Stored,
        ask: &Ask<'_>,
        plan: &str,
        zone: &Zone,
    ) -> Result<Ensured, CloudError> {
        if plan.trim().is_empty() {
            return Err(CloudError::Refused(
                "sandbox cloud: a server with no plan".into(),
            ));
        }
        let cost = self.price_plan(&zone.0, plan)?;
        let out = self.record(st, ask, "server", 0, None, cost)?;
        if let Ensured::Provisioned { id, .. } = &out {
            st.plans.insert(id.id.clone(), plan.to_string());
            self.persist(st)?;
        }
        Ok(out)
    }

    /// A plan change. The server already running `plan`, with no change on record,
    /// is the pool covering it — `OnHand`, as UpCloud answers. A server this sandbox
    /// has never been told about is charged the WHOLE plan: the conservative
    /// direction, since under-charging a cap is the only direction that costs money.
    fn ensure_compute(
        &self,
        st: &mut Stored,
        ask: &Ask<'_>,
        server: &ServerId,
        plan: &str,
    ) -> Result<Ensured, CloudError> {
        let key = ask.key;
        if plan.trim().is_empty() {
            return Err(CloudError::Refused(
                "sandbox cloud: a plan change to no plan".into(),
            ));
        }
        let from = st.plans.get(&server.0).cloned().unwrap_or_default();
        if from == plan {
            st.pool.record(
                key,
                Grant::OnHand {
                    server: server.0.clone(),
                    gib: 0,
                    free_after: 0,
                },
            );
            self.persist(st)?;
            return Ok(Ensured::OnHand { pool_free_after: 0 });
        }
        let cost = self.price_delta(&self.cfg.zone.0, &from, plan)?;
        let out = self.record(st, ask, "compute", 0, Some(from), cost)?;
        st.plans.insert(server.0.clone(), plan.to_string());
        self.persist(st)?;
        Ok(out)
    }

    fn lookup<'a>(st: &'a mut Stored, id: &ResourceId) -> Result<&'a mut FakeResource, CloudError> {
        if id.provider != PROVIDER {
            return Err(CloudError::UnknownResource(id.clone()));
        }
        st.resources
            .get_mut(&id.id)
            .ok_or_else(|| CloudError::UnknownResource(id.clone()))
    }
}

#[allow(dead_code)]
impl Sandbox {
    pub fn provider(&self) -> &'static str {
        PROVIDER
    }

    pub fn zones(&self) -> &[Zone] {
        &self.zones
    }

    pub fn price_resource(&self, resource: &Resource) -> Result<Price, CloudError> {
        match resource {
            Resource::Disk { gib, .. } => self.price_disk(&self.cfg.zone.0, *gib),
            Resource::Server { plan, zone, .. } => self.price_plan(&zone.0, plan),
            Resource::Compute { server, plan } => {
                let from = self
                    .state
                    .lock()
                    .unwrap_or_else(|e| e.into_inner())
                    .plans
                    .get(&server.0)
                    .cloned()
                    .unwrap_or_default();
                self.price_delta(&self.cfg.zone.0, &from, plan)
            }
        }
    }

    pub fn ensure(&self, reference: &str, resource: &Resource) -> Result<Ensured, CloudError> {
        self.ensure_room(reference, resource, None)
    }

    /// **The plans a declared table names** (monetize v2.0 P1, the money-free open default): a
    /// buyer sizes an estate from them, so a plan is laid with no money. Cores and memory are
    /// read off the plan's name as UpCloud spells it (`STARTER-2xCPU-4GB`, `HIMEM-64xCPU-256GB`);
    /// a declared name that does not spell them is refused by name. A measured book has no
    /// list here (it prices what it is asked): the vetra build's own catalog is UpCloud's.
    pub fn plan_catalog(&self, zone: &Zone) -> Result<Vec<crate::lay::PlanSpec>, CloudError> {
        let Prices::Declared(d) = &self.prices else {
            return Err(crate::lay::unsupported(PROVIDER));
        };
        let mut out = Vec::new();
        for (name, minor) in &d.plan_minor_per_month {
            let (cores, memory_mib) = plan_shape(name).ok_or_else(|| CloudError::Refused(format!("sandbox cloud: the declared plan {name:?} does not spell its cores and memory (`<n>xCPU-<m>GB`)")))?;
            out.push(crate::lay::PlanSpec {
                name: name.clone(),
                cores,
                memory_mib,
                price: Price {
                    minor_per_month: *minor,
                    currency: d.currency,
                },
            });
        }
        let _ = zone;
        out.sort_by_key(|p| (p.cores, p.memory_mib, p.name.clone()));
        Ok(out)
    }

    /// **A keep boot of play iron** (monetize v2.0 P1): nothing boots here, so the machine stands
    /// as it was; journaled ([`crate::lay::step::KEEP_BOOT`]) and recorded in the call log, so
    /// an update's step on the sandbox is seen and not pretended. The server must stand.
    pub fn keep_boot(
        &self,
        keep: &crate::lay::KeepBoot,
        journal: &crate::grow::Journal,
    ) -> Result<crate::lay::LaidMachine, CloudError> {
        use crate::grow::{GrowRecord, Mark};
        let id = Self::rid(&keep.machine.server);
        if self.status(&id)? != ResourceStatus::Active {
            return Err(CloudError::UnknownResource(id));
        }
        let medium = if keep.medium.is_empty() {
            keep.medium_object
                .as_ref()
                .map(|o| o.sha256.clone())
                .unwrap_or_default()
        } else {
            keep.medium.clone()
        };
        if medium.is_empty() {
            return Err(CloudError::Refused(
                "sandbox cloud: a keep boot names no medium".into(),
            ));
        }
        let epoch = journal.last_epoch().unwrap_or_else(|| (self.clock)());
        let mut r = GrowRecord::set_wide(
            (self.clock)(),
            epoch,
            crate::lay::step::KEEP_BOOT,
            Mark::Confirmed,
            keep.machine.server.clone(),
        );
        r.volume = keep.machine.hostname.clone();
        journal.append(r)?;
        self.calls
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .push(Call::KeepBoot {
                server: keep.machine.server.clone(),
                medium,
            });
        Ok(keep.machine.clone())
    }

    /// **The estate's firewall, written again**: play iron has no firewall; the ask is recorded
    /// and nothing is pretended.
    pub fn set_estate_firewall(&self, laid: &crate::lay::LaidEstate) -> Result<(), CloudError> {
        self.calls
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .push(Call::Firewall {
                reference: laid.reference.clone(),
                machines: laid.machines.len(),
            });
        Ok(())
    }

    /// **Lay the estate with play iron** (monetize v2.0 P1): every disk and every server of the
    /// plan `ensure`d under the estate's reference (the same ledger, so a second lay adopts and
    /// `release_for` gives it back), journaled as a real provider's lay is. The addresses are
    /// TEST-NET and the utility net; nothing boots. No medium is cloned (a sandbox lays no
    /// release medium: `medium_clone` is empty).
    pub fn lay_estate(
        &self,
        plan: &crate::lay::EstatePlan,
        journal: &crate::grow::Journal,
    ) -> Result<crate::lay::LaidEstate, CloudError> {
        use crate::grow::{step as gstep, GrowRecord, Mark};
        use crate::lay::{step, LaidDisk, LaidEstate, LaidMachine};
        plan.check()?;
        let epoch = journal.open_epoch().unwrap_or_else(|| {
            journal
                .last_epoch()
                .map(|l| l + 1_000)
                .unwrap_or_else(|| (self.clock)())
        });
        journal.append(GrowRecord::set_wide(
            epoch,
            epoch,
            step::LAY,
            Mark::Intent,
            plan.reference.clone(),
        ))?;
        // Each machine's disks and server are minted under the LAY's own key (`lay:<role>:<what>`,
        // as UpCloud labels them), never the resource's fingerprint: two machines of one plan are
        // two servers. Adopted by that key on a second lay.
        let mint = |what: &str,
                    title: &str,
                    word: &str,
                    resource: Resource,
                    size_gib: u64,
                    cost: Price|
         -> Result<String, CloudError> {
            let fp = format!("lay:{what}");
            let key = pool::Ledger::key(&plan.reference, &fp);
            let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
            if let Some(r) = st.resources.values().find(|r| {
                r.status == ResourceStatus::Active
                    && r.reference == plan.reference
                    && r.fingerprint == fp
            }) {
                return Ok(r.id.clone());
            }
            let mut intent = GrowRecord::set_wide(epoch, epoch, word, Mark::Intent, String::new());
            intent.volume = title.to_string();
            journal.append(intent)?;
            let kind = if matches!(resource, Resource::Server { .. }) {
                "server"
            } else {
                "disk"
            };
            let plan_name = if let Resource::Server { plan, .. } = &resource {
                Some(plan.clone())
            } else {
                None
            };
            let id = match self.record(
                &mut st,
                &Ask {
                    key: &key,
                    reference: &plan.reference,
                    fp: &fp,
                    resource: &resource,
                },
                kind,
                size_gib,
                None,
                cost,
            )? {
                Ensured::Provisioned { id, .. } => id.id,
                Ensured::OnHand { .. } => {
                    return Err(CloudError::Refused(format!(
                        "sandbox cloud: {title} was not minted"
                    )))
                }
            };
            if let Some(p) = plan_name {
                st.plans.insert(id.clone(), p);
                self.persist(&st)?;
            }
            drop(st);
            self.calls
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(Call::Ensure {
                    reference: plan.reference.clone(),
                    fingerprint: fp,
                });
            let mut done = GrowRecord::set_wide(epoch, epoch, word, Mark::Confirmed, id.clone());
            done.volume = title.to_string();
            journal.append(done)?;
            Ok(id)
        };
        let zone = plan.zone.clone();
        let mut machines = Vec::new();
        for (k, m) in plan.machines.iter().enumerate() {
            let mut disks = Vec::new();
            for (i, d) in m.disks.iter().enumerate() {
                if d.gib == 0 {
                    return Err(CloudError::Refused(format!(
                        "sandbox cloud: {} is a disk of 0 GiB",
                        d.title
                    )));
                }
                let cost = self.price_disk(&zone.0, d.gib)?;
                let id = mint(
                    &format!("{}:disk{i}", m.role),
                    &d.title,
                    step::DISK,
                    Resource::Disk {
                        server: ServerId(m.hostname.clone()),
                        gib: d.gib,
                    },
                    d.gib,
                    cost,
                )?;
                disks.push(LaidDisk {
                    title: d.title.clone(),
                    id,
                    gib: d.gib,
                    tier: d.tier.clone(),
                    address: format!("virtio:{i}"),
                });
            }
            let cost = self.price_plan(&zone.0, &m.plan)?;
            let server = mint(
                &format!("{}:server", m.role),
                &m.title,
                step::SERVER,
                Resource::Server {
                    plan: m.plan.clone(),
                    zone: zone.clone(),
                    role: crate::ServerRole::Primary,
                },
                0,
                cost,
            )?;
            machines.push(LaidMachine {
                role: m.role.clone(),
                hostname: m.hostname.clone(),
                title: m.title.clone(),
                plan: m.plan.clone(),
                server,
                public_ipv4: format!("198.51.100.{}", (10 + k) % 250),
                utility_ipv4: format!("10.6.{}.{}", (10 + k) / 250, (10 + k) % 250),
                disks,
                medium_clone: String::new(),
            });
        }
        journal.append(GrowRecord::set_wide(
            epoch,
            epoch,
            gstep::CLOSED,
            Mark::Confirmed,
            "laid",
        ))?;
        Ok(LaidEstate {
            reference: plan.reference.clone(),
            company: plan.company.clone(),
            zone: plan.zone.clone(),
            machines,
            narrate_from: plan.narrate_from.clone(),
            shape: plan.shape.clone(),
        })
    }

    pub fn ensure_with(
        &self,
        reference: &str,
        resource: &Resource,
        room: Room,
    ) -> Result<Ensured, CloudError> {
        self.ensure_room(reference, resource, Some(room))
    }

    /// Idempotent: a `Gone` resource destroyed again is `Ok`. An id this sandbox
    /// never minted is `UnknownResource` — never silently "gone".
    pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
        self.calls
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .push(Call::Destroy(id.clone()));
        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
        let now = (self.clock)();
        let r = Self::lookup(&mut st, id)?;
        if r.status == ResourceStatus::Gone {
            return Ok(());
        }
        r.status = ResourceStatus::Gone;
        r.destroyed_unix_ms = Some(now);
        let revert = match (&r.resource, &r.from_plan) {
            (Resource::Compute { server, .. }, Some(from)) => {
                Some((server.0.clone(), from.clone()))
            }
            (Resource::Server { .. }, _) => Some((r.id.clone(), String::new())),
            _ => None,
        };
        if let Some((server, plan)) = revert {
            if plan.is_empty() {
                st.plans.remove(&server);
            } else {
                st.plans.insert(server, plan);
            }
        }
        for k in st.pool.keys_for_id(&id.id) {
            st.pool.forget(&k);
        }
        self.persist(&st)
    }

    pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
        Ok(Self::lookup(&mut st, id)?.status.clone())
    }

    /// What it "owns", from its persisted state: every `Active` disk and server,
    /// `provenance: sandbox`. A plan change is not a thing that exists, so it is
    /// not listed. Nothing here is foreign — the sandbox has no account.
    pub fn list_owned(&self) -> Result<Inventory, CloudError> {
        let st = self.state.lock().unwrap_or_else(|e| e.into_inner());
        let owned = st
            .resources
            .values()
            .filter(|r| r.status == ResourceStatus::Active)
            .filter_map(|r| {
                let (kind, attached_to) = match &r.resource {
                    Resource::Disk { server, .. } => (OwnedKind::Disk, Some(server.0.clone())),
                    Resource::Server { .. } => (OwnedKind::Server, None),
                    Resource::Compute { .. } => return None,
                };
                Some(Owned {
                    id: Self::rid(&r.id),
                    kind,
                    gib: r.size_gib,
                    zone: self.cfg.zone.0.clone(),
                    attached_to,
                    reference: Some(r.reference.clone()),
                    monthly_minor: Some(r.cost.minor_per_month),
                    currency: r.cost.currency,
                    provenance: PROVIDER.into(),
                    resource: None,
                    backup: None,
                })
            })
            .collect();
        Ok(Inventory {
            owned,
            foreign: 0,
            currency: self.currency(),
        })
    }
}
#[allow(unused_variables, unreachable_code)]
impl crate::cloud::Cloud for Sandbox {
    fn manifest(&self) -> crate::cloud::CloudManifest {
        crate::cloud::CloudManifest {
            provider: self.provider().to_string(),
            zones: self.zones().to_vec(),
            settings: Vec::new(),
            grow_refused: Some(crate::grow::unsupported(self.provider()).to_string()),
        }
    }
    fn catalog(&self, zone: &crate::Zone) -> Result<crate::cloud::Catalog, crate::CloudError> {
        Ok(crate::cloud::Catalog {
            plans: self.plan_catalog(zone)?,
            ipv4: None,
        })
    }
    fn price(&self, what: &crate::cloud::Priced<'_>) -> Result<crate::Price, crate::CloudError> {
        match what {
            crate::cloud::Priced::Resource(r) => self.price_resource(r),
            crate::cloud::Priced::Plan(p) => {
                crate::price_estate_by_rows(p, |r| self.price_resource(r), None)
            }
            crate::cloud::Priced::Grow(set) => Err(crate::grow::unsupported(self.provider())),
        }
    }
    fn lay(
        &self,
        plan: &crate::lay::EstatePlan,
        journal: &crate::grow::Journal,
    ) -> Result<crate::lay::LaidEstate, crate::CloudError> {
        self.lay_estate(plan, journal)
    }
    fn change(
        &self,
        change: &crate::cloud::Change,
        journal: &crate::grow::Journal,
    ) -> Result<crate::cloud::Changed, crate::CloudError> {
        Ok(match change {
            crate::cloud::Change::Plan {
                server,
                plan,
                delta,
            } => return Err(crate::lay::unsupported_verb(self.provider(), "change_plan")),
            crate::cloud::Change::Grow { set, estate } => {
                return Err(crate::grow::unsupported(self.provider()))
            }
            crate::cloud::Change::Settle { set } => {
                return Err(crate::grow::unsupported(self.provider()))
            }
            crate::cloud::Change::ForgetMedia { laid } => {
                crate::cloud::Changed::Forgotten(Vec::new())
            }
            crate::cloud::Change::Ensure {
                reference,
                resource,
                room,
            } => crate::cloud::Changed::Ensured(match room {
                Some(r) => self.ensure_with(reference, resource, *r)?,
                None => self.ensure(reference, resource)?,
            }),
            crate::cloud::Change::Retire { reference, machine } => crate::cloud::Changed::Retired(
                crate::cloud::retire_by_rows(self, reference, machine)?,
            ),
            crate::cloud::Change::LabelAdopted { .. } => {
                return Err(crate::lay::unsupported_verb(
                    self.provider(),
                    "label_adopted",
                ))
            }
        })
    }
    fn boot(
        &self,
        boot: &crate::cloud::Boot,
        journal: &crate::grow::Journal,
    ) -> Result<crate::cloud::Booted, crate::CloudError> {
        Ok(match boot {
            crate::cloud::Boot::Keep(k) => {
                crate::cloud::Booted::Machine(self.keep_boot(k, journal)?)
            }
            crate::cloud::Boot::Power { server, on } => {
                return Err(crate::lay::unsupported_verb(self.provider(), "set_power"))
            }
            crate::cloud::Boot::Restore(r) => {
                return Err(crate::lay::unsupported_verb(
                    self.provider(),
                    "restore_from_backup",
                ))
            }
        })
    }
    fn firewall(&self, laid: &crate::lay::LaidEstate) -> Result<(), crate::CloudError> {
        self.set_estate_firewall(laid)
    }
    fn destroy(
        &self,
        what: &crate::cloud::Give<'_>,
    ) -> Result<crate::Reclaimed, crate::CloudError> {
        match what {
            crate::cloud::Give::References(refs) => {
                Ok(crate::release_for_by_rows(self.list_owned()?, refs, |id| {
                    self.destroy_id(id)
                }))
            }
            crate::cloud::Give::Resource(id) => {
                self.destroy_id(id)?;
                Ok(crate::Reclaimed {
                    destroyed: vec![(*id).clone()],
                    ..crate::Reclaimed::default()
                })
            }
        }
    }
    fn inventory(
        &self,
        what: &crate::cloud::Listing<'_>,
    ) -> Result<crate::cloud::Listed, crate::CloudError> {
        Ok(match what {
            crate::cloud::Listing::Account => crate::cloud::Listed::Account(self.list_owned()?),
            crate::cloud::Listing::Set(set) => {
                return Err(crate::grow::unsupported(self.provider()))
            }
            crate::cloud::Listing::Status(id) => crate::cloud::Listed::Status(self.status(id)?),
            crate::cloud::Listing::Under(refs) => {
                crate::cloud::Listed::Under(crate::cloud::under(self.list_owned()?, refs))
            }
            crate::cloud::Listing::Room { delta, room } => {
                crate::cloud::Listed::Room(crate::cloud::RoomLeft {
                    serve: Ok(()),
                    left_bytes: None,
                    ceilings: Default::default(),
                })
            }
            crate::cloud::Listing::Admits(_plan) => crate::cloud::Listed::Admitted,
        })
    }
}

/// Pure: the cores and memory a plan name spells (`STARTER-2xCPU-4GB` → (2, 4096)); `None` for
/// a name that spells neither.
pub fn plan_shape(name: &str) -> Option<(u32, u64)> {
    let cpu = name.find("xCPU-")?;
    let cores: u32 = name[..cpu].rsplit('-').next()?.parse().ok()?;
    let rest = &name[cpu + "xCPU-".len()..];
    let gb: u64 = rest.strip_suffix("GB")?.parse().ok()?;
    Some((cores, gb * 1024))
}

#[cfg(test)]
mod tests;