crates/upcloud-api/src/lib.rs
//! **`upcloud-api` — the UpCloud API 1.3 surface as ONE trait, ONE wire, ONE
//! decision about which cloud answers.**
//!
//! # Why this crate exists
//!
//! On 2026-09-21 the nordisk estates had **five** UpCloud clients:
//! `private-gunnar-ops`'s xtask (two of them, until lane T3 made them one),
//! `gunnar/deploy/upcloud` (the re-image procedure, with its OWN scripted fake),
//! `gunnar-loadbench`, `private-holger-ops`'s xtask, and `monetize-cloud-impl`.
//! Each one spelled `https://api.upcloud.com/1.3` for itself. Each one decided
//! for itself whether a run was aimed at a fake. Four bugs that day were the
//! same bug: **a mock run reached the account because one client in the chain
//! decided differently from the rest** — the worst of them was
//! `gunnar-upcloud`'s `Api::new(DEFAULT_BASE)`, so `cargo xtask mock reimage
//! --apply` would have re-imaged the LIVE appliance with the estate's token
//! while the banner said FAKE.
//!
//! A test against the fake is only worth something if the code under test is
//! the SAME code that runs against the account. So:
//!
//! * [`UpCloudApi`] is the only surface. **No method takes a path, a query
//! string or a base URL**, so no caller can name an endpoint.
//! * There is **one** wire implementation. It builds every request for the
//! account and for a mock the same way, from the same code — only the base
//! differs. A run against `mock-upcloud` therefore exercises the exact URL,
//! body and header construction a run against the account does.
//! * The base comes from an [`Endpoint`], and an `Endpoint` is decided ONCE, at
//! the edge of the program, from what the operator TYPED
//! ([`Endpoint::account`] / [`Endpoint::mock`]). A mock endpoint is
//! loopback-only by construction; the account endpoint refuses to exist while
//! the shell carries a mock variable ([`MOCK_ENVS`]).
//! * The in-process fake — `FakeUpCloud` over `mock-upcloud`'s `Estate` — lives
//! beside that state machine in the `mock-upcloud` crate, so a fault armed
//! once applies to terraform (the HTTP face) and to Rust callers (the trait
//! face) alike: one world, two faces.
//! * [`over`] is where a typed call becomes ONE method, path and body: the wire
//! and every fake are an [`Exchange`] under [`Over`], so a fake answers the
//! exact request the account would have been sent.
//! * [`guard`] is the test every consuming repository runs: nothing outside the
//! files it names may spell the provider or build an API path.
//!
//! # The method set is DERIVED, not designed
//!
//! Every method exists because a real call site in one of the ported clients
//! calls that endpoint. The doc on each names the caller. A general-purpose
//! UpCloud client is a second thing to keep true; this is not one.
use std::path::Path;
use serde_json::Value;
pub mod guard;
#[cfg(feature = "wire")]
pub mod mock_door;
#[cfg(feature = "wire")]
pub mod net;
pub mod over;
#[cfg(feature = "wire")]
mod wire;
pub use over::{Call, Exchange, Method, Over};
#[cfg(feature = "wire")]
pub use wire::{connect, Credential, Options};
/// **The account.** Private: the one spelling of the provider in this crate,
/// used by [`wire`] as the base of [`Endpoint::Account`]. A consumer can print
/// it through [`ACCOUNT_BASE_FOR_DISPLAY`] and can build nothing from it.
const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
/// The account's base, for a REPORT and a display comparison — never to build a
/// request from; nothing can be called on a `&'static str`.
pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
/// The account's ROOT — the base without the version — for a config that spells
/// `/1.3` in its paths or not at all (monetize-cloud-impl's `api_base`). Display
/// and comparison only, like [`ACCOUNT_BASE_FOR_DISPLAY`].
pub const ACCOUNT_ROOT_FOR_DISPLAY: &str = "https://api.upcloud.com";
/// The variable a mock-selected run reads its loopback base from. On an
/// account-selected run its PRESENCE is a refusal ([`Endpoint::account`]).
pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
/// The terraform provider's own debug knob. Not read here — named so an
/// account run can refuse when the shell carries it.
pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
/// Every variable whose presence means "this shell is aimed at a fake".
pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
// ── the endpoint: decided once, at the edge ─────────────────────────────────
/// **Which UpCloud answers.** Built only by [`Endpoint::account`] (refuses in a
/// mock-carrying shell) or [`Endpoint::mock`] (refuses anything off loopback).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Endpoint {
/// `https://api.upcloud.com/1.3`. Real machines, a real bill.
Account,
/// A `mock-upcloud` on loopback. The base always ends `/1.3`.
Mock(MockBase),
}
/// A loopback base ending `/1.3`. The field is private, so the only way to hold
/// one is [`Endpoint::mock`] — which is what makes "a fake pointed at a real
/// host" unrepresentable rather than merely refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MockBase(String);
impl MockBase {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Endpoint {
/// The account — unless the shell carries a mock variable, in which case the
/// run does not start. An operator who exported [`MOCK_BASE_ENV`] for a fake
/// run and then typed a real verb in the same shell used to get writes to the
/// account from some clients and to the fake from others.
pub fn account() -> Result<Endpoint, String> {
Endpoint::account_given(|k| std::env::var(k).ok())
}
/// [`Endpoint::account`] over an injected environment, for tests.
pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
if set.is_empty() {
return Ok(Endpoint::Account);
}
Err(format!(
"REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
<loopback base>`), or unset {s} to use the account.",
s = set.join(" and ")
))
}
/// A `mock-upcloud` at `base`. **Loopback or nothing**: a fake that can be
/// pointed off this machine is a fake that can be pointed at something real.
/// `http://127.0.0.1:8099` and `http://127.0.0.1:8099/1.3` are the same door.
pub fn mock(base: &str) -> Result<Endpoint, String> {
let mut b = base.trim().trim_end_matches('/').to_string();
if !is_loopback(&b) {
return Err(format!(
"REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
machine can be one."
));
}
if !b.ends_with("/1.3") {
b.push_str("/1.3");
}
Ok(Endpoint::Mock(MockBase(b)))
}
/// **A configured base, read as a choice.** For a caller whose selection is
/// a CONFIG value rather than a verb (monetize-cloud-impl's `api_base`): the
/// account's own base — with or without `/1.3`, with or without a trailing
/// slash — is THE ACCOUNT; a loopback base is a mock; anything else is
/// refused by name, because a client that can be pointed at an arbitrary
/// host can be pointed at something real that is not the account.
pub fn for_base(base: &str) -> Result<Endpoint, String> {
let b = base.trim().trim_end_matches('/');
let root = b.strip_suffix("/1.3").unwrap_or(b);
if root == ACCOUNT_ROOT_FOR_DISPLAY {
return Ok(Endpoint::Account);
}
Endpoint::mock(root).map_err(|e| format!("{base:?} is neither the account nor a loopback mock — {e}"))
}
/// A mock from [`MOCK_BASE_ENV`], for a run that has ALREADY been selected
/// as a mock run by what the operator typed. Unset is a refusal by name.
pub fn mock_from_env() -> Result<Endpoint, String> {
let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
format!(
"REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
{MOCK_BASE_ENV}=http://127.0.0.1:8099."
)
})?;
Endpoint::mock(&raw)
}
pub fn is_account(&self) -> bool {
matches!(self, Endpoint::Account)
}
/// Where `/…` goes — for a report. Never build a request from it; hold an
/// implementation from [`connect`] instead.
pub fn base_for_display(&self) -> &str {
match self {
Endpoint::Account => ACCOUNT_BASE,
Endpoint::Mock(b) => b.as_str(),
}
}
/// The one line that goes at the top of anything a person might read as a
/// measurement of the account.
pub fn banner(&self) -> String {
match self {
Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
Endpoint::Mock(b) => format!(
"provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
nothing measured here says anything about the account.",
b.as_str()
),
}
}
/// The argv a parent hands a CHILD process so the child makes the same
/// choice: `["--mock-api", base]` for a mock, nothing for the account. A
/// child that receives nothing and finds a mock variable in its environment
/// refuses ([`Endpoint::account`]) — which is how the choice cannot be lost
/// crossing a process boundary.
pub fn child_args(&self) -> Vec<String> {
match self {
Endpoint::Account => Vec::new(),
Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
}
}
}
/// The flag [`Endpoint::child_args`] emits and [`Endpoint::from_flag`] reads.
pub const MOCK_API_FLAG: &str = "--mock-api";
impl Endpoint {
/// A child's side of [`Endpoint::child_args`]: `Some(base)` from
/// `--mock-api` is the mock (loopback-checked); `None` is the account (and
/// refuses in a mock-carrying shell). There is no third answer.
pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
match mock_api {
Some(b) => Endpoint::mock(b),
None => Endpoint::account(),
}
}
}
/// `http://127.0.0.1:PORT` or `http://localhost:PORT`, and nothing else. No
/// IPv6 — these estates have none by law, and `[::1]` is refused with the rest.
pub fn is_loopback(url: &str) -> bool {
let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
host == "127.0.0.1" || host == "localhost"
}
// ── the answer ──────────────────────────────────────────────────────────────
/// **One answer from the API, with NO judgement about its status.** The callers
/// decide what a code means, because they genuinely disagree: a `404` on
/// `GET /server/{uuid}` is a FACT for a sweep and a REFUSAL for a resize. A
/// transport failure is the `Err` and is never a status.
#[derive(Debug, Clone)]
pub struct Reply {
pub status: u16,
/// The body as JSON; `Null` when it was empty or not JSON
/// ([`Reply::parse_error`] tells which; [`Reply::json`] is the checked read).
pub body: Value,
/// The body exactly as it arrived, so a non-JSON error page from a proxy
/// reaches the operator intact instead of becoming a parse error that names
/// nothing.
pub text: String,
}
impl Reply {
/// The ONE way a reply is read off the wire: `body` is the JSON, or `Null`
/// when the text was empty or not JSON — and [`Reply::parse_error`] then
/// tells the two apart, so a non-JSON page is never mistaken for a missing
/// field.
pub fn from_text(status: u16, text: String) -> Reply {
let body = if text.trim().is_empty() { Value::Null } else { serde_json::from_str(&text).unwrap_or(Value::Null) };
Reply { status, body, text }
}
/// Why the body is not JSON, or `None` when it is (or is empty).
pub fn parse_error(&self) -> Option<String> {
if self.text.trim().is_empty() {
return None;
}
match serde_json::from_str::<Value>(&self.text) {
Ok(_) => None,
Err(e) => {
let start: String = self.text.trim().chars().take(120).collect();
Some(format!("the {} reply is not JSON ({e}): {start}", self.status))
}
}
}
/// The body, checked: the JSON, or the parse error naming the status and the
/// start of the text. A reader of fields goes through this instead of
/// `body`, so "field missing" is only ever said about JSON.
pub fn json(&self) -> Result<&Value, String> {
match self.parse_error() {
Some(e) => Err(e),
None => Ok(&self.body),
}
}
pub fn ok(&self) -> bool {
(200..300).contains(&self.status)
}
/// `error.error_code`, or `""`.
pub fn error_code(&self) -> &str {
self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
}
/// `error.error_message`, or the raw text.
pub fn error_message(&self) -> &str {
self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
}
/// `409 SERVER_STATE_ILLEGAL — server state is started`: status, code and
/// message, because each answers a different question.
pub fn describe_failure(&self, what: &str) -> String {
let code = self.error_code();
if code.is_empty() {
format!("{what} answered {} — {}", self.status, self.error_message())
} else {
format!("{what} answered {} {code} — {}", self.status, self.error_message())
}
}
}
// ── the words a caller uses instead of UpCloud's spelling ───────────────────
/// How a server is stopped. `Soft` carries a grace in seconds; `Hard` pulls the
/// plug, and is the word a delete needs first.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stop {
Soft { timeout_s: u32 },
Hard,
}
/// Whether a server's delete takes its storages with it, and what becomes of
/// their backups. UpCloud spells this as a query string; no caller spells it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WithStorages {
/// `?storages=1&backups=delete` — the server and everything under it.
/// Caller: private-gunnar-ops orphan sweep.
AndTheirBackups,
/// `?storages=1&backups=keep` — the server and its volumes; the backups
/// stay. Caller: monetize-cloud-impl `destroy`.
AndKeepBackups,
/// The server alone; its volumes are left behind (and become orphans).
LeaveThem,
}
/// What a storage's delete does with the storage's backups.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backups {
/// No `backups=` is sent; the account's default applies. Callers:
/// gunnar-upcloud (seed media), private-gunnar-ops.
Unsaid,
/// `?backups=keep`. Caller: monetize-cloud-impl `destroy`.
Keep,
/// `?backups=delete`.
Delete,
}
/// How a device rides on a server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
/// What the firmware boots. UpCloud is SeaBIOS-only.
Cdrom,
/// A virtio disk.
Disk,
}
impl DeviceKind {
pub fn as_str(self) -> &'static str {
match self {
DeviceKind::Cdrom => "cdrom",
DeviceKind::Disk => "disk",
}
}
}
/// What the hypervisor boots first on its next START (a guest reboot does not
/// re-read it — MEASURED 2026-09-14).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootOrder {
Cdrom,
Disk,
}
impl BootOrder {
pub fn as_str(self) -> &'static str {
match self {
BootOrder::Cdrom => "cdrom",
BootOrder::Disk => "disk",
}
}
}
/// The VNC console. `Vnc` re-provisions host AND port; the reply carries them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Console<'a> {
Off,
Vnc { password: &'a str },
}
/// How a storage size goes out in `PUT /storage/{uuid}`. See
/// [`UpCloudApi::modify_storage_size_as`] for why this is the caller's word.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SizeSpelling {
/// `{"storage": {"size": "64"}}`
String,
/// `{"storage": {"size": 64}}`
Number,
}
/// One label, `key=value`. UpCloud filters lists by it (`?label=key%3Dvalue`)
/// and carries it on storages and servers.
pub type Label<'a> = (&'a str, &'a str);
/// **One inbound firewall rule** as UpCloud 1.3 spells it (`firewall_rule`): every field a
/// string, an EMPTY field not sent at all (the provider's default: any address, any port).
/// The set is written whole ([`UpCloudApi::set_firewall_rules`]); its order is the order the
/// provider matches in, so a catch-all `drop` goes last.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FirewallRule {
/// `in` | `out`.
pub direction: String,
/// `accept` | `drop`.
pub action: String,
/// `IPv4` | `IPv6`; empty only on a catch-all rule.
pub family: String,
/// `tcp` | `udp` | `icmp`; empty is every protocol.
pub protocol: String,
pub source_address_start: String,
pub source_address_end: String,
pub source_port_start: String,
pub source_port_end: String,
pub destination_address_start: String,
pub destination_address_end: String,
pub destination_port_start: String,
pub destination_port_end: String,
pub icmp_type: String,
pub comment: String,
}
/// A new storage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewStorage<'a> {
pub title: &'a str,
pub zone: &'a str,
pub size_gib: u64,
/// `maxiops`, `standard`, …
pub tier: &'a str,
/// Empty sends no `labels` key at all.
pub labels: &'a [Label<'a>],
}
// ── the surface ─────────────────────────────────────────────────────────────
/// **The UpCloud API 1.3 surface the nordisk estates actually use.** Typed in,
/// typed out. Not one method takes a path, a query string or a base URL.
pub trait UpCloudApi {
/// A word for what answered, for a report that must never read as a
/// measurement of the account when it was not one.
fn describe(&self) -> String;
/// Is this the real account? A question about the implementation, never a
/// handle to one.
fn is_the_account(&self) -> bool;
// ── reads ───────────────────────────────────────────────────────────────
/// `GET /account`. Callers: private-gunnar-ops, gunnar-loadbench,
/// private-holger-ops, monetize-cloud-impl (credential probe, grow judge).
fn account(&self) -> Result<Reply, String>;
/// `GET /price`. Caller: monetize-cloud-impl `price`.
fn price(&self) -> Result<Reply, String>;
/// `GET /server`. Callers: private-gunnar-ops / private-holger-ops orphan
/// sweeps, gunnar-loadbench.
fn servers(&self) -> Result<Reply, String>;
/// `GET /server?label=…` — every label must match. Caller:
/// monetize-cloud-impl (the label search, and the inventory with none).
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
/// ONE [`Page`] of [`UpCloudApi::servers_labelled`]: `GET /server?label=…&limit=…&offset=…`.
/// Caller: monetize-cloud-impl, which reads pages until a short one.
fn servers_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String>;
/// `GET /server/{uuid}`. Callers: everywhere.
fn server(&self, uuid: &str) -> Result<Reply, String>;
/// `GET /server/{uuid}/firewall_rule`. Caller: private-gunnar-ops estate.
fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
/// `GET /storage/private`. Callers: the orphan sweeps, gunnar-upcloud
/// (adopting media already on the account).
fn storages_private(&self) -> Result<Reply, String>;
/// `GET /storage?label=…` — every label must match; no label lists
/// everything the account can see. Caller: monetize-cloud-impl.
fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
/// ONE [`Page`] of [`UpCloudApi::storages_labelled`]: `GET /storage?label=…&limit=…&offset=…`.
/// Caller: monetize-cloud-impl, which reads pages until a short one.
fn storages_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String>;
/// `GET /storage/{uuid}`. Callers: grow, gunnar-upcloud's online wait,
/// monetize-cloud-impl.
fn storage(&self, uuid: &str) -> Result<Reply, String>;
/// `GET /zone`. Caller: gunnar-loadbench `--from-upcloud`.
fn zones(&self) -> Result<Reply, String>;
/// `GET /plan`. Caller: gunnar-loadbench `--from-upcloud`.
fn plans(&self) -> Result<Reply, String>;
// ── server writes ───────────────────────────────────────────────────────
/// `POST /server` with the caller's server document (`{"server": {…}}`).
/// The DOCUMENT is the caller's — a plan, a template clone, labels, SSH
/// keys; the PATH is not. Caller: monetize-cloud-impl `ensure`.
fn create_server(&self, document: &Value) -> Result<Reply, String>;
/// `POST /server/{uuid}/stop`.
fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
/// `POST /server/{uuid}/start`.
fn start_server(&self, uuid: &str) -> Result<Reply, String>;
/// `PUT /server/{uuid}` with a plan — a plan change, which ALSO mints a
/// `Resize Backup`. Callers: private-gunnar-ops grow, monetize-cloud-impl.
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
/// `PUT /server/{uuid}` with a boot order. Caller: gunnar-upcloud.
fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
/// `PUT /server/{uuid}` toggling the VNC console. Caller: gunnar-upcloud
/// `console::open` (the toggle that defeats the stale port).
fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
/// `GET /storage/{uuid}/import` — the import session (its `state`, and once the bytes
/// are in, their `sha256sum`). Provided: refuses by name where unknown. Caller:
/// monetize-cloud-impl (the http import of a release medium).
fn storage_import(&self, uuid: &str) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot read storage {uuid}'s import", self.describe()))
}
/// `PUT /server/{uuid}` with `metadata: "yes" | "no"` — the metadata service on or
/// off. A sold estate's seed rides its `user_data`; after the hand-off the shop turns
/// the service OFF so the seed is no longer served (user_data itself cannot be
/// edited). Whether UpCloud takes it on a STARTED server is NOT MEASURED; the mock
/// accepts it in any state. Provided: an implementation that does not know the call
/// refuses by name. Caller: monetize-cloud-impl (after Done).
fn set_server_metadata(&self, uuid: &str, on: bool) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot turn the metadata service {} on server {uuid}", self.describe(), if on { "on" } else { "off" }))
}
/// `PUT /server/{uuid}/firewall_rule` — the server's WHOLE inbound rule set replaced by
/// `rules`, in order (UpCloud's firewall is a set: a write replaces it). A server laid
/// `"firewall": "on"` with no rules is open or shut by the provider's default, never by
/// the estate's declaration (private-gunnar-ops terraform `upcloud_firewall_rules`).
/// Provided: an implementation that does not know the call refuses by name. Caller:
/// monetize-cloud-impl (a sold estate's lay, per role).
fn set_firewall_rules(&self, uuid: &str, rules: &[FirewallRule]) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot write server {uuid}'s firewall ({} rule(s))", self.describe(), rules.len()))
}
/// `POST /server/{uuid}/storage/attach`, optionally AT an address
/// (`virtio`, `virtio:5`). Callers: gunnar-upcloud (no address),
/// monetize-cloud-impl (always an address).
fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
/// `POST /server/{uuid}/storage/detach` — names an ADDRESS (`ide:0:0`,
/// `virtio:5`), never a storage uuid. Callers: gunnar-upcloud,
/// monetize-cloud-impl.
fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
/// `POST /server/{uuid}/cdrom/eject` — legal on a STARTED server
/// (MEASURED). Caller: gunnar-upcloud (the never-loop primitive).
fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
/// `DELETE /server/{uuid}`. **This really deletes.**
fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
// ── storage writes ──────────────────────────────────────────────────────
/// `POST /storage`. Callers: gunnar-upcloud (the seed a medium is imported
/// into), monetize-cloud-impl (a labelled volume).
fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
/// `POST /storage/{uuid}/clone`. Caller: gunnar-upcloud `clone_probe`.
fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
/// `PUT /storage/{uuid}` with `labels` — the storage's labels REPLACED by these. A
/// clone takes no labels at the clone call, so the shop labels a release clone right
/// after it: the label search (and the orphan sweep) then finds it by estate.
/// Provided: an implementation that does not know the call refuses by name. Caller:
/// monetize-cloud-impl.
fn set_storage_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot label storage {uuid} ({} label(s))", self.describe(), labels.len()))
}
/// `PUT /server/{uuid}` with `labels` — the server's WHOLE label set replaced (MAP-SEG 4,
/// 2026-10-26: the ONE box labels the kit-made machines of a site it adopts, so they join
/// its labelled listing). A server carries `{"labels": {"label": [{key, value}]}}`. Provided:
/// an implementation that does not know the call refuses by name. Caller:
/// monetize-cloud-impl (`label_adopted`).
fn set_server_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot label server {uuid} ({} label(s))", self.describe(), labels.len()))
}
/// `POST /storage/{uuid}/import` with `source: direct_upload` — the reply
/// carries the URL [`UpCloudApi::upload_direct`] PUTs to. Caller:
/// gunnar-upcloud.
fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
/// `POST /storage/{uuid}/import` with `source: http_import` — UpCloud FETCHES the
/// bytes from `url` itself (2026-10-09: the shop lays a sold estate from the release
/// media in Object Storage; no machine of ours uploads at a sale). A presigned `url` is
/// itself a credential and is never printed whole ([`redact_object_url`]). Provided:
/// an implementation that does not know the call refuses it by name rather than
/// sending something else. Caller: monetize-cloud-impl.
fn import_http(&self, uuid: &str, url: &str) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot import storage {uuid} from a URL (http_import) — {}", self.describe(), redact_object_url(url)))
}
/// `PUT <direct_upload_url>` with the file's bytes. The URL is the one
/// [`UpCloudApi::import_direct_upload`] answered, **is itself a
/// credential** (never printed whole; no bearer sent), and must belong to
/// the same cloud this implementation talks to — the account's upload host
/// for the account, loopback for a mock. Caller: gunnar-upcloud.
fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
/// `PUT /storage/{uuid}` with a new size as a STRING (`"64"`) — the volume
/// grows. Caller: private-gunnar-ops grow (its spelling since before this
/// crate; unchanged by the port).
fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
/// `PUT /storage/{uuid}` with the size spelled as the CALLER has always
/// sent it. **A port must not change a request's wire format**, and the two
/// callers disagree: private-gunnar-ops sends a string, monetize-cloud-impl
/// (the live money path) a number — and UpCloud's own SDK declares sizes
/// `int` (T7, 2026-09-21). Nobody here holds a recorded account answer that
/// either spelling is accepted where the other is, so neither is chosen for
/// the other. Caller: monetize-cloud-impl grow ([`SizeSpelling::Number`]).
///
/// The provided body serves `String` through [`UpCloudApi::modify_storage_size`]
/// and REFUSES `Number`, so an implementation that does not know the other
/// spelling cannot silently send the wrong one; [`Over`] implements both.
fn modify_storage_size_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> {
match spelling {
SizeSpelling::String => self.modify_storage_size(uuid, gb),
SizeSpelling::Number => Err(format!("{}: this implementation cannot send a storage size as a JSON number", self.describe())),
}
}
/// `POST /storage/{uuid}/resize` — the FILESYSTEM grows, and the provider
/// mints a `Resize Backup` that nothing deletes.
fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
/// `DELETE /storage/{uuid}`. **This really deletes.**
fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
/// `POST /storage/{backup}/restore` — the backup written back over its ORIGIN (the
/// origin must exist; every server holding it stopped). **This overwrites the disk.**
/// Provided: an implementation that does not know the call refuses it by name. Caller:
/// monetize-cloud-impl `restore_from_backup` (S8, a sold estate's lost front restored
/// from its daily backup, 2026-10-11).
fn restore_backup(&self, backup: &str) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot restore backup {backup}", self.describe()))
}
/// ONE [`Page`] of `GET /storage/backup?limit=…&offset=…` — the account's backups only
/// (each names its `origin`, the storage it was taken of, which may since be deleted).
/// Provided, refused by name where unknown. Caller: monetize-cloud-impl
/// `restore_from_backup` (S8, finding a lost front's newest daily backup).
fn storages_backup_page(&self, page: Page) -> Result<Reply, String> {
Err(format!("{}: this UpCloud implementation cannot list backups (page at {})", self.describe(), page.offset))
}
}
/// Forward every method through a pointer, so `&T` and `Box<T>` are
/// implementations too.
macro_rules! forward {
($($ty:tt)*) => {
impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
fn describe(&self) -> String { (**self).describe() }
fn is_the_account(&self) -> bool { (**self).is_the_account() }
fn account(&self) -> Result<Reply, String> { (**self).account() }
fn price(&self) -> Result<Reply, String> { (**self).price() }
fn servers(&self) -> Result<Reply, String> { (**self).servers() }
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
fn servers_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String> { (**self).servers_labelled_page(labels, page) }
fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
fn storages_labelled_page(&self, labels: &[Label<'_>], page: Page) -> Result<Reply, String> { (**self).storages_labelled_page(labels, page) }
fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
fn zones(&self) -> Result<Reply, String> { (**self).zones() }
fn plans(&self) -> Result<Reply, String> { (**self).plans() }
fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
fn import_http(&self, uuid: &str, url: &str) -> Result<Reply, String> { (**self).import_http(uuid, url) }
fn storage_import(&self, uuid: &str) -> Result<Reply, String> { (**self).storage_import(uuid) }
fn set_server_metadata(&self, uuid: &str, on: bool) -> Result<Reply, String> { (**self).set_server_metadata(uuid, on) }
fn set_storage_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).set_storage_labels(uuid, labels) }
fn set_server_labels(&self, uuid: &str, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).set_server_labels(uuid, labels) }
fn set_firewall_rules(&self, uuid: &str, rules: &[FirewallRule]) -> Result<Reply, String> { (**self).set_firewall_rules(uuid, rules) }
fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
fn modify_storage_size_as(&self, uuid: &str, gb: u64, spelling: SizeSpelling) -> Result<Reply, String> { (**self).modify_storage_size_as(uuid, gb, spelling) }
fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
fn restore_backup(&self, backup: &str) -> Result<Reply, String> { (**self).restore_backup(backup) }
fn storages_backup_page(&self, page: Page) -> Result<Reply, String> { (**self).storages_backup_page(page) }
}
};
}
forward!(&T);
forward!(Box<T>);
/// The query a delete sends — ONE spelling, for the wire and for any fake that
/// records what a call means.
pub fn delete_server_query(with: WithStorages) -> &'static str {
match with {
WithStorages::AndTheirBackups => "?storages=1&backups=delete",
WithStorages::AndKeepBackups => "?storages=1&backups=keep",
WithStorages::LeaveThem => "",
}
}
/// See [`delete_server_query`].
pub fn delete_storage_query(backups: Backups) -> &'static str {
match backups {
Backups::Unsaid => "",
Backups::Keep => "?backups=keep",
Backups::Delete => "?backups=delete",
}
}
/// **One page of a list** (`limit`, `offset`). UpCloud's docs say its lists are paged —
/// 25 rows when no `limit` is asked, at most 100 — so a list read once may be only its first
/// page (NOT MEASURED against the account; asking is right either way).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Page {
pub limit: u32,
pub offset: u64,
}
impl Page {
/// The most rows the docs say one page carries.
pub const MAX_LIMIT: u32 = 100;
/// The first page of [`Page::MAX_LIMIT`] rows.
pub fn first() -> Page {
Page { limit: Self::MAX_LIMIT, offset: 0 }
}
/// The page after this one.
pub fn next(self) -> Page {
Page { limit: self.limit, offset: self.offset + u64::from(self.limit) }
}
/// `query` (`""` or `?…`) with this page's `limit` and `offset` appended.
pub fn on(self, query: String) -> String {
format!("{query}{}limit={}&offset={}", if query.is_empty() { '?' } else { '&' }, self.limit, self.offset)
}
}
/// `?label=k%3Dv&label=…`, or `""` — every character outside RFC 3986's
/// unreserved set percent-encoded, so a label is never read as query syntax.
pub fn label_query(labels: &[Label<'_>]) -> String {
fn enc(s: &str, out: &mut String) {
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
}
let mut q = String::new();
for (k, v) in labels {
q.push(if q.is_empty() { '?' } else { '&' });
q.push_str("label=");
enc(&format!("{k}={v}"), &mut q);
}
q
}
// ── request bodies: ONE spelling, shared by the wire and by any fake ────────
/// The JSON each write sends. Public so an in-process fake reads the SAME
/// bodies the wire sends rather than a parallel copy of UpCloud's quirks.
pub mod body {
use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
use serde_json::{json, Value};
/// UpCloud's 1.3 API wants the timeout as a STRING. It was sent as a
/// number once and the call was accepted and IGNORED.
pub fn stop(stop: Stop) -> Value {
match stop {
Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
}
}
/// `{"storage": {"size": 64}}` — a JSON number.
pub fn storage_size_number(gb: u64) -> Value {
json!({"storage": {"size": gb}})
}
pub fn storage_size(gb: u64) -> Value {
json!({"storage": {"size": gb.to_string()}})
}
pub fn server_plan(plan: &str) -> Value {
json!({"server": {"plan": plan}})
}
pub fn boot_order(order: BootOrder) -> Value {
json!({"server": {"boot_order": order.as_str()}})
}
pub fn console(c: &Console<'_>) -> Value {
match c {
Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
Console::Vnc { password } => json!({"server": {
"remote_access_enabled": "yes",
"remote_access_type": "vnc",
"remote_access_password": password,
}}),
}
}
pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
match at {
None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
}
}
/// Detach names an **address**, never a storage uuid.
pub fn detach(address: &str) -> Value {
json!({"storage_device": {"address": address}})
}
pub fn create_storage(n: &NewStorage<'_>) -> Value {
let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
if !n.labels.is_empty() {
v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
}
v
}
/// A clone names the new title and zone; the tier rides along.
pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
json!({"storage": {"tier": tier, "title": title, "zone": zone}})
}
pub fn direct_upload() -> Value {
json!({"storage_import": {"source": "direct_upload"}})
}
/// `"yes"`/`"no"` — strings, like every boolean of this API.
pub fn server_metadata(on: bool) -> Value {
json!({"server": {"metadata": if on { "yes" } else { "no" }}})
}
/// A server's whole label set: `{"server": {"labels": {"label": [{key, value}]}}}`.
pub fn server_labels(labels: &[crate::Label<'_>]) -> Value {
json!({"server": {"labels": {"label": labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>()}}})
}
/// The whole label set, `{key, value}` objects (the create's spelling).
pub fn storage_labels(labels: &[crate::Label<'_>]) -> Value {
json!({"storage": {"labels": labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>()}})
}
/// `{"firewall_rules": {"firewall_rule": [...]}}` — the bulk spelling, each rule's
/// empty fields left out.
pub fn firewall_rules(rules: &[crate::FirewallRule]) -> Value {
let one = |r: &crate::FirewallRule| {
let mut m = serde_json::Map::new();
for (k, v) in [
("direction", &r.direction),
("action", &r.action),
("family", &r.family),
("protocol", &r.protocol),
("source_address_start", &r.source_address_start),
("source_address_end", &r.source_address_end),
("source_port_start", &r.source_port_start),
("source_port_end", &r.source_port_end),
("destination_address_start", &r.destination_address_start),
("destination_address_end", &r.destination_address_end),
("destination_port_start", &r.destination_port_start),
("destination_port_end", &r.destination_port_end),
("icmp_type", &r.icmp_type),
("comment", &r.comment),
] {
if !v.is_empty() {
m.insert(k.to_string(), Value::String(v.clone()));
}
}
Value::Object(m)
};
json!({"firewall_rules": {"firewall_rule": rules.iter().map(one).collect::<Vec<_>>()}})
}
/// UpCloud fetches `url` itself.
pub fn http_import(url: &str) -> Value {
json!({"storage_import": {"source": "http_import", "source_location": url}})
}
}
/// `https://<bucket host>/release/front/<sha>.iso?X-Amz-…&X-Amz-Signature=…` →
/// `https://<bucket host>/release/front/<sha>.iso?…`: a presigned object URL is a
/// credential while it lives (anyone holding it reads the object), so its query is
/// never printed.
pub fn redact_object_url(url: &str) -> String {
match url.split_once('?') {
Some((base, _)) => format!("{base}?…"),
None => url.to_string(),
}
}
/// `https://fi-hel1.img.upcloud.com/uploader/session/<secret>` →
/// `…/uploader/session/…`: the session id IS a credential (anyone holding it
/// can write the storage a box boots from), so it is never printed whole.
pub fn redact_upload_url(url: &str) -> String {
match url.find("/session/") {
Some(i) => format!("{}/session/…", &url[..i]),
None => match url.rfind('/') {
Some(i) => format!("{}/…", &url[..i]),
None => "…".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mock_endpoint_is_loopback_or_nothing() {
assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
let e = Endpoint::mock(bad).unwrap_err();
assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
}
}
#[test]
fn a_configured_base_is_the_account_a_mock_or_refused() {
for a in ["https://api.upcloud.com", "https://api.upcloud.com/", "https://api.upcloud.com/1.3", "https://api.upcloud.com/1.3/"] {
assert_eq!(Endpoint::for_base(a).unwrap(), Endpoint::Account, "{a}");
}
assert_eq!(Endpoint::for_base("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099").unwrap());
assert!(Endpoint::for_base("https://api.upcloud.com.evil.example").is_err());
assert!(Endpoint::for_base("http://10.13.0.247:8099").is_err());
assert_eq!(ACCOUNT_BASE_FOR_DISPLAY, format!("{ACCOUNT_ROOT_FOR_DISPLAY}/1.3"));
}
#[test]
fn both_spellings_of_a_mock_base_reach_the_same_door() {
assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
}
/// **FAILS-BEFORE, BY NEUTRALISATION**: make `account_given` ignore the
/// environment and the first assertion below fails — an account run in a
/// shell aimed at a fake is exactly the ambiguity that must not start.
#[test]
fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
for k in MOCK_ENVS {
let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
}
assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
// An empty value is not a choice.
assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
}
#[test]
fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
let args = m.child_args();
assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
assert!(Endpoint::Account.child_args().is_empty());
assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
}
#[test]
fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
assert!(Endpoint::Account.banner().contains("a real bill"));
}
#[test]
fn the_stop_timeout_goes_out_as_a_string() {
let b = body::stop(Stop::Soft { timeout_s: 60 });
assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
}
#[test]
fn a_label_filter_is_one_encoded_pair_per_label() {
assert_eq!(label_query(&[]), "");
assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
}
#[test]
fn a_page_rides_after_the_label_filter_and_the_next_starts_where_it_ended() {
let p = Page::first();
assert_eq!(p.on(label_query(&[])), "?limit=100&offset=0");
assert_eq!(p.next().on(label_query(&[("monetize_ref", "abc")])), "?label=monetize_ref%3Dabc&limit=100&offset=100");
}
#[test]
fn a_delete_says_what_happens_to_backups_in_one_spelling() {
assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
assert_eq!(delete_storage_query(Backups::Unsaid), "");
assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
}
#[test]
fn an_attach_names_an_address_only_when_asked() {
assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
}
#[test]
fn an_upload_session_is_never_printed_whole() {
let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
}
#[test]
fn a_failure_names_the_api_error_code() {
let r = Reply {
status: 409,
body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
text: String::new(),
};
let m = r.describe_failure("POST /server/{uuid}/storage/attach");
assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
assert!(page.describe_failure("GET /x").contains("bad gateway"));
}
/// ★ Row 23 (code-smell review 2026-10-10): a reply that is not JSON became
/// `Null`, and a reader of its fields said "field missing" about a proxy's
/// HTML page. The text is still kept, `Null` still means "no JSON" — and the
/// reply now SAYS it did not parse, and a typed read names the parse error.
#[test]
fn a_reply_that_is_not_json_says_so_and_a_checked_read_names_the_parse_error() {
let page = Reply::from_text(200, "<html>bad gateway</html>".into());
assert_eq!(page.body, Value::Null);
let why = page.parse_error().expect("a non-JSON body is said to be one");
assert!(why.contains("200") && why.contains("not JSON"), "{why}");
let e = page.json().unwrap_err();
assert!(e.contains("not JSON") && e.contains("<html>bad gateway"), "the parse error and the start of the text, never a missing field: {e}");
let empty = Reply::from_text(204, String::new());
assert!(empty.parse_error().is_none(), "an empty body is no parse error");
assert_eq!(empty.json().unwrap(), &Value::Null);
let good = Reply::from_text(200, r#"{"server":{"state":"started"}}"#.into());
assert!(good.parse_error().is_none());
assert_eq!(good.json().unwrap()["server"]["state"], "started");
}
}