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>
285 lines
9.5 KiB
Bash
Executable file
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
|