@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
@@ -9,6 +9,7 @@ function p(member: unknown): Record<string, unknown> {
9
9
  }
10
10
  import { StatefulApp } from "./stateful-app";
11
11
  import { ArgoAppFor, ArgoAppSetForRegions, registerArgoCluster } from "./argo-app";
12
+ import { FluxGitSource, FluxAppFor } from "./flux-app";
12
13
  import { CronWorkload } from "./cron-workload";
13
14
  import { AutoscaledService } from "./autoscaled-service";
14
15
  import { WorkerPool } from "./worker-pool";
@@ -3359,6 +3360,121 @@ describe("registerArgoCluster", () => {
3359
3360
  });
3360
3361
  });
3361
3362
 
3363
+ describe("FluxGitSource", () => {
3364
+ test("returns a single GitRepository", () => {
3365
+ const result = FluxGitSource("home-chant", { url: "https://github.com/jhgaylor/home-chant" });
3366
+ expect(result.gitRepository).toBeDefined();
3367
+ expect((result.gitRepository as any).entityType).toBe("K8s::Flux::GitRepository");
3368
+ });
3369
+
3370
+ test("defaults: 5m interval, main branch, flux-system namespace", () => {
3371
+ const result = FluxGitSource("home-chant", { url: "https://github.com/jhgaylor/home-chant" });
3372
+ const meta = p(result.gitRepository).metadata as any;
3373
+ const spec = p(result.gitRepository).spec as any;
3374
+ expect(meta.namespace).toBe("flux-system");
3375
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
3376
+ expect(spec.interval).toBe("5m");
3377
+ expect(spec.url).toBe("https://github.com/jhgaylor/home-chant");
3378
+ expect(spec.ref).toEqual({ branch: "main" });
3379
+ });
3380
+
3381
+ test("a tag pin wins over the branch default", () => {
3382
+ const spec = p(FluxGitSource("app", { url: "u", tag: "v1.2.3" }).gitRepository).spec as any;
3383
+ expect(spec.ref).toEqual({ tag: "v1.2.3" });
3384
+ });
3385
+
3386
+ test("secretRef renders as a name reference", () => {
3387
+ const spec = p(FluxGitSource("app", { url: "u", secretRef: "git-creds" }).gitRepository).spec as any;
3388
+ expect(spec.secretRef).toEqual({ name: "git-creds" });
3389
+ });
3390
+ });
3391
+
3392
+ describe("FluxAppFor", () => {
3393
+ const source = () => FluxGitSource("home-chant", { url: "https://github.com/jhgaylor/home-chant" });
3394
+
3395
+ test("returns a single Kustomization (the source is not duplicated)", () => {
3396
+ const result = FluxAppFor("hello-chant", { source: source(), path: "./apps/hello-chant/k8s" });
3397
+ expect(result.kustomization).toBeDefined();
3398
+ expect((result.kustomization as any).entityType).toBe("K8s::Flux::Kustomization");
3399
+ expect((result as any).gitRepository).toBeUndefined();
3400
+ });
3401
+
3402
+ test("defaults: 10m interval, prune, wait, flux-system namespace", () => {
3403
+ const result = FluxAppFor("hello-chant", { source: source(), path: "./apps/hello-chant/k8s" });
3404
+ const meta = p(result.kustomization).metadata as any;
3405
+ const spec = p(result.kustomization).spec as any;
3406
+ expect(meta.name).toBe("hello-chant");
3407
+ expect(meta.namespace).toBe("flux-system");
3408
+ expect(spec.interval).toBe("10m");
3409
+ expect(spec.prune).toBe(true);
3410
+ expect(spec.wait).toBe(true);
3411
+ expect(spec.path).toBe("./apps/hello-chant/k8s");
3412
+ });
3413
+
3414
+ test("derives sourceRef from a FluxGitSource result", () => {
3415
+ const spec = p(FluxAppFor("hello", { source: source(), path: "./k8s" }).kustomization).spec as any;
3416
+ expect(spec.sourceRef).toEqual({ kind: "GitRepository", name: "home-chant", namespace: "flux-system" });
3417
+ });
3418
+
3419
+ test("many apps share one source", () => {
3420
+ const shared = source();
3421
+ const a = FluxAppFor("mealie", { source: shared, path: "./apps/mealie/k8s" });
3422
+ const b = FluxAppFor("ntfy", { source: shared, path: "./apps/ntfy/k8s" });
3423
+ expect((p(a.kustomization).spec as any).sourceRef.name).toBe("home-chant");
3424
+ expect((p(b.kustomization).spec as any).sourceRef.name).toBe("home-chant");
3425
+ });
3426
+
3427
+ test("a string source names an existing GitRepository (flux-system)", () => {
3428
+ const spec = p(FluxAppFor("mealie", { source: "flux-system", path: "./apps/mealie/generated" }).kustomization)
3429
+ .spec as any;
3430
+ expect(spec.sourceRef).toEqual({ kind: "GitRepository", name: "flux-system" });
3431
+ });
3432
+
3433
+ test("an explicit ref carries a non-git source kind", () => {
3434
+ const spec = p(
3435
+ FluxAppFor("fountain", { source: { kind: "OCIRepository", name: "fountain" }, path: "./k8s" }).kustomization,
3436
+ ).spec as any;
3437
+ expect(spec.sourceRef).toEqual({ kind: "OCIRepository", name: "fountain" });
3438
+ });
3439
+
3440
+ test("dependsOn is a plain name list rendered to name objects", () => {
3441
+ const spec = p(
3442
+ FluxAppFor("hello", {
3443
+ source: "flux-system",
3444
+ path: "./k8s",
3445
+ dependsOn: ["cert-manager", "traefik"],
3446
+ }).kustomization,
3447
+ ).spec as any;
3448
+ expect(spec.dependsOn).toEqual([{ name: "cert-manager" }, { name: "traefik" }]);
3449
+ });
3450
+
3451
+ test("omits dependsOn when the list is empty", () => {
3452
+ const spec = p(FluxAppFor("hello", { source: "flux-system", path: "./k8s" }).kustomization).spec as any;
3453
+ expect(spec.dependsOn).toBeUndefined();
3454
+ });
3455
+
3456
+ test("maps targetNamespace and opt-outs onto the spec", () => {
3457
+ const spec = p(
3458
+ FluxAppFor("cloudflared", {
3459
+ source: "flux-system",
3460
+ path: "./apps/cloudflared/generated",
3461
+ targetNamespace: "cloudflared",
3462
+ wait: false,
3463
+ prune: false,
3464
+ suspend: true,
3465
+ timeout: "3m",
3466
+ serviceAccountName: "flux-applier",
3467
+ }).kustomization,
3468
+ ).spec as any;
3469
+ expect(spec.targetNamespace).toBe("cloudflared");
3470
+ expect(spec.wait).toBe(false);
3471
+ expect(spec.prune).toBe(false);
3472
+ expect(spec.suspend).toBe(true);
3473
+ expect(spec.timeout).toBe("3m");
3474
+ expect(spec.serviceAccountName).toBe("flux-applier");
3475
+ });
3476
+ });
3477
+
3362
3478
  describe("package index re-exports (regression guard)", () => {
3363
3479
  test("Argo composites are reachable from the package entry", async () => {
3364
3480
  const pkg: any = await import("../index");
@@ -3366,4 +3482,10 @@ describe("package index re-exports (regression guard)", () => {
3366
3482
  expect(typeof pkg.ArgoAppSetForRegions).toBe("function");
3367
3483
  expect(typeof pkg.registerArgoCluster).toBe("function");
3368
3484
  });
3485
+
3486
+ test("Flux composites are reachable from the package entry", async () => {
3487
+ const pkg: any = await import("../index");
3488
+ expect(typeof pkg.FluxGitSource).toBe("function");
3489
+ expect(typeof pkg.FluxAppFor).toBe("function");
3490
+ });
3369
3491
  });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Flux CD composites — turn Chant build targets into Flux sources and
3
+ * `Kustomization`s.
4
+ *
5
+ * The k8s lexicon stays runtime-agnostic: it only emits manifests. These
6
+ * composites are the opt-in bridge to Flux's reconciliation layer, the
7
+ * `ArgoAppFor` analogue for the source-controller/kustomize-controller pair.
8
+ * Authoring a `GitRepository` + `Kustomization` by hand is ~40 lines of YAML
9
+ * per app; `FluxAppFor` collapses the pair to one call with defaults taken
10
+ * from real Flux estates.
11
+ *
12
+ * - FluxGitSource(name, opts) → a single K8s::Flux::GitRepository
13
+ * - FluxAppFor(target, opts) → a single K8s::Flux::Kustomization
14
+ *
15
+ * The two are split deliberately: the common estate shape is one source
16
+ * shared by many Kustomizations. A `GitRepository` per app is the mistake
17
+ * this split makes hard — declare the source once, hand its result to every
18
+ * `FluxAppFor` that reconciles a path out of it.
19
+ */
20
+
21
+ import { Composite, mergeDefaults } from "@intentius/chant";
22
+ import {
23
+ GitRepository as GitRepositoryResource,
24
+ Kustomization as KustomizationResource,
25
+ } from "../generated";
26
+
27
+ // ── Shared types ─────────────────────────────────────────────────────────────
28
+
29
+ /** The namespace the Flux controllers run in and watch by default. */
30
+ const FLUX_NAMESPACE = "flux-system";
31
+
32
+ /** Source kinds a Kustomization can reconcile from. */
33
+ export type FluxSourceKind = "GitRepository" | "OCIRepository" | "Bucket";
34
+
35
+ /** An explicit reference to a source declared elsewhere. */
36
+ export interface FluxSourceRef {
37
+ /** Source kind (default "GitRepository"). */
38
+ kind?: FluxSourceKind;
39
+ /** Name of the source object. */
40
+ name: string;
41
+ /** Namespace of the source, when it differs from the Kustomization's. */
42
+ namespace?: string;
43
+ }
44
+
45
+ // ── FluxGitSource ────────────────────────────────────────────────────────────
46
+
47
+ export interface FluxGitSourceOptions {
48
+ /** Git repository URL the source-controller fetches. */
49
+ url: string;
50
+ /** Branch to track (default "main"). Ignored when `tag` is set. */
51
+ branch?: string;
52
+ /** Tag to pin instead of a branch. */
53
+ tag?: string;
54
+ /** Fetch interval (default "5m"). */
55
+ interval?: string;
56
+ /** Name of the Secret holding git credentials, for private repos. */
57
+ secretRef?: string;
58
+ /** Namespace the GitRepository lives in (default "flux-system"). */
59
+ fluxNamespace?: string;
60
+ /** Extra labels applied to the GitRepository. */
61
+ labels?: Record<string, string>;
62
+ defaults?: { gitRepository?: Partial<Record<string, unknown>> };
63
+ }
64
+
65
+ export type FluxGitSourceResult = {
66
+ gitRepository: InstanceType<typeof GitRepositoryResource>;
67
+ }
68
+
69
+ const FluxGitRepository = Composite<{ name: string } & FluxGitSourceOptions, FluxGitSourceResult>(
70
+ (props) => {
71
+ const {
72
+ name,
73
+ url,
74
+ branch = "main",
75
+ tag,
76
+ interval = "5m",
77
+ secretRef,
78
+ fluxNamespace = FLUX_NAMESPACE,
79
+ labels = {},
80
+ defaults,
81
+ } = props;
82
+
83
+ const gitRepository = new GitRepositoryResource(mergeDefaults({
84
+ metadata: {
85
+ name,
86
+ namespace: fluxNamespace,
87
+ labels: {
88
+ "app.kubernetes.io/name": name,
89
+ "app.kubernetes.io/managed-by": "chant",
90
+ ...labels,
91
+ },
92
+ },
93
+ spec: {
94
+ interval,
95
+ url,
96
+ // An unset spec.ref falls back to `master` — always pin (FLUX001).
97
+ ref: tag !== undefined ? { tag } : { branch },
98
+ ...(secretRef !== undefined && { secretRef: { name: secretRef } }),
99
+ },
100
+ }, defaults?.gitRepository));
101
+
102
+ return { gitRepository };
103
+ },
104
+ "FluxGitRepository",
105
+ );
106
+
107
+ /**
108
+ * Declare a Flux `GitRepository` source in one call.
109
+ *
110
+ * Declare it once and share it: every `FluxAppFor` that reconciles a path out
111
+ * of the same repo should take this result as its `source`.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * import { FluxGitSource } from "@intentius/chant-lexicon-k8s";
116
+ *
117
+ * export const source = FluxGitSource("home-chant", {
118
+ * url: "https://github.com/jhgaylor/home-chant",
119
+ * branch: "main",
120
+ * });
121
+ * ```
122
+ */
123
+ export function FluxGitSource(name: string, options: FluxGitSourceOptions): FluxGitSourceResult {
124
+ return FluxGitRepository({ name, ...options });
125
+ }
126
+
127
+ // ── FluxAppFor ───────────────────────────────────────────────────────────────
128
+
129
+ export interface FluxAppForOptions {
130
+ /**
131
+ * The source to reconcile from — a `FluxGitSource` result, the name of an
132
+ * existing `GitRepository` (e.g. the bootstrap-created "flux-system"), or an
133
+ * explicit `{ kind, name }` reference for OCIRepository/Bucket sources.
134
+ */
135
+ source: FluxGitSourceResult | FluxSourceRef | string;
136
+ /** Path within the source holding the manifests (e.g. "./apps/api/k8s"). */
137
+ path: string;
138
+ /** Namespace the reconciled resources land in (sets spec.targetNamespace). */
139
+ targetNamespace?: string;
140
+ /** Reconcile interval (default "10m"). */
141
+ interval?: string;
142
+ /** Delete resources that disappear from the source (default true). */
143
+ prune?: boolean;
144
+ /** Wait for reconciled resources to become ready (default true). */
145
+ wait?: boolean;
146
+ /**
147
+ * Names of Kustomizations that must be ready first, as a plain name list
148
+ * (rendered to spec.dependsOn). Validated against the build by FLUX003.
149
+ */
150
+ dependsOn?: string[];
151
+ /** Timeout for health checks and apply operations. */
152
+ timeout?: string;
153
+ /** Pause reconciliation without deleting anything. */
154
+ suspend?: boolean;
155
+ /** ServiceAccount the kustomize-controller impersonates for this app. */
156
+ serviceAccountName?: string;
157
+ /** Namespace the Kustomization object itself lives in (default "flux-system"). */
158
+ fluxNamespace?: string;
159
+ /** Extra labels applied to the Kustomization. */
160
+ labels?: Record<string, string>;
161
+ defaults?: { kustomization?: Partial<Record<string, unknown>> };
162
+ }
163
+
164
+ export type FluxAppForResult = {
165
+ kustomization: InstanceType<typeof KustomizationResource>;
166
+ }
167
+
168
+ /** Resolve the `source` option to a rendered sourceRef. */
169
+ function resolveSourceRef(
170
+ target: string,
171
+ source: FluxGitSourceResult | FluxSourceRef | string,
172
+ ): Record<string, unknown> {
173
+ if (typeof source === "string") {
174
+ return { kind: "GitRepository", name: source };
175
+ }
176
+ if ("gitRepository" in source && source.gitRepository !== undefined) {
177
+ const metadata = (source.gitRepository as { props?: { metadata?: { name?: unknown; namespace?: unknown } } })
178
+ .props?.metadata;
179
+ const name = metadata?.name;
180
+ if (typeof name !== "string" || name === "") {
181
+ throw new Error(
182
+ `FluxAppFor("${target}"): the supplied FluxGitSource result has no metadata.name to reference.`,
183
+ );
184
+ }
185
+ return {
186
+ kind: "GitRepository",
187
+ name,
188
+ ...(typeof metadata?.namespace === "string" && { namespace: metadata.namespace }),
189
+ };
190
+ }
191
+ const ref = source as FluxSourceRef;
192
+ return {
193
+ kind: ref.kind ?? "GitRepository",
194
+ name: ref.name,
195
+ ...(ref.namespace !== undefined && { namespace: ref.namespace }),
196
+ };
197
+ }
198
+
199
+ const FluxKustomization = Composite<{ target: string } & FluxAppForOptions, FluxAppForResult>(
200
+ (props) => {
201
+ const {
202
+ target,
203
+ source,
204
+ path,
205
+ targetNamespace,
206
+ interval = "10m",
207
+ prune = true,
208
+ wait = true,
209
+ dependsOn = [],
210
+ timeout,
211
+ suspend,
212
+ serviceAccountName,
213
+ fluxNamespace = FLUX_NAMESPACE,
214
+ labels = {},
215
+ defaults,
216
+ } = props;
217
+
218
+ const sourceRef = resolveSourceRef(target, source);
219
+
220
+ // The Kustomization's namespace also scopes the sourceRef: an in-spec
221
+ // sourceRef without a namespace resolves in the Kustomization's own.
222
+ const kustomization = new KustomizationResource(mergeDefaults({
223
+ metadata: {
224
+ name: target,
225
+ namespace: fluxNamespace,
226
+ labels: {
227
+ "app.kubernetes.io/name": target,
228
+ "app.kubernetes.io/managed-by": "chant",
229
+ ...labels,
230
+ },
231
+ },
232
+ spec: {
233
+ interval,
234
+ path,
235
+ prune,
236
+ wait,
237
+ sourceRef,
238
+ ...(targetNamespace !== undefined && { targetNamespace }),
239
+ ...(dependsOn.length > 0 && { dependsOn: dependsOn.map((name) => ({ name })) }),
240
+ ...(timeout !== undefined && { timeout }),
241
+ ...(suspend !== undefined && { suspend }),
242
+ ...(serviceAccountName !== undefined && { serviceAccountName }),
243
+ },
244
+ }, defaults?.kustomization));
245
+
246
+ return { kustomization };
247
+ },
248
+ "FluxKustomization",
249
+ );
250
+
251
+ /**
252
+ * Reconcile a Chant build target with Flux in one call — the `Kustomization`
253
+ * half of the GitRepository + Kustomization pair. The source is passed in,
254
+ * not created here, so many apps share one repo declaration.
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * import { FluxGitSource, FluxAppFor } from "@intentius/chant-lexicon-k8s";
259
+ *
260
+ * export const source = FluxGitSource("home-chant", {
261
+ * url: "https://github.com/jhgaylor/home-chant",
262
+ * });
263
+ *
264
+ * export const hello = FluxAppFor("hello-chant", {
265
+ * source,
266
+ * path: "./apps/hello-chant/k8s",
267
+ * targetNamespace: "default",
268
+ * dependsOn: ["cert-manager", "traefik"],
269
+ * });
270
+ * ```
271
+ */
272
+ export function FluxAppFor(target: string, options: FluxAppForOptions): FluxAppForResult {
273
+ return FluxKustomization({ target, ...options });
274
+ }
@@ -93,3 +93,12 @@ export type {
93
93
  RegisterArgoClusterOptions,
94
94
  RegisterArgoClusterResult,
95
95
  } from "./argo-app";
96
+ export { FluxGitSource, FluxAppFor } from "./flux-app";
97
+ export type {
98
+ FluxSourceKind,
99
+ FluxSourceRef,
100
+ FluxGitSourceOptions,
101
+ FluxGitSourceResult,
102
+ FluxAppForOptions,
103
+ FluxAppForResult,
104
+ } from "./flux-app";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The `k8s` cluster-binding block typechecks under `satisfies ChantConfig`
3
+ * (#1455), and its schema rejects the typos that land a read on the wrong
4
+ * cluster (#1344).
5
+ *
6
+ * #1455's reproduction is the first test, verbatim: `ChantConfig` did not
7
+ * declare the `k8s` key the lexicon reads, so the one block that stops a live
8
+ * read hitting the wrong cluster was also the one block that could not
9
+ * typecheck — and the path of least resistance was dropping `satisfies
10
+ * ChantConfig`, which silently gives up checking on everything else in the
11
+ * file. The lexicon's module augmentation (declared in `./config.ts` and
12
+ * `./config-schema.ts`) is what makes it compile; this file is the forgejo
13
+ * lexicon's `config-augmentation.test.ts` guard, ported.
14
+ */
15
+
16
+ import { describe, test, expect } from "vitest";
17
+ import type { ChantConfig } from "@intentius/chant";
18
+ import type { K8sChantConfig } from "./config";
19
+ import { k8sConfigSchema } from "./config-schema";
20
+
21
+ describe("the k8s config namespace (#1455, #1344)", () => {
22
+ test("the issue's reproduction typechecks as a ChantConfig", () => {
23
+ // The compile-time assertion is the point; the runtime one keeps vitest happy.
24
+ const config = {
25
+ lexicons: ["k8s"],
26
+ environments: ["local"],
27
+ k8s: {
28
+ profiles: { local: { context: "k3d-fountain-local" } },
29
+ } satisfies K8sChantConfig,
30
+ } satisfies ChantConfig;
31
+ expect(config.k8s.profiles.local.context).toBe("k3d-fountain-local");
32
+ });
33
+
34
+ test("an empty namespace is valid — every key is optional", () => {
35
+ expect(k8sConfigSchema.safeParse({}).success).toBe(true);
36
+ });
37
+
38
+ test("the schema accepts the documented shape", () => {
39
+ const parsed = k8sConfigSchema.safeParse({
40
+ profiles: { prod: { context: "prod-eks" }, staging: { context: "staging-eks" } },
41
+ execCredentialPlugins: ["aws", "my-org-oidc-helper"],
42
+ });
43
+ expect(parsed.success).toBe(true);
44
+ });
45
+
46
+ test("an unknown key at the top of the namespace is rejected, not ignored", () => {
47
+ // Core applies `.strict()` when validating a declared namespace
48
+ // (`validateLexiconConfig`), so `profile` instead of `profiles` errors
49
+ // rather than silently leaving every environment unbound.
50
+ expect(k8sConfigSchema.strict().safeParse({ profile: { local: { context: "x" } } }).success).toBe(false);
51
+ });
52
+
53
+ test("a typo inside a profile is rejected too — the nesting is strict", () => {
54
+ // `contxt` used to be accepted and ignored, which meant the ambient
55
+ // kubectl context won and the wrong cluster was read (#1488's setup).
56
+ const parsed = k8sConfigSchema.safeParse({ profiles: { prod: { contxt: "prod-eks" } } });
57
+ expect(parsed.success).toBe(false);
58
+ });
59
+
60
+ test("a wrong value type is rejected", () => {
61
+ expect(k8sConfigSchema.safeParse({ profiles: { prod: { context: 42 } } }).success).toBe(false);
62
+ expect(k8sConfigSchema.safeParse({ execCredentialPlugins: "aws" }).success).toBe(false);
63
+ });
64
+
65
+ test("the schema and the exported interface describe the same namespace", () => {
66
+ const value: K8sChantConfig = {
67
+ profiles: { local: { context: "k3d-fountain-local" } },
68
+ execCredentialPlugins: ["aws"],
69
+ };
70
+ expect(k8sConfigSchema.safeParse(value).success).toBe(true);
71
+ });
72
+ });
@@ -23,6 +23,11 @@ export const k8sClusterProfileSchema = z.strictObject({
23
23
  export const k8sConfigSchema = z.strictObject({
24
24
  profiles: z.record(z.string(), k8sClusterProfileSchema).optional(),
25
25
  execCredentialPlugins: z.array(z.string()).optional(),
26
+ kustomize: z
27
+ .strictObject({
28
+ roots: z.array(z.string()).optional(),
29
+ })
30
+ .optional(),
26
31
  });
27
32
 
28
33
  declare module "@intentius/chant/config" {
package/src/config.ts CHANGED
@@ -29,13 +29,15 @@
29
29
  * resolves this via `@intentius/chant/kubectl-context`'s
30
30
  * `resolveClusterTarget`:
31
31
  *
32
- * - A declared binding is passed explicitly as `kubectl ... --context <bound>`
33
- * on every invocation, and checked against the ambient context first — a
34
- * mismatch refuses loudly (naming the environment, the expected context,
35
- * and the ambient one) instead of silently reading the wrong cluster.
32
+ * - A declared binding is passed explicitly to the typed client on every
33
+ * read and write, regardless of what `kubectl` is ambiently pointed at
34
+ * (#1488 the binding is a selection, not a check). A bound context the
35
+ * kubeconfig does not have fails with an error naming the context and the
36
+ * `k8s.profiles.<env>.context` binding — never by falling back to ambient.
36
37
  * - No binding for the environment keeps today's behavior — the ambient
37
38
  * context — but logs a visible note that nothing is pinned, so the
38
- * fallback is never silent.
39
+ * fallback is never silent. Read failures name the context that was read
40
+ * and the missing binding.
39
41
  *
40
42
  * `ChantConfig` uses `.passthrough()` in its Zod schema so the `k8s` key is
41
43
  * accepted at runtime without core changes, exactly like `temporal.profiles`
@@ -83,6 +85,32 @@ export interface K8sChantConfig {
83
85
  * ```
84
86
  */
85
87
  execCredentialPlugins?: string[];
88
+
89
+ /**
90
+ * Kustomize settings (#1548 piece 3).
91
+ *
92
+ * `roots` names kustomization directories that are build roots: each is
93
+ * rendered (`kustomize build`, `kubectl kustomize` fallback) at build time
94
+ * and the rendered documents join the manifest set — serialized into the
95
+ * build output, ownership-stamped, checked by post-synth rules, and
96
+ * observed by `lifecycle diff --live` like any declared resource. This is
97
+ * how an estate that keeps its overlay tree gets a declared side without
98
+ * converting a single manifest to typed source.
99
+ *
100
+ * Paths are relative to the project root (the directory holding
101
+ * `chant.config.*`), NOT `sourceDir` — the overlay tree usually lives
102
+ * beside the typed source, not inside it.
103
+ *
104
+ * ```ts
105
+ * k8s: {
106
+ * kustomize: { roots: ["overlays/prod"] },
107
+ * } satisfies K8sChantConfig
108
+ * ```
109
+ */
110
+ kustomize?: {
111
+ /** Kustomization directories to render into the build. */
112
+ roots?: string[];
113
+ };
86
114
  }
87
115
 
88
116
  declare module "@intentius/chant/config" {
@@ -277,6 +277,40 @@ const FLUX_OPERATOR_INSTALL = `https://github.com/controlplaneio-fluxcd/flux-ope
277
277
  const KUBEMICROVM_CHART = "oci://ghcr.io/codriverlabs/helm/kube-microvm-operator";
278
278
  const KUBEMICROVM_VERSION = "1.0.11";
279
279
 
280
+ /**
281
+ * k3s bundled-controller CRDs — helm.cattle.io/v1 + k3s.cattle.io/v1
282
+ *
283
+ * k3s's manifest auto-deploy machinery speaks these CRDs: drop a HelmChart
284
+ * into /var/lib/rancher/k3s/server/manifests and the embedded helm-controller
285
+ * installs the chart; every auto-deployed manifest is tracked by an Addon.
286
+ * Typed here for the same reason the Flux toolkit kinds are — they are what a
287
+ * k3s estate's GitOps surface is written in.
288
+ *
289
+ * Produces (both groups map to the `K3s` namespace — see
290
+ * GROUP_NAMESPACE_OVERRIDES in crd/parser.ts):
291
+ * K8s::K3s::HelmChart → apiVersion: helm.cattle.io/v1, kind: HelmChart
292
+ * K8s::K3s::HelmChartConfig → apiVersion: helm.cattle.io/v1, kind: HelmChartConfig
293
+ * K8s::K3s::Addon → apiVersion: k3s.cattle.io/v1, kind: Addon
294
+ *
295
+ * The pin is k3s v1.36.3+k3s1 (the release the k3s lexicon pins), but the k3s
296
+ * repo itself publishes no CRD YAMLs — its manifests/ directory carries only
297
+ * the bundled charts, and the API types live in dependency repos. The URLs
298
+ * below point at the exact versions that release vendors in its go.mod:
299
+ * github.com/k3s-io/helm-controller v0.17.7 (helm.cattle.io CRDs)
300
+ * github.com/k3s-io/api v0.1.4 (k3s.cattle.io CRDs)
301
+ * When bumping, re-read go.mod at the new k3s tag and move both pins together.
302
+ *
303
+ * k3s-io/api also ships an ETCDSnapshotFile CRD — deliberately left out. It is
304
+ * status-only controller bookkeeping (the server writes them to describe
305
+ * snapshots it took); nothing an author would declare.
306
+ *
307
+ * Controller install: none — both controllers are embedded in the k3s binary.
308
+ */
309
+ const K3S_HELM_CONTROLLER_VERSION = "v0.17.7"; // vendored by k3s v1.36.3+k3s1
310
+ const K3S_API_VERSION = "v0.1.4"; // vendored by k3s v1.36.3+k3s1
311
+ const K3S_HELM_CONTROLLER_CRD_BASE = `https://raw.githubusercontent.com/k3s-io/helm-controller/${K3S_HELM_CONTROLLER_VERSION}/pkg/crds/yaml/generated`;
312
+ const K3S_API_CRD_BASE = `https://raw.githubusercontent.com/k3s-io/api/${K3S_API_VERSION}/pkg/crds/yaml/generated`;
313
+
280
314
  export const CRD_SOURCES: CRDSource[] = [
281
315
  { type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayclusters.yaml` },
282
316
  { type: "url", url: `${KUBERAY_CRD_BASE}/ray.io_rayjobs.yaml` },
@@ -335,6 +369,9 @@ export const CRD_SOURCES: CRDSource[] = [
335
369
  url: FLUX_OPERATOR_INSTALL,
336
370
  kinds: ["FluxInstance", "FluxReport", "ResourceSet", "ResourceSetInputProvider"],
337
371
  },
372
+ { type: "url", url: `${K3S_HELM_CONTROLLER_CRD_BASE}/helm.cattle.io_helmcharts.yaml` },
373
+ { type: "url", url: `${K3S_HELM_CONTROLLER_CRD_BASE}/helm.cattle.io_helmchartconfigs.yaml` },
374
+ { type: "url", url: `${K3S_API_CRD_BASE}/k3s.cattle.io_addons.yaml` },
338
375
  {
339
376
  type: "helm",
340
377
  chart: KUBEMICROVM_CHART,
package/src/crd/parser.ts CHANGED
@@ -52,6 +52,12 @@ const GROUP_NAMESPACE_OVERRIDES: Record<string, string> = {
52
52
  // Not `Secrets`: `K8s::Secrets::InfisicalSecret` reads like a core Secret,
53
53
  // and `K8s::Core::Secret` is right there to be confused with.
54
54
  "secrets.infisical.com": "Infisical",
55
+ // k3s's bundled controllers ship under two groups but are one thing to an
56
+ // author — the k3s auto-deploy surface. The first-segment rule would give
57
+ // `K8s::Helm::HelmChart`, which reads like it belongs to the helm lexicon,
58
+ // and would split HelmChart from the Addon that tracks its deployment.
59
+ "helm.cattle.io": "K3s",
60
+ "k3s.cattle.io": "K3s",
55
61
  };
56
62
 
57
63
  /**