From f46c803ad49a121527f07c786a874aa67a96a14b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 1 Nov 2026 13:07:18 -0500 Subject: [PATCH] Move the gateway to junior.gib: CI deploy, compose copy, renewal script, docs --- .gitea/workflows/gateway.yml | 15 ++++++++------- README.md | 19 ++++++++++--------- deploy/compose.yml | 31 ++++++++++++++++++++----------- docs/plan.md | 32 +++++++++++++++++--------------- gateway/Containerfile | 2 +- gateway/src/cli.ts | 2 +- scripts/renew-hosts.sh | 8 +++++--- 7 files changed, 62 insertions(+), 47 deletions(-) diff --git a/.gitea/workflows/gateway.yml b/.gitea/workflows/gateway.yml index ebe2471..e45ee3e 100644 --- a/.gitea/workflows/gateway.yml +++ b/.gitea/workflows/gateway.yml @@ -1,11 +1,11 @@ name: Gateway # Every push touching the gateway: typecheck and tests. On main, also build the image, push it to -# the Gitea registry tagged with the commit and `latest`, and ask server-gib to deploy it now. -# Watchtower's nightly pull there is the fallback when the deploy trigger fails. +# the Gitea registry tagged with the commit and `latest`, and ask junior.gib (the VPS) to deploy it +# now. Its nightly update-containers timer is the fallback when the deploy trigger fails. # # Uses the user-level secrets REGISTRY_USER and REGISTRY_PASSWORD, and the repo secret -# DEPLOY_SSH_KEY_HOME_B64, a base64 encoded key that server-gib only accepts for +# DEPLOY_SSH_KEY_VPS_B64, a base64 encoded key that junior.gib only accepts for # ~/Server/ci-deploy.sh. on: @@ -79,10 +79,11 @@ jobs: docker push "$IMAGE:latest" - name: Deploy - # The secret is the private key base64 encoded on one line. The runner prints each step's + # 192.168.2.2 is junior's WireGuard address, reachable from every runner. The secret is the + # private key base64 encoded on one line. The runner prints each step's # env, and Gitea only masks a secret it can match in full, which a multi-line key defeats. env: - DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_HOME_B64 }} + DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_VPS_B64 }} run: | install -m600 /dev/null "$HOME/.deploy_key" printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key" @@ -90,6 +91,6 @@ jobs: -o BatchMode=yes \ -o StrictHostKeyChecking=accept-new \ -o ConnectTimeout=15 \ - gib@192.168.1.2 wristcode \ - || echo "::warning::deploy trigger failed; Watchtower will deploy at 04:00 instead" + gib@192.168.2.2 wristcode \ + || echo "::warning::deploy trigger failed; the nightly update timer will deploy instead" rm -f "$HOME/.deploy_key" diff --git a/README.md b/README.md index a9e7124..2d2512e 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,24 @@ Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per enviro ## Running the gateway -The gateway is one container with one SQLite file in `/data`. [`deploy/compose.yml`](deploy/compose.yml) -is the stack on server-gib, and [`gateway/Containerfile`](gateway/Containerfile) builds the image. -CI builds and deploys every push to `main` that touches `gateway/`. +The gateway is one container with one SQLite file in `/data`. It runs under rootless podman on +junior.gib, the VPS. [`deploy/compose.yml`](deploy/compose.yml) is a copy of that stack, and +[`gateway/Containerfile`](gateway/Containerfile) builds the image. CI builds and deploys every push +to `main` that touches `gateway/`. -Operators use the `wristcode` CLI inside the container: +Operators use the `wristcode` CLI inside the container, on junior.gib: ```sh # Connect a T3 Code server. Make the one-time credential on that server first: t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway" -docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org --label "Mac mini" +podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org --label "Mac mini" # Pair a watch: enter the printed code on the watch within 5 minutes. -docker exec -it wristcode-gateway wristcode pair +podman exec -it wristcode-gateway wristcode pair -docker exec -it wristcode-gateway wristcode host list -docker exec -it wristcode-gateway wristcode device list -docker exec -it wristcode-gateway wristcode device revoke +podman exec -it wristcode-gateway wristcode host list +podman exec -it wristcode-gateway wristcode device list +podman exec -it wristcode-gateway wristcode device revoke ``` The gateway exchanges the credential for a 30 day T3 session limited to reading and driving diff --git a/deploy/compose.yml b/deploy/compose.yml index aff0f5a..0c73875 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -1,27 +1,36 @@ -# The gateway on server-gib, at ~/Server/Wristcode/compose.yml. Nginx Proxy Manager serves -# wristcode.gbrown.org and forwards to wristcode-gateway:8080 over the nginx-bridge network. -# CI deploys new images through ~/Server/ci-deploy.sh. Watchtower's nightly pull is the fallback. +# A copy of the stack on junior.gib (the VPS). The live file is server/vps/Wristcode/compose.yml in +# gib/Panama, symlinked into ~/Server/Wristcode/, beside podman-wristcode.service. # -# .env beside this file (mode 600) holds the APNs settings: -# APNS_KEY_ID=... the key's ID from the Apple Developer portal +# WristCode gateway: the backend for the WristCode Apple Watch app (git.gbrown.org/gib/wristcode). +# It holds a WebSocket to every T3 Code server, serves the watch's HTTPS API, and sends APNs pushes. +# NPM serves wristcode.gbrown.org and forwards to wristcode-gateway:8080 over nginx-bridge. +# +# .env beside this file (mode 600): +# APNS_KEY_ID=... the APNs key's ID # APNS_TEAM_ID=GQJNM5YK2M -# APNS_KEY_PATH=/data/apns.p8 the .p8 file, placed in ./data -name: wristcode - +# APNS_KEY_PATH=/data/apns.p8 the .p8 file, in ./data +# +# Operator CLI: podman exec -it wristcode-gateway wristcode networks: nginx-bridge: - external: true + external: true # ALWAYS external; see AGENTS.md §5 services: wristcode-gateway: image: git.gbrown.org/gib/wristcode-gateway:latest container_name: wristcode-gateway hostname: wristcode-gateway + domainname: wristcode.gbrown.org networks: ['nginx-bridge'] env_file: [.env] environment: - TZ=America/New_York + # The image runs as `bun` (uid 1000). keep-id maps it to gib, so ./data stays gib-owned (AGENTS.md §3.2). + userns_mode: "keep-id:uid=1000,gid=1000" volumes: - - ./data:/data - labels: ['com.centurylinklabs.watchtower.enable=true'] + - ./data:/data:z # lowercase :z survives update-containers recreates, as in Agentchat restart: unless-stopped + healthcheck: + test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1 + interval: 30s + start_period: 10s diff --git a/docs/plan.md b/docs/plan.md index e3b9d46..4217fc7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -10,7 +10,7 @@ builds on it. ## Shape ``` -watchOS app --HTTPS + device token--> gateway (Bun, server-gib) --WebSocket + bearer--> T3 server x N +watchOS app --HTTPS + device token--> gateway (Bun, junior.gib) --WebSocket + bearer--> T3 server x N | +--HTTP/2--> APNs --> watch notifications ``` @@ -51,8 +51,8 @@ recommends. It needs no Effect and no vendored contracts. ```sh # on the T3 host t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway" -# on server-gib -docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org +# on junior.gib +podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org ``` The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores @@ -124,15 +124,16 @@ to end check is a manual run against server-gib's own T3 before deploying. typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes `git.gbrown.org/gib/wristcode-gateway:` and `:latest`. It needs the repo secrets `REGISTRY_USER` and `REGISTRY_PASSWORD`. -- On server-gib the stack lives in `~/Server/Wristcode/compose.yml`, modeled on `~/Server/Gbrown`. - It joins `nginx-bridge`, publishes no host ports, bind mounts `./data`, and carries the watchtower - label. The APNs key and other config come from a mode 600 `.env`. -- Nginx Proxy Manager gets a proxy host for `wristcode.gbrown.org` that points at - `wristcode-gateway:8080`, using the `*.gbrown.org` certificate. DNS and the Cloudflare tunnel - already cover the subdomain. -- Deploys: after pushing the image, CI calls `~/Server/ci-deploy.sh wristcode` on server-gib over - SSH with a deploy key, which pulls and restarts the stack. Watchtower's nightly pull is the - fallback. +- The stack runs under rootless podman on junior.gib, the VPS, beside Gitea and most other + services. Its compose file and systemd user unit live in gib/Panama under + `server/vps/Wristcode/` and are symlinked into `~/Server/Wristcode/` and + `~/.config/systemd/user/`. It joins `nginx-bridge`, publishes no host ports, and bind mounts + `./data`. The APNs key and other config come from a mode 600 `.env`. +- junior's Nginx Proxy Manager serves `wristcode.gbrown.org` and forwards to + `wristcode-gateway:8080`. The Cloudflare record is proxied, as every VPS hostname must be. +- Deploys: after pushing the image, CI SSHes to junior's WireGuard address with a deploy key + pinned to `~/Server/ci-deploy.sh`, which pulls and restarts the stack. The nightly + `update-containers` timer is the fallback. - Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds. ## Watch app @@ -181,7 +182,7 @@ Each phase ends with something usable. pairing, and `GET /v1/feed`. Checked against server-gib's T3 from the Fedora desktop. 2. **Gateway write path and pairing.** Send, respond, stop, launch, the projects route, watch pairing codes, and device tokens. Checked with curl against a real thread. -3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on server-gib, and the NPM +3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on junior.gib, and the NPM proxy host. All four hosts are added. 4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator against the deployed gateway. @@ -198,8 +199,9 @@ they push to `docs/`. - Threads started from the watch inherit the host's default runtime mode (`full-access` on these hosts). -- CI deploys right away through a `wristcode` entry in `~/Server/ci-deploy.sh`, with Watchtower's - nightly pull as the fallback. +- The gateway runs on junior.gib, the VPS, which is more reliable than the home server. CI deploys + right away through junior's `~/Server/ci-deploy.sh`, with the nightly update timer as the + fallback. - The App Store record uses `org.gbrown.wristcode`, an empty iOS wrapper the App Store requires around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also its APNs topic. diff --git a/gateway/Containerfile b/gateway/Containerfile index a472410..e86af26 100644 --- a/gateway/Containerfile +++ b/gateway/Containerfile @@ -9,7 +9,7 @@ WORKDIR /app COPY package.json tsconfig.json ./ COPY src ./src -# `wristcode ` for operators: docker exec -it wristcode-gateway wristcode pair +# `wristcode ` for operators: podman exec -it wristcode-gateway wristcode pair RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/wristcode \ && chmod +x /usr/local/bin/wristcode \ && mkdir -p /data && chown bun:bun /data diff --git a/gateway/src/cli.ts b/gateway/src/cli.ts index f20f031..66197de 100755 --- a/gateway/src/cli.ts +++ b/gateway/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun // Operator commands. They edit the same SQLite file the running gateway reads, so changes apply -// within a few seconds. In the container: `docker exec -it wristcode-gateway wristcode `. +// within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode `. import { loadConfig } from "./config.ts"; import { openDb } from "./db.ts"; diff --git a/scripts/renew-hosts.sh b/scripts/renew-hosts.sh index 858bdc5..6617a30 100755 --- a/scripts/renew-hosts.sh +++ b/scripts/renew-hosts.sh @@ -8,7 +8,9 @@ # scripts/renew-hosts.sh "Mac mini" renew one host by label set -euo pipefail -GATEWAY_SSH="${GATEWAY_SSH:-server.gib}" +GATEWAY_SSH="${GATEWAY_SSH:-junior.gib}" +# The container runtime on the gateway host: podman on junior, docker on the home server. +GATEWAY_RUNTIME="${GATEWAY_RUNTIME:-podman}" GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-wristcode-gateway}" # label | SSH host, or "local" for the machine running this script | public T3 URL @@ -46,7 +48,7 @@ renew() { fi # The credential travels on stdin, not on a command line other processes could read. ssh -o BatchMode=yes "$GATEWAY_SSH" \ - "read -r credential && docker exec $GATEWAY_CONTAINER wristcode host add '$url' \"\$credential\" --label '$label'" \ + "read -r credential && $GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host add '$url' \"\$credential\" --label '$label'" \ <<<"$credential" } @@ -57,5 +59,5 @@ for entry in "${HOSTS[@]}"; do renew "$label" "$ssh_host" "$url" || failed=1 done -ssh -o BatchMode=yes "$GATEWAY_SSH" "docker exec $GATEWAY_CONTAINER wristcode host list" | cut -f1,4 +ssh -o BatchMode=yes "$GATEWAY_SSH" "$GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host list" | cut -f1,4 exit "$failed"