#!/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