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

nordisk/gunnar

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