2025-11-02 13:09:23 -08:00
|
|
|
#!/bin/sh
|
|
|
|
|
set -eu
|
|
|
|
|
|
|
|
|
|
# rsync wrapper for unreliable network connections
|
|
|
|
|
# my home network is spotty and rsync often dies mid-transfer
|
|
|
|
|
# this lets me manually retry the failed rsync and continue the deploy
|
|
|
|
|
rsync_retry() {
|
|
|
|
|
if ! rsync "$@"; then
|
|
|
|
|
echo ""
|
|
|
|
|
echo "rsync failed (probably network). run this manually:"
|
|
|
|
|
echo "rsync $*"
|
|
|
|
|
echo ""
|
|
|
|
|
printf "press enter after manual rsync completes to continue..."
|
|
|
|
|
read _
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Deployment topology and zero-downtime process
|
|
|
|
|
#
|
|
|
|
|
# Directory structure on remote:
|
|
|
|
|
# $base/$project/
|
|
|
|
|
# base_port - starting port number (default 3100)
|
|
|
|
|
# releases/{stamp}_{hash}/
|
|
|
|
|
# dist/ - static assets served by nginx
|
|
|
|
|
# server/ - node server from frontend/server/entrypoint.express.js
|
|
|
|
|
# api/ - python flask API (salience/, nltk_data/, etc.)
|
|
|
|
|
# assigned_port - port allocated to frontend for this release
|
|
|
|
|
# assigned_api_port - port allocated to API for this release
|
|
|
|
|
# current -> releases/{latest}
|
|
|
|
|
# systemd/ - unit files per release (frontend + API)
|
|
|
|
|
#
|
|
|
|
|
# Zero-downtime deployment:
|
|
|
|
|
# 1. rsync new release (frontend/dist/ + frontend/server/ + api/)
|
|
|
|
|
# 2. install dependencies (npm for frontend, uv for API)
|
|
|
|
|
# 3. allocate two ports (frontend + API via get-next-port.sh)
|
|
|
|
|
# 4. generate systemd units for new release with unique ports
|
|
|
|
|
# - frontend: node server
|
|
|
|
|
# - API: gunicorn with 4 workers running Flask app
|
|
|
|
|
# 5. start new services, wait for health
|
|
|
|
|
# 6. update nginx upstream to point to new ports
|
|
|
|
|
# 7. reload nginx (graceful, no dropped connections)
|
|
|
|
|
# 8. stop old services
|
|
|
|
|
# 9. cleanup old releases (keep 3 most recent)
|
|
|
|
|
#
|
|
|
|
|
# Port allocation: get-next-port.sh reads base_port and existing
|
|
|
|
|
# assigned_port files to find first available port.
|
|
|
|
|
# Each release runs independently until cutover.
|
|
|
|
|
|
|
|
|
|
ssh=deploy-peoplesgrocers-website
|
|
|
|
|
base=/home/peoplesgrocers
|
|
|
|
|
project=salience
|
|
|
|
|
nginx_conf=/etc/nginx/sites-available/$project
|
2025-11-02 14:25:28 -08:00
|
|
|
service_listen_address=127.221.91.58
|
2025-11-02 13:09:23 -08:00
|
|
|
local_nginx_snippet="$HOME/src/work/infra/servers/chicago-web01/nginx/snippets/qwik-city-apps/salience.conf"
|
|
|
|
|
|
|
|
|
|
test -d frontend/dist || { echo 'no frontend/dist/'; exit 1; }
|
|
|
|
|
test -d frontend/server || { echo 'no frontend/server/'; exit 1; }
|
|
|
|
|
test -d .git || { echo 'not a git repo'; exit 1; }
|
|
|
|
|
git diff-index --quiet HEAD || { echo 'git repo dirty'; exit 1; }
|
|
|
|
|
|
|
|
|
|
hash=$(git rev-parse --short=8 HEAD)
|
|
|
|
|
stamp=$(date +%Y-%b-%d-%a-%I-%M%p | tr 'APM' 'apm')
|
|
|
|
|
release="${stamp}_${hash}"
|
|
|
|
|
service_name="${project}-${release}"
|
|
|
|
|
|
|
|
|
|
echo "deploying: $project @ $release"
|
|
|
|
|
printf "continue? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
test "$ans" = "y" || exit 1
|
|
|
|
|
|
|
|
|
|
# prepare remote directories
|
|
|
|
|
ssh $ssh "mkdir -p $base/$project/{releases,systemd} $base/$project/releases/$release"
|
|
|
|
|
|
|
|
|
|
# sync both dist and server
|
|
|
|
|
echo "syncing dist..."
|
|
|
|
|
rsync_retry -tvaz frontend/dist/ $ssh:$base/$project/releases/$release/dist/
|
|
|
|
|
echo "syncing server..."
|
|
|
|
|
rsync_retry -tvaz frontend/server/ $ssh:$base/$project/releases/$release/server/
|
|
|
|
|
|
|
|
|
|
# copy server package.json and install dependencies
|
|
|
|
|
echo "copying server package.json..."
|
|
|
|
|
scp frontend/package.json $ssh:$base/$project/releases/$release/package.json
|
|
|
|
|
echo "installing server dependencies..."
|
|
|
|
|
ssh $ssh "source ~/.nvm/nvm.sh && cd $base/$project/releases/$release && npm install"
|
|
|
|
|
|
|
|
|
|
# sync api directory (exclude benchmarks, include specific files/dirs)
|
|
|
|
|
echo "syncing api..."
|
|
|
|
|
rsync_retry -tvaz \
|
|
|
|
|
--include='salience/' --include='salience/**' \
|
|
|
|
|
--include='nltk_data/' --include='nltk_data/**' \
|
|
|
|
|
--include='pyproject.toml' \
|
|
|
|
|
--include='uv.lock' \
|
|
|
|
|
--include='transcript.txt' \
|
|
|
|
|
--include='README.md' \
|
|
|
|
|
--exclude='*' \
|
|
|
|
|
api/ $ssh:$base/$project/releases/$release/api/
|
|
|
|
|
|
|
|
|
|
# link to shared models cache
|
|
|
|
|
echo "linking to shared models_cache..."
|
|
|
|
|
ssh $ssh "mkdir -p $base/$project/shared/models_cache"
|
|
|
|
|
ssh $ssh "ln -sfn ../../../shared/models_cache $base/$project/releases/$release/api/models_cache"
|
|
|
|
|
|
|
|
|
|
echo "installing api dependencies..."
|
|
|
|
|
ssh $ssh "cd $base/$project/releases/$release/api && ~/.local/bin/uv sync"
|
|
|
|
|
|
|
|
|
|
set -x
|
|
|
|
|
# determine ports for this release (frontend and api)
|
|
|
|
|
port=$(sh get-next-port.sh)
|
|
|
|
|
echo "frontend port for this release: $port"
|
|
|
|
|
api_port=$(sh get-next-port.sh)
|
|
|
|
|
echo "api port for this release: $api_port"
|
|
|
|
|
|
|
|
|
|
# record port assignments
|
|
|
|
|
ssh $ssh "echo $port > $base/$project/releases/$release/assigned_port"
|
|
|
|
|
ssh $ssh "echo $api_port > $base/$project/releases/$release/assigned_api_port"
|
|
|
|
|
|
|
|
|
|
set +x
|
|
|
|
|
|
|
|
|
|
# generate systemd unit file
|
|
|
|
|
unit_content="[Unit]
|
|
|
|
|
Description=${project} release ${release}
|
|
|
|
|
After=network.target
|
|
|
|
|
|
|
|
|
|
[Service]
|
|
|
|
|
Type=simple
|
|
|
|
|
User=peoplesgrocers
|
|
|
|
|
WorkingDirectory=$base/$project/releases/$release
|
|
|
|
|
Environment=\"PORT=$port\"
|
|
|
|
|
Environment=\"ORIGIN=https://peoplesgrocers.com\"
|
|
|
|
|
ExecStart=/home/peoplesgrocers/.nvm/versions/node/v24.10.0/bin/node server/entry.express.js
|
|
|
|
|
Restart=on-failure
|
|
|
|
|
RestartSec=10
|
|
|
|
|
|
|
|
|
|
[Install]
|
|
|
|
|
WantedBy=multi-user.target"
|
|
|
|
|
|
|
|
|
|
echo "$unit_content" | ssh $ssh "cat > $base/$project/systemd/${service_name}.service"
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "systemd unit created at: $base/$project/systemd/${service_name}.service"
|
|
|
|
|
echo ""
|
|
|
|
|
|
|
|
|
|
# generate systemd unit file for API
|
|
|
|
|
api_service_name="${project}-api-${release}"
|
|
|
|
|
api_unit_content="[Unit]
|
|
|
|
|
Description=${project} API release ${release}
|
|
|
|
|
After=network.target
|
|
|
|
|
|
|
|
|
|
[Service]
|
|
|
|
|
Type=simple
|
|
|
|
|
User=peoplesgrocers
|
|
|
|
|
WorkingDirectory=$base/$project/releases/$release/api
|
|
|
|
|
Environment=\"PORT=$api_port\"
|
|
|
|
|
ExecStart=$base/$project/releases/$release/api/.venv/bin/gunicorn --bind ${service_listen_address}:$api_port --workers 4 salience:app
|
|
|
|
|
Restart=on-failure
|
|
|
|
|
RestartSec=10
|
|
|
|
|
|
|
|
|
|
[Install]
|
|
|
|
|
WantedBy=multi-user.target"
|
|
|
|
|
|
|
|
|
|
echo "$api_unit_content" | ssh $ssh "cat > $base/$project/systemd/${api_service_name}.service"
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "API systemd unit created at: $base/$project/systemd/${api_service_name}.service"
|
|
|
|
|
echo ""
|
|
|
|
|
|
|
|
|
|
# find old services
|
|
|
|
|
old_service=$(ssh $ssh "systemctl list-units --type=service --state=running | grep '^${project}-' | grep -v 'api' | awk '{print \$1}' | head -1" || true)
|
|
|
|
|
if [ -n "$old_service" ]; then
|
|
|
|
|
old_port=$(ssh $ssh "systemctl show $old_service --property=Environment" | sed -n 's/.*PORT=\([0-9]*\).*/\1/p')
|
|
|
|
|
echo "old frontend service: $old_service (port $old_port)"
|
|
|
|
|
else
|
|
|
|
|
old_port=""
|
|
|
|
|
echo "no old frontend service running"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
old_api_service=$(ssh $ssh "systemctl list-units --type=service --state=running | grep '^${project}-api-' | awk '{print \$1}' | head -1" || true)
|
|
|
|
|
if [ -n "$old_api_service" ]; then
|
|
|
|
|
old_api_port=$(ssh $ssh "systemctl show $old_api_service --property=Environment" | sed -n 's/.*PORT=\([0-9]*\).*/\1/p')
|
|
|
|
|
echo "old API service: $old_api_service (port $old_api_port)"
|
|
|
|
|
else
|
|
|
|
|
old_api_port=""
|
|
|
|
|
echo "no old API service running"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Update local nginx snippet with new port
|
|
|
|
|
if [ -n "$local_nginx_snippet" ] && [ -f "$local_nginx_snippet" ]; then
|
|
|
|
|
echo "updating local nginx snippet: $local_nginx_snippet"
|
|
|
|
|
if [ -n "$old_port" ]; then
|
|
|
|
|
echo " changing port $old_port -> $port"
|
|
|
|
|
sed -i.bak "s/${service_listen_address}:${old_port}/${service_listen_address}:${port}/g" "$local_nginx_snippet"
|
|
|
|
|
else
|
|
|
|
|
echo " setting port to $port"
|
|
|
|
|
sed -i.bak "s/${service_listen_address}:[0-9]\{4,5\}/${service_listen_address}:${port}/g" "$local_nginx_snippet"
|
|
|
|
|
fi
|
|
|
|
|
rm -f "${local_nginx_snippet}.bak"
|
|
|
|
|
echo "nginx snippet updated locally"
|
|
|
|
|
else
|
|
|
|
|
echo "warning: local_nginx_snippet not set or file not found"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "--- run these commands on $ssh ---"
|
|
|
|
|
echo ""
|
|
|
|
|
echo "# install and start new services"
|
|
|
|
|
echo "sudo ln -sf $base/$project/systemd/${service_name}.service /etc/systemd/system/"
|
|
|
|
|
echo "sudo ln -sf $base/$project/systemd/${api_service_name}.service /etc/systemd/system/"
|
|
|
|
|
echo "sudo systemctl daemon-reload"
|
|
|
|
|
echo "sudo systemctl start ${service_name}"
|
|
|
|
|
echo "sudo systemctl start ${api_service_name}"
|
|
|
|
|
echo ""
|
|
|
|
|
echo "# verify services are healthy"
|
|
|
|
|
echo "sudo systemctl status ${service_name}"
|
|
|
|
|
echo "sudo systemctl status ${api_service_name}"
|
|
|
|
|
echo "curl http://${service_listen_address}:$port/"
|
|
|
|
|
echo "curl http://${service_listen_address}:$api_port/models"
|
|
|
|
|
echo ""
|
|
|
|
|
echo "# then deploy your nginx configuration and reload nginx"
|
|
|
|
|
echo ""
|
|
|
|
|
|
|
|
|
|
if [ -n "$old_service" ]; then
|
|
|
|
|
echo "# stop old frontend service"
|
|
|
|
|
echo "sudo systemctl stop $old_service"
|
|
|
|
|
echo ""
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$old_api_service" ]; then
|
|
|
|
|
echo "# stop old API service"
|
|
|
|
|
echo "sudo systemctl stop $old_api_service"
|
|
|
|
|
echo ""
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "# update current symlink"
|
|
|
|
|
echo "ln -sfn releases/$release $base/$project/current"
|
|
|
|
|
echo ""
|
|
|
|
|
echo "--- end commands ---"
|
|
|
|
|
echo ""
|
|
|
|
|
|
|
|
|
|
printf "test health checks? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
if [ "$ans" = "y" ]; then
|
|
|
|
|
echo "testing frontend..."
|
|
|
|
|
ssh $ssh "curl -v http://${service_listen_address}:$port/" || echo "frontend health check failed"
|
|
|
|
|
echo "testing API..."
|
|
|
|
|
ssh $ssh "curl -v http://${service_listen_address}:$api_port/models" || echo "API health check failed"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$old_service" ]; then
|
|
|
|
|
echo ""
|
|
|
|
|
printf "stop old frontend service ($old_service)? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
if [ "$ans" = "y" ]; then
|
|
|
|
|
ssh $ssh "sudo systemctl stop $old_service"
|
|
|
|
|
echo "old frontend service stopped"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -n "$old_api_service" ]; then
|
|
|
|
|
echo ""
|
|
|
|
|
printf "stop old API service ($old_api_service)? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
if [ "$ans" = "y" ]; then
|
|
|
|
|
ssh $ssh "sudo systemctl stop $old_api_service"
|
|
|
|
|
echo "old API service stopped"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
printf "update current symlink? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
if [ "$ans" = "y" ]; then
|
|
|
|
|
ssh $ssh "ln -sfn releases/$release $base/$project/current"
|
|
|
|
|
echo "current -> $release"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "cleanup old releases (keep 3):"
|
|
|
|
|
old_releases=$(ssh $ssh "cd $base/$project/releases && ls -t | sed -n '4,\$p'" || true)
|
|
|
|
|
if [ -n "$old_releases" ]; then
|
|
|
|
|
echo "$old_releases"
|
|
|
|
|
printf "remove these? [y/n] "
|
|
|
|
|
read ans
|
|
|
|
|
if [ "$ans" = "y" ]; then
|
|
|
|
|
ssh $ssh "cd $base/$project/releases && ls -t | sed -n '4,\$p' | while read r; do
|
|
|
|
|
rm -rf \"\$r\"
|
|
|
|
|
sudo systemctl stop ${project}-\${r} 2>/dev/null || true
|
|
|
|
|
sudo systemctl stop ${project}-api-\${r} 2>/dev/null || true
|
|
|
|
|
sudo rm -f /etc/systemd/system/${project}-\${r}.service
|
|
|
|
|
sudo rm -f /etc/systemd/system/${project}-api-\${r}.service
|
|
|
|
|
rm -f $base/$project/systemd/${project}-\${r}.service
|
|
|
|
|
rm -f $base/$project/systemd/${project}-api-\${r}.service
|
|
|
|
|
done"
|
|
|
|
|
ssh $ssh "sudo systemctl daemon-reload"
|
|
|
|
|
echo "cleanup done"
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
echo "no old releases to clean"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
echo "done: $release (frontend port $port, API port $api_port)"
|