Files
praxis/scripts/proxmox/test/lxc-deploy.bats
T
Praxis CI 1b3617da3b docs(P02): review + audit — APPROVE_WITH_NOTES, HEALTHY, 2 P0 fixed
REVIEW.md: 2 P0 fixed (stale test defaults, sandbox isolation),
  8 P1+ flagged for post-hoc review. Verdict: APPROVE_WITH_NOTES.
AUDIT.md: 0 critical, 5 warnings. Reconstruction PASS, file
  discipline PASS, branch hygiene PASS, commit discipline PASS.
  Verdict: HEALTHY. Doc-drift fixed (REQ statuses → complete).

P0 fixes in working tree:
  1. lxc-config.bats: aligned stale defaults with production code
  2. lxc-deploy.bats: fixed sandbox isolation (HOME redirect)

---ci---
project: praxis
phase: 2
milestone: v0.2
status: review
---/ci---
2026-08-03 18:54:38 +00:00

383 lines
15 KiB
Bash

#!/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"
# Isolate from the operator's real .env.secrets files: lxc-deploy.sh
# sources ~/coreci/.ciagent/.env.secrets + .ciagent/.env.secrets, which
# on a live deploy host would override the test's PROXMOX_LXC_VMID (and
# other vars) with cluster values. Point HOME + the script's PROJ_ROOT
# computation at the sandbox so neither secrets file is found (the
# deploy script emits a warning + relies on the exported test env).
export HOME="${STUB_DIR}"
# Stub cd so PROJ_ROOT resolves inside the sandbox: lxc-deploy.sh uses
# `cd "${SCRIPT_DIR}/../.."`. SCRIPT_DIR is the sandbox <ROOT>; we make
# <ROOT>/../.. resolve to <STUB_DIR> by creating <STUB_DIR>/.. (already
# exists) — the default mktemp parent. No .ciagent/.env.secrets there.
# 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"
}