#!/usr/bin/env bash set -euo pipefail echo "================================================================================" echo " CivicSuite SERVER installation — clerks access via browser at http://:8080" echo " CivicRecords AI — Installation Script" echo " Supports: Linux (Ubuntu/Debian), macOS" echo "================================================================================" echo "" OS="$(uname -s)" # Check/install Docker if ! command -v docker &>/dev/null; then echo ">>> Docker not found. Installing..." if [ "$OS" = "Linux" ]; then curl -fsSL https://get.docker.com | sh sudo usermod -aG docker "$USER" echo "Docker installed. You may need to log out and back in for group changes." elif [ "$OS" = "Darwin" ]; then echo "Please install Docker Desktop for Mac from:" echo " https://www.docker.com/products/docker-desktop/" echo "Then re-run this script." exit 1 fi fi # Check Docker Compose if ! docker compose version &>/dev/null; then echo ">>> Docker Compose not found." if [ "$OS" = "Linux" ]; then sudo apt-get update && sudo apt-get install -y docker-compose-plugin else echo "Docker Compose is included with Docker Desktop. Make sure Docker Desktop is running." exit 1 fi fi # Check Docker is running if ! docker info &>/dev/null; then echo "ERROR: Docker daemon is not running. Start Docker first." exit 1 fi echo "[OK] Docker $(docker --version | cut -d' ' -f3)" echo "[OK] $(docker compose version)" # Create .env from template if not exists if [ ! -f .env ]; then echo ">>> Creating .env from template..." cp .env.example .env JWT_SECRET=$(openssl rand -hex 32) # T2C: generate a strong admin password instead of leaving the placeholder. # Settings.check_first_admin_password rejects the .env.example value at startup. # Use hex so the value contains no shell or .env-parser metacharacters. ADMIN_PASSWORD=$(openssl rand -hex 16) # T6 / ENG-001: generate an at-rest encryption key for the # data_sources.connection_config column. Fernet expects 44 chars of # URL-safe base64 encoding 32 random bytes. `openssl rand -base64 32` # produces standard base64; swap `+`→`-` and `/`→`_` for URL-safe. ENCRYPTION_KEY=$(openssl rand -base64 32 | tr '+/' '-_') SECRET_DIR="${CIVICRECORDS_SECRET_DIR:-./data/secrets}" mkdir -p "$SECRET_DIR" umask 077 printf '%s\n' "$JWT_SECRET" > "$SECRET_DIR/jwt_secret" printf '%s\n' "$ADMIN_PASSWORD" > "$SECRET_DIR/first_admin_password" chmod 0400 "$SECRET_DIR/jwt_secret" "$SECRET_DIR/first_admin_password" if [ "$OS" = "Darwin" ]; then sed -i '' "s|CHANGE-ME-generate-with-fernet-generate-key|$ENCRYPTION_KEY|" .env else sed -i "s|CHANGE-ME-generate-with-fernet-generate-key|$ENCRYPTION_KEY|" .env fi echo "" echo "================================================================================" echo " ADMIN PASSWORD GENERATED — copy this now" echo "================================================================================" echo " Email: admin@example.gov (edit .env to change)" echo " Password: $ADMIN_PASSWORD" echo "================================================================================" echo " This password is stored in $SECRET_DIR/first_admin_password (0400)." echo " Store it in your password manager; it is not written to .env." echo " Press Enter when you have copied it." echo "" echo "================================================================================" echo " AT-REST ENCRYPTION KEY GENERATED (T6 / ENG-001)" echo "================================================================================" echo " Key: $ENCRYPTION_KEY" echo "================================================================================" echo " This key encrypts data_sources.connection_config at rest." echo "" echo " *** BACK THIS UP SEPARATELY FROM YOUR DATABASE. ***" echo " Losing this key means every saved data-source connection" echo " configuration becomes unreadable. Store it alongside your" echo " JWT secret from $SECRET_DIR/jwt_secret in a password manager" echo " or secrets vault — NOT in the same location as DB backups." echo "" read -p "Press Enter to continue, or Ctrl+C to edit .env first..." # T5D — install-time PORTAL_MODE selection. Default is "private" (the # safer posture — staff-only, no public surface). Operators choose # "public" to expose the resident landing page + submission form + # resident-registration path (locked B4=(b) minimal surface). # Non-interactive installs can set CIVICRECORDS_PORTAL_MODE before # invoking install.sh to skip the prompt. if [ -n "$CIVICRECORDS_PORTAL_MODE" ]; then PORTAL_MODE_CHOICE=$(echo "$CIVICRECORDS_PORTAL_MODE" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') if [ "$PORTAL_MODE_CHOICE" != "public" ] && [ "$PORTAL_MODE_CHOICE" != "private" ]; then echo "" echo "[WARN] CIVICRECORDS_PORTAL_MODE='$CIVICRECORDS_PORTAL_MODE' is not 'public' or 'private' — falling back to 'private'." PORTAL_MODE_CHOICE="private" else echo "" echo "PORTAL_MODE=$PORTAL_MODE_CHOICE (from env, non-interactive install)" fi elif [ -t 0 ]; then echo "" echo "================================================================================" echo " Portal mode (T5D)" echo "================================================================================" echo " private (default): staff-only deployment. No public routes. Residents" echo " cannot self-register. Login screen is the only" echo " externally reachable page." echo " public: exposes a minimal public surface — landing page," echo " resident registration, and an authenticated" echo " records-request submission form for residents." echo "" read -p "Install in public mode? [y/N] " PORTAL_ANSWER PORTAL_ANSWER=$(echo "$PORTAL_ANSWER" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') case "$PORTAL_ANSWER" in y|yes|public) PORTAL_MODE_CHOICE="public" ;; *) PORTAL_MODE_CHOICE="private" ;; esac echo "PORTAL_MODE=$PORTAL_MODE_CHOICE" else PORTAL_MODE_CHOICE="private" echo "PORTAL_MODE=private (non-interactive install, default)" fi # Persist the chosen mode into .env, replacing the default "private" # value shipped by .env.example. if [ "$OS" = "Darwin" ]; then sed -i '' "s|^PORTAL_MODE=private$|PORTAL_MODE=$PORTAL_MODE_CHOICE|" .env else sed -i "s|^PORTAL_MODE=private$|PORTAL_MODE=$PORTAL_MODE_CHOICE|" .env fi fi # ─── Hardware Detection ─────────────────────────────────────────────────────── # B2 / QA-002: migrate existing .env files from recoverable container env # secrets to Docker-mounted secret files. Fresh .env.example files already have # these keys; older v1.5.x installs are rewritten in place. SECRET_DIR=$(grep -E '^CIVICRECORDS_SECRET_DIR=' .env 2>/dev/null | tail -1 | cut -d= -f2-) SECRET_DIR="${SECRET_DIR:-./data/secrets}" mkdir -p "$SECRET_DIR" umask 077 LEGACY_JWT=$(grep -E '^JWT_SECRET=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true) LEGACY_ADMIN_PASSWORD=$(grep -E '^FIRST_ADMIN_PASSWORD=' .env 2>/dev/null | tail -1 | cut -d= -f2- || true) if [ -n "$LEGACY_JWT" ] && [ "$LEGACY_JWT" != "CHANGE-ME-generate-with-openssl-rand-hex-32" ]; then printf '%s\n' "$LEGACY_JWT" > "$SECRET_DIR/jwt_secret" elif [ ! -s "$SECRET_DIR/jwt_secret" ]; then openssl rand -hex 32 > "$SECRET_DIR/jwt_secret" fi if [ -n "$LEGACY_ADMIN_PASSWORD" ] && [ "$LEGACY_ADMIN_PASSWORD" != "CHANGE-ME-on-first-login" ]; then printf '%s\n' "$LEGACY_ADMIN_PASSWORD" > "$SECRET_DIR/first_admin_password" elif [ ! -s "$SECRET_DIR/first_admin_password" ]; then openssl rand -hex 16 > "$SECRET_DIR/first_admin_password" fi chmod 0400 "$SECRET_DIR/jwt_secret" "$SECRET_DIR/first_admin_password" if [ "$OS" = "Darwin" ]; then sed -i '' '/^JWT_SECRET=/d' .env sed -i '' '/^FIRST_ADMIN_PASSWORD=/d' .env sed -i '' '/^JWT_SECRET_FILE=/d' .env sed -i '' '/^FIRST_ADMIN_PASSWORD_FILE=/d' .env sed -i '' '/^CIVICRECORDS_SECRET_DIR=/d' .env else sed -i '/^JWT_SECRET=/d' .env sed -i '/^FIRST_ADMIN_PASSWORD=/d' .env sed -i '/^JWT_SECRET_FILE=/d' .env sed -i '/^FIRST_ADMIN_PASSWORD_FILE=/d' .env sed -i '/^CIVICRECORDS_SECRET_DIR=/d' .env fi cat >> .env <:8080 — this gate checks the" echo " server box, not a clerk workstation." echo " Installation aborted. Review the hardware-detection output above for the" echo " specific failure, or rerun scripts/detect_hardware.sh on its own to see" echo " the full probe." exit 1 fi echo "" # Source the hardware config if [ -f ".env.hardware" ]; then # shellcheck disable=SC1091 source .env.hardware fi # ─── Select Compose Configuration ──────────────────────────────────────────── COMPOSE_FILES="-f docker-compose.yml" if [ "${CIVICRECORDS_GPU_ENABLED:-false}" = "true" ]; then echo "GPU acceleration enabled — using ROCm device passthrough" COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.gpu.yml" else echo "GPU not detected or not available — using CPU inference" fi # ─── Build Application Images ──────────────────────────────────────────────── echo "" echo ">>> Pulling Docker images..." # shellcheck disable=SC2086 docker compose $COMPOSE_FILES pull echo ">>> Building application images..." # shellcheck disable=SC2086 docker compose $COMPOSE_FILES build # ─── Start Infrastructure and Wait for Database ────────────────────────────── echo ">>> Starting database and cache..." # shellcheck disable=SC2086 docker compose $COMPOSE_FILES --env-file .env up -d postgres redis echo ">>> Waiting for database..." for i in $(seq 1 30); do if docker compose exec -T postgres pg_isready -U civicrecords -q 2>/dev/null; then echo "[OK] Database is ready" break fi if [ "$i" -eq 30 ]; then echo "ERROR: PostgreSQL did not become ready after 30 attempts." echo "Check: docker compose logs postgres" exit 1 fi echo " Waiting for database... ($i/30)" sleep 2 done # ─── Run Migrations ────────────────────────────────────────────────────────── echo ">>> Running database migrations..." # shellcheck disable=SC2086 docker compose $COMPOSE_FILES run --rm api alembic upgrade head # ─── Start All Services ────────────────────────────────────────────────────── echo ">>> Starting all services..." # shellcheck disable=SC2086 if [ -f ".env.hardware" ]; then docker compose $COMPOSE_FILES --env-file .env --env-file .env.hardware up -d else docker compose $COMPOSE_FILES --env-file .env up -d fi # Wait for API health echo ">>> Waiting for API to be healthy..." API_HEALTHY=false for i in $(seq 1 30); do if curl -sf http://localhost:8000/health &>/dev/null; then echo "[OK] API is healthy!" API_HEALTHY=true break fi echo " Waiting... ($i/30)" sleep 5 done if [ "$API_HEALTHY" = "false" ]; then echo "[WARN] API health check timed out. Check logs with: docker compose logs api" fi # ─── Gemma 4 Model Picker + Auto-Pull ──────────────────────────────────────── # Tier 5 Blocker 1 (locked 2026-04-21). All four supported Gemma 4 tags are # presented. Default is gemma4:e4b. Target profile baseline is Windows 11 Pro # 23H2+ / 32 GB min (64 GB rec) / GPU optional / CPU-only supportable. # Only gemma4:e2b and gemma4:e4b are supportable at baseline. gemma4:26b and # gemma4:31b require stronger hardware and are gated behind an explicit # "yes" confirmation. # Non-interactive install (CI, piped stdin): default is used unless env var # CIVICRECORDS_SELECTED_MODEL= is set. echo "" echo ">>> Waiting for Ollama to be ready..." for i in $(seq 1 30); do # shellcheck disable=SC2086 if docker compose $COMPOSE_FILES exec -T ollama ollama list &>/dev/null; then echo "[OK] Ollama is ready" break fi if [ "$i" -eq 30 ]; then echo "[WARN] Ollama did not become ready. Model pull may fail." fi echo " Waiting for Ollama... ($i/30)" sleep 3 done echo ">>> Pulling embedding model (required for search)..." # shellcheck disable=SC2086 docker compose $COMPOSE_FILES exec ollama ollama pull nomic-embed-text || echo "[WARN] Embedding model pull failed — retry: docker compose exec ollama ollama pull nomic-embed-text" DEFAULT_MODEL="gemma4:e4b" SUPPORTED_MODELS="gemma4:e2b gemma4:e4b gemma4:26b gemma4:31b" # Detect whether any supported Gemma 4 model is already present in Ollama # shellcheck disable=SC2086 OLLAMA_LIST_OUTPUT=$(docker compose $COMPOSE_FILES exec -T ollama ollama list 2>/dev/null || true) EXISTING_MODEL="" for M in $SUPPORTED_MODELS; do if printf '%s\n' "$OLLAMA_LIST_OUTPUT" | grep -q "^${M} "; then EXISTING_MODEL="$M" break fi done SELECTED_MODEL="${CIVICRECORDS_SELECTED_MODEL:-}" if [ -n "$EXISTING_MODEL" ] && [ -z "$SELECTED_MODEL" ]; then echo "" echo "[OK] Supported Gemma 4 model already present in Ollama: $EXISTING_MODEL" echo " Skipping language-model pull." SELECTED_MODEL="$EXISTING_MODEL" else echo "" echo "===== Gemma 4 model picker =====" echo "" echo "CivicRecords AI supports four Gemma 4 models. The target profile is" echo "Windows 11 Pro 23H2+ / 32 GB RAM minimum (64 GB recommended) /" echo "GPU optional / CPU-only supported. Models 26b and 31b require stronger" echo "hardware than the baseline and must be selected explicitly." echo "" echo " 1) gemma4:e2b Edge / 2.3B effective params / 7.2 GB disk / ~16 GB RAM [supportable]" echo " 2) gemma4:e4b Edge / 4.5B effective params / 9.6 GB disk / ~20 GB RAM [supportable] (DEFAULT)" echo " 3) gemma4:26b Workstation MoE / 25.2B total, 3.8B active / 18 GB disk / 48+ GB RAM recommended [not supportable at 32 GB baseline]" echo " 4) gemma4:31b Workstation dense / 30.7B params / 20 GB disk / 64+ GB RAM recommended [not supportable at 32 GB baseline; GPU recommended]" echo "" if [ -n "$SELECTED_MODEL" ]; then echo "Using CIVICRECORDS_SELECTED_MODEL=$SELECTED_MODEL (non-interactive override)." elif [ -t 0 ]; then printf "Enter 1-4 (or press Enter for default gemma4:e4b): " read -r CHOICE case "$CHOICE" in 1) SELECTED_MODEL="gemma4:e2b" ;; ""|2) SELECTED_MODEL="gemma4:e4b" ;; 3) SELECTED_MODEL="gemma4:26b" ;; 4) SELECTED_MODEL="gemma4:31b" ;; *) echo "Unknown choice '$CHOICE' — falling back to default gemma4:e4b." SELECTED_MODEL="gemma4:e4b" ;; esac case "$SELECTED_MODEL" in gemma4:26b) echo "" echo "WARNING: gemma4:26b is NOT supportable at the 32 GB baseline target profile." echo " Your machine should have at least 48 GB RAM for acceptable performance." printf "Type 'yes' to confirm and proceed with gemma4:26b anyway: " read -r CONFIRM if [ "$CONFIRM" != "yes" ]; then echo "Aborted gemma4:26b selection. Falling back to default gemma4:e4b." SELECTED_MODEL="gemma4:e4b" fi ;; gemma4:31b) echo "" echo "WARNING: gemma4:31b is NOT supportable at the 32 GB baseline target profile." echo " Your machine should have at least 64 GB RAM, and a GPU is recommended." printf "Type 'yes' to confirm and proceed with gemma4:31b anyway: " read -r CONFIRM if [ "$CONFIRM" != "yes" ]; then echo "Aborted gemma4:31b selection. Falling back to default gemma4:e4b." SELECTED_MODEL="gemma4:e4b" fi ;; esac else SELECTED_MODEL="$DEFAULT_MODEL" echo "Non-interactive install — selecting default: $SELECTED_MODEL" echo "Override by setting CIVICRECORDS_SELECTED_MODEL= before running." fi echo "" echo ">>> Pulling $SELECTED_MODEL (this may take several minutes)..." # shellcheck disable=SC2086 if docker compose $COMPOSE_FILES exec ollama ollama pull "$SELECTED_MODEL"; then echo "[OK] Model pulled: $SELECTED_MODEL" else echo "[WARN] Model pull for $SELECTED_MODEL failed. Retry manually:" echo " docker compose exec ollama ollama pull $SELECTED_MODEL" fi fi echo "" echo "Selected LLM model: $SELECTED_MODEL" # Get IP OS="$(uname -s)" if [ "$OS" = "Darwin" ]; then IP=$(ipconfig getifaddr en0 2>/dev/null || echo "localhost") else IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost") fi echo "" echo "================================================================================" echo " CivicSuite SERVER installation — clerks access via browser at http://${IP}:8080" echo " Installation complete!" echo "" echo " Clerk URL: http://${IP}:8080 (any modern browser, zero client install)" echo " Admin panel: http://${IP}:8080" echo " API: http://${IP}:8000" echo " API docs: http://${IP}:8000/docs" echo "" if [ "${CIVICRECORDS_GPU_ENABLED:-false}" = "true" ]; then echo " GPU inference: ENABLED (${CIVICRECORDS_PLATFORM} / GFX ${CIVICRECORDS_GFX_VERSION})" else echo " GPU inference: DISABLED (CPU only)" echo " See docs for GPU enablement on AMD Ryzen" fi echo "" echo " Run sovereignty check: bash scripts/verify-sovereignty.sh" echo "================================================================================"