adeo-maturity-tool/deploy/deploy.sh
DJP 8e969fe015 Add login + role-based access (admin/user) backed by Postgres
Adds a Postgres-backed user store with bcrypt + JWT cookie sessions,
login screen, role-gated UI, and Microsoft SSO scaffolding ready to
fill in.

Backend
- New `db` service (Postgres 16-alpine) in compose, healthcheck-gated
  app startup, free-port autodetect (5435-5499) like other apps.
- `server/db.js` runs versioned `.sql` migrations on boot.
- `server/auth.js`: bcrypt + JWT cookie (httpOnly, sameSite=strict,
  path-scoped to /adeo-maturity), rate-limited login (10/15min),
  dummy bcrypt-compare on missing users to defeat timing oracles.
- `requireAdmin` on all writes (POST/import/sync); `authenticate`
  on all reads. /api/health stays public.
- Microsoft SSO endpoints stubbed at /api/auth/msft/{login,callback}
  (return 501); DB has azure_oid column ready; comments document
  exactly how to wire @azure/msal-node.

Frontend
- Login screen with email/password + greyed-out "Sign in with
  Microsoft" button; init() checks /api/auth/me first.
- Logout button + user badge in header.
- body.role-user CSS hides .admin-only elements (Update tab, New
  Client cards). Server enforces regardless.

Deploy
- deploy.sh generates DB_PASSWORD and AUTH_SECRET on first run and
  persists to .env, then runs `seed-users.js seed-defaults` to
  create admin@oliver.agency + user@oliver.agency with random
  passwords printed once. Subsequent deploys skip seeding unless
  --reseed is passed.
- node server/seed-users.js set-password <email> <pw> for ad-hoc
  resets later.

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

268 lines
8.7 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" ]]
}
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
}
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)…"
RUNNING=$(docker compose ps -q 2>/dev/null | wc -l | tr -d ' ')
if [[ "$RUNNING" -gt 0 ]]; then
ok "Project '$COMPOSE_PROJECT' has $RUNNING container(s) running — keeping current port assignment."
else
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 busy → using $NEW_APP_PORT"
[[ "$NEW_DB_PORT" != "$DB_PORT" ]] && warn "db port $DB_PORT busy → 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)"
fi
# ---------- 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