mock_upcloud/render.rs
1//! **The wire shapes — including the inconsistencies, which are the point.**
2//!
3//! UpCloud stringifies numbers inconsistently: a storage's `size` is a JSON
4//! number, a server's `core_number` and `memory_amount` are strings, a device's
5//! `storage_size` is a number in one place and a string in the next, and a stop
6//! request's `timeout` is a string that is accepted-and-ignored when sent as a
7//! number. `monetize-cloud-impl`'s `Num` exists only because of this. A mock
8//! that rendered everything as a number would let a client that cannot read
9//! `"10"` pass, and that client would then fail at the provider.
10//!
11//! So the rendering here is hand-built `serde_json` rather than derived structs,
12//! and every place the real API sends a string, this sends a string.
13//!
14//! # Two label shapes, and they are not the same shape
15//!
16//! A storage carries `"labels": [{key,value}]`. A server carries
17//! `"labels": {"label": [{key,value}]}`. Both are filtered with
18//! `?label=key%3Dvalue`. This is not a simplification anybody would invent; it
19//! is what the API does, and a client written against one shape breaks on the
20//! other.
21//!
22//! # Behaviour 4: the list is not the detail
23//!
24//! `GET /1.3/server` carries **no `storage_devices`, no `ip_addresses`** — those
25//! exist only in `GET /1.3/server/{uuid}`. `GET /1.3/storage` DOES carry
26//! `labels`. A caller that lists servers and reads attachments off the rows gets
27//! an empty list and concludes there is nothing attached.
28//!
29//! # `created` IS sent — behaviour 10 as first written was wrong
30//!
31//! MEASURED against the live account 2026-09-20, two volumes, both endpoints:
32//! `GET /1.3/storage/private` and `GET /1.3/storage/{uuid}` each carried
33//! `"created"`. This crate previously omitted it from both and claimed the
34//! provider did too, which made a young volume and an old orphan the same row —
35//! a verdict path the real account cannot produce.
36//!
37//! `with_created` is now true unless [`crate::Fault::WithholdCreatedField`] is
38//! armed by name. What behaviour 9 actually turns on is the TITLE, not the
39//! date: the `Resize Backup` carries no product name, and the label is what
40//! recognises it.
41
42use crate::estate::{Estate, Server, Storage, StorageKind};
43use serde_json::{json, Value};
44
45pub fn labels_flat(s: &Storage) -> Value {
46 Value::Array(s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect())
47}
48
49pub fn labels_enveloped(s: &Server) -> Value {
50 json!({ "label": s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect::<Vec<_>>() })
51}
52
53/// One storage row. `with_created` is the fault; `detail` adds the `servers`
54/// envelope that only `GET /1.3/storage/{uuid}` carries.
55pub fn storage(e: &Estate, s: &Storage, detail: bool, with_created: bool) -> Value {
56 let mut v = json!({
57 "uuid": s.uuid,
58 "title": s.title,
59 // A NUMBER here…
60 "size": s.size_gib,
61 "state": s.state,
62 "tier": s.tier,
63 "type": s.kind.as_str(),
64 "zone": s.zone,
65 "access": if s.kind == StorageKind::Template { "public" } else { "private" },
66 "labels": labels_flat(s),
67 });
68 if let Some(o) = &s.origin {
69 v["origin"] = json!(o);
70 }
71 if with_created {
72 v["created"] = json!(iso8601(s.created_ms));
73 }
74 if let Some(im) = &s.import {
75 // A caller that reads the storage row can see that an import HAPPENED,
76 // but its `state` is the import's, not the volume's. Both are here, and
77 // they disagree for a hundred seconds after every upload.
78 v["storage_import"] = import(im);
79 }
80 if detail {
81 let servers = e.attached_servers(&s.uuid);
82 v["servers"] = json!({ "server": servers });
83 v["license"] = json!(0);
84 v["backup_rule"] = json!({});
85 }
86 v
87}
88
89/// One server row.
90///
91/// `detail` is behaviour 4: `storage_devices`, `ip_addresses`, `boot_order`,
92/// `remote_access_*` and the plan's core/memory strings exist ONLY here. The
93/// list row is deliberately thin, and a caller that reads an attachment off it
94/// gets nothing.
95pub fn server(s: &Server, detail: bool, with_created: bool) -> Value {
96 let mut v = json!({
97 "uuid": s.uuid,
98 "title": s.title,
99 "hostname": s.hostname,
100 "plan": s.plan,
101 "zone": s.zone,
102 "state": s.state,
103 // …and a STRING here. Same API, same object graph.
104 "core_number": cores_of(&s.plan).to_string(),
105 "memory_amount": memory_of(&s.plan).to_string(),
106 "labels": labels_enveloped(s),
107 "license": 0,
108 });
109 if with_created {
110 v["created"] = json!(iso8601(s.created_ms));
111 }
112 if !detail {
113 return v;
114 }
115 v["boot_order"] = json!(s.boot_order.as_str());
116 v["remote_access_enabled"] = json!(if s.remote_access_enabled { "yes" } else { "no" });
117 v["remote_access_type"] = json!("vnc");
118 // **Behaviour 14, both halves.** The console is a HOST and a port, and the
119 // reported pair is the pair from BEFORE the last stop/start. `reported_*`,
120 // never the live fields — the difference between them is the defect, and
121 // the host is in it too: MEASURED, the cure returned
122 // `se-sto1.vnc.upcloud.com:60031`, a ZONE host and a five-figure port,
123 // neither derivable from the server. A client that re-reads only the port
124 // dials the right port at the wrong host.
125 //
126 // With remote access OFF the API reports NOTHING here rather than the last
127 // known pair — a stale endpoint answered confidently is what makes a dead
128 // console look alive.
129 if s.remote_access_enabled {
130 v["remote_access_host"] = json!(s.reported_vnc_host);
131 v["remote_access_port"] = json!(s.reported_vnc_port.to_string());
132 v["remote_access_password"] = json!(s.remote_access_password);
133 }
134 // Behaviour 15: SeaBIOS, and there is no other answer. UpCloud exposes no
135 // firmware knob at all, which is why every gunnar ISO must carry a BIOS
136 // boot path and why an OVMF-only image is unbootable here.
137 v["firmware"] = json!("bios");
138 // **The hypervisor says UTC, and it is telling the truth.** The appliance's
139 // clock is still two hours out, because the guest's userland reads the RTC
140 // as local time. A caller that trusts this field to mean "the guest's wall
141 // clock is UTC" is drawing the wrong conclusion from a correct answer — and
142 // that is exactly what happened.
143 v["timezone"] = json!("UTC");
144 v["storage_devices"] = json!({
145 "storage_device": s.devices.iter().map(|d| json!({
146 "address": d.address,
147 "part_of_plan": "no",
148 "storage": d.storage,
149 // **A NUMBER, and the correction is worth more than the entry was
150 // — behaviour 10's lesson, a second time.**
151 //
152 // This sent the STRING, on the reading that the API stringifies
153 // `storage_size` "in one example and not the next" and that a
154 // client which only handles numbers should fall over here rather
155 // than at the provider. WITNESS, 2026-09-21: `UpCloudLtd/upcloud`
156 // 5.44.1 — the provider UpCloud publishes, pointed at this mock
157 // through `UPCLOUD_DEBUG_API_BASE_URL` — refuses it outright:
158 //
159 // json: cannot unmarshal string into Go struct field
160 // …localServerDetails.storage_devices.storage_device.storage_size
161 // of type int
162 //
163 // Its own SDK declares the field `int`. A provider that cannot read
164 // its own account is a provider nobody could ever have applied
165 // terraform with, and holger's estate HAS been applied — so the
166 // live API sends a number here and the string was this crate's.
167 // Every server create against the mock failed on it, which is a
168 // verdict path the account cannot produce: the exact mistake the
169 // crate warns about at the top of `lib.rs`.
170 //
171 // The stringification that IS measured stays where it was measured:
172 // a server's `core_number` and `memory_amount` above, and a stop
173 // request's `timeout`. This field is not one of them.
174 "storage_size": d.storage_size,
175 "storage_title": d.storage_title,
176 "storage_tier": "maxiops",
177 "type": d.kind,
178 "boot_disk": if d.boot_disk { "1" } else { "0" },
179 })).collect::<Vec<_>>()
180 });
181 v["ip_addresses"] = json!({
182 "ip_address": [
183 {"access": "public", "address": s.public_ip, "family": "IPv4"},
184 {"access": "utility", "address": s.utility_ip, "family": "IPv4"},
185 ]
186 });
187 // Behaviour 61: an interface that ASKED for IPv6 gets one, on the flat list too.
188 for i in s.ifaces.iter().filter(|i| i.kind == "public" && i.family == "IPv6") {
189 if let Some(a) = v["ip_addresses"]["ip_address"].as_array_mut() {
190 a.push(json!({"access": "public", "address": ipv6_of(s, i.index), "family": "IPv6"}));
191 }
192 }
193 // **`networking` is `ip_addresses` said again, per INTERFACE, and the two
194 // are not interchangeable.** `ip_addresses` is a flat list with an `access`
195 // on each row; `networking.interfaces.interface` is the ordered list the
196 // machine declared, and it is the one a terraform `network_interface[1]`
197 // resolves against. holger's outputs read the appliance's SECOND interface
198 // to scope the twin's only firewall rule, so an estate rendered without
199 // this reads its utility address off nothing.
200 v["networking"] = json!({
201 "interfaces": {
202 "interface": s.ifaces.iter().map(|i| {
203 let v6 = i.kind == "public" && i.family == "IPv6";
204 let addr = if v6 { ipv6_of(s, i.index) } else { address_of(s, &i.kind) };
205 let family = if v6 { "IPv6" } else { "IPv4" };
206 json!({
207 "index": i.index,
208 "type": i.kind,
209 "mac": mac_for(&s.uuid, i.index),
210 "network": "",
211 "bootable": "no",
212 "source_ip_filtering": "yes",
213 "ip_addresses": {"ip_address": [
214 {"address": addr, "family": family, "floating": "no"}
215 ]},
216 })
217 }).collect::<Vec<_>>()
218 }
219 });
220 v["firewall"] = json!(if s.firewall_on { "on" } else { "off" });
221 v["metadata"] = json!(if s.metadata { "yes" } else { "no" });
222 // The timezone the machine ASKED for. See `Server::timezone`: the guest's
223 // own clock lie is behaviour 19 and lives elsewhere.
224 v["timezone"] = json!(s.timezone);
225 v["simple_backup"] = json!("no");
226 v["nic_model"] = json!("virtio");
227 v["video_model"] = json!("vga");
228 v["host"] = json!(0);
229 v["server_group"] = json!("");
230 v
231}
232
233/// Which of a server's two addresses an interface of this kind carries. A
234/// `private` interface reads the utility address: this mock has one internal
235/// wire, and inventing a second address pool would model a network nobody here
236/// has measured.
237fn address_of(s: &Server, kind: &str) -> String {
238 match kind {
239 "public" => s.public_ip.clone(),
240 _ => s.utility_ip.clone(),
241 }
242}
243
244/// A stable public IPv6 per (server, interface), in UpCloud's Stockholm prefix
245/// shape. Derived, like the MAC, so a seed replays the same estate.
246fn ipv6_of(s: &Server, index: u32) -> String {
247 let m = mac_for(&s.uuid, index).replace(':', "");
248 format!("2a04:3540:1000:310:{}:{}:{}:{}", &m[0..4], &m[4..8], &m[8..12], index)
249}
250
251/// A stable MAC per (server, interface). Derived rather than random so the same
252/// seed replays the same estate, which is this crate's whole bargain.
253fn mac_for(uuid: &str, index: u32) -> String {
254 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
255 for b in uuid.as_bytes().iter().chain(&index.to_le_bytes()) {
256 h ^= *b as u64;
257 h = h.wrapping_mul(0x100_0000_01b3);
258 }
259 format!(
260 "0a:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
261 (h >> 32) as u8,
262 (h >> 24) as u8,
263 (h >> 16) as u8,
264 (h >> 8) as u8,
265 h as u8
266 )
267}
268
269/// The plan table. Small on purpose: these are the plans this estate buys, and
270/// a plan name that is not here is refused by name rather than invented — an
271/// invented plan would price at zero and make a spend cap untestable.
272pub fn cores_of(plan: &str) -> u32 {
273 // `DEV-2xCPU-4GB` is two cores, not one: the count is the digits directly
274 // before the `x`, and a `DEV-` prefix sits in front of them. Reading the
275 // whole first segment made every DEV plan a one-core plan, which is a
276 // silently wrong number rather than a refusal.
277 //
278 // **MEASURED AGAIN 2026-09-21 (lane T3): `STARTER-` is a second prefix and
279 // it did exactly the same thing.** `trim_start_matches("DEV-")` leaves
280 // `STARTER-4xCPU-8GB` whole, `split('x').next()` answers `"STARTER-4"`,
281 // that does not parse, and `unwrap_or(1)` makes gunnar's four-core
282 // appliance a ONE-core machine — silently, with no refusal anywhere. A
283 // named-prefix list is the shape that keeps being wrong, so the digits are
284 // now read from where they actually are: the run directly before `xCPU`.
285 let before = match plan.find("xCPU") {
286 Some(i) => &plan[..i],
287 None => return 1,
288 };
289 let mut digits: Vec<char> = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
290 digits.reverse();
291 digits.into_iter().collect::<String>().parse().unwrap_or(1)
292}
293
294pub fn memory_of(plan: &str) -> u32 {
295 // `2xCPU-4GB` → 4096.
296 plan.rsplit('-')
297 .next()
298 .and_then(|g| g.trim_end_matches("GB").parse::<u32>().ok())
299 .map(|g| g * 1024)
300 .unwrap_or(1024)
301}
302
303/// ★ **The plan table, ONCE.** `GET /1.3/plan` renders this list and
304/// `POST /1.3/server` refuses anything not in it, so the endpoint can never
305/// advertise a plan the create would then call `INVALID_PLAN`.
306///
307/// `DEV-2xCPU-4GB` is here because holger's APPLIANCE is that plan
308/// (`private-holger-ops/estate.toml`, `server_plan`). It was missing while
309/// `DEV-1xCPU-1GB` was present, so the mock would have refused the one machine
310/// the whole estate is about — a refusal that reads as an estate defect and is
311/// the mock's.
312pub const PLANS: &[&str] = &[
313 "1xCPU-1GB",
314 "1xCPU-2GB",
315 "2xCPU-4GB",
316 "4xCPU-8GB",
317 "6xCPU-16GB",
318 "8xCPU-32GB",
319 "DEV-1xCPU-1GB",
320 "DEV-2xCPU-4GB",
321 // **gunnar's three machines, added 2026-09-21 (lane T3).** `estate.toml`
322 // buys `STARTER-4xCPU-8GB` (front), `STARTER-4xCPU-16GB` (appliance) and
323 // `STARTER-1xCPU-1GB` (twin), and not one of them was in this list — so
324 // terraform's own plan validator refused every gunnar server before a
325 // single call reached the state machine. The same hole `DEV-2xCPU-4GB` was,
326 // for the other estate: a refusal that reads as an estate defect and is the
327 // mock's.
328 "STARTER-1xCPU-1GB",
329 "STARTER-4xCPU-8GB",
330 "STARTER-4xCPU-16GB",
331];
332
333pub fn plan_known(plan: &str) -> bool {
334 PLANS.contains(&plan)
335}
336
337/// `GET /1.3/price`.
338///
339/// The figures are UpCloud's published ones for `se-sto1`, in **credits per
340/// hour** where one credit is one euro cent, and `storage_maxiops` is per GB:
341/// 0.000274 credits/GB/h × 730 h ≈ €0.20 per GB-month, which is the number the
342/// spend cap was measured against. `server_memory` is quoted per 256 MB.
343pub fn price(zone: &str) -> Value {
344 json!({
345 "prices": {
346 "zone": [{
347 "name": zone,
348 "server_core": {"amount": 1, "price": 1.3},
349 "server_memory": {"amount": 256, "price": 0.45},
350 "storage_maxiops": {"amount": 1, "price": 0.0274},
351 "storage_hdd": {"amount": 1, "price": 0.0082},
352 "storage_standard": {"amount": 1, "price": 0.0154},
353 "storage_backup": {"amount": 1, "price": 0.0082},
354 "public_ipv4_address": {"amount": 1, "price": 0.416},
355 "ipv4_address": {"amount": 1, "price": 0.416},
356 "server_plan_1xCPU-1GB": {"amount": 1, "price": 0.744},
357 "server_plan_1xCPU-2GB": {"amount": 1, "price": 1.24},
358 "server_plan_2xCPU-4GB": {"amount": 1, "price": 2.48},
359 "server_plan_4xCPU-8GB": {"amount": 1, "price": 4.96},
360 "server_plan_6xCPU-16GB": {"amount": 1, "price": 9.92},
361 "server_plan_8xCPU-32GB": {"amount": 1, "price": 19.84},
362 "server_plan_DEV-1xCPU-1GB": {"amount": 1, "price": 0.372},
363 // `DEV-2xCPU-4GB` was added to PLANS and never to the price
364 // table, so holger's appliance priced at zero — the exact
365 // failure the PLANS doc warns about, one table along.
366 "server_plan_DEV-2xCPU-4GB": {"amount": 1, "price": 1.24},
367 // gunnar's three. ESTIMATED from UpCloud's published monthly
368 // STARTER prices (€5 / €38 / €62) over 730 h, NOT measured
369 // against the account like the figures above — a spend cap
370 // proved against these is proved against an estimate.
371 "server_plan_STARTER-1xCPU-1GB": {"amount": 1, "price": 0.685},
372 "server_plan_STARTER-4xCPU-8GB": {"amount": 1, "price": 5.205},
373 "server_plan_STARTER-4xCPU-16GB": {"amount": 1, "price": 8.493}
374 }]
375 }
376 })
377}
378
379/// **The direct-upload import object**, exactly as the live one answers.
380///
381/// MEASURED on the estate: `created 09:36:16Z`, `completed 09:36:21Z`,
382/// `read_bytes == written_bytes == 43485184`, `md5sum` and `sha256sum` both
383/// present, the sha256 matching the local file. `state` here is the IMPORT's
384/// state and it is not the storage's — that is the two-clock behaviour, and the
385/// reason a caller that polls only this one waits five seconds and then uses a
386/// volume that is still `syncing`.
387pub fn import(im: &crate::estate::Import) -> Value {
388 let mut v = json!({
389 "source": im.source,
390 "state": im.state,
391 "created": iso8601(im.created_ms),
392 "uuid": "",
393 "client_content_length": im.client_content_length,
394 "read_bytes": im.read_bytes,
395 "written_bytes": im.written_bytes,
396 });
397 if !im.direct_upload_url.is_empty() {
398 v["direct_upload_url"] = json!(im.direct_upload_url);
399 }
400 if let Some(c) = im.completed_ms {
401 v["completed"] = json!(iso8601(c));
402 }
403 if let Some(m) = &im.md5sum {
404 v["md5sum"] = json!(m);
405 }
406 if let Some(h) = &im.sha256sum {
407 v["sha256sum"] = json!(h);
408 }
409 if let Some(c) = &im.error_code {
410 v["error_code"] = json!(c);
411 }
412 if let Some(m) = &im.error_message {
413 v["error_message"] = json!(m);
414 }
415 v
416}
417
418/// The ORDINARY error envelope: `{"error": {"error_code", "error_message"}}`.
419pub fn error(code: &str, message: &str) -> Value {
420 json!({"error": {"error_code": code, "error_message": message}})
421}
422
423/// **Behaviour 1 — the other error envelope, and the whole reason it is here.**
424///
425/// A firewall-rule read for a server that does not exist (because it was
426/// DELETED) answers `403` with a `problem+json` body naming
427/// `ERROR_AUTHENTICATION_FAILED` and a `correlation_id`. Not 404. Not
428/// `SERVER_NOT_FOUND`.
429///
430/// Terraform cannot tell this from a revoked token, so it does not treat it as
431/// "the resource is gone, remove it from state" — it STOPS. That cost the first
432/// hour of 2026-09-20 and it will cost the next hour too, because nothing about
433/// it is going to change at the provider. It is reproducible here on demand,
434/// which is the difference between a defect you plan around and a defect you
435/// rediscover.
436pub fn auth_failed(correlation_id: &str) -> Value {
437 json!({
438 "type": "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED",
439 "title": "Authentication failed using the given username and password.",
440 "correlation_id": correlation_id,
441 "status": 403
442 })
443}
444
445/// The mock's own refusal for a path it does not implement. Loud, and it names
446/// the path: an unimplemented call must never be able to read as a working one.
447pub fn not_implemented(method: &str, path: &str) -> Value {
448 json!({"error": {
449 "error_code": "MOCK_UPCLOUD_NOT_IMPLEMENTED",
450 "error_message": format!("mock-upcloud does not implement {method} {path}; it is not part of the surface this estate drives")
451 }})
452}
453
454/// Virtual milliseconds as an RFC 3339 stamp. The epoch is arbitrary and fixed
455/// (2026-01-01T00:00:00Z) so a replayed seed produces byte-identical stamps.
456pub fn iso8601(ms: u64) -> String {
457 const EPOCH_DAYS: u64 = 20454; // 2026-01-01 since 1970-01-01
458 let secs = ms / 1000;
459 let days = EPOCH_DAYS + secs / 86_400;
460 let rem = secs % 86_400;
461 let (y, m, d) = civil_from_days(days);
462 format!(
463 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
464 rem / 3600,
465 (rem % 3600) / 60,
466 rem % 60
467 )
468}
469
470/// Howard Hinnant's `civil_from_days`, so the stamp is a real date and not an
471/// approximation. No chrono: the whole need is one conversion.
472fn civil_from_days(z: u64) -> (u64, u64, u64) {
473 let z = z + 719_468;
474 let era = z / 146_097;
475 let doe = z - era * 146_097;
476 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
477 let y = yoe + era * 400;
478 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
479 let mp = (5 * doy + 2) / 153;
480 let d = doy - (153 * mp + 2) / 5 + 1;
481 let m = if mp < 10 { mp + 3 } else { mp - 9 };
482 (if m <= 2 { y + 1 } else { y }, m, d)
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn the_epoch_is_the_first_of_january() {
491 assert_eq!(iso8601(0), "2026-01-01T00:00:00Z");
492 assert_eq!(iso8601(86_400_000), "2026-01-02T00:00:00Z");
493 assert_eq!(iso8601(105_000), "2026-01-01T00:01:45Z");
494 }
495
496 #[test]
497 fn a_plan_is_read_into_cores_and_megabytes() {
498 assert_eq!(cores_of("2xCPU-4GB"), 2);
499 assert_eq!(memory_of("2xCPU-4GB"), 4096);
500 assert_eq!(memory_of("8xCPU-32GB"), 32768);
501 }
502
503 /// The price row is the one a spend cap is measured against: maxiops at
504 /// ~€0.20 per GB-month. If this arithmetic changes, every cap test that
505 /// ever passed was measuring something else.
506 #[test]
507 fn maxiops_is_twenty_cents_a_gigabyte_month() {
508 let p = price("se-sto1");
509 let credits_per_gb_hour = p["prices"]["zone"][0]["storage_maxiops"]["price"].as_f64().unwrap();
510 let cents_per_gb_month = credits_per_gb_hour * 730.0;
511 assert!((cents_per_gb_month - 20.0).abs() < 0.5, "{cents_per_gb_month}");
512 }
513
514 #[test]
515 fn the_auth_failed_body_is_the_one_terraform_stops_on() {
516 let v = auth_failed("abc123");
517 assert_eq!(
518 v["type"],
519 "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED"
520 );
521 assert_eq!(v["correlation_id"], "abc123");
522 assert!(v["error"].is_null(), "it is problem+json, not the error envelope");
523 }
524}