What happened?
On a Crossplane v2 cluster (v2.3.3, upjet-based Azure providers v2.7.0) we observed a namespace take hours to finish terminating. During that time:
- Azure providers issued 825× HTTP 403 attempting to create
providerconfigusages.azure.m.upbound.io in the terminating namespace, with reason: NamespaceTerminating ("unable to create new content in namespace ... because it is being terminated"). RBAC allowed every one of these — the rejection is namespace lifecycle, not permissions.
- The kube namespace-controller swept the namespace's content (
deletecollection, all resource types) at a sustained ~31.6/s the entire time.
Both stopped together the moment the namespace finally finished terminating.
Root cause (traced in crossplane-runtime)
- In
pkg/reconciler/managed/reconciler.go, Reconcile() calls r.external.Connect(externalCtx, managed) unconditionally — before the meta.WasDeleted(managed) branch that leads to external.Delete() and RemoveFinalizer(). This is true even when deletionPolicy: Delete and the managed resource already has a deletion timestamp.
- Upjet-generated providers'
Connect() implementations call ProviderConfigUsageTracker.Track() (pkg/resource/providerconfig.go) first, which does a Get-then-Create-or-Update Apply of the resource's ProviderConfigUsage.
Track()'s only swallowed error class is IsNotAllowed (pkg/resource/resource.go) — an internal sentinel produced by its own AllowUpdateIf predicate, not apierrors.IsForbidden. A genuine 403 from the API server is not swallowed.
- If the
ProviderConfigUsage doesn't currently exist — e.g. it was already removed by the namespace controller's deletecollection sweep of that GVR — and the namespace is Terminating, the Create leg gets a 403 (NamespaceTerminating). That error propagates out of Track() → out of Connect().
- The reconciler's
Connect() error handling (reconciler.go, right after the Connect call) returns early with Requeue: true — before ever reaching the deletion branch. external.Delete() and RemoveFinalizer() never run that cycle.
- Because the managed resource's own finalizer survives, the namespace never fully drains, so the namespace controller re-sweeps every resource type again, which can strip a freshly-recreated
ProviderConfigUsage before the next reconcile — restarting the race.
The loop only breaks when a Track() create happens to land in a timing gap between namespace-controller sweeps, so resolution looks like a race, not a guarantee. We could not establish an upper bound on how long this can take, or whether it can wedge indefinitely under sustained contention.
How to reproduce
- Create a namespace with a namespaced (Crossplane v2) managed resource that references a
ProviderConfig/ClusterProviderConfig, using any upjet-based provider.
- Let it reconcile normally so its
ProviderConfigUsage exists.
- Delete the
ProviderConfigUsage directly (simulating the namespace-controller sweep having already removed it) at roughly the same time as kubectl delete namespace <ns> (or delete the namespace first, then delete the PCU before the next reconcile).
- Observe: the managed resource's
Connect() fails with a NamespaceTerminating 403 while attempting to recreate the ProviderConfigUsage; the managed resource's finalizer is not removed while this keeps failing; the namespace stays Terminating far longer than expected.
Expected behavior
Deleting a namespace holding Crossplane managed resources should complete in bounded, predictable time. At minimum:
Track()/Connect() should not need to succeed in order for a managed resource already being deleted to proceed to external.Delete() and finalizer removal — or the reconciler should check WasDeleted() before calling Connect() when deletion doesn't require re-establishing a fresh ProviderConfigUsage.
- Alternatively, a
NamespaceTerminating 403 encountered while tracking usage should be treated as ignorable for a resource that is itself already being deleted, since the ProviderConfigUsage's job (protecting the ProviderConfig from premature deletion) is moot once the consuming resource is also on its way out.
Environment
- crossplane-runtime: (pin to the version in use by provider-family-azure / provider-azure-* v2.7.0 — check
go.mod at that tag)
- Crossplane: v2.3.3
- Providers:
xpkg.crossplane.io/crossplane-contrib/provider-azure-*:v2.7.0 (upjet-based, Azure family v2 line)
- Kubernetes: AKS
Additional context
This traffic is easy to miss in per-resource audit breakdowns — it's spread thin across ~400 resource types shipped by the Azure provider family CRDs (~543 calls each in our incident), so no single resource type stands out even though the aggregate was ~9.5% of total cluster audit volume that day. Grouping by @user.username for the namespace-controller service account surfaces it; grouping by resource type does not.
What happened?
On a Crossplane v2 cluster (v2.3.3, upjet-based Azure providers v2.7.0) we observed a namespace take hours to finish terminating. During that time:
providerconfigusages.azure.m.upbound.ioin the terminating namespace, withreason: NamespaceTerminating("unable to create new content in namespace ... because it is being terminated"). RBAC allowed every one of these — the rejection is namespace lifecycle, not permissions.deletecollection, all resource types) at a sustained ~31.6/s the entire time.Both stopped together the moment the namespace finally finished terminating.
Root cause (traced in
crossplane-runtime)pkg/reconciler/managed/reconciler.go,Reconcile()callsr.external.Connect(externalCtx, managed)unconditionally — before themeta.WasDeleted(managed)branch that leads toexternal.Delete()andRemoveFinalizer(). This is true even whendeletionPolicy: Deleteand the managed resource already has a deletion timestamp.Connect()implementations callProviderConfigUsageTracker.Track()(pkg/resource/providerconfig.go) first, which does a Get-then-Create-or-UpdateApplyof the resource'sProviderConfigUsage.Track()'s only swallowed error class isIsNotAllowed(pkg/resource/resource.go) — an internal sentinel produced by its ownAllowUpdateIfpredicate, notapierrors.IsForbidden. A genuine 403 from the API server is not swallowed.ProviderConfigUsagedoesn't currently exist — e.g. it was already removed by the namespace controller'sdeletecollectionsweep of that GVR — and the namespace isTerminating, theCreateleg gets a 403 (NamespaceTerminating). That error propagates out ofTrack()→ out ofConnect().Connect()error handling (reconciler.go, right after theConnectcall) returns early withRequeue: true— before ever reaching the deletion branch.external.Delete()andRemoveFinalizer()never run that cycle.ProviderConfigUsagebefore the next reconcile — restarting the race.The loop only breaks when a
Track()create happens to land in a timing gap between namespace-controller sweeps, so resolution looks like a race, not a guarantee. We could not establish an upper bound on how long this can take, or whether it can wedge indefinitely under sustained contention.How to reproduce
ProviderConfig/ClusterProviderConfig, using any upjet-based provider.ProviderConfigUsageexists.ProviderConfigUsagedirectly (simulating the namespace-controller sweep having already removed it) at roughly the same time askubectl delete namespace <ns>(or delete the namespace first, then delete the PCU before the next reconcile).Connect()fails with aNamespaceTerminating403 while attempting to recreate theProviderConfigUsage; the managed resource's finalizer is not removed while this keeps failing; the namespace staysTerminatingfar longer than expected.Expected behavior
Deleting a namespace holding Crossplane managed resources should complete in bounded, predictable time. At minimum:
Track()/Connect()should not need to succeed in order for a managed resource already being deleted to proceed toexternal.Delete()and finalizer removal — or the reconciler should checkWasDeleted()before callingConnect()when deletion doesn't require re-establishing a freshProviderConfigUsage.NamespaceTerminating403 encountered while tracking usage should be treated as ignorable for a resource that is itself already being deleted, since theProviderConfigUsage's job (protecting theProviderConfigfrom premature deletion) is moot once the consuming resource is also on its way out.Environment
go.modat that tag)xpkg.crossplane.io/crossplane-contrib/provider-azure-*:v2.7.0(upjet-based, Azure family v2 line)Additional context
This traffic is easy to miss in per-resource audit breakdowns — it's spread thin across ~400 resource types shipped by the Azure provider family CRDs (~543 calls each in our incident), so no single resource type stands out even though the aggregate was ~9.5% of total cluster audit volume that day. Grouping by
@user.usernamefor the namespace-controller service account surfaces it; grouping by resource type does not.