Barclays-banner-builder/deploy.sh
Vadym Samoilenko 24d9c4f4c3 Fix deploy.sh: target sites-enabled, not sites-available
Apache reads from sites-enabled/ which is a separate file on this server
(not a symlink to sites-available). Previous patches went to the wrong file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:57:52 +01:00

331 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# Barclays BMB Banner Visualiser — Deploy Script
# Idempotent: safe for initial deploy and subsequent updates.
#
# Usage:
# bash /opt/barclays-banner-builder/deploy.sh [--reindex] [--skip-frontend]
#
# Options:
# --reindex Force re-run RAG ingest + icon indexing (costs OpenAI $)
# --skip-frontend Skip npm build + web dir sync (useful for backend-only updates)
# =============================================================================
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
REPO_DIR="/opt/barclays-banner-builder"
WEB_DIR="/var/www/html/barclays-banner-builder"
VHOST_CONF="/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
INCLUDE_LINE=" Include ${REPO_DIR}/deploy/apache-barclays.conf"
APP_BASE_PATH="/barclays-banner-builder"
COMPOSE="docker compose -f ${REPO_DIR}/docker-compose.prod.yml"
API_PORT="8010"
API_HEALTH="http://127.0.0.1:${API_PORT}/api/health"
STATE_DIR="${REPO_DIR}/.deploy_state"
# ── Flags ─────────────────────────────────────────────────────────────────
FORCE_REINDEX=false
SKIP_FRONTEND=false
for arg in "$@"; do
case $arg in
--reindex) FORCE_REINDEX=true ;;
--skip-frontend) SKIP_FRONTEND=true ;;
esac
done
mkdir -p "$STATE_DIR"
# ── Colour helpers ────────────────────────────────────────────────────────────
info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
step() { printf '\n\033[1;37m━━━ %s ━━━\033[0m\n' "$*"; }
# ── 0. Preflight checks ───────────────────────────────────────────────────────
step "Preflight checks"
cd "$REPO_DIR" || fail "Cannot cd to $REPO_DIR — did you clone the repo there?"
command -v docker &>/dev/null || fail "docker is not installed"
docker compose version &>/dev/null || fail "docker compose plugin not found"
command -v node &>/dev/null || fail "node is not installed (required for frontend build)"
command -v npm &>/dev/null || fail "npm is not installed"
command -v git &>/dev/null || fail "git is not installed"
ok "Preflight passed"
# ── 1. Pull latest code ───────────────────────────────────────────────────────
step "Pulling latest code"
git fetch origin
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [[ "$LOCAL" == "$REMOTE" ]]; then
warn "Already at latest commit — still proceeding with deploy"
else
git pull --ff-only origin main
fi
COMMIT=$(git rev-parse --short HEAD)
ok "At commit $COMMIT"
# ── 2. Environment file ───────────────────────────────────────────────────────
step "Checking .env"
if [[ ! -f .env ]]; then
cp .env.example .env
warn ".env was missing — created from .env.example"
warn "Please edit ${REPO_DIR}/.env and set OPENAI_API_KEY, POSTGRES_PASSWORD, SECRET_KEY"
warn "Then re-run this script."
exit 0
fi
# Validate required secrets
for VAR in OPENAI_API_KEY POSTGRES_PASSWORD SECRET_KEY; do
val=$(grep -E "^${VAR}=.+" .env 2>/dev/null | cut -d= -f2- || true)
if [[ -z "$val" ]]; then
fail "$VAR is not set in .env — deployment cannot continue"
fi
done
# Ensure APP_BASE_PATH is set
if ! grep -q "^APP_BASE_PATH=" .env; then
echo "APP_BASE_PATH=${APP_BASE_PATH}" >> .env
ok "Added APP_BASE_PATH=${APP_BASE_PATH} to .env"
fi
# Load env vars for use in this script
set -a
# shellcheck disable=SC1091
source .env
set +a
ok ".env loaded and validated"
# ── 3. Build Docker images (with cache) ───────────────────────────────────────
step "Building Docker images"
# Check if Dockerfile changed since last build
DOCKERFILE_HASH=$(md5sum backend/Dockerfile backend/pyproject.toml 2>/dev/null | md5sum | cut -d' ' -f1 || echo "unknown")
LAST_HASH_FILE="${STATE_DIR}/dockerfile_hash"
if [[ -f "$LAST_HASH_FILE" ]] && [[ "$(cat "$LAST_HASH_FILE")" == "$DOCKERFILE_HASH" ]]; then
info "Dockerfile unchanged — skipping image rebuild (using cached images)"
# Still need to ensure containers are up with latest code via volume mount
else
info "Dockerfile changed or first deploy — building images..."
$COMPOSE build --parallel api worker
echo "$DOCKERFILE_HASH" > "$LAST_HASH_FILE"
ok "Images built"
fi
# ── 4. Start DB + Redis ───────────────────────────────────────────────────────
step "Starting database and Redis"
$COMPOSE up -d postgres redis
info "Waiting for postgres to be ready..."
for i in $(seq 1 30); do
if $COMPOSE exec -T postgres pg_isready -U "${POSTGRES_USER:-banners}" &>/dev/null; then
ok "Postgres is ready"
break
fi
if [[ $i -eq 30 ]]; then
fail "Postgres did not become ready in 30s — check: ${COMPOSE} logs postgres"
fi
sleep 1
done
# ── 5. Start API container (needed for alembic) ───────────────────────────────
step "Starting API container"
$COMPOSE up -d api
sleep 3 # brief wait for container to initialise
# ── 6. Database migrations ────────────────────────────────────────────────────
step "Running database migrations (alembic upgrade head)"
$COMPOSE exec -T api alembic -c alembic.ini upgrade head
ok "Migrations complete"
# ── 7. First-run seeding ──────────────────────────────────────────────────────
step "First-run seeding"
# Seed admin user if users table is empty
USER_COUNT=$($COMPOSE exec -T api python -c "
import asyncio
from app.database import AsyncSessionLocal
from sqlalchemy import text
async def count():
async with AsyncSessionLocal() as db:
r = await db.execute(text('SELECT COUNT(*) FROM users'))
print(r.scalar())
asyncio.run(count())
" 2>/dev/null || echo "0")
if [[ "$USER_COUNT" -eq "0" ]]; then
info "No users found — running initial seed (admin user + default system prompt)..."
$COMPOSE exec -T api python scripts/seed_admin.py
ok "Seed complete — login: admin@barclays.com / change_me_password (CHANGE THIS!)"
else
info "Users exist ($USER_COUNT found) — skipping seed"
fi
# RAG ingest: run if empty or --reindex flag
RAG_COUNT=$($COMPOSE exec -T api python -c "
import asyncio
from app.database import AsyncSessionLocal
from sqlalchemy import text
async def count():
async with AsyncSessionLocal() as db:
r = await db.execute(text('SELECT COUNT(*) FROM rag_chunks'))
print(r.scalar())
asyncio.run(count())
" 2>/dev/null || echo "0")
if [[ "$RAG_COUNT" -eq "0" ]] || [[ "$FORCE_REINDEX" == "true" ]]; then
info "Ingesting RAG corpus (embeddings via OpenAI)..."
$COMPOSE exec -T api python scripts/ingest_rag.py
ok "RAG ingest complete"
else
info "RAG chunks exist ($RAG_COUNT found) — skipping (use --reindex to force)"
fi
# Icon indexing: run if empty or --reindex flag
ICON_COUNT=$($COMPOSE exec -T api python -c "
import asyncio
from app.database import AsyncSessionLocal
from sqlalchemy import text
async def count():
async with AsyncSessionLocal() as db:
r = await db.execute(text('SELECT COUNT(*) FROM icons'))
print(r.scalar())
asyncio.run(count())
" 2>/dev/null || echo "0")
if [[ "$ICON_COUNT" -eq "0" ]] || [[ "$FORCE_REINDEX" == "true" ]]; then
info "Indexing icon library (embeddings via OpenAI)..."
$COMPOSE exec -T api python scripts/index_icons.py
ok "Icon index complete"
else
info "Icons exist ($ICON_COUNT found) — skipping (use --reindex to force)"
fi
# ── 8. Build and deploy frontend ──────────────────────────────────────────────
if [[ "$SKIP_FRONTEND" == "true" ]]; then
warn "--skip-frontend: skipping npm build"
else
step "Building frontend"
cd frontend
# Smart npm install: only reinstall if package.json changed
PKG_HASH=$(md5sum package.json package-lock.json 2>/dev/null | md5sum | cut -d' ' -f1 || echo "unknown")
LAST_PKG_FILE="${STATE_DIR}/npm_hash"
if [[ -f "$LAST_PKG_FILE" ]] && [[ "$(cat "$LAST_PKG_FILE")" == "$PKG_HASH" ]] && [[ -d node_modules ]]; then
info "package.json unchanged — skipping npm ci (using cached node_modules)"
else
info "package.json changed or first run — running npm ci..."
npm ci --prefer-offline
echo "$PKG_HASH" > "$LAST_PKG_FILE"
ok "npm ci complete"
fi
info "Building React app (base path: ${APP_BASE_PATH})..."
VITE_BASE_PATH="${APP_BASE_PATH}" npm run build
ok "Frontend built"
cd "$REPO_DIR"
step "Deploying frontend to ${WEB_DIR}"
sudo mkdir -p "$WEB_DIR"
# Clear old files (keep illustrations symlink if it already exists)
info "Clearing ${WEB_DIR} (preserving illustrations symlink)..."
sudo find "$WEB_DIR" -mindepth 1 -not -name 'illustrations' -delete 2>/dev/null || true
sudo cp -r frontend/dist/. "$WEB_DIR/"
sudo chmod -R a+rX "$WEB_DIR"
# Symlink illustrations so Apache serves PNGs directly
if [[ ! -L "${WEB_DIR}/illustrations" ]]; then
sudo ln -sfn "${REPO_DIR}/assets/illustrations" "${WEB_DIR}/illustrations"
ok "Illustrations symlinked → ${WEB_DIR}/illustrations"
else
ok "Illustrations symlink already in place"
fi
sudo chown -R vadym.samoilenko:vadym.samoilenko "$WEB_DIR"
ok "Frontend deployed to ${WEB_DIR}"
fi
# ── 9. Restart backend + worker (pick up new code) ────────────────────────────
step "Restarting backend services"
$COMPOSE up -d --no-deps --force-recreate api worker
ok "API and worker restarted"
# ── 10. Configure Apache (idempotent) ─────────────────────────────────────────
step "Configuring Apache"
if sudo grep -qF "$INCLUDE_LINE" "$VHOST_CONF" 2>/dev/null; then
ok "Apache include already present — no changes needed"
else
info "Adding Barclays include to Apache vhost..."
# Insert our Include BEFORE any other project Includes so our specific
# ProxyPass rules are evaluated before any catch-all rules in other projects.
if sudo grep -qF "Include /opt/" "$VHOST_CONF" 2>/dev/null; then
# Insert before the first "Include /opt/" line
sudo sed -i "0,/Include \/opt\//s|Include /opt/|${INCLUDE_LINE}\n Include /opt/|" "$VHOST_CONF"
else
# No other project Includes — insert just before </VirtualHost>
sudo sed -i "s|</VirtualHost>|${INCLUDE_LINE}\n</VirtualHost>|" "$VHOST_CONF"
fi
sudo apache2ctl configtest || fail "Apache config test failed — check ${VHOST_CONF}"
sudo systemctl reload apache2
ok "Apache configured and reloaded"
fi
# ── 11. Health check ─────────────────────────────────────────────────────────
step "Health check"
info "Waiting for API to respond on ${API_HEALTH}..."
for i in $(seq 1 15); do
if curl -sf "$API_HEALTH" >/dev/null 2>&1; then
HEALTH=$(curl -sf "$API_HEALTH")
ok "API healthy: $HEALTH"
break
fi
if [[ $i -eq 15 ]]; then
warn "API did not respond in time — it may still be starting"
warn "Check: ${COMPOSE} logs api"
fi
sleep 2
done
# ── 12. Summary ───────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "Deploy complete"
info "Commit: ${COMMIT}"
info "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
info "App URL: https://optical-dev.oliver.solutions${APP_BASE_PATH}/"
info "API docs: https://optical-dev.oliver.solutions${APP_BASE_PATH}/docs"
echo ""
info "Useful commands:"
info " ${COMPOSE} logs -f api # API logs"
info " ${COMPOSE} logs -f worker # Worker logs"
info " ${COMPOSE} ps # Container status"
info " bash deploy.sh --reindex # Force re-embed RAG + icons"
info " bash deploy.sh --skip-frontend # Backend-only update"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"