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