crates/mock-upcloud/src/render.rs
//! **The wire shapes — including the inconsistencies, which are the point.**
//!
//! UpCloud stringifies numbers inconsistently: a storage's `size` is a JSON
//! number, a server's `core_number` and `memory_amount` are strings, a device's
//! `storage_size` is a number in one place and a string in the next, and a stop
//! request's `timeout` is a string that is accepted-and-ignored when sent as a
//! number. `monetize-cloud-impl`'s `Num` exists only because of this. A mock
//! that rendered everything as a number would let a client that cannot read
//! `"10"` pass, and that client would then fail at the provider.
//!
//! So the rendering here is hand-built `serde_json` rather than derived structs,
//! and every place the real API sends a string, this sends a string.
//!
//! # Two label shapes, and they are not the same shape
//!
//! A storage carries `"labels": [{key,value}]`. A server carries
//! `"labels": {"label": [{key,value}]}`. Both are filtered with
//! `?label=key%3Dvalue`. This is not a simplification anybody would invent; it
//! is what the API does, and a client written against one shape breaks on the
//! other.
//!
//! # Behaviour 4: the list is not the detail
//!
//! `GET /1.3/server` carries **no `storage_devices`, no `ip_addresses`** — those
//! exist only in `GET /1.3/server/{uuid}`. `GET /1.3/storage` DOES carry
//! `labels`. A caller that lists servers and reads attachments off the rows gets
//! an empty list and concludes there is nothing attached.
//!
//! # `created` IS sent — behaviour 10 as first written was wrong
//!
//! MEASURED against the live account 2026-09-20, two volumes, both endpoints:
//! `GET /1.3/storage/private` and `GET /1.3/storage/{uuid}` each carried
//! `"created"`. This crate previously omitted it from both and claimed the
//! provider did too, which made a young volume and an old orphan the same row —
//! a verdict path the real account cannot produce.
//!
//! `with_created` is now true unless [`crate::Fault::WithholdCreatedField`] is
//! armed by name. What behaviour 9 actually turns on is the TITLE, not the
//! date: the `Resize Backup` carries no product name, and the label is what
//! recognises it.
use crate::estate::{Estate, Server, Storage, StorageKind};
use serde_json::{json, Value};
pub fn labels_flat(s: &Storage) -> Value {
Value::Array(s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect())
}
pub fn labels_enveloped(s: &Server) -> Value {
json!({ "label": s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect::<Vec<_>>() })
}
/// One storage row. `with_created` is the fault; `detail` adds the `servers`
/// envelope that only `GET /1.3/storage/{uuid}` carries.
pub fn storage(e: &Estate, s: &Storage, detail: bool, with_created: bool) -> Value {
let mut v = json!({
"uuid": s.uuid,
"title": s.title,
// A NUMBER here…
"size": s.size_gib,
"state": s.state,
"tier": s.tier,
"type": s.kind.as_str(),
"zone": s.zone,
"access": if s.kind == StorageKind::Template { "public" } else { "private" },
"labels": labels_flat(s),
});
if let Some(o) = &s.origin {
v["origin"] = json!(o);
}
if with_created {
v["created"] = json!(iso8601(s.created_ms));
}
if let Some(im) = &s.import {
// A caller that reads the storage row can see that an import HAPPENED,
// but its `state` is the import's, not the volume's. Both are here, and
// they disagree for a hundred seconds after every upload.
v["storage_import"] = import(im);
}
if detail {
let servers = e.attached_servers(&s.uuid);
v["servers"] = json!({ "server": servers });
v["license"] = json!(0);
v["backup_rule"] = s.backup_rule.clone().unwrap_or_else(|| json!({}));
}
v
}
/// One server row.
///
/// `detail` is behaviour 4: `storage_devices`, `ip_addresses`, `boot_order`,
/// `remote_access_*` and the plan's core/memory strings exist ONLY here. The
/// list row is deliberately thin, and a caller that reads an attachment off it
/// gets nothing.
pub fn server(s: &Server, detail: bool, with_created: bool) -> Value {
let mut v = json!({
"uuid": s.uuid,
"title": s.title,
"hostname": s.hostname,
"plan": s.plan,
"zone": s.zone,
"state": s.state,
// …and a STRING here. Same API, same object graph.
"core_number": cores_of(&s.plan).to_string(),
"memory_amount": memory_of(&s.plan).to_string(),
"labels": labels_enveloped(s),
"license": 0,
});
if with_created {
v["created"] = json!(iso8601(s.created_ms));
}
if !detail {
return v;
}
v["boot_order"] = json!(s.boot_order.as_str());
v["remote_access_enabled"] = json!(if s.remote_access_enabled { "yes" } else { "no" });
v["remote_access_type"] = json!("vnc");
// **Behaviour 14, both halves.** The console is a HOST and a port, and the
// reported pair is the pair from BEFORE the last stop/start. `reported_*`,
// never the live fields — the difference between them is the defect, and
// the host is in it too: MEASURED, the cure returned
// `se-sto1.vnc.upcloud.com:60031`, a ZONE host and a five-figure port,
// neither derivable from the server. A client that re-reads only the port
// dials the right port at the wrong host.
//
// With remote access OFF the API reports NOTHING here rather than the last
// known pair — a stale endpoint answered confidently is what makes a dead
// console look alive.
if s.remote_access_enabled {
v["remote_access_host"] = json!(s.reported_vnc_host);
v["remote_access_port"] = json!(s.reported_vnc_port.to_string());
v["remote_access_password"] = json!(s.remote_access_password);
}
// Behaviour 15: SeaBIOS, and there is no other answer. UpCloud exposes no
// firmware knob at all, which is why every gunnar ISO must carry a BIOS
// boot path and why an OVMF-only image is unbootable here.
v["firmware"] = json!("bios");
// **The hypervisor says UTC, and it is telling the truth.** The appliance's
// clock is still two hours out, because the guest's userland reads the RTC
// as local time. A caller that trusts this field to mean "the guest's wall
// clock is UTC" is drawing the wrong conclusion from a correct answer — and
// that is exactly what happened.
v["timezone"] = json!("UTC");
v["storage_devices"] = json!({
"storage_device": s.devices.iter().map(|d| json!({
"address": d.address,
"part_of_plan": "no",
"storage": d.storage,
// **A NUMBER, and the correction is worth more than the entry was
// — behaviour 10's lesson, a second time.**
//
// This sent the STRING, on the reading that the API stringifies
// `storage_size` "in one example and not the next" and that a
// client which only handles numbers should fall over here rather
// than at the provider. WITNESS, 2026-09-21: `UpCloudLtd/upcloud`
// 5.44.1 — the provider UpCloud publishes, pointed at this mock
// through `UPCLOUD_DEBUG_API_BASE_URL` — refuses it outright:
//
// json: cannot unmarshal string into Go struct field
// …localServerDetails.storage_devices.storage_device.storage_size
// of type int
//
// Its own SDK declares the field `int`. A provider that cannot read
// its own account is a provider nobody could ever have applied
// terraform with, and holger's estate HAS been applied — so the
// live API sends a number here and the string was this crate's.
// Every server create against the mock failed on it, which is a
// verdict path the account cannot produce: the exact mistake the
// crate warns about at the top of `lib.rs`.
//
// The stringification that IS measured stays where it was measured:
// a server's `core_number` and `memory_amount` above, and a stop
// request's `timeout`. This field is not one of them.
"storage_size": d.storage_size,
"storage_title": d.storage_title,
"storage_tier": "maxiops",
"type": d.kind,
"boot_disk": if d.boot_disk { "1" } else { "0" },
})).collect::<Vec<_>>()
});
let mut rows = Vec::new();
if !s.public_ip.is_empty() {
rows.push(json!({"access": "public", "address": s.public_ip, "family": "IPv4"}));
}
rows.push(json!({"access": "utility", "address": s.utility_ip, "family": "IPv4"}));
v["ip_addresses"] = json!({ "ip_address": rows });
// Behaviour 61: an interface that ASKED for IPv6 gets one, on the flat list too.
for i in s.ifaces.iter().filter(|i| i.kind == "public" && i.family == "IPv6") {
if let Some(a) = v["ip_addresses"]["ip_address"].as_array_mut() {
a.push(json!({"access": "public", "address": ipv6_of(s, i.index), "family": "IPv6"}));
}
}
// **`networking` is `ip_addresses` said again, per INTERFACE, and the two
// are not interchangeable.** `ip_addresses` is a flat list with an `access`
// on each row; `networking.interfaces.interface` is the ordered list the
// machine declared, and it is the one a terraform `network_interface[1]`
// resolves against. holger's outputs read the appliance's SECOND interface
// to scope the twin's only firewall rule, so an estate rendered without
// this reads its utility address off nothing.
v["networking"] = json!({
"interfaces": {
"interface": s.ifaces.iter().map(|i| {
let v6 = i.kind == "public" && i.family == "IPv6";
let addr = if v6 { ipv6_of(s, i.index) } else { address_of(s, &i.kind) };
let family = if v6 { "IPv6" } else { "IPv4" };
json!({
"index": i.index,
"type": i.kind,
"mac": mac_for(&s.uuid, i.index),
"network": "",
"bootable": "no",
"source_ip_filtering": "yes",
"ip_addresses": {"ip_address": [
{"address": addr, "family": family, "floating": "no"}
]},
})
}).collect::<Vec<_>>()
}
});
v["firewall"] = json!(if s.firewall_on { "on" } else { "off" });
v["metadata"] = json!(if s.metadata { "yes" } else { "no" });
// The timezone the machine ASKED for. See `Server::timezone`: the guest's
// own clock lie is behaviour 19 and lives elsewhere.
v["timezone"] = json!(s.timezone);
v["simple_backup"] = json!(s.simple_backup);
v["nic_model"] = json!("virtio");
v["video_model"] = json!("vga");
v["host"] = json!(0);
v["server_group"] = json!("");
v
}
/// Which of a server's two addresses an interface of this kind carries. A
/// `private` interface reads the utility address: this mock has one internal
/// wire, and inventing a second address pool would model a network nobody here
/// has measured.
fn address_of(s: &Server, kind: &str) -> String {
match kind {
"public" => s.public_ip.clone(),
_ => s.utility_ip.clone(),
}
}
/// A stable public IPv6 per (server, interface), in UpCloud's Stockholm prefix
/// shape. Derived, like the MAC, so a seed replays the same estate.
fn ipv6_of(s: &Server, index: u32) -> String {
let m = mac_for(&s.uuid, index).replace(':', "");
format!("2a04:3540:1000:310:{}:{}:{}:{}", &m[0..4], &m[4..8], &m[8..12], index)
}
/// A stable MAC per (server, interface). Derived rather than random so the same
/// seed replays the same estate, which is this crate's whole bargain.
pub(crate) fn mac_for(uuid: &str, index: u32) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in uuid.as_bytes().iter().chain(&index.to_le_bytes()) {
h ^= *b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
format!(
"0a:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
(h >> 32) as u8,
(h >> 24) as u8,
(h >> 16) as u8,
(h >> 8) as u8,
h as u8
)
}
/// The plan table. Small on purpose: these are the plans this estate buys, and
/// a plan name that is not here is refused by name rather than invented — an
/// invented plan would price at zero and make a spend cap untestable.
pub fn cores_of(plan: &str) -> u32 {
// `DEV-2xCPU-4GB` is two cores, not one: the count is the digits directly
// before the `x`, and a `DEV-` prefix sits in front of them. Reading the
// whole first segment made every DEV plan a one-core plan, which is a
// silently wrong number rather than a refusal.
//
// **MEASURED AGAIN 2026-09-21 (lane T3): `STARTER-` is a second prefix and
// it did exactly the same thing.** `trim_start_matches("DEV-")` leaves
// `STARTER-4xCPU-8GB` whole, `split('x').next()` answers `"STARTER-4"`,
// that does not parse, and `unwrap_or(1)` makes gunnar's four-core
// appliance a ONE-core machine — silently, with no refusal anywhere. A
// named-prefix list is the shape that keeps being wrong, so the digits are
// now read from where they actually are: the run directly before `xCPU`.
let before = match plan.find("xCPU") {
Some(i) => &plan[..i],
None => return 1,
};
let mut digits: Vec<char> = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
digits.reverse();
digits.into_iter().collect::<String>().parse().unwrap_or(1)
}
pub fn memory_of(plan: &str) -> u32 {
// `2xCPU-4GB` → 4096.
plan.rsplit('-')
.next()
.and_then(|g| g.trim_end_matches("GB").parse::<u32>().ok())
.map(|g| g * 1024)
.unwrap_or(1024)
}
/// ★ **The plan table, ONCE.** `GET /1.3/plan` renders this list and
/// `POST /1.3/server` refuses anything not in it, so the endpoint can never
/// advertise a plan the create would then call `INVALID_PLAN`.
///
/// `DEV-2xCPU-4GB` is here because holger's APPLIANCE is that plan
/// (`private-holger-ops/estate.toml`, `server_plan`). It was missing while
/// `DEV-1xCPU-1GB` was present, so the mock would have refused the one machine
/// the whole estate is about — a refusal that reads as an estate defect and is
/// the mock's.
pub const PLANS: &[&str] = &[
"1xCPU-1GB",
"1xCPU-2GB",
"2xCPU-4GB",
"4xCPU-8GB",
"6xCPU-16GB",
"8xCPU-32GB",
"DEV-1xCPU-1GB",
"DEV-2xCPU-4GB",
// **gunnar's three machines, added 2026-09-21 (lane T3).** `estate.toml`
// buys `STARTER-4xCPU-8GB` (front), `STARTER-4xCPU-16GB` (appliance) and
// `STARTER-1xCPU-1GB` (twin), and not one of them was in this list — so
// terraform's own plan validator refused every gunnar server before a
// single call reached the state machine. The same hole `DEV-2xCPU-4GB` was,
// for the other estate: a refusal that reads as an estate defect and is the
// mock's.
"STARTER-1xCPU-1GB",
// The twin since private-gunnar-ops dc846b5 (2026-09-27): the 1 GB twin
// went silent under a fill on the account. Missing here, terraform's
// validator refused the whole mock lay (MEASURED loki 2026-09-27, run S).
"STARTER-1xCPU-2GB",
// **Mini since 2026-10-13** (the owner: cheaper without breaking): the front is
// `STARTER-2xCPU-4GB`, main `STARTER-4xCPU-8GB`, and the twin `STARTER-1xCPU-4GB` — 4 GB
// is the twin's floor on every size (a 2 GB box cannot afford gunnar-server's
// handle-cache floor). Missing here, the mock would refuse the smallest estate sold.
"STARTER-1xCPU-4GB",
"STARTER-2xCPU-4GB",
"STARTER-4xCPU-8GB",
"STARTER-4xCPU-16GB",
// **The size ladder's bigger steps** (the sign-up page, 2026-10-07):
// monetize's default ladder goes past the STARTER line into UpCloud's
// General Purpose plans, Small to Gigantic. Missing here, every step past
// Mini would read "the provider does not sell the plan" on the mock.
"12xCPU-48GB",
"16xCPU-64GB",
"20xCPU-96GB",
"20xCPU-128GB",
];
pub fn plan_known(plan: &str) -> bool {
PLANS.contains(&plan)
}
/// `GET /1.3/price`.
///
/// The figures are UpCloud's for `se-sto1`, in **credits per hour** where one
/// credit is one euro cent. The storage tiers, the public address and the
/// STARTER plans an estate is priced from are the LIVE list's, read 2026-10-06
/// (`storage_maxiops` 0.031 credits/GB/h × 730 h = €0.2263 per GB-month, hdd
/// €0.05694, `ipv4_address` €3.51); the other plan rows are the older published
/// figures. `server_memory` is quoted per 256 MB.
pub fn price(zone: &str) -> Value {
json!({
"prices": {
"zone": [{
"name": zone,
"server_core": {"amount": 1, "price": 1.3},
"server_memory": {"amount": 256, "price": 0.45},
"storage_maxiops": {"amount": 1, "price": 0.031},
"storage_hdd": {"amount": 1, "price": 0.0078},
"storage_standard": {"amount": 1, "price": 0.0118},
"storage_backup": {"amount": 1, "price": 0.0078},
// MEASURED on the live se-sto1 list 2026-10-05 17:30 UTC (read-only):
// 0.4812 credits/h = EUR 3.51 a month per address. A private estate
// carries three (front, main, twin), and its price counts them.
"public_ipv4_address": {"amount": 1, "price": 0.4812},
"ipv4_address": {"amount": 1, "price": 0.4812},
"server_plan_1xCPU-1GB": {"amount": 1, "price": 0.744},
"server_plan_1xCPU-2GB": {"amount": 1, "price": 1.24},
"server_plan_2xCPU-4GB": {"amount": 1, "price": 2.48},
"server_plan_4xCPU-8GB": {"amount": 1, "price": 4.96},
"server_plan_6xCPU-16GB": {"amount": 1, "price": 9.92},
"server_plan_8xCPU-32GB": {"amount": 1, "price": 19.84},
"server_plan_DEV-1xCPU-1GB": {"amount": 1, "price": 0.372},
// `DEV-2xCPU-4GB` was added to PLANS and never to the price
// table, so holger's appliance priced at zero — the exact
// failure the PLANS doc warns about, one table along.
"server_plan_DEV-2xCPU-4GB": {"amount": 1, "price": 1.24},
// gunnar's three (and the 1 GB STARTER): MEASURED on the live
// se-sto1 list 2026-10-06 17:40 UTC (read-only GET /1.3/price),
// EUR 3.26 / 6.52 / 21.73 / 30.42 a month. They were ESTIMATES
// (€5 / €8.2 / €38 / €62) until then, and every price the room's
// rule derived from this mock was ~1.7× the real estate's.
"server_plan_STARTER-1xCPU-1GB": {"amount": 1, "price": 0.4464},
"server_plan_STARTER-1xCPU-2GB": {"amount": 1, "price": 0.8928},
// Mini's twin and front since 2026-10-13. `STARTER-2xCPU-4GB` is the
// figure monetize-impl's fixtures already carry (1.7857 credits an hour);
// `STARTER-1xCPU-4GB` is DERIVED from the owner's monthly figure for
// se-sto1 that day (10.71 EUR over UpCloud's 720-hour month), not read
// off the live list here.
"server_plan_STARTER-1xCPU-4GB": {"amount": 1, "price": 1.4881},
"server_plan_STARTER-2xCPU-4GB": {"amount": 1, "price": 1.7857},
"server_plan_STARTER-4xCPU-8GB": {"amount": 1, "price": 2.9761},
"server_plan_STARTER-4xCPU-16GB": {"amount": 1, "price": 4.1666},
// The ladder's General Purpose steps: ESTIMATES on the same
// scale as the older published rows above (each step the
// core and memory price of the one before it, doubled where
// the plan doubles) — not read off the live list.
"server_plan_12xCPU-48GB": {"amount": 1, "price": 29.76},
"server_plan_16xCPU-64GB": {"amount": 1, "price": 39.68},
"server_plan_20xCPU-96GB": {"amount": 1, "price": 59.52},
"server_plan_20xCPU-128GB": {"amount": 1, "price": 79.36}
}]
}
})
}
/// **The direct-upload import object**, exactly as the live one answers.
///
/// MEASURED on the estate: `created 09:36:16Z`, `completed 09:36:21Z`,
/// `read_bytes == written_bytes == 43485184`, `md5sum` and `sha256sum` both
/// present, the sha256 matching the local file. `state` here is the IMPORT's
/// state and it is not the storage's — that is the two-clock behaviour, and the
/// reason a caller that polls only this one waits five seconds and then uses a
/// volume that is still `syncing`.
pub fn import(im: &crate::estate::Import) -> Value {
let mut v = json!({
"source": im.source,
"state": im.state,
"created": iso8601(im.created_ms),
"uuid": "",
"client_content_length": im.client_content_length,
"read_bytes": im.read_bytes,
"written_bytes": im.written_bytes,
});
if !im.direct_upload_url.is_empty() {
v["direct_upload_url"] = json!(im.direct_upload_url);
}
if !im.source_location.is_empty() {
v["source_location"] = json!(im.source_location);
}
if let Some(c) = im.completed_ms {
v["completed"] = json!(iso8601(c));
}
// Behaviour 75 (MEASURED on the account 2026-10-17, test2's front-keep): the digests are
// ALWAYS there, as `""` while the import is pending or importing — a caller that stops at
// "sha256sum is a string" reads an empty digest and gives a good import up.
v["md5sum"] = json!(im.md5sum.as_deref().unwrap_or(""));
v["sha256sum"] = json!(im.sha256sum.as_deref().unwrap_or(""));
if let Some(c) = &im.error_code {
v["error_code"] = json!(c);
}
if let Some(m) = &im.error_message {
v["error_message"] = json!(m);
}
v
}
/// The ORDINARY error envelope: `{"error": {"error_code", "error_message"}}`.
pub fn error(code: &str, message: &str) -> Value {
json!({"error": {"error_code": code, "error_message": message}})
}
/// **Behaviour 1 — the other error envelope, and the whole reason it is here.**
///
/// A firewall-rule read for a server that does not exist (because it was
/// DELETED) answers `403` with a `problem+json` body naming
/// `ERROR_AUTHENTICATION_FAILED` and a `correlation_id`. Not 404. Not
/// `SERVER_NOT_FOUND`.
///
/// Terraform cannot tell this from a revoked token, so it does not treat it as
/// "the resource is gone, remove it from state" — it STOPS. That cost the first
/// hour of 2026-09-20 and it will cost the next hour too, because nothing about
/// it is going to change at the provider. It is reproducible here on demand,
/// which is the difference between a defect you plan around and a defect you
/// rediscover.
pub fn auth_failed(correlation_id: &str) -> Value {
json!({
"type": "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED",
"title": "Authentication failed using the given username and password.",
"correlation_id": correlation_id,
"status": 403
})
}
/// The mock's own refusal for a path it does not implement. Loud, and it names
/// the path: an unimplemented call must never be able to read as a working one.
pub fn not_implemented(method: &str, path: &str) -> Value {
json!({"error": {
"error_code": "MOCK_UPCLOUD_NOT_IMPLEMENTED",
"error_message": format!("mock-upcloud does not implement {method} {path}; it is not part of the surface this estate drives")
}})
}
/// Virtual milliseconds as an RFC 3339 stamp. The epoch is arbitrary and fixed
/// (2026-01-01T00:00:00Z) so a replayed seed produces byte-identical stamps.
pub fn iso8601(ms: u64) -> String {
const EPOCH_DAYS: u64 = 20454; // 2026-01-01 since 1970-01-01
let secs = ms / 1000;
let days = EPOCH_DAYS + secs / 86_400;
let rem = secs % 86_400;
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
/// Howard Hinnant's `civil_from_days`, so the stamp is a real date and not an
/// approximation. No chrono: the whole need is one conversion.
fn civil_from_days(z: u64) -> (u64, u64, u64) {
let z = z + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_epoch_is_the_first_of_january() {
assert_eq!(iso8601(0), "2026-01-01T00:00:00Z");
assert_eq!(iso8601(86_400_000), "2026-01-02T00:00:00Z");
assert_eq!(iso8601(105_000), "2026-01-01T00:01:45Z");
}
#[test]
fn a_plan_is_read_into_cores_and_megabytes() {
assert_eq!(cores_of("2xCPU-4GB"), 2);
assert_eq!(memory_of("2xCPU-4GB"), 4096);
assert_eq!(memory_of("8xCPU-32GB"), 32768);
}
/// gunnar's twin is `STARTER-1xCPU-2GB` since private-gunnar-ops dc846b5
/// (the 1 GB twin went silent under a fill on the account). The mock must
/// sell it, and price it, or terraform's validator refuses the whole lay:
/// MEASURED loki 2026-09-27 (mock run S), "expected plan to be one of
/// [...], got STARTER-1xCPU-2GB".
#[test]
fn gunnars_two_gigabyte_twin_is_sold_and_priced() {
assert!(plan_known("STARTER-1xCPU-2GB"));
assert_eq!(cores_of("STARTER-1xCPU-2GB"), 1);
assert_eq!(memory_of("STARTER-1xCPU-2GB"), 2048);
let p = price("se-sto1");
let credits = p["prices"]["zone"][0]["server_plan_STARTER-1xCPU-2GB"]["price"].as_f64();
assert!(credits.is_some_and(|c| c > 0.685), "priced above the 1 GB twin: {credits:?}");
}
/// ★ **Mini's three machines are sold and priced** (owner, 2026-10-13): front
/// `STARTER-2xCPU-4GB`, main `STARTER-4xCPU-8GB`, twin `STARTER-1xCPU-4GB` — 12.86,
/// 21.43 and 10.71 EUR over UpCloud's 720-hour month at se-sto1, 45.00 together (the
/// old Mini's three were 57.86: 21.43 + 30.00 + 6.43).
#[test]
fn minis_three_machines_are_sold_and_priced() {
let p = price("se-sto1");
let z = &p["prices"]["zone"][0];
let month_720 = |plan: &str| z[format!("server_plan_{plan}").as_str()]["price"].as_f64().unwrap_or_else(|| panic!("{plan} has no price row")) * 720.0 / 100.0;
for (plan, cores, mib, eur) in [("STARTER-2xCPU-4GB", 2, 4096, 12.86), ("STARTER-4xCPU-8GB", 4, 8192, 21.43), ("STARTER-1xCPU-4GB", 1, 4096, 10.71)] {
assert!(plan_known(plan), "{plan} is not in the plan table");
assert_eq!((cores_of(plan), memory_of(plan)), (cores, mib), "{plan}");
assert!((month_720(plan) - eur).abs() < 0.01, "{plan}: {} EUR a month, the owner's figure is {eur}", month_720(plan));
}
let mini: f64 = ["STARTER-2xCPU-4GB", "STARTER-4xCPU-8GB", "STARTER-1xCPU-4GB"].iter().map(|p| month_720(p)).sum();
let old: f64 = ["STARTER-4xCPU-8GB", "STARTER-4xCPU-16GB", "STARTER-1xCPU-2GB"].iter().map(|p| month_720(p)).sum();
assert!((mini - 45.00).abs() < 0.02 && (old - 57.86).abs() < 0.02, "{mini} / {old}");
}
/// ★ **The live se-sto1 list, pinned** (cov-D 2026-10-06: read read-only
/// from UpCloud's `GET /1.3/price` at 17:40 UTC, credits per hour): the
/// mock's STARTER plans were ESTIMATES (38 / 62 / 8.20 EUR a month against
/// live 21.73 / 30.42 / 6.52) and its maxiops 0.20 EUR/GB-month against
/// live 0.2263, so every price the room's rule derived from the mock was
/// ~1.7× the real estate's. The rows an estate is priced from now carry
/// the live figures.
#[test]
fn the_rows_an_estate_is_priced_from_are_the_live_se_sto1_list_of_2026_10_06() {
let p = price("se-sto1");
let z = &p["prices"]["zone"][0];
for (item, credits_per_hour) in [
("server_plan_STARTER-1xCPU-1GB", 0.4464),
("server_plan_STARTER-1xCPU-2GB", 0.8928),
("server_plan_STARTER-4xCPU-8GB", 2.9761),
("server_plan_STARTER-4xCPU-16GB", 4.1666),
("storage_maxiops", 0.031),
("storage_hdd", 0.0078),
("storage_standard", 0.0118),
("storage_backup", 0.0078),
("ipv4_address", 0.4812),
("public_ipv4_address", 0.4812),
] {
assert_eq!(z[item]["price"].as_f64(), Some(credits_per_hour), "{item}");
}
// As EUR a month (730 h): 21.73 / 30.42 / 6.52, maxiops 0.2263, hdd 0.05694, IPv4 3.51.
let month = |item: &str| z[item]["price"].as_f64().unwrap() * 730.0 / 100.0;
assert!((month("server_plan_STARTER-4xCPU-8GB") - 21.73).abs() < 0.01);
assert!((month("server_plan_STARTER-4xCPU-16GB") - 30.42).abs() < 0.01);
assert!((month("server_plan_STARTER-1xCPU-2GB") - 6.52).abs() < 0.01);
assert!((month("storage_maxiops") - 0.2263).abs() < 0.0001);
assert!((month("storage_hdd") - 0.05694).abs() < 0.00001);
assert!((month("ipv4_address") - 3.51).abs() < 0.01);
}
/// The price row is the one a spend cap is measured against: maxiops at
/// €0.2263 per GB-month (the live list, 2026-10-06; it was ~€0.20). If this
/// arithmetic changes, every cap test that ever passed was measuring
/// something else.
#[test]
fn maxiops_is_twenty_two_point_six_cents_a_gigabyte_month() {
let p = price("se-sto1");
let credits_per_gb_hour = p["prices"]["zone"][0]["storage_maxiops"]["price"].as_f64().unwrap();
let cents_per_gb_month = credits_per_gb_hour * 730.0;
assert!((cents_per_gb_month - 22.63).abs() < 0.01, "{cents_per_gb_month}");
}
#[test]
fn the_auth_failed_body_is_the_one_terraform_stops_on() {
let v = auth_failed("abc123");
assert_eq!(
v["type"],
"https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED"
);
assert_eq!(v["correlation_id"], "abc123");
assert!(v["error"].is_null(), "it is problem+json, not the error envelope");
}
}