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

nordisk/gunnar

crates/gunnar-loadbench/src/upcloud.rs

//! **`--from-upcloud`** — measure from a rented VM instead of from the owner's
//! home link, so the numbers are about the estate rather than about a domestic
//! uplink.
//!
//! ## What this module does, and what it refuses to do
//!
//! It runs the **read-only preflight in full**: `GET /1.3/account` (does the
//! token work at all, what are the resource limits, what credit is left),
//! `GET /1.3/server` (the estate as the API sees it — names, zones, plans,
//! states), `GET /1.3/zone` and `GET /1.3/plan` (could a temporary VM be placed
//! at all, and where). Every one of those is a GET; nothing here can create,
//! resize, stop or delete anything, on this account or any other.
//!
//! It then **refuses to provision**, and says why rather than inventing a
//! result. The reason is not IAM and is not stock — both were measured and both
//! are fine — it is this:
//!
//! > The estate has exactly ONE UpCloud client, `private-gunnar-ops/xtask/
//! > src/upcloud.rs`. The brief says to reuse it and not to write a second one,
//! > and that is right: a second client is a second set of credentials, a
//! > second delete path and a second chance to point one at the wrong account
//! > (the same file already carries the scar of a token that meant two
//! > accounts). But that client has **no create verb at all** — it lists,
//! > classifies and, with `--delete`, removes orphans — and its `post`/`delete`
//! > are private to the module. Adding the verb means editing that repo, and
//! > that repo is checked out on branch `ops-gate` with four files already
//! > modified by another lane. Writing into another lane's working tree to
//! > build a bench is not a trade this bench gets to make.
//!
//! So the honest state is recorded here and reported at the top of the run:
//! the remote vantage is **designed, preflighted and not executed**. Completing
//! it is one reviewed commit in `private-gunnar-ops` — a `pub fn create_server`
//! / `pub fn server_state` / `pub fn destroy_server` on the client that already
//! exists — and then this module's [`Provision`] trait has an implementation.
//!
//! ## What the destroy contract must be when that commit lands
//!
//! Recorded now, while the reasoning is fresh, because this is the part that
//! goes wrong: **the VM is destroyed on the success path and on every error
//! path, and the destroy is verified by reading the API back** until the server
//! is gone (a `404` on `GET /1.3/server/{uuid}`), not by trusting the `DELETE`'s
//! own status. A leaked benchmark VM bills forever and looks exactly like an
//! orphan to `cargo xtask upcloud-orphans`.

use std::time::Duration;

use serde_json::Value;

use crate::args::Args;

/// The estate's machines as the API knows them. `njord` shares this account and
/// is never named, never listed and never touched: the filter is applied to the
/// API's answer before anything else looks at it, exactly as
/// `private-gunnar-ops/xtask/src/upcloud.rs` does it.
const PREFIX: &str = "gunnar";

/// The one thing this module cannot do yet. Named as a trait so the missing
/// half is a shape somebody can implement rather than a comment.
#[allow(dead_code)] // the shape the missing half must implement; see the module header
pub trait Provision {
    /// Create ONE temporary VM, returning its uuid.
    fn create(&self, hostname: &str, zone: &str, plan: &str) -> Result<String, String>;
    /// Destroy it, and do not return until `GET /server/{uuid}` says it is gone.
    fn destroy_verified(&self, uuid: &str) -> Result<(), String>;
}

fn token() -> Result<String, String> {
    // The same file and the same key every other command in the estate reads,
    // so this cannot end up pointed at a different account. Never printed.
    let home = std::env::var("HOME").map_err(|_| "HOME is not set")?;
    let path = std::env::var("GUNNAR_SECRETS").unwrap_or_else(|_| format!("{home}/gunnar_secrets"));
    let text = std::fs::read_to_string(&path).map_err(|e| format!("cannot read {path}: {e}"))?;
    for line in text.lines() {
        let line = line.trim().trim_start_matches("export ").trim();
        if let Some(v) = line.strip_prefix("UPCLOUD_TOKEN=") {
            let v = v.split('#').next().unwrap_or("").trim().trim_matches('"');
            if !v.is_empty() {
                return Ok(v.to_string());
            }
        }
    }
    Err(format!("{path} has no UPCLOUD_TOKEN"))
}

/// One read, judged: a non-2xx is refused with the API's own words.
fn must(what: &str, r: Result<upcloud_api::Reply, String>) -> Result<Value, String> {
    let r = r?;
    if !r.ok() {
        return Err(r.describe_failure(what));
    }
    Ok(r.body)
}

/// Run the preflight, print it, and then report the refusal. Returns the exit
/// code the process should use.
pub fn run_from_upcloud(a: &Args) -> Result<i32, String> {
    println!("gunnar-loadbench --from-upcloud — remote vantage");
    println!(
        "PLAN          {:.1} rps offered, {} in flight, {} requests per probe",
        a.rate_per_s, a.max_in_flight, a.requests_per_probe
    );
    println!("READS ONLY    GET /account, /server, /zone, /plan. This mode cannot create, stop, resize or delete anything.");

    // WHICH UpCloud answers: `--mock-api <loopback>` is a mock-upcloud, no
    // flag is the account — and the account refuses in a shell aimed at a
    // fake. Decided here, once; every read below goes through the ONE wire
    // (`upcloud-api`, edda), so a mock run tests these exact requests.
    let endpoint = upcloud_api::Endpoint::from_flag(a.mock_api.as_deref())?;
    println!("{}", endpoint.banner());
    let credential = if endpoint.is_account() {
        token()?
    } else {
        "mock".to_string()
    };
    let api = upcloud_api::connect(
        &endpoint,
        upcloud_api::Credential::Token(credential),
        upcloud_api::Options {
            call_timeout: Duration::from_secs(30),
            ..upcloud_api::Options::default()
        },
    );

    // ── IAM ──────────────────────────────────────────────────────────────────
    let acct = must("GET /account", api.account())?;
    let user = acct
        .pointer("/account/username")
        .and_then(Value::as_str)
        .unwrap_or("?");
    let credits = acct
        .pointer("/account/credits")
        .and_then(Value::as_f64)
        .unwrap_or(0.0);
    let core_limit = acct
        .pointer("/account/resource_limits/cores")
        .and_then(Value::as_i64)
        .unwrap_or(0);
    println!("\nIAM           username {user}, credits {credits:.0}, core limit {core_limit} — the token works");

    // ── the estate, as the API sees it ───────────────────────────────────────
    let servers = must("GET /server", api.servers())?;
    let list = servers
        .pointer("/servers/server")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut others = 0usize;
    println!("\nESTATE (from the API; machines not named {PREFIX}* are counted, never listed)");
    for s in &list {
        let host = s.get("hostname").and_then(Value::as_str).unwrap_or("");
        if !host.starts_with(PREFIX) {
            others += 1;
            continue;
        }
        println!(
            "  {:<20} {:<10} {:<18} {}",
            host,
            s.get("zone").and_then(Value::as_str).unwrap_or("?"),
            s.get("plan").and_then(Value::as_str).unwrap_or("?"),
            s.get("state").and_then(Value::as_str).unwrap_or("?"),
        );
    }
    println!("  ({others} other machine(s) on this account, untouched and unnamed)");

    // ── could a temporary VM be placed? ──────────────────────────────────────
    let zones = must("GET /zone", api.zones())?;
    let nz = zones
        .pointer("/zones/zone")
        .and_then(Value::as_array)
        .map(|v| v.len())
        .unwrap_or(0);
    let plans = must("GET /plan", api.plans())?;
    let cheapest = plans
        .pointer("/plans/plan")
        .and_then(Value::as_array)
        .map(|v| {
            v.iter()
                .filter_map(|p| p.get("name").and_then(Value::as_str))
                .filter(|n| n.starts_with("DEV-1xCPU-1GB"))
                .map(String::from)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    println!(
        "\nCAPACITY      {nz} zones offered; a temporary VM would be {} in the estate's own zone",
        cheapest
            .first()
            .map(String::as_str)
            .unwrap_or("DEV-1xCPU-1GB")
    );
    println!("              nothing was reserved and nothing was created by this check");

    // ── the refusal ──────────────────────────────────────────────────────────
    println!("\nREMOTE RUN    NOT EXECUTED — and not faked.");
    println!("              Not IAM: the token answered 200 on every read above.");
    println!("              Not stock: the zone and plan catalogues are live.");
    println!("              The reason: the estate's ONE UpCloud client lives in");
    println!(
        "              private-gunnar-ops/xtask/src/upcloud.rs, it has no create verb, and its"
    );
    println!(
        "              post()/delete() are private. That repo is on branch `ops-gate` with four"
    );
    println!(
        "              files modified by ANOTHER LANE. Writing a second UpCloud client is what the"
    );
    println!(
        "              brief forbids; writing into another lane's tree is not this bench's call."
    );
    println!(
        "              Unblock: one reviewed commit there adding create_server / server_state /"
    );
    println!(
        "              destroy_server to the client that already exists, then implement Provision."
    );
    println!("\n              Measure from here in the meantime: drop --from-upcloud.");

    Err("the remote vantage is preflighted but not implemented (see above)".to_string())
}

#[cfg(test)]
mod tests {
    /// The account filter is the rule njord's continued existence depends on.
    #[test]
    fn the_prefix_filter_excludes_the_neighbour() {
        assert!("gunnar-front".starts_with(super::PREFIX));
        assert!("gunnar-appliance".starts_with(super::PREFIX));
        assert!(!"njord".starts_with(super::PREFIX));
    }
}

#[cfg(test)]
mod one_client {
    /// Nothing in this crate spells the provider or builds an API path: the
    /// `--from-upcloud` reads go through `upcloud-api`'s ONE wire, which a
    /// `--mock-api` run reaches with the very same requests.
    #[test]
    fn nothing_in_this_crate_spells_the_provider() {
        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        upcloud_api::guard::assert_none(&[&src], &[]);
    }
}