feat(P01): SLICE-05+06+07 — firstboot hook, install-service, deploy orchestrator

SLICE-05 (devops-engineer): firstboot-hook.sh — installs Docker inside CT
  (apt: docker.io, docker-compose-v2, git, curl), clones praxis repo from
  Gitea (GITEA_TOKEN baked into snippet per G-101 fix), runs install-service.sh
SLICE-06 (devops-engineer): install-service.sh — creates praxis user, writes
  /etc/praxis/server.env from lxc.environment vars (G-103: all 16 env vars),
  installs praxis.service systemd unit (Type=simple, ExecStartPre=docker compose
  build, ExecStart=docker compose up, TimeoutStartSec=600 per RESEARCH Q8)
SLICE-07 (devops-engineer): lxc-deploy.sh orchestrator — stage snippet →
  clone → config → start → health-check, idempotent (--recreate/--reconfigure),
  rollback on failure, auto VMID allocation (D-027)

REQ-DEPLOY-06, 09, 10, 11 covered.

---ci---
project: praxis
phase: 1
milestone: v0.2
status: execute
slice: 05-07
wave: 3
---/ci---
This commit is contained in:
Praxis CI
2026-08-01 14:19:05 +00:00
parent bb17615f41
commit d32e4d487e
3 changed files with 361 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
# Praxis — Install the systemd service for Docker-based deployment.
#
# Adapted from coreci/scripts/install-service.sh.
# Coreci installs a Go binary + systemd unit; praxis creates the env
# file from lxc.environment vars, installs the systemd unit that runs
# `docker compose up` (foreground, Type=simple per RESEARCH.md Q8),
# and starts it. The Docker image is built by ExecStartPre.
#
# This script runs INSIDE the CT (called by firstboot-hook.sh via pct exec).
# It must run as root.
set -e
USER_NAME="praxis"
GROUP_NAME="praxis"
DATA_DIR="/var/lib/praxis/data"
LOG_DIR="/var/log/praxis"
ENV_FILE="/etc/praxis/server.env"
SERVICE_FILE="/etc/systemd/system/praxis.service"
APP_DIR="/opt/praxis"
if [ "$(id -u)" -ne 0 ]; then
echo "install-service.sh: must run as root" >&2
exit 1
fi
# Create the praxis user if it does not exist.
if ! id "$USER_NAME" >/dev/null 2>&1; then
echo "Creating user $USER_NAME"
useradd --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$USER_NAME"
fi
# Create data, log, and config directories.
mkdir -p "$DATA_DIR" "$LOG_DIR" /etc/praxis "$APP_DIR"
chown -R "$USER_NAME:$GROUP_NAME" "$DATA_DIR" "$LOG_DIR"
chown "root:$GROUP_NAME" /etc/praxis
chmod 0750 "$DATA_DIR" "$LOG_DIR" /etc/praxis
# Write the env file from the current environment (lxc.environment vars
# are available inside the CT's environment). This file is read by
# docker-compose.yml via env_file (G-101/G-102 secret injection chain).
# G-103 FIX: include ALL env vars the server reads.
cat > "$ENV_FILE" <<EOF
# Praxis service environment. Sourced by docker-compose.yml env_file.
# Do NOT commit — contains secrets injected via lxc.environment.
PRAXIS_HOST=${PRAXIS_HOST:-0.0.0.0}
PRAXIS_PORT=${PRAXIS_PORT:-8789}
PRAXIS_DB_PATH=${PRAXIS_DB_PATH:-/app/data/praxis.db}
PRAXIS_SCENARIOS_DIR=${PRAXIS_SCENARIOS_DIR:-/app/scenarios}
PRAXIS_TTS=${PRAXIS_TTS:-cartesia}
PRAXIS_SCENARIO=${PRAXIS_SCENARIO:-customer_service_refund_ca_v01}
DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY:-}
CARTESIA_API_KEY=${CARTESIA_API_KEY:-}
OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-https://ollama.com/v1}
OLLAMA_CHAT_URL=${OLLAMA_CHAT_URL:-https://ollama.com/api/chat}
OLLAMA_ROLEPLAY_MODEL=${OLLAMA_ROLEPLAY_MODEL:-gemma4:cloud}
OLLAMA_DEBRIEF_MODEL=${OLLAMA_DEBRIEF_MODEL:-deepseek-v4-flash:cloud}
DEEPGRAM_MODEL=${DEEPGRAM_MODEL:-nova-3}
DEEPGRAM_LANGUAGE=${DEEPGRAM_LANGUAGE:-en}
DEEPGRAM_REGION=${DEEPGRAM_REGION:-na}
CARTESIA_VOICE_ID=${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}
EOF
chown "root:${GROUP_NAME}" "$ENV_FILE"
chmod 0640 "$ENV_FILE"
# Ensure curl is present for health checks (stock LXC templates may lack it).
if ! command -v curl >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq curl
fi
# Install the systemd unit.
cat > "$SERVICE_FILE" <<'UNIT'
[Unit]
Description=Praxis — voice-first AI apprenticeship platform
Documentation=https://git.cloudinit.dev/coreci/praxis
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/praxis
EnvironmentFile=-/etc/praxis/server.env
# Build the image first (ExecStartPre), then run in foreground.
# Type=simple + foreground `docker compose up` (no -d) so systemd
# tracks the process. TimeoutStartSec=600 covers the build (RESEARCH Q8).
ExecStartPre=/usr/bin/docker compose build
ExecStart=/usr/bin/docker compose up
ExecStop=/usr/bin/docker compose down
Restart=on-failure
RestartSec=5
TimeoutStartSec=600
TimeoutStopSec=60
# NOTE: Do NOT use coreci's hardening directives (ProtectSystem, PrivateDevices,
# etc.) — they break Docker's need to access /var/run/docker.sock, cgroups,
# and namespaces. Docker-in-LXC requires relaxed sandboxing (RESEARCH Q8).
StandardOutput=journal
StandardError=journal
SyslogIdentifier=praxis
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable praxis.service
# Start the service (this triggers ExecStartPre=docker compose build,
# which may take 3-5 min on first boot).
echo "Starting praxis service (Docker build may take 3-5 min)..."
systemctl start praxis.service || {
echo "Failed to start praxis; check 'journalctl -u praxis -n 50'" >&2
exit 1
}
echo "Praxis service installed and started."
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
# Praxis — Proxmox LXC first-boot hookscript.
#
# Adapted from coreci/scripts/proxmox/firstboot-hook.sh.
# Coreci fetches a pre-built Go binary + pct-pushes it; praxis installs
# Docker inside the CT, clones the repo from Gitea, builds the image,
# and starts the service via systemd (D-022, D-028, D-029).
#
# Referenced by lxc-config.sh via hookscript=local:snippets/praxis-firstboot.sh.
# Proxmox invokes this script at CT lifecycle phases on the PVE HOST
# (not inside the CT). The `post-start` phase does the work.
#
# G-101 FIX: GITEA_TOKEN is baked into this snippet by stage-snippet.sh
# (the hookscript runs on the PVE host where lxc.environment is invisible).
# The token is used to clone the private Gitea repo inside the CT.
#
# Proxmox passes: $1 = VMID, $2 = phase
# Environment (baked in by stage-snippet.sh):
# GITEA_TOKEN — bearer token for the private Gitea repo
# PRAXIS_VERSION — git ref (default: main)
# GITEA_HOST — Gitea hostname (default: git.cloudinit.dev)
set -eu
vmid="${1:-}"
phase="${2:-}"
log() { printf '[praxis-hook %s] %s\n' "$phase" "$*" >&2; }
case "$phase" in
post-start) : ;;
*) exit 0 ;;
esac
log "VMID=${vmid} — first-boot praxis install (Docker-in-LXC)"
VERSION="${PRAXIS_VERSION:-main}"
GITEA_HOST="${GITEA_HOST:-git.cloudinit.dev}"
GITEA_ORG="coreci"
GITEA_REPO="praxis"
CLONE_URL="https://${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_ORG}/${GITEA_REPO}.git"
# Idempotency: skip if praxis is already installed and running.
if pct exec "$vmid" -- sh -c '[ -x /usr/local/bin/praxis-deploy ] && systemctl is-active --quiet praxis' 2>/dev/null; then
log "praxis already installed and active — skipping"
exit 0
fi
# Step 1: Install Docker + docker-compose-v2 inside the CT (D-028).
# Debian 12 standard template + nesting=1 supports Docker.
log "installing Docker inside CT ${vmid}"
pct exec "$vmid" -- sh -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq docker.io docker-compose-v2 git curl
systemctl enable --now docker
'
# Step 2: Clone the praxis repo inside the CT (D-029).
# Clone to /opt/praxis (persistent across container restarts).
log "cloning praxis repo (ref=${VERSION}) into CT"
pct exec "$vmid" -- sh -c "
set -e
mkdir -p /opt/praxis
cd /opt/praxis
git clone --depth 1 --branch '${VERSION}' '${CLONE_URL}' . 2>&1 || {
# If the specific branch doesn't exist, fall back to main
log 'falling back to main branch'
git clone --depth 1 '${CLONE_URL}' . 2>&1
}
"
# Step 3: Write the env file from lxc.environment (passed via the CT's env).
# The lxc.environment vars are available inside the CT's environment.
# install-service.sh writes /etc/praxis/server.env from these.
log "running install-service inside CT"
pct exec "$vmid" -- sh -c '
set -e
cd /opt/praxis
sh scripts/install-service.sh
'
log "praxis installed and started in CT ${vmid}"
exit 0
+154
View File
@@ -0,0 +1,154 @@
#!/bin/sh
# Praxis — Orchestrator: deploy praxis to a Proxmox LXC container.
#
# Adapted from coreci/scripts/proxmox/lxc-deploy.sh.
# Sequence: stage snippet → clone template → configure CT → start →
# health-check → rollback on failure.
#
# Required env (see .env.example + ~/coreci/.ciagent/.env.secrets):
# 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
# (baked into the firstboot snippet by stage-snippet.sh)
#
# Optional env:
# PROXMOX_LXC_VMID — target CT VMID (default: auto-allocate via pve_nextid)
# PRAXIS_VERSION — git ref to deploy (default: main)
# PRAXIS_PORT — server HTTP port (default: 8789)
# PRAXIS_HEALTH_URL — override health-check URL
# PROXMOX_MEMORY_MB — CT memory limit (default: 4096)
# PROXMOX_TLS_SKIP_VERIFY— accept self-signed certs (default: false)
# DEEPGRAM_API_KEY — voice-service key (optional, may be empty)
# CARTESIA_API_KEY — voice-service key (optional, may be empty)
# OLLAMA_API_KEY — voice-service key (optional, may be empty)
#
# Flags:
# --recreate — rollback.sh (stop + destroy) then full redeploy
# --reconfigure — re-PUT lxc-config.sh + restart (no clone)
#
# Exit: 0 on successful deploy, 1 on failure (with rollback attempted)
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=api.sh disable=SC1091
. "${SCRIPT_DIR}/api.sh"
# shellcheck source=ct-exists.sh disable=SC1091
. "${SCRIPT_DIR}/ct-exists.sh"
# shellcheck source=timing.sh disable=SC1091
. "${SCRIPT_DIR}/timing.sh"
pve_env PROXMOX_API_URL PROXMOX_API_TOKEN PROXMOX_NODE \
PROXMOX_STORAGE PROXMOX_TEMPLATE_VOLID GITEA_TOKEN
# ── Flag parsing ───────────────────────────────────────────────────
recreate=0
reconfigure=0
for arg in "$@"; do
case "$arg" in
--recreate) recreate=1 ;;
--reconfigure) reconfigure=1 ;;
*) echo "deploy: unknown argument: $arg" >&2; exit 2 ;;
esac
done
# Step 0: stage the first-boot hookscript to Proxmox snippet storage.
# G-101 FIX: stage-snippet.sh bakes GITEA_TOKEN into the snippet.
hookscript_volid="${PROXMOX_STORAGE:-local}:snippets/praxis-firstboot.sh"
existing=$(pve_get "/nodes/${PROXMOX_NODE}/storage/${PROXMOX_STORAGE:-local}/content" 2>/dev/null | jq -r --arg v "$hookscript_volid" '.[]? | select(.volid==$v) | .volid' 2>/dev/null || true)
if [ -n "$existing" ]; then
echo "deploy: hookscript snippet ${hookscript_volid} already staged — skipping upload" >&2
else
"${SCRIPT_DIR}/stage-snippet.sh"
fi
# Resolve target VMID (D-027: auto-allocate by default).
vmid="${PROXMOX_LXC_VMID:-auto}"
if [ "$vmid" = "auto" ]; then
vmid=$(pve_nextid)
echo "deploy: auto-allocated VMID ${vmid}" >&2
else
echo "deploy: using configured VMID ${vmid}" >&2
fi
# Trap: rollback on any failure (mirrors coreci pattern).
deploy_failed=0
skip_rollback=0
trap 'deploy_failed=1' INT TERM
cleanup() {
rc=$?
if [ "$skip_rollback" -ne 1 ] && { [ "$deploy_failed" -ne 0 ] || [ "$rc" -ne 0 ]; }; then
echo "deploy: FAILED (rc=${rc}) — rolling back VMID ${vmid}" >&2
"${SCRIPT_DIR}/rollback.sh" "$vmid" 2>&1 || true
fi
}
trap cleanup EXIT
# ── Idempotency: detect existing CT before clone ──────────────────
if ct_exists "$vmid"; then
echo "deploy: VMID ${vmid} already exists — checking health" >&2
ct_healthy=0
if ct_running "$vmid"; then
if PRAXIS_HEALTH_TIMEOUT="${IDEMPOTENCY_HEALTH_TIMEOUT:-30}" \
"${SCRIPT_DIR}/health-check.sh" "$vmid" 2>/dev/null; then
ct_healthy=1
fi
fi
if [ "$ct_healthy" -eq 1 ]; then
echo "deploy: VMID ${vmid} already running + healthy — skipping clone/config/start (idempotent re-deploy)" >&2
skip_provision=1
elif [ "$reconfigure" -eq 1 ]; then
echo "deploy: VMID ${vmid} exists but unhealthy — --reconfigure: re-PUT config + restart" >&2
skip_rollback=1
timing_start reconfigure
"${SCRIPT_DIR}/lxc-config.sh" "$vmid"
"${SCRIPT_DIR}/lxc-start.sh" "$vmid"
timing_end reconfigure
timing_start health
"${SCRIPT_DIR}/health-check.sh" "$vmid"
timing_end health
skip_provision=1
elif [ "$recreate" -eq 1 ]; then
echo "deploy: VMID ${vmid} exists but unhealthy — --recreate: rollback + redeploy" >&2
"${SCRIPT_DIR}/rollback.sh" "$vmid"
skip_provision=0
else
echo "deploy: ERROR — VMID ${vmid} exists but is unhealthy." >&2
echo "deploy: Use --recreate to rollback + redeploy, or --reconfigure to update config + restart." >&2
echo "deploy: No action taken (the existing CT was left intact for inspection)." >&2
skip_rollback=1
exit 1
fi
else
skip_provision=0
fi
if [ "${skip_provision:-0}" -eq 0 ]; then
# Step 1: Clone the template
timing_start clone
"${SCRIPT_DIR}/lxc-clone.sh" "$vmid"
timing_end clone
# Step 2: Configure the CT
timing_start config
"${SCRIPT_DIR}/lxc-config.sh" "$vmid"
timing_end config
# Step 3: Start the CT
timing_start start
"${SCRIPT_DIR}/lxc-start.sh" "$vmid"
timing_end start
# Step 4: Health-check (G-104: 600s timeout for Docker build)
timing_start health
"${SCRIPT_DIR}/health-check.sh" "$vmid"
timing_end health
fi
deploy_failed=0
echo "deploy: praxis deployed successfully to VMID ${vmid}" >&2
printf 'VMID=%s\n' "$vmid"