Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ guidance that the changelog's breaking-change entries link to.
We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with
`bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it.

## Unreleased — `kustomization.yaml` is now read by kustomize itself (next minor; behavior change)

The analyzer used to decode `kustomization.yaml` with a hand-written walk over a generic
YAML map, checked against a hand-maintained list of 17 unsupported keys. It now decodes with
kustomize's own type (`sigs.k8s.io/kustomize/api/types.Kustomization`) and runs the same
`Unmarshal` + `FixKustomization` sequence kustomize's builder runs, and it derives the
unsupported set by reflecting over that type: **anything not explicitly modelled refuses the
folder.**

Five verdicts change as a result. Each was verified against a real `kustomize build`, and in
every case the new behaviour is the one that agrees with the renderer:

| Kustomization contains | Before | After |
|---|---|---|
| `vars:` | **accepted** — and `$(VAR)` in a source file was silently overwritten with its substituted value | **refused** (`vars`) |
| `validators:` (plugin code) | **accepted**, unmodelled | **refused** (`validators`) |
| a `kustomization.yaml` kustomize cannot decode (e.g. `resources:` is a string, or the file is really a Flux `Kustomization` CR) | **accepted**, and written into | **refused** (`unparseable`, quoting kustomize's own error) |
| `imageTags:` / `bases:` (deprecated spellings) | `imageTags` ignored; `bases` read | both **normalised** into `images`/`resources`, as the builder does |
| a case-variant key (`newtag:`), or a blank optional component (`newName: ""`) | **refused** | **accepted** — kustomize honours both, so folders that render fine are no longer refused |

**Migration**

- **The first three refuse folders that previously worked.** All three were unsafe: two let
the operator write into a folder whose render it had misunderstood, and the third let it
write into a folder no GitOps controller can build at all. If a `GitTarget` starts failing
with `GitPathAccepted=False` / `UnsupportedContent`, the refusal detail now names the
feature — and for `unparseable`, quotes kustomize's decode error verbatim.
- **`vars` has no supported replacement.** A value derived at render time has no single home
in Git; edit the source field instead.
- Nothing to do for the last two rows: they only ever accept more.

## Unreleased — `pkg/manifestanalyzer`: the overlay fan-out refusal code was renamed (next minor; breaking for consumers)

One refusal reason changed its name, in both the Go constant and the machine-readable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ not have — batch-wide edit reconciliation is not built.

## What this deliberately does not do

> **Superseded on 2026-07-14, in part.** "Option D remains future research" was answered:
> the analyzer now decodes `kustomization.yaml` with kustomize's own type rather than a
> hand-written key walk, and the re-implemented transformer subset is being removed. The
> reasoning, and why the original position was wrong, is in
> [../kustomize-support-boundary.md](../kustomize-support-boundary.md) §7. The rest of
> this section still describes what shipped here.

- No `kustomize build`, no source maps (Option D remains future research).
- No entry creation/deletion, no patch authoring, no `namePrefix`/`nameSuffix`
or generator support — the acceptance boundary for those is unchanged.
Expand Down
63 changes: 54 additions & 9 deletions docs/design/support-boundary/kustomize-support-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,57 @@ kustomize" means two different things:
- **Understanding** (folder → expected render): ours, necessarily — the
writer must know which file supplied each live value.

We keep *re-implementing the narrow transformer subset* rather than embedding
kustomize as the renderer: it is what keeps the refusal boundary honest (we
refuse exactly what we do not model). The worthwhile upgrade is kustomize's
Go API (`krusty`) as a **verification oracle**, not a renderer: in the
acceptance gate or in CI, build each render root in-memory and compare
against our own projection; mismatch → refuse. That buys kustomize's ground
truth without ever depending on semantics we have not modeled — the cheap,
high-confidence version of the "Option D" parked during the `images:`/`replicas:`
work.
**Decision (2026-07-14): we embed kustomize (`sigs.k8s.io/kustomize/api`) as the
renderer. The re-implementation is being removed.** This reverses the earlier
position — kept here because the reasoning was wrong in a specific, instructive way.

The old position was that re-implementing the narrow transformer subset "keeps the
refusal boundary honest: we refuse exactly what we do not model," and that `krusty`
was at best a *verification oracle* comparing against our own projection.

Both halves turned out to be false:

- **The re-implementation does not keep the boundary honest — it makes it dishonest
in two places.** `vars` was never added to the deny-list, so `$(VAR)` in a source
file is silently overwritten with its substituted value. `commonLabels`/`labels`/
`annotations` were classed as benign and leak into source documents as drift.
"We refuse exactly what we do not model" was the *intent*; what shipped is "we
refuse most of what we do not model, and corrupt source files with the rest."
- **The dependency was never the cost we thought.** `sigs.k8s.io/kustomize/api` and
`kyaml` are **already in this module's requirement graph**. Taking them as a direct
dependency at the version Flux ships (v0.21.1) adds **zero new modules** — one line
in `go.mod`, and code we already carry starts getting linked instead of re-typed.

Against that: ~1,050 lines across
[`overrides.go`](../../../internal/manifestanalyzer/overrides.go),
[`overrides_projection.go`](../../../internal/manifestanalyzer/overrides_projection.go)
and [`kustomization.go`](../../../internal/git/manifestedit/kustomization.go)
re-derive image-reference parsing, the image transformer, the replica transformer's
fieldspec, render-root discovery and the resource DAG walk — and every future feature
demands more of the same (strategic-merge semantics, name-reference cascades, the
generator content-hash algorithm). We were re-writing kustomize by instalments.

**Understanding is still ours; deploying is still theirs.** Embedding the library does
not put us in the deployment business. It replaces our *guess* at what Flux will render
with the *library Flux actually renders with*.

### The sandbox is part of the contract

```go
krusty.Options{
LoadRestrictions: kustypes.LoadRestrictionsRootOnly,
PluginConfig: kustypes.DisabledPluginConfig(), // no exec, no Go plugins
}
```

**`LoadRestrictions` does not stop the network, and this was measured, not assumed.**
Given a remote base, kustomize shells out to `/usr/bin/git fetch` — under
`LoadRestrictionsRootOnly` *and* under an in-memory filesystem. Both were tried; both
fetched.

So the remote-base detection we already have
([`hasRemoteResource`/`isRemoteResource`](../../../internal/manifestanalyzer/store.go))
is **not** made redundant by the renderer. It is promoted to a **security precondition
that runs before krusty is ever called**. It is the one piece of the re-implementation
that must survive, and *"we do not run kustomize on a remote base"* stays literally
true — now enforced rather than merely implied by not having a renderer at all.
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,13 @@ above:

## Non-goals

- **No orchestrator emulation.** Interpreters read declarations. They do not run
kustomize on a remote base, resolve a Helm chart, evaluate an ApplicationSet
generator, decrypt SOPS, or contact a registry.
- **No orchestrator emulation.** Interpreters read declarations. They do not resolve a
Helm chart, evaluate an ApplicationSet generator, decrypt SOPS, or contact a
registry. (The analyzer does decode a `kustomization.yaml` with kustomize's own
type, and will render a local render root with kustomize's own library — using the
renderer is the opposite of emulating it. It never runs on a remote base: kustomize
fetches one by shelling out to `git`, so a remote resource is refused before any
build. See [kustomize-support-boundary.md](kustomize-support-boundary.md) §7.)
- **No new operator dependency.** Interpreters serve the analyzer and the repo
scan report. Whether the operator ever consumes a claim is a separate decision; the
ownership *gate* it needs can be fed by Tier 0 (`Encrypted`) alone for the
Expand Down
13 changes: 9 additions & 4 deletions docs/design/support-boundary/support-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,17 @@ we can store — and is argued in

## What we will not do

- **No orchestrator emulation.** We do not run kustomize on a remote base, resolve
a Helm chart, evaluate an ApplicationSet generator or a ResourceSet template,
decrypt SOPS, or contact a registry.
- **No orchestrator emulation.** We do not resolve a Helm chart, evaluate an
ApplicationSet generator or a ResourceSet template, decrypt SOPS, or contact a
registry. **We do run kustomize** — the real `sigs.k8s.io/kustomize/api`, the library
Flux itself renders with — locally, with plugins disabled, and **never on a remote
base**: a remote resource is refused before the build is invoked. Using the renderer
is the opposite of emulating it; see
[kustomize-support-boundary.md](kustomize-support-boundary.md) §7.
- **No Argo CD or Flux Go dependency.** Their kinds are matched by group and kind
over `unstructured`; the upstream checkouts under `external-sources/` are
reference material, never vendored code.
reference material, never vendored code. (`sigs.k8s.io/kustomize` is neither — it is
a Kubernetes SIG library, already in this module's requirement graph.)
- **We never widen the boundary by guessing.** Ownership and provenance only ever
*refuse more*. Everything refused for renderability stays refused.

Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
sigs.k8s.io/cli-utils v0.37.2
sigs.k8s.io/controller-runtime v0.24.1
sigs.k8s.io/kustomize/api v0.21.1
sigs.k8s.io/yaml v1.6.0
)

Expand All @@ -53,6 +54,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
Expand Down Expand Up @@ -135,6 +137,7 @@ require (
k8s.io/streaming v0.36.2 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
Expand Down Expand Up @@ -364,6 +366,10 @@ sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs=
sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI=
sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI=
sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
Expand Down
32 changes: 24 additions & 8 deletions internal/git/placement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,14 @@ func TestPlacement_KustomizeEntryIdempotent_OnRepeatedApply(t *testing.T) {
"the resources: entry must appear exactly once")
}

// A kustomization whose resources: field is malformed (not a sequence) is still
// accepted by the analyzer (only specific disallowed keys, not resources: shape,
// disqualify a kustomization) — so the writer must still place the resource's own
// file, but the resources: entry add is skipped rather than corrupting the
// kustomization, leaving it exactly as it was for a human to fix.
func TestPlacement_KustomizeEntryAppendSkipped_MalformedResourcesField(t *testing.T) {
// A kustomization with no resources: sequence to append to is still accepted — so
// the writer must place the resource's own file, while the resources: entry add is
// skipped rather than inventing the key, leaving the kustomization exactly as it
// was for a human to complete.
func TestPlacement_KustomizeEntryAppendSkipped_NoResourcesSequence(t *testing.T) {
worktree := newWorktreeForTest(t)
root := worktree.Filesystem.Root()
kustYAML := "namespace: app\nresources: not-a-list\n"
kustYAML := "namespace: app\n"
seedPlacedManifest(t, worktree, "overlays/test/kustomization.yaml", kustYAML)

changed := applyEventsWithPolicy(t, worktree, nil, newConfigMapEvent("cache", "app"))
Expand All @@ -222,7 +221,24 @@ func TestPlacement_KustomizeEntryAppendSkipped_MalformedResourcesField(t *testin
// returns the original content untouched, not merely a semantically
// equivalent one.
assert.Equal(t, kustYAML, string(kust), //nolint:testifylint
"a malformed resources: field must be left untouched, not corrupted")
"a kustomization with no resources: sequence must be left untouched, not corrupted")
}

// A kustomization kustomize itself cannot decode (here resources: is not a
// sequence, which fails `kustomize build` outright) refuses the folder rather than
// being tolerated. Before the analyzer used kustomize's own type this file was
// accepted — only specific disallowed *keys* disqualified a kustomization, never
// its shape — so the operator would happily write into a folder that no GitOps
// controller could render.
func TestPlacement_UndecodableKustomization_RefusesTheFlush(t *testing.T) {
worktree := newWorktreeForTest(t)
seedPlacedManifest(t, worktree, "overlays/test/kustomization.yaml", "namespace: app\nresources: not-a-list\n")

w := &BranchWorker{contentWriter: newContentWriter(types.SensitiveResourcePolicy{}), mapper: configMapMapper()}
_, err := w.flushEventsToWorktree(
context.Background(), worktree, "", []Event{newConfigMapEvent("cache", "app")}, nil,
)
require.Error(t, err, "a kustomization kustomize cannot build must refuse the folder, not be written into")
}

func newTestWriteBatch(t *testing.T) *writeBatch {
Expand Down
Loading