v0 plan: AGENTS.md, gate, five task files with given tests, run-plan driver
Tests were run against a private reference implementation: gate ok after every task in order, smoke ok (stream spread ~1000 ms). The reference is not in the repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# v0 task 04: admin endpoints, the `crossbar` binary, the fake upstream
|
||||
|
||||
**Branch:** `v0` (run `git switch v0`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `Add the admin endpoints, the crossbar binary and the fake upstream`
|
||||
|
||||
## Goal
|
||||
|
||||
Make crossbar runnable: `internal/admin` shows the health table and the routes as JSON,
|
||||
`cmd/crossbar` wires config, health, proxy and admin into one HTTP server with graceful shutdown,
|
||||
and the given `cmd/fakeupstream` stands in for a router so the whole thing can be exercised
|
||||
without a real model (task 05 does that).
|
||||
|
||||
## Context
|
||||
|
||||
Operators read `/_crossbar/hosts` to see why a request went where it went, so its shape is
|
||||
fixed: every host, `healthy`, `loaded` (always a JSON array, never `null`), `last_ok` as RFC 3339
|
||||
in UTC or `""`, `last_err`. The admin handler is mounted at `/_crossbar/` on the same listener as
|
||||
the proxy; the proxy already refuses `/{route}/_crossbar/…` (task 03).
|
||||
|
||||
## Files
|
||||
|
||||
- Copy (never edit): `cmd/fakeupstream/main.go`, `example.toml`, `internal/admin/admin_test.go`
|
||||
- Create: `internal/admin/admin.go`, `cmd/crossbar/main.go`
|
||||
- Modify: `docs/implementer-log.md`
|
||||
|
||||
## Interfaces
|
||||
|
||||
Produces, in `internal/admin/admin.go`, package `admin`:
|
||||
|
||||
```go
|
||||
// Hosts is what the admin handler needs from the health table.
|
||||
type Hosts interface { All() map[string]health.Status }
|
||||
|
||||
type HostView struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Loaded []string `json:"loaded"` // never null: an empty slice when nothing is loaded
|
||||
LastOK string `json:"last_ok"` // time.RFC3339 in UTC, or "" if never
|
||||
LastErr string `json:"last_err"`
|
||||
}
|
||||
type RouteView struct {
|
||||
Hosts []string `json:"hosts"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
}
|
||||
|
||||
// Handler serves GET /_crossbar/hosts and GET /_crossbar/routes.
|
||||
func Handler(cfg *config.Config, h Hosts) http.Handler
|
||||
```
|
||||
|
||||
Rules the tests check:
|
||||
|
||||
1. `GET /_crossbar/hosts` → 200, `Content-Type: application/json`, a JSON object mapping host
|
||||
name to `HostView` built from `h.All()`.
|
||||
2. `GET /_crossbar/routes` → 200, JSON object mapping route name to `RouteView` (copy the hosts
|
||||
slice; do not hand out the config's).
|
||||
3. Any other method on those two paths → **405** with an `Allow: GET` header and a JSON
|
||||
`{"error":"method not allowed"}` body. Any other path under the handler → **404** JSON
|
||||
`{"error":"not found"}`. (An `http.ServeMux` with the two exact paths plus a `/` fallback does
|
||||
this.)
|
||||
|
||||
Produces `cmd/crossbar/main.go`, package `main`:
|
||||
|
||||
- Flag `-config` (default `crossbar.toml`). `config.Load`; on error print `crossbar: <err>` to
|
||||
stderr and exit 1.
|
||||
- Logger: `slog.New(slog.NewTextHandler(os.Stderr, nil))`.
|
||||
- `health.New(<name -> BaseURL from cfg.Hosts>, cfg.PollInterval.Duration, nil)`; run it with
|
||||
`go table.Run(ctx)` where `ctx` comes from
|
||||
`signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)`.
|
||||
- `http.ServeMux`: `mux.Handle("/_crossbar/", admin.Handler(cfg, table))`,
|
||||
`mux.Handle("/", proxy.New(cfg, table, log))`.
|
||||
- `&http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 10 * time.Second}`. Log
|
||||
`"listening"` with `addr` before `ListenAndServe`. On signal, log `"shutting down"` and
|
||||
`srv.Shutdown` with a 10 s timeout; exit 0. A `ListenAndServe` error other than
|
||||
`http.ErrServerClosed` exits 1 with the message.
|
||||
|
||||
`cmd/fakeupstream/main.go` is given; read its package comment so you know what it does, and do
|
||||
not change it.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.**
|
||||
|
||||
```sh
|
||||
git switch v0
|
||||
mkdir -p cmd/fakeupstream cmd/crossbar internal/admin
|
||||
cp docs/plans/v0/files/cmd/fakeupstream/main.go cmd/fakeupstream/
|
||||
cp docs/plans/v0/files/example.toml .
|
||||
cp docs/plans/v0/files/internal/admin/admin_test.go internal/admin/
|
||||
```
|
||||
|
||||
- [ ] **2. See the test fail.** `go test ./internal/admin/`. Expected: it does not compile.
|
||||
- [ ] **3. Write `internal/admin/admin.go` and `cmd/crossbar/main.go`.** `gofmt -w .`
|
||||
- [ ] **4. See the test pass.** `go test -race -count=1 ./internal/admin/`. Expected: `ok`.
|
||||
- [ ] **5. Build and run for three seconds.**
|
||||
|
||||
```sh
|
||||
make build
|
||||
timeout --signal=TERM 3 bin/crossbar -config example.toml; echo "exit=$?"
|
||||
```
|
||||
|
||||
Expected on stderr: a line containing `listening` and `addr=127.0.0.1:17777`, then
|
||||
`shutting down`; then `exit=0`. (`example.toml` names two upstreams that are not running; the
|
||||
health table simply records them unhealthy — that is fine here.)
|
||||
|
||||
```sh
|
||||
bin/crossbar -config /nonexistent.toml; echo "exit=$?"
|
||||
```
|
||||
|
||||
Expected: `crossbar: config: open /nonexistent.toml: no such file or directory` and `exit=1`.
|
||||
|
||||
- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **7. Log and commit.** Row `v0/04-admin-main`. `bin/` is build output: do not add it.
|
||||
|
||||
```sh
|
||||
git add internal/admin cmd/crossbar cmd/fakeupstream example.toml docs/implementer-log.md
|
||||
git commit
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- The admin test passes, the three-second run exits 0 with both log lines, the missing-config
|
||||
run exits 1, `make gate` prints `gate: ok`.
|
||||
- `cmp cmd/fakeupstream/main.go docs/plans/v0/files/cmd/fakeupstream/main.go` and the same for
|
||||
`example.toml` and `internal/admin/admin_test.go` print nothing.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `bin/crossbar` does not exit 0 on SIGTERM within the timeout.
|
||||
Reference in New Issue
Block a user