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