diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..deb1345 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,71 @@ +# Deploying drinktracker + +```bash +./deploy/deploy.sh # typecheck, push, build on the LXC, restart, verify +./deploy/deploy.sh --yes # skip the confirmation prompt +./deploy/deploy.sh --push # also publish the image to the Gitea registry +``` + +## Where things actually live + +| | | +|---|---| +| Production LXC | `192.168.2.169` (hostname `drinktracker`) | +| Deploy user | `drinkadmin`, key `~/.ssh/drinktracker_ed25519` | +| Live compose dir | `/root/drinktracker` | +| Image tag | `192.168.2.140:3000/jpscott84/drinktracker:latest` | +| App port | 3000 (host network), unauthenticated requests return 307 → `/login` | + +Two traps worth knowing: + +**`drinktracker.tenseconddelay.net` is the reverse proxy (192.168.2.172), not the container.** `NEXTAUTH_URL` points at the public name, so resolving it and SSHing there fails confusingly — the key looks rejected because it's a different machine. + +**The two checkouts under `/home/drinkadmin/` are stale.** Only `/root/drinktracker` is live; that's what the running container's compose labels point to. + +## How it works + +The image is built **on the LXC** and tagged with the registry name that `docker-compose.prod.yml` already references. Compose finds it locally and never pulls, so no registry credentials are needed on either machine. `--push` publishes it as well, which is only useful if something else pulls that tag. + +There is no CI, so **`git push` on its own deploys nothing** — it only updates the Gitea repo. The build and restart have to happen separately, which is what this script is for. + +## One-time setup + +The script needs passwordless sudo on the LXC, because the deployment lives under `/root`. On the container as root: + +```bash +echo 'drinkadmin ALL=(root) NOPASSWD: ALL' > /etc/sudoers.d/drinkadmin-deploy +chmod 440 /etc/sudoers.d/drinkadmin-deploy +visudo -c +``` + +This grants less than it appears to: `drinkadmin` is already in the `docker` group, and anyone who can talk to the Docker socket can mount the host filesystem into a privileged container, so they are root-equivalent already. If you'd rather not, drop the sudoers file and run the deploy steps by hand from a root shell. + +**Alternative worth considering:** move the deployment out of `/root` into a `drinkadmin`-owned directory and skip sudo entirely. Keep the directory basename `drinktracker` so the compose project name — and therefore the existing container and volume names — stay the same. `.env.production` must be copied across intact or Postgres will reject the app's credentials, since the volume was initialised with the current password. + +## Verifying by hand + +```bash +ssh -i ~/.ssh/drinktracker_ed25519 drinkadmin@192.168.2.169 +sudo docker compose -f /root/drinktracker/docker-compose.prod.yml ps +sudo docker logs drinktracker-app-1 --tail 50 +sudo docker logs drinktracker-app-1 2>&1 | grep '\[switchboard\]' # AI routing + cost per call +``` + +Each AI call logs one `[switchboard] feature=… model=… cost=… latency_ms=…` line. `FAILOVER` warnings are expected — the gateway's `:batch` model variants currently fail on every request and fall back. + +## Rollback + +```bash +git revert && ./deploy/deploy.sh +``` + +Or directly on the LXC, without touching git history: + +```bash +sudo git -C /root/drinktracker reset --hard +cd /root/drinktracker +sudo docker build -t 192.168.2.140:3000/jpscott84/drinktracker:latest . +sudo docker compose -f docker-compose.prod.yml up -d --no-build +``` + +Note the `migrate` service runs `prisma db push --accept-data-loss` on every start, so a rollback that removes a column will drop it and its data. Take a database dump first if the schema changed. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..5c981b0 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Deploy drinktracker to the production LXC. +# +# The image is built ON the LXC and tagged with the registry name that +# docker-compose.prod.yml expects. Compose then finds it locally and never pulls, so +# this needs no registry credentials on either machine. Pass --push to additionally +# publish the image to the Gitea registry (requires `docker login` on the LXC). +# +# Usage: ./deploy/deploy.sh [--push] [--no-build] [--yes] +# +set -euo pipefail + +HOST="${DT_HOST:-drinkadmin@192.168.2.169}" +SSH_KEY="${DT_SSH_KEY:-$HOME/.ssh/drinktracker_ed25519}" +REMOTE_DIR="${DT_REMOTE_DIR:-/root/drinktracker}" +IMAGE="${DT_IMAGE:-192.168.2.140:3000/jpscott84/drinktracker:latest}" +COMPOSE_FILE="docker-compose.prod.yml" +HEALTH_URL="http://localhost:3000/" + +PUSH=0; BUILD=1; ASSUME_YES=0 +for arg in "$@"; do + case "$arg" in + --push) PUSH=1 ;; + --no-build) BUILD=0 ;; + --yes|-y) ASSUME_YES=1 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +SSH=(ssh -o BatchMode=yes -o ConnectTimeout=10 -i "$SSH_KEY" "$HOST") +say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\n\033[1;31mFAILED: %s\033[0m\n' "$*" >&2; exit 1; } + +# ─── Preflight (local) ─────────────────────────────────────────────── +say "Preflight" +cd "$(dirname "$0")/.." + +[ -n "$(git status --porcelain)" ] && die "working tree is dirty - commit or stash first" +BRANCH=$(git rev-parse --abbrev-ref HEAD) +[ "$BRANCH" = "main" ] || die "on branch '$BRANCH', expected main" + +npx tsc --noEmit || die "typecheck failed" +echo " typecheck ok, tree clean, on main" + +# Push first so the server pulls exactly what was verified here. +git -c credential.helper=store push origin main +LOCAL_SHA=$(git rev-parse HEAD) +echo " pushed $(git rev-parse --short HEAD)" + +# ─── Preflight (remote) ────────────────────────────────────────────── +say "Checking $HOST" +"${SSH[@]}" true || die "cannot reach $HOST with key $SSH_KEY" +"${SSH[@]}" 'sudo -n true' 2>/dev/null \ + || die "passwordless sudo required on the LXC (needed for $REMOTE_DIR). See deploy/README.md" + +# A dirty server checkout means someone edited production by hand; clobbering that +# silently would destroy the only copy of the change. +if ! "${SSH[@]}" "sudo -n git -C $REMOTE_DIR diff --quiet && sudo -n git -C $REMOTE_DIR diff --cached --quiet"; then + die "$REMOTE_DIR has uncommitted changes - inspect before deploying" +fi + +if [ "$ASSUME_YES" -ne 1 ]; then + REMOTE_SHA=$("${SSH[@]}" "sudo -n git -C $REMOTE_DIR rev-parse --short HEAD") + echo " remote is at $REMOTE_SHA, deploying ${LOCAL_SHA:0:7}" + read -r -p " proceed? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || { echo "aborted"; exit 1; } +fi + +# ─── Deploy ────────────────────────────────────────────────────────── +say "Syncing $REMOTE_DIR to $LOCAL_SHA" +"${SSH[@]}" "sudo -n git -C $REMOTE_DIR fetch origin main --quiet && sudo -n git -C $REMOTE_DIR reset --hard $LOCAL_SHA --quiet && sudo -n git -C $REMOTE_DIR log --oneline -1" + +# SWITCHBOARD_BASE_URL arrived with the gateway migration and will be missing from +# any .env.production written before it. Compose supplies a default, but making it +# explicit keeps the file honest about what the app reads. +say "Checking .env.production for new variables" +"${SSH[@]}" "sudo -n grep -q '^SWITCHBOARD_BASE_URL=' $REMOTE_DIR/.env.production" \ + && echo " SWITCHBOARD_BASE_URL present" \ + || echo " NOTE: SWITCHBOARD_BASE_URL absent; compose default (http://192.168.2.11:8787/v1) applies" + +if [ "$BUILD" -eq 1 ]; then + say "Building image on the LXC (this takes a few minutes)" + "${SSH[@]}" "cd $REMOTE_DIR && sudo -n docker build -t $IMAGE ." || die "image build failed" +fi + +if [ "$PUSH" -eq 1 ]; then + say "Pushing image to registry" + "${SSH[@]}" "sudo -n docker push $IMAGE" || die "registry push failed (is docker login done on the LXC?)" +fi + +say "Restarting stack" +# --no-build: compose must use the image we just built, not try to rebuild or pull. +"${SSH[@]}" "cd $REMOTE_DIR && sudo -n docker compose -f $COMPOSE_FILE up -d --no-build" + +# ─── Verify ────────────────────────────────────────────────────────── +say "Verifying" +"${SSH[@]}" "sudo -n docker compose -f $REMOTE_DIR/$COMPOSE_FILE ps --format 'table {{.Name}}\t{{.Status}}'" || true + +ok=0 +for i in $(seq 1 30); do + code=$("${SSH[@]}" "curl -s -o /dev/null -w '%{http_code}' -m 5 $HEALTH_URL" || echo 000) + # 307 is the unauthenticated redirect to /login - a healthy response here. + case "$code" in 200|307|302) echo " app responding (HTTP $code) after ${i}0s"; ok=1; break ;; esac + sleep 10 +done +[ "$ok" -eq 1 ] || die "app did not become healthy - check: ${SSH[*]} 'sudo docker logs drinktracker-app-1 --tail 50'" + +say "Recent gateway activity" +"${SSH[@]}" "sudo -n docker logs drinktracker-app-1 --tail 200 2>&1 | grep '\[switchboard\]' | tail -5 || echo ' (no AI calls yet)'" + +say "Deployed ${LOCAL_SHA:0:7}" +echo "To roll back: ./deploy/deploy.sh after 'git revert', or on the LXC:" +echo " sudo git -C $REMOTE_DIR reset --hard && cd $REMOTE_DIR && sudo docker build -t $IMAGE . && sudo docker compose -f $COMPOSE_FILE up -d --no-build"