Skip to main content

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}