Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/mock-upcloud/tests/terraform_contract.rs

//! **The terraform provider's request contract, proven against the mock.**
//!
//! We do NOT build a fake terraform provider. The real one —
//! `UpCloudLtd/upcloud` **5.44.1**, the binary each estate is applied with —
//! runs against the mock's HTTP face through `UPCLOUD_DEBUG_API_BASE_URL`, so
//! the provider code under test is the real code and only the API underneath
//! is fake. This test drives BOTH estates' declarations (private-gunnar-ops
//! and private-holger-ops `terraform/`) through a full lay-down, a re-plan
//! that must show no changes, and a destroy, and fails on:
//!
//! * any `MOCK_UPCLOUD_NOT_IMPLEMENTED`, or any refusal at all during a
//!   lay-down of a declaration the account has accepted;
//! * any request outside [`CONTRACT`], the calls UpCloud answers and 5.44.1
//!   was measured sending (BEHAVIOURS-LEDGER.md §9);
//! * a provider in the lock file that is not the one this was proven against
//!   ([`PROVEN_PROVIDER`]) — an upgrade fails here instead of silently sending
//!   calls the mock has never seen;
//! * a provider that did not talk to THIS mock (behaviour 63): before `apply`
//!   the test asks `/mock/heard` whether this run's token made the provider's
//!   `GET /1.3/account`, and refuses to apply if it did not. That is the guard
//!   every mock verb must copy: `UPCLOUD_DEBUG_API_BASE_URL` is undocumented,
//!   and a release that dropped it would send a "mock" run to the account.
//!
//! Known past breaks it keeps covered: `storage_size` must be an integer; the
//! base URL is given WITHOUT `/1.3` (the provider appends it — `/1.3/1.3/account`
//! was the NOT_IMPLEMENTED); the mock runs at `--speed 20000` (20×), never 0.
//!
//! It needs a terraform binary, the provider in each declaration's
//! `.terraform/providers` and the two private checkouts beside this one. On a box
//! without them (a build container) it says SKIP and what it lacks, by name; on a
//! box with them it runs:
//!
//! ```text
//! cargo test -p mock-upcloud --test terraform_contract -- --nocapture
//! ```
//!
//! `MOCK_UPCLOUD_TF_DECLARATIONS` (`:`-separated directories) overrides where
//! the declarations are read from. Only `*.tf` and `estate.auto.tfvars.json`
//! are copied into a scratch directory: never a state file, never a
//! `terraform.tfvars` (it holds real keys). The real account is never reached:
//! the token is fake, every `UPCLOUD_*` variable of the caller is removed, and
//! the base URL is loopback.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

/// The provider this contract was measured against. The lock file must say it.
const PROVEN_PROVIDER: &str = "5.44.1";

/// Every call 5.44.1 sent across both estates' init/plan/apply/refresh/destroy
/// — measured 2026-09-21 with `--log` — and every one of them is a call UpCloud
/// answers. `{u}` is a uuid.
const CONTRACT: &[&str] = &[
    "GET /1.3/account",
    "GET /1.3/plan",
    "GET /1.3/zone",
    "GET /1.3/storage/template",
    "POST /1.3/server",
    "GET /1.3/server/{u}",
    "PUT /1.3/server/{u}",
    "DELETE /1.3/server/{u}",
    "POST /1.3/server/{u}/stop",
    "POST /1.3/server/{u}/start",
    "GET /1.3/server/{u}/firewall_rule",
    "PUT /1.3/server/{u}/firewall_rule",
    "POST /1.3/storage",
    "GET /1.3/storage/{u}",
    "PUT /1.3/storage/{u}",
    "DELETE /1.3/storage/{u}",
    "POST /1.3/storage/{u}/resize",
];

fn shape(path: &str) -> String {
    path.split('/')
        .map(|s| if s.len() == 36 && s.chars().filter(|c| *c == '-').count() == 4 { "{u}" } else { s })
        .collect::<Vec<_>>()
        .join("/")
}

fn declarations() -> Vec<PathBuf> {
    if let Ok(v) = std::env::var("MOCK_UPCLOUD_TF_DECLARATIONS") {
        return v.split(':').filter(|s| !s.is_empty()).map(PathBuf::from).collect();
    }
    // crates/mock-upcloud → the monetize checkout → its siblings.
    let here = Path::new(env!("CARGO_MANIFEST_DIR"));
    let git = here.parent().and_then(Path::parent).and_then(Path::parent).expect("a checkout inside a directory");
    ["private-gunnar-ops", "private-holger-ops"].iter().map(|r| git.join(r).join("terraform")).collect()
}

/// What this box lacks for the contract (`None`: it has it all): a `terraform` on `path`, and in
/// each declaration its `main.tf` and the provider terraform installed in `.terraform/providers`.
fn lacks(path: Option<&std::ffi::OsStr>, decls: &[PathBuf]) -> Option<String> {
    let on_path = path.is_some_and(|p| std::env::split_paths(p).any(|d| d.join("terraform").is_file()));
    if !on_path {
        return Some("no `terraform` binary on PATH".into());
    }
    for d in decls {
        if !d.join("main.tf").is_file() {
            return Some(format!("no declaration at {}", d.join("main.tf").display()));
        }
        if !d.join(".terraform/providers").is_dir() {
            return Some(format!("no provider installed in {} (`terraform init` there)", d.join(".terraform/providers").display()));
        }
    }
    None
}

fn terraform() -> PathBuf {
    let path = std::env::var_os("PATH").expect("PATH");
    std::env::split_paths(&path)
        .map(|d| d.join("terraform"))
        .find(|p| p.is_file())
        .expect("a `terraform` binary on PATH (1.16.2 was the one this was proven with)")
}

/// A scratch copy that holds the declaration and nothing that could reach, or
/// describe, the real estate.
fn scratch(decl: &Path, port: u16, name: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("mock-upcloud-tf-contract-{}-{name}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    for e in std::fs::read_dir(decl).unwrap() {
        let p = e.unwrap().path();
        let n = p.file_name().unwrap().to_string_lossy().to_string();
        if n.ends_with(".tf") || n == "estate.auto.tfvars.json" {
            std::fs::copy(&p, dir.join(&n)).unwrap();
        }
    }
    std::fs::write(
        dir.join("mock.auto.tfvars"),
        "admin_ssh_keys = [\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA mock-contract-not-a-real-key\"]\n",
    )
    .unwrap();
    let mirror = decl.join(".terraform/providers");
    let proven = mirror.join(format!("registry.terraform.io/upcloudltd/upcloud/{PROVEN_PROVIDER}"));
    assert!(proven.is_dir(), "the provider this contract was proven against is not at {}", proven.display());
    std::fs::write(
        dir.join("terraformrc"),
        format!(
            "provider_installation {{\n  filesystem_mirror {{\n    path    = \"{}\"\n    include = [\"registry.terraform.io/upcloudltd/upcloud\"]\n  }}\n  direct {{\n    exclude = [\"registry.terraform.io/upcloudltd/upcloud\"]\n  }}\n}}\n",
            mirror.display()
        ),
    )
    .unwrap();
    let _ = port;
    dir
}

fn tf(dir: &Path, port: u16, token: &str, args: &[&str]) -> (i32, String) {
    let mut c = Command::new(terraform());
    c.current_dir(dir).args(args).arg("-no-color");
    for (k, _) in std::env::vars() {
        if k.starts_with("UPCLOUD_") || k.starts_with("TF_VAR_") {
            c.env_remove(k);
        }
    }
    c.env("UPCLOUD_TOKEN", token)
        .env("UPCLOUD_DEBUG_API_BASE_URL", format!("http://127.0.0.1:{port}"))
        .env("UPCLOUD_DEBUG_SKIP_CERTIFICATE_VERIFY", "1")
        .env("TF_CLI_CONFIG_FILE", dir.join("terraformrc"))
        .env("TF_IN_AUTOMATION", "1")
        .env("TF_INPUT", "0");
    let out = c.output().expect("run terraform");
    let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
    (out.status.code().unwrap_or(-1), text)
}

fn one_estate(decl: &Path) {
    let name = decl.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    // 20 000 ‰ = twenty times the account, still real waiting (never 0: the
    // provider's own wall-clock budgets expire before a halving clock closes).
    let estate = mock_upcloud::Estate::new(mock_upcloud::Clock::new(20_000), mock_upcloud::Faults::none(), 21);
    let mock = mock_upcloud::Mock::recording(estate);
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    let port = rt.block_on(async { mock_upcloud::serve(mock.clone(), "127.0.0.1:0").await.unwrap().0 });
    let token = format!("ucat_mock_contract_{}_{name}", std::process::id());
    let dir = scratch(decl, port, &name);

    let (c, t) = tf(&dir, port, &token, &["init", "-input=false"]);
    assert_eq!(c, 0, "{name}: init\n{t}");
    let lock = std::fs::read_to_string(dir.join(".terraform.lock.hcl")).unwrap();
    assert!(
        lock.contains(&format!("version     = \"{PROVEN_PROVIDER}\"")),
        "{name}: the lock is not the provider this contract was proven against ({PROVEN_PROVIDER}):\n{lock}"
    );

    // ── behaviour 63: the guard before anything that writes ──────────────────
    let (c, t) = tf(&dir, port, &token, &["plan", "-input=false"]);
    assert_eq!(c, 0, "{name}: first plan\n{t}");
    let heard = mock.heard_from(&mock_upcloud::digest::sha256_hex(token.as_bytes()));
    assert!(
        heard.account_calls > 0,
        "{name}: the provider never called THIS mock's /1.3/account with this run's token — \
         UPCLOUD_DEBUG_API_BASE_URL was not honoured. REFUSING to apply."
    );

    let (c, t) = tf(&dir, port, &token, &["apply", "-auto-approve", "-input=false"]);
    assert_eq!(c, 0, "{name}: apply\n{t}");
    let (c, t) = tf(&dir, port, &token, &["plan", "-detailed-exitcode", "-input=false"]);
    assert_eq!(c, 0, "{name}: a re-plan after the lay-down must show NO changes (2 = drift)\n{t}");
    // ★ The front's daily backup (2026-10-09): a declaration that carries a
    // `backup_rule` must find it ON the account after the lay-down — the
    // re-plan above is clean only if the rule round-trips.
    // The test's OWN reads below are not the provider's: their slice of the
    // recording is left out of the contract check.
    let ours_from = mock.calls.as_ref().unwrap().lock().unwrap().len();
    if std::fs::read_to_string(dir.join("main.tf")).unwrap_or_default().contains("backup_rule") {
        let rules = backup_rules(port, &token);
        // The gunnar front's system disk, or the monetize-admin box's — and
        // nothing else: main and the twin carry none (the twin IS main's backup).
        let backed = |t: &str| t.ends_with("system") && (t.contains("front") || t.contains("monetize-admin"));
        assert!(
            rules.iter().any(|(title, r)| backed(title) && r["interval"] == "daily" && r["time"] == "0300"),
            "{name}: the system disk carries no daily backup_rule after the apply: {rules:?}"
        );
        assert!(rules.iter().all(|(title, _)| backed(title)), "{name}: a backup_rule on a disk that must carry none: {rules:?}");
        eprintln!("{name}: backup rules on the account after the lay-down: {rules:?}");
    }
    let ours_to = mock.calls.as_ref().unwrap().lock().unwrap().len();
    let (c, t) = tf(&dir, port, &token, &["destroy", "-auto-approve", "-input=false"]);
    assert_eq!(c, 0, "{name}: destroy\n{t}");

    let calls: Vec<_> = mock.calls.as_ref().unwrap().lock().unwrap().iter().enumerate().filter(|(i, _)| *i < ours_from || *i >= ours_to).map(|(_, c)| c.clone()).collect();
    let not_impl: Vec<_> = calls.iter().filter(|c| c.3 == "MOCK_UPCLOUD_NOT_IMPLEMENTED").collect();
    assert!(not_impl.is_empty(), "{name}: the provider asked for calls the mock does not answer: {not_impl:?}");
    let refused: Vec<_> = calls.iter().filter(|c| c.0 >= 400 || c.0 == 0).collect();
    assert!(refused.is_empty(), "{name}: a clean lay-down met refusals: {refused:?}");
    let contract: BTreeSet<&str> = CONTRACT.iter().copied().collect();
    let seen: BTreeSet<String> = calls.iter().map(|c| format!("{} {}", c.1, shape(&c.2))).collect();
    let outside: Vec<&String> = seen.iter().filter(|s| !contract.contains(s.as_str())).collect();
    assert!(outside.is_empty(), "{name}: calls outside the measured contract: {outside:?}");
    for must in ["GET /1.3/account", "POST /1.3/server", "DELETE /1.3/server/{u}", "PUT /1.3/server/{u}/firewall_rule"] {
        assert!(seen.contains(must), "{name}: the cycle never sent {must}: {seen:?}");
    }
    eprintln!("{name}: {} calls, {} distinct, all inside the contract, provider {PROVEN_PROVIDER}", calls.len(), seen.len());
    let _ = std::fs::remove_dir_all(&dir);
}

/// `GET` over a plain loopback socket (the mock speaks HTTP/1.1 there), the
/// body as JSON. Only for the assertions after a lay-down.
fn get_json(port: u16, token: &str, path: &str) -> serde_json::Value {
    use std::io::{Read, Write};
    let mut c = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
    write!(c, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n").unwrap();
    let mut raw = Vec::new();
    c.read_to_end(&mut raw).unwrap();
    let text = String::from_utf8_lossy(&raw);
    let body = text.split_once("\r\n\r\n").map(|x| x.1).unwrap_or("");
    serde_json::from_str(body).unwrap_or(serde_json::Value::Null)
}

/// Every private storage's (title, backup_rule), read off each one's detail.
fn backup_rules(port: u16, token: &str) -> Vec<(String, serde_json::Value)> {
    let list = get_json(port, token, "/1.3/storage/private");
    let mut out = Vec::new();
    for s in list["storages"]["storage"].as_array().cloned().unwrap_or_default() {
        let uuid = s["uuid"].as_str().unwrap_or("").to_string();
        let d = get_json(port, token, &format!("/1.3/storage/{uuid}"));
        let rule = d["storage"]["backup_rule"].clone();
        if rule.as_object().map(|o| !o.is_empty()).unwrap_or(false) {
            out.push((s["title"].as_str().unwrap_or("").to_string(), rule));
        }
    }
    out
}

/// Runs where terraform, the provider and the declarations are; elsewhere it says SKIP by name.
/// Declarations named in `MOCK_UPCLOUD_TF_DECLARATIONS` are asked for: there a lack FAILS.
#[test]
fn the_real_provider_lays_down_replans_and_destroys_both_estates_against_the_mock() {
    let decls = declarations();
    assert!(!decls.is_empty());
    if let Some(why) = lacks(std::env::var_os("PATH").as_deref(), &decls) {
        assert!(std::env::var_os("MOCK_UPCLOUD_TF_DECLARATIONS").is_none(), "MOCK_UPCLOUD_TF_DECLARATIONS is set, and {why}");
        eprintln!("SKIP the_real_provider_lays_down_replans_and_destroys_both_estates_against_the_mock: {why}");
        return;
    }
    for d in &decls {
        assert!(d.join("main.tf").is_file(), "no declaration at {}", d.display());
        one_estate(d);
    }
}

/// ★ **The contract says what it lacks, by name** (score bar tests.no-ignore): no terraform on
/// PATH, no declaration, no provider installed in it. A box with all three runs it.
#[test]
fn the_contract_names_what_this_box_lacks() {
    let root = std::env::temp_dir().join(format!("tf-lacks-{}", std::process::id()));
    let (bin, decl) = (root.join("bin"), root.join("estate"));
    std::fs::create_dir_all(&bin).unwrap();
    let path = std::env::join_paths([&bin]).unwrap();
    let why = lacks(Some(&path), std::slice::from_ref(&decl)).expect("no terraform");
    assert!(why.contains("terraform"), "{why}");
    std::fs::write(bin.join("terraform"), b"").unwrap();
    let why = lacks(Some(&path), std::slice::from_ref(&decl)).expect("no declaration");
    assert!(why.contains(&decl.join("main.tf").display().to_string()), "{why}");
    std::fs::create_dir_all(&decl).unwrap();
    std::fs::write(decl.join("main.tf"), b"").unwrap();
    let why = lacks(Some(&path), std::slice::from_ref(&decl)).expect("no provider");
    assert!(why.contains(".terraform/providers"), "{why}");
    std::fs::create_dir_all(decl.join(".terraform/providers")).unwrap();
    assert_eq!(lacks(Some(&path), std::slice::from_ref(&decl)), None);
    assert!(lacks(None, &[decl]).is_some(), "no PATH at all");
    std::fs::remove_dir_all(&root).unwrap();
}

#[test]
fn a_uuid_is_shaped_and_a_word_is_not() {
    assert_eq!(shape("/1.3/server/59164595-4f8b-4fc9-8dfe-40f9f574a187/stop"), "/1.3/server/{u}/stop");
    assert_eq!(shape("/1.3/storage/template"), "/1.3/storage/template");
}