crates/monetize-cloud/src/conformance.rs
//! **The `Cloud` conformance suite** — one test every [`Cloud`] runs against its
//! mock (UpCloud at mock-upcloud, Scaleway at mock-scaleway, the host's guest adapter
//! over a signed module at either). It proves the contract the engine relies on, in
//! the provider's own account listing, never in what the provider SAID it did:
//!
//! 1. the manifest names the provider and lists the plan's zone and its settings;
//! 2. the catalog prices every plan the estate asks for, and `price` is pure;
//! 3. nothing stands under the reference before the lay;
//! 4. `lay` lays every machine, and the INVENTORY lists each server and disk under
//! the reference;
//! 5. a second `lay` ADOPTS: the same servers, nothing more in the inventory;
//! 6. `change` moves one machine to another plan, and the inventory is unchanged;
//! 7. `boot` powers a box off and on;
//! 8. `firewall` writes the rules again;
//! 9. `destroy` gives everything back, and the inventory holds NOTHING under the
//! reference — the proof is the read-back, not the report;
//! 10. a second `destroy` is idempotent: nothing given back, nothing failed.
//!
//! The suite is a function, not a `#[test]`, so each implementation's own test
//! builds its fixture (its mock, its plan, its journal) and calls it. It panics with
//! the step named; a test that calls it is red at that step and nowhere else.
use crate::cloud::{Boot, Booted, Change, Cloud};
use crate::grow::Journal;
use crate::lay::{same_resource, EstatePlan};
use crate::{OwnedKind, ResourceId};
/// **What one implementation hands the suite.**
pub struct CloudFixture<'a> {
pub cloud: &'a dyn Cloud,
/// At least two machines, each with a plan the catalog sells, under a reference
/// nothing else in the mock uses.
pub plan: EstatePlan,
/// A fresh journal per step, named.
pub journal: &'a dyn Fn(&str) -> Journal,
}
/// Rows of the inventory under `reference` (the box's own iron spared).
fn under(cloud: &dyn Cloud, reference: &str) -> Vec<ResourceId> {
crate::ask::account(cloud).expect("inventory: the account is readable").owned.into_iter().filter(|o| o.kind != OwnedKind::Dataset && o.reference.as_deref() == Some(reference)).map(|o| o.id).collect()
}
/// The servers under `reference`: what a lay, a second lay and a change must not add to.
/// (A provider's own backup schedule may add a STORAGE under the reference between two
/// reads — UpCloud's simple backup, its mock too; it is not a purchase, and `destroy`
/// takes it with the estate.)
fn servers_under(cloud: &dyn Cloud, reference: &str) -> usize {
crate::ask::account(cloud).expect("inventory: the account is readable").owned.into_iter().filter(|o| o.kind == OwnedKind::Server && o.reference.as_deref() == Some(reference)).count()
}
/// Every laid id of `laid` is among `rows`.
fn all_listed(laid: &crate::lay::LaidEstate, rows: &[ResourceId], step: &str) {
for lm in &laid.machines {
for id in lm.laid_ids().into_iter().filter(|id| !id.is_empty()) {
assert!(rows.iter().any(|r| same_resource(&r.id, id)), "{step}: {id} of {} is listed under {}: {rows:?}", lm.role, laid.reference);
}
}
}
/// **lay → inventory → change → boot → firewall → destroy leaves nothing.**
pub fn lay_inventory_change_destroy_leaves_nothing(f: &CloudFixture<'_>) {
let cloud = f.cloud;
let plan = &f.plan;
assert!(plan.machines.len() >= 2, "fixture: the plan has at least two machines");
// 1. the manifest
let m = cloud.manifest();
assert!(!m.provider.trim().is_empty(), "manifest: the provider is named");
assert!(m.zones.contains(&plan.zone), "manifest: the plan's zone {} is one the cloud lays in: {:?}", plan.zone.0, m.zones);
assert!(!m.settings.is_empty(), "manifest: the setting NAMES are listed");
// 2. the catalog and the price
let catalog = cloud.catalog(&plan.zone).expect("catalog: the zone's plans are listed");
assert!(!catalog.plans.is_empty(), "catalog: at least one plan");
for mp in &plan.machines {
let spec = catalog.plan(&mp.plan).unwrap_or_else(|| panic!("catalog: the plan {} of {} is sold", mp.plan, mp.role));
assert!(spec.price.minor_per_month > 0, "catalog: {} has a price", mp.plan);
}
let price = cloud.price(&crate::cloud::Priced::Plan(plan)).expect("price: the estate is priced");
assert!(price.minor_per_month > 0, "price: more than nothing");
assert_eq!(cloud.price(&crate::cloud::Priced::Plan(plan)).expect("price: again"), price, "price is pure");
// 3. nothing before
assert!(under(cloud, &plan.reference).is_empty(), "inventory: nothing stands under {} before the lay", plan.reference);
// 4. the lay, and the inventory
let laid = cloud.lay(plan, &(f.journal)("lay")).expect("lay: the estate is laid");
assert_eq!(laid.reference, plan.reference, "lay: the laid estate carries the reference");
assert_eq!(laid.machines.len(), plan.machines.len(), "lay: every machine is laid");
for lm in &laid.machines {
assert!(!lm.server.is_empty(), "lay: {} has a server", lm.role);
assert_eq!(lm.disks.len(), plan.machines.iter().find(|p| p.role == lm.role).map(|p| p.disks.len()).unwrap_or(0), "lay: {} has its disks", lm.role);
}
let rows = under(cloud, &plan.reference);
all_listed(&laid, &rows, "inventory");
let servers = servers_under(cloud, &plan.reference);
assert_eq!(servers, plan.machines.len(), "inventory: one server per machine");
// 5. laid again: adopted
let again = cloud.lay(plan, &(f.journal)("again")).expect("lay: a second lay adopts");
assert_eq!(again.machines.iter().map(|x| &x.server).collect::<Vec<_>>(), laid.machines.iter().map(|x| &x.server).collect::<Vec<_>>(), "lay: the same servers");
all_listed(&again, &under(cloud, &plan.reference), "inventory.again");
assert_eq!(servers_under(cloud, &plan.reference), servers, "inventory: a second lay buys no server");
// 6. a change of plan
let first = &laid.machines[0];
let other = catalog.plans.iter().find(|p| p.name != first.plan).expect("catalog: a second plan to change to");
let delta = crate::Price { minor_per_month: other.price.minor_per_month.saturating_sub(catalog.plan(&first.plan).map(|p| p.price.minor_per_month).unwrap_or(0)), currency: other.price.currency };
let server = ResourceId { provider: m.provider.clone(), id: first.server.clone() };
assert_eq!(cloud.change(&Change::Plan { server: server.clone(), plan: other.name.clone(), delta }, &(f.journal)("change")).expect("change: the plan is changed"), crate::cloud::Changed::Done);
all_listed(&laid, &under(cloud, &plan.reference), "inventory.change");
assert_eq!(servers_under(cloud, &plan.reference), servers, "inventory: a plan change buys no server");
// 7. a boot
assert_eq!(cloud.boot(&Boot::Power { server: server.clone(), on: false }, &(f.journal)("off")).expect("boot: off"), Booted::Powered);
assert_eq!(cloud.boot(&Boot::Power { server: server.clone(), on: true }, &(f.journal)("on")).expect("boot: on"), Booted::Powered);
// 8. the firewall
cloud.firewall(&laid).expect("firewall: written again");
// 9. destroy leaves nothing. A delete is not instant at a provider (UpCloud lists a
// deleted server in `maintenance` for minutes; its mock too): what was given back is
// read again until it is gone, bounded, and what a first pass could not take (a disk
// still attached while its server was being deleted) is taken by the next, as the
// sweep's tick does.
// A row the provider will not let go of YET (a backup still being written answers
// 409) is named in `failures` and taken by a later pass; what must hold is that the
// passes end, bounded, with nothing under the reference and a pass that fails nothing.
let refs = vec![plan.reference.clone()];
// What stands under the reference is the account read again; the laid servers stand.
let standing = crate::ask::orphans_for(cloud, &refs).expect("inventory: what stands under the reference");
all_listed(&laid, &standing, "inventory.under");
let first_server = ResourceId { provider: m.provider.clone(), id: laid.machines[0].server.clone() };
assert!(!matches!(crate::ask::status(cloud, &first_server).expect("inventory: a laid server's standing"), crate::ResourceStatus::Gone), "inventory: a laid server is not gone");
let r = cloud.destroy(&crate::cloud::Give::References(&refs)).expect("destroy: given back");
assert!(!r.destroyed.is_empty(), "destroy: something was given back");
let mut last_failures = r.failures;
let until = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let left = under(cloud, &plan.reference);
if left.is_empty() && last_failures.is_empty() {
break;
}
assert!(std::time::Instant::now() < until, "inventory: {left:?} still stand under {} 30 s after destroy; last failures {last_failures:?}", plan.reference);
std::thread::sleep(std::time::Duration::from_millis(50));
last_failures = cloud.destroy(&crate::cloud::Give::References(&refs)).expect("destroy: tried again").failures;
}
// 10. destroy again
let r2 = cloud.destroy(&crate::cloud::Give::References(&refs)).expect("destroy: a second time");
assert!(r2.destroyed.is_empty() && r2.failures.is_empty(), "destroy: a second destroy gives back nothing and fails nothing: {r2:?}");
assert!(cloud.destroy(&crate::cloud::Give::References(&[])).expect("destroy: an empty filter").destroyed.is_empty(), "destroy: an empty slice matches nothing");
assert!(crate::ask::orphans_for(cloud, &refs).expect("inventory: under, after").is_empty(), "inventory: nothing stands under {} after destroy", plan.reference);
}