Add deploy script and skill for the production LXC

There is no CI, so a git push deploys nothing - the image has to be built
and the stack restarted separately. This captures that as one command
rather than a sequence to remember.

The image is built on the LXC and tagged with the registry name the
compose file already references, so compose finds it locally and never
pulls. That means no registry credentials are needed on either machine.

Also records the two things that cost the most time to work out: the
public hostname resolves to the reverse proxy rather than the container
(192.168.2.169), and the live stack runs from /root/drinktracker while
the checkouts under /home/drinkadmin are stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 17:02:20 +00:00
parent a0e1619072
commit 45d9bd12d7
2 changed files with 185 additions and 0 deletions

114
deploy/deploy.sh Executable file
View File

@@ -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 <previous-sha> && cd $REMOTE_DIR && sudo docker build -t $IMAGE . && sudo docker compose -f $COMPOSE_FILE up -d --no-build"