@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.
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/teardown.d.ts +46 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/describe-resources.ts +6 -1
- package/src/lifecycle-integration.test.ts +42 -0
- package/src/plugin.ts +13 -0
- package/src/teardown.test.ts +204 -0
- package/src/teardown.ts +225 -0
|
@@ -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,
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
60
|
+
"composite": "4021f2a6439bc3da4d7659b7318d997d0d1c8ff70a813c7240094b4bf16e9605"
|
|
61
61
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
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
|
+
}
|