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/held_one_box.rs

//! ★ **SEC-S1, the watcher's half (sec review 2026-11-01)**: the watcher never starts a ONE box
//! its operator stopped on purpose. The operator holds it with a marker file named after the
//! box in the watcher's `hold_dir` (its first line says why); an applied run then starts
//! nothing of it and says so, and a box without a marker is started as before. Against the
//! UpCloud API 1.3 as mock-upcloud serves it on loopback, never the account.

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

use monetize_box_watcher::{run, Config};
use serde_json::{json, Value};

const TOKEN: &str = "ucat_mock_box_watcher_hold_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": "soft", "timeout": "60"}}));
    assert!(status < 300, "stop {uuid}: {status} {body}");
    until(mock, uuid, "stopped").await;
}

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()
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_one_box_its_operator_holds_is_never_started_and_the_run_says_why() {
    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 held = create(&mock, "monetize-one");
    let other = create(&mock, "monetize-one-test2");
    // The operator stops the ONE box on purpose (a disk move), and UpCloud stops the other.
    stop(&mock, &held).await;
    stop(&mock, &other).await;

    let dir = std::env::temp_dir().join(format!("box-watcher-hold-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    let holds = dir.join("hold");
    std::fs::create_dir_all(&holds).unwrap();
    std::fs::write(holds.join("monetize.ignalina.dk"), "rickard: moving its data disk, 2026-11-02\n").unwrap();
    let secrets = dir.join("secrets");
    std::fs::write(&secrets, format!("UPCLOUD_TOKEN={TOKEN}\n")).unwrap();
    let toml = format!(
        "apply = true\napi_base = {base:?}\nhold_dir = {holds:?}\n\n[[account]]\nname = \"gunnar\"\nsecrets_file = {secrets:?}\ntoken_key = \"UPCLOUD_TOKEN\"\n\n[[box]]\nname = \"monetize.ignalina.dk\"\nuuid = {held:?}\n\n[[box]]\nname = \"test2.monetize.ignalina.dk\"\nuuid = {other:?}\n",
        holds = holds.display().to_string(),
        secrets = secrets.display().to_string(),
    );
    let cfg: Config = toml::from_str(&toml).unwrap();
    let out = std::thread::spawn(move || {
        let (apis, refused) = monetize_box_watcher::open_accounts(&cfg);
        run(&cfg, &apis, &refused, &|_: &str| Vec::new())
    })
    .join()
    .unwrap();
    let lines: Vec<String> = out.iter().map(|(n, v)| v.line(n)).collect();
    assert_eq!(starts(&mock), vec![other.clone()], "only the box nobody holds is started: {lines:#?}");
    assert_eq!(state(&mock, &held), "stopped", "the held ONE box stays stopped");
    let said = &lines[0];
    assert!(said.starts_with("held monetize.ignalina.dk") && said.contains(&held) && said.contains("moving its data disk") && said.contains("monetize.ignalina.dk"), "the run says it is held, and why: {said}");
    // The marker removed: the next run starts it.
    std::fs::remove_file(holds.join("monetize.ignalina.dk")).unwrap();
    let cfg: Config = toml::from_str(&toml).unwrap();
    std::thread::spawn(move || {
        let (apis, refused) = monetize_box_watcher::open_accounts(&cfg);
        run(&cfg, &apis, &refused, &|_: &str| Vec::new())
    })
    .join()
    .unwrap();
    assert_eq!(starts(&mock), vec![other, held], "released, it is started");
    let _ = std::fs::remove_dir_all(&dir);
}