programme-pulse-chat/deploy/deploy.sh
DJP 5c6d8bff56 deploy: don't require AZURE_* when DEV_AUTH_BYPASS=true
The SPA skips msalInstance.initialize() entirely in bypass mode, so the
Azure values aren't read by the build. Failing the deploy on missing
Azure config when bypass is on is a false positive — block on it only
when MSAL is actually going to run.

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

283 lines
9.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# Programme Pulse — deploy script.
#
# Idempotent. Safe to re-run on the dev server.
# Public URL: https://optical-dev.oliver.solutions/programme-pulse/
#
# Server layout (mirrors /oliver-sales-ops-platform/, /gsb/, etc.):
# /opt/programme-pulse/ — repo + docker-compose
# /var/www/html/programme-pulse/ — built SPA, served by Apache
#
# What it does:
# 1. Sanity (.env, docker, git on PATH).
# 2. Auto-pick free host port (preferred: PROGRAMME_PULSE_PORT, default 5051).
# If taken, scans 5051..5099 and persists the chosen value to .env.
# 3. Render deploy/apache-programme-pulse.conf from .tmpl.
# 4. git pull (--no-pull to skip).
# 5. docker compose build && up -d (--no-build to skip).
# 6. Build frontend SPA in a one-shot node:20 container and rsync
# dist/ to /var/www/html/programme-pulse/ (--no-frontend to skip).
# 7. Poll /api/health until ready.
# 8. Print the apache Include line and 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 --no-frontend # skip SPA build/copy
# ./deploy/deploy.sh --logs # tail backend logs after deploy
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_PROJECT="programme-pulse"
URL_PATH="/programme-pulse"
WEB_ROOT="/var/www/html/programme-pulse"
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_FRONTEND=1
TAIL_LOGS=0
for arg in "$@"; do
case "$arg" in
--no-pull) DO_PULL=0 ;;
--no-build) DO_BUILD=0 ;;
--no-frontend) DO_FRONTEND=0 ;;
--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; }
if [[ ! -f .env ]]; then
err ".env not found. Copy .env.example and fill it in:"
err " cp .env.example .env && \$EDITOR .env"
exit 1
fi
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; }
# ---------- 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
}
# ---------- 2. Pick port ----------
DEFAULT_PORT=5051
APP_PORT=$(get_env_var PROGRAMME_PULSE_PORT); APP_PORT=${APP_PORT:-$DEFAULT_PORT}
PREV_APP_PORT="$APP_PORT"
log "Resolving host port (preferred: $APP_PORT)…"
RUNNING=$(docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | wc -l | tr -d ' ')
if [[ "$RUNNING" -gt 0 ]]; then
ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — keeping current port assignment."
else
NEW_PORT=$(find_free_port "$APP_PORT" 5051 5099) || NEW_PORT=""
if [[ -z "$NEW_PORT" ]]; then
err "Could not find a free port in 5051..5099."
exit 1
fi
[[ "$NEW_PORT" != "$APP_PORT" ]] && warn "port $APP_PORT busy → using $NEW_PORT"
APP_PORT=$NEW_PORT
set_env_var PROGRAMME_PULSE_PORT "$APP_PORT"
ok "Port: $APP_PORT (persisted to .env)"
fi
# ---------- 3. Render apache conf ----------
APACHE_TMPL="$REPO_ROOT/deploy/apache-programme-pulse.conf.tmpl"
APACHE_CONF="$REPO_ROOT/deploy/apache-programme-pulse.conf"
if [[ -f "$APACHE_TMPL" ]]; then
sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
ok "Rendered apache-programme-pulse.conf with app port $APP_PORT"
else
warn "apache-programme-pulse.conf.tmpl missing — leaving deploy/apache-programme-pulse.conf untouched."
fi
# ---------- 4. git pull ----------
if (( DO_PULL )); then
log "git pull origin main"
git pull --ff-only origin main
fi
# ---------- 5. Backend build + up ----------
if (( DO_BUILD )); then
log "docker compose build"
docker compose -p "$COMPOSE_PROJECT" build
fi
log "docker compose up -d (db + app)"
docker compose -p "$COMPOSE_PROJECT" up -d
# ---------- 6. Frontend build + sync ----------
if (( DO_FRONTEND )); then
# Mirror DEV_AUTH_BYPASS into VITE_DEV_AUTH_BYPASS so a single env var
# decides both backend token validation and frontend MSAL gating.
VITE_BYPASS=$(get_env_var VITE_DEV_AUTH_BYPASS)
[[ -z "$VITE_BYPASS" ]] && VITE_BYPASS=$(get_env_var DEV_AUTH_BYPASS)
[[ -z "$VITE_BYPASS" ]] && VITE_BYPASS="false"
AZ_TENANT=$(get_env_var VITE_AZURE_TENANT_ID); AZ_TENANT=${AZ_TENANT:-$(get_env_var AZURE_TENANT_ID)}
AZ_CLIENT=$(get_env_var VITE_AZURE_CLIENT_ID); AZ_CLIENT=${AZ_CLIENT:-$(get_env_var AZURE_CLIENT_ID)}
# Azure values are only needed when the SPA actually performs MSAL sign-in.
# In bypass mode the SPA skips msalInstance.initialize() entirely, so empty
# strings are fine.
if [[ "$VITE_BYPASS" != "true" ]] && [[ -z "$AZ_TENANT" || -z "$AZ_CLIENT" ]]; then
err "AZURE_TENANT_ID and AZURE_CLIENT_ID must be set in .env to build the SPA."
err " (Or set DEV_AUTH_BYPASS=true to skip MSAL entirely for now.)"
exit 1
fi
log "Building Vite SPA (VITE_DEV_AUTH_BYPASS=${VITE_BYPASS}) in a one-shot node:20 container…"
docker run --rm \
-v "$REPO_ROOT/frontend:/app" \
-w /app \
-e VITE_AZURE_TENANT_ID="$AZ_TENANT" \
-e VITE_AZURE_CLIENT_ID="$AZ_CLIENT" \
-e VITE_API_BASE="/programme-pulse/api" \
-e VITE_DEV_AUTH_BYPASS="$VITE_BYPASS" \
node:20-alpine \
sh -c "npm install --silent && npm run build"
if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then
err "Vite build did not produce frontend/dist — aborting frontend sync."
exit 1
fi
log "Syncing frontend/dist/ → $WEB_ROOT/"
if [[ ! -d "$WEB_ROOT" ]]; then
if command -v sudo >/dev/null 2>&1; then
sudo mkdir -p "$WEB_ROOT"
else
mkdir -p "$WEB_ROOT"
fi
fi
if command -v rsync >/dev/null 2>&1; then
if [[ -w "$WEB_ROOT" ]]; then
rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
else
sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/"
fi
else
if [[ -w "$WEB_ROOT" ]]; then
rm -rf "$WEB_ROOT"/*
cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
else
sudo rm -rf "$WEB_ROOT"/*
sudo cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/"
fi
fi
ok "SPA synced to $WEB_ROOT"
fi
# ---------- 7. Health poll ----------
log "Waiting for /api/health on :$APP_PORT (max 60s)…"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
ok "Backend healthy"
break
fi
sleep 2
if (( i == 30 )); then
err "Backend did not become healthy within 60s. Recent logs:"
docker compose -p "$COMPOSE_PROJECT" logs app --tail 40 || true
exit 1
fi
done
# ---------- 8. Report ----------
ok "Deploy complete."
echo
echo " Backend (local): http://127.0.0.1:${APP_PORT}/api/health"
echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/"
echo " SPA on disk: $WEB_ROOT"
echo " Port: $APP_PORT"
echo
echo " Apache include line for the merged vhost:"
echo " Include $REPO_ROOT/deploy/apache-programme-pulse.conf"
if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-programme-pulse.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then
echo
warn "Backend port changed (or first deploy). Add the Include line above to:"
echo " /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
echo " inside </VirtualHost>, then reload Apache:"
echo " sudo apachectl configtest && sudo systemctl reload apache2"
fi
TRANSCRIPTS_DIR="$REPO_ROOT/docs/Programme Pulse transcripts"
if [[ ! -d "$TRANSCRIPTS_DIR" ]] || [[ -z "$(ls -A "$TRANSCRIPTS_DIR" 2>/dev/null)" ]]; then
echo
warn "Transcripts folder is empty: $TRANSCRIPTS_DIR"
echo " Drop Teams .docx exports there to give the chat narrative context."
fi
echo
if (( TAIL_LOGS )); then
log "Tailing app logs (Ctrl-C to stop)…"
docker compose -p "$COMPOSE_PROJECT" logs -f app
fi