@intentius/chant-lexicon-k8s 0.44.14 → 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.
Files changed (65) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/generate-lexicon.d.ts +15 -1
  3. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  4. package/dist/components/capability-plugin.d.ts.map +1 -1
  5. package/dist/crd/parser.d.ts +28 -1
  6. package/dist/crd/parser.d.ts.map +1 -1
  7. package/dist/deep-observe-hooks.d.ts +60 -4
  8. package/dist/deep-observe-hooks.d.ts.map +1 -1
  9. package/dist/deep-observe.d.ts +36 -61
  10. package/dist/deep-observe.d.ts.map +1 -1
  11. package/dist/describe-resources.d.ts.map +1 -1
  12. package/dist/integrity.json +6 -3
  13. package/dist/lint/audit-catalog.d.ts.map +1 -1
  14. package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
  15. package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
  16. package/dist/lint/post-synth/index.d.ts.map +1 -1
  17. package/dist/lint/post-synth/wk8501.d.ts +14 -0
  18. package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
  19. package/dist/lint/post-synth/wk8502.d.ts +13 -0
  20. package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
  21. package/dist/manifest.json +1 -1
  22. package/dist/meta.json +126 -63
  23. package/dist/okf/index.md +2 -0
  24. package/dist/okf/rules/WK8501.md +11 -0
  25. package/dist/okf/rules/WK8502.md +11 -0
  26. package/dist/plugin.d.ts.map +1 -1
  27. package/dist/rules/crd-schema-helpers.ts +228 -0
  28. package/dist/rules/wk8501.ts +40 -0
  29. package/dist/rules/wk8502.ts +39 -0
  30. package/dist/spec/parse.d.ts +32 -0
  31. package/dist/spec/parse.d.ts.map +1 -1
  32. package/dist/teardown.d.ts +46 -0
  33. package/dist/teardown.d.ts.map +1 -0
  34. package/dist/validate.d.ts.map +1 -1
  35. package/package.json +3 -3
  36. package/src/codegen/docs.ts +0 -1044
  37. package/src/codegen/generate-lexicon.ts +38 -2
  38. package/src/codegen/snapshot.test.ts +20 -0
  39. package/src/components/capability-plugin.test.ts +20 -0
  40. package/src/components/capability-plugin.ts +5 -2
  41. package/src/crd/cnpg.test.ts +1 -1
  42. package/src/crd/infisical.test.ts +1 -1
  43. package/src/crd/parser.test.ts +117 -1
  44. package/src/crd/parser.ts +71 -5
  45. package/src/crd/traefik.test.ts +1 -1
  46. package/src/deep-observe-hooks.ts +78 -16
  47. package/src/deep-observe.test.ts +145 -29
  48. package/src/deep-observe.ts +43 -85
  49. package/src/describe-resources.ts +6 -1
  50. package/src/generated/lexicon-k8s.json +126 -63
  51. package/src/lifecycle-integration.test.ts +42 -0
  52. package/src/lint/audit-catalog.ts +2 -0
  53. package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
  54. package/src/lint/post-synth/index.ts +4 -0
  55. package/src/lint/post-synth/post-synth.test.ts +156 -0
  56. package/src/lint/post-synth/wk8501.ts +40 -0
  57. package/src/lint/post-synth/wk8502.ts +39 -0
  58. package/src/list-map-key-table.test.ts +83 -0
  59. package/src/op/activities/kubectl.ts +2 -2
  60. package/src/plugin.ts +13 -0
  61. package/src/serializer.test.ts +4 -4
  62. package/src/spec/parse.ts +33 -0
  63. package/src/teardown.test.ts +204 -0
  64. package/src/teardown.ts +225 -0
  65. package/src/validate.ts +7 -4
@@ -348,7 +348,7 @@ describe("k8sSerializer", () => {
348
348
  }),
349
349
  );
350
350
 
351
- const result = k8sSerializer.serialize(entities);
351
+ const result = k8sSerializer.serialize(entities) as string;
352
352
  expect(result).toContain("env: prod");
353
353
  // Should not contain "dev" since explicit overrides
354
354
  const envLines = result.split("\n").filter((l: string) => l.includes("env:"));
@@ -366,7 +366,7 @@ describe("k8sSerializer", () => {
366
366
  }),
367
367
  );
368
368
 
369
- const result = k8sSerializer.serialize(entities);
369
+ const result = k8sSerializer.serialize(entities) as string;
370
370
  expect(result).toContain("kind: Deployment");
371
371
  // Only one document — property entities should not appear as separate docs
372
372
  expect(result.split("---").length).toBeLessThanOrEqual(2);
@@ -406,7 +406,7 @@ describe("k8sSerializer", () => {
406
406
  }),
407
407
  );
408
408
 
409
- const result = k8sSerializer.serialize(entities);
409
+ const result = k8sSerializer.serialize(entities) as string;
410
410
  const docs = result.split("---");
411
411
  // First document should be the Namespace
412
412
  expect(docs[0]).toContain("kind: Namespace");
@@ -449,7 +449,7 @@ describe("k8sSerializer", () => {
449
449
  }),
450
450
  );
451
451
 
452
- const result = k8sSerializer.serialize(entities);
452
+ const result = k8sSerializer.serialize(entities) as string;
453
453
  const lines = result.split("\n");
454
454
  const keyLines = lines.filter((l: string) => /^\w+:/.test(l));
455
455
  const keys = keyLines.map((l: string) => l.split(":")[0]);
package/src/spec/parse.ts CHANGED
@@ -78,6 +78,33 @@ export interface ParsedOperation {
78
78
  verbs: string[];
79
79
  }
80
80
 
81
+ /**
82
+ * Compact, recursive field schema for a custom resource's `spec` — chant #1372.
83
+ *
84
+ * Built-in kinds get their field typing from the generated `.d.ts`. A CRD's
85
+ * constructor takes `spec: Record<string, unknown>`, so its field names, enums
86
+ * and scalar types have to travel through the lexicon JSON instead. This is
87
+ * the shape that ships there: no descriptions, no numeric bounds, only what a
88
+ * validator needs to reject a misspelled field or a wrong-typed value.
89
+ */
90
+ export interface CrdFieldSchema {
91
+ /** OpenAPI type; `integer` is kept distinct from `number`. Absent when the schema says nothing. */
92
+ type?: "string" | "integer" | "number" | "boolean" | "object" | "array";
93
+ enum?: string[];
94
+ /** Object members, keyed by field name. */
95
+ fields?: Record<string, CrdFieldSchema>;
96
+ /** Required member names of an object. */
97
+ required?: string[];
98
+ /** Element schema of an array. */
99
+ items?: CrdFieldSchema;
100
+ /**
101
+ * True when the object accepts members its `fields` do not list —
102
+ * `x-kubernetes-preserve-unknown-fields` or `additionalProperties`.
103
+ * `x-kubernetes-int-or-string` also sets it on a scalar, meaning either type passes.
104
+ */
105
+ open?: true;
106
+ }
107
+
81
108
  export interface K8sParseResult {
82
109
  resource: ParsedResource;
83
110
  propertyTypes: ParsedPropertyType[];
@@ -87,6 +114,12 @@ export interface K8sParseResult {
87
114
  isProperty?: boolean;
88
115
  /** How the API addresses this resource. Absent for property types. */
89
116
  operation?: ParsedOperation;
117
+ /**
118
+ * The `spec` field schema of a custom resource (chant #1372). Present only
119
+ * for CRD-derived kinds whose `openAPIV3Schema` declares a `spec`; built-in
120
+ * kinds carry their typing in the `.d.ts` instead.
121
+ */
122
+ specSchema?: CrdFieldSchema;
90
123
  }
91
124
 
92
125
  // ── Swagger types ──────────────────────────────────────────────────
@@ -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
+ }
package/src/validate.ts CHANGED
@@ -42,9 +42,12 @@ export async function validate(opts?: { basePath?: string }): Promise<ValidateRe
42
42
  lexiconJsonFilename: "lexicon-k8s.json",
43
43
  requiredNames: REQUIRED_NAMES,
44
44
  basePath,
45
- // chant #1475 — the k8s baseline was re-baselined together with the k3s
46
- // CRD work (#1605), so a release must now verify the API it ships is the
47
- // reviewed one. Runs only under CHANT_RELEASE_GATE=1.
48
- checkSurfaceSnapshot: true,
45
+ // chant #1475 — the baseline was re-baselined with the k3s CRD work
46
+ // (#1605). k8s generates from a pinned kubernetes release tag plus CRDs
47
+ // vendored in this repo, so a fresh generate is deterministic and the
48
+ // surface can only move through a change here. Gate on every validate,
49
+ // not just under CHANT_RELEASE_GATE, so a CRD batch can no longer leave
50
+ // the snapshot behind the way #1319/#1320/#1321 did.
51
+ checkSurfaceSnapshot: "always",
49
52
  });
50
53
  }