upcloud-api 0.1.4

The UpCloud API 1.3 surface the nordisk estates use, as ONE trait (`UpCloudApi`) with ONE wire implementation. Which cloud a run talks to (the account, or a mock-upcloud on loopback) is an `Endpoint` decided once at the edge, and a mock endpoint cannot be pointed off this machine. The fake that answers the trait in-process lives beside mock-upcloud's state machine.
Documentation
//! **Behaviour 63's guard: did the mock actually HEAR this run?**
//!
//! terraform's UpCloud provider reaches a mock only through its undocumented
//! `UPCLOUD_DEBUG_API_BASE_URL`. A provider release that drops that knob sends
//! a "mock" `apply` or `destroy` to THE ACCOUNT — with whatever token the shell
//! carried. The guard (lane T13's ledger, behaviour 63; lane T14 wires it):
//!
//! 1. a mock run presents a token minted for THIS run ([`mint_token`]) — never
//!    a token of the account, so a provider that ignored the knob is refused by
//!    the account with a 401 instead of acting;
//! 2. before `apply`/`destroy`, a read-only step (a `plan`) runs with it;
//! 3. [`require_heard`] asks the mock's `/mock/heard` door whether that token's
//!    requests — and a `GET /1.3/account` among them — arrived. Zero is a
//!    refusal by name: the provider spoke to somebody, and it was not the mock.

use crate::Endpoint;

/// A bearer for ONE mock run: `ucat_mock_<pid>_<nanos>`. It is not a secret;
/// it is a name the mock can hear.
pub fn mint_token() -> String {
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
    format!("ucat_mock_{}_{nanos}", std::process::id())
}

/// What the mock heard from one token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Heard {
    pub requests: u64,
    pub account_calls: u64,
}

/// Ask the mock at `endpoint` what it heard from `token`. The account has no
/// such door: asking it is a programming error, refused by name.
pub fn heard(endpoint: &Endpoint, token: &str) -> Result<Heard, String> {
    let Endpoint::Mock(base) = endpoint else {
        return Err("REFUSED [heard-asked-of-the-account] /mock/heard is a door of the mock; the account has none".into());
    };
    let root = base.as_str().trim_end_matches("/1.3");
    let digest = nornir_hash::sha256_hex(token.as_bytes());
    let url = format!("{root}/mock/heard?token_sha256={digest}");
    let cfg = ureq::Agent::config_builder().http_status_as_error(false).timeout_global(Some(std::time::Duration::from_secs(10))).build();
    let mut res = ureq::Agent::new_with_config(cfg).get(&url).call().map_err(|e| format!("GET /mock/heard: {e}"))?;
    let status = res.status().as_u16();
    let text = res.body_mut().read_to_string().map_err(|e| format!("GET /mock/heard: {e}"))?;
    if status != 200 {
        return Err(format!("GET /mock/heard answered {status}{} (a mock without behaviour 63 cannot vouch for a run)", text.trim()));
    }
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("GET /mock/heard: not JSON ({e}): {text}"))?;
    Ok(Heard {
        requests: v["requests"].as_u64().unwrap_or(0),
        account_calls: v["account_calls"].as_u64().unwrap_or(0),
    })
}

/// **Refuse `what` unless the mock heard `token`**, including a
/// `GET /1.3/account`. `Ok` carries what was heard, for the transcript.
pub fn require_heard(endpoint: &Endpoint, token: &str, what: &str) -> Result<Heard, String> {
    let h = heard(endpoint, token)?;
    if h.account_calls == 0 {
        return Err(format!(
            "REFUSED [mock-never-heard-this-run] before {what}: the mock at {} heard {} request(s) and NO `GET \
             /1.3/account` from this run's token. The client that was supposed to be aimed at it spoke to somebody \
             else — the likeliest is a terraform provider that no longer honours UPCLOUD_DEBUG_API_BASE_URL, which \
             would send this {what} to THE ACCOUNT. Nothing was applied.",
            endpoint.base_for_display(),
            h.requests
        ));
    }
    Ok(h)
}