mock-upcloud 0.1.3

A faithful fake of the UpCloud API 1.3 — the lies included — backed by real KVM guests
Documentation
//! **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,
                "storage_tier": "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");
    }
}