loreal-utilisation-dept/deploy/deploy.sh
DJP 8e28464bdf deploy.sh: fix self-collision in slug check when clone path != slug
The collision check filtered self-matches by /opt/${SLUG}/, which only
works when the on-disk directory matches the URL slug. When the repo is
cloned to a different directory (e.g. /opt/loreal-utilisation-dept/ to
match the Bitbucket repo name while keeping URL slug "utilisation-dept"),
the script flagged its own apache-*.conf as a foreign collision and
refused to redeploy. Filter against \$REPO_ROOT instead, which holds the
actual on-disk path and matches what grep -l emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:47:03 -04:00

273 lines
11 KiB
Bash
Executable file

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