szabadpalya/scripts/deploy.sh

297 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Deploy to the VPS.
#
# pnpm run deploy build here, ship the image, restart, verify
# pnpm run deploy --dry-run show what would change on the server, touch nothing
# pnpm run deploy --yes skip the confirmation
# pnpm run deploy --server-build build on the VPS instead (the old behaviour)
# pnpm run deploy --no-build copy the source only, leave the containers alone
#
# The image is built on this machine and loaded into the server's docker, which
# never compiles anything. A laptop builds the Go binary and the Vite bundle in
# a fraction of the time a small droplet takes, and a build that fails fails
# here, before the running site has been touched.
#
# What crosses the wire: the source tree by rsync (compose.yaml and the
# Dockerfile have to be there for `--server-build` and for reading), and the
# built image by `docker save | gzip | docker load` over the same ssh
# connection. Layers docker already has are still re-sent — this is a save, not
# a registry push — so expect tens of megabytes per deploy.
#
# Two things this script deliberately does not do:
#
# * It never writes .env. Production secrets live on the server and nowhere
# else; the local .env holds the development ones and would silently
# downgrade the deployment if it were copied. rsync --delete cannot remove
# the remote .env either — excluded files are protected from deletion.
# * It never touches the database. Schema changes are applied by the `migrate`
# service, which compose runs before `app` starts, from this same image.
set -euo pipefail
cd "$(dirname "$0")/.."
HOST=attila_personal
REMOTE_DIR=/home/attila/Projects/szabadpalya
IMAGE=szabadpalya
DRY_RUN=0
ASSUME_YES=0
BUILD=1
SERVER_BUILD=0
for arg in "$@"; do
case "$arg" in
--dry-run|-n) DRY_RUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--no-build) BUILD=0 ;;
--server-build) SERVER_BUILD=1 ;;
*) echo "usage: $0 [--dry-run] [--yes] [--no-build] [--server-build]"; exit 2 ;;
esac
done
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
info() { printf ' %s\n' "$1"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
die() { printf ' \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
command -v ssh >/dev/null || die "ssh is missing"
command -v rsync >/dev/null || die "rsync is missing"
command -v docker >/dev/null || die "docker is missing — needed to build the image here"
[ -f compose.yaml ] || die "run this from the repository — compose.yaml not found"
# A tag per revision, so a bad deploy can be rolled back on the server with a
# retag rather than a rebuild. Both tags name one image; `docker save` of the
# pair sends the layers once.
if git rev-parse --git-dir >/dev/null 2>&1; then
TAG=$(git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d-%H%M%S)
[ -n "$(git status --porcelain)" ] && TAG="$TAG-dirty"
else
TAG=$(date +%Y%m%d-%H%M%S)
fi
# ------------------------------------------------------------ connection ----
# One multiplexed connection for the whole deploy. The deploy key is
# passphrase-protected and there is no agent, so without this the passphrase
# would be asked for once per ssh, once for rsync and once for the image.
CTL_DIR=$(mktemp -d)
CTL="$CTL_DIR/ssh.sock"
SSH_OPTS=(-o ControlPath="$CTL")
cleanup() {
ssh -O exit "${SSH_OPTS[@]}" "$HOST" 2>/dev/null || true
rm -rf "$CTL_DIR"
}
trap cleanup EXIT
bold "Connecting to $HOST"
ssh -o ControlMaster=yes -o ControlPersist=600 -o ConnectTimeout=15 \
"${SSH_OPTS[@]}" -fN "$HOST" || die "could not open an ssh connection to $HOST"
remote() { ssh "${SSH_OPTS[@]}" "$HOST" "$@"; }
ok "connected"
# -------------------------------------------------------------- preflight ----
bold "Preflight"
remote 'command -v docker >/dev/null' || die "docker is not installed on $HOST"
remote 'docker compose version >/dev/null 2>&1' \
|| die "the docker compose plugin is missing on $HOST"
remote 'command -v rsync >/dev/null' \
|| die "rsync is not installed on $HOST — sudo apt install rsync"
ok "docker, compose and rsync present"
# An image built here only runs there if the architectures match. Nothing in
# the build declares a platform, so this is the check that would otherwise
# surface as "exec format error" in the app logs after a successful deploy.
if [ "$SERVER_BUILD" = 0 ] && [ "$BUILD" = 1 ]; then
LOCAL_ARCH=$(docker info --format '{{.Architecture}}' 2>/dev/null || uname -m)
REMOTE_ARCH=$(remote 'uname -m')
[ "$LOCAL_ARCH" = "$REMOTE_ARCH" ] \
|| die "architecture mismatch: this machine is $LOCAL_ARCH, $HOST is $REMOTE_ARCH — build there instead with --server-build"
ok "architecture $LOCAL_ARCH on both ends"
fi
remote "mkdir -p $REMOTE_DIR"
# The server refuses to boot without SESSION_SECRET, and a missing .env would
# also hand compose the dev-default Postgres password. Stop here rather than
# half-deploy: nothing has been copied yet.
if ! remote "test -f $REMOTE_DIR/.env"; then
printf '\n \033[31m✗\033[0m no .env on %s at %s\n\n' "$HOST" "$REMOTE_DIR"
# Quoted heredoc, then substituted: the $(openssl ...) below must reach the
# screen literally so that it runs on the server when pasted. Generating the
# secret here would put it in this terminal's scrollback instead.
sed -e "s|@HOST@|$HOST|g" -e "s|@DIR@|$REMOTE_DIR|g" <<'EOF'
Create it there once. It is the only copy of these secrets, and neither this
script nor rsync --delete will ever overwrite or remove it:
ssh @HOST@
mkdir -p @DIR@ && cd @DIR@
umask 077
{ echo "BASE_URL=https://your.real.domain"
echo "SESSION_SECRET=$(openssl rand -base64 32)"
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "ADMIN_EMAILS=mail@attilak.com"
} > .env
BASE_URL must be the real external origin — it decides whether the session
cookie is marked Secure and what host magic links point at. DATABASE_URL is
not needed: compose builds it from POSTGRES_USER/PASSWORD/DB for the app.
The password is hex, not base64, on purpose. compose interpolates it into
postgresql://user:PASSWORD@db:5432/db, and base64's "/" would end the host
there — a password that fails to parse about two times in five.
EOF
exit 1
fi
remote "grep -qE '^SESSION_SECRET=.+' $REMOTE_DIR/.env" \
|| die "SESSION_SECRET is empty in the remote .env — the server will refuse to start"
REMOTE_BASE_URL=$(remote "grep -E '^BASE_URL=' $REMOTE_DIR/.env | head -1 | cut -d= -f2-" || true)
case "$REMOTE_BASE_URL" in
https://*) ok "BASE_URL=$REMOTE_BASE_URL" ;;
"") warn "BASE_URL is not set in the remote .env — it defaults to http://localhost:3000" ;;
*) warn "BASE_URL=$REMOTE_BASE_URL is not https — the session cookie will not be Secure" ;;
esac
remote "grep -qE '^POSTGRES_PASSWORD=.+' $REMOTE_DIR/.env" \
|| warn "POSTGRES_PASSWORD is not set — compose falls back to the dev default (5433 is loopback-only, so this is defence in depth, not an exposure)"
case "$TAG" in
*-dirty) warn "deploying $TAG — the working tree has uncommitted changes" ;;
*) ok "deploying $TAG" ;;
esac
# ------------------------------------------------------------------ copy ----
# --include before the --excludes: rsync takes the first pattern that matches.
# Everything listed here is either rebuilt on the server (dist, node_modules),
# secret (.env), local-only noise (caches, logs) or internal material that has
# no business on a web server (brand/).
RSYNC_FILTER=(
--include '.env.example'
--exclude '.git/'
--exclude '.githooks/'
--exclude 'node_modules/'
--exclude 'web/dist/'
--exclude '.env'
--exclude '.env.*'
--exclude 'brand/'
--exclude 'api/tmp/'
--exclude 'coverage/'
--exclude '*.tsbuildinfo'
--exclude '*.log'
--exclude '.DS_Store'
)
RSYNC_OPTS=(-az --delete --human-readable "${RSYNC_FILTER[@]}"
-e "ssh -o ControlPath=$CTL")
if [ "$DRY_RUN" = 1 ]; then
bold "Dry run — what would change on $HOST:$REMOTE_DIR"
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes ./ "$HOST:$REMOTE_DIR/"
echo
info "nothing was built, copied or restarted"
exit 0
fi
bold "About to deploy"
info "from $PWD"
info "to $HOST:$REMOTE_DIR"
if [ "$BUILD" = 0 ]; then
info "build none — --no-build, the running containers keep serving the old image"
elif [ "$SERVER_BUILD" = 1 ]; then
info "build on $HOST (docker compose up -d --build)"
else
info "build here, shipped as $IMAGE:$TAG"
fi
if [ "$ASSUME_YES" != 1 ]; then
printf '\n Continue? [y/N] '
read -r reply </dev/tty
case "$reply" in [yY]*) ;; *) echo " aborted"; exit 1 ;; esac
fi
# ----------------------------------------------------------------- build ----
# Before the copy: a compile error should stop the deploy while the server is
# still untouched and still serving the previous release.
if [ "$BUILD" = 1 ] && [ "$SERVER_BUILD" = 0 ]; then
bold "Building locally"
docker build --target runner -t "$IMAGE:$TAG" -t "$IMAGE:latest" . \
|| die "the build failed here — $HOST was not touched"
SIZE=$(docker image inspect "$IMAGE:$TAG" --format '{{.Size}}' | awk '{printf "%.0f MB", $1/1048576}')
ok "$IMAGE:$TAG built ($SIZE)"
fi
bold "Copying the source"
rsync "${RSYNC_OPTS[@]}" --info=stats1 ./ "$HOST:$REMOTE_DIR/"
ok "files in place"
[ "$BUILD" = 1 ] || { echo; ok "copied; skipped the build and the restart as asked"; exit 0; }
# ------------------------------------------------------------------ ship ----
if [ "$SERVER_BUILD" = 0 ]; then
bold "Shipping the image"
LOCAL_ID=$(docker image inspect "$IMAGE:$TAG" --format '{{.Id}}')
REMOTE_ID=$(remote "docker image inspect $IMAGE:$TAG --format '{{.Id}}' 2>/dev/null" || true)
if [ "$LOCAL_ID" = "$REMOTE_ID" ]; then
ok "$HOST already has this exact image — nothing to send"
else
# gzip -1: the wire is slower than the CPU on both ends, but only barely,
# and the image is mostly an already-compressed binary.
docker save "$IMAGE:$TAG" "$IMAGE:latest" \
| gzip -1 \
| remote "gunzip | docker load" \
|| die "could not load the image on $HOST"
ok "image loaded on $HOST"
fi
fi
# --------------------------------------------------------------- restart ----
# migrate is a dependency of app, so compose runs the migrations from this same
# image and waits for them to succeed before the new app container takes over.
bold "Restarting on $HOST"
if [ "$SERVER_BUILD" = 1 ]; then
COMPOSE_CMD="docker compose up -d --build"
else
COMPOSE_CMD="docker compose up -d"
fi
remote "cd $REMOTE_DIR && $COMPOSE_CMD" \
|| die "compose failed — ssh $HOST 'cd $REMOTE_DIR && docker compose logs --tail 50'"
# ----------------------------------------------------------------- verify ----
bold "Verifying"
for i in $(seq 1 30); do
if remote "curl -fsS -o /dev/null http://127.0.0.1:3000/api/health"; then
ok "/api/health is answering on the server"
break
fi
if [ "$i" = 30 ]; then
# Print what the server knows before giving up. A refused connection means
# nothing is listening, which is a container that exited rather than a slow
# boot — and the reason for that is only ever in its logs.
printf '\n'
warn "the app never answered; here is what $HOST says"
remote "cd $REMOTE_DIR && docker compose ps -a --format ' {{.Service}}\t{{.Status}}\t{{.Image}}'" || true
printf '\n'
remote "cd $REMOTE_DIR && docker compose logs --tail 30 --no-color app" 2>&1 | sed 's/^/ /' || true
die "deploy did not come up — the containers were left as they are"
fi
sleep 2
done
remote "cd $REMOTE_DIR && docker compose ps --format ' {{.Service}}\t{{.Status}}'" || true
# Keep the three most recent releases and drop the rest. `docker image prune`
# is no use here: every release carries a tag, so it finds nothing dangling and
# reclaims nothing, and `prune -a` would reach for images this project does not
# own. Untagging by name is the only cleanup that is both effective and narrow.
remote "docker images --filter reference=$IMAGE --format '{{.Tag}}' \
| grep -vx latest | tail -n +4 \
| xargs -r -I% docker rmi $IMAGE:% >/dev/null 2>&1
docker image prune -f >/dev/null 2>&1" || true
echo
ok "deployed $TAG${REMOTE_BASE_URL:+ — $REMOTE_BASE_URL}"
info "roll back: ssh $HOST \"cd $REMOTE_DIR && docker tag $IMAGE:<older-tag> $IMAGE:latest && docker compose up -d\""