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>
331 lines
13 KiB
Bash
Executable file
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|