crates/monetize-cloud-wasm/tests/the_toy_cloud_guest.rs
//! **The cloud guest host against the REAL built toy cloud** — the ABI end to
//! end: vouching, the kind and name gates, the start-path proof, every op
//! keeping its answer's shape and its error's kind, the pool ledger durable in
//! the host's state across calls AND across a rebuild of the host (a restart),
//! and the spend ceiling the host wraps it in refusing over the limit.
//!
//! The module is required, never skipped: build it with
//! `cargo xtask build-guests` (it is in `GUEST_CRATES`).
use std::path::{Path, PathBuf};
use monetize_cloud::cap::{Ceiling, SpendCap};
use monetize_cloud::{CloudError, Ensured, ResourceId, ResourceStatus, Room, ServerId, Zone};
use monetize_cloud_wasm::{guest_settings, GuestCloud};
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"monetize-cloud-guest-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn built(stem: &str) -> PathBuf {
let target = std::env::var("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"));
let p = target.join(format!("wasm32-unknown-unknown/release/{stem}.wasm"));
assert!(p.is_file(), "{} has not been built. This test loads the REAL guest; it does not skip.\nBuild it with:\n cargo xtask build-guests", p.display());
p
}
fn copy(dir: &Path, stem: &str, as_name: &str) -> PathBuf {
let dst = dir.join(as_name);
std::fs::copy(built(stem), &dst).unwrap();
dst
}
/// Sign `module` as a release does (every guest monetize runs is signed, owner 2026-10-17): a
/// CA of its own beside it (`<module>.root.der`), its `.p7s` beside it. Answers the root.
fn vouched(module: &Path) -> PathBuf {
use guest_host::verify::sign::{self, dev};
let (ca_key, ca_der) = dev::mint_ca("test-plugin-root").unwrap();
let mut root = module.as_os_str().to_owned();
root.push(".root.der");
let root = PathBuf::from(root);
std::fs::write(&root, &ca_der).unwrap();
sign::sign_module_file(
module,
&dev::new_ed25519_signer(&ca_key, &ca_der, "test-plugin-signing").unwrap(),
)
.unwrap();
root
}
fn block(module: &Path, extra: serde_json::Value) -> serde_json::Value {
let mut b = serde_json::json!({
"guest": module,
"guest_digest": guest_host::digest_hex(&std::fs::read(module).unwrap()),
"guest_trust_roots": [vouched(module)],
"grants": ["state"],
"disk_minor_per_gib_month": 23,
"tag": "v1",
});
for (k, v) in extra.as_object().unwrap() {
b[k] = v.clone();
}
b
}
fn disk(gib: u64) -> monetize_cloud::Resource {
monetize_cloud::Resource::Disk {
server: ServerId("box-1".into()),
gib,
}
}
#[test]
fn the_toy_cloud_answers_every_op_and_its_ledger_survives_a_restart() {
let dir = scratch("ops");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let b = block(&module, serde_json::json!({}));
let c = GuestCloud::build("toycloud", &dir, &b).expect("the toy cloud loads");
let m = monetize_cloud::Cloud::manifest(&c);
assert_eq!(m.provider, "toycloud");
assert_eq!(m.zones, vec![Zone("toy-1".into())]);
assert_eq!(
monetize_cloud::ask::ceilings(&c).get("cpu_millicores"),
Some(&4_000)
);
// The settings reached the guest; the host's keys did not.
let s = guest_settings(&b);
assert!(
s.get("guest").is_none() && s.get("guest_digest").is_none() && s.get("grants").is_none(),
"{s}"
);
assert_eq!(s["tag"], "v1");
assert_eq!(
monetize_cloud::Cloud::price(&c, &monetize_cloud::cloud::Priced::Resource(&disk(10)))
.unwrap()
.minor_per_month,
230
);
let first = match monetize_cloud::ask::ensure(&c, "ref-a", &disk(10), Some(Room::Buy)).unwrap()
{
Ensured::Provisioned { id, cost } => {
assert_eq!(cost.minor_per_month, 230);
assert!(id.id.starts_with("v1:"), "{}", id.id);
id
}
other => panic!("{other:?}"),
};
// Idempotent through the host's memory: the guest is a fresh instance per
// call, so only the host state can make the retry find the first purchase.
match monetize_cloud::ask::ensure(&c, "ref-a", &disk(10), Some(Room::Buy)).unwrap() {
Ensured::Provisioned { id, .. } => assert_eq!(id, first, "a retried ensure bought twice"),
other => panic!("{other:?}"),
}
assert!(
c.state_file().is_file(),
"the state is durable beside the ledger"
);
assert_eq!(
monetize_cloud::ask::status(&c, &first).unwrap(),
ResourceStatus::Active
);
assert_eq!(monetize_cloud::ask::account(&c).unwrap().owned.len(), 1);
assert!(
matches!(monetize_cloud::ask::ensure(&c, "unavailable", &disk(1), None), Err(CloudError::Unavailable(m)) if m.contains("down")),
"the kind survives the wire"
);
drop(c);
// A RESTART: a new host over the same data dir remembers the grant.
let again = GuestCloud::build("toycloud", &dir, &b).expect("reloads");
match monetize_cloud::ask::ensure(&again, "ref-a", &disk(10), Some(Room::Buy)).unwrap() {
Ensured::Provisioned { id, .. } => assert_eq!(
id, first,
"a restart forgot a purchase and would buy it again"
),
other => panic!("{other:?}"),
}
monetize_cloud::ask::destroy_one(&again, &first).unwrap();
assert_eq!(
monetize_cloud::ask::status(&again, &first).unwrap(),
ResourceStatus::Gone
);
let unknown = ResourceId {
provider: "toycloud".into(),
id: "nothing".into(),
};
assert!(
matches!(monetize_cloud::ask::destroy_one(&again, &unknown), Err(CloudError::UnknownResource(id)) if id == unknown)
);
// The journaled growth CROSSES (2026-09-26): the toy cloud has no growth path,
// and it is the GUEST's own refusal that comes back — naming the toy cloud —
// with nothing written to the host's journal.
let journal = monetize_cloud::grow::Journal::open(dir.join("j.json")).unwrap();
let set = monetize_cloud::grow::DataSet {
product: "p".into(),
server: ServerId("box-1".into()),
members: vec![],
target_gib: 1,
};
let grown = monetize_cloud::ask::grow(&again, &set, false, &journal);
assert!(
matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud cloud cannot grow")),
"{grown:?}"
);
assert!(
journal.records().is_empty(),
"a refused growth journals nothing"
);
// A journal with no file cannot be written through to: refused by the host.
let sinkless = monetize_cloud::grow::Journal::with_sink(
vec![],
Box::new(|_: &monetize_cloud::grow::GrowRecord| Ok(())),
);
assert!(
matches!(monetize_cloud::ask::grow(&again, &set, false, &sinkless), Err(CloudError::Refused(m)) if m.contains("grow_set"))
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_host_refuses_what_is_not_a_cloud_or_not_this_cloud_or_not_vouched_for_or_cannot_prove_itself(
) {
let dir = scratch("refusals");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
// Nobody vouches.
let mut b = block(&module, serde_json::json!({}));
b.as_object_mut().unwrap().remove("guest_digest");
b.as_object_mut().unwrap().remove("guest_trust_roots");
let e = GuestCloud::build("toycloud", &dir, &b).unwrap_err();
assert!(
e.contains("[clouds.toycloud]") && e.contains("nobody vouches"),
"{e}"
);
// Under another name: its purchases would land under another ceiling.
let e = GuestCloud::build("upcloud", &dir, &block(&module, serde_json::json!({}))).unwrap_err();
assert!(
e.contains("[clouds.upcloud]") && e.contains("`toycloud`"),
"{e}"
);
// A PRODUCT module under [clouds.*]: the kind gate. The Lake's guest, a product that opens
// no door, so the refusal is the kind's alone.
let product = copy(&dir, "monetize_guest_lake", "lake.wasm");
let mut pb = block(&product, serde_json::json!({}));
pb["grants"] = serde_json::json!([]);
let e = GuestCloud::build("lake", &dir, &pb).unwrap_err();
assert!(
e.contains("`product` guest") && e.contains("`cloud` guest"),
"{e}"
);
// The toy, a TEST guest, under [clouds.*]: only a products block loads a test guest.
let toy = copy(&dir, "monetize_guest_toy", "toy.wasm");
let mut tb = block(&toy, serde_json::json!({}));
tb["grants"] = serde_json::json!(monetize_product_guest::grants::TEST);
let e = GuestCloud::build("toy", &dir, &tb).unwrap_err();
assert!(
e.contains("`test` guest") && e.contains("`cloud` guest"),
"{e}"
);
// The start-path proof fails: refused at load, by name.
let e = GuestCloud::build(
"toycloud",
&dir,
&block(&module, serde_json::json!({"refuse_verify": true})),
)
.unwrap_err();
assert!(
e.contains("could not prove") && e.contains("credential is dead"),
"{e}"
);
// An ungranted door: the module imports the state door and the block does not grant it.
let mut nb = block(&module, serde_json::json!({}));
nb["grants"] = serde_json::json!([]);
let e = GuestCloud::build("toycloud", &dir, &nb).unwrap_err();
assert!(e.contains("`state`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// **The spend ceiling stays on the host**: the same wrapper a linked cloud
/// gets, and it refuses over the limit before the guest is ever asked to buy.
#[test]
fn the_host_ceiling_refuses_an_over_limit_purchase_before_the_guest_buys() {
let dir = scratch("cap");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({}))).unwrap();
let capped = SpendCap::new(
c,
Ceiling::total(300, monetize_pay::Currency::Eur),
dir.join("toycloud-spend.json"),
)
.unwrap();
assert!(
matches!(
monetize_cloud::ask::ensure(&capped, "r1", &disk(10), Some(Room::Buy)),
Ok(Ensured::Provisioned { .. })
),
"230 of 300"
);
let over = monetize_cloud::ask::ensure(&capped, "r2", &disk(10), Some(Room::Buy));
assert!(
matches!(&over, Err(CloudError::OverCap(m)) if m.contains("3.00 EUR")),
"{over:?}"
);
assert_eq!(
monetize_cloud::ask::account(&capped).unwrap().owned.len(),
1,
"the refused purchase never reached the guest"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// The kit's three machines, one 10 GiB system disk each, for `company`.
fn estate(company: &str) -> monetize_cloud::lay::EstatePlan {
use monetize_cloud::lay::{
default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan,
};
let m = |role: &str| MachinePlan {
role: role.into(),
hostname: format!("gunnar-{role}-{company}"),
title: format!("gunnar {role}-{company}"),
plan: "TOY-1xCPU-2GB-toy-1".into(),
boot_order: "disk,cdrom".into(),
labels: Default::default(),
interfaces: default_interfaces(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: vec![DiskPlan {
title: format!("gunnar-{role}-{company} system"),
gib: 10,
tier: "standard".into(),
labels: Default::default(),
}],
};
EstatePlan {
reference: reference_of(company),
company: company.into(),
zone: Zone("toy-1".into()),
machines: vec![m("front"), m("main"), m("twin")],
narrate_from: String::new(),
shape: None,
}
}
/// ★ **A guest cloud lays a sold estate** (2026-10-08, test2's shop: "the
/// upcloud cloud cannot lay an estate"): the plan list and the estate's
/// price come from the GUEST, the lay crosses journaled — every record the
/// guest writes is in the job's journal FILE when the call returns — and the
/// host's ceiling admits the whole estate before the guest is asked, so an
/// estate over it is refused with nothing laid and nothing journaled.
#[test]
fn a_guest_cloud_lays_an_estate_journaled_to_the_jobs_file_under_the_hosts_ceiling() {
let dir = scratch("lay");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = GuestCloud::build(
"toycloud",
&dir,
&block(&module, serde_json::json!({"tag": "v7"})),
)
.unwrap();
let plans = monetize_cloud::ask::plan_catalog(&c, &Zone("toy-1".into()))
.expect("the guest's plan list, not the trait's refusal");
assert_eq!(plans[0].name, "TOY-1xCPU-2GB-toy-1");
assert_eq!(
monetize_cloud::Cloud::price(&c, &monetize_cloud::cloud::Priced::Plan(&estate("acme")))
.unwrap()
.minor_per_month,
3 * (1_000 + 230),
"three servers and three 10 GiB disks at the toy's prices"
);
// Over the ceiling: refused before the guest lays, nothing journaled.
let tight = SpendCap::new(
GuestCloud::build(
"toycloud",
&dir,
&block(&module, serde_json::json!({"tag": "v7"})),
)
.unwrap(),
Ceiling::total(1_000, monetize_pay::Currency::Eur),
dir.join("tight-spend.json"),
)
.unwrap();
let journal = monetize_cloud::grow::Journal::open(dir.join("tight-journal")).unwrap();
let over = monetize_cloud::Cloud::lay(&tight, &estate("acme"), &journal);
assert!(
matches!(&over, Err(CloudError::OverCap(m)) if m.contains("1000") || m.contains("10.00")),
"{over:?}"
);
assert!(
journal.records().is_empty(),
"nothing was asked of the guest"
);
// Under it: laid, and the job's journal FILE holds every record.
let capped = SpendCap::new(
c,
Ceiling::total(10_000, monetize_pay::Currency::Eur),
dir.join("toycloud-spend.json"),
)
.unwrap();
let journal = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
let laid = monetize_cloud::Cloud::lay(&capped, &estate("acme"), &journal)
.expect("laid through the guest");
assert_eq!(
laid.machines
.iter()
.map(|m| m.role.as_str())
.collect::<Vec<_>>(),
vec!["front", "main", "twin"]
);
assert_eq!(
laid.machine("main").unwrap().server,
"v7:estate/acme:main",
"the block's settings reached the guest"
);
assert_eq!(
journal.records().len(),
6,
"an intent and a confirmation per server, read back by the caller's journal"
);
let file = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
assert_eq!(
file.records().len(),
6,
"written THROUGH to the file, not kept in the guest"
);
assert!(file
.records()
.iter()
.all(|r| r.step == monetize_cloud::lay::step::SERVER));
// A journal with no file: refused by the host, by name, nothing laid.
let sinkless = monetize_cloud::grow::Journal::with_sink(
vec![],
Box::new(|_: &monetize_cloud::grow::GrowRecord| Ok(())),
);
assert!(
matches!(monetize_cloud::Cloud::lay(&capped, &estate("bolt"), &sinkless), Err(CloudError::Refused(m)) if m.contains("lay_estate"))
);
// A verb the toy has not: the GUEST's own refusal, naming it.
let keep = monetize_cloud::lay::KeepBoot {
reference: laid.reference.clone(),
zone: laid.zone.clone(),
machine: laid.machine("front").unwrap().clone(),
medium: String::new(),
medium_object: None,
};
let kept = monetize_cloud::ask::keep_boot(&capped, &keep, &journal);
assert!(
matches!(&kept, Err(CloudError::Refused(m)) if m.contains("toycloud cloud has no `keep_boot`")),
"{kept:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A32 (wiring trace, 2026-10-08): a lay never holds the shop's room
/// purchases back.** A sold estate's lay waits minutes on the provider
/// (UpCloud's servers); on the guest's one lane it held every purchase of the
/// shop behind it for up to 15 minutes. The lay runs on the estate's own
/// lane: a room purchase made while it waits is served at once, durable in
/// the host's state, and the lay finishes as it would have.
#[test]
fn a_room_purchase_is_served_while_a_lay_is_in_progress() {
use std::time::{Duration, Instant};
let dir = scratch("lane");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = std::sync::Arc::new(
GuestCloud::build(
"toycloud",
&dir,
&block(
&module,
serde_json::json!({"tag": "v9", "lay_takes_ms": 4_000}),
),
)
.unwrap(),
);
let journal = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
let lay = {
let c = std::sync::Arc::clone(&c);
std::thread::spawn(move || {
let t = Instant::now();
let laid = monetize_cloud::Cloud::lay(&c, &estate("acme"), &journal);
(laid, t.elapsed())
})
};
std::thread::sleep(Duration::from_millis(500));
let t = Instant::now();
let bought = monetize_cloud::ask::ensure(&c, "room-a", &disk(10), Some(Room::Buy))
.expect("a room purchase while the lay waits");
let took = t.elapsed();
let Ensured::Provisioned { id, .. } = bought else {
panic!("{bought:?}")
};
assert!(
took < Duration::from_secs(2),
"served beside the lay, not behind it: {took:?}"
);
let (laid, lay_took) = lay.join().unwrap();
assert_eq!(
laid.expect("the lay finishes as it would have")
.machine("main")
.unwrap()
.server,
"v9:estate/acme:main"
);
assert!(lay_took >= Duration::from_secs(4), "{lay_took:?}");
assert!(
took + Duration::from_millis(500) < lay_took,
"the purchase was done while the lay still waited: {took:?} against {lay_took:?}"
);
// The purchase is in the host's durable state: a restarted host answers it again.
let again = GuestCloud::build(
"toycloud",
&dir,
&block(&module, serde_json::json!({"tag": "v9"})),
)
.unwrap();
assert!(
matches!(monetize_cloud::ask::ensure(&again, "room-a", &disk(10), Some(Room::Buy)).unwrap(), Ensured::Provisioned { id: same, .. } if same == id),
"the ledger kept it: a retry answers the same purchase"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A sold estate's growth runs on the estate's lane** (wiring trace,
/// 2026-10-08): `SoldEstate.GrowSet` grows a SOLD estate's disks at the shop
/// (detach, resize, attach — minutes at UpCloud). On the everyday lane it
/// waited behind, and held back, the shop's own purchases; `grow_estate_set`
/// runs beside them. Here a slow purchase holds the everyday lane, and the
/// estate's growth is answered at once (the toy grows nothing: its own
/// refusal, by name), while an everyday `grow_set` waits its turn.
#[test]
fn a_sold_estates_growth_is_not_held_behind_the_shops_purchases() {
use std::time::{Duration, Instant};
let dir = scratch("growlane");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = std::sync::Arc::new(
GuestCloud::build(
"toycloud",
&dir,
&block(
&module,
serde_json::json!({"tag": "v10", "ensure_takes_ms": 3_000}),
),
)
.unwrap(),
);
let set = monetize_cloud::grow::DataSet {
product: "gunnar".into(),
server: ServerId("estate-main".into()),
members: vec![],
target_gib: 20,
};
let journal = monetize_cloud::grow::Journal::open(dir.join("grow-journal")).unwrap();
let buying = {
let c = std::sync::Arc::clone(&c);
std::thread::spawn(move || {
monetize_cloud::ask::ensure(&c, "room-b", &disk(10), Some(Room::Buy))
})
};
std::thread::sleep(Duration::from_millis(500));
let t = Instant::now();
let grown = monetize_cloud::ask::grow(&c, &set, true, &journal);
let took = t.elapsed();
assert!(
matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud")),
"the toy's own answer: {grown:?}"
);
assert!(
took < Duration::from_secs(1),
"answered beside the purchase, not behind it: {took:?}"
);
let t = Instant::now();
let _ = monetize_cloud::ask::grow(&c, &set, false, &journal);
assert!(
t.elapsed() >= Duration::from_millis(1_500),
"an everyday growth still waits its turn on the everyday lane"
);
assert!(buying.join().unwrap().is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A lay on the estate's lane never holds the shop's growths back** (code-smell
/// review 2026-10-10, row 17): every JOURNALED call shared one write-through slot and
/// one lock, so a 15-minute lay held an everyday `grow_set` behind it, against the lane
/// rule above. Each call has its own slot now: the growth is answered beside the lay,
/// and the lay still writes its journal through to its own file.
#[test]
fn a_journaled_growth_is_not_held_behind_a_lay_on_the_estates_lane() {
use std::time::{Duration, Instant};
let dir = scratch("journal-lanes");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = std::sync::Arc::new(
GuestCloud::build(
"toycloud",
&dir,
&block(
&module,
serde_json::json!({"tag": "v11", "lay_takes_ms": 4_000}),
),
)
.unwrap(),
);
let lay_journal = monetize_cloud::grow::Journal::open(dir.join("lay-journal")).unwrap();
let lay = {
let c = std::sync::Arc::clone(&c);
std::thread::spawn(move || {
let laid = monetize_cloud::Cloud::lay(&c, &estate("acme"), &lay_journal);
(laid, lay_journal.records().len())
})
};
std::thread::sleep(Duration::from_millis(500));
let set = monetize_cloud::grow::DataSet {
product: "gunnar".into(),
server: ServerId("main".into()),
members: vec![],
target_gib: 20,
};
let grow_journal = monetize_cloud::grow::Journal::open(dir.join("grow-journal")).unwrap();
let t = Instant::now();
let grown = monetize_cloud::ask::grow(&c, &set, false, &grow_journal);
let took = t.elapsed();
assert!(
matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud")),
"the toy's own answer: {grown:?}"
);
assert!(
took < Duration::from_secs(2),
"answered beside the lay, not behind it: {took:?}"
);
let (laid, records) = lay.join().unwrap();
laid.expect("the lay finishes as it would have");
assert!(
records > 0,
"the lay's journal was still written through to its own file"
);
assert!(
grow_journal.records().is_empty(),
"nothing of the lay's journal reached the growth's file"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A purchase the guest made is never answered as nothing bought** (code-smell review
/// 2026-10-10, finding 4): when the host cannot write its state file after the call, the
/// guest's answer — the server it bought — still comes back; the write is tried again on
/// the next call, and a restart remembers the purchase.
#[test]
fn a_state_file_that_cannot_be_written_never_hides_a_purchase_the_guest_made() {
let dir = scratch("unwritten");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let b = block(&module, serde_json::json!({}));
let c = GuestCloud::build("toycloud", &dir, &b).expect("the toy cloud loads");
// The state file's place is taken by a directory that is not empty: no rename lands there.
let state = c.state_file().to_path_buf();
let _ = std::fs::remove_file(&state);
std::fs::create_dir_all(state.join("in-the-way")).unwrap();
let bought = monetize_cloud::ask::ensure(&c, "ref-p", &disk(10), Some(Room::Buy));
let id = match bought {
Ok(Ensured::Provisioned { id, .. }) => id,
other => panic!("the guest bought the disk; the host must say so, whatever its state file did: {other:?}"),
};
assert!(
c.state_unwritten().is_some(),
"the unwritten state is marked, not lost in a log line"
);
std::fs::remove_dir_all(&state).unwrap();
assert_eq!(
monetize_cloud::ask::status(&c, &id).unwrap(),
ResourceStatus::Active
);
assert!(
state.is_file(),
"the next call writes the state that was not written"
);
assert_eq!(
c.state_unwritten(),
None,
"a write that lands clears the mark"
);
drop(c);
let again = GuestCloud::build("toycloud", &dir, &b).expect("reloads");
match monetize_cloud::ask::ensure(&again, "ref-p", &disk(10), Some(Room::Buy)).unwrap() {
Ensured::Provisioned { id: same, .. } => {
assert_eq!(same, id, "a restart forgot the purchase")
}
other => panic!("{other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A linked provider's pool ledger that cannot be read is never taken as no ledger**
/// (code-smell review 2026-10-10, row 10): the first start of a cloud as a guest seeds its
/// state from `<name>-pool.json`. Only a ledger that does not EXIST means "start empty"; one
/// that is there and cannot be read (here a directory; in life a permission, an IO error)
/// refuses the start — an empty ledger would sell again what was sold.
#[test]
fn a_pool_ledger_that_cannot_be_read_refuses_the_start_and_only_a_missing_one_starts_empty() {
let dir = scratch("pool-unreadable");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let b = block(&module, serde_json::json!({}));
std::fs::create_dir_all(dir.join("toycloud-pool.json").join("in-the-way")).unwrap();
let refused = GuestCloud::build("toycloud", &dir, &b)
.expect_err("an unreadable pool ledger refuses the start");
assert!(
refused.contains("toycloud-pool.json"),
"the refusal names the ledger: {refused}"
);
std::fs::remove_dir_all(dir.join("toycloud-pool.json")).unwrap();
GuestCloud::build("toycloud", &dir, &b).expect("no ledger at all: an empty start");
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ **A destroy is not cut off at the everyday call deadline** (F4, the ONE box 2026-10-20:
/// test3-holger-rs's terminate). UpCloud refuses to delete a STARTED server, so the guest stops
/// it hard, waits for `stopped` and only then deletes it with its disks: tens of seconds. The
/// host ran `destroy` under the block's everyday 30 s deadline, the call was cut off at
/// 28.6 s with the main stopped and not deleted ("op 213 (destroy): … deadline"), and the
/// give-back parked. A destroy runs under the cloud kind's whole deadline, as a stop does.
#[test]
fn a_destroy_that_outlasts_the_everyday_deadline_still_gives_the_resource_back() {
use std::time::{Duration, Instant};
let dir = scratch("slow-destroy");
let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
let c = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({"tag": "v12", "guest_call_timeout_ms": 1_000, "destroy_takes_ms": 2_500}))).unwrap();
let Ensured::Provisioned { id, .. } =
monetize_cloud::ask::ensure(&c, "slow-destroy", &disk(10), Some(Room::Buy)).unwrap()
else {
panic!("bought")
};
let t = Instant::now();
let gone = monetize_cloud::ask::destroy_one(&c, &id);
assert!(
gone.is_ok(),
"a destroy that waits past the everyday deadline is not cut off: {gone:?}"
);
assert!(
t.elapsed() >= Duration::from_millis(2_500),
"it waited as the provider did: {:?}",
t.elapsed()
);
assert_eq!(
monetize_cloud::ask::status(&c, &id).unwrap(),
ResourceStatus::Gone,
"and the resource is gone"
);
// The everyday deadline still holds for the everyday calls. The purchase blocks on the host
// and never answers by itself (`ensure_blocks`), so only the deadline can end it: a wait
// of a fixed length raced the epoch ticker, and on a loaded box the call answered Ok.
let slow = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({"tag": "v12", "guest_call_timeout_ms": 1_000, "ensure_blocks": true}))).unwrap();
let bounded = monetize_cloud::ask::ensure(&slow, "slow-ensure", &disk(10), Some(Room::Buy));
assert!(
matches!(&bounded, Err(CloudError::Unavailable(e)) if e.contains("deadline")),
"a purchase is still bounded by the block's deadline: {bounded:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}