Skip to content

An app pod mutator can refuse, and a refusal stops the write - #597

Merged
incognick merged 1 commit into
mainfrom
feat/app-pod-mutator-returns-error
Aug 21, 2026
Merged

An app pod mutator can refuse, and a refusal stops the write#597
incognick merged 1 commit into
mainfrom
feat/app-pod-mutator-returns-error

Conversation

@incognick

Copy link
Copy Markdown
Contributor

WithAppPodMutator grows an error return, and a hook that returns one stops the write.

func (a *Adapter) WithAppPodMutator(fn func(PodIdentity, *corev1.PodSpec) error) *Adapter

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

  • Both invocation sites carry the error out: buildDeployment(*appsv1.Deployment, error), runJob(*batchv1.Job, error). Each builds the object and then returns, so nothing reaches the API server.
  • ApplyWorkload and RunJob fail with the hook's own error, wrapped with the app, the namespace and which of the app's two pods it was. errors.Is recovers the embedder's error.
  • A refusal is not a conflict, so the deploy path's RetryOnConflict returns it rather than re-running the closure — asserted, the hook is called exactly once.
  • ADR-0061 §3 stands: a nil hook, and a hook returning nil, author what they always did. Pinned by comparing the two spellings' output.

What does not change

WithPodMutator keeps its exact signature, stays Deprecated:, and still wires into the same field — its hook cannot fail, so the wrapper reports nil. WithPlatformPodMutator is untouched (ADR-0077 §2, ADR-0100 §4).

The signature pin was edited, deliberately

controlplane/kube/app_pod_identity_test.go:23 pinned WithAppPodMutator'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 in controlplane/kube/placement_test.go:299-302 pinning WithPodMutator and WithPlatformPodMutator are not touched and still compile.

Note for whoever wrote the brief: the WithAppPodMutator pin lives in app_pod_identity_test.go, not in placement_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:

  • The spelling shipped in exactly one release, v0.14.0-rc.53, cut today.
  • Nothing has adopted it. The only known embedder's adoption is an open, unmerged pull request in its own repository, which reworks against this signature.
  • 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.
  • 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:

mutant caught by
applyAppPodMutator swallows the hook's error the two deploy refusal tests, the run refusal test, and the message test
runJob ignores its refusal and returns the Job the run refusal test and the message test
the WithPodMutator wrapper reports an error instead of nil 8 tests, including every pre-existing WithPodMutator test

The 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.

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>
@incognick
incognick merged commit 6daeb10 into main Aug 21, 2026
5 checks passed
@incognick
incognick deleted the feat/app-pod-mutator-returns-error branch August 21, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant