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