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>
128 lines
5.1 KiB
Markdown
128 lines
5.1 KiB
Markdown
# 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.
|