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/tests/retire_machine.rs

//! **`Change::Retire`: a fenced box given back WHOLE** (5.4 guardian, gap-2).
//!
//! The guardian's fence used to destroy the server only: the old box's disks stayed on the
//! bill (they were titled apart so the relay would not adopt them) and nothing proved they
//! were gone. A fence now gives back everything the laid record names — the server, every
//! disk, the release clone — read back from the provider's own listing under the estate's
//! reference, so a provider whose listing ids are `server/<uuid>` while the laid record holds
//! the bare `<uuid>` (UpCloud) is matched, not mistaken for "already gone".

use monetize_cloud::lay::{LaidDisk, LaidMachine};
use monetize_cloud::{Cloud, CloudError, Ensured, Inventory, Owned, OwnedKind, Price, Resource, ResourceId, ResourceStatus, Zone};
use monetize_pay::Currency;
use std::sync::Mutex;

const REF: &str = "estate/acme";

/// An account that lists like UpCloud: `server/<uuid>` and `storage/<uuid>`, labelled by
/// reference; deleting a server deletes the disks attached to it.
struct Account {
    owned: Mutex<Vec<Owned>>,
    destroyed: Mutex<Vec<String>>,
    refuse: Vec<String>,
    blind: bool,
    /// Deletes like UpCloud: a destroy is accepted (`204`) and the object stays listed, on its
    /// way out, until [`Account::settle`] — a deleted server stands in `maintenance` for
    /// minutes (mock-upcloud behaviour 7), a storage for seconds.
    linger: bool,
    going: Mutex<Vec<String>>,
}

fn row(id: &str, kind: OwnedKind, reference: &str, attached_to: Option<&str>) -> Owned {
    Owned {
        id: ResourceId { provider: "fake".into(), id: id.into() },
        kind,
        gib: 10,
        zone: "se-sto1".into(),
        attached_to: attached_to.map(str::to_owned),
        reference: Some(reference.into()),
        monthly_minor: Some(1_000),
        currency: Currency::Sek,
        provenance: "fake".into(),
        resource: None,
        backup: None,
    }
}

impl Account {
    fn new() -> Self {
        Account {
            owned: Mutex::new(vec![
                row("server/old-twin", OwnedKind::Server, REF, None),
                row("storage/old-twin-sys", OwnedKind::Disk, REF, Some("old-twin")),
                row("storage/old-twin-data", OwnedKind::Disk, REF, Some("old-twin")),
                // The main, under the same reference: never the twin's fence's to touch.
                row("server/main", OwnedKind::Server, REF, None),
                row("storage/main-sys", OwnedKind::Disk, REF, Some("main")),
                // Another estate's disk whose bare id collides with nothing of ours.
                row("storage/bolt-sys", OwnedKind::Disk, "estate/bolt", None),
            ]),
            destroyed: Mutex::default(),
            refuse: Vec::new(),
            blind: false,
            linger: false,
            going: Mutex::default(),
        }
    }
    /// The provider finishes every delete it accepted.
    fn settle(&self) {
        let going = std::mem::take(&mut *self.going.lock().unwrap());
        self.owned.lock().unwrap().retain(|o| !going.contains(&o.id.id));
    }
    fn destroyed(&self) -> Vec<String> {
        self.destroyed.lock().unwrap().clone()
    }
    fn listed(&self) -> Vec<String> {
        self.owned.lock().unwrap().iter().map(|o| o.id.id.clone()).collect()
    }
}

#[allow(dead_code)]
impl Account {
    pub fn provider(&self) -> &'static str {
        "fake"
    }
    pub fn zones(&self) -> &[Zone] {
        &[]
    }
    pub fn price_resource(&self, _resource: &Resource) -> Result<Price, CloudError> {
        Err(CloudError::Unavailable("no price list".into()))
    }
    pub fn ensure(&self, _reference: &str, _resource: &Resource) -> Result<Ensured, CloudError> {
        Err(CloudError::Refused("never buys".into()))
    }
    pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
        if self.refuse.contains(&id.id) {
            return Err(CloudError::Unavailable(format!("{} is wedged", id.id)));
        }
        let mut owned = self.owned.lock().unwrap();
        if self.linger {
            if !owned.iter().any(|o| o.id.id == id.id) {
                return Err(CloudError::UnknownResource(id.clone()));
            }
            self.going.lock().unwrap().push(id.id.clone());
            self.destroyed.lock().unwrap().push(id.id.clone());
            return Ok(());
        }
        let before = owned.len();
        let uuid = id.id.rsplit('/').next().unwrap_or_default().to_string();
        owned.retain(|o| o.id.id != id.id && !(id.id.starts_with("server/") && o.attached_to.as_deref() == Some(uuid.as_str())));
        if owned.len() == before {
            return Err(CloudError::UnknownResource(id.clone()));
        }
        self.destroyed.lock().unwrap().push(id.id.clone());
        Ok(())
    }
    pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
        // The bare uuid is not an id this provider knows — what UpCloud's `parse_id` answers.
        if !id.id.contains('/') {
            return Err(CloudError::UnknownResource(id.clone()));
        }
        Ok(if self.owned.lock().unwrap().iter().any(|o| o.id.id == id.id) { ResourceStatus::Active } else { ResourceStatus::Gone })
    }
    pub fn list_owned(&self) -> Result<Inventory, CloudError> {
        if self.blind {
            return Err(CloudError::Unavailable("the listing is down".into()));
        }
        Ok(Inventory { owned: self.owned.lock().unwrap().clone(), foreign: 0, currency: Currency::Sek })
    }
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for Account {
    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,
        })
    }
}

/// The old twin as the shop's laid record holds it: bare provider uuids.
fn old_twin() -> LaidMachine {
    let disk = |id: &str| LaidDisk { title: format!("gunnar-twin-acme {id}"), id: id.into(), gib: 10, tier: "hdd".into(), address: "virtio:0".into() };
    LaidMachine {
        role: "twin".into(),
        hostname: "gunnar-twin-acme".into(),
        title: "gunnar twin-acme".into(),
        plan: "1xCPU-2GB".into(),
        server: "old-twin".into(),
        public_ipv4: String::new(),
        utility_ipv4: "10.6.0.12".into(),
        disks: vec![disk("old-twin-sys"), disk("old-twin-data")],
        medium_clone: String::new(),
    }
}

/// ★ **The fence gives the whole box back**: server first, then every disk the laid record
/// names (those the server's delete already took are gone, not failures), read back from
/// the listing; the main and the other estate are untouched.
#[test]
fn retire_gives_back_the_server_and_every_disk_and_nothing_else() {
    let a = Account::new();
    let standing = monetize_cloud::ask::machine_standing(&a, REF, &old_twin()).unwrap();
    assert_eq!(standing.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(), ["server/old-twin", "storage/old-twin-sys", "storage/old-twin-data"], "bare laid ids matched to the listing's");
    let gone = monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).unwrap();
    assert_eq!(gone.len(), 3, "{gone:?}");
    assert_eq!(a.destroyed().first().map(String::as_str), Some("server/old-twin"), "the server goes first: {:?}", a.destroyed());
    assert_eq!(a.listed(), ["server/main", "storage/main-sys", "storage/bolt-sys"]);
    assert!(monetize_cloud::ask::machine_standing(&a, REF, &old_twin()).unwrap().is_empty());
}

/// ★ **Idempotent**: a second fence of a box already given back gives back nothing and is no
/// failure.
#[test]
fn a_second_retire_is_a_no_op() {
    let a = Account::new();
    monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).unwrap();
    let n = a.destroyed().len();
    assert_eq!(monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).unwrap(), Vec::<ResourceId>::new());
    assert_eq!(a.destroyed().len(), n, "nothing asked of the provider twice");
}

/// ★ **A provider that deletes slowly has the box given back, and gone is read LATER**
/// (5.4 gap-3, found by the real UpCloud guest at mock-upcloud): UpCloud answers a server's
/// delete `204` and lists it in `maintenance` for minutes. The retire is what the provider
/// ACCEPTED — every destroy asked once, none refused — and "gone" is read from the listing by
/// whoever awaits it (the guardian's fence step); a retire that read back at once failed a
/// box that was on its way out.
#[test]
fn a_provider_that_deletes_slowly_has_the_box_given_back_and_read_gone_later() {
    let mut a = Account::new();
    a.linger = true;
    let given = monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).expect("every destroy was accepted");
    assert_eq!(given.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(), ["server/old-twin", "storage/old-twin-sys", "storage/old-twin-data"]);
    assert_eq!(monetize_cloud::ask::machine_standing(&a, REF, &old_twin()).unwrap().len(), 3, "still listed on its way out");
    a.settle();
    assert!(monetize_cloud::ask::machine_standing(&a, REF, &old_twin()).unwrap().is_empty());
    assert_eq!(a.listed(), ["server/main", "storage/main-sys", "storage/bolt-sys"]);
}

/// A disk the provider will not let go of is NAMED, and the fence is not met: the relay must
/// not be laid beside a half-retired box, and the retry finds the rest.
#[test]
fn refuse_twin_a_wedged_disk_fails_the_retire_by_name() {
    let mut a = Account::new();
    a.refuse.push("storage/old-twin-data".into());
    // Not attached, so the server's delete does not take it.
    a.owned.lock().unwrap().iter_mut().filter(|o| o.id.id == "storage/old-twin-data").for_each(|o| o.attached_to = None);
    let e = monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).unwrap_err().to_string();
    assert!(e.contains("storage/old-twin-data"), "{e}");
    assert!(!a.listed().contains(&"server/old-twin".to_string()), "what could go went");
}

/// "I could not look" is never "it is gone": a listing that fails fails the retire and the
/// standing check, and nothing is destroyed on a guess.
#[test]
fn refuse_twin_a_blind_listing_is_never_read_as_gone() {
    let mut a = Account::new();
    a.blind = true;
    assert!(monetize_cloud::ask::machine_standing(&a, REF, &old_twin()).is_err());
    assert!(monetize_cloud::ask::retire_machine(&a, REF, &old_twin()).is_err());
    assert!(a.destroyed().is_empty());
}

/// The reference is a filter, never a wildcard: an empty one matches nothing and is refused.
#[test]
fn refuse_twin_an_empty_reference_is_refused() {
    let a = Account::new();
    assert!(matches!(monetize_cloud::ask::retire_machine(&a, "", &old_twin()), Err(CloudError::Refused(_))));
    assert!(a.destroyed().is_empty());
}

/// Forwarded through every pointer a registry wraps a provider in.
#[test]
fn retire_reaches_through_arc_and_box() {
    let a = std::sync::Arc::new(Account::new());
    let boxed: Box<dyn Cloud> = Box::new(a.clone());
    assert_eq!(monetize_cloud::ask::retire_machine(&boxed, REF, &old_twin()).unwrap().len(), 3);
    assert!(monetize_cloud::ask::machine_standing(&boxed, REF, &old_twin()).unwrap().is_empty());
}