Every given test compiled against a panic-only interface skeleton (go vet clean); nothing was implemented. modernc.org/sqlite v1.59.0 vetted in a scratch module (WAL works); go.sum given. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
122 lines
6.4 KiB
Markdown
122 lines
6.4 KiB
Markdown
# v1 task 06: admin v1 and the wiring
|
|
|
|
**Branch:** `v1` (run `git switch v1`; `git status --short` must be empty, otherwise stop)
|
|
**Commit subject:** `Admin: leases, pin, release, drain, usage, metrics; wire the store into crossbar`
|
|
|
|
## Goal
|
|
|
|
Operators see and steer the system: the hosts view gains slots and drain state, the routes view
|
|
shows leases and pins, `POST` endpoints pin/release a route and drain a host, `/usage` answers
|
|
the accounting questions, `/metrics` exposes them to Prometheus. `cmd/crossbar` opens the store,
|
|
builds the lease table and limiter, runs idle expiry and pruning, and shuts down cleanly.
|
|
`PLAN.md` §7, §7a.
|
|
|
|
## Files
|
|
|
|
- Copy (**replaces** v0's): `internal/admin/admin_test.go`, `example.toml`
|
|
- Modify: `internal/admin/admin.go` (split if over 400 lines), `cmd/crossbar/main.go`, `docs/implementer-log.md`
|
|
|
|
## Interfaces
|
|
|
|
`internal/admin`, package `admin`:
|
|
|
|
```go
|
|
type Hosts interface { All() map[string]health.Status }
|
|
type Drainer interface { Draining(name string) bool; SetDraining(name string, on bool) } // *proxy.Hosts satisfies it
|
|
|
|
type HostView struct {
|
|
Healthy bool `json:"healthy"`
|
|
Loaded []string `json:"loaded"` // never null
|
|
LastOK string `json:"last_ok"` // RFC 3339 UTC or ""
|
|
LastErr string `json:"last_err"`
|
|
FreeSlots int `json:"free_slots"` // lim.FreeSlots(host)
|
|
InFlight int `json:"in_flight"` // sum over the host's configured models
|
|
Queued int `json:"queued"` // same
|
|
Draining bool `json:"draining"`
|
|
}
|
|
type LeaseView struct { FP, Model, Host, State, Created, LastUsed string } // json tags: fp, model, host, state, created, last_used (RFC 3339 UTC)
|
|
type RouteView struct {
|
|
Hosts []string `json:"hosts"`
|
|
DefaultModel string `json:"default_model"`
|
|
Pinned string `json:"pinned"` // "" when not pinned
|
|
Leases []LeaseView `json:"leases"` // never null
|
|
}
|
|
func Handler(cfg *config.Config, h Hosts, lt *lease.Table, lim *limiter.Limiter, st *store.Store, d Drainer) http.Handler
|
|
```
|
|
|
|
Endpoints (all JSON unless said; errors `{"error":"…"}`; wrong method → 405 with `Allow`):
|
|
|
|
- `GET /_crossbar/hosts` → `map[string]HostView`.
|
|
- `GET /_crossbar/routes` → `map[string]RouteView` from config + `lt.Snapshot()` + `lt.Pinned`.
|
|
- `POST /_crossbar/routes/{route}` body `{"host":"…","pin":true}` → `lt.Pin(route, host, now)`;
|
|
`{"release":true}` → `lt.Release(route)` **and** `lt.Unpin(route)`. Unknown route → 404;
|
|
`lt.Pin` returning `ErrUnknownHost` → 404; not JSON, neither form, or both forms at once,
|
|
or `pin` without `host` → 400. Answer `{"ok":true}` (plus `"released": n` for a release).
|
|
`GET` on this path → 405.
|
|
- `POST /_crossbar/hosts/{host}` body `{"drain":true|false}` → `d.SetDraining`; unknown host
|
|
(not in config) → 404; bad body → 400. `{"ok":true}`.
|
|
- `GET /_crossbar/usage?since=…&by=route|model|host` → `[]store.UsageRow` (JSON array; sorted by
|
|
key). `by` defaults to `route`; `since` is either RFC 3339 or a duration like `24h`/`7d`
|
|
(meaning `now - d`); absent = all time; anything else → 400. With `Accept: text/plain`, a
|
|
fixed-width table with a header line containing `key requests errors busy_ms queued_ms
|
|
prompt cached completion cache_hit` and one line per row (`cache_hit` as `0.80`).
|
|
- `GET /_crossbar/metrics` → `text/plain; version=0.0.4`, computed on request. Request counters
|
|
need (route, host, status), which `Usage` (one key) cannot give, so add **one** method to
|
|
`internal/store` — the only change to that package allowed in this task:
|
|
```go
|
|
type StatusCount struct { Route, Host string; Status int; Count int64 }
|
|
func (s *Store) StatusCounts(since time.Time) ([]StatusCount, error) // from `requests` only; rolled-up days are not in it, say so in a comment
|
|
```
|
|
Then emit, in this order:
|
|
```
|
|
# TYPE crossbar_requests_total counter
|
|
crossbar_requests_total{route="…",host="…",status="…"} N (one line per StatusCount)
|
|
# TYPE crossbar_prompt_tokens_total counter
|
|
crossbar_prompt_tokens_total{route="…"} N (Usage(zero, ByRoute))
|
|
# TYPE crossbar_cached_tokens_total counter
|
|
# TYPE crossbar_completion_tokens_total counter
|
|
# TYPE crossbar_queue_wait_ms_total counter crossbar_queue_wait_ms_total{route="…"} N
|
|
# TYPE crossbar_host_healthy gauge crossbar_host_healthy{host="…"} 0|1
|
|
# TYPE crossbar_host_free_slots gauge
|
|
# TYPE crossbar_host_in_flight gauge
|
|
# TYPE crossbar_host_queued gauge
|
|
```
|
|
Label values escaped (`"` and `\`), lines sorted, no trailing spaces.
|
|
|
|
`cmd/crossbar/main.go`:
|
|
|
|
- After `config.Load`: `store.Open(cfg.DB)` (error → exit 1 `crossbar: …`); `defer st.Close()`.
|
|
- `hosts := proxy.HostView(table, cfg)`; `lim := limiter.New()` configured for every host/model
|
|
from config with `cfg.QueueMax`; `leases, err := lease.New(st, hosts, proxy.Chooser(cfg, table, lim), cfg.LeaseIdle.Duration)`.
|
|
- `proxy.New(cfg, table, leases, lim, st, log)`; `admin.Handler(cfg, table, leases, lim, st, hosts)`.
|
|
- Background loops until ctx is done: every minute `leases.ExpireIdle(time.Now())`; every hour
|
|
`st.Prune(time.Now(), cfg.Retention.Duration)`; after every poll round the health table's
|
|
observations are recorded with `st.RecordHostHealth` — do this from a goroutine that every
|
|
`poll_interval` reads `table.All()` and writes one row per host.
|
|
- Shutdown as v0, then `st.Close()`.
|
|
|
|
## Steps
|
|
|
|
- [ ] **1. Copy (replace).** `git switch v1`; copy `admin_test.go` and `example.toml` from `docs/plans/v1/_files/`.
|
|
- [ ] **2. See it fail** (compile). **3. Write the code** (admin, the store's `StatusCounts`, main). `gofmt -w .`
|
|
- [ ] **4. See it pass.** `go test -race -count=1 ./...`.
|
|
- [ ] **5. Build and run for three seconds.**
|
|
|
|
```sh
|
|
make build
|
|
timeout --preserve-status --signal=TERM 3 bin/crossbar -config example.toml; echo "exit=$?"
|
|
ls -la crossbar.db* && rm -f crossbar.db crossbar.db-wal crossbar.db-shm
|
|
```
|
|
|
|
Expected: `listening`, `shutting down`, `exit=0`; the SQLite file was created (then removed).
|
|
- [ ] **6. Run the gate.** `make gate`. **7. Log and commit.** Row `v1/06-admin-main`.
|
|
|
|
```sh
|
|
git add internal/admin internal/store cmd/crossbar example.toml docs/implementer-log.md
|
|
git commit
|
|
```
|
|
|
|
## Done when
|
|
|
|
- All tests pass with `-race`; the three-second run exits 0 and created the db; `make gate` prints `gate: ok`; both copied files byte-identical.
|