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