guests/toy-cloud/src/lib.rs
//! **A toy cloud guest** — fake iron at a declared price. It buys nothing
//! anywhere; what it proves is the cloud ABI end to end: settings travel
//! with every call, the pool ledger lives in the host's durable state (so a
//! retried `ensure` answers the SAME id, across calls, replacements and
//! restarts), a wait happens on the host, and every answer keeps its kind.
//!
//! Settings (the `[clouds.toycloud]` block minus the host's keys):
//! `disk_minor_per_gib_month` (default 23), `tag` (stamped into every id, so a
//! test can tell which build or block answered), `refuse_verify = true` (the
//! start-path proof fails, as a dead credential would), `lay_takes_ms` (a lay
//! waits this long on the host first, as UpCloud's does for its servers — what
//! proves a lay never holds the guest's other calls back, A32), `ensure_takes_ms`
//! (a purchase that waits first — what proves a sold estate's growth runs
//! beside the everyday calls, not behind them), `destroy_takes_ms` (a destroy that
//! waits first, in short sleeps, as UpCloud's stop-then-delete of a STARTED server
//! does — what proves a destroy is not cut off at the everyday call deadline, F4),
//! `ensure_blocks = true` (a purchase that waits on the host in short sleeps and never
//! ends by itself — only the call's deadline ends it, whatever the box's load does to
//! the epoch ticker: what proves a purchase is bounded by the block's deadline).
//!
//! It also LAYS an estate (2026-10-08): two plans in its list, and a lay that
//! journals each server through the host's door and answers toy ids — what
//! proves the estate ops cross the ABI journaled, as a growth does. What it laid
//! (each server and its disks, under the estate's reference) is kept in the host's
//! state beside the pool ledger and listed by `inventory`, so a second lay adopts
//! it, a plan change and a power cycle find it, and the give-back takes it all: the
//! `Cloud` conformance suite runs over this guest through the host's adapter.
use std::collections::BTreeMap;
use std::time::Duration;
use monetize_cloud::pace::Pace;
use monetize_cloud::pool::{Grant, Ledger};
use monetize_cloud::{
CloudError, Ensured, Inventory, Owned, OwnedKind, Price, Resource, ResourceId, ResourceStatus,
Zone,
};
use monetize_cloud_guest::{
kinds, CallContext, CloudGuest, HostLedger, HostPace, HostRef, Manifest,
};
use monetize_pay::Currency;
pub const NAME: &str = "toycloud";
#[derive(Default)]
pub struct ToyCloud;
impl CloudGuest for ToyCloud {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::CLOUD, NAME, &["state"])
}
fn open(
&self,
host: HostRef,
ctx: &CallContext,
) -> Result<Box<dyn monetize_cloud::Cloud>, CloudError> {
let per_gib = ctx
.settings
.get("disk_minor_per_gib_month")
.and_then(|v| v.as_u64())
.unwrap_or(23);
let tag = ctx
.settings
.get("tag")
.and_then(|v| v.as_str())
.unwrap_or("v1")
.to_string();
let lay_takes_ms = ctx
.settings
.get("lay_takes_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let ensure_takes_ms = ctx
.settings
.get("ensure_takes_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let destroy_takes_ms = ctx
.settings
.get("destroy_takes_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let ensure_blocks = ctx
.settings
.get("ensure_blocks")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(Box::new(Toy {
host,
per_gib,
tag,
lay_takes_ms,
ensure_takes_ms,
ensure_blocks,
destroy_takes_ms,
zones: vec![Zone("toy-1".into())],
}))
}
fn verify(&self, host: HostRef, ctx: &CallContext) -> Result<(), CloudError> {
if ctx
.settings
.get("refuse_verify")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err(CloudError::Refused(
"the toy's credential is dead (refuse_verify = true)".into(),
));
}
self.open(host, ctx).map(|_| ())
}
}
monetize_cloud_guest::export_cloud!(ToyCloud, grants = [state]);
struct Toy {
host: HostRef,
per_gib: u64,
tag: String,
lay_takes_ms: u64,
ensure_takes_ms: u64,
ensure_blocks: bool,
destroy_takes_ms: u64,
zones: Vec<Zone>,
}
impl Toy {
fn ledger(&self) -> Result<Ledger, CloudError> {
Ledger::with_store(Box::new(HostLedger {
host: self.host,
key: "pool".into(),
}))
}
/// What the estate ops laid, apart from the pool: a lay runs on the estate's own lane
/// beside the shop's purchases, and one state key per writer loses no update.
fn laid(&self) -> Result<Ledger, CloudError> {
Ledger::with_store(Box::new(HostLedger {
host: self.host,
key: "laid".into(),
}))
}
/// A laid server, by id, or the provider's own refusal of an id it never laid.
fn known_server(&self, server: &ResourceId) -> Result<(), CloudError> {
if self.laid()?.keys_for_id(&server.id).is_empty() {
return Err(CloudError::UnknownResource(server.clone()));
}
Ok(())
}
fn price_of(&self, r: &Resource) -> Price {
let minor = match r {
Resource::Disk { gib, .. } => gib * self.per_gib,
Resource::Server { .. } => 1_000,
Resource::Compute { .. } => 500,
};
Price {
minor_per_month: minor,
currency: Currency::Eur,
}
}
}
#[allow(dead_code)]
impl Toy {
pub fn provider(&self) -> &'static str {
NAME
}
pub fn zones(&self) -> &[Zone] {
&self.zones
}
pub fn price_resource(&self, r: &Resource) -> Result<Price, CloudError> {
Ok(self.price_of(r))
}
pub fn ensure(&self, reference: &str, r: &Resource) -> Result<Ensured, CloudError> {
if self.ensure_takes_ms > 0 {
HostPace(self.host).sleep(Duration::from_millis(self.ensure_takes_ms));
}
// Blocked until the host cuts the call off: each short sleep returns into this loop,
// whose back edge is where the host's epoch deadline interrupts it. No answer comes.
if self.ensure_blocks {
loop {
HostPace(self.host).sleep(Duration::from_millis(50));
}
}
if reference == "unavailable" {
return Err(CloudError::Unavailable("the toy provider is down".into()));
}
let fp = monetize_cloud::fingerprint(r);
let key = Ledger::key(reference, &fp);
let mut ledger = self.ledger()?;
if let Some(Grant::Provisioned { id, cost, .. }) = ledger.grant(&key) {
return Ok(Ensured::Provisioned {
id: ResourceId {
provider: NAME.into(),
id: id.clone(),
},
cost: *cost,
});
}
// One wait on the host, the way a real provider waits for `online`.
let before = HostPace(self.host).now_ms();
HostPace(self.host).sleep(Duration::from_millis(5));
let _ = before;
let cost = self.price_of(r);
let id = format!("{}:{}:{fp}", self.tag, ledger.data().grants.len() + 1);
let (server, gib) = match r {
Resource::Disk { server, gib } => (Some(server.0.clone()), *gib),
_ => (None, 0),
};
ledger.record(
&key,
Grant::Provisioned {
id: id.clone(),
cost,
server,
gib,
},
)?;
Ok(Ensured::Provisioned {
id: ResourceId {
provider: NAME.into(),
id,
},
cost,
})
}
pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
// The stop's wait, polled the way the upcloud guest polls `stopped`: short sleeps.
let mut waited = 0;
while waited < self.destroy_takes_ms {
let step = (self.destroy_takes_ms - waited).min(100);
HostPace(self.host).sleep(Duration::from_millis(step));
waited += step;
}
let mut found = false;
for mut ledger in [self.ledger()?, self.laid()?] {
for k in ledger.keys_for_id(&id.id) {
ledger.forget(&k)?;
found = true;
}
}
if !found {
return Err(CloudError::UnknownResource(id.clone()));
}
Ok(())
}
pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
let held = !self.ledger()?.keys_for_id(&id.id).is_empty()
|| !self.laid()?.keys_for_id(&id.id).is_empty();
Ok(if held {
ResourceStatus::Active
} else {
ResourceStatus::Gone
})
}
pub fn list_owned(&self) -> Result<Inventory, CloudError> {
let (pool, laid) = (self.ledger()?, self.laid()?);
let owned = pool
.data()
.grants
.iter()
.chain(laid.data().grants.iter())
.filter_map(|(key, g)| match g {
Grant::Provisioned {
id,
cost,
gib,
server,
} => Some(Owned {
id: ResourceId {
provider: NAME.into(),
id: id.clone(),
},
kind: if *gib > 0 {
OwnedKind::Disk
} else {
OwnedKind::Server
},
gib: *gib,
zone: "toy-1".into(),
attached_to: server.clone(),
reference: key.split('|').next().map(str::to_string),
monthly_minor: Some(cost.minor_per_month),
currency: Currency::Eur,
provenance: NAME.into(),
resource: None,
backup: None,
}),
_ => None,
})
.collect();
Ok(Inventory {
owned,
foreign: 0,
currency: Currency::Eur,
})
}
pub fn ceilings(&self) -> BTreeMap<String, u64> {
[("cpu_millicores".to_string(), 4_000u64)]
.into_iter()
.collect()
}
pub fn plan_catalog(
&self,
zone: &Zone,
) -> Result<Vec<monetize_cloud::lay::PlanSpec>, CloudError> {
Ok(vec![
monetize_cloud::lay::PlanSpec {
name: format!("TOY-1xCPU-2GB-{}", zone.0),
cores: 1,
memory_mib: 2048,
price: Price {
minor_per_month: 1_000,
currency: Currency::Eur,
},
},
monetize_cloud::lay::PlanSpec {
name: format!("TOY-2xCPU-4GB-{}", zone.0),
cores: 2,
memory_mib: 4096,
price: Price {
minor_per_month: 2_000,
currency: Currency::Eur,
},
},
])
}
/// A laid server moves to a plan the toy sells; nothing is bought.
pub fn change_plan(
&self,
server: &ResourceId,
plan: &str,
_delta: Price,
_journal: &monetize_cloud::grow::Journal,
) -> Result<(), CloudError> {
self.known_server(server)?;
let zone = self.zones[0].clone();
if !self.plan_catalog(&zone)?.iter().any(|p| p.name == plan) {
return Err(CloudError::Refused(format!("the toy sells no plan {plan}")));
}
Ok(())
}
/// A laid server is powered off or on.
pub fn set_power(&self, server: &ResourceId, _on: bool) -> Result<(), CloudError> {
self.known_server(server)
}
/// The rules of every laid server are written again (the toy holds no rules).
pub fn set_estate_firewall(
&self,
laid: &monetize_cloud::lay::LaidEstate,
) -> Result<(), CloudError> {
for m in &laid.machines {
self.known_server(&ResourceId {
provider: NAME.into(),
id: m.server.clone(),
})?;
}
Ok(())
}
/// One intent and one confirmation per server, through the host's door;
/// toy ids stamped with the block's `tag`.
pub fn lay_estate(
&self,
plan: &monetize_cloud::lay::EstatePlan,
journal: &monetize_cloud::grow::Journal,
) -> Result<monetize_cloud::lay::LaidEstate, CloudError> {
use monetize_cloud::grow::{GrowRecord, Mark};
use monetize_cloud::lay::{step, LaidDisk, LaidEstate, LaidMachine};
plan.check()?;
if self.lay_takes_ms > 0 {
HostPace(self.host).sleep(Duration::from_millis(self.lay_takes_ms));
}
let now = self.host.clock_unix_ms();
let mut machines = Vec::new();
for (i, m) in plan.machines.iter().enumerate() {
let id = format!("{}:{}:{}", self.tag, plan.reference, m.role);
for (mark, detail) in [(Mark::Intent, String::new()), (Mark::Confirmed, id.clone())] {
let mut rec = GrowRecord::set_wide(now, now, step::SERVER, mark, detail);
rec.volume = m.hostname.clone();
journal.append(rec)?;
}
// Kept, under the estate's reference: a second lay finds the same ids (adopts).
let mut laid = self.laid()?;
let server_key = Ledger::key(&plan.reference, &format!("server:{}", m.role));
if laid.grant(&server_key).is_none() {
laid.record(
&server_key,
Grant::Provisioned {
id: id.clone(),
cost: Price {
minor_per_month: 1_000,
currency: Currency::Eur,
},
server: None,
gib: 0,
},
)?;
}
let mut disks = Vec::new();
for (j, d) in m.disks.iter().enumerate() {
let disk = format!("{id}:disk{j}");
let key = Ledger::key(&plan.reference, &format!("disk:{}:{j}", m.role));
if laid.grant(&key).is_none() {
let cost = Price {
minor_per_month: d.gib * self.per_gib,
currency: Currency::Eur,
};
laid.record(
&key,
Grant::Provisioned {
id: disk.clone(),
cost,
server: Some(id.clone()),
gib: d.gib,
},
)?;
}
disks.push(LaidDisk {
title: d.title.clone(),
id: disk,
gib: d.gib,
tier: d.tier.clone(),
address: format!("virtio:{j}"),
});
}
machines.push(LaidMachine {
role: m.role.clone(),
hostname: m.hostname.clone(),
title: m.title.clone(),
plan: m.plan.clone(),
server: id,
public_ipv4: format!("192.0.2.{}", i + 1),
utility_ipv4: format!("10.0.0.{}", i + 1),
disks,
// The toy lays no release medium.
medium_clone: String::new(),
});
}
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(),
})
}
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for Toy {
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) => {
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> {
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> {
self.set_estate_firewall(laid)
}
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: self.ceilings(),
})
}
monetize_cloud::cloud::Listing::Admits(_plan) => {
monetize_cloud::cloud::Listed::Admitted
}
})
}
}