video-accessibility/scripts/deploy-dev.sh
Vadym Samoilenko 46477b7b32 fix(deploy): target sites-enabled instead of sites-available for Apache Include injection
On optical-dev the Apache vhost is a standalone file in sites-enabled (not
a symlink to sites-available), so injecting the Include into sites-available
had no effect and the ProxyPassMatch rules were never loaded by Apache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:32:23 +01:00

292 lines
12 KiB
Bash
Executable file

#!/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 <<APACHE
# =============================================================
# video-accessibility — Apache config fragment
# Generated by deploy-dev.sh — do NOT edit manually
# =============================================================
# ── Timeouts for large video uploads (up to 2 GB, ~10 min) ──
<IfModule mod_proxy.c>
ProxyTimeout 600
</IfModule>
# ── 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}
<Directory ${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]
</Directory>
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|</VirtualHost>|${INCLUDE_LINE}\n</VirtualHost>|" "$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://<bucket>)"
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 "$@"