#!/usr/bin/env bash # ============================================================================= # Deploy script for optical-dev.oliver.solutions # Run from: /opt/video-accessibility/ # # Usage: # First deploy: ./scripts/deploy-dev.sh # Code-only: ./scripts/deploy-dev.sh --redeploy # Custom: ./scripts/deploy-dev.sh [--skip-build] [--skip-frontend] [--skip-migrations] # # --redeploy shorthand for --skip-build --skip-frontend --skip-migrations # (just git pull + docker up -d, no rebuilds) # ============================================================================= set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── PROJECT_DIR="/opt/video-accessibility" WEBROOT="/var/www/html/video-accessibility" APACHE_CONF_DIR="/etc/apache2/sites-enabled" APACHE_VHOST="optical-dev.oliver.solutions.conf" COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.optical-dev.yml --env-file .env.production" API_INTERNAL_PORT=8012 # host port the api container exposes VITE_BASE="/video-accessibility" GCS_BUCKET="${GCS_BUCKET:-$(grep '^GCS_BUCKET=' .env.production 2>/dev/null | cut -d= -f2)}" # In fallback mode (USE_CELERY_FALLBACK=true) all pipeline workers run locally. # whisper-worker uses a separate Dockerfile target; tts/ffmpeg-worker reuse the # worker target but need their own image tag for docker compose up. BUILD_SERVICES="api worker tts-worker ffmpeg-worker whisper-worker" # ── Flags ───────────────────────────────────────────────────────────────────── SKIP_BUILD=false SKIP_FRONTEND=false SKIP_MIGRATIONS=false for arg in "$@"; do case $arg in --redeploy) SKIP_BUILD=true; SKIP_FRONTEND=true; SKIP_MIGRATIONS=true ;; --skip-build) SKIP_BUILD=true ;; --skip-frontend) SKIP_FRONTEND=true ;; --skip-migrations) SKIP_MIGRATIONS=true ;; esac done # ── Helpers ─────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' ok() { echo -e "${GREEN}✓ $*${NC}"; } info() { echo -e "${BLUE}» $*${NC}"; } warn() { echo -e "${YELLOW}⚠ $*${NC}"; } fail() { echo -e "${RED}✗ $*${NC}"; exit 1; } # ── Pre-flight ──────────────────────────────────────────────────────────────── preflight() { info "Pre-flight checks..." [[ -f "docker-compose.yml" ]] || fail "Run from /opt/video-accessibility/" [[ -f ".env.production" ]] || fail ".env.production not found" [[ -f "secrets/gcp-credentials.json" ]] || fail "secrets/gcp-credentials.json not found" docker info &>/dev/null || fail "Docker not running" docker compose version &>/dev/null || fail "docker compose not found" # Warn if free RAM < 2 GB FREE_MB=$(free -m | awk '/^Mem:/ {print $7}') if (( FREE_MB < 2048 )); then warn "Low free RAM: ${FREE_MB}MB — build may be slow or OOM" fi ok "Pre-flight passed (free RAM: ${FREE_MB}MB)" } # ── Git pull ────────────────────────────────────────────────────────────────── pull_code() { info "Pulling latest code from main..." git pull origin main ok "Code updated ($(git rev-parse --short HEAD))" } # ── Docker build (sequential, one service at a time) ───────────────────────── build_images() { if $SKIP_BUILD; then warn "Skipping Docker build (--skip-build)"; return; fi info "Building Docker images sequentially..." for svc in $BUILD_SERVICES; do info " Building: $svc" $COMPOSE build "$svc" ok " $svc — done" done ok "All images built" } # ── Start services ──────────────────────────────────────────────────────────── start_services() { info "Starting services..." $COMPOSE up -d ok "Containers started" # Wait for API to be healthy (up to 60s) info "Waiting for API to be healthy..." for i in $(seq 1 30); do if curl -sf "http://localhost:${API_INTERNAL_PORT}/health" &>/dev/null; then ok "API is healthy" return fi sleep 2 done warn "API health check timed out — check logs: docker compose logs api" } # ── Migrations ──────────────────────────────────────────────────────────────── run_migrations() { if $SKIP_MIGRATIONS; then warn "Skipping migrations (--skip-migrations)"; return; fi info "Running database migrations..." $COMPOSE exec -T api python migrate.py up ok "Migrations complete" } # ── Frontend build & deploy ─────────────────────────────────────────────────── deploy_frontend() { if $SKIP_FRONTEND; then warn "Skipping frontend (--skip-frontend)"; return; fi info "Building frontend..." cd frontend npm ci --prefer-offline VITE_BASE_PATH="${VITE_BASE}" npm run build cd .. ok "Frontend built" info "Deploying frontend to Apache webroot..." sudo mkdir -p "${WEBROOT}" sudo rsync -a --delete frontend/dist/ "${WEBROOT}/" sudo chown -R www-data:www-data "${WEBROOT}" sudo chmod -R 755 "${WEBROOT}" ok "Frontend deployed to ${WEBROOT}" } # ── Apache modules ──────────────────────────────────────────────────────────── ensure_apache_modules() { info "Enabling required Apache modules..." sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers 2>/dev/null ok "Apache modules enabled" } # ── Apache fragment ─────────────────────────────────────────────────────────── ensure_apache_config() { FRAGMENT="${PROJECT_DIR}/deploy/apache-video-accessibility.conf" # Always regenerate the fragment so it picks up PORT/WEBROOT changes info "Writing Apache config fragment..." sudo mkdir -p "${PROJECT_DIR}/deploy" sudo tee "$FRAGMENT" > /dev/null < ProxyTimeout 600 # ── WebSocket proxy (MUST be before /api/ HTTP proxy) ──────── # ProxyPassMatch uses regex — takes precedence over Alias even when the # physical directory ${WEBROOT} exists on disk. ProxyPassMatch ^/video-accessibility/api/v1/ws/(.*)$ ws://127.0.0.1:${API_INTERNAL_PORT}/api/v1/ws/\$1 disablereuse=on ProxyPassReverse /video-accessibility/api/v1/ws/ ws://127.0.0.1:${API_INTERNAL_PORT}/api/v1/ws/ # ── API proxy (strip /video-accessibility prefix) ──────────── ProxyPassMatch ^/video-accessibility/api/(.*)$ http://127.0.0.1:${API_INTERNAL_PORT}/api/\$1 ProxyPassReverse /video-accessibility/api/ http://127.0.0.1:${API_INTERNAL_PORT}/api/ # Swagger docs ProxyPassMatch ^/video-accessibility/docs(/.*)?$ http://127.0.0.1:${API_INTERNAL_PORT}/docs\$1 ProxyPassReverse /video-accessibility/docs http://127.0.0.1:${API_INTERNAL_PORT}/docs ProxyPassMatch ^/video-accessibility/openapi\.json$ http://127.0.0.1:${API_INTERNAL_PORT}/openapi.json ProxyPassReverse /video-accessibility/openapi.json http://127.0.0.1:${API_INTERNAL_PORT}/openapi.json # ── SPA static files ───────────────────────────────────────── Alias /video-accessibility ${WEBROOT} Options -Indexes +FollowSymLinks AllowOverride None Require all granted # Allow large video file uploads (2 GB) LimitRequestBody 2147483648 RewriteEngine On RewriteBase /video-accessibility/ # Serve real files/dirs directly RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] # All other paths → index.html (React Router handles client-side nav) RewriteRule ^ index.html [L] APACHE ok "Apache fragment written to $FRAGMENT" VHOST_FILE="${APACHE_CONF_DIR}/${APACHE_VHOST}" INCLUDE_LINE=" Include ${FRAGMENT}" if [[ -f "$VHOST_FILE" ]]; then if ! sudo grep -qF "$FRAGMENT" "$VHOST_FILE"; then info "Injecting Include into Apache vhost..." sudo sed -i "s||${INCLUDE_LINE}\n|" "$VHOST_FILE" ok "Include injected" else ok "Apache Include already present" fi sudo apache2ctl configtest && sudo systemctl reload apache2 ok "Apache reloaded" else warn "Vhost file not found: $VHOST_FILE — add manually:" warn " Include ${FRAGMENT}" fi } # ── Smoke test ──────────────────────────────────────────────────────────────── smoke_test() { info "Smoke test..." # Internal API if curl -sf "http://localhost:${API_INTERNAL_PORT}/health" | python3 -m json.tool; then ok "API /health — OK" else warn "API /health — failed" fi # Public URL PUBLIC_URL="https://optical-dev.oliver.solutions${VITE_BASE}" HTTP_CODE=$(curl -o /dev/null -sw "%{http_code}" "${PUBLIC_URL}/" 2>/dev/null || echo "000") if [[ "$HTTP_CODE" == "200" ]]; then ok "Frontend ${PUBLIC_URL}/ — HTTP 200" else warn "Frontend ${PUBLIC_URL}/ — HTTP ${HTTP_CODE} (Apache may need config)" fi echo "" info "Container status:" $COMPOSE ps } # ── GCS CORS (needed for browser→GCS chunked uploads) ──────────────────────── ensure_gcs_cors() { if [[ -z "$GCS_BUCKET" ]]; then warn "GCS_BUCKET not set — skipping CORS config (set manually: gsutil cors set infra/gcs-cors.json gs://)" return fi info "Setting GCS CORS configuration for gs://${GCS_BUCKET}..." if gsutil cors set "${PROJECT_DIR}/infra/gcs-cors.json" "gs://${GCS_BUCKET}" 2>/dev/null; then ok "GCS CORS set" else warn "gsutil cors set failed — run manually: gsutil cors set infra/gcs-cors.json gs://${GCS_BUCKET}" fi } # ── Main ────────────────────────────────────────────────────────────────────── main() { echo "" echo -e "${BLUE}══════════════════════════════════════════${NC}" echo -e "${BLUE} video-accessibility — optical-dev deploy ${NC}" echo -e "${BLUE}══════════════════════════════════════════${NC}" echo "" cd "$PROJECT_DIR" preflight pull_code build_images start_services run_migrations deploy_frontend ensure_apache_modules ensure_apache_config ensure_gcs_cors smoke_test echo "" ok "Deploy complete!" echo "" echo " App: https://optical-dev.oliver.solutions/video-accessibility/" echo " API: https://optical-dev.oliver.solutions/video-accessibility/api/v1/health" echo " Docs: https://optical-dev.oliver.solutions/video-accessibility/docs" echo "" echo "Logs: $COMPOSE logs -f api" echo "Redeploy: ./scripts/deploy-dev.sh --redeploy # just pull + restart" echo "Code+front: ./scripts/deploy-dev.sh --skip-build # rebuild frontend, skip docker build" echo "Full: ./scripts/deploy-dev.sh # everything from scratch" } main "$@"