upcloud-api 0.1.3

The UpCloud API 1.3 surface the nordisk estates use, as ONE trait (`UpCloudApi`) with ONE wire implementation. Which cloud a run talks to (the account, or a mock-upcloud on loopback) is an `Endpoint` decided once at the edge, and a mock endpoint cannot be pointed off this machine. The fake that answers the trait in-process lives beside mock-upcloud's state machine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
//! **`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 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 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.
    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 {
    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 },
}

/// 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);

/// 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>;
    /// `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>;
    /// `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>;
    /// `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>;
    /// `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>;
    /// `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 — the volume grows. Callers:
    /// private-gunnar-ops grow, monetize-cloud-impl grow.
    fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
    /// `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>;
}

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

/// `?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"}}),
        }
    }
    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"}})
    }
}

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