An app pod mutator can refuse, and a refusal stops the write - #597
Merged
Conversation
ADR-0100 §1 widened the app pod hook so it is told which app's pod it is shaping. A hook that carries per-app policy has to read that policy from somewhere, and a read can fail — but the hook it was given returns nothing, so it cannot say so. Both ways out of that are worse than a failed deploy. A hook can guess, apply some default, and let the pod run under a policy nobody chose; that is the outcome least likely to be noticed and hardest to explain afterwards. Or it can corrupt the spec on purpose, writing a value it knows the API server will reject, which does stop the write but reports the refusal as whatever validation error the poison happens to trigger — an operator is told their runtime class is not a valid DNS subdomain and has to already know that means a database was unreachable. A deploy that fails for a reason nobody can read is only half of failing closed. So the hook returns an error: WithAppPodMutator(func(PodIdentity, *corev1.PodSpec) error) *Adapter Returning nil is the ordinary outcome. Returning an error aborts the write at both invocation sites: buildDeployment and runJob build the object and then return, so nothing reaches the API server and ApplyWorkload or RunJob fails carrying the hook's own error, wrapped with the app, the namespace and which of the app's two pods it was. A refusal is not a conflict, so the deploy path's RetryOnConflict returns it rather than re-running the closure — a hook that cannot answer will not answer differently three times in a row. WithPodMutator is untouched. Its exact signature is kept, it stays Deprecated:, and it still wires into the same field; its hook cannot fail, so the wrapper reports nil and every install compiled against it behaves exactly as it did. WithPlatformPodMutator is untouched too, as ADR-0077 §2 promised and ADR-0100 §4 restated. This is a source break for anything compiled against WithAppPodMutator, and it is taken deliberately rather than avoided with a fourth name for one hook. That spelling shipped in exactly one release, v0.14.0-rc.53, and nothing has adopted it: the only known embedder's adoption is an open, unmerged pull request in its own repository. ADR-0100's Consequences already call two spellings of one seam a wart; three would be worse than a compile error nobody will ever see. The signature pin on WithAppPodMutator is edited, deliberately and with a comment saying so, because a pin edited quietly is a pin defeated. The two lines pinning WithPodMutator and WithPlatformPodMutator are not touched. Signed-off-by: Nicholas Phillips <nsphilli@gmail.com>
incognick
added a commit
that referenced
this pull request
Aug 21, 2026
The maintainer accepted the record. Status and index row move to Accepted, which unblocks the implementation in #597. Signed-off-by: Nicholas Phillips <nsphilli@gmail.com>
incognick
added a commit
that referenced
this pull request
Aug 21, 2026
* ADR-0101: A pod mutator can refuse the pod ADR-0100 told the app hook which app it is shaping. It still cannot decline one: the signature returns nothing, so an operator whose per-app policy lookup fails must either guess, which fails open, or poison the pod spec so the API server rejects it. Poisoning does fail closed, and it is only half of failing closed. What surfaces is an admission complaint about a malformed name carrying a smuggled string, and it spends the API server's validation as an error channel, so the behaviour is a property of the cluster rather than of this code. The app hook gains an error return. A nil return behaves exactly as before; a non-nil one stops the write, and the object is assembled in memory and dropped rather than sent. A refusal is not a conflict, so the retry loop returns it instead of calling the hook again. WithPodMutator and WithPlatformPodMutator are untouched, and ADR-0061 §3 still holds. The break is taken on the widened spelling, which has been released once with no adopters, rather than adding a fourth name for one seam. Lands Proposed. Signed-off-by: Nicholas Phillips <nsphilli@gmail.com> * ADR-0101: accept The maintainer accepted the record. Status and index row move to Accepted, which unblocks the implementation in #597. Signed-off-by: Nicholas Phillips <nsphilli@gmail.com> --------- Signed-off-by: Nicholas Phillips <nsphilli@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WithAppPodMutatorgrows an error return, and a hook that returns one stops the write.Needs a record. This refines ADR-0100 §1 the way ADR-0100 refined ADR-0061 §1. Not merged until that record is accepted.
Why
A hook told which app it is shaping will read per-app policy from somewhere, and a read can fail. With a void hook it has two ways out and both are worse than a failed deploy: guess — apply a default and run the pod under a policy nobody chose, the outcome least likely to be noticed; or poison the spec so the API server rejects it, which stops the write but reports the refusal as whatever validation error the poison triggers. An operator is told their runtime class is not a valid DNS subdomain and has to already know that means a database was unreachable. A deploy that fails for a reason nobody can read is only half of failing closed.
What changes
buildDeployment→(*appsv1.Deployment, error),runJob→(*batchv1.Job, error). Each builds the object and then returns, so nothing reaches the API server.ApplyWorkloadandRunJobfail with the hook's own error, wrapped with the app, the namespace and which of the app's two pods it was.errors.Isrecovers the embedder's error.RetryOnConflictreturns it rather than re-running the closure — asserted, the hook is called exactly once.What does not change
WithPodMutatorkeeps its exact signature, staysDeprecated:, and still wires into the same field — its hook cannot fail, so the wrapper reports nil.WithPlatformPodMutatoris untouched (ADR-0077 §2, ADR-0100 §4).The signature pin was edited, deliberately
controlplane/kube/app_pod_identity_test.go:23pinnedWithAppPodMutator's old type and has been changed, with a comment saying it was changed on purpose and why — a pin edited quietly is a pin defeated. The two lines incontrolplane/kube/placement_test.go:299-302pinningWithPodMutatorandWithPlatformPodMutatorare not touched and still compile.Note for whoever wrote the brief: the
WithAppPodMutatorpin lives inapp_pod_identity_test.go, not inplacement_test.go— ADR-0100 §5 describes it as a third line beside the other two, and it landed in the identity test's own file instead.Compatibility, decided rather than assumed
This is a source break for anything compiled against
WithAppPodMutator. Taken deliberately, not avoided with a fourth name for one hook:v0.14.0-rc.53, cut today.WithPodMutator's retention is not analogous: that seam has been released and wired for many versions, and it cannot fail, so it has nothing to gain from the change.Verification
On the dev box at
a01653a, go1.26.3:gofmt -l .clean,go vet ./...clean,go build ./...clean,go test ./...fully green. CI is the authority for the tiers this cannot run.Three mutation rounds, each build-gated and reverted:
applyAppPodMutatorswallows the hook's errorrunJobignores its refusal and returns the JobWithPodMutatorwrapper reports an error instead of nilWithPodMutatortestThe refusal tests use a fake cluster that fails a run-Job create rather than only recording it, so a regression that let a refused run through fails in milliseconds instead of waiting out
RunJob's ten-minute deadline — a guard whose failure mode is a ten-minute hang is one somebody deletes rather than fixes.