diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go
index 26484788..bec4fb5b 100644
--- a/cmd/manifest-analyzer/main_test.go
+++ b/cmd/manifest-analyzer/main_test.go
@@ -139,7 +139,7 @@ func TestRun_ScanJSON(t *testing.T) {
// scanRepoFixture builds a tiny two-folder repo: a plain KRM app folder and a kustomize
// overlay reaching an out-of-subtree base, so scan-repo reports both an accepted plain
-// candidate and a refused kustomize-overlay one.
+// candidate and an accepted kustomize-overlay one (render-root scoping adopts the overlay).
func scanRepoFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
@@ -167,7 +167,7 @@ func TestRun_ScanRepoText(t *testing.T) {
if code := run([]string{"--mode", "scan-repo", scanRepoFixture(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-unsupported"} {
+ for _, want := range []string{"candidates:", "kustomize-overlay", "accepted=2 refused=0"} {
if !strings.Contains(out.String(), want) {
t.Errorf("scan-repo text missing %q:\n%s", want, out.String())
}
diff --git a/docs/design/support-boundary/README.md b/docs/design/support-boundary/README.md
index 6e99f311..c6b7da7f 100644
--- a/docs/design/support-boundary/README.md
+++ b/docs/design/support-boundary/README.md
@@ -46,8 +46,9 @@ flowchart LR
The remaining overlay gap is deliberately narrow: creating a **new object** and adding its
`resources:` entry needs a placement/write-path correction. It is planned, not shipped.
-`scan-repo` likewise still labels external-base overlays as unsupported while its discovery
-classification catches up with the runtime.
+`scan-repo` now adopts external-base overlays too, matching the runtime: it reports a
+`kustomize-overlay` candidate as accepted and lets its `editable` count show how much the
+overlay owns (a pure passthrough overlay is adopted yet `editable: 0`).
## Active design work
diff --git a/docs/design/support-boundary/render-root-scoping.md b/docs/design/support-boundary/render-root-scoping.md
index e23190cc..949980d8 100644
--- a/docs/design/support-boundary/render-root-scoping.md
+++ b/docs/design/support-boundary/render-root-scoping.md
@@ -39,9 +39,10 @@ flowchart LR
whole overlay merely by existing.
- A base-owned field is refused rather than written through to the shared base.
-The discovery report has not caught up: `scan-repo` still uses
-`overlay-fan-out-unsupported` for these layouts. That is a classification follow-up, not a
-runtime boundary.
+The discovery report has caught up: `scan-repo` runs the same adoption gate over an
+external-base overlay's render scope, so a `kustomize-overlay` candidate is now reported
+**accepted** (the `overlay-fan-out-unsupported` reason is retired) with its `editable` count
+showing how much the overlay owns.
## 2. Why verification, not inversion
@@ -93,7 +94,8 @@ feature.
1. Correct overlay-local new-object placement, including a deterministic `resources:` entry.
2. Add missing `images:` and `replicas:` declarations only under the same verification proof.
-3. Flip discovery classification and add a dedicated cluster end-to-end overlay case.
+3. Discovery classification is flipped (an external-base overlay reports accepted); a dedicated
+ cluster end-to-end overlay case remains.
4. Author a narrow scalar strategic-merge patch for base-owned fields; lists, deletes, and
structural merges require separate decisions.
diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md
index 1716fc74..582acbb3 100644
--- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md
+++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md
@@ -136,14 +136,14 @@ flowchart TD
### Layout classification
Discovery reports the layout and its **scanner classification** as two distinct truths.
-The narrow external-base overlay runtime has shipped, but this scanner has not yet flipped
-its overlay classification (see below).
+The narrow external-base overlay runtime has shipped, and this scanner now matches it: it
+runs the same adoption gate over the overlay's render scope rather than blanket-refusing.
| `layout` | Meaning | Scanner output today |
|---|---|---|
| `plain` | Directory of KRM docs, explicit namespaces, no kustomization | ✅ supported today |
| `kustomize-single` | One render root; `namespace` + `resources`/`images`/`replicas` | ✅ supported today |
-| `kustomize-overlay` | Base + N overlay roots, one namespace per overlay | ⛔ reports `overlay-fan-out-unsupported`; runtime accepts the narrow existing-document/image/replica slice, and this classification needs to catch up |
+| `kustomize-overlay` | Base + N overlay roots, one namespace per overlay | ✅ adopted today; the render scope passes the gate and `editable` shows how much the overlay owns (a pure passthrough overlay is adopted yet `editable: 0`). Base-owned fields and new overlay objects stay write-time-planned |
| `refused-structural` | Helm inflation, generators with hash suffixes, `components`, `namePrefix`/`nameSuffix`, remote bases, `configurations`/`openapi`/`crds` | ⛔ permanent — the support contract |
| `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 |
@@ -160,11 +160,12 @@ its overlay classification (see below).
> cut therefore ships four candidate layouts: `plain`, `kustomize-single`,
> `kustomize-overlay`, `refused-structural`.
-The distinction between `overlay-fan-out-unsupported` and `refused-structural` remains
-load-bearing: the first currently identifies a **scanner lag** on a bounded supported
-layout, while the second is the **permanent** boundary. Discovery must never collapse them
-into one refusal. The classification follow-up should make the scanner match the runtime
-without widening either accepted layout or the structural wall.
+The distinction between an **adopted** `kustomize-overlay` and a **permanently refused**
+`refused-structural` render root stays load-bearing: the first is a bounded supported layout
+the operator renders through render-root scoping, the second is the permanent boundary.
+Discovery must never collapse them — an overlay must not be reported as a hopeless structural
+refusal, and helm inflation must not be reported as an adoptable overlay. The former
+`overlay-fan-out-unsupported` reason is retired now that the scanner matches the runtime.
## The report contract
@@ -283,11 +284,14 @@ refuses `openapi`/`crds` alongside `configurations`, matching the
**Classification findings.**
-- The overlay discriminator that trips `overlay-fan-out-unsupported` 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).
+- The overlay discriminator that marks a render root `kustomize-overlay` is precisely **the
+ base escaping the render root's own subtree** (`../../base` resolves outside the candidate).
+ A base nested *within* the subtree keeps the root `kustomize-single`. Both are adopted; the
+ distinction is only that an overlay renders — and cannot edit — an out-of-subtree base, so
+ its `editable` count can be lower than `rendered`. Acceptance of the overlay comes from
+ running the same gate the live writer runs over the overlay's **render scope** (the overlay
+ subtree plus the exact base files its graph reaches), so the report cannot drift from the
+ runtime; the former `overlay-fan-out-unsupported` reason is retired.
- `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
@@ -334,10 +338,11 @@ nested base (deduped `rendered`), an overlay base holding parked YAML (excluded
shipped — the acceptance gate, render-root computation
([images/replicas edit-through](finished/images-and-replicas-edit-through.md)),
and namespace inference. Discovery is additive tooling on top of the shared engine.
-- **Overlay support changes discovery's *output*, not its code.** Today overlay
- candidates still report `acceptedByOperator: false` + `overlay-fan-out-unsupported`.
- The pending classification change should report the shipped existing-document and
- `images:`/`replicas:` slice, while keeping new overlay resource creation planned.
+- **Overlay support changed discovery's *output*, not the operator.** An external-base
+ overlay candidate now reports `acceptedByOperator: true` (its render scope passes the same
+ gate the live writer runs) with an `editable` count that shows how much it owns — a pure
+ passthrough overlay is adopted yet `editable: 0`. New overlay object creation and base-owned
+ field edits stay write-time-planned; the scan reports folder adoption, not write capability.
## Test plan
@@ -382,12 +387,10 @@ nested base (deduped `rendered`), an overlay base holding parked YAML (excluded
remains a presentation choice.
4. **Library vs. CLI as the integration point** — _settled:_ library-first
(`ScanRepo`), CLI (`--mode scan-repo`) as the thin wrapper + CI gate.
-5. **`overlay-fan-out-unsupported` naming** — the reason code still 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`, or keep the published code — it
- is part of `pkg/manifestanalyzer`'s surface — and rely on the detail. _Not settled._
+5. **`overlay-fan-out-unsupported` naming** — _moot: retired._ Render-root scoping shipped and
+ the scanner now adopts external-base overlays, so this reason is no longer emitted. The
+ public `pkg/manifestanalyzer` constant is kept (deprecated) for source compatibility; the
+ naming debate is closed because the code is gone from the output.
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 scan-repo
diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go
index 19b4bd4d..be76485d 100644
--- a/internal/manifestanalyzer/scan_repo.go
+++ b/internal/manifestanalyzer/scan_repo.go
@@ -31,9 +31,10 @@ import (
// 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 while overlays stay unsupported: a kustomize-overlay
-// has a well-understood layout yet is not accepted until render-root scoping lands.
+// Layout is the structural shape of a candidate subtree. Layout and acceptedByOperator are
+// two distinct truths: a kustomize-overlay has a well-understood layout and is now adopted
+// when its render scope passes the gate, but its editable count can still be 0 when every
+// rendered field is base-owned.
type Layout string
const (
@@ -46,9 +47,10 @@ const (
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-unsupported reason — it flips to
- // accepted if render-root scoping ships.
+ // Render-root scoping shipped, so the operator adopts it when the adoption gate accepts
+ // its render scope; the editable count shows how much of the render the overlay owns (a
+ // pure passthrough overlay is adopted yet editable: 0). It is a distinct layout from
+ // kustomize-single because it still renders — and cannot edit — an out-of-subtree base.
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,
@@ -57,14 +59,14 @@ const (
LayoutRefusedStructural Layout = "refused-structural"
)
-// Refusal reason codes. The distinction between the two is load-bearing:
-// ReasonOverlayFanOutUnsupported is a forward-looking "not yet" that flips to accepted
-// if render-root scoping ships; ReasonRefusedStructural is the permanent boundary. Discovery must
-// never collapse them into one "refused".
-const (
- ReasonOverlayFanOutUnsupported = "overlay-fan-out-unsupported"
- ReasonRefusedStructural = "refused-structural"
-)
+// ReasonRefusedStructural is the permanent support boundary: a render root whose kustomization
+// uses a construct the writer cannot map back to editable source. It is the only render-root
+// refusal reason now that external-base overlays are adopted through render-root scoping (the
+// former forward-looking overlay-fan-out-unsupported reason is retired; the public
+// pkg/manifestanalyzer constant is kept for consumers pinning the string, but the classifier no
+// longer emits it). An overlay refused for a real structural fault carries that fault's own
+// gate-issue code instead.
+const ReasonRefusedStructural = "refused-structural"
// RefusalReason is one machine-readable reason a candidate is not accepted, with a
// human detail. A candidate carries none when accepted.
@@ -191,7 +193,7 @@ func scanRepoFS(ctx context.Context, fsys fs.FS) RepoReport {
candidates := make([]RepoCandidate, 0)
for _, rootDir := range renderRoots(kusts) {
- candidates = append(candidates, classifyRenderRoot(ctx, fsys, rootDir, kusts, kustContent, store))
+ candidates = append(candidates, classifyRenderRoot(ctx, fsys, rootDir, scan, kusts, kustContent, store))
}
candidates = append(candidates, plainCandidates(ctx, fsys, store, kusts, ownedFiles)...)
@@ -205,12 +207,15 @@ func scanRepoFS(ctx context.Context, fsys fs.FS) RepoReport {
}
// 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.
+// (unsupported kustomization), an external-base kustomize-overlay, or a self-contained
+// kustomize-single. An overlay is no longer refused merely for reaching an out-of-subtree
+// base — render-root scoping shipped, so the operator adopts it — and instead runs the same
+// adoption gate over its render scope.
func classifyRenderRoot(
ctx context.Context,
fsys fs.FS,
rootDir string,
+ scan FolderScan,
kusts map[string]*kustomizationDoc,
kustContent map[string][]byte,
store *ManifestStore,
@@ -233,13 +238,19 @@ func classifyRenderRoot(
outsideBases := outOfSubtreeBases(rootDir, kusts)
if len(outsideBases) > 0 {
+ // External-base overlay: the operator now renders it through render-root scoping, so it
+ // is adopted when the same gate the live writer runs accepts its render scope. The
+ // editable count still shows how much the overlay owns — a pure passthrough overlay is
+ // adoptable yet editable: 0, since every field is base-owned. A gate refusal (foreign
+ // content in the overlay, an unbuildable base, an unsupported nested kustomization)
+ // surfaces as its own reason rather than a blanket overlay refusal.
c.Layout = LayoutKustomizeOverlay
- c.AcceptedByOperator = false
c.ReadScope = outsideBases
- c.RefusalReasons = []RefusalReason{{
- Code: ReasonOverlayFanOutUnsupported,
- Detail: overlayFanOutDetail(outsideBases[0], kusts),
- }}
+ acc := overlayCandidateAcceptance(ctx, rootDir, scan, kusts)
+ c.AcceptedByOperator = acc.Accepted
+ if !acc.Accepted {
+ c.RefusalReasons = issuesToReasons(acc.Issues)
+ }
c.Resources = countResources(store, rootDir, rendered)
return c
}
@@ -304,6 +315,78 @@ func plainCandidates(
return out
}
+// overlayCandidateAcceptance runs the operator's own adoption gate over an external-base
+// overlay's RENDER SCOPE — the overlay subtree PLUS the exact base files its resources/patches
+// graph reaches — so the discovery report matches what the live writer's render-root scoping
+// (internal/git/render_scope.go) decides. Only the files the graph actually reaches enter the
+// scope, never a whole base directory, so parked YAML a base does not reference can never
+// refuse the overlay (mirroring the runtime's "read scope is the exact reachable file set").
+//
+// The scoped store keeps repo-relative paths, so a `../../base` reference resolves within it
+// exactly as kustomize resolves it. Acceptance here is folder adoption (GitPathAccepted); the
+// write half — editable overlay-local documents and declared images/replicas, but never a
+// base-owned field or a new overlay object — is out of scope for a read-only report, and the
+// candidate's editable count already reflects how much of the render the overlay owns.
+func overlayCandidateAcceptance(
+ ctx context.Context,
+ rootDir string,
+ scan FolderScan,
+ kusts map[string]*kustomizationDoc,
+) Acceptance {
+ reached := renderScopePaths(rootDir, kusts)
+ scoped := FolderScan{}
+ for _, f := range scan.YAMLFiles {
+ if pathWithin(f.Path, rootDir) || setContains(reached, f.Path) {
+ scoped.YAMLFiles = append(scoped.YAMLFiles, f)
+ }
+ }
+ // Only the overlay's OWN non-YAML/foreign content bears on its acceptance; a base's loose
+ // files are never read (the render scope pulls in referenced files only), just as at runtime.
+ for _, p := range scan.NonYAML {
+ if pathWithin(p, rootDir) {
+ scoped.NonYAML = append(scoped.NonYAML, p)
+ }
+ }
+ for _, fe := range scan.Foreign {
+ if pathWithin(fe.Path, rootDir) {
+ scoped.Foreign = append(scoped.Foreign, fe)
+ }
+ }
+ store := buildStore(ctx, scoped, nil, WriterAllowlist())
+ return Accept(store, AcceptancePolicy{Allowlist: WriterAllowlist()})
+}
+
+// renderScopePaths returns the files an overlay render root reaches through its resources and
+// patches graph: every referenced resource file, plus each reached kustomization's own file
+// and strategic-merge patch inputs. These are the exact build inputs kustomize loads, so the
+// scoped acceptance store can render `../../base` without importing a base's unreferenced
+// content. Paths are scan-root-relative slash paths, matching the store's file keys.
+func renderScopePaths(rootDir string, kusts map[string]*kustomizationDoc) map[string]struct{} {
+ paths := map[string]struct{}{}
+ for f := range reachedResourceFilesFrom(rootDir, kusts) {
+ paths[f] = struct{}{}
+ }
+ dirs := reachedKustomizationDirs(rootDir, kusts)
+ dirs[rootDir] = struct{}{}
+ for dir := range dirs {
+ doc := kusts[dir]
+ if doc == nil {
+ continue
+ }
+ paths[doc.path] = struct{}{}
+ for _, patch := range doc.patches {
+ paths[patch] = struct{}{}
+ }
+ }
+ return paths
+}
+
+// setContains reports membership in a set-like map.
+func setContains(set map[string]struct{}, key string) bool {
+ _, ok := set[key]
+ return ok
+}
+
// 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
@@ -425,21 +508,6 @@ func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{
return out
}
-// overlayFanOutDetail explains why an overlay is unsupported: 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 required",
- base, shared)
-}
-
// refusedStructuralDetail names the specific unsupported kustomize features so the
// refusal is actionable, not a bare "refused". The features come off the parsed doc, which is
// the same judgement the acceptance gate reads, so the scan and the operator cannot drift.
diff --git a/internal/manifestanalyzer/scan_repo_test.go b/internal/manifestanalyzer/scan_repo_test.go
index 8981c012..840cf654 100644
--- a/internal/manifestanalyzer/scan_repo_test.go
+++ b/internal/manifestanalyzer/scan_repo_test.go
@@ -88,32 +88,46 @@ func checkGoldenFixture(t *testing.T, fixture string) {
}
}
-// TestScanRepo_RefusalCodesStayDistinct pins the load-bearing distinction the design
-// calls out: a forward-looking overlay-fan-out-unsupported must never collapse into the
-// permanent refused-structural boundary. base-overlays is the former; helm-inflation and
-// unsupported-kustomize are the latter.
-func TestScanRepo_RefusalCodesStayDistinct(t *testing.T) {
- cases := []struct {
- fixture string
- wantCode string
- layout Layout
- }{
- {"unsupported/base-overlays", ReasonOverlayFanOutUnsupported, LayoutKustomizeOverlay},
- {"unsupported/overlay-parked-base", ReasonOverlayFanOutUnsupported, 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 := ScanRepo(context.Background(), filepath.Join("testdata", "scan-repo", tc.fixture))
- if err != nil {
- t.Fatalf("ScanRepo: %v", err)
- }
- if len(rep.Candidates) == 0 {
- t.Fatalf("expected at least one candidate")
+// scanFixtureCandidates scans one corpus fixture and returns its candidates, failing the
+// test on a scan error or an empty candidate set.
+func scanFixtureCandidates(t *testing.T, fixture string) []RepoCandidate {
+ t.Helper()
+ rep, err := ScanRepo(context.Background(), filepath.Join("testdata", "scan-repo", fixture))
+ if err != nil {
+ t.Fatalf("ScanRepo(%s): %v", fixture, err)
+ }
+ if len(rep.Candidates) == 0 {
+ t.Fatalf("%s: expected at least one candidate", fixture)
+ }
+ return rep.Candidates
+}
+
+// TestScanRepo_ExternalBaseOverlayAdopted pins that render-root scoping's discovery half
+// shipped: an external-base overlay is ADOPTED (its render scope passes the same gate the
+// live writer runs), no longer refused with the retired overlay-fan-out reason.
+func TestScanRepo_ExternalBaseOverlayAdopted(t *testing.T) {
+ for _, fixture := range []string{"supported/base-overlays", "supported/overlay-parked-base"} {
+ t.Run(fixture, func(t *testing.T) {
+ for _, c := range scanFixtureCandidates(t, fixture) {
+ if c.Layout != LayoutKustomizeOverlay {
+ t.Errorf("%s: candidate %s layout = %q, want %q", fixture, c.Path, c.Layout, LayoutKustomizeOverlay)
+ }
+ if !c.AcceptedByOperator {
+ t.Errorf("%s: external-base overlay %s should now be adopted; reasons=%+v",
+ fixture, c.Path, c.RefusalReasons)
+ }
}
- for _, c := range rep.Candidates {
- assertRefused(t, tc.fixture, c, tc.layout, tc.wantCode)
+ })
+ }
+}
+
+// TestScanRepo_StructuralStaysRefused pins the other half of the load-bearing distinction:
+// the permanent refused-structural boundary must never collapse into an adoptable overlay.
+func TestScanRepo_StructuralStaysRefused(t *testing.T) {
+ for _, fixture := range []string{"unsupported/helm-inflation", "unsupported/unsupported-kustomize"} {
+ t.Run(fixture, func(t *testing.T) {
+ for _, c := range scanFixtureCandidates(t, fixture) {
+ assertRefused(t, fixture, c, LayoutRefusedStructural, ReasonRefusedStructural)
}
})
}
@@ -138,7 +152,7 @@ func assertRefused(t *testing.T, fixture string, c RepoCandidate, layout Layout,
// 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 TestScanRepo_RenderedCountDedupesNestedBase(t *testing.T) {
- fixture := filepath.Join("testdata", "scan-repo", "unsupported", "overlay-nested-base")
+ fixture := filepath.Join("testdata", "scan-repo", "supported", "overlay-nested-base")
rep, err := ScanRepo(context.Background(), fixture)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
@@ -159,7 +173,7 @@ func TestScanRepo_RenderedCountDedupesNestedBase(t *testing.T) {
// 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 TestScanRepo_RenderedExcludesParkedYAML(t *testing.T) {
- fixture := filepath.Join("testdata", "scan-repo", "unsupported", "overlay-parked-base")
+ fixture := filepath.Join("testdata", "scan-repo", "supported", "overlay-parked-base")
rep, err := ScanRepo(context.Background(), fixture)
if err != nil {
t.Fatalf("ScanRepo: %v", err)
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json
similarity index 52%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays.golden.json
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json
index e8728b62..73532bd2 100644
--- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays.golden.json
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json
@@ -3,13 +3,7 @@
{
"path": "overlays/acc",
"layout": "kustomize-overlay",
- "acceptedByOperator": false,
- "refusalReasons": [
- {
- "code": "overlay-fan-out-unsupported",
- "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping required"
- }
- ],
+ "acceptedByOperator": true,
"renderRoot": true,
"readScope": [
"base"
@@ -24,13 +18,7 @@
{
"path": "overlays/prod",
"layout": "kustomize-overlay",
- "acceptedByOperator": false,
- "refusalReasons": [
- {
- "code": "overlay-fan-out-unsupported",
- "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping required"
- }
- ],
+ "acceptedByOperator": true,
"renderRoot": true,
"readScope": [
"base"
@@ -45,13 +33,7 @@
{
"path": "overlays/test",
"layout": "kustomize-overlay",
- "acceptedByOperator": false,
- "refusalReasons": [
- {
- "code": "overlay-fan-out-unsupported",
- "detail": "base \"base\" is read from outside this folder's subtree and is shared by 3 render root(s); render-root scoping required"
- }
- ],
+ "acceptedByOperator": true,
"renderRoot": true,
"readScope": [
"base"
@@ -68,7 +50,7 @@
"candidatesByLayout": {
"kustomize-overlay": 3
},
- "accepted": 0,
- "refused": 3
+ "accepted": 3,
+ "refused": 0
}
}
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/deployment.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/deployment.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/deployment.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/deployment.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/service.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/service.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/base/service.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/base/service.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/acc/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/acc/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/acc/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/acc/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/prod/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/prod/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/prod/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/prod/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/test/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/base-overlays/overlays/test/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays/overlays/test/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json
similarity index 55%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base.golden.json
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json
index 318d7fd3..e9ef5b68 100644
--- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base.golden.json
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json
@@ -3,13 +3,7 @@
{
"path": "overlays/test",
"layout": "kustomize-overlay",
- "acceptedByOperator": false,
- "refusalReasons": [
- {
- "code": "overlay-fan-out-unsupported",
- "detail": "base \"base\" is read from outside this folder's subtree and is shared by 1 render root(s); render-root scoping required"
- }
- ],
+ "acceptedByOperator": true,
"renderRoot": true,
"readScope": [
"base"
@@ -26,7 +20,7 @@
"candidatesByLayout": {
"kustomize-overlay": 1
},
- "accepted": 0,
- "refused": 1
+ "accepted": 1,
+ "refused": 0
}
}
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/common/configmap.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/common/configmap.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/common/configmap.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/common/configmap.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/common/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/common/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/common/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/common/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/deployment.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/deployment.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/deployment.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/deployment.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/base/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/base/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/overlays/test/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-nested-base/overlays/test/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base/overlays/test/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json
similarity index 55%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base.golden.json
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json
index 13781d47..7cf5c1ed 100644
--- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base.golden.json
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json
@@ -3,13 +3,7 @@
{
"path": "overlays/test",
"layout": "kustomize-overlay",
- "acceptedByOperator": false,
- "refusalReasons": [
- {
- "code": "overlay-fan-out-unsupported",
- "detail": "base \"base\" is read from outside this folder's subtree and is shared by 1 render root(s); render-root scoping required"
- }
- ],
+ "acceptedByOperator": true,
"renderRoot": true,
"readScope": [
"base"
@@ -26,7 +20,7 @@
"candidatesByLayout": {
"kustomize-overlay": 1
},
- "accepted": 0,
- "refused": 1
+ "accepted": 1,
+ "refused": 0
}
}
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/deployment.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/deployment.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/deployment.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/deployment.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/kustomization.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/parked.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/parked.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/base/parked.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/base/parked.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/overlays/test/kustomization.yaml
similarity index 100%
rename from internal/manifestanalyzer/testdata/scan-repo/unsupported/overlay-parked-base/overlays/test/kustomization.yaml
rename to internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base/overlays/test/kustomization.yaml
diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go
index 6d7072ee..8144f9fc 100644
--- a/pkg/manifestanalyzer/repo.go
+++ b/pkg/manifestanalyzer/repo.go
@@ -30,11 +30,15 @@ const (
LayoutRefusedStructural Layout = "refused-structural"
)
-// Refusal reason codes. The distinction is load-bearing and a consumer must not collapse
-// it: one is a "not yet", the other is permanent.
+// Refusal reason codes a candidate may carry.
const (
- // ReasonOverlayFanOutUnsupported is the forward-looking refusal that flips to accepted
- // when render-root scoping ships.
+ // ReasonOverlayFanOutUnsupported was the forward-looking refusal for an external-base
+ // overlay. Render-root scoping shipped, so the scanner now ADOPTS such an overlay and no
+ // longer emits this code; it is retained only so a consumer pinning the string still
+ // compiles. A kustomize-overlay candidate is accepted (its editable count shows how much
+ // it owns); an overlay refused for a real fault carries that fault's own code.
+ //
+ // Deprecated: no longer emitted; kept for source compatibility.
ReasonOverlayFanOutUnsupported = "overlay-fan-out-unsupported"
// ReasonRefusedStructural is the permanent support boundary.
ReasonRefusedStructural = "refused-structural"
diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go
index 294713a9..fd1bf6eb 100644
--- a/pkg/manifestanalyzer/repo_test.go
+++ b/pkg/manifestanalyzer/repo_test.go
@@ -48,43 +48,41 @@ func TestScanRepo_PlainPerEnvironmentFoldersAreCandidates(t *testing.T) {
require.Zero(t, report.Summary.Refused)
}
-// The two refusal codes mean different things and a consumer must be able to tell them
-// apart: one is "not yet, when render-root scoping ships", the other is the permanent
-// support boundary. Collapsing them would mislabel an onboardable repository as hopeless.
-func TestScanRepo_RefusalCodesStayDistinct(t *testing.T) {
+// An external-base overlay and a permanent structural refusal are different truths a consumer
+// must be able to tell apart: render-root scoping shipped, so the overlay is ADOPTED, while
+// helm inflation and unsupported kustomize stay the permanent support boundary. Collapsing the
+// two would either mislabel an onboardable overlay as hopeless or an unbuildable folder as fine.
+func TestScanRepo_OverlayAdoptedDistinctFromStructural(t *testing.T) {
t.Parallel()
- tests := map[string]struct {
- group, name string
- wantCode string
- wantLayout manifestanalyzer.Layout
- }{
- "overlay fan-out is a not-yet": {
- group: "unsupported", name: "base-overlays",
- wantCode: manifestanalyzer.ReasonOverlayFanOutUnsupported,
- wantLayout: manifestanalyzer.LayoutKustomizeOverlay,
- },
- "helm inflation is permanent": {
- group: "unsupported", name: "helm-inflation",
- wantCode: manifestanalyzer.ReasonRefusedStructural,
- wantLayout: manifestanalyzer.LayoutRefusedStructural,
- },
- "unsupported kustomize is permanent": {
- group: "unsupported", name: "unsupported-kustomize",
- wantCode: manifestanalyzer.ReasonRefusedStructural,
- wantLayout: manifestanalyzer.LayoutRefusedStructural,
- },
+ t.Run("external-base overlay is adopted", func(t *testing.T) {
+ t.Parallel()
+ report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "base-overlays"))
+ require.NoError(t, err)
+
+ var seen bool
+ for _, cand := range report.Candidates {
+ require.Equal(t, manifestanalyzer.LayoutKustomizeOverlay, cand.Layout)
+ require.True(t, cand.AcceptedByOperator, "render-root scoping adopts the external-base overlay")
+ require.Empty(t, cand.RefusalReasons)
+ seen = true
+ }
+ require.True(t, seen, "expected at least one kustomize-overlay candidate")
+ })
+
+ structural := map[string]string{
+ "helm inflation is permanent": "helm-inflation",
+ "unsupported kustomize is permanent": "unsupported-kustomize",
}
-
- for name, tc := range tests {
+ for name, fixtureName := range structural {
t.Run(name, func(t *testing.T) {
t.Parallel()
- report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, tc.group, tc.name))
+ report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "unsupported", fixtureName))
require.NoError(t, err)
var seen bool
for _, cand := range report.Candidates {
- if cand.Layout != tc.wantLayout {
+ if cand.Layout != manifestanalyzer.LayoutRefusedStructural {
continue
}
seen = true
@@ -93,9 +91,9 @@ func TestScanRepo_RefusalCodesStayDistinct(t *testing.T) {
for _, reason := range cand.RefusalReasons {
codes = append(codes, reason.Code)
}
- require.Contains(t, codes, tc.wantCode)
+ require.Contains(t, codes, manifestanalyzer.ReasonRefusedStructural)
}
- require.True(t, seen, "expected at least one %s candidate", tc.wantLayout)
+ require.True(t, seen, "expected at least one refused-structural candidate")
})
}
}
diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md
index f0e1240b..7c66e46e 100644
--- a/test/fixtures/gitops-layouts/support-today.md
+++ b/test/fixtures/gitops-layouts/support-today.md
@@ -26,12 +26,12 @@ Reading rules:
|---|---:|---|---:|---:|---|---|---|
| 1-desired-state/argocd-app-of-apps | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None |
| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml: ci-metadata.yaml: YAML is not a Kubernetes manifest |
-| 1-desired-state/flux-monorepo | 0 | Partial | 4 | 2 | kustomize-overlay=2, kustomize-single=4 | - | overlay-fan-out-unsupported: base "apps/base/frontend" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required
overlay-fan-out-unsupported: base "apps/base/frontend" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
+| 1-desired-state/flux-monorepo | 0 | All reported candidates accepted | 6 | 0 | kustomize-overlay=2, kustomize-single=4 | - | None |
| 1-desired-state/repo-per-environment | 0 | All reported candidates accepted | 9 | 0 | plain=9 | - | None |
| 2-rendered/argocd-external-helm | 0 | Partial | 2 | 1 | plain=3 | - | non-krm-yaml: values.yaml: YAML is not a Kubernetes manifest |
| 2-rendered/helm-chart | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None |
| 2-rendered/helm-environment-values | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None |
-| 2-rendered/kustomize-overlay-minimal | 0 | No reported candidates accepted | 0 | 2 | kustomize-overlay=2 | - | overlay-fan-out-unsupported: base "base" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required
overlay-fan-out-unsupported: base "base" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
+| 2-rendered/kustomize-overlay-minimal | 0 | All reported candidates accepted | 2 | 0 | kustomize-overlay=2 | - | None |
| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural: kustomization uses unsupported feature(s): remote-base
refused-structural: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural: kustomization uses unsupported feature(s): configMapGenerator, namePrefix |
| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural: kustomization uses unsupported feature(s): namePrefix
refused-structural: kustomization uses unsupported feature(s): namePrefix |
| 3-expanded/argocd-applicationset-directories | 0 | All reported candidates accepted | 5 | 0 | plain=5 | - | None |
@@ -68,13 +68,13 @@ Unsupported constructs: `none`. Fleet root: `false`.
## 1-desired-state/flux-monorepo
-Reported rc `0`. Accepted `4`, refused `2`.
+Reported rc `0`. Accepted `6`, refused `0`.
Unsupported constructs: `none`. Fleet root: `false`.
| Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons |
|---|---|---|---|---|---|
-| `apps/production` | `kustomize-overlay` | false | `production` | 2/0/0 | overlay-fan-out-unsupported: base "apps/base/frontend" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
-| `apps/staging` | `kustomize-overlay` | false | `staging` | 2/0/0 | overlay-fan-out-unsupported: base "apps/base/frontend" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
+| `apps/production` | `kustomize-overlay` | true | `production` | 2/0/0 | none |
+| `apps/staging` | `kustomize-overlay` | true | `staging` | 2/0/0 | none |
| `clusters/production` | `kustomize-single` | true | `flux-system` | 7/7/0 | none |
| `clusters/staging` | `kustomize-single` | true | `flux-system` | 7/7/0 | none |
| `infrastructure/configs` | `kustomize-single` | true | `-` | 1/1/0 | none |
@@ -128,13 +128,13 @@ Unsupported constructs: `none`. Fleet root: `false`.
## 2-rendered/kustomize-overlay-minimal
-Reported rc `0`. Accepted `0`, refused `2`.
+Reported rc `0`. Accepted `2`, refused `0`.
Unsupported constructs: `none`. Fleet root: `false`.
| Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons |
|---|---|---|---|---|---|
-| `overlays/production` | `kustomize-overlay` | false | `frontend-production` | 2/0/0 | overlay-fan-out-unsupported: base "base" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
-| `overlays/staging` | `kustomize-overlay` | false | `frontend-staging` | 2/0/0 | overlay-fan-out-unsupported: base "base" is read from outside this folder's subtree and is shared by 2 render root(s); render-root scoping required |
+| `overlays/production` | `kustomize-overlay` | true | `frontend-production` | 2/0/0 | none |
+| `overlays/staging` | `kustomize-overlay` | true | `frontend-staging` | 2/0/0 | none |
## 2-rendered/kustomize-overlays