Adding a node was deploy/bootstrap/onboard-node.sh, which is not installed
anywhere and wanted the node's name, the control plane's address and a
dozen flags. It is now in the binary, run on the control plane:
sudo llmgw node add ubuntu@10.0.0.21
The node id is the machine's hostname, and the address its agent dials is
this machine's on the route to it, checked against the server certificate.
It installs llmgw there (the release for its architecture, fetched and
checked by the node; uname rather than dpkg, so RHEL works), runs doctor
there and stops on a problem before changing anything, issues and installs
the certificate, writes agent.yaml and the unit, starts the agent, and waits
for it to join and install llama.cpp. The authority is read first, so a
missing one is said before the node is touched.
The steps are events. In a terminal they draw a checklist (bubbletea) that
asks what a script cannot: which fabric each group of active adapters is on,
grouped by the subnet manager or RoCE network they report, and whether to go
on past a problem. Elsewhere, one line per step.
Found by running it on an empty VM: ssh's "Permanently added" warning on
stderr was read as the machine's hostname, so standard error is now kept
apart; and a node added again while cordoned never counted as joined, so
joining now means a heartbeat since the restart.
llmgw node remove forgets a lost node, which otherwise stays listed until
the control plane restarts. ctl nodes no longer lists vLLM as skipped on
every CPU node. The quick install, install and onboarding docs use it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
691 lines
21 KiB
Go
691 lines
21 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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) 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)
|
|
}
|
|
}
|