@@ -24,6 +24,7 @@ import (
2424 "strings"
2525
2626 helmv2 "github.com/fluxcd/helm-controller/api/v2"
27+ corev1 "k8s.io/api/core/v1"
2728 apierrors "k8s.io/apimachinery/pkg/api/errors"
2829 "k8s.io/apimachinery/pkg/api/resource"
2930 "k8s.io/apimachinery/pkg/labels"
@@ -211,19 +212,35 @@ func (r *REST) validateTenantResourceQuotas(ctx context.Context, app *appsv1alph
211212 return append (allErrs , field .InternalError (fldPath , err ))
212213 }
213214
215+ // Current usage of the parent's pool (its own namespace plus its unbounded
216+ // descendants). Counting it keeps the invariant — a sub-tree may never, via
217+ // quota, be promised more than its root owns — holding at declaration time:
218+ // without it a parent already using most of its budget could still carve a
219+ // full-budget child and the two would collectively exceed the root. This is
220+ // best-effort (a snapshot; concurrent writes can briefly overshoot and
221+ // already-running pods are never evicted) — the tenant-quota controller is
222+ // the runtime backstop.
223+ childNamespace := r .computeTenantNamespace (app .Namespace , app .Name )
224+ poolUsed , err := r .parentPoolUsage (ctx , app .Namespace , childNamespace )
225+ if err != nil {
226+ return append (allErrs , field .InternalError (fldPath , err ))
227+ }
228+
214229 for _ , res := range sortedQuotaKeys (parentQuota ) {
215230 want , requested := childQuota [res ]
216231 if ! requested {
217232 continue
218233 }
219234 parentLimit := parentQuota [res ]
220235 allocated := siblings [res ] // zero value when no sibling bounds this resource
236+ used := poolUsed [renderedLimitKey (res )]
221237 remaining := parentLimit .DeepCopy ()
222238 remaining .Sub (allocated )
239+ remaining .Sub (used )
223240 if want .Cmp (remaining ) > 0 {
224241 allErrs = append (allErrs , field .Forbidden (fldPath .Key (res ),
225- fmt .Sprintf ("requested %s exceeds the parent tenant's remaining %q quota of %s (parent allows %s, %s already allocated to sibling tenants); a child tenant may not be granted more quota than its parent has left" ,
226- want .String (), res , remaining .String (), parentLimit .String (), allocated .String ())))
242+ fmt .Sprintf ("requested %s exceeds the parent tenant's remaining %q quota of %s (parent allows %s, %s already allocated to sibling tenants, %s currently in use by the parent pool ); a child tenant may not be granted more quota than its parent has left" ,
243+ want .String (), res , remaining .String (), parentLimit .String (), allocated .String (), used . String () )))
227244 }
228245 }
229246 return allErrs
@@ -251,3 +268,162 @@ func sortedQuotaKeys(q map[string]resource.Quantity) []string {
251268 sort .Strings (keys )
252269 return keys
253270}
271+
272+ // rawQuotaKeys are the shorthand resourceQuotas keys that the tenant chart emits
273+ // verbatim as ResourceQuota hard keys (no limits./requests. prefix). Kept in
274+ // sync with cozy-lib.resources.flattenResource ($rawQuotaKeys).
275+ var rawQuotaKeys = map [string ]struct {}{
276+ "pods" : {},
277+ "services" : {},
278+ "services.loadbalancers" : {},
279+ "services.nodeports" : {},
280+ "services.clusterip" : {},
281+ "configmaps" : {},
282+ "secrets" : {},
283+ "persistentvolumeclaims" : {},
284+ "replicationcontrollers" : {},
285+ "resourcequotas" : {},
286+ }
287+
288+ // renderedLimitKey maps a shorthand resourceQuotas key (the vocabulary admission
289+ // works in) to the rendered ResourceQuota key that carries its limit-side
290+ // budget, mirroring cozy-lib.resources.flatten. The mapping is allocation-ratio
291+ // independent: ratios only scale the requests.* side that flatten derives from
292+ // the same shorthand value, so the limit-side key always holds the operator's
293+ // declared amount. This lets admission read a tenant's current usage from its
294+ // rendered ResourceQuota.status.used without replicating the chart's ratio math.
295+ //
296+ // cpu, memory, ephemeral-storage, custom resources -> "limits.<key>"
297+ // storage -> "requests.storage" (no hard limits.storage)
298+ // pods, services, services.loadbalancers, ... -> "<key>" (verbatim)
299+ func renderedLimitKey (raw string ) string {
300+ if raw == "storage" {
301+ return "requests.storage"
302+ }
303+ if _ , ok := rawQuotaKeys [raw ]; ok {
304+ return raw
305+ }
306+ return "limits." + raw
307+ }
308+
309+ // parentNamespaceOf returns the namespace owned by the parent of the tenant that
310+ // owns ns, or "" for the root tenant and non-tenant namespaces. It mirrors
311+ // tenantquota.parentNamespace / the inverse of computeTenantNamespace: the
312+ // hierarchy is encoded in the namespace name, so the parent is recovered by
313+ // stripping the trailing "-<name>" segment.
314+ func (r * REST ) parentNamespaceOf (ns string ) string {
315+ if ns == rootTenantNamespace || ! strings .HasPrefix (ns , r .releaseConfig .Prefix ) {
316+ return ""
317+ }
318+ segments := strings .Split (ns , "-" )
319+ if len (segments ) == 2 {
320+ return rootTenantNamespace
321+ }
322+ return strings .Join (segments [:len (segments )- 1 ], "-" )
323+ }
324+
325+ // poolRootOf returns the nearest ancestor namespace (inclusive of ns) that is
326+ // bounded — declares its own quota — or "" when none is. That ancestor is the
327+ // pool root governing ns.
328+ func (r * REST ) poolRootOf (ns string , bounded map [string ]bool ) string {
329+ for cur := ns ; cur != "" ; cur = r .parentNamespaceOf (cur ) {
330+ if bounded [cur ] {
331+ return cur
332+ }
333+ }
334+ return ""
335+ }
336+
337+ // parentPoolUsage sums the current usage of the pool rooted at parentNS: the
338+ // parent's own namespace plus every unbounded descendant whose nearest bounded
339+ // ancestor is parentNS. Bounded sub-tenants are excluded — their reservations
340+ // are already charged as sibling carve-outs — as is excludeNS (the tenant being
341+ // created/updated). Usage is read from each member's ResourceQuota.status.used,
342+ // taking a per-resource max within a namespace to avoid double-counting the
343+ // chart-rendered and controller-allocated quotas, and is keyed in the rendered
344+ // quota key space (see renderedLimitKey). Boundedness is decided by declared
345+ // resourceQuotas so the result does not depend on whether Flux has rendered a
346+ // freshly-created tenant's quota yet.
347+ func (r * REST ) parentPoolUsage (ctx context.Context , parentNS , excludeNS string ) (map [string ]resource.Quantity , error ) {
348+ releases := & helmv2.HelmReleaseList {}
349+ selector := labels .SelectorFromSet (labels.Set {
350+ ApplicationKindLabel : r .kindName ,
351+ ApplicationGroupLabel : r .gvk .Group ,
352+ })
353+ if err := r .c .List (ctx , releases , & client.ListOptions {LabelSelector : selector }); err != nil {
354+ return nil , err
355+ }
356+
357+ bounded := map [string ]bool {}
358+ owned := make ([]string , 0 , len (releases .Items ))
359+ for i := range releases .Items {
360+ hr := & releases .Items [i ]
361+ name := strings .TrimPrefix (hr .Name , r .releaseConfig .Prefix )
362+ ns := r .computeTenantNamespace (hr .Namespace , name )
363+ owned = append (owned , ns )
364+ quotas , err := declaredQuotasFromHelmRelease (hr )
365+ if err != nil {
366+ klog .Warningf ("skipping tenant %s/%s with unparseable quotas while summing pool usage: %v" , hr .Namespace , hr .Name , err )
367+ continue
368+ }
369+ if len (quotas ) > 0 {
370+ bounded [ns ] = true
371+ }
372+ }
373+
374+ members := map [string ]bool {parentNS : true }
375+ for _ , ns := range owned {
376+ if ns == excludeNS {
377+ continue
378+ }
379+ if r .poolRootOf (ns , bounded ) == parentNS {
380+ members [ns ] = true
381+ }
382+ }
383+ delete (members , excludeNS )
384+
385+ quotas := & corev1.ResourceQuotaList {}
386+ if err := r .c .List (ctx , quotas ); err != nil {
387+ return nil , err
388+ }
389+ perNamespace := map [string ]map [string ]resource.Quantity {}
390+ for i := range quotas .Items {
391+ rq := & quotas .Items [i ]
392+ if ! members [rq .Namespace ] {
393+ continue
394+ }
395+ perNamespace [rq .Namespace ] = maxQuotas (perNamespace [rq .Namespace ], resourceListToQuotas (rq .Status .Used ))
396+ }
397+
398+ total := map [string ]resource.Quantity {}
399+ for _ , used := range perNamespace {
400+ addQuotas (total , used )
401+ }
402+ return total , nil
403+ }
404+
405+ // resourceListToQuotas converts a corev1.ResourceList to the string-keyed quota
406+ // map the admission arithmetic uses.
407+ func resourceListToQuotas (rl corev1.ResourceList ) map [string ]resource.Quantity {
408+ out := make (map [string ]resource.Quantity , len (rl ))
409+ for k , v := range rl {
410+ out [string (k )] = v
411+ }
412+ return out
413+ }
414+
415+ // maxQuotas returns the per-resource maximum of dst and src as a new map.
416+ // Multiple ResourceQuotas in one namespace each report the same usage, so usage
417+ // is merged with a max (not a sum) to avoid double counting.
418+ func maxQuotas (dst , src map [string ]resource.Quantity ) map [string ]resource.Quantity {
419+ out := map [string ]resource.Quantity {}
420+ for k , v := range dst {
421+ out [k ] = v .DeepCopy ()
422+ }
423+ for k , v := range src {
424+ if cur , ok := out [k ]; ! ok || v .Cmp (cur ) > 0 {
425+ out [k ] = v .DeepCopy ()
426+ }
427+ }
428+ return out
429+ }
0 commit comments