Merge v2.2: route templates, multiple wake broadcast addresses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -70,7 +70,7 @@ func run() error {
|
|||||||
if h.Wake == nil {
|
if h.Wake == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
targets[name] = wake.Target{MAC: h.Wake.MAC, Broadcast: h.Wake.Broadcast, Wait: h.Wake.Wait.Duration}
|
targets[name] = wake.Target{MAC: h.Wake.MAC, Broadcasts: h.Wake.Addresses(), Wait: h.Wake.Wait.Duration}
|
||||||
}
|
}
|
||||||
waker := wake.New(targets, hosts)
|
waker := wake.New(targets, hosts)
|
||||||
for name, h := range cfg.Hosts {
|
for name, h := range cfg.Hosts {
|
||||||
@@ -104,7 +104,7 @@ func run() error {
|
|||||||
checker = identity.NewHeaderChecker()
|
checker = identity.NewHeaderChecker()
|
||||||
}
|
}
|
||||||
handler = identity.Middleware(checker, func(route string) ([]string, bool) {
|
handler = identity.Middleware(checker, func(route string) ([]string, bool) {
|
||||||
rt, ok := cfg.Routes[route]
|
rt, _, ok := cfg.Route(route)
|
||||||
return rt.Peers, ok
|
return rt.Peers, ok
|
||||||
}, p)
|
}, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ owner fills in the Model column. The reviewer adds findings under "Reviews" once
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| v2.2/02-broadcasts | 2026-09-25 | done | 1 | pass | The Wake struct and checkWake live in `internal/config/identity.go` (added in task 04), not `config.go`, so I edited `identity.go` rather than `config.go`; `wake.go` logs a broadcast that fails to resolve/send before continuing (task rule 2 allows "logged or ignored"). | Added `Broadcasts` to `Wake` and `Wake.Addresses()` (Broadcast then Broadcasts, never empty for a parsed config); `checkWake` errors on both-set → `.broadcasts`, neither-or-empty-list → `.broadcast`, and a non-`host:port` entry → `.broadcasts`; `Target` gains `Broadcasts` and `Wake`/`sendAll` send to Broadcast then each Broadcasts in order, logging/past a failure and returning false only when no address could be sent; `main.go` fills `Target.Broadcasts` from `Wake.Addresses()` and leaves `Target.Broadcast` empty so `sendAll` does not double-send. Given `broadcasts_test.go` and `config_v22_test.go` byte-identical, v2 `wake_test.go`/`config_v2_test.go` untouched and green; `make gate` → `gate: ok` first run. | ? |
|
||||||
|
| v2.2/01-route-templates | 2026-09-25 | done | 1 | fail | `internal/config` red only on `Wake.Addresses()` (task 02), the one allowed red; `go build ./...` clean, proxy/admin/health/wake/lease/store/identity/fingerprint all pass under `-race`. New `internal/config/route.go`: `templateName` pattern `^[a-z0-9][a-z0-9-]*-\*$` and `Route()` (valid-name guard excludes `*`; exact wins; else longest `"<prefix>-*"`, prefix keeps the dash, non-empty remainder required, longest-prefix wins deterministically). `config.go`: the route-name check accepts a template too (one line). `proxy.go`: `route()` and `ServeHTTP` resolve both path and `X-Crossbar-Route` header forms through `cfg.Route`, and the conflicting-route check compares concrete names via `cfg.Route` (identical to before for non-template configs). `admin.go` `routeView` lists a lease under the exact key it matches or the longest template key; `admin_ops.go` `routePin` resolves through `cfg.Route` so a concrete route under a template can be pinned before its first request and the template name 404s. `main.go` identity lookup uses `cfg.Route`. All three given tests byte-identical (`config_v22_test.go` keeps `TestWakeBroadcasts`, which is why config is red). | ? |
|
||||||
| v2.1/02-props-loaded-only | 2026-09-25 | done | 1 | pass | `movedHeader` separator `><`→`>` and the `CtxHeader` doc comment in `proxy.go`, both forced by the given router test (`moved:small>big`) which the task text did not mention; no production code parses the separator (`forward.go` passes it straight through) so it is safe. | Implemented per-model context. `health`: added `ModelCtx` and a `Models map[string]ModelCtx` field on `Status`, plus `PerSlotCtxFor(model)` (per-model figure when present, else host-level `PerSlotCtx` for a loaded model, else 0); moved `props` into a new `props.go` and added `propsModel`/`propsModels`. Poller rules 1-4: `/v1/models` treats an entry as loaded only with no `status` or `status.value=="loaded"` (other values dropped from `Loaded`); plain `/props` with `role:router` leaves host NCtx/Slots 0; each loaded model is asked `GET /props?model=<url.QueryEscape(id)>` and a failed/malformed answer leaves that id absent without failing the host; `Models` is a fresh non-nil map every successful poll, `MarkDown` leaves it. `ctxguard.go`: every `PerSlotCtx()` became `PerSlotCtxFor(model)` (leased host, candidates, wake "cannot serve" check) and `largestSlotCtx(hosts,h,model)` counts only hosts that have it loaded. `admin.go`: `HostView` gains `models` (empty object, never null). A plain single server keeps working as v2. All three given tests byte-identical; `make gate` → `gate: ok` on the first run. | ? |
|
| v2.1/02-props-loaded-only | 2026-09-25 | done | 1 | pass | `movedHeader` separator `><`→`>` and the `CtxHeader` doc comment in `proxy.go`, both forced by the given router test (`moved:small>big`) which the task text did not mention; no production code parses the separator (`forward.go` passes it straight through) so it is safe. | Implemented per-model context. `health`: added `ModelCtx` and a `Models map[string]ModelCtx` field on `Status`, plus `PerSlotCtxFor(model)` (per-model figure when present, else host-level `PerSlotCtx` for a loaded model, else 0); moved `props` into a new `props.go` and added `propsModel`/`propsModels`. Poller rules 1-4: `/v1/models` treats an entry as loaded only with no `status` or `status.value=="loaded"` (other values dropped from `Loaded`); plain `/props` with `role:router` leaves host NCtx/Slots 0; each loaded model is asked `GET /props?model=<url.QueryEscape(id)>` and a failed/malformed answer leaves that id absent without failing the host; `Models` is a fresh non-nil map every successful poll, `MarkDown` leaves it. `ctxguard.go`: every `PerSlotCtx()` became `PerSlotCtxFor(model)` (leased host, candidates, wake "cannot serve" check) and `largestSlotCtx(hosts,h,model)` counts only hosts that have it loaded. `admin.go`: `HostView` gains `models` (empty object, never null). A plain single server keeps working as v2. All three given tests byte-identical; `make gate` → `gate: ok` on the first run. | ? |
|
||||||
| v2.1/01-cancel-record | 2026-09-25 | done | 1 | pass | none | Implemented the rule: added a `cancelled` field to `forwardState`; the `ErrorHandler` sets it when it observes `context.Canceled` (client gone before any response byte) so the delivered row is no longer turned into a 499 by a pooled close after the body; removed the post-hoc `r.Context().Err()` check in the normal path, leaving the recover path's `http.ErrAbortHandler` (mid-body) check as the other 499 source. Given test failed the first run (`Errors:7`, status counts held 25×200/7×499), passes 3× under `-race`; `TestClientCancelMidStreamIsRecorded`, `TestClientCancelWhileQueuedIsRecorded` and `TestQueueFullIs503` still pass; `forward.go` 230 lines; `make gate` printed `gate: ok` on the first run. | ? |
|
| v2.1/01-cancel-record | 2026-09-25 | done | 1 | pass | none | Implemented the rule: added a `cancelled` field to `forwardState`; the `ErrorHandler` sets it when it observes `context.Canceled` (client gone before any response byte) so the delivered row is no longer turned into a 499 by a pooled close after the body; removed the post-hoc `r.Context().Err()` check in the normal path, leaving the recover path's `http.ErrAbortHandler` (mid-body) check as the other 499 source. Given test failed the first run (`Errors:7`, status counts held 25×200/7×499), passes 3× under `-race`; `TestClientCancelMidStreamIsRecorded`, `TestClientCancelWhileQueuedIsRecorded` and `TestQueueFullIs503` still pass; `forward.go` 230 lines; `make gate` printed `gate: ok` on the first run. | ? |
|
||||||
| v2/05-wiring-smoke | 2026-09-25 | done | 1 | pass | none | The wiring in `cmd/crossbar/main.go` and `internal/proxy/{proxy,forward,ctxguard}.go` plus the README section were already in the working tree from a prior session; this session only ran the tests, the gate, the log row, and the commit. `go test -race -count=1 ./...` failed once on `TestQueueFullIs503` (`Errors:2`, the 503 not recorded) — the known v1 recording defect the owner scheduled as a v2.1 task 01; reran once and it passed. `make gate` printed `gate: ok` on the first run. Committed the two owner-corrected given v1 tests (`internal/limiter/limiter_test.go`, `internal/proxy/proxy_test.go`) alongside the prior session's changes. | ? |
|
| v2/05-wiring-smoke | 2026-09-25 | done | 1 | pass | none | The wiring in `cmd/crossbar/main.go` and `internal/proxy/{proxy,forward,ctxguard}.go` plus the README section were already in the working tree from a prior session; this session only ran the tests, the gate, the log row, and the commit. `go test -race -count=1 ./...` failed once on `TestQueueFullIs503` (`Errors:2`, the 503 not recorded) — the known v1 recording defect the owner scheduled as a v2.1 task 01; reran once and it passed. `make gate` printed `gate: ok` on the first run. Committed the two owner-corrected given v1 tests (`internal/limiter/limiter_test.go`, `internal/proxy/proxy_test.go`) alongside the prior session's changes. | ? |
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ models = { "ornith-1.5-35b-a3b" = { parallel = 2 } }
|
|||||||
[hosts.beta.wake] # v2: wake a sleeping host when nothing else can take a new lease
|
[hosts.beta.wake] # v2: wake a sleeping host when nothing else can take a new lease
|
||||||
mac = "aa:bb:cc:dd:ee:02"
|
mac = "aa:bb:cc:dd:ee:02"
|
||||||
broadcast = "127.0.0.1:19082" # the LAN broadcast address, port 9, in production
|
broadcast = "127.0.0.1:19082" # the LAN broadcast address, port 9, in production
|
||||||
|
# broadcasts = ["127.0.0.1:19082", "127.0.0.1:19083"] # v2.2: a roaming host on several networks; this or broadcast, not both, and at least one
|
||||||
wait = "20s"
|
wait = "20s"
|
||||||
|
|
||||||
# v1: a route is a set of candidate hosts; each conversation gets a sticky lease on the host with
|
# v1: a route is a set of candidate hosts; each conversation gets a sticky lease on the host with
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ func (hx *handler) routesGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (hx *handler) routeView(route string, hosts []string, defaultModel string, snap []lease.Lease) RouteView {
|
func (hx *handler) routeView(route string, hosts []string, defaultModel string, snap []lease.Lease) RouteView {
|
||||||
leases := make([]LeaseView, 0)
|
leases := make([]LeaseView, 0)
|
||||||
for _, l := range snap {
|
for _, l := range snap {
|
||||||
if l.Route == route {
|
// A concrete route lists under the exact key it matches, or the longest
|
||||||
|
// template that matches it; a template's row is every such lease.
|
||||||
|
if _, key, ok := hx.cfg.Route(l.Route); ok && key == route {
|
||||||
leases = append(leases, leaseView(l))
|
leases = append(leases, leaseView(l))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (hx *handler) routePin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
route := r.PathValue("route")
|
route := r.PathValue("route")
|
||||||
routeCfg, ok := hx.cfg.Routes[route]
|
routeCfg, _, ok := hx.cfg.Route(route)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusNotFound, "unknown route")
|
writeError(w, http.StatusNotFound, "unknown route")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package admin_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/admin"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/config"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/health"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/lease"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/limiter"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The routes view lists a template once, under its own name, with the leases of every concrete
|
||||||
|
// route it matched. A concrete route can be pinned; the template itself cannot.
|
||||||
|
func TestRoutesViewAndPinWithTemplates(t *testing.T) {
|
||||||
|
cfg, err := config.Parse(strings.NewReader(`
|
||||||
|
listen = "127.0.0.1:1"
|
||||||
|
[hosts.alpha]
|
||||||
|
base_url = "http://alpha:1"
|
||||||
|
models = { "m" = { parallel = 2 } }
|
||||||
|
[routes."opencode-*"]
|
||||||
|
hosts = ["alpha"]
|
||||||
|
default_model = "m"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "x.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
hosts := &fakeHosts{
|
||||||
|
st: map[string]health.Status{"alpha": {Healthy: true, Loaded: []string{"m"}}},
|
||||||
|
draining: map[string]bool{},
|
||||||
|
}
|
||||||
|
lt, err := lease.New(st, hosts, hosts, 30*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := lt.Acquire(lease.Key{Route: "opencode-projecta-4242", FP: "fp1", Model: "m"}, []string{"alpha"}, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := lt.Acquire(lease.Key{Route: "opencode-projectb-7", FP: "fp2", Model: "m"}, []string{"alpha"}, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lim := limiter.New()
|
||||||
|
lim.Configure("alpha", "m", 2, 8)
|
||||||
|
r := &rig{h: admin.Handler(cfg, hosts, lt, lim, st, hosts), store: st, leases: lt, hosts: hosts}
|
||||||
|
|
||||||
|
rec := r.do(t, "GET", "/_crossbar/routes", "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out map[string]admin.RouteView
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, ok := out["opencode-*"]
|
||||||
|
if !ok || len(out) != 1 {
|
||||||
|
t.Fatalf("routes view keys = %v, want exactly the template", keysOf(out))
|
||||||
|
}
|
||||||
|
if len(v.Hosts) != 1 || v.Hosts[0] != "alpha" || v.DefaultModel != "m" || len(v.Leases) != 2 {
|
||||||
|
t.Errorf("template view = %+v, want hosts [alpha], model m and the two concrete routes' leases", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = r.do(t, "POST", "/_crossbar/routes/opencode-projecta-4242", `{"host":"alpha","pin":true}`)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("pin of a concrete templated route: %d %s, want 200", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = r.do(t, "POST", "/_crossbar/routes/opencode-*", `{"host":"alpha","pin":true}`)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Errorf("pin of the template itself: %d, want 404 unknown route", rec.Code)
|
||||||
|
}
|
||||||
|
rec = r.do(t, "POST", "/_crossbar/routes/opencode-nothing-yet", `{"host":"alpha","pin":true}`)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("pin of a not-yet-seen concrete route under a template: %d %s, want 200 (it is a valid route)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]admin.RouteView) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -358,7 +358,7 @@ func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool)
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
r := c.Routes[name]
|
r := c.Routes[name]
|
||||||
|
|
||||||
if !routeName.MatchString(name) {
|
if !routeName.MatchString(name) && !templateName.MatchString(name) {
|
||||||
return &Error{Field: fmt.Sprintf("routes.%s", name), Msg: "must match [a-z0-9][a-z0-9-]*"}
|
return &Error{Field: fmt.Sprintf("routes.%s", name), Msg: "must match [a-z0-9][a-z0-9-]*"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const templateBase = `
|
||||||
|
listen = "127.0.0.1:1"
|
||||||
|
[hosts.a]
|
||||||
|
base_url = "http://a:1"
|
||||||
|
models = { "m" = { }, "n" = { } }
|
||||||
|
[routes."opencode-*"]
|
||||||
|
hosts = ["a"]
|
||||||
|
default_model = "m"
|
||||||
|
[routes."opencode-rust-*"]
|
||||||
|
hosts = ["a"]
|
||||||
|
default_model = "n"
|
||||||
|
[routes.opencode-fixed]
|
||||||
|
hosts = ["a"]
|
||||||
|
[routes.paper]
|
||||||
|
hosts = ["a"]
|
||||||
|
`
|
||||||
|
|
||||||
|
// A route whose name ends in "-*" is a template: any request route that starts with the part
|
||||||
|
// before the star, with something after it, uses that route's config. An exact name wins over a
|
||||||
|
// template; the longest matching template wins over shorter ones.
|
||||||
|
func TestRouteTemplatesResolve(t *testing.T) {
|
||||||
|
c, err := config.Parse(strings.NewReader(templateBase))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, wantKey, wantModel string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"paper", "paper", "", true},
|
||||||
|
{"opencode-fixed", "opencode-fixed", "", true}, // exact beats template
|
||||||
|
{"opencode-projecta-4242", "opencode-*", "m", true}, // template
|
||||||
|
{"opencode-rust-a-7", "opencode-rust-*", "n", true}, // longest template wins
|
||||||
|
{"opencode-", "", "", false}, // nothing after the prefix
|
||||||
|
{"opencode", "", "", false}, // the dash is part of the prefix
|
||||||
|
{"opencodex", "", "", false}, // not a prefix match
|
||||||
|
{"opencode-*", "", "", false}, // a literal star is never a request route
|
||||||
|
{"Opencode-A", "", "", false}, // not a valid route name
|
||||||
|
{"nope", "", "", false},
|
||||||
|
} {
|
||||||
|
r, key, ok := c.Route(tc.name)
|
||||||
|
if ok != tc.ok || key != tc.wantKey || (ok && r.DefaultModel != tc.wantModel) {
|
||||||
|
t.Errorf("Route(%q) = (%+v, %q, %v), want key %q model %q ok %v", tc.name, r, key, ok, tc.wantKey, tc.wantModel, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteTemplateNamesAreValidated(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
route string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
"star in the middle": {`"open*code"`, "routes.open*code"},
|
||||||
|
"star without dash": {`"opencode*"`, "routes.opencode*"},
|
||||||
|
"bare star": {`"*"`, "routes.*"},
|
||||||
|
"double star": {`"opencode-**"`, "routes.opencode-**"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
text := "listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[routes." + tc.route + "]\nhosts = [\"a\"]\n"
|
||||||
|
_, err := config.Parse(strings.NewReader(text))
|
||||||
|
ce, ok := err.(*config.Error)
|
||||||
|
if !ok || ce.Field != tc.wantErr {
|
||||||
|
t.Fatalf("err = %v, want *config.Error on %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A template alone satisfies "at least one route".
|
||||||
|
if _, err := config.Parse(strings.NewReader("listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[routes.\"x-*\"]\nhosts = [\"a\"]\n")); err != nil {
|
||||||
|
t.Errorf("a template-only config must parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcasts: a wake target may name several broadcast addresses (a host that roams between two
|
||||||
|
// Wi-Fi networks). `broadcast` (one) and `broadcasts` (a list) are alternatives: exactly one.
|
||||||
|
func TestWakeBroadcasts(t *testing.T) {
|
||||||
|
head := "listen = \"127.0.0.1:1\"\n[hosts.a]\nbase_url = \"http://a:1\"\nmodels = { \"m\" = { } }\n[hosts.a.wake]\nmac = \"aa:bb:cc:dd:ee:ff\"\n"
|
||||||
|
tail := "\n[routes.r]\nhosts = [\"a\"]\n"
|
||||||
|
c, err := config.Parse(strings.NewReader(head + `broadcasts = ["192.168.88.255:9", "192.168.1.255:9"]` + tail))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := c.Hosts["a"].Wake.Addresses(); len(got) != 2 || got[0] != "192.168.88.255:9" || got[1] != "192.168.1.255:9" {
|
||||||
|
t.Errorf("Addresses() = %v, want both, in order", got)
|
||||||
|
}
|
||||||
|
c, err = config.Parse(strings.NewReader(head + `broadcast = "192.168.88.255:9"` + tail))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := c.Hosts["a"].Wake.Addresses(); len(got) != 1 || got[0] != "192.168.88.255:9" {
|
||||||
|
t.Errorf("Addresses() = %v, want the single broadcast", got)
|
||||||
|
}
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"both": "broadcast = \"192.168.88.255:9\"\nbroadcasts = [\"192.168.1.255:9\"]",
|
||||||
|
"neither": "wait = \"30s\"",
|
||||||
|
"empty list": "broadcasts = []",
|
||||||
|
"bad entry": "broadcasts = [\"192.168.1.255\"]", // no port
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := config.Parse(strings.NewReader(head + body + tail))
|
||||||
|
ce, ok := err.(*config.Error)
|
||||||
|
if !ok || !strings.HasPrefix(ce.Field, "hosts.a.wake") {
|
||||||
|
t.Fatalf("err = %v, want *config.Error under hosts.a.wake", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Wake is the magic-wake pattern sent to a host to rouse it: its MAC, the
|
// Wake is the magic-wake pattern sent to a host to rouse it: its MAC, the
|
||||||
// broadcast address to aim at, and how long to wait for the answer.
|
// broadcast address(s) to aim at, and how long to wait for the answer. A host
|
||||||
|
// that roams between networks names several, so Broadcast (one) and Broadcasts
|
||||||
|
// (a list) are alternatives: exactly one must be set.
|
||||||
type Wake struct {
|
type Wake struct {
|
||||||
MAC string `toml:"mac"`
|
MAC string `toml:"mac"`
|
||||||
Broadcast string `toml:"broadcast"`
|
Broadcast string `toml:"broadcast"`
|
||||||
|
Broadcasts []string `toml:"broadcasts"`
|
||||||
Wait Duration `toml:"wait"`
|
Wait Duration `toml:"wait"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Addresses is Broadcast (when set) followed by Broadcasts: the ordered list to
|
||||||
|
// send wake packets to, never empty for a parsed config.
|
||||||
|
func (w *Wake) Addresses() []string {
|
||||||
|
addrs := make([]string, 0, 1+len(w.Broadcasts))
|
||||||
|
if w.Broadcast != "" {
|
||||||
|
addrs = append(addrs, w.Broadcast)
|
||||||
|
}
|
||||||
|
return append(addrs, w.Broadcasts...)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultWakeWait = 45 * time.Second
|
DefaultWakeWait = 45 * time.Second
|
||||||
MinWakeWait = 5 * time.Second
|
MinWakeWait = 5 * time.Second
|
||||||
@@ -40,8 +53,19 @@ func (c *Config) checkWake() *Error {
|
|||||||
return &Error{Field: wakeField + ".mac", Msg: "must be a MAC address"}
|
return &Error{Field: wakeField + ".mac", Msg: "must be a MAC address"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, _, err := net.SplitHostPort(w.Broadcast); err != nil || w.Broadcast == "" {
|
broadcastSet := w.Broadcast != ""
|
||||||
|
broadcastsSet := len(w.Broadcasts) > 0
|
||||||
|
switch {
|
||||||
|
case broadcastSet && broadcastsSet:
|
||||||
|
return &Error{Field: wakeField + ".broadcasts", Msg: "choose broadcast or broadcasts, not both"}
|
||||||
|
case !broadcastSet && !broadcastsSet:
|
||||||
return &Error{Field: wakeField + ".broadcast", Msg: "must be a non-empty host:port"}
|
return &Error{Field: wakeField + ".broadcast", Msg: "must be a non-empty host:port"}
|
||||||
|
default:
|
||||||
|
for _, a := range w.Broadcasts {
|
||||||
|
if _, _, err := net.SplitHostPort(a); err != nil || a == "" {
|
||||||
|
return &Error{Field: wakeField + ".broadcasts", Msg: "must be a non-empty host:port"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.Wait.Duration == 0 {
|
if w.Wait.Duration == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// templateName matches a route template: a valid route name ending in "-*".
|
||||||
|
var templateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*-\*$`)
|
||||||
|
|
||||||
|
// Route resolves a request route name: an exact entry wins; else the longest template
|
||||||
|
// "<prefix>-*" whose prefix (including the dash) starts name with a non-empty remainder;
|
||||||
|
// else ok is false. key is the config key that matched (the template's name for a template).
|
||||||
|
// A name that is not a valid route name (the pattern below) or contains '*' never matches.
|
||||||
|
func (c *Config) Route(name string) (r Route, key string, ok bool) {
|
||||||
|
if !routeName.MatchString(name) {
|
||||||
|
return Route{}, "", false
|
||||||
|
}
|
||||||
|
if rt, found := c.Routes[name]; found {
|
||||||
|
return rt, name, true
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
best Route
|
||||||
|
bestKey string
|
||||||
|
bestLen int
|
||||||
|
)
|
||||||
|
for tmpl, rt := range c.Routes {
|
||||||
|
if !templateName.MatchString(tmpl) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := tmpl[:len(tmpl)-1] // drop the trailing '*', keeping the dash
|
||||||
|
if len(prefix) > bestLen && strings.HasPrefix(name, prefix) && len(name) > len(prefix) {
|
||||||
|
best, bestKey, bestLen = rt, tmpl, len(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestKey == "" {
|
||||||
|
return Route{}, "", false
|
||||||
|
}
|
||||||
|
return best, bestKey, true
|
||||||
|
}
|
||||||
@@ -144,13 +144,13 @@ func (p *Handler) route(r *http.Request) (route, rest string, code int, msg stri
|
|||||||
if hdr != "" {
|
if hdr != "" {
|
||||||
rest := r.URL.Path
|
rest := r.URL.Path
|
||||||
// A path that also carries a (different) route name is a client mistake: the header is the
|
// A path that also carries a (different) route name is a client mistake: the header is the
|
||||||
// operator's intent, but the path disagrees.
|
// operator's intent, but the path disagrees. Compare concrete names.
|
||||||
if rname, _, ok := SplitRoute(rest); ok {
|
if rname, _, ok := SplitRoute(rest); ok {
|
||||||
if _, known := p.cfg.Routes[rname]; known && rname != hdr {
|
if _, _, rok := p.cfg.Route(rname); rok && rname != hdr {
|
||||||
return "", "", http.StatusBadRequest, "conflicting route"
|
return "", "", http.StatusBadRequest, "conflicting route"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, known := p.cfg.Routes[hdr]; !known {
|
if _, _, ok := p.cfg.Route(hdr); !ok {
|
||||||
return "", "", http.StatusNotFound, "unknown route"
|
return "", "", http.StatusNotFound, "unknown route"
|
||||||
}
|
}
|
||||||
if !allowedPath(rest) {
|
if !allowedPath(rest) {
|
||||||
@@ -162,7 +162,7 @@ func (p *Handler) route(r *http.Request) (route, rest string, code int, msg stri
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", "", http.StatusBadRequest, "missing route"
|
return "", "", http.StatusBadRequest, "missing route"
|
||||||
}
|
}
|
||||||
if _, known := p.cfg.Routes[route]; !known {
|
if _, _, ok := p.cfg.Route(route); !ok {
|
||||||
return "", "", http.StatusNotFound, "unknown route"
|
return "", "", http.StatusNotFound, "unknown route"
|
||||||
}
|
}
|
||||||
if !allowedPath(rest) {
|
if !allowedPath(rest) {
|
||||||
@@ -207,7 +207,7 @@ func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
p.writeError(w, code, msg)
|
p.writeError(w, code, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
routeCfg := p.cfg.Routes[route]
|
routeCfg, _, _ := p.cfg.Route(route)
|
||||||
|
|
||||||
model, body, err := peekModel(r)
|
model, body, err := peekModel(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package proxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/proxy"
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const templateHosts = `
|
||||||
|
listen = "127.0.0.1:1"
|
||||||
|
[hosts.alpha]
|
||||||
|
base_url = %q
|
||||||
|
models = { "shared" = { parallel = 4 } }
|
||||||
|
[routes."opencode-*"]
|
||||||
|
hosts = ["alpha"]
|
||||||
|
default_model = "shared"
|
||||||
|
[routes.opencode-fixed]
|
||||||
|
hosts = ["alpha"]
|
||||||
|
default_model = "shared"
|
||||||
|
`
|
||||||
|
|
||||||
|
// One OpenCode instance per route, without listing every instance in the config: a route named
|
||||||
|
// "opencode-*" serves any request route "opencode-<something>". Leases and accounting are keyed
|
||||||
|
// by the concrete route name, so two instances never share a lease and each gets its own usage
|
||||||
|
// row. The literal template name is never a request route.
|
||||||
|
func TestRouteTemplateServesConcreteRoutes(t *testing.T) {
|
||||||
|
alpha := newUpstream(t, "alpha")
|
||||||
|
r := newRig(t, templateHosts, alpha)
|
||||||
|
|
||||||
|
resp := r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 1))
|
||||||
|
drain(resp)
|
||||||
|
if resp.StatusCode != 200 || resp.Header.Get(proxy.HostHeader) != "alpha" || resp.Header.Get(proxy.LeaseHeader) != "new" {
|
||||||
|
t.Fatalf("first turn on a templated route: %d %q %q, want 200 alpha new", resp.StatusCode, resp.Header.Get(proxy.HostHeader), resp.Header.Get(proxy.LeaseHeader))
|
||||||
|
}
|
||||||
|
resp = r.post("/opencode-projecta-4242/v1/chat/completions", conversation(1, 2))
|
||||||
|
drain(resp)
|
||||||
|
if resp.Header.Get(proxy.LeaseHeader) != "reused" {
|
||||||
|
t.Errorf("second turn should reuse the lease, got %q", resp.Header.Get(proxy.LeaseHeader))
|
||||||
|
}
|
||||||
|
// A second instance with the same conversation shape is a different route: its own lease.
|
||||||
|
resp = r.post("/opencode-projectb-7/v1/chat/completions", conversation(1, 1))
|
||||||
|
drain(resp)
|
||||||
|
if resp.StatusCode != 200 || resp.Header.Get(proxy.LeaseHeader) != "new" {
|
||||||
|
t.Errorf("another instance must get its own lease: %d %q", resp.StatusCode, resp.Header.Get(proxy.LeaseHeader))
|
||||||
|
}
|
||||||
|
// The header form resolves templates too.
|
||||||
|
resp = r.post("/v1/chat/completions", conversation(2, 1), proxy.RouteHeader, "opencode-projectc-1")
|
||||||
|
drain(resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("X-Crossbar-Route with a templated name: %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// An exact route still works and is not shadowed by the template.
|
||||||
|
resp = r.post("/opencode-fixed/v1/chat/completions", conversation(3, 1))
|
||||||
|
drain(resp)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("exact route: %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{"/opencode-*/v1/models", "/opencode-/v1/models", "/opencode/v1/models", "/opencodex/v1/models"} {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, r.front.URL+path, nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
drain(resp)
|
||||||
|
if resp.StatusCode != 404 {
|
||||||
|
t.Errorf("%s: %d, want 404 unknown route", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := r.store.Usage(time.Time{}, store.ByRoute)
|
||||||
|
keys := map[string]int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
keys[row.Key] = row.Requests
|
||||||
|
}
|
||||||
|
if keys["opencode-projecta-4242"] != 2 || keys["opencode-projectb-7"] != 1 || keys["opencode-projectc-1"] != 1 || keys["opencode-fixed"] != 1 {
|
||||||
|
t.Errorf("usage by route = %v, want rows per concrete route", keys)
|
||||||
|
}
|
||||||
|
if _, present := keys["opencode-*"]; present {
|
||||||
|
t.Errorf("the template name must never be an accounting key: %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package wake_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/wake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listener returns a UDP socket on 127.0.0.1 and a channel that gets one value per datagram.
|
||||||
|
func listener(t *testing.T) (string, <-chan []byte) {
|
||||||
|
t.Helper()
|
||||||
|
pc, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pc.Close() })
|
||||||
|
got := make(chan []byte, 4)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
n, _, err := pc.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
copy(b, buf[:n])
|
||||||
|
got <- b
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return pc.LocalAddr().String(), got
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectPacket(t *testing.T, name string, got <-chan []byte) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case b := <-got:
|
||||||
|
if len(b) != 102 {
|
||||||
|
t.Errorf("%s: got %d bytes, want a 102-byte magic packet", name, len(b))
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Errorf("%s: no packet within two seconds", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target may name several broadcast addresses (a host that roams between two networks): the
|
||||||
|
// packet goes to every one of them, and one address that cannot be resolved does not stop the
|
||||||
|
// others.
|
||||||
|
func TestWakeSendsToEveryBroadcast(t *testing.T) {
|
||||||
|
a, gotA := listener(t)
|
||||||
|
b, gotB := listener(t)
|
||||||
|
h := &fakeHealth{after: 1 << 30} // never healthy
|
||||||
|
w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcasts: []string{a, "256.1.1.1:9", b}, Wait: 300 * time.Millisecond}}, h)
|
||||||
|
w.PollEvery(20 * time.Millisecond)
|
||||||
|
if w.Wake(t.Context(), "titan") {
|
||||||
|
t.Errorf("Wake must report false when the host never comes up")
|
||||||
|
}
|
||||||
|
expectPacket(t, "first address", gotA)
|
||||||
|
expectPacket(t, "third address, after an unresolvable second", gotB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single-address form keeps working, alone or together with the list.
|
||||||
|
func TestWakeBroadcastAndBroadcastsCombine(t *testing.T) {
|
||||||
|
a, gotA := listener(t)
|
||||||
|
b, gotB := listener(t)
|
||||||
|
h := &fakeHealth{after: 1 << 30} // never healthy
|
||||||
|
w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcast: a, Broadcasts: []string{b}, Wait: 300 * time.Millisecond}}, h)
|
||||||
|
w.PollEvery(20 * time.Millisecond)
|
||||||
|
w.Wake(t.Context(), "titan")
|
||||||
|
expectPacket(t, "Broadcast", gotA)
|
||||||
|
expectPacket(t, "Broadcasts[0]", gotB)
|
||||||
|
}
|
||||||
+40
-7
@@ -7,14 +7,24 @@ package wake
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target describes how to wake one named host.
|
// log reports a broadcast that fails to resolve or send. Wake continues past
|
||||||
|
// such failures (rule: one dead address must not stop the others), so this is
|
||||||
|
// the only place the package logs; the address and error are not request data.
|
||||||
|
var log = slog.New(slog.Default().Handler())
|
||||||
|
|
||||||
|
// Target describes how to wake one named host. Broadcast is the single-address
|
||||||
|
// form (as before); Broadcasts names more than one (a host that roams between
|
||||||
|
// networks). Wake sends to Broadcast (if set) and then each of Broadcasts.
|
||||||
type Target struct {
|
type Target struct {
|
||||||
MAC, Broadcast string // MAC "aa:bb:cc:dd:ee:ff" (any separator, any case); Broadcast "host:port"
|
MAC string
|
||||||
|
Broadcast string
|
||||||
|
Broadcasts []string
|
||||||
Wait time.Duration
|
Wait time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +77,26 @@ func Send(mac, broadcast string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendAll emits one magic packet for mac to broadcast (if non-empty) and then
|
||||||
|
// to each address in the rest, in order. An address that fails to resolve or
|
||||||
|
// send is logged and does not stop the others; it reports whether at least one
|
||||||
|
// packet went out.
|
||||||
|
func sendAll(mac, broadcast string, rest []string) bool {
|
||||||
|
addrs := append([]string{broadcast}, rest...)
|
||||||
|
sent := false
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := Send(mac, addr); err != nil {
|
||||||
|
log.Error("wake broadcast failed", "addr", addr, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
return sent
|
||||||
|
}
|
||||||
|
|
||||||
// Waker wakes named hosts at most once per wait window and waits for the health
|
// Waker wakes named hosts at most once per wait window and waits for the health
|
||||||
// table to report them healthy. It is safe for concurrent Wake calls.
|
// table to report them healthy. It is safe for concurrent Wake calls.
|
||||||
type Waker struct {
|
type Waker struct {
|
||||||
@@ -95,10 +125,13 @@ func (w *Waker) PollEvery(d time.Duration) {
|
|||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake sends a magic packet for host if none was sent in the last Wait, then
|
// Wake sends a magic packet for host to every broadcast address — Broadcast
|
||||||
// polls health until the host is healthy, the wait elapses, or ctx is done. It
|
// (if set) then each of Broadcasts, in order — if none was sent in the last
|
||||||
// returns true only when the host becomes healthy, and false for an unknown
|
// Wait, then polls health until the host is healthy, the wait elapses, or ctx is
|
||||||
// host, on timeout, or when ctx ends first.
|
// done. A broadcast that fails to resolve or send is logged and does not stop
|
||||||
|
// the others; Wake returns true only when the host becomes healthy, and false
|
||||||
|
// for an unknown host, on timeout, when ctx ends first, or when no address
|
||||||
|
// could be sent to.
|
||||||
func (w *Waker) Wake(ctx context.Context, host string) bool {
|
func (w *Waker) Wake(ctx context.Context, host string) bool {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
target, ok := w.targets[host]
|
target, ok := w.targets[host]
|
||||||
@@ -110,7 +143,7 @@ func (w *Waker) Wake(ctx context.Context, host string) bool {
|
|||||||
if last, sent := w.lastSent[host]; !sent || now.Sub(last) >= target.Wait {
|
if last, sent := w.lastSent[host]; !sent || now.Sub(last) >= target.Wait {
|
||||||
w.lastSent[host] = now
|
w.lastSent[host] = now
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
if err := Send(target.MAC, target.Broadcast); err != nil {
|
if !sendAll(target.MAC, target.Broadcast, target.Broadcasts) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user