Bug description
After a Tenant namespace is deleted, its entry can survive in the Tenant status, and every
subsequent ResourceQuota event for that Tenant then fails the whole ResourceQuota sync in a hot
loop:
{"level":"error","logger":"capsule.ctrl.tenant","msg":"cannot sync ResourceQuotas",
"tenant":"opencell",
"error":"namespace \"opencell-continuous-migration-15992\": namespaces \"opencell-continuous-migration-15992\" not found"}
We measured ~10 of these per minute, indefinitely, on a Tenant with 98 namespaces and a busy CI
creating/deleting namespaces continuously.
Where it comes from
syncResourceQuotasForResourceQuota (internal/controllers/tenant/manager.go, around L478) is
registered as an event handler on ResourceQuota. It iterates over the namespaces recorded in the
Tenant status and does a Get per namespace; a single stale entry returns NotFound and the error
is propagated, so the entire ResourceQuota sync for that Tenant fails — not just the missing
namespace. Because any ResourceQuota event in any of the Tenant's namespaces triggers the handler,
the failure repeats forever.
The Tenant itself stays state: Active but its Ready condition is never refreshed
(generation == observedGeneration), so the stale status is not self-healing.
How we hit it (reproduction)
- Tenant with many namespaces, CI creating short-lived namespaces.
- Upgrade Capsule so that the manager pods roll (in our case 0.13.9 → 0.14.0). During the rollout,
admission is briefly refused in tenant namespaces — the new binary registers
generic.rules.mutating.projectcapsule.dev in capsule-dynamic-webhook while the old pod is
still in the Service endpoints and does not serve that path, so the API server gets a 404 and
fails closed.
- A namespace created during that window, then deleted by CI, stays referenced in the Tenant
status → the loop above starts and never stops.
What made it hard to diagnose (v0.14.0 specific)
There are two lists to look at, and the second one is new in 0.14.0:
status.namespaces (list of strings)
status.spaces (list of objects {name, uid, metadata, conditions}) ← new in 0.14.0
Removing the stale entry from status.namespaces alone changed nothing — the error kept naming the
same namespace, including after a full rollout restart of the manager, which initially led us
to suspect a stale cache. The actual source was status.spaces. After removing the entry from both
lists, the error stopped immediately (90s sliding window: 10 → 7 → 3 → 0) and the manager went back
to zero errors.
Workaround (status subresource only, so no GitOps drift):
kubectl patch tenant <t> --subresource=status --type=json -p \
'[{"op":"test","path":"/status/spaces/<i>/name","value":"<stale-ns>"},
{"op":"remove","path":"/status/spaces/<i>"}]'
Suggested fix
- Treat
IsNotFound as a skip in the per-namespace loop of syncResourceQuotasForResourceQuota
instead of failing the whole sync — a namespace that no longer exists needs no quota.
- Prune namespaces that no longer exist from the Tenant status (both
status.namespaces and
status.spaces) during reconciliation, so the condition is self-healing.
Environment
- Capsule 0.14.0 (Helm chart 0.14.0), 2 replicas, leader election on
- Kubernetes 1.35 (Talos), cert-manager for webhook TLS
- 4 Tenants, largest one 98 namespaces, ~104 tenant namespaces overall
webhooks.hooks.globalresourcequotas.enabled: false (no GlobalResourceQuota in use)
Bug description
After a Tenant namespace is deleted, its entry can survive in the Tenant status, and every
subsequent ResourceQuota event for that Tenant then fails the whole ResourceQuota sync in a hot
loop:
We measured ~10 of these per minute, indefinitely, on a Tenant with 98 namespaces and a busy CI
creating/deleting namespaces continuously.
Where it comes from
syncResourceQuotasForResourceQuota(internal/controllers/tenant/manager.go, around L478) isregistered as an event handler on ResourceQuota. It iterates over the namespaces recorded in the
Tenant status and does a
Getper namespace; a single stale entry returnsNotFoundand the erroris propagated, so the entire ResourceQuota sync for that Tenant fails — not just the missing
namespace. Because any ResourceQuota event in any of the Tenant's namespaces triggers the handler,
the failure repeats forever.
The Tenant itself stays
state: Activebut itsReadycondition is never refreshed(
generation == observedGeneration), so the stale status is not self-healing.How we hit it (reproduction)
admission is briefly refused in tenant namespaces — the new binary registers
generic.rules.mutating.projectcapsule.devincapsule-dynamic-webhookwhile the old pod isstill in the Service endpoints and does not serve that path, so the API server gets a 404 and
fails closed.
status → the loop above starts and never stops.
What made it hard to diagnose (v0.14.0 specific)
There are two lists to look at, and the second one is new in 0.14.0:
status.namespaces(list of strings)status.spaces(list of objects{name, uid, metadata, conditions}) ← new in 0.14.0Removing the stale entry from
status.namespacesalone changed nothing — the error kept naming thesame namespace, including after a full
rollout restartof the manager, which initially led usto suspect a stale cache. The actual source was
status.spaces. After removing the entry from bothlists, the error stopped immediately (90s sliding window: 10 → 7 → 3 → 0) and the manager went back
to zero errors.
Workaround (status subresource only, so no GitOps drift):
Suggested fix
IsNotFoundas a skip in the per-namespace loop ofsyncResourceQuotasForResourceQuotainstead of failing the whole sync — a namespace that no longer exists needs no quota.
status.namespacesandstatus.spaces) during reconciliation, so the condition is self-healing.Environment
webhooks.hooks.globalresourcequotas.enabled: false(no GlobalResourceQuota in use)