feat(P01): SLICE-03+04 — PVE API layer (8 scripts) + health-check

SLICE-03 (devops-engineer): ported from coreci/scripts/proxmox/:
  api.sh (verbatim), ct-exists.sh, lxc-clone.sh (4GB/16GB, hostname=praxis),
  lxc-config.sh (praxis env vars, praxis-firstboot.sh snippet), lxc-start.sh,
  stage-snippet.sh (G-101 FIX: bakes GITEA_TOKEN into snippet via sed),
  timing.sh (verbatim), rollback.sh (proxy code removed)
SLICE-04 (devops-engineer): health-check.sh adapted for /health:8789
  (G-104 FIX: timeout bumped 300s→600s for Docker build margin)

REQ-DEPLOY-03, 04, 05, 07, 08 covered.

---ci---
project: praxis
phase: 1
milestone: v0.2
status: execute
slice: 03-04
wave: 2
---/ci---
This commit is contained in:
Praxis CI
2026-08-01 14:18:23 +00:00
parent f04b9b3588
commit bb17615f41
9 changed files with 807 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
#!/bin/sh
# CoreCI — Proxmox VE REST API shared helpers.
#
# Sourced by the other scripts/proxmox/*.sh scripts. Provides:
# pve_curl — authenticated curl wrapper (PVEAPIToken header, TLS opt)
# pve_poll — poll an async UPID until status == "stopped"
# pve_nextid — fetch the next free VMID
# pve_get — GET with 503 bounded retry (idempotent reads only)
# pve_env — validate required env vars are set
#
# All helpers use `set -eu` semantics (fail fast). The caller is
# expected to `set -eu` and `source` this file.
# ── TLS handling ──────────────────────────────────────────────
# PROXMOX_TLS_SKIP_VERIFY=true → curl --insecure (self-signed certs).
# Default is false (secure; operator opts in for self-signed).
pve_tls_insecure() {
case "${PROXMOX_TLS_SKIP_VERIFY:-false}" in
true|1|yes|TRUE) echo "--insecure" ;;
*) echo "" ;;
esac
}
# ── Auth header ────────────────────────────────────────────────
# PVEAPIToken=USER@REALM!TOKENID=SECRET (no ticket step, no CSRF)
pve_auth_header() {
printf '%s' "PVEAPIToken=${PROXMOX_API_TOKEN:?PROXMOX_API_TOKEN is required}"
}
# ── Core curl wrapper ──────────────────────────────────────────
# Usage: pve_curl <method> <path> [form-data-args...]
# Returns the raw JSON `data` field on stdout (jq -r .data).
# Exits non-zero on HTTP >= 300 or curl failure.
pve_curl() {
method="$1"; path="$2"; shift 2
url="${PROXMOX_API_URL:?PROXMOX_API_URL is required}${path}"
insecure="$(pve_tls_insecure)"
if [ "$#" -gt 0 ]; then
# Form-encoded body for POST/PUT (key=value pairs)
data_args=""
for pair in "$@"; do
data_args="${data_args} --data-urlencode ${pair}"
done
# shellcheck disable=SC2086
response=$(curl -sS $insecure \
-X "$method" \
-H "Authorization: $(pve_auth_header)" \
-H "Content-Type: application/x-www-form-urlencoded" \
$data_args \
"$url")
else
# shellcheck disable=SC2086
response=$(curl -sS $insecure \
-X "$method" \
-H "Authorization: $(pve_auth_header)" \
"$url")
fi
# Proxmox always wraps responses in {"data": ...}. Check for errors.
status=$(printf '%s' "$response" | jq -r '.errors // empty')
if [ -n "$status" ]; then
echo "pve_curl: API error for $method $path: $status" >&2
printf '%s' "$response" >&2
return 1
fi
printf '%s' "$response" | jq -r '.data'
}
# ── GET with 503 bounded retry (idempotent reads only) ────────
# IDEATE-19: transient 503s (node busy/restarting) retried 3× / 2s backoff.
# NOT used for mutating calls (clone/start/stop) — those are UPID-polled.
pve_get() {
path="$1"
url="${PROXMOX_API_URL:?}${path}"
insecure="$(pve_tls_insecure)"
attempt=0
max=3
while [ "$attempt" -lt "$max" ]; do
# shellcheck disable=SC2086
response=$(curl -sS -w '\n%{http_code}' $insecure \
-X GET \
-H "Authorization: $(pve_auth_header)" \
"$url")
http_code=$(printf '%s' "$response" | tail -1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" = "503" ] && [ "$((attempt + 1))" -lt "$max" ]; then
attempt=$((attempt + 1))
echo "pve_get: 503 from $path, retry $attempt/$max in 2s..." >&2
sleep 2
continue
fi
if [ "$http_code" != "200" ]; then
echo "pve_get: HTTP $http_code for $path" >&2
printf '%s' "$body" >&2
return 1
fi
printf '%s' "$body" | jq -r '.data'
return 0
done
# Exhausted all 503 retries.
echo "pve_get: 503 from $path after $max attempts" >&2
return 1
}
# ── UPID polling ───────────────────────────────────────────────
# Mutating Proxmox calls return a UPID string. Poll until done.
# Usage: pve_poll <upid>
# Exits non-zero if the task exitstatus != "OK".
pve_poll() {
upid="$1"
node="${PROXMOX_NODE:?PROXMOX_NODE is required}"
path="/nodes/${node}/tasks/${upid}/status"
attempt=0
max_attempts=120 # 120 × 2s = 4 min max
while [ "$attempt" -lt "$max_attempts" ]; do
status=$(pve_curl GET "$path")
running=$(printf '%s' "$status" | jq -r '.status')
if [ "$running" = "stopped" ]; then
exitstatus=$(printf '%s' "$status" | jq -r '.exitstatus')
# "OK" is the clean success. "WARNINGS: N" is a successful
# completion with non-fatal warnings (e.g. systemd 255
# nesting hint on CT create). Both are acceptable.
case "$exitstatus" in
OK|WARNINGS\ *)
return 0
;;
*)
echo "pve_poll: task $upid failed with exitstatus: $exitstatus" >&2
return 1
;;
esac
fi
attempt=$((attempt + 1))
sleep 2
done
echo "pve_poll: timeout waiting for task $upid" >&2
return 1
}
# ── Next free VMID ────────────────────────────────────────────
pve_nextid() {
pve_curl GET "/cluster/nextid" | jq -r '. | tonumber'
}
# ── Env validation ────────────────────────────────────────────
# Usage: pve_env VAR1 VAR2 ... — exits 1 if any is unset/empty
pve_env() {
missing=0
for var in "$@"; do
eval "val=\"\${${var}:-}\""
if [ -z "$val" ]; then
echo "pve_env: $var is required but not set" >&2
missing=1
fi
done
return "$missing"
}
# ── lxc.environment form-encoding helper ──────────────────────
# Proxmox PUT /config accepts repeated lxc.environment=KEY=value.
# This builds the curl data args from KEY=value pairs.
# Usage: pve_lxc_env_args KEY1=VAL1 KEY2=VAL2 ...
# Emits one "lxc.environment=KEY=VAL" token per arg, newline-separated,
# so the caller can pass each line to curl --data-urlencode. (Prior
# version concatenated all args into a single malformed blob.)
pve_lxc_env_args() {
first=1
for pair in "$@"; do
[ "$first" -eq 0 ] && printf '\n'
printf '%s' "lxc.environment=${pair}"
first=0
done
}
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Praxis — CT existence + running-state helpers (P16 — deploy idempotency).
#
# Sourced by the deploy orchestrator (lxc-deploy.sh) to detect an
# existing CT before clone. Idempotent re-deploy:
# - healthy + running → skip clone/config/start (exit 0 / continue)
# - exists but unhealthy → error with guidance (--recreate / --reconfigure)
# - not exists → proceed with clone (current path)
#
# These helpers wrap pve_get against GET /nodes/{node}/lxc/{vmid}/status/current.
# A 404 (CT not found) returns HTTP non-200 → pve_get exits non-zero; the
# helpers translate that into the 0/1 return codes the orchestrators branch on.
# `set -eu` is NOT used here (the caller is set -eu; this file defines
# functions that intentionally swallow non-zero pve_get returns).
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE (via api.sh)
# Functions:
# ct_exists <vmid> → 0 if the CT exists (200), 1 if not (404/other)
# ct_running <vmid> → 0 if the CT exists AND status == "running",
# 1 otherwise (not exists, or not running)
# ct_status <vmid> → echoes the raw status string (e.g. "running",
# "stopped") on stdout; empty if not exists
#
# Source this file AFTER api.sh:
# . "${SCRIPT_DIR}/ct-exists.sh"
# ct_exists <vmid> → 0 if the CT exists, 1 if not.
# Uses pve_get against /status/current; a non-200 (404) is "not found".
# Under `set -eu` in the caller, the `|| true` prevents an exit on the
# pve_get failure path.
ct_exists() {
vmid="$1"
node="${PROXMOX_NODE:?PROXMOX_NODE is required}"
status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true)
[ -n "$status_json" ] && [ "$status_json" != "null" ]
}
# ct_running <vmid> → 0 if the CT exists AND status == "running", else 1.
ct_running() {
vmid="$1"
node="${PROXMOX_NODE:?PROXMOX_NODE is required}"
status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true)
if [ -z "$status_json" ] || [ "$status_json" = "null" ]; then
return 1
fi
running=$(printf '%s' "$status_json" | jq -r '.status // empty' 2>/dev/null || true)
[ "$running" = "running" ]
}
# ct_status <vmid> → echoes the status string on stdout; empty if not exists.
ct_status() {
vmid="$1"
node="${PROXMOX_NODE:?PROXMOX_NODE is required}"
status_json=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true)
if [ -z "$status_json" ] || [ "$status_json" = "null" ]; then
return 0
fi
printf '%s' "$(printf '%s' "$status_json" | jq -r '.status // empty' 2>/dev/null || true)"
}
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
# Praxis — Poll a deployed LXC container's /health endpoint.
#
# Adapted from coreci/scripts/proxmox/health-check.sh.
# Coreci polls /healthz:18080; praxis polls /health:8789.
#
# If PRAXIS_HEALTH_URL is set, use it directly. Otherwise, query
# the Proxmox /interfaces endpoint for the CT's bridge IP and
# construct http://<ip>:<port>/health.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE,
# PRAXIS_HEALTH_URL (optional override), PRAXIS_PORT (default 8789),
# PRAXIS_HEALTH_TIMEOUT (default 600 — first-boot Docker build +
# compose up may take up to 5 min; G-104 FIX bumped from 300s to
# give margin vs the 5-min worst-case build time per RESEARCH.md Q7)
# Args: $1 = VMID
# Exit: 0 if healthy within timeout, 1 otherwise
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
vmid="${1:?usage: health-check.sh <vmid>}"
http_port="${PRAXIS_PORT:-8789}"
timeout_s="${PRAXIS_HEALTH_TIMEOUT:-600}"
# Resolve health URL
if [ -n "${PRAXIS_HEALTH_URL:-}" ]; then
health_url="${PRAXIS_HEALTH_URL}"
else
# Query the CT's network interfaces for the bridge IP.
node="${PROXMOX_NODE}"
ifaces=$(pve_get "/nodes/${node}/lxc/${vmid}/interfaces" 2>/dev/null || true)
if [ -z "$ifaces" ] || [ "$ifaces" = "null" ]; then
echo "health-check: cannot resolve bridge IP for VMID ${vmid} (set PRAXIS_HEALTH_URL)" >&2
exit 1
fi
# Pick the first non-loopback IPv4 address. Emit only the IP fields
# (not hwaddr — it precedes .inet/.ip in PVE's response and head -1
# would pick the MAC — a bug fixed in coreci v3.6 P18 review).
ip=$(printf '%s' "$ifaces" | jq -r \
'.[] | select(.name != "lo") | (.inet? // .ip? // empty)' 2>/dev/null | grep -v '^$' | head -1)
if [ -z "$ip" ] || [ "$ip" = "null" ]; then
echo "health-check: no bridge IP found for VMID ${vmid} (set PRAXIS_HEALTH_URL)" >&2
exit 1
fi
health_url="http://${ip}:${http_port}/health"
fi
echo "health-check: polling ${health_url} for up to ${timeout_s}s..." >&2
ok=0
# shellcheck disable=SC2034
for i in $(seq 1 "$timeout_s"); do
if curl -fsS --connect-timeout 2 "$health_url" >/dev/null 2>&1; then
ok=1
break
fi
sleep 1
done
if [ "$ok" -ne 1 ]; then
echo "health-check: praxis did not become healthy within ${timeout_s}s at ${health_url}" >&2
exit 1
fi
echo "health-check: praxis healthy at ${health_url}" >&2
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Praxis — Create a Proxmox LXC container from a template via REST API.
#
# Uses the POST /nodes/{node}/lxc endpoint with ostemplate=<volid>
# (create-from-template) instead of the storage clone endpoint. The
# clone endpoint rejects API tokens (`user != root@pam` guard), but
# the create endpoint accepts them — so this path works end-to-end
# with a PVEAPIToken. Pure REST, no SSH.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE,
# PROXMOX_STORAGE, PROXMOX_TEMPLATE_VOLID
# Args: $1 = target VMID (from pve_nextid)
# Stdout: the new VMID (integer)
# Exit: 0 on success, 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \
PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID
newid="${1:?usage: lxc-clone.sh <newid>}"
node="${PROXMOX_NODE}"
storage="${PROXMOX_STORAGE}"
template_volid="${PROXMOX_TEMPLATE_VOLID}"
# POST /nodes/{node}/lxc — create a CT from a template.
# Body (form-encoded): vmid, ostemplate, hostname, storage, rootfs, ...
# Returns: UPID (async task). Poll until done.
create_path="/nodes/${node}/lxc"
hostname="${PRAXIS_HOSTNAME:-praxis}"
echo "lxc-clone: creating VMID ${newid} from ${template_volid}" >&2
upid=$(pve_curl POST "$create_path" \
"vmid=${newid}" \
"ostemplate=${template_volid}" \
"hostname=${hostname}" \
"storage=${storage}" \
"rootfs=${storage}:16" \
"memory=${PROXMOX_MEMORY_MB:-4096}" \
"net0=name=eth0,bridge=vmbr0,ip=dhcp" \
"arch=amd64" \
"features=nesting=1")
if [ -z "$upid" ] || [ "$upid" = "null" ]; then
echo "lxc-clone: failed to start create (empty UPID)" >&2
exit 1
fi
echo "lxc-clone: polling create task ${upid}" >&2
pve_poll "$upid"
echo "lxc-clone: CT ${newid} created from ${template_volid}" >&2
printf '%s\n' "$newid"
+127
View File
@@ -0,0 +1,127 @@
#!/bin/sh
# Praxis — Configure a created LXC container.
#
# Sets memory + onboot via the REST PUT /config (API-token-accepted),
# then sets hookscript + lxc.environment via SSH to the PVE host (these
# are root-only via REST: `hookscript` rejects API tokens, and
# `lxc.environment` is not in the REST schema). The hookscript points
# at the snippet staged by stage-snippet.sh (local:snippets/praxis-
# firstboot.sh).
#
# G-101: The GITEA_TOKEN must be available to the hookscript which runs
# on the PVE HOST (lxc.environment is NOT visible to the host-side
# hookscript). The token is baked into the snippet by stage-snippet.sh.
# The lxc.environment lines here put GITEA_TOKEN into the CT for the
# CT's own use (docker-compose env_file reads it), but the hookscript
# relies on the baked-in value.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE,
# PRAXIS_VERSION (git clone tag/branch, default latest),
# GITEA_TOKEN (for the private repo fetch inside the CT),
# DEEPGRAM_API_KEY, CARTESIA_API_KEY, OLLAMA_API_KEY (secrets,
# may be empty in v0.2 infrastructure-only),
# PRAXIS_DB_PATH (default /app/data/praxis.db),
# PRAXIS_TTS, PRAXIS_SCENARIO (optional, with defaults),
# OLLAMA_BASE_URL, OLLAMA_CHAT_URL, OLLAMA_ROLEPLAY_MODEL,
# OLLAMA_DEBRIEF_MODEL,
# DEEPGRAM_MODEL, DEEPGRAM_LANGUAGE, DEEPGRAM_REGION,
# CARTESIA_VOICE_ID,
# PRAXIS_PORT (default 8789),
# PROXMOX_MEMORY_MB (optional, default 4096),
# PROXMOX_STORAGE (for the hookscript volid prefix),
# PROXMOX_SSH_HOST (optional; defaults to PROXMOX_NODE)
# Args: $1 = VMID
# Exit: 0 on success, 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
vmid="${1:?usage: lxc-config.sh <vmid>}"
node="${PROXMOX_NODE}"
memory="${PROXMOX_MEMORY_MB:-4096}"
version="${PRAXIS_VERSION:-latest}"
port="${PRAXIS_PORT:-8789}"
db_path="${PRAXIS_DB_PATH:-/app/data/praxis.db}"
storage="${PROXMOX_STORAGE:-local}"
hookscript_volid="${storage}:snippets/praxis-firstboot.sh"
ssh_host="${PROXMOX_SSH_HOST:-${node}}"
# Optional praxis config (with defaults; empty is valid for v0.2).
praxis_tts="${PRAXIS_TTS:-deepgram}"
praxis_scenario="${PRAXIS_SCENARIO:-default}"
# Secret keys (may be empty in v0.2 infrastructure-only slice).
deepgram_key="${DEEPGRAM_API_KEY:-}"
cartesia_key="${CARTESIA_API_KEY:-}"
ollama_key="${OLLAMA_API_KEY:-}"
# Ollama config (with defaults).
ollama_base="${OLLAMA_BASE_URL:-http://ollama.cloudinit.dev:11434}"
ollama_chat="${OLLAMA_CHAT_URL:-${ollama_base}/v1/chat/completions}"
ollama_roleplay="${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}"
ollama_debrief="${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}"
# Deepgram config (with defaults).
deepgram_model="${DEEPGRAM_MODEL:-nova-3}"
deepgram_lang="${DEEPGRAM_LANGUAGE:-en-US}"
deepgram_region="${DEEPGRAM_REGION:-us-east-1}"
# Cartesia config (with defaults; empty in v0.2).
cartesia_voice="${CARTESIA_VOICE_ID:-}"
config_path="/nodes/${node}/lxc/${vmid}/config"
echo "lxc-config: configuring VMID ${vmid} (memory=${memory}MB, onboot=1, hookscript=${hookscript_volid})" >&2
# Step 1: REST-accepted fields (memory, onboot). PUT /config is
# synchronous (no UPID), returns null on success.
pve_curl PUT "$config_path" "onboot=1" "memory=${memory}"
# Step 2: root-only fields (hookscript, lxc.environment) via SSH to the
# PVE host config file. These are rejected by the REST API for API
# tokens and lxc.environment is not in the REST schema at all.
conf_file="/etc/pve/lxc/${vmid}.conf"
ssh_opts="-o StrictHostKeyChecking=no"
# Build the lines to append (remove any prior hookscript/onboot/lxc.environment
# lines first to keep the config idempotent).
append_lines() {
printf 'onboot: 1\n'
printf 'hookscript: %s\n' "$hookscript_volid"
printf 'lxc.environment: PRAXIS_VERSION=%s\n' "$version"
printf 'lxc.environment: PRAXIS_PORT=%s\n' "$port"
printf 'lxc.environment: PRAXIS_DB_PATH=%s\n' "$db_path"
printf 'lxc.environment: PRAXIS_TTS=%s\n' "$praxis_tts"
printf 'lxc.environment: PRAXIS_SCENARIO=%s\n' "$praxis_scenario"
if [ -n "${GITEA_TOKEN:-}" ]; then
printf 'lxc.environment: GITEA_TOKEN=%s\n' "$GITEA_TOKEN"
fi
printf 'lxc.environment: DEEPGRAM_API_KEY=%s\n' "$deepgram_key"
printf 'lxc.environment: CARTESIA_API_KEY=%s\n' "$cartesia_key"
printf 'lxc.environment: OLLAMA_API_KEY=%s\n' "$ollama_key"
printf 'lxc.environment: OLLAMA_BASE_URL=%s\n' "$ollama_base"
printf 'lxc.environment: OLLAMA_CHAT_URL=%s\n' "$ollama_chat"
printf 'lxc.environment: OLLAMA_ROLEPLAY_MODEL=%s\n' "$ollama_roleplay"
printf 'lxc.environment: OLLAMA_DEBRIEF_MODEL=%s\n' "$ollama_debrief"
printf 'lxc.environment: DEEPGRAM_MODEL=%s\n' "$deepgram_model"
printf 'lxc.environment: DEEPGRAM_LANGUAGE=%s\n' "$deepgram_lang"
printf 'lxc.environment: DEEPGRAM_REGION=%s\n' "$deepgram_region"
printf 'lxc.environment: CARTESIA_VOICE_ID=%s\n' "$cartesia_voice"
}
# shellcheck disable=SC2029
# SC2029: conf='${conf_file}' intentionally expands on the client side —
# the script builds the remote /etc/pve/lxc/<vmid>.conf path from the
# local variable and ships the literal path to the remote host.
append_lines | ssh "$ssh_opts" "root@${ssh_host}" "
conf='${conf_file}'
# Remove prior hookscript/onboot/lxc.environment lines.
sed -i '/^hookscript:/d;/^onboot:/d;/^lxc\.environment: PRAXIS/d;/^lxc\.environment: GITEA_TOKEN/d;/^lxc\.environment: DEEPGRAM/d;/^lxc\.environment: CARTESIA/d;/^lxc\.environment: OLLAMA/d' \"\$conf\" 2>/dev/null || true
cat >> \"\$conf\"
echo 'lxc-config: SSH config updated' >&2
"
echo "lxc-config: VMID ${vmid} configured" >&2
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Praxis — Start a Proxmox LXC container and poll the async task.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE
# Args: $1 = VMID
# Exit: 0 on success, 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
vmid="${1:?usage: lxc-start.sh <vmid>}"
node="${PROXMOX_NODE}"
start_path="/nodes/${node}/lxc/${vmid}/status/start"
echo "lxc-start: starting VMID ${vmid}" >&2
upid=$(pve_curl POST "$start_path")
if [ -z "$upid" ] || [ "$upid" = "null" ]; then
echo "lxc-start: failed to start (empty UPID)" >&2
exit 1
fi
echo "lxc-start: polling start task ${upid}" >&2
pve_poll "$upid"
echo "lxc-start: VMID ${vmid} is running" >&2
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Praxis — Rollback a failed LXC deployment.
#
# Stops (graceful, then force) and destroys the CT. Idempotent:
# a 404 (CT already gone) is not an error.
#
# Praxis v0.2 has no proxy/traefik tier, so there is no backend-route
# removal step here (unlike the coreci rollback which referenced
# PROXY_VMID and proxy/backend-remove.sh). If a proxy tier is added in
# a later slice, restore that step from coreci/scripts/proxmox/rollback.sh.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE
# Args: $1 = VMID
# Exit: 0 on success (including already-gone), 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
vmid="${1:?usage: rollback.sh <vmid>}"
node="${PROXMOX_NODE}"
echo "rollback: cleaning up VMID ${vmid}" >&2
# Graceful shutdown
shutdown_path="/nodes/${node}/lxc/${vmid}/status/shutdown"
upid=$(pve_curl POST "$shutdown_path" "timeoutStop=30" 2>/dev/null || true)
if [ -n "$upid" ] && [ "$upid" != "null" ]; then
pve_poll "$upid" 2>/dev/null || true
fi
# Check if still running; force stop if so
status=$(pve_get "/nodes/${node}/lxc/${vmid}/status/current" 2>/dev/null || true)
if [ -n "$status" ] && [ "$status" != "null" ]; then
running=$(printf '%s' "$status" | jq -r '.status' 2>/dev/null || true)
if [ "$running" = "running" ]; then
echo "rollback: force-stopping VMID ${vmid}" >&2
stop_path="/nodes/${node}/lxc/${vmid}/status/stop"
upid=$(pve_curl POST "$stop_path" 2>/dev/null || true)
if [ -n "$upid" ] && [ "$upid" != "null" ]; then
pve_poll "$upid" 2>/dev/null || true
fi
fi
fi
# Destroy (idempotent — 404 is fine)
echo "rollback: destroying VMID ${vmid}" >&2
destroy_path="/nodes/${node}/lxc/${vmid}"
upid=$(pve_curl DELETE "$destroy_path" 2>/dev/null || true)
if [ -n "$upid" ] && [ "$upid" != "null" ]; then
pve_poll "$upid" 2>/dev/null || true
fi
echo "rollback: VMID ${vmid} cleaned up" >&2
+127
View File
@@ -0,0 +1,127 @@
#!/bin/sh
# Praxis — Stage the first-boot hookscript to Proxmox snippet storage.
#
# Uploads scripts/proxmox/firstboot-hook.sh to local:snippets/ via the
# Proxmox `download-url` endpoint, fetching it from the Gitea raw URL
# (the repo is private, so the token is passed in the query string —
# acceptable for an automated deploy pipeline).
#
# G-101 FIX: The hookscript runs on the PVE HOST where lxc.environment
# is NOT available. The GITEA_TOKEN (needed to clone the private repo
# during first-boot) must be BAKED INTO the snippet itself. This script:
# a) Fetches the raw firstboot-hook.sh from Gitea
# b) Uses sed to replace the ${GITEA_TOKEN} placeholder with the
# actual token value (baking the secret into the snippet)
# c) Serves the modified snippet over a local HTTP one-shot server
# so the Proxmox download-url endpoint can fetch it
# d) Polls the upload task and verifies the snippet is staged
#
# Idempotent: re-running overwrites the snippet (download-url replaces
# the file). Run this before lxc-deploy.sh creates the CT, since
# lxc-config.sh references the snippet via hookscript=.
#
# Env: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE,
# PROXMOX_STORAGE, GITEA_TOKEN (for the private repo raw URL and
# to bake into the snippet — REQUIRED for G-101),
# GITEA_HOST (optional; default git.cloudinit.dev),
# PRAXIS_VERSION (optional; git ref for the raw URL, default main)
# Args: none
# Exit: 0 on success, 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE PROXMOX_STORAGE GITEA_TOKEN
GITEA_HOST="${GITEA_HOST:-git.cloudinit.dev}"
PRAXIS_REF="${PRAXIS_VERSION:-main}"
SNIPPET_NAME="praxis-firstboot.sh"
# Gitea raw URL with token in the query string. Gitea accepts ?token=
# for raw file access on private repos. The repo is coreci/praxis
# (org=coreci, repo=praxis) on the same Gitea host as coreci/coreci.
RAW_URL="https://${GITEA_HOST}/coreci/praxis/raw/branch/${PRAXIS_REF}/scripts/proxmox/firstboot-hook.sh?token=${GITEA_TOKEN}"
# Fetch the raw snippet to a temp file.
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
raw_snippet="${tmp_dir}/${SNIPPET_NAME}"
echo "stage-snippet: fetching firstboot-hook.sh from Gitea" >&2
insecure="$(pve_tls_insecure)"
# shellcheck disable=SC2086
curl -sS -f $insecure -o "$raw_snippet" "$RAW_URL"
# G-101: Bake the GITEA_TOKEN into the snippet. The hookscript runs on
# the PVE host where lxc.environment is not visible, so the token must
# be embedded in the snippet itself. The firstboot-hook.sh uses a
# literal `${GITEA_TOKEN}` placeholder that we substitute here.
# Using a sed delimiter unlikely to appear in a token (= would break on
# base64 padding; | is safe for typical token charsets).
echo "stage-snippet: baking GITEA_TOKEN into snippet (G-101 fix)" >&2
sed -i "s|\${GITEA_TOKEN}|${GITEA_TOKEN}|g" "$raw_snippet"
# Serve the modified snippet over a local one-shot HTTP server so the
# Proxmox download-url endpoint can fetch it. Proxmox runs on the PVE
# host; this script runs on the deploy host which may be the PVE host
# itself (loopback) or a remote box. Use a high port and bind to
# loopback; tell Proxmox to fetch from 127.0.0.1 only if this deploy
# host IS the PVE host. For the remote case, PROXMOX_DOWNLOAD_URL must
# be set to a URL the PVE host can reach this host by.
#
# Simplest robust path: use python3's http.server bound to loopback,
# run it in the background, point Proxmox at the loopback URL. This
# works when the deploy host and PVE host are the same machine (the
# common praxis case — single-node PVE).
listen_port="${STAGE_SNIPPET_PORT:-18099}"
listen_host="${STAGE_SNIPPET_HOST:-127.0.0.1}"
# The URL Proxmox will fetch from. If PROXMOX_DOWNLOAD_URL_BASE is set,
# use it (operator override for remote-deploy-host cases); otherwise
# default to the loopback URL (deploy-host == PVE-host).
download_url_base="${PROXMOX_DOWNLOAD_URL_BASE:-http://${listen_host}:${listen_port}}"
fetch_url="${download_url_base}/${SNIPPET_NAME}"
# Start a one-shot HTTP server (serve the temp dir, then exit after one
# download). python3 is available on the PVE host by default.
( cd "$tmp_dir" && python3 -m http.server --bind "$listen_host" "$listen_port" >/dev/null 2>&1 &
http_pid=$!
# Kill the server after 60s as a safety net (download-url is fast).
( sleep 60 && kill "$http_pid" 2>/dev/null ) &
wait "$http_pid" 2>/dev/null || true
) &
server_pid=$!
# Give the server a moment to bind.
sleep 1
dl_path="/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE}/download-url"
echo "stage-snippet: uploading ${SNIPPET_NAME} to ${PROXMOX_STORAGE}:snippets/ (via ${fetch_url})" >&2
# download-url params: url=<remote>, content=snippets, filename=<name>
upid=$(pve_curl POST "$dl_path" \
"url=${fetch_url}" \
"content=snippets" \
"filename=${SNIPPET_NAME}")
if [ -z "$upid" ] || [ "$upid" = "null" ]; then
echo "stage-snippet: failed to start download (empty UPID)" >&2
kill "$server_pid" 2>/dev/null || true
exit 1
fi
echo "stage-snippet: polling upload task ${upid}" >&2
pve_poll "$upid"
# Stop the HTTP server (download-url is done).
kill "$server_pid" 2>/dev/null || true
# Verify the snippet is now present in storage.
content=$(pve_get "/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE}/content")
volid="${PROXMOX_STORAGE}:snippets/${SNIPPET_NAME}"
if ! printf '%s' "$content" | jq -e --arg v "$volid" '.[] | select(.volid==$v)' >/dev/null 2>&1; then
echo "stage-snippet: snippet ${volid} not found after upload" >&2
exit 1
fi
echo "stage-snippet: ${volid} staged" >&2
+102
View File
@@ -0,0 +1,102 @@
# CoreCI — deploy-stage timing helper (P11 — IDEATE-39).
#
# Sourced (not executed) by the deploy orchestrators
# (proxy-deploy.sh, lxc-deploy.sh) to emit structured slog-style
# JSON timing lines for each deploy stage to stderr, where a log
# aggregator (or `2>>timing.log`) can pick them up.
#
# Usage:
# . /path/to/timing.sh
# timing_start clone
# ... clone work ...
# timing_end clone
#
# Emits one JSON line per timing_end to stderr:
# {"event":"deploy_timing","stage":"clone","duration_s":3}
#
# Optional node_exporter textfile collector: if the env var
# NODE_TEXTFILE_COLLECTOR_DIR points to a writable directory, the
# latest per-stage duration is ALSO written there as
# `coreci_deploy_timing_<stage>.prom` so a node_exporter textfile
# collector scrapes it. If the dir is unset or unwritable, only the
# JSON log is emitted (the structured-log-first decision, PLAN v3.6
# P11 Wave 2).
#
# Dependencies: date (POSIX epoch via +%s). jq is NOT required (the
# JSON line is constructed with printf so there is no external dep
# on the slow path). Idempotent: re-sourcing is harmless (the
# _TIMING_STARTS associative state is reset on source, but the
# orchestrator sources exactly once at startup).
#
# NOTE: Ported verbatim from coreci. The metric/prefix names retain
# the `coreci_` origin identifier for compatibility with existing
# node_exporter dashboards; rename in a follow-up if desired.
#
# shellcheck shell=sh
# _TIMING_STARTS is a flat file-backed map (stage → epoch seconds).
# POSIX sh has no associative arrays, so we use a single newline-
# separated string of "stage=epoch" records and scan it. Stages are
# short identifiers (clone/config/start/health/smoke) so the linear
# scan is trivially cheap.
_TIMING_STARTS=""
# timing_start <stage> — record the current epoch for <stage>.
# Overwrites a prior start for the same stage (idempotent re-entry).
timing_start() {
_stage="$1"
_now=$(date +%s)
# Drop any prior record for this stage, then append the fresh one.
_TIMING_STARTS="$(printf '%s\n' "$_TIMING_STARTS" \
| while IFS= read -r _line; do
case "$_line" in
"${_stage}="*) ;;
*) [ -n "$_line" ] && printf '%s\n' "$_line" ;;
esac
done)"
_TIMING_STARTS="${_TIMING_STARTS:+${_TIMING_STARTS}
}${_stage}=${_now}"
}
# timing_end <stage> — compute duration since timing_start <stage>,
# emit the JSON line to stderr, and optionally write the textfile
# collector entry. If no start was recorded for <stage>, emit nothing
# (defensive — a stray timing_end with no start is a no-op).
timing_end() {
_stage="$1"
_now=$(date +%s)
_start=""
# Scan the records for the matching stage.
_rest=""
while IFS= read -r _line; do
[ -n "$_line" ] || continue
case "$_line" in
"${_stage}="*)
_start="${_line#*=}"
;;
*)
_rest="${_rest:+${_rest}
}${_line}"
;;
esac
done <<EOF
${_TIMING_STARTS}
EOF
[ -n "$_start" ] || return 0
_duration=$((_now - _start))
_TIMING_STARTS="$_rest"
# Structured JSON to stderr (slog-style: single-line JSON).
printf '{"event":"deploy_timing","stage":"%s","duration_s":%s}\n' \
"$_stage" "$_duration" >&2
# Optional node_exporter textfile collector.
if [ -n "${NODE_TEXTFILE_COLLECTOR_DIR:-}" ] && \
[ -d "$NODE_TEXTFILE_COLLECTOR_DIR" ] && \
[ -w "$NODE_TEXTFILE_COLLECTOR_DIR" ]; then
_tf="${NODE_TEXTFILE_COLLECTOR_DIR}/coreci_deploy_timing_${_stage}.prom"
{
printf '# HELP coreci_deploy_timing_seconds Duration of the %s deploy stage.\n' "$_stage"
printf '# TYPE coreci_deploy_timing_seconds gauge\n'
printf 'coreci_deploy_timing_seconds{stage="%s"} %s\n' "$_stage" "$_duration"
} > "$_tf" 2>/dev/null || true
fi
}