diff --git a/SUBSTRATE.md b/SUBSTRATE.md index 1dd5a79..2f8db03 100644 --- a/SUBSTRATE.md +++ b/SUBSTRATE.md @@ -20,7 +20,7 @@ boot guard in `api/app/main.py` that refuses to start there in production. | Port | Service | |---|---| | 8600 | `windy-git-api` — our plane | -| 3000 | Gitea | +| **3080** | Gitea — host 3000 and 3300 are taken by resident projects on Veron 1 | | 5432 | Postgres | | 2000 | cloudflared metrics (probe target) | @@ -30,7 +30,7 @@ Zone `windygit.com` = `9d8637dcac3415607b2116e6099fe567` · account `193b347aede | Hostname | → | |---|---| -| `app.windygit.com` | Gitea :3000 — UI **and** git over HTTPS | +| `app.windygit.com` | Gitea :3080 — UI **and** git over HTTPS | | `api.windygit.com` | our plane :8600 | | `models.windygit.com` | HF-compatible endpoint :8600 (v2) | | `windygit.com` | Cloudflare Pages — marketing, Grant-gated | diff --git a/api/app/config.py b/api/app/config.py index 6328ee3..e8af3b1 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -50,6 +50,12 @@ class Settings(BaseSettings): # ---- account-server OIDC (human identity) ----------------------------- account_server_base_url: str = "https://account.windyword.ai" + # cloudflared binds its metrics on the HOST, so from inside a container + # `localhost` is the wrong box. A health check that is permanently red is as + # useless as one that is permanently green -- it trains people to ignore the + # dashboard, which is how a 37-day-dead fleet canary goes unnoticed. + tunnel_metrics_url: str = "http://host.docker.internal:2000/metrics" + # ---- storage law (I-3, G4.4) ------------------------------------------ # Git object databases MUST live on a POSIX filesystem. A test asserts this # path does not resolve to a network mount. diff --git a/api/app/providers/registry.py b/api/app/providers/registry.py index 3880910..c4945ad 100644 --- a/api/app/providers/registry.py +++ b/api/app/providers/registry.py @@ -122,7 +122,7 @@ class TunnelProvider(Provider): async def probe(self) -> ProbeResult: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: try: - r = await client.get("http://localhost:2000/metrics") + r = await client.get(self._s.tunnel_metrics_url) except httpx.RequestError as exc: return ProbeResult(False, f"cloudflared metrics unreachable: {exc}") return ProbeResult(r.status_code == 200, f"cloudflared metrics -> {r.status_code}", True) diff --git a/docker-compose.yml b/docker-compose.yml index 37b0697..0e5aee6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: # nothing needs to be reachable from the LAN, let alone the internet (G1.6). ports: ["127.0.0.1:${API_PORT:-8600}:8600"] depends_on: {db: {condition: service_healthy}} + # cloudflared runs on the host, not in this network. Without this the tunnel + # probe is permanently red and stops meaning anything. + extra_hosts: ["host.docker.internal:host-gateway"] restart: unless-stopped gitea: diff --git a/docs/RUNBOOK-VERON.md b/docs/RUNBOOK-VERON.md new file mode 100644 index 0000000..b915e02 --- /dev/null +++ b/docs/RUNBOOK-VERON.md @@ -0,0 +1,90 @@ +# RUNBOOK — Windy Git on Veron 1 (rung R0) + +Host `Veron-1-5090`, WireGuard `10.10.0.6`, alias `wg-veron`. Passwordless sudo. + +⛔ **Kit 0 is never a host for this service** (D-4). `api/app/main.py` refuses to +boot in production if it finds itself on `72.60.118.54`. + +## Layout + +| Path | Holds | +|---|---| +| `/srv/windygit/src` | the deploy checkout (clone of `sneakyfree/windy-git`) | +| `/srv/windygit/git` | **git object databases + Gitea data** — local NVMe, truth (I-3) | +| `/srv/windygit/src/data/pg` | Postgres data | +| `/etc/cloudflared/config.yml` | tunnel ingress | +| `/etc/cloudflared/windy-git.json` | tunnel credentials, mode 600 | +| `/srv/windygit/src/.env` | secrets, mode 600, **never committed** | + +## Ports — all loopback, on purpose + +| Port | Service | +|---|---| +| `127.0.0.1:3080` | Gitea (host 3000 is a resident node dev server; 3300 is nginx — **do not fight them for a port**) | +| `127.0.0.1:8600` | windy-git API | +| `127.0.0.1:2000` | cloudflared metrics | + +**No inbound port is opened.** cloudflared dials out, so the dynamic residential +IP is irrelevant and there is no firewall hole to maintain. + +## Start / stop + +```bash +ssh wg-veron +cd /srv/windygit/src +sudo docker compose ps +sudo docker compose logs -f api +sudo systemctl status windygit-tunnel +``` + +## Deploy + +```bash +ssh wg-veron +cd /srv/windygit/src && git pull +export COMMIT_SHA_BUILD=$(git rev-parse HEAD) BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) +sudo -E docker compose up -d --build +curl -s https://api.windygit.com/version # MUST equal git rev-parse HEAD +``` + +⚠️ **Never put `COMMIT_SHA` in `.env`.** It does nothing here — the sha is baked +into the image and a runtime override is ignored with a warning (I-12). That env +pin is the documented root cause of nine sibling services misreporting their +commit, and one reporting another repo's commit entirely. + +## Verify (the four things that must be true) + +```bash +curl -s https://api.windygit.com/version | jq # source must be "baked" +curl -s https://api.windygit.com/health/full | jq # degraded is HONEST, not broken +curl -sI https://app.windygit.com/ | head -1 # Gitea, 200 +sudo ss -tlnp | grep -E "3080|8600" # both must be 127.0.0.1 +``` + +## Troubleshooting + +**A hostname returns 530 or won't resolve** — the tunnel is down. `sudo systemctl +restart windygit-tunnel`, then `journalctl -u windygit-tunnel -n 50`. + +**TLS handshake fails with `curl` exit 35 and no HTTP status at all** — someone +added a **two-level** hostname. Free Universal SSL covers `windygit.com` and +`*.windygit.com` only. The request dies before the tunnel is consulted, so it +presents as "the app is broken" when the app is perfect. Either go back to a +single level or buy Advanced Certificate Manager ($10/mo). + +**Port bind fails on `docker compose up`** — a resident project took the port. +Set `GITEA_PORT` / `API_PORT` in `.env` and update `/etc/cloudflared/config.yml` +to match. **Never stop another project's container to free a port.** + +**`/health/full` says degraded** — that is the design (I-8). Read `checks`: an +unconfigured provider is honest, not broken. R2, Gitea admin token and Eternitas +are wired in strands G2–G4. + +## Promotion to R1 (first external push) + +R0's honest limits: no SLA, it is Grant's workstation, and there are no +VPS-style snapshots. All acceptable while Grant is the only user; all +disqualifying the moment a stranger depends on it. **The trigger is not a date — +it is the first external push.** Move the control plane to a dedicated VPS (not +Kit 0), keep Veron 1 as the runner. It is an rsync, a Postgres dump and three +DNS record edits.