@intentius/chant-lexicon-k8s 0.45.0 → 0.46.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.
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAuBtE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,SAAS,GAAG;IAClD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAUA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACzC,GAAG,EAAE,SAAS,GACb,MAAM,GAAG,SAAS,CAIpB;AAQD,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAgK5B"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAuBtE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,SAAS,GAAG;IAClD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAUA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACzC,GAAG,EAAE,SAAS,GACb,MAAM,GAAG,SAAS,CAIpB;AAQD,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAmK5B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "d494ac9907d9da8ddb4456e697be2efe6e5d238df855db3c62abeb7d7036f406",
4
+ "manifest.json": "0640b884facfca510e23b1d775b4973fa6644a3d502678e3a8f04c303148be03",
5
5
  "meta.json": "6ec9419aa1aed8402b7957955f2a67e8c641861cfd3650886fb0898db5df14f6",
6
6
  "types/index.d.ts": "ef2c9df6a4d1ebfbe33f96c094fbbe3fc7909522ea38e086666fbf3c8ac477fd",
7
7
  "rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
@@ -57,5 +57,5 @@
57
57
  "skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5",
58
58
  "skills/chant-k8s-flux.md": "9670efc2e9854bd14686b5adeb707396b9030a4dcaee6529b30abf56efe2c50c"
59
59
  },
60
- "composite": "1594ddb769d445bb1e070ee0e591c94da0716f03f2a4681c6284992981cf616a"
60
+ "composite": "4021f2a6439bc3da4d7659b7318d997d0d1c8ff70a813c7240094b4bf16e9605"
61
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "K8s",
6
6
  "intrinsics": [],
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AA2BjG,eAAO,MAAM,SAAS,EAAE,aAqqBvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AA2BjG,eAAO,MAAM,SAAS,EAAE,aAkrBvB,CAAC"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Env teardown for the k8s lexicon (chant #1222) — both halves of the
3
+ * `teardownOwned` / `executeTeardown` capability pair.
4
+ *
5
+ * Selection is the prune's marker selector (op/activities/kubectl.ts) with the
6
+ * env dimension added: `app.kubernetes.io/managed-by=chant` +
7
+ * `chant.intentius.io/stack=<stack>` + `chant.intentius.io/env=<env>`, sent to
8
+ * the server as a label selector and re-checked object by object with
9
+ * `readOwnership` — the server-side filter narrows the read, the client-side
10
+ * re-check is the one that matters, because a delete is not undoable.
11
+ *
12
+ * Swept kinds are chant's default sweep set (`DEFAULT_IMPORT_TYPES`, the same
13
+ * list the ownership-scoped prune unions in), listed cluster-wide: env
14
+ * teardown has no apply set to scope namespaces by, and the marker selector is
15
+ * the scope. A kind the cluster's discovery does not serve cannot hold a
16
+ * marked object and is skipped; a kind that resolves but fails to list is a
17
+ * hole (#1089) — unknown, never absent.
18
+ *
19
+ * Execution deletes the handed-over candidates through the typed client's own
20
+ * `delete()` — the identical delete the prune and `chant kube delete` use —
21
+ * with namespaces last, so a namespace's own members get individual outcomes
22
+ * before the cascade would have swallowed them. Before each delete the live
23
+ * object is re-read and its marker re-verified; an object that vanished is
24
+ * `deleted` (teardown is idempotent), one whose identity no longer matches is
25
+ * `not-prunable`, never deleted.
26
+ */
27
+ import type { TeardownCandidate, TeardownEnumeration, TeardownExecution } from "@intentius/chant/lexicon";
28
+ import { type OwnershipMarker } from "@intentius/chant/ownership";
29
+ import { type K8sConnector } from "./api/connect.js";
30
+ export interface K8sTeardownOptions {
31
+ environment: string;
32
+ marker: OwnershipMarker;
33
+ }
34
+ /**
35
+ * Enumerate the cluster's marker-matching objects — the k8s half of
36
+ * `chant lifecycle teardown <env>` planning.
37
+ */
38
+ export declare function teardownOwned(options: K8sTeardownOptions, connect?: K8sConnector): Promise<TeardownEnumeration>;
39
+ /**
40
+ * Delete the handed-over candidates, namespaces last. One outcome per
41
+ * candidate, always.
42
+ */
43
+ export declare function executeTeardown(options: K8sTeardownOptions & {
44
+ candidates: TeardownCandidate[];
45
+ }, connect?: K8sConnector): Promise<TeardownExecution>;
46
+ //# sourceMappingURL=teardown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"teardown.d.ts","sourceRoot":"","sources":["../src/teardown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EAGlB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AAIpC,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAIvE,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAoBD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,mBAAmB,CAAC,CA4D9B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,GAAG;IAAE,UAAU,EAAE,iBAAiB,EAAE,CAAA;CAAE,EACjE,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAe5B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,7 +76,7 @@
76
76
  "@types/js-yaml": "^4.0.9"
77
77
  },
78
78
  "optionalDependencies": {
79
- "@intentius/chant-k8s-client": "^0.45.0"
79
+ "@intentius/chant-k8s-client": "^0.46.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@intentius/chant": "*",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "peerDependencies": {
86
86
  "zod": "^4.3.6",
87
- "@intentius/chant": "^0.45.0",
87
+ "@intentius/chant": "^0.46.0",
88
88
  "typescript": "^5.9.3"
89
89
  }
90
90
  }
@@ -56,7 +56,7 @@
56
56
 
57
57
  import type { ObservationResult, ResourceMetadata, UnobservedEntity, UnobservedReason } from "@intentius/chant/lexicon";
58
58
  import { observation, unobservedAll } from "@intentius/chant/observation";
59
- import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
59
+ import { hasOwnershipMarker, classifyOwnership, readOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
60
60
  import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
61
61
  import { defaultK8sConnector, type K8sConnector } from "./api/connect";
62
62
  import {
@@ -472,6 +472,9 @@ export async function describeResources(
472
472
  status: statusFromObject(obj),
473
473
  lastUpdated: obj.metadata?.creationTimestamp,
474
474
  ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
475
+ // Marker identity (#1222): the stack/env labels stamped at synthesis,
476
+ // read back verbatim. Undefined when the managed-by label is absent.
477
+ marker: readOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
475
478
  attributes: pruneUndefined({
476
479
  namespace: obj.metadata?.namespace,
477
480
  labels: obj.metadata?.labels,
@@ -774,6 +777,8 @@ async function addRuntimeChildren(
774
777
  status: statusFromObject(obj),
775
778
  lastUpdated: obj.metadata?.creationTimestamp,
776
779
  ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
780
+ // Marker identity (#1222), same rule as the declared-entity read above.
781
+ marker: readOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
777
782
  ownerChain,
778
783
  attributes: pruneUndefined({
779
784
  namespace,
@@ -279,5 +279,47 @@ describeObservationConformance({
279
279
  });
280
280
  },
281
281
  },
282
+ {
283
+ name: "a marker-stamped object surfaces its stack/env identity; a foreign one surfaces none (#1222)",
284
+ declared: ["web", "legacy"],
285
+ expectPresent: ["web", "legacy"],
286
+ expectMarker: { web: { stack: "shop", env: "prod" } },
287
+ expectNoMarker: ["legacy"],
288
+ run: () => {
289
+ clusterState.fail = undefined;
290
+ clusterState.objects = {
291
+ [objectKey("apps/v1", "Deployment", "web", "default")]: {
292
+ apiVersion: "apps/v1",
293
+ kind: "Deployment",
294
+ metadata: {
295
+ name: "web",
296
+ namespace: "default",
297
+ uid: "uid-1",
298
+ labels: {
299
+ "app.kubernetes.io/managed-by": "chant",
300
+ "chant.intentius.io/stack": "shop",
301
+ "chant.intentius.io/env": "prod",
302
+ },
303
+ },
304
+ status: { readyReplicas: 1, replicas: 1 },
305
+ },
306
+ [objectKey("apps/v1", "Deployment", "legacy", "default")]: {
307
+ apiVersion: "apps/v1",
308
+ kind: "Deployment",
309
+ metadata: { name: "legacy", namespace: "default", uid: "uid-2" },
310
+ status: { readyReplicas: 1, replicas: 1 },
311
+ },
312
+ };
313
+ return k8sPlugin.describeResources!({
314
+ environment: "prod",
315
+ buildOutput: "",
316
+ entityNames: ["web", "legacy"],
317
+ entities: new Map([
318
+ ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
319
+ ["legacy", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "legacy" } } }],
320
+ ]),
321
+ });
322
+ },
323
+ },
282
324
  ],
283
325
  });
package/src/plugin.ts CHANGED
@@ -678,6 +678,19 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
678
678
  return describeResources(options);
679
679
  },
680
680
 
681
+ // Env teardown (#1222): enumeration + execution over the marker label
682
+ // selector (managed-by + stack + env), through the same typed client the
683
+ // prune uses. Execution deletes namespaces last. See ./teardown.ts.
684
+ async teardownOwned(options) {
685
+ const { teardownOwned } = await import("./teardown");
686
+ return teardownOwned(options);
687
+ },
688
+
689
+ async executeTeardown(options) {
690
+ const { executeTeardown } = await import("./teardown");
691
+ return executeTeardown(options);
692
+ },
693
+
681
694
  /**
682
695
  * Deploy-unit presence + health for `chant components status --live`
683
696
  * (#1495 piece 3): a Kubernetes deploy unit is the label selector chant's
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Env teardown over the fake cluster (chant #1222) — the k8s halves of the
3
+ * teardownOwned / executeTeardown capability pair, mirroring the typed-prune
4
+ * tests in op/activities/kubectl.test.ts: a real client over a fake request
5
+ * layer, so discovery, path construction, and the delete path all run for
6
+ * real.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
11
+ import { statusBody } from "@intentius/chant-k8s-client/testing";
12
+ import { fakeCluster, objectKey, ownedObject } from "./api/fake-cluster";
13
+ import { teardownOwned, executeTeardown } from "./teardown";
14
+
15
+ const MARKER = { stack: "shop", env: "dev" };
16
+
17
+ function markerLabels(stack: string, env?: string): Record<string, string> {
18
+ return {
19
+ [LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
20
+ [LABEL_OWNERSHIP_KEYS.stack]: stack,
21
+ ...(env ? { [LABEL_OWNERSHIP_KEYS.env]: env } : {}),
22
+ };
23
+ }
24
+
25
+ /** A cluster holding this env's estate plus every kind of near-miss. */
26
+ function estateCluster(overrides: Parameters<typeof fakeCluster>[0] = {}) {
27
+ return fakeCluster({
28
+ objects: {
29
+ // Mine: full marker, this stack, this env.
30
+ [objectKey("apps/v1", "Deployment", "web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "web", "prod-ns", {
31
+ metadata: { labels: markerLabels("shop", "dev") },
32
+ }),
33
+ [objectKey("v1", "Service", "cache", "prod-ns")]: ownedObject("v1", "Service", "cache", "prod-ns", {
34
+ metadata: { labels: markerLabels("shop", "dev") },
35
+ }),
36
+ [objectKey("v1", "Namespace", "dev-ns")]: ownedObject("v1", "Namespace", "dev-ns", undefined, {
37
+ metadata: { labels: markerLabels("shop", "dev") },
38
+ }),
39
+ // Near-misses: foreign stack, foreign env, env-less marker, unmarked.
40
+ [objectKey("apps/v1", "Deployment", "their-web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "their-web", "prod-ns", {
41
+ metadata: { labels: markerLabels("blog", "dev") },
42
+ }),
43
+ [objectKey("apps/v1", "Deployment", "prod-twin", "prod-ns")]: ownedObject("apps/v1", "Deployment", "prod-twin", "prod-ns", {
44
+ metadata: { labels: markerLabels("shop", "prod") },
45
+ }),
46
+ [objectKey("apps/v1", "Deployment", "no-env", "prod-ns")]: ownedObject("apps/v1", "Deployment", "no-env", "prod-ns", {
47
+ metadata: { labels: markerLabels("shop") },
48
+ }),
49
+ [objectKey("apps/v1", "Deployment", "handmade", "prod-ns")]: {
50
+ apiVersion: "apps/v1",
51
+ kind: "Deployment",
52
+ metadata: { name: "handmade", namespace: "prod-ns", labels: { app: "handmade" } },
53
+ },
54
+ },
55
+ ...overrides,
56
+ });
57
+ }
58
+
59
+ describe("teardownOwned — marker-env-scoped enumeration", () => {
60
+ test("selects exactly the objects carrying this stack + env, nothing near", async () => {
61
+ const cluster = estateCluster();
62
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
63
+ expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["dev-ns", "prod-ns/cache", "prod-ns/web"]);
64
+ for (const candidate of enumeration.candidates) {
65
+ expect(candidate.marker).toEqual(MARKER);
66
+ }
67
+ const web = enumeration.candidates.find((c) => c.name === "prod-ns/web")!;
68
+ expect(web.type).toBe("K8s::Apps::Deployment");
69
+ expect(web.physicalId).toBe("uid-web");
70
+ expect(enumeration.holes ?? []).toEqual([]);
71
+ });
72
+
73
+ test("the selector is sent to the server: managed-by + stack + env", async () => {
74
+ const cluster = estateCluster();
75
+ await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
76
+ const listQueries = cluster.layer.requests
77
+ .filter((r) => r.method === "GET" && r.query.labelSelector !== undefined)
78
+ .map((r) => r.query.labelSelector);
79
+ expect(listQueries.length).toBeGreaterThan(0);
80
+ for (const selector of listQueries) {
81
+ expect(selector).toBe(
82
+ `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE},` +
83
+ `${LABEL_OWNERSHIP_KEYS.stack}=shop,${LABEL_OWNERSHIP_KEYS.env}=dev`,
84
+ );
85
+ }
86
+ });
87
+
88
+ test("an object already terminating is not a candidate", async () => {
89
+ const cluster = fakeCluster({
90
+ objects: {
91
+ [objectKey("apps/v1", "Deployment", "leaving", "prod-ns")]: ownedObject("apps/v1", "Deployment", "leaving", "prod-ns", {
92
+ metadata: { labels: markerLabels("shop", "dev"), deletionTimestamp: "2026-08-23T00:00:00Z" } as never,
93
+ }),
94
+ },
95
+ });
96
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
97
+ expect(enumeration.candidates).toEqual([]);
98
+ });
99
+
100
+ test("a kind that fails to list is a hole, and the others still enumerate (#1089)", async () => {
101
+ const cluster = estateCluster({
102
+ respond: (req) => {
103
+ if (req.method === "GET" && req.path.endsWith("/deployments") && req.query.labelSelector !== undefined) {
104
+ return { status: 500, body: statusBody(500, "InternalError", "etcd is unhappy") };
105
+ }
106
+ return undefined;
107
+ },
108
+ });
109
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
110
+ expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["dev-ns", "prod-ns/cache"]);
111
+ const hole = (enumeration.holes ?? []).find((h) => h.name === "K8s::Apps::Deployment");
112
+ expect(hole).toBeDefined();
113
+ expect(hole!.reason).toBe("read-failed");
114
+ });
115
+ });
116
+
117
+ describe("executeTeardown — typed deletes, namespaces last", () => {
118
+ const candidates = [
119
+ { name: "dev-ns", type: "K8s::Core::Namespace", marker: MARKER },
120
+ { name: "prod-ns/web", type: "K8s::Apps::Deployment", physicalId: "uid-web", marker: MARKER },
121
+ { name: "prod-ns/cache", type: "K8s::Core::Service", marker: MARKER },
122
+ ];
123
+
124
+ test("deletes every candidate through the typed client, namespace last", async () => {
125
+ const cluster = estateCluster();
126
+ const execution = await executeTeardown({ environment: "dev", marker: MARKER, candidates }, cluster.connector);
127
+
128
+ expect(execution.outcomes.map((o) => ({ name: o.name, outcome: o.outcome }))).toEqual([
129
+ { name: "prod-ns/web", outcome: "deleted" },
130
+ { name: "prod-ns/cache", outcome: "deleted" },
131
+ { name: "dev-ns", outcome: "deleted" },
132
+ ]);
133
+ const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
134
+ expect(deletes).toEqual([
135
+ "/apis/apps/v1/namespaces/prod-ns/deployments/web",
136
+ "/api/v1/namespaces/prod-ns/services/cache",
137
+ "/api/v1/namespaces/dev-ns",
138
+ ]);
139
+ });
140
+
141
+ test("an already-absent candidate is deleted (idempotent), with no DELETE issued", async () => {
142
+ const cluster = fakeCluster({ objects: {} });
143
+ const execution = await executeTeardown(
144
+ { environment: "dev", marker: MARKER, candidates: [candidates[1]] },
145
+ cluster.connector,
146
+ );
147
+ expect(execution.outcomes).toEqual([
148
+ {
149
+ name: "prod-ns/web",
150
+ type: "K8s::Apps::Deployment",
151
+ physicalId: "uid-web",
152
+ outcome: "deleted",
153
+ detail: "already absent",
154
+ },
155
+ ]);
156
+ expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
157
+ });
158
+
159
+ test("a live object whose marker changed since planning is not-prunable, never deleted", async () => {
160
+ const cluster = fakeCluster({
161
+ objects: {
162
+ [objectKey("apps/v1", "Deployment", "web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "web", "prod-ns", {
163
+ metadata: { labels: markerLabels("blog", "dev") }, // re-stamped by someone else
164
+ }),
165
+ },
166
+ });
167
+ const execution = await executeTeardown(
168
+ { environment: "dev", marker: MARKER, candidates: [candidates[1]] },
169
+ cluster.connector,
170
+ );
171
+ expect(execution.outcomes[0].outcome).toBe("not-prunable");
172
+ expect(execution.outcomes[0].detail).toContain("marker identity");
173
+ expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
174
+ });
175
+
176
+ test("a refused delete is a failed outcome with the server's message, not a throw", async () => {
177
+ const cluster = estateCluster({
178
+ respond: (req) =>
179
+ req.method === "DELETE"
180
+ ? { status: 403, body: statusBody(403, "Forbidden", "RBAC says no") }
181
+ : undefined,
182
+ });
183
+ const execution = await executeTeardown(
184
+ { environment: "dev", marker: MARKER, candidates: [candidates[1]] },
185
+ cluster.connector,
186
+ );
187
+ expect(execution.outcomes[0].outcome).toBe("failed");
188
+ expect(execution.outcomes[0].detail).toContain("RBAC says no");
189
+ });
190
+
191
+ test("a candidate of an unmapped type is not-prunable, not a crash", async () => {
192
+ const cluster = fakeCluster({ objects: {} });
193
+ const execution = await executeTeardown(
194
+ {
195
+ environment: "dev",
196
+ marker: MARKER,
197
+ candidates: [{ name: "mystery", type: "K8s::NotAThing::Widget", marker: MARKER }],
198
+ },
199
+ cluster.connector,
200
+ );
201
+ expect(execution.outcomes[0].outcome).toBe("not-prunable");
202
+ expect(execution.outcomes[0].detail).toContain("no operation mapping");
203
+ });
204
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Env teardown for the k8s lexicon (chant #1222) — both halves of the
3
+ * `teardownOwned` / `executeTeardown` capability pair.
4
+ *
5
+ * Selection is the prune's marker selector (op/activities/kubectl.ts) with the
6
+ * env dimension added: `app.kubernetes.io/managed-by=chant` +
7
+ * `chant.intentius.io/stack=<stack>` + `chant.intentius.io/env=<env>`, sent to
8
+ * the server as a label selector and re-checked object by object with
9
+ * `readOwnership` — the server-side filter narrows the read, the client-side
10
+ * re-check is the one that matters, because a delete is not undoable.
11
+ *
12
+ * Swept kinds are chant's default sweep set (`DEFAULT_IMPORT_TYPES`, the same
13
+ * list the ownership-scoped prune unions in), listed cluster-wide: env
14
+ * teardown has no apply set to scope namespaces by, and the marker selector is
15
+ * the scope. A kind the cluster's discovery does not serve cannot hold a
16
+ * marked object and is skipped; a kind that resolves but fails to list is a
17
+ * hole (#1089) — unknown, never absent.
18
+ *
19
+ * Execution deletes the handed-over candidates through the typed client's own
20
+ * `delete()` — the identical delete the prune and `chant kube delete` use —
21
+ * with namespaces last, so a namespace's own members get individual outcomes
22
+ * before the cascade would have swallowed them. Before each delete the live
23
+ * object is re-read and its marker re-verified; an object that vanished is
24
+ * `deleted` (teardown is idempotent), one whose identity no longer matches is
25
+ * `not-prunable`, never deleted.
26
+ */
27
+
28
+ import type {
29
+ TeardownCandidate,
30
+ TeardownEnumeration,
31
+ TeardownExecution,
32
+ TeardownHole,
33
+ TeardownOutcome,
34
+ } from "@intentius/chant/lexicon";
35
+ import {
36
+ LABEL_OWNERSHIP_KEYS,
37
+ OWNERSHIP_MANAGED_BY_VALUE,
38
+ readOwnership,
39
+ type OwnershipMarker,
40
+ } from "@intentius/chant/ownership";
41
+ import type { K8sClient } from "@intentius/chant-k8s-client";
42
+ import { DEFAULT_IMPORT_TYPES } from "./api/sweep-types";
43
+ import { operationFor } from "./api/operation-surface";
44
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
45
+
46
+ const NAMESPACE_TYPE = "K8s::Core::Namespace";
47
+
48
+ export interface K8sTeardownOptions {
49
+ environment: string;
50
+ marker: OwnershipMarker;
51
+ }
52
+
53
+ /** The label selector that scopes everything here: managed-by + stack + env. */
54
+ function markerSelector(marker: OwnershipMarker): string {
55
+ return [
56
+ `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
57
+ `${LABEL_OWNERSHIP_KEYS.stack}=${marker.stack}`,
58
+ ...(marker.env ? [`${LABEL_OWNERSHIP_KEYS.env}=${marker.env}`] : []),
59
+ ].join(",");
60
+ }
61
+
62
+ /** True when a live object's own labels carry exactly the requested identity. */
63
+ function matchesMarker(
64
+ labels: Record<string, string> | undefined,
65
+ marker: OwnershipMarker,
66
+ ): boolean {
67
+ const read = readOwnership(labels, LABEL_OWNERSHIP_KEYS);
68
+ return read !== undefined && read.stack === marker.stack && read.env === marker.env;
69
+ }
70
+
71
+ /**
72
+ * Enumerate the cluster's marker-matching objects — the k8s half of
73
+ * `chant lifecycle teardown <env>` planning.
74
+ */
75
+ export async function teardownOwned(
76
+ options: K8sTeardownOptions,
77
+ connect: K8sConnector = defaultK8sConnector,
78
+ ): Promise<TeardownEnumeration> {
79
+ const { client } = await connect({ environment: options.environment });
80
+ const selector = markerSelector(options.marker);
81
+
82
+ const candidates: TeardownCandidate[] = [];
83
+ const holes: TeardownHole[] = [];
84
+
85
+ for (const entityType of DEFAULT_IMPORT_TYPES) {
86
+ const operation = operationFor(entityType);
87
+ if (!operation) continue;
88
+ const ref = { apiVersion: operation.apiVersion, kind: operation.kind };
89
+
90
+ let served: boolean;
91
+ try {
92
+ served = (await client.resolve(ref)) !== undefined;
93
+ } catch (err) {
94
+ holes.push({
95
+ name: entityType,
96
+ type: entityType,
97
+ reason: "read-failed",
98
+ detail: err instanceof Error ? err.message : String(err),
99
+ });
100
+ continue;
101
+ }
102
+ // A kind this cluster's discovery does not serve cannot hold a marked
103
+ // object — absence of the kind is knowledge, not a hole.
104
+ if (!served) continue;
105
+
106
+ let items;
107
+ try {
108
+ items = await client.list(ref, { labelSelector: selector });
109
+ } catch (err) {
110
+ holes.push({
111
+ name: entityType,
112
+ type: entityType,
113
+ reason: "read-failed",
114
+ detail: err instanceof Error ? err.message : String(err),
115
+ });
116
+ continue;
117
+ }
118
+
119
+ for (const item of items) {
120
+ const name = item.metadata?.name;
121
+ if (!name) continue;
122
+ // The re-check that matters: a server (or fake) that ignored the label
123
+ // selector must not widen the set past the requested identity.
124
+ if (!matchesMarker(item.metadata?.labels, options.marker)) continue;
125
+ // Already terminating — deleting again is noise, not progress.
126
+ if (item.metadata?.deletionTimestamp !== undefined) continue;
127
+ const namespace = item.metadata?.namespace;
128
+ candidates.push({
129
+ name: namespace ? `${namespace}/${name}` : name,
130
+ type: entityType,
131
+ ...(item.metadata?.uid ? { physicalId: String(item.metadata.uid) } : {}),
132
+ marker: readOwnership(item.metadata?.labels, LABEL_OWNERSHIP_KEYS)!,
133
+ });
134
+ }
135
+ }
136
+
137
+ return { candidates, ...(holes.length > 0 ? { holes } : {}) };
138
+ }
139
+
140
+ /**
141
+ * Delete the handed-over candidates, namespaces last. One outcome per
142
+ * candidate, always.
143
+ */
144
+ export async function executeTeardown(
145
+ options: K8sTeardownOptions & { candidates: TeardownCandidate[] },
146
+ connect: K8sConnector = defaultK8sConnector,
147
+ ): Promise<TeardownExecution> {
148
+ const { client } = await connect({ environment: options.environment });
149
+
150
+ // Namespaces last: their members get individual deletes (and individual
151
+ // outcomes) before the namespace cascade sweeps whatever is left.
152
+ const ordered = [
153
+ ...options.candidates.filter((c) => c.type !== NAMESPACE_TYPE),
154
+ ...options.candidates.filter((c) => c.type === NAMESPACE_TYPE),
155
+ ];
156
+
157
+ const outcomes: TeardownOutcome[] = [];
158
+ for (const candidate of ordered) {
159
+ outcomes.push(await deleteCandidate(client, candidate, options.marker));
160
+ }
161
+ return { outcomes };
162
+ }
163
+
164
+ async function deleteCandidate(
165
+ client: K8sClient,
166
+ candidate: TeardownCandidate,
167
+ marker: OwnershipMarker,
168
+ ): Promise<TeardownOutcome> {
169
+ const base = {
170
+ name: candidate.name,
171
+ type: candidate.type,
172
+ ...(candidate.physicalId ? { physicalId: candidate.physicalId } : {}),
173
+ };
174
+
175
+ const operation = operationFor(candidate.type);
176
+ if (!operation) {
177
+ return { ...base, outcome: "not-prunable", detail: `no operation mapping for type ${candidate.type}` };
178
+ }
179
+
180
+ let info;
181
+ try {
182
+ info = await client.resolve({ apiVersion: operation.apiVersion, kind: operation.kind });
183
+ } catch (err) {
184
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
185
+ }
186
+ if (!info) {
187
+ return { ...base, outcome: "not-prunable", detail: `the cluster does not serve ${operation.kind}` };
188
+ }
189
+
190
+ const slash = candidate.name.indexOf("/");
191
+ const namespace = info.namespaced && slash > 0 ? candidate.name.slice(0, slash) : undefined;
192
+ const name = info.namespaced && slash > 0 ? candidate.name.slice(slash + 1) : candidate.name;
193
+ const ref = {
194
+ apiVersion: operation.apiVersion,
195
+ kind: operation.kind,
196
+ name,
197
+ ...(namespace !== undefined ? { namespace } : {}),
198
+ };
199
+
200
+ // Re-read and re-verify the identity right before deleting: the enumeration
201
+ // is a moment old at best, and a delete is not undoable.
202
+ let live;
203
+ try {
204
+ live = await client.readIfPresent(ref);
205
+ } catch (err) {
206
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
207
+ }
208
+ if (live === undefined) {
209
+ return { ...base, outcome: "deleted", detail: "already absent" };
210
+ }
211
+ if (!matchesMarker(live.metadata?.labels, marker)) {
212
+ return {
213
+ ...base,
214
+ outcome: "not-prunable",
215
+ detail: "the live object no longer carries the requested marker identity",
216
+ };
217
+ }
218
+
219
+ try {
220
+ await client.delete(ref);
221
+ } catch (err) {
222
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
223
+ }
224
+ return { ...base, outcome: "deleted" };
225
+ }