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>
183 lines
7 KiB
Bash
Executable file
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)"
|