#!/usr/bin/env bash # deploy.sh — idempotent deploy script for loreal-prod-tracker # Idempotent — safe to run multiple times (initial deploy or update) # Run as normal user; uses sudo internally for apt/apache/ufw set -euo pipefail # ── Colours ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m' info() { echo -e "${GREEN}[deploy]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } error() { echo -e "${RED}[error]${NC} $*" >&2; } # ── Args ───────────────────────────────────────────────────────────────────── SKIP_PULL=false for arg in "$@"; do [[ "$arg" == "--skip-pull" ]] && SKIP_PULL=true; done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # Preferred host ports. If either is in use on the host, we auto-pick the # next free one (see find_free_port below) and update the Apache conf + # docker-compose env vars to match. Callers can override explicitly by # exporting APP_HOST_PORT / DB_HOST_PORT before running this script. PREFERRED_APP_PORT=3002 PREFERRED_DB_PORT=5492 # Compose project name — belt-and-braces with the `name:` field in # docker-compose.yml. CLAUDE.md rule: the shared optical-dev server runs # multiple apps from deploy dirs and Compose defaults the project name to # the parent directory, so without this they collide on containers # (loreal-prod-tracker-db-1 vs hp-prod-tracker-db-1) and volumes. COMPOSE_PROJECT=loreal-prod-tracker # ── Port helpers ──────────────────────────────────────────────────────────── # is_port_free uses bash's /dev/tcp — no external tool needed, works on # Linux and macOS. If opening a TCP connection to localhost:PORT succeeds, # something is listening (port busy). If it fails, port is free. is_port_free() { local p=$1 (: /dev/null 2>&1 && return 1 || return 0 } # Find the lowest free port >= start, scanning up to 50 candidates. # Our own stopped containers don't count as "in use" — we `docker compose # down` before this runs, so a previous deploy's port is freed. find_free_port() { local start=$1 local p for ((p=start; p/dev/null; then warn "This script needs sudo for apt/apache/ufw — you may be prompted for your password." fi # ───────────────────────────────────────────────────────────────────────────── # STEP 1: Prerequisites # ───────────────────────────────────────────────────────────────────────────── info "Step 1: Checking prerequisites..." install_if_missing() { local cmd=$1 pkg=${2:-$1} if ! command -v "$cmd" &>/dev/null; then info " Installing $pkg..." sudo apt-get install -y "$pkg" -qq else info " $cmd: OK" fi } sudo apt-get update -qq install_if_missing docker docker.io install_if_missing git git install_if_missing curl curl install_if_missing ufw ufw if ! docker compose version &>/dev/null 2>&1; then info " Installing docker-compose-plugin..." sudo apt-get install -y docker-compose-plugin -qq fi if ! command -v apache2 &>/dev/null; then info " Installing apache2..." sudo apt-get install -y apache2 -qq fi info " Enabling Apache modules..." sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite -q # Ensure current user can run docker without sudo if ! groups | grep -q docker; then sudo usermod -aG docker "$USER" warn "Added $USER to docker group — you may need to re-login for this to take effect" fi # ───────────────────────────────────────────────────────────────────────────── # STEP 2: Pull latest code # ───────────────────────────────────────────────────────────────────────────── info "Step 2: Pulling latest code..." if [[ "$SKIP_PULL" == true ]]; then warn " Skipping git pull (--skip-pull)" else # Switch to SSH remote if still on HTTPS (avoids credential prompts) current_remote=$(git remote get-url origin 2>/dev/null || true) if [[ "$current_remote" == https://* ]]; then ssh_remote=$(echo "$current_remote" \ | sed 's|https://bitbucket.org/|git@bitbucket.org:|' \ | sed 's|\.git$||') git remote set-url origin "${ssh_remote}.git" info " Switched remote to SSH: $(git remote get-url origin)" fi git fetch origin LOCAL=$(git rev-parse HEAD) REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "") if [[ "$LOCAL" == "$REMOTE" ]]; then info " Already up to date ($(git rev-parse --short HEAD))" else git pull --ff-only || { error "git pull failed — resolve conflicts manually then re-run"; exit 1; } info " Updated to $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)" fi fi # ───────────────────────────────────────────────────────────────────────────── # STEP 3: Environment file # ───────────────────────────────────────────────────────────────────────────── info "Step 3: Checking .env..." if [[ ! -f .env ]]; then if [[ -f .env.example ]]; then cp .env.example .env warn " .env was missing — copied from .env.example" warn " Edit .env with real secrets before continuing (Ctrl-C to abort)" read -rp " Press Enter to continue or Ctrl-C to abort..." else error " .env and .env.example both missing — cannot continue" exit 1 fi fi # Warn on unset critical vars for var in AUTH_SECRET AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_REDIRECT_URI DB_PASSWORD; do val=$(grep -E "^${var}=" .env | cut -d= -f2- | tr -d '"' || true) [[ -z "$val" ]] && warn " WARNING: $var is not set in .env" done info " .env OK" # Box JWT app config — docker-compose declares ./secrets/box-config.json as # a docker secret. Compose refuses to start when the file is missing, so # we stub an empty {} if it doesn't exist. Box features stay disabled # (isBoxConfigured() returns false) until the user drops the real config # from the Box developer console at this path. mkdir -p "$SCRIPT_DIR/secrets" if [[ ! -f "$SCRIPT_DIR/secrets/box-config.json" ]]; then echo '{}' > "$SCRIPT_DIR/secrets/box-config.json" info " Created empty secrets/box-config.json — Box features disabled until configured" fi # ───────────────────────────────────────────────────────────────────────────── # STEP 4: Build and start Docker containers # ───────────────────────────────────────────────────────────────────────────── info "Step 4: Building and starting containers..." # Stop our own containers first — that releases any ports they held so the # probe below doesn't consider them "in use". docker compose -p "$COMPOSE_PROJECT" down --remove-orphans # Probe host ports. App and DB are both fully containerized — the host ports # are only used by (a) Apache's reverse proxy pointing at the app, and (b) # psql-from-host debugging for the DB. If the preferred port is busy (e.g. # hp-prod-tracker on 3001, some other project squatting 5492), we pick the # next free one automatically rather than failing the deploy. if [[ -z "${APP_HOST_PORT:-}" ]]; then APP_HOST_PORT=$(find_free_port "$PREFERRED_APP_PORT") [[ -z "$APP_HOST_PORT" ]] && { error " Could not find a free app port"; exit 1; } fi if [[ -z "${DB_HOST_PORT:-}" ]]; then DB_HOST_PORT=$(find_free_port "$PREFERRED_DB_PORT") [[ -z "$DB_HOST_PORT" ]] && { error " Could not find a free db port"; exit 1; } fi export APP_HOST_PORT DB_HOST_PORT if [[ "$APP_HOST_PORT" != "$PREFERRED_APP_PORT" ]]; then warn " Preferred app port $PREFERRED_APP_PORT is busy — using $APP_HOST_PORT" else info " App host port: $APP_HOST_PORT" fi if [[ "$DB_HOST_PORT" != "$PREFERRED_DB_PORT" ]]; then warn " Preferred db port $PREFERRED_DB_PORT is busy — using $DB_HOST_PORT" else info " DB host port: $DB_HOST_PORT" fi # Persist the chosen ports to .env so subsequent `docker compose up` # calls (without going through deploy.sh) pick them up automatically. # Compose auto-loads .env for variable substitution in the working dir. # Without this, running `docker compose up -d` would fall back to the # defaults in docker-compose.yml and collide with whatever else is on # 5492 / 3002. Replaces any existing lines so a port change is reflected. for varline in "APP_HOST_PORT=${APP_HOST_PORT}" "DB_HOST_PORT=${DB_HOST_PORT}"; do varname="${varline%%=*}" if grep -q "^${varname}=" .env; then sed -i.bak "s|^${varname}=.*|${varline}|" .env && rm -f .env.bak else echo "${varline}" >> .env fi done info " Persisted APP_HOST_PORT=$APP_HOST_PORT DB_HOST_PORT=$DB_HOST_PORT to .env" docker compose -p "$COMPOSE_PROJECT" up -d --build # ───────────────────────────────────────────────────────────────────────────── # STEP 5: Wait for Postgres # ───────────────────────────────────────────────────────────────────────────── info "Step 5: Waiting for Postgres..." MAX_WAIT=30 for i in $(seq 1 $MAX_WAIT); do if docker compose -p "$COMPOSE_PROJECT" exec -T db pg_isready -U postgres &>/dev/null; then info " Postgres ready (${i}s)" break fi if [[ $i -eq $MAX_WAIT ]]; then error " Postgres did not become ready within ${MAX_WAIT}s" docker compose -p "$COMPOSE_PROJECT" logs db --tail 20 exit 1 fi sleep 1 done # ───────────────────────────────────────────────────────────────────────────── # STEP 6: Wait for app health check # ───────────────────────────────────────────────────────────────────────────── info "Step 6: Waiting for app to be healthy (Prisma migrations run on startup)..." HEALTH_URL="http://localhost:${APP_HOST_PORT}/loreal-prod-tracker/api/health" for i in $(seq 1 40); do if curl -sf "$HEALTH_URL" &>/dev/null; then info " App healthy (${i}s)" break fi if [[ $i -eq 40 ]]; then error " App did not become healthy within 120s" error " Check logs: docker compose -p $COMPOSE_PROJECT logs app --tail 50" docker compose -p "$COMPOSE_PROJECT" logs app --tail 30 exit 1 fi echo -n "." sleep 3 done echo "" # ───────────────────────────────────────────────────────────────────────────── # STEP 7: Seed database (only if empty — first deploy or clean-slate) # ───────────────────────────────────────────────────────────────────────────── info "Step 7: Checking if database needs seeding..." # Count orgs via psql. A fresh DB returns 0; a seeded DB returns ≥ 1. # `-tA` strips headers/padding so we get a clean integer. 2>/dev/null # swallows the "relation does not exist" error that shows up if migrations # haven't actually run (shouldn't happen — Step 6 waits for healthy, which # happens after migrate deploy — but belt-and-braces). ORG_COUNT=$(docker compose -p "$COMPOSE_PROJECT" exec -T db \ psql -U postgres -d loreal_prod_tracker -tA \ -c "SELECT COUNT(*) FROM organizations;" 2>/dev/null \ | tr -d '[:space:]' || echo "0") ORG_COUNT="${ORG_COUNT:-0}" if [[ "$ORG_COUNT" == "0" ]]; then info " Empty DB detected — running one-time seed (admin + pipeline + teams)..." docker compose -p "$COMPOSE_PROJECT" exec -T app npm run db:seed info " Seed complete — check the output above for the admin email + temp password" else info " DB already seeded ($ORG_COUNT org(s)) — skipping" fi # ───────────────────────────────────────────────────────────────────────────── # STEP 8: Apache — add Include if not already present # ───────────────────────────────────────────────────────────────────────────── info "Step 8: Configuring Apache..." # Apache vhost resolution: standard Ubuntu uses a2ensite to symlink # sites-enabled → sites-available. But optical-dev has a separately-managed # sites-enabled file (not a symlink), so editing sites-available silently # does nothing — Apache reads sites-enabled. Detect and pick the right file. APACHE_SITES_ENABLED="/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf" APACHE_SITES_AVAILABLE="/etc/apache2/sites-available/optical-dev.oliver.solutions.conf" if [[ -L "$APACHE_SITES_ENABLED" ]]; then # Proper symlink setup — edits to sites-available propagate. APACHE_CONF="$APACHE_SITES_AVAILABLE" elif [[ -f "$APACHE_SITES_ENABLED" ]]; then # Separately-managed file — edit sites-enabled directly. APACHE_CONF="$APACHE_SITES_ENABLED" warn " sites-enabled file is NOT a symlink to sites-available — editing $APACHE_CONF directly" else APACHE_CONF="$APACHE_SITES_AVAILABLE" fi APACHE_TMPL="$SCRIPT_DIR/apache/loreal-prod-tracker.conf.tmpl" APACHE_SNIPPET="$SCRIPT_DIR/apache/loreal-prod-tracker.conf" INCLUDE_LINE=" Include $APACHE_SNIPPET" # Render the template with the chosen APP_HOST_PORT — the committed .tmpl # has ${APP_HOST_PORT} placeholders that we expand here. Always re-render # so a changed port on a subsequent deploy is reflected without manual edits. sed "s|\\\${APP_HOST_PORT}|${APP_HOST_PORT}|g" "$APACHE_TMPL" > "$APACHE_SNIPPET" info " Rendered $APACHE_SNIPPET (APP_HOST_PORT=$APP_HOST_PORT)" if [[ ! -f "$APACHE_CONF" ]]; then warn " $APACHE_CONF not found — skipping Include insertion (add manually: $INCLUDE_LINE)" elif grep -qF "$APACHE_SNIPPET" "$APACHE_CONF"; then info " Include already present — reloading Apache to pick up the re-rendered snippet" sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \ || { error "Apache config test failed — check $APACHE_SNIPPET"; sudo apache2ctl configtest; exit 1; } sudo systemctl reload apache2 info " Apache reloaded" else # Remove the old manually-added inline block before inserting the canonical Include sudo sed -i '/# .*HP-PROD-TRACKER\|HP-PROD-TRACKER.*3001/d' "$APACHE_CONF" sudo sed -i '/ProxyPass[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF" sudo sed -i '/ProxyPassReverse[[:space:]].*loreal-prod-tracker/d' "$APACHE_CONF" # Insert Include before sudo sed -i "s||$INCLUDE_LINE\n|" "$APACHE_CONF" info " Added: $INCLUDE_LINE" sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK" \ || { error "Apache config test failed — check $APACHE_CONF"; sudo apache2ctl configtest; exit 1; } sudo systemctl reload apache2 info " Apache reloaded OK" fi # ───────────────────────────────────────────────────────────────────────────── # STEP 9: UFW Firewall # ───────────────────────────────────────────────────────────────────────────── info "Step 9: Configuring UFW firewall..." sudo ufw default deny incoming 2>/dev/null || true sudo ufw default allow outgoing 2>/dev/null || true sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw --force enable info " UFW enabled (22/tcp, 80/tcp allowed)" # ───────────────────────────────────────────────────────────────────────────── # Done # ───────────────────────────────────────────────────────────────────────────── echo "" docker compose -p "$COMPOSE_PROJECT" ps echo "" info "Deploy complete!" info " Commit : $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)" info " App : https://optical-dev.oliver.solutions/loreal-prod-tracker" info " Ports : app=$APP_HOST_PORT db=$DB_HOST_PORT (internal container ports unchanged)" info " Logs : docker compose -p $COMPOSE_PROJECT logs -f app"