A model has an owner (org, team or project). Quotas guarantee a scope GPUs, cores and memory and may limit it; what a scope holds is summed from its models' replicas and those beneath it. A replica within its owner's guarantee is not taken for another scope; what is held beyond a guarantee is borrowed and taken back for a scope within its own. A limit stops a model before it starts, and headroom stops there too. ctl quota and ctl model owner set them; metrics and a CapacityAtLimit alert report them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
108 lines
3.9 KiB
Go
108 lines
3.9 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// fakeQuotaStore is Acme with teams ml and web, and Scania with another ml.
|
|
type fakeQuotaStore struct {
|
|
quotas []store.CapacityQuota
|
|
}
|
|
|
|
func (f *fakeQuotaStore) CapacityQuotas(context.Context) ([]store.CapacityQuota, error) {
|
|
return f.quotas, nil
|
|
}
|
|
func (f *fakeQuotaStore) PutCapacityQuota(_ context.Context, q store.CapacityQuota) error {
|
|
for i := range f.quotas {
|
|
if f.quotas[i].Scope == q.Scope && f.quotas[i].ScopeID == q.ScopeID {
|
|
f.quotas[i] = q
|
|
return nil
|
|
}
|
|
}
|
|
f.quotas = append(f.quotas, q)
|
|
return nil
|
|
}
|
|
func (f *fakeQuotaStore) DeleteCapacityQuota(_ context.Context, s store.Scope, id string) error {
|
|
for i, q := range f.quotas {
|
|
if q.Scope == s && q.ScopeID == id {
|
|
f.quotas = append(f.quotas[:i], f.quotas[i+1:]...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeQuotaStore) ScopeParents(context.Context) (map[string][]string, error) {
|
|
return map[string][]string{"team:t-ml": {"org:o-acme"}, "team:t-web": {"org:o-acme"},
|
|
"team:t-sml": {"org:o-scania"}}, nil
|
|
}
|
|
func (f *fakeQuotaStore) AllOrgs(context.Context) ([]store.Org, error) {
|
|
return []store.Org{{ID: "o-acme", Name: "Acme"}, {ID: "o-scania", Name: "Scania"}}, nil
|
|
}
|
|
func (f *fakeQuotaStore) AllTeams(context.Context) ([]store.TeamPath, error) {
|
|
return []store.TeamPath{{ID: "t-ml", Name: "ml", OrgID: "o-acme", OrgName: "Acme"},
|
|
{ID: "t-web", Name: "web", OrgID: "o-acme", OrgName: "Acme"},
|
|
{ID: "t-sml", Name: "ml", OrgID: "o-scania", OrgName: "Scania"}}, nil
|
|
}
|
|
func (f *fakeQuotaStore) AllProjects(context.Context) ([]store.ProjectPath, error) { return nil, nil }
|
|
|
|
func TestAScopeIsNamedByPathOrIDAndNeverGuessed(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
a.SetQuotas(&fakeQuotaStore{})
|
|
for in, want := range map[string]string{
|
|
"org:Acme": "o-acme", "org:o-acme": "o-acme", "team:Acme/ml": "t-ml",
|
|
"team: acme / ML ": "t-ml", "team:Scania/ml": "t-sml", "team:web": "t-web",
|
|
} {
|
|
_, id, _, err := a.resolveScope(t.Context(), in)
|
|
if err != nil || id != want {
|
|
t.Errorf("%q: %q %v, expected %q", in, id, err, want)
|
|
}
|
|
}
|
|
for _, bad := range []string{"team:ml", "team:Acme/nope", "group:Acme", "Acme", "org:"} {
|
|
if _, _, _, err := a.resolveScope(t.Context(), bad); err == nil {
|
|
t.Errorf("%q was resolved", bad)
|
|
}
|
|
}
|
|
if _, _, _, err := a.resolveScope(t.Context(), "team:ml"); err == nil || !strings.Contains(err.Error(), "could be any of 2") {
|
|
t.Errorf("an ambiguous name should say so: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAQuotaIsSetListedAndCleared(t *testing.T) {
|
|
a, _ := setup(t, testModels())
|
|
qs := &fakeQuotaStore{}
|
|
a.SetQuotas(qs)
|
|
mux := http.NewServeMux()
|
|
a.Register(mux)
|
|
call := func(method, path, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Llmgw-Actor", "ops")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
if rec := call("PUT", "/api/v1/quotas", `{"scope":"team:Acme/ml","guarantee":{"gpus":8},"limit":{"gpus":16}}`); rec.Code != 200 {
|
|
t.Fatalf("set: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if len(qs.quotas) != 1 || qs.quotas[0].ScopeID != "t-ml" || *qs.quotas[0].Guarantee.GPUs != 8 || qs.quotas[0].UpdatedBy != "ops" {
|
|
t.Errorf("stored %+v", qs.quotas)
|
|
}
|
|
rec := call("GET", "/api/v1/quotas", "")
|
|
var views []QuotaView
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &views); err != nil || len(views) != 1 || views[0].Name != "Acme / ml" {
|
|
t.Errorf("list %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := call("PUT", "/api/v1/quotas", `{"scope":"team:Acme/ml"}`); rec.Code != 400 {
|
|
t.Errorf("an empty quota was accepted: %d", rec.Code)
|
|
}
|
|
if rec := call("DELETE", "/api/v1/quotas/team:Acme%2Fml", ""); rec.Code != 200 || len(qs.quotas) != 0 {
|
|
t.Errorf("clear: %d %s %+v", rec.Code, rec.Body, qs.quotas)
|
|
}
|
|
}
|