#!/usr/bin/env bash # Deploy script for /opt/utilisation-dept/ on optical-dev.oliver.solutions. # Idempotent. Picks a free backend port from 8200-8299 (preferred 8200), # persists it to .env, renders the Apache include from the template, # builds the frontend into /var/www/html/utilisation-dept/, builds and # (re)starts the backend container, then health-polls. # # Flags: # --no-pull skip `git pull --ff-only` # --no-build skip `docker compose build` AND frontend build # --no-frontend skip only the frontend build # --logs tail backend container logs after deploy # # The Apache vhost Include line is NOT touched by this script. The script # prints it at the end so you can paste it into the vhost manually the # first time the app is deployed. See: # /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" COMPOSE_PROJECT="utilisation-dept" SLUG="utilisation-dept" URL_PATH="/${SLUG}" FRONTEND_OUT="/var/www/html/${SLUG}" PORT_PREFERRED=8200 PORT_RANGE_LOW=8200 PORT_RANGE_HIGH=8299 APACHE_TMPL="${SCRIPT_DIR}/apache-${SLUG}.conf.tmpl" APACHE_CONF="${SCRIPT_DIR}/apache-${SLUG}.conf" ENV_FILE="${REPO_ROOT}/.env" NO_PULL=false NO_BUILD=false NO_FRONTEND=false TAIL_LOGS=false for arg in "$@"; do case "$arg" in --no-pull) NO_PULL=true ;; --no-build) NO_BUILD=true; NO_FRONTEND=true ;; --no-frontend) NO_FRONTEND=true ;; --logs) TAIL_LOGS=true ;; -h|--help) sed -n '2,18p' "$0"; exit 0 ;; *) echo "Unknown flag: $arg" >&2; exit 64 ;; esac done # ─── helpers ────────────────────────────────────────────────────────────── c_red=$'\033[0;31m'; c_yel=$'\033[0;33m'; c_grn=$'\033[0;32m'; c_blu=$'\033[0;34m'; c_off=$'\033[0m' log() { printf '%s[deploy]%s %s\n' "$c_blu" "$c_off" "$*"; } ok() { printf '%s[deploy]%s %s\n' "$c_grn" "$c_off" "$*"; } warn() { printf '%s[deploy]%s %s\n' "$c_yel" "$c_off" "$*"; } err() { printf '%s[deploy]%s %s\n' "$c_red" "$c_off" "$*" >&2; } port_in_use() { local p="$1" # ss is on the deploy server; fall back to lsof on dev macOS. if command -v ss >/dev/null 2>&1; then ss -ltn "sport = :$p" 2>/dev/null | grep -q ":$p " elif command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"$p" -sTCP:LISTEN 2>/dev/null | grep -q LISTEN else # Last-resort: try to bind (echo > "/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0 || return 1 fi } find_free_port() { local p="$PORT_PREFERRED" if ! port_in_use "$p"; then echo "$p"; return; fi for p in $(seq "$PORT_RANGE_LOW" "$PORT_RANGE_HIGH"); do if ! port_in_use "$p"; then echo "$p"; return; fi done err "No free port in ${PORT_RANGE_LOW}-${PORT_RANGE_HIGH}"; exit 1 } get_env_var() { [[ -f "$ENV_FILE" ]] || { echo ""; return; } grep -E "^${1}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true } set_env_var() { local key="$1" val="$2" touch "$ENV_FILE" if grep -qE "^${key}=" "$ENV_FILE"; then # macOS sed and GNU sed differ on -i; use a tmpfile to be portable. local tmp; tmp="$(mktemp)" awk -v k="$key" -v v="$val" 'BEGIN{FS=OFS="="} $1==k{print k"="v; next}{print}' "$ENV_FILE" > "$tmp" mv "$tmp" "$ENV_FILE" else printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" fi } require() { command -v "$1" >/dev/null 2>&1 || { err "Required: $1 not on PATH"; exit 2; } } hash_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } # ─── git pull (first, so the rest of the script is the latest version) ──── # Every deploy looks for upstream changes by default. `--no-pull` opts out. # If `deploy.sh` itself was updated by the pull, we re-exec the new copy # (DEPLOY_RECURSE_GUARD prevents an infinite loop if anything goes weird). cd "$REPO_ROOT" require git if ! $NO_PULL && [[ -z "${DEPLOY_RECURSE_GUARD:-}" ]]; then if git rev-parse --git-dir >/dev/null 2>&1 && git remote >/dev/null 2>&1 \ && git ls-remote --exit-code origin main >/dev/null 2>&1; then before=$(hash_file "$0") log "git pull --ff-only origin main" git pull --ff-only origin main after=$(hash_file "$0") if [[ "$before" != "$after" ]]; then ok "deploy.sh updated — re-execing latest version" DEPLOY_RECURSE_GUARD=1 exec "$0" "$@" fi else warn "Skipping pull: not a git repo, no remote, or origin/main unreachable." fi fi # ─── sanity ─────────────────────────────────────────────────────────────── require docker docker compose version >/dev/null 2>&1 || { err "docker compose v2 required"; exit 2; } [[ -f docker-compose.yml ]] || { err "docker-compose.yml missing"; exit 2; } [[ -f "$APACHE_TMPL" ]] || { err "$APACHE_TMPL missing"; exit 2; } # ─── server-side collision check ────────────────────────────────────────── # Only runs on optical-dev where the shared vhost lives. On dev laptops the # file won't exist, so this block silently skips. VHOST=/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf if [[ -r "$VHOST" ]]; then # 1. Slug collision: is another app already claiming /utilisation-dept/? # Look at the live vhost AND every sibling app's apache-*.conf, # filtering out our OWN conf (matched on REPO_ROOT, not SLUG, since # the on-disk path may not match the URL slug — e.g. cloned to # /opt/loreal-utilisation-dept/ with slug "utilisation-dept"). sibling_confs=(/opt/*/deploy/apache-*.conf) collision=$(grep -lE "(ProxyPass|Alias)[[:space:]]+${URL_PATH}/" \ "$VHOST" "${sibling_confs[@]}" 2>/dev/null \ | grep -vF "${REPO_ROOT}/" || true) if [[ -n "$collision" ]]; then err "Another app claims ${URL_PATH}/ in:" echo "$collision" | sed 's/^/ /' >&2 err "Pick a different slug, or remove the conflicting Include line." exit 6 fi # 2. Port range usage report: show what's already taken in 8200-8299 # so the operator knows where we sit. taken_ports=$(grep -hoE 'http://127\.0\.0\.1:82[0-9][0-9]' \ "${sibling_confs[@]}" 2>/dev/null \ | grep -oE '82[0-9][0-9]' | sort -u || true) if [[ -n "$taken_ports" ]]; then log "Ports already claimed in 8200-8299 by sibling apps: $(echo $taken_ports | tr '\n' ' ')" fi fi if [[ ! -f "$ENV_FILE" ]]; then err ".env missing. Copy .env.example to .env and fill in required values (AIRTABLE_PAT, SESSION_SECRET, ADMIN_PASSWORD_BCRYPT) before deploying." exit 3 fi # Quickly validate required env vars are non-empty. missing=() for k in AIRTABLE_PAT SESSION_SECRET ADMIN_PASSWORD_BCRYPT; do [[ -n "$(get_env_var "$k")" ]] || missing+=("$k") done if (( ${#missing[@]} > 0 )); then err ".env is missing required values: ${missing[*]}" exit 3 fi # ─── pick port ──────────────────────────────────────────────────────────── PERSISTED_PORT="$(get_env_var UTILISATION_DEPT_PORT || echo "")" if [[ -n "$PERSISTED_PORT" ]] && docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | grep -q .; then PORT="$PERSISTED_PORT" log "Keeping current port $PORT (container is running)." elif [[ -n "$PERSISTED_PORT" ]] && ! port_in_use "$PERSISTED_PORT"; then PORT="$PERSISTED_PORT" log "Reusing persisted port $PORT." else PORT="$(find_free_port)" log "Picked port $PORT." fi set_env_var UTILISATION_DEPT_PORT "$PORT" # ─── render Apache include ──────────────────────────────────────────────── sed "s#__APP_PORT__#${PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF" ok "Rendered $APACHE_CONF" # ─── frontend build ─────────────────────────────────────────────────────── if ! $NO_FRONTEND; then log "Building frontend → $FRONTEND_OUT" require node require npm ( cd "$REPO_ROOT/frontend" npm ci --no-audit --no-fund npm run build ) if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then err "frontend/dist not produced"; exit 4 fi # Use sudo only if needed (deploys on the server need it; local dry runs don't). if [[ -w "$(dirname "$FRONTEND_OUT")" ]]; then mkdir -p "$FRONTEND_OUT" rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/" else sudo mkdir -p "$FRONTEND_OUT" sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/" fi ok "Frontend deployed to $FRONTEND_OUT" fi # ─── backend build + up ─────────────────────────────────────────────────── if ! $NO_BUILD; then log "docker compose build" docker compose -p "$COMPOSE_PROJECT" build fi log "docker compose up -d" docker compose -p "$COMPOSE_PROJECT" --env-file "$ENV_FILE" up -d # ─── health poll ────────────────────────────────────────────────────────── log "Waiting for /api/health on http://127.0.0.1:${PORT} (up to 60s)…" healthy=false for _ in $(seq 1 30); do if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then healthy=true; break fi sleep 2 done if $healthy; then ok "Backend healthy." else err "Backend did not become healthy. Logs:" docker compose -p "$COMPOSE_PROJECT" logs --tail=80 backend || true exit 5 fi # ─── final report ───────────────────────────────────────────────────────── echo ok "Deploy complete." echo echo " URL : https://optical-dev.oliver.solutions${URL_PATH}/" echo " API health : https://optical-dev.oliver.solutions${URL_PATH}/api/health" echo " Backend : 127.0.0.1:${PORT}" echo " Frontend : ${FRONTEND_OUT}" echo # Detect whether the Include line is already in the vhost; warn if not. VHOST=/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf if [[ -r "$VHOST" ]] && grep -qF "apache-${SLUG}.conf" "$VHOST"; then ok "Apache vhost already Includes ${APACHE_CONF}." else warn "First-time deploy: add this line INSIDE of $VHOST :" echo echo " Include ${APACHE_CONF}" echo warn "Then: sudo apachectl configtest && sudo systemctl reload apache2" fi if $TAIL_LOGS; then docker compose -p "$COMPOSE_PROJECT" logs -f --tail=50 backend fi