mock_upcloud/tf.rs
1//! **The terraform door — the same API 1.3, asked for by a different caller.**
2//!
3//! Everything else in this crate answers the calls `monetize-cloud-impl`'s
4//! upcloud plugin and `gunnar/deploy/upcloud` make. `private-holger-ops` reaches
5//! the SAME account through `UpCloudLtd/upcloud` 5.44 and terraform, and that
6//! caller asks for a handful of things the plugin never asks for: the plan
7//! table, the public template list, a server whose create body carries its
8//! network interfaces and its login block, and a firewall rule SET written in
9//! one call. Without them the estate this crate exists to exercise cannot be
10//! laid down at all — measured 2026-09-21, the first `terraform plan` against
11//! the mock died on `GET /1.3/plan` before it reached a single resource.
12//!
13//! # What is MEASURED here and what is INFERRED
14//!
15//! The wire shapes below are INFERRED from the provider binary's own behaviour
16//! (5.44.1, `UPCLOUD_DEBUG_API_BASE_URL` pointed at this mock) and from UpCloud's
17//! published 1.3 surface — not from a capture of the live account, which this
18//! lane is forbidden to touch. They are marked here rather than presented as
19//! measurement, in the crate's own house style: a mock stricter than the
20//! provider invents verdicts, and a mock that CLAIMS more than it measured
21//! lends its lies authority they have not earned.
22//!
23//! The one thing that IS proven by construction: every field the provider
24//! reads back and this module does not send shows up as a terraform permadiff,
25//! which is loud. A field sent wrong is the dangerous one, so the shapes here
26//! are kept to what the provider demonstrably consumes.
27
28use crate::estate::{Iface, Rule};
29use serde_json::{json, Value};
30
31/// `GET /1.3/plan`. The provider fetches this before every server create and
32/// refuses a plan name that is not in it — which is a real check and the reason
33/// this endpoint is worth having rather than stubbing: a plan renamed in
34/// `estate.toml` is caught here, offline, in the same words the account would
35/// use.
36///
37/// The list is [`crate::render::plan_known`]'s, so there is ONE plan table in
38/// this crate and the endpoint cannot advertise a plan the create refuses.
39pub fn plans() -> Value {
40 let rows: Vec<Value> = crate::render::PLANS
41 .iter()
42 .map(|p| {
43 json!({
44 "name": p,
45 "core_number": crate::render::cores_of(p),
46 "memory_amount": crate::render::memory_of(p),
47 "public_traffic_out": 2048,
48 "storage_size": 25,
49 "storage_tier": "maxiops",
50 })
51 })
52 .collect();
53 json!({ "plans": { "plan": rows } })
54}
55
56/// The interfaces a terraform create body asks for:
57/// `server.networking.interfaces.interface[]`, each with an `index` and a
58/// `type` (`public` · `utility` · `private`).
59///
60/// A body with no networking block at all gets the pair every other caller in
61/// this crate has always got — one public, one utility — because that is what
62/// the plugin's servers have and changing it under them is not this door's
63/// business.
64pub fn interfaces_from_body(server: &Value) -> Vec<Iface> {
65 let arr = server["networking"]["interfaces"]["interface"].as_array().cloned().unwrap_or_default();
66 if arr.is_empty() {
67 return Iface::default_pair();
68 }
69 arr.iter()
70 .enumerate()
71 .map(|(i, v)| Iface {
72 index: v["index"].as_u64().unwrap_or(i as u64 + 1) as u32,
73 kind: v["type"].as_str().unwrap_or("public").to_string(),
74 // `ip_addresses.ip_address[0].family`, as the provider sends it.
75 family: v["ip_addresses"]["ip_address"][0]["family"].as_str().unwrap_or("IPv4").to_string(),
76 })
77 .collect()
78}
79
80/// One firewall rule as the provider sends it and reads it back. Every field is
81/// a STRING on the wire, including `position` and the port numbers — the same
82/// stringification the rest of this crate reproduces, and the reason a client
83/// that expects numbers here fails at the provider and not in a test.
84pub fn rules_from_body(body: &Value) -> Vec<Rule> {
85 // Both spellings: the bulk PUT sends `firewall_rules.firewall_rule[]`, the
86 // single POST sends one `firewall_rule`. A door that took only one of them
87 // would pass or fail on the provider's choice of call rather than on the
88 // rules themselves.
89 let arr: Vec<Value> = match body["firewall_rules"]["firewall_rule"].as_array() {
90 Some(a) => a.clone(),
91 None => match body["firewall_rule"].as_object() {
92 Some(_) => vec![body["firewall_rule"].clone()],
93 None => vec![],
94 },
95 };
96 arr.iter()
97 .enumerate()
98 .map(|(i, v)| Rule {
99 position: v["position"].as_str().map(str::to_string).unwrap_or_else(|| (i + 1).to_string()),
100 direction: s(v, "direction", "in"),
101 action: s(v, "action", "drop"),
102 family: s(v, "family", "IPv4"),
103 protocol: s(v, "protocol", ""),
104 source_address_start: s(v, "source_address_start", ""),
105 source_address_end: s(v, "source_address_end", ""),
106 source_port_start: s(v, "source_port_start", ""),
107 source_port_end: s(v, "source_port_end", ""),
108 destination_address_start: s(v, "destination_address_start", ""),
109 destination_address_end: s(v, "destination_address_end", ""),
110 destination_port_start: s(v, "destination_port_start", ""),
111 destination_port_end: s(v, "destination_port_end", ""),
112 icmp_type: s(v, "icmp_type", ""),
113 comment: s(v, "comment", ""),
114 })
115 .collect()
116}
117
118/// A field that may arrive as a string or a number, rendered as the string the
119/// API sends. `position` and the ports are the ones that actually arrive both
120/// ways.
121fn s(v: &Value, key: &str, default: &str) -> String {
122 match &v[key] {
123 Value::String(x) => x.clone(),
124 Value::Number(n) => n.to_string(),
125 _ => default.to_string(),
126 }
127}
128
129pub fn render_rule(r: &Rule) -> Value {
130 json!({
131 "position": r.position,
132 "direction": r.direction,
133 "action": r.action,
134 "family": r.family,
135 "protocol": r.protocol,
136 "source_address_start": r.source_address_start,
137 "source_address_end": r.source_address_end,
138 "source_port_start": r.source_port_start,
139 "source_port_end": r.source_port_end,
140 "destination_address_start": r.destination_address_start,
141 "destination_address_end": r.destination_address_end,
142 "destination_port_start": r.destination_port_start,
143 "destination_port_end": r.destination_port_end,
144 "icmp_type": r.icmp_type,
145 "comment": r.comment,
146 })
147}
148
149pub fn render_rules(rules: &[Rule]) -> Value {
150 json!({ "firewall_rules": { "firewall_rule": rules.iter().map(render_rule).collect::<Vec<_>>() } })
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 /// The plan table the endpoint advertises is the plan table the create
158 /// accepts. Two lists would let terraform pick a plan the mock then refuses
159 /// with `INVALID_PLAN`, which reads as an estate defect and is not one.
160 #[test]
161 fn every_advertised_plan_is_a_plan_the_create_accepts() {
162 let p = plans();
163 let rows = p["plans"]["plan"].as_array().expect("plans").clone();
164 assert!(!rows.is_empty());
165 for r in rows {
166 let name = r["name"].as_str().expect("a plan has a name");
167 assert!(crate::render::plan_known(name), "{name} is advertised and would be refused");
168 }
169 }
170
171 /// The bulk PUT and the single POST are the same rules. The provider picks
172 /// one; the estate's declaration must not depend on which.
173 #[test]
174 fn a_rule_reads_the_same_from_either_spelling() {
175 let one = json!({"firewall_rule": {"action":"accept","direction":"in","protocol":"tcp",
176 "destination_port_start": 443, "destination_port_end":"443","comment":"HTTPS"}});
177 let bulk = json!({"firewall_rules": {"firewall_rule": [one["firewall_rule"].clone()]}});
178 let a = rules_from_body(&one);
179 let b = rules_from_body(&bulk);
180 assert_eq!(a.len(), 1);
181 assert_eq!(a[0].comment, "HTTPS");
182 // A number on the wire reads as the string the API sends back.
183 assert_eq!(a[0].destination_port_start, "443");
184 assert_eq!(a[0].destination_port_start, b[0].destination_port_start);
185 assert_eq!(a[0].position, "1");
186 }
187
188 /// A create body with no networking block keeps the pair every other caller
189 /// in this crate has always had.
190 #[test]
191 fn an_absent_networking_block_is_the_old_pair() {
192 assert_eq!(interfaces_from_body(&json!({})), Iface::default_pair());
193 let tf = json!({"networking": {"interfaces": {"interface": [
194 {"index": 1, "type": "public"}, {"index": 2, "type": "utility"}]}}});
195 let got = interfaces_from_body(&tf);
196 assert_eq!(got.len(), 2);
197 assert_eq!(got[1].kind, "utility");
198 }
199}