Merge feat/convergence-u3-provision — unify hetzner+vultr into Rust crate

This commit is contained in:
Parfii-bot 2026-04-23 03:43:51 +08:00
commit 4658297232
15 changed files with 955 additions and 1 deletions

View file

@ -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"

View file

@ -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]

View file

@ -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"]

View file

@ -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==");
}
}

View file

@ -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<String>,
/// e.g. `fsn1` (hetzner), `ams` (vultr).
pub location: Option<String>,
/// e.g. `debian-12` (hetzner), `2136` (vultr os-id).
pub image: Option<String>,
/// SSH key id/name (backend-specific).
pub ssh_key: Option<String>,
/// Firewall name (hetzner) / group id (vultr).
pub firewall: Option<String>,
/// Cloud-init user-data file path.
pub user_data_path: Option<PathBuf>,
}
/// Normalized server info across all backends.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub id: String,
pub name: String,
pub ipv4: Option<String>,
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<ServerInfo>;
/// `Ok(None)` if absent; never fails on absence.
fn status(&self, name: &str) -> Result<Option<ServerInfo>>;
/// 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<Vec<ServerInfo>>;
}

View file

@ -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<Option<Value>> {
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<ServerInfo> {
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<Option<ServerInfo>> {
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<Vec<ServerInfo>> {
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<Vec<String>> {
let mut args: Vec<String> = 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(),
}
}

View file

@ -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<Box<dyn Backend>> {
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)"
)),
}
}

View file

@ -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 <id> → `{ "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<Vec<Value>> {
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<Option<Value>> {
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<String> {
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<ServerInfo> {
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<Option<ServerInfo>> {
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<Vec<ServerInfo>> {
self.ensure_ready()?;
Ok(self.list_raw()?.iter().map(parse_server).collect())
}
}
fn build_create_args(label: &str, opts: &CreateOpts, os_id: &str) -> Result<Vec<String>> {
let mut args: Vec<String> = 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(),
}
}

View file

@ -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<Option<serde_json::Value>> {
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<Option<serde_json::Value>> {
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<std::path::PathBuf> {
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<String> {
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)."
)),
}
}

View file

@ -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;

View file

@ -0,0 +1,141 @@
//! kei-provision — unified VPS provisioner CLI.
//!
//! USAGE
//! kei-provision <backend> create <name> [--type T] [--location L]
//! [--image I] [--ssh-key K]
//! [--firewall F] [--user-data PATH]
//! kei-provision <backend> status <name>
//! kei-provision <backend> destroy <name> [--force]
//! kei-provision <backend> list
//!
//! <backend>: 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<String>,
/// Datacenter (hetzner: `fsn1`; vultr: `ams`).
#[arg(long)]
location: Option<String>,
/// Image / OS (hetzner: `debian-12`; vultr: os-id string).
#[arg(long)]
image: Option<String>,
/// SSH key id/name.
#[arg(long = "ssh-key")]
ssh_key: Option<String>,
/// Firewall id/name.
#[arg(long)]
firewall: Option<String>,
/// cloud-init user-data file.
#[arg(long = "user-data")]
user_data: Option<PathBuf>,
},
/// 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())
);
}

View file

@ -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 `<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());
}

View file

@ -1,4 +1,9 @@
#!/usr/bin/env bash
# [SUPERSEDED v0.24] Prefer the unified `kei-provision hetzner <cmd>` 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

View file

@ -1,4 +1,9 @@
#!/usr/bin/env bash
# [SUPERSEDED v0.24] Prefer the unified `kei-provision vultr <cmd>` 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

View file

@ -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 <backend>`** | **1 day** | **atom substrate — but additive, not removing existing scripts** |
| **7** | **Cluster 2: Provisioner unification `kei-provision <backend>`** | **1 day** | **atom substrate — but additive, not removing existing scripts** |
**Total pre-unlock: ~3 days**. All non-breaking, all safe to parallel-agent.