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;