@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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag parsing shared by every `chant kube <verb>` (chant #1079).
|
|
3
|
+
*
|
|
4
|
+
* `CommandGroupContext.rawArgs` is unparsed argv — core has no vocabulary for
|
|
5
|
+
* a lexicon's own verbs (chant #1078) — so every verb here does its own
|
|
6
|
+
* parsing, reusing `splitJoinedFlags`/`unknownFlagError` for the same
|
|
7
|
+
* `--flag=value` splitting and rejection discipline core's own parser
|
|
8
|
+
* applies (chant #1127). This module adds the flag vocabulary every verb
|
|
9
|
+
* shares (namespace, selector, output, cluster targeting) so each verb file
|
|
10
|
+
* only has to declare what is actually its own (`--tail`, `--for`, ...).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
|
|
14
|
+
import type { ConnectOptions } from "../api/connect";
|
|
15
|
+
|
|
16
|
+
/** A verb's own flag vocabulary, merged with the common set below. */
|
|
17
|
+
export interface FlagSpec {
|
|
18
|
+
/** Value flags this verb accepts, alias → canonical key (e.g. `{ "--tail": "tail" }`). */
|
|
19
|
+
value?: Record<string, string>;
|
|
20
|
+
/** Boolean flags this verb accepts, alias → canonical key. */
|
|
21
|
+
boolean?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every `chant kube` verb accepts these — kubectl's own names. */
|
|
25
|
+
const COMMON_VALUE: Record<string, string> = {
|
|
26
|
+
"-n": "namespace",
|
|
27
|
+
"--namespace": "namespace",
|
|
28
|
+
"-l": "selector",
|
|
29
|
+
"--selector": "selector",
|
|
30
|
+
"-o": "output",
|
|
31
|
+
"--output": "output",
|
|
32
|
+
"--context": "context",
|
|
33
|
+
"--env": "env",
|
|
34
|
+
"--kubeconfig": "kubeconfig",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const COMMON_BOOLEAN: Record<string, string> = {
|
|
38
|
+
"-A": "allNamespaces",
|
|
39
|
+
"--all-namespaces": "allNamespaces",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface ParsedFlags {
|
|
43
|
+
/** Non-flag tokens, in order — the verb's own positional arguments (kind, name, ...). */
|
|
44
|
+
positional: string[];
|
|
45
|
+
/** Resolved value-flag key → the value given. */
|
|
46
|
+
values: Record<string, string>;
|
|
47
|
+
/** Resolved boolean-flag key → true (absent keys are false). */
|
|
48
|
+
flags: Record<string, boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse `rawArgs` against the common flag set plus `spec`. Throws
|
|
53
|
+
* {@link unknownFlagError}'s shape on anything starting with `-` that
|
|
54
|
+
* neither set recognizes, and on a value flag given with nothing after it.
|
|
55
|
+
*/
|
|
56
|
+
export function parseKubeFlags(rawArgs: string[], spec: FlagSpec = {}): ParsedFlags {
|
|
57
|
+
const valueFlags: Record<string, string> = { ...COMMON_VALUE, ...(spec.value ?? {}) };
|
|
58
|
+
const booleanFlags: Record<string, string> = { ...COMMON_BOOLEAN, ...(spec.boolean ?? {}) };
|
|
59
|
+
const args = splitJoinedFlags(rawArgs, new Set(Object.keys(booleanFlags)));
|
|
60
|
+
|
|
61
|
+
const positional: string[] = [];
|
|
62
|
+
const values: Record<string, string> = {};
|
|
63
|
+
const flags: Record<string, boolean> = {};
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
const arg = args[i];
|
|
67
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
68
|
+
positional.push(arg);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg in booleanFlags) {
|
|
72
|
+
flags[booleanFlags[arg]] = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg in valueFlags) {
|
|
76
|
+
const value = args[++i];
|
|
77
|
+
if (value === undefined) throw new Error(`${arg} requires a value`);
|
|
78
|
+
values[valueFlags[arg]] = value;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
throw unknownFlagError(arg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { positional, values, flags };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the typed client's `ConnectOptions` from a parsed common flag set. */
|
|
88
|
+
export function connectOptionsFrom(values: Record<string, string>): ConnectOptions {
|
|
89
|
+
const options: ConnectOptions = {};
|
|
90
|
+
if (values.env !== undefined) options.environment = values.env;
|
|
91
|
+
if (values.context !== undefined) options.context = values.context;
|
|
92
|
+
if (values.kubeconfig !== undefined) options.client = { kubeconfigPath: values.kubeconfig };
|
|
93
|
+
return options;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** kubectl duration shorthand: "3600" (bare seconds), "1h", "90m", "45s". Shared by `logs --since` and `wait --timeout`. */
|
|
97
|
+
export function parseDurationSeconds(value: string): number {
|
|
98
|
+
const match = /^(\d+)(s|m|h)?$/.exec(value.trim());
|
|
99
|
+
if (!match) throw new Error(`expected a duration like "1h", "90m", "45s", or a bare second count, got "${value}"`);
|
|
100
|
+
const n = Number(match[1]);
|
|
101
|
+
switch (match[2]) {
|
|
102
|
+
case "h":
|
|
103
|
+
return n * 3600;
|
|
104
|
+
case "m":
|
|
105
|
+
return n * 60;
|
|
106
|
+
default:
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** {@link parseDurationSeconds} in milliseconds. */
|
|
112
|
+
export function parseDurationMs(value: string): number {
|
|
113
|
+
return parseDurationSeconds(value) * 1000;
|
|
114
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
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 { runGet } from "./get";
|
|
5
|
+
import { fakeDeclarable, fakeProjectContext } from "./testing";
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
function spyConsole() {
|
|
10
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
11
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
12
|
+
return { log, error };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("chant kube get (#1079)", () => {
|
|
16
|
+
test("happy path: lists live objects in a table, chant column reads unavailable with no project", async () => {
|
|
17
|
+
const cluster = fakeCluster({
|
|
18
|
+
objects: {
|
|
19
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod", {
|
|
20
|
+
status: { readyReplicas: 2, replicas: 2 },
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const { log } = spyConsole();
|
|
25
|
+
|
|
26
|
+
const code = await runGet(["deployments", "-n", "prod"], { connect: cluster.connector });
|
|
27
|
+
|
|
28
|
+
expect(code).toBe(0);
|
|
29
|
+
const out = log.mock.calls.map((c) => c[0]).join("\n");
|
|
30
|
+
expect(out).toContain("NAME");
|
|
31
|
+
expect(out).toContain("CHANT");
|
|
32
|
+
expect(out).toContain("web");
|
|
33
|
+
expect(out).toContain("unavailable");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("with a project: a matching declared entity renders as declared, an unmatched foreign object as foreign-owned", async () => {
|
|
37
|
+
const cluster = fakeCluster({
|
|
38
|
+
objects: {
|
|
39
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: {
|
|
40
|
+
apiVersion: "apps/v1",
|
|
41
|
+
kind: "Deployment",
|
|
42
|
+
metadata: { name: "web", namespace: "prod", uid: "u1", labels: { app: "web" } },
|
|
43
|
+
spec: { replicas: 2 },
|
|
44
|
+
status: { readyReplicas: 2, replicas: 2 },
|
|
45
|
+
},
|
|
46
|
+
[objectKey("apps/v1", "Deployment", "unrelated", "prod")]: {
|
|
47
|
+
apiVersion: "apps/v1",
|
|
48
|
+
kind: "Deployment",
|
|
49
|
+
metadata: { name: "unrelated", namespace: "prod", uid: "u2" },
|
|
50
|
+
status: { readyReplicas: 1, replicas: 1 },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const project = fakeProjectContext({
|
|
55
|
+
web: fakeDeclarable("K8s::Apps::Deployment", {
|
|
56
|
+
metadata: { name: "web", namespace: "prod" },
|
|
57
|
+
spec: { replicas: 2 },
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
const { log } = spyConsole();
|
|
61
|
+
|
|
62
|
+
const code = await runGet(["deployments", "-n", "prod"], {
|
|
63
|
+
connect: cluster.connector,
|
|
64
|
+
loadProject: async () => project,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
const out = log.mock.calls.map((c) => c[0]).join("\n");
|
|
69
|
+
expect(out).toMatch(/web\s+.*declared/);
|
|
70
|
+
expect(out).toMatch(/unrelated\s+.*foreign-owned/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a declared match whose live spec disagrees renders as drifted", async () => {
|
|
74
|
+
const cluster = fakeCluster({
|
|
75
|
+
objects: {
|
|
76
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: {
|
|
77
|
+
apiVersion: "apps/v1",
|
|
78
|
+
kind: "Deployment",
|
|
79
|
+
metadata: { name: "web", namespace: "prod", uid: "u1" },
|
|
80
|
+
spec: { replicas: 9 },
|
|
81
|
+
status: { readyReplicas: 9, replicas: 9 },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const project = fakeProjectContext({
|
|
86
|
+
web: fakeDeclarable("K8s::Apps::Deployment", {
|
|
87
|
+
metadata: { name: "web", namespace: "prod" },
|
|
88
|
+
spec: { replicas: 2 },
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
const { log } = spyConsole();
|
|
92
|
+
|
|
93
|
+
await runGet(["deployments", "-n", "prod"], { connect: cluster.connector, loadProject: async () => project });
|
|
94
|
+
|
|
95
|
+
const out = log.mock.calls.map((c) => c[0]).join("\n");
|
|
96
|
+
expect(out).toMatch(/web\s+.*drifted/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("binding mismatch: refuses loudly, renders NOT-OBSERVED, no resources printed", async () => {
|
|
100
|
+
const { error, log } = spyConsole();
|
|
101
|
+
const connect = async () => {
|
|
102
|
+
throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const code = await runGet(["deployments", "--env", "prod"], { connect });
|
|
106
|
+
|
|
107
|
+
expect(code).toBe(1);
|
|
108
|
+
expect(error).toHaveBeenCalled();
|
|
109
|
+
const message = error.mock.calls.map((c) => c[0]).join("\n");
|
|
110
|
+
expect(message).toContain("no binding for this environment");
|
|
111
|
+
expect(message).toContain("prod-eks");
|
|
112
|
+
expect(log).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("unknown flag is rejected before any connection is attempted", async () => {
|
|
116
|
+
const { error } = spyConsole();
|
|
117
|
+
const connect = vi.fn();
|
|
118
|
+
|
|
119
|
+
const code = await runGet(["deployments", "--bogus"], { connect });
|
|
120
|
+
|
|
121
|
+
expect(code).toBe(1);
|
|
122
|
+
expect(error.mock.calls[0][0]).toContain("Unknown flag");
|
|
123
|
+
expect(connect).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("tri-state: an RBAC-denied list renders NOT-OBSERVED, not an empty table", async () => {
|
|
127
|
+
const cluster = fakeCluster({
|
|
128
|
+
respond: (req) =>
|
|
129
|
+
req.path === "/apis/apps/v1/namespaces/prod/deployments"
|
|
130
|
+
? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "deployments is forbidden", code: 403 } }
|
|
131
|
+
: undefined,
|
|
132
|
+
});
|
|
133
|
+
const { error, log } = spyConsole();
|
|
134
|
+
|
|
135
|
+
const code = await runGet(["deployments", "-n", "prod"], { connect: cluster.connector });
|
|
136
|
+
|
|
137
|
+
expect(code).toBe(1);
|
|
138
|
+
const message = error.mock.calls.map((c) => c[0]).join("\n");
|
|
139
|
+
expect(message).toContain("no credentials");
|
|
140
|
+
expect(log).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("tri-state: a kind the cluster's discovery has never heard of is an explicit error, not \"No resources found.\"", async () => {
|
|
144
|
+
const cluster = fakeCluster({ serves: [] });
|
|
145
|
+
const { error, log } = spyConsole();
|
|
146
|
+
|
|
147
|
+
const code = await runGet(["widgets"], { connect: cluster.connector });
|
|
148
|
+
|
|
149
|
+
expect(code).toBe(1);
|
|
150
|
+
expect(error.mock.calls[0][0]).toContain(`doesn't have a resource type "widgets"`);
|
|
151
|
+
expect(log).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a single get with a name that does not exist reports NotFound, kubectl-style", async () => {
|
|
155
|
+
const cluster = fakeCluster({});
|
|
156
|
+
const { error } = spyConsole();
|
|
157
|
+
|
|
158
|
+
const code = await runGet(["deployment", "ghost", "-n", "prod"], { connect: cluster.connector });
|
|
159
|
+
|
|
160
|
+
expect(code).toBe(1);
|
|
161
|
+
expect(error.mock.calls[0][0]).toContain("NotFound");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("-o json prints the raw object, not the table", async () => {
|
|
165
|
+
const cluster = fakeCluster({
|
|
166
|
+
objects: {
|
|
167
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
const { log } = spyConsole();
|
|
171
|
+
|
|
172
|
+
await runGet(["deployment", "web", "-n", "prod", "-o", "json"], { connect: cluster.connector });
|
|
173
|
+
|
|
174
|
+
const parsed = JSON.parse(log.mock.calls[0][0] as string);
|
|
175
|
+
expect(parsed.metadata.name).toBe("web");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("resolves a declared entity name directly, chant vocabulary first", async () => {
|
|
179
|
+
const cluster = fakeCluster({
|
|
180
|
+
objects: {
|
|
181
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const project = fakeProjectContext({
|
|
185
|
+
myWeb: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }),
|
|
186
|
+
});
|
|
187
|
+
const { log } = spyConsole();
|
|
188
|
+
|
|
189
|
+
const code = await runGet(["myWeb"], { connect: cluster.connector, loadProject: async () => project });
|
|
190
|
+
|
|
191
|
+
expect(code).toBe(0);
|
|
192
|
+
const out = log.mock.calls.map((c) => c[0]).join("\n");
|
|
193
|
+
expect(out).toContain("web");
|
|
194
|
+
});
|
|
195
|
+
});
|
package/src/kube/get.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant kube get` (chant #1079) — kubectl's own muscle-memory verb, plus the
|
|
3
|
+
* chant verdict column the issue's acceptance criterion names.
|
|
4
|
+
*
|
|
5
|
+
* The cluster edge is the typed client (chant #1074/#1177), reached the same
|
|
6
|
+
* way every other read does: `--env` resolves and honors the cluster
|
|
7
|
+
* binding (chant #1100/#1155, refusing loudly on a mismatch), `--context` is
|
|
8
|
+
* an explicit override, neither given falls back to the ambient kubeconfig
|
|
9
|
+
* context. A connect failure, an RBAC-denied read, or a kind the cluster's
|
|
10
|
+
* discovery has never heard of all render as an honest message rather than
|
|
11
|
+
* an empty table — "No resources found." is reserved for the one case that
|
|
12
|
+
* actually means it: the kind is real and there are zero live instances.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
16
|
+
import { formatUnobserved, type UnobservedEntity } from "@intentius/chant/observation";
|
|
17
|
+
import { defaultK8sConnector, type K8sConnector } from "../api/connect";
|
|
18
|
+
import { classifyApiFailure, isUnknownResource } from "../api/classify";
|
|
19
|
+
import { statusFromObject } from "../describe-resources";
|
|
20
|
+
import { kubeConnect } from "./connect";
|
|
21
|
+
import { parseKubeFlags, connectOptionsFrom } from "./flags";
|
|
22
|
+
import { loadKubeProjectContext, type KubeProjectContext } from "./project";
|
|
23
|
+
import { resolveKubeTarget, matchLiveObject, isTargetError, type KubeTarget } from "./target";
|
|
24
|
+
import { verdictFor } from "./verdict";
|
|
25
|
+
import {
|
|
26
|
+
parseOutput,
|
|
27
|
+
renderKubeTable,
|
|
28
|
+
relativeAge,
|
|
29
|
+
renderJson,
|
|
30
|
+
renderYaml,
|
|
31
|
+
renderName,
|
|
32
|
+
renderChantSource,
|
|
33
|
+
renderJsonPath,
|
|
34
|
+
renderCustomColumns,
|
|
35
|
+
type KubeRow,
|
|
36
|
+
} from "./render";
|
|
37
|
+
|
|
38
|
+
export interface GetDeps {
|
|
39
|
+
connect?: K8sConnector;
|
|
40
|
+
loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function targetLabel(target: KubeTarget): string {
|
|
44
|
+
return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runGet(rawArgs: string[], deps: GetDeps = {}): Promise<number> {
|
|
48
|
+
const connect = deps.connect ?? defaultK8sConnector;
|
|
49
|
+
const loadProject = deps.loadProject ?? loadKubeProjectContext;
|
|
50
|
+
|
|
51
|
+
let flags;
|
|
52
|
+
try {
|
|
53
|
+
flags = parseKubeFlags(rawArgs);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let output;
|
|
60
|
+
try {
|
|
61
|
+
output = parseOutput(flags.values.output);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const project = await loadProject();
|
|
68
|
+
const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
|
|
69
|
+
if (isTargetError(target)) {
|
|
70
|
+
console.error(`error: ${target.error}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
|
|
75
|
+
if (connected.kind === "unobserved") {
|
|
76
|
+
console.error(formatUnobserved(targetLabel(target), unobservedEntity(connected)));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
const { client } = connected;
|
|
80
|
+
|
|
81
|
+
let info;
|
|
82
|
+
try {
|
|
83
|
+
info = await client.resolve(target.selector);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const outcome = classifyApiFailure(err);
|
|
86
|
+
console.error(
|
|
87
|
+
formatUnobserved(targetLabel(target), {
|
|
88
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
89
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
if (!info) {
|
|
95
|
+
console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const namespace = flags.flags.allNamespaces ? undefined : (target.namespace ?? client.defaultNamespace);
|
|
100
|
+
const ref = { apiVersion: info.apiVersion, kind: info.kind };
|
|
101
|
+
|
|
102
|
+
let objects: K8sObject[];
|
|
103
|
+
if (target.name) {
|
|
104
|
+
try {
|
|
105
|
+
const obj = await client.readIfPresent({ ...ref, name: target.name, ...(namespace ? { namespace } : {}) });
|
|
106
|
+
if (!obj) {
|
|
107
|
+
console.error(`Error from server (NotFound): ${info.name} "${target.name}" not found`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
objects = [obj];
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (isUnknownResource(err)) {
|
|
113
|
+
console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const outcome = classifyApiFailure(err);
|
|
117
|
+
console.error(
|
|
118
|
+
formatUnobserved(`${info.kind}/${target.name}`, {
|
|
119
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
120
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
objects = await client.list(ref, {
|
|
128
|
+
...(namespace ? { namespace } : {}),
|
|
129
|
+
...(flags.values.selector ? { labelSelector: flags.values.selector } : {}),
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const outcome = classifyApiFailure(err);
|
|
133
|
+
console.error(
|
|
134
|
+
formatUnobserved(info.kind, {
|
|
135
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
136
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
switch (output.kind) {
|
|
144
|
+
case "json":
|
|
145
|
+
console.log(renderJson(objects));
|
|
146
|
+
return 0;
|
|
147
|
+
case "yaml":
|
|
148
|
+
console.log(renderYaml(objects));
|
|
149
|
+
return 0;
|
|
150
|
+
case "name":
|
|
151
|
+
console.log(renderName(objects));
|
|
152
|
+
return 0;
|
|
153
|
+
case "chant":
|
|
154
|
+
console.log(renderChantSource(objects));
|
|
155
|
+
return 0;
|
|
156
|
+
case "jsonpath":
|
|
157
|
+
console.log(renderJsonPath(output.expr, objects));
|
|
158
|
+
return 0;
|
|
159
|
+
case "custom-columns":
|
|
160
|
+
console.log(renderCustomColumns(output.spec, objects));
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const allNamespaces = flags.flags.allNamespaces ?? false;
|
|
165
|
+
const rows: KubeRow[] = objects.map((obj) => {
|
|
166
|
+
const match = matchLiveObject(project, {
|
|
167
|
+
apiVersion: obj.apiVersion ?? info.apiVersion,
|
|
168
|
+
kind: obj.kind ?? info.kind,
|
|
169
|
+
name: obj.metadata?.name ?? "",
|
|
170
|
+
namespace: obj.metadata?.namespace,
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
namespace: obj.metadata?.namespace,
|
|
174
|
+
name: obj.metadata?.name ?? "",
|
|
175
|
+
kind: info.kind,
|
|
176
|
+
status: statusFromObject(obj),
|
|
177
|
+
age: relativeAge(obj.metadata?.creationTimestamp),
|
|
178
|
+
verdict: project ? verdictFor(obj, match) : "unavailable",
|
|
179
|
+
labels: obj.metadata?.labels,
|
|
180
|
+
object: obj,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
console.log(renderKubeTable(rows, { allNamespaces, wide: output.kind === "wide", showVerdict: true }));
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function unobservedEntity(connected: { reason: UnobservedEntity["reason"]; detail: string }): UnobservedEntity {
|
|
189
|
+
return { reason: connected.reason, detail: connected.detail };
|
|
190
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { kubeCommandGroup } from "./group";
|
|
3
|
+
|
|
4
|
+
describe("kubeCommandGroup (chant #1079)", () => {
|
|
5
|
+
test("mounts as \"kube\" and carries every verb the issue names", () => {
|
|
6
|
+
const group = kubeCommandGroup();
|
|
7
|
+
expect(group.name).toBe("kube");
|
|
8
|
+
const names = group.commands.map((c) => c.name);
|
|
9
|
+
expect(names).toEqual(
|
|
10
|
+
expect.arrayContaining(["version", "get", "describe", "logs", "events", "top", "wait", "source", "apply", "delete"]),
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("every verb carries a non-empty description (shown in --help composition)", () => {
|
|
15
|
+
const group = kubeCommandGroup();
|
|
16
|
+
for (const command of group.commands) {
|
|
17
|
+
expect(command.description.length).toBeGreaterThan(0);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("version still behaves exactly as #1078 shipped it (unchanged tenant)", async () => {
|
|
22
|
+
const group = kubeCommandGroup();
|
|
23
|
+
const version = group.commands.find((c) => c.name === "version")!;
|
|
24
|
+
const logSpy = (await import("vitest")).vi.spyOn(console, "log").mockImplementation(() => {});
|
|
25
|
+
try {
|
|
26
|
+
const code = await version.handler({ verb: "version", rawArgs: ["--format", "json"] });
|
|
27
|
+
expect(code).toBe(0);
|
|
28
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toHaveProperty("schemaVersion");
|
|
29
|
+
} finally {
|
|
30
|
+
logSpy.mockRestore();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant kube` — the terminal surface over the typed client (chant #1079),
|
|
3
|
+
* mounted through the command-group seam #1078 built and reserved this exact
|
|
4
|
+
* namespace for (`lexicons/k8s/src/plugin.ts`'s `commands()`).
|
|
5
|
+
*
|
|
6
|
+
* Every verb below except `version` dynamically imports its own module. That
|
|
7
|
+
* is not a style choice: `describe-resources.ts`, `api/connect.ts` and the
|
|
8
|
+
* rest of the read/write paths sit behind a dynamic import specifically so
|
|
9
|
+
* `chant build` never has to resolve `@intentius/chant-k8s-client`
|
|
10
|
+
* (chant #1074, `examples/k8s-client-boundary.test.ts`). This module IS
|
|
11
|
+
* statically reachable from `plugin.ts` — it is plain data, a list of verb
|
|
12
|
+
* names and descriptions — so every verb whose implementation touches the
|
|
13
|
+
* client must be reached from here only through `await import(...)`, exactly
|
|
14
|
+
* like `version`'s existing `await import("../spec/fetch")`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
|
|
18
|
+
import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
|
|
19
|
+
|
|
20
|
+
async function versionHandler(ctx: CommandGroupContext): Promise<number> {
|
|
21
|
+
const args = splitJoinedFlags(ctx.rawArgs);
|
|
22
|
+
let format: "text" | "json" = "text";
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const a = args[i];
|
|
25
|
+
if (a === "--format" || a === "-f") {
|
|
26
|
+
format = args[++i] === "json" ? "json" : "text";
|
|
27
|
+
} else if (a.startsWith("-")) {
|
|
28
|
+
throw unknownFlagError(a, `"chant kube version" only accepts --format <text|json>.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const { K8S_SCHEMA_VERSION } = await import("../spec/fetch");
|
|
32
|
+
if (format === "json") {
|
|
33
|
+
console.log(JSON.stringify({ schemaVersion: K8S_SCHEMA_VERSION }));
|
|
34
|
+
} else {
|
|
35
|
+
console.log(K8S_SCHEMA_VERSION);
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The full `chant kube` verb group. */
|
|
41
|
+
export function kubeCommandGroup(): CommandGroup {
|
|
42
|
+
return {
|
|
43
|
+
name: "kube",
|
|
44
|
+
description: "Terminal surface over the k8s typed client — kubectl-compatible reads plus chant's own verdict, source, and gated writes",
|
|
45
|
+
commands: [
|
|
46
|
+
{
|
|
47
|
+
name: "version",
|
|
48
|
+
description: "Print the pinned Kubernetes API schema version this lexicon was generated from",
|
|
49
|
+
handler: versionHandler,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "get",
|
|
53
|
+
description: "List or read live resources — kubectl's own flags, plus a declared/owned/drifted/foreign-owned column",
|
|
54
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
55
|
+
const { runGet } = await import("./get");
|
|
56
|
+
return runGet(ctx.rawArgs);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "describe",
|
|
61
|
+
description: "Human-readable detail for one or more resources, including chant's provenance and the object's Events",
|
|
62
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
63
|
+
const { runDescribe } = await import("./describe");
|
|
64
|
+
return runDescribe(ctx.rawArgs);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "logs",
|
|
69
|
+
description: "A Pod's log (snapshot only — no --follow)",
|
|
70
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
71
|
+
const { runLogs } = await import("./logs");
|
|
72
|
+
return runLogs(ctx.rawArgs);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "events",
|
|
77
|
+
description: "List Events, oldest first, optionally filtered with --for=Kind/name",
|
|
78
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
79
|
+
const { runEvents } = await import("./events");
|
|
80
|
+
return runEvents(ctx.rawArgs);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "top",
|
|
85
|
+
description: "Pod/node resource usage from metrics.k8s.io, when the cluster runs metrics-server",
|
|
86
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
87
|
+
const { runTop } = await import("./top");
|
|
88
|
+
return runTop(ctx.rawArgs);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "wait",
|
|
93
|
+
description: "Block until a resource's readiness spec is met (registry-driven — the same one op/activities/wait-for-ready.ts uses)",
|
|
94
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
95
|
+
const { runWait } = await import("./wait");
|
|
96
|
+
return runWait(ctx.rawArgs);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "source",
|
|
101
|
+
description: "Resolve a live object back to the .ts file and composite that declared it",
|
|
102
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
103
|
+
const { runSource } = await import("./source");
|
|
104
|
+
return runSource(ctx.rawArgs);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "apply",
|
|
109
|
+
description: "Server-side apply a manifest (dry-run preview by default — pass --yes to persist); same machinery the Op activity uses",
|
|
110
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
111
|
+
const { runApply } = await import("./apply");
|
|
112
|
+
return runApply(ctx.rawArgs);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "delete",
|
|
117
|
+
description: "Delete one named resource (never a bare kind sweep; preview by default — pass --yes to delete)",
|
|
118
|
+
async handler(ctx: CommandGroupContext): Promise<number> {
|
|
119
|
+
const { runDelete } = await import("./delete");
|
|
120
|
+
return runDelete(ctx.rawArgs);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|