The count is scaling.min, and until now the only way to change it was to edit server.yaml on the host and restart — or, for a model added at runtime, to remove it and add it back, which means having its whole definition to hand. `llmgw ctl scale <model> --min N` instead. Only the scaling block moves: everything else is read back from what was stored and written out untouched, so changing a number cannot quietly become a redefinition. The catalog is rebuilt and validated before anything is written, as in Add. A max below a min is refused with the stored definition untouched — persisting a rejected change would come back on the next restart as a catalog that does not build. The answer reports the scaling in force rather than the request. The catalog fills in a max that was left out, and a caller echoing its own request would print a number the cluster is not using. A model declared in configuration is refused, for the reason Remove already refuses one: the YAML is read on every start, so the new count would hold until the next restart and then silently revert. A cluster disagreeing with its own config file is worse than an error saying where to edit. The help says what --max does, because it is not what it looks like: a model with a minimum runs exactly that many and nothing scales it up under load, and an on-demand model loads one replica however high its max. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
421 lines
13 KiB
Go
421 lines
13 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
|
)
|
|
|
|
// nullSink discards published endpoints; these tests are about the API.
|
|
type nullSink struct{}
|
|
|
|
func (nullSink) SetEndpoints(context.Context, []endpoint.Info) {}
|
|
|
|
// nullDispatcher accepts every command.
|
|
type nullDispatcher struct{}
|
|
|
|
func (nullDispatcher) StartShard(context.Context, reconciler.ShardCommand) error { return nil }
|
|
func (nullDispatcher) StopShard(context.Context, string, string) error { return nil }
|
|
func (nullDispatcher) DrainShard(context.Context, string, string) error { return nil }
|
|
|
|
func setup(t *testing.T, models []catalog.Model, nodes ...inventory.Node) (*API, *control.Controller) {
|
|
t.Helper()
|
|
cat, err := catalog.New(models)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := control.DefaultConfig()
|
|
// No startup settling window: these tests build the whole fleet before
|
|
// anything runs, which is the state settling waits for. Leaving it on would
|
|
// make every test here assert that placement had not happened yet.
|
|
cfg.Reconciler.SettleFor = -1
|
|
ctl := control.New(cfg, catalog.NewLive(cat), nullDispatcher{}, nullSink{}, slog.New(slog.DiscardHandler))
|
|
for _, n := range nodes {
|
|
ctl.Inventory().Upsert(n)
|
|
}
|
|
return New(ctl), ctl
|
|
}
|
|
|
|
func gpuNode(id string, n int, fabrics ...string) inventory.Node {
|
|
nd := inventory.Node{ID: id, State: inventory.Ready, AgentVersion: "v1"}
|
|
for i := range n {
|
|
nd.GPUs = append(nd.GPUs, discovery.GPU{
|
|
Index: i, Name: "NVIDIA H100 80GB HBM3", MemoryTotalMB: 81559,
|
|
})
|
|
}
|
|
for _, f := range fabrics {
|
|
nd.Fabrics = append(nd.Fabrics, inventory.FabricAttachment{Fabric: f, HCAs: []string{"mlx5_0"}})
|
|
}
|
|
return nd
|
|
}
|
|
|
|
func testModels() []catalog.Model {
|
|
return []catalog.Model{{
|
|
Name: "llama", Aliases: []string{"l"}, Source: "/m",
|
|
Scaling: catalog.Scaling{Min: 2, Max: 4},
|
|
Profiles: []catalog.Profile{{Name: "tp8", GPUClass: "h100-80g", TP: 8}},
|
|
}}
|
|
}
|
|
|
|
func call(t *testing.T, a *API, method, path string, body string, out any) int {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
var r *http.Request
|
|
if body != "" {
|
|
r = httptest.NewRequest(method, path, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
r = httptest.NewRequest(method, path, nil)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, r)
|
|
|
|
if out != nil && rec.Code == http.StatusOK {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
|
t.Fatalf("decoding %s: %v (%s)", path, err, rec.Body.String())
|
|
}
|
|
}
|
|
return rec.Code
|
|
}
|
|
|
|
func TestListNodes(t *testing.T) {
|
|
a, _ := setup(t, testModels(), gpuNode("n1", 8, "ib-a"))
|
|
|
|
var nodes []NodeView
|
|
if code := call(t, a, "GET", "/api/v1/nodes", "", &nodes); code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("got %d nodes", len(nodes))
|
|
}
|
|
n := nodes[0]
|
|
if n.ID != "n1" || n.State != "ready" {
|
|
t.Errorf("%+v", n)
|
|
}
|
|
if n.GPUTotal != 8 || n.GPUFree != 8 {
|
|
t.Errorf("gpus %d/%d", n.GPUFree, n.GPUTotal)
|
|
}
|
|
if len(n.Classes) != 1 || n.Classes[0] != "h100-80g" {
|
|
t.Errorf("classes=%v", n.Classes)
|
|
}
|
|
if len(n.Fabrics) != 1 || n.Fabrics[0] != "ib-a" {
|
|
t.Errorf("fabrics=%v", n.Fabrics)
|
|
}
|
|
}
|
|
|
|
func TestUnhealthyGPUsAreSurfaced(t *testing.T) {
|
|
// Usually the answer when a node looks idle but nothing schedules onto it.
|
|
n := gpuNode("n1", 4)
|
|
n.GPUs[1].Health.Problems = []string{"3 uncorrected ECC errors"}
|
|
a, _ := setup(t, testModels(), n)
|
|
|
|
var nodes []NodeView
|
|
call(t, a, "GET", "/api/v1/nodes", "", &nodes)
|
|
|
|
if len(nodes[0].Unhealthy) != 1 {
|
|
t.Fatalf("unhealthy=%v", nodes[0].Unhealthy)
|
|
}
|
|
if !strings.Contains(nodes[0].Unhealthy[0], "ECC") {
|
|
t.Errorf("the reason was lost: %q", nodes[0].Unhealthy[0])
|
|
}
|
|
// And such a GPU is not counted as free.
|
|
if nodes[0].GPUFree != 3 {
|
|
t.Errorf("free=%d, an unhealthy GPU was counted", nodes[0].GPUFree)
|
|
}
|
|
}
|
|
|
|
func TestListModels(t *testing.T) {
|
|
a, _ := setup(t, testModels(), gpuNode("n1", 8))
|
|
|
|
var models []ModelView
|
|
call(t, a, "GET", "/api/v1/models", "", &models)
|
|
|
|
if len(models) != 1 {
|
|
t.Fatalf("got %d models", len(models))
|
|
}
|
|
m := models[0]
|
|
if m.Name != "llama" || m.Min != 2 || m.Max != 4 {
|
|
t.Errorf("%+v", m)
|
|
}
|
|
if len(m.Aliases) != 1 || m.Aliases[0] != "l" {
|
|
t.Errorf("aliases=%v", m.Aliases)
|
|
}
|
|
if len(m.Profiles) != 1 || m.Profiles[0] != "tp8" {
|
|
t.Errorf("profiles=%v", m.Profiles)
|
|
}
|
|
}
|
|
|
|
func TestExplainPlacementForUnplaceableModel(t *testing.T) {
|
|
// Four GPUs, a profile that needs eight.
|
|
a, ctl := setup(t, testModels(), gpuNode("n1", 4))
|
|
ctl.Reconciler().Reconcile(t.Context())
|
|
|
|
var v ExplainView
|
|
if code := call(t, a, "GET", "/api/v1/explain-placement?model=llama", "", &v); code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if v.Placed {
|
|
t.Error("an unplaceable model was reported as placed")
|
|
}
|
|
if len(v.Rejections) == 0 {
|
|
t.Fatal("no rejections were recorded; the operator learns nothing")
|
|
}
|
|
found := false
|
|
for _, r := range v.Rejections {
|
|
if strings.Contains(r.Reason, "needs 8") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("the reason does not say what was missing: %+v", v.Rejections)
|
|
}
|
|
}
|
|
|
|
func TestExplainPlacementRequiresAKnownModel(t *testing.T) {
|
|
a, _ := setup(t, testModels(), gpuNode("n1", 8))
|
|
|
|
if code := call(t, a, "GET", "/api/v1/explain-placement", "", nil); code != 400 {
|
|
t.Errorf("missing model gave %d, expected 400", code)
|
|
}
|
|
if code := call(t, a, "GET", "/api/v1/explain-placement?model=nope", "", nil); code != 404 {
|
|
t.Errorf("unknown model gave %d, expected 404", code)
|
|
}
|
|
}
|
|
|
|
func TestSetNodeState(t *testing.T) {
|
|
a, ctl := setup(t, testModels(), gpuNode("n1", 8))
|
|
|
|
for _, c := range []struct {
|
|
body string
|
|
want inventory.NodeState
|
|
}{
|
|
{`{"state":"cordon"}`, inventory.Cordoned},
|
|
{`{"state":"drain"}`, inventory.Draining},
|
|
{`{"state":"uncordon"}`, inventory.Ready},
|
|
{`{"state":"cordoned"}`, inventory.Cordoned},
|
|
} {
|
|
if code := call(t, a, "POST", "/api/v1/nodes/n1/state", c.body, nil); code != 200 {
|
|
t.Fatalf("%s gave %d", c.body, code)
|
|
}
|
|
n, _ := ctl.Inventory().Get("n1")
|
|
if n.State != c.want {
|
|
t.Errorf("%s left the node %s, expected %s", c.body, n.State, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetNodeStateRejectsNonsense(t *testing.T) {
|
|
a, _ := setup(t, testModels(), gpuNode("n1", 8))
|
|
|
|
if code := call(t, a, "POST", "/api/v1/nodes/n1/state", `{"state":"banana"}`, nil); code != 400 {
|
|
t.Errorf("an unknown state gave %d, expected 400", code)
|
|
}
|
|
if code := call(t, a, "POST", "/api/v1/nodes/nope/state", `{"state":"cordon"}`, nil); code != 404 {
|
|
t.Errorf("an unknown node gave %d, expected 404", code)
|
|
}
|
|
if code := call(t, a, "POST", "/api/v1/nodes/n1/state", `not json`, nil); code != 400 {
|
|
t.Errorf("malformed body gave %d, expected 400", code)
|
|
}
|
|
}
|
|
|
|
func TestEmptyClusterReturnsArraysNotNull(t *testing.T) {
|
|
// A JSON null would make every consumer special-case it.
|
|
a, _ := setup(t, testModels())
|
|
|
|
for _, path := range []string{"/api/v1/nodes", "/api/v1/replicas"} {
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
|
|
|
|
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
|
|
t.Errorf("%s returned %q, expected []", path, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeModels struct {
|
|
added []catalog.Model
|
|
actors []string
|
|
removed []string
|
|
scaled []catalog.Scaling
|
|
err error
|
|
}
|
|
|
|
func (f *fakeModels) Scale(_ context.Context, name string, s catalog.Scaling, by string) (catalog.Model, error) {
|
|
if f.err != nil {
|
|
return catalog.Model{}, f.err
|
|
}
|
|
f.scaled = append(f.scaled, s)
|
|
f.actors = append(f.actors, by)
|
|
// Max following min is what the catalog does; the fake has to do it too or
|
|
// the handler test would pass while the real answer reported a zero.
|
|
if s.Max == 0 && s.Min > 0 {
|
|
s.Max = s.Min
|
|
}
|
|
return catalog.Model{Name: name, Scaling: s}, nil
|
|
}
|
|
|
|
func (f *fakeModels) Add(_ context.Context, m catalog.Model, by string) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.added = append(f.added, m)
|
|
f.actors = append(f.actors, by)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeModels) Remove(_ context.Context, name string) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.removed = append(f.removed, name)
|
|
return nil
|
|
}
|
|
|
|
func TestAddingAModelOverTheAPI(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
fm := &fakeModels{}
|
|
a.SetModels(fm)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
body := `{"name":"new","source":"/models/new","profiles":[{"name":"p","engine":"llamacpp"}]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/models", strings.NewReader(body))
|
|
req.Header.Set("X-Llmgw-Actor", "ilker")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(fm.added) != 1 || fm.added[0].Name != "new" {
|
|
t.Fatalf("added = %+v", fm.added)
|
|
}
|
|
// Who made the change, kept so a catalog that grew over months can say
|
|
// where each entry came from.
|
|
if len(fm.actors) != 1 || fm.actors[0] != "ilker" {
|
|
t.Errorf("actor = %v", fm.actors)
|
|
}
|
|
}
|
|
|
|
func TestABadModelDefinitionIsTheCallersFault(t *testing.T) {
|
|
// Every failure here is the definition being wrong — a collision, a missing
|
|
// fallback, a profile that cannot be placed. None are server faults, and
|
|
// returning 500 for them would send someone looking at the wrong logs.
|
|
a, _ := setup(t, testModels())
|
|
a.SetModels(&fakeModels{err: errors.New("model \"x\" collides")})
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/models", strings.NewReader(`{"name":"x"}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status %d, expected 400", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "collides") {
|
|
t.Errorf("the reason was lost: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWithoutAStoreTheAPIRefusesRatherThanForgets(t *testing.T) {
|
|
// Accepting a model that vanishes on the next restart is worse than saying
|
|
// it cannot be kept.
|
|
a, _ := setup(t, testModels()) // SetModels never called
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/models", strings.NewReader(`{"name":"x"}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotImplemented {
|
|
t.Errorf("status %d, expected 501", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "restart") {
|
|
t.Errorf("the reason does not explain the consequence: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRemovingAModelOverTheAPI(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
fm := &fakeModels{}
|
|
a.SetModels(fm)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/models/gone", nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(fm.removed) != 1 || fm.removed[0] != "gone" {
|
|
t.Errorf("removed = %v", fm.removed)
|
|
}
|
|
}
|
|
|
|
func TestScalingAModelOverTheAPI(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
fm := &fakeModels{}
|
|
a.SetModels(fm)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/models/m1/scale",
|
|
strings.NewReader(`{"min":3}`))
|
|
req.Header.Set("X-Llmgw-Actor", "ilker")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body)
|
|
}
|
|
var res ScaleResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The scaling in force, not the request echoed back: max follows min when
|
|
// it is left out, and a caller printing its own request would show 0.
|
|
if res.Min != 3 || res.Max != 3 {
|
|
t.Errorf("expected 3/3 in force, got %d/%d", res.Min, res.Max)
|
|
}
|
|
if len(fm.actors) != 1 || fm.actors[0] != "ilker" {
|
|
t.Errorf("the change should be attributed to whoever made it; got %v", fm.actors)
|
|
}
|
|
}
|
|
|
|
func TestANegativeReplicaCountIsRefused(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
fm := &fakeModels{}
|
|
a.SetModels(fm)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/models/m1/scale",
|
|
strings.NewReader(`{"min":-1}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
if len(fm.scaled) != 0 {
|
|
t.Error("nothing should have reached the service")
|
|
}
|
|
}
|