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