Skip to main content

upcloud_api/
mock_door.rs

1//! **Behaviour 63's guard: did the mock actually HEAR this run?**
2//!
3//! terraform's UpCloud provider reaches a mock only through its undocumented
4//! `UPCLOUD_DEBUG_API_BASE_URL`. A provider release that drops that knob sends
5//! a "mock" `apply` or `destroy` to THE ACCOUNT — with whatever token the shell
6//! carried. The guard (lane T13's ledger, behaviour 63; lane T14 wires it):
7//!
8//! 1. a mock run presents a token minted for THIS run ([`mint_token`]) — never
9//!    a token of the account, so a provider that ignored the knob is refused by
10//!    the account with a 401 instead of acting;
11//! 2. before `apply`/`destroy`, a read-only step (a `plan`) runs with it;
12//! 3. [`require_heard`] asks the mock's `/mock/heard` door whether that token's
13//!    requests — and a `GET /1.3/account` among them — arrived. Zero is a
14//!    refusal by name: the provider spoke to somebody, and it was not the mock.
15
16use crate::Endpoint;
17
18/// A bearer for ONE mock run: `ucat_mock_<pid>_<nanos>`. It is not a secret;
19/// it is a name the mock can hear.
20pub fn mint_token() -> String {
21    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
22    format!("ucat_mock_{}_{nanos}", std::process::id())
23}
24
25/// What the mock heard from one token.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Heard {
28    pub requests: u64,
29    pub account_calls: u64,
30}
31
32/// Ask the mock at `endpoint` what it heard from `token`. The account has no
33/// such door: asking it is a programming error, refused by name.
34pub fn heard(endpoint: &Endpoint, token: &str) -> Result<Heard, String> {
35    let Endpoint::Mock(base) = endpoint else {
36        return Err("REFUSED [heard-asked-of-the-account] /mock/heard is a door of the mock; the account has none".into());
37    };
38    let root = base.as_str().trim_end_matches("/1.3");
39    let digest = nornir_hash::sha256_hex(token.as_bytes());
40    let url = format!("{root}/mock/heard?token_sha256={digest}");
41    let cfg = ureq::Agent::config_builder().http_status_as_error(false).timeout_global(Some(std::time::Duration::from_secs(10))).build();
42    let mut res = ureq::Agent::new_with_config(cfg).get(&url).call().map_err(|e| format!("GET /mock/heard: {e}"))?;
43    let status = res.status().as_u16();
44    let text = res.body_mut().read_to_string().map_err(|e| format!("GET /mock/heard: {e}"))?;
45    if status != 200 {
46        return Err(format!("GET /mock/heard answered {status} — {} (a mock without behaviour 63 cannot vouch for a run)", text.trim()));
47    }
48    let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("GET /mock/heard: not JSON ({e}): {text}"))?;
49    Ok(Heard {
50        requests: v["requests"].as_u64().unwrap_or(0),
51        account_calls: v["account_calls"].as_u64().unwrap_or(0),
52    })
53}
54
55/// **Refuse `what` unless the mock heard `token`**, including a
56/// `GET /1.3/account`. `Ok` carries what was heard, for the transcript.
57pub fn require_heard(endpoint: &Endpoint, token: &str, what: &str) -> Result<Heard, String> {
58    let h = heard(endpoint, token)?;
59    if h.account_calls == 0 {
60        return Err(format!(
61            "REFUSED [mock-never-heard-this-run] before {what}: the mock at {} heard {} request(s) and NO `GET \
62             /1.3/account` from this run's token. The client that was supposed to be aimed at it spoke to somebody \
63             else — the likeliest is a terraform provider that no longer honours UPCLOUD_DEBUG_API_BASE_URL, which \
64             would send this {what} to THE ACCOUNT. Nothing was applied.",
65            endpoint.base_for_display(),
66            h.requests
67        ));
68    }
69    Ok(h)
70}