@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.
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.d.ts +15 -1
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/crd/parser.d.ts +28 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +60 -4
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +36 -61
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +6 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8501.d.ts +14 -0
- package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8502.d.ts +13 -0
- package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +126 -63
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WK8501.md +11 -0
- package/dist/okf/rules/WK8502.md +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/crd-schema-helpers.ts +228 -0
- package/dist/rules/wk8501.ts +40 -0
- package/dist/rules/wk8502.ts +39 -0
- package/dist/spec/parse.d.ts +32 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/teardown.d.ts +46 -0
- package/dist/teardown.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/codegen/docs.ts +0 -1044
- package/src/codegen/generate-lexicon.ts +38 -2
- package/src/codegen/snapshot.test.ts +20 -0
- package/src/components/capability-plugin.test.ts +20 -0
- package/src/components/capability-plugin.ts +5 -2
- package/src/crd/cnpg.test.ts +1 -1
- package/src/crd/infisical.test.ts +1 -1
- package/src/crd/parser.test.ts +117 -1
- package/src/crd/parser.ts +71 -5
- package/src/crd/traefik.test.ts +1 -1
- package/src/deep-observe-hooks.ts +78 -16
- package/src/deep-observe.test.ts +145 -29
- package/src/deep-observe.ts +43 -85
- package/src/describe-resources.ts +6 -1
- package/src/generated/lexicon-k8s.json +126 -63
- package/src/lifecycle-integration.test.ts +42 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/post-synth.test.ts +156 -0
- package/src/lint/post-synth/wk8501.ts +40 -0
- package/src/lint/post-synth/wk8502.ts +39 -0
- package/src/list-map-key-table.test.ts +83 -0
- package/src/op/activities/kubectl.ts +2 -2
- package/src/plugin.ts +13 -0
- package/src/serializer.test.ts +4 -4
- package/src/spec/parse.ts +33 -0
- package/src/teardown.test.ts +204 -0
- package/src/teardown.ts +225 -0
- package/src/validate.ts +7 -4
package/src/serializer.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/teardown.ts
ADDED
|
@@ -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
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
}
|