Pre-unlock wave U3 (highest-ROI). Task 7 from CONVERGENCE-PLAN — consolidate 2 provision-*.sh scripts into Rust via Backend trait. Old shells (provision-hetzner.sh, provision-vultr.sh) had identical 6-subcommand surface (create|status|destroy|list), log/die/check_deps helpers, idempotency contract. Sole delta: hcloud vs vultr-cli. RULE 0.2 says Rust-first when >50 LOC + growth expected. New crate _primitives/_rust/kei-provision/: - src/backend.rs (58 LOC) — Backend trait: create/status/destroy/list; CreateOpts and ServerInfo structs - src/backends/hetzner.rs (143 LOC) — shells to `hcloud server ...` --output=json, parses JSON response, honors HCLOUD_TOKEN env (RULE 0.8) - src/backends/vultr.rs (189 LOC) — same pattern, `vultr-cli instance`, honors VULTR_API_KEY env - src/exec.rs (100 LOC) — Command runner + PATH-aware env preservation - src/b64.rs (49 LOC) — minimal user-data base64 encoder; zero transitive deps - src/main.rs (141 LOC) — clap CLI `kei-provision <backend> <cmd>` - tests/backend_smoke.rs (184 LOC) — tempdir PATH-inject fake hcloud + fake vultr-cli, no real cloud. Mutex-serialized (Rust test parallelism). Tests: 11/11 (3 b64 unit + 8 backend_smoke integration). Coverage: hetzner status present/absent/list, vultr status found/absent/destroy idempotent, unknown-backend error, CreateOpts default. Old shells kept with superseded-v0.17 header — install.sh still copies them, legacy scripts still work. New users get kei-provision binary. harden-base.sh untouched (different lifecycle — runs on target VPS). Backend trait factored to accept aws/doctl/linode follow-ups without re-architecture. Workspace Cargo.toml: +kei-provision member (1 line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
5.6 KiB
Bash
Executable file
169 lines
5.6 KiB
Bash
Executable file
#!/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
|
|
#
|
|
# USAGE
|
|
# provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \
|
|
# [--image debian-12] [--ssh-key <id>] \
|
|
# [--firewall <name>] [--user-data <file>]
|
|
# provision-hetzner.sh status <name>
|
|
# provision-hetzner.sh destroy <name> [--force]
|
|
# provision-hetzner.sh list
|
|
#
|
|
# ENV (RULE 0.8 — secrets single source)
|
|
# HCLOUD_TOKEN — Hetzner API token (REQUIRED). Source:
|
|
# $(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2)
|
|
#
|
|
# EXIT
|
|
# 0 ok
|
|
# 1 usage / missing args / missing deps / unknown command
|
|
# 2 hcloud API error (non-idempotent path — inspect stderr)
|
|
#
|
|
# IDEMPOTENCY
|
|
# `create <name>` on an existing server prints its IP + exits 0.
|
|
# `destroy <name>` on a missing server exits 0 (nothing to do).
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[%s] [provision-hetzner] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
|
|
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
|
|
|
check_deps() {
|
|
command -v hcloud >/dev/null 2>&1 || \
|
|
die "hcloud CLI missing. Install: brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases" 1
|
|
command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1
|
|
[ -n "${HCLOUD_TOKEN:-}" ] || die "HCLOUD_TOKEN not set. Source ~/.claude/secrets/.env first." 1
|
|
}
|
|
|
|
# Print server JSON if it exists, empty string otherwise. Never fails.
|
|
server_json() {
|
|
local name="$1"
|
|
hcloud server describe "$name" -o json 2>/dev/null || true
|
|
}
|
|
|
|
cmd_list() {
|
|
check_deps
|
|
hcloud server list -o 'columns=id,name,status,ipv4,location,server_type,created'
|
|
}
|
|
|
|
cmd_status() {
|
|
check_deps
|
|
local name="${1:-}"; [ -n "$name" ] || die "status: <name> required" 1
|
|
local json; json=$(server_json "$name")
|
|
if [ -z "$json" ]; then
|
|
echo "absent"
|
|
return 0
|
|
fi
|
|
printf 'name=%s\nstatus=%s\nipv4=%s\nlocation=%s\ntype=%s\n' \
|
|
"$(jq -r .name <<<"$json")" \
|
|
"$(jq -r .status <<<"$json")" \
|
|
"$(jq -r '.public_net.ipv4.ip // "-"' <<<"$json")" \
|
|
"$(jq -r .datacenter.location.name <<<"$json")" \
|
|
"$(jq -r .server_type.name <<<"$json")"
|
|
}
|
|
|
|
cmd_create() {
|
|
check_deps
|
|
local name="${1:-}"; shift || true
|
|
[ -n "$name" ] || die "create: <name> required" 1
|
|
|
|
local type="cx22" location="fsn1" image="debian-12"
|
|
local ssh_key="" firewall="" user_data=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--type) type="$2"; shift 2 ;;
|
|
--location) location="$2"; shift 2 ;;
|
|
--image) image="$2"; shift 2 ;;
|
|
--ssh-key) ssh_key="$2"; shift 2 ;;
|
|
--firewall) firewall="$2"; shift 2 ;;
|
|
--user-data) user_data="$2"; shift 2 ;;
|
|
*) die "create: unknown flag '$1'" 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Idempotent fast-path: if the server already exists, just print its IP.
|
|
local existing; existing=$(server_json "$name")
|
|
if [ -n "$existing" ]; then
|
|
local ip; ip=$(jq -r '.public_net.ipv4.ip // "-"' <<<"$existing")
|
|
log "server '$name' already exists → $ip (no-op)"
|
|
echo "$ip"
|
|
return 0
|
|
fi
|
|
|
|
local args=(server create
|
|
--name "$name"
|
|
--type "$type"
|
|
--image "$image"
|
|
--location "$location"
|
|
--label "project=kei"
|
|
)
|
|
[ -n "$ssh_key" ] && args+=(--ssh-key "$ssh_key")
|
|
[ -n "$firewall" ] && args+=(--firewall "$firewall")
|
|
[ -n "$user_data" ] && { [ -r "$user_data" ] || die "user-data not readable: $user_data" 1; args+=(--user-data-from-file "$user_data"); }
|
|
|
|
log "creating '$name' ($type @ $location, image=$image)…"
|
|
hcloud "${args[@]}" -o json >/tmp/provision-hetzner-$$.json
|
|
local ip; ip=$(jq -r '.server.public_net.ipv4.ip' /tmp/provision-hetzner-$$.json)
|
|
rm -f /tmp/provision-hetzner-$$.json
|
|
[ "$ip" != "null" ] && [ -n "$ip" ] || die "create returned no IPv4 (check stderr)"
|
|
log "created '$name' → $ip"
|
|
echo "$ip"
|
|
}
|
|
|
|
cmd_destroy() {
|
|
check_deps
|
|
local name="${1:-}"; shift || true
|
|
[ -n "$name" ] || die "destroy: <name> required" 1
|
|
local force=""
|
|
[ "${1:-}" = "--force" ] && force=1
|
|
|
|
local existing; existing=$(server_json "$name")
|
|
if [ -z "$existing" ]; then
|
|
log "server '$name' absent (no-op)"
|
|
return 0
|
|
fi
|
|
|
|
if [ -z "$force" ]; then
|
|
printf 'Destroy server "%s"? [y/N] ' "$name" >&2
|
|
read -r ans
|
|
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; }
|
|
fi
|
|
|
|
log "deleting '$name'…"
|
|
hcloud server delete "$name" >&2
|
|
log "deleted '$name'"
|
|
}
|
|
|
|
main() {
|
|
local cmd="${1:-}"; shift || true
|
|
case "$cmd" in
|
|
create) cmd_create "$@" ;;
|
|
destroy) cmd_destroy "$@" ;;
|
|
status) cmd_status "$@" ;;
|
|
list) cmd_list "$@" ;;
|
|
-h|--help|"") cat <<EOF >&2
|
|
provision-hetzner.sh — idempotent Hetzner Cloud server provisioning.
|
|
USAGE
|
|
provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \\
|
|
[--image debian-12] [--ssh-key <id>] \\
|
|
[--firewall <name>] [--user-data <file>]
|
|
provision-hetzner.sh status <name>
|
|
provision-hetzner.sh destroy <name> [--force]
|
|
provision-hetzner.sh list
|
|
|
|
ENV
|
|
HCLOUD_TOKEN (required) — load via: source ~/.claude/secrets/.env
|
|
EOF
|
|
[ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1
|
|
;;
|
|
*) die "unknown command '$cmd'. Run --help." 1 ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|