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:
Executable
+122
@@ -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."
|
||||
Executable
+85
@@ -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
|
||||
Executable
+154
@@ -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"
|
||||
Reference in New Issue
Block a user