@intentius/chant-lexicon-k8s 0.31.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 (103) hide show
  1. package/dist/api/classify.d.ts +10 -0
  2. package/dist/api/classify.d.ts.map +1 -1
  3. package/dist/api/owner-chain.d.ts +46 -0
  4. package/dist/api/owner-chain.d.ts.map +1 -0
  5. package/dist/api/sweep-types.d.ts +17 -0
  6. package/dist/api/sweep-types.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +86 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +146 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +15 -0
  12. package/dist/describe-resources.d.ts.map +1 -1
  13. package/dist/export-resources.d.ts +6 -2
  14. package/dist/export-resources.d.ts.map +1 -1
  15. package/dist/integrity.json +2 -2
  16. package/dist/kube/apply.d.ts +26 -0
  17. package/dist/kube/apply.d.ts.map +1 -0
  18. package/dist/kube/connect.d.ts +30 -0
  19. package/dist/kube/connect.d.ts.map +1 -0
  20. package/dist/kube/delete.d.ts +27 -0
  21. package/dist/kube/delete.d.ts.map +1 -0
  22. package/dist/kube/describe.d.ts +19 -0
  23. package/dist/kube/describe.d.ts.map +1 -0
  24. package/dist/kube/events.d.ts +34 -0
  25. package/dist/kube/events.d.ts.map +1 -0
  26. package/dist/kube/flags.d.ts +40 -0
  27. package/dist/kube/flags.d.ts.map +1 -0
  28. package/dist/kube/get.d.ts +21 -0
  29. package/dist/kube/get.d.ts.map +1 -0
  30. package/dist/kube/group.d.ts +19 -0
  31. package/dist/kube/group.d.ts.map +1 -0
  32. package/dist/kube/logs.d.ts +16 -0
  33. package/dist/kube/logs.d.ts.map +1 -0
  34. package/dist/kube/project.d.ts +59 -0
  35. package/dist/kube/project.d.ts.map +1 -0
  36. package/dist/kube/render.d.ts +75 -0
  37. package/dist/kube/render.d.ts.map +1 -0
  38. package/dist/kube/source.d.ts +19 -0
  39. package/dist/kube/source.d.ts.map +1 -0
  40. package/dist/kube/target.d.ts +52 -0
  41. package/dist/kube/target.d.ts.map +1 -0
  42. package/dist/kube/testing.d.ts +21 -0
  43. package/dist/kube/testing.d.ts.map +1 -0
  44. package/dist/kube/top.d.ts +23 -0
  45. package/dist/kube/top.d.ts.map +1 -0
  46. package/dist/kube/verdict.d.ts +45 -0
  47. package/dist/kube/verdict.d.ts.map +1 -0
  48. package/dist/kube/wait.d.ts +23 -0
  49. package/dist/kube/wait.d.ts.map +1 -0
  50. package/dist/manifest.json +1 -1
  51. package/dist/op/activities/index.d.ts +6 -3
  52. package/dist/op/activities/index.d.ts.map +1 -1
  53. package/dist/op/activities/kubectl.d.ts +91 -9
  54. package/dist/op/activities/kubectl.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/api/classify.ts +13 -0
  58. package/src/api/owner-chain.test.ts +171 -0
  59. package/src/api/owner-chain.ts +144 -0
  60. package/src/api/sweep-types.ts +36 -0
  61. package/src/deep-observe-hooks.ts +125 -0
  62. package/src/deep-observe.test.ts +668 -0
  63. package/src/deep-observe.ts +299 -0
  64. package/src/describe-resources.test.ts +224 -1
  65. package/src/describe-resources.ts +86 -1
  66. package/src/export-resources.ts +6 -22
  67. package/src/kube/apply.test.ts +105 -0
  68. package/src/kube/apply.ts +101 -0
  69. package/src/kube/connect.ts +54 -0
  70. package/src/kube/delete.test.ts +123 -0
  71. package/src/kube/delete.ts +174 -0
  72. package/src/kube/describe.test.ts +106 -0
  73. package/src/kube/describe.ts +195 -0
  74. package/src/kube/events.test.ts +115 -0
  75. package/src/kube/events.ts +160 -0
  76. package/src/kube/flags.test.ts +65 -0
  77. package/src/kube/flags.ts +114 -0
  78. package/src/kube/get.test.ts +195 -0
  79. package/src/kube/get.ts +190 -0
  80. package/src/kube/group.test.ts +33 -0
  81. package/src/kube/group.ts +125 -0
  82. package/src/kube/logs.test.ts +126 -0
  83. package/src/kube/logs.ts +96 -0
  84. package/src/kube/project.test.ts +57 -0
  85. package/src/kube/project.ts +106 -0
  86. package/src/kube/render.test.ts +142 -0
  87. package/src/kube/render.ts +252 -0
  88. package/src/kube/source.test.ts +138 -0
  89. package/src/kube/source.ts +139 -0
  90. package/src/kube/target.test.ts +67 -0
  91. package/src/kube/target.ts +89 -0
  92. package/src/kube/testing.ts +42 -0
  93. package/src/kube/top.test.ts +149 -0
  94. package/src/kube/top.ts +120 -0
  95. package/src/kube/verdict.test.ts +48 -0
  96. package/src/kube/verdict.ts +77 -0
  97. package/src/kube/wait.test.ts +125 -0
  98. package/src/kube/wait.ts +160 -0
  99. package/src/op/activities/index.ts +6 -3
  100. package/src/op/activities/kubectl.test.ts +298 -26
  101. package/src/op/activities/kubectl.ts +313 -20
  102. package/src/plugin.test.ts +58 -1
  103. package/src/plugin.ts +29 -0
@@ -7,16 +7,26 @@
7
7
  * that signature; what changed is underneath. The name is kept for the same
8
8
  * reason.
9
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
+ *
10
22
  * Consequences worth knowing:
11
23
  *
12
24
  * - **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.
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.
20
30
  * - **Documents apply in file order**, as `kubectl apply -f` does, and a
21
31
  * directory's files are read in sorted order.
22
32
  */
@@ -25,8 +35,24 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
25
35
  import { join } from "node:path";
26
36
  import { loadAll } from "js-yaml";
27
37
  import { safeHeartbeat } from "@intentius/chant/op";
28
- import type { K8sObject } from "@intentius/chant-k8s-client";
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";
29
45
  import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
46
+ import { operationFor } from "../../api/operation-surface";
47
+ import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
48
+
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";
30
56
 
31
57
  export interface KubectlApplyArgs {
32
58
  /** Path to a manifest file, or a directory of them. */
@@ -44,10 +70,56 @@ export interface KubectlApplyArgs {
44
70
  * the previous behavior of using whatever the kubeconfig selects.
45
71
  */
46
72
  environment?: string;
47
- /** Field manager recorded on the applied objects. Default `chant`. */
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
+ */
48
78
  fieldManager?: string;
49
- /** Take ownership of fields another manager owns instead of failing (chant #1075). */
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
+ */
50
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[];
51
123
  }
52
124
 
53
125
  /** Read a manifest path — one file, or every YAML/JSON file in a directory. */
@@ -71,15 +143,69 @@ export function readManifestDocuments(path: string): Record<string, unknown>[] {
71
143
  }
72
144
 
73
145
  /**
74
- * Apply every document in `args.manifest`.
75
- * 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.
76
155
  */
77
- export async function kubectlApply(
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.
201
+ */
202
+ export async function applyManifest(
78
203
  args: KubectlApplyArgs,
79
204
  signal?: AbortSignal,
80
205
  connect: K8sConnector = defaultK8sConnector,
81
- ): Promise<void> {
206
+ ): Promise<ApplyManifestResult> {
82
207
  const documents = readManifestDocuments(args.manifest);
208
+ const { fieldManager, stack } = await resolveApplyIdentity(args);
83
209
  const heartbeatInterval = setInterval(() => {
84
210
  safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
85
211
  }, 15_000);
@@ -88,24 +214,191 @@ export async function kubectlApply(
88
214
  const { client } = await connect({
89
215
  ...(args.environment !== undefined ? { environment: args.environment } : {}),
90
216
  ...(args.context !== undefined ? { context: args.context } : {}),
217
+ ...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
91
218
  });
92
219
 
220
+ const applied: AppliedRef[] = [];
93
221
  for (const document of documents) {
94
- const applied = await client.apply(document as K8sObject, {
95
- fieldManager: args.fieldManager ?? "chant",
222
+ const result = await client.apply(document as K8sObject, {
223
+ fieldManager,
96
224
  force: args.force ?? false,
225
+ dryRun: args.dryRun,
97
226
  signal,
98
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);
99
237
  safeHeartbeat({
100
238
  step: "kubectl apply",
101
239
  manifest: args.manifest,
102
- applied: `${applied.kind ?? document.kind}/${applied.metadata?.name ?? "?"}`,
240
+ applied: `${ref.kind}/${ref.name}`,
103
241
  });
104
- console.log(
105
- `${String(applied.apiVersion ?? document.apiVersion)} ${String(applied.kind ?? document.kind)}/${String(applied.metadata?.name ?? "")} applied`,
106
- );
242
+ console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`);
107
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 };
108
255
  } finally {
109
256
  clearInterval(heartbeatInterval);
110
257
  }
111
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,4 +1,4 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, test, expect, vi } from "vitest";
2
2
  import { existsSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
@@ -211,4 +211,61 @@ describe("k8sPlugin", () => {
211
211
  test("package() method exists", () => {
212
212
  expect(typeof k8sPlugin.package).toBe("function");
213
213
  });
214
+
215
+ // chant #1078 — the command-group seam's first real tenant: `commands()`
216
+ // mounts a "kube" verb group, reserving the namespace #1079's `chant kube`
217
+ // terminal surface will add its own verbs to.
218
+ describe("commands() — the `kube` command group (#1078)", () => {
219
+ test("contributes a group named kube with a version verb", () => {
220
+ const group = k8sPlugin.commands!();
221
+ expect(group.name).toBe("kube");
222
+ expect(group.commands.map((c) => c.name)).toContain("version");
223
+ });
224
+
225
+ test("kube version prints the pinned K8S_SCHEMA_VERSION", async () => {
226
+ const { K8S_SCHEMA_VERSION } = await import("./spec/fetch");
227
+ const group = k8sPlugin.commands!();
228
+ const version = group.commands.find((c) => c.name === "version")!;
229
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
230
+ try {
231
+ const exitCode = await version.handler({ verb: "version", rawArgs: [] });
232
+ expect(exitCode).toBe(0);
233
+ expect(logSpy).toHaveBeenCalledWith(K8S_SCHEMA_VERSION);
234
+ } finally {
235
+ logSpy.mockRestore();
236
+ }
237
+ });
238
+
239
+ test("kube version --format json emits a JSON envelope", async () => {
240
+ const { K8S_SCHEMA_VERSION } = await import("./spec/fetch");
241
+ const group = k8sPlugin.commands!();
242
+ const version = group.commands.find((c) => c.name === "version")!;
243
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
244
+ try {
245
+ const exitCode = await version.handler({ verb: "version", rawArgs: ["--format", "json"] });
246
+ expect(exitCode).toBe(0);
247
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ schemaVersion: K8S_SCHEMA_VERSION }));
248
+ } finally {
249
+ logSpy.mockRestore();
250
+ }
251
+ });
252
+
253
+ test("kube version --format=json (joined form, #1127 discipline) works identically", async () => {
254
+ const group = k8sPlugin.commands!();
255
+ const version = group.commands.find((c) => c.name === "version")!;
256
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
257
+ try {
258
+ await version.handler({ verb: "version", rawArgs: ["--format=json"] });
259
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("schemaVersion"));
260
+ } finally {
261
+ logSpy.mockRestore();
262
+ }
263
+ });
264
+
265
+ test("kube version rejects an unknown flag (#1127 discipline applies to mounted commands)", async () => {
266
+ const group = k8sPlugin.commands!();
267
+ const version = group.commands.find((c) => c.name === "version")!;
268
+ await expect(version.handler({ verb: "version", rawArgs: ["--bogus"] })).rejects.toThrow(/Unknown flag: --bogus/);
269
+ });
270
+ });
214
271
  });
package/src/plugin.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { LexiconPlugin, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
9
+ import type { CommandGroup } from "@intentius/chant/cli/command-group";
10
+ import { kubeCommandGroup } from "./kube/group";
9
11
  import { detectTemplate } from "./detect";
10
12
  import type { LintRule } from "@intentius/chant/lint/rule";
11
13
  import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
@@ -23,6 +25,7 @@ import { k8sCompletions } from "./lsp/completions";
23
25
  import { k8sHover } from "./lsp/hover";
24
26
  import { K8sParser } from "./import/parser";
25
27
  import { K8sGenerator } from "./import/generator";
28
+ import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
26
29
 
27
30
  export const k8sPlugin: LexiconPlugin = {
28
31
  name: "k8s",
@@ -37,6 +40,17 @@ export const k8sPlugin: LexiconPlugin = {
37
40
  upstream: { owner: "kubernetes", repo: "kubernetes", kind: "releases" },
38
41
  },
39
42
 
43
+ // chant #1078/#1079 — the command-group seam's tenant. `kube` mounts the
44
+ // terminal surface over the typed client: kubectl-compatible reads (get,
45
+ // describe, logs, events, top, wait), chant's own additions (source, the
46
+ // `get` verdict column, -o chant), and gated writes (apply, delete) that
47
+ // route through the exact same machinery the Op activities use — never a
48
+ // parallel implementation. See ./kube/group.ts for the full verb list;
49
+ // this plugin only mounts it.
50
+ commands(): CommandGroup {
51
+ return kubeCommandGroup();
52
+ },
53
+
40
54
  lintRules(): LintRule[] {
41
55
  return [
42
56
  hardcodedNamespaceRule,
@@ -617,4 +631,19 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
617
631
  const { exportResources } = await import("./export-resources");
618
632
  return exportResources(options);
619
633
  },
634
+
635
+ // Property-level live drift via SSA managed-fields (#1076, epic #1073).
636
+ // The reader lives in ./deep-observe.ts, loaded only through this dynamic
637
+ // import — same reason describeResources/exportResources are — so the API
638
+ // client never becomes reachable from the build path (chant #1074,
639
+ // examples/k8s-client-boundary.test.ts). deepNormalizationHooks is plain
640
+ // data with no client dependency (./deep-observe-hooks.ts) and is imported
641
+ // statically above, because core applies it to the *declared* tree whether
642
+ // or not a live read ever happens.
643
+ async observeResourcesDeep(options) {
644
+ const { observeResourcesDeepK8s } = await import("./deep-observe");
645
+ return observeResourcesDeepK8s(options);
646
+ },
647
+
648
+ deepNormalizationHooks: k8sDeepNormalizationHooks,
620
649
  };