@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.
- package/dist/api/classify.d.ts +10 -0
- package/dist/api/classify.d.ts.map +1 -1
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +15 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +6 -2
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +6 -3
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +91 -9
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/classify.ts +13 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +224 -1
- package/src/describe-resources.ts +86 -1
- package/src/export-resources.ts +6 -22
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/op/activities/index.ts +6 -3
- package/src/op/activities/kubectl.test.ts +298 -26
- package/src/op/activities/kubectl.ts +313 -20
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
package/src/export-resources.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|