salary-benchmark/deploy/deploy.sh
DJP 929929113b Pin compose project name to avoid collision with other /opt/<app>/deploy/ apps
Compose derives project name from parent dir by default — every app on
this server landed on project "deploy", sharing container names and
volume namespaces. Another app on the box just lost 2 days of data from
this exact issue.

Fix: name: salary-benchmark in docker-compose.prod.yml, plus -p on every
docker compose call in deploy-local.sh and deploy.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:39:49 -04:00

183 lines
7 KiB
Bash
Executable file

#!/usr/bin/env bash
# Deploy Salary Benchmark to optical-dev.oliver.solutions under /salary-benchmark/.
# Idempotent: safe to re-run for updates.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
if [[ ! -f "${SCRIPT_DIR}/config.sh" ]]; then
echo "ERROR: ${SCRIPT_DIR}/config.sh not found. Copy config.sh.example to config.sh and edit." >&2
exit 1
fi
source "${SCRIPT_DIR}/config.sh"
: "${SSH_TARGET:?SSH_TARGET must be set in config.sh}"
: "${REMOTE_APP_DIR:?REMOTE_APP_DIR must be set in config.sh}"
: "${REMOTE_WEB_DIR:?REMOTE_WEB_DIR must be set in config.sh}"
: "${REMOTE_VHOST_FILE:?REMOTE_VHOST_FILE must be set in config.sh}"
: "${URL_SUBPATH:?URL_SUBPATH must be set in config.sh}"
: "${PORT_SCAN_START:=8100}"
: "${PORT_SCAN_END:=8199}"
step() { printf "\n\033[1;33m==> %s\033[0m\n" "$*"; }
info() { printf " %s\n" "$*"; }
die() { printf "\033[1;31mERROR: %s\033[0m\n" "$*" >&2; exit 1; }
# ----------------------------------------------------------------------------
step "Pre-flight checks"
command -v rsync >/dev/null || die "rsync not installed locally"
command -v ssh >/dev/null || die "ssh not installed locally"
command -v npm >/dev/null || die "npm not installed locally"
ssh -o BatchMode=yes -o ConnectTimeout=5 "${SSH_TARGET}" true \
|| die "cannot ssh to ${SSH_TARGET}"
info "SSH to ${SSH_TARGET} ok"
# ----------------------------------------------------------------------------
step "Pick a free backend port on remote (range ${PORT_SCAN_START}-${PORT_SCAN_END})"
APP_PORT=$(ssh "${SSH_TARGET}" bash -s <<EOF
set -e
used=\$(ss -tlnH 2>/dev/null | awk '{print \$4}' | awk -F: '{print \$NF}' | sort -u)
for p in \$(seq ${PORT_SCAN_START} ${PORT_SCAN_END}); do
if ! grep -qx "\$p" <<<"\$used"; then echo "\$p"; exit 0; fi
done
exit 1
EOF
)
[[ -n "${APP_PORT}" ]] || die "no free port in range ${PORT_SCAN_START}-${PORT_SCAN_END}"
info "Selected APP_PORT=${APP_PORT}"
# ----------------------------------------------------------------------------
step "Build frontend with VITE_BASE=${URL_SUBPATH}"
pushd "${REPO_ROOT}/frontend" >/dev/null
npm ci --prefer-offline --no-audit --no-fund
VITE_BASE="${URL_SUBPATH}" npm run build
popd >/dev/null
info "Built: ${REPO_ROOT}/frontend/dist/"
# ----------------------------------------------------------------------------
step "Prepare remote directories"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
sudo mkdir -p "${REMOTE_APP_DIR}" "${REMOTE_WEB_DIR}"
sudo chown -R "\$USER":"\$USER" "${REMOTE_APP_DIR}"
EOF
# ----------------------------------------------------------------------------
step "Sync backend + deploy assets to ${REMOTE_APP_DIR}"
rsync -az --delete \
--exclude '.venv' --exclude '__pycache__' --exclude '*.pyc' \
--exclude 'pgdata' --exclude '.env' --exclude 'deploy/config.sh' \
"${REPO_ROOT}/app/" "${SSH_TARGET}:${REMOTE_APP_DIR}/app/"
rsync -az --delete \
--exclude '__pycache__' \
"${REPO_ROOT}/alembic/" "${SSH_TARGET}:${REMOTE_APP_DIR}/alembic/"
rsync -az \
"${REPO_ROOT}/alembic.ini" \
"${REPO_ROOT}/requirements.txt" \
"${REPO_ROOT}/deploy/Dockerfile.prod" \
"${REPO_ROOT}/deploy/docker-compose.prod.yml" \
"${SSH_TARGET}:${REMOTE_APP_DIR}/"
rsync -az \
"${REPO_ROOT}/deploy/apache-salary-benchmark.conf.template" \
"${SSH_TARGET}:${REMOTE_APP_DIR}/deploy/"
# ----------------------------------------------------------------------------
step "Sync frontend dist to ${REMOTE_WEB_DIR}"
rsync -az --delete --rsync-path="sudo rsync" \
"${REPO_ROOT}/frontend/dist/" "${SSH_TARGET}:${REMOTE_WEB_DIR}/"
# ----------------------------------------------------------------------------
step "Write .env.prod on remote (preserves existing JWT_SECRET if set)"
# Collect API keys from local .env if they exist
LOCAL_ENV="${REPO_ROOT}/.env"
get_local() { grep -E "^${1}=" "${LOCAL_ENV}" 2>/dev/null | head -1 | cut -d= -f2- || true; }
SERPER_API_KEY="${SERPER_API_KEY:-$(get_local SERPER_API_KEY)}"
FIRECRAWL_API_KEY="${FIRECRAWL_API_KEY:-$(get_local FIRECRAWL_API_KEY)}"
COHERE_API_KEY="${COHERE_API_KEY:-$(get_local COHERE_API_KEY)}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$(get_local ANTHROPIC_API_KEY)}"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
cd "${REMOTE_APP_DIR}"
ENVF=".env.prod"
if [[ -f "\${ENVF}" ]]; then
EXISTING_JWT=\$(grep -E '^JWT_SECRET=' "\${ENVF}" | cut -d= -f2- || true)
EXISTING_DBPW=\$(grep -E '^DB_PASSWORD=' "\${ENVF}" | cut -d= -f2- || true)
fi
JWT_SECRET="\${EXISTING_JWT:-\$(openssl rand -hex 32)}"
DB_PASSWORD="\${EXISTING_DBPW:-\$(openssl rand -hex 16)}"
cat > "\${ENVF}" <<ENV
# Generated by deploy.sh — do not commit
APP_PORT=${APP_PORT}
DB_USER=salary_user
DB_PASSWORD=\${DB_PASSWORD}
DB_HOST=db
DB_PORT=5432
DB_NAME=salary_benchmark
JWT_SECRET=\${JWT_SECRET}
JWT_ALGORITHM=HS256
JWT_EXPIRES_MINUTES=480
SERPER_API_KEY=${SERPER_API_KEY}
FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
COHERE_API_KEY=${COHERE_API_KEY}
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
ENV
chmod 600 "\${ENVF}"
echo " wrote ${REMOTE_APP_DIR}/\${ENVF}"
EOF
# ----------------------------------------------------------------------------
step "Build & start containers"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
cd "${REMOTE_APP_DIR}"
export APP_PORT="${APP_PORT}"
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod build
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod up -d
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod ps
EOF
# ----------------------------------------------------------------------------
step "Install Apache config fragment"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
FRAG_SRC="${REMOTE_APP_DIR}/deploy/apache-salary-benchmark.conf.template"
FRAG_DST="${REMOTE_APP_DIR}/deploy/apache-salary-benchmark.conf"
sed "s|__APP_PORT__|${APP_PORT}|g" "\${FRAG_SRC}" > "\${FRAG_DST}"
echo " rendered \${FRAG_DST} (APP_PORT=${APP_PORT})"
INCLUDE_LINE=" Include \${FRAG_DST}"
if sudo grep -Fq "\${FRAG_DST}" "${REMOTE_VHOST_FILE}"; then
echo " Include already present in ${REMOTE_VHOST_FILE}"
else
sudo cp -a "${REMOTE_VHOST_FILE}" "${REMOTE_VHOST_FILE}.bak.\$(date +%s)"
sudo sed -i "0,/<\/VirtualHost>/s|</VirtualHost>|\${INCLUDE_LINE}\n</VirtualHost>|" "${REMOTE_VHOST_FILE}"
echo " added Include line to ${REMOTE_VHOST_FILE}"
fi
sudo apache2ctl configtest
sudo systemctl reload apache2
echo " Apache reloaded"
EOF
# ----------------------------------------------------------------------------
step "Verify"
sleep 2
HEALTH_URL="https://optical-dev.oliver.solutions${URL_SUBPATH}api/health"
if curl -sfk "${HEALTH_URL}" >/dev/null; then
info "Health check ok: ${HEALTH_URL}"
else
info "WARNING: health check failed at ${HEALTH_URL} (container may still be starting)"
fi
step "Done"
info "App: https://optical-dev.oliver.solutions${URL_SUBPATH}"
info "Backend port on remote: 127.0.0.1:${APP_PORT}"
info "Login: admin@oliver.agency / Oliver2026! (password is seeded by migration 004; change in DB if needed)"