Files
praxis/scripts/proxmox/test/firstboot-hook.bats
T
Praxis CI 6cf63cb064 docs(P01): verify — APPROVE_WITH_NOTES, 4 P0 fixed, 18/20 REQ covered
Verification layers:
  Structural: PASS (all scripts executable, syntax clean, Dockerfile valid)
  Behavioral: PASS (121 bats, 77 pytest, docker build succeeds, compose valid)
  Security: PASS (no secrets committed, .dockerignore excludes .env*, env_file pattern)
  Quality: PASS (coreci patterns followed, no coreci refs, G-104/G-105/G-106 verified)

P0 issues found and auto-fixed:
  1. docker-compose.yml: removed invalid restart_policy key, fixed env_file syntax
  2. pyproject.toml: added fastapi + uvicorn deps (v0.1 gap exposed by Dockerfile)
  3. timing.sh: renamed coreci_deploy_timing → praxis_deploy_timing (TASK-03-07)
  4. firstboot-hook.sh: fixed idempotency check (/opt/praxis/.git not /usr/local/bin/praxis-deploy)

P1+ issues: 8 (1 fixed: lxc-config.sh default alignment, 7 noted for post-hoc review)
REQ coverage: 18/20 covered, 2 deferred (live first-boot timing + live E2E require cluster)
Must-haves: 25/28 pass, 2 partial (comment-only diffs, no Makefile), 1 deferred

---ci---
project: praxis
phase: 1
milestone: v0.2
status: verify
---/ci---
2026-08-03 18:37:45 +00:00

194 lines
7.3 KiB
Bash

#!/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 /opt/praxis/.git 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"
}