@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
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `chant kube wait` (chant #1079) — the issue names this one explicitly:
3
+ * "`wait` should use the existing readiness-spec registry in
4
+ * `op/activities/wait-for-ready.ts`, which already handles the Argo case
5
+ * where health and sync replace a `Ready` condition — something `kubectl
6
+ * wait --for` cannot express without jsonpath gymnastics." This verb is a
7
+ * thin CLI shell around that exact registry (`readinessFor`/`isReady`/
8
+ * `firstTerminal`/`waitForReady`) — not a second implementation of it.
9
+ *
10
+ * `--for=condition=<Type>[=<Status>]` overrides the registry with a single
11
+ * condition check, matching kubectl's own flag. `--for=delete` polls for
12
+ * absence instead. Neither given falls back to the registry: the generic
13
+ * `Ready` condition, or a resource-specific override (Argo's `Application`
14
+ * today).
15
+ */
16
+
17
+ import { formatUnobserved } from "@intentius/chant/observation";
18
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
19
+ import { classifyApiFailure } from "../api/classify";
20
+ import {
21
+ waitForReady,
22
+ readinessFor,
23
+ ReadinessFailedError,
24
+ type ReadinessSpec,
25
+ type ResourceFetcher,
26
+ } from "../op/activities/wait-for-ready";
27
+ import { kubeConnect } from "./connect";
28
+ import { parseKubeFlags, connectOptionsFrom, parseDurationMs } from "./flags";
29
+ import { loadKubeProjectContext, type KubeProjectContext } from "./project";
30
+ import { resolveKubeTarget, isTargetError, type KubeTarget } from "./target";
31
+
32
+ export interface WaitDeps {
33
+ connect?: K8sConnector;
34
+ loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
35
+ }
36
+
37
+ type ForSpec = { kind: "delete" } | { kind: "spec"; spec: ReadinessSpec };
38
+
39
+ function parseFor(value: string | undefined): ForSpec | undefined {
40
+ if (value === undefined) return undefined;
41
+ if (value === "delete") return { kind: "delete" };
42
+ const match = /^condition=([^=]+)(?:=(.+))?$/.exec(value);
43
+ if (!match) {
44
+ throw new Error(`--for expects "condition=<Type>[=<Status>]" or "delete", got "${value}"`);
45
+ }
46
+ return { kind: "spec", spec: { ready: [{ conditionType: match[1], status: match[2] ?? "True" }] } };
47
+ }
48
+
49
+ function targetLabel(target: KubeTarget): string {
50
+ return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
51
+ }
52
+
53
+ export async function runWait(rawArgs: string[], deps: WaitDeps = {}): Promise<number> {
54
+ const connect = deps.connect ?? defaultK8sConnector;
55
+ const loadProject = deps.loadProject ?? loadKubeProjectContext;
56
+
57
+ let flags;
58
+ let forSpec: ForSpec | undefined;
59
+ let timeoutMs: number;
60
+ try {
61
+ flags = parseKubeFlags(rawArgs, { value: { "--for": "for", "--timeout": "timeout" } });
62
+ forSpec = parseFor(flags.values.for);
63
+ timeoutMs = flags.values.timeout !== undefined ? parseDurationMs(flags.values.timeout) : 30_000;
64
+ } catch (err) {
65
+ console.error(err instanceof Error ? err.message : String(err));
66
+ return 1;
67
+ }
68
+
69
+ const project = await loadProject();
70
+ const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
71
+ if (isTargetError(target)) {
72
+ console.error(`error: ${target.error}`);
73
+ return 1;
74
+ }
75
+ if (!target.name) {
76
+ console.error("error: chant kube wait requires a single named resource, e.g. `chant kube wait deployment web`");
77
+ return 1;
78
+ }
79
+
80
+ const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
81
+ if (connected.kind === "unobserved") {
82
+ console.error(formatUnobserved(targetLabel(target), { reason: connected.reason, detail: connected.detail }));
83
+ return 1;
84
+ }
85
+ const { client } = connected;
86
+
87
+ let info;
88
+ try {
89
+ info = await client.resolve(target.selector);
90
+ } catch (err) {
91
+ const outcome = classifyApiFailure(err);
92
+ console.error(
93
+ formatUnobserved(targetLabel(target), {
94
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
95
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
96
+ }),
97
+ );
98
+ return 1;
99
+ }
100
+ if (!info) {
101
+ console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
102
+ return 1;
103
+ }
104
+
105
+ const namespace = target.namespace ?? client.defaultNamespace;
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
108
+
109
+ try {
110
+ if (forSpec?.kind === "delete") {
111
+ await waitForDeletion(client, { apiVersion: info.apiVersion, kind: info.kind, name: target.name, namespace }, controller.signal);
112
+ console.log(`${info.kind}/${target.name} deleted`);
113
+ return 0;
114
+ }
115
+
116
+ const spec = forSpec?.kind === "spec" ? forSpec.spec : readinessFor(info.group, info.kind);
117
+ const fetcher: ResourceFetcher = async (args, signal) =>
118
+ (await client.read(
119
+ { apiVersion: info.apiVersion, kind: info.kind, name: args.name, ...(args.namespace ? { namespace: args.namespace } : {}) },
120
+ { signal },
121
+ )) as Record<string, unknown>;
122
+
123
+ await waitForReady({ kind: info.kind, name: target.name, namespace, group: info.group, spec }, controller.signal, fetcher);
124
+ console.log(`${info.kind}/${target.name} condition met`);
125
+ return 0;
126
+ } catch (err) {
127
+ if (err instanceof ReadinessFailedError) {
128
+ console.error(err.message);
129
+ return 1;
130
+ }
131
+ if (controller.signal.aborted) {
132
+ console.error(`error: timed out after ${timeoutMs}ms waiting for ${info.kind}/${target.name}`);
133
+ return 1;
134
+ }
135
+ const outcome = classifyApiFailure(err);
136
+ console.error(
137
+ formatUnobserved(`${info.kind}/${target.name}`, {
138
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
139
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
140
+ }),
141
+ );
142
+ return 1;
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+
148
+ async function waitForDeletion(
149
+ client: { readIfPresent: (ref: { apiVersion: string; kind: string; name: string; namespace?: string }, opts: { signal?: AbortSignal }) => Promise<unknown> },
150
+ ref: { apiVersion: string; kind: string; name: string; namespace?: string },
151
+ signal: AbortSignal,
152
+ intervalMs = 5_000,
153
+ ): Promise<void> {
154
+ while (true) {
155
+ if (signal.aborted) throw new Error("wait --for=delete aborted");
156
+ const obj = await client.readIfPresent(ref, { signal });
157
+ if (!obj) return;
158
+ await new Promise((r) => setTimeout(r, intervalMs));
159
+ }
160
+ }
@@ -2,7 +2,10 @@
2
2
  * k8s Op activities — resolved by the core activity registry when a project's
3
3
  * `chant.config.ts` lists the `k8s` lexicon. Relocated from the temporal lexicon
4
4
  * (#809) so Kubernetes-facing imperative activities live with their product:
5
- * - kubectlApply — `kubectl apply` a rendered manifest
5
+ * - kubectlApply — server-side apply a rendered manifest (and, since chant
6
+ * #1075, prune chant-owned objects it no longer declares; `applyManifest`
7
+ * is the same work with a report of what it did, which is what the
8
+ * Temporal lexicon's `nativeApply` dispatcher calls for a kubectl target)
6
9
  * - k3dUp / k3dDown — boot/tear down a local k3d cluster
7
10
  * - waitForArgoSync — block until an Argo CD Application is Healthy && Synced
8
11
  *
@@ -11,8 +14,8 @@
11
14
  * dependency-light — it shells out to a CLI and does not import the k8s declarable
12
15
  * surface — so a Temporal worker loads it cheaply.
13
16
  */
14
- export { kubectlApply, readManifestDocuments } from "./kubectl";
15
- export type { KubectlApplyArgs } from "./kubectl";
17
+ export { kubectlApply, applyManifest, readManifestDocuments } from "./kubectl";
18
+ export type { KubectlApplyArgs, ApplyManifestResult, AppliedRef, ApplyDeleteMode } from "./kubectl";
16
19
 
17
20
  export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d";
18
21
  export type { K3dUpArgs, K3dDownArgs } from "./k3d";
@@ -1,19 +1,25 @@
1
1
  /**
2
- * `kubectlApply` over the typed API client (chant #1074).
2
+ * `kubectlApply` over the typed API client (chant #1074, #1075).
3
3
  *
4
4
  * The activity contract is what Temporal workers register, so the shape of the
5
5
  * arguments and the `Promise<void>` return are asserted alongside the new
6
6
  * behavior. Nothing here spawns a process or reads an ambient kubeconfig,
7
7
  * which is the acceptance criterion: a worker image needs no `kubectl` binary.
8
+ *
9
+ * chant #1075 adds two things to assert: that the field manager is derived
10
+ * from the project's `ownership.stack` rather than hardcoded, and that the
11
+ * ownership-scoped prune the shelled `kubectl apply --prune` used to do is
12
+ * still exactly as narrow now that it runs here.
8
13
  */
9
14
 
10
15
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
11
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
12
17
  import { tmpdir } from "node:os";
13
18
  import { join } from "node:path";
14
- import { kubectlApply, readManifestDocuments } from "./kubectl";
15
- import { fakeCluster } from "../../api/fake-cluster";
19
+ import { applyManifest, kubectlApply, readManifestDocuments, resolveFieldManager } from "./kubectl";
20
+ import { fakeCluster, objectKey, ownedObject } from "../../api/fake-cluster";
16
21
  import { statusBody } from "@intentius/chant-k8s-client/testing";
22
+ import type { RecordedRequest } from "@intentius/chant-k8s-client/testing";
17
23
 
18
24
  let dir: string;
19
25
 
@@ -35,6 +41,21 @@ spec:
35
41
  - port: 80
36
42
  `;
37
43
 
44
+ /** Echo an applied object back, the way an API server does. */
45
+ const echoApplies = (req: RecordedRequest): { body: unknown } | undefined =>
46
+ req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined;
47
+
48
+ /** A project directory whose config sets an ownership stack. */
49
+ function projectWithStack(stack: string, extra: Record<string, unknown> = {}): string {
50
+ const project = join(dir, `project-${stack}`);
51
+ mkdirSync(project, { recursive: true });
52
+ writeFileSync(
53
+ join(project, "chant.config.json"),
54
+ JSON.stringify({ lexicons: ["k8s"], ownership: { stack, ...extra } }),
55
+ );
56
+ return project;
57
+ }
58
+
38
59
  beforeEach(() => {
39
60
  dir = mkdtempSync(join(tmpdir(), "chant-k8s-apply-"));
40
61
  });
@@ -65,15 +86,40 @@ describe("readManifestDocuments", () => {
65
86
  });
66
87
  });
67
88
 
89
+ describe("the field manager chant applies as (chant #1075)", () => {
90
+ test("no ownership stack in the project → the unqualified chant", async () => {
91
+ const project = join(dir, "no-config");
92
+ mkdirSync(project);
93
+ expect(await resolveFieldManager({ manifest: "x", cwd: project })).toBe("chant");
94
+ });
95
+
96
+ test("an ownership stack qualifies it, from the same config the label marker reads", async () => {
97
+ expect(await resolveFieldManager({ manifest: "x", cwd: projectWithStack("web") })).toBe("chant:web");
98
+ });
99
+
100
+ test("ownership disabled falls back to the unqualified chant, as the label marker does", async () => {
101
+ const project = projectWithStack("web", { enabled: false });
102
+ expect(await resolveFieldManager({ manifest: "x", cwd: project })).toBe("chant");
103
+ });
104
+
105
+ test("an explicit field manager wins over the config", async () => {
106
+ const project = projectWithStack("web");
107
+ expect(await resolveFieldManager({ manifest: "x", cwd: project, fieldManager: "chant-op" })).toBe("chant-op");
108
+ });
109
+
110
+ test("an explicit stack wins over the config and skips reading it", async () => {
111
+ const project = projectWithStack("web");
112
+ expect(await resolveFieldManager({ manifest: "x", cwd: project, stack: "other" })).toBe("chant:other");
113
+ });
114
+ });
115
+
68
116
  describe("kubectlApply", () => {
69
117
  test("server-side applies every document, in file order, as the chant field manager", async () => {
70
118
  const file = join(dir, "k8s.yaml");
71
119
  writeFileSync(file, deploymentYaml);
72
- const cluster = fakeCluster({
73
- respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
74
- });
120
+ const cluster = fakeCluster({ respond: echoApplies });
75
121
 
76
- await kubectlApply({ manifest: file }, undefined, cluster.connector);
122
+ await kubectlApply({ manifest: file, cwd: projectWithStack("web") }, undefined, cluster.connector);
77
123
 
78
124
  const patches = cluster.layer.requests.filter((r) => r.method === "PATCH");
79
125
  expect(patches.map((p) => p.path)).toEqual([
@@ -82,16 +128,14 @@ describe("kubectlApply", () => {
82
128
  ]);
83
129
  for (const patch of patches) {
84
130
  expect(patch.headers["Content-Type"]).toBe("application/apply-patch+yaml");
85
- expect(patch.query).toMatchObject({ fieldManager: "chant", force: "false" });
131
+ expect(patch.query).toMatchObject({ fieldManager: "chant:web", force: "false" });
86
132
  }
87
133
  });
88
134
 
89
135
  test("an explicit context is honored and skips the environment lookup entirely", async () => {
90
136
  const file = join(dir, "k8s.yaml");
91
137
  writeFileSync(file, deploymentYaml);
92
- const cluster = fakeCluster({
93
- respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
94
- });
138
+ const cluster = fakeCluster({ respond: echoApplies });
95
139
 
96
140
  await kubectlApply({ manifest: file, context: "test-context" }, undefined, cluster.connector);
97
141
 
@@ -102,47 +146,275 @@ describe("kubectlApply", () => {
102
146
  test("an environment is passed through so the cluster binding applies to the write path too", async () => {
103
147
  const file = join(dir, "k8s.yaml");
104
148
  writeFileSync(file, deploymentYaml);
105
- const cluster = fakeCluster({
106
- respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
107
- });
149
+ const cluster = fakeCluster({ respond: echoApplies });
108
150
 
109
151
  await kubectlApply({ manifest: file, environment: "prod" }, undefined, cluster.connector);
110
152
  expect(cluster.connects[0]).toMatchObject({ environment: "prod" });
111
153
  });
112
154
 
113
- test("force is forwarded, for the caller that means to take ownership (chant #1075)", async () => {
155
+ test("force is forwarded, for the caller that means to take ownership", async () => {
114
156
  const file = join(dir, "k8s.yaml");
115
157
  writeFileSync(file, deploymentYaml);
116
- const cluster = fakeCluster({
117
- respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
118
- });
158
+ const cluster = fakeCluster({ respond: echoApplies });
119
159
 
120
160
  await kubectlApply({ manifest: file, force: true, fieldManager: "chant-op" }, undefined, cluster.connector);
121
161
  const patch = cluster.layer.requests.find((r) => r.method === "PATCH")!;
122
162
  expect(patch.query).toMatchObject({ force: "true", fieldManager: "chant-op" });
123
163
  });
124
164
 
125
- test("a field-ownership conflict surfaces as a typed error rather than parsed stderr", async () => {
165
+ test("force is off unless asked for — chant never resolves a conflict by itself", async () => {
166
+ const file = join(dir, "k8s.yaml");
167
+ writeFileSync(file, deploymentYaml);
168
+ const cluster = fakeCluster({ respond: echoApplies });
169
+
170
+ await kubectlApply({ manifest: file }, undefined, cluster.connector);
171
+ for (const patch of cluster.layer.requests.filter((r) => r.method === "PATCH")) {
172
+ expect(patch.query.force).toBe("false");
173
+ }
174
+ });
175
+
176
+ test("a field-ownership conflict surfaces as a presented error, not a raw 409", async () => {
126
177
  const file = join(dir, "k8s.yaml");
127
178
  writeFileSync(file, deploymentYaml);
128
179
  const cluster = fakeCluster({
129
180
  respond: (req) =>
130
181
  req.method === "PATCH"
131
- ? { status: 409, body: statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "kubectl"') }
182
+ ? {
183
+ status: 409,
184
+ body: {
185
+ ...statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "kubectl" using apps/v1'),
186
+ details: {
187
+ causes: [
188
+ {
189
+ type: "FieldManagerConflict",
190
+ message: 'conflict with "kubectl" using apps/v1',
191
+ field: ".spec.replicas",
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ }
132
197
  : undefined,
133
198
  });
134
199
 
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);
200
+ const err = (await kubectlApply({ manifest: file, stack: "web" }, undefined, cluster.connector).catch(
201
+ (e: unknown) => e,
202
+ )) as Error & { statusCode?: number; byManager?: Record<string, string[]>; fieldManager?: string };
203
+
204
+ expect(err.name).toBe("FieldManagerConflictError");
205
+ expect(err.statusCode).toBe(409);
206
+ expect(err.byManager).toEqual({ kubectl: [".spec.replicas"] });
207
+ expect(err.fieldManager).toBe("chant:web");
208
+ expect(err.message).toContain("apps/v1 Deployment prod/web");
209
+ expect(err.message).toContain(".spec.replicas");
210
+ expect(err.message).toContain("chant does not force this for you");
138
211
  });
139
212
 
140
213
  test("resolves to undefined, keeping the activity's Promise<void> contract", async () => {
141
214
  const file = join(dir, "k8s.yaml");
142
215
  writeFileSync(file, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n namespace: prod\n");
216
+ const cluster = fakeCluster({ respond: echoApplies });
217
+ await expect(kubectlApply({ manifest: file }, undefined, cluster.connector)).resolves.toBeUndefined();
218
+ });
219
+
220
+ test("applyManifest reports what it did, which is what nativeApply relays", async () => {
221
+ const file = join(dir, "k8s.yaml");
222
+ writeFileSync(file, deploymentYaml);
223
+ const cluster = fakeCluster({ respond: echoApplies });
224
+ const result = await applyManifest({ manifest: file, stack: "web" }, undefined, cluster.connector);
225
+ expect(result.fieldManager).toBe("chant:web");
226
+ expect(result.applied).toEqual([
227
+ { apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" },
228
+ { apiVersion: "v1", kind: "Service", name: "web-svc", namespace: "prod" },
229
+ ]);
230
+ expect(result.pruned).toEqual([]);
231
+ });
232
+ });
233
+
234
+ describe("the ownership-scoped prune (chant #1075)", () => {
235
+ const manifest = (): string => {
236
+ const file = join(dir, "k8s.yaml");
237
+ writeFileSync(
238
+ file,
239
+ "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\n namespace: prod\n",
240
+ );
241
+ return file;
242
+ };
243
+
244
+ /** A cluster holding one chant-owned orphan, one foreign object, and the declared one. */
245
+ const clusterWithOrphans = () =>
246
+ fakeCluster({
247
+ respond: echoApplies,
248
+ objects: {
249
+ [objectKey("apps/v1", "Deployment", "old", "prod")]: ownedObject("apps/v1", "Deployment", "old", "prod"),
250
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
251
+ [objectKey("apps/v1", "Deployment", "handmade", "prod")]: {
252
+ apiVersion: "apps/v1",
253
+ kind: "Deployment",
254
+ metadata: { name: "handmade", namespace: "prod", labels: { app: "handmade" } },
255
+ },
256
+ [objectKey("apps/v1", "Deployment", "elsewhere", "staging")]: ownedObject(
257
+ "apps/v1",
258
+ "Deployment",
259
+ "elsewhere",
260
+ "staging",
261
+ ),
262
+ },
263
+ });
264
+
265
+ test("deleteMode never deletes nothing, and issues no DELETE at all", async () => {
266
+ const cluster = clusterWithOrphans();
267
+ const result = await applyManifest({ manifest: manifest() }, undefined, cluster.connector);
268
+ expect(result.pruned).toEqual([]);
269
+ expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
270
+ });
271
+
272
+ test("owned-only deletes the chant-owned object the manifest no longer declares", async () => {
273
+ const cluster = clusterWithOrphans();
274
+ const result = await applyManifest(
275
+ { manifest: manifest(), deleteMode: "owned-only" },
276
+ undefined,
277
+ cluster.connector,
278
+ );
279
+ expect(result.pruned).toEqual([
280
+ { apiVersion: "apps/v1", kind: "Deployment", name: "old", namespace: "prod" },
281
+ ]);
282
+ expect(cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path)).toEqual([
283
+ "/apis/apps/v1/namespaces/prod/deployments/old",
284
+ ]);
285
+ });
286
+
287
+ test("gated prunes exactly as owned-only does — the gate is the composite's business", async () => {
288
+ const cluster = clusterWithOrphans();
289
+ const result = await applyManifest({ manifest: manifest(), deleteMode: "gated" }, undefined, cluster.connector);
290
+ expect(result.pruned.map((p) => p.name)).toEqual(["old"]);
291
+ });
292
+
293
+ test("an object without chant's marker is never a candidate", async () => {
294
+ const cluster = clusterWithOrphans();
295
+ await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
296
+ const deleted = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
297
+ expect(deleted.some((p) => p.includes("handmade"))).toBe(false);
298
+ });
299
+
300
+ test("the object the manifest still declares is not pruned after being applied", async () => {
301
+ const cluster = clusterWithOrphans();
302
+ await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
303
+ const deleted = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
304
+ expect(deleted.some((p) => p.endsWith("/deployments/web"))).toBe(false);
305
+ });
306
+
307
+ test("a namespace the manifest never mentions is not swept", async () => {
308
+ const cluster = clusterWithOrphans();
309
+ await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
310
+ const listed = cluster.layer.requests.filter((r) => r.method === "GET").map((r) => r.path);
311
+ expect(listed.some((p) => p.includes("/namespaces/staging/"))).toBe(false);
312
+ });
313
+
314
+ test("the sweep is sent to the server as a marker selector, stack-scoped when one is configured", async () => {
315
+ const cluster = clusterWithOrphans();
316
+ await applyManifest(
317
+ { manifest: manifest(), deleteMode: "owned-only", stack: "web" },
318
+ undefined,
319
+ cluster.connector,
320
+ );
321
+ const sweeps = cluster.layer.requests.filter((r) => r.query.labelSelector !== undefined);
322
+ expect(sweeps.length).toBeGreaterThan(0);
323
+ for (const sweep of sweeps) {
324
+ expect(sweep.query.labelSelector).toBe(
325
+ "app.kubernetes.io/managed-by=chant,chant.intentius.io/stack=web",
326
+ );
327
+ }
328
+ });
329
+
330
+ test("with no stack configured the selector is the marker alone, as the shelled prune's was", async () => {
331
+ const cluster = clusterWithOrphans();
332
+ const project = join(dir, "no-ownership");
333
+ mkdirSync(project);
334
+ await applyManifest(
335
+ { manifest: manifest(), deleteMode: "owned-only", cwd: project },
336
+ undefined,
337
+ cluster.connector,
338
+ );
339
+ const sweep = cluster.layer.requests.find((r) => r.query.labelSelector !== undefined)!;
340
+ expect(sweep.query.labelSelector).toBe("app.kubernetes.io/managed-by=chant");
341
+ });
342
+
343
+ test("a kind removed from source entirely is still swept, via the default sweep set", async () => {
344
+ // Nothing in the manifest is a Service, yet a chant-owned Service must not
345
+ // survive being deleted from source. This is why the sweep is the union of
346
+ // the applied kinds and the default set rather than the applied kinds alone.
143
347
  const cluster = fakeCluster({
144
- respond: (req) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
348
+ respond: echoApplies,
349
+ objects: {
350
+ [objectKey("v1", "Service", "gone", "prod")]: ownedObject("v1", "Service", "gone", "prod"),
351
+ },
145
352
  });
146
- await expect(kubectlApply({ manifest: file }, undefined, cluster.connector)).resolves.toBeUndefined();
353
+ const result = await applyManifest(
354
+ { manifest: manifest(), deleteMode: "owned-only" },
355
+ undefined,
356
+ cluster.connector,
357
+ );
358
+ expect(result.pruned).toEqual([{ apiVersion: "v1", kind: "Service", name: "gone", namespace: "prod" }]);
359
+ });
360
+
361
+ test("a kind the cluster does not serve is skipped, not fatal", async () => {
362
+ const cluster = fakeCluster({
363
+ respond: echoApplies,
364
+ // Only these two kinds exist here; every other sweep 404s on discovery.
365
+ serves: ["K8s::Apps::Deployment", "K8s::Core::Service"],
366
+ objects: {
367
+ [objectKey("apps/v1", "Deployment", "old", "prod")]: ownedObject("apps/v1", "Deployment", "old", "prod"),
368
+ },
369
+ });
370
+ const result = await applyManifest(
371
+ { manifest: manifest(), deleteMode: "owned-only" },
372
+ undefined,
373
+ cluster.connector,
374
+ );
375
+ expect(result.pruned.map((p) => p.name)).toEqual(["old"]);
376
+ });
377
+
378
+ test("a manifest that names no namespace does not become a cluster-wide sweep", async () => {
379
+ // Whether a kind is namespaced comes from the cluster's discovery, not from
380
+ // whether the document happened to name a namespace. Getting that wrong is
381
+ // the one way this could reach outside the namespace being applied to.
382
+ const file = join(dir, "no-namespace.yaml");
383
+ writeFileSync(file, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\n");
384
+ const cluster = fakeCluster({
385
+ respond: echoApplies,
386
+ objects: {
387
+ [objectKey("apps/v1", "Deployment", "elsewhere", "staging")]: ownedObject(
388
+ "apps/v1",
389
+ "Deployment",
390
+ "elsewhere",
391
+ "staging",
392
+ ),
393
+ },
394
+ });
395
+
396
+ const result = await applyManifest({ manifest: file, deleteMode: "owned-only" }, undefined, cluster.connector);
397
+
398
+ expect(result.pruned).toEqual([]);
399
+ const sweeps = cluster.layer.requests.filter((r) => r.query.labelSelector !== undefined).map((r) => r.path);
400
+ // Namespaced kinds are swept in the context's default namespace, never
401
+ // across the cluster.
402
+ expect(sweeps).toContain("/apis/apps/v1/namespaces/default/deployments");
403
+ expect(sweeps).not.toContain("/apis/apps/v1/deployments");
404
+ });
405
+
406
+ test("an object already terminating is left alone", async () => {
407
+ const terminating = ownedObject("apps/v1", "Deployment", "old", "prod");
408
+ terminating.metadata!.deletionTimestamp = "2026-07-20T10:00:00Z";
409
+ const cluster = fakeCluster({
410
+ respond: echoApplies,
411
+ objects: { [objectKey("apps/v1", "Deployment", "old", "prod")]: terminating },
412
+ });
413
+ const result = await applyManifest(
414
+ { manifest: manifest(), deleteMode: "owned-only" },
415
+ undefined,
416
+ cluster.connector,
417
+ );
418
+ expect(result.pruned).toEqual([]);
147
419
  });
148
420
  });