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,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