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.
369 lines
17 KiB
Bash
Executable file
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 ""
|