Skip to main content

upcloud_api/
lib.rs

1//! **`upcloud-api` — the UpCloud API 1.3 surface as ONE trait, ONE wire, ONE
2//! decision about which cloud answers.**
3//!
4//! # Why this crate exists
5//!
6//! On 2026-09-21 the nordisk estates had **five** UpCloud clients:
7//! `private-gunnar-ops`'s xtask (two of them, until lane T3 made them one),
8//! `gunnar/deploy/upcloud` (the re-image procedure, with its OWN scripted fake),
9//! `gunnar-loadbench`, `private-holger-ops`'s xtask, and `monetize-cloud-impl`.
10//! Each one spelled `https://api.upcloud.com/1.3` for itself. Each one decided
11//! for itself whether a run was aimed at a fake. Four bugs that day were the
12//! same bug: **a mock run reached the account because one client in the chain
13//! decided differently from the rest** — the worst of them was
14//! `gunnar-upcloud`'s `Api::new(DEFAULT_BASE)`, so `cargo xtask mock reimage
15//! --apply` would have re-imaged the LIVE appliance with the estate's token
16//! while the banner said FAKE.
17//!
18//! A test against the fake is only worth something if the code under test is
19//! the SAME code that runs against the account. So:
20//!
21//! * [`UpCloudApi`] is the only surface. **No method takes a path, a query
22//!   string or a base URL**, so no caller can name an endpoint.
23//! * There is **one** wire implementation. It builds every request for the
24//!   account and for a mock the same way, from the same code — only the base
25//!   differs. A run against `mock-upcloud` therefore exercises the exact URL,
26//!   body and header construction a run against the account does.
27//! * The base comes from an [`Endpoint`], and an `Endpoint` is decided ONCE, at
28//!   the edge of the program, from what the operator TYPED
29//!   ([`Endpoint::account`] / [`Endpoint::mock`]). A mock endpoint is
30//!   loopback-only by construction; the account endpoint refuses to exist while
31//!   the shell carries a mock variable ([`MOCK_ENVS`]).
32//! * The in-process fake — `FakeUpCloud` over `mock-upcloud`'s `Estate` — lives
33//!   beside that state machine in the `mock-upcloud` crate, so a fault armed
34//!   once applies to terraform (the HTTP face) and to Rust callers (the trait
35//!   face) alike: one world, two faces.
36//! * [`over`] is where a typed call becomes ONE method, path and body: the wire
37//!   and every fake are an [`Exchange`] under [`Over`], so a fake answers the
38//!   exact request the account would have been sent.
39//! * [`guard`] is the test every consuming repository runs: nothing outside the
40//!   files it names may spell the provider or build an API path.
41//!
42//! # The method set is DERIVED, not designed
43//!
44//! Every method exists because a real call site in one of the ported clients
45//! calls that endpoint. The doc on each names the caller. A general-purpose
46//! UpCloud client is a second thing to keep true; this is not one.
47
48use std::path::Path;
49
50use serde_json::Value;
51
52pub mod guard;
53#[cfg(feature = "wire")]
54pub mod mock_door;
55#[cfg(feature = "wire")]
56pub mod net;
57pub mod over;
58#[cfg(feature = "wire")]
59mod wire;
60
61pub use over::{Call, Exchange, Method, Over};
62#[cfg(feature = "wire")]
63pub use wire::{connect, Credential, Options};
64
65/// **The account.** Private: the one spelling of the provider in this crate,
66/// used by [`wire`] as the base of [`Endpoint::Account`]. A consumer can print
67/// it through [`ACCOUNT_BASE_FOR_DISPLAY`] and can build nothing from it.
68const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
69
70/// The account's base, for a REPORT and a display comparison — never to build a
71/// request from; nothing can be called on a `&'static str`.
72pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
73
74/// The variable a mock-selected run reads its loopback base from. On an
75/// account-selected run its PRESENCE is a refusal ([`Endpoint::account`]).
76pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
77
78/// The terraform provider's own debug knob. Not read here — named so an
79/// account run can refuse when the shell carries it.
80pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
81
82/// Every variable whose presence means "this shell is aimed at a fake".
83pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
84
85// ── the endpoint: decided once, at the edge ─────────────────────────────────
86
87/// **Which UpCloud answers.** Built only by [`Endpoint::account`] (refuses in a
88/// mock-carrying shell) or [`Endpoint::mock`] (refuses anything off loopback).
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Endpoint {
91    /// `https://api.upcloud.com/1.3`. Real machines, a real bill.
92    Account,
93    /// A `mock-upcloud` on loopback. The base always ends `/1.3`.
94    Mock(MockBase),
95}
96
97/// A loopback base ending `/1.3`. The field is private, so the only way to hold
98/// one is [`Endpoint::mock`] — which is what makes "a fake pointed at a real
99/// host" unrepresentable rather than merely refused.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct MockBase(String);
102
103impl MockBase {
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107}
108
109impl Endpoint {
110    /// The account — unless the shell carries a mock variable, in which case the
111    /// run does not start. An operator who exported [`MOCK_BASE_ENV`] for a fake
112    /// run and then typed a real verb in the same shell used to get writes to the
113    /// account from some clients and to the fake from others.
114    pub fn account() -> Result<Endpoint, String> {
115        Endpoint::account_given(|k| std::env::var(k).ok())
116    }
117
118    /// [`Endpoint::account`] over an injected environment, for tests.
119    pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
120        let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
121        if set.is_empty() {
122            return Ok(Endpoint::Account);
123        }
124        Err(format!(
125            "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
126             a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
127             process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
128             <loopback base>`), or unset {s} to use the account.",
129            s = set.join(" and ")
130        ))
131    }
132
133    /// A `mock-upcloud` at `base`. **Loopback or nothing**: a fake that can be
134    /// pointed off this machine is a fake that can be pointed at something real.
135    /// `http://127.0.0.1:8099` and `http://127.0.0.1:8099/1.3` are the same door.
136    pub fn mock(base: &str) -> Result<Endpoint, String> {
137        let mut b = base.trim().trim_end_matches('/').to_string();
138        if !is_loopback(&b) {
139            return Err(format!(
140                "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
141                 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
142                 machine can be one."
143            ));
144        }
145        if !b.ends_with("/1.3") {
146            b.push_str("/1.3");
147        }
148        Ok(Endpoint::Mock(MockBase(b)))
149    }
150
151    /// A mock from [`MOCK_BASE_ENV`], for a run that has ALREADY been selected
152    /// as a mock run by what the operator typed. Unset is a refusal by name.
153    pub fn mock_from_env() -> Result<Endpoint, String> {
154        let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
155            format!(
156                "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
157                 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
158                 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
159            )
160        })?;
161        Endpoint::mock(&raw)
162    }
163
164    pub fn is_account(&self) -> bool {
165        matches!(self, Endpoint::Account)
166    }
167
168    /// Where `/…` goes — for a report. Never build a request from it; hold an
169    /// implementation from [`connect`] instead.
170    pub fn base_for_display(&self) -> &str {
171        match self {
172            Endpoint::Account => ACCOUNT_BASE,
173            Endpoint::Mock(b) => b.as_str(),
174        }
175    }
176
177    /// The one line that goes at the top of anything a person might read as a
178    /// measurement of the account.
179    pub fn banner(&self) -> String {
180        match self {
181            Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
182            Endpoint::Mock(b) => format!(
183                "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
184                 nothing measured here says anything about the account.",
185                b.as_str()
186            ),
187        }
188    }
189
190    /// The argv a parent hands a CHILD process so the child makes the same
191    /// choice: `["--mock-api", base]` for a mock, nothing for the account. A
192    /// child that receives nothing and finds a mock variable in its environment
193    /// refuses ([`Endpoint::account`]) — which is how the choice cannot be lost
194    /// crossing a process boundary.
195    pub fn child_args(&self) -> Vec<String> {
196        match self {
197            Endpoint::Account => Vec::new(),
198            Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
199        }
200    }
201}
202
203/// The flag [`Endpoint::child_args`] emits and [`Endpoint::from_flag`] reads.
204pub const MOCK_API_FLAG: &str = "--mock-api";
205
206impl Endpoint {
207    /// A child's side of [`Endpoint::child_args`]: `Some(base)` from
208    /// `--mock-api` is the mock (loopback-checked); `None` is the account (and
209    /// refuses in a mock-carrying shell). There is no third answer.
210    pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
211        match mock_api {
212            Some(b) => Endpoint::mock(b),
213            None => Endpoint::account(),
214        }
215    }
216}
217
218/// `http://127.0.0.1:PORT` or `http://localhost:PORT`, and nothing else. No
219/// IPv6 — these estates have none by law, and `[::1]` is refused with the rest.
220pub fn is_loopback(url: &str) -> bool {
221    let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
222    host == "127.0.0.1" || host == "localhost"
223}
224
225// ── the answer ──────────────────────────────────────────────────────────────
226
227/// **One answer from the API, with NO judgement about its status.** The callers
228/// decide what a code means, because they genuinely disagree: a `404` on
229/// `GET /server/{uuid}` is a FACT for a sweep and a REFUSAL for a resize. A
230/// transport failure is the `Err` and is never a status.
231#[derive(Debug, Clone)]
232pub struct Reply {
233    pub status: u16,
234    /// The body as JSON; `Null` when it was empty or not JSON.
235    pub body: Value,
236    /// The body exactly as it arrived, so a non-JSON error page from a proxy
237    /// reaches the operator intact instead of becoming a parse error that names
238    /// nothing.
239    pub text: String,
240}
241
242impl Reply {
243    pub fn ok(&self) -> bool {
244        (200..300).contains(&self.status)
245    }
246
247    /// `error.error_code`, or `""`.
248    pub fn error_code(&self) -> &str {
249        self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
250    }
251
252    /// `error.error_message`, or the raw text.
253    pub fn error_message(&self) -> &str {
254        self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
255    }
256
257    /// `409 SERVER_STATE_ILLEGAL — server state is started`: status, code and
258    /// message, because each answers a different question.
259    pub fn describe_failure(&self, what: &str) -> String {
260        let code = self.error_code();
261        if code.is_empty() {
262            format!("{what} answered {} — {}", self.status, self.error_message())
263        } else {
264            format!("{what} answered {} {code} — {}", self.status, self.error_message())
265        }
266    }
267}
268
269// ── the words a caller uses instead of UpCloud's spelling ───────────────────
270
271/// How a server is stopped. `Soft` carries a grace in seconds; `Hard` pulls the
272/// plug, and is the word a delete needs first.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum Stop {
275    Soft { timeout_s: u32 },
276    Hard,
277}
278
279/// Whether a server's delete takes its storages with it, and what becomes of
280/// their backups. UpCloud spells this as a query string; no caller spells it.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum WithStorages {
283    /// `?storages=1&backups=delete` — the server and everything under it.
284    /// Caller: private-gunnar-ops orphan sweep.
285    AndTheirBackups,
286    /// `?storages=1&backups=keep` — the server and its volumes; the backups
287    /// stay. Caller: monetize-cloud-impl `destroy`.
288    AndKeepBackups,
289    /// The server alone; its volumes are left behind (and become orphans).
290    LeaveThem,
291}
292
293/// What a storage's delete does with the storage's backups.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum Backups {
296    /// No `backups=` is sent; the account's default applies. Callers:
297    /// gunnar-upcloud (seed media), private-gunnar-ops.
298    Unsaid,
299    /// `?backups=keep`. Caller: monetize-cloud-impl `destroy`.
300    Keep,
301    /// `?backups=delete`.
302    Delete,
303}
304
305/// How a device rides on a server.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum DeviceKind {
308    /// What the firmware boots. UpCloud is SeaBIOS-only.
309    Cdrom,
310    /// A virtio disk.
311    Disk,
312}
313
314impl DeviceKind {
315    pub fn as_str(self) -> &'static str {
316        match self {
317            DeviceKind::Cdrom => "cdrom",
318            DeviceKind::Disk => "disk",
319        }
320    }
321}
322
323/// What the hypervisor boots first on its next START (a guest reboot does not
324/// re-read it — MEASURED 2026-09-14).
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum BootOrder {
327    Cdrom,
328    Disk,
329}
330
331impl BootOrder {
332    pub fn as_str(self) -> &'static str {
333        match self {
334            BootOrder::Cdrom => "cdrom",
335            BootOrder::Disk => "disk",
336        }
337    }
338}
339
340/// The VNC console. `Vnc` re-provisions host AND port; the reply carries them.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub enum Console<'a> {
343    Off,
344    Vnc { password: &'a str },
345}
346
347/// One label, `key=value`. UpCloud filters lists by it (`?label=key%3Dvalue`)
348/// and carries it on storages and servers.
349pub type Label<'a> = (&'a str, &'a str);
350
351/// A new storage.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct NewStorage<'a> {
354    pub title: &'a str,
355    pub zone: &'a str,
356    pub size_gib: u64,
357    /// `maxiops`, `standard`, …
358    pub tier: &'a str,
359    /// Empty sends no `labels` key at all.
360    pub labels: &'a [Label<'a>],
361}
362
363// ── the surface ─────────────────────────────────────────────────────────────
364
365/// **The UpCloud API 1.3 surface the nordisk estates actually use.** Typed in,
366/// typed out. Not one method takes a path, a query string or a base URL.
367pub trait UpCloudApi {
368    /// A word for what answered, for a report that must never read as a
369    /// measurement of the account when it was not one.
370    fn describe(&self) -> String;
371
372    /// Is this the real account? A question about the implementation, never a
373    /// handle to one.
374    fn is_the_account(&self) -> bool;
375
376    // ── reads ───────────────────────────────────────────────────────────────
377    /// `GET /account`. Callers: private-gunnar-ops, gunnar-loadbench,
378    /// private-holger-ops, monetize-cloud-impl (credential probe, grow judge).
379    fn account(&self) -> Result<Reply, String>;
380    /// `GET /price`. Caller: monetize-cloud-impl `price`.
381    fn price(&self) -> Result<Reply, String>;
382    /// `GET /server`. Callers: private-gunnar-ops / private-holger-ops orphan
383    /// sweeps, gunnar-loadbench.
384    fn servers(&self) -> Result<Reply, String>;
385    /// `GET /server?label=…` — every label must match. Caller:
386    /// monetize-cloud-impl (the label search, and the inventory with none).
387    fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
388    /// `GET /server/{uuid}`. Callers: everywhere.
389    fn server(&self, uuid: &str) -> Result<Reply, String>;
390    /// `GET /server/{uuid}/firewall_rule`. Caller: private-gunnar-ops estate.
391    fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
392    /// `GET /storage/private`. Callers: the orphan sweeps, gunnar-upcloud
393    /// (adopting media already on the account).
394    fn storages_private(&self) -> Result<Reply, String>;
395    /// `GET /storage?label=…` — every label must match; no label lists
396    /// everything the account can see. Caller: monetize-cloud-impl.
397    fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
398    /// `GET /storage/{uuid}`. Callers: grow, gunnar-upcloud's online wait,
399    /// monetize-cloud-impl.
400    fn storage(&self, uuid: &str) -> Result<Reply, String>;
401    /// `GET /zone`. Caller: gunnar-loadbench `--from-upcloud`.
402    fn zones(&self) -> Result<Reply, String>;
403    /// `GET /plan`. Caller: gunnar-loadbench `--from-upcloud`.
404    fn plans(&self) -> Result<Reply, String>;
405
406    // ── server writes ───────────────────────────────────────────────────────
407    /// `POST /server` with the caller's server document (`{"server": {…}}`).
408    /// The DOCUMENT is the caller's — a plan, a template clone, labels, SSH
409    /// keys; the PATH is not. Caller: monetize-cloud-impl `ensure`.
410    fn create_server(&self, document: &Value) -> Result<Reply, String>;
411    /// `POST /server/{uuid}/stop`.
412    fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
413    /// `POST /server/{uuid}/start`.
414    fn start_server(&self, uuid: &str) -> Result<Reply, String>;
415    /// `PUT /server/{uuid}` with a plan — a plan change, which ALSO mints a
416    /// `Resize Backup`. Callers: private-gunnar-ops grow, monetize-cloud-impl.
417    fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
418    /// `PUT /server/{uuid}` with a boot order. Caller: gunnar-upcloud.
419    fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
420    /// `PUT /server/{uuid}` toggling the VNC console. Caller: gunnar-upcloud
421    /// `console::open` (the toggle that defeats the stale port).
422    fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
423    /// `POST /server/{uuid}/storage/attach`, optionally AT an address
424    /// (`virtio`, `virtio:5`). Callers: gunnar-upcloud (no address),
425    /// monetize-cloud-impl (always an address).
426    fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
427    /// `POST /server/{uuid}/storage/detach` — names an ADDRESS (`ide:0:0`,
428    /// `virtio:5`), never a storage uuid. Callers: gunnar-upcloud,
429    /// monetize-cloud-impl.
430    fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
431    /// `POST /server/{uuid}/cdrom/eject` — legal on a STARTED server
432    /// (MEASURED). Caller: gunnar-upcloud (the never-loop primitive).
433    fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
434    /// `DELETE /server/{uuid}`. **This really deletes.**
435    fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
436
437    // ── storage writes ──────────────────────────────────────────────────────
438    /// `POST /storage`. Callers: gunnar-upcloud (the seed a medium is imported
439    /// into), monetize-cloud-impl (a labelled volume).
440    fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
441    /// `POST /storage/{uuid}/clone`. Caller: gunnar-upcloud `clone_probe`.
442    fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
443    /// `POST /storage/{uuid}/import` with `source: direct_upload` — the reply
444    /// carries the URL [`UpCloudApi::upload_direct`] PUTs to. Caller:
445    /// gunnar-upcloud.
446    fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
447    /// `PUT <direct_upload_url>` with the file's bytes. The URL is the one
448    /// [`UpCloudApi::import_direct_upload`] answered, **is itself a
449    /// credential** (never printed whole; no bearer sent), and must belong to
450    /// the same cloud this implementation talks to — the account's upload host
451    /// for the account, loopback for a mock. Caller: gunnar-upcloud.
452    fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
453    /// `PUT /storage/{uuid}` with a new size — the volume grows. Callers:
454    /// private-gunnar-ops grow, monetize-cloud-impl grow.
455    fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
456    /// `POST /storage/{uuid}/resize` — the FILESYSTEM grows, and the provider
457    /// mints a `Resize Backup` that nothing deletes.
458    fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
459    /// `DELETE /storage/{uuid}`. **This really deletes.**
460    fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
461}
462
463/// Forward every method through a pointer, so `&T` and `Box<T>` are
464/// implementations too.
465macro_rules! forward {
466    ($($ty:tt)*) => {
467        impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
468            fn describe(&self) -> String { (**self).describe() }
469            fn is_the_account(&self) -> bool { (**self).is_the_account() }
470            fn account(&self) -> Result<Reply, String> { (**self).account() }
471            fn price(&self) -> Result<Reply, String> { (**self).price() }
472            fn servers(&self) -> Result<Reply, String> { (**self).servers() }
473            fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
474            fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
475            fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
476            fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
477            fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
478            fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
479            fn zones(&self) -> Result<Reply, String> { (**self).zones() }
480            fn plans(&self) -> Result<Reply, String> { (**self).plans() }
481            fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
482            fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
483            fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
484            fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
485            fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
486            fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
487            fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
488            fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
489            fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
490            fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
491            fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
492            fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
493            fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
494            fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
495            fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
496            fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
497            fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
498        }
499    };
500}
501forward!(&T);
502forward!(Box<T>);
503
504/// The query a delete sends — ONE spelling, for the wire and for any fake that
505/// records what a call means.
506pub fn delete_server_query(with: WithStorages) -> &'static str {
507    match with {
508        WithStorages::AndTheirBackups => "?storages=1&backups=delete",
509        WithStorages::AndKeepBackups => "?storages=1&backups=keep",
510        WithStorages::LeaveThem => "",
511    }
512}
513
514/// See [`delete_server_query`].
515pub fn delete_storage_query(backups: Backups) -> &'static str {
516    match backups {
517        Backups::Unsaid => "",
518        Backups::Keep => "?backups=keep",
519        Backups::Delete => "?backups=delete",
520    }
521}
522
523/// `?label=k%3Dv&label=…`, or `""` — every character outside RFC 3986's
524/// unreserved set percent-encoded, so a label is never read as query syntax.
525pub fn label_query(labels: &[Label<'_>]) -> String {
526    fn enc(s: &str, out: &mut String) {
527        for b in s.bytes() {
528            if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
529                out.push(b as char);
530            } else {
531                out.push_str(&format!("%{b:02X}"));
532            }
533        }
534    }
535    let mut q = String::new();
536    for (k, v) in labels {
537        q.push(if q.is_empty() { '?' } else { '&' });
538        q.push_str("label=");
539        enc(&format!("{k}={v}"), &mut q);
540    }
541    q
542}
543
544// ── request bodies: ONE spelling, shared by the wire and by any fake ────────
545
546/// The JSON each write sends. Public so an in-process fake reads the SAME
547/// bodies the wire sends rather than a parallel copy of UpCloud's quirks.
548pub mod body {
549    use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
550    use serde_json::{json, Value};
551
552    /// UpCloud's 1.3 API wants the timeout as a STRING. It was sent as a
553    /// number once and the call was accepted and IGNORED.
554    pub fn stop(stop: Stop) -> Value {
555        match stop {
556            Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
557            Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
558        }
559    }
560    pub fn storage_size(gb: u64) -> Value {
561        json!({"storage": {"size": gb.to_string()}})
562    }
563    pub fn server_plan(plan: &str) -> Value {
564        json!({"server": {"plan": plan}})
565    }
566    pub fn boot_order(order: BootOrder) -> Value {
567        json!({"server": {"boot_order": order.as_str()}})
568    }
569    pub fn console(c: &Console<'_>) -> Value {
570        match c {
571            Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
572            Console::Vnc { password } => json!({"server": {
573                "remote_access_enabled": "yes",
574                "remote_access_type": "vnc",
575                "remote_access_password": password,
576            }}),
577        }
578    }
579    pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
580        match at {
581            None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
582            Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
583        }
584    }
585    /// Detach names an **address**, never a storage uuid.
586    pub fn detach(address: &str) -> Value {
587        json!({"storage_device": {"address": address}})
588    }
589    pub fn create_storage(n: &NewStorage<'_>) -> Value {
590        let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
591        if !n.labels.is_empty() {
592            v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
593        }
594        v
595    }
596    /// A clone names the new title and zone; the tier rides along.
597    pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
598        json!({"storage": {"tier": tier, "title": title, "zone": zone}})
599    }
600    pub fn direct_upload() -> Value {
601        json!({"storage_import": {"source": "direct_upload"}})
602    }
603}
604
605/// `https://fi-hel1.img.upcloud.com/uploader/session/<secret>` →
606/// `…/uploader/session/…`: the session id IS a credential (anyone holding it
607/// can write the storage a box boots from), so it is never printed whole.
608pub fn redact_upload_url(url: &str) -> String {
609    match url.find("/session/") {
610        Some(i) => format!("{}/session/…", &url[..i]),
611        None => match url.rfind('/') {
612            Some(i) => format!("{}/…", &url[..i]),
613            None => "…".to_string(),
614        },
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn a_mock_endpoint_is_loopback_or_nothing() {
624        assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
625        assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
626        for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
627            let e = Endpoint::mock(bad).unwrap_err();
628            assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
629        }
630    }
631
632    #[test]
633    fn both_spellings_of_a_mock_base_reach_the_same_door() {
634        assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
635        assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
636    }
637
638    /// **FAILS-BEFORE, BY NEUTRALISATION**: make `account_given` ignore the
639    /// environment and the first assertion below fails — an account run in a
640    /// shell aimed at a fake is exactly the ambiguity that must not start.
641    #[test]
642    fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
643        for k in MOCK_ENVS {
644            let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
645            assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
646        }
647        assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
648        // An empty value is not a choice.
649        assert_eq!(Endpoint::account_given(|_| Some("  ".into())).unwrap(), Endpoint::Account);
650    }
651
652    #[test]
653    fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
654        let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
655        let args = m.child_args();
656        assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
657        assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
658        assert!(Endpoint::Account.child_args().is_empty());
659        assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
660    }
661
662    #[test]
663    fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
664        let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
665        assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
666        assert!(Endpoint::Account.banner().contains("a real bill"));
667    }
668
669    #[test]
670    fn the_stop_timeout_goes_out_as_a_string() {
671        let b = body::stop(Stop::Soft { timeout_s: 60 });
672        assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
673        assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
674    }
675
676    #[test]
677    fn a_label_filter_is_one_encoded_pair_per_label() {
678        assert_eq!(label_query(&[]), "");
679        assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
680        assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
681    }
682
683    #[test]
684    fn a_delete_says_what_happens_to_backups_in_one_spelling() {
685        assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
686        assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
687        assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
688        assert_eq!(delete_storage_query(Backups::Unsaid), "");
689        assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
690    }
691
692    #[test]
693    fn an_attach_names_an_address_only_when_asked() {
694        assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
695        assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
696        let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
697        assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
698    }
699
700    #[test]
701    fn an_upload_session_is_never_printed_whole() {
702        let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
703        assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
704    }
705
706    #[test]
707    fn a_failure_names_the_api_error_code() {
708        let r = Reply {
709            status: 409,
710            body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
711            text: String::new(),
712        };
713        let m = r.describe_failure("POST /server/{uuid}/storage/attach");
714        assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
715        let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
716        assert!(page.describe_failure("GET /x").contains("bad gateway"));
717    }
718}