Move the gateway to junior.gib: CI deploy, compose copy, renewal script, docs
Gateway / check (push) Successful in 7s
Gateway / image (push) Successful in 19s

This commit is contained in:
gib committed 2026-11-01 13:07:18 -05:00
1 parent 27469dbc77
commit f46c803ad4
7 files changed
+62 -47

No files matched your search

+8 -7
View File
@@ -1,11 +1,11 @@
name: Gateway name: Gateway
# Every push touching the gateway: typecheck and tests. On main, also build the image, push it to # 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. # the Gitea registry tagged with the commit and `latest`, and ask junior.gib (the VPS) to deploy it
# Watchtower's nightly pull there is the fallback when the deploy trigger fails. # 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 # 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. # ~/Server/ci-deploy.sh.
on: on:
@@ -79,10 +79,11 @@ jobs:
docker push "$IMAGE:latest" docker push "$IMAGE:latest"
- name: Deploy - 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, and Gitea only masks a secret it can match in full, which a multi-line key defeats.
env: env:
DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_HOME_B64 }} DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_VPS_B64 }}
run: | run: |
install -m600 /dev/null "$HOME/.deploy_key" install -m600 /dev/null "$HOME/.deploy_key"
printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key" printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key"
@@ -90,6 +91,6 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 \ -o ConnectTimeout=15 \
[email protected].1.2 wristcode \ [email protected].2.2 wristcode \
|| echo "::warning::deploy trigger failed; Watchtower will deploy at 04:00 instead" || echo "::warning::deploy trigger failed; the nightly update timer will deploy instead"
rm -f "$HOME/.deploy_key" rm -f "$HOME/.deploy_key"
+10 -9
View File
@@ -25,23 +25,24 @@ Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per enviro
## Running the gateway ## Running the gateway
The gateway is one container with one SQLite file in `/data`. [`deploy/compose.yml`](deploy/compose.yml) The gateway is one container with one SQLite file in `/data`. It runs under rootless podman on
is the stack on server-gib, and [`gateway/Containerfile`](gateway/Containerfile) builds the image. junior.gib, the VPS. [`deploy/compose.yml`](deploy/compose.yml) is a copy of that stack, and
CI builds and deploys every push to `main` that touches `gateway/`. [`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 ```sh
# Connect a T3 Code server. Make the one-time credential on that server first: # 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" 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 <credential> --label "Mac mini" podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <credential> --label "Mac mini"
# Pair a watch: enter the printed code on the watch within 5 minutes. # 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 podman exec -it wristcode-gateway wristcode host list
docker exec -it wristcode-gateway wristcode device list podman exec -it wristcode-gateway wristcode device list
docker exec -it wristcode-gateway wristcode device revoke <id or name> podman exec -it wristcode-gateway wristcode device revoke <id or name>
``` ```
The gateway exchanges the credential for a 30 day T3 session limited to reading and driving The gateway exchanges the credential for a 30 day T3 session limited to reading and driving
+20 -11
View File
@@ -1,27 +1,36 @@
# The gateway on server-gib, at ~/Server/Wristcode/compose.yml. Nginx Proxy Manager serves # A copy of the stack on junior.gib (the VPS). The live file is server/vps/Wristcode/compose.yml in
# wristcode.gbrown.org and forwards to wristcode-gateway:8080 over the nginx-bridge network. # gib/Panama, symlinked into ~/Server/Wristcode/, beside podman-wristcode.service.
# CI deploys new images through ~/Server/ci-deploy.sh. Watchtower's nightly pull is the fallback.
# #
# .env beside this file (mode 600) holds the APNs settings: # WristCode gateway: the backend for the WristCode Apple Watch app (git.gbrown.org/gib/wristcode).
# APNS_KEY_ID=... the key's ID from the Apple Developer portal # 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_TEAM_ID=GQJNM5YK2M
# APNS_KEY_PATH=/data/apns.p8 the .p8 file, placed in ./data # APNS_KEY_PATH=/data/apns.p8 the .p8 file, in ./data
name: wristcode #
# Operator CLI: podman exec -it wristcode-gateway wristcode <host add|pair|device list|...>
networks: networks:
nginx-bridge: nginx-bridge:
external: true external: true # ALWAYS external; see AGENTS.md §5
services: services:
wristcode-gateway: wristcode-gateway:
image: git.gbrown.org/gib/wristcode-gateway:latest image: git.gbrown.org/gib/wristcode-gateway:latest
container_name: wristcode-gateway container_name: wristcode-gateway
hostname: wristcode-gateway hostname: wristcode-gateway
domainname: wristcode.gbrown.org
networks: ['nginx-bridge'] networks: ['nginx-bridge']
env_file: [.env] env_file: [.env]
environment: environment:
- TZ=America/New_York - 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: volumes:
- ./data:/data - ./data:/data:z # lowercase :z survives update-containers recreates, as in Agentchat
labels: ['com.centurylinklabs.watchtower.enable=true']
restart: unless-stopped restart: unless-stopped
healthcheck:
test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1
interval: 30s
start_period: 10s
+17 -15
View File
@@ -10,7 +10,7 @@ builds on it.
## Shape ## 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 +--HTTP/2--> APNs --> watch notifications
``` ```
@@ -51,8 +51,8 @@ recommends. It needs no Effect and no vendored contracts.
```sh ```sh
# on the T3 host # on the T3 host
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway" t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
# on server-gib # on junior.gib
docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential> podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential>
``` ```
The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores 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 typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes
`git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets `git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets
`REGISTRY_USER` and `REGISTRY_PASSWORD`. `REGISTRY_USER` and `REGISTRY_PASSWORD`.
- On server-gib the stack lives in `~/Server/Wristcode/compose.yml`, modeled on `~/Server/Gbrown`. - The stack runs under rootless podman on junior.gib, the VPS, beside Gitea and most other
It joins `nginx-bridge`, publishes no host ports, bind mounts `./data`, and carries the watchtower services. Its compose file and systemd user unit live in gib/Panama under
label. The APNs key and other config come from a mode 600 `.env`. `server/vps/Wristcode/` and are symlinked into `~/Server/Wristcode/` and
- Nginx Proxy Manager gets a proxy host for `wristcode.gbrown.org` that points at `~/.config/systemd/user/`. It joins `nginx-bridge`, publishes no host ports, and bind mounts
`wristcode-gateway:8080`, using the `*.gbrown.org` certificate. DNS and the Cloudflare tunnel `./data`. The APNs key and other config come from a mode 600 `.env`.
already cover the subdomain. - junior's Nginx Proxy Manager serves `wristcode.gbrown.org` and forwards to
- Deploys: after pushing the image, CI calls `~/Server/ci-deploy.sh wristcode` on server-gib over `wristcode-gateway:8080`. The Cloudflare record is proxied, as every VPS hostname must be.
SSH with a deploy key, which pulls and restarts the stack. Watchtower's nightly pull is the - Deploys: after pushing the image, CI SSHes to junior's WireGuard address with a deploy key
fallback. 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. - Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds.
## Watch app ## 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. 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 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. 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. proxy host. All four hosts are added.
4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator 4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator
against the deployed gateway. 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 - Threads started from the watch inherit the host's default runtime mode (`full-access` on these
hosts). hosts).
- CI deploys right away through a `wristcode` entry in `~/Server/ci-deploy.sh`, with Watchtower's - The gateway runs on junior.gib, the VPS, which is more reliable than the home server. CI deploys
nightly pull as the fallback. 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 - 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 around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also
its APNs topic. its APNs topic.
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY package.json tsconfig.json ./ COPY package.json tsconfig.json ./
COPY src ./src COPY src ./src
# `wristcode <command>` for operators: docker exec -it wristcode-gateway wristcode pair # `wristcode <command>` for operators: podman exec -it wristcode-gateway wristcode pair
RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/wristcode \ RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/wristcode \
&& chmod +x /usr/local/bin/wristcode \ && chmod +x /usr/local/bin/wristcode \
&& mkdir -p /data && chown bun:bun /data && mkdir -p /data && chown bun:bun /data
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// Operator commands. They edit the same SQLite file the running gateway reads, so changes apply // 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 <command>`. // within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode <command>`.
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts"; import { openDb } from "./db.ts";
+5 -3
View File
@@ -8,7 +8,9 @@
# scripts/renew-hosts.sh "Mac mini" renew one host by label # scripts/renew-hosts.sh "Mac mini" renew one host by label
set -euo pipefail 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}" GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-wristcode-gateway}"
# label | SSH host, or "local" for the machine running this script | public T3 URL # label | SSH host, or "local" for the machine running this script | public T3 URL
@@ -46,7 +48,7 @@ renew() {
fi fi
# The credential travels on stdin, not on a command line other processes could read. # The credential travels on stdin, not on a command line other processes could read.
ssh -o BatchMode=yes "$GATEWAY_SSH" \ 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" <<<"$credential"
} }
@@ -57,5 +59,5 @@ for entry in "${HOSTS[@]}"; do
renew "$label" "$ssh_host" "$url" || failed=1 renew "$label" "$ssh_host" "$url" || failed=1
done 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" exit "$failed"