crates/mock-upcloud/src/tf.rs
//! **The terraform door — the same API 1.3, asked for by a different caller.**
//!
//! Everything else in this crate answers the calls `monetize-cloud-impl`'s
//! upcloud plugin and `gunnar/deploy/upcloud` make. `private-holger-ops` reaches
//! the SAME account through `UpCloudLtd/upcloud` 5.44 and terraform, and that
//! caller asks for a handful of things the plugin never asks for: the plan
//! table, the public template list, a server whose create body carries its
//! network interfaces and its login block, and a firewall rule SET written in
//! one call. Without them the estate this crate exists to exercise cannot be
//! laid down at all — measured 2026-09-21, the first `terraform plan` against
//! the mock died on `GET /1.3/plan` before it reached a single resource.
//!
//! # What is MEASURED here and what is INFERRED
//!
//! The wire shapes below are INFERRED from the provider binary's own behaviour
//! (5.44.1, `UPCLOUD_DEBUG_API_BASE_URL` pointed at this mock) and from UpCloud's
//! published 1.3 surface — not from a capture of the live account, which this
//! lane is forbidden to touch. They are marked here rather than presented as
//! measurement, in the crate's own house style: a mock stricter than the
//! provider invents verdicts, and a mock that CLAIMS more than it measured
//! lends its lies authority they have not earned.
//!
//! The one thing that IS proven by construction: every field the provider
//! reads back and this module does not send shows up as a terraform permadiff,
//! which is loud. A field sent wrong is the dangerous one, so the shapes here
//! are kept to what the provider demonstrably consumes.
use crate::estate::{Iface, Rule};
use serde_json::{json, Value};
/// `GET /1.3/plan`. The provider fetches this before every server create and
/// refuses a plan name that is not in it — which is a real check and the reason
/// this endpoint is worth having rather than stubbing: a plan renamed in
/// `estate.toml` is caught here, offline, in the same words the account would
/// use.
///
/// The list is [`crate::render::plan_known`]'s, so there is ONE plan table in
/// this crate and the endpoint cannot advertise a plan the create refuses.
pub fn plans() -> Value {
let rows: Vec<Value> = crate::render::PLANS
.iter()
.map(|p| {
json!({
"name": p,
"core_number": crate::render::cores_of(p),
"memory_amount": crate::render::memory_of(p),
"public_traffic_out": 2048,
"storage_size": 25,
// A Starter (and the retired Developer) plan's included storage is STANDARD;
// the others' MaxIOPS (UpCloud: cloud server configurations, read 2026-10-14).
"storage_tier": if p.starts_with("STARTER-") || p.starts_with("DEV-") { "standard" } else { "maxiops" },
})
})
.collect();
json!({ "plans": { "plan": rows } })
}
/// The interfaces a terraform create body asks for:
/// `server.networking.interfaces.interface[]`, each with an `index` and a
/// `type` (`public` · `utility` · `private`).
///
/// A body with no networking block at all gets the pair every other caller in
/// this crate has always got — one public, one utility — because that is what
/// the plugin's servers have and changing it under them is not this door's
/// business.
pub fn interfaces_from_body(server: &Value) -> Vec<Iface> {
let arr = server["networking"]["interfaces"]["interface"].as_array().cloned().unwrap_or_default();
if arr.is_empty() {
return Iface::default_pair();
}
arr.iter()
.enumerate()
.map(|(i, v)| Iface {
index: v["index"].as_u64().unwrap_or(i as u64 + 1) as u32,
kind: v["type"].as_str().unwrap_or("public").to_string(),
// `ip_addresses.ip_address[0].family`, as the provider sends it.
family: v["ip_addresses"]["ip_address"][0]["family"].as_str().unwrap_or("IPv4").to_string(),
})
.collect()
}
/// One firewall rule as the provider sends it and reads it back. Every field is
/// a STRING on the wire, including `position` and the port numbers — the same
/// stringification the rest of this crate reproduces, and the reason a client
/// that expects numbers here fails at the provider and not in a test.
pub fn rules_from_body(body: &Value) -> Vec<Rule> {
// Both spellings: the bulk PUT sends `firewall_rules.firewall_rule[]`, the
// single POST sends one `firewall_rule`. A door that took only one of them
// would pass or fail on the provider's choice of call rather than on the
// rules themselves.
let arr: Vec<Value> = match body["firewall_rules"]["firewall_rule"].as_array() {
Some(a) => a.clone(),
None => match body["firewall_rule"].as_object() {
Some(_) => vec![body["firewall_rule"].clone()],
None => vec![],
},
};
arr.iter()
.enumerate()
.map(|(i, v)| Rule {
position: v["position"].as_str().map(str::to_string).unwrap_or_else(|| (i + 1).to_string()),
direction: s(v, "direction", "in"),
action: s(v, "action", "drop"),
family: s(v, "family", "IPv4"),
protocol: s(v, "protocol", ""),
source_address_start: s(v, "source_address_start", ""),
source_address_end: s(v, "source_address_end", ""),
source_port_start: s(v, "source_port_start", ""),
source_port_end: s(v, "source_port_end", ""),
destination_address_start: s(v, "destination_address_start", ""),
destination_address_end: s(v, "destination_address_end", ""),
destination_port_start: s(v, "destination_port_start", ""),
destination_port_end: s(v, "destination_port_end", ""),
icmp_type: s(v, "icmp_type", ""),
comment: s(v, "comment", ""),
})
.collect()
}
/// A field that may arrive as a string or a number, rendered as the string the
/// API sends. `position` and the ports are the ones that actually arrive both
/// ways.
fn s(v: &Value, key: &str, default: &str) -> String {
match &v[key] {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => default.to_string(),
}
}
pub fn render_rule(r: &Rule) -> Value {
json!({
"position": r.position,
"direction": r.direction,
"action": r.action,
"family": r.family,
"protocol": r.protocol,
"source_address_start": r.source_address_start,
"source_address_end": r.source_address_end,
"source_port_start": r.source_port_start,
"source_port_end": r.source_port_end,
"destination_address_start": r.destination_address_start,
"destination_address_end": r.destination_address_end,
"destination_port_start": r.destination_port_start,
"destination_port_end": r.destination_port_end,
"icmp_type": r.icmp_type,
"comment": r.comment,
})
}
pub fn render_rules(rules: &[Rule]) -> Value {
json!({ "firewall_rules": { "firewall_rule": rules.iter().map(render_rule).collect::<Vec<_>>() } })
}
#[cfg(test)]
mod tests {
use super::*;
/// The plan table the endpoint advertises is the plan table the create
/// accepts. Two lists would let terraform pick a plan the mock then refuses
/// with `INVALID_PLAN`, which reads as an estate defect and is not one.
#[test]
fn every_advertised_plan_is_a_plan_the_create_accepts() {
let p = plans();
let rows = p["plans"]["plan"].as_array().expect("plans").clone();
assert!(!rows.is_empty());
for r in rows {
let name = r["name"].as_str().expect("a plan has a name");
assert!(crate::render::plan_known(name), "{name} is advertised and would be refused");
}
}
/// The bulk PUT and the single POST are the same rules. The provider picks
/// one; the estate's declaration must not depend on which.
#[test]
fn a_rule_reads_the_same_from_either_spelling() {
let one = json!({"firewall_rule": {"action":"accept","direction":"in","protocol":"tcp",
"destination_port_start": 443, "destination_port_end":"443","comment":"HTTPS"}});
let bulk = json!({"firewall_rules": {"firewall_rule": [one["firewall_rule"].clone()]}});
let a = rules_from_body(&one);
let b = rules_from_body(&bulk);
assert_eq!(a.len(), 1);
assert_eq!(a[0].comment, "HTTPS");
// A number on the wire reads as the string the API sends back.
assert_eq!(a[0].destination_port_start, "443");
assert_eq!(a[0].destination_port_start, b[0].destination_port_start);
assert_eq!(a[0].position, "1");
}
/// A create body with no networking block keeps the pair every other caller
/// in this crate has always had.
#[test]
fn an_absent_networking_block_is_the_old_pair() {
assert_eq!(interfaces_from_body(&json!({})), Iface::default_pair());
let tf = json!({"networking": {"interfaces": {"interface": [
{"index": 1, "type": "public"}, {"index": 2, "type": "utility"}]}}});
let got = interfaces_from_body(&tf);
assert_eq!(got.len(), 2);
assert_eq!(got[1].kind, "utility");
}
}