@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.
- package/dist/api/classify.d.ts +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kubectlApply` over the typed API client (chant #1074, #1075).
|
|
3
|
+
*
|
|
4
|
+
* The activity contract is what Temporal workers register, so the shape of the
|
|
5
|
+
* arguments and the `Promise<void>` return are asserted alongside the new
|
|
6
|
+
* behavior. Nothing here spawns a process or reads an ambient kubeconfig,
|
|
7
|
+
* which is the acceptance criterion: a worker image needs no `kubectl` binary.
|
|
8
|
+
*
|
|
9
|
+
* 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.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { applyManifest, kubectlApply, readManifestDocuments, resolveFieldManager } from "./kubectl";
|
|
20
|
+
import { fakeCluster, objectKey, ownedObject } from "../../api/fake-cluster";
|
|
21
|
+
import { statusBody } from "@intentius/chant-k8s-client/testing";
|
|
22
|
+
import type { RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
23
|
+
|
|
24
|
+
let dir: string;
|
|
25
|
+
|
|
26
|
+
const deploymentYaml = `apiVersion: apps/v1
|
|
27
|
+
kind: Deployment
|
|
28
|
+
metadata:
|
|
29
|
+
name: web
|
|
30
|
+
namespace: prod
|
|
31
|
+
spec:
|
|
32
|
+
replicas: 3
|
|
33
|
+
---
|
|
34
|
+
apiVersion: v1
|
|
35
|
+
kind: Service
|
|
36
|
+
metadata:
|
|
37
|
+
name: web-svc
|
|
38
|
+
namespace: prod
|
|
39
|
+
spec:
|
|
40
|
+
ports:
|
|
41
|
+
- port: 80
|
|
42
|
+
`;
|
|
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
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
dir = mkdtempSync(join(tmpdir(), "chant-k8s-apply-"));
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("readManifestDocuments", () => {
|
|
67
|
+
test("splits a multi-document file, dropping empty documents", () => {
|
|
68
|
+
const file = join(dir, "k8s.yaml");
|
|
69
|
+
writeFileSync(file, `${deploymentYaml}---\n---\n`);
|
|
70
|
+
const docs = readManifestDocuments(file);
|
|
71
|
+
expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a directory is read in sorted file order, as `kubectl apply -f <dir>` does", () => {
|
|
75
|
+
writeFileSync(join(dir, "20-service.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: b\n");
|
|
76
|
+
writeFileSync(join(dir, "10-deployment.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: a\n");
|
|
77
|
+
writeFileSync(join(dir, "notes.txt"), "ignored");
|
|
78
|
+
const docs = readManifestDocuments(dir);
|
|
79
|
+
expect(docs.map((d) => d.kind)).toEqual(["Deployment", "Service"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("JSON manifests are read too", () => {
|
|
83
|
+
const file = join(dir, "k8s.json");
|
|
84
|
+
writeFileSync(file, JSON.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: "c" } }));
|
|
85
|
+
expect(readManifestDocuments(file).map((d) => d.kind)).toEqual(["ConfigMap"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
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
|
+
|
|
116
|
+
describe("kubectlApply", () => {
|
|
117
|
+
test("server-side applies every document, in file order, as the chant field manager", async () => {
|
|
118
|
+
const file = join(dir, "k8s.yaml");
|
|
119
|
+
writeFileSync(file, deploymentYaml);
|
|
120
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
121
|
+
|
|
122
|
+
await kubectlApply({ manifest: file, cwd: projectWithStack("web") }, undefined, cluster.connector);
|
|
123
|
+
|
|
124
|
+
const patches = cluster.layer.requests.filter((r) => r.method === "PATCH");
|
|
125
|
+
expect(patches.map((p) => p.path)).toEqual([
|
|
126
|
+
"/apis/apps/v1/namespaces/prod/deployments/web",
|
|
127
|
+
"/api/v1/namespaces/prod/services/web-svc",
|
|
128
|
+
]);
|
|
129
|
+
for (const patch of patches) {
|
|
130
|
+
expect(patch.headers["Content-Type"]).toBe("application/apply-patch+yaml");
|
|
131
|
+
expect(patch.query).toMatchObject({ fieldManager: "chant:web", force: "false" });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an explicit context is honored and skips the environment lookup entirely", async () => {
|
|
136
|
+
const file = join(dir, "k8s.yaml");
|
|
137
|
+
writeFileSync(file, deploymentYaml);
|
|
138
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
139
|
+
|
|
140
|
+
await kubectlApply({ manifest: file, context: "test-context" }, undefined, cluster.connector);
|
|
141
|
+
|
|
142
|
+
expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
|
|
143
|
+
expect(cluster.connects[0].environment).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("an environment is passed through so the cluster binding applies to the write path too", async () => {
|
|
147
|
+
const file = join(dir, "k8s.yaml");
|
|
148
|
+
writeFileSync(file, deploymentYaml);
|
|
149
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
150
|
+
|
|
151
|
+
await kubectlApply({ manifest: file, environment: "prod" }, undefined, cluster.connector);
|
|
152
|
+
expect(cluster.connects[0]).toMatchObject({ environment: "prod" });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("force is forwarded, for the caller that means to take ownership", async () => {
|
|
156
|
+
const file = join(dir, "k8s.yaml");
|
|
157
|
+
writeFileSync(file, deploymentYaml);
|
|
158
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
159
|
+
|
|
160
|
+
await kubectlApply({ manifest: file, force: true, fieldManager: "chant-op" }, undefined, cluster.connector);
|
|
161
|
+
const patch = cluster.layer.requests.find((r) => r.method === "PATCH")!;
|
|
162
|
+
expect(patch.query).toMatchObject({ force: "true", fieldManager: "chant-op" });
|
|
163
|
+
});
|
|
164
|
+
|
|
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 () => {
|
|
177
|
+
const file = join(dir, "k8s.yaml");
|
|
178
|
+
writeFileSync(file, deploymentYaml);
|
|
179
|
+
const cluster = fakeCluster({
|
|
180
|
+
respond: (req) =>
|
|
181
|
+
req.method === "PATCH"
|
|
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
|
+
}
|
|
197
|
+
: undefined,
|
|
198
|
+
});
|
|
199
|
+
|
|
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");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("resolves to undefined, keeping the activity's Promise<void> contract", async () => {
|
|
214
|
+
const file = join(dir, "k8s.yaml");
|
|
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.
|
|
347
|
+
const cluster = fakeCluster({
|
|
348
|
+
respond: echoApplies,
|
|
349
|
+
objects: {
|
|
350
|
+
[objectKey("v1", "Service", "gone", "prod")]: ownedObject("v1", "Service", "gone", "prod"),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
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([]);
|
|
419
|
+
});
|
|
420
|
+
});
|