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

nordisk/gunnar

deploy/upcloud/src/api.rs

//! UpCloud API 1.3 for the re-image: the handful of typed calls this procedure
//! makes, and the six fields it reads back — over [`upcloud_api::UpCloudApi`].
//!
//! # Why this file no longer builds a URL
//!
//! Until 2026-09-21 this module carried its own `DEFAULT_BASE =
//! "https://api.upcloud.com/1.3"`, its own URL builder and its own `Http`
//! transport, and `main.rs` built the client as `Api::new(DEFAULT_BASE)` — with
//! NO override of any kind. So `cargo xtask mock reimage --apply` in
//! `private-gunnar-ops`, which chains into `reinstall-appliance` and so into
//! this binary, would have re-imaged the LIVE appliance with the estate's token
//! while the run's banner said FAKE (lane T14's finding). A fake that the code
//! under test never reaches proves nothing about that code.
//!
//! Every request now goes through the constellation's ONE UpCloud surface,
//! `upcloud-api` (edda): one trait, one wire implementation that builds the
//! request for the account and for a `mock-upcloud` by the SAME code, and an
//! [`upcloud_api::Endpoint`] decided once, in `main`, from `--mock-api` — see
//! `main.rs`. This file holds only what is the re-image's own: the seed's size
//! and tier, the reply fields it decides from, and the refusal wording.
//!
//! # Why `serde_json::Value` and not derived structs
//!
//! UpCloud stringifies numbers inconsistently and this procedure reads six
//! fields in total. [`field_str`] and friends read what is asked for and name
//! what was missing when it is not there.

use serde_json::Value;
use upcloud_api::{NewStorage, Reply, UpCloudApi};

pub use upcloud_api::{BootOrder, DeviceKind};

/// The zone the appliance lives in.
pub const DEFAULT_ZONE: &str = "se-sto1";

/// The storage tier the installer media are created on. `maxiops` is what the
/// account's existing media use.
pub const TIER: &str = "maxiops";

/// The size, in GiB, of the seed storage created in step 1.
///
/// **One**, and that is the proven value rather than a rounded-up ISO size: the
/// direct-upload import grows the storage to fit the image it receives, so a
/// seed only has to be legal, not large.
pub const IMPORT_SEED_SIZE_GIB: u64 = 1;

/// `soft` asks the guest to shut down; `hard` pulls the plug.
///
/// The re-image uses **both, at different moments, on purpose**: soft to take
/// the serving appliance down politely before the media go on, hard afterwards
/// because by then the guest is an installer that has finished and has nothing
/// left to flush — and because a wedged installer answers no ACPI event at all.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StopKind {
    Soft,
    Hard,
}

impl StopKind {
    pub fn as_str(self) -> &'static str {
        match self {
            StopKind::Soft => "soft",
            StopKind::Hard => "hard",
        }
    }
}

// ── reading replies ──────────────────────────────────────────────────────────

/// A string field, or an error that says which field was missing and where.
pub fn field_str(v: &Value, path: &[&str]) -> Result<String, String> {
    let mut cur = v;
    for key in path {
        cur = cur.get(key).ok_or_else(|| {
            format!(
                "the reply has no `{}` (looked for {:?})",
                path.join("."),
                key
            )
        })?;
    }
    match cur {
        Value::String(s) => Ok(s.clone()),
        Value::Number(n) => Ok(n.to_string()),
        other => Err(format!("`{}` is {other}, not a string", path.join("."))),
    }
}

pub fn field_u64(v: &Value, path: &[&str]) -> Result<u64, String> {
    let s = field_str(v, path)?;
    s.trim()
        .parse()
        .map_err(|e| format!("`{}` = {s:?} is not a number: {e}", path.join(".")))
}

/// One `storage_device` off `GET /server/{uuid}`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Device {
    /// `ide:0:0`, `virtio:5`, … — what a detach names.
    pub address: String,
    /// The storage uuid. **Empty for an IDE cdrom device whose medium was
    /// ejected** — the device stays on the server with nothing in it.
    pub storage: String,
    pub title: String,
    /// `disk` or `cdrom`.
    pub kind: String,
}

impl Device {
    /// A cdrom device with a medium in it — the one thing that can boot an
    /// installer. An ejected one is `cdrom` with an empty `storage`.
    pub fn is_loaded_cdrom(&self) -> bool {
        self.kind == "cdrom" && !self.storage.is_empty()
    }
}

/// **What one `GET /server/{uuid}` says**, the four facts this procedure
/// decides from. One read, so a decision is never taken on two different
/// moments of the same box.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerView {
    pub title: String,
    /// `started`, `stopped`, `maintenance`, `error`.
    pub state: String,
    /// `cdrom` or `disk` — what the API holds; a guest reboot does NOT re-read
    /// it (MEASURED 2026-09-14), only a hypervisor start does.
    pub boot_order: String,
    pub devices: Vec<Device>,
}

impl ServerView {
    pub fn is_started(&self) -> bool {
        self.state == "started"
    }
    pub fn is_stopped(&self) -> bool {
        self.state == "stopped"
    }
    pub fn boots_cdrom(&self) -> bool {
        self.boot_order == "cdrom"
    }
}

pub fn parse_server_view(v: &Value) -> Result<ServerView, String> {
    let rows = v
        .get("server")
        .and_then(|s| s.get("storage_devices"))
        .and_then(|s| s.get("storage_device"))
        .and_then(|s| s.as_array())
        .cloned()
        .unwrap_or_default();
    Ok(ServerView {
        title: field_str(v, &["server", "title"])?,
        state: field_str(v, &["server", "state"])?,
        boot_order: v
            .get("server")
            .and_then(|s| s.get("boot_order"))
            .and_then(|b| b.as_str())
            .unwrap_or("")
            .to_string(),
        devices: rows
            .iter()
            .map(|r| Device {
                address: str_or_empty(r, "address"),
                storage: str_or_empty(r, "storage"),
                title: str_or_empty(r, "storage_title"),
                kind: str_or_empty(r, "type"),
            })
            .collect(),
    })
}

/// The typed calls, over whichever [`UpCloudApi`] the run holds — the account or
/// a `mock-upcloud`, chosen in `main` and nowhere else.
pub struct Client<A: UpCloudApi> {
    pub api: A,
}

impl<A: UpCloudApi> Client<A> {
    pub fn new(api: A) -> Client<A> {
        Client { api }
    }

    /// Judge one answer: a non-2xx is refused **by name** — status plus the
    /// API's own `error_code`, because `SERVER_STATE_ILLEGAL` tells an operator
    /// what to do and `409` does not. An empty body is `Null`; a non-empty body
    /// that is not JSON is quoted rather than summarised.
    pub fn must(what: &str, r: Result<Reply, String>) -> Result<Value, String> {
        let r = r?;
        if !r.ok() {
            return Err(r.describe_failure(what));
        }
        if r.text.trim().is_empty() {
            return Ok(Value::Null);
        }
        if r.body.is_null() {
            return Err(format!(
                "{what}: the reply is not JSON: {}",
                truncate(&r.text, 400)
            ));
        }
        Ok(r.body)
    }

    // ── the calls, in the order the procedure makes them ────────────────────

    pub fn create_storage(&self, title: &str, zone: &str) -> Result<String, String> {
        let new = NewStorage {
            title,
            zone,
            size_gib: IMPORT_SEED_SIZE_GIB,
            tier: TIER,
            labels: &[],
        };
        let v = Self::must("POST /storage", self.api.create_storage(&new))?;
        field_str(&v, &["storage", "uuid"])
    }

    /// Step 2 — ask for a direct upload and get the URL to `PUT` at.
    pub fn direct_upload_url(&self, storage_uuid: &str) -> Result<String, String> {
        let v = Self::must(
            "POST /storage/{uuid}/import",
            self.api.import_direct_upload(storage_uuid),
        )?;
        field_str(&v, &["storage_import", "direct_upload_url"])
    }

    /// `POST /storage/{uuid}/clone` — UpCloud cuts a new storage from an
    /// existing one, server-side.
    ///
    /// MEASURED 2026-09-20: a clone reaches `online` in **47 s**, an import of
    /// the same bytes in **114 s** — but a clone cannot begin until its source
    /// is `online`, so import-then-clone (161 s) loses to the two INDEPENDENT
    /// imports the procedure runs together (~115 s). The re-image does not use
    /// it; `tests/clone_probe.rs` is what says so.
    pub fn clone_storage(&self, uuid: &str, title: &str, zone: &str) -> Result<String, String> {
        let v = Self::must(
            "POST /storage/{uuid}/clone",
            self.api.clone_storage(uuid, title, zone, TIER),
        )?;
        field_str(&v, &["storage", "uuid"])
    }

    pub fn storage_state(&self, uuid: &str) -> Result<String, String> {
        let v = Self::must("GET /storage/{uuid}", self.api.storage(uuid))?;
        field_str(&v, &["storage", "state"])
    }

    /// The account's private storages as `(uuid, title, state)`.
    pub fn private_storages(&self) -> Result<Vec<(String, String, String)>, String> {
        let v = Self::must("GET /storage/private", self.api.storages_private())?;
        let rows = v
            .get("storages")
            .and_then(|s| s.get("storage"))
            .and_then(|s| s.as_array())
            .cloned()
            .unwrap_or_default();
        Ok(rows
            .iter()
            .filter_map(|r| {
                Some((
                    r.get("uuid")?.as_str()?.to_string(),
                    r.get("title")
                        .and_then(|t| t.as_str())
                        .unwrap_or("")
                        .to_string(),
                    r.get("state")
                        .and_then(|t| t.as_str())
                        .unwrap_or("")
                        .to_string(),
                ))
            })
            .collect())
    }

    pub fn server_title(&self, uuid: &str) -> Result<String, String> {
        let v = Self::must("GET /server/{uuid}", self.api.server(uuid))?;
        field_str(&v, &["server", "title"])
    }

    pub fn server_state(&self, uuid: &str) -> Result<String, String> {
        let v = Self::must("GET /server/{uuid}", self.api.server(uuid))?;
        field_str(&v, &["server", "state"])
    }

    /// Every attached device, with the **address** a detach has to name.
    pub fn server_devices(&self, uuid: &str) -> Result<Vec<Device>, String> {
        Ok(self.server_view(uuid)?.devices)
    }

    /// Title, state, boot order and devices — in ONE read. See [`ServerView`].
    pub fn server_view(&self, uuid: &str) -> Result<ServerView, String> {
        let v = Self::must("GET /server/{uuid}", self.api.server(uuid))?;
        parse_server_view(&v)
    }

    /// Eject the medium out of the cdrom device. Legal on a STARTED server
    /// (MEASURED 2026-09-14); the device itself stays until a detach on a
    /// stopped box. **The never-loop primitive.**
    pub fn eject_cdrom(&self, uuid: &str) -> Result<(), String> {
        Self::must(
            "POST /server/{uuid}/cdrom/eject",
            self.api.eject_cdrom(uuid),
        )
        .map(|_| ())
    }

    pub fn stop_server(&self, uuid: &str, kind: StopKind, timeout_s: u64) -> Result<(), String> {
        let stop = match kind {
            StopKind::Soft => upcloud_api::Stop::Soft {
                timeout_s: timeout_s.min(u32::MAX as u64) as u32,
            },
            StopKind::Hard => upcloud_api::Stop::Hard,
        };
        Self::must("POST /server/{uuid}/stop", self.api.stop_server(uuid, stop)).map(|_| ())
    }

    pub fn start_server(&self, uuid: &str) -> Result<(), String> {
        Self::must("POST /server/{uuid}/start", self.api.start_server(uuid)).map(|_| ())
    }

    pub fn attach(&self, server: &str, kind: DeviceKind, storage: &str) -> Result<(), String> {
        Self::must(
            "POST /server/{uuid}/storage/attach",
            self.api.attach_storage(server, kind, storage, None),
        )
        .map(|_| ())
    }

    /// Take a device off. **Only on a stopped server**, for both device kinds
    /// this procedure attaches — MEASURED 2026-09-14: the IDE cdrom answers
    /// `409 IDE_HOTPLUG_UNSUPPORTED` while started, the virtio payload `511
    /// HOTPLUG_FAILED`. The cdrom's MEDIUM can still leave a started box:
    /// [`Client::eject_cdrom`].
    pub fn detach(&self, server: &str, address: &str) -> Result<(), String> {
        Self::must(
            "POST /server/{uuid}/storage/detach",
            self.api.detach_storage(server, address),
        )
        .map(|_| ())
    }

    /// Give back the seed storage a medium was uploaded into.
    ///
    /// MEASURED 2026-09-13: detaching is not giving back — every re-image left
    /// two 1 GiB storages behind, `online`, attached to nothing, and billed. The
    /// storage must be detached first: UpCloud refuses to delete one a server
    /// still carries, and that refusal is the safety relied on.
    pub fn delete_storage(&self, uuid: &str) -> Result<(), String> {
        Self::must(
            "DELETE /storage/{uuid}",
            self.api.delete_storage(uuid, upcloud_api::Backups::Unsaid),
        )
        .map(|_| ())
    }

    pub fn set_boot_order(&self, server: &str, order: BootOrder) -> Result<(), String> {
        Self::must("PUT /server/{uuid}", self.api.set_boot_order(server, order)).map(|_| ())
    }
}

fn truncate(s: &str, n: usize) -> String {
    if s.len() <= n {
        s.to_string()
    } else {
        let mut i = n;
        while !s.is_char_boundary(i) {
            i -= 1;
        }
        format!("{}… ({} bytes)", &s[..i], s.len())
    }
}

fn str_or_empty(v: &Value, key: &str) -> String {
    match v.get(key) {
        Some(Value::String(s)) => s.clone(),
        Some(Value::Number(n)) => n.to_string(),
        _ => String::new(),
    }
}

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

    #[test]
    fn a_failure_names_the_api_error_code() {
        let r = Reply {
            status: 409,
            body: json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
            text: "x".into(),
        };
        let msg = Client::<Box<dyn UpCloudApi>>::must("POST /server/{uuid}/storage/attach", Ok(r))
            .unwrap_err();
        assert!(msg.contains("409"), "{msg}");
        assert!(msg.contains("SERVER_STATE_ILLEGAL"), "{msg}");
        assert!(msg.contains("server state is started"), "{msg}");
    }

    /// Red-first, seen with a bare `serde_json::from_str(..)?`: `expected value
    /// at line 1 column 1` — true, and it says nothing about the proxy page.
    #[test]
    fn a_non_json_reply_is_quoted_rather_than_summarised() {
        let r = Reply {
            status: 200,
            body: Value::Null,
            text: "<html>bad gateway</html>".into(),
        };
        let err = Client::<Box<dyn UpCloudApi>>::must("GET /x", Ok(r)).unwrap_err();
        assert!(err.contains("bad gateway"), "{err}");
    }

    /// The view reads the four facts, and an ejected cdrom device — `type`
    /// cdrom with no `storage` — is NOT a loaded one.
    #[test]
    fn a_server_view_reads_state_boot_order_and_an_ejected_cdrom_as_unloaded() {
        let v = json!({"server": {
            "title": "gunnar-appliance", "state": "started", "boot_order": "disk",
            "storage_devices": {"storage_device": [
                {"address": "virtio:0", "storage": "boot", "storage_title": "gunnar-boot", "type": "disk"},
                {"address": "ide:0:0", "storage": "", "storage_title": "", "type": "cdrom"},
                {"address": "virtio:5", "storage": "vd", "storage_title": "gunnar-installer-open-3a413c60-virtio", "type": "disk"},
            ]}
        }});
        let view = parse_server_view(&v).unwrap();
        assert!(view.is_started() && !view.boots_cdrom());
        assert_eq!(view.devices.len(), 3);
        assert!(
            !view.devices[1].is_loaded_cdrom(),
            "ejected: nothing to boot"
        );
        let loaded = Device {
            storage: "cd".into(),
            ..view.devices[1].clone()
        };
        assert!(loaded.is_loaded_cdrom());
    }

    #[test]
    fn a_missing_field_says_which_one() {
        let v = json!({"storage": {"uuid": "x"}});
        let err = field_str(&v, &["storage", "state"]).unwrap_err();
        assert!(err.contains("storage.state"), "{err}");
    }

    #[test]
    fn the_seed_is_one_gib_and_maxiops() {
        assert_eq!(IMPORT_SEED_SIZE_GIB, 1);
        assert_eq!(TIER, "maxiops");
    }
}