Loading docs/openapi.yaml +16 −6 Changes for docs/openapi.yaml: 16 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading Loading @@ -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: [] }] Loading @@ -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. Loading @@ -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': Loading internal/api/api_test.go +3 −15 Changes for internal/api/api_test.go: 3 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading internal/api/handlers_patch_test.go +40 −0 Changes for internal/api/handlers_patch_test.go: 40 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) } } } internal/api/handlers_user.go +12 −11 Changes for internal/api/handlers_user.go: 12 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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) Loading internal/api/handlers_users.go +17 −4 Changes for internal/api/handlers_users.go: 17 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ package api import ( "errors" "math" "net/http" "strconv" "strings" Loading Loading @@ -317,12 +318,24 @@ 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")) // 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) if err != nil { Loading Loading
docs/openapi.yaml +16 −6 Changes for docs/openapi.yaml: 16 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading Loading @@ -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: [] }] Loading @@ -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. Loading @@ -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': Loading
internal/api/api_test.go +3 −15 Changes for internal/api/api_test.go: 3 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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 } Loading
internal/api/handlers_patch_test.go +40 −0 Changes for internal/api/handlers_patch_test.go: 40 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) } } }
internal/api/handlers_user.go +12 −11 Changes for internal/api/handlers_user.go: 12 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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) Loading
internal/api/handlers_users.go +17 −4 Changes for internal/api/handlers_users.go: 17 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ package api import ( "errors" "math" "net/http" "strconv" "strings" Loading Loading @@ -317,12 +318,24 @@ 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")) // 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) if err != nil { Loading