Fail fast if another app on optical-dev already claims /utilisation-dept/ in the shared Apache vhost or any sibling /opt/*/deploy/apache-*.conf, and surface which 8200-8299 ports are already taken by sibling apps so the port picker's choice is visible. Silently skips on dev laptops where the vhost file isn't present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
Bash
Executable file
252 lines
10 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; }
|
|
}
|
|
|
|
# ─── sanity ───────────────────────────────────────────────────────────────
|
|
cd "$REPO_ROOT"
|
|
require docker
|
|
require git
|
|
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.
|
|
sibling_confs=(/opt/*/deploy/apache-*.conf)
|
|
collision=$(grep -lE "(ProxyPass|Alias)[[:space:]]+${URL_PATH}/" \
|
|
"$VHOST" "${sibling_confs[@]}" 2>/dev/null \
|
|
| grep -v "/opt/${SLUG}/" || 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"
|
|
|
|
# ─── git pull ─────────────────────────────────────────────────────────────
|
|
if ! $NO_PULL && git rev-parse --git-dir >/dev/null 2>&1 && git remote >/dev/null 2>&1; then
|
|
if git ls-remote --exit-code origin main >/dev/null 2>&1; then
|
|
log "git pull --ff-only origin main"
|
|
git pull --ff-only origin main || warn "git pull failed — proceeding with current checkout"
|
|
fi
|
|
fi
|
|
|
|
# ─── 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 /opt/${SLUG}/deploy/apache-${SLUG}.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
|