From ed0746267b954a4089b654e87cb21acb0ca30f72 Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 27 Apr 2026 16:34:05 -0400 Subject: [PATCH] deploy: auto-pick free ports + render Apache conf from template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script bailed when 8003 was taken on the dev server. Per spec, it should never block on a port clash — find a free port and run with it. How it picks ports: - Reads OSOP_DB_PORT / OSOP_REDIS_PORT / OSOP_BACKEND_PORT from .env, falling back to defaults 5435 / 6380 / 8003. - For each, if the preferred port is taken on the host, scans upward in a sane range (5435-5499 / 6380-6399 / 8003-8099) for the next free one. - Persists chosen ports back to .env via an idempotent KEY=VALUE upsert, so subsequent deploys keep using the same allocation. - If our compose project is already running, skips the scan and reuses the current ports (re-deploy in place). Compose port mappings now reference those env vars with defaults: 127.0.0.1:${OSOP_DB_PORT:-5435}:5432, etc. Apache config templating: - deploy/apache-osop.conf.tmpl has __BACKEND_PORT__ placeholder. - The script renders it to deploy/apache-osop.conf each run with the chosen backend port substituted in. The rendered file is gitignored (the template is the source of truth in git). - If the backend port changed (or the Apache vhost doesn't yet Include our conf), the script tells the user to reload Apache. This means a fresh server hits the conflict on 8003 (something else is listening), the script picks 8004 silently, writes it to .env, renders apache-osop.conf with 8004, brings the stack up, and tells you to reload Apache. Re-running the script on the same server keeps 8004. --- .gitignore | 5 + ...apache-osop.conf => apache-osop.conf.tmpl} | 17 +- deploy/deploy.sh | 205 ++++++++++++------ docker-compose.yml | 8 +- 4 files changed, 157 insertions(+), 78 deletions(-) rename deploy/{apache-osop.conf => apache-osop.conf.tmpl} (69%) diff --git a/.gitignore b/.gitignore index 14ad268..47f9442 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ node_modules/ dist/ +# Generated by deploy/deploy.sh from apache-osop.conf.tmpl with the +# server's actual backend port substituted in. The template is the +# source of truth. +deploy/apache-osop.conf + # Compiled Python bytecode *.py[cod] __pycache__/ diff --git a/deploy/apache-osop.conf b/deploy/apache-osop.conf.tmpl similarity index 69% rename from deploy/apache-osop.conf rename to deploy/apache-osop.conf.tmpl index 25576d1..c76e25d 100644 --- a/deploy/apache-osop.conf +++ b/deploy/apache-osop.conf.tmpl @@ -1,25 +1,26 @@ -# OLIVER Sales Ops Platform — Apache reverse-proxy block +# OLIVER Sales Ops Platform — Apache reverse-proxy block (template) +# +# This file is the template. The actual `apache-osop.conf` next to it is +# generated by `deploy/deploy.sh` on every run, with the chosen backend +# port substituted in. apache-osop.conf is gitignored. # # Drop into the merged optical-dev.oliver.solutions vhost via Include: # Include /opt/oliver-sales-ops-platform/deploy/apache-osop.conf # # Mirrors the /gsb/ pattern on the same server: -# - Backend FastAPI runs in Docker on 127.0.0.1:8003 (db 5435, redis 6380). -# - Frontend is a Vite static build served straight off the filesystem +# - Backend FastAPI runs in Docker on 127.0.0.1:__BACKEND_PORT__ +# - Frontend is a Vite static build, served straight off the filesystem # by Apache from /var/www/html/oliver-sales-ops-platform/. # # Public URL: # https://optical-dev.oliver.solutions/oliver-sales-ops-platform/ -# -# 5-minute timeout matches the /gsb/ block — the matching agent and -# deep extraction can run for ~60-180s on large RFPs. ProxyTimeout 300 TimeOut 300 # Backend API -ProxyPass /oliver-sales-ops-platform/api/ http://127.0.0.1:8003/api/ timeout=300 -ProxyPassReverse /oliver-sales-ops-platform/api/ http://127.0.0.1:8003/api/ +ProxyPass /oliver-sales-ops-platform/api/ http://127.0.0.1:__BACKEND_PORT__/api/ timeout=300 +ProxyPassReverse /oliver-sales-ops-platform/api/ http://127.0.0.1:__BACKEND_PORT__/api/ # Frontend SPA (built artefacts) Alias /oliver-sales-ops-platform /var/www/html/oliver-sales-ops-platform diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4e6e384..5b18d90 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -9,18 +9,23 @@ # /var/www/html/oliver-sales-ops-platform/ — built SPA, served by Apache # # What it does: -# 1. Sanity check (.env, docker, git on PATH). -# 2. Port-conflict check on backend host ports (5435 db, 6380 redis, -# 8003 backend). If our compose project is already running, those -# ports are ours and re-deploying in place is fine; otherwise we -# verify they're free. -# 3. git pull (--no-pull to skip). -# 4. docker compose build && up -d (--no-build to skip). -# 5. Build frontend SPA (Vite) inside a one-shot node container and -# sync the dist/ output to /var/www/html/oliver-sales-ops-platform/ +# 1. Sanity (.env, docker, git on PATH). +# 2. Auto-pick free host ports: +# - prefers OSOP_DB_PORT (default 5435) +# - prefers OSOP_REDIS_PORT (default 6380) +# - prefers OSOP_BACKEND_PORT (default 8003) +# If any preferred port is taken on the host, scans upward in its +# range for the next free port and persists the chosen value to +# .env so subsequent deploys keep using it. +# 3. Render deploy/apache-osop.conf from .tmpl with the chosen +# backend port. +# 4. git pull (--no-pull to skip). +# 5. docker compose build && up -d (--no-build to skip). +# 6. Build frontend SPA (Vite) inside a one-shot node container and +# sync dist/ to /var/www/html/oliver-sales-ops-platform/ # (--no-frontend to skip). -# 6. Poll /api/health until ready, probe the SPA index. -# 7. Print URLs + admin email. +# 7. Poll /api/health until ready. +# 8. Print URLs + admin email + apache-reload reminder. # # Usage: # ./deploy/deploy.sh # full deploy @@ -39,9 +44,10 @@ WEB_ROOT="/var/www/html/oliver-sales-ops-platform" cd "$REPO_ROOT" -log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; } -err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; } -ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; } +log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; } +ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; } DO_PULL=1 DO_BUILD=1 @@ -79,59 +85,121 @@ command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; } # serves the SPA off the filesystem; the Vite container is local-dev-only. unset COMPOSE_PROFILES -# 2. Port-conflict check (backend ports only — frontend is filesystem on prod) -PORTS=(5435 6380 8003) -log "Checking host ports: ${PORTS[*]}" +# ---------- helpers ---------- + +# True (0) if something else is listening on $1 on the host. +port_in_use() { + local port=$1 + local pid="" + if command -v lsof >/dev/null 2>&1; then + pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 ) + else + pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 ) + fi + [[ -n "$pid" ]] +} + +# Find the first free port. Tries $preferred first, then scans [start..end]. +find_free_port() { + local preferred=$1 + local start=$2 + local end=$3 + if ! port_in_use "$preferred"; then + printf '%s' "$preferred" + return 0 + fi + local p + for ((p=start; p<=end; p++)); do + if ! port_in_use "$p"; then + printf '%s' "$p" + return 0 + fi + done + return 1 +} + +# Idempotent KEY=VALUE upsert into .env. +set_env_var() { + local key=$1 + local value=$2 + local file="${REPO_ROOT}/.env" + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i.bak "s#^${key}=.*#${key}=${value}#" "$file" + rm -f "${file}.bak" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +# Echo current value of KEY from .env (empty if absent). +get_env_var() { + grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true +} + +# ---------- 2. Pick ports ---------- + +DEFAULT_DB_PORT=5435 +DEFAULT_REDIS_PORT=6380 +DEFAULT_BACKEND_PORT=8003 + +DB_PORT=$(get_env_var OSOP_DB_PORT); DB_PORT=${DB_PORT:-$DEFAULT_DB_PORT} +REDIS_PORT=$(get_env_var OSOP_REDIS_PORT); REDIS_PORT=${REDIS_PORT:-$DEFAULT_REDIS_PORT} +BACKEND_PORT=$(get_env_var OSOP_BACKEND_PORT); BACKEND_PORT=${BACKEND_PORT:-$DEFAULT_BACKEND_PORT} + +PREV_BACKEND_PORT="$BACKEND_PORT" + +log "Resolving host ports (preferred: db=$DB_PORT redis=$REDIS_PORT backend=$BACKEND_PORT)…" RUNNING=$(docker compose ps -q 2>/dev/null | wc -l | tr -d ' ') if [[ "$RUNNING" -gt 0 ]]; then - ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — ports are ours, re-deploy in place." + ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — keeping current port assignment." else - # NB: lsof / ss exit non-zero when nothing is listening on a port. Under - # `set -euo pipefail` that propagated as a fatal error and the script - # exited silently right after "Checking host ports". Wrap with `|| true`. - port_owner_pid() { - local port=$1 - local pid="" - if command -v lsof >/dev/null 2>&1; then - pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 ) - else - pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 ) - fi - printf '%s' "$pid" - } - - CONFLICTS=() - for port in "${PORTS[@]}"; do - pid=$(port_owner_pid "$port") || true - if [[ -n "$pid" ]]; then - CONFLICTS+=("$port (pid $pid)") - else - ok " port $port — free" - fi - done - - if (( ${#CONFLICTS[@]} > 0 )); then - err "Port conflicts detected (something else is listening on the host):" - for c in "${CONFLICTS[@]}"; do err " $c"; done - err - err "Options:" - err " 1. Stop the conflicting service." - err " 2. Change the host port mapping in docker-compose.yml AND update" - err " deploy/apache-osop.conf to match." - err " 3. If those ports are owned by docker-proxy from a stale stack," - err " run: docker compose -p $COMPOSE_PROJECT down then re-run." + NEW_DB_PORT=$(find_free_port "$DB_PORT" 5435 5499) || NEW_DB_PORT="" + NEW_REDIS_PORT=$(find_free_port "$REDIS_PORT" 6380 6399) || NEW_REDIS_PORT="" + NEW_BACKEND_PORT=$(find_free_port "$BACKEND_PORT" 8003 8099) || NEW_BACKEND_PORT="" + if [[ -z "$NEW_DB_PORT" || -z "$NEW_REDIS_PORT" || -z "$NEW_BACKEND_PORT" ]]; then + err "Could not find a free port in the configured ranges." + err " db desired=$DB_PORT scanned=5435-5499" + err " redis desired=$REDIS_PORT scanned=6380-6399" + err " backend desired=$BACKEND_PORT scanned=8003-8099" exit 1 fi + + [[ "$NEW_DB_PORT" != "$DB_PORT" ]] && warn "db port $DB_PORT busy → using $NEW_DB_PORT" + [[ "$NEW_REDIS_PORT" != "$REDIS_PORT" ]] && warn "redis port $REDIS_PORT busy → using $NEW_REDIS_PORT" + [[ "$NEW_BACKEND_PORT" != "$BACKEND_PORT" ]] && warn "backend port $BACKEND_PORT busy → using $NEW_BACKEND_PORT" + + DB_PORT=$NEW_DB_PORT + REDIS_PORT=$NEW_REDIS_PORT + BACKEND_PORT=$NEW_BACKEND_PORT + + set_env_var OSOP_DB_PORT "$DB_PORT" + set_env_var OSOP_REDIS_PORT "$REDIS_PORT" + set_env_var OSOP_BACKEND_PORT "$BACKEND_PORT" + + ok "Ports: db=$DB_PORT redis=$REDIS_PORT backend=$BACKEND_PORT (persisted to .env)" fi -# 3. git pull +# ---------- 3. Render apache-osop.conf from template ---------- + +APACHE_TMPL="$REPO_ROOT/deploy/apache-osop.conf.tmpl" +APACHE_CONF="$REPO_ROOT/deploy/apache-osop.conf" +if [[ -f "$APACHE_TMPL" ]]; then + sed "s#__BACKEND_PORT__#${BACKEND_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF" + ok "Rendered apache-osop.conf with backend port $BACKEND_PORT" +else + warn "apache-osop.conf.tmpl missing — leaving deploy/apache-osop.conf untouched." +fi + +# ---------- 4. git pull ---------- + if (( DO_PULL )); then log "git pull origin main" git pull --ff-only origin main fi -# 4. Backend build + up +# ---------- 5. Backend build + up ---------- + if (( DO_BUILD )); then log "docker compose build" docker compose build @@ -140,7 +208,8 @@ fi log "docker compose up -d (db + redis + backend)" docker compose up -d -# 5. Frontend build + sync to web root +# ---------- 6. Frontend build + sync ---------- + if (( DO_FRONTEND )); then log "Building Vite SPA in a one-shot node:20 container…" docker run --rm \ @@ -163,7 +232,6 @@ if (( DO_FRONTEND )); then fi fi - # rsync if available (atomic-ish, only changes); else cp -a. if command -v rsync >/dev/null 2>&1; then if [[ -w "$WEB_ROOT" ]]; then rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/" @@ -182,10 +250,11 @@ if (( DO_FRONTEND )); then ok "SPA synced to $WEB_ROOT" fi -# 6. Health poll -log "Waiting for backend /api/health (max 60s)…" +# ---------- 7. Health poll ---------- + +log "Waiting for backend /api/health on :$BACKEND_PORT (max 60s)…" for i in $(seq 1 30); do - if curl -fsS http://127.0.0.1:8003/api/health >/dev/null 2>&1; then + if curl -fsS "http://127.0.0.1:${BACKEND_PORT}/api/health" >/dev/null 2>&1; then ok "Backend healthy" break fi @@ -197,21 +266,25 @@ for i in $(seq 1 30); do fi done -# 7. Report -ADMIN_EMAIL=$(grep -E '^DEV_AUTH_EMAIL=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || echo "admin@oliver.agency") -PUBLIC_URL=$(grep -E '^APP_PUBLIC_URL=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || echo "") +# ---------- 8. Report ---------- + +ADMIN_EMAIL=$(get_env_var DEV_AUTH_EMAIL); ADMIN_EMAIL=${ADMIN_EMAIL:-admin@oliver.agency} +PUBLIC_URL=$(get_env_var APP_PUBLIC_URL) ok "Deploy complete." echo -echo " Backend (local): http://127.0.0.1:8003/api/health" +echo " Backend (local): http://127.0.0.1:${BACKEND_PORT}/api/health" [[ -n "$PUBLIC_URL" ]] && echo " Public URL: ${PUBLIC_URL%/}${URL_PATH}/" [[ -d "$WEB_ROOT" ]] && echo " SPA on disk: $WEB_ROOT" echo " Admin user: $ADMIN_EMAIL (role=admin, no password — DEV_AUTH_BYPASS=true)" +echo " Ports: db=$DB_PORT redis=$REDIS_PORT backend=$BACKEND_PORT" echo echo " Apache include line for the merged vhost:" echo " Include $REPO_ROOT/deploy/apache-osop.conf" -echo -echo " After adding the Include and reloading Apache:" -echo " sudo apachectl configtest && sudo systemctl reload apache2" +if [[ "$BACKEND_PORT" != "$PREV_BACKEND_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-osop.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then + echo + warn "Backend port changed (or first deploy). Reload Apache to pick up the new ProxyPass:" + echo " sudo apachectl configtest && sudo systemctl reload apache2" +fi echo if (( TAIL_LOGS )); then diff --git a/docker-compose.yml b/docker-compose.yml index 65850e8..199c06b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: volumes: - osop_pgdata:/var/lib/postgresql/data ports: - - "127.0.0.1:5435:5432" + - "127.0.0.1:${OSOP_DB_PORT:-5435}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U osop_user -d oliver_sales_ops"] interval: 5s @@ -22,7 +22,7 @@ services: image: redis:7-alpine restart: unless-stopped ports: - - "127.0.0.1:6380:6379" + - "127.0.0.1:${OSOP_REDIS_PORT:-6380}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -55,7 +55,7 @@ services: APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3011} APP_PATH_PREFIX: ${APP_PATH_PREFIX:-/oliver-sales-ops-platform} ports: - - "127.0.0.1:8003:8000" + - "127.0.0.1:${OSOP_BACKEND_PORT:-8003}:8000" volumes: - ${DATA_DIR:-./data}:/app/data - ./backend/app:/app/app @@ -75,7 +75,7 @@ services: environment: VITE_DEV_AUTH_BYPASS: ${VITE_DEV_AUTH_BYPASS:-} ports: - - "127.0.0.1:3011:3000" + - "127.0.0.1:${OSOP_FRONTEND_PORT:-3011}:3000" volumes: - ./frontend/src:/app/src - ./frontend/index.html:/app/index.html