Background Mutation Engine#
mutateExisting is Kyverno's mechanism for mutating resources that already exist in the cluster — as opposed to resources caught at admission time. Unlike standard admission-webhook mutation, which patches the object being admitted, mutateExisting rules specify explicit target resources that may be entirely unrelated to the trigger. All target mutations are deferred to a background controller and applied after the admission response has been returned.
Architecture Overview#
The two main stages are:
- Webhook phase — Evaluates which
mutateExistingrules pass background checks and creates anUpdateRequestCRD for each. - Background phase — The background controller processes each
UpdateRequest, re-evaluates the mutation against live target resources, and writes the patched objects back via the dynamic client.
Stage 1 – UpdateRequest Creation (Webhook)#
When an admission request arrives, handleBackgroundApplies() is called asynchronously (30-second timeout). It invokes handleMutateExisting(), which:
- Builds a
PolicyContextfrom the admission request. - Calls
engine.ApplyBackgroundChecks()to identify whichmutateExistingrules pass without executing the actual mutation. - For each passing rule, calls
applyUpdateRequest()withruleType = kyvernov2.Mutate. - The
transform()function converts each engine response into anUpdateRequestSpeccarrying the policy name, rule name, trigger resource details, and the originalAdmissionRequest.
The UpdateRequest object is written to the Kyverno namespace by generator.Apply(), which spawns a background goroutine with exponential-backoff retry. Requests are labeled using MutateLabelsSet(policy, resource) so they can be bulk-deleted if the policy is removed later .
Stage 2 – Background Controller Processing#
The background controller watches UpdateRequest objects via an informer. On Add/Update events it enqueues the request key, skipping those already in Completed or Skip state .
syncUpdateRequest() dequeues each key, fetches the UpdateRequest, and — if its state is Pending — calls processUR(). For kyvernov2.Mutate requests, processUR instantiates a mutateExistingController and calls its ProcessUR method.
ProcessUR drives the per-rule loop:
- Trigger retrieval — Fetches the trigger resource from the API or falls back to the
AdmissionRequestbody for subresources . - PolicyContext construction —
common.NewBackgroundContext()builds a context with the trigger and its namespace labels. - GVK resolution — If an
AdmissionRequestis present, the trigger'sGroupVersionKindis resolved from the GVR via discovery . - Engine evaluation —
engine.Mutate()runs the rule against the policy context, which internally dispatches tomutate_existing.gofor target loading and patch application. - Status update — The
UpdateRequestis markedCompletedorFailedviaupdateURStatus. Completed requests are then deleted byreconcileURStatus; failed ones are reset toPendingfor retry (up tomaxRetries = 10) .
Target Resource Discovery#
Inside the engine, mutate_existing.go calls loadTargets() for each rule's mutation.targets list. For each target spec:
- Variable substitution —
resolveSpec()marshals theTargetSelectorto JSON, runsvariables.SubstituteAll()against the policy context, and unmarshals back to a typed selector. This enables dynamic target selectors whose fields are JMESPath expressions. - Resource fetch —
getTargets()callsclient.GetResources()with the resolved group/version/kind/subresource and an optionalLabelSelector. For namespaced policies, the target namespace is locked to the policy's own namespace . - Subresource tracking — Each returned object is wrapped in a
resourceInfostruct that records thesubresourcename andparentResourceGVR, needed later for correct API routing .
The handler then iterates over the loaded targets, per-target context entries are loaded, per-target preconditions are evaluated, and the patch is computed via mutate.Mutate() or forEachMutator.mutateForEach() . The buildRuleResponse() helper attaches the PatchedTarget (including parentResourceGVR and subresource) to the rule response.
Subresource Routing and API Update#
After engine.Mutate() returns, ProcessUR iterates over each RuleResponse and writes the patched target back to Kubernetes via dclient.Interface. The routing logic branches on the patchedSubresource field :
| Condition | Method called |
|---|---|
patchedSubresource == "status" | UpdateStatusResource() — calls .UpdateStatus() on the dynamic resource interface |
patchedSubresource != "" (other subresource) | UpdateResource(..., patchedSubresource) — resolves the parent GVK from discovery, then calls .Update() with the subresource path appended |
| no subresource | UpdateResource() — standard update on the resource itself |
The ResourceVersion of the original (pre-patch) object is copied onto the patched object before the update call , satisfying Kubernetes optimistic concurrency.
On error, the failure is appended to the error slice, an event is emitted, and the UpdateRequest is eventually retried. On success, an event is emitted via event.NewBackgroundSuccessEvent . If MutateExistingReportsEnabled() is true and the trigger resource is reportable, an ephemeral policy report is created .
Key Entry Points#
| Component | File | Purpose |
|---|---|---|
| Webhook trigger | pkg/webhooks/resource/updaterequest.go | handleMutateExisting() — creates UpdateRequests |
| UR generator | pkg/webhooks/updaterequest/generator.go | Apply() / tryApplyResource() — writes UR to API |
| Background controller | pkg/background/update_request_controller.go | syncUpdateRequest() / processUR() |
| MutateExisting controller | pkg/background/mutate/mutate.go | ProcessUR() — per-rule orchestration |
| Engine handler | pkg/engine/handlers/mutation/mutate_existing.go | Process() — target loading + patch computation |
| Target loader | pkg/engine/handlers/mutation/load_targets.go | loadTargets() / getTargets() |
| ForEach mutation | pkg/engine/handlers/mutation/common.go | forEachMutator.mutateForEach() |
| Dynamic client | pkg/clients/dclient/client.go | UpdateResource() / UpdateStatusResource() |