@intentius/chant-lexicon-k8s 0.30.0 → 0.31.0

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 (54) hide show
  1. package/dist/api/classify.d.ts +55 -0
  2. package/dist/api/classify.d.ts.map +1 -0
  3. package/dist/api/connect.d.ts +58 -0
  4. package/dist/api/connect.d.ts.map +1 -0
  5. package/dist/api/fake-cluster.d.ts +55 -0
  6. package/dist/api/fake-cluster.d.ts.map +1 -0
  7. package/dist/api/operation-surface.d.ts +64 -0
  8. package/dist/api/operation-surface.d.ts.map +1 -0
  9. package/dist/codegen/generate-operations.d.ts +29 -0
  10. package/dist/codegen/generate-operations.d.ts.map +1 -0
  11. package/dist/codegen/generate.d.ts.map +1 -1
  12. package/dist/config.d.ts +17 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/crd/parser.d.ts.map +1 -1
  15. package/dist/crd/types.d.ts +7 -0
  16. package/dist/crd/types.d.ts.map +1 -1
  17. package/dist/describe-resources.d.ts +41 -24
  18. package/dist/describe-resources.d.ts.map +1 -1
  19. package/dist/export-resources.d.ts +27 -1
  20. package/dist/export-resources.d.ts.map +1 -1
  21. package/dist/integrity.json +2 -2
  22. package/dist/manifest.json +1 -1
  23. package/dist/op/activities/index.d.ts +2 -2
  24. package/dist/op/activities/index.d.ts.map +1 -1
  25. package/dist/op/activities/kubectl.d.ts +38 -2
  26. package/dist/op/activities/kubectl.d.ts.map +1 -1
  27. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  28. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  29. package/dist/spec/parse.d.ts +42 -0
  30. package/dist/spec/parse.d.ts.map +1 -1
  31. package/package.json +5 -2
  32. package/src/api/classify.test.ts +133 -0
  33. package/src/api/classify.ts +131 -0
  34. package/src/api/connect.ts +104 -0
  35. package/src/api/fake-cluster.ts +218 -0
  36. package/src/api/operation-surface.test.ts +116 -0
  37. package/src/api/operation-surface.ts +129 -0
  38. package/src/codegen/generate-operations.ts +56 -0
  39. package/src/codegen/generate.ts +9 -0
  40. package/src/config.ts +17 -0
  41. package/src/crd/parser.ts +8 -0
  42. package/src/crd/types.ts +7 -0
  43. package/src/describe-resources.test.ts +396 -191
  44. package/src/describe-resources.ts +134 -118
  45. package/src/export-resources-io.test.ts +76 -51
  46. package/src/export-resources.ts +63 -35
  47. package/src/generated/operations.json +2156 -0
  48. package/src/lifecycle-integration.test.ts +132 -92
  49. package/src/op/activities/index.ts +2 -1
  50. package/src/op/activities/kubectl.test.ts +148 -0
  51. package/src/op/activities/kubectl.ts +86 -13
  52. package/src/op/activities/wait-for-ready.test.ts +94 -0
  53. package/src/op/activities/wait-for-ready.ts +66 -15
  54. package/src/spec/parse.ts +93 -1
@@ -1,34 +1,41 @@
1
1
  /**
2
2
  * Cross-lexicon lifecycle integration (#163) — Kubernetes row.
3
3
  *
4
- * Drives the REAL k8sPlugin through core's live-import driver and the changeset
5
- * path, with the `kubectl` edge mocked.
4
+ * Drives the REAL k8sPlugin through core's live-import driver and the
5
+ * changeset path, with the cluster edge faked at
6
+ * `@kubernetes/client-node`'s request layer (chant #1074, previously at
7
+ * `kubectl`). The plugin's own `describeResources` / `exportResources` are what
8
+ * run; only the socket is replaced, and only a literal kubeconfig is ever read.
6
9
  */
7
10
  import { describe, test, expect, vi, beforeEach } from "vitest";
8
11
  import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
9
12
  import { tmpdir } from "node:os";
10
13
  import { join } from "node:path";
11
14
 
12
- const execMock = vi.fn();
13
- vi.mock("node:child_process", async () => {
14
- const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
15
+ /**
16
+ * The plugin reaches the cluster through `./api/connect`'s default connector,
17
+ * which is the one thing a test must not let run for real: it would read the
18
+ * developer's kubeconfig. Replacing the module swaps in a connector backed by
19
+ * `fakeCluster`, which still builds a real client over a literal kubeconfig.
20
+ */
21
+ const clusterState: { objects: Record<string, unknown>; fail?: (path: string) => { status: number; body: unknown } | undefined } = {
22
+ objects: {},
23
+ };
24
+
25
+ vi.mock("./api/connect", async () => {
26
+ const { fakeCluster } = await import("./api/fake-cluster");
15
27
  return {
16
- ...actual,
17
- exec: (
18
- cmd: string,
19
- cb: (err: Error | null, out: { stdout: string; stderr: string }) => void,
20
- ) => {
21
- const r = execMock(cmd);
22
- queueMicrotask(() =>
23
- r instanceof Error
24
- ? cb(r, { stdout: "", stderr: "" })
25
- : cb(null, r as { stdout: string; stderr: string }),
26
- );
27
- },
28
+ defaultK8sConnector: (options: unknown) =>
29
+ fakeCluster({
30
+ objects: clusterState.objects as never,
31
+ respond: (req) => clusterState.fail?.(req.path),
32
+ }).connector(options as never),
28
33
  };
29
34
  });
30
35
 
31
36
  const { k8sPlugin } = await import("./plugin");
37
+ const { objectKey } = await import("./api/fake-cluster");
38
+ const { statusBody } = await import("@intentius/chant-k8s-client/testing");
32
39
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
33
40
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
34
41
  const { normalizeObservation } = await import("@intentius/chant/observation");
@@ -42,24 +49,24 @@ const liveDeployment = {
42
49
  spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
43
50
  };
44
51
 
45
- const emptyList = { stdout: JSON.stringify({ items: [] }), stderr: "" };
52
+ const observedWeb = {
53
+ apiVersion: "apps/v1",
54
+ kind: "Deployment",
55
+ metadata: { name: "web", namespace: "prod", uid: "uid-1" },
56
+ status: { readyReplicas: 3, replicas: 3 },
57
+ };
46
58
 
47
59
  describe("k8s lifecycle integration (#163)", () => {
48
- beforeEach(() => execMock.mockReset());
60
+ beforeEach(() => {
61
+ clusterState.objects = {};
62
+ clusterState.fail = undefined;
63
+ });
49
64
 
50
65
  test("live-import driver: real exportResources → IR → generated source", async () => {
51
- execMock.mockImplementation((cmd?: string) =>
52
- cmd?.includes("get deployment.apps")
53
- ? { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" }
54
- : emptyList,
55
- );
66
+ clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment };
56
67
  const output = mkdtempSync(join(tmpdir(), "chant-k8s-li-"));
57
68
  try {
58
- const result = await liveImportFromPlugins([k8sPlugin], {
59
- environment: "prod",
60
- output,
61
- force: true,
62
- });
69
+ const result = await liveImportFromPlugins([k8sPlugin], { environment: "prod", output, force: true });
63
70
  expect(result.success).toBe(true);
64
71
  expect(result.generatedFiles.length).toBeGreaterThan(0);
65
72
  const all = readdirSync(output)
@@ -73,17 +80,7 @@ describe("k8s lifecycle integration (#163)", () => {
73
80
  });
74
81
 
75
82
  test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
76
- execMock.mockImplementation((cmd?: string) =>
77
- cmd?.includes("deployment.apps web")
78
- ? {
79
- stdout: JSON.stringify({
80
- metadata: { name: "web", namespace: "prod", uid: "uid-1" },
81
- status: { readyReplicas: 3, replicas: 3 },
82
- }),
83
- stderr: "",
84
- }
85
- : new Error("not found"),
86
- );
83
+ clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "prod")]: observedWeb };
87
84
 
88
85
  const { resources: observedNow } = normalizeObservation(
89
86
  await k8sPlugin.describeResources!({
@@ -97,45 +94,32 @@ describe("k8s lifecycle integration (#163)", () => {
97
94
  );
98
95
  expect(observedNow.web?.type).toBe("K8s::Apps::Deployment");
99
96
 
100
- const cs = buildChangeSet("prod", {
101
- declared: new Set(["webSvc"]),
102
- observedNow,
103
- observedThen: undefined,
104
- });
97
+ const cs = buildChangeSet("prod", { declared: new Set(["webSvc"]), observedNow, observedThen: undefined });
105
98
  const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
106
99
  expect(byName.webSvc).toBe("create");
107
100
  expect(byName.web).toBe("adopt");
108
101
 
109
- const cs2 = buildChangeSet("prod", {
110
- declared: new Set(["web"]),
111
- observedNow,
112
- observedThen: undefined,
113
- });
102
+ const cs2 = buildChangeSet("prod", { declared: new Set(["web"]), observedNow, observedThen: undefined });
114
103
  expect(cs2.entries.find((e) => e.name === "web")!.action).toBe("noop");
115
104
  });
116
105
 
117
106
  /**
118
- * The #1089 chain, end to end on the real plugin: a declared CRD the lexicon
119
- * has no reader for goes describe plan component status without ever
120
- * turning into a create or a "stale" component.
107
+ * The #1089 chain, end to end on the real plugin. The entity that used to
108
+ * demonstrate it a CRD with no `KUBECTL_RESOURCE` entry is no longer a
109
+ * hole, because #1074 removed the map; an RBAC-denied read is what produces
110
+ * a hole now, and it has to survive describe → plan → status the same way.
121
111
  */
122
- test("tri-state chain: an unreadable CRD stays unobserved through describe → plan → status (#1089)", async () => {
123
- execMock.mockImplementation((cmd?: string) =>
124
- cmd?.includes("deployment.apps web")
125
- ? {
126
- stdout: JSON.stringify({
127
- metadata: { name: "web", namespace: "prod", uid: "uid-1" },
128
- status: { readyReplicas: 3, replicas: 3 },
129
- }),
130
- stderr: "",
131
- }
132
- : new Error('Error from server (NotFound): deployments.apps "gone" not found'),
133
- );
112
+ test("tri-state chain: an unreadable entity stays unobserved through describe → plan → status (#1089)", async () => {
113
+ clusterState.objects = { [objectKey("apps/v1", "Deployment", "web", "prod")]: observedWeb };
114
+ clusterState.fail = (path) =>
115
+ path.endsWith("/deployments/widget")
116
+ ? { status: 403, body: statusBody(403, "Forbidden", 'deployments.apps "widget" is forbidden') }
117
+ : undefined;
134
118
 
135
119
  const entities = new Map([
136
120
  ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
137
121
  ["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone", namespace: "prod" } } }],
138
- ["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget", namespace: "prod" } } }],
122
+ ["widget", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "widget", namespace: "prod" } } }],
139
123
  ]);
140
124
 
141
125
  // 1. describe — three declared entities, three different verdicts.
@@ -148,10 +132,11 @@ describe("k8s lifecycle integration (#163)", () => {
148
132
  }),
149
133
  );
150
134
  expect(Object.keys(observed.resources)).toEqual(["web"]);
151
- expect(observed.unobserved.widget.reason).toBe("unsupported-kind");
135
+ expect(observed.unobserved.widget.reason).toBe("no-credentials");
152
136
  expect(observed.unobserved.gone).toBeUndefined(); // NotFound is an absence
153
137
 
154
- // 2. plan — the CRD is `unobserved`; only the confirmed-absent one is a create.
138
+ // 2. plan — the unreadable one is `unobserved`; only the confirmed-absent
139
+ // one is a create.
155
140
  const cs = buildChangeSet("prod", {
156
141
  declared: new Set(entities.keys()),
157
142
  observedNow: observed.resources,
@@ -163,15 +148,64 @@ describe("k8s lifecycle integration (#163)", () => {
163
148
 
164
149
  // 3. status — a recorded component whose entity was never read reports
165
150
  // `unknown`, not `stale`, and carries no `live` boolean at all.
166
- const rows = reconcileStatus("prod", [
167
- { component: "widget", env: "prod", digest: "sha256:abc", gitSha: "g", runId: "r", timestamp: "2026-01-01T00:00:00Z", actor: "ci" },
168
- ], { liveEvidence: liveEvidenceFromChangeSet(cs) });
151
+ const rows = reconcileStatus(
152
+ "prod",
153
+ [
154
+ {
155
+ component: "widget",
156
+ env: "prod",
157
+ digest: "sha256:abc",
158
+ gitSha: "g",
159
+ runId: "r",
160
+ timestamp: "2026-01-01T00:00:00Z",
161
+ actor: "ci",
162
+ },
163
+ ],
164
+ { liveEvidence: liveEvidenceFromChangeSet(cs) },
165
+ );
169
166
  const widgetRow = rows.find((r) => r.component === "widget")!;
170
167
  expect(widgetRow.reconciliation).toBe("unknown");
171
168
  expect(widgetRow.live).toBeUndefined();
172
- expect(widgetRow.unobserved?.reason).toBe("unsupported-kind");
169
+ expect(widgetRow.unobserved?.reason).toBe("no-credentials");
173
170
  expect(widgetRow.detail).toContain("could not be observed");
174
171
  });
172
+
173
+ /**
174
+ * chant #1074's coverage claim, on the real plugin: a CRD that the old path
175
+ * warn-skipped as `unsupported-kind` now reads like anything else.
176
+ */
177
+ test("a CRD is observed, not skipped (chant #1074)", async () => {
178
+ clusterState.objects = {
179
+ [objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: {
180
+ apiVersion: "ray.io/v1",
181
+ kind: "RayCluster",
182
+ metadata: { name: "ml", namespace: "ray", uid: "uid-ray" },
183
+ status: { phase: "ready" },
184
+ },
185
+ };
186
+
187
+ const observed = normalizeObservation(
188
+ await k8sPlugin.describeResources!({
189
+ environment: "prod",
190
+ buildOutput: "",
191
+ entityNames: ["ml"],
192
+ entities: new Map([
193
+ ["ml", { entityType: "K8s::Ray::RayCluster", props: { metadata: { name: "ml", namespace: "ray" } } }],
194
+ ]),
195
+ }),
196
+ );
197
+
198
+ expect(observed.unobserved).toEqual({});
199
+ expect(observed.resources.ml?.physicalId).toBe("uid-ray");
200
+
201
+ const cs = buildChangeSet("prod", {
202
+ declared: new Set(["ml"]),
203
+ observedNow: observed.resources,
204
+ observedThen: undefined,
205
+ unobserved: observed.unobserved,
206
+ });
207
+ expect(cs.entries.find((e) => e.name === "ml")!.action).toBe("noop");
208
+ });
175
209
  });
176
210
 
177
211
  // The shared conformance suite (#1089) — every observing lexicon runs it.
@@ -179,18 +213,22 @@ describeObservationConformance({
179
213
  lexicon: "k8s",
180
214
  scenarios: [
181
215
  {
182
- name: "a CRD kind with no reader",
216
+ name: "an RBAC-denied read alongside a confirmed absence",
183
217
  declared: ["widget", "gone"],
184
218
  expectUnobserved: ["widget"],
185
219
  expectAbsent: ["gone"],
186
220
  run: () => {
187
- execMock.mockImplementation(() => new Error('Error from server (NotFound): deployments.apps "gone" not found'));
221
+ clusterState.objects = {};
222
+ clusterState.fail = (path) =>
223
+ path.endsWith("/deployments/widget")
224
+ ? { status: 403, body: statusBody(403, "Forbidden", "forbidden") }
225
+ : undefined;
188
226
  return k8sPlugin.describeResources!({
189
227
  environment: "prod",
190
228
  buildOutput: "",
191
229
  entityNames: ["widget", "gone"],
192
230
  entities: new Map([
193
- ["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget" } } }],
231
+ ["widget", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "widget" } } }],
194
232
  ["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone" } } }],
195
233
  ]),
196
234
  });
@@ -201,18 +239,15 @@ describeObservationConformance({
201
239
  declared: ["web"],
202
240
  expectUnobserved: ["web"],
203
241
  run: () => {
204
- execMock.mockImplementation(() =>
205
- Object.assign(new Error("kubectl failed"), {
206
- stderr: "The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?",
207
- }),
208
- );
242
+ clusterState.objects = {};
243
+ clusterState.fail = () => {
244
+ throw new Error("connect ECONNREFUSED 127.0.0.1:6443");
245
+ };
209
246
  return k8sPlugin.describeResources!({
210
247
  environment: "prod",
211
248
  buildOutput: "",
212
249
  entityNames: ["web"],
213
- entities: new Map([
214
- ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
215
- ]),
250
+ entities: new Map([["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }]]),
216
251
  });
217
252
  },
218
253
  },
@@ -221,20 +256,25 @@ describeObservationConformance({
221
256
  declared: ["web"],
222
257
  expectPresent: ["web"],
223
258
  run: () => {
224
- execMock.mockImplementation(() => ({
225
- stdout: JSON.stringify({
226
- metadata: { name: "web", namespace: "prod", uid: "uid-1", labels: { "app.kubernetes.io/managed-by": "chant" } },
259
+ clusterState.fail = undefined;
260
+ clusterState.objects = {
261
+ [objectKey("apps/v1", "Deployment", "web", "default")]: {
262
+ apiVersion: "apps/v1",
263
+ kind: "Deployment",
264
+ metadata: {
265
+ name: "web",
266
+ namespace: "default",
267
+ uid: "uid-1",
268
+ labels: { "app.kubernetes.io/managed-by": "chant" },
269
+ },
227
270
  status: { readyReplicas: 1, replicas: 1 },
228
- }),
229
- stderr: "",
230
- }));
271
+ },
272
+ };
231
273
  return k8sPlugin.describeResources!({
232
274
  environment: "prod",
233
275
  buildOutput: "",
234
276
  entityNames: ["web"],
235
- entities: new Map([
236
- ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
237
- ]),
277
+ entities: new Map([["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }]]),
238
278
  });
239
279
  },
240
280
  },
@@ -11,7 +11,7 @@
11
11
  * dependency-light — it shells out to a CLI and does not import the k8s declarable
12
12
  * surface — so a Temporal worker loads it cheaply.
13
13
  */
14
- export { kubectlApply } from "./kubectl";
14
+ export { kubectlApply, readManifestDocuments } from "./kubectl";
15
15
  export type { KubectlApplyArgs } from "./kubectl";
16
16
 
17
17
  export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d";
@@ -23,6 +23,7 @@ export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./ar
23
23
  export {
24
24
  waitForReady,
25
25
  defaultResourceFetcher,
26
+ apiResourceFetcher,
26
27
  ReadinessFailedError,
27
28
  readinessFor,
28
29
  isReady,
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `kubectlApply` over the typed API client (chant #1074).
3
+ *
4
+ * The activity contract is what Temporal workers register, so the shape of the
5
+ * arguments and the `Promise<void>` return are asserted alongside the new
6
+ * behavior. Nothing here spawns a process or reads an ambient kubeconfig,
7
+ * which is the acceptance criterion: a worker image needs no `kubectl` binary.
8
+ */
9
+
10
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { kubectlApply, readManifestDocuments } from "./kubectl";
15
+ import { fakeCluster } from "../../api/fake-cluster";
16
+ import { statusBody } from "@intentius/chant-k8s-client/testing";
17
+
18
+ let dir: string;
19
+
20
+ const deploymentYaml = `apiVersion: apps/v1
21
+ kind: Deployment
22
+ metadata:
23
+ name: web
24
+ namespace: prod
25
+ spec:
26
+ replicas: 3
27
+ ---
28
+ apiVersion: v1
29
+ kind: Service
30
+ metadata:
31
+ name: web-svc
32
+ namespace: prod
33
+ spec:
34
+ ports:
35
+ - port: 80
36
+ `;
37
+
38
+ beforeEach(() => {
39
+ dir = mkdtempSync(join(tmpdir(), "chant-k8s-apply-"));
40
+ });
41
+ afterEach(() => {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ describe("readManifestDocuments", () => {
46
+ test("splits a multi-document file, dropping empty documents", () => {
47
+ const file = join(dir, "k8s.yaml");
48
+ writeFileSync(file, `${deploymentYaml}---\n---\n`);
49
+ const docs = readManifestDocuments(file);
50
+ expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
51
+ });
52
+
53
+ test("a directory is read in sorted file order, as `kubectl apply -f <dir>` does", () => {
54
+ writeFileSync(join(dir, "20-service.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: b\n");
55
+ writeFileSync(join(dir, "10-deployment.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: a\n");
56
+ writeFileSync(join(dir, "notes.txt"), "ignored");
57
+ const docs = readManifestDocuments(dir);
58
+ expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
59
+ });
60
+
61
+ test("JSON manifests are read too", () => {
62
+ const file = join(dir, "k8s.json");
63
+ writeFileSync(file, JSON.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: "c" } }));
64
+ expect(readManifestDocuments(file).map((d) => d.kind)).toEqual(["ConfigMap"]);
65
+ });
66
+ });
67
+
68
+ describe("kubectlApply", () => {
69
+ test("server-side applies every document, in file order, as the chant field manager", async () => {
70
+ const file = join(dir, "k8s.yaml");
71
+ writeFileSync(file, deploymentYaml);
72
+ const cluster = fakeCluster({
73
+ respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
74
+ });
75
+
76
+ await kubectlApply({ manifest: file }, undefined, cluster.connector);
77
+
78
+ const patches = cluster.layer.requests.filter((r) => r.method === "PATCH");
79
+ expect(patches.map((p) => p.path)).toEqual([
80
+ "/apis/apps/v1/namespaces/prod/deployments/web",
81
+ "/api/v1/namespaces/prod/services/web-svc",
82
+ ]);
83
+ for (const patch of patches) {
84
+ expect(patch.headers["Content-Type"]).toBe("application/apply-patch+yaml");
85
+ expect(patch.query).toMatchObject({ fieldManager: "chant", force: "false" });
86
+ }
87
+ });
88
+
89
+ test("an explicit context is honored and skips the environment lookup entirely", async () => {
90
+ const file = join(dir, "k8s.yaml");
91
+ writeFileSync(file, deploymentYaml);
92
+ const cluster = fakeCluster({
93
+ respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
94
+ });
95
+
96
+ await kubectlApply({ manifest: file, context: "test-context" }, undefined, cluster.connector);
97
+
98
+ expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
99
+ expect(cluster.connects[0].environment).toBeUndefined();
100
+ });
101
+
102
+ test("an environment is passed through so the cluster binding applies to the write path too", async () => {
103
+ const file = join(dir, "k8s.yaml");
104
+ writeFileSync(file, deploymentYaml);
105
+ const cluster = fakeCluster({
106
+ respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
107
+ });
108
+
109
+ await kubectlApply({ manifest: file, environment: "prod" }, undefined, cluster.connector);
110
+ expect(cluster.connects[0]).toMatchObject({ environment: "prod" });
111
+ });
112
+
113
+ test("force is forwarded, for the caller that means to take ownership (chant #1075)", async () => {
114
+ const file = join(dir, "k8s.yaml");
115
+ writeFileSync(file, deploymentYaml);
116
+ const cluster = fakeCluster({
117
+ respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
118
+ });
119
+
120
+ await kubectlApply({ manifest: file, force: true, fieldManager: "chant-op" }, undefined, cluster.connector);
121
+ const patch = cluster.layer.requests.find((r) => r.method === "PATCH")!;
122
+ expect(patch.query).toMatchObject({ force: "true", fieldManager: "chant-op" });
123
+ });
124
+
125
+ test("a field-ownership conflict surfaces as a typed error rather than parsed stderr", async () => {
126
+ const file = join(dir, "k8s.yaml");
127
+ writeFileSync(file, deploymentYaml);
128
+ const cluster = fakeCluster({
129
+ respond: (req) =>
130
+ req.method === "PATCH"
131
+ ? { status: 409, body: statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "kubectl"') }
132
+ : undefined,
133
+ });
134
+
135
+ const err = await kubectlApply({ manifest: file }, undefined, cluster.connector).catch((e: unknown) => e);
136
+ expect((err as { name?: string }).name).toBe("K8sApiError");
137
+ expect((err as { statusCode?: number }).statusCode).toBe(409);
138
+ });
139
+
140
+ test("resolves to undefined, keeping the activity's Promise<void> contract", async () => {
141
+ const file = join(dir, "k8s.yaml");
142
+ writeFileSync(file, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n namespace: prod\n");
143
+ const cluster = fakeCluster({
144
+ respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
145
+ });
146
+ await expect(kubectlApply({ manifest: file }, undefined, cluster.connector)).resolves.toBeUndefined();
147
+ });
148
+ });
@@ -1,10 +1,35 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { safeHeartbeat } from "@intentius/chant/op";
1
+ /**
2
+ * `kubectlApply` apply a rendered manifest to a cluster.
3
+ *
4
+ * chant #1074 moved this off `kubectl apply -f`. The activity contract is
5
+ * unchanged (a manifest path, an optional context, `Promise<void>`, the
6
+ * `longInfra` profile's 15s heartbeat) because Temporal workers register it by
7
+ * that signature; what changed is underneath. The name is kept for the same
8
+ * reason.
9
+ *
10
+ * Consequences worth knowing:
11
+ *
12
+ * - **A worker image needs no `kubectl` binary.** That was the point.
13
+ * - **It is a server-side apply**, with `chant` as the field manager, rather
14
+ * than the client-side three-way merge `kubectl apply` performs by default.
15
+ * Server-side apply is the direction Kubernetes itself has taken, it removes
16
+ * the `last-applied-configuration` annotation from the story, and it is what
17
+ * chant #1075 builds the field-ownership and conflict surface on. A conflict
18
+ * with another field manager arrives here as a typed 409 rather than a line
19
+ * of stderr; #1075 is where it gets a proper presentation.
20
+ * - **Documents apply in file order**, as `kubectl apply -f` does, and a
21
+ * directory's files are read in sorted order.
22
+ */
4
23
 
5
- const execAsync = promisify(exec);
24
+ import { readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { loadAll } from "js-yaml";
27
+ import { safeHeartbeat } from "@intentius/chant/op";
28
+ import type { K8sObject } from "@intentius/chant-k8s-client";
29
+ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
6
30
 
7
31
  export interface KubectlApplyArgs {
32
+ /** Path to a manifest file, or a directory of them. */
8
33
  manifest: string;
9
34
  /**
10
35
  * kubectl context name. Uses the ambient context if omitted. To target the
@@ -13,25 +38,73 @@ export interface KubectlApplyArgs {
13
38
  * `./index.ts` and pass `.context` through.
14
39
  */
15
40
  context?: string;
41
+ /**
42
+ * chant environment, used to resolve `k8s.profiles.<env>.context` when no
43
+ * explicit `context` is given. Optional and additive: omitting both keeps
44
+ * the previous behavior of using whatever the kubeconfig selects.
45
+ */
46
+ environment?: string;
47
+ /** Field manager recorded on the applied objects. Default `chant`. */
48
+ fieldManager?: string;
49
+ /** Take ownership of fields another manager owns instead of failing (chant #1075). */
50
+ force?: boolean;
51
+ }
52
+
53
+ /** Read a manifest path — one file, or every YAML/JSON file in a directory. */
54
+ export function readManifestDocuments(path: string): Record<string, unknown>[] {
55
+ const files = statSync(path).isDirectory()
56
+ ? readdirSync(path)
57
+ .filter((f) => /\.(ya?ml|json)$/i.test(f))
58
+ .sort()
59
+ .map((f) => join(path, f))
60
+ : [path];
61
+
62
+ const documents: Record<string, unknown>[] = [];
63
+ for (const file of files) {
64
+ for (const doc of loadAll(readFileSync(file, "utf-8"))) {
65
+ // Multi-document YAML files routinely carry empty documents between
66
+ // separators; they are not objects to apply.
67
+ if (doc && typeof doc === "object") documents.push(doc as Record<string, unknown>);
68
+ }
69
+ }
70
+ return documents;
16
71
  }
17
72
 
18
73
  /**
19
- * Run `kubectl apply -f <manifest>`.
74
+ * Apply every document in `args.manifest`.
20
75
  * Uses longInfra profile — 20m timeout, heartbeat every 15s.
21
76
  */
22
- export async function kubectlApply(args: KubectlApplyArgs, signal?: AbortSignal): Promise<void> {
23
- const ctx = args.context ? `--context ${args.context}` : "";
77
+ export async function kubectlApply(
78
+ args: KubectlApplyArgs,
79
+ signal?: AbortSignal,
80
+ connect: K8sConnector = defaultK8sConnector,
81
+ ): Promise<void> {
82
+ const documents = readManifestDocuments(args.manifest);
24
83
  const heartbeatInterval = setInterval(() => {
25
84
  safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
26
85
  }, 15_000);
27
86
 
28
87
  try {
29
- const { stdout, stderr } = await execAsync(
30
- `kubectl apply -f ${args.manifest} ${ctx} --wait=true`,
31
- { signal },
32
- );
33
- if (stdout) console.log(stdout);
34
- if (stderr) console.error(stderr);
88
+ const { client } = await connect({
89
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
90
+ ...(args.context !== undefined ? { context: args.context } : {}),
91
+ });
92
+
93
+ for (const document of documents) {
94
+ const applied = await client.apply(document as K8sObject, {
95
+ fieldManager: args.fieldManager ?? "chant",
96
+ force: args.force ?? false,
97
+ signal,
98
+ });
99
+ safeHeartbeat({
100
+ step: "kubectl apply",
101
+ manifest: args.manifest,
102
+ applied: `${applied.kind ?? document.kind}/${applied.metadata?.name ?? "?"}`,
103
+ });
104
+ console.log(
105
+ `${String(applied.apiVersion ?? document.apiVersion)} ${String(applied.kind ?? document.kind)}/${String(applied.metadata?.name ?? "")} applied`,
106
+ );
107
+ }
35
108
  } finally {
36
109
  clearInterval(heartbeatInterval);
37
110
  }