#17633·kyverno

[Bug] NamespacedGeneratingPolicy downstreams are never deleted or synced: WatchManager key is namespace/name but the policy label is the bare name

Author: Elvand-LieCreated Sep 18, 2026Updated Sep 18, 2026

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
  • GetDownstreams always 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.Policy is ngpol.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, via ur.Spec.GetPolicyKey())
  • Label side, pkg/cel/libs/context.go (addGenerateLabels) sets generate.kyverno.io/policy-name to cp.genCtx.policyName, and pkg/cel/policies/gpol/engine/engine.go sets that from policy.Policy.GetName(), which is the bare name. It does not set generate.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

  1. Create a namespace tenant-ns.
  2. Create a NamespacedGeneratingPolicy in tenant-ns with synchronization.enabled: true and matchConstraints matching Secrets in that namespace, generating a target Secret.
  3. Create a matching source Secret. Kyverno generates the downstream Secret.
  4. 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.