#!/usr/bin/env bash # deploy.sh — idempotent deploy script for GMAL Scope Builder # Run from the repo root: sudo ./deploy.sh # First-time setup: clone repo to /opt/gmal-scope-builder, create .env, then run this script. set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WEB_DIR="/var/www/html/gmal-scope-builder" APACHE_CONF="/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf" APACHE_MARKER="gmal-scope-builder" # Used to detect if block already added APP_URL_PATH="/gsb" BACKEND_PORT="8002" HEALTH_URL="http://127.0.0.1:${BACKEND_PORT}/api/health" # ── Colours ─────────────────────────────────────────────────────────────────── GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' log() { echo -e "${GREEN}[deploy]${NC} $*"; } warn() { echo -e "${YELLOW}[ warn ]${NC} $*"; } die() { echo -e "${RED}[error ]${NC} $*" >&2; exit 1; } # ── 1. Pre-flight checks ────────────────────────────────────────────────────── log "Running pre-flight checks..." [[ -f "$REPO_DIR/.env" ]] || die ".env not found at $REPO_DIR/.env — copy .env.example and fill in values" # Check required env vars for var in POSTGRES_PASSWORD ANTHROPIC_API_KEY AZURE_TENANT_ID AZURE_CLIENT_ID; do grep -q "^${var}=" "$REPO_DIR/.env" || die "Missing required variable '$var' in .env" done command -v docker &>/dev/null || die "Docker is not installed" sudo docker compose version &>/dev/null || die "Docker Compose plugin not available" # Resolve DATA_DIR (from .env or default to persistent web dir) DATA_DIR=$(grep "^DATA_DIR=" "$REPO_DIR/.env" | cut -d= -f2- | tr -d '"' || true) DATA_DIR="${DATA_DIR:-/var/www/html/gmal-scope-builder/data}" sudo mkdir -p "$DATA_DIR" # Warn if no GMAL Excel file (non-fatal — data may already be in the DB) if ! sudo ls "${DATA_DIR}/"*.xlsx &>/dev/null 2>&1; then warn "No .xlsx file found in $DATA_DIR — GMAL ingest will need to be triggered manually after deploy" fi log "Pre-flight OK" # ── 2. Pull latest code ─────────────────────────────────────────────────────── log "Pulling latest code from origin/main..." git -C "$REPO_DIR" pull origin main # ── 3. Build and start backend services ────────────────────────────────────── log "Building Docker images and starting services (using build cache)..." sudo docker compose -f "$REPO_DIR/docker-compose.yml" --env-file "$REPO_DIR/.env" \ up -d --build --remove-orphans # ── 4. Wait for backend health ──────────────────────────────────────────────── log "Waiting for backend to become healthy..." TIMEOUT=90 ELAPSED=0 until curl -sf "$HEALTH_URL" > /dev/null 2>&1; do sleep 3 ELAPSED=$((ELAPSED + 3)) [[ $ELAPSED -ge $TIMEOUT ]] && { warn "Backend logs:" sudo docker compose -f "$REPO_DIR/docker-compose.yml" logs --tail=30 backend die "Backend did not become healthy within ${TIMEOUT}s" } log " Still waiting... (${ELAPSED}s)" done log "Backend healthy at $HEALTH_URL" # ── 5. Database migrations ──────────────────────────────────────────────────── # create_all() runs automatically inside start.sh on each container start (idempotent). # Uncomment the line below once Alembic migrations are set up: # sudo docker compose -f "$REPO_DIR/docker-compose.yml" exec -T backend alembic upgrade head log "Database schema is managed by start.sh (create_all) — no separate migration step needed" # ── 6. Build frontend ───────────────────────────────────────────────────────── log "Building frontend via Node Docker container..." sudo docker run --rm \ -v "$REPO_DIR/frontend:/app" \ -w /app \ node:20-alpine \ sh -c "npm ci --prefer-offline && npm run build" # ── 7. Deploy frontend static files ────────────────────────────────────────── log "Deploying frontend to $WEB_DIR..." sudo mkdir -p "$WEB_DIR" # Remove only frontend files — preserve data/ subdirectory sudo find "${WEB_DIR:?}" -maxdepth 1 -mindepth 1 ! -name 'data' -exec rm -rf {} + sudo cp -r "$REPO_DIR/frontend/dist/." "$WEB_DIR/" sudo chown -R www-data:www-data "$WEB_DIR" sudo chown -R root:root "$DATA_DIR" # data/ stays root-owned for Docker log "Frontend deployed ($(find "$REPO_DIR/frontend/dist" -type f | wc -l) files)" # ── 8. Apache config (idempotent) ───────────────────────────────────────────── if sudo grep -q "$APACHE_MARKER" "$APACHE_CONF" 2>/dev/null; then log "Apache block for $APP_URL_PATH already present — skipping" else log "Adding Apache config block for $APP_URL_PATH ..." sudo python3 - << PYEOF apache_conf = "$APACHE_CONF" block = """ # ---------------------------------------------------------------- # GMAL Scope Builder — FastAPI backend at :$BACKEND_PORT # ---------------------------------------------------------------- ProxyPass $APP_URL_PATH/api/ http://127.0.0.1:$BACKEND_PORT/api/ ProxyPassReverse $APP_URL_PATH/api/ http://127.0.0.1:$BACKEND_PORT/api/ # GMAL Scope Builder SPA Alias $APP_URL_PATH $WEB_DIR Options -Indexes +FollowSymLinks AllowOverride None Require all granted RewriteEngine On RewriteBase $APP_URL_PATH/ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.html [L] """ with open(apache_conf) as f: content = f.read() if "$APACHE_MARKER" not in content: content = content.replace("", block + "\n") with open(apache_conf, "w") as f: f.write(content) print("Apache config updated") else: print("Apache config already has $APACHE_MARKER block (written by another process)") PYEOF fi # ── 9. Validate and reload Apache ───────────────────────────────────────────── log "Validating Apache config..." sudo apache2ctl configtest log "Reloading Apache..." sudo systemctl reload apache2 # ── Done ────────────────────────────────────────────────────────────────────── echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ Deployment complete! ║${NC}" echo -e "${GREEN}║ https://optical-dev.oliver.solutions${APP_URL_PATH}/ ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" echo "" log "If this is the first deploy, trigger GMAL ingest:" log " curl -X POST -H 'Authorization: Bearer ' https://optical-dev.oliver.solutions${APP_URL_PATH}/api/gmal/ingest"