API takes time to initialize — immediate curl always fails. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
341 lines
16 KiB
Bash
Executable file
341 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# DeckForge Production Deploy Script
|
|
# Idempotent — safe to run multiple times
|
|
# Run as normal user; script uses sudo internally for privileged operations
|
|
set -euo pipefail
|
|
|
|
# ── Colours ──────────────────────────────────────────────────────────────────
|
|
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
|
|
info() { echo -e "${GREEN}[deploy]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
|
|
error() { echo -e "${RED}[error]${NC} $*" >&2; }
|
|
|
|
# ── Sudo check (need it for apt/apache/ufw but NOT for git/docker) ────────────
|
|
if ! sudo -n true 2>/dev/null; then
|
|
warn "This script needs sudo for apt/apache/ufw — you may be prompted for your password."
|
|
fi
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
# ── Default ports — read from .env first, then env, then hardcoded default ────
|
|
env_val() { local key=$1 def=$2; grep -E "^${key}=" .env 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 | grep -v '^$' || echo "$def"; }
|
|
API_PORT=${API_PORT:-$(env_val API_PORT 8000)}
|
|
WEB_PORT=${WEB_PORT:-$(env_val WEB_PORT 3000)}
|
|
PG_PORT=${PG_PORT:-$(env_val PG_PORT 5432)}
|
|
REDIS_PORT=${REDIS_PORT:-$(env_val REDIS_PORT 6379)}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 1: Prerequisites — install missing packages
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 1: Checking prerequisites..."
|
|
|
|
install_if_missing() {
|
|
local cmd=$1; local pkg=${2:-$1}
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
info " Installing $pkg..."
|
|
sudo apt-get install -y "$pkg" -qq
|
|
else
|
|
info " $cmd: OK"
|
|
fi
|
|
}
|
|
|
|
sudo apt-get update -qq
|
|
|
|
install_if_missing docker docker.io
|
|
install_if_missing git git
|
|
install_if_missing curl curl
|
|
install_if_missing ss iproute2
|
|
install_if_missing ufw ufw
|
|
|
|
# Docker Compose plugin
|
|
if ! docker compose version &>/dev/null 2>&1; then
|
|
info " Installing docker-compose-plugin..."
|
|
sudo apt-get install -y docker-compose-plugin -qq
|
|
fi
|
|
|
|
# Apache2
|
|
if ! command -v apache2 &>/dev/null; then
|
|
info " Installing apache2..."
|
|
sudo apt-get install -y apache2 -qq
|
|
fi
|
|
|
|
# Enable required Apache modules
|
|
info " Enabling Apache modules..."
|
|
sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite -q
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 1.5: Port conflict check
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 1.5: Checking port availability..."
|
|
|
|
# Check if a port is in use by a NON-deckforge process
|
|
port_taken_by_other() {
|
|
local port=$1
|
|
# Check if anything is listening on this port
|
|
ss -tlnp 2>/dev/null | grep -qw ":${port}" || return 1
|
|
|
|
# Check if it belongs to OUR docker-compose project
|
|
local our_ids
|
|
our_ids=$(docker compose -f docker-compose.yml -f docker-compose.prod.yml ps -q 2>/dev/null || true)
|
|
if [[ -n "$our_ids" ]]; then
|
|
# shellcheck disable=SC2086
|
|
if docker inspect $our_ids 2>/dev/null | grep -q "\"HostPort\": \"${port}\""; then
|
|
return 1 # It's ours — will be replaced on restart
|
|
fi
|
|
fi
|
|
return 0 # Taken by someone else
|
|
}
|
|
|
|
# Find next free port starting from given port
|
|
find_free_port() {
|
|
local port=$1
|
|
while port_taken_by_other "$port"; do
|
|
port=$((port + 1))
|
|
done
|
|
echo "$port"
|
|
}
|
|
|
|
# Write or update a key in .env
|
|
set_env_port() {
|
|
local key=$1 val=$2
|
|
if grep -qE "^${key}=" .env 2>/dev/null; then
|
|
sed -i "s|^${key}=.*|${key}=${val}|" .env
|
|
else
|
|
echo "${key}=${val}" >> .env
|
|
fi
|
|
}
|
|
|
|
check_port() {
|
|
local port=$1
|
|
local varname=$2
|
|
local service=$3
|
|
|
|
if ! port_taken_by_other "$port"; then
|
|
info " Port $port ($service): OK"
|
|
return
|
|
fi
|
|
|
|
local new_port
|
|
new_port=$(find_free_port "$((port + 1))")
|
|
warn " Port $port ($service) is taken by another process — using $new_port instead"
|
|
eval "$varname=$new_port"
|
|
set_env_port "$varname" "$new_port"
|
|
info " Saved ${varname}=${new_port} to .env"
|
|
}
|
|
|
|
check_port "$API_PORT" API_PORT "api"
|
|
check_port "$WEB_PORT" WEB_PORT "web"
|
|
check_port "$PG_PORT" PG_PORT "postgres"
|
|
check_port "$REDIS_PORT" REDIS_PORT "redis"
|
|
|
|
export API_PORT WEB_PORT PG_PORT REDIS_PORT
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 2: Pull latest code
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 2: Pulling latest code..."
|
|
git pull origin main
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 3: Environment file
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 3: Checking .env..."
|
|
|
|
if [[ ! -f .env ]]; then
|
|
if [[ -f .env.example ]]; then
|
|
cp .env.example .env
|
|
warn ".env was missing — copied from .env.example"
|
|
warn "Edit .env with real secrets before continuing (Ctrl-C to abort)"
|
|
read -rp "Press Enter to continue or Ctrl-C to abort..."
|
|
else
|
|
error ".env and .env.example both missing. Cannot continue."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Ensure ALLOWED_ORIGINS contains the production domain
|
|
if ! grep -q "optical-dev.oliver.solutions" .env; then
|
|
warn "ALLOWED_ORIGINS may not include https://optical-dev.oliver.solutions — please verify"
|
|
fi
|
|
|
|
# Warn on default secrets
|
|
check_default() {
|
|
local key=$1; local default=$2
|
|
local val
|
|
val=$(grep -E "^${key}=" .env | cut -d= -f2- | tr -d '"' || true)
|
|
if [[ "$val" == "$default" || -z "$val" ]]; then
|
|
warn "${key} appears to be unset or default ('${default}') — change it!"
|
|
fi
|
|
}
|
|
check_default JWT_SECRET_KEY "changeme"
|
|
check_default DEV_AUTH_PASSWORD "changeme"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 4: Patch next.config.mjs with basePath (build-time config)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 4: Patching frontend/next.config.mjs with basePath..."
|
|
|
|
NEXT_CONFIG="frontend/next.config.mjs"
|
|
|
|
if grep -q 'basePath' "$NEXT_CONFIG"; then
|
|
info " basePath already present — skipping"
|
|
else
|
|
# Insert basePath + assetPrefix after "const nextConfig = {"
|
|
sed -i 's/const nextConfig = {/const nextConfig = {\n basePath: "\/ppt-tool",\n assetPrefix: "\/ppt-tool",/' "$NEXT_CONFIG"
|
|
info " Injected basePath and assetPrefix into $NEXT_CONFIG"
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 5: Generate docker-compose.prod.yml with resolved ports
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 5: Writing docker-compose.prod.yml..."
|
|
|
|
# The file is already in the repo; just export port vars so Docker Compose
|
|
# variable substitution picks them up (already done via export above).
|
|
# Re-state for clarity:
|
|
info " API_PORT=$API_PORT WEB_PORT=$WEB_PORT PG_PORT=$PG_PORT REDIS_PORT=$REDIS_PORT"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 6: Build Docker images
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 6: Building Docker images..."
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 7: Start services (except nginx — Apache replaces it)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 7: Starting services..."
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
|
|
up -d postgres redis api worker web
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 8: Wait for Postgres to be ready
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 8: Waiting for Postgres..."
|
|
MAX_WAIT=30
|
|
for i in $(seq 1 $MAX_WAIT); do
|
|
if docker compose -f docker-compose.yml -f docker-compose.prod.yml \
|
|
exec -T postgres pg_isready -U deckforge &>/dev/null; then
|
|
info " Postgres ready (${i}s)"
|
|
break
|
|
fi
|
|
if [[ $i -eq $MAX_WAIT ]]; then
|
|
error "Postgres did not become ready within ${MAX_WAIT}s"
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 9: Database migrations + seed
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 9: Running Alembic migrations..."
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
|
|
exec -T api alembic upgrade head
|
|
|
|
info " Running seed script (idempotent)..."
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
|
|
exec -T api python -m scripts.seed || warn "Seed script failed or already seeded — continuing"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 10: Apache configuration
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 10: Configuring Apache..."
|
|
|
|
APACHE_CONF_SRC="$SCRIPT_DIR/apache/deckforge.conf"
|
|
APACHE_CONF_DST="/etc/apache2/sites-available/deckforge.conf"
|
|
|
|
# Substitute actual ports into the template
|
|
sed \
|
|
-e "s/API_PORT/${API_PORT}/g" \
|
|
-e "s/WEB_PORT/${WEB_PORT}/g" \
|
|
"$APACHE_CONF_SRC" | sudo tee "$APACHE_CONF_DST" > /dev/null
|
|
|
|
info " Written $APACHE_CONF_DST"
|
|
|
|
# Disable default site on first install
|
|
if [[ -L /etc/apache2/sites-enabled/000-default.conf ]]; then
|
|
sudo a2dissite 000-default.conf -q || true
|
|
info " Disabled 000-default.conf"
|
|
fi
|
|
|
|
sudo a2ensite deckforge.conf -q
|
|
info " Enabled deckforge.conf"
|
|
|
|
# Health check page — Google LB requires GET / to return 200
|
|
sudo mkdir -p /var/www/html
|
|
sudo tee /var/www/html/index.html > /dev/null << 'EOF'
|
|
<!DOCTYPE html>
|
|
<html><head><meta http-equiv="refresh" content="0;url=/ppt-tool/"></head>
|
|
<body><a href="/ppt-tool/">DeckForge</a></body></html>
|
|
EOF
|
|
info " Health check page written to /var/www/html/index.html"
|
|
|
|
# Test Apache config before reloading
|
|
if sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
|
|
sudo systemctl reload apache2
|
|
info " Apache reloaded"
|
|
else
|
|
error "Apache config test failed — check $APACHE_CONF_DST"
|
|
sudo apache2ctl configtest
|
|
exit 1
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 11: UFW Firewall
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 11: Configuring UFW firewall..."
|
|
sudo ufw default deny incoming -y 2>/dev/null || true
|
|
sudo ufw default allow outgoing -y 2>/dev/null || true
|
|
sudo ufw allow 22/tcp
|
|
sudo ufw allow 80/tcp
|
|
sudo ufw --force enable
|
|
info " UFW enabled (22/tcp, 80/tcp allowed)"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# STEP 12: Verification
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
info "Step 12: Verifying deployment..."
|
|
|
|
FAIL=0
|
|
|
|
info " Waiting for API to be ready..."
|
|
API_OK=0
|
|
for i in $(seq 1 24); do
|
|
if curl -sf "http://127.0.0.1:${API_PORT}/docs" > /dev/null 2>&1; then
|
|
API_OK=1; break
|
|
fi
|
|
sleep 5
|
|
done
|
|
if [ "$API_OK" -eq 1 ]; then
|
|
info " API (port ${API_PORT}): OK"
|
|
else
|
|
warn " API (port ${API_PORT}): not responding — check 'docker compose logs api'"
|
|
FAIL=1
|
|
fi
|
|
|
|
info " Waiting for frontend to be ready (Next.js takes ~30-60s to start)..."
|
|
WEB_OK=0
|
|
for i in $(seq 1 24); do
|
|
if curl -sf "http://127.0.0.1:${WEB_PORT}/ppt-tool/" > /dev/null 2>&1; then
|
|
WEB_OK=1; break
|
|
fi
|
|
sleep 5
|
|
done
|
|
if [ "$WEB_OK" -eq 1 ]; then
|
|
info " Frontend (port ${WEB_PORT}): OK"
|
|
else
|
|
warn " Frontend (port ${WEB_PORT}): not responding — check 'docker compose logs web'"
|
|
FAIL=1
|
|
fi
|
|
|
|
echo ""
|
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
|
|
echo ""
|
|
|
|
if [[ $FAIL -eq 0 ]]; then
|
|
info "Deploy complete. Visit https://optical-dev.oliver.solutions/ppt-tool/"
|
|
else
|
|
warn "Deploy finished with warnings. Review above messages."
|
|
fi
|