diff --git a/cmd/manifest-analyzer/main.go b/cmd/manifest-analyzer/main.go
index 72281c17..71d64399 100644
--- a/cmd/manifest-analyzer/main.go
+++ b/cmd/manifest-analyzer/main.go
@@ -9,12 +9,17 @@
// Usage:
//
// manifest-analyzer [flags]
+// manifest-analyzer --mode repo-walker [flags]
// manifest-analyzer --mode discovery [flags]
//
-// --mode analyze|scan|discovery what to produce (default analyze)
+// --mode analyze|scan|repo-walker|discovery what to produce (default analyze)
// analyze: the structural report (files, GVK inventory)
// scan: the adoption dry-run (acceptance + plan), the
// shared scan-mode pipeline with no flush
+// repo-walker: the F8 whole-repo onboarding scan — walk
+// every folder, classify candidate GitTargets.
+// Report-only: --policy is not applied (the
+// repo-level refuse gate is deferred)
// discovery: raw Kubernetes API discovery dump
// --format text|json output format (default text)
// --policy report|refuse
@@ -82,9 +87,9 @@ func runWithDiscoveryClientFactory(
) int {
fs := flag.NewFlagSet("manifest-analyzer", flag.ContinueOnError)
fs.SetOutput(stderr)
- mode := fs.String("mode", "analyze", "what to produce: analyze|scan|discovery")
+ mode := fs.String("mode", "analyze", "what to produce: analyze|scan|repo-walker|discovery")
format := fs.String("format", "text", "output format: text|json")
- policy := fs.String("policy", "report", "adoption policy: report|refuse")
+ policy := fs.String("policy", "report", "adoption policy: report|refuse (repo-walker is report-only)")
kubeconfig := fs.String(
"kubeconfig",
"",
@@ -93,6 +98,7 @@ func runWithDiscoveryClientFactory(
contextName := fs.String("context", "", "kubeconfig context for --mode discovery")
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: manifest-analyzer [flags] ")
+ fmt.Fprintln(stderr, " manifest-analyzer --mode repo-walker [flags] ")
fmt.Fprintln(stderr, " manifest-analyzer --mode discovery [flags]")
fs.PrintDefaults()
}
@@ -100,16 +106,7 @@ func runWithDiscoveryClientFactory(
if err := fs.Parse(args); err != nil {
return exitUsage
}
- if *mode != "analyze" && *mode != "scan" && *mode != "discovery" {
- fmt.Fprintf(stderr, "error: unknown mode %q (want analyze|scan|discovery)\n", *mode)
- return exitUsage
- }
- if *format != "text" && *format != "json" {
- fmt.Fprintf(stderr, "error: unknown format %q (want text|json)\n", *format)
- return exitUsage
- }
- if *policy != "report" && *policy != "refuse" {
- fmt.Fprintf(stderr, "error: unknown policy %q (want report|refuse)\n", *policy)
+ if !validChoices(*mode, *format, *policy, stderr) {
return exitUsage
}
if *mode == "discovery" {
@@ -125,11 +122,35 @@ func runWithDiscoveryClientFactory(
fs.Usage()
return exitUsage
}
+ return runDirMode(*mode, fs.Arg(0), *format, *policy, stdout, stderr)
+}
- if *mode == "scan" {
- return runScan(fs.Arg(0), *format, *policy, stdout, stderr)
+// validChoices validates the mode/format/policy enum flags, reporting the first bad one
+// to stderr. Splitting it out of run keeps the top-level dispatch simple.
+func validChoices(mode, format, policy string, stderr io.Writer) bool {
+ switch {
+ case mode != "analyze" && mode != "scan" && mode != "repo-walker" && mode != "discovery":
+ fmt.Fprintf(stderr, "error: unknown mode %q (want analyze|scan|repo-walker|discovery)\n", mode)
+ case format != "text" && format != "json":
+ fmt.Fprintf(stderr, "error: unknown format %q (want text|json)\n", format)
+ case policy != "report" && policy != "refuse":
+ fmt.Fprintf(stderr, "error: unknown policy %q (want report|refuse)\n", policy)
+ default:
+ return true
+ }
+ return false
+}
+
+// runDirMode dispatches the directory-argument modes (everything but discovery).
+func runDirMode(mode, dir, format, policy string, stdout, stderr io.Writer) int {
+ switch mode {
+ case "scan":
+ return runScan(dir, format, policy, stdout, stderr)
+ case "repo-walker":
+ return runRepoWalk(dir, format, stdout, stderr)
+ default:
+ return runAnalyze(dir, format, policy, stdout, stderr)
}
- return runAnalyze(fs.Arg(0), *format, *policy, stdout, stderr)
}
// runAnalyze renders the structural report and applies the refuse policy over its
@@ -240,3 +261,26 @@ func runScan(dir, format, policy string, stdout, stderr io.Writer) int {
}
return exitOK
}
+
+// runRepoWalk runs the F8 whole-repo onboarding scan: walk every folder, enumerate
+// candidate GitTarget subtrees, classify each one's layout and acceptance, and emit the
+// report. It is read-only and needs no cluster. Exit codes stay simple for this cut
+// (exitOK, or exitUsage on an I/O error); the repo-level --policy refuse gate is
+// deferred per the design doc.
+func runRepoWalk(root, format string, stdout, stderr io.Writer) int {
+ rep, err := manifestanalyzer.WalkRepo(context.Background(), root)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+
+ if format == "json" {
+ if err := manifestanalyzer.RenderRepoJSON(stdout, rep); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+ } else {
+ manifestanalyzer.RenderRepoText(stdout, rep)
+ }
+ return exitOK
+}
diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go
index abc8cc3c..c1a5d308 100644
--- a/cmd/manifest-analyzer/main_test.go
+++ b/cmd/manifest-analyzer/main_test.go
@@ -137,6 +137,67 @@ func TestRun_ScanJSON(t *testing.T) {
}
}
+// repoWalkFixture builds a tiny two-folder repo: a plain KRM app folder and a kustomize
+// overlay reaching an out-of-subtree base, so repo-walker reports both an accepted plain
+// candidate and a refused kustomize-overlay one.
+func repoWalkFixture(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ write(t, mkdir(t, root, "plain"), "deploy.yaml", deployYAML)
+ base := mkdir(t, root, "base")
+ write(t, base, "kustomization.yaml", "resources:\n- deploy.yaml\n")
+ write(t, base, "deploy.yaml", deployYAML)
+ overlay := mkdir(t, root, filepath.Join("overlays", "test"))
+ write(t, overlay, "kustomization.yaml", "namespace: test\nresources:\n- ../../base\n")
+ return root
+}
+
+// mkdir creates dir/sub (and parents) and returns the full path.
+func mkdir(t *testing.T, dir, sub string) string {
+ t.Helper()
+ full := filepath.Join(dir, sub)
+ if err := os.MkdirAll(full, 0o750); err != nil {
+ t.Fatalf("mkdir %s: %v", full, err)
+ }
+ return full
+}
+
+func TestRun_RepoWalkText(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ if code := run([]string{"--mode", "repo-walker", repoWalkFixture(t)}, &out, &errBuf); code != 0 {
+ t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String())
+ }
+ for _, want := range []string{"candidates:", "kustomize-overlay", "overlay-fan-out-needs-f2"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("repo-walker text missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
+func TestRun_RepoWalkJSON(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ args := []string{"--mode", "repo-walker", "--format", "json", repoWalkFixture(t)}
+ if code := run(args, &out, &errBuf); code != 0 {
+ t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String())
+ }
+ var parsed struct {
+ Candidates []struct {
+ Path string `json:"path"`
+ Layout string `json:"layout"`
+ } `json:"candidates"`
+ Summary map[string]any `json:"summary"`
+ }
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("repo-walker JSON invalid: %v\n%s", err, out.String())
+ }
+ if len(parsed.Candidates) != 2 {
+ t.Fatalf("want 2 candidates, got %d: %s", len(parsed.Candidates), out.String())
+ }
+ if parsed.Summary == nil {
+ t.Errorf("repo-walker JSON missing summary: %s", out.String())
+ }
+}
+
func TestRun_DiscoveryJSON(t *testing.T) {
client := fakeDiscovery{
groups: []*metav1.APIGroup{
@@ -214,6 +275,8 @@ func TestRun_Errors(t *testing.T) {
{"discovery rejects dir", []string{"--mode", "discovery", "x"}, 2},
{"missing dir", []string{filepath.Join("definitely", "missing", "dir")}, 2},
{"scan missing dir", []string{"--mode", "scan", filepath.Join("definitely", "missing")}, 2},
+ {"repo-walker missing dir", []string{"--mode", "repo-walker", filepath.Join("definitely", "missing")}, 2},
+ {"repo-walker no args", []string{"--mode", "repo-walker"}, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
diff --git a/docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md b/docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md
index 6c2a79c9..1019934e 100644
--- a/docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md
+++ b/docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md
@@ -1,8 +1,10 @@
# F8: Repo discovery and onboarding scan (CLI / product-layer)
-> Status: designed, unbuilt — CLI/onboarding tooling, not an operator write-model
-> feature. The engine it needs is already shipped; this is a new entry point plus
-> a report contract.
+> Status: designed; **first cut shipped 2026-07-09** (branch `feat/gitops-api-f8`) —
+> CLI/onboarding tooling, not an operator write-model feature. The engine it needs is
+> already shipped; this adds a new entry point plus a report contract. See
+> [First cut (shipped)](#first-cut-shipped-2026-07-09) for what actually landed and where
+> it refines the sketch below.
> Captured: 2026-07-09
> Related:
> [README.md](README.md),
@@ -56,6 +58,25 @@ layer — exactly the division already recorded in
| **`manifest-analyzer` CLI + `internal/manifestanalyzer` library** | folder walk, acceptance gate, placement, refusal reasons — already shared with the operator | **Yes.** New repo-wide entry point + report. Read-only, writes nothing, needs no cluster. |
| **Product / GitHub App layer** | repo access, CR generation, PR creation, session/branch policy | Consumes the report → generates `GitProvider`/`GitTarget`/`WatchRule` → opens PRs. Still no operator Git-host knowledge. |
+### Why walking a whole repo does not re-open Pandora's box: discovery ≠ support
+
+Scanning every folder sounds like it re-opens every kustomize question. It does not,
+because **discovery is broad and read-only while the write contract stays narrow and
+principled** — they are deliberately different jobs. repo-walker classifies what a folder
+*is*; it never widens what the operator will *write*. The write boundary is not a
+hand-maintained feature allowlist but a **consequence of one rule**: every edit must have a
+single writable Git destination that round-trips
+([boundary §1–§4](kustomize-support-boundary-and-product-model.md)). Anything that creates
+resources or mutates identity (generators, patches, `components`, `namePrefix`/`nameSuffix`,
+Helm inflation, `replacements`) has no source document to write an edit into, so it is
+refused *structurally* — not "unimplemented," but non-invertible.
+
+That is the escape from the "support every kustomize feature" trap: a repo may use
+constructs the operator will never write, and discovery's only job is to **say so, honestly,
+per folder**. You onboard a kustomize repo without supporting kustomize writes. The refusal
+reasons are the product's honesty surface — "here is exactly what we will not touch, and
+why" — not a gap to apologise for.
+
## The engine is already there
The discovery pass is mostly reuse. The CLI and the live reconciler already
@@ -126,29 +147,51 @@ truths — they diverge during the F2 gap (see below).
| `refused-fleet-root` | `clusters/` + `apps/` + `infra/` cluster-root repo (a `GitTarget` points at an app subtree, never a cluster root) | ⛔ out of scope by design |
| `refused-out-of-band` | Namespace/transform injected outside the folder (Flux `postBuild`/`targetNamespace`, Argo Application-level overrides) | ⛔ permanent — round-trip cannot hold |
+> **Shipped reconciliation (first cut).** Two rows above are *not* per-candidate
+> layouts in practice. `refused-fleet-root` is emitted as a **repo-level** signal
+> (`summary.fleetRoot`), because the cluster root is never itself a candidate — leaf app
+> folders still surface as normal candidates. `refused-out-of-band` is **not detected**:
+> a structure-only folder walk cannot see a transform injected by a Flux `Kustomization`
+> or Argo `Application`. Those CRs are themselves KRM documents in the repo, so detecting
+> out-of-band overrides later means parsing the fleet CRs and cross-referencing the app
+> subtree they target — a bounded future capability, already flagged as a "much-later
+> feature" in [boundary §2](kustomize-support-boundary-and-product-model.md). The first
+> cut therefore ships four candidate layouts: `plain`, `kustomize-single`,
+> `kustomize-overlay`, `refused-structural`.
+
The distinction between `overlay-fan-out-needs-f2` and `refused-structural` is
the load-bearing one: the first is a **forward-looking** "not yet" that flips to
accepted when [F2](README.md) render-root scoping lands; the second is the
-permanent boundary. Discovery must never collapse them into one "refused."
+permanent boundary. Discovery must never collapse them into one "refused." **This
+two-bucket split is what keeps the kustomize surface finite:** every construct sorts into
+a **ladder** (a bounded roadmap of layouts we choose to build — plain → single →
+overlay/F2 → …) or a **wall** (structurally non-invertible, refused forever). Neither is
+unbounded work — the ladder is a sequence you climb deliberately; the wall costs nothing
+but a clear reason.
## The report contract
Two renderings, matching the existing `--format text|json` split. JSON is the
-product's interface; per candidate:
+product's interface. The shape below is what the [first cut](#first-cut-shipped-2026-07-09)
+emits, per candidate — **except `proposedGitTarget`, which is the eventual goal and is
+not emitted yet** (CR proposal is deferred). Do not copy `proposedGitTarget` as if it
+were live output. (The block is fenced `jsonc` because it carries an explanatory
+comment — the shipped report is strict JSON.)
-```json
+```jsonc
{
"path": "apps/podinfo/overlays/test",
"layout": "kustomize-overlay",
"acceptedByOperator": false,
"refusalReasons": [
- { "code": "overlay-fan-out-needs-f2", "detail": "base reached by 3 overlay roots; render-root scoping (F2) required" }
+ { "code": "overlay-fan-out-needs-f2", "detail": "base \"apps/podinfo/base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping (F2) required" }
],
"renderRoot": true,
"readScope": ["apps/podinfo/base"],
"inferredNamespace": "podinfo-test",
- "documents": { "krm": 4, "nonKrm": 0 },
- "proposedGitTarget": {
+ "resources": { "rendered": 4, "editable": 0, "nonKrm": 0 },
+
+ "proposedGitTarget": { // FUTURE — not emitted by the first cut
"spec": {
"providerRef": { "name": "" },
"branch": "",
@@ -158,33 +201,105 @@ product's interface; per candidate:
}
```
-Plus a repo-level summary: candidate counts by layout, accepted vs. refused,
-overlap conflicts, and the set of unsupported constructs seen (so the product
-can say "this repo uses Helm inflation in `infra/`, which we don't manage").
+`resources` replaces the earlier `documents: { krm, nonKrm }` sketch with the shipped
+`{ rendered, editable, nonKrm }` split (see [First cut](#first-cut-shipped-2026-07-09) for
+why rendered and editable diverge for overlays). Plus a repo-level summary:
+`candidatesByLayout`, `accepted`/`refused` counts, `overlapConflicts`, `fleetRoot`, and
+`unsupportedConstructs` (so the product can say "this repo uses Helm inflation in
+`infra/`, which we don't manage").
**Exit codes** reuse the CLI convention (`exitOK=0`, `exitRefused=1`,
-`exitUsage=2`). In repo mode, `--policy report` always exits 0 (pure report);
-`--policy refuse` exits 1 when **zero** candidates are acceptable — i.e. "is this
-repo onboardable at all?", the useful CI/onboarding gate. (Per-candidate refusals
-are data, not a failure, under `report`.)
+`exitUsage=2`). The eventual design: `--policy report` always exits 0 (pure report);
+`--policy refuse` exits 1 when **zero** candidates are acceptable — i.e. "is this repo
+onboardable at all?", the useful CI/onboarding gate. **The first cut does not yet apply
+`--policy` in repo-walker mode** — it always exits 0 (or 2 on an I/O error); the refuse
+gate is deferred.
## CLI surface
-A new mode on `manifest-analyzer`, e.g.:
+A new mode on `manifest-analyzer` (shipped form):
```
-manifest-analyzer --mode repo --format json [--policy report|refuse]
+manifest-analyzer --mode repo-walker --format json
```
Naming note: the existing `--mode discovery` is **Kubernetes API discovery** (a
-served-GVR dump against a kubeconfig), unrelated to repo discovery. To avoid
-confusion this doc uses `--mode repo`; a follow-up should consider renaming the
-existing mode to `--mode api-discovery` so "discovery" is not overloaded.
+served-GVR dump against a kubeconfig), unrelated to repo discovery. The first cut
+named the new mode `repo-walker` (not `repo`) to stay clear of it; renaming the
+existing mode to `--mode api-discovery` so "discovery" is not overloaded remains a
+possible follow-up. `--policy` is accepted for symmetry but the repo-level refuse gate
+is deferred (see [First cut](#first-cut-shipped-2026-07-09)).
The report should also be exposed as a **library function** (the product is
likely part of the same Go binary family), with the CLI mode as the thin wrapper
+ CI gate. Library-first keeps the product from shelling out and re-parsing JSON.
+## First cut (shipped 2026-07-09)
+
+A lean first slice landed on branch `feat/gitops-api-f8`. It **reports**; it does not
+yet **propose**. Library-first, as recommended above.
+
+**Surface.** `WalkRepo(ctx, root) (RepoReport, error)` in
+`internal/manifestanalyzer/repowalk.go`, with `--mode repo-walker `
+(`--format text|json`) as a thin CLI wrapper. Read-only, no cluster, symlinks never
+followed — the same posture as `ScanDir`, over the whole tree. It reuses `collectFiles`,
+`parseKustomizations`/`renderRoots`, the `Scan`/acceptance gate, and mirrors
+`gittarget_path_overlap` for nesting.
+
+**Report shape as shipped**, refining the sketch above. Per candidate: `path`, `layout`,
+`acceptedByOperator`, `refusalReasons[]` (`{code, detail}`), `renderRoot`, `readScope[]`,
+`inferredNamespace`, `resources`, `overlapsWith[]`.
+
+- **`resources` splits the KRM two ways** instead of the sketch's single `documents.krm`:
+ `{ rendered, editable, nonKrm }`. `rendered` counts the documents the candidate actually
+ renders — for a kustomize candidate that is the **resources graph** (files the
+ kustomization lists, following bases), *not* parked YAML a kustomization does not
+ reference; for a plain folder it is the whole directory. `editable` counts only the
+ rendered source physically in the candidate's own subtree. They are equal for plain /
+ self-contained kustomize candidates and **diverge for an overlay** (e.g. `rendered: 2,
+ editable: 0`) — the overlay renders a base it cannot yet edit, which makes the F2 gap
+ legible at a glance.
+- Repo summary: `candidatesByLayout`, `accepted`, `refused`, `overlapConflicts[]`,
+ `fleetRoot`, `unsupportedConstructs[]`.
+- `proposedGitTarget` is **not** emitted yet — CR proposal is deferred.
+
+**Fidelity to the operator.** `acceptedByOperator` runs the live writer's own gate per
+candidate — `Scan` with `WriterAllowlist` (kustomize build directives **plus** the
+operator's `.sops.yaml` bootstrap config), so a folder the writer would adopt is not
+falsely reported refused. A refused plain / self-contained candidate carries the gate's
+issues as `refusalReasons` (`{code, detail}` — duplicate identity, non-KRM YAML, a foreign
+file, an unsupported nested kustomization, …), never a bare `false`. The structural gate
+refuses `openapi`/`crds` alongside `configurations`, matching the
+[support boundary §1](kustomize-support-boundary-and-product-model.md).
+
+**Classification findings.**
+
+- The overlay discriminator that trips `overlay-fan-out-needs-f2` is precisely **the base
+ escaping the render root's own subtree** (`../../base` resolves outside the candidate) —
+ the very fact the operator's hard subtree-scope hits. Fan-out (how many overlay roots
+ share the base) is carried as informative `detail`, not the trigger. A base nested
+ *within* the subtree keeps the root `kustomize-single` (accepted today).
+- `refused-fleet-root` ships as a repo-summary flag (`fleetRoot: true`), not a
+ per-candidate layout: the repo root is never itself a candidate, and leaf app folders
+ still surface normally. `refused-out-of-band` is not detected yet — a structure-only
+ scan sees no out-of-band namespace/transform signal to refuse on.
+- Namespace inference is purely structural (no cluster): a render root's `namespace:`
+ transformer, else the single explicit `metadata.namespace` on a plain folder's
+ documents (none/ambiguous → empty).
+
+**Deferred** (unchanged from the non-goals): `proposedGitTarget`/CR + `WatchRule`
+generation, the `--mode discovery` rename, and the repo-level `--policy refuse` exit gate
+(exit codes stay simple — `exitOK`, or `exitUsage` on an I/O error).
+
+**Corpus.** Golden reports under
+`internal/manifestanalyzer/testdata/repo-walker/{supported,unsupported}//` with a
+sibling `.golden.json` each (regenerate with `UPDATE_GOLDEN=1`), covering
+plain-per-env, kustomize-single, base+overlays, HelmRelease document vs. helm inflation,
+unsupported kustomize (incl. `openapi`/`crds`), fleet-root, overlapping, no-krm, and
+regression fixtures for the counting/acceptance edges — an overlay whose base pulls in a
+nested base (deduped `rendered`), an overlay base holding parked YAML (excluded from
+`rendered`), and a plain folder the gate refuses (issues surfaced as `refusalReasons`).
+
## Boundaries and non-goals
- **Not deploy discovery.** We are the reverse of Argo/Flux; this finds *editing*
@@ -234,10 +349,26 @@ likely part of the same Go binary family), with the CLI mode as the thin wrapper
## Open questions
-1. **Mode naming / the `discovery` collision** — rename the existing K8s-API mode
- to `api-discovery`?
-2. **Stop at `GitTarget`, or also propose `WatchRule`s?**
+1. **Mode naming / the `discovery` collision** — _partly settled:_ the new mode shipped
+ as `repo-walker` (no collision). Whether to also rename the K8s-API mode to
+ `api-discovery` is still open.
+2. **Stop at `GitTarget`, or also propose `WatchRule`s?** — moot for now: the first cut
+ proposes neither (reports only).
3. **Read-scope depth for overlays pre-F2** — how far up the tree to follow
- `../../base` when the operator would still refuse the folder.
-4. **Library vs. CLI as the product's integration point** — recommend
- library-first, CLI as wrapper + CI gate.
+ `../../base` when the operator would still refuse the folder. _First cut:_ `readScope`
+ is the **minimal** set of out-of-subtree base directories (a base nested under another
+ reached base is folded into its parent, so the rendered-document count never
+ double-counts a shared nested base). How many levels the product should *display*
+ remains a presentation choice.
+4. **Library vs. CLI as the product's integration point** — _settled:_ library-first
+ (`WalkRepo`), CLI (`--mode repo-walker`) as the thin wrapper + CI gate.
+5. **`overlay-fan-out-needs-f2` naming** — the reason code reads as if *fan-out* (a base
+ shared by several overlays) is the trigger, but the real trigger is a base **escaping
+ the render root's own subtree**, true even at fan-out = 1. The first cut carries the
+ shared-root count as descriptive `detail`, not as the condition. Open: rename to
+ something like `overlay-base-out-of-subtree-needs-f2`, or keep the product-facing code
+ and rely on the detail. _Not settled._
+6. **Repo-level `--policy refuse` gate** — the eventual "zero acceptable candidates → exit
+ 1" onboarding gate (see [Exit codes](#the-report-contract)) is **deferred**: the first
+ cut always exits 0 (or 2 on an I/O error) and `--policy` is not applied in repo-walker
+ mode. Open: implement it as the CI/onboarding gate when the product needs it.
diff --git a/docs/design/gitops-api/kustomize-support-boundary-and-product-model.md b/docs/design/gitops-api/kustomize-support-boundary-and-product-model.md
index 710134da..72ec84b8 100644
--- a/docs/design/gitops-api/kustomize-support-boundary-and-product-model.md
+++ b/docs/design/gitops-api/kustomize-support-boundary-and-product-model.md
@@ -114,6 +114,14 @@ Explicitly **out of scope**, and worth saying in user docs:
controller CRs in-cluster to learn those transforms is a possible
much-later feature, not part of this boundary.)
+This allowlist is a **write** contract, not a discovery one. The read-only
+onboarding scanner ([F8 repo-walker](f8-repo-discovery-and-onboarding-scan.md))
+walks a whole repo and classifies each folder against *exactly* this boundary
+— `plain` / `kustomize-single` (accepted), `kustomize-overlay` (forward-looking,
+needs F2), `refused-structural` (the permanent wall above) — without widening
+what the operator writes. Discovering broadly and writing narrowly is what lets
+onboarding meet real GitOps repos without eroding round-trippability.
+
## 3. Why overlays are a different animal
Fan-out is exactly why the governing rule is "one writable destination per
diff --git a/internal/manifestanalyzer/repowalk.go b/internal/manifestanalyzer/repowalk.go
new file mode 100644
index 00000000..99cb3a55
--- /dev/null
+++ b/internal/manifestanalyzer/repowalk.go
@@ -0,0 +1,759 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package manifestanalyzer
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "sort"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+)
+
+// This file implements the first cut of F8 (repo discovery / onboarding scan),
+// designed in docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md. It
+// walks a WHOLE repository once (today's Scan/ScanDir is subtree-only), enumerates
+// candidate GitTarget subtrees, classifies each one's layout, runs the same
+// acceptance gate the operator runs, and emits a machine-readable report.
+//
+// It is deliberately reuse-heavy: the repo walk is collectFiles, the kustomization
+// graph and render roots are parseKustomizations/renderRoots, the adoption decision
+// is Scan/Accept, and overlap detection mirrors the controller's
+// gittarget_path_overlap. What is new here is the whole-repo pass, candidate
+// enumeration, layout classification, and the report contract.
+//
+// Scope of this cut: it REPORTS, it does not PROPOSE. There is no GitTarget/WatchRule
+// generation yet, no rename of the existing --mode discovery, and no repo-level
+// --policy refuse exit semantics — see the design doc's "explicitly defer" list.
+
+// Layout is the structural shape of a candidate subtree. Layout and acceptedByOperator
+// are two distinct truths that diverge during the F2 gap: a kustomize-overlay has a
+// well-understood layout yet is not accepted until render-root scoping (F2) lands.
+type Layout string
+
+const (
+ // LayoutPlain is a directory of raw KRM documents with explicit namespaces and no
+ // kustomization — the "one plain folder per environment" launch layout. Accepted.
+ LayoutPlain Layout = "plain"
+ // LayoutKustomizeSingle is a self-contained render root: one kustomization whose
+ // resources graph stays within its own subtree (local files, or a base directory
+ // nested underneath it). Accepted — the operator can render the whole subtree.
+ LayoutKustomizeSingle Layout = "kustomize-single"
+ // LayoutKustomizeOverlay is a render root that reaches a base kustomization OUTSIDE
+ // its own subtree (the classic base/ + overlays/{env} shape reached via ../../base).
+ // The operator hard-scopes to one subtree and cannot see the base, so it is refused
+ // today with the forward-looking overlay-fan-out-needs-f2 reason — it flips to
+ // accepted when F2 render-root scoping ships.
+ LayoutKustomizeOverlay Layout = "kustomize-overlay"
+ // LayoutRefusedStructural is a render root whose kustomization uses a feature the
+ // contextual-namespace writer cannot map back to editable source (helm inflation,
+ // generators, patches, components, name(pre|suf)fix, remote bases, malformed
+ // images/replicas). This is the permanent support boundary, never a "not yet".
+ LayoutRefusedStructural Layout = "refused-structural"
+)
+
+// Refusal reason codes. The distinction between the two is load-bearing:
+// ReasonOverlayFanOutNeedsF2 is a forward-looking "not yet" that flips to accepted
+// when F2 lands; ReasonRefusedStructural is the permanent boundary. Discovery must
+// never collapse them into one "refused".
+const (
+ ReasonOverlayFanOutNeedsF2 = "overlay-fan-out-needs-f2"
+ ReasonRefusedStructural = "refused-structural"
+)
+
+// RefusalReason is one machine-readable reason a candidate is not accepted, with a
+// human detail. A candidate carries none when accepted.
+type RefusalReason struct {
+ Code string `json:"code"`
+ Detail string `json:"detail"`
+}
+
+// ResourceCounts splits the KRM a candidate covers into what it renders versus what it
+// can actually edit. For a plain or self-contained kustomize candidate the two are
+// equal; for an overlay they diverge — rendered counts the documents pulled from the
+// out-of-subtree base, editable counts only the source physically in the candidate's
+// own subtree (zero for a pure overlay), making the F2 gap legible at a glance.
+type ResourceCounts struct {
+ // Rendered is the number of managed KRM documents the candidate renders: its own
+ // subtree plus every base it reads (readScope).
+ Rendered int `json:"rendered"`
+ // Editable is the number of managed KRM documents physically in the candidate's own
+ // subtree — the source the operator would own and write in place.
+ Editable int `json:"editable"`
+ // NonKRM is the number of non-KRM YAML documents and foreign (non-YAML/symlink)
+ // entries in the candidate's own subtree. Retained build directives (kustomization
+ // files) are neither KRM nor NonKRM and are not counted.
+ NonKRM int `json:"nonKrm"`
+}
+
+// RepoCandidate is one subtree the product could turn into a GitTarget, with its
+// layout, current operator acceptance, and the facts a product layer needs to decide.
+// This cut reports these; it proposes no GitTarget/WatchRule.
+type RepoCandidate struct {
+ // Path is the candidate directory, slash-separated and relative to the repo root.
+ Path string `json:"path"`
+ // Layout is the candidate's structural shape.
+ Layout Layout `json:"layout"`
+ // AcceptedByOperator reports whether the operator would adopt this subtree today.
+ AcceptedByOperator bool `json:"acceptedByOperator"`
+ // RefusalReasons explains a non-acceptance; empty when accepted.
+ RefusalReasons []RefusalReason `json:"refusalReasons,omitempty"`
+ // RenderRoot reports whether the candidate is a kustomize render root (versus a
+ // plain KRM folder).
+ RenderRoot bool `json:"renderRoot"`
+ // ReadScope lists the base directories outside this candidate's own subtree that its
+ // kustomization reads. Empty for plain and self-contained candidates.
+ ReadScope []string `json:"readScope,omitempty"`
+ // InferredNamespace is the namespace the candidate resolves to: the kustomization's
+ // namespace transformer for a render root, or the single explicit metadata.namespace
+ // for a plain folder. Empty when none is set or the folder is ambiguous.
+ InferredNamespace string `json:"inferredNamespace,omitempty"`
+ // Resources counts the KRM this candidate covers (rendered vs editable) plus non-KRM.
+ Resources ResourceCounts `json:"resources"`
+ // OverlapsWith lists other candidate paths this one nests with. Two overlapping
+ // candidates can never both be proposed (one-owner-per-folder); the conflict is
+ // reported, not resolved, in this cut.
+ OverlapsWith []string `json:"overlapsWith,omitempty"`
+}
+
+// OverlapConflict records a nesting conflict between two candidates: ancestor strictly
+// contains descendant in the folder tree.
+type OverlapConflict struct {
+ Ancestor string `json:"ancestor"`
+ Descendant string `json:"descendant"`
+}
+
+// RepoSummary is the repo-level roll-up a product uses to describe onboardability.
+type RepoSummary struct {
+ // CandidatesByLayout counts candidates per layout class.
+ CandidatesByLayout map[Layout]int `json:"candidatesByLayout"`
+ // Accepted and Refused count candidates by current operator acceptance.
+ Accepted int `json:"accepted"`
+ Refused int `json:"refused"`
+ // OverlapConflicts lists every nesting conflict between candidates.
+ OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"`
+ // FleetRoot is true when the repo root is a cluster/fleet root (top-level clusters/ +
+ // apps/ + infra/): a GitTarget points at an app subtree, never such a root. The root
+ // is never itself a candidate; leaf folders still surface normally.
+ FleetRoot bool `json:"fleetRoot,omitempty"`
+ // UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize
+ // features seen across refused-structural candidates, so a product can say "this repo
+ // uses Helm inflation, which we don't manage".
+ UnsupportedConstructs []string `json:"unsupportedConstructs,omitempty"`
+}
+
+// RepoReport is the whole-repo discovery report: the machine-readable contract the
+// product layer consumes.
+type RepoReport struct {
+ // Root is the scanned repository root as passed to WalkRepo. It is informational.
+ Root string `json:"root,omitempty"`
+ // Candidates are the enumerated subtrees, sorted by path.
+ Candidates []RepoCandidate `json:"candidates"`
+ // Summary is the repo-level roll-up.
+ Summary RepoSummary `json:"summary"`
+}
+
+// WalkRepo is the F8 whole-repo discovery pass (the library entry point; the CLI
+// --mode repo-walker is a thin wrapper). It is read-only, writes nothing, needs no
+// cluster, and never follows symlinks — the same posture as ScanDir, just over the
+// whole tree rather than one subtree. It verifies root is a directory, then walks
+// os.DirFS(root).
+func WalkRepo(ctx context.Context, root string) (RepoReport, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return RepoReport{}, err
+ }
+ if !info.IsDir() {
+ return RepoReport{}, fmt.Errorf("not a directory: %s", root)
+ }
+ rep := walkRepoFS(ctx, os.DirFS(root))
+ rep.Root = root
+ return rep, nil
+}
+
+// walkRepoFS is WalkRepo over an fs.FS, so it is testable against an in-memory tree.
+func walkRepoFS(ctx context.Context, fsys fs.FS) RepoReport {
+ scan := collectFiles(fsys)
+ kusts := parseKustomizations(scan.YAMLFiles)
+ // Structure-only whole-repo store built with the live writer's allowlist (WriterAllowlist:
+ // kustomization files + the operator's .sops.yaml bootstrap config), so acceptance and the
+ // document counts match what the operator would actually adopt. Acceptance is decided
+ // per-candidate against its own subtree, not from this whole-repo store.
+ store := buildStore(ctx, scan, nil, WriterAllowlist())
+ kustContent := kustomizationContentByDir(scan)
+ ownedFiles := reachedResourceFiles(kusts)
+
+ candidates := make([]RepoCandidate, 0)
+ for _, rootDir := range renderRoots(kusts) {
+ candidates = append(candidates, classifyRenderRoot(ctx, fsys, rootDir, kusts, kustContent, store))
+ }
+ candidates = append(candidates, plainCandidates(ctx, fsys, store, kusts, ownedFiles)...)
+
+ sort.Slice(candidates, func(i, j int) bool { return candidates[i].Path < candidates[j].Path })
+ detectOverlaps(candidates)
+
+ return RepoReport{
+ Candidates: candidates,
+ Summary: summarize(candidates, fsys, kustContent),
+ }
+}
+
+// classifyRenderRoot classifies one kustomize render root into a candidate: refused
+// (unsupported kustomization), a kustomize-overlay reaching an out-of-subtree base, or
+// a self-contained kustomize-single.
+func classifyRenderRoot(
+ ctx context.Context,
+ fsys fs.FS,
+ rootDir string,
+ kusts map[string]*kustomizationDoc,
+ kustContent map[string][]byte,
+ store *ManifestStore,
+) RepoCandidate {
+ c := RepoCandidate{Path: rootDir, RenderRoot: true, InferredNamespace: renderRootNamespace(kusts, rootDir, store)}
+ // rendered/editable count only the documents the kustomization graph actually renders
+ // (its resources: entries), never parked YAML a kustomization does not reference.
+ rendered := reachedResourceFilesFrom(rootDir, kusts)
+
+ if doc := kusts[rootDir]; doc == nil || doc.unsupported {
+ c.Layout = LayoutRefusedStructural
+ c.AcceptedByOperator = false
+ c.RefusalReasons = []RefusalReason{{
+ Code: ReasonRefusedStructural,
+ Detail: refusedStructuralDetail(kustContent[rootDir]),
+ }}
+ c.Resources = countResources(store, rootDir, rendered)
+ return c
+ }
+
+ outsideBases := outOfSubtreeBases(rootDir, kusts)
+ if len(outsideBases) > 0 {
+ c.Layout = LayoutKustomizeOverlay
+ c.AcceptedByOperator = false
+ c.ReadScope = outsideBases
+ c.RefusalReasons = []RefusalReason{{
+ Code: ReasonOverlayFanOutNeedsF2,
+ Detail: overlayFanOutDetail(outsideBases[0], kusts),
+ }}
+ c.Resources = countResources(store, rootDir, rendered)
+ return c
+ }
+
+ // Self-contained render root: run the same gate the operator runs, scoped to the
+ // subtree. A within-subtree base is reachable, so acceptance is truthful here; a gate
+ // refusal (duplicate, non-KRM, foreign, unsupported nested kustomization, …) is
+ // surfaced as refusal reasons rather than a bare false.
+ c.Layout = LayoutKustomizeSingle
+ acc := candidateAcceptance(ctx, fsys, rootDir)
+ c.AcceptedByOperator = acc.Accepted
+ if !acc.Accepted {
+ c.RefusalReasons = issuesToReasons(acc.Issues)
+ }
+ c.Resources = countResources(store, rootDir, rendered)
+ return c
+}
+
+// plainCandidates enumerates plain KRM leaf folders: directories that directly hold a
+// managed KRM document, carry no kustomization, and are not already owned by a
+// kustomization's resources graph (so a base a kustomization renders is not also
+// proposed as a bare folder).
+func plainCandidates(
+ ctx context.Context,
+ fsys fs.FS,
+ store *ManifestStore,
+ kusts map[string]*kustomizationDoc,
+ ownedFiles map[string]struct{},
+) []RepoCandidate {
+ dirs := map[string]struct{}{}
+ for filePath, fm := range store.FilesByPath {
+ if len(fm.Documents) == 0 {
+ continue
+ }
+ dir := slashDir(filePath)
+ if _, isKust := kusts[dir]; isKust {
+ continue // a kustomization directory is a render root, not a plain folder
+ }
+ if _, owned := ownedFiles[filePath]; owned {
+ continue // a resource file some kustomization already renders
+ }
+ dirs[dir] = struct{}{}
+ }
+
+ out := make([]RepoCandidate, 0, len(dirs))
+ for dir := range dirs {
+ acc := candidateAcceptance(ctx, fsys, dir)
+ cand := RepoCandidate{
+ Path: dir,
+ Layout: LayoutPlain,
+ AcceptedByOperator: acc.Accepted,
+ InferredNamespace: singleExplicitNamespace(store, dir),
+ // A plain folder is applied directory-wise, so it renders its whole subtree
+ // (renderedFiles nil); no kustomization graph scopes it.
+ Resources: countResources(store, dir, nil),
+ }
+ if !acc.Accepted {
+ cand.RefusalReasons = issuesToReasons(acc.Issues)
+ }
+ out = append(out, cand)
+ }
+ return out
+}
+
+// candidateAcceptance runs the structure-only adoption gate over the candidate subtree —
+// the exact gate the operator's live writer runs (Scan with WriterAllowlist, which retains
+// the kustomize build directives and the operator's .sops.yaml bootstrap config). It
+// returns the full acceptance so a refused candidate can carry the gate's issues as
+// refusal reasons rather than collapsing them to a bare boolean.
+func candidateAcceptance(ctx context.Context, fsys fs.FS, dir string) Acceptance {
+ sub, err := fs.Sub(fsys, dir)
+ if err != nil {
+ return Acceptance{Issues: []AcceptanceIssue{{Kind: IssueForeignFile, Path: dir, Message: err.Error()}}}
+ }
+ policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: WriterAllowlist()}}
+ return Scan(ctx, sub, nil, nil, policy).Acceptance
+}
+
+// issuesToReasons projects acceptance-gate issues into refusal reasons so a refused plain
+// or self-contained kustomize candidate reports WHY — duplicate identity, non-KRM YAML, a
+// foreign file, a mixed build-directive file, an unsupported nested kustomization — not
+// just acceptedByOperator: false. The issue Kind is the machine code; the path-qualified
+// message is the detail.
+func issuesToReasons(issues []AcceptanceIssue) []RefusalReason {
+ out := make([]RefusalReason, 0, len(issues))
+ for _, iss := range issues {
+ detail := iss.Message
+ if iss.Path != "" {
+ detail = iss.Path + ": " + iss.Message
+ }
+ out = append(out, RefusalReason{Code: string(iss.Kind), Detail: detail})
+ }
+ return out
+}
+
+// outOfSubtreeBases returns the sorted, MINIMAL base kustomization directories a render
+// root reaches that lie OUTSIDE its own subtree — the escaping-subtree fact that makes an
+// overlay unrenderable by the operator today. Bases nested within the subtree do not
+// count (the operator can render them, so the root stays kustomize-single). The set is
+// minimal: a reached base nested under another reached base is dropped, since it is read
+// transitively through its parent — this keeps readScope non-overlapping so the rendered
+// document count never double-counts a shared nested base.
+func outOfSubtreeBases(rootDir string, kusts map[string]*kustomizationDoc) []string {
+ var out []string
+ for base := range reachedKustomizationDirs(rootDir, kusts) {
+ if !pathWithin(base, rootDir) {
+ out = append(out, base)
+ }
+ }
+ out = minimalDirs(out)
+ sort.Strings(out)
+ return out
+}
+
+// minimalDirs drops any directory nested under another directory in the set, leaving only
+// the top-level roots. Used so readScope reports (and counts) a base and its own nested
+// base once, through the parent, rather than twice.
+func minimalDirs(dirs []string) []string {
+ out := make([]string, 0, len(dirs))
+ for _, d := range dirs {
+ nested := false
+ for _, other := range dirs {
+ if other != d && pathWithin(d, other) {
+ nested = true
+ break
+ }
+ }
+ if !nested {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// reachedKustomizationDirs returns every kustomization directory reachable from
+// rootDir through the resources graph (excluding rootDir itself), following the same
+// cleanJoin resolution the store uses. The on-path set bounds cycles.
+func reachedKustomizationDirs(rootDir string, kusts map[string]*kustomizationDoc) map[string]struct{} {
+ reached := map[string]struct{}{}
+ onPath := map[string]struct{}{}
+ var walk func(dir string)
+ walk = func(dir string) {
+ cur := kusts[dir]
+ if cur == nil {
+ return
+ }
+ if _, cycling := onPath[dir]; cycling {
+ return
+ }
+ onPath[dir] = struct{}{}
+ for _, entry := range cur.resources {
+ target := cleanJoin(dir, entry)
+ if target == "" {
+ continue
+ }
+ if _, isKust := kusts[target]; isKust {
+ reached[target] = struct{}{}
+ walk(target)
+ }
+ }
+ delete(onPath, dir)
+ }
+ walk(rootDir)
+ return reached
+}
+
+// reachedResourceFiles is the set of resource-file paths (non-kustomization targets)
+// any kustomization in the repo references. A plain folder whose file is in this set is
+// already owned by a render and is not proposed as a bare candidate.
+func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{} {
+ out := map[string]struct{}{}
+ for dir, k := range kusts {
+ for _, entry := range k.resources {
+ target := cleanJoin(dir, entry)
+ if target == "" {
+ continue
+ }
+ if _, isKust := kusts[target]; !isKust {
+ out[target] = struct{}{}
+ }
+ }
+ }
+ return out
+}
+
+// overlayFanOutDetail explains why an overlay needs F2: the shared base and how many
+// render roots reach it from outside their subtree.
+func overlayFanOutDetail(base string, kusts map[string]*kustomizationDoc) string {
+ shared := 0
+ for _, root := range renderRoots(kusts) {
+ if _, ok := reachedKustomizationDirs(root, kusts)[base]; ok && !pathWithin(base, root) {
+ shared++
+ }
+ }
+ return fmt.Sprintf(
+ "base %q is read from outside this folder's subtree and is shared by %d render root(s); "+
+ "render-root scoping (F2) required",
+ base, shared)
+}
+
+// refusedStructuralDetail names the specific unsupported kustomize features so the
+// refusal is actionable, not a bare "refused".
+func refusedStructuralDetail(content []byte) string {
+ features := unsupportedKustomizeFeatures(content)
+ 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")
+ }
+ out := make([]string, 0, len(seen))
+ for f := range seen {
+ out = append(out, f)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// renderRootNamespace resolves the namespace a render root renders under: the
+// kustomization's own namespace transformer, falling back to a single explicit
+// namespace on its resources when the kustomization sets none.
+func renderRootNamespace(kusts map[string]*kustomizationDoc, rootDir string, store *ManifestStore) string {
+ if doc := kusts[rootDir]; doc != nil && doc.namespace != "" {
+ return doc.namespace
+ }
+ return singleExplicitNamespace(store, rootDir)
+}
+
+// singleExplicitNamespace returns the one explicit metadata.namespace shared by every
+// managed document under dir, or "" when there is none or they disagree.
+func singleExplicitNamespace(store *ManifestStore, dir string) string {
+ seen := map[string]struct{}{}
+ for filePath, fm := range store.FilesByPath {
+ if !pathWithin(filePath, dir) {
+ continue
+ }
+ for _, dm := range fm.Documents {
+ if ns := dm.ManifestIdentity.Namespace; ns != "" {
+ seen[ns] = struct{}{}
+ }
+ }
+ }
+ if len(seen) != 1 {
+ return ""
+ }
+ for ns := range seen {
+ return ns
+ }
+ return ""
+}
+
+// countResources counts the KRM a candidate renders and can edit, plus non-KRM noise in
+// its own subtree. renderedFiles is the exact set of resource files the candidate renders —
+// the kustomization resources graph for a render root; a nil set means "every managed file
+// in the candidate's own subtree", the plain-folder case applied directory-wise. rendered
+// counts documents in the rendered files; editable counts the subset physically in the
+// candidate's own subtree (the source the operator would own and write) — a pure overlay
+// renders its base but edits nothing locally (editable = 0). nonKRM counts non-KRM YAML
+// documents and foreign entries under dir.
+func countResources(store *ManifestStore, dir string, renderedFiles map[string]struct{}) ResourceCounts {
+ var rendered, editable int
+ for filePath, fm := range store.FilesByPath {
+ if !fileIsRendered(filePath, dir, renderedFiles) {
+ continue
+ }
+ rendered += len(fm.Documents)
+ if pathWithin(filePath, dir) {
+ editable += len(fm.Documents)
+ }
+ }
+ return ResourceCounts{Rendered: rendered, Editable: editable, NonKRM: nonKRMUnder(store, dir)}
+}
+
+// fileIsRendered reports whether a managed file counts toward a candidate's rendered set:
+// membership in renderedFiles for a kustomize candidate, or presence in the candidate's own
+// subtree when renderedFiles is nil (a plain folder renders its whole directory).
+func fileIsRendered(filePath, dir string, renderedFiles map[string]struct{}) bool {
+ if renderedFiles == nil {
+ return pathWithin(filePath, dir)
+ }
+ _, ok := renderedFiles[filePath]
+ return ok
+}
+
+// reachedResourceFilesFrom returns the set of resource-file paths a render root actually
+// renders: the non-kustomization targets reached by following the resources graph from
+// rootDir. Each kustomization contributes only the entries it lists, so parked YAML a
+// kustomization does not reference is never counted. The on-path set bounds cycles.
+func reachedResourceFilesFrom(rootDir string, kusts map[string]*kustomizationDoc) map[string]struct{} {
+ files := map[string]struct{}{}
+ onPath := map[string]struct{}{}
+ var walk func(dir string)
+ walk = func(dir string) {
+ cur := kusts[dir]
+ if cur == nil {
+ return
+ }
+ if _, cycling := onPath[dir]; cycling {
+ return
+ }
+ onPath[dir] = struct{}{}
+ for _, entry := range cur.resources {
+ target := cleanJoin(dir, entry)
+ if target == "" {
+ continue
+ }
+ if _, isKust := kusts[target]; isKust {
+ walk(target)
+ } else {
+ files[target] = struct{}{}
+ }
+ }
+ delete(onPath, dir)
+ }
+ walk(rootDir)
+ return files
+}
+
+// nonKRMUnder counts non-KRM YAML documents and foreign entries under dir. Retained
+// build directives are excluded (they are neither KRM nor noise).
+func nonKRMUnder(store *ManifestStore, dir string) int {
+ n := 0
+ for _, d := range store.Diagnostics {
+ if d.Reason == manifestedit.ReasonNotKRM && pathWithin(d.Path, dir) {
+ n++
+ }
+ }
+ for _, f := range store.Foreign {
+ if pathWithin(f.Path, dir) {
+ n++
+ }
+ }
+ return n
+}
+
+// detectOverlaps fills OverlapsWith on each candidate and returns nothing; the summary
+// collects the conflicts separately. Two candidates overlap when one strictly contains
+// the other — the one-owner-per-folder invariant mirrored from gittarget_path_overlap.
+func detectOverlaps(candidates []RepoCandidate) {
+ for i := range candidates {
+ for j := i + 1; j < len(candidates); j++ {
+ a, b := candidates[i].Path, candidates[j].Path
+ if pathWithin(a, b) || pathWithin(b, a) {
+ candidates[i].OverlapsWith = append(candidates[i].OverlapsWith, b)
+ candidates[j].OverlapsWith = append(candidates[j].OverlapsWith, a)
+ }
+ }
+ }
+}
+
+// summarize rolls the candidates up into the repo-level summary and adds the fleet-root
+// signal read from the repo's top-level directories. Unsupported constructs are
+// recomputed from each refused-structural candidate's kustomization bytes, so the
+// summary shares one source of truth with the per-candidate detail.
+func summarize(candidates []RepoCandidate, fsys fs.FS, kustContent map[string][]byte) RepoSummary {
+ s := RepoSummary{CandidatesByLayout: map[Layout]int{}}
+ constructs := map[string]struct{}{}
+ for _, c := range candidates {
+ s.CandidatesByLayout[c.Layout]++
+ if c.AcceptedByOperator {
+ s.Accepted++
+ } else {
+ s.Refused++
+ }
+ if c.Layout == LayoutRefusedStructural {
+ for _, f := range unsupportedKustomizeFeatures(kustContent[c.Path]) {
+ constructs[f] = struct{}{}
+ }
+ }
+ for _, other := range c.OverlapsWith {
+ if pathWithin(other, c.Path) { // c is the ancestor of other
+ s.OverlapConflicts = append(s.OverlapConflicts, OverlapConflict{Ancestor: c.Path, Descendant: other})
+ }
+ }
+ }
+ if len(constructs) > 0 {
+ s.UnsupportedConstructs = sortedKeysOf(constructs)
+ }
+ sort.Slice(s.OverlapConflicts, func(i, j int) bool {
+ if s.OverlapConflicts[i].Ancestor != s.OverlapConflicts[j].Ancestor {
+ return s.OverlapConflicts[i].Ancestor < s.OverlapConflicts[j].Ancestor
+ }
+ return s.OverlapConflicts[i].Descendant < s.OverlapConflicts[j].Descendant
+ })
+ s.FleetRoot = isFleetRoot(fsys)
+ return s
+}
+
+// isFleetRoot reports whether the repo root is a cluster/fleet root: top-level
+// clusters/ + apps/ + infra/ directories. A GitTarget points at an app subtree, never
+// such a root.
+func isFleetRoot(fsys fs.FS) bool {
+ entries, err := fs.ReadDir(fsys, ".")
+ if err != nil {
+ return false
+ }
+ top := map[string]struct{}{}
+ for _, e := range entries {
+ if e.IsDir() {
+ top[e.Name()] = struct{}{}
+ }
+ }
+ for _, want := range []string{"clusters", "apps", "infra"} {
+ if _, ok := top[want]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// kustomizationContentByDir maps each kustomization directory to its raw bytes, so the
+// refused-structural detail can name the specific unsupported features.
+func kustomizationContentByDir(scan FolderScan) map[string][]byte {
+ out := map[string][]byte{}
+ for _, f := range scan.YAMLFiles {
+ if isKustomizationFile(f.Path) {
+ out[slashDir(f.Path)] = f.Content
+ }
+ }
+ return out
+}
+
+// pathWithin reports whether the slash path p is within dir: equal to it, or nested
+// under it on a segment boundary ("a/b" is within "a" but "ab" is not).
+func pathWithin(p, dir string) bool {
+ p = path.Clean(p)
+ dir = path.Clean(dir)
+ if dir == "." {
+ return true // the repo root contains every path
+ }
+ return p == dir || strings.HasPrefix(p, dir+"/")
+}
+
+// sortedKeysOf returns the sorted keys of a string set.
+func sortedKeysOf(m map[string]struct{}) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// RenderRepoJSON writes the repo report as indented JSON — the product's interface,
+// matching the existing --format json convention.
+func RenderRepoJSON(w io.Writer, rep RepoReport) error {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(rep)
+}
+
+// RenderRepoText writes a compact human summary of the repo report: one line per
+// candidate, then the roll-up. It is a convenience view; JSON is the contract.
+func RenderRepoText(w io.Writer, rep RepoReport) {
+ fmt.Fprintf(w, "repo: %s\n", rep.Root)
+ fmt.Fprintf(w, "candidates: %d\n", len(rep.Candidates))
+ for _, c := range rep.Candidates {
+ status := "accepted"
+ if !c.AcceptedByOperator {
+ status = "refused"
+ if len(c.RefusalReasons) > 0 {
+ status = c.RefusalReasons[0].Code
+ }
+ }
+ ns := c.InferredNamespace
+ if ns == "" {
+ ns = "-"
+ }
+ fmt.Fprintf(w, " %-40s %-18s %-10s ns=%-16s rendered=%d editable=%d\n",
+ c.Path, c.Layout, status, ns, c.Resources.Rendered, c.Resources.Editable)
+ if len(c.ReadScope) > 0 {
+ fmt.Fprintf(w, " reads: %s\n", strings.Join(c.ReadScope, ", "))
+ }
+ if len(c.OverlapsWith) > 0 {
+ fmt.Fprintf(w, " overlaps: %s\n", strings.Join(c.OverlapsWith, ", "))
+ }
+ }
+ fmt.Fprintf(w, "summary: accepted=%d refused=%d", rep.Summary.Accepted, rep.Summary.Refused)
+ if rep.Summary.FleetRoot {
+ fmt.Fprint(w, " fleet-root=true")
+ }
+ if len(rep.Summary.OverlapConflicts) > 0 {
+ fmt.Fprintf(w, " overlap-conflicts=%d", len(rep.Summary.OverlapConflicts))
+ }
+ if len(rep.Summary.UnsupportedConstructs) > 0 {
+ fmt.Fprintf(w, " unsupported=[%s]", strings.Join(rep.Summary.UnsupportedConstructs, ", "))
+ }
+ fmt.Fprintln(w)
+}
diff --git a/internal/manifestanalyzer/repowalk_test.go b/internal/manifestanalyzer/repowalk_test.go
new file mode 100644
index 00000000..3ee87410
--- /dev/null
+++ b/internal/manifestanalyzer/repowalk_test.go
@@ -0,0 +1,223 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package manifestanalyzer
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "testing/fstest"
+)
+
+// updateGolden regenerates the golden reports when UPDATE_GOLDEN=1 is set, the standard
+// golden-file workflow: run once to (re)write the expectations, then review the diff.
+var updateGolden = os.Getenv("UPDATE_GOLDEN") == "1"
+
+// deployYAMLNS is a minimal namespaced manifest for in-memory (fstest) repo-walk tests.
+const deployYAMLNS = `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: demo
+`
+
+// TestWalkRepo_Golden drives the whole discovery corpus under testdata/repo-walker.
+// Each fixture is a self-contained repo with a sibling .golden.json pinning its
+// report, so layout classification, refusal codes, overlap detection, namespace
+// inference, and the rendered/editable split are all fixed by real layouts rather than
+// prose. The corpus is split supported/ vs unsupported/ mirroring the
+// contextual-namespace corpus. See
+// docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md.
+func TestWalkRepo_Golden(t *testing.T) {
+ for _, group := range []string{"supported", "unsupported"} {
+ base := filepath.Join("testdata", "repo-walker", group)
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ t.Fatalf("read corpus %s: %v", base, err)
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue // the sibling .golden.json files
+ }
+ fixture := filepath.Join(base, e.Name())
+ t.Run(filepath.Join(group, e.Name()), func(t *testing.T) { checkGoldenFixture(t, fixture) })
+ }
+ }
+}
+
+// checkGoldenFixture walks one fixture repo and compares (or, under UPDATE_GOLDEN,
+// rewrites) its sibling golden report. The machine-specific root is blanked so the
+// golden is path-independent.
+func checkGoldenFixture(t *testing.T, fixture string) {
+ t.Helper()
+ rep, err := WalkRepo(context.Background(), fixture)
+ if err != nil {
+ t.Fatalf("WalkRepo(%s): %v", fixture, err)
+ }
+ if rep.Root != fixture {
+ t.Errorf("Root = %q, want %q", rep.Root, fixture)
+ }
+ rep.Root = ""
+
+ var buf bytes.Buffer
+ if err := RenderRepoJSON(&buf, rep); err != nil {
+ t.Fatalf("render json: %v", err)
+ }
+
+ golden := fixture + ".golden.json"
+ if updateGolden {
+ if err := os.WriteFile(golden, buf.Bytes(), 0o600); err != nil {
+ t.Fatalf("write golden: %v", err)
+ }
+ return
+ }
+ want, err := os.ReadFile(golden)
+ if err != nil {
+ t.Fatalf("read golden (run UPDATE_GOLDEN=1 to create): %v", err)
+ }
+ if !bytes.Equal(want, buf.Bytes()) {
+ t.Errorf("report mismatch for %s\n--- want ---\n%s\n--- got ---\n%s", fixture, want, buf.Bytes())
+ }
+}
+
+// TestWalkRepo_RefusalCodesStayDistinct pins the load-bearing distinction the design
+// calls out: a forward-looking overlay-fan-out-needs-f2 must never collapse into the
+// permanent refused-structural boundary. base-overlays is the former; helm-inflation and
+// unsupported-kustomize are the latter.
+func TestWalkRepo_RefusalCodesStayDistinct(t *testing.T) {
+ cases := []struct {
+ fixture string
+ wantCode string
+ layout Layout
+ }{
+ {"unsupported/base-overlays", ReasonOverlayFanOutNeedsF2, LayoutKustomizeOverlay},
+ {"unsupported/overlay-parked-base", ReasonOverlayFanOutNeedsF2, LayoutKustomizeOverlay},
+ {"unsupported/helm-inflation", ReasonRefusedStructural, LayoutRefusedStructural},
+ {"unsupported/unsupported-kustomize", ReasonRefusedStructural, LayoutRefusedStructural},
+ }
+ for _, tc := range cases {
+ t.Run(tc.fixture, func(t *testing.T) {
+ rep, err := WalkRepo(context.Background(), filepath.Join("testdata", "repo-walker", tc.fixture))
+ if err != nil {
+ t.Fatalf("WalkRepo: %v", err)
+ }
+ if len(rep.Candidates) == 0 {
+ t.Fatalf("expected at least one candidate")
+ }
+ for _, c := range rep.Candidates {
+ assertRefused(t, tc.fixture, c, tc.layout, tc.wantCode)
+ }
+ })
+ }
+}
+
+// assertRefused checks one candidate is refused with the expected layout and reason code.
+func assertRefused(t *testing.T, fixture string, c RepoCandidate, layout Layout, code string) {
+ t.Helper()
+ if c.AcceptedByOperator {
+ t.Errorf("%s: candidate %s should be refused", fixture, c.Path)
+ }
+ if c.Layout != layout {
+ t.Errorf("%s: candidate %s layout = %q, want %q", fixture, c.Path, c.Layout, layout)
+ }
+ if len(c.RefusalReasons) == 0 || c.RefusalReasons[0].Code != code {
+ t.Errorf("%s: candidate %s reasons = %+v, want code %q", fixture, c.Path, c.RefusalReasons, code)
+ }
+}
+
+// TestWalkRepo_RenderedCountDedupesNestedBase guards the double-count: an overlay whose
+// out-of-subtree base pulls in a nested base must count each rendered document exactly
+// once (rendered=2 here: base/deployment + base/common/configmap), and readScope must be
+// the minimal [base] rather than [base, base/common]. A regression would report 3.
+func TestWalkRepo_RenderedCountDedupesNestedBase(t *testing.T) {
+ fixture := filepath.Join("testdata", "repo-walker", "unsupported", "overlay-nested-base")
+ rep, err := WalkRepo(context.Background(), fixture)
+ if err != nil {
+ t.Fatalf("WalkRepo: %v", err)
+ }
+ if len(rep.Candidates) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(rep.Candidates), rep.Candidates)
+ }
+ c := rep.Candidates[0]
+ if c.Resources.Rendered != 2 {
+ t.Errorf("rendered = %d, want 2 (deduped; a double-count reports 3)", c.Resources.Rendered)
+ }
+ if len(c.ReadScope) != 1 || c.ReadScope[0] != "base" {
+ t.Errorf("readScope = %v, want [base] (minimal; nested base/common folded in)", c.ReadScope)
+ }
+}
+
+// TestWalkRepo_RenderedExcludesParkedYAML guards that rendered counts only what the
+// kustomization graph reaches: a base holding a parked.yaml its kustomization does not
+// list must not inflate rendered. overlay-parked-base renders 1 (base/deployment), not 2.
+func TestWalkRepo_RenderedExcludesParkedYAML(t *testing.T) {
+ fixture := filepath.Join("testdata", "repo-walker", "unsupported", "overlay-parked-base")
+ rep, err := WalkRepo(context.Background(), fixture)
+ if err != nil {
+ t.Fatalf("WalkRepo: %v", err)
+ }
+ if len(rep.Candidates) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(rep.Candidates), rep.Candidates)
+ }
+ if got := rep.Candidates[0].Resources.Rendered; got != 1 {
+ t.Errorf("rendered = %d, want 1 (parked.yaml is not in the resources graph)", got)
+ }
+}
+
+// TestWalkRepo_RefusedPlainSurfacesGateReasons guards that a plain candidate the
+// acceptance gate refuses reports WHY (the gate issues) rather than a bare
+// acceptedByOperator: false. plain-nonkrm holds a non-KRM values.yaml.
+func TestWalkRepo_RefusedPlainSurfacesGateReasons(t *testing.T) {
+ fixture := filepath.Join("testdata", "repo-walker", "unsupported", "plain-nonkrm")
+ rep, err := WalkRepo(context.Background(), fixture)
+ if err != nil {
+ t.Fatalf("WalkRepo: %v", err)
+ }
+ if len(rep.Candidates) != 1 {
+ t.Fatalf("want 1 candidate, got %d", len(rep.Candidates))
+ }
+ c := rep.Candidates[0]
+ if c.AcceptedByOperator {
+ t.Fatal("candidate with a non-KRM file should be refused")
+ }
+ if len(c.RefusalReasons) == 0 {
+ t.Fatalf("a gate refusal must surface reasons, got none (acceptedByOperator=false with no why)")
+ }
+ if c.RefusalReasons[0].Code == "" || c.RefusalReasons[0].Detail == "" {
+ t.Errorf("refusal reason should carry a code and detail, got %+v", c.RefusalReasons[0])
+ }
+}
+
+// TestWalkRepo_SopsBootstrapAcceptedLikeWriter guards that repo-walker's acceptance
+// matches the live writer's allowlist: a folder holding the operator's .sops.yaml
+// bootstrap config is accepted (the writer retains it), not refused as non-KRM, and the
+// .sops.yaml is not counted as non-KRM noise.
+func TestWalkRepo_SopsBootstrapAcceptedLikeWriter(t *testing.T) {
+ fsys := fstest.MapFS{
+ "app/deployment.yaml": {Data: []byte(deployYAMLNS)},
+ "app/.sops.yaml": {Data: []byte(
+ "creation_rules:\n - path_regex: .*\n age: age1exampleexampleexampleexampleexampleexampleexample\n")},
+ }
+ rep := walkRepoFS(context.Background(), fsys)
+ if len(rep.Candidates) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(rep.Candidates), rep.Candidates)
+ }
+ c := rep.Candidates[0]
+ if !c.AcceptedByOperator {
+ t.Errorf("a folder with the operator .sops.yaml bootstrap should be accepted like the writer, reasons=%+v",
+ c.RefusalReasons)
+ }
+ if c.Resources.NonKRM != 0 {
+ t.Errorf(".sops.yaml is a retained build directive, not non-KRM noise; nonKrm=%d", c.Resources.NonKRM)
+ }
+}
+
+// TestWalkRepo_NotADirectory covers the usage error path: a missing or non-directory
+// root is an error, not an empty report.
+func TestWalkRepo_NotADirectory(t *testing.T) {
+ if _, err := WalkRepo(context.Background(), filepath.Join("testdata", "does-not-exist")); err == nil {
+ t.Fatal("expected an error for a missing root")
+ }
+}
diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go
index cbeb1a2f..845b0a61 100644
--- a/internal/manifestanalyzer/store.go
+++ b/internal/manifestanalyzer/store.go
@@ -802,6 +802,24 @@ func kustomizationUsesUnsupportedFeature(content []byte) bool {
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-walker'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
@@ -809,14 +827,7 @@ func kustomizationUsesUnsupportedFeature(content []byte) bool {
// (labels, annotations) do not. images/replicas are parsed separately (overrides.go)
// and disqualify only when malformed.
func hasUnsupportedKustomizeFeature(raw map[string]interface{}) bool {
- unsupported := []string{
- "generators", "configMapGenerator", "secretGenerator",
- "helmCharts", "helmGlobals", "helmChartInflationGenerator",
- "patches", "patchesStrategicMerge", "patchesJson6902",
- "replacements", "components", "transformers", "configurations",
- "namePrefix", "nameSuffix",
- }
- for _, key := range unsupported {
+ for _, key := range unsupportedKustomizeFeatureKeys() {
if v, ok := raw[key]; ok && !isEmptyValue(v) {
return true
}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root.golden.json
new file mode 100644
index 00000000..17fb8bcf
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root.golden.json
@@ -0,0 +1,48 @@
+{
+ "candidates": [
+ {
+ "path": "apps/podinfo",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "podinfo",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ },
+ {
+ "path": "clusters/prod",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "flux-system",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ },
+ {
+ "path": "infra/ingress-nginx",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "infra-system",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 3
+ },
+ "accepted": 3,
+ "refused": 0,
+ "fleetRoot": true
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/apps/podinfo/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/apps/podinfo/deployment.yaml
new file mode 100644
index 00000000..512b26e5
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/apps/podinfo/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: podinfo
+ namespace: podinfo
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/clusters/prod/flux-system.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/clusters/prod/flux-system.yaml
new file mode 100644
index 00000000..99d9cc5e
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/clusters/prod/flux-system.yaml
@@ -0,0 +1,10 @@
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: apps
+ namespace: flux-system
+spec:
+ path: ./apps
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/infra/ingress-nginx/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/infra/ingress-nginx/deployment.yaml
new file mode 100644
index 00000000..0b4761ad
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/fleet-root/infra/ingress-nginx/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: ingress-nginx
+ namespace: infra-system
+spec:
+ replicas: 2
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document.golden.json
new file mode 100644
index 00000000..34e11e89
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document.golden.json
@@ -0,0 +1,23 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "flux-system",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 1
+ },
+ "accepted": 1,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document/app/helmrelease.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document/app/helmrelease.yaml
new file mode 100644
index 00000000..e78f327f
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/helm-release-document/app/helmrelease.yaml
@@ -0,0 +1,9 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: podinfo
+ namespace: flux-system
+spec:
+ chart:
+ spec:
+ chart: podinfo
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single.golden.json
new file mode 100644
index 00000000..34c7b7d2
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single.golden.json
@@ -0,0 +1,23 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "kustomize-single",
+ "acceptedByOperator": true,
+ "renderRoot": true,
+ "inferredNamespace": "demo",
+ "resources": {
+ "rendered": 2,
+ "editable": 2,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "kustomize-single": 1
+ },
+ "accepted": 1,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/deployment.yaml
new file mode 100644
index 00000000..1f8651ce
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/deployment.yaml
@@ -0,0 +1,6 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/kustomization.yaml
new file mode 100644
index 00000000..42a2fd5d
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/kustomization.yaml
@@ -0,0 +1,12 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: demo
+resources:
+ - deployment.yaml
+ - service.yaml
+images:
+ - name: web
+ newTag: v2
+replicas:
+ - name: web
+ count: 2
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/service.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/service.yaml
new file mode 100644
index 00000000..ddd3b8fd
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/kustomize-single/app/service.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: web
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm.golden.json
new file mode 100644
index 00000000..84e8fbeb
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm.golden.json
@@ -0,0 +1,8 @@
+{
+ "candidates": [],
+ "summary": {
+ "candidatesByLayout": {},
+ "accepted": 0,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/README.md b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/README.md
new file mode 100644
index 00000000..2bb9f467
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/README.md
@@ -0,0 +1,2 @@
+# Notes
+This folder holds no Kubernetes manifests.
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/notes.txt b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/notes.txt
new file mode 100644
index 00000000..bdd55983
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/no-krm/docs/notes.txt
@@ -0,0 +1 @@
+just some notes, not YAML
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping.golden.json
new file mode 100644
index 00000000..bb957e77
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping.golden.json
@@ -0,0 +1,47 @@
+{
+ "candidates": [
+ {
+ "path": "apps/web",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "web",
+ "resources": {
+ "rendered": 2,
+ "editable": 2,
+ "nonKrm": 0
+ },
+ "overlapsWith": [
+ "apps/web/extra"
+ ]
+ },
+ {
+ "path": "apps/web/extra",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "web",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ },
+ "overlapsWith": [
+ "apps/web"
+ ]
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 2
+ },
+ "accepted": 2,
+ "refused": 0,
+ "overlapConflicts": [
+ {
+ "ancestor": "apps/web",
+ "descendant": "apps/web/extra"
+ }
+ ]
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/deployment.yaml
new file mode 100644
index 00000000..66cf436f
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: web
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/extra/configmap.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/extra/configmap.yaml
new file mode 100644
index 00000000..49ac1aec
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/overlapping/apps/web/extra/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: web-extra
+ namespace: web
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env.golden.json b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env.golden.json
new file mode 100644
index 00000000..0a3b316d
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env.golden.json
@@ -0,0 +1,35 @@
+{
+ "candidates": [
+ {
+ "path": "apps/app/prod",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "app-prod",
+ "resources": {
+ "rendered": 2,
+ "editable": 2,
+ "nonKrm": 0
+ }
+ },
+ {
+ "path": "apps/app/test",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "app-test",
+ "resources": {
+ "rendered": 2,
+ "editable": 2,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 2
+ },
+ "accepted": 2,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/deployment.yaml
new file mode 100644
index 00000000..aee9592c
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: app-prod
+spec:
+ replicas: 3
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/service.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/service.yaml
new file mode 100644
index 00000000..33398d70
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/prod/service.yaml
@@ -0,0 +1,5 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: web
+ namespace: app-prod
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/deployment.yaml
new file mode 100644
index 00000000..85cb2b9e
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: app-test
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/service.yaml b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/service.yaml
new file mode 100644
index 00000000..b9914f23
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/supported/plain-per-env/apps/app/test/service.yaml
@@ -0,0 +1,5 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: web
+ namespace: app-test
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays.golden.json
new file mode 100644
index 00000000..497e4dc0
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays.golden.json
@@ -0,0 +1,74 @@
+{
+ "candidates": [
+ {
+ "path": "overlays/acc",
+ "layout": "kustomize-overlay",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "overlay-fan-out-needs-f2",
+ "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping (F2) required"
+ }
+ ],
+ "renderRoot": true,
+ "readScope": [
+ "base"
+ ],
+ "inferredNamespace": "podinfo-acc",
+ "resources": {
+ "rendered": 2,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ },
+ {
+ "path": "overlays/prod",
+ "layout": "kustomize-overlay",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "overlay-fan-out-needs-f2",
+ "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping (F2) required"
+ }
+ ],
+ "renderRoot": true,
+ "readScope": [
+ "base"
+ ],
+ "inferredNamespace": "podinfo-prod",
+ "resources": {
+ "rendered": 2,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ },
+ {
+ "path": "overlays/test",
+ "layout": "kustomize-overlay",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "overlay-fan-out-needs-f2",
+ "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping (F2) required"
+ }
+ ],
+ "renderRoot": true,
+ "readScope": [
+ "base"
+ ],
+ "inferredNamespace": "podinfo-test",
+ "resources": {
+ "rendered": 2,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "kustomize-overlay": 3
+ },
+ "accepted": 0,
+ "refused": 3
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/deployment.yaml
new file mode 100644
index 00000000..408e9499
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/deployment.yaml
@@ -0,0 +1,6 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: podinfo
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/kustomization.yaml
new file mode 100644
index 00000000..a33121c3
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
+ - service.yaml
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/service.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/service.yaml
new file mode 100644
index 00000000..af09bda2
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/base/service.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: podinfo
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/acc/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/acc/kustomization.yaml
new file mode 100644
index 00000000..dc4ad099
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/acc/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: podinfo-acc
+resources:
+ - ../../base
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/prod/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/prod/kustomization.yaml
new file mode 100644
index 00000000..94d40d5e
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/prod/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: podinfo-prod
+resources:
+ - ../../base
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/test/kustomization.yaml
new file mode 100644
index 00000000..6d6ab27f
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/base-overlays/overlays/test/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: podinfo-test
+resources:
+ - ../../base
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation.golden.json
new file mode 100644
index 00000000..9804be62
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation.golden.json
@@ -0,0 +1,31 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "refused-structural",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "refused-structural",
+ "detail": "kustomization uses unsupported feature(s): helmCharts"
+ }
+ ],
+ "renderRoot": true,
+ "resources": {
+ "rendered": 0,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "refused-structural": 1
+ },
+ "accepted": 0,
+ "refused": 1,
+ "unsupportedConstructs": [
+ "helmCharts"
+ ]
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation/app/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation/app/kustomization.yaml
new file mode 100644
index 00000000..47bcfd29
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/helm-inflation/app/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+helmCharts:
+ - name: podinfo
+ repo: https://stefanprodan.github.io/podinfo
+ version: 6.0.0
+ releaseName: podinfo
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base.golden.json
new file mode 100644
index 00000000..1cb4de55
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base.golden.json
@@ -0,0 +1,32 @@
+{
+ "candidates": [
+ {
+ "path": "overlays/test",
+ "layout": "kustomize-overlay",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "overlay-fan-out-needs-f2",
+ "detail": "base \"base\" is read from outside this folder's subtree and is shared by 1 render root(s); render-root scoping (F2) required"
+ }
+ ],
+ "renderRoot": true,
+ "readScope": [
+ "base"
+ ],
+ "inferredNamespace": "nested-test",
+ "resources": {
+ "rendered": 2,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "kustomize-overlay": 1
+ },
+ "accepted": 0,
+ "refused": 1
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/configmap.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/configmap.yaml
new file mode 100644
index 00000000..49da8f31
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: shared
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/kustomization.yaml
new file mode 100644
index 00000000..9fb65fa6
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/common/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - configmap.yaml
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/deployment.yaml
new file mode 100644
index 00000000..408e9499
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/deployment.yaml
@@ -0,0 +1,6 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: podinfo
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/kustomization.yaml
new file mode 100644
index 00000000..18a5b38d
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/base/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
+ - common
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/overlays/test/kustomization.yaml
new file mode 100644
index 00000000..99a9a44c
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-nested-base/overlays/test/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: nested-test
+resources:
+ - ../../base
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base.golden.json
new file mode 100644
index 00000000..a4d980a3
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base.golden.json
@@ -0,0 +1,32 @@
+{
+ "candidates": [
+ {
+ "path": "overlays/test",
+ "layout": "kustomize-overlay",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "overlay-fan-out-needs-f2",
+ "detail": "base \"base\" is read from outside this folder's subtree and is shared by 1 render root(s); render-root scoping (F2) required"
+ }
+ ],
+ "renderRoot": true,
+ "readScope": [
+ "base"
+ ],
+ "inferredNamespace": "parked-test",
+ "resources": {
+ "rendered": 1,
+ "editable": 0,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "kustomize-overlay": 1
+ },
+ "accepted": 0,
+ "refused": 1
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/deployment.yaml
new file mode 100644
index 00000000..408e9499
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/deployment.yaml
@@ -0,0 +1,6 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: podinfo
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/kustomization.yaml
new file mode 100644
index 00000000..9c2d28b0
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/parked.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/parked.yaml
new file mode 100644
index 00000000..a86adc7a
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/base/parked.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: parked
+data:
+ note: not referenced by kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/overlays/test/kustomization.yaml
new file mode 100644
index 00000000..e9a4f154
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/overlay-parked-base/overlays/test/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: parked-test
+resources:
+ - ../../base
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm.golden.json
new file mode 100644
index 00000000..740c8806
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm.golden.json
@@ -0,0 +1,29 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "plain",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "non-krm-yaml",
+ "detail": "values.yaml: YAML is not a Kubernetes manifest"
+ }
+ ],
+ "renderRoot": false,
+ "inferredNamespace": "demo",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 1
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 1
+ },
+ "accepted": 0,
+ "refused": 1
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/deployment.yaml
new file mode 100644
index 00000000..71613a91
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/deployment.yaml
@@ -0,0 +1,7 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: demo
+spec:
+ replicas: 1
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/values.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/values.yaml
new file mode 100644
index 00000000..b8aba1dd
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/plain-nonkrm/app/values.yaml
@@ -0,0 +1,5 @@
+# valid YAML, but not a Kubernetes manifest
+foo: bar
+baz:
+ - 1
+ - 2
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize.golden.json b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize.golden.json
new file mode 100644
index 00000000..faec48e8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize.golden.json
@@ -0,0 +1,35 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "refused-structural",
+ "acceptedByOperator": false,
+ "refusalReasons": [
+ {
+ "code": "refused-structural",
+ "detail": "kustomization uses unsupported feature(s): components, crds, namePrefix, openapi, secretGenerator"
+ }
+ ],
+ "renderRoot": true,
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "refused-structural": 1
+ },
+ "accepted": 0,
+ "refused": 1,
+ "unsupportedConstructs": [
+ "components",
+ "crds",
+ "namePrefix",
+ "openapi",
+ "secretGenerator"
+ ]
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/deployment.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/deployment.yaml
new file mode 100644
index 00000000..ad62c9ea
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/deployment.yaml
@@ -0,0 +1,4 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
diff --git a/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/kustomization.yaml b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/kustomization.yaml
new file mode 100644
index 00000000..e3cf93bc
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/repo-walker/unsupported/unsupported-kustomize/app/kustomization.yaml
@@ -0,0 +1,15 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namePrefix: prod-
+components:
+ - ../component
+secretGenerator:
+ - name: creds
+ literals:
+ - password=s3cret
+openapi:
+ path: openapi/schema.json
+crds:
+ - crds/myresource.yaml
+resources:
+ - deployment.yaml