# 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: ` to stderr and exit 1. - Logger: `slog.New(slog.NewTextHandler(os.Stderr, nil))`. - `health.New( 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.