Files

128 lines
5.3 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 --preserve-status --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` (`--preserve-status` makes `timeout` report crossbar's own exit code; without it GNU `timeout` prints 124 for any child it had to signal). (`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.