Skip to content

feat(kustomize): tolerate a patch, without authoring one#235

Closed
sunib wants to merge 5 commits into
mainfrom
feat/kustomize-tolerate-patches
Closed

feat(kustomize): tolerate a patch, without authoring one#235
sunib wants to merge 5 commits into
mainfrom
feat/kustomize-tolerate-patches

Conversation

@sunib

@sunib sunib commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #234. Base is fix/kustomize-source-form-projection — review that first; this PR's own diff is the second commit. #234 is the gate on this one, not a coincidence: a patched base would otherwise absorb one environment's values, and no re-render can catch that (the patch re-imposes its value, so the render comes out identical either way).

What changes

A patches: block used to refuse the whole GitTarget. Not the edit — the target. A folder whose patch pins a replica count also lost images:/replicas: edit-through, which the patch has nothing to do with. It is the single biggest refusal cause in the layout corpus.

A strategic-merge patch named by path: is now read-only build context:

  • the folder is accepted, and what it renders is mirrored;
  • the patch file is retained, never managed;
  • images:/replicas: edit-through works in a patched folder, exactly as anywhere else;
  • an edit to a field the patch owns is refused per object, not per folder.

Tolerating a patch is not authoring one. Nothing is ever written into a patch file.

The patch file is a build input, not a manifest

This is the part nothing else in the store would have got right, and it is why "just drop patches from the deny-list" is not the change. A sparse strategic-merge patch is a KRM document — apiVersion, kind, metadata.name. Materialised, the store would index it as a manifest, match a live object to it, mirror a whole Deployment over the sparse patch, or sweep it away as an orphan nothing in the cluster answers to.

That a patch is not a resource is not our claim — it is the render's: a patch file never appears as a rendered object's origin. TestPatches_ThePatchFileIsNeverARenderOrigin pins exactly that, so the day it stops being true, retention stops hiding a resource.

One shape in, the rest refused by name

"Unsupported" is not something a user can act on. "Your patch is inline, and we can only read one from a file" is.

Shape Verdict
patches: [{path: patch.yaml}] — a sparse KRM document inside the tree tolerated
patches: [{patch: "..."}] — inline (including an inline JSON6902 op list) patches-inline
patches: [{path: json-patch.yaml}] where the file is an op/path/value list patches-json6902
a path: naming no file in the tree, or escaping it patches-outside-tree
patchesStrategicMerge: / patchesJson6902: refused under their own names

Measured, not assumed — the prompt for this work suspected the opposite: FixKustomization folds bases into resources and imageTags into images, but it does not fold the deprecated patch spellings into Patches. They stay in their own fields and refuse under their own names. TestPatches_DeprecatedSpellingsAreNotFoldedIntoPatches pins it, so a kustomize bump that starts folding them cannot silently widen what we accept to a shape we have never looked at.

The corpus says something the plan did not expect

This is the deliverable of the stage, and it is unflattering:

Tolerating patches accepts zero new candidates.

Every patched overlay in the corpus also reads a base from outside its own folder — so patches was masking the real refusal. flux-monorepo/apps/{staging,production} now report:

overlay-fan-out-unsupported: base "apps/base/frontend" is read from outside this folder's
subtree and is shared by 2 render root(s); render-root scoping required

That verdict is one render-root-scoping.md §5 records as never having been observed, because refused-structural always fired first and hid it. So the finding is: render-root scoping, not patches, is the single blocker on the corpus's most tractable layout.

The two rows that move:

Fixture Before After
flux-monorepo/apps/{staging,production} refused-structural: patches kustomize-overlay / overlay-fan-out-unsupported
kustomize-overlays/apps/frontend/overlays/* …, patches, … patches drops out; still refused for generators + namePrefix/nameSuffix

The mechanism itself is proven by fixtures rather than by the corpus, at both levels: a self-contained patched root is accepted, its image bump routes to the entry, its in-sync state is a no-op, and its patch-owned scale is refused, naming the object.

What is still deferred

Stages 2–4 of the milestone (attribute a patch scalar with the dye, route an edit into the patch, per-field refusal accounting). Nothing here reads a patch's contents to decide anything — reading a patch tells you what it asks for, never what the build does, and the ordering fact pinned in #234 ([PatchTransformer, ReplicaCountTransformer, ImageTagTransformer] — the transformers win) is exactly what you would get wrong by trying.

Validation

task lint · task test (coverage 75.7% → 75.8%) · task test-e2e (55/55) all pass.

🤖 Generated with Claude Code

…input

The writer mirrors a live object into the file that produced it. Under kustomize
that file is not what the cluster runs, and mirroring the live object straight
back writes the BUILD'S OUTPUT into the build's INPUT.

Measured, on a folder we accept today, with nothing changed in the cluster and
nothing changed in the render: a kustomization declaring `labels:` +
`commonAnnotations:` and nothing else commits the overlay's `env: prod` and
`owner: platform` into the base manifest, on the first reconcile of an unchanged
folder. Every reconcile of an in-sync folder produced a commit, and the file was
left wrong. In a base shared by two overlays, the value baked in is one
environment's.

The fix is one rule, and it models no transformer:

    WHERE THE LIVE OBJECT AND THE RENDER AGREE, THE SOURCE KEEPS ITS BYTES.
    WHERE THEY DISAGREE, THE USER CHANGED SOMETHING, AND THAT IS WHAT WE WRITE.

Agreement means the build already produces exactly what the cluster runs, so the
source is — by construction — what produced it, and there is nothing to write.
Disagreement is the user's edit: it is routed to an images:/replicas: entry when
the dye says one supplies the field, and written through to the source otherwise
— where, if the build owns the field, the re-render refuses the flush.

Because it needs no model of labels, of namespace, or of a patch, it closes all
of them at once. It is also the gate on tolerating `patches:` at all: a patched
base would otherwise absorb one environment's values, and no re-render can catch
that (the patch re-imposes its value, so the render comes out identical).

Two behavior changes go with it:

  - the re-render now runs for ANY document a render root produces, not only one
    an override chain governs. A change to a build-supplied field in a folder
    with no images:/replicas: entries used to be committed and silently never
    converge; it is now a reported refusal.
  - a live change the projection cannot place is refused (`unplaceable-edit`):
    the build and the user both rewrote one list whose elements carry no unique
    `name:` to pair them by. Pairing by position is not a conservative guess, it
    is measurably wrong — kustomize PREPENDS a container a patch adds.

The corpus no-op invariant now compares the WHOLE document, not just its images,
which is how a projection that quietly rewrote every field we had not modelled
passed it for as long as it did: 8 documents checked before, 53 now.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ea2c018-c3ad-45b0-a982-959af9e02876

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kustomize-tolerate-patches

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…e, fix the vars contradiction

The SourceFormRefusedError message (surfaced to the user via WriteBoundaryRefused)
and its IssueUnplaceableEdit doc comment both cut off mid-clause; complete them.
And §2's vars bullet claimed the folder is accepted today while a parenthetical
said it now refuses -- rewrite the passage in past tense (both leaks it cites are
now closed, by #229 and by sourceForm).
@sunib
sunib force-pushed the feat/kustomize-tolerate-patches branch from 9cfa2a8 to 7024d3d Compare July 15, 2026 04:47
sunib and others added 3 commits July 15, 2026 07:59
…lementation

Bring the support-boundary design docs onto this PR — they are the design that sits
next to this real implementation, so they belong together rather than in a separate
PR: render-fidelity.md (our render is not the orchestrator's; the render-vs-live
fence, the blocking RenderFaithful condition, and where to implement it),
admission-consent.md, orchestrator-reconcile-trigger.md, and the kpt-and-krm-functions
orientation note, plus their README index rows. Consolidated from PR #237, now closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `patches:` block used to refuse the whole GitTarget. Not the edit — the TARGET.
A folder whose patch pins a replica count also lost images:/replicas: edit-through,
which the patch has nothing to do with. It is the single biggest refusal cause in
the layout corpus.

A strategic-merge patch named by `path:` is now READ-ONLY BUILD CONTEXT:

  - the folder is accepted, and what it renders is mirrored;
  - the patch FILE is retained, never managed. This is the part nothing else in the
    store would have got right: a sparse patch IS a KRM document, so materialised it
    would be indexed as a manifest, matched to a live object, mirrored over with a
    whole Deployment, or swept as an orphan nothing in the cluster answers to. That
    a patch is not a resource is not our claim — it is the RENDER's: a patch file
    never appears as a rendered object's origin;
  - images:/replicas: edit-through works in a patched folder, exactly as anywhere;
  - an edit to a field the PATCH owns is refused per OBJECT, not per folder.

Tolerating a patch is not authoring one. Nothing is ever written into a patch file.

Exactly one shape is tolerated; the rest refuse BY NAME, because "unsupported" is not
something a user can act on and "your patch is inline, and we can only read one from a
file" is: patches-inline (which is also where an inline JSON6902 op list arrives, since
it decodes into the same field), patches-json6902 (a path to an op list — a YAML
sequence, not a sparse KRM document), patches-outside-tree.

MEASURED, NOT ASSUMED: FixKustomization folds `bases` into resources and `imageTags`
into images, but it does NOT fold `patchesStrategicMerge` or `patchesJson6902` into
`Patches`. They keep refusing under their own names, and a test pins that so a kustomize
bump cannot silently widen what we accept.

THE CORPUS SAYS SOMETHING THE PLAN DID NOT EXPECT, and it is the deliverable here:
tolerating patches accepts ZERO new candidates. Every patched overlay in the corpus also
reads a base from OUTSIDE its own folder, so `patches` was masking the real refusal.
flux-monorepo/apps/{staging,production} now report overlay-fan-out-unsupported — the
verdict render-root-scoping.md §5 records as never having been observed, because
refused-structural always fired first and hid it. Render-root scoping, not patches, is
the single blocker on the corpus's most tractable layout.

The mechanism itself is proven by fixtures at both levels: a self-contained patched root
is accepted, its image bump routes to the entry, its in-sync state is a no-op, and its
patch-owned scale is refused.
The prompt's stage 1 shipped, and three of its premises did not survive contact
with the measurement: the gate was the projection rather than the deny-list,
FixKustomization does not fold the deprecated spellings, and tolerating patches
accepted zero new corpus candidates because every patched overlay in the corpus
is really blocked on render-root scoping.
@sunib
sunib force-pushed the feat/kustomize-tolerate-patches branch from 7024d3d to 77fcf73 Compare July 15, 2026 08:00
Base automatically changed from fix/kustomize-source-form-projection to main July 15, 2026 11:57
@sunib

sunib commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #239. The two are the connected halves of the same capability — render-root scoping (reading ../../base) is the blocker this tolerate-patches work surfaced ("render-root scoping, not patches, is the single blocker on the corpus's most tractable layout"), so they now ship together: a patched overlay reading ../../base is rendered, accepted, gets image/replica edit-through, and refuses patch-owned edits per-object.

The two patches-tolerance commits (4a255a1, 77fcf73) were cherry-picked onto #239 and reconciled with render-root scoping (parseKustomization signature; regenerated the combined layout baseline). Combined validation: lint ✓, unit ✓ (coverage 76.2%), e2e ✓ (56/56).

@sunib sunib closed this Jul 15, 2026
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