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

nordisk/monetize

crates/monetize-box-watcher/tests/against_the_mock.rs

//! The watcher against the UpCloud API 1.3 as mock-upcloud serves it on loopback (never the
//! account): UpCloud stops every server; a dry run starts nothing; an applied run starts the
//! stopped ONE boxes and NOTHING else (the ONE box starts its estates itself); a token that
//! cannot be read is named, never shown.

use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;

use monetize_box_watcher::{run, Account, Api, Config, Verdict, WatchedBox};
use serde_json::{json, Value};

const TOKEN: &str = "ucat_mock_box_watcher_never_the_account";

fn create(mock: &Arc<mock_upcloud::Mock>, host: &str) -> String {
    let (status, body) = mock_upcloud::http::probe(mock, "POST", "/1.3/server", json!({"server": {"zone": "se-sto1", "title": host, "hostname": host, "plan": "1xCPU-2GB", "storage_devices": {"storage_device": [{"action": "create", "title": "boot", "size": 10, "tier": "maxiops"}]}}}));
    assert!(status < 300, "create {host}: {status} {body}");
    body["server"]["uuid"].as_str().unwrap().to_string()
}

fn state(mock: &Arc<mock_upcloud::Mock>, uuid: &str) -> String {
    mock_upcloud::http::probe(mock, "GET", &format!("/1.3/server/{uuid}"), Value::Null).1["server"]["state"].as_str().unwrap_or_default().to_string()
}

async fn until(mock: &Arc<mock_upcloud::Mock>, uuid: &str, want: &str) {
    for _ in 0..1000 {
        if state(mock, uuid) == want {
            return;
        }
        tokio::time::sleep(Duration::from_millis(10)).await;
    }
    panic!("{uuid} never read {want}: {}", state(mock, uuid));
}

async fn stop(mock: &Arc<mock_upcloud::Mock>, uuid: &str) {
    until(mock, uuid, "started").await;
    let (status, body) = mock_upcloud::http::probe(mock, "POST", &format!("/1.3/server/{uuid}/stop"), json!({"stop_server": {"stop_type": "hard", "timeout": "60"}}));
    assert!(status < 300, "stop {uuid}: {status} {body}");
    until(mock, uuid, "stopped").await;
}

fn public_ip(mock: &Arc<mock_upcloud::Mock>, uuid: &str) -> String {
    let v = mock_upcloud::http::probe(mock, "GET", &format!("/1.3/server/{uuid}"), Value::Null).1;
    v["server"]["ip_addresses"]["ip_address"].as_array().unwrap().iter().find(|ip| ip["access"] == "public" && ip["family"] == "IPv4").map(|ip| ip["address"].as_str().unwrap().to_string()).expect("a public IPv4")
}

fn starts(mock: &Arc<mock_upcloud::Mock>) -> Vec<String> {
    mock.calls.as_ref().unwrap().lock().unwrap().iter().filter(|(_, m, p, _)| m == "POST" && p.ends_with("/start")).map(|(_, _, p, _)| p.trim_start_matches("/1.3/server/").trim_end_matches("/start").to_string()).collect()
}

fn writes(mock: &Arc<mock_upcloud::Mock>) -> usize {
    mock.calls.as_ref().unwrap().lock().unwrap().iter().filter(|(_, m, _, _)| m != "GET").count()
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_stopped_one_box_is_started_only_when_applied_and_nothing_else_is_touched() {
    let mock = mock_upcloud::Mock::recording(mock_upcloud::Estate::new(mock_upcloud::Clock::new(1_000_000), mock_upcloud::Faults::none(), 2024));
    let (port, _task) = mock_upcloud::serve(mock.clone(), "127.0.0.1:0").await.unwrap();
    let base = format!("http://127.0.0.1:{port}");
    let one = create(&mock, "monetize-one");
    let one_t2 = create(&mock, "monetize-one-test2");
    let main = create(&mock, "gunnar-main-acme");
    let twin = create(&mock, "gunnar-twin-acme");
    // UpCloud stops every server in the account.
    for u in [&one, &one_t2, &main, &twin] {
        stop(&mock, u).await;
    }
    let one_t2_ip = public_ip(&mock, &one_t2);

    let dir = std::env::temp_dir().join(format!("box-watcher-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let secrets = dir.join("secrets");
    std::fs::write(&secrets, format!("# a secret file\nOTHER=1\nexport UPCLOUD_TOKEN=\"{TOKEN}\"\n")).unwrap();
    let mut cfg = Config {
        apply: false,
        hold_dir: None,
        api_base: base.clone(),
        accounts: vec![Account { name: "gunnar".into(), secrets_file: secrets.clone(), token_key: "UPCLOUD_TOKEN".into() }, Account { name: "nokey".into(), secrets_file: secrets.clone(), token_key: "NO_SUCH_TOKEN".into() }],
        boxes: vec![
            WatchedBox { name: "monetize.ignalina.dk".into(), uuid: one.clone(), address: String::new(), account: "gunnar".into() },
            // Found by the address its DNS name resolves to (the resolver is the test's).
            WatchedBox { name: "test2.monetize.ignalina.dk".into(), uuid: String::new(), address: "test2.monetize.mock.invalid".into(), account: String::new() },
        ],
    };
    let ip = one_t2_ip.clone();
    let resolver = move |name: &str| if name == "test2.monetize.mock.invalid" { vec![ip.clone()] } else { Vec::new() };
    let tokio_free = |cfg: Config| {
        let r = resolver.clone();
        std::thread::spawn(move || {
            let (apis, refused): (BTreeMap<String, Api>, BTreeMap<String, String>) = monetize_box_watcher::open_accounts(&cfg);
            assert!(refused.get("nokey").is_some_and(|w| w.contains("NO_SUCH_TOKEN") && !w.contains(TOKEN)), "a missing key is named, never a value: {refused:?}");
            run(&cfg, &apis, &refused, &r)
        })
        .join()
        .unwrap()
    };
    let writes_before = writes(&mock);

    // ── a dry run: it says what it would do, and starts nothing ──────────────────────────
    let dry = tokio_free(cfg.clone());
    assert_eq!(dry, vec![("monetize.ignalina.dk".to_string(), Verdict::WouldStart { uuid: one.clone() }), ("test2.monetize.ignalina.dk".to_string(), Verdict::WouldStart { uuid: one_t2.clone() })]);
    assert!(dry.iter().all(|(n, v)| v.line(n).starts_with("DRY-RUN") && !v.line(n).contains(TOKEN)));
    assert_eq!(writes(&mock), writes_before, "a dry run writes nothing at the provider");

    // ── applied: the two ONE boxes are started, the estate's boxes are not ───────────────
    cfg.apply = true;
    let applied = tokio_free(cfg.clone());
    assert_eq!(applied, vec![("monetize.ignalina.dk".to_string(), Verdict::Started { uuid: one.clone() }), ("test2.monetize.ignalina.dk".to_string(), Verdict::Started { uuid: one_t2.clone() })]);
    assert_eq!(starts(&mock), vec![one.clone(), one_t2.clone()], "only the ONE boxes are started");
    until(&mock, &one, "started").await;
    until(&mock, &one_t2, "started").await;
    assert_eq!(state(&mock, &main), "stopped", "the estate's main is the ONE box's to start");
    assert_eq!(state(&mock, &twin), "stopped");

    // ── the next run finds them running and does nothing ─────────────────────────────────
    let again = tokio_free(cfg.clone());
    assert_eq!(again, vec![("monetize.ignalina.dk".to_string(), Verdict::Running { uuid: one.clone() }), ("test2.monetize.ignalina.dk".to_string(), Verdict::Running { uuid: one_t2.clone() })]);
    assert_eq!(starts(&mock).len(), 2, "nothing started twice");

    // ── a box in no account is an ALERT by name ──────────────────────────────────────────
    cfg.boxes = vec![WatchedBox { name: "ghost".into(), uuid: "00000000-0000-0000-0000-000000000000".into(), address: String::new(), account: String::new() }];
    let ghost = tokio_free(cfg);
    assert!(matches!(&ghost[0].1, Verdict::NotFound { why } if why.contains("no such server") && why.contains("NO_SUCH_TOKEN")), "{ghost:?}");
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn a_token_is_read_by_key_and_never_shown() {
    let dir = std::env::temp_dir().join(format!("box-watcher-tok-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let f = dir.join("s");
    std::fs::write(&f, "A=1\nUPCLOUD_TOKEN='ucat_secret_value'\nEMPTY=\n").unwrap();
    let t = monetize_box_watcher::read_token(&f, "UPCLOUD_TOKEN").unwrap();
    assert_eq!(format!("{t:?}"), "Token(<redacted>)");
    let e = monetize_box_watcher::read_token(&f, "EMPTY").unwrap_err();
    assert!(e.contains("EMPTY") && !e.contains("ucat_secret_value"), "{e}");
    assert!(monetize_box_watcher::read_token(&f, "MISSING").unwrap_err().contains("no MISSING"));
    let _ = std::fs::remove_dir_all(&dir);
}