Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
}