|
|
|
|
@ -0,0 +1,296 @@
|
|
|
|
|
#!/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\""
|