Unverified Commit 7aa5034c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(quota): 配额表单整体替换,留空即不限、0 即不给、负数和越界拒收,超配额的服务器仍可调小

parent ee4065b9
Loading
Loading
Loading
Loading
+16 −6
Changes for docs/openapi.yaml: 16 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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':
+3 −15
Changes for internal/api/api_test.go: 3 added lines, 15 removed lines.
Original line number Diff line number Diff line
@@ -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
}

+40 −0
Changes for internal/api/handlers_patch_test.go: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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)
		}
	}
}
+12 −11
Changes for internal/api/handlers_user.go: 12 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -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)
+17 −4
Changes for internal/api/handlers_users.go: 17 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ package api

import (
	"errors"
	"math"
	"net/http"
	"strconv"
	"strings"
@@ -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