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