adeo-maturity-tool/deploy/deploy.sh
DJP 88e4df59be Validate persisted ports against live host on every deploy
Previously the script only ran port resolution when no compose
containers were already running, on the assumption that running
containers meant the persisted port was still ours. On the shared
optical-dev server that's wrong: another app can grab a port
between our deploys, leaving us with a stale .env value that fails
to bind.

Now find_free_port treats a port as "available for us" if either
nothing is listening, or one of our own compose containers is
publishing it (so re-deploys don't shuffle). Other-app listeners
trigger a re-pick and a warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:22:06 -04:00

285 lines
9.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# adeo-maturity-tool — deploy script.
#
# Idempotent. Safe to re-run on the dev server.
# Public URL: https://optical-dev.oliver.solutions/adeo-maturity/
#
# Server layout (mirrors /opt/oliver-sales-ops-platform/, etc.):
# /opt/adeo-maturity-tool/ — repo + docker-compose
#
# What it does:
# 1. Sanity (docker, git on PATH).
# 2. Generate secrets if missing (DB_PASSWORD, AUTH_SECRET).
# 3. Auto-pick free host ports for db (5435-5499) and app (3102-3199);
# persist to .env so subsequent deploys keep them.
# 4. Render deploy/apache-adeo.conf from .tmpl with the chosen app port.
# 5. git pull (--no-pull to skip).
# 6. docker compose build && up -d (--no-build to skip).
# 7. Poll /api/health until ready.
# 8. First deploy only: seed admin@oliver.agency + user@oliver.agency
# with random passwords and PRINT them once.
# 9. Print URLs + apache-reload reminder.
#
# Usage:
# ./deploy/deploy.sh # full deploy
# ./deploy/deploy.sh --no-pull # skip git pull
# ./deploy/deploy.sh --no-build # skip docker rebuild
# ./deploy/deploy.sh --reseed # force-reset both seed-user passwords
# ./deploy/deploy.sh --logs # tail app logs after deploy
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_PROJECT="adeo-maturity-tool"
URL_PATH="/adeo-maturity"
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' "$*"; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; }
DO_PULL=1
DO_BUILD=1
DO_RESEED=0
TAIL_LOGS=0
for arg in "$@"; do
case "$arg" in
--no-pull) DO_PULL=0 ;;
--no-build) DO_BUILD=0 ;;
--reseed) DO_RESEED=1 ;;
--logs) TAIL_LOGS=1 ;;
--help|-h)
sed -n '2,/^set/p' "$0" | grep -E '^# ' | sed 's/^# //'
exit 0
;;
*)
err "Unknown flag: $arg (try --help)"
exit 2
;;
esac
done
# 1. Sanity
[[ -f docker-compose.yml ]] || { err "docker-compose.yml not found in $REPO_ROOT"; exit 1; }
command -v docker >/dev/null 2>&1 || { err "docker not on PATH"; exit 1; }
command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; }
docker compose version >/dev/null 2>&1 || { err "docker compose v2 not found"; exit 1; }
unset COMPOSE_PROFILES
# ---------- helpers ----------
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" ]]
}
# Echo the compose project name of the docker container publishing $port,
# or empty string if no docker container holds it (might be a host process
# or a non-published container).
port_owner_project() {
local port=$1
docker ps --format '{{.Label "com.docker.compose.project"}}|{{.Ports}}' 2>/dev/null \
| awk -F'|' -v p=":${port}->" '$2 ~ p {print $1; exit}'
}
# True (0) if the port is free for us to bind: either nothing's listening,
# or one of our own compose containers is publishing it (recreating the
# stack will hand it back to us). Other-process listeners → false.
port_available_for_us() {
local port=$1
if ! port_in_use "$port"; then return 0; fi
[[ "$(port_owner_project "$port")" == "$COMPOSE_PROJECT" ]]
}
find_free_port() {
local preferred=$1
local start=$2
local end=$3
if port_available_for_us "$preferred"; then
printf '%s' "$preferred"
return 0
fi
local p
for ((p=start; p<=end; p++)); do
if port_available_for_us "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
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
}
get_env_var() {
grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true
}
# Generate URL-safe random string of N characters of base64url entropy.
gen_secret() {
local bytes=${1:-32}
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 "$bytes" | tr '+/' '-_' | tr -d '=' | head -c "$bytes"
else
head -c $((bytes * 2)) /dev/urandom | base64 | tr '+/' '-_' | tr -d '=' | head -c "$bytes"
fi
}
# ---------- 2. Secrets (generate-once, persist to .env) ----------
DB_PASSWORD=$(get_env_var DB_PASSWORD)
if [[ -z "$DB_PASSWORD" ]]; then
DB_PASSWORD=$(gen_secret 24)
set_env_var DB_PASSWORD "$DB_PASSWORD"
ok "Generated DB_PASSWORD"
fi
AUTH_SECRET=$(get_env_var AUTH_SECRET)
if [[ -z "$AUTH_SECRET" ]]; then
AUTH_SECRET=$(gen_secret 48)
set_env_var AUTH_SECRET "$AUTH_SECRET"
ok "Generated AUTH_SECRET"
fi
# Cookie scoped to /adeo-maturity so it isn't sent to other apps on the
# shared optical-dev vhost. Secure=true relies on HTTPS termination upstream.
[[ -z "$(get_env_var COOKIE_PATH)" ]] && set_env_var COOKIE_PATH "$URL_PATH"
[[ -z "$(get_env_var COOKIE_SECURE)" ]] && set_env_var COOKIE_SECURE "true"
[[ -z "$(get_env_var DB_USER)" ]] && set_env_var DB_USER "adeo"
[[ -z "$(get_env_var DB_NAME)" ]] && set_env_var DB_NAME "adeo_maturity"
# ---------- 3. Pick ports ----------
DEFAULT_APP_PORT=3102
DEFAULT_DB_PORT=5435
APP_PORT=$(get_env_var ADEO_PORT); APP_PORT=${APP_PORT:-$DEFAULT_APP_PORT}
DB_PORT=$(get_env_var ADEO_DB_PORT); DB_PORT=${DB_PORT:-$DEFAULT_DB_PORT}
PREV_APP_PORT="$APP_PORT"
log "Resolving host ports (preferred: app=$APP_PORT db=$DB_PORT)…"
# Always validate against live host state. `find_free_port` returns the
# preferred port if it's free OR already held by one of our own compose
# containers (so re-deploys don't shuffle ports). If another app on the
# shared host has grabbed the port, we re-pick.
NEW_APP_PORT=$(find_free_port "$APP_PORT" 3102 3199) || NEW_APP_PORT=""
NEW_DB_PORT=$(find_free_port "$DB_PORT" 5435 5499) || NEW_DB_PORT=""
if [[ -z "$NEW_APP_PORT" || -z "$NEW_DB_PORT" ]]; then
err "Could not find free ports."
err " app desired=$APP_PORT scanned=3102-3199"
err " db desired=$DB_PORT scanned=5435-5499"
exit 1
fi
[[ "$NEW_APP_PORT" != "$APP_PORT" ]] && warn "app port $APP_PORT taken by another app → using $NEW_APP_PORT"
[[ "$NEW_DB_PORT" != "$DB_PORT" ]] && warn "db port $DB_PORT taken by another app → using $NEW_DB_PORT"
APP_PORT=$NEW_APP_PORT
DB_PORT=$NEW_DB_PORT
set_env_var ADEO_PORT "$APP_PORT"
set_env_var ADEO_DB_PORT "$DB_PORT"
ok "Ports: app=$APP_PORT db=$DB_PORT (persisted to .env)"
# ---------- 4. Render apache-adeo.conf from template ----------
APACHE_TMPL="$REPO_ROOT/deploy/apache-adeo.conf.tmpl"
APACHE_CONF="$REPO_ROOT/deploy/apache-adeo.conf"
if [[ -f "$APACHE_TMPL" ]]; then
sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
ok "Rendered apache-adeo.conf with app port $APP_PORT"
else
warn "apache-adeo.conf.tmpl missing — leaving deploy/apache-adeo.conf untouched."
fi
# ---------- 5. git pull ----------
if (( DO_PULL )); then
log "git pull origin main"
git pull --ff-only origin main
fi
# ---------- 6. Docker build + up ----------
if (( DO_BUILD )); then
log "docker compose build"
docker compose build
fi
log "docker compose up -d"
docker compose up -d
# ---------- 7. Health poll ----------
log "Waiting for app /api/health on :$APP_PORT (max 90s)…"
for i in $(seq 1 45); do
if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
ok "App healthy"
break
fi
sleep 2
if (( i == 45 )); then
err "App did not become healthy within 90s. Recent logs:"
docker compose logs app --tail 60 || true
exit 1
fi
done
# ---------- 8. First-deploy seeding ----------
FIRST_RUN_FLAG="${REPO_ROOT}/.deployed"
SEED_ARGS="seed-defaults"
if (( DO_RESEED )); then SEED_ARGS="seed-defaults --force"; fi
if [[ ! -f "$FIRST_RUN_FLAG" ]] || (( DO_RESEED )); then
log "Seeding default users in db (admin@oliver.agency + user@oliver.agency)…"
# Run the seed script inside the app container so it shares DATABASE_URL.
docker compose exec -T app node server/seed-users.js $SEED_ARGS
touch "$FIRST_RUN_FLAG"
else
log "Seed users already present — skipping (use --reseed to reset passwords)"
fi
# ---------- 9. Report ----------
ok "Deploy complete."
echo
echo " App (local): http://127.0.0.1:${APP_PORT}/api/health"
echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/"
echo " Ports: app=$APP_PORT db=$DB_PORT"
echo
echo " Apache include line for the merged vhost:"
echo " Include $REPO_ROOT/deploy/apache-adeo.conf"
if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-adeo.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then
echo
warn "App port changed (or first deploy). Reload Apache to pick up the new ProxyPass:"
echo " sudo apachectl configtest && sudo systemctl reload apache2"
fi
echo
warn "Reminder: clients/adeo/data.json is NOT in git."
echo " Upload it to ${REPO_ROOT}/clients/adeo/data.json if you haven't already."
echo
if (( TAIL_LOGS )); then
log "Tailing app logs (Ctrl-C to stop)…"
docker compose logs -f app
fi