feat(api): enforce CPU/memory/storage quotas (spec §9.3, §22)

Add per-user resource quota enforcement across all four dimensions:
max_servers, max_cpu_milli, max_memory_mb, and max_storage_gb.

- Migration 0013: add cached_cpu_milli, cached_memory_mb, cached_storage_mb
  columns to servers table for pure-SQL per-owner aggregation
- SeedServer now writes resource cache alongside server row
- QuotaCheck replaces QuotaAvailable at claim time, checking all four caps
  against the owning user's cumulative usage
- handlePatchServer checks owner's quota before allowing memory/resource
  changes on owned servers; unowned servers skip the gate
- handleInternalClaim mirrors the full quota check
- UpdateServerResources keeps the cache in sync after spec mutations
- Reaper zeros resource cache on ReleaseWorld so released resources
  are not counted against a former owner
- quantityToMilli/quantityToMB helpers convert K8s quantities to
  quota-comparable integers

19 test packages pass.
This commit is contained in:
Lemon-miaow committed 2026-07-05 02:05:44 +08:00
1 parent 91bfa27e8c
commit e574749877
8 files changed
+211 -23

No files matched your search

+14 -1
View File
@@ -209,6 +209,19 @@ func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, err
}
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
func (f *fakeRepo) QuotaCheck(_ context.Context, userID string, _ string, _ ResourceSpec) (bool, error) {
// For hermetic tests, QuotaCheck delegates to the same QuotaAvailable
// store — tests that care about per-dimension checks should use
// fakeQuotas with direct inspection.
return f.QuotaAvailable(nil, userID)
}
func (f *fakeRepo) UpdateServerResources(_ context.Context, _ string, _, _, _ int) error { return nil }
func (f *fakeRepo) ServerResources(_ context.Context, _ string) (ResourceSpec, error) {
return ResourceSpec{}, nil
}
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
return nil
@@ -532,7 +545,7 @@ func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
}
return f.owners, nil
}
func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string) error {
func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string, _, _, _ int) error {
if f.seedErr != nil {
return f.seedErr
}