Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
760 lines
24 KiB
Go
760 lines
24 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// 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) SetAdapters(_ context.Context, name string, as []catalog.Adapter, _ string) (catalog.Model, error) {
|
|
return catalog.Model{Name: name, Adapters: as}, f.err
|
|
}
|
|
|
|
func (f *fakeModels) SetOwner(_ context.Context, name, owner, _ string) (catalog.Model, error) {
|
|
return catalog.Model{Name: name, Owner: owner}, f.err
|
|
}
|
|
|
|
func (f *fakeModels) SetClass(_ context.Context, name, class, _ string) (catalog.Model, error) {
|
|
return catalog.Model{Name: name, Class: class}, f.err
|
|
}
|
|
|
|
func (f *fakeModels) Resources(_ context.Context, name, profile string, mem, cores int, _ string) (catalog.Model, error) {
|
|
if f.err != nil {
|
|
return catalog.Model{}, f.err
|
|
}
|
|
return catalog.Model{Name: name, Profiles: []catalog.Profile{{Name: "p", MinMemoryGB: mem, CPUCores: cores}}}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
type memCapacity struct{ rows []store.StoredCapacity }
|
|
|
|
func (m *memCapacity) PutCapacity(_ context.Context, c store.StoredCapacity) error {
|
|
m.rows = append(m.rows, c)
|
|
return nil
|
|
}
|
|
|
|
func (m *memCapacity) Capacities(context.Context) ([]store.StoredCapacity, error) {
|
|
return m.rows, nil
|
|
}
|
|
|
|
func capacityAPI(t *testing.T) (*API, *memCapacity, *http.ServeMux) {
|
|
t.Helper()
|
|
a, _ := setup(t, testModels())
|
|
cap := &memCapacity{}
|
|
a.SetCapacity(cap)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
return a, cap, mux
|
|
}
|
|
|
|
func TestAMeasurementIsFiledAgainstItsModelAndProfile(t *testing.T) {
|
|
_, cap, mux := capacityAPI(t)
|
|
|
|
body := `{"profile":"tp8","max_concurrency_at_slo":8,"suggested_target_waiting":3}`
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/models/llama/capacity", strings.NewReader(body))
|
|
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)
|
|
}
|
|
if len(cap.rows) != 1 {
|
|
t.Fatalf("expected one row, got %d", len(cap.rows))
|
|
}
|
|
// The model comes from the path, not the body: a measurement filed under a
|
|
// name the caller made up would be worse than none.
|
|
if cap.rows[0].Model != "llama" || cap.rows[0].Profile != "tp8" {
|
|
t.Errorf("wrong key: %s/%s", cap.rows[0].Model, cap.rows[0].Profile)
|
|
}
|
|
if cap.rows[0].MeasuredBy != "ilker" {
|
|
t.Errorf("the measurement should say who took it; got %q", cap.rows[0].MeasuredBy)
|
|
}
|
|
}
|
|
|
|
func TestAMeasurementForAModelThatIsNotThereIsRefused(t *testing.T) {
|
|
_, cap, mux := capacityAPI(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/models/typo/capacity",
|
|
strings.NewReader(`{"profile":"tp8"}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
// A typo in the name files the measurement somewhere nobody will look, and
|
|
// the model then looks as though it was never benchmarked.
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for an unknown model, got %d", rec.Code)
|
|
}
|
|
if len(cap.rows) != 0 {
|
|
t.Error("nothing should have been stored")
|
|
}
|
|
}
|
|
|
|
func TestAMeasurementNeedsTheProfileItWasTakenOn(t *testing.T) {
|
|
_, cap, mux := capacityAPI(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/models/llama/capacity",
|
|
strings.NewReader(`{"max_concurrency_at_slo":8}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
// Capacity belongs to the hardware as much as the weights. Without a
|
|
// profile there is nothing to compare a later run against.
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 without a profile, got %d", rec.Code)
|
|
}
|
|
if len(cap.rows) != 0 {
|
|
t.Error("nothing should have been stored")
|
|
}
|
|
}
|
|
|
|
func TestAProfileTheModelDoesNotHaveIsRefused(t *testing.T) {
|
|
_, _, mux := capacityAPI(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/models/llama/capacity",
|
|
strings.NewReader(`{"profile":"not-a-profile"}`))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for a profile the model does not have, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestCapacityWithoutAStoreIsAnEmptyListRatherThanAnError(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/capacity", nil))
|
|
|
|
// Nothing measured and no store look the same to a reader, and both mean
|
|
// "there is nothing here" — which is not a server fault.
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", rec.Code)
|
|
}
|
|
var rows []CapacityView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil || len(rows) != 0 {
|
|
t.Errorf("expected an empty list, got %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
type memUsage struct {
|
|
rows []store.ScopeUsage
|
|
scope store.Scope
|
|
since time.Time
|
|
err error
|
|
}
|
|
|
|
func (m *memUsage) UsageByScope(_ context.Context, s store.Scope, since time.Time) ([]store.ScopeUsage, error) {
|
|
m.scope, m.since = s, since
|
|
return m.rows, m.err
|
|
}
|
|
|
|
func usageAPI(t *testing.T, u *memUsage) *http.ServeMux {
|
|
t.Helper()
|
|
a, _ := setup(t, testModels())
|
|
a.SetUsage(u)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
return mux
|
|
}
|
|
|
|
func TestUsageDefaultsToTeamsOverTheLastDay(t *testing.T) {
|
|
u := &memUsage{}
|
|
rec := httptest.NewRecorder()
|
|
usageAPI(t, u).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/usage", nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body)
|
|
}
|
|
// The team is the level a quota is usually written on, so it is the one
|
|
// somebody asking for "usage" with no argument means.
|
|
if u.scope != store.ScopeTeam {
|
|
t.Errorf("expected teams by default, got %q", u.scope)
|
|
}
|
|
if d := time.Since(u.since); d < 23*time.Hour || d > 25*time.Hour {
|
|
t.Errorf("expected roughly a day, got %v", d)
|
|
}
|
|
}
|
|
|
|
func TestUsageReportsTheFractionOnlyWhenThereIsABudget(t *testing.T) {
|
|
budget := int64(2000)
|
|
u := &memUsage{rows: []store.ScopeUsage{
|
|
{Scope: store.ScopeTeam, ID: "t1", Name: "platform", CostMicros: 1000,
|
|
Budget: &budget, BudgetPeriod: store.PeriodMonth},
|
|
{Scope: store.ScopeTeam, ID: "t2", Name: "research", CostMicros: 100},
|
|
}}
|
|
rec := httptest.NewRecorder()
|
|
usageAPI(t, u).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/usage", nil))
|
|
|
|
var got []ScopeUsageView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got[0].Fraction == nil || *got[0].Fraction != 0.5 {
|
|
t.Errorf("expected half the budget used, got %v", got[0].Fraction)
|
|
}
|
|
// A team bounded only by its organization has no budget of its own; a
|
|
// fraction of nothing would have to invent a denominator.
|
|
if got[1].Fraction != nil || got[1].Budget != nil {
|
|
t.Errorf("a group with no budget should report neither, got %+v", got[1])
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownGroupingIsTheCallersMistake(t *testing.T) {
|
|
u := &memUsage{err: errors.New(`usage cannot be grouped by "model"`)}
|
|
rec := httptest.NewRecorder()
|
|
usageAPI(t, u).ServeHTTP(rec,
|
|
httptest.NewRequest(http.MethodGet, "/api/v1/usage?by=model", nil))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestABadSinceIsRefusedBeforeTheQuery(t *testing.T) {
|
|
u := &memUsage{}
|
|
rec := httptest.NewRecorder()
|
|
usageAPI(t, u).ServeHTTP(rec,
|
|
httptest.NewRequest(http.MethodGet, "/api/v1/usage?since=lastweek", nil))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rec.Code)
|
|
}
|
|
if !u.since.IsZero() {
|
|
t.Error("nothing should have reached the store")
|
|
}
|
|
}
|
|
|
|
func TestAReplicaRunningAnEditedProfileIsMarkedStale(t *testing.T) {
|
|
models := testModels()
|
|
a, ctl := setup(t, models)
|
|
|
|
// Placed from the profile as it was, then the profile is edited and keeps
|
|
// its name — which is what makes the two indistinguishable without a
|
|
// fingerprint.
|
|
was := models[0].Profiles[0].Fingerprint()
|
|
edited := models[0]
|
|
edited.Profiles[0].MinMemoryGB = 64
|
|
built, err := catalog.New([]catalog.Model{edited})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctl.ReplaceCatalog(t.Context(), built)
|
|
|
|
if !a.profileChanged("llama", "tp8", was) {
|
|
t.Error("an edited profile should read as changed")
|
|
}
|
|
if a.profileChanged("llama", "tp8", edited.Profiles[0].Fingerprint()) {
|
|
t.Error("the profile in force is not a change")
|
|
}
|
|
}
|
|
|
|
func TestAReplicaWithNoRecordedProfileIsNotStale(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
// Every replica adopted from an agent older than the field. Marking those
|
|
// stale would call a whole fleet wrong on the first upgrade.
|
|
if a.profileChanged("llama", "tp8", "") {
|
|
t.Error("unknown is not stale")
|
|
}
|
|
}
|
|
|
|
func TestAProfileThatIsGoneIsNotReportedAsStale(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
// The model or the profile disappearing is a different thing, and the
|
|
// replica's own state already says it.
|
|
if a.profileChanged("llama", "no-such-profile", "abc") {
|
|
t.Error("a missing profile is not a changed one")
|
|
}
|
|
if a.profileChanged("no-such-model", "tp8", "abc") {
|
|
t.Error("a missing model is not a changed profile")
|
|
}
|
|
}
|
|
|
|
func TestOnlyANodeThatIsGoneCanBeRemoved(t *testing.T) {
|
|
a, ctl := setup(t, testModels(), gpuNode("n1", 8))
|
|
|
|
// Connected, even cordoned: it would register again on its next
|
|
// heartbeat, so removing it would do nothing but confuse.
|
|
ctl.Inventory().SetState("n1", inventory.Cordoned)
|
|
if code := call(t, a, "DELETE", "/api/v1/nodes/n1", "", nil); code != 409 {
|
|
t.Errorf("a connected node gave %d, expected 409", code)
|
|
}
|
|
ctl.Inventory().SetState("n1", inventory.Lost)
|
|
if code := call(t, a, "DELETE", "/api/v1/nodes/n1", "", nil); code != 200 {
|
|
t.Fatalf("a lost node gave %d, expected 200", code)
|
|
}
|
|
if _, ok := ctl.Inventory().Get("n1"); ok {
|
|
t.Error("the node is still listed")
|
|
}
|
|
if code := call(t, a, "DELETE", "/api/v1/nodes/n1", "", nil); code != 404 {
|
|
t.Errorf("removing it again gave %d, expected 404", code)
|
|
}
|
|
}
|
|
|
|
func TestRemovingANodeRevokesItsCertificate(t *testing.T) {
|
|
a, ctl := setup(t, testModels(), gpuNode("n1", 8), gpuNode("n2", 8))
|
|
var revoked []string
|
|
a.SetRevoker(func(_ context.Context, id string, _ time.Time) error {
|
|
revoked = append(revoked, id)
|
|
return nil
|
|
})
|
|
ctl.Inventory().SetState("n1", inventory.Lost)
|
|
if code := call(t, a, "DELETE", "/api/v1/nodes/n1", "", nil); code != 200 {
|
|
t.Fatalf("a lost node gave %d, expected 200", code)
|
|
}
|
|
if !slices.Equal(revoked, []string{"n1"}) {
|
|
t.Errorf("revoked %v, expected n1", revoked)
|
|
}
|
|
|
|
// Not revoked, not removed: a node left listed is one the operator can
|
|
// see and try again on.
|
|
a.SetRevoker(func(context.Context, string, time.Time) error { return errors.New("store down") })
|
|
ctl.Inventory().SetState("n2", inventory.Lost)
|
|
if code := call(t, a, "DELETE", "/api/v1/nodes/n2", "", nil); code != 500 {
|
|
t.Errorf("a failed revocation gave %d, expected 500", code)
|
|
}
|
|
if _, ok := ctl.Inventory().Get("n2"); !ok {
|
|
t.Error("the node was removed though its certificate was not revoked")
|
|
}
|
|
}
|
|
|
|
func TestANodesCardInTheWrongSlotIsAWarning(t *testing.T) {
|
|
n := gpuNode("g1", 2)
|
|
n.GPUs[0].PCIe = discovery.PCIeLink{Gen: 5, GPUGen: 5, Width: 16, WidthMax: 16}
|
|
n.GPUs[1].PCIe = discovery.PCIeLink{Gen: 5, GPUGen: 5, Width: 8, WidthMax: 16}
|
|
api, _ := setup(t, testModels(), n)
|
|
var warned []string
|
|
for _, v := range api.Nodes() {
|
|
warned = append(warned, v.Warnings...)
|
|
}
|
|
if len(warned) != 1 || !strings.Contains(warned[0], "GPU 1 (NVIDIA H100 80GB HBM3) linked at x8 of its x16 lanes") {
|
|
t.Errorf("warnings %q", warned)
|
|
}
|
|
}
|
|
|
|
func (f *fakeModels) Stop(_ context.Context, name, _ string) (catalog.Model, error) {
|
|
return catalog.Model{Name: name, Stopped: &catalog.Scaling{Min: 1, Max: 1}}, f.err
|
|
}
|
|
|
|
func (f *fakeModels) Start(_ context.Context, name, _ string) (catalog.Model, error) {
|
|
return catalog.Model{Name: name, Scaling: catalog.Scaling{Min: 1, Max: 1}}, f.err
|
|
}
|