hp-studios-ai-content-agent/deploy/deploy.sh
DJP b0516d8bba deploy.sh: patch sites-enabled too (it's diverged, not a symlink)
On this specific shared host, /etc/apache2/sites-enabled/*.conf is
a hand-maintained copy of sites-available, not the usual a2ensite
symlink. Apache loads sites-enabled, so patching sites-available
alone produced exactly the observed bug: configtest passed, our
Include sat in sites-available, and Apache kept 404-ing because the
active config had no Include line.

Script now:
- lists sites-enabled paths first, sites-available second
- readlink -f resolves each to its real path; a dedup set skips
  double-patching when the enabled entry IS a normal symlink
- picks up both the plain HTTP vhost and any *-le-ssl.conf variant

A symlink-based install keeps working (one file edited once); a
divergent-copy install like this one gets both files patched.
2026-04-22 17:09:05 -04:00

369 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
# deploy.sh — idempotent deploy for hp-studios-ai-content-agent on
# optical-dev.oliver.solutions (or any similar shared Apache + Docker server).
#
# What it does:
# 1. Validates required secrets in .env
# 2. Picks free host ports if not already pinned in .env
# 3. Sets VITE_API_URL and VITE_BASE_PATH for subpath deployment
# 4. Builds + starts all containers with a pinned project name
# 5. Runs Alembic migrations and seeds the admin user (first run only)
# 6. Renders + installs the Apache proxy snippet and reloads apache2
# 7. Ensures UFW allows 22/80
#
# Run from the repo root on the server:
# cd /opt/hp-studios-ai-content-agent && sudo ./deploy/deploy.sh
#
# Reruns are safe — existing .env values are preserved.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
PROJECT_SLUG="hp-studios-ai-content-agent"
PUBLIC_BASE_PATH="/hp-content-agent"
PUBLIC_HOST="optical-dev.oliver.solutions"
# Vhost files we might need to patch. On a normal Apache install
# sites-enabled/*.conf is a symlink into sites-available/, so editing one
# suffices — but on this shared host the two are diverged hand-maintained
# copies. Patch every path that exists; skip symlinks if we've already
# patched their target.
APACHE_VHOST_CANDIDATES=(
"/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
"/etc/apache2/sites-enabled/optical-dev.oliver.solutions-le-ssl.conf"
"/etc/apache2/sites-available/optical-dev.oliver.solutions.conf"
"/etc/apache2/sites-available/optical-dev.oliver.solutions-le-ssl.conf"
)
APACHE_SNIPPET_SRC="$SCRIPT_DIR/apache/hp-content-agent.conf.template"
APACHE_SNIPPET_DST="$SCRIPT_DIR/apache/hp-content-agent.conf"
INCLUDE_LINE=" Include $APACHE_SNIPPET_DST"
ENV_FILE="$REPO_ROOT/.env"
ENV_EXAMPLE="$REPO_ROOT/.env.example"
# Port search range (avoid common defaults; tune if needed)
PORT_SEARCH_MIN=20000
PORT_SEARCH_MAX=29999
COLOR_INFO='\033[0;36m'
COLOR_WARN='\033[0;33m'
COLOR_ERR='\033[0;31m'
COLOR_OK='\033[0;32m'
COLOR_OFF='\033[0m'
info() { printf "${COLOR_INFO}${COLOR_OFF} %s\n" "$*"; }
warn() { printf "${COLOR_WARN}${COLOR_OFF} %s\n" "$*"; }
error() { printf "${COLOR_ERR}${COLOR_OFF} %s\n" "$*" >&2; }
ok() { printf "${COLOR_OK}${COLOR_OFF} %s\n" "$*"; }
# ─────────────────────────────────────────────────────────────────────────────
# STEP 0: Preflight
# ─────────────────────────────────────────────────────────────────────────────
info "Step 0: Preflight checks"
command -v docker >/dev/null || { error "docker not found"; exit 1; }
docker compose version >/dev/null 2>&1 || { error "docker compose (v2) not found"; exit 1; }
if [[ $EUID -ne 0 ]] && ! sudo -n true 2>/dev/null; then
warn "Not running as root and passwordless sudo unavailable — Apache + UFW steps may prompt for password."
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 1: .env — create from example if missing; validate secrets
# ─────────────────────────────────────────────────────────────────────────────
info "Step 1: .env validation"
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_EXAMPLE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
warn "Created .env from .env.example — fill in secrets, then re-run."
warn "Required: ANTHROPIC_API_KEY, OPENAI_API_KEY (or VOYAGE_API_KEY), JWT_SECRET"
exit 1
else
error ".env.example missing — cannot bootstrap .env"
exit 1
fi
fi
# Load .env
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
# Generate JWT_SECRET if placeholder or empty
if [[ -z "${JWT_SECRET:-}" ]] || [[ "$JWT_SECRET" == "change-me-"* ]]; then
NEW_SECRET="$(openssl rand -hex 32)"
if grep -q '^JWT_SECRET=' "$ENV_FILE"; then
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$NEW_SECRET|" "$ENV_FILE"
else
echo "JWT_SECRET=$NEW_SECRET" >> "$ENV_FILE"
fi
JWT_SECRET="$NEW_SECRET"
info " Generated new JWT_SECRET"
fi
# Require at least ANTHROPIC_API_KEY
if [[ -z "${ANTHROPIC_API_KEY:-}" ]] || [[ "$ANTHROPIC_API_KEY" == "your-"* ]]; then
error "ANTHROPIC_API_KEY not set in $ENV_FILE"; exit 1
fi
if [[ -z "${OPENAI_API_KEY:-}" ]] && [[ -z "${VOYAGE_API_KEY:-}" ]]; then
error "Neither OPENAI_API_KEY nor VOYAGE_API_KEY set — pick one for embeddings."; exit 1
fi
ok " Secrets OK"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 2: Pick free ports (stable across reruns)
# ─────────────────────────────────────────────────────────────────────────────
info "Step 2: Port assignment"
is_port_free() {
# Prod compose binds publishes to 127.0.0.1:PORT (IPv4 loopback only), so
# we only need to prove that address is bindable. Checking IPv6 ::0 as well
# caused false positives on this host where Docker has stale :::PORT
# reservations that don't collide with our IPv4-only bind.
local p="$1"
# 1. Actively listening on loopback?
if timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$p" 2>/dev/null; then
exec 3<&-; exec 3>&-
return 1
fi
# 2. Any container (running OR stopped) already publishes this port on 127.0.0.1
# or 0.0.0.0 — stopped containers still hold Docker's port reservation.
if command -v docker >/dev/null && \
docker ps -a --format '{{.Ports}}' 2>/dev/null | grep -Eq "(^|[^0-9])${p}->"; then
return 1
fi
# 3. Actually bind 127.0.0.1:$p to confirm the OS will give it to us.
if command -v python3 >/dev/null; then
python3 - <<PYEOF
import socket, sys
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", ${p}))
s.close()
except OSError:
sys.exit(1)
sys.exit(0)
PYEOF
[[ $? -ne 0 ]] && return 1
fi
return 0
}
# Find a port starting at $1, skipping ones in use.
find_free_port() {
local start="$1" candidate
for ((candidate=start; candidate<=PORT_SEARCH_MAX; candidate++)); do
if is_port_free "$candidate"; then
echo "$candidate"
return 0
fi
done
error "No free port found in [$start, $PORT_SEARCH_MAX]"
exit 1
}
# Assign a var in .env if not already set to a usable value.
assign_port() {
local var="$1" want
local current="${!var:-}"
if [[ -n "$current" ]] && is_port_free "$current"; then
info " $var already set to $current (free) — keeping"
return
fi
if [[ -n "$current" ]]; then
warn " $var was $current but port is in use — picking new one"
fi
# Use a rough hash of the var name so ports stay ≈stable between clean installs
local base=$(( PORT_SEARCH_MIN + (RANDOM % 5000) ))
want="$(find_free_port "$base")"
if grep -q "^$var=" "$ENV_FILE"; then
sed -i "s|^$var=.*|$var=$want|" "$ENV_FILE"
else
echo "$var=$want" >> "$ENV_FILE"
fi
export "$var=$want"
info " $var=$want"
}
# In prod, postgres and redis aren't published to the host — intra-service
# calls use the internal compose network. We only need host ports for the
# two services Apache reverse-proxies.
assign_port API_HOST_PORT
assign_port FRONTEND_HOST_PORT
# Defensively assign postgres/redis too, in case someone runs the plain dev
# compose file (which does publish them). Prod override drops the publish.
assign_port POSTGRES_HOST_PORT
assign_port REDIS_HOST_PORT
# ─────────────────────────────────────────────────────────────────────────────
# STEP 3: Configure public URLs and CORS in .env
# ─────────────────────────────────────────────────────────────────────────────
info "Step 3: Public URLs"
upsert_env() {
local key="$1" val="$2"
if grep -q "^$key=" "$ENV_FILE"; then
sed -i "s|^$key=.*|$key=$val|" "$ENV_FILE"
else
echo "$key=$val" >> "$ENV_FILE"
fi
export "$key=$val"
}
PUBLIC_URL="https://$PUBLIC_HOST$PUBLIC_BASE_PATH"
upsert_env VITE_API_URL "$PUBLIC_BASE_PATH/api"
upsert_env VITE_BASE_PATH "$PUBLIC_BASE_PATH/"
upsert_env CORS_ORIGIN "https://$PUBLIC_HOST"
ok " VITE_API_URL=$VITE_API_URL"
ok " VITE_BASE_PATH=$VITE_BASE_PATH"
ok " CORS_ORIGIN=$CORS_ORIGIN"
# Reload .env so subsequent commands see the updates
set -a; source "$ENV_FILE"; set +a
# ─────────────────────────────────────────────────────────────────────────────
# STEP 4: Build and start containers
# ─────────────────────────────────────────────────────────────────────────────
info "Step 4: docker compose build + up"
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.prod.yml"
# Clear any stale containers from this project before up. Orphans from a
# previously failed deploy otherwise keep port reservations that our probes
# can't always see (they live in Docker's internal state, not in ss/netstat).
# We keep volumes so Postgres data survives.
# shellcheck disable=SC2086
docker compose -p "$PROJECT_SLUG" $COMPOSE_FILES rm -fsv 2>/dev/null || true
# shellcheck disable=SC2086
docker compose -p "$PROJECT_SLUG" $COMPOSE_FILES build
# shellcheck disable=SC2086
if ! docker compose -p "$PROJECT_SLUG" $COMPOSE_FILES up -d; then
error "docker compose up failed. Likely a port collision we couldn't detect upfront."
error "Check: docker ps -a --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'"
error "Then remove the conflicting port line from .env and re-run this script:"
error " sudo sed -i '/^FRONTEND_HOST_PORT=/d' .env # (or the colliding var)"
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 5: Migrations + seed admin (idempotent)
# ─────────────────────────────────────────────────────────────────────────────
info "Step 5: Database migrations"
# Wait for postgres to be healthy (up to 60s)
for i in $(seq 1 30); do
if docker compose -p "$PROJECT_SLUG" ps postgres 2>/dev/null | grep -q healthy; then
break
fi
sleep 2
done
docker compose -p "$PROJECT_SLUG" exec -T api alembic upgrade head
info "Step 5b: Seeding admin user (no-op if exists)"
docker compose -p "$PROJECT_SLUG" exec -T api python -m app.cli.seed || warn "Seed step returned non-zero (may already exist — ignoring)"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 6: Render Apache snippet
# ─────────────────────────────────────────────────────────────────────────────
info "Step 6: Apache snippet render"
export API_HOST_PORT FRONTEND_HOST_PORT
envsubst '${API_HOST_PORT} ${FRONTEND_HOST_PORT}' < "$APACHE_SNIPPET_SRC" > "$APACHE_SNIPPET_DST"
ok " Wrote $APACHE_SNIPPET_DST"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 7: Apache vhost Include
# ─────────────────────────────────────────────────────────────────────────────
info "Step 7: Apache vhost include"
# Make sure mod_proxy + mod_headers are available BEFORE we reload, so the
# ProxyPass directives in our snippet don't error out on the first try.
sudo a2enmod proxy proxy_http headers >/dev/null 2>&1 || true
changed=0
found_any=0
declare -A patched_inode # skip file we've already written via another path (symlink)
for VHOST in "${APACHE_VHOST_CANDIDATES[@]}"; do
if [[ ! -e "$VHOST" ]]; then
continue
fi
# Resolve symlinks so we don't patch the same real file twice.
REAL="$(sudo readlink -f "$VHOST" 2>/dev/null || echo "$VHOST")"
if [[ -n "${patched_inode[$REAL]:-}" ]]; then
info " $(basename "$VHOST")$REAL already patched — skipping"
continue
fi
patched_inode[$REAL]=1
found_any=1
if sudo grep -qF "$APACHE_SNIPPET_DST" "$REAL"; then
info " Include already present in $REAL — skipping"
continue
fi
# Insert Include before the FIRST </VirtualHost> in the file.
sudo sed -i "0,/<\/VirtualHost>/s||$INCLUDE_LINE\n</VirtualHost>|" "$REAL"
info " Added Include to $REAL"
changed=1
done
if [[ $found_any -eq 0 ]]; then
warn " No vhost file found at any of:"
for VHOST in "${APACHE_VHOST_CANDIDATES[@]}"; do warn " $VHOST"; done
warn " Add this line manually to your vhost(s) inside the <VirtualHost> block:"
warn " $INCLUDE_LINE"
fi
# Always configtest + reload so any port changes take effect even when we
# didn't modify the file this run.
if sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
sudo systemctl reload apache2
ok " Apache reloaded$([ $changed -eq 1 ] && echo " (Include added)" || echo " (port changes picked up)")"
else
error "Apache config test failed. Output:"
sudo apache2ctl configtest
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 8: UFW — idempotent allow 22/80
# ─────────────────────────────────────────────────────────────────────────────
info "Step 8: UFW firewall"
if command -v ufw >/dev/null; then
sudo ufw allow 22/tcp >/dev/null 2>&1 || true
sudo ufw allow 80/tcp >/dev/null 2>&1 || true
ok " UFW allows 22/tcp, 80/tcp"
else
warn " ufw not installed — skipping (firewall is presumably managed elsewhere)"
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 9: Sanity check
# ─────────────────────────────────────────────────────────────────────────────
info "Step 9: Sanity check"
sleep 2
API_OK=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${API_HOST_PORT}/health" || echo "000")
FE_OK=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${FRONTEND_HOST_PORT}/" || echo "000")
echo ""
printf " api http://127.0.0.1:%s/health → %s\n" "$API_HOST_PORT" "$API_OK"
printf " frontend http://127.0.0.1:%s/ → %s\n" "$FRONTEND_HOST_PORT" "$FE_OK"
PUBLIC_OK=$(curl -s -o /dev/null -w "%{http_code}" "$PUBLIC_URL/" || echo "000")
printf " public %s/ → %s\n" "$PUBLIC_URL" "$PUBLIC_OK"
echo ""
ok "Deploy complete."
echo ""
echo " Public URL: $PUBLIC_URL/"
echo " Compose: docker compose -p $PROJECT_SLUG $COMPOSE_FILES ps"
echo " Logs: docker compose -p $PROJECT_SLUG $COMPOSE_FILES logs -f api worker"
echo " Ports: postgres=$POSTGRES_HOST_PORT redis=$REDIS_HOST_PORT api=$API_HOST_PORT frontend=$FRONTEND_HOST_PORT"
echo ""