@intentius/chant-lexicon-k8s 0.30.0 → 0.32.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 (137) hide show
  1. package/dist/api/classify.d.ts +65 -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/api/owner-chain.d.ts +46 -0
  10. package/dist/api/owner-chain.d.ts.map +1 -0
  11. package/dist/api/sweep-types.d.ts +17 -0
  12. package/dist/api/sweep-types.d.ts.map +1 -0
  13. package/dist/codegen/generate-operations.d.ts +29 -0
  14. package/dist/codegen/generate-operations.d.ts.map +1 -0
  15. package/dist/codegen/generate.d.ts.map +1 -1
  16. package/dist/config.d.ts +17 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/crd/parser.d.ts.map +1 -1
  19. package/dist/crd/types.d.ts +7 -0
  20. package/dist/crd/types.d.ts.map +1 -1
  21. package/dist/deep-observe-hooks.d.ts +86 -0
  22. package/dist/deep-observe-hooks.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +146 -0
  24. package/dist/deep-observe.d.ts.map +1 -0
  25. package/dist/describe-resources.d.ts +58 -26
  26. package/dist/describe-resources.d.ts.map +1 -1
  27. package/dist/export-resources.d.ts +31 -1
  28. package/dist/export-resources.d.ts.map +1 -1
  29. package/dist/integrity.json +2 -2
  30. package/dist/kube/apply.d.ts +26 -0
  31. package/dist/kube/apply.d.ts.map +1 -0
  32. package/dist/kube/connect.d.ts +30 -0
  33. package/dist/kube/connect.d.ts.map +1 -0
  34. package/dist/kube/delete.d.ts +27 -0
  35. package/dist/kube/delete.d.ts.map +1 -0
  36. package/dist/kube/describe.d.ts +19 -0
  37. package/dist/kube/describe.d.ts.map +1 -0
  38. package/dist/kube/events.d.ts +34 -0
  39. package/dist/kube/events.d.ts.map +1 -0
  40. package/dist/kube/flags.d.ts +40 -0
  41. package/dist/kube/flags.d.ts.map +1 -0
  42. package/dist/kube/get.d.ts +21 -0
  43. package/dist/kube/get.d.ts.map +1 -0
  44. package/dist/kube/group.d.ts +19 -0
  45. package/dist/kube/group.d.ts.map +1 -0
  46. package/dist/kube/logs.d.ts +16 -0
  47. package/dist/kube/logs.d.ts.map +1 -0
  48. package/dist/kube/project.d.ts +59 -0
  49. package/dist/kube/project.d.ts.map +1 -0
  50. package/dist/kube/render.d.ts +75 -0
  51. package/dist/kube/render.d.ts.map +1 -0
  52. package/dist/kube/source.d.ts +19 -0
  53. package/dist/kube/source.d.ts.map +1 -0
  54. package/dist/kube/target.d.ts +52 -0
  55. package/dist/kube/target.d.ts.map +1 -0
  56. package/dist/kube/testing.d.ts +21 -0
  57. package/dist/kube/testing.d.ts.map +1 -0
  58. package/dist/kube/top.d.ts +23 -0
  59. package/dist/kube/top.d.ts.map +1 -0
  60. package/dist/kube/verdict.d.ts +45 -0
  61. package/dist/kube/verdict.d.ts.map +1 -0
  62. package/dist/kube/wait.d.ts +23 -0
  63. package/dist/kube/wait.d.ts.map +1 -0
  64. package/dist/manifest.json +1 -1
  65. package/dist/op/activities/index.d.ts +7 -4
  66. package/dist/op/activities/index.d.ts.map +1 -1
  67. package/dist/op/activities/kubectl.d.ts +120 -2
  68. package/dist/op/activities/kubectl.d.ts.map +1 -1
  69. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  70. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  71. package/dist/plugin.d.ts.map +1 -1
  72. package/dist/spec/parse.d.ts +42 -0
  73. package/dist/spec/parse.d.ts.map +1 -1
  74. package/package.json +5 -2
  75. package/src/api/classify.test.ts +133 -0
  76. package/src/api/classify.ts +144 -0
  77. package/src/api/connect.ts +104 -0
  78. package/src/api/fake-cluster.ts +218 -0
  79. package/src/api/operation-surface.test.ts +116 -0
  80. package/src/api/operation-surface.ts +129 -0
  81. package/src/api/owner-chain.test.ts +171 -0
  82. package/src/api/owner-chain.ts +144 -0
  83. package/src/api/sweep-types.ts +36 -0
  84. package/src/codegen/generate-operations.ts +56 -0
  85. package/src/codegen/generate.ts +9 -0
  86. package/src/config.ts +17 -0
  87. package/src/crd/parser.ts +8 -0
  88. package/src/crd/types.ts +7 -0
  89. package/src/deep-observe-hooks.ts +125 -0
  90. package/src/deep-observe.test.ts +668 -0
  91. package/src/deep-observe.ts +299 -0
  92. package/src/describe-resources.test.ts +619 -191
  93. package/src/describe-resources.ts +218 -117
  94. package/src/export-resources-io.test.ts +76 -51
  95. package/src/export-resources.ts +47 -35
  96. package/src/generated/operations.json +2156 -0
  97. package/src/kube/apply.test.ts +105 -0
  98. package/src/kube/apply.ts +101 -0
  99. package/src/kube/connect.ts +54 -0
  100. package/src/kube/delete.test.ts +123 -0
  101. package/src/kube/delete.ts +174 -0
  102. package/src/kube/describe.test.ts +106 -0
  103. package/src/kube/describe.ts +195 -0
  104. package/src/kube/events.test.ts +115 -0
  105. package/src/kube/events.ts +160 -0
  106. package/src/kube/flags.test.ts +65 -0
  107. package/src/kube/flags.ts +114 -0
  108. package/src/kube/get.test.ts +195 -0
  109. package/src/kube/get.ts +190 -0
  110. package/src/kube/group.test.ts +33 -0
  111. package/src/kube/group.ts +125 -0
  112. package/src/kube/logs.test.ts +126 -0
  113. package/src/kube/logs.ts +96 -0
  114. package/src/kube/project.test.ts +57 -0
  115. package/src/kube/project.ts +106 -0
  116. package/src/kube/render.test.ts +142 -0
  117. package/src/kube/render.ts +252 -0
  118. package/src/kube/source.test.ts +138 -0
  119. package/src/kube/source.ts +139 -0
  120. package/src/kube/target.test.ts +67 -0
  121. package/src/kube/target.ts +89 -0
  122. package/src/kube/testing.ts +42 -0
  123. package/src/kube/top.test.ts +149 -0
  124. package/src/kube/top.ts +120 -0
  125. package/src/kube/verdict.test.ts +48 -0
  126. package/src/kube/verdict.ts +77 -0
  127. package/src/kube/wait.test.ts +125 -0
  128. package/src/kube/wait.ts +160 -0
  129. package/src/lifecycle-integration.test.ts +132 -92
  130. package/src/op/activities/index.ts +7 -3
  131. package/src/op/activities/kubectl.test.ts +420 -0
  132. package/src/op/activities/kubectl.ts +379 -13
  133. package/src/op/activities/wait-for-ready.test.ts +94 -0
  134. package/src/op/activities/wait-for-ready.ts +66 -15
  135. package/src/plugin.test.ts +58 -1
  136. package/src/plugin.ts +29 -0
  137. package/src/spec/parse.ts +93 -1
@@ -1,10 +1,61 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
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
+ * chant #1075 finished the job on two axes:
11
+ *
12
+ * - **Identity.** The field manager is no longer the bare `chant` but
13
+ * `chant:<stack>`, derived from the project's `ownership.stack` — the same
14
+ * identity the ownership label already carries. See
15
+ * `@intentius/chant-k8s-client`'s `field-manager.ts`.
16
+ * - **Deletes.** `nativeApply`'s kubectl branch used to shell
17
+ * `kubectl apply --prune --selector <marker>`; that branch now routes here,
18
+ * so the prune had to come with it. {@link applyManifest} implements it
19
+ * against the typed client, scoped to the ownership marker exactly as the
20
+ * shelled prune was, and never touching an object that does not carry it.
21
+ *
22
+ * Consequences worth knowing:
23
+ *
24
+ * - **A worker image needs no `kubectl` binary.** That was the point.
25
+ * - **It is a server-side apply**, rather than the client-side three-way merge
26
+ * `kubectl apply` performs by default. A conflict with another field manager
27
+ * arrives as a `FieldManagerConflictError` naming the competing manager and
28
+ * the contested paths — never as a silent overwrite, and never force-resolved
29
+ * on chant's initiative.
30
+ * - **Documents apply in file order**, as `kubectl apply -f` does, and a
31
+ * directory's files are read in sorted order.
32
+ */
33
+
34
+ import { readFileSync, readdirSync, statSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { loadAll } from "js-yaml";
3
37
  import { safeHeartbeat } from "@intentius/chant/op";
38
+ import { loadChantConfig, resolveOwnershipMarker } from "@intentius/chant/config";
39
+ import {
40
+ hasOwnershipMarker,
41
+ LABEL_OWNERSHIP_KEYS,
42
+ OWNERSHIP_MANAGED_BY_VALUE,
43
+ } from "@intentius/chant/ownership";
44
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
45
+ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
46
+ import { operationFor } from "../../api/operation-surface";
47
+ import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
4
48
 
5
- const execAsync = promisify(exec);
49
+ /**
50
+ * How the apply treats chant-owned objects that are no longer declared. The
51
+ * same three values `nativeApply` has always taken; `gated` differs from
52
+ * `owned-only` only in that the workflow pauses for approval first, which is
53
+ * the composite's business, not this activity's.
54
+ */
55
+ export type ApplyDeleteMode = "never" | "owned-only" | "gated";
6
56
 
7
57
  export interface KubectlApplyArgs {
58
+ /** Path to a manifest file, or a directory of them. */
8
59
  manifest: string;
9
60
  /**
10
61
  * kubectl context name. Uses the ambient context if omitted. To target the
@@ -13,26 +64,341 @@ export interface KubectlApplyArgs {
13
64
  * `./index.ts` and pass `.context` through.
14
65
  */
15
66
  context?: string;
67
+ /**
68
+ * chant environment, used to resolve `k8s.profiles.<env>.context` when no
69
+ * explicit `context` is given. Optional and additive: omitting both keeps
70
+ * the previous behavior of using whatever the kubeconfig selects.
71
+ */
72
+ environment?: string;
73
+ /**
74
+ * Field manager recorded on the applied fields. Omitted derives it from the
75
+ * project's `ownership.stack` (chant #1075) — `chant:<stack>`, or the bare
76
+ * `chant` when the project sets none.
77
+ */
78
+ fieldManager?: string;
79
+ /**
80
+ * Ownership stack, when the caller already knows it and would rather not
81
+ * have the project config read. Ignored if `fieldManager` is set.
82
+ */
83
+ stack?: string;
84
+ /**
85
+ * Take ownership of fields another manager owns instead of failing with a
86
+ * `FieldManagerConflictError`. **Never defaulted on** — see chant #1075.
87
+ */
88
+ force?: boolean;
89
+ /**
90
+ * Delete chant-owned objects that are no longer declared. Default `never`.
91
+ * Deletes are scoped to the ownership marker, so an object chant did not
92
+ * stamp is never a candidate.
93
+ */
94
+ deleteMode?: ApplyDeleteMode;
95
+ /**
96
+ * Server-side dry run — every document is validated and would-be-applied,
97
+ * nothing is persisted, and pruning is skipped entirely (a prune candidate
98
+ * list computed from a state that was never written would be misleading).
99
+ * Optional and additive (chant #1079): `chant kube apply`'s confirmation
100
+ * gate uses this to preview an apply before anything mutates the cluster;
101
+ * Op callers keep the previous behavior by leaving it unset.
102
+ */
103
+ dryRun?: boolean;
104
+ /** Project directory whose `chant.config.ts` carries `ownership`. Defaults to cwd. */
105
+ cwd?: string;
106
+ }
107
+
108
+ /** One object the apply touched. */
109
+ export interface AppliedRef {
110
+ apiVersion: string;
111
+ kind: string;
112
+ name: string;
113
+ namespace?: string;
114
+ }
115
+
116
+ /** What {@link applyManifest} did. */
117
+ export interface ApplyManifestResult {
118
+ /** The field manager every object was applied as. */
119
+ fieldManager: string;
120
+ applied: AppliedRef[];
121
+ /** Objects deleted because they carried chant's marker and are no longer declared. */
122
+ pruned: AppliedRef[];
123
+ }
124
+
125
+ /** Read a manifest path — one file, or every YAML/JSON file in a directory. */
126
+ export function readManifestDocuments(path: string): Record<string, unknown>[] {
127
+ const files = statSync(path).isDirectory()
128
+ ? readdirSync(path)
129
+ .filter((f) => /\.(ya?ml|json)$/i.test(f))
130
+ .sort()
131
+ .map((f) => join(path, f))
132
+ : [path];
133
+
134
+ const documents: Record<string, unknown>[] = [];
135
+ for (const file of files) {
136
+ for (const doc of loadAll(readFileSync(file, "utf-8"))) {
137
+ // Multi-document YAML files routinely carry empty documents between
138
+ // separators; they are not objects to apply.
139
+ if (doc && typeof doc === "object") documents.push(doc as Record<string, unknown>);
140
+ }
141
+ }
142
+ return documents;
16
143
  }
17
144
 
18
145
  /**
19
- * Run `kubectl apply -f <manifest>`.
20
- * Uses longInfra profile 20m timeout, heartbeat every 15s.
146
+ * The field manager to apply as: an explicit one, else `chant:<stack>` from a
147
+ * caller-supplied stack, else `chant:<stack>` from the project's `ownership`
148
+ * config, else the bare `chant`.
149
+ *
150
+ * A project config that cannot be read falls back to the bare `chant` with a
151
+ * warning rather than failing the apply — an activity handed a manifest path
152
+ * and nothing else is a supported way to call this, and its cwd need not be a
153
+ * chant project at all. The warning is there because a *silent* downgrade
154
+ * would change which fields chant owns without saying so.
155
+ */
156
+ export async function resolveFieldManager(args: KubectlApplyArgs): Promise<string> {
157
+ return (await resolveApplyIdentity(args)).fieldManager;
158
+ }
159
+
160
+ /**
161
+ * The stack and the field manager together, resolved once. The prune needs the
162
+ * stack for its selector and the apply needs the manager; reading the project
163
+ * config twice for one apply would be silly, and could disagree with itself if
164
+ * the file changed in between.
165
+ */
166
+ async function resolveApplyIdentity(
167
+ args: KubectlApplyArgs,
168
+ ): Promise<{ fieldManager: string; stack?: string }> {
169
+ const { fieldManagerFor, assertValidFieldManager } = await import("@intentius/chant-k8s-client");
170
+
171
+ let stack = args.stack;
172
+ if (stack === undefined) {
173
+ try {
174
+ const { config } = await loadChantConfig(args.cwd ?? process.cwd());
175
+ stack = resolveOwnershipMarker(config)?.stack;
176
+ } catch (err) {
177
+ console.warn(
178
+ `[k8s] could not read the project config to derive a field manager ` +
179
+ `(${err instanceof Error ? err.message : String(err)}); applying as the unqualified "chant"`,
180
+ );
181
+ }
182
+ }
183
+
184
+ if (args.fieldManager !== undefined) {
185
+ assertValidFieldManager(args.fieldManager);
186
+ return { fieldManager: args.fieldManager, ...(stack !== undefined ? { stack } : {}) };
187
+ }
188
+ return {
189
+ fieldManager: fieldManagerFor(stack !== undefined ? { stack } : undefined),
190
+ ...(stack !== undefined ? { stack } : {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Apply every document in `args.manifest`, then prune when asked.
196
+ *
197
+ * Returns what it did. `kubectlApply` is the registered activity and keeps its
198
+ * `Promise<void>` contract; this is the function `nativeApply` calls, because a
199
+ * dispatcher that reports "applied 12, pruned 2" is worth more than one that
200
+ * reports the shell command it ran.
21
201
  */
22
- export async function kubectlApply(args: KubectlApplyArgs, signal?: AbortSignal): Promise<void> {
23
- const ctx = args.context ? `--context ${args.context}` : "";
202
+ export async function applyManifest(
203
+ args: KubectlApplyArgs,
204
+ signal?: AbortSignal,
205
+ connect: K8sConnector = defaultK8sConnector,
206
+ ): Promise<ApplyManifestResult> {
207
+ const documents = readManifestDocuments(args.manifest);
208
+ const { fieldManager, stack } = await resolveApplyIdentity(args);
24
209
  const heartbeatInterval = setInterval(() => {
25
210
  safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
26
211
  }, 15_000);
27
212
 
28
213
  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);
214
+ const { client } = await connect({
215
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
216
+ ...(args.context !== undefined ? { context: args.context } : {}),
217
+ ...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
218
+ });
219
+
220
+ const applied: AppliedRef[] = [];
221
+ for (const document of documents) {
222
+ const result = await client.apply(document as K8sObject, {
223
+ fieldManager,
224
+ force: args.force ?? false,
225
+ dryRun: args.dryRun,
226
+ signal,
227
+ });
228
+ const ref: AppliedRef = {
229
+ apiVersion: String(result.apiVersion ?? document.apiVersion ?? ""),
230
+ kind: String(result.kind ?? document.kind ?? ""),
231
+ name: String(result.metadata?.name ?? ""),
232
+ ...(result.metadata?.namespace !== undefined
233
+ ? { namespace: String(result.metadata.namespace) }
234
+ : {}),
235
+ };
236
+ applied.push(ref);
237
+ safeHeartbeat({
238
+ step: "kubectl apply",
239
+ manifest: args.manifest,
240
+ applied: `${ref.kind}/${ref.name}`,
241
+ });
242
+ console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`);
243
+ }
244
+
245
+ const deleteMode = args.deleteMode ?? "never";
246
+ const pruned =
247
+ deleteMode === "never" || args.dryRun
248
+ ? []
249
+ : await pruneOrphans(client, applied, {
250
+ ...(stack !== undefined ? { stack } : {}),
251
+ signal,
252
+ });
253
+
254
+ return { fieldManager, applied, pruned };
35
255
  } finally {
36
256
  clearInterval(heartbeatInterval);
37
257
  }
38
258
  }
259
+
260
+ /**
261
+ * Apply every document in `args.manifest`.
262
+ * Uses longInfra profile — 20m timeout, heartbeat every 15s.
263
+ */
264
+ export async function kubectlApply(
265
+ args: KubectlApplyArgs,
266
+ signal?: AbortSignal,
267
+ connect: K8sConnector = defaultK8sConnector,
268
+ ): Promise<void> {
269
+ await applyManifest(args, signal, connect);
270
+ }
271
+
272
+ /**
273
+ * Delete chant-owned objects that the apply set no longer declares.
274
+ *
275
+ * This replaces `kubectl apply --prune --selector <marker>=chant`, and keeps
276
+ * its two safety properties exactly:
277
+ *
278
+ * - **Marker-scoped.** Only objects carrying chant's managed-by label are
279
+ * candidates; the label selector is sent to the server, and the marker is
280
+ * re-checked on every object before it is deleted. An unmarked resource is
281
+ * never a candidate, so a hand-made object in the same namespace is safe.
282
+ * - **Namespace-scoped to the apply.** Namespaced kinds are swept only in the
283
+ * namespaces the apply set touched (the context's default namespace when it
284
+ * named none), which is what kubectl's prune does. A manifest that mentions
285
+ * `prod` never reaches into `staging`. Whether a kind *is* namespaced comes
286
+ * from the cluster's own discovery, never from whether a document happened
287
+ * to name a namespace — guessing that wrong is the one way this could widen
288
+ * into a cluster-wide sweep.
289
+ *
290
+ * Which *kinds* get swept is the union of the kinds the apply set contains and
291
+ * chant's default sweep set (`DEFAULT_IMPORT_TYPES` — the same list `chant
292
+ * import` uses when nothing narrows it). The union matters: a kind removed
293
+ * from source entirely appears nowhere in the apply set, and pruning only what
294
+ * is still declared would leave it behind forever. Per-namespace lists rather
295
+ * than one cluster-wide list per kind, so a namespace-scoped service account
296
+ * can still prune.
297
+ */
298
+ async function pruneOrphans(
299
+ client: K8sClient,
300
+ applied: readonly AppliedRef[],
301
+ options: { stack?: string; signal?: AbortSignal },
302
+ ): Promise<AppliedRef[]> {
303
+ const stack = options.stack;
304
+ const selector = [
305
+ `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
306
+ ...(stack ? [`${LABEL_OWNERSHIP_KEYS.stack}=${stack}`] : []),
307
+ ].join(",");
308
+
309
+ const namespaces = [...new Set(applied.map((a) => a.namespace).filter((n): n is string => !!n))];
310
+ if (namespaces.length === 0 && applied.length > 0) namespaces.push(client.defaultNamespace);
311
+ const declared = new Set(applied.map(refKey));
312
+
313
+ // Every kind that could hold an orphan: the apply set's own, plus the
314
+ // default sweep set so a kind deleted from source entirely is still reached.
315
+ const sweeps = new Map<string, { apiVersion: string; kind: string }>();
316
+ for (const entityType of DEFAULT_IMPORT_TYPES) {
317
+ const operation = operationFor(entityType);
318
+ if (!operation) continue;
319
+ sweeps.set(`${operation.apiVersion}/${operation.kind}`, {
320
+ apiVersion: operation.apiVersion,
321
+ kind: operation.kind,
322
+ });
323
+ }
324
+ for (const ref of applied) {
325
+ if (!ref.apiVersion || !ref.kind) continue;
326
+ sweeps.set(`${ref.apiVersion}/${ref.kind}`, { apiVersion: ref.apiVersion, kind: ref.kind });
327
+ }
328
+
329
+ // (kind, namespace) pairs to list. Scope comes from the cluster's discovery,
330
+ // which is the only thing that knows it for the version this cluster serves;
331
+ // a kind discovery does not report is dropped rather than swept blindly.
332
+ const resolved = await client.concurrently([...sweeps.values()], async (sweep) => {
333
+ const info = await client
334
+ .resolve({ apiVersion: sweep.apiVersion, kind: sweep.kind }, options.signal)
335
+ .catch(() => undefined);
336
+ return info ? { ...sweep, namespaced: info.namespaced } : undefined;
337
+ });
338
+
339
+ const targets: Array<{ apiVersion: string; kind: string; namespace?: string }> = [];
340
+ for (const sweep of resolved) {
341
+ if (!sweep) continue;
342
+ if (!sweep.namespaced) {
343
+ targets.push({ apiVersion: sweep.apiVersion, kind: sweep.kind });
344
+ continue;
345
+ }
346
+ for (const namespace of namespaces) {
347
+ targets.push({ apiVersion: sweep.apiVersion, kind: sweep.kind, namespace });
348
+ }
349
+ }
350
+
351
+ const found = await client.concurrently(targets, async (target) => {
352
+ try {
353
+ const items = await client.list(
354
+ { apiVersion: target.apiVersion, kind: target.kind },
355
+ {
356
+ labelSelector: selector,
357
+ ...(target.namespace !== undefined ? { namespace: target.namespace } : {}),
358
+ ...(options.signal ? { signal: options.signal } : {}),
359
+ },
360
+ );
361
+ return items.map((item) => ({ target, item }));
362
+ } catch {
363
+ // Kind not served here, or the read was denied. Neither is evidence that
364
+ // something should be deleted, so the sweep skips it rather than failing
365
+ // the apply that already succeeded.
366
+ return [];
367
+ }
368
+ });
369
+
370
+ const candidates: AppliedRef[] = [];
371
+ const seen = new Set<string>();
372
+ for (const { target, item } of found.flat()) {
373
+ const name = item.metadata?.name;
374
+ if (!name) continue;
375
+ // The server filtered by label already; this re-check is the one that
376
+ // matters, because a delete is not undoable.
377
+ if (!hasOwnershipMarker(item.metadata?.labels, LABEL_OWNERSHIP_KEYS)) continue;
378
+ // Already terminating — deleting again is noise, not progress.
379
+ if (item.metadata?.deletionTimestamp !== undefined) continue;
380
+ const ref: AppliedRef = {
381
+ apiVersion: target.apiVersion,
382
+ kind: target.kind,
383
+ name,
384
+ ...(item.metadata?.namespace !== undefined ? { namespace: item.metadata.namespace } : {}),
385
+ };
386
+ const key = refKey(ref);
387
+ if (declared.has(key) || seen.has(key)) continue;
388
+ seen.add(key);
389
+ candidates.push(ref);
390
+ }
391
+
392
+ const pruned: AppliedRef[] = [];
393
+ for (const ref of candidates) {
394
+ await client.delete(ref, { ...(options.signal ? { signal: options.signal } : {}) });
395
+ pruned.push(ref);
396
+ safeHeartbeat({ step: "prune", pruned: `${ref.kind}/${ref.name}` });
397
+ console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
398
+ }
399
+ return pruned;
400
+ }
401
+
402
+ function refKey(ref: AppliedRef): string {
403
+ return `${ref.apiVersion}/${ref.kind}/${ref.namespace ?? ""}/${ref.name}`;
404
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  waitForReady,
4
+ apiResourceFetcher,
4
5
  ReadinessFailedError,
5
6
  readinessFor,
6
7
  isReady,
@@ -11,6 +12,7 @@ import {
11
12
  } from "./wait-for-ready";
12
13
  // The k8sWait profile marks ReadinessFailedError non-retryable for this activity.
13
14
  import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal/config";
15
+ import { fakeCluster, objectKey } from "../../api/fake-cluster";
14
16
 
15
17
  /** A fetcher returning a scripted sequence of objects, repeating the last. */
16
18
  function scriptedFetcher(sequence: Array<Record<string, unknown>>): ResourceFetcher {
@@ -103,3 +105,95 @@ describe("waitForReady", () => {
103
105
  expect((obj as any).status.state).toBe("running");
104
106
  });
105
107
  });
108
+
109
+ /**
110
+ * chant #1074 — the reader underneath. The activity's contract is unchanged
111
+ * (`kind` is still whatever `kubectl get` accepts), so what has to be proven is
112
+ * that the same strings still resolve, now through the cluster's own API
113
+ * discovery rather than by handing them to a `kubectl` process.
114
+ */
115
+ describe("apiResourceFetcher (chant #1074)", () => {
116
+ const readyObject = (apiVersion: string, kind: string, name: string, namespace?: string) => ({
117
+ apiVersion,
118
+ kind,
119
+ metadata: { name, ...(namespace ? { namespace } : {}), generation: 1 },
120
+ status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] },
121
+ });
122
+
123
+ test.each([
124
+ ["raycluster.ray.io", "ray.io/v1", "RayCluster", "rayclusters", "K8s::Ray::RayCluster"],
125
+ ["certificates", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
126
+ ["Certificate", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
127
+ ["deployments", "apps/v1", "Deployment", "deployments", "K8s::Apps::Deployment"],
128
+ ])("`%s` resolves to %s %s via discovery", async (kindArg, apiVersion, kind, plural, entityType) => {
129
+ const cluster = fakeCluster({
130
+ serves: [entityType],
131
+ objects: { [objectKey(apiVersion, kind, "thing", "prod")]: readyObject(apiVersion, kind, "thing", "prod") },
132
+ });
133
+
134
+ const obj = await waitForReady(
135
+ { kind: kindArg, name: "thing", namespace: "prod", intervalMs: 0 },
136
+ undefined,
137
+ apiResourceFetcher(cluster.connector),
138
+ );
139
+
140
+ expect((obj as Record<string, unknown>).kind).toBe(kind);
141
+ expect(cluster.layer.paths()).toContain(
142
+ `${apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`}/namespaces/prod/${plural}/thing`,
143
+ );
144
+ });
145
+
146
+ test("resolution and the connection are done once, not once per poll", async () => {
147
+ const notReady = {
148
+ apiVersion: "cert-manager.io/v1",
149
+ kind: "Certificate",
150
+ metadata: { name: "tls", namespace: "prod", generation: 1 },
151
+ status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "False" }] },
152
+ };
153
+ let polls = 0;
154
+ const cluster = fakeCluster({
155
+ serves: ["K8s::CertManager::Certificate"],
156
+ objects: { [objectKey("cert-manager.io/v1", "Certificate", "tls", "prod")]: notReady },
157
+ respond: (req) => {
158
+ if (!req.path.endsWith("/certificates/tls")) return undefined;
159
+ polls++;
160
+ return polls < 3
161
+ ? { body: notReady }
162
+ : { body: { ...notReady, status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] } } };
163
+ },
164
+ });
165
+
166
+ await waitForReady(
167
+ { kind: "certificate", name: "tls", namespace: "prod", intervalMs: 0 },
168
+ undefined,
169
+ apiResourceFetcher(cluster.connector),
170
+ );
171
+
172
+ expect(polls).toBe(3);
173
+ expect(cluster.connects).toHaveLength(1);
174
+ // Discovery once; the three object reads reuse the cached resource list.
175
+ expect(cluster.layer.paths().filter((p) => p === "/apis/cert-manager.io/v1")).toHaveLength(1);
176
+ });
177
+
178
+ test("a kind the cluster does not serve fails loudly instead of polling forever", async () => {
179
+ const cluster = fakeCluster({ serves: ["K8s::Apps::Deployment"] });
180
+ await expect(
181
+ waitForReady({ kind: "widgets", name: "w", intervalMs: 0 }, undefined, apiResourceFetcher(cluster.connector)),
182
+ ).rejects.toThrow(/no resource matching "widgets"/);
183
+ });
184
+
185
+ test("an explicit context is passed to the connector, closing the read/write split", async () => {
186
+ const cluster = fakeCluster({
187
+ serves: ["K8s::Apps::Deployment"],
188
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: readyObject("apps/v1", "Deployment", "web", "prod") },
189
+ });
190
+
191
+ await waitForReady(
192
+ { kind: "deployments", name: "web", namespace: "prod", context: "test-context", intervalMs: 0 },
193
+ undefined,
194
+ apiResourceFetcher(cluster.connector),
195
+ );
196
+
197
+ expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
198
+ });
199
+ });
@@ -1,8 +1,5 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
-
5
- const execAsync = promisify(exec);
2
+ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
6
3
 
7
4
  /**
8
5
  * waitForReady — block until any operator-backed Kubernetes resource reports
@@ -11,9 +8,16 @@ const execAsync = promisify(exec);
11
8
  * Like `waitForArgoSync`, this activity is intentionally **dependency-light**:
12
9
  * its signature is primitives + a plain readiness spec, so a Temporal worker
13
10
  * loads it without importing the generated CRD declarable surface. It reads the
14
- * resource via `kubectl get -o json` (injectable for tests) and evaluates the
15
- * spec's predicates. It generalizes the bespoke `waitForArgoSync` /
16
- * `waitForStack` waits — see #365.
11
+ * resource and evaluates the spec's predicates. It generalizes the bespoke
12
+ * `waitForArgoSync` / `waitForStack` waits see #365.
13
+ *
14
+ * chant #1074 moved the read from `kubectl get -o json` to the typed API
15
+ * client, so a worker image needs no `kubectl` binary. The signature is
16
+ * unchanged — `kind` is still whatever `kubectl get` accepts, because that is
17
+ * what every existing caller passes, and the client resolves it through the
18
+ * cluster's own API discovery exactly as kubectl does: plural, then singular,
19
+ * then kind, then short name, with anything after the first dot read as the
20
+ * API group.
17
21
  */
18
22
 
19
23
  // ── Readiness spec (plain data — no generated-type imports) ──────────
@@ -154,6 +158,11 @@ export interface WaitForReadyArgs {
154
158
  * `.context` through.
155
159
  */
156
160
  context?: string;
161
+ /**
162
+ * chant environment, used to resolve `k8s.profiles.<env>.context` when no
163
+ * explicit `context` is given. Optional and additive.
164
+ */
165
+ environment?: string;
157
166
  /** API group, used to pick a readiness override when `spec` is not given. */
158
167
  group?: string;
159
168
  /** Explicit readiness spec — wins over the registry/default. */
@@ -168,15 +177,57 @@ export type ResourceFetcher = (
168
177
  signal?: AbortSignal,
169
178
  ) => Promise<Record<string, unknown>>;
170
179
 
171
- /** Read the resource via `kubectl get -o json`. */
172
- async function fetchViaKubectl(args: WaitForReadyArgs, signal?: AbortSignal): Promise<Record<string, unknown>> {
173
- const ns = args.namespace ? `-n ${args.namespace}` : "";
174
- const ctx = args.context ? `--context ${args.context}` : "";
175
- const { stdout } = await execAsync(`kubectl get ${args.kind} ${args.name} ${ns} ${ctx} -o json`, { signal });
176
- return JSON.parse(stdout) as Record<string, unknown>;
180
+ /**
181
+ * Read the resource through the typed API client.
182
+ *
183
+ * A client is built once per `waitForReady` call and reused for every poll, so
184
+ * a 20-minute wait does not re-parse the kubeconfig or re-invoke an exec
185
+ * credential plugin on each iteration — and neither does it re-run discovery,
186
+ * which the client caches per API version.
187
+ */
188
+ export function apiResourceFetcher(connect: K8sConnector = defaultK8sConnector): ResourceFetcher {
189
+ let connection: ReturnType<K8sConnector> | undefined;
190
+ let resolved: { apiVersion: string; kind: string } | undefined;
191
+
192
+ return async (args, signal) => {
193
+ connection ??= connect({
194
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
195
+ ...(args.context !== undefined ? { context: args.context } : {}),
196
+ });
197
+ const { client } = await connection;
198
+
199
+ if (!resolved) {
200
+ const info = await client.resolve(
201
+ { resource: args.kind, ...(args.group ? { group: args.group } : {}) },
202
+ signal,
203
+ );
204
+ if (!info) {
205
+ throw new Error(
206
+ `waitForReady: the cluster's API discovery reports no resource matching "${args.kind}"` +
207
+ `${args.group ? ` in group "${args.group}"` : ""} — nothing to wait for`,
208
+ );
209
+ }
210
+ resolved = { apiVersion: info.apiVersion, kind: info.kind };
211
+ }
212
+
213
+ return (await client.read(
214
+ {
215
+ apiVersion: resolved.apiVersion,
216
+ kind: resolved.kind,
217
+ name: args.name,
218
+ ...(args.namespace ? { namespace: args.namespace } : {}),
219
+ },
220
+ { signal },
221
+ )) as Record<string, unknown>;
222
+ };
177
223
  }
178
224
 
179
- export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchViaKubectl(args, signal);
225
+ /**
226
+ * The production reader. Each call builds its own fetcher, so nothing is
227
+ * shared between two unrelated waits; a single `waitForReady` passes one
228
+ * fetcher through all of its polls, which is where the caching matters.
229
+ */
230
+ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => apiResourceFetcher()(args, signal);
180
231
 
181
232
  /**
182
233
  * Poll until the resource satisfies its readiness spec. Throws
@@ -189,7 +240,7 @@ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchVi
189
240
  export async function waitForReady(
190
241
  args: WaitForReadyArgs,
191
242
  signal?: AbortSignal,
192
- fetcher: ResourceFetcher = defaultResourceFetcher,
243
+ fetcher: ResourceFetcher = apiResourceFetcher(),
193
244
  ): Promise<Record<string, unknown>> {
194
245
  const spec = args.spec ?? readinessFor(args.group, args.kind);
195
246
  const interval = args.intervalMs ?? 15_000;