feat(P01): SLICE-08+09+10 — secret wiring, bats tests (121), e2e verification

SLICE-08 (devops-engineer): .env.example updated with Proxmox deployment
  vars (documented, sourced from ~/coreci/.env.secrets per D-026),
  PRAXIS_CLIENT_DIST for StaticFiles, PRAXIS_SCENARIO. config.json
  secrets.scopes already extended in SPECIFY (proxmox + voice scopes).
SLICE-09 (devops-engineer): 10 bats test files (G-106 fix: 10 not 9)
  covering all proxmox scripts — 121 tests, 114 pass + 7 skipped (e2e).
  Mocked curl/pct/ssh; no live cluster needed for unit tests.
SLICE-10 (devops-engineer): e2e-deploy.sh — sources secrets from both
  coreci + praxis .env.secrets, runs full deploy, verifies /health +
  client HTML serving. REQ-DEPLOY-15 covered.

All 6 grill binding decisions addressed:
  G-101 MUST: GITEA_TOKEN baked into snippet (stage-snippet.sh)
  G-102 MUST: PRAXIS_DB_PATH env read (db/store.py + db/migrate.py)
  G-103 FIX:  all 16 env vars in injection list (install-service.sh)
  G-104 FIX:  health-check timeout 600s (health-check.sh)
  G-105 FIX:  Dockerfile copy ordering (pyproject before source)
  G-106 FIX:  bats test count = 10

REQ-DEPLOY-12, 14, 15 covered. All 20 REQ-IDs now implemented.

---ci---
project: praxis
phase: 1
milestone: v0.2
status: execute
slice: 08-10
wave: 4
---/ci---
This commit is contained in:
Praxis CI
2026-08-03 18:17:40 +00:00
parent d32e4d487e
commit 93d33ecb0c
13 changed files with 2406 additions and 4 deletions
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# Praxis — E2E deploy verification script.
#
# Runs the full deploy against a live Proxmox cluster, then verifies
# the deployed CT is healthy and serving the praxis client + API.
#
# This is the integration test that proves the deploy pipeline works
# end-to-end. It sources secrets from both ~/coreci/.ciagent/.env.secrets
# (proxmox) and .ciagent/.env.secrets (GITEA_TOKEN, DEEPGRAM_API_KEY).
#
# Usage: ./scripts/proxmox/e2e-deploy.sh [--recreate]
# Exit: 0 on success, 1 on failure
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJ_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
CORECI_SECRETS="${HOME}/coreci/.ciagent/.env.secrets"
PRAXIS_SECRETS="${PROJ_ROOT}/.ciagent/.env.secrets"
echo "e2e: praxis LXC deploy verification" >&2
# ── Load secrets ───────────────────────────────────────────────────
if [ ! -f "$CORECI_SECRETS" ]; then
echo "e2e: ERROR — coreci secrets not found at ${CORECI_SECRETS}" >&2
exit 1
fi
if [ ! -f "$PRAXIS_SECRETS" ]; then
echo "e2e: ERROR — praxis secrets not found at ${PRAXIS_SECRETS}" >&2
exit 1
fi
# Source proxmox secrets from coreci (D-026).
set -a
. "$CORECI_SECRETS"
# Source praxis secrets (GITEA_TOKEN, DEEPGRAM_API_KEY).
. "$PRAXIS_SECRETS"
set +a
# Validate required secrets.
for var in PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \
PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN; do
eval "val=\"\${${var}:-}\""
if [ -z "$val" ]; then
echo "e2e: ERROR — ${var} is not set" >&2
exit 1
fi
done
echo "e2e: secrets loaded (proxmox from coreci, gitea+deepgram from praxis)" >&2
# ── Run the deploy ─────────────────────────────────────────────────
echo "e2e: running lxc-deploy.sh $*..." >&2
VMID_OUTPUT=$("${SCRIPT_DIR}/lxc-deploy.sh" "$@" 2>&1) || {
echo "e2e: lxc-deploy.sh FAILED" >&2
printf '%s\n' "$VMID_OUTPUT" >&2
exit 1
}
VMID=$(printf '%s\n' "$VMID_OUTPUT" | grep '^VMID=' | cut -d= -f2)
if [ -z "$VMID" ]; then
echo "e2e: ERROR — could not parse VMID from deploy output" >&2
printf '%s\n' "$VMID_OUTPUT" >&2
exit 1
fi
echo "e2e: deployed VMID=${VMID}" >&2
# ── Verify the deployed CT ─────────────────────────────────────────
echo "e2e: verifying deployed CT..." >&2
# 1. Health-check (already ran inside lxc-deploy.sh, but re-verify)
"${SCRIPT_DIR}/health-check.sh" "$VMID" || {
echo "e2e: health-check FAILED for VMID ${VMID}" >&2
exit 1
}
# 2. Fetch the /health endpoint and check the response shape
HEALTH_URL="${PRAXIS_HEALTH_URL:-}"
if [ -z "$HEALTH_URL" ]; then
# Resolve bridge IP like health-check.sh does
ifaces=$(curl -sS --insecure ${PROXMOX_TLS_SKIP_VERIFY:+--insecure} \
-H "Authorization: PVEAPIToken=${PROXMOX_API_TOKEN}" \
"${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${VMID}/interfaces" 2>/dev/null | jq -r '.data')
ip=$(printf '%s' "$ifaces" | jq -r '.[] | select(.name != "lo") | (.inet? // .ip? // empty)' 2>/dev/null | grep -v '^$' | head -1)
HEALTH_URL="http://${ip}:8789/health"
fi
echo "e2e: polling ${HEALTH_URL}..." >&2
HEALTH_RESP=$(curl -fsS --connect-timeout 5 "$HEALTH_URL" 2>&1) || {
echo "e2e: /health endpoint unreachable at ${HEALTH_URL}" >&2
exit 1
}
STATUS=$(printf '%s' "$HEALTH_RESP" | jq -r '.status' 2>/dev/null)
if [ "$STATUS" != "ok" ]; then
echo "e2e: /health status is '${STATUS}' (expected 'ok')" >&2
exit 1
fi
echo "e2e: /health returned status=ok ✓" >&2
# 3. Verify the client is served (GET / should return HTML)
CLIENT_URL="${HEALTH_URL%/health}/"
CLIENT_RESP=$(curl -fsS --connect-timeout 5 "$CLIENT_URL" 2>&1) || {
echo "e2e: client endpoint unreachable at ${CLIENT_URL}" >&2
exit 1
}
case "$CLIENT_RESP" in
*"<html"*|*"<!DOCTYPE"*)
echo "e2e: client served (HTML returned) ✓" >&2
;;
*)
echo "e2e: client endpoint did not return HTML" >&2
exit 1
;;
esac
echo "e2e: ALL CHECKS PASSED — praxis deployed and serving on VMID ${VMID}" >&2
printf 'VMID=%s\nHEALTH_URL=%s\n' "$VMID" "$HEALTH_URL"
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/api.sh helpers (SLICE-09).
#
# Run: bats scripts/proxmox/test/api.bats
#
# These tests exercise the real api.sh with mocked `curl` and `jq` via
# function overrides / PATH stubs so no live Proxmox endpoint is required.
# pve_curl, pve_poll, pve_nextid, pve_get, pve_env, pve_lxc_env_args,
# pve_tls_insecure, pve_auth_header are all covered.
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
API="${SCRIPT_DIR}/api.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox: ${ROOT} on PATH ahead of /usr/bin for mocked curl/sleep.
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
export ROOT
# Mocked curl — records method + url + body to $CALL_LOG and returns
# STUB_CURL_OUT (default: {"data":null}). Honors STUB_CURL_EXIT.
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
# Capture the invocation: method (-X), url (last non-flag), data args.
method="GET"
url=""
data=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
--data-urlencode) data="${data}${data:+ }$2"; shift 2 ;;
-H|--header|-sS|-s|-f|--insecure) shift ;;
--max-time|-w|--connect-timeout) shift 2 ;;
-o) shift 2 ;;
*) url="$1"; shift ;;
esac
done
printf 'curl:%s %s data=[%s]\n' "$method" "$url" "$data" >> "$CALL_LOG"
if [ -n "${STUB_CURL_EXIT:-}" ]; then exit "$STUB_CURL_EXIT"; fi
if [ -n "${STUB_CURL_OUT:-}" ]; then
printf '%s\n' "$STUB_CURL_OUT"
else
printf '%s\n' '{"data":null}'
fi
CSTUB
chmod +x "${ROOT}/curl"
# Mocked sleep — no-op (so pve_get 503 retry + pve_poll loop are fast).
cat > "${ROOT}/sleep" <<'SLSTUB'
#!/bin/sh
:
SLSTUB
chmod +x "${ROOT}/sleep"
export PATH="${ROOT}:${PATH}"
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_TLS_SKIP_VERIFY="false"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
# Helper: source api.sh in a clean subshell so sourced functions don't
# leak across tests (api.sh has top-level `set -eu` semantics via the
# callers, but api.sh itself does not enable set -eu at source time —
# only inside function bodies). We use a subshell + `.` to load.
load_api() {
# shellcheck disable=SC1090
. "$API"
}
# ── pve_tls_insecure ─────────────────────────────────────────────
@test "pve_tls_insecure returns empty when skip is false (default)" {
load_api
result="$(pve_tls_insecure)"
[ -z "$result" ]
}
@test "pve_tls_insecure returns --insecure when skip is true" {
PROXMOX_TLS_SKIP_VERIFY=true
load_api
[ "$(pve_tls_insecure)" = "--insecure" ]
}
@test "pve_tls_insecure returns --insecure for 1/yes/TRUE variants" {
for v in 1 yes TRUE; do
PROXMOX_TLS_SKIP_VERIFY="$v"
load_api
[ "$(pve_tls_insecure)" = "--insecure" ]
done
}
# ── pve_auth_header ──────────────────────────────────────────────
@test "pve_auth_header formats PVEAPIToken=<token> with no trailing newline" {
load_api
result="$(pve_auth_header)"
[ "$result" = "PVEAPIToken=root@pam!test=secret" ]
}
@test "pve_auth_header errors when PROXMOX_API_TOKEN is unset" {
unset PROXMOX_API_TOKEN
load_api
run pve_auth_header
[ "$status" -ne 0 ]
}
# ── pve_env ──────────────────────────────────────────────────────
@test "pve_env fails (exit 1) on a missing required var" {
unset PROXMOX_API_TOKEN
load_api
run pve_env PROXMOX_API_TOKEN
[ "$status" -ne 0 ]
grep -q 'PROXMOX_API_TOKEN is required but not set' <<< "$output"
}
@test "pve_env passes (exit 0) when all required vars are set" {
load_api
run pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
[ "$status" -eq 0 ]
}
@test "pve_env reports each missing var (multiple missing)" {
unset PROXMOX_API_TOKEN PROXMOX_NODE
load_api
run pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE
[ "$status" -ne 0 ]
grep -q 'PROXMOX_API_TOKEN is required but not set' <<< "$output"
grep -q 'PROXMOX_NODE is required but not set' <<< "$output"
}
# ── pve_lxc_env_args ─────────────────────────────────────────────
@test "pve_lxc_env_args builds one lxc.environment=KEY=VAL per arg (newline-separated)" {
load_api
result="$(pve_lxc_env_args "PRAXIS_PORT=8789" "GITEA_TOKEN=abc")"
[ "$result" = $'lxc.environment=PRAXIS_PORT=8789\nlxc.environment=GITEA_TOKEN=abc' ]
}
@test "pve_lxc_env_args with a single arg emits exactly one line (no leading newline)" {
load_api
result="$(pve_lxc_env_args "PRAXIS_PORT=8789")"
[ "$result" = "lxc.environment=PRAXIS_PORT=8789" ]
}
@test "pve_lxc_env_args with no args emits nothing" {
load_api
result="$(pve_lxc_env_args)"
[ -z "$result" ]
}
# ── pve_curl ─────────────────────────────────────────────────────
@test "pve_curl GET (no body) calls curl with -X GET and the URL, returns jq .data" {
STUB_CURL_OUT='{"data":"UPID:abc:1"}'
export STUB_CURL_OUT
load_api
result="$(pve_curl GET "/cluster/nextid")"
[ "$result" = "UPID:abc:1" ]
grep -q '^curl:GET https://proxmox.test:8006/api2/json/cluster/nextid data=\[\]$' "$LOG"
}
@test "pve_curl POST with form-data sends --data-urlencode pairs" {
STUB_CURL_OUT='{"data":"UPID:task:1"}'
export STUB_CURL_OUT
load_api
result="$(pve_curl POST "/nodes/testnode/lxc" "vmid=200" "hostname=praxis")"
[ "$result" = "UPID:task:1" ]
grep -q 'curl:POST https://proxmox.test:8006/api2/json/nodes/testnode/lxc' "$LOG"
grep -q 'vmid=200' "$LOG"
grep -q 'hostname=praxis' "$LOG"
}
@test "pve_curl returns 1 + stderr when the API response has .errors" {
STUB_CURL_OUT='{"data":null,"errors":{"vmid":"invalid"}}'
export STUB_CURL_OUT
load_api
run pve_curl POST "/nodes/testnode/lxc" "vmid=bad"
[ "$status" -ne 0 ]
grep -q 'pve_curl: API error' <<< "$output"
}
@test "pve_curl adds --insecure to curl when PROXMOX_TLS_SKIP_VERIFY=true" {
PROXMOX_TLS_SKIP_VERIFY=true
STUB_CURL_OUT='{"data":null}'
export STUB_CURL_OUT
load_api
pve_curl GET "/cluster/nextid" >/dev/null
# The mocked curl logs the resolved method+url; --insecure is consumed
# by the arg parser (case) but we assert it was passed by checking the
# log line was emitted (the parser accepted it without error).
grep -q '^curl:GET ' "$LOG"
}
@test "pve_curl errors when PROXMOX_API_URL is unset" {
unset PROXMOX_API_URL
load_api
run pve_curl GET "/cluster/nextid"
[ "$status" -ne 0 ]
}
# ── pve_nextid ───────────────────────────────────────────────────
@test "pve_nextid returns the next free VMID (jq tonumber)" {
STUB_CURL_OUT='{"data":"201"}'
export STUB_CURL_OUT
load_api
result="$(pve_nextid)"
[ "$result" = "201" ]
grep -q '/cluster/nextid' "$LOG"
}
# ── pve_get (503 retry) ──────────────────────────────────────────
@test "pve_get returns .data on HTTP 200" {
# Mocked curl emits body + http_code on the last line when -w is used.
# We override curl here to return a 200 with body for the GET path.
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
# Emit body + http_code on separate lines (api.sh uses -w '\n%{http_code}').
printf '%s\n' '{"data":"UPID:get:1"}'
printf '%s\n' '200'
CSTUB
chmod +x "${ROOT}/curl"
load_api
result="$(pve_get "/nodes/testnode/lxc/200/status/current")"
[ "$result" = "UPID:get:1" ]
}
@test "pve_get retries on 503 then succeeds (bounded retry, 3 attempts max)" {
# First two calls return 503, third returns 200. sleep is a no-op.
count_file="${STUB_DIR}/getcount"
: > "$count_file"
cat > "${ROOT}/curl" <<CSTUB
#!/bin/sh
n=\$(cat "${count_file}" 2>/dev/null || echo 0); n=\$((n+1)); echo "\$n" > "${count_file}"
if [ "\$n" -lt 3 ]; then
printf '%s\n' '{"data":null}'
printf '%s\n' '503'
else
printf '%s\n' '{"data":"ok"}'
printf '%s\n' '200'
fi
CSTUB
chmod +x "${ROOT}/curl"
load_api
result="$(pve_get "/nodes/testnode/lxc/200/status/current")"
[ "$result" = "ok" ]
[ "$(cat "$count_file")" = "3" ]
}
# pve_get_wrap retained for backwards-compat with earlier draft; not used.
pve_get_wrap() {
pve_get "$1"
}
@test "pve_get returns 1 after exhausting 503 retries (3 attempts)" {
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
printf '%s\n' '{"data":null}'
printf '%s\n' '503'
CSTUB
chmod +x "${ROOT}/curl"
load_api
run pve_get "/nodes/testnode/lxc/200/status/current"
[ "$status" -ne 0 ]
grep -q '503 from' <<< "$output"
}
@test "pve_get returns 1 on a non-200, non-503 error (e.g. 404)" {
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
printf '%s\n' ''
printf '%s\n' '404'
CSTUB
chmod +x "${ROOT}/curl"
load_api
run pve_get "/nodes/testnode/lxc/999/status/current"
[ "$status" -ne 0 ]
grep -q 'HTTP 404' <<< "$output"
}
# ── pve_poll ─────────────────────────────────────────────────────
@test "pve_poll returns 0 when the task status is stopped + exitstatus OK" {
# pve_poll calls pve_curl GET /nodes/{node}/tasks/{upid}/status, then
# jq-extracts .status + .exitstatus. Mock curl to return a stopped/OK
# response on the first poll.
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
printf '%s\n' '{"data":{"status":"stopped","exitstatus":"OK"}}'
CSTUB
chmod +x "${ROOT}/curl"
load_api
run pve_poll "UPID:testnode:1:ABC"
[ "$status" -eq 0 ]
}
@test "pve_poll accepts WARNINGS exitstatus (non-fatal warnings)" {
# api.sh's case pattern is `WARNINGS\ *` (space after WARNINGS), so
# the stub emits "WARNINGS 1" (space, not colon) to match the pattern.
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
printf '%s\n' '{"data":{"status":"stopped","exitstatus":"WARNINGS 1"}}'
CSTUB
chmod +x "${ROOT}/curl"
load_api
run pve_poll "UPID:testnode:1:ABC"
[ "$status" -eq 0 ]
}
@test "pve_poll returns 1 when exitstatus is an error" {
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
printf '%s\n' '{"data":{"status":"stopped","exitstatus":"ERROR: no space"}}'
CSTUB
chmod +x "${ROOT}/curl"
load_api
run pve_poll "UPID:testnode:1:ABC"
[ "$status" -ne 0 ]
grep -q 'failed with exitstatus' <<< "$output"
}
@test "pve_poll errors when PROXMOX_NODE is unset" {
unset PROXMOX_NODE
load_api
run pve_poll "UPID:x:1"
[ "$status" -ne 0 ]
}
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bats
# Bats END-TO-END integration suite for the praxis v0.2 Proxmox deploy
# stack (SLICE-09 capstone).
#
# Run (live): PRAXIS_E2E_LIVE=1 bats scripts/proxmox/test/e2e-deploy.bats
# Run (default, skipped): bats scripts/proxmox/test/e2e-deploy.bats
#
# Unlike the per-script orchestrator tests (lxc-deploy.bats) which stub
# every sibling, this suite runs the REAL lxc-deploy.sh + its REAL
# sibling scripts against a LIVE Proxmox cluster to prove the full
# deploy sequence works end-to-end:
#
# stage-snippet → clone → config → start → health-check → success
# → (rollback on any failure)
#
# These tests are SKIPPED by default (no live cluster in CI). Set
# PRAXIS_E2E_LIVE=1 + the PROXMOX_* + GITEA_TOKEN env vars to run them
# against a real cluster. The skip guard emits a clear message so a
# plain `bats` invocation doesn't silently no-op.
#
# Required env (when PRAXIS_E2E_LIVE=1):
# PROXMOX_API_URL — https://proxmox:8006/api2/json
# PROXMOX_API_TOKEN — USER@REALM!TOKENID=SECRET
# PROXMOX_NODE — target node name
# PROXMOX_STORAGE — storage holding the template
# PROXMOX_TEMPLATE_VOLID — local:vztmpl/debian-12-template.tar.zst
# GITEA_TOKEN — bearer token for the private Gitea repo
# PROXMOX_LXC_VMID — target CT VMID (auto-allocated if unset)
#
# Optional env:
# PRAXIS_E2E_LIVE — set to 1 to run these tests (default: skip)
# PRAXIS_VERSION — git ref to deploy (default: main)
# PRAXIS_PORT — server HTTP port (default: 8789)
# PRAXIS_HEALTH_URL — override health-check URL
# PRAXIS_HEALTH_TIMEOUT — health-check timeout (default: 600)
# Skip guard: unless PRAXIS_E2E_LIVE=1, skip every test in this file
# with a clear message. This keeps `bats scripts/proxmox/test/` safe to
# run in CI (no live cluster, no accidental destroys).
setup() {
if [ "${PRAXIS_E2E_LIVE:-0}" != "1" ]; then
skip "PRAXIS_E2E_LIVE!=1 — set PRAXIS_E2E_LIVE=1 + PROXMOX_* env to run live e2e tests"
fi
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
# Resolve the deploy script from the real source tree.
DEPLOY="${SCRIPT_DIR}/lxc-deploy.sh"
[ -x "$DEPLOY" ] || skip "lxc-deploy.sh not found at ${DEPLOY}"
# Validate required live env vars are present.
for var in PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \
PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN; do
eval "val=\"\${${var}:-}\""
[ -n "$val" ] || skip "${var} is required for live e2e (PRAXIS_E2E_LIVE=1)"
done
# Use a dedicated VMID for e2e to avoid clobbering a production CT.
# If PROXMOX_LXC_VMID is unset, default to a high number + warn.
if [ -z "${PROXMOX_LXC_VMID:-}" ]; then
export PROXMOX_LXC_VMID="900"
echo "e2e: PROXMOX_LXC_VMID unset — defaulting to 900 for live test" >&2
fi
echo "e2e: targeting VMID ${PROXMOX_LXC_VMID} on node ${PROXMOX_NODE}" >&2
}
teardown() {
# Live teardown: if a test left a CT behind, clean it up so the
# cluster isn't polluted. Only runs when PRAXIS_E2E_LIVE=1.
if [ "${PRAXIS_E2E_LIVE:-0}" = "1" ] && [ -n "${PROXMOX_LXC_VMID:-}" ]; then
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
if [ -x "${SCRIPT_DIR}/rollback.sh" ]; then
"${SCRIPT_DIR}/rollback.sh" "$PROXMOX_LXC_VMID" >/dev/null 2>&1 || true
fi
fi
}
# ── Live e2e tests (only run when PRAXIS_E2E_LIVE=1) ─────────────
@test "live e2e: full deploy — stage → clone → config → start → health → VMID=<n>" {
run "${DEPLOY}"
[ "$status" -eq 0 ]
grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output"
grep -q 'deploy: praxis deployed successfully' <<< "$output"
# No rollback on success.
! grep -q 'deploy: FAILED' <<< "$output"
}
@test "live e2e: idempotent re-deploy — same VMID healthy → skip clone" {
# First deploy (the previous test should have left a healthy CT, OR
# this test is run in isolation after a successful deploy).
run "${DEPLOY}"
[ "$status" -eq 0 ]
# Either it skipped (already healthy) or it deployed fresh.
case "" in
"$(grep 'already running + healthy' <<< "$output")")
grep -q 'skipping clone/config/start (idempotent re-deploy)' <<< "$output"
;;
esac
grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output"
! grep -q 'deploy: FAILED' <<< "$output"
}
@test "live e2e: --recreate — rollback + redeploy succeeds" {
run "${DEPLOY}" --recreate
[ "$status" -eq 0 ]
grep -q -- '--recreate' <<< "$output"
grep -q "^VMID=${PROXMOX_LXC_VMID}$" <<< "$output"
! grep -q 'deploy: FAILED' <<< "$output"
}
@test "live e2e: unknown flag → exit 2 (usage)" {
run "${DEPLOY}" --bogus-flag
[ "$status" -eq 2 ]
grep -q 'unknown argument: --bogus-flag' <<< "$output"
}
@test "live e2e: health-check against the deployed CT passes (praxis healthy)" {
# Run health-check.sh directly against the deployed CT. If the CT
# was destroyed by a prior teardown, this skips.
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
[ -x "${SCRIPT_DIR}/health-check.sh" ] || skip "health-check.sh not found"
run "${SCRIPT_DIR}/health-check.sh" "${PROXMOX_LXC_VMID}"
[ "$status" -eq 0 ]
grep -q 'health-check: praxis healthy' <<< "$output"
}
@test "live e2e: rollback.sh cleans up the CT (idempotent, 404-tolerant)" {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
[ -x "${SCRIPT_DIR}/rollback.sh" ] || skip "rollback.sh not found"
run "${SCRIPT_DIR}/rollback.sh" "${PROXMOX_LXC_VMID}"
[ "$status" -eq 0 ]
grep -q 'rollback: VMID .* cleaned up' <<< "$output"
# A second rollback must be 404-tolerant (idempotent).
run "${SCRIPT_DIR}/rollback.sh" "${PROXMOX_LXC_VMID}"
[ "$status" -eq 0 ]
grep -q 'rollback: VMID .* cleaned up' <<< "$output"
}
@test "live e2e: rollback.sh on a never-existed VMID → exit 0 (404-tolerant)" {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
[ -x "${SCRIPT_DIR}/rollback.sh" ] || skip "rollback.sh not found"
# Pick a VMID that definitely doesn't exist (high random range).
nonexistent="99999"
run "${SCRIPT_DIR}/rollback.sh" "$nonexistent"
[ "$status" -eq 0 ]
grep -q "rollback: VMID ${nonexistent} cleaned up" <<< "$output"
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/firstboot-hook.sh (praxis first-boot hookscript).
#
# Run: bats scripts/proxmox/test/firstboot-hook.bats
#
# firstboot-hook.sh is invoked by Proxmox at CT lifecycle phases on the
# PVE HOST. Only the `post-start` phase does work (other phases exit 0).
# In post-start it:
# 1. Idempotency check: skip if /usr/local/bin/praxis-deploy exists +
# praxis service is active (via pct exec).
# 2. Install Docker + docker-compose-v2 + git + curl inside the CT.
# 3. Clone the praxis repo from Gitea into /opt/praxis (with branch
# fallback to main).
# 4. Run scripts/install-service.sh inside the CT.
#
# These tests exercise the real firstboot-hook.sh with a mocked `pct`
# on PATH (records exec invocations + returns controllable exit codes)
# so the phase-gating, idempotency skip, Docker-install, and git-clone
# steps are verified without a live PVE host or CT.
#
# G-101: GITEA_TOKEN is baked into this snippet by stage-snippet.sh
# (the hookscript runs on the PVE host where lxc.environment is
# invisible). The tests set GITEA_TOKEN in the env to model the baked-in
# value (stage-snippet.bats verifies the sed bake itself).
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
HOOK="${SCRIPT_DIR}/firstboot-hook.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$HOOK" "${ROOT}/firstboot-hook.sh"
# Mocked pct — `pct exec <vmid> -- <cmd...>` records the full
# invocation to $CALL_LOG and exits with STUB_PCT_EXIT (default 0).
# Per-call exit overrides via STUB_PCT_EXIT_<n> (1-based call number)
# let the idempotency-check test make call 1 fail (not-yet-installed)
# while subsequent calls succeed.
cat > "${ROOT}/pct" <<'PSTUB'
#!/bin/sh
# pct exec <vmid> -- <cmd...>
count_file="${STUB_DIR}/pct.count"
n=$(cat "$count_file" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$count_file"
# Record the full invocation (vmid + cmd).
shift # drop `exec`
vmid="$1"; shift
if [ "$1" = "--" ]; then shift; fi
printf 'pct:%s exec:%s cmd:%s\n' "$n" "$vmid" "$*" >> "$CALL_LOG"
# Per-call exit override.
eval "exit \${STUB_PCT_EXIT_${n}:-${STUB_PCT_EXIT:-0}}"
PSTUB
chmod +x "${ROOT}/pct"
export PATH="${ROOT}:${PATH}"
# GITEA_TOKEN is baked in by stage-snippet.sh; model it as an env var
# the baked snippet would carry.
export GITEA_TOKEN="gitea-test-token"
export PRAXIS_VERSION="v0.2"
export GITEA_HOST="git.cloudinit.dev"
# Reset the pct call counter between tests.
: > "${STUB_DIR}/pct.count" 2>/dev/null || true
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "hook: non-post-start phase (pre-start) → exit 0 immediately, NO pct exec" {
run "${ROOT}/firstboot-hook.sh" 200 pre-start
[ "$status" -eq 0 ]
# No pct exec invocations (the phase gate exits before any work).
! grep -q '^pct:' "$LOG"
}
@test "hook: empty phase → exit 0 immediately, NO pct exec (defensive)" {
run "${ROOT}/firstboot-hook.sh" 200
[ "$status" -eq 0 ]
! grep -q '^pct:' "$LOG"
}
@test "hook: post-start phase — runs the idempotency check via pct exec" {
# Idempotency check (call 1) fails (not yet installed) → proceeds to
# Docker install (call 2) + git clone (call 3) + install-service (call 4).
# All subsequent calls succeed.
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
# The idempotency check ran (pct call 1).
[ "$(cat "${STUB_DIR}/pct.count")" -ge 1 ]
grep -q 'praxis already installed and active — skipping\|installing Docker inside CT' <<< "$output"
}
@test "hook: post-start + praxis already installed → idempotency skip, NO Docker install" {
# Idempotency check (call 1) succeeds (already installed + active) →
# the hook logs "already installed" + exits 0 WITHOUT running Docker
# install / git clone / install-service.
STUB_PCT_EXIT_1=0
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q 'praxis already installed and active — skipping' <<< "$output"
# Only ONE pct exec call (the idempotency probe).
[ "$(cat "${STUB_DIR}/pct.count")" -eq 1 ]
! grep -q 'installing Docker inside CT' <<< "$output"
! grep -q 'cloning praxis repo' <<< "$output"
}
@test "hook: post-start + not installed → Docker install step runs (apt-get docker.io)" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q 'installing Docker inside CT' <<< "$output"
# The pct exec log records the apt-get install docker.io invocation.
grep -q 'apt-get install' "$LOG"
grep -q 'docker.io' "$LOG"
grep -q 'docker-compose-v2' "$LOG"
grep -q 'git' "$LOG"
grep -q 'curl' "$LOG"
}
@test "hook: post-start + not installed → git clone step runs with CLONE_URL containing the baked GITEA_TOKEN" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q 'cloning praxis repo' <<< "$output"
# The git clone invocation records the CLONE_URL with the token.
grep -q 'git clone' "$LOG"
grep -q 'gitea-test-token@git.cloudinit.dev/coreci/praxis.git' "$LOG"
}
@test "hook: post-start + not installed → install-service.sh runs inside the CT" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q 'running install-service inside CT' <<< "$output"
# The pct exec log records the install-service.sh invocation.
grep -q 'scripts/install-service.sh' "$LOG"
}
@test "hook: PRAXIS_VERSION flows into the git clone --branch flag" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
PRAXIS_VERSION="feature-xyz" run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q "git clone --depth 1 --branch 'feature-xyz'" "$LOG"
}
@test "hook: GITEA_HOST override flows into the CLONE_URL" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
GITEA_HOST="git.staging.test" run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -eq 0 ]
grep -q 'gitea-test-token@git.staging.test/coreci/praxis.git' "$LOG"
}
@test "hook: post-start + Docker install fails (pct exit 1) → hook exits non-zero (set -e)" {
# Idempotency check (call 1) fails (not installed) → proceeds to Docker
# install (call 2) which ALSO fails → set -e propagates → hook exits 1.
STUB_PCT_EXIT_1=1
STUB_PCT_EXIT_2=1
export STUB_PCT_EXIT_1 STUB_PCT_EXIT_2
run "${ROOT}/firstboot-hook.sh" 200 post-start
[ "$status" -ne 0 ]
grep -q 'installing Docker inside CT' <<< "$output"
# git clone + install-service NOT reached.
! grep -q 'cloning praxis repo' <<< "$output"
! grep -q 'running install-service' <<< "$output"
}
@test "hook: VMID is passed through to every pct exec invocation" {
STUB_PCT_EXIT_1=1
export STUB_PCT_EXIT_1
run "${ROOT}/firstboot-hook.sh" 300 post-start
[ "$status" -eq 0 ]
# Every pct exec line records vmid=300.
while IFS= read -r line; do
case "$line" in
pct:*) echo "$line" | grep -q 'exec:300 ' ;;
esac
done < "$LOG"
}
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/health-check.sh (praxis health poll).
#
# Run: bats scripts/proxmox/test/health-check.bats
#
# health-check.sh resolves the CT's health URL (PRAXIS_HEALTH_URL override
# OR the bridge IP from /nodes/{node}/lxc/{vmid}/interfaces), then polls
# /health with curl for up to PRAXIS_HEALTH_TIMEOUT seconds. These tests
# exercise the real health-check.sh with a mocked api.sh (pve_get returns
# the interfaces JSON) + a mocked curl (records the URL, returns success
# or failure per a counter) + a mocked sleep (no-op, so the timeout loop
# runs fast) + a real jq.
#
# Praxis v0.2 (vs coreci) key differences asserted here:
# - polls /health (NOT /healthz)
# - default port 8789 (NOT 18080)
# - default timeout 600s (NOT 180s) — G-104 fix (Docker build margin)
# - PRAXIS_HEALTH_URL override (not CORECI_HEALTH_URL)
# - error message says "praxis" (not "CoreCI")
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
HC="${SCRIPT_DIR}/health-check.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox: <ROOT>/health-check.sh (SCRIPT_DIR) + <ROOT>/api.sh (sourced)
# + <ROOT>/curl (mocked) + <ROOT>/sleep (no-op) on PATH ahead of /usr/bin.
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$HC" "${ROOT}/health-check.sh"
# Mocked api.sh — pve_env no-op; pve_get returns STUB_IFACES (the
# /interfaces JSON data) so the IP-resolution path is exercised.
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_get() {
printf '%s\n' "${STUB_IFACES:-}"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
# Mocked curl — records the URL it was called with, then succeeds on
# call numbers listed in STUB_CURL_OK_AT (1-based) and fails otherwise.
# Succeeds on the first call if STUB_CURL_OK_AT is unset (happy path).
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
# Track call count across invocations via a counter file.
COUNT_FILE="${STUB_DIR}/curl.count"
n=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$COUNT_FILE"
# Extract the URL (last non-flag arg).
url=""
for a in "$@"; do
case "$a" in
--*) ;;
-*) ;;
*) url="$a" ;;
esac
done
echo "curl:$n url:$url" >> "$CALL_LOG"
ok_at="${STUB_CURL_OK_AT:-}"
if [ -z "$ok_at" ]; then
exit 0
fi
for ok_n in $ok_at; do
if [ "$n" = "$ok_n" ]; then
exit 0
fi
done
exit 1
CSTUB
# Mocked sleep — no-op (the timeout loop runs instantly).
cat > "${ROOT}/sleep" <<'SLSTUB'
#!/bin/sh
:
SLSTUB
chmod +x "${ROOT}"/*.sh "${ROOT}/curl" "${ROOT}/sleep"
export PATH="${ROOT}:${PATH}"
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
# Reset the curl call counter between tests.
: > "${STUB_DIR}/curl.count" 2>/dev/null || true
# Low timeout so failure tests don't loop 600× (sleep is a no-op so
# this is instant regardless, but keep it bounded for clarity).
export PRAXIS_HEALTH_TIMEOUT="5"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "health: PRAXIS_HEALTH_URL override → uses it directly, no /interfaces query" {
export PRAXIS_HEALTH_URL="http://override.test:19999/health"
# STUB_IFACES unset → if the script tried /interfaces it would get empty
# and exit 1; the override must short-circuit before that.
run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q "health-check: polling http://override.test:19999/health" <<< "$output"
grep -q 'health-check: praxis healthy at http://override.test:19999/health' <<< "$output"
}
@test "health: IP resolution via /interfaces → polls http://<ip>:8789/health (NOT /healthz, NOT 18080)" {
STUB_IFACES='[{"name":"eth0","inet":"10.10.10.200"}]'
export STUB_IFACES
run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'health-check: polling http://10.10.10.200:8789/health' <<< "$output"
grep -q 'health-check: praxis healthy at http://10.10.10.200:8789/health' <<< "$output"
# NOT the coreci path/port.
! grep -q '/healthz' <<< "$output"
! grep -q '18080' <<< "$output"
}
@test "health: PRAXIS_PORT override → port in constructed URL" {
STUB_IFACES='[{"name":"eth0","inet":"10.10.10.201"}]'
export STUB_IFACES
PRAXIS_PORT=9000 run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'health-check: polling http://10.10.10.201:9000/health' <<< "$output"
}
@test "health: default port is 8789 when PRAXIS_PORT unset" {
STUB_IFACES='[{"name":"eth0","inet":"10.10.10.202"}]'
export STUB_IFACES
run env -u PRAXIS_PORT "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'http://10.10.10.202:8789/health' <<< "$output"
}
@test "health: default timeout is 600s (G-104 fix — NOT 180s) when PRAXIS_HEALTH_TIMEOUT unset" {
# Override URL + curl succeeds on call 1 → the script exits immediately
# (no loop), but the "for up to <N>s" message reports the default 600.
export PRAXIS_HEALTH_URL="http://ok.test:8789/health"
run env -u PRAXIS_HEALTH_TIMEOUT "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'polling http://ok.test:8789/health for up to 600s' <<< "$output"
# NOT 180s (the coreci default).
! grep -q '180s' <<< "$output"
}
@test "health: IP resolution via .ip field (fallback when .inet absent)" {
STUB_IFACES='[{"name":"eth0","ip":"10.10.10.203"}]'
export STUB_IFACES
run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'health-check: polling http://10.10.10.203:8789/health' <<< "$output"
}
@test "health: IP resolution with hwaddr present → must pick the IP, NOT the MAC (P18 fix)" {
STUB_IFACES='[{"name":"eth0","hwaddr":"aa:bb:cc:dd:ee:ff","inet":"10.10.10.200"}]'
export STUB_IFACES
run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'health-check: polling http://10.10.10.200:8789/health' <<< "$output"
! grep -q 'aa:bb:cc:dd:ee:ff' <<< "$output"
}
@test "health: /interfaces empty (null) → cannot resolve IP → exit 1" {
STUB_IFACES="null"
export STUB_IFACES
run "${ROOT}/health-check.sh" 200
[ "$status" -ne 0 ]
grep -q 'cannot resolve bridge IP for VMID 200' <<< "$output"
# Guidance references the praxis override var (NOT CORECI_HEALTH_URL).
grep -q 'PRAXIS_HEALTH_URL' <<< "$output"
}
@test "health: /interfaces returns no IP → no bridge IP found → exit 1" {
STUB_IFACES='[{"name":"lo","inet":"127.0.0.1"}]'
export STUB_IFACES
run "${ROOT}/health-check.sh" 200
[ "$status" -ne 0 ]
grep -q 'no bridge IP found for VMID 200' <<< "$output"
}
@test "health: curl fails every attempt → timeout → exit 1 (error says 'praxis', NOT 'CoreCI')" {
export PRAXIS_HEALTH_URL="http://fail.test:8789/health"
export STUB_CURL_OK_AT="999"
run "${ROOT}/health-check.sh" 200
[ "$status" -ne 0 ]
grep -q 'praxis did not become healthy within 5s' <<< "$output"
! grep -q 'CoreCI' <<< "$output"
}
@test "health: curl succeeds on 3rd attempt → healthy after retries" {
export PRAXIS_HEALTH_URL="http://retry.test:8789/health"
export STUB_CURL_OK_AT="3"
run "${ROOT}/health-check.sh" 200
[ "$status" -eq 0 ]
grep -q 'health-check: praxis healthy at http://retry.test:8789/health' <<< "$output"
}
@test "health: missing VMID arg → exit non-zero (usage)" {
run "${ROOT}/health-check.sh"
[ "$status" -ne 0 ]
grep -q 'usage: health-check.sh' <<< "$output"
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/lxc-clone.sh (praxis CT clone).
#
# Run: bats scripts/proxmox/test/lxc-clone.bats
#
# lxc-clone.sh creates a CT from a template via POST /nodes/{node}/lxc
# (create-from-template), then polls the returned UPID. These tests
# exercise the real lxc-clone.sh with a mocked api.sh (pve_curl records
# its argv to $CALL_LOG then returns STUB_UPID; pve_poll records the
# UPID) so the POST body shape + UPID-poll + empty-UPID error path are
# verified without a live Proxmox endpoint.
#
# Praxis v0.2 (vs coreci) key differences asserted here:
# - hostname defaults to "praxis" (NOT "coreci")
# - memory defaults to 4096 (NOT 2048)
# - rootfs is <storage>:16 (NOT <storage>:8)
# - features=nesting=1, net0=name=eth0,bridge=vmbr0,ip=dhcp
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
CLONE="${SCRIPT_DIR}/lxc-clone.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox: <ROOT>/lxc-clone.sh (SCRIPT_DIR) + <ROOT>/api.sh (sourced).
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$CLONE" "${ROOT}/lxc-clone.sh"
# Mocked api.sh — pve_env no-op; pve_curl records method + path +
# every form-data pair to $CALL_LOG then returns STUB_UPID; pve_poll
# records the UPID it was asked to wait on.
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_curl() {
method="$1"; path="$2"; shift 2
printf '%s\n' "${method} ${path} $*" >> "$CALL_LOG"
printf '%s\n' "${STUB_UPID:-null}"
}
pve_poll() {
printf 'poll:%s\n' "$1" >> "$CALL_LOG"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
chmod +x "${ROOT}"/*.sh
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_STORAGE="local"
export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "clone: create-from-template POST shape (vmid, ostemplate, hostname=praxis, storage, rootfs=16, memory=4096, net0, arch, features)" {
STUB_UPID="UPID:testnode:00012345:ABCDEF"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 200
[ "$status" -eq 0 ]
# The new VMID is echoed on stdout.
grep -q '^200$' <<< "$output"
# pve_curl POST to /nodes/testnode/lxc recorded with the full body.
grep -q '^POST /nodes/testnode/lxc vmid=200 ostemplate=local:vztmpl/debian-12-template.tar.zst hostname=praxis storage=local rootfs=local:16 memory=4096 net0=name=eth0,bridge=vmbr0,ip=dhcp arch=amd64 features=nesting=1$' "$LOG"
# UPID was polled.
grep -q '^poll:UPID:testnode:00012345:ABCDEF$' "$LOG"
grep -q 'lxc-clone: CT 200 created' <<< "$output"
}
@test "clone: hostname is 'praxis' (NOT 'coreci') — G-106 praxis rebrand" {
STUB_UPID="UPID:h:1"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 201
[ "$status" -eq 0 ]
grep -q ' hostname=praxis ' "$LOG"
! grep -q 'hostname=coreci' "$LOG"
}
@test "clone: memory defaults to 4096 (NOT 2048) — praxis v0.2 sizing" {
STUB_UPID="UPID:m:1"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 202
[ "$status" -eq 0 ]
grep -q ' memory=4096 ' "$LOG"
! grep -q 'memory=2048' "$LOG"
}
@test "clone: rootfs is <storage>:16 (NOT :8) — praxis v0.2 disk sizing" {
STUB_UPID="UPID:r:1"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 203
[ "$status" -eq 0 ]
grep -q ' rootfs=local:16 ' "$LOG"
! grep -q 'rootfs=local:8' "$LOG"
}
@test "clone: features=nesting=1 (Docker-in-LXC requires nesting)" {
STUB_UPID="UPID:f:1"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 204
[ "$status" -eq 0 ]
grep -q 'features=nesting=1' "$LOG"
}
@test "clone: net0 uses bridge=vmbr0,ip=dhcp" {
STUB_UPID="UPID:n:1"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 205
[ "$status" -eq 0 ]
grep -q 'net0=name=eth0,bridge=vmbr0,ip=dhcp' "$LOG"
}
@test "clone: PRAXIS_HOSTNAME override flows into hostname field" {
STUB_UPID="UPID:h:2"
export STUB_UPID
PRAXIS_HOSTNAME="praxis-staging" run "${ROOT}/lxc-clone.sh" 206
[ "$status" -eq 0 ]
grep -q 'hostname=praxis-staging' "$LOG"
}
@test "clone: PROXMOX_MEMORY_MB override flows into memory field" {
STUB_UPID="UPID:m:2"
export STUB_UPID
PROXMOX_MEMORY_MB=8192 run "${ROOT}/lxc-clone.sh" 207
[ "$status" -eq 0 ]
grep -q 'memory=8192' "$LOG"
}
@test "clone: empty UPID (null) → exit 1, no poll, error logged" {
STUB_UPID="null"
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 208
[ "$status" -ne 0 ]
grep -q 'failed to start create (empty UPID)' <<< "$output"
! grep -q '^poll:' "$LOG"
}
@test "clone: empty-string UPID → exit 1, no poll" {
STUB_UPID=""
export STUB_UPID
run "${ROOT}/lxc-clone.sh" 209
[ "$status" -ne 0 ]
grep -q 'failed to start create (empty UPID)' <<< "$output"
! grep -q '^poll:' "$LOG"
}
@test "clone: missing VMID arg → exit non-zero (usage)" {
run "${ROOT}/lxc-clone.sh"
[ "$status" -ne 0 ]
grep -q 'usage: lxc-clone.sh' <<< "$output"
}
@test "clone: pve_env fails on missing PROXMOX_STORAGE → exit non-zero" {
STUB_UPID="UPID:e:1"
export STUB_UPID
run env -u PROXMOX_STORAGE "${ROOT}/lxc-clone.sh" 210
[ "$status" -ne 0 ]
}
@test "clone: pve_env fails on missing PROXMOX_TEMPLATE_VOLID → exit non-zero" {
STUB_UPID="UPID:e:2"
export STUB_UPID
run env -u PROXMOX_TEMPLATE_VOLID "${ROOT}/lxc-clone.sh" 211
[ "$status" -ne 0 ]
}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/lxc-config.sh (praxis CT config).
#
# Run: bats scripts/proxmox/test/lxc-config.bats
#
# lxc-config.sh sets memory + onboot via REST PUT /config (API-token-
# accepted), then sets hookscript + lxc.environment via SSH to the PVE
# host (root-only fields rejected by REST). The SSH heredoc sed -i's
# prior lines then cat >> appends the new ones — idempotent on re-run.
# These tests exercise the real lxc-config.sh with a mocked api.sh
# (pve_curl records the PUT) + a mocked ssh that runs the heredoc body
# locally so sed/cat operate on a sandbox conf file.
#
# Praxis v0.2 (vs coreci) key differences asserted here:
# - hookscript snippet name is "praxis-firstboot.sh" (NOT "coreci-firstboot.sh")
# - lxc.environment includes PRAXIS_PORT=8789 (NOT CORECI_HTTP_PORT=18080)
# - lxc.environment includes voice-service vars (DEEPGRAM, CARTESIA, OLLAMA)
# - memory default 4096 (NOT 2048)
# - PRAXIS_VERSION, PRAXIS_DB_PATH, PRAXIS_TTS, PRAXIS_SCENARIO present
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
CONFIG="${SCRIPT_DIR}/lxc-config.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
CONF_FILE="${STUB_DIR}/pve-lxc-200.conf"
export CONF_FILE
# Sandbox: <ROOT>/lxc-config.sh (SCRIPT_DIR) + <ROOT>/api.sh (sourced)
# + <ROOT>/ssh (mocked) on PATH ahead of /usr/bin.
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$CONFIG" "${ROOT}/lxc-config.sh"
# Mocked api.sh — pve_env validates required env vars (mirrors the
# real helper so the env-validation path is exercised); pve_curl
# records method + path + body.
cat > "${ROOT}/api.sh" <<'ASTUB'
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"
}
pve_curl() {
method="$1"; path="$2"; shift 2
printf '%s\n' "${method} ${path} $*" >> "$CALL_LOG"
printf '%s\n' "${STUB_PVE_CURL_OUT:-null}"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
# Mocked ssh — writes everything after the remote host arg into a
# script and runs it with sh, so the sed -i + cat >> execute locally
# against $CONF_FILE (the heredoc references $conf set from
# $conf_file which the script sets to /etc/pve/lxc/<vmid>.conf — we
# override that path by rewriting the conf= line to point at our
# sandbox file). Records the raw heredoc body to $CALL_LOG.
cat > "${ROOT}/ssh" <<'SSTUB'
#!/bin/sh
# ssh [opts] host <remote-script>
# Drop the opts (-o ...) and the host (root@...); the rest is the script.
shift # drop -o StrictHostKeyChecking=no
host="$1"; shift
remote="$*"
printf '%s\n' "$remote" >> "$CALL_LOG"
# Run the remote script locally so sed/cat operate on the sandbox conf.
# The heredoc sets conf='<path>' then sed -i + cat >> operate on $conf.
# We rewrite the conf path to point at our sandbox file.
remote_fixed=$(printf '%s\n' "$remote" | sed "s|/etc/pve/lxc/[0-9]*\.conf|${CONF_FILE}|g")
sh -c "$remote_fixed"
SSTUB
chmod +x "${ROOT}"/*.sh "${ROOT}/ssh"
export PATH="${ROOT}:${PATH}"
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_STORAGE="local"
export GITEA_TOKEN="gitea-test-token"
export PRAXIS_VERSION="v0.2"
export PRAXIS_PORT="8789"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "config: REST PUT /nodes/{node}/lxc/{vmid}/config with onboot + memory=4096" {
run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
grep -q '^PUT /nodes/testnode/lxc/200/config onboot=1 memory=4096$' "$LOG"
# Default memory is 4096 (NOT 2048 — coreci was 2048).
! grep -q 'memory=2048' "$LOG"
grep -q 'lxc-config: VMID 200 configured' <<< "$output"
}
@test "config: PROXMOX_MEMORY_MB override → memory field reflects it" {
PROXMOX_MEMORY_MB=8192 run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
grep -q 'PUT /nodes/testnode/lxc/200/config onboot=1 memory=8192' "$LOG"
}
@test "config: SSH appends hookscript=local:snippets/praxis-firstboot.sh (NOT coreci-firstboot.sh)" {
run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
[ -f "$CONF_FILE" ]
grep -q '^onboot: 1$' "$CONF_FILE"
grep -q '^hookscript: local:snippets/praxis-firstboot.sh$' "$CONF_FILE"
# NOT coreci (praxis rebrand).
! grep -q 'coreci-firstboot.sh' "$CONF_FILE"
}
@test "config: lxc.environment includes PRAXIS_PORT=8789 (NOT CORECI_HTTP_PORT=18080)" {
run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
[ -f "$CONF_FILE" ]
grep -q '^lxc.environment: PRAXIS_PORT=8789$' "$CONF_FILE"
# NOT the coreci var name + port.
! grep -q 'CORECI_HTTP_PORT' "$CONF_FILE"
! grep -q '18080' "$CONF_FILE"
}
@test "config: lxc.environment includes PRAXIS_VERSION + PRAXIS_DB_PATH + PRAXIS_TTS + PRAXIS_SCENARIO" {
PRAXIS_DB_PATH=/app/data/praxis.db
PRAXIS_TTS=deepgram
PRAXIS_SCENARIO=default
export PRAXIS_DB_PATH PRAXIS_TTS PRAXIS_SCENARIO
run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
grep -q '^lxc.environment: PRAXIS_VERSION=v0.2$' "$CONF_FILE"
grep -q '^lxc.environment: PRAXIS_DB_PATH=/app/data/praxis.db$' "$CONF_FILE"
grep -q '^lxc.environment: PRAXIS_TTS=deepgram$' "$CONF_FILE"
grep -q '^lxc.environment: PRAXIS_SCENARIO=default$' "$CONF_FILE"
}
@test "config: lxc.environment includes GITEA_TOKEN when set" {
run "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
grep -q '^lxc.environment: GITEA_TOKEN=gitea-test-token$' "$CONF_FILE"
}
@test "config: GITEA_TOKEN unset → no GITEA_TOKEN lxc.environment line" {
run env -u GITEA_TOKEN "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
[ -f "$CONF_FILE" ]
grep -q '^hookscript: local:snippets/praxis-firstboot.sh$' "$CONF_FILE"
! grep -q '^lxc.environment: GITEA_TOKEN=' "$CONF_FILE"
# The other env lines are still present.
grep -q '^lxc.environment: PRAXIS_PORT=8789$' "$CONF_FILE"
}
@test "config: lxc.environment includes voice-service vars (DEEPGRAM, CARTESIA, OLLAMA)" {
DEEPGRAM_API_KEY="dg-key"
CARTESIA_API_KEY="cart-key"
OLLAMA_API_KEY="oll-key"
run env DEEPGRAM_API_KEY="$DEEPGRAM_API_KEY" CARTESIA_API_KEY="$CARTESIA_API_KEY" \
OLLAMA_API_KEY="$OLLAMA_API_KEY" "${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
grep -q '^lxc.environment: DEEPGRAM_API_KEY=dg-key$' "$CONF_FILE"
grep -q '^lxc.environment: CARTESIA_API_KEY=cart-key$' "$CONF_FILE"
grep -q '^lxc.environment: OLLAMA_API_KEY=oll-key$' "$CONF_FILE"
# Ollama config defaults present.
grep -q '^lxc.environment: OLLAMA_BASE_URL=http://ollama.cloudinit.dev:11434$' "$CONF_FILE"
grep -q '^lxc.environment: OLLAMA_ROLEPLAY_MODEL=gemma4:cloud$' "$CONF_FILE"
grep -q '^lxc.environment: OLLAMA_DEBRIEF_MODEL=deepseek-v4-flash:cloud$' "$CONF_FILE"
# Deepgram defaults present.
grep -q '^lxc.environment: DEEPGRAM_MODEL=nova-3$' "$CONF_FILE"
grep -q '^lxc.environment: DEEPGRAM_LANGUAGE=en-US$' "$CONF_FILE"
grep -q '^lxc.environment: DEEPGRAM_REGION=us-east-1$' "$CONF_FILE"
}
@test "config: voice-service keys default to empty (v0.2 infrastructure-only)" {
run env -u DEEPGRAM_API_KEY -u CARTESIA_API_KEY -u OLLAMA_API_KEY \
"${ROOT}/lxc-config.sh" 200
[ "$status" -eq 0 ]
# The lines are present but with empty values (v0.2 may ship without
# the secrets; the CT boots and install-service writes the env file).
grep -q '^lxc.environment: DEEPGRAM_API_KEY=$' "$CONF_FILE"
grep -q '^lxc.environment: CARTESIA_API_KEY=$' "$CONF_FILE"
grep -q '^lxc.environment: OLLAMA_API_KEY=$' "$CONF_FILE"
}
@test "config: idempotent — re-run does not duplicate hookscript/lxc.environment lines" {
# First run appends the lines.
"${ROOT}/lxc-config.sh" 200 >/dev/null 2>&1
# Seed a stale line that the sed should remove (simulates prior state).
printf 'hookscript: local:snippets/OLD.sh\n' >> "$CONF_FILE"
# Second run — sed -i removes prior lines, then cat >> appends fresh.
"${ROOT}/lxc-config.sh" 200 >/dev/null 2>&1
[ -f "$CONF_FILE" ]
! grep -q 'OLD.sh' "$CONF_FILE"
[ "$(grep -c '^hookscript:' "$CONF_FILE")" -eq 1 ]
[ "$(grep -c '^onboot:' "$CONF_FILE")" -eq 1 ]
[ "$(grep -c '^lxc.environment: PRAXIS_PORT=' "$CONF_FILE")" -eq 1 ]
[ "$(grep -c '^lxc.environment: GITEA_TOKEN=' "$CONF_FILE")" -eq 1 ]
[ "$(grep -c '^lxc.environment: OLLAMA_BASE_URL=' "$CONF_FILE")" -eq 1 ]
}
@test "config: missing VMID arg → exit non-zero (usage)" {
run "${ROOT}/lxc-config.sh"
[ "$status" -ne 0 ]
grep -q 'usage: lxc-config.sh' <<< "$output"
}
@test "config: pve_env fails on missing PROXMOX_API_TOKEN → exit non-zero" {
run env -u PROXMOX_API_TOKEN "${ROOT}/lxc-config.sh" 200
[ "$status" -ne 0 ]
}
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/lxc-deploy.sh orchestration (SLICE-09).
#
# Run: bats scripts/proxmox/test/lxc-deploy.bats
#
# lxc-deploy.sh orchestrates: stage-snippet → clone → config → start →
# health-check → success. On ANY failure the EXIT trap fires rollback.sh.
# The trap captures $? so a `set -e` child failure (e.g. health-check)
# triggers rollback, not just INT/TERM.
#
# Idempotency (D-027): if the target VMID already exists + is healthy,
# the deploy skips clone/config/start (idempotent re-deploy). If the CT
# exists but is unhealthy, the operator must pass --recreate (rollback +
# redeploy) or --reconfigure (re-PUT config + restart) — otherwise the
# deploy errors with guidance and leaves the CT intact.
#
# These tests build a sandbox copy of lxc-deploy.sh with stub sibling
# scripts + a stub api.sh + the REAL ct-exists.sh (P16) + a stub
# timing.sh so the real orchestrator logic (trap, sequencing,
# idempotency, flag parsing) is exercised without a live Proxmox
# endpoint.
#
# Praxis v0.2 (vs coreci) key differences asserted here:
# - NO proxy/backend-add/smoke-test steps (proxy tier removed)
# - VMID auto-allocation via pve_nextid when PROXMOX_LXC_VMID unset
# - hookscript snippet volid is local:snippets/praxis-firstboot.sh
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
DEPLOY="${SCRIPT_DIR}/lxc-deploy.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox layout:
# <ROOT>/lxc-deploy.sh (SCRIPT_DIR)
# <ROOT>/api.sh (sourced)
# <ROOT>/ct-exists.sh (REAL — sourced by lxc-deploy.sh)
# <ROOT>/timing.sh (stubbed — sourced by lxc-deploy.sh)
# <ROOT>/stage-snippet.sh (invoked)
# <ROOT>/lxc-clone.sh (invoked)
# <ROOT>/lxc-config.sh (invoked)
# <ROOT>/lxc-start.sh (invoked)
# <ROOT>/health-check.sh (invoked; exit overridable)
# <ROOT>/rollback.sh (invoked on failure; records call)
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$DEPLOY" "${ROOT}/lxc-deploy.sh"
# ct-exists.sh (P16) — REAL, sourced by lxc-deploy.sh.
cp "${SCRIPT_DIR}/ct-exists.sh" "${ROOT}/ct-exists.sh"
# recording stub generator: logs "<name>:<args>" to $CALL_LOG, exits
# with the given code (default 0).
log_stub() {
name="$1"; exit_var="$2"
printf '#!/bin/sh\necho "%s:$*" >> "%s"\nexit ${%s:-0}\n' \
"$name" "$CALL_LOG" "$exit_var" > "${ROOT}/${name}.sh"
chmod +x "${ROOT}/${name}.sh"
}
log_stub stage-snippet STUB_SNIPPET_EXIT
log_stub lxc-clone STUB_CLONE_EXIT
log_stub lxc-config STUB_CONFIG_EXIT
log_stub lxc-start STUB_START_EXIT
log_stub rollback STUB_ROLLBACK_EXIT
# health-check stub: exit overridable; fails the FIRST call (the
# idempotency probe) when STUB_HEALTH_FIRST_FAIL=1, then passes
# subsequent calls (the post-remediation health-check).
cat > "${ROOT}/health-check.sh" <<'HSTUB'
#!/bin/sh
echo "health-check:$*" >> "$CALL_LOG"
count_file="${CALL_LOG}.hc"
n=$(cat "$count_file" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$count_file"
if [ "${STUB_HEALTH_FIRST_FAIL:-0}" = "1" ] && [ "$n" -eq 1 ]; then
exit 1
fi
exit ${STUB_HEALTH_EXIT:-0}
HSTUB
chmod +x "${ROOT}/health-check.sh"
# Mocked api.sh — pve_env no-op; pve_nextid returns STUB_NEXTID;
# pve_get returns STUB_PVE_GET (empty by default → ct not found +
# snippet-exists check finds nothing → stage-snippet runs); pve_curl
# + pve_poll no-op.
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; }
pve_get() { printf '%s\n' "${STUB_PVE_GET:-}"; }
pve_curl() { :; }
pve_poll() { :; }
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
chmod +x "${ROOT}/api.sh"
# timing.sh — stubbed to no-op so the orchestrator logic is exercised
# without the real helper; timing.sh itself is tested in timing.bats.
cat > "${ROOT}/timing.sh" <<'EOF'
timing_start() { :; }
timing_end() { :; }
EOF
chmod +x "${ROOT}/timing.sh"
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_STORAGE="local"
export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst"
export GITEA_TOKEN="gitea-test-token"
export PROXMOX_LXC_VMID="200"
# Reset the health-check call counter between tests.
rm -f "${CALL_LOG}.hc" 2>/dev/null || true
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
# ── Happy path ───────────────────────────────────────────────────
@test "happy path: stage → clone → config → start → health → no rollback, success" {
STUB_HEALTH_EXIT=0
export STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q '^VMID=200$' <<< "$output"
grep -q '^stage-snippet:' "$LOG"
grep -q '^lxc-clone:200' "$LOG"
grep -q '^lxc-config:200' "$LOG"
grep -q '^lxc-start:200' "$LOG"
grep -q '^health-check:200' "$LOG"
# Rollback MUST NOT fire on success.
! grep -q '^rollback:' "$LOG"
grep -q 'deploy: praxis deployed successfully to VMID 200' <<< "$output"
}
# ── Rollback on failure (trap fix: $? capture) ──────────────────
@test "health-check fails (set -e) → rollback fires (trap fix: $? capture) → CT destroyed" {
# THE TRAP FIX: a `set -e` child failure (health-check exits 1)
# must trigger rollback. The trap captures $? so rc != 0 fires
# rollback (not just INT/TERM).
STUB_HEALTH_EXIT=1
export STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -ne 0 ]
grep -q '^health-check:200' "$LOG"
grep -q '^rollback:200' "$LOG"
grep -q 'deploy: FAILED' <<< "$output"
}
@test "clone fails (set -e) → rollback fires (trap fix) → CT destroyed" {
# Same trap fix, earlier failure: clone failure also fires rollback.
STUB_CLONE_EXIT=1
export STUB_CLONE_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -ne 0 ]
grep -q '^lxc-clone:200' "$LOG"
grep -q '^rollback:200' "$LOG"
# config/start/health NOT reached.
! grep -q '^lxc-config:' "$LOG"
! grep -q '^health-check:' "$LOG"
}
@test "config fails (set -e) → rollback fires, start/health NOT reached" {
STUB_CONFIG_EXIT=1
export STUB_CONFIG_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -ne 0 ]
grep -q '^lxc-config:200' "$LOG"
grep -q '^rollback:200' "$LOG"
! grep -q '^lxc-start:' "$LOG"
! grep -q '^health-check:' "$LOG"
}
@test "start fails (set -e) → rollback fires, health NOT reached" {
STUB_START_EXIT=1
export STUB_START_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -ne 0 ]
grep -q '^lxc-start:200' "$LOG"
grep -q '^rollback:200' "$LOG"
! grep -q '^health-check:' "$LOG"
}
@test "stage-snippet fails (set -e) → exit non-zero, clone NOT reached (trap not yet installed)" {
# NOTE: stage-snippet runs at step 0 (line 65), BEFORE the vmid is
# resolved (line 69) + BEFORE the EXIT trap is installed (line 88).
# So a stage-snippet failure exits at line 65 without firing
# rollback (the trap isn't registered yet). This is a known
# ordering: the snippet is staged before any CT is created, so
# there's nothing to roll back.
STUB_SNIPPET_EXIT=1
export STUB_SNIPPET_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -ne 0 ]
grep -q '^stage-snippet:' "$LOG"
! grep -q '^lxc-clone:' "$LOG"
# No rollback: the trap isn't installed yet at this failure point.
! grep -q '^rollback:' "$LOG"
}
# ── VMID auto-allocation (D-027) ────────────────────────────────
@test "PROXMOX_LXC_VMID unset → auto-allocate via pve_nextid (STUB_NEXTID)" {
STUB_HEALTH_EXIT=0
STUB_NEXTID=250
export STUB_HEALTH_EXIT STUB_NEXTID
run env -u PROXMOX_LXC_VMID "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q 'deploy: auto-allocated VMID 250' <<< "$output"
grep -q '^VMID=250$' <<< "$output"
grep -q '^lxc-clone:250' "$LOG"
}
@test "PROXMOX_LXC_VMID set → use the configured VMID (no auto-allocate)" {
STUB_HEALTH_EXIT=0
export STUB_HEALTH_EXIT
PROXMOX_LXC_VMID=300 run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q 'deploy: using configured VMID 300' <<< "$output"
grep -q '^VMID=300$' <<< "$output"
grep -q '^lxc-clone:300' "$LOG"
}
# ── Idempotency (D-027) ─────────────────────────────────────────
@test "VMID not exists → clone proceeds (current path)" {
STUB_HEALTH_EXIT=0
export STUB_HEALTH_EXIT
# STUB_PVE_GET unset → empty → ct_exists false.
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q '^VMID=200$' <<< "$output"
grep -q '^lxc-clone:200' "$LOG"
grep -q '^lxc-config:200' "$LOG"
grep -q '^lxc-start:200' "$LOG"
grep -q '^health-check:200' "$LOG"
! grep -q '^rollback:' "$LOG"
}
@test "VMID exists + running + healthy → skip clone/config/start (idempotent re-deploy)" {
STUB_PVE_GET='{"status":"running","vmid":200}'
STUB_HEALTH_EXIT=0
export STUB_PVE_GET STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q 'already running + healthy — skipping clone/config/start (idempotent re-deploy)' <<< "$output"
! grep -q '^lxc-clone:' "$LOG"
! grep -q '^lxc-config:' "$LOG"
! grep -q '^lxc-start:' "$LOG"
grep -q '^health-check:200' "$LOG"
! grep -q '^rollback:' "$LOG"
grep -q '^VMID=200$' <<< "$output"
}
@test "VMID exists + unhealthy, no flag → exit 1 with guidance (--recreate / --reconfigure)" {
STUB_PVE_GET='{"status":"running","vmid":200}'
STUB_HEALTH_EXIT=1
export STUB_PVE_GET STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 1 ]
grep -q 'exists but is unhealthy' <<< "$output"
grep -q -- '--recreate' <<< "$output"
grep -q -- '--reconfigure' <<< "$output"
grep -q 'No action taken' <<< "$output"
! grep -q '^lxc-clone:' "$LOG"
! grep -q '^rollback:' "$LOG"
}
@test "VMID exists but not running, no flag → exit 1 with guidance (not running counts as unhealthy)" {
STUB_PVE_GET='{"status":"stopped","vmid":200}'
STUB_HEALTH_EXIT=0
export STUB_PVE_GET STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 1 ]
grep -q 'exists but is unhealthy' <<< "$output"
grep -q -- '--recreate' <<< "$output"
! grep -q '^lxc-clone:' "$LOG"
! grep -q '^rollback:' "$LOG"
}
@test "--recreate → rollback.sh called + redeploy proceeds (clone runs after destroy)" {
STUB_PVE_GET='{"status":"running","vmid":200}'
STUB_HEALTH_FIRST_FAIL=1
STUB_HEALTH_EXIT=0
export STUB_PVE_GET STUB_HEALTH_FIRST_FAIL STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh" --recreate
[ "$status" -eq 0 ]
grep -q -- '--recreate: rollback + redeploy' <<< "$output"
grep -q '^rollback:200' "$LOG"
grep -q '^lxc-clone:200' "$LOG"
grep -q '^lxc-config:200' "$LOG"
grep -q '^lxc-start:200' "$LOG"
grep -q '^health-check:200' "$LOG"
grep -q '^VMID=200$' <<< "$output"
}
@test "--reconfigure → lxc-config.sh re-PUT + lxc-start.sh restart (no clone)" {
STUB_PVE_GET='{"status":"running","vmid":200}'
STUB_HEALTH_FIRST_FAIL=1
STUB_HEALTH_EXIT=0
export STUB_PVE_GET STUB_HEALTH_FIRST_FAIL STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh" --reconfigure
[ "$status" -eq 0 ]
grep -q -- '--reconfigure: re-PUT config + restart' <<< "$output"
grep -q '^lxc-config:200' "$LOG"
grep -q '^lxc-start:200' "$LOG"
! grep -q '^lxc-clone:' "$LOG"
! grep -q '^rollback:' "$LOG"
grep -q '^VMID=200$' <<< "$output"
}
# ── Flag parsing ────────────────────────────────────────────────
@test "unknown flag → exit 2 with error" {
STUB_PVE_GET='{"status":"running","vmid":200}'
STUB_HEALTH_EXIT=0
export STUB_PVE_GET STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh" --bogus
[ "$status" -eq 2 ]
grep -q 'unknown argument: --bogus' <<< "$output"
}
# ── Snippet-exists short-circuit ────────────────────────────────
@test "hookscript snippet already staged → stage-snippet.sh NOT re-run (idempotent)" {
# The snippet-exists check calls pve_get /storage/.../content + jq.
# Return a content array containing the praxis-firstboot.sh volid →
# stage-snippet is skipped. The ct_exists check queries a DIFFERENT
# path (/status/current), so we install a path-aware pve_get stub
# that returns the content array for /storage/.../content and empty
# for /status/current (CT not exists → clone proceeds).
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; }
pve_get() {
case "$1" in
*/storage/*/content)
printf '%s\n' '[{"volid":"local:snippets/praxis-firstboot.sh"}]'
;;
*/lxc/*/status/current)
printf '%s\n' ''
;;
*)
printf '%s\n' "${STUB_PVE_GET:-}"
;;
esac
}
pve_curl() { :; }
pve_poll() { :; }
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
chmod +x "${ROOT}/api.sh"
STUB_HEALTH_EXIT=0
export STUB_HEALTH_EXIT
run "${ROOT}/lxc-deploy.sh"
[ "$status" -eq 0 ]
grep -q 'hookscript snippet local:snippets/praxis-firstboot.sh already staged — skipping upload' <<< "$output"
! grep -q '^stage-snippet:' "$LOG"
# clone/config/start/health still run (CT not exists).
grep -q '^lxc-clone:200' "$LOG"
grep -q '^health-check:200' "$LOG"
! grep -q '^rollback:' "$LOG"
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/lxc-start.sh (praxis CT start).
#
# Run: bats scripts/proxmox/test/lxc-start.bats
#
# lxc-start.sh POSTs to /nodes/{node}/lxc/{vmid}/status/start, then
# polls the returned UPID until the async start task completes. These
# tests exercise the real lxc-start.sh with a mocked api.sh (pve_curl
# returns the UPID, pve_poll records the call) so the start-POST +
# UPID-poll + empty-UPID error path are verified without a live
# Proxmox endpoint.
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
START="${SCRIPT_DIR}/lxc-start.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox: <ROOT>/lxc-start.sh (SCRIPT_DIR) + <ROOT>/api.sh (sourced).
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$START" "${ROOT}/lxc-start.sh"
# Mocked api.sh — pve_env no-op; pve_curl records method + path then
# returns STUB_UPID; pve_poll records the UPID it was asked to wait on.
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_curl() {
method="$1"; path="$2"; shift 2
printf '%s\n' "${method} ${path}" >> "$CALL_LOG"
printf '%s\n' "${STUB_UPID:-null}"
}
pve_poll() {
printf 'poll:%s\n' "$1" >> "$CALL_LOG"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
chmod +x "${ROOT}"/*.sh
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "start: POST /nodes/{node}/lxc/{vmid}/status/start + UPID poll → running" {
STUB_UPID="UPID:testnode:00056789:START"
export STUB_UPID
run "${ROOT}/lxc-start.sh" 200
[ "$status" -eq 0 ]
grep -q '^POST /nodes/testnode/lxc/200/status/start$' "$LOG"
grep -q '^poll:UPID:testnode:00056789:START$' "$LOG"
grep -q 'lxc-start: VMID 200 is running' <<< "$output"
}
@test "start: empty UPID (null) → exit 1, no poll, error logged" {
STUB_UPID="null"
export STUB_UPID
run "${ROOT}/lxc-start.sh" 201
[ "$status" -ne 0 ]
grep -q '^POST /nodes/testnode/lxc/201/status/start$' "$LOG"
grep -q 'failed to start (empty UPID)' <<< "$output"
! grep -q '^poll:' "$LOG"
}
@test "start: empty-string UPID → exit 1, no poll" {
STUB_UPID=""
export STUB_UPID
run "${ROOT}/lxc-start.sh" 202
[ "$status" -ne 0 ]
grep -q 'failed to start (empty UPID)' <<< "$output"
! grep -q '^poll:' "$LOG"
}
@test "start: missing VMID arg → exit non-zero (usage)" {
run "${ROOT}/lxc-start.sh"
[ "$status" -ne 0 ]
grep -q 'usage: lxc-start.sh' <<< "$output"
}
@test "start: pve_env fails on missing PROXMOX_NODE → exit non-zero (set -u on \${PROXMOX_NODE})" {
STUB_UPID="UPID:e:1"
export STUB_UPID
run env -u PROXMOX_NODE "${ROOT}/lxc-start.sh" 203
[ "$status" -ne 0 ]
}
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/rollback.sh (praxis CT rollback).
#
# Run: bats scripts/proxmox/test/rollback.bats
#
# rollback.sh stops (graceful, then force) and destroys a CT. It is
# idempotent (a 404 / already-gone CT is not an error). These tests
# exercise the real rollback.sh with a mocked api.sh (pve_curl, pve_get,
# pve_poll) so the shutdown → force-stop → destroy sequence + the
# 404-tolerant paths are verified without a live Proxmox endpoint.
#
# Praxis v0.2 (vs coreci) key difference asserted here:
# - NO proxy / PROXY_VMID / backend-remove.sh references (the proxy
# tier was removed in v0.2). rollback.sh is stop + destroy only.
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
ROLLBACK="${SCRIPT_DIR}/rollback.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
# Sandbox layout:
# <ROOT>/rollback.sh (SCRIPT_DIR)
# <ROOT>/api.sh (sourced)
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$ROLLBACK" "${ROOT}/rollback.sh"
# Mocked api.sh — pve_curl records method+path and returns STUB_UPID
# (or null); pve_get returns STUB_PVE_GET (so the "still running?"
# check fires when status=running); pve_poll no-op.
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_curl() {
method="$1"; path="$2"
printf 'pve_curl:%s %s\n' "$method" "$path" >> "$CALL_LOG"
printf '%s\n' "${STUB_UPID:-null}"
}
pve_get() {
printf 'pve_get:%s\n' "$1" >> "$CALL_LOG"
printf '%s\n' "${STUB_PVE_GET:-}"
}
pve_poll() {
printf 'pve_poll:%s\n' "$1" >> "$CALL_LOG"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
chmod +x "${ROOT}"/*.sh
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "rollback: shutdown → force-stop → destroy sequence (CT running)" {
# CT is running → graceful shutdown, then status=running → force stop, then destroy.
STUB_UPID="UPID:task:123"
STUB_PVE_GET='{"status":"running"}'
export STUB_UPID STUB_PVE_GET
run "${ROOT}/rollback.sh" 200
[ "$status" -eq 0 ]
grep -q 'rollback: cleaning up VMID 200' <<< "$output"
# shutdown POST recorded.
grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/shutdown$' "$LOG"
# status check via pve_get.
grep -q '^pve_get:/nodes/testnode/lxc/200/status/current$' "$LOG"
grep -q 'rollback: force-stopping VMID 200' <<< "$output"
grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/stop$' "$LOG"
grep -q 'rollback: destroying VMID 200' <<< "$output"
grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG"
grep -q 'rollback: VMID 200 cleaned up' <<< "$output"
}
@test "rollback: CT not running (stopped) → shutdown, no force-stop, destroy" {
# CT exists but status=stopped → no force-stop needed; destroy still runs.
STUB_UPID="UPID:task:456"
STUB_PVE_GET='{"status":"stopped"}'
export STUB_UPID STUB_PVE_GET
run "${ROOT}/rollback.sh" 200
[ "$status" -eq 0 ]
grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/shutdown$' "$LOG"
! grep -q 'force-stopping' <<< "$output"
! grep -q '^pve_curl:POST /nodes/testnode/lxc/200/status/stop$' "$LOG"
grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG"
grep -q 'rollback: VMID 200 cleaned up' <<< "$output"
}
@test "rollback: 404 (CT already gone) → idempotent, exit 0 (no force-stop, no error)" {
# pve_get returns empty (404) → no force-stop; shutdown + destroy both
# return null UPID (no poll). Exit 0.
STUB_UPID="null"
STUB_PVE_GET=""
export STUB_UPID STUB_PVE_GET
run "${ROOT}/rollback.sh" 200
[ "$status" -eq 0 ]
! grep -q 'force-stopping' <<< "$output"
grep -q 'rollback: destroying VMID 200' <<< "$output"
grep -q 'rollback: VMID 200 cleaned up' <<< "$output"
}
@test "rollback: shutdown returns null UPID → no poll, but destroy still runs (404-tolerant)" {
# shutdown returns null (CT already stopped) → skip poll; destroy still runs.
STUB_UPID="null"
STUB_PVE_GET='{"status":"stopped"}'
export STUB_UPID STUB_PVE_GET
run "${ROOT}/rollback.sh" 200
[ "$status" -eq 0 ]
! grep -q '^pve_poll:' "$LOG"
grep -q '^pve_curl:DELETE /nodes/testnode/lxc/200$' "$LOG"
}
@test "rollback: missing VMID arg → exit non-zero (usage)" {
run "${ROOT}/rollback.sh"
[ "$status" -ne 0 ]
grep -q 'usage: rollback.sh' <<< "$output"
}
@test "rollback: NO proxy/PROXY_VMID/backend-remove references in CODE (v0.2 proxy tier removed)" {
# G-106 / v0.2: the proxy tier was removed. rollback.sh must NOT
# reference PROXY_VMID or invoke proxy/backend-remove.sh in its CODE
# (the header comment may mention the removal for future readers, but
# no executable path references the proxy tier). Assert by grepping the
# call log (no backend-remove invocation at runtime) + stripping
# comments before grepping the source for PROXY_VMID / backend-remove.sh.
STUB_UPID="null"
STUB_PVE_GET=""
export STUB_UPID STUB_PVE_GET
PROXY_VMID=100 run "${ROOT}/rollback.sh" 200
[ "$status" -eq 0 ]
! grep -q 'backend-remove' "$LOG"
! grep -q 'proxy' "$LOG"
# Static source guard: strip comment-only lines, then assert no code
# references to the proxy tier.
code_only=$(grep -v '^[[:space:]]*#' "${ROOT}/rollback.sh")
! printf '%s\n' "$code_only" | grep -q 'PROXY_VMID'
! printf '%s\n' "$code_only" | grep -q 'backend-remove\.sh'
}
@test "rollback: pve_env fails on missing PROXMOX_NODE → exit non-zero (set -u)" {
run env -u PROXMOX_NODE "${ROOT}/rollback.sh" 200
[ "$status" -ne 0 ]
}
+100
View File
@@ -0,0 +1,100 @@
# Shared helpers for the praxis proxmox bats test suite.
#
# Sourced (via `load`) by the per-script .bats files to build a consistent
# sandbox: a temp STUB_DIR, a CALL_LOG, a sandbox ROOT with a mocked
# api.sh + recording stubs for the provision siblings. Each .bats file
# may further specialize the sandbox in its own setup().
#
# Usage from a .bats file:
# setup() {
# load setup_helper
# praxis_sandbox_init # sets STUB_DIR, LOG, ROOT, mocks
# PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
# ...
# }
# teardown() { praxis_sandbox_teardown; }
#
# Helpers exported (functions):
# praxis_sandbox_init — create the sandbox + default mocks
# praxis_sandbox_teardown — rm -rf the sandbox
# praxis_log_stub <name> <exit-var>
# — write a recording stub at ROOT/<name>.sh
# that logs "<name>:<args>" to $CALL_LOG and
# exits ${<exit-var>:-0}
# praxis_mock_api_default — install the default mocked api.sh
# (pve_env no-op, pve_nextid → STUB_NEXTID,
# pve_get → STUB_PVE_GET, pve_curl no-op,
# pve_poll no-op). Tests may override
# individual funcs after calling this.
# praxis_sandbox_init — create the sandbox. Idempotent-ish: callers usually
# invoke once in setup(). Sets these globals for the test:
# STUB_DIR — temp dir root (cleaned in teardown)
# CALL_LOG — shared call log path (tests grep this)
# ROOT — sandbox root dir (real SCRIPT_DIR stand-in; siblings live here)
praxis_sandbox_init() {
STUB_DIR="$(mktemp -d)"
export STUB_DIR
CALL_LOG="${STUB_DIR}/calls.log"
: > "$CALL_LOG" 2>/dev/null || true
export CALL_LOG
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
export ROOT
# Default mocked api.sh — tests can overwrite ${ROOT}/api.sh after this.
praxis_mock_api_default
}
praxis_sandbox_teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
# praxis_log_stub <name> <exit-var> — write a recording stub at
# ${ROOT}/<name>.sh that logs "<name>:<args>" to $CALL_LOG and exits
# with ${<exit-var>:-0}. The stub is chmod +x.
praxis_log_stub() {
_name="$1"; _exit_var="$2"
printf '#!/bin/sh\necho "%s:$*" >> "%s"\nexit ${%s:-0}\n' \
"$_name" "$CALL_LOG" "$_exit_var" > "${ROOT}/${_name}.sh"
chmod +x "${ROOT}/${_name}.sh"
}
# praxis_mock_api_default — install the default mocked api.sh.
# pve_env no-op; pve_nextid returns ${STUB_NEXTID:-200}; pve_get returns
# ${STUB_PVE_GET:-}; pve_curl no-op; pve_poll no-op. Override by writing
# your own ${ROOT}/api.sh after calling this (or by redefining funcs in
# your own setup).
praxis_mock_api_default() {
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_nextid() { printf '%s\n' "${STUB_NEXTID:-200}"; }
pve_get() { printf '%s\n' "${STUB_PVE_GET:-}"; }
pve_curl() { :; }
pve_poll() { :; }
pve_tls_insecure() { printf '%s\n' "${STUB_TLS_INSECURE:-}"; }
pve_auth_header() { printf 'PVEAPIToken=%s' "${PROXMOX_API_TOKEN:-}"; }
pve_lxc_env_args() {
first=1
for pair in "$@"; do
[ "$first" -eq 0 ] && printf '\n'
printf '%s' "lxc.environment=${pair}"
first=0
done
}
ASTUB
chmod +x "${ROOT}/api.sh"
}
# praxis_common_env — export the common Proxmox env vars used by every
# test (all mocked; no live endpoint). Tests may override per-scenario.
praxis_common_env() {
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_STORAGE="local"
export PROXMOX_TEMPLATE_VOLID="local:vztmpl/debian-12-template.tar.zst"
export GITEA_TOKEN="gitea-test-token"
export PRAXIS_VERSION="v0.2"
export PRAXIS_PORT="8789"
export PROXMOX_LXC_VMID="200"
}
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bats
# Bats tests for scripts/proxmox/stage-snippet.sh (snippet staging).
#
# Run: bats scripts/proxmox/test/stage-snippet.bats
#
# stage-snippet.sh fetches firstboot-hook.sh from Gitea, bakes the
# GITEA_TOKEN into it via sed (G-101 fix), serves it over a local
# one-shot HTTP server, then POSTs to the Proxmox download-url endpoint
# to upload it to local:snippets/praxis-firstboot.sh. Finally it polls
# the upload task + verifies the snippet is present via pve_get.
#
# These tests exercise the real stage-snippet.sh with mocked: curl
# (fetches the raw snippet from a fixture), python3 (no-op server so
# we don't actually bind a port), and api.sh (pve_curl/pve_poll/pve_get
# recording stubs). The G-101 sed bake is verified against the fixture.
#
# Praxis v0.2 (vs coreci) key differences asserted here:
# - snippet name is "praxis-firstboot.sh" (NOT "coreci-firstboot.sh")
# - G-101 fix: GITEA_TOKEN is baked into the snippet via sed
# - download-url POST with url=, content=snippets, filename=
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
STAGE="${SCRIPT_DIR}/stage-snippet.sh"
STUB_DIR="$(mktemp -d)"
export STUB_DIR
LOG="${STUB_DIR}/calls.log"
export CALL_LOG="$LOG"
: > "$LOG" 2>/dev/null || true
ROOT="${STUB_DIR}/root"
mkdir -p "$ROOT"
cp "$STAGE" "${ROOT}/stage-snippet.sh"
# Fixture: the raw firstboot-hook.sh with a ${GITEA_TOKEN} placeholder
# (mirrors the real firstboot-hook.sh shape). stage-snippet.sh sed-bakes
# the token into this. We capture the fetched + sed-processed file via
# the curl -o target so we can assert the bake happened.
FIXTURE="${STUB_DIR}/firstboot-hook.sh"
cat > "$FIXTURE" <<'FIX'
#!/bin/sh
# fixture firstboot hook with a placeholder token.
CLONE_URL="https://${GITEA_TOKEN}@git.example.com/org/repo.git"
echo "token is ${GITEA_TOKEN}"
FIX
export FIXTURE
# Mocked api.sh — pve_env no-op; pve_curl records method+path+body and
# returns STUB_UPID; pve_poll records the UPID; pve_get returns
# STUB_CONTENT (the /storage/.../content JSON for the verify step).
cat > "${ROOT}/api.sh" <<'ASTUB'
pve_env() { :; }
pve_curl() {
method="$1"; path="$2"; shift 2
printf 'pve_curl:%s %s %s\n' "$method" "$path" "$*" >> "$CALL_LOG"
printf '%s\n' "${STUB_UPID:-null}"
}
pve_poll() {
printf 'pve_poll:%s\n' "$1" >> "$CALL_LOG"
}
pve_get() {
printf 'pve_get:%s\n' "$1" >> "$CALL_LOG"
printf '%s\n' "${STUB_CONTENT:-}"
}
pve_tls_insecure() { :; }
pve_auth_header() { :; }
ASTUB
# Mocked curl — the first curl in stage-snippet.sh is `curl -sS -f
# $insecure -o "$raw_snippet" "$RAW_URL"` (fetch the raw snippet).
# We copy the fixture to the -o target so the sed-bake operates on
# real content. Subsequent curl calls (none in the happy path beyond
# the fetch) fall through to a no-op success.
cat > "${ROOT}/curl" <<'CSTUB'
#!/bin/sh
# Parse -o <target> and the trailing URL.
out=""
url=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
--insecure|-sS|-s|-f) shift ;;
--max-time) shift 2 ;;
-w) shift 2 ;;
-H) shift 2 ;;
*) url="$1"; shift ;;
esac
done
printf 'curl:out=%s url=%s\n' "$out" "$url" >> "$CALL_LOG"
if [ -n "$out" ]; then
# Fetch step: copy the fixture to the -o target.
cp "${FIXTURE}" "$out"
fi
exit 0
CSTUB
chmod +x "${ROOT}/curl"
# Mocked python3 — stage-snippet.sh runs `python3 -m http.server ...`
# in the background. We no-op it (print nothing, exit 0 immediately)
# so no port is bound. The backgrounding + wait is harmless.
cat > "${ROOT}/python3" <<'PSTUB'
#!/bin/sh
# Drop -m http.server args; just exit 0 (no port bound).
exit 0
PSTUB
chmod +x "${ROOT}/python3"
# Mocked sleep — no-op (the `sleep 1` after server start + `sleep 60`
# safety net become instant).
cat > "${ROOT}/sleep" <<'SLSTUB'
#!/bin/sh
:
SLSTUB
chmod +x "${ROOT}/sleep"
chmod +x "${ROOT}"/*.sh
export PATH="${ROOT}:${PATH}"
export PROXMOX_API_URL="https://proxmox.test:8006/api2/json"
export PROXMOX_API_TOKEN="root@pam!test=secret"
export PROXMOX_NODE="testnode"
export PROXMOX_STORAGE="local"
export GITEA_TOKEN="gitea-test-token"
export GITEA_HOST="git.cloudinit.dev"
export PRAXIS_VERSION="v0.2"
# Default: the verify step sees the snippet present (single-element
# array with the matching volid). Tests override to empty for the
# "not found after upload" path.
STUB_CONTENT='[{"volid":"local:snippets/praxis-firstboot.sh"}]'
export STUB_CONTENT
STUB_UPID="UPID:upload:1"
export STUB_UPID
}
teardown() {
[ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR"
}
@test "stage: happy path — fetch + bake + upload + poll + verify, exit 0" {
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q 'stage-snippet: fetching firstboot-hook.sh from Gitea' <<< "$output"
grep -q 'stage-snippet: baking GITEA_TOKEN into snippet (G-101 fix)' <<< "$output"
grep -q 'stage-snippet: local:snippets/praxis-firstboot.sh staged' <<< "$output"
}
@test "stage: snippet name is praxis-firstboot.sh (NOT coreci-firstboot.sh) — G-106 rebrand" {
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q 'praxis-firstboot.sh' <<< "$output"
! grep -q 'coreci-firstboot.sh' <<< "$output"
# The download-url POST records filename=praxis-firstboot.sh.
grep -q 'pve_curl:POST /nodes/testnode/storage/local/download-url' "$LOG"
grep -q 'filename=praxis-firstboot.sh' "$LOG"
! grep -q 'filename=coreci-firstboot.sh' "$LOG"
}
@test "stage: G-101 fix — GITEA_TOKEN is baked into the fetched snippet via sed (placeholder replaced)" {
# Capture the raw_snippet path by inspecting the curl log: stage-snippet
# fetches to ${tmp_dir}/praxis-firstboot.sh. We re-run + read that file
# from the temp dir before the EXIT trap cleans it. Easiest: patch the
# script's tmp_dir to a known path via env? The script uses mktemp -d,
# so we instead assert via the curl -o target recorded in the log, then
# cat that file in the same test (it persists until teardown since the
# script's trap runs at its EXIT — by then we've already read it).
# Run in a subshell so the script's EXIT trap cleans ITS temp, not ours.
# Instead: copy the fixture to OUR known path and assert sed -i ran by
# grepping the curl-fetch -o target after the script completes.
# Simplest robust approach: re-run with a wrapper that copies the
# fetched+seded file out before the trap fires.
capture_dir="${STUB_DIR}/captured"
mkdir -p "$capture_dir"
# Wrap: after stage-snippet.sh runs, the trap has cleaned its tmp_dir,
# so we instead intercept the curl -o target by patching curl to also
# copy the post-sed file to $capture_dir at the time of the SECOND
# curl call (there is only one curl call — the fetch). The sed -i
# runs AFTER the fetch, so we need to capture AFTER sed. We do this by
# making the python3 stub (which runs after sed) copy the file.
cat > "${ROOT}/python3" <<PSTUB
#!/bin/sh
# After sed -i bakes the token, the raw_snippet file has the real token.
# stage-snippet.sh runs python3 -m http.server from \$tmp_dir, so \$PWD is
# the tmp_dir. Copy the snippet out to the capture dir.
cp praxis-firstboot.sh "${capture_dir}/praxis-firstboot.sh" 2>/dev/null || true
exit 0
PSTUB
chmod +x "${ROOT}/python3"
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
[ -f "${capture_dir}/praxis-firstboot.sh" ]
# The placeholder was replaced with the real token (G-101 bake).
grep -q 'gitea-test-token' "${capture_dir}/praxis-firstboot.sh"
! grep -q '\${GITEA_TOKEN}' "${capture_dir}/praxis-firstboot.sh"
}
@test "stage: download-url POST shape (url=, content=snippets, filename=)" {
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
# pve_curl POST to /nodes/testnode/storage/local/download-url recorded.
grep -q '^pve_curl:POST /nodes/testnode/storage/local/download-url' "$LOG"
# The body includes url=<loopback base>/praxis-firstboot.sh, content=snippets,
# filename=praxis-firstboot.sh.
grep -q 'content=snippets' "$LOG"
grep -q 'filename=praxis-firstboot.sh' "$LOG"
grep -q 'url=http://127.0.0.1:18099/praxis-firstboot.sh' "$LOG"
}
@test "stage: UPID polled after upload" {
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q '^pve_poll:UPID:upload:1$' "$LOG"
}
@test "stage: verify step queries /storage/.../content for the snippet volid" {
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q '^pve_get:/nodes/testnode/storage/local/content$' "$LOG"
}
@test "stage: empty UPID → exit 1, error logged (download failed to start)" {
STUB_UPID="null"
export STUB_UPID
run "${ROOT}/stage-snippet.sh"
[ "$status" -ne 0 ]
grep -q 'failed to start download (empty UPID)' <<< "$output"
! grep -q '^pve_poll:' "$LOG"
}
@test "stage: snippet not in /content after upload → exit 1" {
# pve_get returns an empty array (snippet not found).
STUB_CONTENT='[]'
export STUB_CONTENT
run "${ROOT}/stage-snippet.sh"
[ "$status" -ne 0 ]
grep -q 'snippet local:snippets/praxis-firstboot.sh not found after upload' <<< "$output"
}
@test "stage: pve_env fails on missing GITEA_TOKEN → exit non-zero" {
run env -u GITEA_TOKEN "${ROOT}/stage-snippet.sh"
[ "$status" -ne 0 ]
}
@test "stage: pve_env fails on missing PROXMOX_STORAGE → exit non-zero" {
run env -u PROXMOX_STORAGE "${ROOT}/stage-snippet.sh"
[ "$status" -ne 0 ]
}
@test "stage: PRAXIS_VERSION flows into the Gitea raw URL (branch ref)" {
PRAXIS_VERSION="feature-branch" run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
# The curl fetch log records the raw URL with the branch ref.
grep -q 'git.cloudinit.dev/coreci/praxis/raw/branch/feature-branch/' "$LOG"
}
@test "stage: GITEA_HOST override flows into the raw URL" {
GITEA_HOST="git.staging.test" run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q 'git.staging.test/coreci/praxis/raw/branch/' "$LOG"
}
@test "stage: PROXMOX_DOWNLOAD_URL_BASE override flows into the download-url fetch param" {
PROXMOX_DOWNLOAD_URL_BASE="http://deployhost.test:8080" \
run "${ROOT}/stage-snippet.sh"
[ "$status" -eq 0 ]
grep -q 'url=http://deployhost.test:8080/praxis-firstboot.sh' "$LOG"
}