fix(quota): 配额表单整体替换,留空即不限、0 即不给、负数和越界拒收,超配额的服务器仍可调小
This commit is contained in:
17 files changed
+346
-109
No files matched your search
+16
-6
@@ -843,7 +843,7 @@ components:
|
||||
|
||||
QuotaView:
|
||||
type: object
|
||||
description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited.
|
||||
description: A user's quotas row (internal/api/repo.go QuotaView). An absent or null field is unlimited; 0 grants none of that resource.
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
@@ -4186,6 +4186,11 @@ paths:
|
||||
tags: [users]
|
||||
operationId: setQuotas
|
||||
summary: Set a user's quotas (admin only).
|
||||
description: >-
|
||||
Replaces all four caps at once. An absent or null field is unlimited; 0
|
||||
grants none of that resource, so every claim that needs it is refused. An
|
||||
empty body lifts every cap. A cap below what the user already owns refuses
|
||||
new claims and leaves the servers they have alone.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: owner
|
||||
security: [{ sessionCookie: [] }]
|
||||
@@ -4198,10 +4203,10 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
max_servers: { type: integer, nullable: true }
|
||||
max_cpu_milli: { type: integer, nullable: true }
|
||||
max_memory_mb: { type: integer, nullable: true }
|
||||
max_storage_gb: { type: integer, nullable: true }
|
||||
max_servers: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||||
max_cpu_milli: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||||
max_memory_mb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||||
max_storage_gb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||||
responses:
|
||||
'200':
|
||||
description: Quotas updated.
|
||||
@@ -4209,7 +4214,12 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuotaView' }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
description: >-
|
||||
invalid_quota, a cap outside 0..2147483647; or a body that is not JSON
|
||||
or carries a fraction.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
|
||||
@@ -1511,21 +1511,9 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _
|
||||
if f.fakeQuotas == nil {
|
||||
f.fakeQuotas = map[string]*QuotaView{}
|
||||
}
|
||||
if _, ok := f.fakeQuotas[userID]; !ok {
|
||||
f.fakeQuotas[userID] = &QuotaView{UserID: userID}
|
||||
}
|
||||
if qi.MaxServers != nil {
|
||||
f.fakeQuotas[userID].MaxServers = qi.MaxServers
|
||||
}
|
||||
if qi.MaxCPUMilli != nil {
|
||||
f.fakeQuotas[userID].MaxCPUMilli = qi.MaxCPUMilli
|
||||
}
|
||||
if qi.MaxMemoryMB != nil {
|
||||
f.fakeQuotas[userID].MaxMemoryMB = qi.MaxMemoryMB
|
||||
}
|
||||
if qi.MaxStorageGB != nil {
|
||||
f.fakeQuotas[userID].MaxStorageGB = qi.MaxStorageGB
|
||||
}
|
||||
// A full replacement, like the SQL upsert: nil is unlimited.
|
||||
f.fakeQuotas[userID] = &QuotaView{UserID: userID, MaxServers: qi.MaxServers,
|
||||
MaxCPUMilli: qi.MaxCPUMilli, MaxMemoryMB: qi.MaxMemoryMB, MaxStorageGB: qi.MaxStorageGB}
|
||||
return f.fakeQuotas[userID], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -434,3 +434,43 @@ func TestPatchServerClearsDisplayName(t *testing.T) {
|
||||
t.Fatalf("patched displayName = %v, want an empty one", p.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner over a cap an admin lowered (quota set below what they already use)
|
||||
// must still be brought back under it: only growth is held to the caps. Before,
|
||||
// every resource patch ran the quota check, so shrinking a server of an over-cap
|
||||
// owner got the same 403 as growing it.
|
||||
func TestPatchServerOverQuotaMayShrink(t *testing.T) {
|
||||
api, repo, cl, _ := newPatchAPI()
|
||||
seedResources(cl)
|
||||
repo.byName["survival"].OwnerID = "u1"
|
||||
repo.quota["u1"] = false // over every cap: any check refuses
|
||||
repo.serverResources["survival"] = ResourceSpec{CPUMilli: 1000, MemoryMB: 4096, StorageMB: 10240}
|
||||
|
||||
for _, body := range []string{`{"resources":{"cpu":"500m"}}`, `{"resources":{"cpu":"1"}}`} {
|
||||
delete(cl.patched, "survival")
|
||||
w := patchSurvival(api, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code = %d, want 200 (%s)", body, w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := cl.patched["survival"]; !ok {
|
||||
t.Fatalf("%s: the patch did not reach the cluster", body)
|
||||
}
|
||||
}
|
||||
if len(repo.quotaChecked) != 0 {
|
||||
t.Errorf("a patch that grows nothing was quota-checked: %+v", repo.quotaChecked)
|
||||
}
|
||||
if got := repo.resourceUpdates["survival"]; got != (ResourceSpec{CPUMilli: 1000, MemoryMB: 4096, StorageMB: 10240}) {
|
||||
t.Errorf("resource cache = %+v, want cpu 1000 / mem 4096 / storage kept", got)
|
||||
}
|
||||
|
||||
for _, body := range []string{`{"resources":{"cpu":"2"}}`, `{"resources":{"cpu":"500m","memory":"8Gi"}}`} {
|
||||
delete(cl.patched, "survival")
|
||||
w := patchSurvival(api, body)
|
||||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "quota_exceeded" {
|
||||
t.Fatalf("growing an over-cap owner's server %s: code = %d body %s, want 403 quota_exceeded", body, w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := cl.patched["survival"]; ok {
|
||||
t.Fatalf("a refused growth %s reached the cluster", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,7 +1041,17 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if rec != nil && rec.OwnerID != "" {
|
||||
var cur ResourceSpec
|
||||
if rec != nil {
|
||||
if cur, err = a.Repo.ServerResources(r.Context(), name); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Only growth is held to the caps. A change that grows neither CPU nor memory
|
||||
// cannot push the owner past one, and it is how an admin brings a server back
|
||||
// under a cap lowered below what the owner already uses.
|
||||
if rec != nil && rec.OwnerID != "" && (newCPU > cur.CPUMilli || newMemMB > cur.MemoryMB) {
|
||||
ok, err := a.Repo.QuotaCheck(r.Context(), rec.OwnerID, name,
|
||||
ResourceSpec{CPUMilli: newCPU, MemoryMB: newMemMB})
|
||||
if err != nil {
|
||||
@@ -1062,16 +1072,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
||||
// A resource patch cannot change storage, so its cached contribution must
|
||||
// be preserved: passing 0 would silently zero the storage dimension of the
|
||||
// owner's four-cap aggregate (the cached columns are its only input).
|
||||
storMB := 0
|
||||
if rec != nil {
|
||||
cur, err := a.Repo.ServerResources(r.Context(), name)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
storMB = cur.StorageMB
|
||||
}
|
||||
_ = a.Repo.UpdateServerResources(r.Context(), name, newCPU, newMemMB, storMB)
|
||||
_ = a.Repo.UpdateServerResources(r.Context(), name, newCPU, newMemMB, cur.StorageMB)
|
||||
} else {
|
||||
if err := a.Cluster.PatchServerSpec(r.Context(), name, patch); err != nil {
|
||||
a.writeLookupError(w, r, err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -317,11 +318,23 @@ func (a *API) handleSetQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject a body where every field is nil — a silent no-op is a client mistake.
|
||||
if body.MaxServers == nil && body.MaxCPUMilli == nil && body.MaxMemoryMB == nil && body.MaxStorageGB == nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
"at least one quota field must be set"))
|
||||
return
|
||||
// The body replaces all four caps; an empty one lifts every cap. A negative cap
|
||||
// would refuse every claim the way 0 does while reading like a mistake, and the
|
||||
// columns are 32-bit.
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
v *int
|
||||
}{
|
||||
{"max_servers", body.MaxServers},
|
||||
{"max_cpu_milli", body.MaxCPUMilli},
|
||||
{"max_memory_mb", body.MaxMemoryMB},
|
||||
{"max_storage_gb", body.MaxStorageGB},
|
||||
} {
|
||||
if f.v != nil && (*f.v < 0 || *f.v > math.MaxInt32) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "invalid_quota",
|
||||
"%s must be a whole number from 0 to 2147483647, or null for unlimited", f.name))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
v, err := a.Repo.SetQuotas(r.Context(), id, body, p.Email)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -127,3 +128,65 @@ func TestAdminSubresourcesRequireLiveUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The quotas form replaces all four caps at once, so an owner can lift a cap they
|
||||
// set: a missing or null field is unlimited, 0 grants none of it. Before, a null
|
||||
// field meant "leave it", the panel sent null for every emptied box, and a cap once
|
||||
// set could only be moved, never removed; a negative one was written as is.
|
||||
func TestSetQuotasReplacesAllCaps(t *testing.T) {
|
||||
owner := &Principal{UserID: "usr-root", Role: "owner", ViaAdminAccess: true}
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "usr-root", Username: "root", Role: "owner"})
|
||||
repo.seedUser(UserView{ID: "u2", Username: "alice", Role: "user"})
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: owner}
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
caps := func() string {
|
||||
t.Helper()
|
||||
w := do(eh, "GET", "/api/v1/users/u2/quotas", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get quotas: code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
return strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
put := func(body string, want int) {
|
||||
t.Helper()
|
||||
w := do(eh, "PUT", "/api/v1/users/u2/quotas", body, jsonHeader)
|
||||
if w.Code != want {
|
||||
t.Fatalf("PUT %s: code = %d body %s, want %d", body, w.Code, w.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
put(`{"max_servers":0,"max_cpu_milli":2000,"max_memory_mb":4096,"max_storage_gb":20}`, http.StatusOK)
|
||||
if got, want := caps(), `{"user_id":"u2","max_servers":0,"max_cpu_milli":2000,"max_memory_mb":4096,"max_storage_gb":20}`; got != want {
|
||||
t.Fatalf("after setting every cap: %s, want %s", got, want)
|
||||
}
|
||||
// What the panel sends after the owner empties two boxes.
|
||||
put(`{"max_servers":null,"max_cpu_milli":1000,"max_memory_mb":null,"max_storage_gb":20}`, http.StatusOK)
|
||||
if got, want := caps(), `{"user_id":"u2","max_cpu_milli":1000,"max_storage_gb":20}`; got != want {
|
||||
t.Fatalf("after emptying two boxes: %s, want %s", got, want)
|
||||
}
|
||||
put(`{}`, http.StatusOK)
|
||||
if got, want := caps(), `{"user_id":"u2"}`; got != want {
|
||||
t.Fatalf("after lifting every cap: %s, want %s", got, want)
|
||||
}
|
||||
|
||||
put(`{"max_servers":3}`, http.StatusOK)
|
||||
for _, body := range []string{
|
||||
`{"max_servers":-1}`,
|
||||
`{"max_servers":1,"max_storage_gb":-5}`,
|
||||
`{"max_memory_mb":2147483648}`,
|
||||
`{"max_cpu_milli":1.5}`,
|
||||
} {
|
||||
put(body, http.StatusBadRequest)
|
||||
}
|
||||
if got, want := caps(), `{"user_id":"u2","max_servers":3}`; got != want {
|
||||
t.Fatalf("a refused write changed the caps: %s, want %s", got, want)
|
||||
}
|
||||
w := do(eh, "PUT", "/api/v1/users/u2/quotas", `{"max_storage_gb":-5}`, jsonHeader)
|
||||
if !strings.Contains(w.Body.String(), `"invalid_quota"`) || !strings.Contains(w.Body.String(), "max_storage_gb") {
|
||||
t.Fatalf("a negative cap should name the field: %s", w.Body.String())
|
||||
}
|
||||
put(`{"max_memory_mb":2147483647}`, http.StatusOK)
|
||||
}
|
||||
+12
-56
@@ -2027,55 +2027,23 @@ func (p *PGRepo) requireLiveUser(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetQuotas upserts a quotas row. Nil fields are left unchanged; a non-nil
|
||||
// zero-value field clears the cap.
|
||||
// SetQuotas replaces the user's quotas row. A nil field stores NULL, which every
|
||||
// quota gate reads as unlimited; any other value is the cap, 0 included. The form
|
||||
// always carries all four caps, so clearing one is a matter of leaving it out.
|
||||
func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, setBy string) (*QuotaView, error) {
|
||||
if err := p.requireLiveUser(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type col struct {
|
||||
name string
|
||||
value *int
|
||||
}
|
||||
cols := []col{
|
||||
{"max_servers", qi.MaxServers},
|
||||
{"max_cpu_milli", qi.MaxCPUMilli},
|
||||
{"max_memory_mb", qi.MaxMemoryMB},
|
||||
{"max_storage_gb", qi.MaxStorageGB},
|
||||
}
|
||||
|
||||
// Build the ON CONFLICT upsert dynamically.
|
||||
var insCols, insVals []string
|
||||
var upd []string
|
||||
var args []any
|
||||
argn := 0
|
||||
args = append(args, userID) // $1 = user_id
|
||||
argn++
|
||||
args = append(args, setBy) // $2 = updated_by
|
||||
argn++
|
||||
insCols = append(insCols, "user_id", "updated_by")
|
||||
insVals = append(insVals, "$1", "$2")
|
||||
|
||||
for _, c := range cols {
|
||||
if c.value == nil {
|
||||
continue
|
||||
}
|
||||
argn++
|
||||
insCols = append(insCols, c.name)
|
||||
insVals = append(insVals, fmt.Sprintf("$%d", argn))
|
||||
args = append(args, *c.value)
|
||||
upd = append(upd, fmt.Sprintf("%s = EXCLUDED.%s", c.name, c.name))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`INSERT INTO quotas (%s) VALUES (%s)
|
||||
ON CONFLICT (user_id) DO UPDATE SET %s, updated_by = $2
|
||||
RETURNING user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb`,
|
||||
joinStr(insCols), joinStr(insVals), joinStr(upd))
|
||||
|
||||
v := QuotaView{}
|
||||
switch err := p.db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); {
|
||||
case err != nil:
|
||||
if err := p.db.QueryRowContext(ctx,
|
||||
`INSERT INTO quotas (user_id, updated_by, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET updated_by = EXCLUDED.updated_by,
|
||||
max_servers = EXCLUDED.max_servers, max_cpu_milli = EXCLUDED.max_cpu_milli,
|
||||
max_memory_mb = EXCLUDED.max_memory_mb, max_storage_gb = EXCLUDED.max_storage_gb
|
||||
RETURNING user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb`,
|
||||
userID, setBy, qi.MaxServers, qi.MaxCPUMilli, qi.MaxMemoryMB, qi.MaxStorageGB).Scan(
|
||||
&v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
@@ -2735,18 +2703,6 @@ func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string,
|
||||
return err
|
||||
}
|
||||
|
||||
// joinStr joins a slice of strings with ", ".
|
||||
func joinStr(vals []string) string {
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := vals[0]
|
||||
for _, v := range vals[1:] {
|
||||
s += ", " + v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique-constraint
|
||||
// violation (code 23505).
|
||||
func isUniqueViolation(err error) bool {
|
||||
|
||||
@@ -723,8 +723,8 @@ type Repo interface {
|
||||
// GetQuotas returns the quotas row for a user, or a zero-value view when no
|
||||
// row exists (which means unlimited per spec §9.3).
|
||||
GetQuotas(ctx context.Context, userID string) (*QuotaView, error)
|
||||
// SetQuotas upserts a quotas row for userID. Nil fields leave the column
|
||||
// untouched; a zero-value (non-nil) field clears the cap (unlimited).
|
||||
// SetQuotas replaces the quotas row for userID with q: a nil field is stored as
|
||||
// NULL (unlimited), any other value is the cap, 0 included.
|
||||
SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error)
|
||||
|
||||
// ---- session admin (admin-only) ----
|
||||
@@ -854,8 +854,9 @@ type QuotaView struct {
|
||||
MaxStorageGB *int `json:"max_storage_gb,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaInput is the admin set-quotas form. Nil fields are left unchanged;
|
||||
// a non-nil zero-value field clears the cap (unlimited).
|
||||
// QuotaInput is the admin set-quotas form. It replaces all four caps at once: an
|
||||
// absent or null field is unlimited, and 0 grants none of that resource, so every
|
||||
// claim that needs it is refused. The handler rejects a value outside 0..MaxInt32.
|
||||
type QuotaInput struct {
|
||||
MaxServers *int `json:"max_servers,omitempty"`
|
||||
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
|
||||
|
||||
@@ -553,6 +553,79 @@ func TestClaimServerQuotaAtomicGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The quotas form replaces every cap at once: a nil field lifts that cap and 0 grants
|
||||
// none of it. SetQuotas used to skip nil fields, so a cap once set could be moved but
|
||||
// never removed, and the panel's emptied box changed nothing.
|
||||
func TestSetQuotasReplacesEveryCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
u := newUser(t, "user", "qrepl")
|
||||
name := "qr-" + suffix(t)
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`,
|
||||
name); err != nil {
|
||||
t.Fatalf("seed server: %v", err)
|
||||
}
|
||||
spec := api.ResourceSpec{CPUMilli: 100, MemoryMB: 128, StorageMB: 1}
|
||||
n := func(v int) *int { return &v }
|
||||
caps := func(q *api.QuotaView) string {
|
||||
s := make([]string, 0, 4)
|
||||
for _, v := range []*int{q.MaxServers, q.MaxCPUMilli, q.MaxMemoryMB, q.MaxStorageGB} {
|
||||
if v == nil {
|
||||
s = append(s, "-")
|
||||
} else {
|
||||
s = append(s, fmt.Sprint(*v))
|
||||
}
|
||||
}
|
||||
return strings.Join(s, "/")
|
||||
}
|
||||
set := func(in api.QuotaInput, want string) {
|
||||
t.Helper()
|
||||
got, err := repo.SetQuotas(ctx, u.ID, in, "pgint-"+want)
|
||||
if err != nil {
|
||||
t.Fatalf("SetQuotas(%s): %v", want, err)
|
||||
}
|
||||
if caps(got) != want {
|
||||
t.Fatalf("SetQuotas returned %s, want %s", caps(got), want)
|
||||
}
|
||||
read, err := repo.GetQuotas(ctx, u.ID)
|
||||
if err != nil || caps(read) != want {
|
||||
t.Fatalf("GetQuotas = %s, %v; want %s", caps(read), err, want)
|
||||
}
|
||||
var by string
|
||||
if err := db.QueryRowContext(ctx, `SELECT updated_by FROM quotas WHERE user_id = $1`, u.ID).Scan(&by); err != nil || by != "pgint-"+want {
|
||||
t.Fatalf("updated_by = %q, %v; want the latest writer", by, err)
|
||||
}
|
||||
}
|
||||
gate := func(want bool) {
|
||||
t.Helper()
|
||||
if ok, err := repo.QuotaCheck(ctx, u.ID, "", spec); err != nil || ok != want {
|
||||
t.Fatalf("QuotaCheck = %v, %v; want %v", ok, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
set(api.QuotaInput{MaxServers: n(0), MaxCPUMilli: n(2000), MaxMemoryMB: n(4096), MaxStorageGB: n(20)}, "0/2000/4096/20")
|
||||
gate(false)
|
||||
if ok, err := repo.QuotaAvailable(ctx, u.ID); err != nil || ok {
|
||||
t.Fatalf("QuotaAvailable at max_servers 0 = %v, %v; want false", ok, err)
|
||||
}
|
||||
if _, err := repo.ClaimServer(ctx, name, u.ID); !errors.Is(err, api.ErrQuotaExceeded) {
|
||||
t.Fatalf("claim at max_servers 0 = %v, want ErrQuotaExceeded", err)
|
||||
}
|
||||
|
||||
set(api.QuotaInput{MaxCPUMilli: n(2000), MaxStorageGB: n(20)}, "-/2000/-/20")
|
||||
gate(true)
|
||||
set(api.QuotaInput{MaxCPUMilli: n(0)}, "-/0/-/-")
|
||||
gate(false)
|
||||
set(api.QuotaInput{}, "-/-/-/-")
|
||||
gate(true)
|
||||
if ok, err := repo.QuotaAvailable(ctx, u.ID); err != nil || !ok {
|
||||
t.Fatalf("QuotaAvailable with every cap lifted = %v, %v; want true", ok, err)
|
||||
}
|
||||
if claimed, err := repo.ClaimServer(ctx, name, u.ID); err != nil || !claimed {
|
||||
t.Fatalf("claim with every cap lifted = %v, %v; want claimed", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The fleet read needs every live server's claim state: the owner's id to tell
|
||||
// the caller's own servers apart, the display name (email, else username), and
|
||||
// the unclaimed rows too, since only those may be claimed. A soft-deleted row is
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"users_quota_memory": "Max Memory (MiB)",
|
||||
"users_quota_storage": "Max Storage (GiB)",
|
||||
"users_quota_unlimited": "Unlimited",
|
||||
"users_quota_hint": "Leave a box empty for unlimited. 0 grants none of it, so every claim that needs it is refused. A cap below current use only stops new claims; servers the user already has stay.",
|
||||
"users_sessions": "Sessions",
|
||||
"users_no_sessions": "No active sessions.",
|
||||
"users_session_expires": "Expires",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"account_retired": "This account has been retired — sign in with the account it was migrated to.",
|
||||
"no_migration": "There is no migration in progress.",
|
||||
"not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.",
|
||||
"invalid_quota": "A quota must be a whole number from 0 to 2147483647. Leave it empty for unlimited.",
|
||||
"already_confirmed": "This migration has already been confirmed.",
|
||||
"invalid_target": "The migration target must be a different account.",
|
||||
"target_not_found": "No account matches that migration target.",
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"users_quota_memory": "最大内存(MiB)",
|
||||
"users_quota_storage": "最大存储(GiB)",
|
||||
"users_quota_unlimited": "无限制",
|
||||
"users_quota_hint": "留空为不限。填 0 表示这一项不给额度,该用户需要它的认领都会被拒绝。调低到已用量以下只挡新的认领,已有的服务器不受影响。",
|
||||
"users_sessions": "活跃会话",
|
||||
"users_no_sessions": "无活跃会话。",
|
||||
"users_session_expires": "过期时间",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
|
||||
"no_migration": "当前没有进行中的迁移。",
|
||||
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
|
||||
"invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。",
|
||||
"already_confirmed": "该迁移已经确认过了。",
|
||||
"invalid_target": "迁移目标账户不能与来源账户相同。",
|
||||
"target_not_found": "找不到迁移目标账户。",
|
||||
|
||||
@@ -1146,6 +1146,8 @@ export function humanizeError(e: unknown): string {
|
||||
return t("no_migration");
|
||||
case "not_confirmed":
|
||||
return t("not_confirmed");
|
||||
case "invalid_quota":
|
||||
return t("invalid_quota");
|
||||
case "already_confirmed":
|
||||
return t("already_confirmed");
|
||||
case "invalid_target":
|
||||
|
||||
@@ -1263,7 +1263,10 @@ export interface paths {
|
||||
};
|
||||
/** Get a user's quotas (admin only). */
|
||||
get: operations["getQuotas"];
|
||||
/** Set a user's quotas (admin only). */
|
||||
/**
|
||||
* Set a user's quotas (admin only).
|
||||
* @description Replaces all four caps at once. An absent or null field is unlimited; 0 grants none of that resource, so every claim that needs it is refused. An empty body lifts every cap. A cap below what the user already owns refuses new claims and leaves the servers they have alone.
|
||||
*/
|
||||
put: operations["setQuotas"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -2620,7 +2623,7 @@ export interface components {
|
||||
verified_at: string;
|
||||
}[];
|
||||
};
|
||||
/** @description A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited. */
|
||||
/** @description A user's quotas row (internal/api/repo.go QuotaView). An absent or null field is unlimited; 0 grants none of that resource. */
|
||||
QuotaView: {
|
||||
user_id: string;
|
||||
max_servers?: number | null;
|
||||
@@ -6119,7 +6122,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["QuotaView"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
/** @description invalid_quota, a cap outside 0..2147483647; or a body that is not JSON or carries a fraction. */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown user. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { UserDetail } from "@/lib/types";
|
||||
const calls = vi.hoisted(() => ({
|
||||
getUser: vi.fn(),
|
||||
getUserQuotas: vi.fn(),
|
||||
setUserQuotas: vi.fn(),
|
||||
listUserSessions: vi.fn(),
|
||||
revokeUserSession: vi.fn(),
|
||||
revokeUserSessions: vi.fn(),
|
||||
@@ -240,3 +241,57 @@ describe("UserDetailPage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserDetailPage quotas", () => {
|
||||
function quotaBox(label: string) {
|
||||
return screen.getByLabelText(i18next.t(`admin:${label}`)) as HTMLInputElement;
|
||||
}
|
||||
function quotaCard() {
|
||||
return quotaBox("users_quota_servers").closest(".space-y-4") as HTMLElement;
|
||||
}
|
||||
|
||||
it("sends every box, an emptied one as unlimited and 0 as none", async () => {
|
||||
calls.getUser.mockResolvedValue(USER);
|
||||
calls.getUserQuotas.mockResolvedValue({ user_id: "u-1", max_servers: 3, max_cpu_milli: 2000 });
|
||||
calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_cpu_milli: 2000, max_storage_gb: 0 });
|
||||
renderPage();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => expect(quotaBox("users_quota_servers").value).toBe("3"));
|
||||
expect(within(quotaCard()).getByText(i18next.t("admin:users_quota_hint"))).toBeTruthy();
|
||||
await user.clear(quotaBox("users_quota_servers"));
|
||||
await user.type(quotaBox("users_quota_storage"), "0");
|
||||
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
|
||||
|
||||
await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1));
|
||||
expect(calls.setUserQuotas).toHaveBeenCalledWith("u-1", {
|
||||
max_servers: null,
|
||||
max_cpu_milli: 2000,
|
||||
max_memory_mb: null,
|
||||
max_storage_gb: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a box that is not a whole number the server can store, and sends nothing", async () => {
|
||||
calls.getUser.mockResolvedValue(USER);
|
||||
renderPage();
|
||||
const user = userEvent.setup();
|
||||
await waitFor(() => expect(quotaBox("users_quota_servers")).toBeTruthy());
|
||||
|
||||
for (const bad of ["-1", "1.5", "2abc", "2147483648"]) {
|
||||
await user.clear(quotaBox("users_quota_memory"));
|
||||
await user.type(quotaBox("users_quota_memory"), bad);
|
||||
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
|
||||
expect(await within(quotaCard()).findByText(i18next.t("errors:invalid_quota"))).toBeTruthy();
|
||||
expect(quotaBox("users_quota_memory").value).toBe(bad);
|
||||
}
|
||||
expect(calls.setUserQuotas).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(quotaBox("users_quota_memory"));
|
||||
await user.type(quotaBox("users_quota_memory"), " 2147483647 ");
|
||||
calls.setUserQuotas.mockResolvedValue({ user_id: "u-1", max_memory_mb: 2147483647 });
|
||||
await user.click(within(quotaCard()).getByRole("button", { name: i18next.t("admin:users_save_btn") }));
|
||||
await waitFor(() => expect(calls.setUserQuotas).toHaveBeenCalledTimes(1));
|
||||
expect(calls.setUserQuotas.mock.calls[0][1].max_memory_mb).toBe(2147483647);
|
||||
});
|
||||
});
|
||||
@@ -428,13 +428,27 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
setOk(null);
|
||||
// The form replaces all four caps: an empty box is unlimited, 0 grants none.
|
||||
// Anything else must be a whole number the server's 32-bit columns hold; the
|
||||
// boxes are text so a stray letter stays visible and is refused here instead
|
||||
// of turning into an empty (unlimited) number field.
|
||||
const parse = (s: string): number | null | undefined => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
return /^\d+$/.test(v) && Number(v) <= 2147483647 ? Number(v) : undefined;
|
||||
};
|
||||
const [servers, cpu, mem, storage] = [maxServers, maxCpu, maxMem, maxStorage].map(parse);
|
||||
if (servers === undefined || cpu === undefined || mem === undefined || storage === undefined) {
|
||||
setErr(t("errors:invalid_quota"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const toNum = (s: string) => (s === "" ? null : parseInt(s, 10));
|
||||
await api.setUserQuotas(userId, {
|
||||
max_servers: toNum(maxServers),
|
||||
max_cpu_milli: toNum(maxCpu),
|
||||
max_memory_mb: toNum(maxMem),
|
||||
max_storage_gb: toNum(maxStorage),
|
||||
max_servers: servers,
|
||||
max_cpu_milli: cpu,
|
||||
max_memory_mb: mem,
|
||||
max_storage_gb: storage,
|
||||
});
|
||||
setOk(t("users_save_ok"));
|
||||
reload();
|
||||
@@ -464,7 +478,8 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
<Label htmlFor="user-detail-quota-servers" className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
|
||||
<Input
|
||||
id="user-detail-quota-servers"
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxServers}
|
||||
onChange={(e) => setMaxServers(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
@@ -475,7 +490,8 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
<Label htmlFor="user-detail-quota-cpu" className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
|
||||
<Input
|
||||
id="user-detail-quota-cpu"
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxCpu}
|
||||
onChange={(e) => setMaxCpu(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
@@ -486,7 +502,8 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
<Label htmlFor="user-detail-quota-memory" className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
|
||||
<Input
|
||||
id="user-detail-quota-memory"
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxMem}
|
||||
onChange={(e) => setMaxMem(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
@@ -497,7 +514,8 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
<Label htmlFor="user-detail-quota-storage" className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
|
||||
<Input
|
||||
id="user-detail-quota-storage"
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxStorage}
|
||||
onChange={(e) => setMaxStorage(e.target.value)}
|
||||
placeholder={t("users_quota_unlimited")}
|
||||
@@ -506,6 +524,8 @@ function QuotasCard({ userId }: { userId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t("users_quota_hint")}</p>
|
||||
|
||||
{err && (
|
||||
<MessageLine kind="error" message={err} />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user