Pre-unlock wave U3 (highest-ROI). Task 7 from CONVERGENCE-PLAN — consolidate 2 provision-*.sh scripts into Rust via Backend trait. Old shells (provision-hetzner.sh, provision-vultr.sh) had identical 6-subcommand surface (create|status|destroy|list), log/die/check_deps helpers, idempotency contract. Sole delta: hcloud vs vultr-cli. RULE 0.2 says Rust-first when >50 LOC + growth expected. New crate _primitives/_rust/kei-provision/: - src/backend.rs (58 LOC) — Backend trait: create/status/destroy/list; CreateOpts and ServerInfo structs - src/backends/hetzner.rs (143 LOC) — shells to `hcloud server ...` --output=json, parses JSON response, honors HCLOUD_TOKEN env (RULE 0.8) - src/backends/vultr.rs (189 LOC) — same pattern, `vultr-cli instance`, honors VULTR_API_KEY env - src/exec.rs (100 LOC) — Command runner + PATH-aware env preservation - src/b64.rs (49 LOC) — minimal user-data base64 encoder; zero transitive deps - src/main.rs (141 LOC) — clap CLI `kei-provision <backend> <cmd>` - tests/backend_smoke.rs (184 LOC) — tempdir PATH-inject fake hcloud + fake vultr-cli, no real cloud. Mutex-serialized (Rust test parallelism). Tests: 11/11 (3 b64 unit + 8 backend_smoke integration). Coverage: hetzner status present/absent/list, vultr status found/absent/destroy idempotent, unknown-backend error, CreateOpts default. Old shells kept with superseded-v0.17 header — install.sh still copies them, legacy scripts still work. New users get kei-provision binary. harden-base.sh untouched (different lifecycle — runs on target VPS). Backend trait factored to accept aws/doctl/linode follow-ups without re-architecture. Workspace Cargo.toml: +kei-provision member (1 line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
5.8 KiB
Rust
184 lines
5.8 KiB
Rust
//! Smoke tests for kei-provision backends.
|
|
//!
|
|
//! Strategy: no real cloud calls. We inject a tempdir onto PATH containing
|
|
//! fake `hcloud` / `vultr-cli` shell scripts that echo canned JSON matching
|
|
//! the real v1 / v3 CLI output shapes. The Backend impls then parse these
|
|
//! exactly as they would production output.
|
|
|
|
use kei_provision::{resolve, CreateOpts};
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
use tempfile::TempDir;
|
|
|
|
// Process-global PATH + env vars are not thread-safe across the parallel
|
|
// `cargo test` runner. Serialize tests that mutate env.
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
/// Create a fake CLI script at `<dir>/<bin>` that emits `stdout` verbatim
|
|
/// (regardless of arguments) and exits 0.
|
|
fn install_fake(dir: &Path, bin: &str, stdout: &str) {
|
|
let path = dir.join(bin);
|
|
// printf with escaped % for shell robustness — none of our fixtures
|
|
// need printf interpolation, so use `cat <<'EOF'`.
|
|
let script = format!("#!/usr/bin/env bash\ncat <<'EOF'\n{stdout}\nEOF\n");
|
|
fs::write(&path, script).expect("write fake");
|
|
let mut perms = fs::metadata(&path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&path, perms).unwrap();
|
|
}
|
|
|
|
/// Install a fake that always exits non-zero (simulates "server absent").
|
|
fn install_fake_fail(dir: &Path, bin: &str) {
|
|
let path = dir.join(bin);
|
|
let script = "#!/usr/bin/env bash\nexit 1\n";
|
|
fs::write(&path, script).expect("write fake");
|
|
let mut perms = fs::metadata(&path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&path, perms).unwrap();
|
|
}
|
|
|
|
/// Prepend tempdir to PATH so the fake binary wins, but keep the rest of
|
|
/// PATH so `#!/usr/bin/env bash` can still find `bash`.
|
|
fn prep_env(dir: &Path, token_var: &str) {
|
|
let old = std::env::var("PATH").unwrap_or_default();
|
|
let new = format!("{}:{}", dir.display(), old);
|
|
std::env::set_var("PATH", new);
|
|
std::env::set_var(token_var, "fake-token-for-tests");
|
|
}
|
|
|
|
const HETZNER_DESCRIBE: &str = r#"{
|
|
"id": 42,
|
|
"name": "kgl-test",
|
|
"status": "running",
|
|
"public_net": { "ipv4": { "ip": "1.2.3.4" } },
|
|
"server_type": { "name": "cx22" },
|
|
"datacenter": { "location": { "name": "fsn1" } }
|
|
}"#;
|
|
|
|
const HETZNER_LIST: &str = r#"[
|
|
{
|
|
"id": 42,
|
|
"name": "kgl-a",
|
|
"status": "running",
|
|
"public_net": { "ipv4": { "ip": "1.2.3.4" } }
|
|
},
|
|
{
|
|
"id": 43,
|
|
"name": "kgl-b",
|
|
"status": "running",
|
|
"public_net": { "ipv4": { "ip": "5.6.7.8" } }
|
|
}
|
|
]"#;
|
|
|
|
const VULTR_LIST: &str = r#"{
|
|
"instances": [
|
|
{
|
|
"id": "abc-123",
|
|
"label": "kgl-vultr",
|
|
"status": "active",
|
|
"power_status": "running",
|
|
"main_ip": "9.8.7.6",
|
|
"region": "ams",
|
|
"plan": "vc2-1c-1gb"
|
|
}
|
|
]
|
|
}"#;
|
|
|
|
#[test]
|
|
fn hetzner_status_parses_ipv4_and_id() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake(dir.path(), "hcloud", HETZNER_DESCRIBE);
|
|
prep_env(dir.path(), "HCLOUD_TOKEN");
|
|
|
|
let b = resolve("hetzner").unwrap();
|
|
let info = b.status("kgl-test").unwrap().expect("server present");
|
|
assert_eq!(info.name, "kgl-test");
|
|
assert_eq!(info.id, "42");
|
|
assert_eq!(info.ipv4.as_deref(), Some("1.2.3.4"));
|
|
assert_eq!(info.status, "running");
|
|
}
|
|
|
|
#[test]
|
|
fn hetzner_status_absent_returns_none() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake_fail(dir.path(), "hcloud");
|
|
prep_env(dir.path(), "HCLOUD_TOKEN");
|
|
|
|
let b = resolve("hetzner").unwrap();
|
|
assert!(b.status("nonexistent").unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hetzner_list_parses_array() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake(dir.path(), "hcloud", HETZNER_LIST);
|
|
prep_env(dir.path(), "HCLOUD_TOKEN");
|
|
|
|
let b = resolve("hetzner").unwrap();
|
|
let servers = b.list().unwrap();
|
|
assert_eq!(servers.len(), 2);
|
|
assert_eq!(servers[0].name, "kgl-a");
|
|
assert_eq!(servers[1].ipv4.as_deref(), Some("5.6.7.8"));
|
|
}
|
|
|
|
#[test]
|
|
fn vultr_status_matches_label() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake(dir.path(), "vultr-cli", VULTR_LIST);
|
|
prep_env(dir.path(), "VULTR_API_KEY");
|
|
|
|
let b = resolve("vultr").unwrap();
|
|
let info = b.status("kgl-vultr").unwrap().expect("found");
|
|
assert_eq!(info.id, "abc-123");
|
|
assert_eq!(info.ipv4.as_deref(), Some("9.8.7.6"));
|
|
assert_eq!(info.status, "active");
|
|
}
|
|
|
|
#[test]
|
|
fn vultr_status_absent_when_label_missing() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake(dir.path(), "vultr-cli", VULTR_LIST);
|
|
prep_env(dir.path(), "VULTR_API_KEY");
|
|
|
|
let b = resolve("vultr").unwrap();
|
|
assert!(b.status("not-in-list").unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn vultr_destroy_absent_is_idempotent() {
|
|
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let dir = TempDir::new().unwrap();
|
|
install_fake(dir.path(), "vultr-cli", VULTR_LIST);
|
|
prep_env(dir.path(), "VULTR_API_KEY");
|
|
|
|
let b = resolve("vultr").unwrap();
|
|
// "ghost" is not in VULTR_LIST → destroy must succeed no-op
|
|
b.destroy("ghost", true).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_backend_errors_out() {
|
|
let err = match resolve("gcp") {
|
|
Ok(_) => panic!("gcp should not resolve"),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
assert!(err.contains("unknown backend"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn create_opts_default_is_none_everywhere() {
|
|
let o = CreateOpts::default();
|
|
assert!(o.server_type.is_none());
|
|
assert!(o.location.is_none());
|
|
assert!(o.image.is_none());
|
|
assert!(o.ssh_key.is_none());
|
|
assert!(o.firewall.is_none());
|
|
assert!(o.user_data_path.is_none());
|
|
}
|