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

nordisk/monetize

crates/mock-scaleway/src/account.rs

//! **The account as a state machine**: every route of the Instance API v1 the mock serves, as
//! one function of `(method, path, query, body)` over the account's records.
//!
//! Transitions are lazy: an action writes the state it starts (`starting`, `stopping`) and the
//! virtual time it ends at; [`Account::settle`] applies every transition whose time has come at
//! the start of each request. On a virtual clock (`--speed 0`) every request also moves the
//! clock by [`Account::step_ms`], so a client that polls sees the transition close after a few
//! polls, as it does against the provider.

use std::collections::BTreeMap;
use std::sync::Arc;

use mock_upcloud::kvm::{GuestEngine, Machine, MachineDisk, INSTALLER_PUBLIC_PORTS};
use mock_upcloud::Clock;
use serde_json::{json, Value};

use crate::catalog;

/// The zones the mock answers (Paris 1 and 2, Amsterdam 1, Warsaw 1).
pub const ZONES: &[&str] = &["fr-par-1", "fr-par-2", "nl-ams-1", "pl-waw-1"];

/// How long a transition takes on the provider (virtual ms).
#[derive(Clone, Copy, Debug)]
pub struct Timings {
    pub poweron_ms: u64,
    pub poweroff_ms: u64,
    pub reboot_ms: u64,
    pub terminate_ms: u64,
    pub snapshot_ms: u64,
}

impl Default for Timings {
    fn default() -> Self {
        Timings {
            poweron_ms: 30_000,
            poweroff_ms: 15_000,
            reboot_ms: 40_000,
            terminate_ms: 15_000,
            snapshot_ms: 20_000,
        }
    }
}

/// **The armable faults.** Each is a behaviour the provider HAS; armed, it is sticky until
/// disarmed, as the provider's are.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fault {
    /// Behaviour 3: `412 out_of_stock` on `poweron` (and `shortage` in the availability list)
    /// for every commercial type, or only for the one named. Verified against fr-par-1 on
    /// 2026-09-09: it lasted days.
    OutOfStock(Option<String>),
}

impl Fault {
    pub fn parse(name: &str, commercial_type: Option<&str>) -> Option<Fault> {
        match name {
            "out-of-stock" | "out_of_stock" => {
                Some(Fault::OutOfStock(commercial_type.map(str::to_string)))
            }
            _ => None,
        }
    }
    pub fn name(&self) -> &'static str {
        match self {
            Fault::OutOfStock(_) => "out-of-stock",
        }
    }
}

/// What a route answers.
#[derive(Debug, Clone, PartialEq)]
pub enum Answer {
    Json {
        status: u16,
        body: Value,
        total: Option<usize>,
    },
    Text {
        status: u16,
        body: Vec<u8>,
    },
    Empty {
        status: u16,
    },
}

fn ok(status: u16, body: Value) -> Answer {
    Answer::Json {
        status,
        body,
        total: None,
    }
}

/// Scaleway's error body: `{"type": …, "message": …}` plus the type's own fields.
pub fn err(status: u16, kind: &str, message: &str) -> Answer {
    ok(status, json!({ "type": kind, "message": message }))
}

fn not_found(resource: &str, id: &str) -> Answer {
    ok(
        404,
        json!({ "type": "unknown_resource", "message": format!("\"{resource}\" not found"), "resource": resource, "resource_id": id }),
    )
}

fn invalid_argument(arg: &str, help: &str) -> Answer {
    ok(
        400,
        json!({ "type": "invalid_arguments", "message": "invalid argument(s)", "details": [{ "argument_name": arg, "reason": "constraint", "help_message": help }] }),
    )
}

fn invalid_request(message: &str) -> Answer {
    err(400, "invalid_request_error", message)
}

fn still_in_use(resource: &str, id: &str, help: &str) -> Answer {
    ok(
        412,
        json!({ "type": "precondition_failed", "precondition": "resource_still_in_use", "message": "resource is still in use", "help_message": help, "resource": resource, "resource_id": id }),
    )
}

fn transient(resource: &str, id: &str, state: &str) -> Answer {
    ok(
        409,
        json!({ "type": "transient_state", "message": format!("resource is in a transient state: {state}"), "resource": resource, "resource_id": id, "current_state": state }),
    )
}

fn permissions_denied(resource: &str, action: &str) -> Answer {
    ok(
        403,
        json!({ "type": "permissions_denied", "message": "insufficient permissions", "details": [{ "resource": resource, "action": action }] }),
    )
}

#[derive(Clone, Debug)]
pub struct Server {
    pub id: String,
    pub zone: String,
    pub name: String,
    pub project: String,
    pub commercial_type: String,
    pub state: String,
    /// `(state it becomes, at virtual ms)`; `"gone"` = deleted (terminate).
    pub pending: Option<(String, u64)>,
    pub tags: Vec<String>,
    /// Index → volume id.
    pub volumes: BTreeMap<String, String>,
    pub image: Option<String>,
    pub security_group: String,
    /// The DYNAMIC ip's address (released with the server), when it has one.
    pub dynamic_ip: Option<String>,
    pub private_ip: Option<String>,
    pub boot_type: String,
    pub created_ms: u64,
    pub modified_ms: u64,
    pub user_data: BTreeMap<String, Vec<u8>>,
}

#[derive(Clone, Debug)]
pub struct Volume {
    pub id: String,
    pub zone: String,
    pub name: String,
    pub project: String,
    pub volume_type: String,
    pub size: u64,
    pub tags: Vec<String>,
    pub server: Option<String>,
    pub created_ms: u64,
}

#[derive(Clone, Debug)]
pub struct Snapshot {
    pub id: String,
    pub zone: String,
    pub name: String,
    pub project: String,
    pub volume_type: String,
    pub size: u64,
    pub tags: Vec<String>,
    pub base_volume: Option<(String, String)>,
    pub state: String,
    pub ready_ms: u64,
    pub created_ms: u64,
}

#[derive(Clone, Debug)]
pub struct Image {
    pub id: String,
    pub zone: String,
    pub name: String,
    pub project: String,
    pub root_volume: String,
    pub extra_volumes: BTreeMap<String, String>,
    pub tags: Vec<String>,
    pub public: bool,
    pub from_server: Option<String>,
    pub created_ms: u64,
}

#[derive(Clone, Debug)]
pub struct Rule {
    pub id: String,
    pub action: String,
    pub protocol: String,
    pub direction: String,
    pub ip_range: String,
    pub dest_port_from: Option<u16>,
    pub dest_port_to: Option<u16>,
    pub position: u32,
    pub editable: bool,
}

#[derive(Clone, Debug)]
pub struct SecurityGroup {
    pub id: String,
    pub zone: String,
    pub name: String,
    pub project: String,
    pub inbound_default_policy: String,
    pub outbound_default_policy: String,
    pub stateful: bool,
    pub project_default: bool,
    pub tags: Vec<String>,
    pub rules: Vec<Rule>,
    pub created_ms: u64,
}

#[derive(Clone, Debug)]
pub struct Ip {
    pub id: String,
    pub zone: String,
    pub address: String,
    pub project: String,
    pub tags: Vec<String>,
    pub server: Option<String>,
    /// A dynamic ip lives and dies with its server and is not listed under `/ips`.
    pub dynamic: bool,
}

#[derive(Clone, Debug)]
pub struct Task {
    pub id: String,
    pub zone: String,
    pub description: String,
    pub href_from: String,
    pub href_result: String,
    pub done_ms: u64,
    pub started_ms: u64,
}

/// **The account.**
pub struct Account {
    pub clock: Arc<Clock>,
    pub timings: Timings,
    /// Virtual ms each request moves a virtual clock (`speed 0`); 0 = the clock is the caller's.
    pub step_ms: u64,
    pub organization: String,
    pub project: String,
    /// The secret keys the account accepts. Empty: any non-empty key.
    pub tokens: Vec<String>,
    /// Keys that WERE good and were revoked (behaviour 5).
    pub revoked: Vec<String>,
    pub faults: Vec<Fault>,
    pub servers: BTreeMap<String, Server>,
    pub volumes: BTreeMap<String, Volume>,
    pub snapshots: BTreeMap<String, Snapshot>,
    pub images: BTreeMap<String, Image>,
    pub security_groups: BTreeMap<String, SecurityGroup>,
    pub ips: BTreeMap<String, Ip>,
    pub tasks: BTreeMap<String, Task>,
    /// The mock's object store: what a snapshot IMPORT (`bucket` + `key`) reads.
    pub objects: BTreeMap<(String, String), Vec<u8>>,
    pub engine: Option<Arc<dyn GuestEngine>>,
    /// Public addresses on loopback (`127.A.B.n`), so an engine can serve the installer's
    /// ports on the address the API reports. `None`: TEST-NET 2, reached by nobody.
    pub public_on_loopback: Option<(u8, u8)>,
    /// The utility `X.Y` a container engine's network is on (`X.Y.8.97…`).
    pub utility: String,
    counter: u64,
    addr_counter: u32,
    /// Every request, in order (`METHOD path`), for a test's reading.
    pub heard: Vec<String>,
}

fn now_iso(ms: u64) -> String {
    // The account's epoch: 2026-10-24T00:00:00Z plus the virtual clock.
    const BASE_S: i64 = 1_792_800_000;
    let secs = BASE_S + (ms / 1000) as i64;
    let micros = (ms % 1000) * 1000;
    let days = secs.div_euclid(86_400);
    let rem = secs.rem_euclid(86_400);
    // Howard Hinnant's civil_from_days.
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{micros:06}+00:00",
        rem / 3600,
        (rem % 3600) / 60,
        rem % 60
    )
}

fn tags_of(v: &Value) -> Vec<String> {
    v.as_array()
        .map(|a| {
            a.iter()
                .filter_map(|t| t.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

fn has_all(tags: &[String], want: &[String]) -> bool {
    want.iter().all(|w| tags.contains(w))
}

/// Does `cidr` (`a.b.c.d/n`) hold `ip`?
pub fn cidr_holds(cidr: &str, ip: &str) -> bool {
    let (net, bits) = cidr.split_once('/').unwrap_or((cidr, "32"));
    let (Ok(net), Ok(ip), Ok(bits)) = (
        net.parse::<std::net::Ipv4Addr>(),
        ip.parse::<std::net::Ipv4Addr>(),
        bits.parse::<u32>(),
    ) else {
        return false;
    };
    if bits == 0 {
        return true;
    }
    let mask = if bits >= 32 {
        u32::MAX
    } else {
        !(u32::MAX >> bits)
    };
    (u32::from(net) & mask) == (u32::from(ip) & mask)
}

/// **What a security group admits** of the installer's public ports from `source`: the rules
/// in `position` order, first match wins, else the inbound default policy.
pub fn admitted(sg: &SecurityGroup, source: &str) -> Vec<(&'static str, u16)> {
    let mut rules: Vec<&Rule> = sg
        .rules
        .iter()
        .filter(|r| r.direction == "inbound")
        .collect();
    rules.sort_by_key(|r| r.position);
    INSTALLER_PUBLIC_PORTS
        .iter()
        .copied()
        .filter(|(proto, port)| {
            let hit = rules.iter().find(|r| {
                let p = r.protocol.as_str();
                let proto_ok = p == "ANY" || p.eq_ignore_ascii_case(proto);
                let from = r.dest_port_from.unwrap_or(0);
                let to = r.dest_port_to.or(r.dest_port_from).unwrap_or(u16::MAX);
                let port_ok = r.dest_port_from.is_none() || (from..=to).contains(port);
                proto_ok && port_ok && cidr_holds(&r.ip_range, source)
            });
            match hit {
                Some(r) => r.action == "accept",
                None => sg.inbound_default_policy == "accept",
            }
        })
        .collect()
}

impl Account {
    pub fn new(clock: Arc<Clock>) -> Account {
        let mut a = Account {
            step_ms: if clock.speed_milli() == 0 { 5_000 } else { 0 },
            clock,
            timings: Timings::default(),
            organization: "0rg00000-0000-4000-8000-000000000001".into(),
            project: "pr0jec70-0000-4000-8000-000000000001".into(),
            tokens: Vec::new(),
            revoked: Vec::new(),
            faults: Vec::new(),
            servers: BTreeMap::new(),
            volumes: BTreeMap::new(),
            snapshots: BTreeMap::new(),
            images: BTreeMap::new(),
            security_groups: BTreeMap::new(),
            ips: BTreeMap::new(),
            tasks: BTreeMap::new(),
            objects: BTreeMap::new(),
            engine: None,
            public_on_loopback: None,
            utility: mock_upcloud::container::MOCK_UTILITY.into(),
            counter: 0,
            addr_counter: 0,
            heard: Vec::new(),
        };
        // The public marketplace image every zone has (by label and by id).
        for z in ZONES {
            let snap = a.new_id("5a");
            let now = 0;
            a.snapshots.insert(
                snap.clone(),
                Snapshot {
                    id: snap.clone(),
                    zone: z.to_string(),
                    name: "ubuntu_noble root".into(),
                    project: "marketplace".into(),
                    volume_type: "l_ssd".into(),
                    size: 10 * catalog::GB,
                    tags: vec![],
                    base_volume: None,
                    state: "available".into(),
                    ready_ms: 0,
                    created_ms: now,
                },
            );
            let img = a.new_id("1a");
            a.images.insert(
                img.clone(),
                Image {
                    id: img,
                    zone: z.to_string(),
                    name: "Ubuntu 24.04 Noble Numbat".into(),
                    project: "marketplace".into(),
                    root_volume: snap,
                    extra_volumes: BTreeMap::new(),
                    tags: vec!["ubuntu_noble".into()],
                    public: true,
                    from_server: None,
                    created_ms: now,
                },
            );
        }
        a
    }

    fn now(&self) -> u64 {
        self.clock.now_ms()
    }

    /// A fresh UUIDv4-shaped id; `kind` is the first two hex digits, so a reader can tell kinds apart.
    fn new_id(&mut self, kind: &str) -> String {
        self.counter += 1;
        format!(
            "{kind}{:06x}-0000-4000-8000-{:012x}",
            self.counter, self.counter
        )
    }

    fn new_public_address(&mut self) -> String {
        self.addr_counter += 1;
        let n = self.addr_counter;
        match self.public_on_loopback {
            Some((a, b)) => format!("127.{a}.{b}.{}", 10 + n),
            None => format!("51.15.{}.{}", n / 250, 1 + n % 250),
        }
    }

    pub fn out_of_stock(&self, commercial_type: &str) -> bool {
        self.faults.iter().any(|f| matches!(f, Fault::OutOfStock(t) if t.as_deref().is_none_or(|t| t == commercial_type)))
    }

    /// The account's default security group in `zone`, made on first need, as Scaleway does.
    fn default_sg(&mut self, zone: &str, project: &str) -> String {
        if let Some(sg) = self
            .security_groups
            .values()
            .find(|g| g.zone == zone && g.project == project && g.project_default)
        {
            return sg.id.clone();
        }
        let id = self.new_id("56");
        let now = self.now();
        self.security_groups.insert(
            id.clone(),
            SecurityGroup {
                id: id.clone(),
                zone: zone.into(),
                name: "Default security group".into(),
                project: project.into(),
                inbound_default_policy: "accept".into(),
                outbound_default_policy: "accept".into(),
                stateful: true,
                project_default: true,
                tags: vec![],
                rules: vec![],
                created_ms: now,
            },
        );
        id
    }

    // ── time ────────────────────────────────────────────────────────────────

    /// Apply every transition whose time has come.
    pub fn settle(&mut self) {
        let now = self.now();
        let due: Vec<(String, String)> = self
            .servers
            .values()
            .filter_map(|s| {
                s.pending
                    .as_ref()
                    .filter(|(_, at)| *at <= now)
                    .map(|(st, _)| (s.id.clone(), st.clone()))
            })
            .collect();
        for (id, st) in due {
            if st == "gone" {
                self.remove_server(&id, true);
                continue;
            }
            if let Some(s) = self.servers.get_mut(&id) {
                s.state = st;
                s.pending = None;
                s.modified_ms = now;
            }
        }
        for s in self.snapshots.values_mut() {
            if s.state == "snapshotting" && s.ready_ms <= now {
                s.state = "available".into();
            }
        }
        // A machine that died under a running server stops it (the engine's word stands).
        if let Some(e) = &self.engine {
            for s in self.servers.values_mut() {
                if s.state == "running" && e.running(&s.id) == Some(false) {
                    s.state = "stopped".into();
                }
            }
        }
    }

    // ── rendering ───────────────────────────────────────────────────────────

    fn volume_json(&self, v: &Volume) -> Value {
        let server = v
            .server
            .as_ref()
            .and_then(|id| self.servers.get(id))
            .map(|s| json!({ "id": s.id, "name": s.name }));
        json!({
            "id": v.id, "name": v.name, "export_uri": null, "size": v.size, "volume_type": v.volume_type,
            "creation_date": now_iso(v.created_ms), "modification_date": now_iso(v.created_ms),
            "organization": self.organization, "project": v.project, "tags": v.tags,
            "server": server, "state": if v.server.is_some() { "in_use" } else { "available" }, "zone": v.zone,
        })
    }

    fn ip_json(&self, ip: &Ip) -> Value {
        let server = ip
            .server
            .as_ref()
            .and_then(|id| self.servers.get(id))
            .map(|s| json!({ "id": s.id, "name": s.name }));
        json!({
            "id": ip.id, "address": ip.address, "reverse": null, "server": server, "organization": self.organization,
            "project": ip.project, "zone": ip.zone, "type": "routed_ipv4", "state": if ip.server.is_some() { "attached" } else { "detached" },
            "tags": ip.tags, "prefix": null, "ipam_id": null,
        })
    }

    fn server_ips(&self, id: &str) -> Vec<&Ip> {
        self.ips
            .values()
            .filter(|i| i.server.as_deref() == Some(id))
            .collect()
    }

    pub fn server_json(&self, s: &Server) -> Value {
        let mut vols = serde_json::Map::new();
        for (k, vid) in &s.volumes {
            if let Some(v) = self.volumes.get(vid) {
                let mut j = self.volume_json(v);
                j["boot"] = json!(k == "0");
                vols.insert(k.clone(), j);
            }
        }
        let ips = self.server_ips(&s.id);
        let public_ip = ips.first().map(|i| json!({ "id": i.id, "address": i.address, "dynamic": i.dynamic, "family": "inet", "provisioning_mode": "dhcp", "state": "attached", "tags": i.tags }));
        let (cpus, ram) = catalog::server_row(&s.commercial_type).unwrap_or((1, 1));
        let sg = self
            .security_groups
            .get(&s.security_group)
            .map(|g| json!({ "id": g.id, "name": g.name }));
        let image = s
            .image
            .as_ref()
            .and_then(|i| self.images.get(i))
            .map(|i| json!({ "id": i.id, "name": i.name, "arch": "x86_64" }));
        let allowed: &[&str] = match s.state.as_str() {
            "running" => &["poweroff", "terminate", "reboot", "stop_in_place", "backup"],
            "stopped" => &["poweron", "backup"],
            "stopped in place" => &["poweron", "backup", "terminate"],
            _ => &[],
        };
        json!({
            "id": s.id, "name": s.name, "hostname": s.name, "organization": self.organization, "project": s.project,
            "commercial_type": s.commercial_type, "arch": "x86_64", "state": s.state, "state_detail": "",
            "tags": s.tags, "volumes": vols, "image": image, "protected": false, "dynamic_ip_required": s.dynamic_ip.is_some(),
            "public_ip": public_ip, "public_ips": ips.iter().map(|i| json!({ "id": i.id, "address": i.address, "dynamic": i.dynamic })).collect::<Vec<_>>(),
            "private_ip": s.private_ip, "security_group": sg, "boot_type": s.boot_type, "allowed_actions": allowed,
            "creation_date": now_iso(s.created_ms), "modification_date": now_iso(s.modified_ms), "zone": s.zone,
            "location": { "zone_id": s.zone, "cluster_id": "1", "hypervisor_id": "1", "node_id": "1", "platform_id": "14" },
            "ipv6": null, "maintenances": [], "placement_group": null, "private_nics": [], "routed_ip_enabled": true,
            "_ncpus": cpus, "_ram": ram * catalog::GIB,
        })
    }

    fn snapshot_json(&self, s: &Snapshot) -> Value {
        json!({
            "id": s.id, "name": s.name, "organization": self.organization, "project": s.project, "tags": s.tags,
            "volume_type": s.volume_type, "size": s.size, "state": s.state,
            "base_volume": s.base_volume.as_ref().map(|(id, name)| json!({ "id": id, "name": name })),
            "creation_date": now_iso(s.created_ms), "modification_date": now_iso(s.created_ms), "zone": s.zone, "error_reason": null,
        })
    }

    fn image_json(&self, i: &Image) -> Value {
        let snap = |id: &str| {
            self.snapshots.get(id).map(|s| json!({ "id": s.id, "name": s.name, "size": s.size, "volume_type": s.volume_type }))
        };
        let extra: serde_json::Map<String, Value> = i
            .extra_volumes
            .iter()
            .filter_map(|(k, v)| snap(v).map(|j| (k.clone(), j)))
            .collect();
        json!({
            "id": i.id, "name": i.name, "arch": "x86_64", "organization": self.organization, "project": i.project, "tags": i.tags,
            "root_volume": snap(&i.root_volume), "extra_volumes": extra, "public": i.public, "state": "available",
            "from_server": i.from_server, "creation_date": now_iso(i.created_ms), "modification_date": now_iso(i.created_ms), "zone": i.zone,
        })
    }

    fn sg_json(&self, g: &SecurityGroup) -> Value {
        let servers: Vec<Value> = self
            .servers
            .values()
            .filter(|s| s.security_group == g.id)
            .map(|s| json!({ "id": s.id, "name": s.name }))
            .collect();
        json!({
            "id": g.id, "name": g.name, "description": "", "organization": self.organization, "project": g.project, "tags": g.tags,
            "inbound_default_policy": g.inbound_default_policy, "outbound_default_policy": g.outbound_default_policy,
            "stateful": g.stateful, "project_default": g.project_default, "organization_default": false, "enable_default_security": true,
            "servers": servers, "state": "available", "creation_date": now_iso(g.created_ms), "modification_date": now_iso(g.created_ms), "zone": g.zone,
        })
    }

    fn rule_json(r: &Rule, zone: &str) -> Value {
        json!({
            "id": r.id, "action": r.action, "protocol": r.protocol, "direction": r.direction, "ip_range": r.ip_range,
            "dest_port_from": r.dest_port_from, "dest_port_to": r.dest_port_to, "position": r.position, "editable": r.editable, "zone": zone,
        })
    }

    fn task(
        &mut self,
        zone: &str,
        description: &str,
        href_from: String,
        href_result: String,
        takes_ms: u64,
    ) -> Value {
        let id = self.new_id("7a");
        let now = self.now();
        let t = Task {
            id: id.clone(),
            zone: zone.into(),
            description: description.into(),
            href_from,
            href_result,
            done_ms: now + takes_ms,
            started_ms: now,
        };
        let j = self.task_json(&t);
        self.tasks.insert(id, t);
        j
    }

    fn task_json(&self, t: &Task) -> Value {
        let done = self.now() >= t.done_ms;
        json!({
            "id": t.id, "description": t.description, "status": if done { "success" } else { "pending" }, "progress": if done { 100 } else { 0 },
            "href_from": t.href_from, "href_result": t.href_result, "started_at": now_iso(t.started_ms),
            "terminated_at": if done { Value::String(now_iso(t.done_ms)) } else { Value::Null }, "zone": t.zone,
        })
    }

    // ── the machine seam ────────────────────────────────────────────────────

    fn machine_of(&self, s: &Server) -> Machine {
        let (cores, ram) = catalog::server_row(&s.commercial_type).unwrap_or((1, 1));
        let public_ip = self
            .server_ips(&s.id)
            .first()
            .map(|i| i.address.clone())
            .unwrap_or_default();
        let admitted = self
            .security_groups
            .get(&s.security_group)
            .map(|g| admitted(g, mock_upcloud::kvm::OPERATOR_SOURCE))
            .unwrap_or_default();
        Machine {
            server: s.id.clone(),
            mem_mb: (ram * 1024) as u32,
            cores,
            disks: s
                .volumes
                .values()
                .filter_map(|v| self.volumes.get(v))
                .map(|v| MachineDisk {
                    storage: v.id.clone(),
                    size_gib: v.size / catalog::GIB,
                    template: None,
                })
                .collect(),
            medium: None,
            cdrom_first: false,
            disk_first: false,
            rtc_skew_seconds: 0,
            utility_mac: format!(
                "02:00:{}",
                s.id.chars()
                    .filter(|c| c.is_ascii_hexdigit())
                    .rev()
                    .take(8)
                    .collect::<String>()
                    .as_bytes()
                    .chunks(2)
                    .map(|c| String::from_utf8_lossy(c).into_owned())
                    .collect::<Vec<_>>()
                    .join(":")
            ),
            utility_offer: mock_upcloud::net::DhcpOffer::for_address(
                s.private_ip.as_deref().unwrap_or(""),
            ),
            dhcp_late_first_boot_ms: 0,
            hostname: s.name.clone(),
            ssh_keys: Vec::new(),
            user_data: s
                .user_data
                .get("cloud-init")
                .map(|b| String::from_utf8_lossy(b).into_owned()),
            public_ip,
            public_admitted: admitted,
        }
    }

    fn tell_firewall(&self, sg: &str) {
        let Some(e) = &self.engine else { return };
        let Some(g) = self.security_groups.get(sg) else {
            return;
        };
        let ports = admitted(g, mock_upcloud::kvm::OPERATOR_SOURCE);
        for s in self.servers.values().filter(|s| s.security_group == sg) {
            let _ = e.firewall(&s.id, &ports);
        }
    }

    fn remove_server(&mut self, id: &str, with_volumes: bool) {
        let Some(s) = self.servers.remove(id) else {
            return;
        };
        if let Some(e) = &self.engine {
            e.forget_server(id);
        }
        for vid in s.volumes.values() {
            if with_volumes {
                self.volumes.remove(vid);
                if let Some(e) = &self.engine {
                    e.forget_storage(vid);
                }
            } else if let Some(v) = self.volumes.get_mut(vid) {
                v.server = None;
            }
        }
        // The dynamic ip goes with the server; a flexible ip stays, detached (and billed).
        self.ips
            .retain(|_, i| !(i.dynamic && i.server.as_deref() == Some(id)));
        for i in self.ips.values_mut() {
            if i.server.as_deref() == Some(id) {
                i.server = None;
            }
        }
    }

    // ── the router ──────────────────────────────────────────────────────────

    /// One request. `token`: the `X-Auth-Token` header's value; `content_type`: the body's.
    pub fn handle(
        &mut self,
        method: &str,
        path: &str,
        query: &[(String, String)],
        token: Option<&str>,
        content_type: Option<&str>,
        body: &[u8],
    ) -> Answer {
        self.heard.push(format!("{method} {path}"));
        if self.step_ms > 0 {
            self.clock.advance_ms(self.step_ms);
        }
        self.settle();
        let seg: Vec<&str> = path.trim_matches('/').split('/').collect();
        if seg.first() == Some(&"mock") {
            return self.mock_door(method, &seg[1..], query, body);
        }
        // Auth.
        let token = token.unwrap_or("").trim();
        let revoked = !token.is_empty() && self.revoked.iter().any(|t| t == token);
        if token.is_empty()
            || (!revoked && !self.tokens.is_empty() && !self.tokens.iter().any(|t| t == token))
        {
            return ok(
                401,
                json!({ "type": "denied_authentication", "message": "authentication is denied", "method": "api_key", "reason": "invalid_argument" }),
            );
        }
        let ["instance", "v1", "zones", zone, rest @ ..] = seg.as_slice() else {
            return not_found("api_route", path);
        };
        if !ZONES.contains(zone) {
            return invalid_argument("zone", &format!("zone must be one of {}", ZONES.join(", ")));
        }
        let zone = zone.to_string();
        let jbody: Value =
            if body.is_empty() || content_type.is_some_and(|c| c.starts_with("text/plain")) {
                Value::Null
            } else {
                match serde_json::from_slice(body) {
                    Ok(v) => v,
                    Err(e) => return invalid_request(&format!("the body is not JSON: {e}")),
                }
            };
        // Behaviour 5: a revoked key lists NO servers (200, empty) and is refused 403 on other
        // endpoints. Never a clean 401 (verified against Scaleway after the IAM change of 2026-08-26).
        if revoked {
            return match (method, rest) {
                ("GET", ["servers"]) => Answer::Json {
                    status: 200,
                    body: json!({ "servers": [] }),
                    total: Some(0),
                },
                (_, [kind, ..]) => permissions_denied(
                    &format!("instance_{}", kind.trim_end_matches('s')),
                    if method == "GET" { "read" } else { "write" },
                ),
                _ => permissions_denied("instance", "read"),
            };
        }
        let project_q = query
            .iter()
            .find(|(k, _)| k == "project")
            .map(|(_, v)| v.clone());
        let tags_q: Vec<String> = query
            .iter()
            .find(|(k, _)| k == "tags")
            .map(|(_, v)| {
                v.split(',')
                    .filter(|t| !t.is_empty())
                    .map(str::to_string)
                    .collect()
            })
            .unwrap_or_default();
        let name_q = query
            .iter()
            .find(|(k, _)| k == "name")
            .map(|(_, v)| v.clone());
        let page = |items: Vec<Value>, key: &str| -> Answer {
            let per_page = query
                .iter()
                .find(|(k, _)| k == "per_page")
                .and_then(|(_, v)| v.parse::<usize>().ok())
                .unwrap_or(50);
            if per_page == 0 || per_page > 100 {
                return invalid_argument("per_page", "per_page must be between 1 and 100");
            }
            let pg = query
                .iter()
                .find(|(k, _)| k == "page")
                .and_then(|(_, v)| v.parse::<usize>().ok())
                .unwrap_or(1)
                .max(1);
            let total = items.len();
            let slice: Vec<Value> = items
                .into_iter()
                .skip((pg - 1) * per_page)
                .take(per_page)
                .collect();
            Answer::Json {
                status: 200,
                body: json!({ key: slice }),
                total: Some(total),
            }
        };
        let keep = |p: &str, tags: &[String], name: &str| -> bool {
            project_q.as_deref().is_none_or(|q| q == p)
                && has_all(tags, &tags_q)
                && name_q.as_deref().is_none_or(|n| name.contains(n))
        };
        match (method, rest) {
            // ── catalog ──
            ("GET", ["products", "servers"]) => ok(200, catalog::servers_json()),
            ("GET", ["products", "servers", "availability"]) => {
                let m: serde_json::Map<String, Value> = catalog::SERVERS.iter().map(|r| (r.0.to_string(), json!({ "availability": if self.out_of_stock(r.0) { "shortage" } else { "available" } }))).collect();
                ok(200, json!({ "servers": m }))
            }
            ("GET", ["products", "volumes"]) => ok(200, catalog::volumes_json()),
            // ── servers ──
            ("GET", ["servers"]) => {
                // Newest first, as the provider lists them by default.
                let mut v: Vec<&Server> = self
                    .servers
                    .values()
                    .filter(|s| s.zone == zone && keep(&s.project, &s.tags, &s.name))
                    .collect();
                v.sort_by(|a, b| b.created_ms.cmp(&a.created_ms).then(b.id.cmp(&a.id)));
                let items = v.into_iter().map(|s| self.server_json(s)).collect();
                page(items, "servers")
            }
            ("POST", ["servers"]) => self.create_server(&zone, &jbody),
            ("GET", ["servers", id]) => match self.servers.get(*id).filter(|s| s.zone == zone) {
                Some(s) => ok(200, json!({ "server": self.server_json(s) })),
                None => not_found("instance_server", id),
            },
            ("PATCH", ["servers", id]) => self.patch_server(&zone, id, &jbody),
            ("DELETE", ["servers", id]) => {
                let Some(s) = self.servers.get(*id).filter(|s| s.zone == zone) else {
                    return not_found("instance_server", id);
                };
                if s.pending.is_some() {
                    return transient("instance_server", id, &s.state);
                }
                if s.state != "stopped" && s.state != "stopped in place" {
                    return invalid_request("instance must be stopped to be deleted; terminate deletes a running instance with its volumes");
                }
                // DELETE keeps the volumes, detached: they go on being billed until deleted.
                self.remove_server(id, false);
                Answer::Empty { status: 204 }
            }
            ("POST", ["servers", id, "action"]) => self.action(&zone, id, &jbody),
            ("GET", ["servers", id, "user_data"]) => match self.servers.get(*id) {
                Some(s) => ok(
                    200,
                    json!({ "user_data": s.user_data.keys().collect::<Vec<_>>() }),
                ),
                None => not_found("instance_server", id),
            },
            ("GET", ["servers", id, "user_data", key]) => {
                match self.servers.get(*id).and_then(|s| s.user_data.get(*key)) {
                    Some(b) => Answer::Text {
                        status: 200,
                        body: b.clone(),
                    },
                    None => not_found("instance_user_data", key),
                }
            }
            ("PATCH", ["servers", id, "user_data", key]) => {
                if !content_type.is_some_and(|c| c.starts_with("text/plain")) {
                    return invalid_request("user data must be sent as text/plain");
                }
                let Some(s) = self.servers.get_mut(*id) else {
                    return not_found("instance_server", id);
                };
                s.user_data.insert(key.to_string(), body.to_vec());
                if *key == "cloud-init" {
                    if let Some(e) = &self.engine {
                        e.metadata(id, Some(&String::from_utf8_lossy(body)));
                    }
                }
                Answer::Empty { status: 204 }
            }
            ("DELETE", ["servers", id, "user_data", key]) => {
                let Some(s) = self.servers.get_mut(*id) else {
                    return not_found("instance_server", id);
                };
                if s.user_data.remove(*key).is_none() {
                    return not_found("instance_user_data", key);
                }
                Answer::Empty { status: 204 }
            }
            // ── volumes ──
            ("GET", ["volumes"]) => {
                let items = self
                    .volumes
                    .values()
                    .filter(|v| v.zone == zone && keep(&v.project, &v.tags, &v.name))
                    .map(|v| self.volume_json(v))
                    .collect();
                page(items, "volumes")
            }
            ("POST", ["volumes"]) => self.create_volume(&zone, &jbody),
            ("GET", ["volumes", id]) => match self.volumes.get(*id) {
                Some(v) => ok(200, json!({ "volume": self.volume_json(v) })),
                None => not_found("instance_volume", id),
            },
            ("PATCH", ["volumes", id]) => {
                let Some(v) = self.volumes.get(*id).cloned() else {
                    return not_found("instance_volume", id);
                };
                let mut v = v;
                if let Some(size) = jbody.get("size").and_then(Value::as_u64) {
                    let row = catalog::volume_row(&v.volume_type);
                    if !row.is_some_and(|r| r.3) {
                        return invalid_request(&format!(
                            "a {} volume cannot be resized",
                            v.volume_type
                        ));
                    }
                    if size < v.size {
                        return invalid_argument("size", "a volume can only grow");
                    }
                    v.size = size;
                    if let Some(e) = &self.engine {
                        let _ = e.resize_disk(id, size / catalog::GIB);
                    }
                }
                if let Some(n) = jbody.get("name").and_then(Value::as_str) {
                    v.name = n.into();
                }
                if jbody.get("tags").is_some() {
                    v.tags = tags_of(&jbody["tags"]);
                }
                let j = self.volume_json(&v);
                self.volumes.insert(v.id.clone(), v);
                ok(200, json!({ "volume": j }))
            }
            ("DELETE", ["volumes", id]) => {
                let Some(v) = self.volumes.get(*id) else {
                    return not_found("instance_volume", id);
                };
                if v.server.is_some() {
                    return still_in_use(
                        "instance_volume",
                        id,
                        "detach the volume from its server first",
                    );
                }
                self.volumes.remove(*id);
                if let Some(e) = &self.engine {
                    e.forget_storage(id);
                }
                Answer::Empty { status: 204 }
            }
            // ── snapshots (backups) ──
            ("GET", ["snapshots"]) => {
                let items = self
                    .snapshots
                    .values()
                    .filter(|s| {
                        s.zone == zone
                            && s.project != "marketplace"
                            && keep(&s.project, &s.tags, &s.name)
                    })
                    .map(|s| self.snapshot_json(s))
                    .collect();
                page(items, "snapshots")
            }
            ("POST", ["snapshots"]) => self.create_snapshot(&zone, &jbody),
            ("GET", ["snapshots", id]) => match self.snapshots.get(*id) {
                Some(s) => ok(200, json!({ "snapshot": self.snapshot_json(s) })),
                None => not_found("instance_snapshot", id),
            },
            ("DELETE", ["snapshots", id]) => {
                if !self.snapshots.contains_key(*id) {
                    return not_found("instance_snapshot", id);
                }
                if self
                    .images
                    .values()
                    .any(|i| i.root_volume == *id || i.extra_volumes.values().any(|v| v == id))
                {
                    return still_in_use(
                        "instance_snapshot",
                        id,
                        "the snapshot is the volume of an image: delete the image first",
                    );
                }
                self.snapshots.remove(*id);
                Answer::Empty { status: 204 }
            }
            // ── images ──
            ("GET", ["images"]) => {
                let public = query
                    .iter()
                    .find(|(k, _)| k == "public")
                    .map(|(_, v)| v == "true");
                let items = self
                    .images
                    .values()
                    .filter(|i| {
                        i.zone == zone
                            && public.is_none_or(|p| p == i.public)
                            && (i.public || keep(&i.project, &i.tags, &i.name))
                    })
                    .map(|i| self.image_json(i))
                    .collect();
                page(items, "images")
            }
            ("POST", ["images"]) => self.create_image(&zone, &jbody),
            ("GET", ["images", id]) => match self.images.get(*id) {
                Some(i) => ok(200, json!({ "image": self.image_json(i) })),
                None => not_found("instance_image", id),
            },
            ("DELETE", ["images", id]) => {
                if self.images.get(*id).is_none_or(|i| i.public) {
                    return not_found("instance_image", id);
                }
                // THE TRAP: deleting an image keeps its snapshots (and they go on being billed).
                self.images.remove(*id);
                Answer::Empty { status: 204 }
            }
            // ── security groups ──
            ("GET", ["security_groups"]) => {
                let items = self
                    .security_groups
                    .values()
                    .filter(|g| g.zone == zone && keep(&g.project, &g.tags, &g.name))
                    .map(|g| self.sg_json(g))
                    .collect();
                page(items, "security_groups")
            }
            ("POST", ["security_groups"]) => {
                let Some(project) = jbody
                    .get("project")
                    .and_then(Value::as_str)
                    .map(str::to_string)
                else {
                    return invalid_argument("project", "project is required");
                };
                let Some(name) = jbody
                    .get("name")
                    .and_then(Value::as_str)
                    .map(str::to_string)
                else {
                    return invalid_argument("name", "name is required");
                };
                let pol = |k: &str| {
                    jbody
                        .get(k)
                        .and_then(Value::as_str)
                        .unwrap_or("accept")
                        .to_string()
                };
                let (inb, outb) = (
                    pol("inbound_default_policy"),
                    pol("outbound_default_policy"),
                );
                if ![&inb, &outb].iter().all(|p| *p == "accept" || *p == "drop") {
                    return invalid_argument("inbound_default_policy", "accept or drop");
                }
                let id = self.new_id("56");
                let now = self.now();
                let g = SecurityGroup {
                    id: id.clone(),
                    zone: zone.clone(),
                    name,
                    project,
                    inbound_default_policy: inb,
                    outbound_default_policy: outb,
                    stateful: jbody
                        .get("stateful")
                        .and_then(Value::as_bool)
                        .unwrap_or(true),
                    project_default: false,
                    tags: tags_of(&jbody["tags"]),
                    rules: vec![],
                    created_ms: now,
                };
                let j = self.sg_json(&g);
                self.security_groups.insert(id, g);
                ok(201, json!({ "security_group": j }))
            }
            ("GET", ["security_groups", id]) => match self.security_groups.get(*id) {
                Some(g) => ok(200, json!({ "security_group": self.sg_json(g) })),
                None => not_found("instance_security_group", id),
            },
            ("PATCH", ["security_groups", id]) => {
                let Some(g) = self.security_groups.get_mut(*id) else {
                    return not_found("instance_security_group", id);
                };
                for k in ["inbound_default_policy", "outbound_default_policy"] {
                    if let Some(p) = jbody.get(k).and_then(Value::as_str) {
                        if k == "inbound_default_policy" {
                            g.inbound_default_policy = p.into()
                        } else {
                            g.outbound_default_policy = p.into()
                        }
                    }
                }
                if jbody.get("tags").is_some() {
                    g.tags = tags_of(&jbody["tags"]);
                }
                let id = id.to_string();
                self.tell_firewall(&id);
                ok(
                    200,
                    json!({ "security_group": self.sg_json(&self.security_groups[&id]) }),
                )
            }
            ("DELETE", ["security_groups", id]) => {
                if !self.security_groups.contains_key(*id) {
                    return not_found("instance_security_group", id);
                }
                if self.servers.values().any(|s| s.security_group == *id) {
                    return still_in_use(
                        "instance_security_group",
                        id,
                        "the group is attached to a server",
                    );
                }
                self.security_groups.remove(*id);
                Answer::Empty { status: 204 }
            }
            ("GET", ["security_groups", id, "rules"]) => match self.security_groups.get(*id) {
                Some(g) => {
                    let mut r = g.rules.clone();
                    r.sort_by_key(|r| r.position);
                    let items = r.iter().map(|r| Self::rule_json(r, &g.zone)).collect();
                    page(items, "rules")
                }
                None => not_found("instance_security_group", id),
            },
            ("PUT", ["security_groups", id, "rules"]) => self.put_rules(id, &jbody),
            // ── ips ──
            ("GET", ["ips"]) => {
                let items = self
                    .ips
                    .values()
                    .filter(|i| !i.dynamic && i.zone == zone && keep(&i.project, &i.tags, ""))
                    .map(|i| self.ip_json(i))
                    .collect();
                page(items, "ips")
            }
            ("POST", ["ips"]) => {
                let Some(project) = jbody
                    .get("project")
                    .and_then(Value::as_str)
                    .map(str::to_string)
                else {
                    return invalid_argument("project", "project is required");
                };
                let server = jbody
                    .get("server")
                    .and_then(Value::as_str)
                    .map(str::to_string);
                if let Some(s) = &server {
                    if !self.servers.contains_key(s) {
                        return not_found("instance_server", s);
                    }
                }
                let id = self.new_id("19");
                let address = self.new_public_address();
                let ip = Ip {
                    id: id.clone(),
                    zone: zone.clone(),
                    address,
                    project,
                    tags: tags_of(&jbody["tags"]),
                    server,
                    dynamic: false,
                };
                let j = self.ip_json(&ip);
                self.ips.insert(id, ip);
                ok(201, json!({ "ip": j }))
            }
            ("GET", ["ips", id]) => match self.ips.get(*id).filter(|i| !i.dynamic) {
                Some(i) => ok(200, json!({ "ip": self.ip_json(i) })),
                None => not_found("instance_ip", id),
            },
            ("PATCH", ["ips", id]) => {
                let Some(mut ip) = self.ips.get(*id).filter(|i| !i.dynamic).cloned() else {
                    return not_found("instance_ip", id);
                };
                match jbody.get("server") {
                    Some(Value::Null) => ip.server = None,
                    Some(Value::String(s)) if self.servers.contains_key(s) => {
                        ip.server = Some(s.clone())
                    }
                    Some(Value::String(s)) => return not_found("instance_server", s),
                    _ => {}
                }
                if jbody.get("tags").is_some() {
                    ip.tags = tags_of(&jbody["tags"]);
                }
                let j = self.ip_json(&ip);
                self.ips.insert(ip.id.clone(), ip);
                ok(200, json!({ "ip": j }))
            }
            ("DELETE", ["ips", id]) => {
                if self.ips.get(*id).is_none_or(|i| i.dynamic) {
                    return not_found("instance_ip", id);
                }
                self.ips.remove(*id);
                Answer::Empty { status: 204 }
            }
            // ── tasks ──
            ("GET", ["tasks", id]) => match self.tasks.get(*id) {
                Some(t) => ok(200, json!({ "task": self.task_json(t) })),
                None => not_found("instance_task", id),
            },
            _ => not_found("api_route", path),
        }
    }

    fn create_server(&mut self, zone: &str, b: &Value) -> Answer {
        let project = match (
            b.get("project").and_then(Value::as_str),
            b.get("organization").and_then(Value::as_str),
        ) {
            (Some(p), _) => p.to_string(),
            (None, Some(_)) => self.project.clone(),
            (None, None) => {
                return invalid_argument("project", "one of project or organization is required")
            }
        };
        let Some(ct) = b
            .get("commercial_type")
            .and_then(Value::as_str)
            .map(str::to_string)
        else {
            return invalid_argument("commercial_type", "commercial_type is required");
        };
        if catalog::server_row(&ct).is_none() {
            return invalid_argument(
                "commercial_type",
                &format!("{ct} is not a commercial type of this zone"),
            );
        }
        let name = b
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or("scw-mock")
            .to_string();
        // The image: by id, or by a marketplace label (`ubuntu_noble`).
        let image = match b.get("image").and_then(Value::as_str) {
            None | Some("") => None,
            Some(i) => match self.images.values().find(|im| {
                im.zone == zone && (im.id == i || (im.public && im.tags.iter().any(|t| t == i)))
            }) {
                Some(im) => Some(im.id.clone()),
                None => return not_found("instance_image", i),
            },
        };
        let vols_in = b
            .get("volumes")
            .and_then(Value::as_object)
            .cloned()
            .unwrap_or_default();
        if image.is_none() && !vols_in.contains_key("0") {
            return invalid_argument("image", "an image or a root volume (volumes.0) is required");
        }
        let sg = match b.get("security_group") {
            Some(Value::String(id)) => id.clone(),
            Some(Value::Object(o)) => o
                .get("id")
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string(),
            _ => self.default_sg(zone, &project),
        };
        if !self.security_groups.contains_key(&sg) {
            return not_found("instance_security_group", &sg);
        }
        let id = self.new_id("5e");
        let now = self.now();
        // Volumes: existing ones by id, new ones by size and type; the root from the image.
        let mut volumes = BTreeMap::new();
        let mut made: Vec<Volume> = Vec::new();
        let mut keys: Vec<&String> = vols_in.keys().collect();
        keys.sort_by_key(|k| k.parse::<u32>().unwrap_or(u32::MAX));
        for k in keys {
            let v = &vols_in[k];
            if let Some(vid) = v.get("id").and_then(Value::as_str) {
                match self.volumes.get(vid) {
                    None => return not_found("instance_volume", vid),
                    Some(ex) if ex.server.is_some() => {
                        return still_in_use(
                            "instance_volume",
                            vid,
                            "the volume is attached to another server",
                        )
                    }
                    Some(_) => {
                        volumes.insert(k.clone(), vid.to_string());
                    }
                }
            } else {
                let vt = v
                    .get("volume_type")
                    .and_then(Value::as_str)
                    .unwrap_or("l_ssd")
                    .to_string();
                let Some(row) = catalog::volume_row(&vt) else {
                    return invalid_argument("volume_type", &format!("{vt} is not a volume type"));
                };
                let size = v
                    .get("size")
                    .and_then(Value::as_u64)
                    .unwrap_or(20 * catalog::GB);
                if size < row.4 || size > row.5 {
                    return invalid_argument(
                        "size",
                        &format!("a {vt} volume is {}..{} bytes", row.4, row.5),
                    );
                }
                let vid = self.new_id("70");
                made.push(Volume {
                    id: vid.clone(),
                    zone: zone.into(),
                    name: format!("{name}-vol-{k}"),
                    project: project.clone(),
                    volume_type: vt,
                    size,
                    tags: vec![],
                    server: Some(id.clone()),
                    created_ms: now,
                });
                volumes.insert(k.clone(), vid);
            }
        }
        if !volumes.contains_key("0") {
            let im = &self.images[image.as_ref().unwrap()];
            let snap = self.snapshots.get(&im.root_volume).cloned();
            let vid = self.new_id("70");
            let (vt, size) = snap
                .as_ref()
                .map(|s| (s.volume_type.clone(), s.size))
                .unwrap_or(("l_ssd".into(), 10 * catalog::GB));
            made.push(Volume {
                id: vid.clone(),
                zone: zone.into(),
                name: format!("{name}-root"),
                project: project.clone(),
                volume_type: vt,
                size,
                tags: vec![],
                server: Some(id.clone()),
                created_ms: now,
            });
            if let (Some(e), Some(s)) = (&self.engine, &snap) {
                let _ = e.copy_disk(&s.id, &vid);
            }
            volumes.insert("0".into(), vid);
        }
        for v in made {
            self.volumes.insert(v.id.clone(), v);
        }
        for vid in volumes.values() {
            if let Some(v) = self.volumes.get_mut(vid) {
                v.server = Some(id.clone());
            }
        }
        let dynamic = b
            .get("dynamic_ip_required")
            .and_then(Value::as_bool)
            .unwrap_or(true)
            && b.get("public_ip").is_none_or(Value::is_null);
        let dynamic_ip = if dynamic {
            let ipid = self.new_id("19");
            let address = self.new_public_address();
            self.ips.insert(
                ipid.clone(),
                Ip {
                    id: ipid,
                    zone: zone.into(),
                    address: address.clone(),
                    project: project.clone(),
                    tags: vec![],
                    server: Some(id.clone()),
                    dynamic: true,
                },
            );
            Some(address)
        } else {
            None
        };
        if let Some(ipid) = b.get("public_ip").and_then(Value::as_str) {
            match self.ips.get_mut(ipid) {
                Some(ip) => ip.server = Some(id.clone()),
                None => return not_found("instance_ip", ipid),
            }
        }
        let n = self.servers.len() as u32 + 97;
        let private_ip = Some(format!(
            "{}.{}.{}",
            self.utility,
            8 + (n / 256) * 4,
            n % 256
        ));
        let s = Server {
            id: id.clone(),
            zone: zone.into(),
            name,
            project,
            commercial_type: ct,
            state: "stopped".into(),
            pending: None,
            tags: tags_of(&b["tags"]),
            volumes,
            image,
            security_group: sg,
            dynamic_ip,
            private_ip,
            boot_type: b
                .get("boot_type")
                .and_then(Value::as_str)
                .unwrap_or("local")
                .into(),
            created_ms: now,
            modified_ms: now,
            user_data: BTreeMap::new(),
        };
        let j = self.server_json(&s);
        self.servers.insert(id, s);
        ok(201, json!({ "server": j }))
    }

    fn patch_server(&mut self, zone: &str, id: &str, b: &Value) -> Answer {
        let Some(mut s) = self.servers.get(id).filter(|s| s.zone == zone).cloned() else {
            return not_found("instance_server", id);
        };
        let stopped = s.state == "stopped" || s.state == "stopped in place";
        if let Some(ct) = b.get("commercial_type").and_then(Value::as_str) {
            if catalog::server_row(ct).is_none() {
                return invalid_argument(
                    "commercial_type",
                    &format!("{ct} is not a commercial type of this zone"),
                );
            }
            if s.pending.is_some() {
                return transient("instance_server", id, &s.state);
            }
            if !stopped && ct != s.commercial_type {
                return invalid_request(
                    "the instance must be stopped to change its commercial type",
                );
            }
            s.commercial_type = ct.into();
        }
        if let Some(vols) = b.get("volumes").and_then(Value::as_object) {
            if !stopped {
                return invalid_request("the instance must be stopped to change its volumes");
            }
            let mut next = BTreeMap::new();
            for (k, v) in vols {
                let Some(vid) = v.get("id").and_then(Value::as_str) else {
                    return invalid_argument("volumes", "each volume is given by its id");
                };
                match self.volumes.get(vid) {
                    None => return not_found("instance_volume", vid),
                    Some(ex) if ex.server.as_deref().is_some_and(|o| o != id) => {
                        return still_in_use(
                            "instance_volume",
                            vid,
                            "the volume is attached to another server",
                        )
                    }
                    _ => {
                        next.insert(k.clone(), vid.to_string());
                    }
                }
            }
            if !next.contains_key("0") {
                return invalid_argument("volumes", "volumes.0 (the root) is required");
            }
            for old in s.volumes.values() {
                if let Some(v) = self.volumes.get_mut(old) {
                    v.server = None;
                }
            }
            for vid in next.values() {
                if let Some(v) = self.volumes.get_mut(vid) {
                    v.server = Some(id.into());
                }
            }
            s.volumes = next;
        }
        if let Some(v @ (Value::Object(_) | Value::String(_))) = b.get("security_group") {
            let sg = v
                .as_str()
                .or_else(|| v.get("id").and_then(Value::as_str))
                .unwrap_or("")
                .to_string();
            if !self.security_groups.contains_key(&sg) {
                return not_found("instance_security_group", &sg);
            }
            s.security_group = sg;
        }
        if let Some(n) = b.get("name").and_then(Value::as_str) {
            s.name = n.into();
        }
        if b.get("tags").is_some() {
            s.tags = tags_of(&b["tags"]);
        }
        s.modified_ms = self.now();
        let sg = s.security_group.clone();
        let j = self.server_json(&s);
        self.servers.insert(id.into(), s);
        self.tell_firewall(&sg);
        ok(200, json!({ "server": j }))
    }

    fn action(&mut self, zone: &str, id: &str, b: &Value) -> Answer {
        let Some(s) = self.servers.get(id).filter(|s| s.zone == zone).cloned() else {
            return not_found("instance_server", id);
        };
        let action = b.get("action").and_then(Value::as_str).unwrap_or("");
        if s.pending.is_some() && action != "backup" {
            return transient("instance_server", id, &s.state);
        }
        let now = self.now();
        let t = self.timings;
        let href = format!("/servers/{id}");
        let bad_state = |s: &Server| {
            invalid_request(&format!(
                "action {action} is not allowed in state {}",
                s.state
            ))
        };
        let (next, takes, desc) = match action {
            "poweron" => {
                if s.state == "running" {
                    return bad_state(&s);
                }
                if self.out_of_stock(&s.commercial_type) {
                    return ok(412, json!({ "type": "out_of_stock", "message": format!("no {} available in {zone}", s.commercial_type), "resource": "instance_server" }));
                }
                if let Some(e) = &self.engine {
                    if let Err(why) = e.power_on(&self.machine_of(&s)) {
                        return err(500, "internal_error", &format!("the machine did not start: {why}"));
                    }
                }
                ("running", t.poweron_ms, "server_batch_poweron")
            }
            "poweroff" => {
                if s.state != "running" {
                    return bad_state(&s);
                }
                if let Some(e) = &self.engine {
                    e.power_off(id, false);
                }
                ("stopped", t.poweroff_ms, "server_poweroff")
            }
            "stop_in_place" => {
                if s.state != "running" {
                    return bad_state(&s);
                }
                if let Some(e) = &self.engine {
                    e.power_off(id, false);
                }
                ("stopped in place", t.poweroff_ms, "server_stop_in_place")
            }
            "reboot" => {
                if s.state != "running" {
                    return bad_state(&s);
                }
                if let Some(e) = &self.engine {
                    e.power_off(id, true);
                    let _ = e.power_on(&self.machine_of(&s));
                }
                ("running", t.reboot_ms, "server_reboot")
            }
            "terminate" => {
                if s.state == "stopped" {
                    return invalid_request("a stopped instance is deleted with DELETE, not terminated");
                }
                if let Some(e) = &self.engine {
                    e.power_off(id, true);
                }
                ("gone", t.terminate_ms, "server_terminate")
            }
            "backup" => {
                // An image of the whole server: one snapshot per volume, the image over them.
                let name = b.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{}-backup", s.name));
                let mut root = String::new();
                let mut extra = BTreeMap::new();
                for (k, vid) in &s.volumes {
                    let Some(v) = self.volumes.get(vid).cloned() else { continue };
                    let sid = self.new_id("5a");
                    self.snapshots.insert(sid.clone(), Snapshot { id: sid.clone(), zone: zone.into(), name: format!("{name}-{k}"), project: s.project.clone(), volume_type: v.volume_type.clone(), size: v.size, tags: vec![], base_volume: Some((v.id.clone(), v.name.clone())), state: "snapshotting".into(), ready_ms: now + t.snapshot_ms, created_ms: now });
                    if let Some(e) = &self.engine {
                        let _ = e.copy_disk(&v.id, &sid);
                    }
                    if k == "0" { root = sid } else { extra.insert(k.clone(), sid); }
                }
                let iid = self.new_id("1a");
                self.images.insert(iid.clone(), Image { id: iid.clone(), zone: zone.into(), name, project: s.project.clone(), root_volume: root, extra_volumes: extra, tags: vec![], public: false, from_server: Some(id.into()), created_ms: now });
                let task = self.task(zone, "server_backup", href, format!("/images/{iid}"), t.snapshot_ms);
                return ok(202, json!({ "task": task }));
            }
            other => return invalid_argument("action", &format!("{other:?} is not an action (poweron, poweroff, stop_in_place, reboot, terminate, backup)")),
        };
        let transitional = match next {
            "running" => "starting",
            _ => "stopping",
        };
        if let Some(sv) = self.servers.get_mut(id) {
            sv.state = transitional.into();
            sv.pending = Some((next.into(), now + takes));
            sv.modified_ms = now;
        }
        let task = self.task(zone, desc, href.clone(), href, takes);
        ok(202, json!({ "task": task }))
    }

    fn create_volume(&mut self, zone: &str, b: &Value) -> Answer {
        let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
            return invalid_argument("project", "project is required");
        };
        let vt = b
            .get("volume_type")
            .and_then(Value::as_str)
            .unwrap_or("b_ssd")
            .to_string();
        let Some(row) = catalog::volume_row(&vt) else {
            return invalid_argument("volume_type", &format!("{vt} is not a volume type"));
        };
        let from = b
            .get("base_snapshot")
            .and_then(Value::as_str)
            .map(str::to_string);
        let size = match (&from, b.get("size").and_then(Value::as_u64)) {
            (Some(sid), s) => match self.snapshots.get(sid) {
                Some(snap) => s.unwrap_or(snap.size).max(snap.size),
                None => return not_found("instance_snapshot", sid),
            },
            (None, Some(s)) => s,
            (None, None) => return invalid_argument("size", "size or base_snapshot is required"),
        };
        if size < row.4 || size > row.5 {
            return invalid_argument(
                "size",
                &format!("a {vt} volume is {}..{} bytes", row.4, row.5),
            );
        }
        let id = self.new_id("70");
        let now = self.now();
        if let (Some(e), Some(sid)) = (&self.engine, &from) {
            let _ = e.copy_disk(sid, &id);
        }
        let v = Volume {
            id: id.clone(),
            zone: zone.into(),
            name: b
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or("vol")
                .into(),
            project,
            volume_type: vt,
            size,
            tags: tags_of(&b["tags"]),
            server: None,
            created_ms: now,
        };
        let j = self.volume_json(&v);
        self.volumes.insert(id, v);
        ok(201, json!({ "volume": j }))
    }

    fn create_snapshot(&mut self, zone: &str, b: &Value) -> Answer {
        let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
            return invalid_argument("project", "project is required");
        };
        let name = b
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or("snapshot")
            .to_string();
        let now = self.now();
        let id = self.new_id("5a");
        let snap = if let Some(vid) = b.get("volume_id").and_then(Value::as_str) {
            let Some(v) = self.volumes.get(vid).cloned() else {
                return not_found("instance_volume", vid);
            };
            if let Some(e) = &self.engine {
                let _ = e.copy_disk(&v.id, &id);
            }
            Snapshot {
                id: id.clone(),
                zone: zone.into(),
                name,
                project,
                volume_type: v.volume_type.clone(),
                size: v.size,
                tags: tags_of(&b["tags"]),
                base_volume: Some((v.id, v.name)),
                state: "snapshotting".into(),
                ready_ms: now + self.timings.snapshot_ms,
                created_ms: now,
            }
        } else if let (Some(bucket), Some(key)) = (
            b.get("bucket").and_then(Value::as_str),
            b.get("key").and_then(Value::as_str),
        ) {
            // An IMPORT from object storage (a medium, qcow2 on the provider; here the bytes).
            let Some(bytes) = self
                .objects
                .get(&(bucket.to_string(), key.to_string()))
                .cloned()
            else {
                return invalid_request(&format!("the object s3://{bucket}/{key} cannot be read"));
            };
            if let Some(e) = &self.engine {
                if let Err(why) = e.store_medium(&id, &bytes) {
                    return invalid_request(&format!("the object is not an image: {why}"));
                }
            }
            let size = b
                .get("size")
                .and_then(Value::as_u64)
                .unwrap_or(10 * catalog::GB);
            Snapshot {
                id: id.clone(),
                zone: zone.into(),
                name,
                project,
                volume_type: b
                    .get("volume_type")
                    .and_then(Value::as_str)
                    .unwrap_or("l_ssd")
                    .into(),
                size,
                tags: tags_of(&b["tags"]),
                base_volume: None,
                state: "snapshotting".into(),
                ready_ms: now + self.timings.snapshot_ms,
                created_ms: now,
            }
        } else {
            return invalid_argument("volume_id", "volume_id, or bucket and key, is required");
        };
        let j = self.snapshot_json(&snap);
        self.snapshots.insert(id.clone(), snap);
        let task = self.task(
            zone,
            "volume_snapshot",
            format!("/snapshots/{id}"),
            format!("/snapshots/{id}"),
            self.timings.snapshot_ms,
        );
        ok(201, json!({ "snapshot": j, "task": task }))
    }

    fn create_image(&mut self, zone: &str, b: &Value) -> Answer {
        let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
            return invalid_argument("project", "project is required");
        };
        let Some(root) = b
            .get("root_volume")
            .and_then(Value::as_str)
            .map(str::to_string)
        else {
            return invalid_argument("root_volume", "root_volume (a snapshot id) is required");
        };
        match self.snapshots.get(&root) {
            None => return not_found("instance_snapshot", &root),
            Some(s) if s.state != "available" => {
                return transient("instance_snapshot", &root, &s.state)
            }
            _ => {}
        }
        let id = self.new_id("1a");
        let now = self.now();
        let extra = b
            .get("extra_volumes")
            .and_then(Value::as_object)
            .map(|m| {
                m.iter()
                    .filter_map(|(k, v)| {
                        v.get("id")
                            .and_then(Value::as_str)
                            .map(|s| (k.clone(), s.to_string()))
                    })
                    .collect()
            })
            .unwrap_or_default();
        let i = Image {
            id: id.clone(),
            zone: zone.into(),
            name: b
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or("image")
                .into(),
            project,
            root_volume: root,
            extra_volumes: extra,
            tags: tags_of(&b["tags"]),
            public: false,
            from_server: None,
            created_ms: now,
        };
        let j = self.image_json(&i);
        self.images.insert(id, i);
        ok(201, json!({ "image": j }))
    }

    fn put_rules(&mut self, id: &str, b: &Value) -> Answer {
        if !self.security_groups.contains_key(id) {
            return not_found("instance_security_group", id);
        }
        let Some(rules) = b.get("rules").and_then(Value::as_array).cloned() else {
            return invalid_argument("rules", "rules is required");
        };
        let mut out = Vec::new();
        for (n, r) in rules.iter().enumerate() {
            let s = |k: &str, d: &str| r.get(k).and_then(Value::as_str).unwrap_or(d).to_string();
            let (action, protocol, direction, ip_range) = (
                s("action", "accept"),
                s("protocol", "TCP"),
                s("direction", "inbound"),
                s("ip_range", "0.0.0.0/0"),
            );
            if !["accept", "drop"].contains(&action.as_str()) {
                return invalid_argument("action", "accept or drop");
            }
            if !["TCP", "UDP", "ICMP", "ANY"].contains(&protocol.as_str()) {
                return invalid_argument("protocol", "TCP, UDP, ICMP or ANY");
            }
            if !["inbound", "outbound"].contains(&direction.as_str()) {
                return invalid_argument("direction", "inbound or outbound");
            }
            let port = |k: &str| r.get(k).and_then(Value::as_u64).map(|p| p as u16);
            let (from, to) = (port("dest_port_from"), port("dest_port_to"));
            if let (Some(f), Some(t)) = (from, to) {
                if t < f {
                    return invalid_argument(
                        "dest_port_to",
                        "dest_port_to must not be below dest_port_from",
                    );
                }
            }
            if (protocol == "ICMP" || protocol == "ANY") && from.is_some() {
                return invalid_argument("dest_port_from", "a port range needs TCP or UDP");
            }
            let rid = self.new_id("4b");
            out.push(Rule {
                id: rid,
                action,
                protocol,
                direction,
                ip_range,
                dest_port_from: from,
                dest_port_to: to,
                position: r
                    .get("position")
                    .and_then(Value::as_u64)
                    .map(|p| p as u32)
                    .unwrap_or(n as u32 + 1),
                editable: true,
            });
        }
        let g = self.security_groups.get_mut(id).unwrap();
        g.rules.retain(|r| !r.editable);
        g.rules.extend(out);
        let zone = g.zone.clone();
        let mut rs = g.rules.clone();
        rs.sort_by_key(|r| r.position);
        self.tell_firewall(id);
        ok(
            200,
            json!({ "rules": rs.iter().map(|r| Self::rule_json(r, &zone)).collect::<Vec<_>>() }),
        )
    }

    /// The mock's own doors (`/mock/...`), never the provider's: a test's or an operator's hand.
    fn mock_door(
        &mut self,
        method: &str,
        seg: &[&str],
        query: &[(String, String)],
        body: &[u8],
    ) -> Answer {
        let ct = query
            .iter()
            .find(|(k, _)| k == "commercial_type")
            .map(|(_, v)| v.as_str());
        match (method, seg) {
            ("GET", ["state"]) => ok(200, self.state_json()),
            ("POST", ["arm", name]) => match Fault::parse(name, ct) {
                Some(f) => {
                    self.faults.push(f);
                    ok(200, json!({ "armed": name }))
                }
                None => invalid_argument("fault", "out-of-stock"),
            },
            ("POST", ["disarm", name]) => {
                self.faults.retain(|f| f.name() != *name);
                ok(200, json!({ "disarmed": name }))
            }
            ("POST", ["revoke", token]) => {
                self.revoked.push(token.to_string());
                ok(200, json!({ "revoked": true }))
            }
            ("POST", ["advance", ms]) => {
                self.clock.advance_ms(ms.parse().unwrap_or(0));
                self.settle();
                ok(200, json!({ "now_ms": self.now() }))
            }
            ("PUT", ["objects", bucket, key @ ..]) => {
                self.objects
                    .insert((bucket.to_string(), key.join("/")), body.to_vec());
                Answer::Empty { status: 204 }
            }
            _ => not_found("mock_route", &seg.join("/")),
        }
    }

    /// Everything the account holds, as one JSON document (`GET /mock/state`).
    pub fn state_json(&self) -> Value {
        json!({
            "now_ms": self.now(),
            "servers": self.servers.values().map(|s| self.server_json(s)).collect::<Vec<_>>(),
            "volumes": self.volumes.values().map(|v| self.volume_json(v)).collect::<Vec<_>>(),
            "snapshots": self.snapshots.values().filter(|s| s.project != "marketplace").map(|s| self.snapshot_json(s)).collect::<Vec<_>>(),
            "images": self.images.values().filter(|i| !i.public).map(|i| self.image_json(i)).collect::<Vec<_>>(),
            "security_groups": self.security_groups.values().map(|g| self.sg_json(g)).collect::<Vec<_>>(),
            "ips": self.ips.values().map(|i| self.ip_json(i)).collect::<Vec<_>>(),
            "faults": self.faults.iter().map(|f| f.name()).collect::<Vec<_>>(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_account_epoch_reads_as_a_date() {
        assert_eq!(now_iso(0), "2026-10-24T00:00:00.000000+00:00");
        assert_eq!(
            now_iso(8 * 86_400_000 + 3_723_004),
            "2026-11-01T01:02:03.004000+00:00"
        );
    }

    #[test]
    fn a_cidr_holds_what_it_should() {
        assert!(cidr_holds("0.0.0.0/0", "127.0.0.1"));
        assert!(cidr_holds("127.0.0.1/32", "127.0.0.1"));
        assert!(!cidr_holds("10.0.0.0/8", "127.0.0.1"));
        assert!(cidr_holds("127.0.0.0/8", "127.9.9.9"));
    }
}