diff --git a/Dockerfile b/Dockerfile index dd2bbaa..f42238a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,12 +44,6 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["server"] -# Migrations run from the same image and the same code path the server uses, -# so the two can never disagree about what "migrated" means. -FROM runner AS migrator -USER app -CMD ["server", "-migrate"] - # ---------------------------------------------------------------- seeder ---- # The fixture loader, for development and CI only. Its first act is to DELETE # every campaign, organisation and user, so it must not be reachable from the diff --git a/compose.yaml b/compose.yaml index 266571a..dd37875 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,8 +14,12 @@ services: POSTGRES_USER: ${POSTGRES_USER:-szabadpalya} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-szabadpalya} POSTGRES_DB: ${POSTGRES_DB:-szabadpalya} + # Loopback only. Docker publishes ports by writing DNAT rules that bypass + # UFW entirely, so a bare "5433:5432" would put Postgres on the public + # internet no matter what the host firewall says. Development still reaches + # it on localhost:5433, which is all scripts/db.sh ever wanted. ports: - - "5433:5432" + - "127.0.0.1:5433:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -25,10 +29,17 @@ services: retries: 10 # Runs once, migrates, exits. The app starts only after it succeeds. + # + # Same image as app, same binary, one flag different — so the two can never + # disagree about what "migrated" means. That used to be a separate Dockerfile + # stage; a command override says it with less machinery and one less image to + # ship. migrate: + image: szabadpalya:latest + command: ["server", "-migrate"] build: context: . - target: migrator + target: runner environment: DATABASE_URL: postgresql://${POSTGRES_USER:-szabadpalya}:${POSTGRES_PASSWORD:-szabadpalya}@db:5432/${POSTGRES_DB:-szabadpalya} SESSION_SECRET: ${SESSION_SECRET:-} @@ -37,7 +48,11 @@ services: condition: service_healthy restart: "no" + # `image:` is what lets scripts/deploy.sh build on a laptop and ship the + # result: compose uses an image of this name when one is already present and + # only falls back to `build:` when it is not, or when asked with --build. app: + image: szabadpalya:latest build: context: . target: runner @@ -46,8 +61,12 @@ services: SESSION_SECRET: ${SESSION_SECRET:-} BASE_URL: ${BASE_URL:-http://localhost:3000} ADMIN_EMAILS: ${ADMIN_EMAILS:-} + # Loopback only: nginx terminates TLS and proxies to 127.0.0.1:3000. + # Published on every interface this would also answer on http://:3000, + # which is the same site without TLS — and BASE_URL being https there means + # the session cookie is Secure, so logging in that way silently fails. ports: - - "3000:3000" + - "127.0.0.1:3000:3000" depends_on: migrate: condition: service_completed_successfully diff --git a/package.json b/package.json new file mode 100644 index 0000000..a07a021 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "szabadpalya", + "version": "0.1.0", + "private": true, + "description": "Repository root. The frontend is its own package under web/ with its own lockfile; this file exists only so the operational scripts have a name you can run from here.", + "scripts": { + "deploy": "./scripts/deploy.sh", + "ci": "./scripts/ci.sh", + "up": "./scripts/up.sh" + }, + "packageManager": "pnpm@11.8.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9b60ae1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/scripts/ci.sh b/scripts/ci.sh index 854adf2..00fc50e 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -166,7 +166,7 @@ step "seed loads" seed_ci boot_app() { docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$APP_CONTAINER" -p "${APP_PORT}:3000" \ + docker run -d --name "$APP_CONTAINER" -p "127.0.0.1:${APP_PORT}:3000" \ --add-host=host.docker.internal:host-gateway \ --health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=3 \ -e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@host.docker.internal:5433/${CI_DB}" \ @@ -293,7 +293,7 @@ step "docker reports container healthy" check_docker_health # pass as healthy. Uses a throwaway container so the real database is untouched. check_health_degraded() { docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$SICK_CONTAINER" -p "${SICK_PORT}:3000" \ + docker run -d --name "$SICK_CONTAINER" -p "127.0.0.1:${SICK_PORT}:3000" \ --health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=2 \ -e DATABASE_URL="postgresql://nobody:nobody@127.0.0.1:5999/nothing" \ -e SESSION_SECRET="ci-only-secret" \ diff --git a/scripts/db.sh b/scripts/db.sh index bdbaa82..1ebbae6 100755 --- a/scripts/db.sh +++ b/scripts/db.sh @@ -4,6 +4,10 @@ # Uses the docker compose plugin when it is available (that is the case on the # server); when it is not, it brings up the same container with plain docker run. # +# Either way the port is published on 127.0.0.1 only, matching compose.yaml: +# docker's published ports bypass UFW, so loopback is what keeps the database +# off the internet on a host that has a public address. +# # ./scripts/db.sh up | down | psql | logs | reset set -euo pipefail @@ -27,7 +31,7 @@ up() { docker volume create "$VOLUME" >/dev/null docker run -d --name "$NAME" --restart unless-stopped \ -e POSTGRES_USER="$USER" -e POSTGRES_PASSWORD="$PASS" -e POSTGRES_DB="$DB" \ - -p "$PORT:5432" -v "$VOLUME:/var/lib/postgresql/data" \ + -p "127.0.0.1:$PORT:5432" -v "$VOLUME:/var/lib/postgresql/data" \ --health-cmd "pg_isready -U $USER -d $DB" --health-interval 5s \ "$IMAGE" >/dev/null fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..9b3b927 --- /dev/null +++ b/scripts/deploy.sh @@ -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/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: $IMAGE:latest && docker compose up -d\""