pull-request / validate (pull_request) Successful in 32s
The companion SHA tag step failed on the last build even though the image had already been pushed and deployed. Harbor's vulnerability policy blocks manifest reads while Trivy's scan is Running, and `imagetools create` copies the manifest, so Harbor answered 412 Precondition Failed. Two faults compounded. The loop gated on `imagetools inspect`, but a probe passing is no guarantee the copy will be allowed. And `create` sat outside the condition, so under the default `bash -e` its first failure aborted the step before `exit 0` or any further attempt: the log shows one waiting line, then the error, with 58 of 60 retries unused. Retry the real operation instead, inside the condition so a blocked copy is a reason to wait rather than fatal. The trade-off is that a permanently failing copy now takes the full ten minutes to report instead of failing fast; the previous behaviour failed fast but wrongly, on a transient block. Also records that only the primary tag matters for rollout, since the Flux image policy matches the timestamp tag, so a failure in this step costs the audit tag rather than the deployment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
6.1 KiB
Markdown
115 lines
6.1 KiB
Markdown
# AGENTS.md
|
|
|
|
Bitcoin price tracker UI — Python FastAPI web application. Serves the HTML/Tailwind UI and
|
|
proxies live price data from btcdata over WebSocket.
|
|
|
|
## Build & Development Commands
|
|
|
|
- `make run` — Start dev server on port 4000; creates `.venv` and installs `requirements.txt`
|
|
on first run (the `venv` target, keyed off `requirements.txt`)
|
|
- `cp .env.example .env` — optional; `.env` is `-include`d, so a missing one is not fatal
|
|
- `make run-local` — Same, but with `BTCDATA_URL=$BTCDATA_LOCAL_URL`
|
|
- `make tailwind` — Watch Tailwind CSS for changes
|
|
- `make test` — Installs test-only dependencies from `requirements-dev.txt` and runs `pytest tests/`
|
|
- `make venv` — Bootstrap the virtualenv without starting the server
|
|
|
|
## Docker
|
|
|
|
Docker runs alongside local dev on different ports:
|
|
|
|
| Service | Local dev | Docker |
|
|
|----------|----------------|--------------------------------------|
|
|
| btcprice | localhost:4000 | localhost:4041 (container port 4040) |
|
|
| btcdata | localhost:4100 | localhost:4102 |
|
|
|
|
`docker compose up -d` here starts **btcprice only**. Both projects attach to the external
|
|
`btc-network` (`docker network create btc-network` once), and btcdata must be started from
|
|
`../btcdata`. Inside the network btcprice reaches it at `http://btcdata:${DOCKER_BTCDATA_PORT:-4102}`.
|
|
|
|
The image does not set `BTCDATA_URL`; deployments must provide it. `BUILD_SHA` is embedded as
|
|
`APP_VERSION` and exposed at `/api/version`. The port in `CMD` is hardcoded to 4040.
|
|
|
|
## CI/CD and production
|
|
|
|
`.gitea/workflows/build.yml` tests pushes to `main` and `v*` tags on the `anvil` Gitea Actions
|
|
runner, then publishes immutable linux/amd64 images to
|
|
`harbor.pmagnus.cloud/btcprice/btcprice`. It embeds the short Git SHA as `APP_VERSION`.
|
|
|
|
The build pushes two tags: `main-<timestamp>`, then a companion `main-<timestamp>-<sha>` for
|
|
audit. Harbor's vulnerability policy blocks manifest reads while Trivy is still scanning, so
|
|
the companion tag is retried until the scan clears. Only the first tag matters for deployment —
|
|
the Flux image policy matches `^main-<14 digits>$` — so a failure in that last step costs the
|
|
audit tag and nothing else.
|
|
|
|
Flux image automation updates the manifests in the separate `magnus/deploy` repository and Flux
|
|
reconciles k3s. CI has no Kubernetes credentials. Production runs two replicas—one on hostinger and
|
|
one on citadel—and is served at https://btcprice.k8s.pmagnus.cloud through ingress-nginx and
|
|
hostinger Traefik TLS. Deployment changes belong in `../deploy/apps/btcprice`; do not deploy with
|
|
Compose or manual `kubectl apply`.
|
|
|
|
## Legacy deployment
|
|
|
|
`./deploy-citadel.sh` — build locally for the remote's arch, `docker save | ssh docker load`,
|
|
`docker compose up -d --no-build` in `/home/pmp/apps/btcprice` on citadel (192.168.50.195).
|
|
Mirrors `../elprice/deploy-citadel.sh`; no registry.
|
|
|
|
- `docker-compose.citadel.yml` → remote `docker-compose.yml`; `.env.citadel` → remote `.env`.
|
|
- btcdata also runs on citadel, so both attach to the external `btc-network` there and
|
|
`BTCDATA_URL` is `http://btcdata:4102` (service name, not a LAN address). The deploy script
|
|
creates the network on the remote if it is missing.
|
|
- Temporary migration fallback at http://192.168.50.195:4041; it is not the production endpoint.
|
|
|
|
## Architecture
|
|
|
|
- **Framework:** FastAPI
|
|
- **Server:** Uvicorn
|
|
- **Templates:** Inline HTML strings + HTMX (WebSocket extension)
|
|
- **Styling:** Tailwind CSS v4
|
|
- **Data backend:** btcdata (separate service at `BTCDATA_URL`, default `http://localhost:4100`)
|
|
- **No database** — all data comes from btcdata
|
|
- **Probes:** `/api/health` is process-only liveness; `/api/ready` checks btcdata with a 3-second
|
|
timeout; `/api/version` exposes the build identifier
|
|
|
|
### btcdata connection
|
|
|
|
The browser connects only to btcprice: `ws-connect="/ws/price"` and `ws-connect="/ws/strike"`.
|
|
Each endpoint runs `_ws_proxy`, which opens an upstream WebSocket to btcdata's `/api/price/ws`
|
|
and `/api/strike/ws`, converts each JSON message to an HTML fragment (`price_json_to_html`,
|
|
`strike_json_to_html`) and forwards it. `BTCDATA_URL` is the single point of contact;
|
|
`_btcdata_ws_url` rewrites its scheme to `ws`/`wss`.
|
|
|
|
### Recovering from a btcdata restart
|
|
|
|
htmx's WebSocket extension reconnects only after an abnormal close
|
|
(1006/1011/1012/1013); a normal 1000 close means "the server is done" and the panel stays
|
|
frozen until the visitor reloads. So a btcdata restart must never reach the browser as a
|
|
normal close:
|
|
|
|
- `_ws_proxy` reconnects upstream behind the still-open browser socket, on the bounded
|
|
`UPSTREAM_RETRY_DELAYS_SECONDS` schedule. An ordinary restart is invisible to the page —
|
|
the last values stay on screen and updates resume.
|
|
- A connection that lasted `UPSTREAM_STABLE_SECONDS` resets that schedule, so a long-lived
|
|
page treats a later restart as a fresh outage rather than a continuing one.
|
|
- When the schedule runs out, the proxy closes with `BROWSER_RETRY_CLOSE_CODE` (1012) and
|
|
htmx's own backoff takes over. Every server-side exit uses that code; only a browser that
|
|
has already left can see a plain close.
|
|
- A payload that cannot be rendered is logged and skipped, not treated as a dead stream.
|
|
|
|
`tests/test_ws_proxy.py` covers this against a real loopback WebSocket server, including
|
|
same-socket recovery across an upstream restart and the retryable close code. The regression
|
|
to watch for is a 1000 close after upstream loss.
|
|
|
|
## Source Layout
|
|
|
|
btcprice acts as a WebSocket proxy between the browser and btcdata:
|
|
1. Browser connects via HTMX WebSocket to `/ws/price` or `/ws/strike`
|
|
2. btcprice proxies to btcdata's `/api/price/ws` or `/api/strike/ws`
|
|
3. JSON from btcdata is transformed into HTML fragments (in `price_json_to_html` / `strike_json_to_html`)
|
|
4. HTMX swaps the HTML into the DOM by matching element IDs (`price-display`, `strike-display`)
|
|
|
|
### Key Conventions
|
|
|
|
- Numbers use Danish locale formatting (dots for thousands, commas for decimals) via `da_fmt()`
|
|
- Price changes are color-coded: green (up), red (down), gray (unchanged)
|
|
- All HTML is built as string concatenation in Python — no template engine
|