[Bug] NamespacedGeneratingPolicy downstreams are never deleted or synced: WatchManager key is namespace/name but the policy label is the bare name
Description
For a NamespacedGeneratingPolicy with synchronization enabled, the background controller never deletes or syncs the resources it generated. Concretely:
- deleting the trigger leaves the generated downstream resource behind
- a trigger update that stops matching the policy leaves the stale downstream resource behind
- deleting the policy (without
orphanDownstreamOnPolicyDelete) leaves the generated resource behind - the entries stay in the WatchManager cache forever, so the background controller accumulates them for the lifetime of the process
GetDownstreamsalways returns an empty list, so the policy controller keeps rewriting cache-restore UpdateRequests instead of syncing from the in-memory cache
The cluster-scoped GeneratingPolicy path is not affected. Only the namespaced twin is.
Root cause
The WatchManager is keyed by namespace/name for namespaced policies, but the downstream resources are labeled with the bare policy name:
- Key side,
ur.Spec.Policyisngpol.GetNamespace() + "/" + ngpol.GetName():pkg/policy/updaterequest.go(newGenerateUR, NamespacedGeneratingPolicy branch)pkg/policy/gpol.go(createURForNamespacedGeneratingPolicy, handleNamespacedGenerateExisting)pkg/policy/policy_controller.go(deletePolicy, updatePolicy)pkg/background/gpol/generate_controller.go(ProcessUR, viaur.Spec.GetPolicyKey())
- Label side,
pkg/cel/libs/context.go(addGenerateLabels) setsgenerate.kyverno.io/policy-nametocp.genCtx.policyName, andpkg/cel/policies/gpol/engine/engine.gosets that frompolicy.Policy.GetName(), which is the bare name. It does not setgenerate.kyverno.io/policy-namespace.
The WatchManager compares the label to the full key, so the comparison is never true for a namespaced policy. The six sites are in pkg/background/gpol/dynamic_watcher.go: SyncWatchers (stale deletion and still-referenced check), GetDownstreams, walkDownstreams (used by DeleteDownstreams and InvalidateDownstreams), CleanupStaleDownstreams, and RemoveWatchersForPolicy.
The key cannot simply be shortened to the bare name because pkg/cel/policies/gpol/engine/fetch.go splits it to select the namespaced lister.
Steps to reproduce
- Create a namespace
tenant-ns. - Create a
NamespacedGeneratingPolicyintenant-nswithsynchronization.enabled: trueandmatchConstraintsmatching Secrets in that namespace, generating a target Secret. - Create a matching source Secret. Kyverno generates the downstream Secret.
- Delete the source Secret.
Expected: the generated Secret is removed by the background controller. Actual: the generated Secret stays.
Equivalent with a UID-level repro through the WatchManager: register the policy with key tenant-ns/pol and cache a downstream labeled generate.kyverno.io/policy-name: pol (no namespace label). GetDownstreams("tenant-ns/pol") returns nothing and RemoveWatchersForPolicy("tenant-ns/pol", true) deletes nothing, while the same calls with the key pol and the same labels work.
Expected behavior
For namespaced policies, downstream matching must use the policy name and the policy namespace. Cluster-scoped policies keep matching the bare name (their resources carry an empty namespace label).
Additional context
This looks novel. Issue #16832 / PR #16838 fixed the same class of failure for the cluster-scoped GeneratingPolicy; the namespaced twin regressed through the key/label mismatch described above. A fix plus focused tests will follow in a PR that closes this issue.
Source: kyverno/kyverno