@intentius/chant-lexicon-k8s 0.42.1 → 0.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/api/classify.d.ts.map +1 -1
  2. package/dist/api/connect.d.ts.map +1 -1
  3. package/dist/codegen/docs.d.ts.map +1 -1
  4. package/dist/components/argo-app.d.ts +104 -0
  5. package/dist/components/argo-app.d.ts.map +1 -0
  6. package/dist/components/builders.d.ts +8 -0
  7. package/dist/components/builders.d.ts.map +1 -1
  8. package/dist/components/capability-plugin.d.ts +1 -0
  9. package/dist/components/capability-plugin.d.ts.map +1 -1
  10. package/dist/components/flux-reconcile.d.ts +84 -0
  11. package/dist/components/flux-reconcile.d.ts.map +1 -0
  12. package/dist/components/index.d.ts +2 -0
  13. package/dist/components/index.d.ts.map +1 -1
  14. package/dist/components/kustomize-apply.d.ts +29 -7
  15. package/dist/components/kustomize-apply.d.ts.map +1 -1
  16. package/dist/composites/flux-app.d.ts +132 -0
  17. package/dist/composites/flux-app.d.ts.map +1 -0
  18. package/dist/composites/index.d.ts +2 -0
  19. package/dist/composites/index.d.ts.map +1 -1
  20. package/dist/config-schema.d.ts +3 -0
  21. package/dist/config-schema.d.ts.map +1 -1
  22. package/dist/config.d.ts +32 -5
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/crd/crd-sources.d.ts.map +1 -1
  25. package/dist/crd/parser.d.ts.map +1 -1
  26. package/dist/describe-resources.d.ts +10 -8
  27. package/dist/describe-resources.d.ts.map +1 -1
  28. package/dist/generated/index.d.ts +15 -4
  29. package/dist/generated/index.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/integrity.json +10 -6
  33. package/dist/kube/connect.d.ts +5 -4
  34. package/dist/kube/connect.d.ts.map +1 -1
  35. package/dist/kube/get.d.ts +4 -4
  36. package/dist/kustomize/render.d.ts +15 -0
  37. package/dist/kustomize/render.d.ts.map +1 -0
  38. package/dist/kustomize/rendered-entity.d.ts +39 -0
  39. package/dist/kustomize/rendered-entity.d.ts.map +1 -0
  40. package/dist/kustomize/root.d.ts +19 -0
  41. package/dist/kustomize/root.d.ts.map +1 -0
  42. package/dist/lint/audit-catalog.d.ts.map +1 -1
  43. package/dist/lint/post-synth/argo-helpers.d.ts +2 -0
  44. package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
  45. package/dist/lint/post-synth/flux002.d.ts +14 -0
  46. package/dist/lint/post-synth/flux002.d.ts.map +1 -0
  47. package/dist/lint/post-synth/flux003.d.ts +17 -0
  48. package/dist/lint/post-synth/flux003.d.ts.map +1 -0
  49. package/dist/lint/post-synth/index.d.ts.map +1 -1
  50. package/dist/lint/rules/flux-source-ref-pin.d.ts +16 -0
  51. package/dist/lint/rules/flux-source-ref-pin.d.ts.map +1 -0
  52. package/dist/manifest.json +1 -1
  53. package/dist/meta.json +92 -16
  54. package/dist/okf/index.md +7 -1
  55. package/dist/okf/rules/FLUX001.md +17 -0
  56. package/dist/okf/rules/FLUX002.md +19 -0
  57. package/dist/okf/rules/FLUX003.md +15 -0
  58. package/dist/okf/types/Addon.md +14 -0
  59. package/dist/okf/types/Bucket.md +4 -0
  60. package/dist/okf/types/{HelmChart.md → FluxHelmChart.md} +2 -2
  61. package/dist/okf/types/GitRepository.md +5 -0
  62. package/dist/okf/types/HelmChartConfig.md +14 -0
  63. package/dist/okf/types/K3sHelmChart.md +14 -0
  64. package/dist/okf/types/Kustomization.md +6 -0
  65. package/dist/okf/types/Namespace.md +1 -0
  66. package/dist/okf/types/Node.md +1 -0
  67. package/dist/okf/types/OCIRepository.md +4 -0
  68. package/dist/plugin.d.ts.map +1 -1
  69. package/dist/rules/argo-helpers.ts +2 -0
  70. package/dist/rules/flux-source-ref-pin.ts +52 -0
  71. package/dist/rules/flux002.ts +77 -0
  72. package/dist/rules/flux003.ts +75 -0
  73. package/dist/serializer.d.ts.map +1 -1
  74. package/dist/skills/chant-k8s-flux.md +162 -0
  75. package/dist/types/index.d.ts +41 -3
  76. package/dist/validate.d.ts.map +1 -1
  77. package/package.json +3 -3
  78. package/src/api/classify.test.ts +37 -0
  79. package/src/api/classify.ts +11 -1
  80. package/src/api/connect.ts +10 -0
  81. package/src/codegen/docs.ts +11 -0
  82. package/src/components/argo-app.test.ts +137 -0
  83. package/src/components/argo-app.ts +163 -0
  84. package/src/components/builders.ts +8 -0
  85. package/src/components/capability-plugin.ts +11 -1
  86. package/src/components/flux-reconcile.test.ts +145 -0
  87. package/src/components/flux-reconcile.ts +159 -0
  88. package/src/components/index.ts +13 -0
  89. package/src/components/kustomize-apply.ts +12 -41
  90. package/src/composites/composites.test.ts +122 -0
  91. package/src/composites/flux-app.ts +274 -0
  92. package/src/composites/index.ts +9 -0
  93. package/src/config-augmentation.test.ts +72 -0
  94. package/src/config-schema.ts +5 -0
  95. package/src/config.ts +33 -5
  96. package/src/crd/crd-sources.ts +37 -0
  97. package/src/crd/parser.ts +6 -0
  98. package/src/describe-resources.test.ts +290 -6
  99. package/src/describe-resources.ts +235 -127
  100. package/src/generated/index.d.ts +41 -3
  101. package/src/generated/index.ts +15 -4
  102. package/src/generated/lexicon-k8s.json +92 -16
  103. package/src/generated/operations.json +48 -0
  104. package/src/index.ts +4 -0
  105. package/src/kube/connect.ts +5 -4
  106. package/src/kube/get.ts +4 -4
  107. package/src/kustomize/render.ts +84 -0
  108. package/src/kustomize/rendered-entity.ts +72 -0
  109. package/src/kustomize/root.test.ts +268 -0
  110. package/src/kustomize/root.ts +109 -0
  111. package/src/lint/audit-catalog.ts +2 -0
  112. package/src/lint/post-synth/argo-helpers.ts +2 -0
  113. package/src/lint/post-synth/flux002.ts +77 -0
  114. package/src/lint/post-synth/flux003.ts +75 -0
  115. package/src/lint/post-synth/index.ts +4 -0
  116. package/src/lint/post-synth/post-synth.test.ts +132 -0
  117. package/src/lint/rules/flux-source-ref-pin.ts +52 -0
  118. package/src/lint/rules/rules.test.ts +51 -0
  119. package/src/plugin.test.ts +2 -0
  120. package/src/plugin.ts +47 -0
  121. package/src/serializer.ts +34 -4
  122. package/src/skills/chant-k8s-flux.md +162 -0
  123. package/src/testdata/kustomize-root/base/deployment.yaml +17 -0
  124. package/src/testdata/kustomize-root/base/kustomization.yaml +3 -0
  125. package/src/testdata/kustomize-root/base/service.yaml +9 -0
  126. package/src/testdata/kustomize-root/overlays/prod/kustomization.yaml +6 -0
  127. package/src/validate.ts +4 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Kustomize build roots (#1548 piece 3). What must hold: a configured root
3
+ * renders through the shared runner (kustomize first, kubectl fallback) and
4
+ * its documents become verbatim manifest entities that reach the BUILD
5
+ * output — serialized by the k8s serializer with ownership stamped, carrying
6
+ * the overlay-dir provenance annotation, visible to post-synth checks — and
7
+ * the offline failure modes are sentences naming the problem (the dir, the
8
+ * kustomization file, the binaries), never a stack trace out of a subprocess.
9
+ */
10
+ import { describe, test, expect } from "vitest";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ import { mkdir, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { build } from "@intentius/chant";
16
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
17
+ import { renderKustomizeRoots } from "./root";
18
+ import { renderCommand } from "./render";
19
+ import { KUSTOMIZE_ROOT_ANNOTATION, isRenderedManifestEntity } from "./rendered-entity";
20
+ import { k8sSerializer } from "../serializer";
21
+ import { k8sPlugin } from "../plugin";
22
+ import { wk8005 } from "../lint/post-synth/wk8005";
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const fixtureRoot = join(here, "..", "testdata", "kustomize-root");
26
+
27
+ /** What `kustomize build overlays/prod` over the fixture would emit. */
28
+ const RENDERED = `apiVersion: v1
29
+ kind: Service
30
+ metadata:
31
+ name: prod-web
32
+ spec:
33
+ ports:
34
+ - port: 80
35
+ selector:
36
+ app: web
37
+ ---
38
+ apiVersion: apps/v1
39
+ kind: Deployment
40
+ metadata:
41
+ name: prod-web
42
+ spec:
43
+ replicas: 3
44
+ selector:
45
+ matchLabels:
46
+ app: web
47
+ template:
48
+ metadata:
49
+ labels:
50
+ app: web
51
+ spec:
52
+ containers:
53
+ - name: web
54
+ image: nginx:1.27
55
+ env:
56
+ - name: DB_PASSWORD
57
+ value: hunter2
58
+ ---
59
+ apiVersion: cert-manager.io/v1
60
+ kind: Certificate
61
+ metadata:
62
+ name: prod-web-tls
63
+ spec:
64
+ secretName: prod-web-tls
65
+ dnsNames:
66
+ - web.example.com
67
+ `;
68
+
69
+ const cannedRunner = (commands: string[]) => async (command: string) => {
70
+ commands.push(command);
71
+ return { stdout: RENDERED };
72
+ };
73
+
74
+ describe("renderKustomizeRoots", () => {
75
+ test("renders a configured root into verbatim entities with deterministic keys and provenance", async () => {
76
+ const commands: string[] = [];
77
+ const { entities, warnings } = await renderKustomizeRoots({
78
+ projectRoot: fixtureRoot,
79
+ roots: ["overlays/prod"],
80
+ run: cannedRunner(commands),
81
+ });
82
+
83
+ expect(commands).toEqual([renderCommand(join(fixtureRoot, "overlays/prod"), "kustomize")]);
84
+ expect(warnings).toEqual([]);
85
+ expect([...entities.keys()]).toEqual([
86
+ "overlays/prod/serviceProdWeb",
87
+ "overlays/prod/deploymentProdWeb",
88
+ "overlays/prod/certificateProdWebTls",
89
+ ]);
90
+
91
+ const deployment = entities.get("overlays/prod/deploymentProdWeb")!;
92
+ expect(isRenderedManifestEntity(deployment)).toBe(true);
93
+ expect(deployment.lexicon).toBe("k8s");
94
+ // The generated operation surface's naming, so `lifecycle diff --live`
95
+ // observes a rendered Deployment through the same reader a declared one uses.
96
+ expect(deployment.entityType).toBe("K8s::Apps::Deployment");
97
+ expect(entities.get("overlays/prod/certificateProdWebTls")!.entityType).toBe("K8s::Cert-manager::Certificate");
98
+
99
+ const props = (deployment as unknown as { props: Record<string, unknown> }).props;
100
+ const metadata = props.metadata as { annotations: Record<string, string> };
101
+ expect(metadata.annotations[KUSTOMIZE_ROOT_ANNOTATION]).toBe("overlays/prod");
102
+ expect((props.spec as { replicas: number }).replicas).toBe(3);
103
+ });
104
+
105
+ test("falls back to kubectl's vendored kustomize when the standalone binary is missing", async () => {
106
+ const commands: string[] = [];
107
+ const { entities } = await renderKustomizeRoots({
108
+ projectRoot: fixtureRoot,
109
+ roots: ["overlays/prod"],
110
+ run: async (command) => {
111
+ commands.push(command);
112
+ if (command.startsWith("kustomize ")) throw new Error("spawn kustomize ENOENT");
113
+ return { stdout: RENDERED };
114
+ },
115
+ });
116
+ const dir = join(fixtureRoot, "overlays/prod");
117
+ expect(commands).toEqual([renderCommand(dir, "kustomize"), renderCommand(dir, "kubectl")]);
118
+ expect(entities.size).toBe(3);
119
+ });
120
+
121
+ test("both binaries missing fails with a message naming them, not a stack trace", async () => {
122
+ await expect(
123
+ renderKustomizeRoots({
124
+ projectRoot: fixtureRoot,
125
+ roots: ["overlays/prod"],
126
+ run: async (command) => {
127
+ throw new Error(command.startsWith("kustomize ") ? "spawn kustomize ENOENT" : "spawn kubectl ENOENT");
128
+ },
129
+ }),
130
+ ).rejects.toThrow(/neither the `kustomize` binary nor `kubectl`.*found on PATH/);
131
+ });
132
+
133
+ test("a root that does not exist refuses before any subprocess runs", async () => {
134
+ let ran = false;
135
+ await expect(
136
+ renderKustomizeRoots({
137
+ projectRoot: fixtureRoot,
138
+ roots: ["overlays/staging"],
139
+ run: async () => {
140
+ ran = true;
141
+ return { stdout: "" };
142
+ },
143
+ }),
144
+ ).rejects.toThrow(/overlays\/staging.*directory not found/);
145
+ expect(ran).toBe(false);
146
+ });
147
+
148
+ test("a dir without a kustomization file refuses, naming the expected files", async () => {
149
+ await expect(
150
+ renderKustomizeRoots({
151
+ projectRoot: fixtureRoot,
152
+ roots: ["base/../.."], // the testdata parent — exists, holds no kustomization
153
+ run: async () => ({ stdout: "" }),
154
+ }),
155
+ ).rejects.toThrow(/no kustomization file.*kustomization\.yaml/);
156
+ });
157
+
158
+ test("a rendered document without apiVersion/kind is skipped with a warning", async () => {
159
+ const { entities, warnings } = await renderKustomizeRoots({
160
+ projectRoot: fixtureRoot,
161
+ roots: ["overlays/prod"],
162
+ run: async () => ({ stdout: "just: data\n---\napiVersion: v1\nkind: Namespace\nmetadata:\n name: web\n" }),
163
+ });
164
+ expect(warnings).toHaveLength(1);
165
+ expect(warnings[0]).toContain("without apiVersion/kind");
166
+ expect([...entities.keys()]).toEqual(["overlays/prod/namespaceWeb"]);
167
+ });
168
+ });
169
+
170
+ describe("kustomize roots through the build pipeline", () => {
171
+ test("rendered docs reach the build output, ownership-stamped, and post-synth checks fire on them", async () => {
172
+ // An empty source dir: the estate keeps its overlay tree and declares no
173
+ // typed manifest — the whole point of a build root.
174
+ const srcDir = join(tmpdir(), `kustomize-root-int-${Date.now()}-${Math.random()}`);
175
+ await mkdir(srcDir, { recursive: true });
176
+ try {
177
+ const result = await build(srcDir, [k8sSerializer], undefined, {
178
+ ownership: { stack: "web", env: "prod" },
179
+ buildRoots: [
180
+ () =>
181
+ renderKustomizeRoots({
182
+ projectRoot: fixtureRoot,
183
+ roots: ["overlays/prod"],
184
+ run: cannedRunner([]),
185
+ }),
186
+ ],
187
+ });
188
+
189
+ expect(result.errors).toEqual([]);
190
+ expect(result.entities.size).toBe(3);
191
+
192
+ const output = result.outputs.get("k8s");
193
+ expect(output).toBeDefined();
194
+ const yaml = typeof output === "string" ? output : output!.primary;
195
+
196
+ // Verbatim manifests, ownership stamped by the serializer's normal merge.
197
+ expect(yaml).toContain("kind: Deployment");
198
+ expect(yaml).toContain("name: prod-web");
199
+ expect(yaml).toContain("app.kubernetes.io/managed-by: chant");
200
+ expect(yaml).toContain("chant.intentius.io/stack: web");
201
+ // Provenance names the overlay the doc came from.
202
+ expect(yaml).toContain(`${KUSTOMIZE_ROOT_ANNOTATION}: overlays/prod`);
203
+ // Render-final shape survives: the CRD instance keeps its own spec.
204
+ expect(yaml).toContain("kind: Certificate");
205
+ expect(yaml).toContain("secretName: prod-web-tls");
206
+
207
+ // Post-synth checks see the rendered docs like any other manifests —
208
+ // the fixture Deployment hardcodes a sensitive env var, so WK8005 fires.
209
+ const ctx: PostSynthContext = {
210
+ outputs: result.outputs,
211
+ entities: result.entities,
212
+ buildResult: {
213
+ outputs: result.outputs,
214
+ entities: result.entities,
215
+ warnings: result.warnings,
216
+ errors: [],
217
+ sourceFileCount: result.sourceFileCount,
218
+ },
219
+ };
220
+ const diagnostics = wk8005.check(ctx);
221
+ expect(diagnostics.some((d) => d.message.includes("DB_PASSWORD"))).toBe(true);
222
+ } finally {
223
+ await rm(srcDir, { recursive: true, force: true });
224
+ }
225
+ });
226
+
227
+ test("a missing renderer surfaces as a build error naming the binaries", async () => {
228
+ const srcDir = join(tmpdir(), `kustomize-root-miss-${Date.now()}-${Math.random()}`);
229
+ await mkdir(srcDir, { recursive: true });
230
+ try {
231
+ const result = await build(srcDir, [k8sSerializer], undefined, {
232
+ buildRoots: [
233
+ () =>
234
+ renderKustomizeRoots({
235
+ projectRoot: fixtureRoot,
236
+ roots: ["overlays/prod"],
237
+ run: async () => {
238
+ throw new Error("spawn kustomize ENOENT");
239
+ },
240
+ }),
241
+ ],
242
+ });
243
+ expect(result.errors).toHaveLength(1);
244
+ expect(result.errors[0].message).toContain("neither the `kustomize` binary nor `kubectl`");
245
+ } finally {
246
+ await rm(srcDir, { recursive: true, force: true });
247
+ }
248
+ });
249
+ });
250
+
251
+ describe("k8sPlugin.buildRoots", () => {
252
+ test("no configured roots contributes nothing", async () => {
253
+ const contribution = await k8sPlugin.buildRoots!({ projectRoot: fixtureRoot, config: {} });
254
+ expect(contribution.entities.size).toBe(0);
255
+ });
256
+
257
+ test("reads k8s.kustomize.roots from the project config namespace", async () => {
258
+ // The real hook uses the subprocess runner, so only the config plumbing is
259
+ // asserted here: a configured root that fails its offline existence check
260
+ // proves the hook read the namespace (no subprocess involved).
261
+ await expect(
262
+ k8sPlugin.buildRoots!({
263
+ projectRoot: fixtureRoot,
264
+ config: { k8s: { kustomize: { roots: ["overlays/missing"] } } },
265
+ }),
266
+ ).rejects.toThrow(/overlays\/missing.*directory not found/);
267
+ });
268
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Kustomize build roots (#1548 piece 3).
3
+ *
4
+ * An estate that keeps its overlay tree has no typed chant source for those
5
+ * manifests — and piece 4 (a declarable Kustomization) is parked precisely
6
+ * because forcing a modelling migration onto every kustomize estate is the
7
+ * mistake behold#138 documented. What such an estate CAN declare is the
8
+ * directory: `k8s.kustomize.roots` in `chant.config.ts` names kustomization
9
+ * dirs that render at build time into the manifest set. The rendered
10
+ * documents become entities (see `./rendered-entity.ts`), so they are
11
+ * serialized into the build output, ownership-stamped, seen by post-synth
12
+ * checks, and observed by `lifecycle diff --live` — the declared side an
13
+ * overlay estate never had.
14
+ *
15
+ * Renders through the same injectable runner the `kustomize-apply`
16
+ * capability uses (`./render.ts`): `kustomize build`, `kubectl kustomize`
17
+ * fallback, and a both-binaries-missing failure that names them. Two
18
+ * build-time guarantees on top:
19
+ *
20
+ * - **Deterministic**: entity names derive from the root dir plus each
21
+ * document's kind/name/namespace, disambiguated in render order — the same
22
+ * overlay renders the same entity set every build.
23
+ * - **Fail loudly, not weirdly**: a root that doesn't exist, or a dir with no
24
+ * kustomization file, refuses with the path in the message before any
25
+ * subprocess runs — offline, the failure mode is a sentence, never a
26
+ * render attempt's stack trace.
27
+ */
28
+ import { existsSync } from "node:fs";
29
+ import { isAbsolute, join, relative, resolve } from "node:path";
30
+ import type { Declarable } from "@intentius/chant/declarable";
31
+ import { defaultKustomizeRunner, renderKustomizeDocuments, type KustomizeRunner } from "./render";
32
+ import { renderedManifestEntity } from "./rendered-entity";
33
+
34
+ const KUSTOMIZATION_FILES = ["kustomization.yaml", "kustomization.yml", "Kustomization"];
35
+
36
+ export interface KustomizeRootsResult {
37
+ entities: Map<string, Declarable>;
38
+ warnings: string[];
39
+ }
40
+
41
+ /** `Deployment` + `my-app` → `deploymentMyApp`, the import path's naming. */
42
+ function logicalId(kind: string, name: string | undefined): string {
43
+ const prefix = kind.charAt(0).toLowerCase() + kind.slice(1);
44
+ if (!name) return prefix;
45
+ const pascal = name
46
+ .split(/[^a-zA-Z0-9]+/)
47
+ .filter(Boolean)
48
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
49
+ .join("");
50
+ return `${prefix}${pascal}`;
51
+ }
52
+
53
+ /**
54
+ * Render each configured kustomization root into entities, keyed
55
+ * `<root>/<kindName>` (namespace-qualified, then numbered, on collision).
56
+ */
57
+ export async function renderKustomizeRoots(opts: {
58
+ /** Directory the project config was loaded from; relative roots resolve against it. */
59
+ projectRoot: string;
60
+ /** `k8s.kustomize.roots` — kustomization dirs, usually relative. */
61
+ roots: readonly string[];
62
+ /** Injectable renderer (tests); defaults to the real subprocess runner. */
63
+ run?: KustomizeRunner;
64
+ }): Promise<KustomizeRootsResult> {
65
+ const run = opts.run ?? defaultKustomizeRunner;
66
+ const entities = new Map<string, Declarable>();
67
+ const warnings: string[] = [];
68
+
69
+ for (const root of opts.roots) {
70
+ const rootDir = isAbsolute(root) ? root : resolve(opts.projectRoot, root);
71
+ // The label rendered docs carry as provenance: the declared (relative)
72
+ // form when possible, so it names the overlay the way the config does.
73
+ const rootLabel = isAbsolute(root) ? (relative(opts.projectRoot, root) || ".") : root;
74
+
75
+ if (!existsSync(rootDir)) {
76
+ throw new Error(`k8s.kustomize.roots entry "${root}": directory not found at ${rootDir}`);
77
+ }
78
+ if (!KUSTOMIZATION_FILES.some((f) => existsSync(join(rootDir, f)))) {
79
+ throw new Error(
80
+ `k8s.kustomize.roots entry "${root}": ${rootDir} contains no kustomization file ` +
81
+ `(expected one of ${KUSTOMIZATION_FILES.join(", ")})`,
82
+ );
83
+ }
84
+
85
+ const documents = await renderKustomizeDocuments(rootDir, run);
86
+ for (const doc of documents) {
87
+ const entity = renderedManifestEntity(doc, rootLabel);
88
+ if (!entity) {
89
+ warnings.push(`kustomize root "${rootLabel}" rendered a document without apiVersion/kind — skipped`);
90
+ continue;
91
+ }
92
+ const metadata = entity.props.metadata as Record<string, unknown>;
93
+ const kind = entity.props.kind as string;
94
+ const name = typeof metadata.name === "string" ? metadata.name : undefined;
95
+ const namespace = typeof metadata.namespace === "string" ? metadata.namespace : undefined;
96
+
97
+ // Deterministic, human-readable keys. Two documents can share
98
+ // kind+name (different namespaces, or different API groups with the
99
+ // same kind word) — qualify by namespace first, then number.
100
+ const base = `${rootLabel}/${logicalId(kind, name)}`;
101
+ let key = base;
102
+ if (entities.has(key) && namespace) key = `${base}.${namespace}`;
103
+ for (let n = 2; entities.has(key); n++) key = `${base}~${n}`;
104
+ entities.set(key, entity);
105
+ }
106
+ }
107
+
108
+ return { entities, warnings };
109
+ }
@@ -8,6 +8,8 @@ export const k8sAuditCatalog: Record<string, RuleMeta> = {
8
8
  ARGO002: auditRule("ARGO002", "merge-worthy", "guidance", "Argo Application references an undeclared AppProject", "Declare the named AppProject or reference an existing project.", { category: "correctness" }),
9
9
  ARGO003: auditRule("ARGO003", "merge-worthy", "guidance", "Argo Application targets an unregistered cluster", "Point spec.destination at a registered cluster or the in-cluster target.", { category: "correctness" }),
10
10
  ARGO005: auditRule("ARGO005", "report-only", "guidance", "Argo source.path may not resolve", "Ensure the source path exists under the build root.", { category: "best-practice" }),
11
+ FLUX002: auditRule("FLUX002", "merge-worthy", "guidance", "Flux Kustomization references an undeclared source", "Declare the GitRepository/OCIRepository/Bucket, or point sourceRef at the bootstrap flux-system repo.", { category: "correctness" }),
12
+ FLUX003: auditRule("FLUX003", "report-only", "guidance", "Flux Kustomization dependsOn names an undeclared Kustomization", "Fix the dependsOn name, or ignore if the dependency is declared in another repo.", { category: "correctness" }),
11
13
  WK8005: auditRule("WK8005", "merge-worthy", "guidance", "Hardcoded secret in env var", "Use a secretKeyRef instead of a literal value, and rotate the secret.", { authority: [K8S_SECRETS] }),
12
14
  WK8006: auditRule("WK8006", "merge-worthy", "guidance", "Image uses :latest or no tag", "Pin the image to an explicit version tag (ideally a digest).", { authority: [SCORECARD_PINNED] }),
13
15
  WK8041: auditRule("WK8041", "merge-worthy", "guidance", "Hardcoded API key in env var", "Move the key to a Secret and rotate it.", { authority: [K8S_SECRETS] }),
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Shared helpers for the Argo post-synth checks (ARGO002, ARGO003, ARGO005).
3
+ * The generic manifest collectors (`allManifests`, `manifestsOfKind`) are
4
+ * shared by the Flux checks (FLUX002, FLUX003) too.
3
5
  *
4
6
  * Excluded from check auto-discovery by the "helper" filename filter.
5
7
  */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * FLUX002: Kustomization.spec.sourceRef must reference a declared source
3
+ *
4
+ * A Flux `Kustomization` names its source in `spec.sourceRef` — a
5
+ * `GitRepository`, `OCIRepository`, or `Bucket` the source-controller fetches.
6
+ * If the referenced source isn't declared in the build, the
7
+ * kustomize-controller waits on an artifact that never arrives and the
8
+ * Kustomization stalls. The bootstrap-created `flux-system` GitRepository
9
+ * always exists on a bootstrapped cluster, so a reference to it is never
10
+ * flagged — the Flux analogue of ARGO002's built-in `default` project.
11
+ */
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { allManifests, manifestsOfKind } from "./argo-helpers";
14
+
15
+ /** Source kinds the kustomize-controller can reconcile from. */
16
+ const SOURCE_KINDS = ["GitRepository", "OCIRepository", "Bucket"] as const;
17
+
18
+ /** The GitRepository `flux bootstrap` creates alongside the controllers. */
19
+ const BOOTSTRAP_SOURCE_NAME = "flux-system";
20
+
21
+ export const flux002: PostSynthCheck = {
22
+ id: "FLUX002",
23
+ description: "Kustomization.spec.sourceRef must reference a declared source (or the bootstrap flux-system repo)",
24
+
25
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
26
+ const diagnostics: PostSynthDiagnostic[] = [];
27
+ const manifests = allManifests(ctx);
28
+
29
+ // Declared sources, keyed "Kind/name". Namespace is deliberately ignored:
30
+ // an unset sourceRef.namespace resolves in the Kustomization's own, and
31
+ // the common estate keeps everything in flux-system anyway.
32
+ const declaredSources = new Set<string>();
33
+ for (const kind of SOURCE_KINDS) {
34
+ for (const source of manifestsOfKind(manifests, kind)) {
35
+ const name = source.metadata?.name;
36
+ if (typeof name === "string") declaredSources.add(`${kind}/${name}`);
37
+ }
38
+ }
39
+
40
+ for (const kustomization of manifestsOfKind(manifests, "Kustomization")) {
41
+ // Flux and kustomize.config.k8s.io share the kind name; only the Flux CR
42
+ // carries a sourceRef-bearing spec.
43
+ const apiVersion = kustomization.apiVersion;
44
+ if (typeof apiVersion === "string" && !apiVersion.startsWith("kustomize.toolkit.fluxcd.io/")) continue;
45
+
46
+ const name = kustomization.metadata?.name ?? "Kustomization";
47
+ const sourceRef = kustomization.spec?.sourceRef as
48
+ | { kind?: unknown; name?: unknown }
49
+ | undefined;
50
+
51
+ if (!sourceRef || typeof sourceRef.name !== "string" || sourceRef.name === "") {
52
+ diagnostics.push({
53
+ checkId: "FLUX002",
54
+ severity: "error",
55
+ message: `Kustomization "${name}" has no spec.sourceRef.name — the kustomize-controller cannot resolve a source.`,
56
+ entity: name,
57
+ lexicon: "k8s",
58
+ });
59
+ continue;
60
+ }
61
+
62
+ const kind = typeof sourceRef.kind === "string" && sourceRef.kind !== "" ? sourceRef.kind : "GitRepository";
63
+ if (kind === "GitRepository" && sourceRef.name === BOOTSTRAP_SOURCE_NAME) continue;
64
+ if (declaredSources.has(`${kind}/${sourceRef.name}`)) continue;
65
+
66
+ diagnostics.push({
67
+ checkId: "FLUX002",
68
+ severity: "error",
69
+ message: `Kustomization "${name}" references ${kind} "${sourceRef.name}", which is not declared. Declare the source (e.g. FluxGitSource) or point sourceRef at the bootstrap flux-system repo.`,
70
+ entity: name,
71
+ lexicon: "k8s",
72
+ });
73
+ }
74
+
75
+ return diagnostics;
76
+ },
77
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * FLUX003: Kustomization.spec.dependsOn entries should name declared Kustomizations
3
+ *
4
+ * `dependsOn` is Flux's reconcile-ordering edge: the Kustomization stays
5
+ * pending until every named Kustomization is ready. The names are plain
6
+ * strings with no referential integrity — a typo, or an entry left behind
7
+ * after a rename, stalls the app silently. This check joins each entry
8
+ * against the Kustomizations the build actually declares.
9
+ *
10
+ * It is a warning, not an error: estates legitimately split infra and apps
11
+ * across repos, so a dependency (say `cert-manager`) may be declared by a
12
+ * build this one never sees. A self-referencing entry is flagged too — Flux
13
+ * can never satisfy it.
14
+ */
15
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
16
+ import { allManifests, manifestsOfKind } from "./argo-helpers";
17
+ import type { K8sManifest } from "./k8s-helpers";
18
+
19
+ /** Flux Kustomizations only — the kustomize.config.k8s.io kind shares the name. */
20
+ function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
21
+ return manifestsOfKind(manifests, "Kustomization").filter((m) => {
22
+ const apiVersion = m.apiVersion;
23
+ return typeof apiVersion !== "string" || apiVersion.startsWith("kustomize.toolkit.fluxcd.io/");
24
+ });
25
+ }
26
+
27
+ export const flux003: PostSynthCheck = {
28
+ id: "FLUX003",
29
+ description: "Kustomization.spec.dependsOn entries should name Kustomizations declared in the build",
30
+
31
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
32
+ const diagnostics: PostSynthDiagnostic[] = [];
33
+ const kustomizations = fluxKustomizations(allManifests(ctx));
34
+
35
+ const declared = new Set(
36
+ kustomizations
37
+ .map((k) => k.metadata?.name)
38
+ .filter((n): n is string => typeof n === "string"),
39
+ );
40
+
41
+ for (const kustomization of kustomizations) {
42
+ const name = kustomization.metadata?.name ?? "Kustomization";
43
+ const dependsOn = kustomization.spec?.dependsOn;
44
+ if (!Array.isArray(dependsOn)) continue;
45
+
46
+ for (const entry of dependsOn) {
47
+ const depName = (entry as { name?: unknown } | null)?.name;
48
+ if (typeof depName !== "string" || depName === "") continue;
49
+
50
+ if (depName === name) {
51
+ diagnostics.push({
52
+ checkId: "FLUX003",
53
+ severity: "warning",
54
+ message: `Kustomization "${name}" depends on itself — Flux can never satisfy the edge and the app will not reconcile.`,
55
+ entity: name,
56
+ lexicon: "k8s",
57
+ });
58
+ continue;
59
+ }
60
+
61
+ if (declared.has(depName)) continue;
62
+
63
+ diagnostics.push({
64
+ checkId: "FLUX003",
65
+ severity: "warning",
66
+ message: `Kustomization "${name}" depends on Kustomization "${depName}", which nothing in the build declares — reconciliation stalls until it exists. Fix the name, or ignore if it is declared in another repo.`,
67
+ entity: name,
68
+ lexicon: "k8s",
69
+ });
70
+ }
71
+ }
72
+
73
+ return diagnostics;
74
+ },
75
+ };
@@ -3,6 +3,8 @@ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
3
3
  import { argo002 } from "./argo002";
4
4
  import { argo003 } from "./argo003";
5
5
  import { argo005 } from "./argo005";
6
+ import { flux002 } from "./flux002";
7
+ import { flux003 } from "./flux003";
6
8
  import { wk8005 } from "./wk8005";
7
9
  import { wk8006 } from "./wk8006";
8
10
  import { wk8041 } from "./wk8041";
@@ -34,6 +36,8 @@ export const postSynthChecks: PostSynthCheck[] = [
34
36
  argo002,
35
37
  argo003,
36
38
  argo005,
39
+ flux002,
40
+ flux003,
37
41
  wk8005,
38
42
  wk8006,
39
43
  wk8041,