From d32e4d487ee22405ffafa07d8f01583bec694c37 Mon Sep 17 00:00:00 2001 From: Praxis CI Date: Sat, 1 Aug 2026 14:19:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(P01):=20SLICE-05+06+07=20=E2=80=94=20first?= =?UTF-8?q?boot=20hook,=20install-service,=20deploy=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--- --- scripts/install-service.sh | 122 +++++++++++++++++++++++ scripts/proxmox/firstboot-hook.sh | 85 +++++++++++++++++ scripts/proxmox/lxc-deploy.sh | 154 ++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100755 scripts/install-service.sh create mode 100755 scripts/proxmox/firstboot-hook.sh create mode 100755 scripts/proxmox/lxc-deploy.sh diff --git a/scripts/install-service.sh b/scripts/install-service.sh new file mode 100755 index 0000000..82d9015 --- /dev/null +++ b/scripts/install-service.sh @@ -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" </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." \ No newline at end of file diff --git a/scripts/proxmox/firstboot-hook.sh b/scripts/proxmox/firstboot-hook.sh new file mode 100755 index 0000000..96b06bb --- /dev/null +++ b/scripts/proxmox/firstboot-hook.sh @@ -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 \ No newline at end of file diff --git a/scripts/proxmox/lxc-deploy.sh b/scripts/proxmox/lxc-deploy.sh new file mode 100755 index 0000000..e6ba7c1 --- /dev/null +++ b/scripts/proxmox/lxc-deploy.sh @@ -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" \ No newline at end of file