Merge feat/convergence-u3-provision — unify hetzner+vultr into Rust crate
This commit is contained in:
commit
4658297232
15 changed files with 955 additions and 1 deletions
12
_primitives/_rust/Cargo.lock
generated
12
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
29
_primitives/_rust/kei-provision/Cargo.toml
Normal file
29
_primitives/_rust/kei-provision/Cargo.toml
Normal 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"]
|
||||
49
_primitives/_rust/kei-provision/src/b64.rs
Normal file
49
_primitives/_rust/kei-provision/src/b64.rs
Normal 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==");
|
||||
}
|
||||
}
|
||||
58
_primitives/_rust/kei-provision/src/backend.rs
Normal file
58
_primitives/_rust/kei-provision/src/backend.rs
Normal 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>>;
|
||||
}
|
||||
143
_primitives/_rust/kei-provision/src/backends/hetzner.rs
Normal file
143
_primitives/_rust/kei-provision/src/backends/hetzner.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
17
_primitives/_rust/kei-provision/src/backends/mod.rs
Normal file
17
_primitives/_rust/kei-provision/src/backends/mod.rs
Normal 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)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
189
_primitives/_rust/kei-provision/src/backends/vultr.rs
Normal file
189
_primitives/_rust/kei-provision/src/backends/vultr.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
||||
100
_primitives/_rust/kei-provision/src/exec.rs
Normal file
100
_primitives/_rust/kei-provision/src/exec.rs
Normal 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)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
20
_primitives/_rust/kei-provision/src/lib.rs
Normal file
20
_primitives/_rust/kei-provision/src/lib.rs
Normal 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;
|
||||
141
_primitives/_rust/kei-provision/src/main.rs
Normal file
141
_primitives/_rust/kei-provision/src/main.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
184
_primitives/_rust/kei-provision/tests/backend_smoke.rs
Normal file
184
_primitives/_rust/kei-provision/tests/backend_smoke.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue