135 lines
6.1 KiB
Bash
135 lines
6.1 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Production is the only deployed environment (local dev uses scripts/db-refresh-dev.sh).
|
|
# deploy-remote.sh <sha> deploy back up, migrate, seed, preflight, switch, health-check
|
|
# deploy-remote.sh <sha> rollback back up, preflight, switch to an earlier image (no migrations;
|
|
# the database is not rolled back, restore a backup for that)
|
|
sha=${1:?commit SHA required}
|
|
mode=${2:-deploy}
|
|
case "$sha" in
|
|
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
|
|
esac
|
|
case "$mode" in deploy|rollback) ;; *) echo 'Mode must be deploy or rollback' >&2; exit 2 ;; esac
|
|
directory=/opt/personal-website
|
|
origin=https://stanrunge.dev
|
|
environment=/etc/personal-website/prod.env
|
|
environment_name=production
|
|
|
|
image="git.stanrunge.dev/stan/website:$sha"
|
|
compose="$directory/compose.prod.yml"
|
|
compose_files=(-f "$compose")
|
|
# Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books;
|
|
# the app streams them only to the owner via /trackers/books/files/<id> (no static path).
|
|
# Layout: $books_host_dir/library/<files.path> and $books_host_dir/covers/<works.cover_path>.
|
|
# compose.prod.yml is host-managed, so the mount goes in a generated override file.
|
|
books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books}
|
|
case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
|
|
if [ -d "$books_host_dir/library" ]; then
|
|
sudo tee "$directory/compose.books.yml" >/dev/null <<YAML
|
|
services:
|
|
app:
|
|
environment:
|
|
BOOKS_ROOT: /books
|
|
volumes:
|
|
- type: bind
|
|
source: $books_host_dir
|
|
target: /books
|
|
read_only: true
|
|
YAML
|
|
compose_files+=(-f "$directory/compose.books.yml")
|
|
else
|
|
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
|
|
fi
|
|
# Images built before the repo rename (stan/personal-website -> stan/website) keep
|
|
# their old local name; retag so rollbacks to those SHAs still work.
|
|
legacy_image="git.stanrunge.dev/stan/personal-website:$sha"
|
|
if ! sudo docker image inspect "$image" >/dev/null 2>&1 &&
|
|
sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then
|
|
sudo docker tag "$legacy_image" "$image"
|
|
fi
|
|
if ! sudo docker image inspect "$image" >/dev/null 2>&1; then
|
|
echo "Image $image is not on this host" >&2; exit 1
|
|
fi
|
|
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
|
switched=0
|
|
rollback() {
|
|
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
|
|
echo "Health check failed; restoring prior image tag $previous_tag" >&2
|
|
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
|
|
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
|
fi
|
|
}
|
|
trap rollback ERR
|
|
|
|
# The deploy account cannot read the env file directly; check inside a container.
|
|
if ! sudo docker run --rm --env-file "$environment" --entrypoint sh "$image" \
|
|
-c 'test -n "${DB_MIGRATE_URL:-}"'; then
|
|
echo "DB_MIGRATE_URL (schema owner) is missing from $environment" >&2; exit 1
|
|
fi
|
|
db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
|
bun scripts/print-db-server-version.ts)
|
|
case "$db_version" in
|
|
*[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;;
|
|
esac
|
|
db_major=$((db_version / 10000))
|
|
if [ "$db_major" -gt 17 ]; then
|
|
echo "Server is PostgreSQL $db_major; update the pinned postgres:17-alpine backup image" >&2; exit 1
|
|
fi
|
|
backup_image=postgres:17-alpine
|
|
backup_dir=/var/backups/personal-website
|
|
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
|
backup_name="${environment_name}-${backup_stamp}-${sha}.dump"
|
|
sudo docker pull "$backup_image"
|
|
# The deploy account can run Docker, but has no general host-file sudo access.
|
|
# A bind volume creates the root-owned directory; restrict it before dumping.
|
|
sudo docker run --rm --network backend --env-file "$environment" \
|
|
--volume "$backup_dir:/backups" \
|
|
--env "BACKUP_FILE=$backup_name" "$backup_image" \
|
|
sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_MIGRATE_URL"'
|
|
sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \
|
|
"$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null
|
|
echo "Verified $environment_name database backup at $backup_dir/$backup_name"
|
|
|
|
# Migrations and seeds (ref.* is read-only for the app) need the owner role.
|
|
# DB_MIGRATE_URL (role personal) must be set in the env file; DB_URL is the app
|
|
# role (personal_app), which has no DDL rights.
|
|
run_database_script() {
|
|
echo "Running $1"
|
|
sudo docker run --rm --network backend --env-file "$environment" \
|
|
--entrypoint sh "$image" -eu -c 'DB_URL="$DB_MIGRATE_URL" exec bun "$0"' "$1"
|
|
}
|
|
|
|
if [ "$mode" = deploy ]; then
|
|
# Schema changes: Drizzle migrations from ./drizzle, as the schema owner.
|
|
run_database_script scripts/db-migrate.ts
|
|
run_database_script scripts/seed-catalog.ts
|
|
run_database_script scripts/seed-cod-catalog.ts
|
|
run_database_script scripts/seed-pokemon-research.ts
|
|
run_database_script scripts/seed-cod-challenges.ts
|
|
run_database_script scripts/seed-cod-art.ts
|
|
fi
|
|
|
|
sudo docker run --rm --network backend --env-file "$environment" "$image" \
|
|
bun scripts/deploy-preflight.ts "$origin"
|
|
|
|
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
|
|
switched=1
|
|
sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d
|
|
|
|
container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app)
|
|
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
|
|
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
|
|
if [ "$actual_image" != "$expected_image" ]; then
|
|
echo 'Compose did not start the intended image' >&2
|
|
false
|
|
fi
|
|
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
|
"$origin/login" >/dev/null
|
|
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
|
"$origin/projects/game-tracker/pokemon" >/dev/null
|
|
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
|
|
"$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null
|
|
trap - ERR
|
|
echo "Deployed $sha to $origin ($mode)"
|