Files
llmgw/internal/control/adminapi/api_test.go
T
Ilker ManapandClaude Opus 5 707d774c76 Change a model's replica count without editing a file on the control plane
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>
2026-09-27 11:25:05 +02:00

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")
}
}