Files
trace/README.md

121 lines
9.7 KiB
Markdown
Raw Normal View History

2026-07-20 20:25:17 +02:00
# trace
Private incident notebook, Git fix-candidate inbox, deployment-receipt evidence store, and reviewed Astro Markdown exporter for goonk's `/failures` page. One ~70-line HTTP server (`src/server.mjs`), Node's built-in `node:sqlite`, no framework, no runtime dependencies.
2026-07-20 20:25:17 +02:00
```bash
cp .env.example .env # set TRACE_PASSWORD and TRACE_RECEIPT_TOKENS at minimum
2026-07-20 20:25:17 +02:00
npm start
```
Node 24+. `npm run dev` for `--watch` during local work; `npm test` runs the receipt-contract tests; `npm run build` is a syntax check (`node --check`), not a bundle — there's nothing to bundle.
2026-07-20 21:09:04 +02:00
## What trace actually is
Two things that get conflated everywhere else, kept structurally separate on purpose:
1. **A private incident notebook.** Record what broke, what you tried, what was actually true, and what you'd do differently — while you're still in it, not reconstructed from memory afterward. Nothing here is public until a human explicitly publishes it.
2. **An operational receipts ledger.** CI says what it built; a deploy host says what it actually started and verified. Trace stores both as separate, versioned, authenticated event types and never lets a green build masquerade as a live deployment.
The two features share a database and a process, but not a purpose — the incident notebook is where a human writes a story, the receipts ledger is where machines report bare facts.
## The private incident notebook
Everything under `/` (Basic Auth, `trace:$TRACE_PASSWORD`) is private by default:
- **`/`** — every incident, newest first, with a live status breakdown.
- **`/incidents/new`** → **`/incidents/:id`** — title, project, status (`investigating` / `mitigated` / `resolved` / `abandoned`), severity (`annoyance``security-risk`), and the fields that actually make a postmortem useful: symptom, impact, root cause, confidence, fix, verification, prevention, remaining risk, lesson. Confidence is tracked separately from status on purpose — "resolved, high confidence" and "resolved, we're not totally sure why" are different claims and shouldn't collapse into one field.
- **Timeline** — append-only entries per incident, each tagged `observation` / `hypothesis` / `action` / `result` / `decision` / `deploy` / `note`. This is the actual investigation log, in order, not reconstructed after the fact.
- **Hypotheses** — a statement, a status (`untested` / `supported` / `rejected` / `inconclusive`), and evidence. Explicitly tracking rejected hypotheses is the point — "we thought it was X, it wasn't, here's how we knew" is worth as much as the real answer.
- **`/candidates`** (the Git inbox) — click "scan configured repositories" to pull commits whose subject line matches fix-shaped keywords (`fix`, `bug`, `broken`, `race`, `crash`, `regression`, `rollback`, `restore`, `duplicate`, `auth`, `fail`) from either locally mounted read-only repo mirrors (`TRACE_REPOS`) or Gitea's API with no checkout at all (`TRACE_GITEA_URL` + `TRACE_GITEA_REPOS`). Link a candidate to an incident's UUID to connect "here's the bug" with "here's the commit that caused or fixed it."
### Publishing a real postmortem
An incident page has an **export public draft** action (writes reviewed Markdown to `data/exports/` for hand-editing) and, once you're happy with it, **publish / update reviewed article** — this sets a public slug and makes the incident visible through the read-only public API below. **Unpublish** reverses it instantly. Nothing crosses from private to public without that explicit action; a `symptom` field with an internal hostname in it never accidentally becomes a blog post.
```bash
curl -u trace:$TRACE_PASSWORD -X POST http://localhost:3082/incidents/new \
--data-urlencode "title=under-the-hood showed a stale deploy commit" \
--data-urlencode "project=goonk" \
--data-urlencode "detected_at=2026-07-21T01:00" \
--data-urlencode "status=resolved" \
--data-urlencode "severity=annoyance" \
--data-urlencode "symptom=Deployed showed an older commit and earlier timestamp than Built." \
--data-urlencode "root_cause=Nothing on the deploy host ever submitted a deployment receipt." \
--data-urlencode "fix=Added best-effort receipt submission to the shared deploy wrapper." \
--data-urlencode "public_summary=A deploy dashboard kept showing an old commit as current."
# → 303 redirect to /incidents/<new-uuid>
```
The form works the same from a browser; this is just the same POST scripted, useful for backfilling incidents from notes taken elsewhere.
## Deployment receipts (v1)
Two separate authenticated write paths, one shared authenticated read-only view:
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
| `/api/v1/events` | `POST` | `Authorization: Bearer <project-scoped token>` | Submit a build/deployment/release/rollback receipt |
| `/events` | `GET` | Basic (`trace:$TRACE_PASSWORD`) | Private, human-readable feed of every received event, raw payload included |
| `/api/v1/public/projects/:project/latest` | `GET` | none | Latest **deployment** event only, allowlisted fields |
| `/api/v1/public/projects` | `GET` | none | Latest deployment per project, same allowlisted shape |
| `/api/health` | `GET` | none | Liveness probe (used by the Dockerfile's `HEALTHCHECK`) |
A receipt (`schemaVersion: 1`) needs `eventType` (`build`/`deployment`/`release`/`rollback`), `eventId`, `project`, `occurredAt` (ISO-8601), `result` (`success`/`failed`/`cancelled`/`unknown`), and `source.commit` (a 764 char hex SHA). `eventId` is the idempotency key — resubmitting the same one returns `{"ok":true,"duplicate":true}` rather than a second row, which is what makes "retry the CI step that failed to reach trace" safe.
```bash
# CI, right after pushing images (a build receipt)
curl -H "Authorization: Bearer $TRACE_RECEIPT_TOKEN" -H "Content-Type: application/json" \
--data-binary '{"schemaVersion":1,"eventType":"build","eventId":"goonk-build-713-3777586","project":"goonk",
"occurredAt":"2026-07-21T03:06:00Z","result":"success",
"source":{"commit":"37775868ab303f9f8a7da09b354dd0cc31bb0c6f","branch":"main"},
"artifacts":[{"component":"web","digest":"sha256:fe9e15f73804..."}],
"verification":{"build":"success"}}' \
https://trace.dev.xplwd.com/api/v1/events
# A deploy host, after docker compose up -d and a real health check
curl -H "Authorization: Bearer $TRACE_RECEIPT_TOKEN" -H "Content-Type: application/json" \
--data-binary '{"schemaVersion":1,"eventType":"deployment","eventId":"goonk-production-3777586-20260721T013412Z",
"project":"goonk","environment":"production","occurredAt":"2026-07-21T01:34:12Z","result":"success",
"source":{"commit":"37775868ab303f9f8a7da09b354dd0cc31bb0c6f","branch":"main"},
"verification":{"health":"healthy"}}' \
https://trace.dev.xplwd.com/api/v1/events
# Check what a public consumer sees
curl https://trace.dev.xplwd.com/api/v1/public/projects/goonk/latest
```
Full producer onboarding — registering a project, per-project tokens, the exact CI step shape, and the deploy-host responsibilities above — is in [`docs/INTEGRATING_PROJECTS.md`](docs/INTEGRATING_PROJECTS.md). `keep/scripts/deploy.sh` is the reference deploy-host implementation of the second `curl` above: after `docker compose up -d`, it polls the app's own embedded build identity until healthy, then submits exactly this receipt — opt-in per project via a `.trace-meta` marker file, never assumed.
### A known rough edge
`POST /api/receipts` (Basic Auth, no `/v1`, no bearer token) still exists in `src/server.mjs` as a leftover from before the v1 events API was built. It writes to a `receipts` table nothing else ever reads from — dead code, not a second real API. Use `/api/v1/events` for everything; the old path is scheduled for removal, not a documented feature.
## Configuration reference
| Variable | Required | Purpose |
|---|---|---|
| `PORT` | no (default `3082`) | HTTP listen port |
| `TRACE_PASSWORD` | effectively yes | Basic Auth password for `/`, `/events`, and the publish/unpublish actions. Empty disables auth entirely — fine for a throwaway local instance, never for anything reachable outside localhost |
| `TRACE_RECEIPT_TOKENS` | yes, for receipt ingestion | `project:token,project2:token2` — one random token per producer (`openssl rand -hex 32`), never shared across projects |
| `TRACE_REPOS` | no | `id:/absolute/path,id2:/absolute/path2` — locally mounted **read-only** repo mirrors for the Git inbox |
| `TRACE_GITEA_URL` | no (default `https://repo.explewd.com`) | Base URL for Gitea-API-based candidate scanning, no checkout needed |
| `TRACE_GITEA_REPOS` | no | Comma-separated `owner/repo` list to scan via the Gitea API |
| `TRACE_GITEA_TOKEN` | no | Only needed for private Gitea repositories; public ones scan with no token |
## Deploying
```bash
cp .env.example .env # fill in real values
docker compose up -d
```
Single service, a named volume for `data/` (SQLite + exports), a `HEALTHCHECK` hitting `/api/health`. CI (`.gitea/workflows/docker.yml`) runs `npm test` and `npm run build` before it will push an image — a broken receipt-validation contract or a syntax error never reaches the registry.
## Security boundaries
- One bearer token per project; a `retur` token cannot submit a `goonk` receipt (checked with `crypto.timingSafeEqual`, not `===`).
- `TRACE_PASSWORD` gates every private route in one place — the notebook, the raw events feed, and publish/unpublish.
- The public API (`/api/v1/public/*`) never includes raw payloads, tokens, internal hostnames, or full registry references — only the allowlisted fields in `publicEvent()`/`publicIncident()`.
- `TRACE_REPOS` should be empty in any deployment with no deliberate read-only mirrors mounted; receipt ingestion never requires repository access at all.
- Trace being unreachable should never break a producer's build or deploy — every documented integration point treats submission as best-effort.