@intentius/chant-lexicon-k8s 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/api/classify.d.ts +10 -0
  2. package/dist/api/classify.d.ts.map +1 -1
  3. package/dist/api/owner-chain.d.ts +46 -0
  4. package/dist/api/owner-chain.d.ts.map +1 -0
  5. package/dist/api/sweep-types.d.ts +17 -0
  6. package/dist/api/sweep-types.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +86 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +146 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +15 -0
  12. package/dist/describe-resources.d.ts.map +1 -1
  13. package/dist/export-resources.d.ts +6 -2
  14. package/dist/export-resources.d.ts.map +1 -1
  15. package/dist/integrity.json +2 -2
  16. package/dist/kube/apply.d.ts +26 -0
  17. package/dist/kube/apply.d.ts.map +1 -0
  18. package/dist/kube/connect.d.ts +30 -0
  19. package/dist/kube/connect.d.ts.map +1 -0
  20. package/dist/kube/delete.d.ts +27 -0
  21. package/dist/kube/delete.d.ts.map +1 -0
  22. package/dist/kube/describe.d.ts +19 -0
  23. package/dist/kube/describe.d.ts.map +1 -0
  24. package/dist/kube/events.d.ts +34 -0
  25. package/dist/kube/events.d.ts.map +1 -0
  26. package/dist/kube/flags.d.ts +40 -0
  27. package/dist/kube/flags.d.ts.map +1 -0
  28. package/dist/kube/get.d.ts +21 -0
  29. package/dist/kube/get.d.ts.map +1 -0
  30. package/dist/kube/group.d.ts +19 -0
  31. package/dist/kube/group.d.ts.map +1 -0
  32. package/dist/kube/logs.d.ts +16 -0
  33. package/dist/kube/logs.d.ts.map +1 -0
  34. package/dist/kube/project.d.ts +59 -0
  35. package/dist/kube/project.d.ts.map +1 -0
  36. package/dist/kube/render.d.ts +75 -0
  37. package/dist/kube/render.d.ts.map +1 -0
  38. package/dist/kube/source.d.ts +19 -0
  39. package/dist/kube/source.d.ts.map +1 -0
  40. package/dist/kube/target.d.ts +52 -0
  41. package/dist/kube/target.d.ts.map +1 -0
  42. package/dist/kube/testing.d.ts +21 -0
  43. package/dist/kube/testing.d.ts.map +1 -0
  44. package/dist/kube/top.d.ts +23 -0
  45. package/dist/kube/top.d.ts.map +1 -0
  46. package/dist/kube/verdict.d.ts +45 -0
  47. package/dist/kube/verdict.d.ts.map +1 -0
  48. package/dist/kube/wait.d.ts +23 -0
  49. package/dist/kube/wait.d.ts.map +1 -0
  50. package/dist/manifest.json +1 -1
  51. package/dist/op/activities/index.d.ts +6 -3
  52. package/dist/op/activities/index.d.ts.map +1 -1
  53. package/dist/op/activities/kubectl.d.ts +91 -9
  54. package/dist/op/activities/kubectl.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/api/classify.ts +13 -0
  58. package/src/api/owner-chain.test.ts +171 -0
  59. package/src/api/owner-chain.ts +144 -0
  60. package/src/api/sweep-types.ts +36 -0
  61. package/src/deep-observe-hooks.ts +125 -0
  62. package/src/deep-observe.test.ts +668 -0
  63. package/src/deep-observe.ts +299 -0
  64. package/src/describe-resources.test.ts +224 -1
  65. package/src/describe-resources.ts +86 -1
  66. package/src/export-resources.ts +6 -22
  67. package/src/kube/apply.test.ts +105 -0
  68. package/src/kube/apply.ts +101 -0
  69. package/src/kube/connect.ts +54 -0
  70. package/src/kube/delete.test.ts +123 -0
  71. package/src/kube/delete.ts +174 -0
  72. package/src/kube/describe.test.ts +106 -0
  73. package/src/kube/describe.ts +195 -0
  74. package/src/kube/events.test.ts +115 -0
  75. package/src/kube/events.ts +160 -0
  76. package/src/kube/flags.test.ts +65 -0
  77. package/src/kube/flags.ts +114 -0
  78. package/src/kube/get.test.ts +195 -0
  79. package/src/kube/get.ts +190 -0
  80. package/src/kube/group.test.ts +33 -0
  81. package/src/kube/group.ts +125 -0
  82. package/src/kube/logs.test.ts +126 -0
  83. package/src/kube/logs.ts +96 -0
  84. package/src/kube/project.test.ts +57 -0
  85. package/src/kube/project.ts +106 -0
  86. package/src/kube/render.test.ts +142 -0
  87. package/src/kube/render.ts +252 -0
  88. package/src/kube/source.test.ts +138 -0
  89. package/src/kube/source.ts +139 -0
  90. package/src/kube/target.test.ts +67 -0
  91. package/src/kube/target.ts +89 -0
  92. package/src/kube/testing.ts +42 -0
  93. package/src/kube/top.test.ts +149 -0
  94. package/src/kube/top.ts +120 -0
  95. package/src/kube/verdict.test.ts +48 -0
  96. package/src/kube/verdict.ts +77 -0
  97. package/src/kube/wait.test.ts +125 -0
  98. package/src/kube/wait.ts +160 -0
  99. package/src/op/activities/index.ts +6 -3
  100. package/src/op/activities/kubectl.test.ts +298 -26
  101. package/src/op/activities/kubectl.ts +313 -20
  102. package/src/plugin.test.ts +58 -1
  103. package/src/plugin.ts +29 -0
@@ -20,33 +20,17 @@ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexico
20
20
  import { defaultK8sConnector, type K8sConnector } from "./api/connect";
21
21
  import { operationFor } from "./api/operation-surface";
22
22
  import { buildExportFromObjects } from "./import/live-export";
23
+ import { DEFAULT_IMPORT_TYPES } from "./api/sweep-types";
23
24
 
24
25
  /**
25
26
  * Entity types a bare `chant import --from <cluster>` sweeps: the workload,
26
27
  * config, networking and RBAC kinds people actually author. Everything else is
27
- * reachable by naming it with `--selector type=...`.
28
+ * reachable by naming it with `--selector type=...`. Defined in
29
+ * `./api/sweep-types.ts` and re-exported here, its original home — chant
30
+ * #1075's ownership-scoped prune needs the same list without pulling the
31
+ * import parser into a Temporal worker.
28
32
  */
29
- export const DEFAULT_IMPORT_TYPES: readonly string[] = [
30
- "K8s::Apps::Deployment",
31
- "K8s::Apps::StatefulSet",
32
- "K8s::Apps::DaemonSet",
33
- "K8s::Apps::ReplicaSet",
34
- "K8s::Core::Service",
35
- "K8s::Core::ConfigMap",
36
- "K8s::Core::Secret",
37
- "K8s::Core::Namespace",
38
- "K8s::Core::Pod",
39
- "K8s::Core::PersistentVolumeClaim",
40
- "K8s::Core::ServiceAccount",
41
- "K8s::Batch::Job",
42
- "K8s::Batch::CronJob",
43
- "K8s::Networking::Ingress",
44
- "K8s::Networking::NetworkPolicy",
45
- "K8s::Rbac::Role",
46
- "K8s::Rbac::RoleBinding",
47
- "K8s::Rbac::ClusterRole",
48
- "K8s::Rbac::ClusterRoleBinding",
49
- ];
33
+ export { DEFAULT_IMPORT_TYPES };
50
34
 
51
35
  export async function exportResources(
52
36
  options: {
@@ -0,0 +1,105 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
3
+ import { runApply } from "./apply";
4
+ import type { ApplyManifestResult } from "../op/activities/kubectl";
5
+
6
+ afterEach(() => vi.restoreAllMocks());
7
+
8
+ function spyConsole() {
9
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
10
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
11
+ return { log, error };
12
+ }
13
+
14
+ const RESULT: ApplyManifestResult = {
15
+ fieldManager: "chant",
16
+ applied: [{ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" }],
17
+ pruned: [],
18
+ };
19
+
20
+ describe("chant kube apply (#1079)", () => {
21
+ test("without --yes, applyManifest is called with dryRun and nothing is reported as persisted", async () => {
22
+ const apply = vi.fn().mockResolvedValue(RESULT);
23
+ const connect = vi.fn();
24
+ const { log } = spyConsole();
25
+
26
+ const code = await runApply(["-f", "manifest.yaml"], { apply, connect });
27
+
28
+ expect(code).toBe(0);
29
+ expect(apply).toHaveBeenCalledWith(expect.objectContaining({ manifest: "manifest.yaml", dryRun: true }), undefined, connect);
30
+ expect(log.mock.calls[0][0]).toContain("DRY RUN");
31
+ expect(log.mock.calls[0][0]).toContain("--yes");
32
+ });
33
+
34
+ test("--yes performs the real apply (dryRun: false) and reports what happened", async () => {
35
+ const apply = vi.fn().mockResolvedValue(RESULT);
36
+ const connect = vi.fn();
37
+ const { log } = spyConsole();
38
+
39
+ const code = await runApply(["-f", "manifest.yaml", "--yes"], { apply, connect });
40
+
41
+ expect(code).toBe(0);
42
+ expect(apply).toHaveBeenCalledWith(expect.objectContaining({ dryRun: false }), undefined, connect);
43
+ expect(log.mock.calls[0][0]).toContain("applied 1 object(s)");
44
+ });
45
+
46
+ test("an explicit manifest is mandatory — no bare sweeps", async () => {
47
+ const apply = vi.fn();
48
+ const { error } = spyConsole();
49
+
50
+ const code = await runApply([], { apply });
51
+
52
+ expect(code).toBe(1);
53
+ expect(error.mock.calls[0][0]).toContain("--filename/-f is required");
54
+ expect(apply).not.toHaveBeenCalled();
55
+ });
56
+
57
+ test("binding mismatch refuses loudly, rendered as NOT-OBSERVED", async () => {
58
+ const apply = vi.fn().mockRejectedValue(new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks"));
59
+ const { error } = spyConsole();
60
+
61
+ const code = await runApply(["-f", "manifest.yaml", "--env", "prod", "--yes"], { apply });
62
+
63
+ expect(code).toBe(1);
64
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
65
+ });
66
+
67
+ test("unknown flag is rejected before apply runs", async () => {
68
+ const apply = vi.fn();
69
+ const { error } = spyConsole();
70
+
71
+ const code = await runApply(["-f", "manifest.yaml", "--bogus"], { apply });
72
+
73
+ expect(code).toBe(1);
74
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
75
+ expect(apply).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test("a field-manager conflict renders the same conflict surface the Op activity produces, not a raw 409", async () => {
79
+ class FieldManagerConflictError extends Error {
80
+ constructor(message: string) {
81
+ super(message);
82
+ this.name = "FieldManagerConflictError";
83
+ }
84
+ }
85
+ const apply = vi.fn().mockRejectedValue(new FieldManagerConflictError('k8s: server-side apply of apps/v1 Deployment prod/web was refused'));
86
+ const { error } = spyConsole();
87
+
88
+ const code = await runApply(["-f", "manifest.yaml", "--yes"], { apply });
89
+
90
+ expect(code).toBe(1);
91
+ expect(error.mock.calls[0][0]).toContain("was refused");
92
+ });
93
+
94
+ test("tri-state: a transport failure renders NOT-OBSERVED, not a silent no-op", async () => {
95
+ const err = new Error("connect ECONNREFUSED");
96
+ (err as { name?: string }).name = "K8sTransportError";
97
+ const apply = vi.fn().mockRejectedValue(err);
98
+ const { error } = spyConsole();
99
+
100
+ const code = await runApply(["-f", "manifest.yaml", "--yes"], { apply });
101
+
102
+ expect(code).toBe(1);
103
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
104
+ });
105
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `chant kube apply` (chant #1079) — "writes are not cloned": this calls
3
+ * `applyManifest` (`../op/activities/kubectl.ts`) directly, the exact
4
+ * function the k8s Op activity registers under `kubectlApply` — server-side
5
+ * apply, chant's field-manager identity, the same `FieldManagerConflictError`
6
+ * surface (chant #1075/#1178) — never a second implementation of `kubectl
7
+ * apply`. An explicit manifest path is mandatory; there is no bare-sweep form.
8
+ *
9
+ * The one thing this verb adds on top of the activity is a confirmation
10
+ * gate, matching the issue's "route through plan, gate, and ownership": a
11
+ * bare `chant kube apply -f x.yaml` performs a server-side dry run — every
12
+ * document validated and previewed, nothing persisted — and says so; only
13
+ * `--yes` performs the real, persisting apply. That is a deliberate
14
+ * divergence from `kubectl apply`, which persists by default; a terminal
15
+ * command that can rewrite a live Deployment is worth one extra keystroke of
16
+ * intent.
17
+ */
18
+
19
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
20
+ import { formatUnobserved } from "@intentius/chant/observation";
21
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
22
+ import { classifyApiFailure, isMissingClientPackage, MISSING_CLIENT_DETAIL } from "../api/classify";
23
+ import { applyManifest, type KubectlApplyArgs, type ApplyManifestResult } from "../op/activities/kubectl";
24
+ import { parseKubeFlags } from "./flags";
25
+
26
+ export type ApplyFn = (args: KubectlApplyArgs, signal?: AbortSignal, connect?: K8sConnector) => Promise<ApplyManifestResult>;
27
+
28
+ export interface ApplyDeps {
29
+ connect?: K8sConnector;
30
+ apply?: ApplyFn;
31
+ }
32
+
33
+ export async function runApply(rawArgs: string[], deps: ApplyDeps = {}): Promise<number> {
34
+ const connect = deps.connect ?? defaultK8sConnector;
35
+ const apply = deps.apply ?? applyManifest;
36
+
37
+ let flags;
38
+ try {
39
+ flags = parseKubeFlags(rawArgs, {
40
+ value: { "-f": "filename", "--filename": "filename", "--field-manager": "fieldManager" },
41
+ boolean: { "--force-conflicts": "forceConflicts", "--yes": "yes", "-y": "yes" },
42
+ });
43
+ } catch (err) {
44
+ console.error(err instanceof Error ? err.message : String(err));
45
+ return 1;
46
+ }
47
+
48
+ const manifest = flags.values.filename;
49
+ if (!manifest) {
50
+ console.error(
51
+ "error: --filename/-f is required — chant kube apply never applies without an explicit manifest path (no bare sweeps)",
52
+ );
53
+ return 1;
54
+ }
55
+
56
+ const dryRun = !(flags.flags.yes ?? false);
57
+ const args: KubectlApplyArgs = {
58
+ manifest,
59
+ ...(flags.values.env !== undefined ? { environment: flags.values.env } : {}),
60
+ ...(flags.values.context !== undefined ? { context: flags.values.context } : {}),
61
+ ...(flags.values.fieldManager !== undefined ? { fieldManager: flags.values.fieldManager } : {}),
62
+ force: flags.flags.forceConflicts ?? false,
63
+ dryRun,
64
+ };
65
+
66
+ let result: ApplyManifestResult;
67
+ try {
68
+ result = await apply(args, undefined, connect);
69
+ } catch (err) {
70
+ if (err instanceof Error && err.name === "FieldManagerConflictError") {
71
+ console.error(err.message);
72
+ return 1;
73
+ }
74
+ if (err instanceof ClusterBindingMismatchError) {
75
+ console.error(formatUnobserved(manifest, { reason: "no-binding", detail: err.message }));
76
+ return 1;
77
+ }
78
+ if (isMissingClientPackage(err)) {
79
+ console.error(formatUnobserved(manifest, { reason: "read-failed", detail: MISSING_CLIENT_DETAIL }));
80
+ return 1;
81
+ }
82
+ const outcome = classifyApiFailure(err);
83
+ console.error(
84
+ formatUnobserved(manifest, {
85
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
86
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
87
+ }),
88
+ );
89
+ return 1;
90
+ }
91
+
92
+ // applyManifest already logged one line per applied object (including,
93
+ // since chant #1079, whether it was a dry run) — this is the summary.
94
+ if (dryRun) {
95
+ console.log(`DRY RUN — ${result.applied.length} object(s) server-validated, nothing persisted. Pass --yes to apply for real.`);
96
+ } else {
97
+ console.log(`applied ${result.applied.length} object(s) as field manager "${result.fieldManager}"`);
98
+ if (result.pruned.length > 0) console.log(`pruned ${result.pruned.length} chant-owned object(s) no longer declared`);
99
+ }
100
+ return 0;
101
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Connecting to a cluster on `chant kube`'s behalf (chant #1079), honoring
3
+ * the exact same environment→cluster binding every other observing path
4
+ * does (chant #1100/#1155): `--env` resolves `k8s.profiles.<env>.context`
5
+ * and refuses loudly on a mismatch, `--context` is an explicit override that
6
+ * skips the binding entirely, and neither given falls back to the ambient
7
+ * kubeconfig context — the same three modes `describeResources` and the Op
8
+ * activities already use, via the same `defaultK8sConnector`. Nothing here
9
+ * reimplements that resolution; this only turns a connect failure into the
10
+ * observation tri-state so a verb can render NOT-OBSERVED-with-reason
11
+ * instead of a stack trace.
12
+ */
13
+
14
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
15
+ import type { UnobservedReason } from "@intentius/chant/observation";
16
+ import { defaultK8sConnector, type ConnectOptions, type ConnectedClient, type K8sConnector } from "../api/connect";
17
+ import { classifyApiFailure, isMissingClientPackage, isWholeLexiconFailure, MISSING_CLIENT_DETAIL } from "../api/classify";
18
+
19
+ export type KubeConnectResult =
20
+ | ({ kind: "connected" } & ConnectedClient)
21
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
22
+
23
+ /**
24
+ * Connect, or classify the failure into the tri-state. A bound-but-mismatched
25
+ * `--env` (`ClusterBindingMismatchError`), a missing `@intentius/chant-k8s-client`
26
+ * install, and a refused exec-credential plugin are the "nothing left the
27
+ * process" failures every verb must treat identically: NOT-OBSERVED, never
28
+ * an empty/absent result.
29
+ */
30
+ export async function kubeConnect(
31
+ options: ConnectOptions,
32
+ connect: K8sConnector = defaultK8sConnector,
33
+ ): Promise<KubeConnectResult> {
34
+ try {
35
+ const connected = await connect(options);
36
+ return { kind: "connected", ...connected };
37
+ } catch (err) {
38
+ if (isMissingClientPackage(err)) {
39
+ return { kind: "unobserved", reason: "read-failed", detail: MISSING_CLIENT_DETAIL };
40
+ }
41
+ if (err instanceof ClusterBindingMismatchError) {
42
+ return { kind: "unobserved", reason: "no-binding", detail: err.message };
43
+ }
44
+ if (isWholeLexiconFailure(err)) {
45
+ const outcome = classifyApiFailure(err);
46
+ return {
47
+ kind: "unobserved",
48
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
49
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
50
+ };
51
+ }
52
+ throw err;
53
+ }
54
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
3
+ import { fakeCluster, objectKey, ownedObject } from "../api/fake-cluster";
4
+ import { runDelete } from "./delete";
5
+
6
+ afterEach(() => vi.restoreAllMocks());
7
+
8
+ function spyConsole() {
9
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
10
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
11
+ return { log, error };
12
+ }
13
+
14
+ describe("chant kube delete (#1079)", () => {
15
+ test("without --yes, previews the delete and issues no DELETE request", async () => {
16
+ const cluster = fakeCluster({
17
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod") },
18
+ });
19
+ const { log } = spyConsole();
20
+
21
+ const code = await runDelete(["deployment", "web", "-n", "prod"], { connect: cluster.connector });
22
+
23
+ expect(code).toBe(0);
24
+ expect(log.mock.calls[0][0]).toContain("Would delete");
25
+ expect(log.mock.calls[0][0]).toContain("--yes");
26
+ expect(cluster.layer.requests.some((r) => r.method === "DELETE")).toBe(false);
27
+ });
28
+
29
+ test("--yes deletes through the typed client's own delete()", async () => {
30
+ const cluster = fakeCluster({
31
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod") },
32
+ });
33
+ const { log } = spyConsole();
34
+
35
+ const code = await runDelete(["deployment", "web", "-n", "prod", "--yes"], { connect: cluster.connector });
36
+
37
+ expect(code).toBe(0);
38
+ expect(log.mock.calls.at(-1)?.[0]).toContain("deleted");
39
+ const deleted = cluster.layer.requests.find((r) => r.method === "DELETE");
40
+ expect(deleted?.path).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
41
+ });
42
+
43
+ test("a non-chant-owned object gets a visible warning but is still deletable (explicit name = informed decision)", async () => {
44
+ const cluster = fakeCluster({
45
+ objects: {
46
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: {
47
+ apiVersion: "apps/v1",
48
+ kind: "Deployment",
49
+ metadata: { name: "web", namespace: "prod", uid: "u1" },
50
+ },
51
+ },
52
+ });
53
+ const { log } = spyConsole();
54
+
55
+ const code = await runDelete(["deployment", "web", "-n", "prod", "--yes"], { connect: cluster.connector });
56
+
57
+ expect(code).toBe(0);
58
+ const out = log.mock.calls.map((c) => c[0]).join("\n");
59
+ expect(out).toContain("no chant ownership marker");
60
+ expect(out).toContain("deleted");
61
+ });
62
+
63
+ test("explicit-target requirement: a bare kind with no name is refused — no sweeps", async () => {
64
+ const connect = vi.fn();
65
+ const { error } = spyConsole();
66
+
67
+ const code = await runDelete(["deployment"], { connect });
68
+
69
+ expect(code).toBe(1);
70
+ expect(error.mock.calls[0][0]).toContain("no bare kind sweeps");
71
+ expect(connect).not.toHaveBeenCalled();
72
+ });
73
+
74
+ test("a resource that is already gone reports NotFound rather than a silent success", async () => {
75
+ const cluster = fakeCluster({});
76
+ const { error } = spyConsole();
77
+
78
+ const code = await runDelete(["deployment", "ghost", "-n", "prod", "--yes"], { connect: cluster.connector });
79
+
80
+ expect(code).toBe(1);
81
+ expect(error.mock.calls[0][0]).toContain("NotFound");
82
+ });
83
+
84
+ test("binding mismatch refuses loudly, before any read or delete", async () => {
85
+ const { error } = spyConsole();
86
+ const connect = async () => {
87
+ throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
88
+ };
89
+
90
+ const code = await runDelete(["deployment", "web", "--env", "prod", "--yes"], { connect });
91
+
92
+ expect(code).toBe(1);
93
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
94
+ });
95
+
96
+ test("unknown flag is rejected before any connection", async () => {
97
+ const { error } = spyConsole();
98
+ const connect = vi.fn();
99
+
100
+ const code = await runDelete(["deployment", "web", "--bogus"], { connect });
101
+
102
+ expect(code).toBe(1);
103
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
104
+ expect(connect).not.toHaveBeenCalled();
105
+ });
106
+
107
+ test("tri-state: an RBAC-denied read-before-delete renders NOT-OBSERVED, never a silent delete", async () => {
108
+ const cluster = fakeCluster({
109
+ respond: (req) =>
110
+ req.path === "/apis/apps/v1/namespaces/prod/deployments/web"
111
+ ? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "forbidden" } }
112
+ : undefined,
113
+ });
114
+ const { error, log } = spyConsole();
115
+
116
+ const code = await runDelete(["deployment", "web", "-n", "prod", "--yes"], { connect: cluster.connector });
117
+
118
+ expect(code).toBe(1);
119
+ expect(error.mock.calls[0][0]).toContain("no credentials");
120
+ expect(log).not.toHaveBeenCalled();
121
+ expect(cluster.layer.requests.some((r) => r.method === "DELETE")).toBe(false);
122
+ });
123
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `chant kube delete` (chant #1079) — routes through the typed client's own
3
+ * `delete()` (`packages/k8s-client/src/client.ts`), the identical typed
4
+ * delete `applyManifest`'s prune step already uses (chant #1075) — never a
5
+ * second implementation. A kind and a name are both mandatory: there is no
6
+ * bare `chant kube delete deployments` form, so nothing here can become a
7
+ * sweep by omission (the issue's "nothing destructive happens without an
8
+ * explicit resource argument").
9
+ *
10
+ * Two guardrails on top of the typed delete itself:
11
+ *
12
+ * - **Gate.** A bare invocation reads the object, reports what it found (and
13
+ * whether chant owns it), and stops — only `--yes` performs the delete.
14
+ * - **Ownership, surfaced not enforced.** kubectl itself deletes anything
15
+ * RBAC allows, chant-applied or not, and this keeps that: an explicit name
16
+ * is already an informed decision. What's added is visibility — a
17
+ * warning when the object carries no chant ownership marker, so the
18
+ * operator knows they are about to delete something chant did not apply.
19
+ */
20
+
21
+ import type { K8sClient } from "@intentius/chant-k8s-client";
22
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
23
+ import { formatUnobserved, type UnobservedReason } from "@intentius/chant/observation";
24
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
25
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
26
+ import { classifyApiFailure } from "../api/classify";
27
+ import { kubeConnect } from "./connect";
28
+ import { parseKubeFlags, connectOptionsFrom } from "./flags";
29
+ import { loadKubeProjectContext, type KubeProjectContext } from "./project";
30
+ import { resolveKubeTarget, isTargetError, type KubeTarget } from "./target";
31
+
32
+ export interface DeleteDeps {
33
+ connect?: K8sConnector;
34
+ loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
35
+ }
36
+
37
+ function targetLabel(target: KubeTarget): string {
38
+ return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
39
+ }
40
+
41
+ const PROPAGATION_POLICIES = new Set(["Foreground", "Background", "Orphan"]);
42
+
43
+ export async function runDelete(rawArgs: string[], deps: DeleteDeps = {}): Promise<number> {
44
+ const connect = deps.connect ?? defaultK8sConnector;
45
+ const loadProject = deps.loadProject ?? loadKubeProjectContext;
46
+
47
+ let flags;
48
+ try {
49
+ flags = parseKubeFlags(rawArgs, {
50
+ value: { "--propagation-policy": "propagationPolicy" },
51
+ boolean: { "--yes": "yes", "-y": "yes" },
52
+ });
53
+ } catch (err) {
54
+ console.error(err instanceof Error ? err.message : String(err));
55
+ return 1;
56
+ }
57
+
58
+ if (flags.values.propagationPolicy !== undefined && !PROPAGATION_POLICIES.has(flags.values.propagationPolicy)) {
59
+ console.error(`error: --propagation-policy must be one of Foreground, Background, Orphan`);
60
+ return 1;
61
+ }
62
+
63
+ const project = await loadProject();
64
+ const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
65
+ if (isTargetError(target)) {
66
+ console.error(`error: ${target.error}`);
67
+ return 1;
68
+ }
69
+ if (!target.name) {
70
+ console.error(
71
+ "error: chant kube delete requires an explicit resource name — no bare kind sweeps " +
72
+ "(e.g. `chant kube delete deployment web`, not `chant kube delete deployment`)",
73
+ );
74
+ return 1;
75
+ }
76
+
77
+ const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
78
+ if (connected.kind === "unobserved") {
79
+ console.error(formatUnobserved(targetLabel(target), { reason: connected.reason, detail: connected.detail }));
80
+ return 1;
81
+ }
82
+ const { client } = connected;
83
+
84
+ let info;
85
+ try {
86
+ info = await client.resolve(target.selector);
87
+ } catch (err) {
88
+ const outcome = classifyApiFailure(err);
89
+ console.error(
90
+ formatUnobserved(targetLabel(target), {
91
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
92
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
93
+ }),
94
+ );
95
+ return 1;
96
+ }
97
+ if (!info) {
98
+ console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
99
+ return 1;
100
+ }
101
+
102
+ const namespace = target.namespace ?? client.defaultNamespace;
103
+ const ref = { apiVersion: info.apiVersion, kind: info.kind, name: target.name, namespace };
104
+
105
+ const object = await readForDelete(client, ref);
106
+ if (object.kind === "unobserved") {
107
+ console.error(formatUnobserved(`${info.kind}/${target.name}`, { reason: object.reason, detail: object.detail }));
108
+ return 1;
109
+ }
110
+ if (!object.found) {
111
+ console.error(`Error from server (NotFound): ${info.name} "${target.name}" not found`);
112
+ return 1;
113
+ }
114
+
115
+ const owned = hasOwnershipMarker(object.labels, LABEL_OWNERSHIP_KEYS);
116
+ const label = `${info.kind}/${target.name}${namespace ? ` -n ${namespace}` : ""}`;
117
+
118
+ if (!(flags.flags.yes ?? false)) {
119
+ console.log(`Would delete ${label}${owned ? "" : " (warning: not chant-owned)"}. Pass --yes to delete for real.`);
120
+ return 0;
121
+ }
122
+
123
+ if (!owned) {
124
+ console.log(`warning: ${label} carries no chant ownership marker — deleting an object chant did not apply`);
125
+ }
126
+
127
+ try {
128
+ await client.delete(
129
+ { apiVersion: info.apiVersion, kind: info.kind, name: target.name, ...(namespace ? { namespace } : {}) },
130
+ { ...(flags.values.propagationPolicy ? { propagationPolicy: flags.values.propagationPolicy as "Foreground" | "Background" | "Orphan" } : {}) },
131
+ );
132
+ } catch (err) {
133
+ const outcome = classifyApiFailure(err);
134
+ if (outcome.kind === "absent") {
135
+ console.error(`Error from server (NotFound): ${info.name} "${target.name}" not found`);
136
+ return 1;
137
+ }
138
+ console.error(
139
+ formatUnobserved(label, {
140
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
141
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
142
+ }),
143
+ );
144
+ return 1;
145
+ }
146
+
147
+ console.log(`${label} deleted`);
148
+ return 0;
149
+ }
150
+
151
+ type ReadForDeleteResult =
152
+ | { kind: "found"; found: true; labels: Record<string, string> | undefined }
153
+ | { kind: "found"; found: false; labels: undefined }
154
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
155
+
156
+ async function readForDelete(
157
+ client: K8sClient,
158
+ ref: { apiVersion: string; kind: string; name: string; namespace?: string },
159
+ ): Promise<ReadForDeleteResult> {
160
+ try {
161
+ const obj = await client.readIfPresent(ref);
162
+ return obj ? { kind: "found", found: true, labels: obj.metadata?.labels } : { kind: "found", found: false, labels: undefined };
163
+ } catch (err) {
164
+ if (err instanceof ClusterBindingMismatchError) {
165
+ return { kind: "unobserved", reason: "no-binding", detail: err.message };
166
+ }
167
+ const outcome = classifyApiFailure(err);
168
+ return {
169
+ kind: "unobserved",
170
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
171
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
172
+ };
173
+ }
174
+ }