v2.2 plan: route templates (opencode-*) and multiple wake broadcast addresses; given tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
# v2.2 task 01: route templates
|
||||||
|
|
||||||
|
**Branch:** `v2.2` (`git switch -c v2.2 master` if it does not exist, else `git switch v2.2`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `Route templates: a route named x-* serves any request route x-<something>`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`PLAN.md` §4a gives every OpenCode instance its own route (`CROSSBAR_ROUTE="$(basename "$PWD")-$$"`),
|
||||||
|
but the config only knows explicit `[routes.NAME]` tables and everything else is `404 unknown
|
||||||
|
route`. After this task a route whose name ends in `-*` is a **template**: a request route that
|
||||||
|
starts with the part before the star, with something non-empty after it, uses that route's hosts,
|
||||||
|
default model and peers. Leases and accounting stay keyed by the concrete route name, so two
|
||||||
|
instances never share a lease and each has its own usage row.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/config/config_v22_test.go` (its `TestWakeBroadcasts` belongs to task 02 and
|
||||||
|
will fail to compile until then — see step 2), `internal/proxy/template_test.go`,
|
||||||
|
`internal/admin/admin_template_test.go`
|
||||||
|
- Modify: `internal/config/config.go`, `internal/proxy/proxy.go`, `internal/admin/admin.go`,
|
||||||
|
`internal/admin/admin_ops.go`, `cmd/crossbar/main.go` (the identity middleware's route→peers
|
||||||
|
lookup), `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package config
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Config route keys match `^[a-z0-9][a-z0-9-]*$` (as before) **or** `^[a-z0-9][a-z0-9-]*-\*$`
|
||||||
|
(a template). Anything else with a `*` is `routes.<name>: must match …` as today. A
|
||||||
|
template alone satisfies "at least one route".
|
||||||
|
2. Resolution order: exact, then longest matching template, then none.
|
||||||
|
3. The proxy resolves both the path form and the `X-Crossbar-Route` header form through
|
||||||
|
`cfg.Route`; the concrete name (not the template key) is the route used for leases,
|
||||||
|
accounting rows, logs and headers. The "conflicting route" check compares concrete names.
|
||||||
|
4. Admin: `POST /_crossbar/routes/{route}` resolves through `cfg.Route` — a concrete route under
|
||||||
|
a template can be pinned/released even before its first request; the template name itself
|
||||||
|
is `404 unknown route`. `GET /_crossbar/routes` lists config keys (templates under their own
|
||||||
|
name) and, for a template, the leases of every concrete route it matches.
|
||||||
|
5. `main.go`: the identity middleware's `func(route string) ([]string, bool)` uses `cfg.Route`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** Branch as above; copy the three given tests.
|
||||||
|
- [ ] **2. See them fail.** `config_v22_test.go` also references `Wake.Addresses()` (task 02); until
|
||||||
|
then run the config package with `-run 'TestRouteTemplate'` **after** adding a temporary
|
||||||
|
stub? No — do not add stubs. Instead implement task 01 and run
|
||||||
|
`go test -race -count=1 ./internal/proxy/ ./internal/admin/` for the behaviour, and `go vet
|
||||||
|
./internal/config/` will fail only on the missing `Addresses` method until task 02: that is
|
||||||
|
expected and is the one allowed red at the end of this task. Say so in the log row.
|
||||||
|
- [ ] **3.** `config.go`: the template regex, `Route()`. **4.** `proxy.go` route resolution.
|
||||||
|
**5.** `admin.go` / `admin_ops.go`. **6.** `main.go`.
|
||||||
|
- [ ] **7.** `gofmt -w`; `go test -race -count=1 ./internal/proxy/ ./internal/admin/ ./internal/health/ ./internal/wake/` → `ok`.
|
||||||
|
- [ ] **8.** Row `v2.2/01-route-templates`; commit (the gate runs green after task 02).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/config internal/proxy internal/admin cmd/crossbar docs/implementer-log.md
|
||||||
|
git commit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `TestRouteTemplateServesConcreteRoutes` and `TestRoutesViewAndPinWithTemplates` pass under
|
||||||
|
`-race`; every earlier proxy/admin test still passes; given files byte-identical; no file over
|
||||||
|
400 lines. `internal/config` is red only on `Addresses` (task 02).
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- Passing needs a change to any earlier given test.
|
||||||
|
- The 400-line limit forces `proxy.go` apart: say which split you propose before doing it.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# v2.2 task 02: several broadcast addresses per wake target
|
||||||
|
|
||||||
|
**Branch:** `v2.2` (`git switch v2.2`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `Wake: a target may list several broadcast addresses`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Titan roams between two Wi-Fi networks; hyperborea sits on both segments. A wake target can
|
||||||
|
therefore name **several** broadcast addresses and the magic packet goes to all of them. Config
|
||||||
|
keeps `broadcast = "host:port"` (one) and adds `broadcasts = ["host:port", …]` (a list);
|
||||||
|
exactly one of the two must be present.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `internal/wake/broadcasts_test.go` (`internal/config/config_v22_test.go` was copied in
|
||||||
|
task 01 and its `TestWakeBroadcasts` becomes green here)
|
||||||
|
- Modify: `internal/config/config.go`, `internal/wake/wake.go`, `cmd/crossbar/main.go`,
|
||||||
|
`example.toml` (show the list form, commented), `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
package config
|
||||||
|
type Wake struct {
|
||||||
|
MAC string `toml:"mac"`
|
||||||
|
Broadcast string `toml:"broadcast"`
|
||||||
|
Broadcasts []string `toml:"broadcasts"`
|
||||||
|
Wait Duration `toml:"wait"`
|
||||||
|
}
|
||||||
|
// Addresses is Broadcast (when set) followed by Broadcasts: the list to send to, never empty
|
||||||
|
// for a parsed config.
|
||||||
|
func (w *Wake) Addresses() []string
|
||||||
|
|
||||||
|
package wake
|
||||||
|
type Target struct {
|
||||||
|
MAC string
|
||||||
|
Broadcast string // one address, as before
|
||||||
|
Broadcasts []string // more addresses; Send goes to Broadcast (if set) and then each of these
|
||||||
|
Wait time.Duration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Validation (`hosts.<h>.wake…` fields): `broadcast` and `broadcasts` both set → error on
|
||||||
|
`hosts.<h>.wake.broadcasts`; neither, or an empty list → error on `hosts.<h>.wake.broadcast`;
|
||||||
|
every entry must be `host:port` (same check as `broadcast` today) → error on
|
||||||
|
`hosts.<h>.wake.broadcasts`.
|
||||||
|
2. `Waker.Wake` sends one packet to every address in order. An address that fails to resolve
|
||||||
|
or send is logged (or ignored) and does **not** stop the remaining addresses; `Wake` returns
|
||||||
|
false only if *no* address could be sent to (or on the existing timeout/ctx rules).
|
||||||
|
3. `main.go` fills `Target.Broadcasts` from `Wake.Addresses()`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1.** `git switch v2.2`; copy `broadcasts_test.go`.
|
||||||
|
- [ ] **2. See it fail** (compile). **3.** `config.go`, then `wake.go`, then `main.go`, `example.toml`.
|
||||||
|
- [ ] **4.** `go test -race -count=3 ./internal/wake/ ./internal/config/` → `ok`. **5.** `go test -race -count=1 ./...`; `make smoke`.
|
||||||
|
- [ ] **6.** `make gate`. **7.** Row `v2.2/02-broadcasts`; commit.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add internal/config internal/wake cmd/crossbar example.toml docs/implementer-log.md
|
||||||
|
git commit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- All given tests pass; the v2 `wake_test.go` and `config_v2_test.go` are untouched and green;
|
||||||
|
gate and smoke ok; given files byte-identical.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# v2.2 implementation plan: what the hyperborea deploy exposed
|
||||||
|
|
||||||
|
> **For the implementing model:** do not work from this file. The owner gives you one task file at
|
||||||
|
> a time. This file is the index for the owner and the reviewer.
|
||||||
|
|
||||||
|
**Goal:** two small gaps found on 2026-09-25 when crossbar went live on hyperborea.
|
||||||
|
|
||||||
|
- **01-route-templates** — `PLAN.md`'s one-route-per-OpenCode-instance launcher produces route
|
||||||
|
names the config has never seen, and unknown routes are 404. A route named `opencode-*` now
|
||||||
|
serves every `opencode-<something>`; leases and accounting stay per concrete route. Given:
|
||||||
|
`config/config_v22_test.go`, `proxy/template_test.go`, `admin/admin_template_test.go`.
|
||||||
|
- **02-broadcasts** — titan roams between two Wi-Fi networks and hyperborea is on both segments,
|
||||||
|
so a wake target needs more than one broadcast address. Given: `wake/broadcasts_test.go`
|
||||||
|
(+ `TestWakeBroadcasts` in the config test above).
|
||||||
|
|
||||||
|
**Order matters:** the config given test covers both tasks, so `internal/config` is red on one
|
||||||
|
method between task 01's commit and task 02's. Task 01's text says so; the gate runs after 02.
|
||||||
|
|
||||||
|
**How this plan was made:** acceptance tests first, no reference implementation; the given tests
|
||||||
|
compiled against a panic-only skeleton of the new names and failed on the v2.1 tree for the
|
||||||
|
intended reasons.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Everything in `AGENTS.md`. Branch `v2.2` from `master`. One task, one fresh OpenCode session,
|
||||||
|
one commit. Given files are copied and never edited; earlier plans' given files stay protected.
|
||||||
|
|
||||||
|
## Changes during the run
|
||||||
|
|
||||||
|
(none yet)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user