diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index e982ccd..0b0c21e 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -2058,6 +2058,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "kei-provision" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "kei-refactor-engine" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index c7354d5..f8b7655 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -39,6 +39,8 @@ members = [ "kei-agent-runtime", # agent substrate v1 — phase 3 hook-protocol CLI adapter "kei-capability", + # v0.24 unification — unified VPS provisioner (supersedes provision-{hetzner,vultr}.sh) + "kei-provision", ] [workspace.package] diff --git a/_primitives/_rust/kei-provision/Cargo.toml b/_primitives/_rust/kei-provision/Cargo.toml new file mode 100644 index 0000000..117faad --- /dev/null +++ b/_primitives/_rust/kei-provision/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "kei-provision" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +description = "Unified VPS provisioner — one CLI for Hetzner / Vultr / (future) AWS / DO / Linode. Supersedes provision-hetzner.sh + provision-vultr.sh." + +[[bin]] +name = "kei-provision" +path = "src/main.rs" + +[lib] +name = "kei_provision" +path = "src/lib.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = "1" +thiserror = "1" + +[dev-dependencies] +tempfile = "3" + +[package.metadata.keisei] +backend = "external-cli" +description = "Shells out to `hcloud` (Hetzner) or `vultr-cli` (Vultr). Parses JSON output. Honors HCLOUD_TOKEN / VULTR_API_KEY env refs per RULE 0.8." +supersedes = ["provision-hetzner.sh", "provision-vultr.sh"] diff --git a/_primitives/_rust/kei-provision/src/b64.rs b/_primitives/_rust/kei-provision/src/b64.rs new file mode 100644 index 0000000..8db49c0 --- /dev/null +++ b/_primitives/_rust/kei-provision/src/b64.rs @@ -0,0 +1,49 @@ +//! Minimal base64 encoder. Vultr `--userdata` takes base64. We don't pull +//! in the `base64` crate for a single call site. + +const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn encode(raw: &[u8]) -> String { + let mut out = String::with_capacity((raw.len() + 2) / 3 * 4); + for chunk in raw.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + out.push(TABLE[(b0 >> 2) as usize] as char); + out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { + TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + TABLE[(b2 & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty() { + assert_eq!(encode(b""), ""); + } + + #[test] + fn padding() { + assert_eq!(encode(b"a"), "YQ=="); + assert_eq!(encode(b"ab"), "YWI="); + assert_eq!(encode(b"abc"), "YWJj"); + } + + #[test] + fn hello() { + assert_eq!(encode(b"Hello, World!"), "SGVsbG8sIFdvcmxkIQ=="); + } +} diff --git a/_primitives/_rust/kei-provision/src/backend.rs b/_primitives/_rust/kei-provision/src/backend.rs new file mode 100644 index 0000000..847d7ed --- /dev/null +++ b/_primitives/_rust/kei-provision/src/backend.rs @@ -0,0 +1,58 @@ +//! Backend trait + shared data types for the unified provisioner. +//! +//! A `Backend` shells out to an external CLI (hcloud / vultr-cli / future +//! aws / doctl / linode-cli). All IO is through the `Backend` methods; +//! `main.rs` never touches `std::process::Command` directly. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Opts passed to `Backend::create`. Fields are `Option` because every +/// backend has different defaults (hetzner = cx22/fsn1/debian-12; vultr = +/// vc2-1c-1gb/ams/resolve-Debian-12). Backend fills blanks. +#[derive(Debug, Default, Clone)] +pub struct CreateOpts { + /// e.g. `cx22` (hetzner), `vc2-1c-1gb` (vultr). + pub server_type: Option, + /// e.g. `fsn1` (hetzner), `ams` (vultr). + pub location: Option, + /// e.g. `debian-12` (hetzner), `2136` (vultr os-id). + pub image: Option, + /// SSH key id/name (backend-specific). + pub ssh_key: Option, + /// Firewall name (hetzner) / group id (vultr). + pub firewall: Option, + /// Cloud-init user-data file path. + pub user_data_path: Option, +} + +/// Normalized server info across all backends. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub id: String, + pub name: String, + pub ipv4: Option, + pub status: String, + /// Raw backend JSON for details the normalized fields drop + /// (region, plan, power status, datacenter, etc). + pub backend_specific: serde_json::Value, +} + +/// Implemented by each cloud provider adapter. +pub trait Backend { + /// Short identifier — `"hetzner"`, `"vultr"`. + fn name(&self) -> &'static str; + + /// Create a server with `name` or return existing (idempotent). + fn create(&self, name: &str, opts: &CreateOpts) -> Result; + + /// `Ok(None)` if absent; never fails on absence. + fn status(&self, name: &str) -> Result>; + + /// Idempotent: absent server = Ok(()). `force` skips confirm prompt. + fn destroy(&self, name: &str, force: bool) -> Result<()>; + + /// All servers owned by the current token. + fn list(&self) -> Result>; +} diff --git a/_primitives/_rust/kei-provision/src/backends/hetzner.rs b/_primitives/_rust/kei-provision/src/backends/hetzner.rs new file mode 100644 index 0000000..4b479da --- /dev/null +++ b/_primitives/_rust/kei-provision/src/backends/hetzner.rs @@ -0,0 +1,143 @@ +//! Hetzner Cloud adapter. Shells out to `hcloud server …`. +//! +//! JSON shape (hcloud v1.44): +//! describe → `{ "id": u64, "name": str, "status": str, +//! "public_net": { "ipv4": { "ip": str } }, ... }` +//! list → `[ { same shape } ]` +//! create → `{ "server": { same shape as describe } }` + +use crate::backend::{Backend, CreateOpts, ServerInfo}; +use crate::exec::{require_cli, require_env, run_json, run_json_strict, run_void}; +use anyhow::{anyhow, Result}; +use serde_json::Value; + +const BIN: &str = "hcloud"; +const INSTALL_HINT: &str = + "brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases"; +const ENV_TOKEN: &str = "HCLOUD_TOKEN"; + +pub struct HetznerBackend; + +impl HetznerBackend { + pub fn new() -> Self { + Self + } + + fn ensure_ready(&self) -> Result<()> { + require_cli(BIN, INSTALL_HINT)?; + require_env(ENV_TOKEN)?; + Ok(()) + } + + fn describe(&self, name: &str) -> Result> { + run_json(BIN, &["server", "describe", name, "-o", "json"]) + } +} + +impl Default for HetznerBackend { + fn default() -> Self { + Self::new() + } +} + +impl Backend for HetznerBackend { + fn name(&self) -> &'static str { + "hetzner" + } + + fn create(&self, name: &str, opts: &CreateOpts) -> Result { + self.ensure_ready()?; + if let Some(v) = self.describe(name)? { + return Ok(parse_server(&v)); + } + let args = build_create_args(name, opts)?; + let argrefs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let v = run_json_strict(BIN, &argrefs)? + .ok_or_else(|| anyhow!("hcloud create emitted no JSON"))?; + let server = v.get("server").cloned().unwrap_or(v); + Ok(parse_server(&server)) + } + + fn status(&self, name: &str) -> Result> { + self.ensure_ready()?; + Ok(self.describe(name)?.map(|v| parse_server(&v))) + } + + fn destroy(&self, name: &str, _force: bool) -> Result<()> { + self.ensure_ready()?; + if self.describe(name)?.is_none() { + return Ok(()); // idempotent absent + } + run_void(BIN, &["server", "delete", name]) + } + + fn list(&self) -> Result> { + self.ensure_ready()?; + let v = run_json_strict(BIN, &["server", "list", "-o", "json"])? + .ok_or_else(|| anyhow!("hcloud list emitted no JSON"))?; + let arr = v + .as_array() + .ok_or_else(|| anyhow!("hcloud list: expected array, got {v:?}"))?; + Ok(arr.iter().map(parse_server).collect()) + } +} + +fn build_create_args(name: &str, opts: &CreateOpts) -> Result> { + let mut args: Vec = vec![ + "server".into(), + "create".into(), + "--name".into(), + name.into(), + "--type".into(), + opts.server_type.clone().unwrap_or_else(|| "cx22".into()), + "--image".into(), + opts.image.clone().unwrap_or_else(|| "debian-12".into()), + "--location".into(), + opts.location.clone().unwrap_or_else(|| "fsn1".into()), + "--label".into(), + "project=kei".into(), + ]; + if let Some(k) = &opts.ssh_key { + args.extend(["--ssh-key".into(), k.clone()]); + } + if let Some(f) = &opts.firewall { + args.extend(["--firewall".into(), f.clone()]); + } + if let Some(p) = &opts.user_data_path { + if !p.is_file() { + return Err(anyhow!("user-data not readable: {}", p.display())); + } + args.extend(["--user-data-from-file".into(), p.display().to_string()]); + } + args.extend(["-o".into(), "json".into()]); + Ok(args) +} + +fn parse_server(v: &Value) -> ServerInfo { + let id = v + .get("id") + .map(|x| x.to_string().trim_matches('"').to_string()) + .unwrap_or_default(); + let name = v + .get("name") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let status = v + .get("status") + .and_then(|x| x.as_str()) + .unwrap_or("unknown") + .to_string(); + let ipv4 = v + .pointer("/public_net/ipv4/ip") + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty() && *s != "-") + .map(|s| s.to_string()); + ServerInfo { + id, + name, + ipv4, + status, + backend_specific: v.clone(), + } +} diff --git a/_primitives/_rust/kei-provision/src/backends/mod.rs b/_primitives/_rust/kei-provision/src/backends/mod.rs new file mode 100644 index 0000000..377feb4 --- /dev/null +++ b/_primitives/_rust/kei-provision/src/backends/mod.rs @@ -0,0 +1,17 @@ +//! Backend registry. Add a new cloud: implement `Backend`, register here. + +pub mod hetzner; +pub mod vultr; + +use crate::backend::Backend; +use anyhow::{anyhow, Result}; + +pub fn resolve(name: &str) -> Result> { + match name { + "hetzner" => Ok(Box::new(hetzner::HetznerBackend::new())), + "vultr" => Ok(Box::new(vultr::VultrBackend::new())), + other => Err(anyhow!( + "unknown backend `{other}`. Known: hetzner, vultr. (aws, do, linode: planned)" + )), + } +} diff --git a/_primitives/_rust/kei-provision/src/backends/vultr.rs b/_primitives/_rust/kei-provision/src/backends/vultr.rs new file mode 100644 index 0000000..411d85e --- /dev/null +++ b/_primitives/_rust/kei-provision/src/backends/vultr.rs @@ -0,0 +1,189 @@ +//! Vultr adapter. Shells out to `vultr-cli instance …` (v3 CLI). +//! +//! JSON shape (vultr-cli v3): +//! instance list → `{ "instances": [ { "id": str, "label": str, +//! "main_ip": str, "status": str, "region": str, +//! "plan": str, "power_status": str, ... } ] }` +//! instance get → `{ "instance": {...} }` (id required, not label) +//! instance create → `{ "instance": {...} }` +//! os list → `{ "os": [ { "id": int, "name": str, ... } ] }` + +use crate::b64; +use crate::backend::{Backend, CreateOpts, ServerInfo}; +use crate::exec::{require_cli, require_env, run_json_strict, run_void}; +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; + +const BIN: &str = "vultr-cli"; +const INSTALL_HINT: &str = + "brew install vultr/vultr-cli/vultr-cli | https://github.com/vultr/vultr-cli"; +const ENV_TOKEN: &str = "VULTR_API_KEY"; + +pub struct VultrBackend; + +impl VultrBackend { + pub fn new() -> Self { + Self + } + + fn ensure_ready(&self) -> Result<()> { + require_cli(BIN, INSTALL_HINT)?; + require_env(ENV_TOKEN)?; + Ok(()) + } + + fn list_raw(&self) -> Result> { + let v = run_json_strict(BIN, &["instance", "list", "-o", "json"])? + .ok_or_else(|| anyhow!("vultr-cli list emitted no JSON"))?; + let arr = v + .get("instances") + .and_then(|x| x.as_array()) + .cloned() + .ok_or_else(|| anyhow!("vultr-cli list: missing .instances array"))?; + Ok(arr) + } + + fn find_by_label(&self, label: &str) -> Result> { + for inst in self.list_raw()? { + if inst.get("label").and_then(|x| x.as_str()) == Some(label) { + return Ok(Some(inst)); + } + } + Ok(None) + } + + fn resolve_debian_12(&self) -> Result { + let v = run_json_strict(BIN, &["os", "list", "-o", "json"])? + .ok_or_else(|| anyhow!("vultr-cli os list emitted no JSON"))?; + let arr = v + .get("os") + .and_then(|x| x.as_array()) + .ok_or_else(|| anyhow!("vultr-cli os list: missing .os array"))?; + for os in arr { + let name = os.get("name").and_then(|x| x.as_str()).unwrap_or(""); + if name.to_lowercase().contains("debian 12") + && name.to_lowercase().contains("x64") + { + let id = os.get("id").ok_or_else(|| anyhow!("os.id missing"))?; + return Ok(id.to_string().trim_matches('"').to_string()); + } + } + Err(anyhow!( + "cannot resolve Debian 12 x64 OS id. Pass --image explicitly." + )) + } +} + +impl Default for VultrBackend { + fn default() -> Self { + Self::new() + } +} + +impl Backend for VultrBackend { + fn name(&self) -> &'static str { + "vultr" + } + + fn create(&self, name: &str, opts: &CreateOpts) -> Result { + self.ensure_ready()?; + if let Some(v) = self.find_by_label(name)? { + return Ok(parse_server(&v)); + } + let os_id = match &opts.image { + Some(s) => s.clone(), + None => self.resolve_debian_12()?, + }; + let args = build_create_args(name, opts, &os_id)?; + let argrefs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let v = run_json_strict(BIN, &argrefs)? + .ok_or_else(|| anyhow!("vultr-cli create emitted no JSON"))?; + let inst = v.get("instance").cloned().unwrap_or(v); + Ok(parse_server(&inst)) + } + + fn status(&self, name: &str) -> Result> { + self.ensure_ready()?; + Ok(self.find_by_label(name)?.as_ref().map(parse_server)) + } + + fn destroy(&self, name: &str, _force: bool) -> Result<()> { + self.ensure_ready()?; + let Some(inst) = self.find_by_label(name)? else { + return Ok(()); // idempotent absent + }; + let id = inst + .get("id") + .and_then(|x| x.as_str()) + .context("instance.id missing")?; + run_void(BIN, &["instance", "delete", id]) + } + + fn list(&self) -> Result> { + self.ensure_ready()?; + Ok(self.list_raw()?.iter().map(parse_server).collect()) + } +} + +fn build_create_args(label: &str, opts: &CreateOpts, os_id: &str) -> Result> { + let mut args: Vec = vec![ + "instance".into(), + "create".into(), + "--region".into(), + opts.location.clone().unwrap_or_else(|| "ams".into()), + "--plan".into(), + opts.server_type.clone().unwrap_or_else(|| "vc2-1c-1gb".into()), + "--os".into(), + os_id.into(), + "--label".into(), + label.into(), + "--tags".into(), + "project=kei".into(), + ]; + if let Some(k) = &opts.ssh_key { + args.extend(["--ssh-keys".into(), k.clone()]); + } + if let Some(f) = &opts.firewall { + args.extend(["--firewall-group-id".into(), f.clone()]); + } + if let Some(p) = &opts.user_data_path { + if !p.is_file() { + return Err(anyhow!("user-data not readable: {}", p.display())); + } + let raw = std::fs::read(p)?; + args.extend(["--userdata".into(), b64::encode(&raw)]); + } + args.extend(["-o".into(), "json".into()]); + Ok(args) +} + +fn parse_server(v: &Value) -> ServerInfo { + let id = v + .get("id") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let name = v + .get("label") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let status = v + .get("status") + .and_then(|x| x.as_str()) + .unwrap_or("unknown") + .to_string(); + let ipv4 = v + .get("main_ip") + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty() && *s != "0.0.0.0") + .map(|s| s.to_string()); + ServerInfo { + id, + name, + ipv4, + status, + backend_specific: v.clone(), + } +} + diff --git a/_primitives/_rust/kei-provision/src/exec.rs b/_primitives/_rust/kei-provision/src/exec.rs new file mode 100644 index 0000000..fd7a03c --- /dev/null +++ b/_primitives/_rust/kei-provision/src/exec.rs @@ -0,0 +1,100 @@ +//! Shared subprocess helper for backend adapters. +//! +//! Centralises `std::process::Command` so both Hetzner and Vultr backends +//! have a single JSON-exec path. Makes test-time PATH injection uniform. + +use anyhow::{anyhow, Context, Result}; +use std::process::Command; + +/// Run `bin args…` and return parsed JSON on exit code 0. +/// Returns `Ok(None)` when the child exits non-zero (caller decides if +/// that's an error or an "absent" signal). +pub fn run_json(bin: &str, args: &[&str]) -> Result> { + let output = Command::new(bin) + .args(args) + .output() + .with_context(|| format!("failed to spawn `{bin}`"))?; + if !output.status.success() { + return Ok(None); + } + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("`{bin}` stdout not utf-8"))?; + if stdout.trim().is_empty() { + return Ok(None); + } + let v: serde_json::Value = serde_json::from_str(&stdout) + .with_context(|| format!("`{bin}` did not emit valid JSON"))?; + Ok(Some(v)) +} + +/// Run `bin args…` and fail loudly on non-zero (create/delete paths). +/// Returns the parsed JSON or `None` for empty output. +pub fn run_json_strict(bin: &str, args: &[&str]) -> Result> { + let output = Command::new(bin) + .args(args) + .output() + .with_context(|| format!("failed to spawn `{bin}`"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "`{bin} {}` failed (code {:?}): {}", + args.join(" "), + output.status.code(), + stderr.trim() + )); + } + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("`{bin}` stdout not utf-8"))?; + if stdout.trim().is_empty() { + return Ok(None); + } + let v: serde_json::Value = serde_json::from_str(&stdout) + .with_context(|| format!("`{bin}` did not emit valid JSON"))?; + Ok(Some(v)) +} + +/// Plain void run — success = ok, failure = err with stderr context. +pub fn run_void(bin: &str, args: &[&str]) -> Result<()> { + let output = Command::new(bin) + .args(args) + .output() + .with_context(|| format!("failed to spawn `{bin}`"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "`{bin} {}` failed (code {:?}): {}", + args.join(" "), + output.status.code(), + stderr.trim() + )); + } + Ok(()) +} + +/// Assert a CLI binary is on PATH (friendly error). +pub fn require_cli(bin: &str, install_hint: &str) -> Result<()> { + which(bin).map(|_| ()).ok_or_else(|| { + anyhow!("`{bin}` not found on PATH. Install: {install_hint}") + }) +} + +fn which(bin: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(bin); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Assert an env var is set + non-empty (friendly error). +pub fn require_env(var: &str) -> Result { + match std::env::var(var) { + Ok(v) if !v.is_empty() => Ok(v), + _ => Err(anyhow!( + "env {var} not set. Source ~/.claude/secrets/.env first (RULE 0.8)." + )), + } +} diff --git a/_primitives/_rust/kei-provision/src/lib.rs b/_primitives/_rust/kei-provision/src/lib.rs new file mode 100644 index 0000000..b14c403 --- /dev/null +++ b/_primitives/_rust/kei-provision/src/lib.rs @@ -0,0 +1,20 @@ +//! kei-provision — unified VPS provisioner (Hetzner + Vultr, extensible). +//! +//! Supersedes `_primitives/provision-hetzner.sh` + `_primitives/provision-vultr.sh`. +//! +//! Layers: +//! `backend` — `Backend` trait + `CreateOpts` + `ServerInfo`. +//! `backends::hetzner` — adapts `hcloud server …` JSON output. +//! `backends::vultr` — adapts `vultr-cli instance …` JSON output. +//! `exec` — shared `std::process::Command` + env/cli checks. +//! +//! Tests inject a temp PATH containing a fake `hcloud` / `vultr-cli` that +//! emits canned JSON, so no cloud calls happen in CI. + +pub mod b64; +pub mod backend; +pub mod backends; +pub mod exec; + +pub use backend::{Backend, CreateOpts, ServerInfo}; +pub use backends::resolve; diff --git a/_primitives/_rust/kei-provision/src/main.rs b/_primitives/_rust/kei-provision/src/main.rs new file mode 100644 index 0000000..25c45b4 --- /dev/null +++ b/_primitives/_rust/kei-provision/src/main.rs @@ -0,0 +1,141 @@ +//! kei-provision — unified VPS provisioner CLI. +//! +//! USAGE +//! kei-provision create [--type T] [--location L] +//! [--image I] [--ssh-key K] +//! [--firewall F] [--user-data PATH] +//! kei-provision status +//! kei-provision destroy [--force] +//! kei-provision list +//! +//! : hetzner | vultr +//! +//! ENV (RULE 0.8 — secrets single source) +//! HCLOUD_TOKEN — Hetzner API token +//! VULTR_API_KEY — Vultr API key +//! +//! Source via: `source ~/.claude/secrets/.env` before invocation. + +use clap::{Parser, Subcommand}; +use kei_provision::{resolve, CreateOpts}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command( + name = "kei-provision", + about = "Unified VPS provisioner — Hetzner, Vultr, (future) AWS/DO/Linode." +)] +struct Cli { + /// Backend to use. + backend: String, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Create a server (idempotent — returns existing IP if name/label taken). + Create { + name: String, + /// Server type / plan (hetzner: `cx22`; vultr: `vc2-1c-1gb`). + #[arg(long)] + r#type: Option, + /// Datacenter (hetzner: `fsn1`; vultr: `ams`). + #[arg(long)] + location: Option, + /// Image / OS (hetzner: `debian-12`; vultr: os-id string). + #[arg(long)] + image: Option, + /// SSH key id/name. + #[arg(long = "ssh-key")] + ssh_key: Option, + /// Firewall id/name. + #[arg(long)] + firewall: Option, + /// cloud-init user-data file. + #[arg(long = "user-data")] + user_data: Option, + }, + /// Print server info (absent ⇒ "absent" line, exit 0). + Status { name: String }, + /// Destroy server (idempotent on absent). + Destroy { + name: String, + #[arg(long)] + force: bool, + }, + /// List all servers on this account. + List, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let backend = match resolve(&cli.backend) { + Ok(b) => b, + Err(e) => { + eprintln!("kei-provision: {e}"); + return ExitCode::from(1); + } + }; + match run(&*backend, cli.cmd) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("kei-provision [{}]: {e}", backend.name()); + ExitCode::from(2) + } + } +} + +fn run(b: &dyn kei_provision::Backend, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Create { + name, + r#type, + location, + image, + ssh_key, + firewall, + user_data, + } => { + let opts = CreateOpts { + server_type: r#type, + location, + image, + ssh_key, + firewall, + user_data_path: user_data, + }; + let info = b.create(&name, &opts)?; + println!("{}", info.ipv4.unwrap_or_else(|| "-".into())); + } + Cmd::Status { name } => match b.status(&name)? { + None => println!("absent"), + Some(i) => print_status(&i), + }, + Cmd::Destroy { name, force } => b.destroy(&name, force)?, + Cmd::List => { + for i in b.list()? { + println!( + "{}\t{}\t{}\t{}", + i.name, + i.status, + i.ipv4.unwrap_or_else(|| "-".into()), + i.id + ); + } + } + } + Ok(()) +} + +fn print_status(i: &kei_provision::ServerInfo) { + println!("name={}", i.name); + println!("id={}", i.id); + println!("status={}", i.status); + println!( + "ipv4={}", + i.ipv4.clone().unwrap_or_else(|| "-".into()) + ); +} diff --git a/_primitives/_rust/kei-provision/tests/backend_smoke.rs b/_primitives/_rust/kei-provision/tests/backend_smoke.rs new file mode 100644 index 0000000..44d1a37 --- /dev/null +++ b/_primitives/_rust/kei-provision/tests/backend_smoke.rs @@ -0,0 +1,184 @@ +//! 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 `/` 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()); +} diff --git a/_primitives/provision-hetzner.sh b/_primitives/provision-hetzner.sh index a63c855..f5507b9 100755 --- a/_primitives/provision-hetzner.sh +++ b/_primitives/provision-hetzner.sh @@ -1,4 +1,9 @@ #!/usr/bin/env bash +# [SUPERSEDED v0.24] Prefer the unified `kei-provision hetzner ` Rust +# binary (_primitives/_rust/kei-provision). This shell remains for deployed +# scripts that haven't migrated yet; functionally identical, retained only +# so existing call sites keep working until the migration sweep lands. +# # provision-hetzner.sh — idempotent Hetzner Cloud server provisioning. # Wraps the `hcloud` CLI. Install path: # $HOME/.claude/agents/_primitives/provision-hetzner.sh diff --git a/_primitives/provision-vultr.sh b/_primitives/provision-vultr.sh index 5aec773..b8fbf14 100755 --- a/_primitives/provision-vultr.sh +++ b/_primitives/provision-vultr.sh @@ -1,4 +1,9 @@ #!/usr/bin/env bash +# [SUPERSEDED v0.24] Prefer the unified `kei-provision vultr ` Rust +# binary (_primitives/_rust/kei-provision). This shell remains for deployed +# scripts that haven't migrated yet; functionally identical, retained only +# so existing call sites keep working until the migration sweep lands. +# # provision-vultr.sh — idempotent Vultr VPS provisioning. # Wraps the `vultr-cli` v3. Install path: # $HOME/.claude/agents/_primitives/provision-vultr.sh diff --git a/docs/CONVERGENCE-PLAN.md b/docs/CONVERGENCE-PLAN.md index c76df83..4d1cf40 100644 --- a/docs/CONVERGENCE-PLAN.md +++ b/docs/CONVERGENCE-PLAN.md @@ -83,7 +83,7 @@ These land during the current schema-lock window (before 2026-05-14 for agent su | 4 | Deprecate `/site-builder` with `[DEPRECATED: use /site-create]` header | 0.1 day | none | | 5 | Deprecate `/competitor-analysis` + `/design-inspiration` as presets pointing to `/research` | 0.1 day | none | | 6 | Add `/animate` gateway skill (40 LOC router) | 0.25 day | none | -| **7** | **Cluster 2: Provisioner unification `kei-provision `** | **1 day** | **atom substrate — but additive, not removing existing scripts** | +| **7** ✓ | **Cluster 2: Provisioner unification `kei-provision `** | **1 day** | **atom substrate — but additive, not removing existing scripts** | **Total pre-unlock: ~3 days**. All non-breaking, all safe to parallel-agent.