From a4f233f30e96618343c323528b979cddb34d08ba Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 14 Jul 2026 12:54:01 +0000 Subject: [PATCH] feat(kustomize)!: read kustomization.yaml with kustomize, not a hand-written parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analyzer decoded kustomization.yaml with a hand-written walk over a generic YAML map, and checked it against a hand-maintained list of 17 unsupported keys. It now decodes with kustomize's own type and runs the same Unmarshal + FixKustomization sequence kustomize's builder runs (internal/target: kusttarget.load), and derives the unsupported set by reflecting over that type: anything not explicitly modelled refuses the folder. The dependency was never the cost we thought it was. sigs.k8s.io/kustomize/api and kyaml were already in this module's requirement graph; taking them directly at the version Flux ships adds zero new modules to the build. The hand-written deny-list could only refuse what someone had remembered to add to it, and it had holes. Five verdicts change, each verified against a real `kustomize build`, and in every case the new behaviour is the one that agrees with the renderer: - vars was accepted, and $(VAR) in a source document was silently overwritten with its substituted value on mirror. Now refused. - validators (plugin code) was accepted, unmodelled. Now refused. - A kustomization kustomize cannot decode — resources: as a string, or a file that is really a Flux Kustomization CR — was accepted and written into. Now refused, quoting kustomize's own decode error so the refusal is actionable. - imageTags was silently ignored (an override we never saw still changed the render); bases was read by hand. Both are now normalised into images/resources exactly as the builder does. - A case-variant key (newtag:) and a blank optional component (newName: "") refused the folder. kustomize honours both, so folders that render fine are no longer refused. Remote-base detection does NOT move to the library, and this is the load-bearing exception: kustomize resolves a remote base by shelling out to `git fetch`, and does so under LoadRestrictionsRootOnly and under an in-memory filesystem alike (both measured). No build option turns it off. hasRemoteResource is therefore promoted to a security precondition that runs before any build is invoked, which is what keeps "the operator never fetches a remote base" true. Net production code: -45 lines. The line count is not the point; the next change, which replaces the re-implemented transformers with the real render, is where the bulk goes. Refs docs/design/support-boundary/kustomize-support-boundary.md §7 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/UPGRADING.md | 31 ++ .../images-and-replicas-edit-through.md | 7 + .../kustomize-support-boundary.md | 63 +++- .../orchestrator-knowledge-boundary.md | 10 +- .../support-boundary/support-contract.md | 13 +- go.mod | 3 + go.sum | 6 + internal/git/placement_test.go | 32 +- .../manifestanalyzer/kustomization_parse.go | 281 ++++++++++++++++++ internal/manifestanalyzer/overrides.go | 131 -------- .../manifestanalyzer/overrides_projection.go | 24 +- internal/manifestanalyzer/overrides_test.go | 41 ++- internal/manifestanalyzer/scan_repo.go | 43 +-- internal/manifestanalyzer/store.go | 126 +------- test/fixtures/gitops-layouts/support-today.md | 6 +- 15 files changed, 501 insertions(+), 316 deletions(-) create mode 100644 internal/manifestanalyzer/kustomization_parse.go diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 195ddacdf..54c86425a 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -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 diff --git a/docs/design/support-boundary/finished/images-and-replicas-edit-through.md b/docs/design/support-boundary/finished/images-and-replicas-edit-through.md index f68ddd383..1ad5b12dd 100644 --- a/docs/design/support-boundary/finished/images-and-replicas-edit-through.md +++ b/docs/design/support-boundary/finished/images-and-replicas-edit-through.md @@ -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. diff --git a/docs/design/support-boundary/kustomize-support-boundary.md b/docs/design/support-boundary/kustomize-support-boundary.md index 022140a5f..0ea2b8bed 100644 --- a/docs/design/support-boundary/kustomize-support-boundary.md +++ b/docs/design/support-boundary/kustomize-support-boundary.md @@ -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. diff --git a/docs/design/support-boundary/orchestrator-knowledge-boundary.md b/docs/design/support-boundary/orchestrator-knowledge-boundary.md index ba8711c3f..b56aaec64 100644 --- a/docs/design/support-boundary/orchestrator-knowledge-boundary.md +++ b/docs/design/support-boundary/orchestrator-knowledge-boundary.md @@ -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 diff --git a/docs/design/support-boundary/support-contract.md b/docs/design/support-boundary/support-contract.md index d2d067724..d8f6a2606 100644 --- a/docs/design/support-boundary/support-contract.md +++ b/docs/design/support-boundary/support-contract.md @@ -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. diff --git a/go.mod b/go.mod index 137fdc7c5..21379e5ec 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 @@ -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 ) diff --git a/go.sum b/go.sum index e4430a953..d2558a2f2 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/git/placement_test.go b/internal/git/placement_test.go index 706796662..caa7b2d5c 100644 --- a/internal/git/placement_test.go +++ b/internal/git/placement_test.go @@ -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")) @@ -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 { diff --git a/internal/manifestanalyzer/kustomization_parse.go b/internal/manifestanalyzer/kustomization_parse.go new file mode 100644 index 000000000..65907abca --- /dev/null +++ b/internal/manifestanalyzer/kustomization_parse.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import ( + "reflect" + "sort" + "strings" + + kustypes "sigs.k8s.io/kustomize/api/types" + + "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" +) + +// This file decodes kustomization.yaml with kustomize's own type +// (sigs.k8s.io/kustomize/api/types.Kustomization) instead of a hand-written walk +// over map[string]interface{}, and derives the unsupported-feature set by +// reflecting over that type's fields. +// +// The point is not brevity. It is that the check becomes exhaustive by +// construction: a kustomization field the operator has never heard of lands +// outside supportedKustomizationFields and refuses the folder, instead of being +// silently tolerated. The hand-written deny-list could only refuse what someone +// had remembered to add to it, and it had holes — see supportedKustomizationFields. +// +// See docs/design/support-boundary/kustomize-support-boundary.md §7. + +// supportedKustomizationFields names the struct fields of kustypes.Kustomization +// that the operator models. Every other field, present and non-zero, refuses the +// folder by its own name. +// +// Inverting the old hand-written deny-list into an allowlist over kustomize's own +// type closed two holes it had, each of which let a kustomization render +// differently from what we believed: +// +// - vars — a source document containing $(SOME_VAR) renders to the substituted +// value. We mirrored that value straight back over the variable in the source +// file. Silent corruption, in a folder we accepted. +// - validators — plugin code. Arbitrary code is an unknowable render. +// +// Neither was in the deny-list, and neither could have been missed here: they are +// fields on kustomize's type, and anything not named below is refused. When +// kustomize grows a field, it lands outside this set and refuses rather than being +// silently tolerated. +// +// Deprecated spellings (bases, imageTags) are absent on purpose: FixKustomization +// folds them into resources/images before this map is consulted, exactly as the +// builder does. +func supportedKustomizationFields() map[string]struct{} { + return map[string]struct{}{ + "TypeMeta": {}, // apiVersion / kind + "MetaData": {}, // the kustomization's own metadata; renders nothing + + // The modelled subset: the contextual-namespace walk plus the two + // edit-through channels. + "Namespace": {}, + "Resources": {}, + "Images": {}, + "Replicas": {}, + + // Tolerated exactly as before this change, so it stays a refactor. These + // inject metadata into rendered objects and therefore leak into mirrored + // source files as drift — a known defect, tracked separately, deliberately + // not altered here. + "CommonLabels": {}, + "Labels": {}, + "CommonAnnotations": {}, + "BuildMetadata": {}, + + "GeneratorOptions": {}, // inert on its own; every generator is refused below + "SortOptions": {}, // output ordering only + } +} + +// featureUnparseable is reported when kustomize's own decoder rejects the file. +// Unmarshal disallows unknown fields, so this also covers a key kustomize itself +// would refuse to build: if we cannot read it, we cannot vouch for what it renders. +const featureUnparseable = "unparseable" + +// featureRemoteBase is reported for a resources/bases entry that is not a local +// path. It is the one piece of kustomize semantics we must keep owning: kustomize +// resolves a remote base by shelling out to `git fetch`, and it does so under +// LoadRestrictionsRootOnly and under an in-memory filesystem alike (both measured). +// Detecting it ourselves, before any build is invoked, is what keeps "we never +// fetch a remote base" true. +const featureRemoteBase = "remote-base" + +const ( + featureMalformedImages = "malformed-images" + featureMalformedReplicas = "malformed-replicas" +) + +// parseKustomization decodes one kustomization.yaml and reports every feature the +// operator does not model, sorted. An empty slice means the file is fully modelled. +// The doc is returned even when unsupported: callers keep it (so it never acts as a +// namespace source) rather than dropping it. +func parseKustomization(content []byte, path string) (*kustomizationDoc, []string) { + doc := &kustomizationDoc{path: path} + + // Unmarshal then FixKustomization is exactly what kustomize's own loader does + // (internal/target/kusttarget.go: load), so we model the kustomization the + // builder will actually see: bases folded into resources, imageTags into + // images, the deprecated generator spellings normalised. + // + // The loader's CheckEmpty/EnforceFields validations are deliberately not run + // here: they decide whether a build would succeed, which is a different + // question from whether we can model the render, and adding them would refuse + // more than this change intends to. + var k kustypes.Kustomization + if err := k.Unmarshal(content); err != nil { + doc.unsupported = true + return doc, []string{featureUnparseable} + } + k.FixKustomization() + + doc.namespace = strings.TrimSpace(k.Namespace) + doc.resources = trimmedEntries(k.Resources) + + features := map[string]struct{}{} + for _, name := range unmodelledFields(k) { + features[name] = struct{}{} + } + if hasRemoteResource(doc.resources) { + features[featureRemoteBase] = struct{}{} + } + + var ok bool + if doc.images, ok = imageOverrides(k.Images, path); !ok { + features[featureMalformedImages] = struct{}{} + } + if doc.replicas, ok = replicaOverrides(k.Replicas, path); !ok { + features[featureMalformedReplicas] = struct{}{} + } + + out := make([]string, 0, len(features)) + for f := range features { + out = append(out, f) + } + sort.Strings(out) + doc.unsupported = len(out) > 0 + return doc, out +} + +// kustomizationDecodeError returns kustomize's own decode error for a file it +// cannot build, or "" when it decodes. It is what makes an `unparseable` refusal +// actionable: "your kustomization.yaml is a Flux Kustomization CR" and "resources: +// is a string, not a list" are both unparseable, and the user needs to know which. +func kustomizationDecodeError(content []byte) string { + var k kustypes.Kustomization + if err := k.Unmarshal(content); err != nil { + return err.Error() + } + return "" +} + +// unmodelledFields returns the yaml key of every non-zero Kustomization field that +// is not in supportedKustomizationFields — the features that refuse the folder, +// named as the user wrote them. +func unmodelledFields(k kustypes.Kustomization) []string { + var out []string + modelledFields := supportedKustomizationFields() + v := reflect.ValueOf(k) + t := v.Type() + for i := range t.NumField() { + field := t.Field(i) + if _, modelled := modelledFields[field.Name]; modelled { + continue + } + if v.Field(i).IsZero() { + continue + } + out = append(out, kustomizationFieldKey(field)) + } + return out +} + +// kustomizationFieldKey is the yaml key a user writes for a struct field +// ("configMapGenerator"), so a refusal names the line in their file. +func kustomizationFieldKey(f reflect.StructField) string { + tag := f.Tag.Get("yaml") + if i := strings.Index(tag, ","); i >= 0 { + tag = tag[:i] + } + if tag == "" || tag == "-" { + return f.Name + } + return tag +} + +// imageOverrides converts kustomize's parsed images: entries into the writer's +// model. ok is false when an entry is present but not one we can route an edit +// through, which refuses the folder rather than misunderstanding it silently. +// +// TagSuffix is rejected on purpose. We do not model it, and kustomize's type would +// happily decode it — accepting it would mean writing a file we believe renders one +// way while kustomize renders it another. (The old hand-written key check refused it +// as an unknown key; this preserves that, deliberately.) +func imageOverrides(entries []kustypes.Image, source string) ([]ImageOverride, bool) { + if len(entries) == 0 { + return nil, true + } + out := make([]ImageOverride, 0, len(entries)) + for i, e := range entries { + if strings.TrimSpace(e.Name) == "" || strings.TrimSpace(e.TagSuffix) != "" { + return nil, false + } + out = append(out, ImageOverride{ + Source: source, + Index: i, + Name: e.Name, + NewName: e.NewName, + NewTag: e.NewTag, + Digest: e.Digest, + HasNewName: e.NewName != "", + HasNewTag: e.NewTag != "", + HasDigest: e.Digest != "", + }) + } + return out, true +} + +// replicaOverrides converts kustomize's parsed replicas: entries into the writer's +// model. A negative count is not a render kustomize would produce, so it is refused. +func replicaOverrides(entries []kustypes.Replica, source string) ([]ReplicaOverride, bool) { + if len(entries) == 0 { + return nil, true + } + out := make([]ReplicaOverride, 0, len(entries)) + for i, e := range entries { + if strings.TrimSpace(e.Name) == "" || e.Count < 0 { + return nil, false + } + out = append(out, ReplicaOverride{Source: source, Index: i, Name: e.Name, Count: e.Count}) + } + return out, true +} + +// trimmedEntries returns the graph entries the walker follows, dropping blanks. +// bases: is already folded into resources: by FixKustomization. +func trimmedEntries(lists ...[]string) []string { + var out []string + for _, list := range lists { + for _, e := range list { + if e = strings.TrimSpace(e); e != "" { + out = append(out, e) + } + } + } + return out +} + +// parseKustomizations reads every kustomization.yaml into a kustomizationDoc keyed +// by its directory. An unparseable kustomization, or one using an unsupported +// feature, is kept but marked unsupported so it never acts as a namespace source. +func parseKustomizations(files []manifestedit.FileContent) map[string]*kustomizationDoc { + out := map[string]*kustomizationDoc{} + for _, f := range files { + if !isKustomizationFile(f.Path) { + continue + } + doc, _ := parseKustomization(f.Content, filepathToSlash(f.Path)) + out[slashDir(f.Path)] = doc + } + return out +} + +// kustomizationUsesUnsupportedFeature reports whether a kustomization.yaml uses a +// feature outside the modelled subset — the predicate the acceptance gate uses to +// refuse the folder at the retention site. +func kustomizationUsesUnsupportedFeature(content []byte) bool { + _, features := parseKustomization(content, "") + return len(features) > 0 +} + +// unsupportedKustomizeFeatures names the features a kustomization declares that the +// operator does not model, for the repo scan's per-candidate refusal detail. It is +// the same parse the acceptance gate runs, so the two cannot drift. +func unsupportedKustomizeFeatures(content []byte) []string { + _, features := parseKustomization(content, "") + return features +} diff --git a/internal/manifestanalyzer/overrides.go b/internal/manifestanalyzer/overrides.go index bf95af557..96fa76e49 100644 --- a/internal/manifestanalyzer/overrides.go +++ b/internal/manifestanalyzer/overrides.go @@ -3,7 +3,6 @@ package manifestanalyzer import ( - "math" "sort" "strings" @@ -69,136 +68,6 @@ type KustomizeOverrides struct { Replicas []ReplicaOverride } -// hasOnlyKeys reports whether the entry map uses only the exact key set -// kustomize's typed entry accepts. An unknown key means we can no longer vouch -// for the render (kustomize itself rejects it), so the kustomization is refused -// as malformed rather than silently misunderstood. -func hasOnlyKeys(m map[string]interface{}, keys ...string) bool { - for k := range m { - known := false - for _, want := range keys { - if k == want { - known = true - break - } - } - if !known { - return false - } - } - return true -} - -// parseImageOverrides parses the images: field of a kustomization. ok is false -// when the field is present but not structurally sound (not a list of maps, a -// missing/empty name, a non-string or empty component, an unknown key) — the -// caller marks the kustomization unsupported, because a folder we cannot parse is -// a folder we cannot claim to understand. -func parseImageOverrides(raw map[string]interface{}, source string) ([]ImageOverride, bool) { - v, present := raw["images"] - if !present || isEmptyValue(v) { - return nil, true - } - list, isList := v.([]interface{}) - if !isList { - return nil, false - } - out := make([]ImageOverride, 0, len(list)) - for i, item := range list { - m, isMap := item.(map[string]interface{}) - if !isMap || !hasOnlyKeys(m, "name", "newName", "newTag", "digest") { - return nil, false - } - entry := ImageOverride{Source: source, Index: i} - var ok bool - if entry.Name, ok = requiredString(m, "name"); !ok { - return nil, false - } - if entry.NewName, entry.HasNewName, ok = optionalString(m, "newName"); !ok { - return nil, false - } - if entry.NewTag, entry.HasNewTag, ok = optionalString(m, "newTag"); !ok { - return nil, false - } - if entry.Digest, entry.HasDigest, ok = optionalString(m, "digest"); !ok { - return nil, false - } - out = append(out, entry) - } - return out, true -} - -// parseReplicaOverrides parses the replicas: field of a kustomization. ok is -// false when the field is present but malformed (see parseImageOverrides). -func parseReplicaOverrides(raw map[string]interface{}, source string) ([]ReplicaOverride, bool) { - v, present := raw["replicas"] - if !present || isEmptyValue(v) { - return nil, true - } - list, isList := v.([]interface{}) - if !isList { - return nil, false - } - out := make([]ReplicaOverride, 0, len(list)) - for i, item := range list { - m, isMap := item.(map[string]interface{}) - if !isMap || !hasOnlyKeys(m, "name", "count") { - return nil, false - } - entry := ReplicaOverride{Source: source, Index: i} - var ok bool - if entry.Name, ok = requiredString(m, "name"); !ok { - return nil, false - } - if entry.Count, ok = integerField(m, "count"); !ok || entry.Count < 0 { - return nil, false - } - out = append(out, entry) - } - return out, true -} - -func requiredString(m map[string]interface{}, key string) (string, bool) { - s, isStr := m[key].(string) - if !isStr || strings.TrimSpace(s) == "" { - return "", false - } - return s, true -} - -// optionalString reads an optional string key, returning (value, present, ok): -// present reports whether the key exists, ok is false for a non-string or blank -// value (declared-but-empty is a broken transform, not an unset one). -func optionalString(m map[string]interface{}, key string) (string, bool, bool) { - v, exists := m[key] - if !exists { - return "", false, true - } - s, isStr := v.(string) - if !isStr || strings.TrimSpace(s) == "" { - return "", false, false - } - return s, true, true -} - -// integerField reads a whole-number field. sigs.k8s.io/yaml decodes YAML numbers -// as float64 (via JSON), so an integral float is accepted; anything else is not. -func integerField(m map[string]interface{}, key string) (int64, bool) { - switch n := m[key].(type) { - case float64: - if n != math.Trunc(n) { - return 0, false - } - return int64(n), true - case int: - return int64(n), true - case int64: - return n, true - default: - return 0, false - } -} - // overrideAssignment collects, per resource file, the distinct override chains // the render roots reach it with. Exactly one distinct chain attaches its // flattened overrides; more than one (with any overrides at stake) is the diff --git a/internal/manifestanalyzer/overrides_projection.go b/internal/manifestanalyzer/overrides_projection.go index 5a2446485..4f96ed02c 100644 --- a/internal/manifestanalyzer/overrides_projection.go +++ b/internal/manifestanalyzer/overrides_projection.go @@ -4,6 +4,7 @@ package manifestanalyzer import ( "fmt" + "math" "sort" "strconv" "strings" @@ -453,12 +454,33 @@ func ReplicaCountEdit(dm *DocumentModel, count int64) (OverrideEdit, bool) { }, true } +// sourceReplicaCount reads spec.replicas off a source KRM document. This is a +// manifest field, not kustomize configuration — kustomize's types cover the +// kustomization.yaml, not the documents it renders — so the decoding lives here. +// sigs.k8s.io/yaml decodes YAML numbers as float64 (via JSON), so an integral +// float is accepted; anything else is not a replica count. +func sourceReplicaCount(spec map[string]interface{}) (int64, bool) { + switch n := spec["replicas"].(type) { + case float64: + if n != math.Trunc(n) { + return 0, false + } + return int64(n), true + case int: + return int64(n), true + case int64: + return n, true + default: + return 0, false + } +} + // restoreSourceReplicas puts spec.replicas back to the source document's form on // the desired copy: the source's own value when it has one, absent when the // source omits it (the transformer supplies the field either way). func restoreSourceReplicas(gitRaw map[string]interface{}, out *unstructured.Unstructured) { spec, _ := gitRaw["spec"].(map[string]interface{}) - if src, ok := integerField(spec, "replicas"); ok { + if src, ok := sourceReplicaCount(spec); ok { _ = unstructured.SetNestedField(out.Object, src, "spec", "replicas") return } diff --git a/internal/manifestanalyzer/overrides_test.go b/internal/manifestanalyzer/overrides_test.go index 12d5d1bcf..468e032c8 100644 --- a/internal/manifestanalyzer/overrides_test.go +++ b/internal/manifestanalyzer/overrides_test.go @@ -127,9 +127,14 @@ func TestKustomizeOverridesNestedBaseIsNotARoot(t *testing.T) { } // TestKustomizationOverrideParsing pins the malformed-overrides boundary: a -// kustomization whose images:/replicas: cannot be parsed is unsupported (it -// would fail kustomize build, and we can no longer vouch for the render), while -// well-formed entries keep the folder supported. +// kustomization whose images:/replicas: kustomize itself cannot decode is +// unsupported (it would fail kustomize build, and we can no longer vouch for the +// render), while entries kustomize accepts keep the folder supported. +// +// Three of these cases changed when the hand-written key check was replaced by +// kustomize's own type, and each was verified against a real `kustomize build`: +// we now agree with the renderer where we previously did not. The two marked +// "kustomize honours" were refused before — folders Flux renders happily. func TestKustomizationOverrideParsing(t *testing.T) { cases := []struct { name string @@ -139,16 +144,42 @@ func TestKustomizationOverrideParsing(t *testing.T) { {"well-formed images", "images:\n - name: a/b\n newTag: \"1.2\"\n", false}, {"well-formed replicas", "replicas:\n - name: web\n count: 3\n", false}, {"empty images list", "images: []\n", false}, + // kustomize decodes into a typed struct via JSON, so a key differing only in + // case still binds: `newtag` sets NewTag, and the build applies it. Refusing + // it would refuse a folder that renders. + {"kustomize honours a case-variant key", "images:\n - name: a/b\n newtag: \"1.2\"\n", false}, + // An empty component is not a declared-but-broken transform; kustomize skips + // it and applies the rest of the entry. + { + "kustomize honours a blank newName", + "images:\n - name: a/b\n newName: \"\"\n newTag: \"1.2\"\n", + false, + }, {"images not a list", "images: oops\n", true}, {"images entry not a map", "images:\n - just-a-string\n", true}, {"images entry missing name", "images:\n - newTag: \"1.2\"\n", true}, - {"images unknown key", "images:\n - name: a/b\n newtag: \"1.2\"\n", true}, + {"images entry genuinely unknown key", "images:\n - name: a/b\n bogus: \"1.2\"\n", true}, + // tagSuffix is a real kustomize field we do not model. Decoding it would + // silently ignore a transform kustomize applies, so it refuses the folder. + {"images tagSuffix is not modelled", "images:\n - name: a/b\n tagSuffix: -rc1\n", true}, {"non-string newTag", "images:\n - name: a/b\n newTag: 1.29\n", true}, - {"blank newName", "images:\n - name: a/b\n newName: \"\"\n", true}, {"replicas count string", "replicas:\n - name: web\n count: \"3\"\n", true}, {"replicas count fractional", "replicas:\n - name: web\n count: 2.5\n", true}, {"replicas count negative", "replicas:\n - name: web\n count: -1\n", true}, {"replicas unknown key", "replicas:\n - name: web\n count: 3\n kind: Deployment\n", true}, + // Holes the hand-written deny-list had: neither was on it, and both change + // the render. They are refused now because they are not in the modelled set. + { + "vars", + "vars:\n - name: NS\n objref:\n kind: Service\n name: web\n apiVersion: v1\n", + true, + }, + {"validators (plugin code)", "validators:\n - check.yaml\n", true}, + // imageTags is the deprecated spelling of images, and FixKustomization folds + // it into images exactly as the builder does — so it is supported, and its + // override is seen, where before it was silently ignored. + {"imageTags is normalised into images", "imageTags:\n - name: a/b\n newTag: \"1.2\"\n", false}, + {"bases is normalised into resources", "bases:\n - ../base\n", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index f75343be3..ab980ca08 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -12,8 +12,6 @@ import ( "sort" "strings" - "sigs.k8s.io/yaml" - "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" ) @@ -448,41 +446,14 @@ func refusedStructuralDetail(content []byte) string { if len(features) == 0 { return "kustomization uses an unsupported feature the operator cannot map back to editable source" } - return "kustomization uses unsupported feature(s): " + strings.Join(features, ", ") -} - -// unsupportedKustomizeFeatures returns the sorted, de-duplicated unsupported features a -// kustomization declares: the identity/generator transformers, remote bases, and -// malformed images/replicas. It shares unsupportedKustomizeFeatureKeys with the -// acceptance gate so the two never drift. -func unsupportedKustomizeFeatures(content []byte) []string { - raw := map[string]interface{}{} - if err := yaml.Unmarshal(content, &raw); err != nil { - return []string{"unparseable"} - } - seen := map[string]struct{}{} - add := func(f string) { seen[f] = struct{}{} } - for _, key := range unsupportedKustomizeFeatureKeys() { - if v, ok := raw[key]; ok && !isEmptyValue(v) { - add(key) - } - } - resources := append(stringList(raw, "resources"), stringList(raw, "bases")...) - if hasRemoteResource(resources) { - add("remote-base") - } - if _, ok := parseImageOverrides(raw, ""); !ok { - add("malformed-images") - } - if _, ok := parseReplicaOverrides(raw, ""); !ok { - add("malformed-replicas") + detail := "kustomization uses unsupported feature(s): " + strings.Join(features, ", ") + // "unparseable" on its own says nothing a user can act on. kustomize's decoder + // knows exactly what is wrong — that a resources: is a string, or that the file + // is a Flux Kustomization CR rather than a build file — so quote it. + if err := kustomizationDecodeError(content); err != "" { + detail += " (" + err + ")" } - out := make([]string, 0, len(seen)) - for f := range seen { - out = append(out, f) - } - sort.Strings(out) - return out + return detail } // renderRootNamespace resolves the namespace a render root renders under: the diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index 45330de92..d43546f17 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -11,7 +11,6 @@ import ( "strings" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/yaml" "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" "github.com/ConfigButler/gitops-reverser/internal/types" @@ -758,83 +757,16 @@ func collapseAssignments(nsByFile map[string]map[string]string) map[string]names return out } -// parseKustomizations reads every kustomization.yaml into a kustomizationDoc keyed by -// its directory. An unparseable kustomization, or one using an unsupported feature, is -// kept but marked unsupported so it never acts as a namespace source. -func parseKustomizations(files []manifestedit.FileContent) map[string]*kustomizationDoc { - out := map[string]*kustomizationDoc{} - for _, f := range files { - if !isKustomizationFile(f.Path) { - continue - } - doc := &kustomizationDoc{path: filepathToSlash(f.Path)} - raw := map[string]interface{}{} - if err := yaml.Unmarshal(f.Content, &raw); err != nil { - doc.unsupported = true - out[slashDir(f.Path)] = doc - continue - } - doc.namespace = strings.TrimSpace(stringField(raw, "namespace")) - doc.resources = append(stringList(raw, "resources"), stringList(raw, "bases")...) - images, imagesOK := parseImageOverrides(raw, doc.path) - replicas, replicasOK := parseReplicaOverrides(raw, doc.path) - doc.images, doc.replicas = images, replicas - doc.unsupported = hasUnsupportedKustomizeFeature(raw) || hasRemoteResource(doc.resources) || - !imagesOK || !replicasOK - out[slashDir(f.Path)] = doc - } - return out -} - -// kustomizationUsesUnsupportedFeature reports whether a kustomization.yaml's bytes use a -// feature outside the supported contextual-namespace subset — the same predicate that -// disqualifies it as a namespace source (parseKustomizations), reused by the acceptance -// gate to refuse the folder at the retention site. An unparseable kustomization is treated -// as unsupported: if we cannot read it, we cannot vouch for what it produces. -func kustomizationUsesUnsupportedFeature(content []byte) bool { - raw := map[string]interface{}{} - if err := yaml.Unmarshal(content, &raw); err != nil { - return true - } - resources := append(stringList(raw, "resources"), stringList(raw, "bases")...) - _, imagesOK := parseImageOverrides(raw, "") - _, replicasOK := parseReplicaOverrides(raw, "") - return hasUnsupportedKustomizeFeature(raw) || hasRemoteResource(resources) || !imagesOK || !replicasOK -} - -// unsupportedKustomizeFeatureKeys returns the kustomization fields that create resources -// or mutate resource identity (name/namespace) in ways the contextual-namespace writer -// cannot map back to an editable source document. Their presence disqualifies a -// kustomization as a namespace source. It is the single source of truth for both the -// boolean gate (hasUnsupportedKustomizeFeature) and the repo scan's per-feature -// refusal detail (unsupportedKustomizeFeatures). It returns a fresh slice on every call, -// so no shared state can be mutated. -func unsupportedKustomizeFeatureKeys() []string { - return []string{ - "generators", "configMapGenerator", "secretGenerator", - "helmCharts", "helmGlobals", "helmChartInflationGenerator", - "patches", "patchesStrategicMerge", "patchesJson6902", - "replacements", "components", "transformers", - "configurations", "openapi", "crds", - "namePrefix", "nameSuffix", - } -} - -// hasUnsupportedKustomizeFeature reports whether a kustomization uses a field that -// creates resources or mutates resource identity (name/namespace) in ways the -// contextual-namespace writer cannot map back to an editable source document. Their -// presence disqualifies a kustomization as a namespace source; benign transformers -// (labels, annotations) do not. images/replicas are parsed separately (overrides.go) -// and disqualify only when malformed. -func hasUnsupportedKustomizeFeature(raw map[string]interface{}) bool { - for _, key := range unsupportedKustomizeFeatureKeys() { - if v, ok := raw[key]; ok && !isEmptyValue(v) { - return true - } - } - return false -} - +// hasRemoteResource reports whether any resources/bases entry points outside this +// repository. It is the one piece of kustomize semantics the operator must keep +// owning rather than delegate to the library. +// +// kustomize resolves a remote base by shelling out to `git fetch`, and it does so +// under LoadRestrictionsRootOnly and under an in-memory filesystem alike (both +// measured). No build option turns that off. Detecting a remote entry ourselves, +// and refusing before any build is invoked, is therefore what keeps "the operator +// never fetches a remote base" true — see +// docs/design/support-boundary/kustomize-support-boundary.md §7. func hasRemoteResource(entries []string) bool { for _, e := range entries { if isRemoteResource(e) { @@ -859,44 +791,6 @@ func isRemoteResource(entry string) bool { return false } -func stringField(raw map[string]interface{}, key string) string { - if v, ok := raw[key].(string); ok { - return v - } - return "" -} - -func stringList(raw map[string]interface{}, key string) []string { - v, ok := raw[key].([]interface{}) - if !ok { - return nil - } - out := make([]string, 0, len(v)) - for _, e := range v { - if s, ok := e.(string); ok { - if s = strings.TrimSpace(s); s != "" { - out = append(out, s) - } - } - } - return out -} - -func isEmptyValue(v interface{}) bool { - switch t := v.(type) { - case nil: - return true - case string: - return strings.TrimSpace(t) == "" - case []interface{}: - return len(t) == 0 - case map[string]interface{}: - return len(t) == 0 - default: - return false - } -} - // cleanJoin resolves a kustomization resources entry against the kustomization's // directory into a clean slash path. A trailing slash is dropped so a directory base // keys the same as its kustomization dir. An entry that escapes the scanned root (via diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index 5c51101a0..e61d06be4 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -41,7 +41,7 @@ Reading rules: | 3-expanded/flux-resourceset-pull-requests | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 4-machine-written/flux-image-automation | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=3 | - | None | | 5-opaque/sops-encrypted | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=2, plain=1 | - | None | -| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | kustomize-single=1, plain=4 | - | non-krm-yaml: ci/.gitlab-ci.yml: YAML is not a Kubernetes manifest
non-krm-yaml: ci/docker-compose.yml: YAML is not a Kubernetes manifest
foreign-file: empty-dir/.gitkeep: foreign file empty-dir/.gitkeep is not a managed manifest; remove it or name it in .gittargetignore
mixed-managed-allowlisted: kustomization.yaml: managed resource kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/apps must not live in the allowlisted build-directive file kustomization.yaml
+8 more | +| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | ## 1-desired-state/argocd-app-of-apps @@ -239,11 +239,11 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 6-hostile/mixed-and-hostile Reported rc `0`. Accepted `3`, refused `2`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `unparseable`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `kustomize-single` | false | `backend` | 0/0/6 | non-krm-yaml: ci/.gitlab-ci.yml: YAML is not a Kubernetes manifest
non-krm-yaml: ci/docker-compose.yml: YAML is not a Kubernetes manifest
foreign-file: empty-dir/.gitkeep: foreign file empty-dir/.gitkeep is not a managed manifest; remove it or name it in .gittargetignore
mixed-managed-allowlisted: kustomization.yaml: managed resource kustomize.toolkit.fluxcd.io/v1/Kustomization/flux-system/apps must not live in the allowlisted build-directive file kustomization.yaml
impure-managed-file: mixed/bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file: mixed/bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file: mixed/deployment.json: foreign file mixed/deployment.json is not a managed manifest; remove it or name it in .gittargetignore
invalid-yaml: templates/deployment.yaml: invalid YAML: yaml: line 21: did not find expected key
non-krm-yaml: values.yaml: YAML is not a Kubernetes manifest | +| `.` | `refused-structural` | false | `backend` | 0/0/6 | refused-structural: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | | `crossplane` | `plain` | true | `-` | 1/1/0 | none | | `kro` | `plain` | true | `-` | 1/1/0 | none | | `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore |