crates/monetize-cloud-guest/src/lib.rs
//! **The guest side of a monetize CLOUD** — what a cloud plugin compiles
//! against, and the contract the server's cloud host
//! (`monetize-cloud-wasm`) speaks.
//!
//! Owner ruling 2026-09-25: monetize is BARE. A cloud provider (UpCloud,
//! Scaleway) is a signed `.wasm` the running server takes in, not code linked
//! into its binary. This crate is the half the guest sees:
//!
//! | piece | what it is |
//! |---|---|
//! | [`CloudGuest`] | the trait a cloud plugin implements: its manifest, and ONE [`monetize_cloud::Cloud`] built per call over the host's doors |
//! | [`ops`] | the op table: one number per question the host asks; each is answered from the nine [`monetize_cloud::Cloud`] ops |
//! | [`wire`] | the call envelope (the block's name and settings, then the op's own arguments) and the answer, with `CloudError`'s kind kept |
//! | [`dispatch`] | the op switch — used by the wasm export AND by a native test driving the same guest without a runtime |
//! | [`HostLedger`], [`HostPace`] | the pool ledger kept in the host's durable memory, and the host's clock and wait |
//! | [`export_cloud!`] | the product guest's exports and host bridge (`monetize_product_guest::__export_guest!`), with this dispatch |
//!
//! # What never crosses into the guest
//!
//! * **The spend ceiling.** The host wraps every guest cloud in
//! `monetize_cloud::cap::SpendCap` exactly as it wraps a linked one; the
//! guest is never asked what it may spend and never told what is left.
//! `spend_ceiling`, `reconcile_spend` and `spent_this_month_minor` are not
//! ops.
//! * **The provider's credential.** The host's HTTP door presents it; a guest
//! builds requests without an `Authorization` header.
//! * **The growth journal's file.** `grow_set` and `resume_grow` cross, and
//! every record the provider journals is written THROUGH the host's state door
//! ([`JOURNAL_KEY`]) to the host's file, fsync'd before the step is taken.
//! The PURE growth ops (`grow_gate`,
//! `price_grow`, `survey_set`) do.
//! * **The lay journal's file, the same way** (2026-10-08): `lay_estate` and
//! `change_plan` cross journaled exactly as a growth does (the job's
//! `estate-orders/<company>/journal`); `plan_catalog`, `price_estate` and
//! `set_power` cross plainly. A shop's cloud that is a guest lays a sold
//! estate with the provider's own code — the shop's credential stays the
//! host's, presented per request, and the spend ceiling stays the host's.
//!
//! # One provider per call
//!
//! A guest is a fresh instance per call: it remembers nothing, so it builds
//! its provider from the call's settings, answers, and is dropped. What a
//! provider must remember between calls — the pool ledger — lives in the
//! host's durable memory through [`HostLedger`]; the host serialises calls
//! into one cloud, so two calls never interleave on that ledger.
use std::collections::BTreeMap;
use std::time::Duration;
pub use monetize_cloud;
use monetize_cloud::cloud::Priced;
use monetize_cloud::{
ask, grow, Cloud, CloudError, Ensured, Inventory, Price, Reclaimed, Resource, ResourceId,
ResourceStatus, Room, Zone,
};
pub use monetize_product_guest as product_guest;
pub use monetize_product_guest::{abi, grants, imports, kinds, Host, Manifest, NoHost};
/// **The host, as a cloud guest holds it**: `'static`, so a provider built for
/// the call can keep it inside its HTTP client rather than only borrow it for
/// one function. The wasm bridge is a `static`; a native test leaks its fake.
pub type HostRef = &'static (dyn Host + Sync);
/// **The op table** — one number per question the host
/// forwards. Numbers are the wire; names are for logs and refusals. Cloud
/// ops are 200..299 (products below 100, vendors 100..199), so no number
/// means two things to two kinds even before the kind gate is asked.
pub mod ops {
/// `{ provider, zones, ceilings }` — asked once at load, because
/// `provider()`, `zones()` and `ceilings()` answer borrowed tables the
/// server reads far more often than it buys.
pub const ABOUT: u32 = 200;
/// The start-path proof: a provider that holds a credential proves it here
/// (UpCloud: one `GET /1.3/price`), so a dead token refuses the block by
/// name at load and not at the first purchase.
pub const VERIFY: u32 = 201;
pub const PRICE: u32 = 210;
pub const ENSURE: u32 = 211;
pub const ENSURE_WITH: u32 = 212;
pub const DESTROY: u32 = 213;
pub const STATUS: u32 = 214;
pub const INVENTORY: u32 = 215;
pub const CAN_SERVE: u32 = 216;
pub const CEILINGS: u32 = 217;
pub const ORPHANS_FOR: u32 = 218;
pub const RELEASE_FOR: u32 = 219;
/// `Cloud::catalog`: args a `Zone`, answers an
/// `Option<Price>` (`None`: the provider prices no address of its own). A
/// guest built before it answers "not in this cloud guest's op table",
/// which the host reads as `None` (the room price base's fallback).
pub const PRICE_IPV4: u32 = 220;
pub const GROW_GATE: u32 = 230;
pub const PRICE_GROW: u32 = 231;
pub const SURVEY_SET: u32 = 232;
/// The JOURNALED growth: the guest runs the provider's own `grow_set` /
/// `resume_grow`, and every record it journals is written THROUGH the host's
/// state door under [`super::JOURNAL_KEY`] to the host's journal file (fsync)
/// before the step is taken. Args: [`super::wire::GrowReq`].
pub const GROW_SET: u32 = 233;
pub const RESUME_GROW: u32 = 234;
// ── laying a whole estate (a shop's sold Gunnar), 2026-10-08 ─────────
//
// A guest built before these answers "not in this cloud guest's op
// table"; the host reads that as the trait's own refusal by name (the
// cloud cannot lay an estate) — or, for `price_estate`, prices the estate
// row by row as it always did.
/// `Priced::Plan`: args an
/// `EstatePlan`, answers a `Price` (every server, disk at its tier, and
/// public IPv4 from the provider's own list).
pub const PRICE_ESTATE: u32 = 240;
/// `Cloud::catalog`: args a `Zone`,
/// answers the provider's plans with their monthly price.
pub const PLAN_CATALOG: u32 = 241;
/// `Cloud::lay`, JOURNALED like
/// [`GROW_SET`]: args [`super::wire::LayReq`], answers a `LaidEstate`.
pub const LAY_ESTATE: u32 = 242;
/// `Boot::Power`: args
/// [`super::wire::PowerReq`]. Buys nothing.
pub const SET_POWER: u32 = 243;
/// `Change::Plan`, JOURNALED: args
/// [`super::wire::PlanReq`].
pub const CHANGE_PLAN: u32 = 244;
/// `Change::ForgetMedia`, JOURNALED: args
/// [`super::wire::ForgetReq`], answers the titles given back (2026-10-08).
pub const FORGET_MEDIA: u32 = 245;
/// `Boot::Keep`, JOURNALED: args
/// [`super::wire::KeepBootReq`], answers the `LaidMachine` after (F4, 2026-10-09).
pub const KEEP_BOOT: u32 = 246;
/// `Boot::Restore`, JOURNALED: args
/// [`super::wire::RestoreFromBackupReq`], answers `Option<Restored>` (S8, 2026-10-11).
pub const RESTORE_FROM_BACKUP: u32 = 247;
/// `Cloud::firewall`: args the `LaidEstate`, answers
/// `()` (owner 2026-10-18: every update writes the estate's firewall again first).
pub const SET_ESTATE_FIREWALL: u32 = 248;
/// `Change::LabelAdopted`: args the adopted `LaidEstate`, answers
/// the ids labelled (MAP-SEG 4, 2026-10-26). A guest built before answers NOT_IN_TABLE.
pub const LABEL_ADOPTED: u32 = 249;
pub const ALL: &[(u32, &str)] = &[
(ABOUT, "about"),
(VERIFY, "verify"),
(PRICE, "price"),
(PRICE_IPV4, "price_ipv4"),
(ENSURE, "ensure"),
(ENSURE_WITH, "ensure_with"),
(DESTROY, "destroy"),
(STATUS, "status"),
(INVENTORY, "inventory"),
(CAN_SERVE, "can_serve"),
(CEILINGS, "ceilings"),
(ORPHANS_FOR, "orphans_for"),
(RELEASE_FOR, "release_for"),
(GROW_GATE, "grow_gate"),
(PRICE_GROW, "price_grow"),
(SURVEY_SET, "survey_set"),
(GROW_SET, "grow_set"),
(RESUME_GROW, "resume_grow"),
(PRICE_ESTATE, "price_estate"),
(PLAN_CATALOG, "plan_catalog"),
(LAY_ESTATE, "lay_estate"),
(SET_POWER, "set_power"),
(CHANGE_PLAN, "change_plan"),
(FORGET_MEDIA, "forget_media"),
(KEEP_BOOT, "keep_boot"),
(RESTORE_FROM_BACKUP, "restore_from_backup"),
(SET_ESTATE_FIREWALL, "set_estate_firewall"),
(LABEL_ADOPTED, "label_adopted"),
];
/// The words a guest answers an op it does not have with — what the host
/// reads as "this guest predates the op".
pub const NOT_IN_TABLE: &str = "is not in this cloud guest's op table";
/// The SDK's whole sentence for `op` not in its table — what a guest built
/// before [`super::wire::WireCloudError::NotInTable`] answers, and the
/// words the host says a typed `NotInTable` in.
pub fn not_in_table(op: u32) -> String {
format!("op {op} {NOT_IN_TABLE}")
}
pub fn name(op: u32) -> Option<&'static str> {
ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
}
}
/// **The wire**: JSON both ways, the product guest's encoding.
pub mod wire {
use super::*;
pub use monetize_product_guest::wire::{decode, encode};
/// **Every op's request**: the block the guest is configured by, then the
/// op's own arguments. The block travels with every call because a guest
/// is a fresh instance per call and remembers nothing, and because the
/// guest must never read the config file — the host hands it the keys it
/// owns (the `[clouds.<name>]` block minus `enabled`, `cap` and every
/// `guest*` / door key, which are the host's).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Call<T> {
pub name: String,
pub settings: serde_json::Value,
pub args: T,
}
/// What [`ops::ABOUT`] answers.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct About {
pub provider: String,
pub zones: Vec<Zone>,
pub ceilings: BTreeMap<String, u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EnsureReq {
pub reference: String,
pub resource: Resource,
/// `None` for plain `ensure`.
pub room: Option<Room>,
}
/// `CloudError` across the wire, its kind kept: the transaction releases
/// on `Refused` and `OverCap`, retries on `Unavailable`, and treats
/// `UnknownResource` as already given back. `NotInTable(op)`: this guest
/// has no `op` — the host reads it as "predates `op`" by KIND (stinky
/// upcloud #21, 5.4); a guest built before it said
/// [`ops::not_in_table`] as a `Refused`, which the host still reads, exactly.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum WireCloudError {
Refused(String),
Unavailable(String),
UnknownResource(ResourceId),
OverCap(String),
NotInTable(u32),
/// Appended (a host before it cannot read it: the host updates first).
Exhausted(String),
}
impl From<CloudError> for WireCloudError {
fn from(e: CloudError) -> Self {
match e {
CloudError::Refused(s) => WireCloudError::Refused(s),
CloudError::OverCap(s) => WireCloudError::OverCap(s),
CloudError::Unavailable(s) => WireCloudError::Unavailable(s),
CloudError::UnknownResource(id) => WireCloudError::UnknownResource(id),
CloudError::Exhausted(s) => WireCloudError::Exhausted(s),
}
}
}
impl From<WireCloudError> for CloudError {
fn from(e: WireCloudError) -> Self {
match e {
WireCloudError::Refused(s) => CloudError::Refused(s),
WireCloudError::OverCap(s) => CloudError::OverCap(s),
WireCloudError::NotInTable(op) => CloudError::Refused(ops::not_in_table(op)),
WireCloudError::Unavailable(s) => CloudError::Unavailable(s),
WireCloudError::UnknownResource(id) => CloudError::UnknownResource(id),
WireCloudError::Exhausted(s) => CloudError::Exhausted(s),
}
}
}
/// Every op's answer envelope.
/// A journaled growth call: the set, and the journal AS THE HOST HOLDS IT now
/// (the open epoch, the addresses a detach recorded, the target).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GrowReq {
pub set: grow::DataSet,
pub records: Vec<grow::GrowRecord>,
}
/// A journaled LAY: the plan, and the job's journal as the host holds it.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LayReq {
pub plan: monetize_cloud::lay::EstatePlan,
pub records: Vec<grow::GrowRecord>,
}
/// One server stopped (`on = false`) or started.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PowerReq {
pub server: ResourceId,
pub on: bool,
}
/// A journaled plan change: the server, its new plan, what it adds a
/// month (the host's ceiling already admitted it), and the journal.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PlanReq {
pub server: ResourceId,
pub plan: String,
pub delta: Price,
pub records: Vec<grow::GrowRecord>,
}
/// The release clones of a laid estate given back, and the journal they were recorded in.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ForgetReq {
pub laid: monetize_cloud::lay::LaidEstate,
pub records: Vec<grow::GrowRecord>,
}
/// One laid machine upgraded in place, and the journal it is recorded in.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct KeepBootReq {
pub keep: monetize_cloud::lay::KeepBoot,
pub records: Vec<grow::GrowRecord>,
}
/// A sold estate's lost front restored from its backup, and the journal it is recorded in.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RestoreFromBackupReq {
pub restore: monetize_cloud::lay::RestoreFromBackup,
pub records: Vec<grow::GrowRecord>,
}
pub type Answer<T> = Result<T, WireCloudError>;
}
use wire::{About, Call, EnsureReq, WireCloudError};
/// **The block a call is made under** — the configured name and the keys the
/// guest owns.
#[derive(Clone, Debug, PartialEq)]
pub struct CallContext {
pub name: String,
pub settings: serde_json::Value,
}
/// **A cloud, as a guest writes it.**
pub trait CloudGuest {
/// `Manifest::of_kind(kinds::CLOUD, "<name>", &[grants…])`: the name is
/// the `[clouds.<name>]` it must be configured under.
fn manifest(&self) -> Manifest;
/// **The provider for ONE call**, built from the block's settings over the
/// host's doors. It must be cheap: no network call here — the credential
/// proof is [`CloudGuest::verify`], made once at load.
fn open(&self, host: HostRef, ctx: &CallContext) -> Result<Box<dyn Cloud>, CloudError>;
/// **The start-path proof**, asked once when the host loads the module.
/// The default is that `open` succeeded; a provider with a credential
/// proves it against the provider (and so refuses a dead token by name at
/// load, not at the first purchase).
fn verify(&self, host: HostRef, ctx: &CallContext) -> Result<(), CloudError> {
self.open(host, ctx).map(|_| ())
}
}
/// **The op switch** — one implementation, used by the wasm export and by a
/// native test. Answers the JSON bytes of a [`wire::Answer`]; an unknown op or
/// an undecodable request is a `Refused` in that envelope, never a trap.
pub fn dispatch(guest: &dyn CloudGuest, host: HostRef, op: u32, payload: &[u8]) -> Vec<u8> {
fn ans<T: serde::Serialize>(r: Result<T, CloudError>) -> Vec<u8> {
wire::encode(&r.map_err(WireCloudError::from))
}
fn req<T: serde::de::DeserializeOwned>(
op: u32,
payload: &[u8],
) -> Result<(CallContext, T), CloudError> {
let c: Call<T> = wire::decode(payload).map_err(|e| {
CloudError::Refused(format!(
"op {op} ({}): undecodable request: {e}",
ops::name(op).unwrap_or("?")
))
})?;
Ok((
CallContext {
name: c.name,
settings: c.settings,
},
c.args,
))
}
fn with<A: serde::de::DeserializeOwned, T>(
guest: &dyn CloudGuest,
host: HostRef,
op: u32,
payload: &[u8],
f: impl FnOnce(&dyn Cloud, A) -> Result<T, CloudError>,
) -> Result<T, CloudError> {
let (ctx, args) = req::<A>(op, payload)?;
let p = guest.open(host, &ctx)?;
f(p.as_ref(), args)
}
match op {
ops::ABOUT => ans(with::<(), _>(guest, host, op, payload, |p, ()| {
let m = p.manifest();
Ok(About {
provider: m.provider,
zones: m.zones,
ceilings: ask::ceilings(p),
})
})),
ops::VERIFY => ans(req::<()>(op, payload).and_then(|(ctx, ())| guest.verify(host, &ctx))),
ops::PRICE => ans(with::<Resource, Price>(guest, host, op, payload, |p, r| {
p.price(&Priced::Resource(&r))
})),
ops::PRICE_IPV4 => ans(with::<Zone, Option<Price>>(
guest,
host,
op,
payload,
|p, z| ask::price_ipv4(p, &z),
)),
ops::ENSURE | ops::ENSURE_WITH => ans(with::<EnsureReq, Ensured>(
guest,
host,
op,
payload,
|p, r| ask::ensure(p, &r.reference, &r.resource, r.room),
)),
ops::DESTROY => ans(with::<ResourceId, ()>(guest, host, op, payload, |p, id| {
ask::destroy_one(p, &id)
})),
ops::STATUS => ans(with::<ResourceId, ResourceStatus>(
guest,
host,
op,
payload,
|p, id| ask::status(p, &id),
)),
ops::INVENTORY => ans(with::<(), Inventory>(guest, host, op, payload, |p, ()| {
ask::account(p)
})),
ops::CAN_SERVE => ans(with::<Vec<Resource>, ()>(
guest,
host,
op,
payload,
|p, d| ask::can_serve(p, &d, None).map_err(CloudError::Refused),
)),
ops::CEILINGS => ans(with::<(), BTreeMap<String, u64>>(
guest,
host,
op,
payload,
|p, ()| Ok(ask::ceilings(p)),
)),
ops::ORPHANS_FOR => ans(with::<Vec<String>, Vec<ResourceId>>(
guest,
host,
op,
payload,
|p, r| ask::orphans_for(p, &r),
)),
ops::RELEASE_FOR => ans(with::<Vec<String>, Reclaimed>(
guest,
host,
op,
payload,
|p, r| ask::release_for(p, &r),
)),
ops::GROW_GATE => ans(with::<(), ()>(guest, host, op, payload, |p, ()| {
ask::grow_gate(p)
})),
ops::PRICE_GROW => ans(with::<grow::DataSet, Price>(
guest,
host,
op,
payload,
|p, s| p.price(&Priced::Grow(&s)),
)),
ops::SURVEY_SET => ans(with::<grow::DataSet, grow::SetSurvey>(
guest,
host,
op,
payload,
|p, s| ask::survey(p, &s),
)),
ops::GROW_SET => ans(with::<wire::GrowReq, grow::GrowOutcome>(
guest,
host,
op,
payload,
|p, r| ask::grow(p, &r.set, false, &host_journal(host, r.records)),
)),
ops::RESUME_GROW => ans(with::<wire::GrowReq, grow::GrowOutcome>(
guest,
host,
op,
payload,
|p, r| ask::settle(p, &r.set, &host_journal(host, r.records)),
)),
ops::PRICE_ESTATE => ans(with::<monetize_cloud::lay::EstatePlan, Price>(
guest,
host,
op,
payload,
|p, plan| p.price(&Priced::Plan(&plan)),
)),
ops::PLAN_CATALOG => ans(with::<Zone, Vec<monetize_cloud::lay::PlanSpec>>(
guest,
host,
op,
payload,
|p, z| ask::plan_catalog(p, &z),
)),
ops::LAY_ESTATE => ans(with::<wire::LayReq, monetize_cloud::lay::LaidEstate>(
guest,
host,
op,
payload,
|p, r| p.lay(&r.plan, &host_journal(host, r.records)),
)),
ops::SET_POWER => ans(with::<wire::PowerReq, ()>(
guest,
host,
op,
payload,
|p, r| ask::set_power(p, &r.server, r.on),
)),
ops::CHANGE_PLAN => ans(with::<wire::PlanReq, ()>(
guest,
host,
op,
payload,
|p, r| {
ask::change_plan(
p,
&r.server,
&r.plan,
r.delta,
&host_journal(host, r.records),
)
},
)),
ops::FORGET_MEDIA => ans(with::<wire::ForgetReq, Vec<String>>(
guest,
host,
op,
payload,
|p, r| ask::forget_media(p, &r.laid, &host_journal(host, r.records)),
)),
ops::KEEP_BOOT => ans(with::<wire::KeepBootReq, monetize_cloud::lay::LaidMachine>(
guest,
host,
op,
payload,
|p, r| ask::keep_boot(p, &r.keep, &host_journal(host, r.records)),
)),
ops::RESTORE_FROM_BACKUP => ans(with::<
wire::RestoreFromBackupReq,
Option<monetize_cloud::lay::Restored>,
>(guest, host, op, payload, |p, r| {
ask::restore_from_backup(p, &r.restore, &host_journal(host, r.records))
})),
ops::SET_ESTATE_FIREWALL => ans(with::<monetize_cloud::lay::LaidEstate, ()>(
guest,
host,
op,
payload,
|p, laid| p.firewall(&laid),
)),
ops::LABEL_ADOPTED => ans(with::<monetize_cloud::lay::LaidEstate, Vec<String>>(
guest,
host,
op,
payload,
|p, laid| ask::label_adopted(p, &laid),
)),
other => wire::encode(&Err::<(), _>(WireCloudError::NotInTable(other))),
}
}
/// **The pool ledger in the host's durable memory** — `monetize_cloud::pool`'s
/// [`monetize_cloud::pool::LedgerStore`] over the `state` door. The host keeps
/// a cloud's state in a file beside the server's ledger, so a grant written
/// here survives the call, a replacement of the module and a restart.
/// **The state key the host writes THROUGH to its growth journal** while a
/// journaled op runs (`monetize-product-wasm` `Doors::write_through`): one
/// [`grow::GrowRecord`] as JSON per put. Outside such a call the host holds no
/// journal and the put is an ordinary state write nobody reads.
pub const JOURNAL_KEY: &str = "grow-journal";
/// The guest's journal: `records` as the host holds them, every append written
/// through the host's door first ([`JOURNAL_KEY`]).
pub fn host_journal(host: HostRef, records: Vec<grow::GrowRecord>) -> grow::Journal {
grow::Journal::with_sink(
records,
Box::new(move |r: &grow::GrowRecord| {
let line = serde_json::to_vec(r)
.map_err(|e| CloudError::Unavailable(format!("grow journal encode: {e}")))?;
host.state_put(JOURNAL_KEY, &line).map_err(|e| {
CloudError::Unavailable(format!("grow journal (the host's door): {e}"))
})
}),
)
}
pub struct HostLedger {
pub host: HostRef,
pub key: String,
}
impl monetize_cloud::pool::LedgerStore for HostLedger {
fn load(&self) -> Result<Option<Vec<u8>>, CloudError> {
self.host.state_get(&self.key).map_err(|e| {
CloudError::Unavailable(format!("pool ledger (host state `{}`): {e}", self.key))
})
}
fn save(&self, bytes: &[u8]) -> Result<(), CloudError> {
self.host.state_put(&self.key, bytes).map_err(|e| {
CloudError::Unavailable(format!("pool ledger (host state `{}`): {e}", self.key))
})
}
fn describe(&self) -> String {
format!("host state `{}`", self.key)
}
}
/// **The host's clock and wait** — `monetize_cloud::pace::Pace` over the
/// grant-free `host_clock` and `host_sleep` doors.
pub struct HostPace(pub HostRef);
impl monetize_cloud::pace::Pace for HostPace {
fn now_ms(&self) -> u64 {
self.0.clock_unix_ms()
}
fn sleep(&self, d: Duration) {
self.0.sleep_ms(d.as_millis() as u64)
}
}
/// Generate the exports for a [`CloudGuest`] — the product guest's
/// scaffolding with this crate's [`dispatch`], and `host_sleep` imported.
///
/// ```ignore
/// monetize_cloud_guest::export_cloud!(UpCloudGuest, grants = [http, state]);
/// ```
///
/// The type must implement `CloudGuest` and `Default`. The grant tokens must
/// match what its manifest declares — the host's gate B refuses the module at
/// load if they do not.
#[macro_export]
macro_rules! export_cloud {
($guest:ty, grants = [$($grant:ident),* $(,)?] $(,)?) => {
$crate::product_guest::__export_guest!($guest, $crate::CloudGuest, $crate::dispatch, sleep = yes, grants = [$($grant),*]);
};
}
#[cfg(test)]
mod tests {
use super::*;
use monetize_cloud::{ServerId, Zone};
/// A cloud with canned answers — what proves the switch keeps each
/// answer's shape and each error's kind.
struct Canned;
struct CannedProvider(String);
#[allow(dead_code)]
impl CannedProvider {
pub fn provider(&self) -> &'static str {
"canned"
}
pub fn zones(&self) -> &[Zone] {
static Z: std::sync::OnceLock<Vec<Zone>> = std::sync::OnceLock::new();
Z.get_or_init(|| vec![Zone("se-sto1".into())])
}
pub fn price_resource(&self, _: &Resource) -> Result<Price, CloudError> {
Ok(Price {
minor_per_month: 230,
currency: monetize_pay::Currency::Eur,
})
}
pub fn ensure(&self, reference: &str, _: &Resource) -> Result<Ensured, CloudError> {
if reference == "down" {
return Err(CloudError::Unavailable("provider off".into()));
}
Ok(Ensured::Provisioned {
id: ResourceId {
provider: "canned".into(),
id: format!("{}:{reference}", self.0),
},
cost: self.price_resource(&Resource::Compute {
server: ServerId("s".into()),
plan: "p".into(),
})?,
})
}
pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
Err(CloudError::UnknownResource(id.clone()))
}
pub fn status(&self, _: &ResourceId) -> Result<ResourceStatus, CloudError> {
Ok(ResourceStatus::Active)
}
pub fn list_owned(&self) -> Result<Inventory, CloudError> {
Err(CloudError::Refused("no account".into()))
}
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for CannedProvider {
fn manifest(&self) -> monetize_cloud::cloud::CloudManifest {
monetize_cloud::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(monetize_cloud::grow::unsupported(self.provider()).to_string()),
}
}
fn catalog(
&self,
zone: &monetize_cloud::Zone,
) -> Result<monetize_cloud::cloud::Catalog, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn price(
&self,
what: &monetize_cloud::cloud::Priced<'_>,
) -> Result<monetize_cloud::Price, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Priced::Resource(r) => self.price_resource(r),
monetize_cloud::cloud::Priced::Plan(p) => {
monetize_cloud::price_estate_by_rows(p, |r| self.price_resource(r), None)
}
monetize_cloud::cloud::Priced::Grow(set) => {
Err(monetize_cloud::grow::unsupported(self.provider()))
}
}
}
fn lay(
&self,
plan: &monetize_cloud::lay::EstatePlan,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::lay::LaidEstate, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn change(
&self,
change: &monetize_cloud::cloud::Change,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Changed, monetize_cloud::CloudError> {
Ok(match change {
monetize_cloud::cloud::Change::Plan {
server,
plan,
delta,
} => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"change_plan",
))
}
monetize_cloud::cloud::Change::Grow { set, estate } => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Change::Settle { set } => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Change::ForgetMedia { laid } => {
monetize_cloud::cloud::Changed::Forgotten(Vec::new())
}
monetize_cloud::cloud::Change::Ensure {
reference,
resource,
room,
} => monetize_cloud::cloud::Changed::Ensured(self.ensure(reference, resource)?),
monetize_cloud::cloud::Change::Retire { reference, machine } => {
monetize_cloud::cloud::Changed::Retired(monetize_cloud::cloud::retire_by_rows(
self, reference, machine,
)?)
}
monetize_cloud::cloud::Change::LabelAdopted { .. } => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &monetize_cloud::cloud::Boot,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Booted, monetize_cloud::CloudError> {
Ok(match boot {
monetize_cloud::cloud::Boot::Keep(k) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"keep_boot",
))
}
monetize_cloud::cloud::Boot::Power { server, on } => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"set_power",
))
}
monetize_cloud::cloud::Boot::Restore(r) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(
&self,
laid: &monetize_cloud::lay::LaidEstate,
) -> Result<(), monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &monetize_cloud::cloud::Give<'_>,
) -> Result<monetize_cloud::Reclaimed, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Give::References(refs) => Ok(
monetize_cloud::release_for_by_rows(self.list_owned()?, refs, |id| {
self.destroy_id(id)
}),
),
monetize_cloud::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(monetize_cloud::Reclaimed {
destroyed: vec![(*id).clone()],
..monetize_cloud::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &monetize_cloud::cloud::Listing<'_>,
) -> Result<monetize_cloud::cloud::Listed, monetize_cloud::CloudError> {
Ok(match what {
monetize_cloud::cloud::Listing::Account => {
monetize_cloud::cloud::Listed::Account(self.list_owned()?)
}
monetize_cloud::cloud::Listing::Set(set) => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Listing::Status(id) => {
monetize_cloud::cloud::Listed::Status(self.status(id)?)
}
monetize_cloud::cloud::Listing::Under(refs) => {
monetize_cloud::cloud::Listed::Under(monetize_cloud::cloud::under(
self.list_owned()?,
refs,
))
}
monetize_cloud::cloud::Listing::Room { delta, room } => {
monetize_cloud::cloud::Listed::Room(monetize_cloud::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
monetize_cloud::cloud::Listing::Admits(_plan) => {
monetize_cloud::cloud::Listed::Admitted
}
})
}
}
impl CloudGuest for Canned {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::CLOUD, "canned", &[])
}
fn open(&self, _: HostRef, ctx: &CallContext) -> Result<Box<dyn Cloud>, CloudError> {
Ok(Box::new(CannedProvider(
ctx.settings["zone"].as_str().unwrap_or("none").to_string(),
)))
}
}
fn call<T: serde::Serialize>(args: T) -> Vec<u8> {
wire::encode(&Call {
name: "canned".into(),
settings: serde_json::json!({"zone": "se-sto1"}),
args,
})
}
#[test]
fn every_op_round_trips_and_errors_keep_their_kind() {
let host: HostRef = &NoHost;
let about: wire::Answer<About> =
wire::decode(&dispatch(&Canned, host, ops::ABOUT, &call(()))).unwrap();
assert_eq!(about.unwrap().zones, vec![Zone("se-sto1".into())]);
let disk = Resource::Disk {
server: ServerId("s".into()),
gib: 10,
};
let ok: wire::Answer<Ensured> = wire::decode(&dispatch(
&Canned,
host,
ops::ENSURE,
&call(EnsureReq {
reference: "r1".into(),
resource: disk.clone(),
room: None,
}),
))
.unwrap();
match ok.unwrap() {
Ensured::Provisioned { id, cost } => {
assert_eq!(id.id, "se-sto1:r1", "the settings reached the provider");
assert_eq!(cost.minor_per_month, 230);
}
other => panic!("{other:?}"),
}
let down: wire::Answer<Ensured> = wire::decode(&dispatch(
&Canned,
host,
ops::ENSURE_WITH,
&call(EnsureReq {
reference: "down".into(),
resource: disk,
room: Some(Room::Buy),
}),
))
.unwrap();
assert!(
matches!(CloudError::from(down.unwrap_err()), CloudError::Unavailable(m) if m == "provider off")
);
let gone: wire::Answer<()> = wire::decode(&dispatch(
&Canned,
host,
ops::DESTROY,
&call(ResourceId {
provider: "canned".into(),
id: "x".into(),
}),
))
.unwrap();
assert!(matches!(gone.unwrap_err(), WireCloudError::UnknownResource(id) if id.id == "x"));
let refused: wire::Answer<Inventory> =
wire::decode(&dispatch(&Canned, host, ops::INVENTORY, &call(()))).unwrap();
assert!(matches!(refused.unwrap_err(), WireCloudError::Refused(m) if m == "no account"));
let grow: wire::Answer<()> =
wire::decode(&dispatch(&Canned, host, ops::GROW_GATE, &call(()))).unwrap();
assert!(
matches!(grow.unwrap_err(), WireCloudError::Refused(m) if m.contains("canned")),
"the default growth refusal crosses by name"
);
let unknown: wire::Answer<()> =
wire::decode(&dispatch(&Canned, host, 999, &call(()))).unwrap();
assert_eq!(
unknown.unwrap_err(),
WireCloudError::NotInTable(999),
"an op not in the table is said by KIND"
);
let bad: wire::Answer<Price> =
wire::decode(&dispatch(&Canned, host, ops::PRICE, b"not json")).unwrap();
assert!(
matches!(bad.unwrap_err(), WireCloudError::Refused(m) if m.contains("undecodable"))
);
}
#[test]
fn the_manifest_is_a_cloud_and_the_ops_are_named() {
let m = Canned.manifest();
assert_eq!(
(m.kind.as_str(), m.product.as_str()),
(kinds::CLOUD, "canned")
);
for (n, name) in ops::ALL {
assert_eq!(ops::name(*n), Some(*name));
}
// An older manifest (no `kind`) reads as a product.
let old: Manifest = wire::decode(
br#"{"abi_min":1,"abi_max":1,"product":"x","grants":[],"identity":false}"#,
)
.unwrap();
assert_eq!(old.kind, kinds::PRODUCT);
}
/// **GROW_SET crosses**: the provider's `grow_set` runs in the guest against the
/// records the host sent, and every record it journals is a `state_put` of
/// [`JOURNAL_KEY`] — in order, before the answer.
#[test]
fn grow_set_runs_in_the_guest_and_journals_through_the_host_door() {
struct Journaling;
#[allow(dead_code)]
impl Journaling {
pub fn provider(&self) -> &'static str {
"canned"
}
pub fn zones(&self) -> &[Zone] {
&[]
}
pub fn price_resource(&self, _: &Resource) -> Result<Price, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn ensure(&self, _: &str, _: &Resource) -> Result<Ensured, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn destroy_id(&self, _: &ResourceId) -> Result<(), CloudError> {
Ok(())
}
pub fn status(&self, _: &ResourceId) -> Result<ResourceStatus, CloudError> {
Ok(ResourceStatus::Active)
}
pub fn list_owned(&self) -> Result<Inventory, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn grow_set(
&self,
set: &grow::DataSet,
journal: &grow::Journal,
) -> Result<grow::GrowOutcome, CloudError> {
assert_eq!(journal.open_epoch(), Some(5), "the host's records arrived");
journal.append(grow::GrowRecord::set_wide(
10,
5,
grow::step::RESUME,
grow::Mark::Intent,
set.product.clone(),
))?;
journal.append(grow::GrowRecord::set_wide(
11,
5,
grow::step::CLOSED,
grow::Mark::Confirmed,
"untouched",
))?;
Ok(grow::GrowOutcome::Untouched {
why: "nothing to do".into(),
})
}
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for Journaling {
fn manifest(&self) -> monetize_cloud::cloud::CloudManifest {
monetize_cloud::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(
monetize_cloud::grow::unsupported(self.provider()).to_string(),
),
}
}
fn catalog(
&self,
zone: &monetize_cloud::Zone,
) -> Result<monetize_cloud::cloud::Catalog, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn price(
&self,
what: &monetize_cloud::cloud::Priced<'_>,
) -> Result<monetize_cloud::Price, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Priced::Resource(r) => self.price_resource(r),
monetize_cloud::cloud::Priced::Plan(p) => {
monetize_cloud::price_estate_by_rows(p, |r| self.price_resource(r), None)
}
monetize_cloud::cloud::Priced::Grow(set) => {
Err(monetize_cloud::grow::unsupported(self.provider()))
}
}
}
fn lay(
&self,
plan: &monetize_cloud::lay::EstatePlan,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::lay::LaidEstate, monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported(self.provider()))
}
fn change(
&self,
change: &monetize_cloud::cloud::Change,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Changed, monetize_cloud::CloudError> {
Ok(match change {
monetize_cloud::cloud::Change::Plan {
server,
plan,
delta,
} => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"change_plan",
))
}
monetize_cloud::cloud::Change::Grow { set, estate } => {
monetize_cloud::cloud::Changed::Grown(self.grow_set(set, journal)?)
}
monetize_cloud::cloud::Change::Settle { set } => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Change::ForgetMedia { laid } => {
monetize_cloud::cloud::Changed::Forgotten(Vec::new())
}
monetize_cloud::cloud::Change::Ensure {
reference,
resource,
room,
} => monetize_cloud::cloud::Changed::Ensured(self.ensure(reference, resource)?),
monetize_cloud::cloud::Change::Retire { reference, machine } => {
monetize_cloud::cloud::Changed::Retired(
monetize_cloud::cloud::retire_by_rows(self, reference, machine)?,
)
}
monetize_cloud::cloud::Change::LabelAdopted { .. } => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &monetize_cloud::cloud::Boot,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Booted, monetize_cloud::CloudError> {
Ok(match boot {
monetize_cloud::cloud::Boot::Keep(k) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"keep_boot",
))
}
monetize_cloud::cloud::Boot::Power { server, on } => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"set_power",
))
}
monetize_cloud::cloud::Boot::Restore(r) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(
&self,
laid: &monetize_cloud::lay::LaidEstate,
) -> Result<(), monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &monetize_cloud::cloud::Give<'_>,
) -> Result<monetize_cloud::Reclaimed, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Give::References(refs) => Ok(
monetize_cloud::release_for_by_rows(self.list_owned()?, refs, |id| {
self.destroy_id(id)
}),
),
monetize_cloud::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(monetize_cloud::Reclaimed {
destroyed: vec![(*id).clone()],
..monetize_cloud::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &monetize_cloud::cloud::Listing<'_>,
) -> Result<monetize_cloud::cloud::Listed, monetize_cloud::CloudError> {
Ok(match what {
monetize_cloud::cloud::Listing::Account => {
monetize_cloud::cloud::Listed::Account(self.list_owned()?)
}
monetize_cloud::cloud::Listing::Set(set) => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Listing::Status(id) => {
monetize_cloud::cloud::Listed::Status(self.status(id)?)
}
monetize_cloud::cloud::Listing::Under(refs) => {
monetize_cloud::cloud::Listed::Under(monetize_cloud::cloud::under(
self.list_owned()?,
refs,
))
}
monetize_cloud::cloud::Listing::Room { delta, room } => {
monetize_cloud::cloud::Listed::Room(monetize_cloud::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
monetize_cloud::cloud::Listing::Admits(_plan) => {
monetize_cloud::cloud::Listed::Admitted
}
})
}
}
struct G;
impl CloudGuest for G {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::CLOUD, "canned", &[])
}
fn open(&self, _: HostRef, _: &CallContext) -> Result<Box<dyn Cloud>, CloudError> {
Ok(Box::new(Journaling))
}
}
struct Puts(std::sync::Mutex<Vec<(String, Vec<u8>)>>);
impl Host for Puts {
fn state_put(&self, key: &str, value: &[u8]) -> Result<(), String> {
self.0.lock().unwrap().push((key.into(), value.to_vec()));
Ok(())
}
}
let puts: &'static Puts = Box::leak(Box::new(Puts(Default::default())));
let set = grow::DataSet {
product: "gunnar".into(),
server: monetize_cloud::ServerId("s".into()),
members: vec![],
target_gib: 20,
};
let open =
grow::GrowRecord::set_wide(1, 5, grow::step::PREFLIGHT, grow::Mark::Intent, "open")
.with_gib(20);
let out: wire::Answer<grow::GrowOutcome> = wire::decode(&dispatch(
&G,
puts,
ops::GROW_SET,
&call(wire::GrowReq {
set,
records: vec![open],
}),
))
.unwrap();
assert!(
matches!(out.unwrap(), grow::GrowOutcome::Untouched { ref why } if why == "nothing to do")
);
let got = puts.0.lock().unwrap();
assert_eq!(got.len(), 2);
assert!(got.iter().all(|(k, _)| k == JOURNAL_KEY));
let recs: Vec<grow::GrowRecord> = got
.iter()
.map(|(_, v)| serde_json::from_slice(v).unwrap())
.collect();
assert_eq!(
(recs[0].step.as_str(), recs[1].step.as_str()),
(grow::step::RESUME, grow::step::CLOSED)
);
assert_eq!(recs[0].detail, "gunnar", "the set reached the provider");
}
/// ★ **The estate ops cross** (2026-10-08, test2's shop: "the upcloud
/// cloud cannot lay an estate"): the plan list, the estate's price, the
/// lay — journaled through the host's door like a growth, in order, before
/// the answer — the power switch and the journaled plan change. A provider
/// with no estate verbs answers the trait's own refusal, by name.
#[test]
fn the_estate_ops_cross_and_the_lay_journals_through_the_host_door() {
use monetize_cloud::lay::{self, EstatePlan, LaidEstate, LaidMachine, PlanSpec};
struct Laying;
#[allow(dead_code)]
impl Laying {
pub fn provider(&self) -> &'static str {
"canned"
}
pub fn zones(&self) -> &[Zone] {
&[]
}
pub fn price_resource(&self, _: &Resource) -> Result<Price, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn ensure(&self, _: &str, _: &Resource) -> Result<Ensured, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn destroy_id(&self, _: &ResourceId) -> Result<(), CloudError> {
Ok(())
}
pub fn status(&self, _: &ResourceId) -> Result<ResourceStatus, CloudError> {
Ok(ResourceStatus::Active)
}
pub fn list_owned(&self) -> Result<Inventory, CloudError> {
Err(CloudError::Refused("x".into()))
}
pub fn plan_catalog(&self, zone: &Zone) -> Result<Vec<PlanSpec>, CloudError> {
Ok(vec![PlanSpec {
name: format!("1x2@{}", zone.0),
cores: 1,
memory_mib: 2048,
price: Price {
minor_per_month: 700,
currency: monetize_pay::Currency::Eur,
},
}])
}
pub fn price_estate(&self, plan: &EstatePlan) -> Result<Price, CloudError> {
Ok(Price {
minor_per_month: 100 * plan.machines.len() as u64,
currency: monetize_pay::Currency::Eur,
})
}
pub fn lay_estate(
&self,
plan: &EstatePlan,
journal: &grow::Journal,
) -> Result<LaidEstate, CloudError> {
assert_eq!(journal.records().len(), 1, "the host's records arrived");
journal.append(grow::GrowRecord::set_wide(
10,
9,
lay::step::LAY,
grow::Mark::Intent,
plan.reference.clone(),
))?;
journal.append(grow::GrowRecord::set_wide(
11,
9,
grow::step::CLOSED,
grow::Mark::Confirmed,
"laid",
))?;
Ok(LaidEstate {
reference: plan.reference.clone(),
company: plan.company.clone(),
zone: plan.zone.clone(),
machines: vec![LaidMachine {
role: "front".into(),
hostname: "f".into(),
title: "f".into(),
plan: "1x2".into(),
server: "uuid-f".into(),
public_ipv4: "192.0.2.1".into(),
utility_ipv4: "10.0.0.1".into(),
disks: vec![],
medium_clone: String::new(),
}],
narrate_from: plan.narrate_from.clone(),
shape: plan.shape.clone(),
})
}
pub fn set_power(&self, server: &ResourceId, on: bool) -> Result<(), CloudError> {
if on {
Ok(())
} else {
Err(CloudError::UnknownResource(server.clone()))
}
}
pub fn change_plan(
&self,
server: &ResourceId,
plan: &str,
delta: Price,
journal: &grow::Journal,
) -> Result<(), CloudError> {
journal.append(grow::GrowRecord::set_wide(
12,
9,
lay::step::PLAN,
grow::Mark::Confirmed,
format!("{}:{plan}:{}", server.id, delta.minor_per_month),
))
}
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for Laying {
fn manifest(&self) -> monetize_cloud::cloud::CloudManifest {
monetize_cloud::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(
monetize_cloud::grow::unsupported(self.provider()).to_string(),
),
}
}
fn catalog(
&self,
zone: &monetize_cloud::Zone,
) -> Result<monetize_cloud::cloud::Catalog, monetize_cloud::CloudError> {
Ok(monetize_cloud::cloud::Catalog {
plans: self.plan_catalog(zone)?,
ipv4: None,
})
}
fn price(
&self,
what: &monetize_cloud::cloud::Priced<'_>,
) -> Result<monetize_cloud::Price, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Priced::Resource(r) => self.price_resource(r),
monetize_cloud::cloud::Priced::Plan(p) => self.price_estate(p),
monetize_cloud::cloud::Priced::Grow(set) => {
Err(monetize_cloud::grow::unsupported(self.provider()))
}
}
}
fn lay(
&self,
plan: &monetize_cloud::lay::EstatePlan,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::lay::LaidEstate, monetize_cloud::CloudError> {
self.lay_estate(plan, journal)
}
fn change(
&self,
change: &monetize_cloud::cloud::Change,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Changed, monetize_cloud::CloudError> {
Ok(match change {
monetize_cloud::cloud::Change::Plan {
server,
plan,
delta,
} => {
self.change_plan(server, plan, *delta, journal)?;
monetize_cloud::cloud::Changed::Done
}
monetize_cloud::cloud::Change::Grow { set, estate } => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Change::Settle { set } => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Change::ForgetMedia { laid } => {
monetize_cloud::cloud::Changed::Forgotten(Vec::new())
}
monetize_cloud::cloud::Change::Ensure {
reference,
resource,
room,
} => monetize_cloud::cloud::Changed::Ensured(self.ensure(reference, resource)?),
monetize_cloud::cloud::Change::Retire { reference, machine } => {
monetize_cloud::cloud::Changed::Retired(
monetize_cloud::cloud::retire_by_rows(self, reference, machine)?,
)
}
monetize_cloud::cloud::Change::LabelAdopted { .. } => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &monetize_cloud::cloud::Boot,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::cloud::Booted, monetize_cloud::CloudError> {
Ok(match boot {
monetize_cloud::cloud::Boot::Keep(k) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"keep_boot",
))
}
monetize_cloud::cloud::Boot::Power { server, on } => {
self.set_power(server, *on)?;
monetize_cloud::cloud::Booted::Powered
}
monetize_cloud::cloud::Boot::Restore(r) => {
return Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(
&self,
laid: &monetize_cloud::lay::LaidEstate,
) -> Result<(), monetize_cloud::CloudError> {
Err(monetize_cloud::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &monetize_cloud::cloud::Give<'_>,
) -> Result<monetize_cloud::Reclaimed, monetize_cloud::CloudError> {
match what {
monetize_cloud::cloud::Give::References(refs) => Ok(
monetize_cloud::release_for_by_rows(self.list_owned()?, refs, |id| {
self.destroy_id(id)
}),
),
monetize_cloud::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(monetize_cloud::Reclaimed {
destroyed: vec![(*id).clone()],
..monetize_cloud::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &monetize_cloud::cloud::Listing<'_>,
) -> Result<monetize_cloud::cloud::Listed, monetize_cloud::CloudError> {
Ok(match what {
monetize_cloud::cloud::Listing::Account => {
monetize_cloud::cloud::Listed::Account(self.list_owned()?)
}
monetize_cloud::cloud::Listing::Set(set) => {
return Err(monetize_cloud::grow::unsupported(self.provider()))
}
monetize_cloud::cloud::Listing::Status(id) => {
monetize_cloud::cloud::Listed::Status(self.status(id)?)
}
monetize_cloud::cloud::Listing::Under(refs) => {
monetize_cloud::cloud::Listed::Under(monetize_cloud::cloud::under(
self.list_owned()?,
refs,
))
}
monetize_cloud::cloud::Listing::Room { delta, room } => {
monetize_cloud::cloud::Listed::Room(monetize_cloud::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
monetize_cloud::cloud::Listing::Admits(_plan) => {
monetize_cloud::cloud::Listed::Admitted
}
})
}
}
struct G;
impl CloudGuest for G {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::CLOUD, "canned", &[])
}
fn open(&self, _: HostRef, _: &CallContext) -> Result<Box<dyn Cloud>, CloudError> {
Ok(Box::new(Laying))
}
}
struct Puts(std::sync::Mutex<Vec<(String, Vec<u8>)>>);
impl Host for Puts {
fn state_put(&self, key: &str, value: &[u8]) -> Result<(), String> {
self.0.lock().unwrap().push((key.into(), value.to_vec()));
Ok(())
}
}
let puts: &'static Puts = Box::leak(Box::new(Puts(Default::default())));
let plan = EstatePlan {
reference: lay::reference_of("acme"),
company: "acme".into(),
zone: Zone("se-sto1".into()),
machines: vec![],
narrate_from: String::new(),
shape: None,
};
let plans: wire::Answer<Vec<PlanSpec>> = wire::decode(&dispatch(
&G,
puts,
ops::PLAN_CATALOG,
&call(Zone("se-sto1".into())),
))
.unwrap();
assert_eq!(
plans.unwrap()[0].name,
"1x2@se-sto1",
"the zone reached the provider"
);
let price: wire::Answer<Price> =
wire::decode(&dispatch(&G, puts, ops::PRICE_ESTATE, &call(plan.clone()))).unwrap();
assert_eq!(
price.unwrap().minor_per_month,
0,
"the provider's own estate price, not a sum the host made"
);
let open = grow::GrowRecord::set_wide(1, 9, lay::step::LAY, grow::Mark::Intent, "earlier");
let laid: wire::Answer<LaidEstate> = wire::decode(&dispatch(
&G,
puts,
ops::LAY_ESTATE,
&call(wire::LayReq {
plan: plan.clone(),
records: vec![open.clone()],
}),
))
.unwrap();
let laid = laid.unwrap();
assert_eq!(
(laid.reference.as_str(), laid.machines[0].server.as_str()),
("estate/acme", "uuid-f")
);
{
let got = puts.0.lock().unwrap();
assert_eq!(
got.len(),
2,
"both lay records were written through the host's door"
);
assert!(got.iter().all(|(k, _)| k == JOURNAL_KEY));
let recs: Vec<grow::GrowRecord> = got
.iter()
.map(|(_, v)| serde_json::from_slice(v).unwrap())
.collect();
assert_eq!(
(recs[0].step.as_str(), recs[1].step.as_str()),
(lay::step::LAY, grow::step::CLOSED)
);
assert_eq!(recs[0].detail, "estate/acme");
}
let srv = ResourceId {
provider: "canned".into(),
id: "uuid-m".into(),
};
let on: wire::Answer<()> = wire::decode(&dispatch(
&G,
puts,
ops::SET_POWER,
&call(wire::PowerReq {
server: srv.clone(),
on: true,
}),
))
.unwrap();
assert!(on.is_ok());
let off: wire::Answer<()> = wire::decode(&dispatch(
&G,
puts,
ops::SET_POWER,
&call(wire::PowerReq {
server: srv.clone(),
on: false,
}),
))
.unwrap();
assert!(
matches!(off.unwrap_err(), WireCloudError::UnknownResource(id) if id.id == "uuid-m"),
"the error's kind is kept"
);
let delta = Price {
minor_per_month: 1234,
currency: monetize_pay::Currency::Eur,
};
let changed: wire::Answer<()> = wire::decode(&dispatch(
&G,
puts,
ops::CHANGE_PLAN,
&call(wire::PlanReq {
server: srv,
plan: "2x4".into(),
delta,
records: vec![],
}),
))
.unwrap();
assert!(changed.is_ok());
let last: grow::GrowRecord =
serde_json::from_slice(&puts.0.lock().unwrap().last().unwrap().1).unwrap();
assert_eq!(
(last.step.as_str(), last.detail.as_str()),
(lay::step::PLAN, "uuid-m:2x4:1234")
);
// A provider with no estate verbs: the trait's own refusal, by name.
let none: wire::Answer<Vec<PlanSpec>> = wire::decode(&dispatch(
&Canned,
puts,
ops::PLAN_CATALOG,
&call(Zone("se-sto1".into())),
))
.unwrap();
assert!(
matches!(none.unwrap_err(), WireCloudError::Refused(m) if m.contains("canned cloud cannot lay an estate"))
);
let none: wire::Answer<LaidEstate> = wire::decode(&dispatch(
&Canned,
puts,
ops::LAY_ESTATE,
&call(wire::LayReq {
plan,
records: vec![],
}),
))
.unwrap();
assert!(
matches!(none.unwrap_err(), WireCloudError::Refused(m) if m.contains("cannot lay an estate"))
);
}
/// The pool ledger through the host's memory: a grant written in one call
/// is read by the next, which is what makes `ensure` idempotent in a guest.
#[test]
fn the_ledger_lives_in_the_host_state_between_calls() {
struct Mem(std::sync::Mutex<BTreeMap<String, Vec<u8>>>);
impl Host for Mem {
fn state_get(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
Ok(self.0.lock().unwrap().get(key).cloned())
}
fn state_put(&self, key: &str, value: &[u8]) -> Result<(), String> {
self.0.lock().unwrap().insert(key.into(), value.to_vec());
Ok(())
}
}
let host: HostRef = Box::leak(Box::new(Mem(Default::default())));
let store = || {
Box::new(HostLedger {
host,
key: "pool".into(),
})
};
let mut first = monetize_cloud::pool::Ledger::with_store(store()).unwrap();
first
.record(
"r|disk:s:10",
monetize_cloud::pool::Grant::OnHand {
server: "s".into(),
gib: 10,
free_after: 0,
},
)
.unwrap();
let second = monetize_cloud::pool::Ledger::with_store(store()).unwrap();
assert_eq!(second.sold("s"), 10);
assert!(second.grant("r|disk:s:10").is_some());
}
/// MON-OSSF (2026-10-31): **every kind of [`CloudError`] crosses the wire and back as
/// itself**, since the transaction acts on the kind (release on Refused and OverCap, retry on
/// Unavailable, already-gone on UnknownResource, a full pool on Exhausted); `NotInTable` comes
/// back as the exact words a guest built before it said, which the host still reads.
#[test]
fn every_cloud_error_kind_round_trips_and_not_in_table_reads_as_the_old_words() {
let id = ResourceId {
provider: "canned".into(),
id: "x".into(),
};
for e in [
CloudError::Refused("r".into()),
CloudError::Unavailable("u".into()),
CloudError::UnknownResource(id),
CloudError::OverCap("o".into()),
CloudError::Exhausted("e".into()),
] {
let words = format!("{e:?}");
let w = WireCloudError::from(e);
let back: WireCloudError = wire::decode(&wire::encode(&w)).unwrap();
assert_eq!(back, w);
assert_eq!(
format!("{:?}", CloudError::from(back)),
words,
"the kind and its payload survive"
);
}
assert_eq!(
ops::not_in_table(7),
"op 7 is not in this cloud guest's op table"
);
match CloudError::from(WireCloudError::NotInTable(7)) {
CloudError::Refused(s) => assert_eq!(s, ops::not_in_table(7)),
other => panic!("{other:?}"),
}
}
/// Every op in the table is switched: undecodable bytes are refused under the op's own
/// name, never as `NotInTable` (the host reads that as "this guest predates the op").
#[test]
fn every_op_in_the_table_is_switched_and_refuses_undecodable_bytes_by_its_name() {
for (op, name) in ops::ALL.iter().filter(|(op, _)| *op != ops::ABOUT) {
let a: wire::Answer<serde_json::Value> =
wire::decode(&dispatch(&Canned, &NoHost, *op, b"\xff")).unwrap();
match a {
Err(WireCloudError::Refused(m)) => assert!(
m.contains("undecodable request") && m.contains(&format!("({name})")),
"{name}: {m}"
),
other => panic!("{name}: {other:?}"),
}
}
let mut numbers: Vec<u32> = ops::ALL.iter().map(|(n, _)| *n).collect();
numbers.sort();
numbers.dedup();
assert_eq!(numbers.len(), ops::ALL.len(), "no op number is used twice");
}
fn laid_machine() -> monetize_cloud::lay::LaidMachine {
monetize_cloud::lay::LaidMachine {
role: "main".into(),
hostname: "main.acme".into(),
title: "acme main".into(),
plan: "1x2".into(),
server: "uuid-m".into(),
public_ipv4: "192.0.2.10".into(),
utility_ipv4: "10.0.0.10".into(),
disks: vec![],
medium_clone: String::new(),
}
}
fn laid() -> monetize_cloud::lay::LaidEstate {
monetize_cloud::lay::LaidEstate {
reference: "estate/acme".into(),
company: "acme".into(),
zone: Zone("se-sto1".into()),
machines: vec![laid_machine()],
narrate_from: String::new(),
shape: None,
}
}
/// A provider without the estate verbs answers each of them through the switch with ITS
/// refusal by name (the verb named), and the read-only ops with what it holds.
#[test]
fn a_provider_without_the_estate_verbs_refuses_each_by_name_through_the_switch() {
let host: HostRef = &NoHost;
let verify: wire::Answer<()> =
wire::decode(&dispatch(&Canned, host, ops::VERIFY, &call(()))).unwrap();
assert_eq!(
verify,
Ok(()),
"the default proof is that the provider opens"
);
let id = ResourceId {
provider: "canned".into(),
id: "x".into(),
};
let st: wire::Answer<ResourceStatus> =
wire::decode(&dispatch(&Canned, host, ops::STATUS, &call(id))).unwrap();
assert_eq!(st, Ok(ResourceStatus::Active));
let ceilings: wire::Answer<BTreeMap<String, u64>> =
wire::decode(&dispatch(&Canned, host, ops::CEILINGS, &call(()))).unwrap();
assert!(ceilings.is_ok(), "{ceilings:?}");
let orphans: wire::Answer<Vec<ResourceId>> = wire::decode(&dispatch(
&Canned,
host,
ops::ORPHANS_FOR,
&call(vec!["estate/acme".to_string()]),
))
.unwrap();
assert_eq!(
orphans,
Err(WireCloudError::Refused("no account".into())),
"an account it cannot list is said, not an empty list"
);
let released: wire::Answer<Reclaimed> = wire::decode(&dispatch(
&Canned,
host,
ops::RELEASE_FOR,
&call(vec!["estate/acme".to_string()]),
))
.unwrap();
assert_eq!(released, Err(WireCloudError::Refused("no account".into())));
let set = grow::DataSet {
product: "gunnar".into(),
server: ServerId("s".into()),
members: vec![],
target_gib: 50,
};
for (op, payload) in [
(ops::PRICE_GROW, call(set.clone())),
(ops::SURVEY_SET, call(set.clone())),
(
ops::RESUME_GROW,
call(wire::GrowReq {
set,
records: vec![],
}),
),
] {
let a: wire::Answer<serde_json::Value> =
wire::decode(&dispatch(&Canned, host, op, &payload)).unwrap();
assert!(
matches!(&a, Err(WireCloudError::Refused(m)) if m.contains("canned")),
"{}: {a:?}",
ops::name(op).unwrap()
);
}
for (op, payload, verb) in [
(
ops::KEEP_BOOT,
call(wire::KeepBootReq {
keep: monetize_cloud::lay::KeepBoot {
reference: "estate/acme".into(),
zone: Zone("se-sto1".into()),
machine: laid_machine(),
medium: String::new(),
medium_object: None,
},
records: vec![],
}),
"keep_boot",
),
(
ops::SET_ESTATE_FIREWALL,
call(laid()),
"set_estate_firewall",
),
(ops::LABEL_ADOPTED, call(laid()), "label_adopted"),
] {
let a: wire::Answer<serde_json::Value> =
wire::decode(&dispatch(&Canned, host, op, &payload)).unwrap();
assert!(
matches!(&a, Err(WireCloudError::Refused(m)) if m.contains(verb)),
"{verb}: {a:?}"
);
}
let forgot: wire::Answer<Vec<String>> = wire::decode(&dispatch(
&Canned,
host,
ops::FORGET_MEDIA,
&call(wire::ForgetReq {
laid: laid(),
records: vec![],
}),
))
.unwrap();
assert_eq!(forgot, Ok(vec![]), "nothing to forget is an answer");
}
/// The pool ledger names where it lives, and the pace is the host's clock and wait.
#[test]
fn the_ledger_says_where_it_lives_and_the_pace_is_the_hosts_clock() {
struct Clock;
impl Host for Clock {
fn clock_unix_ms(&self) -> u64 {
1_800_000_000_000
}
}
let host: HostRef = &Clock;
use monetize_cloud::pace::Pace as _;
use monetize_cloud::pool::LedgerStore as _;
assert_eq!(
HostLedger {
host,
key: "pool".into()
}
.describe(),
"host state `pool`"
);
let pace = HostPace(host);
assert_eq!(pace.now_ms(), 1_800_000_000_000);
pace.sleep(Duration::from_millis(1));
// a host without the state door: the ledger's load is Unavailable, by its key
let e = HostLedger {
host: &NoHost,
key: "pool".into(),
}
.load()
.unwrap_err();
assert!(
matches!(&e, CloudError::Unavailable(m) if m.contains("host state `pool`")),
"{e:?}"
);
}
}