dow-prod-tracker/deploy.sh
DJP 7e7ef7b7c1 deploy.sh: auto-detect free host ports, render Apache conf per-deploy
You were right — everything's containerized, the host ports are just
reverse-proxy targets (+ an optional psql peephole for the db). Hardcoding
them is why the local smoke test face-planted on 5492 (amazon-transcreation
was squatting it) and would have done the same any time anything else
bound :3002 or :5492 on the shared server.

docker-compose.yml:
- ports now reference `${APP_HOST_PORT:-3002}` and `${DB_HOST_PORT:-5492}`.
  Defaults match the prior-committed values; override via env vars.
  Container-internal ports (3000, 5432) never change.

apache/dow-prod-tracker.conf → .conf.tmpl:
- Moved to a committed template with `${APP_HOST_PORT}` placeholders in
  both the WebSocket rewrite and the ProxyPass/ProxyPassReverse lines.
- deploy.sh renders the real .conf from the template on every run with
  the chosen port substituted in. Rendered .conf is gitignored so it
  can vary per server without drift.

deploy.sh:
- New is_port_free() and find_free_port() using bash's /dev/tcp — no
  external tool dependency, works identically on Ubuntu and macOS.
- After `docker compose down` (which frees any of OUR ports), probe for
  APP_HOST_PORT starting from 3002 and DB_HOST_PORT from 5492. Pick the
  first free port (scan up to 50). Warn if the preferred port was busy.
  Honors explicit override: `APP_HOST_PORT=3005 ./deploy.sh` works.
- Exports the chosen ports before `docker compose up` so compose
  substitutes them into the `ports:` mappings.
- Renders apache/dow-prod-tracker.conf from the .tmpl with the same
  APP_HOST_PORT, every deploy. If the Apache Include line is already in
  the vhost, we reload Apache anyway (picks up the re-rendered snippet
  in case the port changed).
- Health check URL uses APP_HOST_PORT.
- "Deploy complete" banner now prints the chosen ports.

.gitignore:
- Added docker-compose.override.yml (per-machine local overrides) and
  apache/dow-prod-tracker.conf (rendered by deploy.sh, varies per server).

DEPLOY.md updated with the auto-detection behaviour and override recipe.

Sanity-checked locally:
- is_port_free correctly identifies 5492 busy (amazon-transcreation),
  5493 busy (our smoke-test db), 3002 busy (Docker Desktop grabs 3000-3002
  on this Mac), and picks 5494/3003 respectively.
- `APP_HOST_PORT=3999 DB_HOST_PORT=5999 docker compose config` produces
  published ports 3999 and 5999.
- `bash -n deploy.sh` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:59:30 -04:00

298 lines
15 KiB
Bash

#!/usr/bin/env bash
# deploy.sh — idempotent deploy script for dow-prod-tracker
# Idempotent — safe to run multiple times (initial deploy or update)
# Run as normal user; uses sudo internally for apt/apache/ufw
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; }
# ── Args ─────────────────────────────────────────────────────────────────────
SKIP_PULL=false
for arg in "$@"; do [[ "$arg" == "--skip-pull" ]] && SKIP_PULL=true; done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Preferred host ports. If either is in use on the host, we auto-pick the
# next free one (see find_free_port below) and update the Apache conf +
# docker-compose env vars to match. Callers can override explicitly by
# exporting APP_HOST_PORT / DB_HOST_PORT before running this script.
PREFERRED_APP_PORT=3002
PREFERRED_DB_PORT=5492
# Compose project name — belt-and-braces with the `name:` field in
# docker-compose.yml. CLAUDE.md rule: the shared optical-dev server runs
# multiple apps from deploy dirs and Compose defaults the project name to
# the parent directory, so without this they collide on containers
# (dow-prod-tracker-db-1 vs hp-prod-tracker-db-1) and volumes.
COMPOSE_PROJECT=dow-prod-tracker
# ── Port helpers ────────────────────────────────────────────────────────────
# is_port_free uses bash's /dev/tcp — no external tool needed, works on
# Linux and macOS. If opening a TCP connection to localhost:PORT succeeds,
# something is listening (port busy). If it fails, port is free.
is_port_free() {
local p=$1
(: </dev/tcp/127.0.0.1/"$p") >/dev/null 2>&1 && return 1 || return 0
}
# Find the lowest free port >= start, scanning up to 50 candidates.
# Our own stopped containers don't count as "in use" — we `docker compose
# down` before this runs, so a previous deploy's port is freed.
find_free_port() {
local start=$1
local p
for ((p=start; p<start+50; p++)); do
if is_port_free "$p"; then
echo "$p"; return 0
fi
done
error " No free port in range ${start}..$((start+49))"
return 1
}
# ── Sudo check ───────────────────────────────────────────────────────────────
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
# ─────────────────────────────────────────────────────────────────────────────
# STEP 1: Prerequisites
# ─────────────────────────────────────────────────────────────────────────────
info "Step 1: Checking prerequisites..."
install_if_missing() {
local cmd=$1 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 ufw ufw
if ! docker compose version &>/dev/null 2>&1; then
info " Installing docker-compose-plugin..."
sudo apt-get install -y docker-compose-plugin -qq
fi
if ! command -v apache2 &>/dev/null; then
info " Installing apache2..."
sudo apt-get install -y apache2 -qq
fi
info " Enabling Apache modules..."
sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite -q
# Ensure current user can run docker without sudo
if ! groups | grep -q docker; then
sudo usermod -aG docker "$USER"
warn "Added $USER to docker group — you may need to re-login for this to take effect"
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 2: Pull latest code
# ─────────────────────────────────────────────────────────────────────────────
info "Step 2: Pulling latest code..."
if [[ "$SKIP_PULL" == true ]]; then
warn " Skipping git pull (--skip-pull)"
else
# Switch to SSH remote if still on HTTPS (avoids credential prompts)
current_remote=$(git remote get-url origin 2>/dev/null || true)
if [[ "$current_remote" == https://* ]]; then
ssh_remote=$(echo "$current_remote" \
| sed 's|https://bitbucket.org/|git@bitbucket.org:|' \
| sed 's|\.git$||')
git remote set-url origin "${ssh_remote}.git"
info " Switched remote to SSH: $(git remote get-url origin)"
fi
git fetch origin
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "")
if [[ "$LOCAL" == "$REMOTE" ]]; then
info " Already up to date ($(git rev-parse --short HEAD))"
else
git pull --ff-only || { error "git pull failed — resolve conflicts manually then re-run"; exit 1; }
info " Updated to $(git rev-parse --short HEAD)$(git log -1 --pretty=%s)"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 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
# Warn on unset critical vars
for var in AUTH_SECRET AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_REDIRECT_URI DB_PASSWORD; do
val=$(grep -E "^${var}=" .env | cut -d= -f2- | tr -d '"' || true)
[[ -z "$val" ]] && warn " WARNING: $var is not set in .env"
done
info " .env OK"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 4: Build and start Docker containers
# ─────────────────────────────────────────────────────────────────────────────
info "Step 4: Building and starting containers..."
# Stop our own containers first — that releases any ports they held so the
# probe below doesn't consider them "in use".
docker compose -p "$COMPOSE_PROJECT" down --remove-orphans
# Probe host ports. App and DB are both fully containerized — the host ports
# are only used by (a) Apache's reverse proxy pointing at the app, and (b)
# psql-from-host debugging for the DB. If the preferred port is busy (e.g.
# hp-prod-tracker on 3001, some other project squatting 5492), we pick the
# next free one automatically rather than failing the deploy.
if [[ -z "${APP_HOST_PORT:-}" ]]; then
APP_HOST_PORT=$(find_free_port "$PREFERRED_APP_PORT")
[[ -z "$APP_HOST_PORT" ]] && { error " Could not find a free app port"; exit 1; }
fi
if [[ -z "${DB_HOST_PORT:-}" ]]; then
DB_HOST_PORT=$(find_free_port "$PREFERRED_DB_PORT")
[[ -z "$DB_HOST_PORT" ]] && { error " Could not find a free db port"; exit 1; }
fi
export APP_HOST_PORT DB_HOST_PORT
if [[ "$APP_HOST_PORT" != "$PREFERRED_APP_PORT" ]]; then
warn " Preferred app port $PREFERRED_APP_PORT is busy — using $APP_HOST_PORT"
else
info " App host port: $APP_HOST_PORT"
fi
if [[ "$DB_HOST_PORT" != "$PREFERRED_DB_PORT" ]]; then
warn " Preferred db port $PREFERRED_DB_PORT is busy — using $DB_HOST_PORT"
else
info " DB host port: $DB_HOST_PORT"
fi
docker compose -p "$COMPOSE_PROJECT" up -d --build
# ─────────────────────────────────────────────────────────────────────────────
# STEP 5: Wait for Postgres
# ─────────────────────────────────────────────────────────────────────────────
info "Step 5: Waiting for Postgres..."
MAX_WAIT=30
for i in $(seq 1 $MAX_WAIT); do
if docker compose -p "$COMPOSE_PROJECT" exec -T db pg_isready -U postgres &>/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"
docker compose -p "$COMPOSE_PROJECT" logs db --tail 20
exit 1
fi
sleep 1
done
# ─────────────────────────────────────────────────────────────────────────────
# STEP 6: Wait for app health check
# ─────────────────────────────────────────────────────────────────────────────
info "Step 6: Waiting for app to be healthy (Prisma migrations run on startup)..."
HEALTH_URL="http://localhost:${APP_HOST_PORT}/dow-prod-tracker/api/health"
for i in $(seq 1 40); do
if curl -sf "$HEALTH_URL" &>/dev/null; then
info " App healthy (${i}s)"
break
fi
if [[ $i -eq 40 ]]; then
error " App did not become healthy within 120s"
error " Check logs: docker compose -p $COMPOSE_PROJECT logs app --tail 50"
docker compose -p "$COMPOSE_PROJECT" logs app --tail 30
exit 1
fi
echo -n "."
sleep 3
done
echo ""
# ─────────────────────────────────────────────────────────────────────────────
# STEP 7: Apache — add Include if not already present
# ─────────────────────────────────────────────────────────────────────────────
info "Step 7: Configuring Apache..."
APACHE_CONF="/etc/apache2/sites-available/optical-dev.oliver.solutions.conf"
APACHE_TMPL="$SCRIPT_DIR/apache/dow-prod-tracker.conf.tmpl"
APACHE_SNIPPET="$SCRIPT_DIR/apache/dow-prod-tracker.conf"
INCLUDE_LINE=" Include $APACHE_SNIPPET"
# Render the template with the chosen APP_HOST_PORT — the committed .tmpl
# has ${APP_HOST_PORT} placeholders that we expand here. Always re-render
# so a changed port on a subsequent deploy is reflected without manual edits.
sed "s|\\\${APP_HOST_PORT}|${APP_HOST_PORT}|g" "$APACHE_TMPL" > "$APACHE_SNIPPET"
info " Rendered $APACHE_SNIPPET (APP_HOST_PORT=$APP_HOST_PORT)"
if [[ ! -f "$APACHE_CONF" ]]; then
warn " $APACHE_CONF not found — skipping Include insertion (add manually: $INCLUDE_LINE)"
elif grep -qF "$APACHE_SNIPPET" "$APACHE_CONF"; then
info " Include already present — reloading Apache to pick up the re-rendered snippet"
sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \
|| { error "Apache config test failed — check $APACHE_SNIPPET"; sudo apache2ctl configtest; exit 1; }
sudo systemctl reload apache2
info " Apache reloaded"
else
# Remove the old manually-added inline block before inserting the canonical Include
sudo sed -i '/# .*HP-PROD-TRACKER\|HP-PROD-TRACKER.*3001/d' "$APACHE_CONF"
sudo sed -i '/ProxyPass[[:space:]].*dow-prod-tracker/d' "$APACHE_CONF"
sudo sed -i '/ProxyPassReverse[[:space:]].*dow-prod-tracker/d' "$APACHE_CONF"
# Insert Include before </VirtualHost>
sudo sed -i "s|</VirtualHost>|$INCLUDE_LINE\n</VirtualHost>|" "$APACHE_CONF"
info " Added: $INCLUDE_LINE"
sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \
|| { error "Apache config test failed — check $APACHE_CONF"; sudo apache2ctl configtest; exit 1; }
sudo systemctl reload apache2
info " Apache reloaded OK"
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 8: UFW Firewall
# ─────────────────────────────────────────────────────────────────────────────
info "Step 8: Configuring UFW firewall..."
sudo ufw default deny incoming 2>/dev/null || true
sudo ufw default allow outgoing 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)"
# ─────────────────────────────────────────────────────────────────────────────
# Done
# ─────────────────────────────────────────────────────────────────────────────
echo ""
docker compose -p "$COMPOSE_PROJECT" ps
echo ""
info "Deploy complete!"
info " Commit : $(git rev-parse --short HEAD)$(git log -1 --pretty=%s)"
info " App : https://optical-dev.oliver.solutions/dow-prod-tracker"
info " Ports : app=$APP_HOST_PORT db=$DB_HOST_PORT (internal container ports unchanged)"
info " Logs : docker compose -p $COMPOSE_PROJECT logs -f app"