@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,163 @@
1
+ /**
2
+ * `argo-app` — the Argo CD deploy leaf for the component model (#1549
3
+ * piece 2).
4
+ *
5
+ * GitOps inverts the flow kubectl-apply assumes: the controller deploys,
6
+ * chant declares the controller's objects and observes convergence. This
7
+ * capability is that inversion as one step — apply the declared
8
+ * `Application` CR(s) through the SAME server-side apply `kubectl-apply`
9
+ * uses (ownership stamping, marker-scoped prune, stack labels all
10
+ * identical), then block until every applied Application reports
11
+ * `health=Healthy && sync=Synced` via the existing `waitForArgoSync`
12
+ * activity (#957).
13
+ *
14
+ * The deploy unit is the CR itself under its stack label — `stack` doubles
15
+ * as ownership identity and deploy unit exactly as kubectl-apply's, so
16
+ * `chant components status --live` observes the Application through the
17
+ * same label sweep with zero new status walks (core
18
+ * `components/deploy-units.ts`).
19
+ *
20
+ * The #1074 boundary applies: nothing exported from the lexicon entry point
21
+ * may statically import the API-client chain, so both the applier and the
22
+ * wait activity are reached by dynamic import inside `run()` and their
23
+ * argument shapes are structural mirrors, checked against the real ones at
24
+ * the call site.
25
+ */
26
+ import type { Capability, DeployContext } from "@intentius/chant/components/capability";
27
+
28
+ /** Structural mirrors of the activity modules' shapes (see the module doc). */
29
+ interface AppliedRef {
30
+ apiVersion: string;
31
+ kind: string;
32
+ name: string;
33
+ namespace?: string;
34
+ }
35
+
36
+ interface ApplyOutcome {
37
+ fieldManager: string;
38
+ applied: AppliedRef[];
39
+ pruned: AppliedRef[];
40
+ }
41
+
42
+ interface ApplierArgs {
43
+ manifest: string;
44
+ environment?: string;
45
+ stack?: string;
46
+ context?: string;
47
+ deleteMode?: "never" | "owned-only" | "gated";
48
+ }
49
+
50
+ type Applier = (args: ApplierArgs) => Promise<ApplyOutcome>;
51
+
52
+ interface ArgoWaiterArgs {
53
+ appName: string;
54
+ namespace?: string;
55
+ server?: string;
56
+ authToken?: string;
57
+ insecure?: boolean;
58
+ context?: string;
59
+ intervalMs?: number;
60
+ }
61
+
62
+ type ArgoWaiter = (args: ArgoWaiterArgs) => Promise<{ health: string; sync: string }>;
63
+
64
+ /** One Application's final status, after the wait. */
65
+ export interface ArgoAppSynced {
66
+ name: string;
67
+ namespace: string;
68
+ health: string;
69
+ sync: string;
70
+ }
71
+
72
+ export interface ArgoAppOutcome extends ApplyOutcome {
73
+ /** Every applied Application, Healthy+Synced, in wait order. */
74
+ synced: ArgoAppSynced[];
75
+ }
76
+
77
+ export interface ArgoAppInput {
78
+ /** Path to the Application CR manifest(s) — a file, or a directory of them. */
79
+ manifest: string;
80
+ /**
81
+ * The deploy unit / ownership stack — same double duty as kubectl-apply's.
82
+ * Omitted derives from the project's `ownership.stack`, but then the unit
83
+ * is invisible to `components status --live`. Name it.
84
+ */
85
+ stack?: string;
86
+ /** kubectl context. Omitted resolves `k8s.profiles.<ctx.env>.context`. */
87
+ context?: string;
88
+ /** Same delete vocabulary as kubectl-apply; default `never`. */
89
+ delete?: "never" | "owned-only";
90
+ /**
91
+ * Argo CD API base URL. When set, sync status is read from the REST API
92
+ * instead of the cluster — pass `authToken` with it (the `waitForArgoSync`
93
+ * surface, #957).
94
+ */
95
+ server?: string;
96
+ /** Bearer token for the Argo CD REST API (used with `server`). */
97
+ authToken?: string;
98
+ /** Skip TLS verification for the REST API (default false). */
99
+ insecure?: boolean;
100
+ /** Poll interval in ms (default 15000). */
101
+ intervalMs?: number;
102
+ }
103
+
104
+ /** True when the ref is an Argo CD Application (any argoproj.io version). */
105
+ function isArgoApplication(ref: AppliedRef): boolean {
106
+ return ref.kind === "Application" && /^argoproj\.io(\/|$)/.test(ref.apiVersion);
107
+ }
108
+
109
+ /** Factory with injectable applier + waiter, so tests assert the exact
110
+ * delegation without a cluster — the kubectl-apply seam. Defaults resolve by
111
+ * dynamic import on first run, keeping the API-client chain off the build
112
+ * path (#1074). */
113
+ export function createArgoAppCapability(
114
+ apply?: Applier,
115
+ wait?: ArgoWaiter,
116
+ ): Capability<ArgoAppInput, ArgoAppOutcome> {
117
+ return {
118
+ kind: "argo-app",
119
+ // The apply half is a server-side apply with no native undo, and Argo
120
+ // offers no declarative "un-sync" — same posture as kubectl-apply.
121
+ rollbackPolicy: "needs-opt-out",
122
+ async run(ctx: DeployContext, input: ArgoAppInput): Promise<ArgoAppOutcome> {
123
+ const applier: Applier = apply ?? (await import("../op/activities/kubectl")).applyManifest;
124
+ const outcome = await applier({
125
+ manifest: input.manifest,
126
+ environment: ctx.env,
127
+ ...(input.stack !== undefined ? { stack: input.stack } : {}),
128
+ ...(input.context !== undefined ? { context: input.context } : {}),
129
+ ...(input.delete !== undefined ? { deleteMode: input.delete } : {}),
130
+ });
131
+
132
+ // AppProject / ApplicationSet in the same manifest apply fine; the wait
133
+ // gates only on Applications (an ApplicationSet's generated Applications
134
+ // are the controller's, not declared here).
135
+ const apps = outcome.applied.filter(isArgoApplication);
136
+ if (apps.length === 0) {
137
+ throw new Error(
138
+ `argo-app: "${input.manifest}" applied no argoproj.io Application — nothing to sync on. ` +
139
+ `A plain manifest belongs on kubectl-apply.`,
140
+ );
141
+ }
142
+
143
+ const waiter: ArgoWaiter = wait ?? (await import("../op/activities/argo")).waitForArgoSync;
144
+ const synced: ArgoAppSynced[] = [];
145
+ for (const app of apps) {
146
+ const namespace = app.namespace ?? "argocd";
147
+ const status = await waiter({
148
+ appName: app.name,
149
+ namespace,
150
+ ...(input.server !== undefined ? { server: input.server } : {}),
151
+ ...(input.authToken !== undefined ? { authToken: input.authToken } : {}),
152
+ ...(input.insecure !== undefined ? { insecure: input.insecure } : {}),
153
+ ...(input.context !== undefined ? { context: input.context } : {}),
154
+ ...(input.intervalMs !== undefined ? { intervalMs: input.intervalMs } : {}),
155
+ });
156
+ synced.push({ name: app.name, namespace, health: status.health, sync: status.sync });
157
+ }
158
+ return { ...outcome, synced };
159
+ },
160
+ };
161
+ }
162
+
163
+ export const argoAppCapability = createArgoAppCapability();
@@ -14,9 +14,17 @@
14
14
  import { step } from "@intentius/chant/components";
15
15
  import type { KubectlApplyInput } from "./kubectl-apply";
16
16
  import type { KustomizeApplyInput } from "./kustomize-apply";
17
+ import type { ArgoAppInput } from "./argo-app";
18
+ import type { FluxReconcileInput } from "./flux-reconcile";
17
19
 
18
20
  export const kubectlApply = step<KubectlApplyInput & { noRollback?: string }>("kubectl-apply");
19
21
 
20
22
  // Same needs-opt-out posture, same sugar (#1548): a kustomize render in front
21
23
  // of the identical server-side apply.
22
24
  export const kustomizeApply = step<KustomizeApplyInput & { noRollback?: string }>("kustomize-apply");
25
+
26
+ // GitOps verbs (#1549 piece 2): the identical server-side apply of the
27
+ // controller's CR, then a convergence wait — Healthy+Synced for Argo, the
28
+ // Flux readiness registry (Ready, terminal on wedge reasons) for Flux.
29
+ export const argoApp = step<ArgoAppInput & { noRollback?: string }>("argo-app");
30
+ export const fluxReconcile = step<FluxReconcileInput & { noRollback?: string }>("flux-reconcile");
@@ -12,9 +12,14 @@ import type { Capability } from "@intentius/chant/components/capability";
12
12
  import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
13
13
  import { kubectlApplyCapability } from "./kubectl-apply";
14
14
  import { kustomizeApplyCapability } from "./kustomize-apply";
15
+ import { argoAppCapability } from "./argo-app";
16
+ import { fluxReconcileCapability } from "./flux-reconcile";
15
17
 
16
18
  export const K8S_VERB_FAMILIES = {
17
19
  apply: ["kubectl-apply", "kustomize-apply"],
20
+ // GitOps verbs (#1549 piece 2): apply the controller's CR through the same
21
+ // stack-labelled pipeline, then wait for the controller to converge.
22
+ gitops: ["argo-app", "flux-reconcile"],
18
23
  } as const;
19
24
 
20
25
  export const k8sCapabilityPlugin: CapabilityPlugin = {
@@ -23,7 +28,12 @@ export const k8sCapabilityPlugin: CapabilityPlugin = {
23
28
  // a literal here was stale one release after it was written.
24
29
  version: ownPackageVersion(import.meta.url),
25
30
  capabilities(): Array<Capability<never, unknown>> {
26
- return [kubectlApplyCapability as Capability<never, unknown>, kustomizeApplyCapability as Capability<never, unknown>];
31
+ return [
32
+ kubectlApplyCapability as Capability<never, unknown>,
33
+ kustomizeApplyCapability as Capability<never, unknown>,
34
+ argoAppCapability as Capability<never, unknown>,
35
+ fluxReconcileCapability as Capability<never, unknown>,
36
+ ];
27
37
  },
28
38
  families(): Record<string, readonly string[]> {
29
39
  return K8S_VERB_FAMILIES;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `flux-reconcile` (#1549 piece 2) — the Flux deploy leaf. What must hold:
3
+ * the capability registers under its kind in the gitops family, the apply
4
+ * goes through the SAME applier delegation kubectl-apply uses, the wait runs
5
+ * the generic `waitForReady` once per applied Flux CR — sources before
6
+ * reconcilers, non-Flux objects skipped — with the group/kind the #1554
7
+ * readiness entries key on, a manifest with no Flux CR is an error, and the
8
+ * stack is the deploy unit core's status walk reads.
9
+ */
10
+ import { describe, test, expect } from "vitest";
11
+ import { createFluxReconcileCapability, fluxReconcileCapability, type FluxReconcileInput } from "./flux-reconcile";
12
+ import { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./capability-plugin";
13
+ import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
14
+ import type { DeployContext } from "@intentius/chant/components/capability";
15
+
16
+ const ctx = { env: "dev" } as DeployContext;
17
+
18
+ const KUSTOMIZATION = {
19
+ apiVersion: "kustomize.toolkit.fluxcd.io/v1",
20
+ kind: "Kustomization",
21
+ name: "apps",
22
+ namespace: "flux-system",
23
+ };
24
+ const GIT_REPO = {
25
+ apiVersion: "source.toolkit.fluxcd.io/v1",
26
+ kind: "GitRepository",
27
+ name: "repo",
28
+ namespace: "flux-system",
29
+ };
30
+ const HELM_RELEASE = {
31
+ apiVersion: "helm.toolkit.fluxcd.io/v2",
32
+ kind: "HelmRelease",
33
+ name: "podinfo",
34
+ namespace: "flux-system",
35
+ };
36
+ const NAMESPACE = { apiVersion: "v1", kind: "Namespace", name: "flux-system" };
37
+
38
+ describe("flux-reconcile capability (#1549 piece 2)", () => {
39
+ test("registers on the k8s plugin, in the gitops family", () => {
40
+ expect(isCapabilityPlugin(k8sCapabilityPlugin)).toBe(true);
41
+ expect(k8sCapabilityPlugin.capabilities().map((c) => c.kind)).toContain("flux-reconcile");
42
+ expect(K8S_VERB_FAMILIES.gitops).toContain("flux-reconcile");
43
+ });
44
+
45
+ test("rollback is needs-opt-out — a server-side apply keeps no previous state", () => {
46
+ expect(fluxReconcileCapability.rollbackPolicy).toBe("needs-opt-out");
47
+ });
48
+
49
+ test("applies through the kubectl-apply delegation, then waits Ready on each Flux CR — sources first, non-Flux skipped", async () => {
50
+ const applierArgs: unknown[] = [];
51
+ const waited: Array<{ kind: string; group?: string }> = [];
52
+ const cap = createFluxReconcileCapability(
53
+ async (args) => {
54
+ applierArgs.push(args);
55
+ // Applied order: reconciler before its source, plus a plain Namespace.
56
+ return { fieldManager: "chant:gitops", applied: [NAMESPACE, KUSTOMIZATION, GIT_REPO, HELM_RELEASE], pruned: [] };
57
+ },
58
+ async (args) => {
59
+ waited.push(args);
60
+ return {};
61
+ },
62
+ );
63
+
64
+ const outcome = await cap.run(ctx, {
65
+ manifest: "flux/",
66
+ stack: "gitops",
67
+ delete: "owned-only",
68
+ intervalMs: 500,
69
+ } as FluxReconcileInput as never);
70
+
71
+ // The exact args kubectl-apply hands the shared applier — same path, same stamping.
72
+ expect(applierArgs).toEqual([
73
+ { manifest: "flux/", environment: "dev", stack: "gitops", deleteMode: "owned-only" },
74
+ ]);
75
+ // Source first (a Kustomization can't be Ready before its GitRepository has
76
+ // an artifact), then the reconcilers in applied order; the Namespace is not waited on.
77
+ expect(waited).toEqual([
78
+ {
79
+ kind: "GitRepository",
80
+ name: "repo",
81
+ namespace: "flux-system",
82
+ group: "source.toolkit.fluxcd.io",
83
+ environment: "dev",
84
+ intervalMs: 500,
85
+ },
86
+ {
87
+ kind: "Kustomization",
88
+ name: "apps",
89
+ namespace: "flux-system",
90
+ group: "kustomize.toolkit.fluxcd.io",
91
+ environment: "dev",
92
+ intervalMs: 500,
93
+ },
94
+ {
95
+ kind: "HelmRelease",
96
+ name: "podinfo",
97
+ namespace: "flux-system",
98
+ group: "helm.toolkit.fluxcd.io",
99
+ environment: "dev",
100
+ intervalMs: 500,
101
+ },
102
+ ]);
103
+ expect(outcome.ready).toEqual([GIT_REPO, KUSTOMIZATION, HELM_RELEASE]);
104
+ expect(outcome.fieldManager).toBe("chant:gitops");
105
+ });
106
+
107
+ test("the group/kind handed to waitForReady key the #1554 readiness registry entries", async () => {
108
+ // The registry is keyed "<group>/<kind>" — assert the pair the capability
109
+ // passes resolves to the ConditionReasonMatch entry, not the generic default.
110
+ const { readinessFor } = await import("../op/activities/wait-for-ready");
111
+ const spec = readinessFor("kustomize.toolkit.fluxcd.io", "Kustomization");
112
+ expect(spec.terminal).toBeDefined();
113
+ expect(JSON.stringify(spec.terminal)).toContain("BuildFailed");
114
+ });
115
+
116
+ test("a manifest that applied no Flux CR is an error, not a silent no-wait", async () => {
117
+ const cap = createFluxReconcileCapability(
118
+ async () => ({ fieldManager: "chant", applied: [NAMESPACE], pruned: [] }),
119
+ async () => ({}),
120
+ );
121
+ await expect(cap.run(ctx, { manifest: "k8s.yaml" } as FluxReconcileInput as never)).rejects.toThrow(
122
+ /no Flux CR/,
123
+ );
124
+ });
125
+
126
+ test("a wedged CR propagates — the step does not report success past a terminal Ready=False", async () => {
127
+ const cap = createFluxReconcileCapability(
128
+ async () => ({ fieldManager: "chant", applied: [KUSTOMIZATION], pruned: [] }),
129
+ async () => {
130
+ throw new Error("Kustomization/apps reached a terminal state");
131
+ },
132
+ );
133
+ await expect(cap.run(ctx, { manifest: "flux/" } as FluxReconcileInput as never)).rejects.toThrow(
134
+ /terminal state/,
135
+ );
136
+ });
137
+
138
+ test("the stack field is the deploy unit core's status walk reads (#1549 piece 2)", async () => {
139
+ const { deployUnits } = await import("@intentius/chant/components/deploy-units");
140
+ const units = deployUnits([
141
+ { phase: "Apply", steps: [{ kind: "flux-reconcile", manifest: "flux/", stack: "gitops" }] } as never,
142
+ ]);
143
+ expect(units).toEqual([{ unit: "gitops", lexicon: "k8s" }]);
144
+ });
145
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `flux-reconcile` — the Flux deploy leaf for the component model (#1549
3
+ * piece 2).
4
+ *
5
+ * The sibling of `argo-app` for the Flux toolkit: apply the declared Flux
6
+ * CRs (`GitRepository`, `Kustomization`, `HelmRelease`, `OCIRepository`, …)
7
+ * through the SAME server-side apply `kubectl-apply` uses — ownership
8
+ * stamping, marker-scoped prune, stack labels all identical — then block
9
+ * until every applied Flux CR reports `Ready=True` via the generic
10
+ * `waitForReady`. The readiness registry's Flux entries (#1554,
11
+ * `ConditionReasonMatch`) make that wait honest: a wedge reason like
12
+ * `BuildFailed` or `UpgradeFailed` fails fast instead of polling out the
13
+ * timeout.
14
+ *
15
+ * Sources are waited first (`source.toolkit.fluxcd.io` before the rest,
16
+ * applied order within each half): a Kustomization cannot become Ready
17
+ * before its GitRepository has an artifact, so gating on the source first
18
+ * surfaces a wedged clone as the source's error, not as a reconciler
19
+ * timeout downstream of it.
20
+ *
21
+ * The deploy unit is the CR itself under its stack label — one name, both
22
+ * jobs, exactly as kubectl-apply (core `components/deploy-units.ts`).
23
+ *
24
+ * The #1074 boundary applies: the applier and the wait activity are reached
25
+ * by dynamic import inside `run()`; their argument shapes are structural
26
+ * mirrors, checked against the real ones at the call site.
27
+ */
28
+ import type { Capability, DeployContext } from "@intentius/chant/components/capability";
29
+
30
+ /** Structural mirrors of the activity modules' shapes (see the module doc). */
31
+ interface AppliedRef {
32
+ apiVersion: string;
33
+ kind: string;
34
+ name: string;
35
+ namespace?: string;
36
+ }
37
+
38
+ interface ApplyOutcome {
39
+ fieldManager: string;
40
+ applied: AppliedRef[];
41
+ pruned: AppliedRef[];
42
+ }
43
+
44
+ interface ApplierArgs {
45
+ manifest: string;
46
+ environment?: string;
47
+ stack?: string;
48
+ context?: string;
49
+ deleteMode?: "never" | "owned-only" | "gated";
50
+ }
51
+
52
+ type Applier = (args: ApplierArgs) => Promise<ApplyOutcome>;
53
+
54
+ interface ReadyWaiterArgs {
55
+ kind: string;
56
+ name: string;
57
+ namespace?: string;
58
+ group?: string;
59
+ environment?: string;
60
+ context?: string;
61
+ intervalMs?: number;
62
+ }
63
+
64
+ type ReadyWaiter = (args: ReadyWaiterArgs) => Promise<Record<string, unknown>>;
65
+
66
+ export interface FluxReconcileOutcome extends ApplyOutcome {
67
+ /** The Flux CRs that reached Ready, in wait order (sources first). */
68
+ ready: AppliedRef[];
69
+ }
70
+
71
+ export interface FluxReconcileInput {
72
+ /** Path to the Flux CR manifest(s) — a file, or a directory of them. */
73
+ manifest: string;
74
+ /**
75
+ * The deploy unit / ownership stack — same double duty as kubectl-apply's.
76
+ * Omitted derives from the project's `ownership.stack`, but then the unit
77
+ * is invisible to `components status --live`. Name it.
78
+ */
79
+ stack?: string;
80
+ /** kubectl context. Omitted resolves `k8s.profiles.<ctx.env>.context`. */
81
+ context?: string;
82
+ /** Same delete vocabulary as kubectl-apply; default `never`. */
83
+ delete?: "never" | "owned-only";
84
+ /** Poll interval in ms (default 15000). */
85
+ intervalMs?: number;
86
+ }
87
+
88
+ /** The API group of a ref, "" for the core group. */
89
+ function groupOf(ref: AppliedRef): string {
90
+ const slash = ref.apiVersion.indexOf("/");
91
+ return slash === -1 ? "" : ref.apiVersion.slice(0, slash);
92
+ }
93
+
94
+ /**
95
+ * True for any Flux CR: the toolkit groups (`source.` / `kustomize.` /
96
+ * `helm.` / `image.` / `notification.toolkit.fluxcd.io`) plus the flux-operator
97
+ * group (`fluxcd.controlplane.io`, `FluxInstance`). Kinds without a registry
98
+ * entry still wait correctly — the toolkit is kstatus-conformant, so the
99
+ * generic `Ready=True` default covers them; the #1554 entries add fail-fast.
100
+ */
101
+ function isFluxRef(ref: AppliedRef): boolean {
102
+ const group = groupOf(ref);
103
+ return /(^|\.)toolkit\.fluxcd\.io$/.test(group) || group === "fluxcd.controlplane.io";
104
+ }
105
+
106
+ /** Factory with injectable applier + waiter — the argo-app seam. Defaults
107
+ * resolve by dynamic import on first run (#1074). */
108
+ export function createFluxReconcileCapability(
109
+ apply?: Applier,
110
+ wait?: ReadyWaiter,
111
+ ): Capability<FluxReconcileInput, FluxReconcileOutcome> {
112
+ return {
113
+ kind: "flux-reconcile",
114
+ // Server-side apply keeps no previous object state, and the controller
115
+ // reconciles forward only — same posture as kubectl-apply.
116
+ rollbackPolicy: "needs-opt-out",
117
+ async run(ctx: DeployContext, input: FluxReconcileInput): Promise<FluxReconcileOutcome> {
118
+ const applier: Applier = apply ?? (await import("../op/activities/kubectl")).applyManifest;
119
+ const outcome = await applier({
120
+ manifest: input.manifest,
121
+ environment: ctx.env,
122
+ ...(input.stack !== undefined ? { stack: input.stack } : {}),
123
+ ...(input.context !== undefined ? { context: input.context } : {}),
124
+ ...(input.delete !== undefined ? { deleteMode: input.delete } : {}),
125
+ });
126
+
127
+ // Namespaces/ConfigMaps alongside the CRs apply fine; the wait gates
128
+ // only on the Flux objects. Sources first — see the module doc.
129
+ const flux = outcome.applied.filter(isFluxRef);
130
+ if (flux.length === 0) {
131
+ throw new Error(
132
+ `flux-reconcile: "${input.manifest}" applied no Flux CR (toolkit.fluxcd.io / fluxcd.controlplane.io) — ` +
133
+ `nothing to reconcile on. A plain manifest belongs on kubectl-apply.`,
134
+ );
135
+ }
136
+ const sources = flux.filter((r) => groupOf(r) === "source.toolkit.fluxcd.io");
137
+ const rest = flux.filter((r) => groupOf(r) !== "source.toolkit.fluxcd.io");
138
+
139
+ const waiter: ReadyWaiter =
140
+ wait ?? ((await import("../op/activities/wait-for-ready")).waitForReady as unknown as ReadyWaiter);
141
+ const ready: AppliedRef[] = [];
142
+ for (const ref of [...sources, ...rest]) {
143
+ await waiter({
144
+ kind: ref.kind,
145
+ name: ref.name,
146
+ group: groupOf(ref),
147
+ environment: ctx.env,
148
+ ...(ref.namespace !== undefined ? { namespace: ref.namespace } : {}),
149
+ ...(input.context !== undefined ? { context: input.context } : {}),
150
+ ...(input.intervalMs !== undefined ? { intervalMs: input.intervalMs } : {}),
151
+ });
152
+ ready.push(ref);
153
+ }
154
+ return { ...outcome, ready };
155
+ },
156
+ };
157
+ }
158
+
159
+ export const fluxReconcileCapability = createFluxReconcileCapability();
@@ -26,3 +26,16 @@ export {
26
26
  type KustomizeApplyInput,
27
27
  type KustomizeApplyOutcome,
28
28
  } from "./kustomize-apply";
29
+ export {
30
+ argoAppCapability,
31
+ createArgoAppCapability,
32
+ type ArgoAppInput,
33
+ type ArgoAppOutcome,
34
+ type ArgoAppSynced,
35
+ } from "./argo-app";
36
+ export {
37
+ fluxReconcileCapability,
38
+ createFluxReconcileCapability,
39
+ type FluxReconcileInput,
40
+ type FluxReconcileOutcome,
41
+ } from "./flux-reconcile";
@@ -14,32 +14,26 @@
14
14
  * `stack` doubles as the ownership identity and the deploy unit, exactly as
15
15
  * `kubectl-apply` — one name, both jobs.
16
16
  *
17
- * Renderer resolution: `kustomize build`, falling back to
17
+ * Renderer resolution lives in `../kustomize/render.ts` (shared with the
18
+ * build-root path, #1548 piece 3): `kustomize build`, falling back to
18
19
  * `kubectl kustomize` (the same renderer vendored into kubectl) when the
19
20
  * standalone binary is absent. Both run through an injectable runner so tests
20
- * assert the exact command without either binary installed. The 64MiB
21
- * maxBuffer mirrors helm-upgrade's — a big overlay renders megabytes.
21
+ * assert the exact command without either binary installed.
22
22
  *
23
23
  * The #1074 boundary applies as it does to kubectl-apply: nothing exported
24
24
  * from the lexicon entry point may statically import the API-client chain, so
25
25
  * the applier is reached by dynamic import inside `run()` and its argument
26
26
  * shape is a structural mirror.
27
27
  */
28
- import { exec } from "node:child_process";
29
- import { promisify } from "node:util";
30
- import { loadAll } from "js-yaml";
31
28
  import type { Capability, DeployContext } from "@intentius/chant/components/capability";
29
+ import {
30
+ defaultKustomizeRunner,
31
+ renderCommand,
32
+ renderKustomizeDocuments,
33
+ type KustomizeRunner,
34
+ } from "../kustomize/render";
32
35
 
33
- const execAsync = promisify(exec);
34
-
35
- /** Injectable shell runner — the helm-upgrade seam, same buffer bound. */
36
- export type KustomizeRunner = (command: string) => Promise<{ stdout: string }>;
37
- const defaultRunner: KustomizeRunner = (command) => execAsync(command, { maxBuffer: 64 * 1024 * 1024 });
38
-
39
- /** Single-quote shell escaping, as helm-upgrade quotes its argv. */
40
- function q(v: string): string {
41
- return `'${v.replace(/'/g, "'\\''")}'`;
42
- }
36
+ export { renderCommand, type KustomizeRunner };
43
37
 
44
38
  /** Structural mirrors of the activity module's shapes (see the module doc). */
45
39
  interface AppliedRef {
@@ -81,33 +75,10 @@ export interface KustomizeApplyInput {
81
75
  delete?: "never" | "owned-only";
82
76
  }
83
77
 
84
- /** The exact render command, standalone binary first. Pure; exported for tests. */
85
- export function renderCommand(dir: string, tool: "kustomize" | "kubectl" = "kustomize"): string {
86
- return tool === "kustomize" ? `kustomize build ${q(dir)}` : `kubectl kustomize ${q(dir)}`;
87
- }
88
-
89
- /** Render the overlay, falling back to kubectl's vendored kustomize when the
90
- * standalone binary is missing (ENOENT/127), and parse the emitted YAML. */
91
- async function renderDocuments(dir: string, run: KustomizeRunner): Promise<Array<Record<string, unknown>>> {
92
- let stdout: string;
93
- try {
94
- ({ stdout } = await run(renderCommand(dir, "kustomize")));
95
- } catch (err) {
96
- const message = err instanceof Error ? err.message : String(err);
97
- if (!/ENOENT|not found|command not found|127/.test(message)) throw err;
98
- ({ stdout } = await run(renderCommand(dir, "kubectl")));
99
- }
100
- const documents: Array<Record<string, unknown>> = [];
101
- for (const doc of loadAll(stdout)) {
102
- if (doc && typeof doc === "object" && !Array.isArray(doc)) documents.push(doc as Record<string, unknown>);
103
- }
104
- return documents;
105
- }
106
-
107
78
  /** Factory with injectable renderer + applier, so tests assert the render
108
79
  * command and the exact applier delegation without kustomize or a cluster. */
109
80
  export function createKustomizeApplyCapability(
110
- run: KustomizeRunner = defaultRunner,
81
+ run: KustomizeRunner = defaultKustomizeRunner,
111
82
  apply?: Applier,
112
83
  ): Capability<KustomizeApplyInput, KustomizeApplyOutcome> {
113
84
  return {
@@ -116,7 +87,7 @@ export function createKustomizeApplyCapability(
116
87
  // undo of its own — same posture as kubectl-apply.
117
88
  rollbackPolicy: "needs-opt-out",
118
89
  async run(ctx: DeployContext, input: KustomizeApplyInput): Promise<KustomizeApplyOutcome> {
119
- const documents = await renderDocuments(input.dir, run);
90
+ const documents = await renderKustomizeDocuments(input.dir, run);
120
91
  const applier: Applier = apply ?? ((await import("../op/activities/kubectl")).applyManifest as unknown as Applier);
121
92
  return applier({
122
93
  manifest: `kustomize:${input.dir}`,