deploy/upcloud/tests/mock_world/mod.rs
//! **The re-image's order proofs, run against `mock-upcloud`** — the published
//! fake of UpCloud 1.3, its measured behaviours included — through the SAME
//! `upcloud_api` calls the production wire sends.
//!
//! Until 2026-09-21 these ran against a scripted world in `tests/scripted`: a
//! route table this repository wrote, answering what the procedure expected.
//! The owner's order was to run them on the mock instead, so a proof here is a
//! proof against the behaviour the ledger records for the account, and a
//! scripted answer the account does not give can no longer make a test green.
//!
//! What this harness adds, and nothing else:
//!
//! * **one world** — `mock_upcloud::Mock` in-process, reached through its
//! trait face `face::FakeUpCloud` (the same router as its HTTP face);
//! * **one clock** — the procedure's `FakeClock` sleeps move the mock's virtual
//! clock too, so a poll every 5 s is 5 s for the provider as well;
//! * **a recorder** — each call as `METHOD /path` (and its body), with the
//! uuids the mock minted replaced by the names the assertions use: the
//! appliance is `SERVER`, this ISO's seeds are `cd-uuid` / `vd-uuid`, a
//! foreign ISO's are `f-cd` / `f-vd`;
//! * **a setup** — the appliance, and the states a crashed run leaves behind,
//! laid down through the mock's own API before recording starts.
#![allow(dead_code)]
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use mock_upcloud::face::FakeUpCloud;
use mock_upcloud::{Clock as MockClock, Estate, Faults, Mock};
use serde_json::{json, Value};
use upcloud_api::{Call, Exchange, Reply, UpCloudApi};
use gunnar_upcloud::upload::Iso;
use gunnar_upcloud::wait::{Clock, FakeClock};
/// What the assertions call the appliance, whatever uuid the mock minted.
pub const SERVER: &str = "SERVER";
pub const TITLE: &str = "gunnar-appliance";
/// One recorded call.
#[derive(Clone, Debug)]
pub struct Rec {
pub method: &'static str,
/// `/server/SERVER/stop`, `/storage/cd-uuid`, `uploader/session/cd-uuid`.
pub path: String,
pub body: Option<Value>,
/// For the medium's PUT: the file it streamed, and its length.
pub file: Option<(PathBuf, u64)>,
}
impl Rec {
pub fn line(&self) -> String {
format!("{} {}", self.method, self.path)
}
}
/// The world a test runs in.
pub struct World {
pub mock: Arc<Mock>,
/// The appliance's real uuid.
pub server: String,
/// Title → the name assertions use.
titles: RefCell<BTreeMap<String, String>>,
rec: Rc<RefCell<Vec<Rec>>>,
}
fn api(mock: &Arc<Mock>) -> upcloud_api::Over<FakeUpCloud> {
FakeUpCloud::over(mock.clone(), "ucat_mock_flow")
}
impl World {
/// A fresh estate with the appliance on it: `started`, its boot disk on
/// `virtio:0`, laid down through the mock's own API.
pub fn new() -> World {
// ONE clock: the mock runs at 1/1000 of real time (so a test's own
// wall-clock contributes a millisecond per second, nothing a poll can
// see) and every procedure sleep moves it by exactly the slept amount
// ([`Coupled`]). NOT `virtual_only()`: at speed 0 the mock advances
// itself halfway to its next deadline on every request, so a sync of
// 100–130 s would pass in a handful of polls — time a caller never
// waited, and a measurement of the procedure's clock would read short.
let mock = Mock::recording(Estate::new(MockClock::new(1), Faults::none(), 20260921));
let a = api(&mock);
// The live appliance: its boot disk on virtio:0 and FOUR data members
// on virtio:1–4, so a medium attached as `virtio` lands on the first
// free slot, virtio:5 — where the live box put it (behaviour 58). The
// members are made first and handed to the create by uuid, as
// terraform does.
let mut members = Vec::new();
for n in 1..=4 {
let title = format!("gunnar-appliance data {n}");
let r = a
.create_storage(&upcloud_api::NewStorage {
title: &title,
zone: "se-sto1",
size_gib: 10,
tier: "maxiops",
labels: &[],
})
.expect("the mock answers");
assert!(r.ok(), "{}", r.text);
members.push(r.body["storage"]["uuid"].as_str().unwrap().to_string());
}
{
let mut e = mock.estate.lock().unwrap();
for _ in 0..20 {
e.clock.advance_ms(30_000);
e.settle();
}
}
let mut devices = vec![
json!({"action": "create", "title": "gunnar-boot", "size": 20, "tier": "maxiops"}),
];
devices.extend(
members
.iter()
.map(|m| json!({"action": "attach", "storage": m, "type": "disk"})),
);
let r = a
.create_server(&json!({"server": {
"zone": "se-sto1", "title": TITLE, "hostname": "gunnar-appliance", "plan": "2xCPU-4GB",
"storage_devices": {"storage_device": devices}
}}))
.expect("the mock answers");
assert!(r.ok(), "the appliance could not be laid: {}", r.text);
let server = r.body["server"]["uuid"]
.as_str()
.expect("a uuid")
.to_string();
let w = World {
mock,
server,
titles: RefCell::new(BTreeMap::new()),
rec: Rc::new(RefCell::new(Vec::new())),
};
w.quiet();
w
}
/// Let every pending transition complete — setup only, never mid-run.
pub fn quiet(&self) {
// At a non-zero speed `run_to_quiet` is a no-op (real time may not
// skip), so setup moves the clock itself: every create, sync, stop and
// start the setup waits on is done within ten minutes of provider time.
let mut e = self.mock.estate.lock().unwrap();
for _ in 0..20 {
e.clock.advance_ms(30_000);
e.settle();
}
}
/// The names the assertions use for this ISO's media (and a foreign one's).
pub fn name_titles(&self, cdrom: &str, virtio: &str) {
let mut t = self.titles.borrow_mut();
t.insert(cdrom.to_string(), "cd-uuid".into());
t.insert(virtio.to_string(), "vd-uuid".into());
}
pub fn name_title(&self, title: &str, name: &str) {
self.titles
.borrow_mut()
.insert(title.to_string(), name.to_string());
}
/// A uuid, as the assertions name it.
pub fn name(&self, uuid: &str) -> String {
if uuid == self.server {
return SERVER.to_string();
}
let e = self.mock.estate.lock().unwrap();
let title = e.storage(uuid).map(|s| s.title.clone());
drop(e);
match title.and_then(|t| self.titles.borrow().get(&t).cloned()) {
Some(n) => n,
None => uuid.to_string(),
}
}
fn named_path(&self, path: &str) -> String {
path.split('/')
.map(|seg| {
if seg.len() == 36 && seg.matches('-').count() == 4 {
self.name(seg)
} else {
seg.to_string()
}
})
.collect::<Vec<_>>()
.join("/")
}
// ── laying down a state, through the mock's API ─────────────────────────
/// A seed titled `title` holding `iso`'s bytes, online, in the account.
pub fn seed(&self, title: &str, iso: &Iso) -> String {
let a = api(&self.mock);
let r = a
.create_storage(&upcloud_api::NewStorage {
title,
zone: "se-sto1",
size_gib: 1,
tier: "maxiops",
labels: &[],
})
.unwrap();
assert!(r.ok(), "{}", r.text);
let uuid = r.body["storage"]["uuid"].as_str().unwrap().to_string();
let imp = a.import_direct_upload(&uuid).unwrap();
assert!(imp.ok(), "{}", imp.text);
let url = imp.body["storage_import"]["direct_upload_url"]
.as_str()
.unwrap()
.to_string();
assert!(a.upload_direct(&url, &iso.path).unwrap().ok());
self.quiet();
uuid
}
/// A seed titled `title` CREATED and not settled: `maintenance`, which
/// the mock refuses to delete (behaviour 59). No import, no bytes.
pub fn seed_unsettled(&self, title: &str) -> String {
let r = api(&self.mock)
.create_storage(&upcloud_api::NewStorage {
title,
zone: "se-sto1",
size_gib: 1,
tier: "maxiops",
labels: &[],
})
.unwrap();
assert!(r.ok(), "{}", r.text);
r.body["storage"]["uuid"].as_str().unwrap().to_string()
}
/// Stop the appliance (hard) and wait for it.
pub fn stop(&self) {
let a = api(&self.mock);
assert!(a
.stop_server(&self.server, upcloud_api::Stop::Hard)
.unwrap()
.ok());
self.quiet();
}
/// A stop SENT and not waited for: the box reads `maintenance` until the
/// mock's stop lands, then `stopped`. What a procedure that reads the box
/// while the cloud is still operating on it sees.
pub fn stop_in_flight(&self) {
let r = api(&self.mock)
.stop_server(&self.server, upcloud_api::Stop::Hard)
.unwrap();
assert!(r.ok(), "{}", r.text);
}
/// A start SENT and not waited for: `maintenance` on its way to `started`.
pub fn start_in_flight(&self) {
let r = api(&self.mock).start_server(&self.server).unwrap();
assert!(r.ok(), "{}", r.text);
}
/// The hypervisor's word for the box, now.
pub fn state(&self) -> String {
self.mock
.estate
.lock()
.unwrap()
.server(&self.server)
.map(|s| s.state.clone())
.unwrap_or_default()
}
/// A storage's state, now.
pub fn storage_state(&self, uuid: &str) -> String {
self.mock
.estate
.lock()
.unwrap()
.storage(uuid)
.map(|s| s.state.clone())
.unwrap_or_default()
}
pub fn attach(&self, kind: upcloud_api::DeviceKind, storage: &str) {
let r = api(&self.mock)
.attach_storage(&self.server, kind, storage, None)
.unwrap();
assert!(r.ok(), "{}", r.text);
self.quiet();
}
pub fn boot_order(&self, order: upcloud_api::BootOrder) {
let r = api(&self.mock).set_boot_order(&self.server, order).unwrap();
assert!(r.ok(), "{}", r.text);
self.quiet();
}
pub fn start(&self) {
let r = api(&self.mock).start_server(&self.server).unwrap();
assert!(r.ok(), "{}", r.text);
self.quiet();
}
pub fn eject(&self) {
let r = api(&self.mock).eject_cdrom(&self.server).unwrap();
assert!(r.ok(), "{}", r.text);
self.quiet();
}
/// The state a CRASHED run left: this ISO's media in the account, on the
/// box (`loaded` = the cdrom still holds its medium), the boot order and
/// the power state as asked. Built the way the account would have got
/// there: attached while stopped, then started if `state` says so.
pub fn left_behind(
&self,
iso: &Iso,
cdrom_title: &str,
virtio_title: &str,
state: &str,
cdrom_first: bool,
loaded: bool,
with_virtio: bool,
) {
let cd = self.seed(cdrom_title, iso);
let vd = self.seed(virtio_title, iso);
self.stop();
self.attach(upcloud_api::DeviceKind::Cdrom, &cd);
if with_virtio {
self.attach(upcloud_api::DeviceKind::Disk, &vd);
}
self.boot_order(if cdrom_first {
upcloud_api::BootOrder::Cdrom
} else {
upcloud_api::BootOrder::Disk
});
if state == "started" {
self.start();
if !loaded {
self.eject();
}
}
}
// ── running ─────────────────────────────────────────────────────────────
/// The trait the procedure holds: the mock's face, recorded.
pub fn client(&self) -> gunnar_upcloud::api::Client<upcloud_api::Over<Recorder<'_>>> {
gunnar_upcloud::api::Client::new(upcloud_api::Over(Recorder {
world: self,
face: api(&self.mock).0,
}))
}
/// The procedure's clock, and the mock's with it.
pub fn clock<'a>(&'a self, own: &'a FakeClock) -> Coupled<'a> {
Coupled {
own,
mock: &self.mock,
}
}
// ── reading back ────────────────────────────────────────────────────────
/// Every call, `METHOD /path`, in order.
pub fn calls(&self) -> Vec<String> {
self.rec.borrow().iter().map(Rec::line).collect()
}
pub fn requests(&self) -> Vec<Rec> {
self.rec.borrow().clone()
}
/// **The calls with each run of REPEATED polls collapsed to one round.**
///
/// The scripted world answered in a fixed number of polls; the mock answers
/// on the provider's clock (a sync of 100–130 s, a stop of seconds), so the
/// NUMBER of polls is a property of the timing, not of the order. What the
/// order proofs assert is the order: which calls, in which sequence, and —
/// for the shared sync wait — that each round asks about BOTH media. A run
/// of identical GET lines becomes one; a run of an identical GET *round*
/// (`GET a, GET b, GET a, GET b`) becomes one round.
pub fn rounds(&self) -> Vec<String> {
collapse(&self.calls())
}
/// The JSON bodies sent to calls whose `METHOD path` contains `needle`.
pub fn bodies(&self, method: &str, needle: &str) -> Vec<Value> {
self.rec
.borrow()
.iter()
.filter(|r| r.method == method && r.path.contains(needle))
.filter_map(|r| r.body.clone())
.collect()
}
/// The bodies of calls whose path ENDS with `suffix`.
pub fn bodies_ending(&self, method: &str, suffix: &str) -> Vec<Value> {
self.rec
.borrow()
.iter()
.filter(|r| r.method == method && r.path.ends_with(suffix))
.filter_map(|r| r.body.clone())
.collect()
}
/// Several uuids, as the assertions name them.
pub fn names(&self, uuids: &[String]) -> Vec<String> {
uuids.iter().map(|u| self.name(u)).collect()
}
}
/// Collapse runs of repeated GET lines, and of repeated GET rounds.
pub fn collapse(calls: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < calls.len() {
// The longest repeated GET-only unit starting here (1 or 2 lines).
let mut advanced = false;
for width in [2usize, 1] {
let unit = &calls[i..(i + width).min(calls.len())];
if unit.len() < width
|| !unit.iter().all(|c| c.starts_with("GET "))
|| (width == 2 && unit[0] == unit[1])
{
continue;
}
let mut j = i + width;
while j + width <= calls.len() && calls[j..j + width] == *unit {
j += width;
}
if j > i + width {
out.extend(unit.iter().cloned());
i = j;
advanced = true;
break;
}
}
if !advanced {
out.push(calls[i].clone());
i += 1;
}
}
out
}
/// The procedure's calls, recorded, then answered by the mock's face.
pub struct Recorder<'a> {
world: &'a World,
face: FakeUpCloud,
}
impl Exchange for Recorder<'_> {
fn describe(&self) -> String {
self.face.describe()
}
fn is_the_account(&self) -> bool {
false
}
fn exchange(&self, call: Call<'_>) -> Result<Reply, String> {
let rec = match &call {
Call::Api { method, path, body } => Rec {
method: method.as_str(),
path: self
.world
.named_path(path.split('?').next().unwrap_or(path)),
body: body.clone(),
file: None,
},
Call::Upload { url, file } => {
let session = url.rsplit('/').next().unwrap_or("");
let len = std::fs::metadata(file).map(|m| m.len()).unwrap_or(0);
Rec {
method: "PUT",
path: format!("uploader/session/{}", self.world.name(session)),
body: None,
file: Some((file.to_path_buf(), len)),
}
}
};
self.world.rec.borrow_mut().push(rec);
self.face.exchange(call)
}
}
/// **One clock for the procedure and the provider.** `now_ms` is the
/// procedure's own (so its ledger arithmetic is exact); every sleep also moves
/// the mock's virtual clock by the same amount, so a 5 s poll is 5 s for the
/// provider too. (The mock runs at 1/1000 of real time, so it never advances
/// itself between requests: its time IS the procedure's.)
pub struct Coupled<'a> {
own: &'a FakeClock,
mock: &'a Arc<Mock>,
}
impl Clock for Coupled<'_> {
fn now_ms(&self) -> u64 {
self.own.now_ms()
}
fn sleep_ms(&self, ms: u64) {
self.own.sleep_ms(ms);
let mut e = self.mock.estate.lock().unwrap();
e.clock.advance_ms(ms);
e.settle();
}
}
/// Inject one refusal (test-only; see `mock_upcloud::http::Injection`).
pub fn inject(
world: &World,
method: &str,
matches: impl Fn(&str, &Estate) -> bool + Send + Sync + 'static,
status: u16,
code: &str,
) {
world.mock.inject(mock_upcloud::http::Injection {
method: method.to_string(),
matches: Box::new(matches),
status,
code: code.to_string(),
message: format!("injected by the test: {code}"),
times: 1,
});
}
/// Arm a mock fault by its name.
pub fn arm(world: &World, name: &str) {
let f = mock_upcloud::Fault::parse(name)
.unwrap_or_else(|| panic!("mock-upcloud has no fault `{name}` in this version"));
world.mock.estate.lock().unwrap().faults.arm(f);
}
/// Disarm it again — for a fault a test wants in its SETUP and not in the run.
pub fn disarm(world: &World, name: &str) {
let f = mock_upcloud::Fault::parse(name)
.unwrap_or_else(|| panic!("mock-upcloud has no fault `{name}` in this version"));
world.mock.estate.lock().unwrap().faults.disarm(f);
}
/// **The appliance's SSH banner, answered by the MOCK's machine** — not by a
/// count of polls. The box answers when the hypervisor reads it `started` AND
/// its guest is running a disk (installed, or the old appliance on its first
/// boot); while it installs, loops, is off or panicked, the port refuses.
///
/// The scripted world answered after N polls whatever the box was doing, which
/// let a run read "serving" off a box that was still installing. Tests that
/// model the APPLIANCE's own software — a wrong build, a box that never comes
/// back, one that comes back late — keep their own probes; this one is the
/// box that simply works.
pub struct MockBanner<'a> {
world: &'a World,
banner: String,
}
impl World {
pub fn banner(&self, banner: &str) -> MockBanner<'_> {
MockBanner {
world: self,
banner: banner.to_string(),
}
}
}
/// The host key the mock box presents once it is serving. A simulated
/// appliance has one: an SSH server presents its host key in the key exchange
/// before it says anything else, so a box that can answer with a banner can
/// answer with a key, and a mock that could not would be modelling a machine
/// that does not exist.
pub const MOCK_HOST_KEY: &str = "SHA256:z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE";
/// The key itself, as a `known_hosts` line carries it.
pub const MOCK_HOST_KEY_OPENSSH: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAMFTUdzMTWe0oliOdl4pLRDM3Y0beuwtw52DS2vS+BM";
impl gunnar_upcloud::banner::BannerProbe for MockBanner<'_> {
/// The same gate as the banner below, for the same reason: a box that is
/// installing, looping, off or panicked presents nothing to anybody.
fn host_key_fingerprint(&self, addr: &str) -> Result<gunnar_upcloud::banner::HostKey, String> {
self.banner(addr).map(|_| gunnar_upcloud::banner::HostKey {
fingerprint: MOCK_HOST_KEY.to_string(),
openssh: MOCK_HOST_KEY_OPENSSH.to_string(),
})
}
fn banner(&self, addr: &str) -> Result<String, String> {
use mock_upcloud::estate::Guest;
let e = self.world.mock.estate.lock().unwrap();
let Some(s) = e.server(&self.world.server) else {
return Err(format!("connect {addr}: No route to host"));
};
if s.state == "started" && matches!(s.guest, Guest::Installed | Guest::TemplateFirstBoot) {
Ok(self.banner.clone())
} else {
Err(format!("connect {addr}: Connection refused (os error 111)"))
}
}
}