@intentius/chant-lexicon-k8s 0.30.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 +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- 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/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- 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 +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- 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 +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -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/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -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 +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- 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/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
|
|
3
|
+
import { fakeCluster } from "../api/fake-cluster";
|
|
4
|
+
import { runLogs } from "./logs";
|
|
5
|
+
import { fakeDeclarable, fakeProjectContext } from "./testing";
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
function spyStdout() {
|
|
10
|
+
return vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function spyConsole() {
|
|
14
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
15
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
16
|
+
return { log, error };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("chant kube logs (#1079)", () => {
|
|
20
|
+
test("happy path: prints the pod's raw log text", async () => {
|
|
21
|
+
const cluster = fakeCluster({
|
|
22
|
+
respond: (req) =>
|
|
23
|
+
req.path === "/api/v1/namespaces/prod/pods/web-abc/log" ? { body: "hello from the container\n" } : undefined,
|
|
24
|
+
});
|
|
25
|
+
const stdout = spyStdout();
|
|
26
|
+
|
|
27
|
+
const code = await runLogs(["web-abc", "-n", "prod"], { connect: cluster.connector });
|
|
28
|
+
|
|
29
|
+
expect(code).toBe(0);
|
|
30
|
+
expect(stdout).toHaveBeenCalledWith("hello from the container\n");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("--container/--tail/--since/--previous/--timestamps become query parameters", async () => {
|
|
34
|
+
const cluster = fakeCluster({
|
|
35
|
+
respond: (req) => (req.path === "/api/v1/namespaces/prod/pods/web-abc/log" ? { body: "x" } : undefined),
|
|
36
|
+
});
|
|
37
|
+
spyStdout();
|
|
38
|
+
|
|
39
|
+
await runLogs(
|
|
40
|
+
["web-abc", "-n", "prod", "-c", "app", "--tail", "50", "--since", "1h", "-p", "--timestamps"],
|
|
41
|
+
{ connect: cluster.connector },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const req = cluster.layer.requests.find((r) => r.path === "/api/v1/namespaces/prod/pods/web-abc/log")!;
|
|
45
|
+
expect(req.query).toEqual({ container: "app", tailLines: "50", sinceSeconds: "3600", previous: "true", timestamps: "true" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("resolves a declared entity name to its Pod, when the entity is a Pod", async () => {
|
|
49
|
+
const cluster = fakeCluster({
|
|
50
|
+
respond: (req) => (req.path === "/api/v1/namespaces/prod/pods/web-abc/log" ? { body: "log text" } : undefined),
|
|
51
|
+
});
|
|
52
|
+
const project = fakeProjectContext({
|
|
53
|
+
myPod: fakeDeclarable("K8s::Core::Pod", { metadata: { name: "web-abc", namespace: "prod" } }),
|
|
54
|
+
});
|
|
55
|
+
const stdout = spyStdout();
|
|
56
|
+
|
|
57
|
+
const code = await runLogs(["myPod"], { connect: cluster.connector, loadProject: async () => project });
|
|
58
|
+
|
|
59
|
+
expect(code).toBe(0);
|
|
60
|
+
expect(stdout).toHaveBeenCalledWith("log text\n");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a declared entity that is not a Pod is refused with a clear message", async () => {
|
|
64
|
+
const project = fakeProjectContext({
|
|
65
|
+
web: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }),
|
|
66
|
+
});
|
|
67
|
+
const { error } = spyConsole();
|
|
68
|
+
const connect = vi.fn();
|
|
69
|
+
|
|
70
|
+
const code = await runLogs(["web"], { connect, loadProject: async () => project });
|
|
71
|
+
|
|
72
|
+
expect(code).toBe(1);
|
|
73
|
+
expect(error.mock.calls[0][0]).toContain("not a Pod");
|
|
74
|
+
expect(connect).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("binding mismatch refuses loudly", async () => {
|
|
78
|
+
const { error } = spyConsole();
|
|
79
|
+
const connect = async () => {
|
|
80
|
+
throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const code = await runLogs(["web-abc", "--env", "prod"], { connect });
|
|
84
|
+
|
|
85
|
+
expect(code).toBe(1);
|
|
86
|
+
expect(error.mock.calls[0][0]).toContain("no binding for this environment");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("unknown flag is rejected before any connection", async () => {
|
|
90
|
+
const { error } = spyConsole();
|
|
91
|
+
const connect = vi.fn();
|
|
92
|
+
|
|
93
|
+
const code = await runLogs(["web-abc", "--bogus"], { connect });
|
|
94
|
+
|
|
95
|
+
expect(code).toBe(1);
|
|
96
|
+
expect(error.mock.calls[0][0]).toContain("Unknown flag");
|
|
97
|
+
expect(connect).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("tri-state: RBAC-denied log read renders NOT-OBSERVED, not empty output", async () => {
|
|
101
|
+
const cluster = fakeCluster({
|
|
102
|
+
respond: (req) =>
|
|
103
|
+
req.path === "/api/v1/namespaces/prod/pods/web-abc/log"
|
|
104
|
+
? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "pods/log is forbidden" } }
|
|
105
|
+
: undefined,
|
|
106
|
+
});
|
|
107
|
+
const { error } = spyConsole();
|
|
108
|
+
const stdout = spyStdout();
|
|
109
|
+
|
|
110
|
+
const code = await runLogs(["web-abc", "-n", "prod"], { connect: cluster.connector });
|
|
111
|
+
|
|
112
|
+
expect(code).toBe(1);
|
|
113
|
+
expect(error.mock.calls[0][0]).toContain("no credentials");
|
|
114
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a missing pod reports NotFound, kubectl-style", async () => {
|
|
118
|
+
const cluster = fakeCluster({});
|
|
119
|
+
const { error } = spyConsole();
|
|
120
|
+
|
|
121
|
+
const code = await runLogs(["ghost", "-n", "prod"], { connect: cluster.connector });
|
|
122
|
+
|
|
123
|
+
expect(code).toBe(1);
|
|
124
|
+
expect(error.mock.calls[0][0]).toContain("NotFound");
|
|
125
|
+
});
|
|
126
|
+
});
|