@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,138 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
|
|
3
|
+
import { fakeCluster, objectKey } from "../api/fake-cluster";
|
|
4
|
+
import { runSource } from "./source";
|
|
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 source (#1079)", () => {
|
|
16
|
+
test("happy path: resolves a live object to its declaring file and composite", async () => {
|
|
17
|
+
const cluster = fakeCluster({
|
|
18
|
+
objects: {
|
|
19
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: {
|
|
20
|
+
apiVersion: "apps/v1",
|
|
21
|
+
kind: "Deployment",
|
|
22
|
+
metadata: { name: "web", namespace: "prod", uid: "u1" },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const project = fakeProjectContext({
|
|
27
|
+
web: fakeDeclarable(
|
|
28
|
+
"K8s::Apps::Deployment",
|
|
29
|
+
{ metadata: { name: "web", namespace: "prod" } },
|
|
30
|
+
{ sourceFile: "/project/src/infra.ts", composite: "WebApp", compositeInstance: "web" },
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
const { log } = spyConsole();
|
|
34
|
+
|
|
35
|
+
const code = await runSource(["deployment", "web", "-n", "prod"], { connect: cluster.connector, loadProject: async () => project });
|
|
36
|
+
|
|
37
|
+
expect(code).toBe(0);
|
|
38
|
+
const out = log.mock.calls.map((c) => c[0]).join("\n");
|
|
39
|
+
expect(out).toContain("web <- web");
|
|
40
|
+
expect(out).toContain("src/infra.ts");
|
|
41
|
+
expect(out).toContain("composite: WebApp");
|
|
42
|
+
expect(out).toContain("line: not tracked");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("-o json emits a machine-readable envelope", async () => {
|
|
46
|
+
const cluster = fakeCluster({
|
|
47
|
+
objects: {
|
|
48
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: {
|
|
49
|
+
apiVersion: "apps/v1",
|
|
50
|
+
kind: "Deployment",
|
|
51
|
+
metadata: { name: "web", namespace: "prod", uid: "u1" },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const project = fakeProjectContext({
|
|
56
|
+
web: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }, { sourceFile: "/project/src/infra.ts" }),
|
|
57
|
+
});
|
|
58
|
+
const { log } = spyConsole();
|
|
59
|
+
|
|
60
|
+
await runSource(["deployment", "web", "-n", "prod", "-o", "json"], { connect: cluster.connector, loadProject: async () => project });
|
|
61
|
+
|
|
62
|
+
const parsed = JSON.parse(log.mock.calls[0][0] as string);
|
|
63
|
+
expect(parsed).toMatchObject({ found: true, entity: "web", file: "src/infra.ts" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a live object with no declared source reports found:false, not an error", async () => {
|
|
67
|
+
const cluster = fakeCluster({
|
|
68
|
+
objects: {
|
|
69
|
+
[objectKey("apps/v1", "Deployment", "unrelated", "prod")]: {
|
|
70
|
+
apiVersion: "apps/v1",
|
|
71
|
+
kind: "Deployment",
|
|
72
|
+
metadata: { name: "unrelated", namespace: "prod", uid: "u2" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const project = fakeProjectContext({});
|
|
77
|
+
const { log } = spyConsole();
|
|
78
|
+
|
|
79
|
+
const code = await runSource(["deployment", "unrelated", "-n", "prod"], { connect: cluster.connector, loadProject: async () => project });
|
|
80
|
+
|
|
81
|
+
expect(code).toBe(0);
|
|
82
|
+
expect(log.mock.calls[0][0]).toContain("no declared entity");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("degrades gracefully outside a chant project — reports unavailable, does not connect", async () => {
|
|
86
|
+
const { log } = spyConsole();
|
|
87
|
+
const connect = vi.fn();
|
|
88
|
+
|
|
89
|
+
const code = await runSource(["deployment", "web"], { connect, loadProject: async () => undefined });
|
|
90
|
+
|
|
91
|
+
expect(code).toBe(1);
|
|
92
|
+
expect(log.mock.calls[0][0]).toContain("unavailable");
|
|
93
|
+
expect(connect).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("binding mismatch refuses loudly", async () => {
|
|
97
|
+
const project = fakeProjectContext({});
|
|
98
|
+
const { error } = spyConsole();
|
|
99
|
+
const connect = async () => {
|
|
100
|
+
throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const code = await runSource(["deployment", "web", "--env", "prod"], { connect, loadProject: async () => project });
|
|
104
|
+
|
|
105
|
+
expect(code).toBe(1);
|
|
106
|
+
expect(error.mock.calls[0][0]).toContain("no binding for this environment");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("unknown flag is rejected before loading the project or connecting", async () => {
|
|
110
|
+
const { error } = spyConsole();
|
|
111
|
+
const loadProject = vi.fn();
|
|
112
|
+
const connect = vi.fn();
|
|
113
|
+
|
|
114
|
+
const code = await runSource(["deployment", "web", "--bogus"], { connect, loadProject });
|
|
115
|
+
|
|
116
|
+
expect(code).toBe(1);
|
|
117
|
+
expect(error.mock.calls[0][0]).toContain("Unknown flag");
|
|
118
|
+
expect(loadProject).not.toHaveBeenCalled();
|
|
119
|
+
expect(connect).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("tri-state: an RBAC-denied read renders NOT-OBSERVED, not a false negative", async () => {
|
|
123
|
+
const project = fakeProjectContext({});
|
|
124
|
+
const cluster = fakeCluster({
|
|
125
|
+
respond: (req) =>
|
|
126
|
+
req.path === "/apis/apps/v1/namespaces/prod/deployments/web"
|
|
127
|
+
? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "forbidden" } }
|
|
128
|
+
: undefined,
|
|
129
|
+
});
|
|
130
|
+
const { error, log } = spyConsole();
|
|
131
|
+
|
|
132
|
+
const code = await runSource(["deployment", "web", "-n", "prod"], { connect: cluster.connector, loadProject: async () => project });
|
|
133
|
+
|
|
134
|
+
expect(code).toBe(1);
|
|
135
|
+
expect(error.mock.calls[0][0]).toContain("no credentials");
|
|
136
|
+
expect(log).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant kube source` (chant #1079) — resolve a live object back to the
|
|
3
|
+
* `.ts` file and composite that declared it, reusing the exact build
|
|
4
|
+
* provenance the gitlab/forgejo lexicons' own `:source` context tools already
|
|
5
|
+
* expose (`@intentius/chant/provenance`, stamped during discovery — chant
|
|
6
|
+
* #1064). That precedent is explicit that provenance here is **entity-level
|
|
7
|
+
* (file + composite), not a YAML-line source map** — chant does not carry
|
|
8
|
+
* one, for k8s or for anything else — so this verb reports the same two
|
|
9
|
+
* facts (never a fabricated line number) and says so plainly rather than
|
|
10
|
+
* silently coming up one field short of the issue's wording.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatUnobserved } from "@intentius/chant/observation";
|
|
14
|
+
import { defaultK8sConnector, type K8sConnector } from "../api/connect";
|
|
15
|
+
import { classifyApiFailure } from "../api/classify";
|
|
16
|
+
import { kubeConnect } from "./connect";
|
|
17
|
+
import { parseKubeFlags, connectOptionsFrom } from "./flags";
|
|
18
|
+
import { loadKubeProjectContext, relativeProvenance, type KubeProjectContext } from "./project";
|
|
19
|
+
import { resolveKubeTarget, matchLiveObject, isTargetError, type KubeTarget } from "./target";
|
|
20
|
+
|
|
21
|
+
export interface SourceDeps {
|
|
22
|
+
connect?: K8sConnector;
|
|
23
|
+
loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function targetLabel(target: KubeTarget): string {
|
|
27
|
+
return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runSource(rawArgs: string[], deps: SourceDeps = {}): Promise<number> {
|
|
31
|
+
const connect = deps.connect ?? defaultK8sConnector;
|
|
32
|
+
const loadProject = deps.loadProject ?? loadKubeProjectContext;
|
|
33
|
+
|
|
34
|
+
let flags;
|
|
35
|
+
try {
|
|
36
|
+
flags = parseKubeFlags(rawArgs);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const json = flags.values.output === "json";
|
|
43
|
+
|
|
44
|
+
const project = await loadProject();
|
|
45
|
+
if (!project) {
|
|
46
|
+
if (json) console.log(JSON.stringify({ available: false, note: "not running inside a chant project (no chant.config.ts, or the project has build errors)" }));
|
|
47
|
+
else console.log("chant source: unavailable — not running inside a chant project (no chant.config.ts, or the project has build errors)");
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
|
|
52
|
+
if (isTargetError(target)) {
|
|
53
|
+
console.error(`error: ${target.error}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
if (!target.name) {
|
|
57
|
+
console.error("error: chant kube source requires a single named resource, e.g. `chant kube source deployment web`");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
|
|
62
|
+
if (connected.kind === "unobserved") {
|
|
63
|
+
console.error(formatUnobserved(targetLabel(target), { reason: connected.reason, detail: connected.detail }));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const { client } = connected;
|
|
67
|
+
|
|
68
|
+
let info;
|
|
69
|
+
try {
|
|
70
|
+
info = await client.resolve(target.selector);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const outcome = classifyApiFailure(err);
|
|
73
|
+
console.error(
|
|
74
|
+
formatUnobserved(targetLabel(target), {
|
|
75
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
76
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
if (!info) {
|
|
82
|
+
console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const namespace = target.namespace ?? client.defaultNamespace;
|
|
87
|
+
let obj;
|
|
88
|
+
try {
|
|
89
|
+
obj = await client.readIfPresent({ apiVersion: info.apiVersion, kind: info.kind, name: target.name, namespace });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const outcome = classifyApiFailure(err);
|
|
92
|
+
console.error(
|
|
93
|
+
formatUnobserved(`${info.kind}/${target.name}`, {
|
|
94
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
95
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (!obj) {
|
|
101
|
+
console.error(`Error from server (NotFound): ${info.name} "${target.name}" not found`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const match =
|
|
106
|
+
target.declaredMatch ??
|
|
107
|
+
matchLiveObject(project, {
|
|
108
|
+
apiVersion: obj.apiVersion ?? info.apiVersion,
|
|
109
|
+
kind: obj.kind ?? info.kind,
|
|
110
|
+
name: obj.metadata?.name ?? target.name,
|
|
111
|
+
namespace: obj.metadata?.namespace ?? namespace,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!match) {
|
|
115
|
+
const note = `no declared entity in this project names ${info.kind}/${target.name}`;
|
|
116
|
+
if (json) console.log(JSON.stringify({ found: false, note }));
|
|
117
|
+
else console.log(`${info.kind}/${target.name}: ${note}`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const prov = relativeProvenance(project, match.entity);
|
|
122
|
+
if (json) {
|
|
123
|
+
console.log(
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
found: true,
|
|
126
|
+
entity: match.entityName,
|
|
127
|
+
file: prov?.sourceFile ?? null,
|
|
128
|
+
composite: prov?.composite ?? null,
|
|
129
|
+
compositeInstance: prov?.compositeInstance ?? null,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(`${info.kind}/${target.name} <- ${match.entityName}`);
|
|
134
|
+
console.log(` file: ${prov?.sourceFile ?? "(unknown)"}`);
|
|
135
|
+
console.log(` composite: ${prov?.composite ?? "(none — declared directly)"}`);
|
|
136
|
+
console.log(` line: not tracked — chant's build provenance is entity-level (file + composite), not a source map`);
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { resolveKubeTarget, isTargetError, matchLiveObject } from "./target";
|
|
3
|
+
import { fakeDeclarable, fakeProjectContext } from "./testing";
|
|
4
|
+
|
|
5
|
+
describe("resolveKubeTarget (chant #1079)", () => {
|
|
6
|
+
test("kubectl-style: <kind> <name> — never consults the project even when a project is present", () => {
|
|
7
|
+
const project = fakeProjectContext({
|
|
8
|
+
web: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "different-name", namespace: "prod" } }),
|
|
9
|
+
});
|
|
10
|
+
const target = resolveKubeTarget(["deployment", "web"], undefined, project);
|
|
11
|
+
expect(isTargetError(target)).toBe(false);
|
|
12
|
+
if (!isTargetError(target)) {
|
|
13
|
+
expect(target.selector).toEqual({ resource: "deployment" });
|
|
14
|
+
expect(target.name).toBe("web");
|
|
15
|
+
expect(target.declaredMatch).toBeUndefined();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("kubectl-style: a bare <kind> lists — name is undefined", () => {
|
|
20
|
+
const target = resolveKubeTarget(["deployments"], undefined, undefined);
|
|
21
|
+
if (!isTargetError(target)) {
|
|
22
|
+
expect(target.name).toBeUndefined();
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error("expected a resolved target");
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("chant vocabulary: a single token matching a declared entity resolves via its own metadata", () => {
|
|
29
|
+
const project = fakeProjectContext({
|
|
30
|
+
myWeb: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }),
|
|
31
|
+
});
|
|
32
|
+
const target = resolveKubeTarget(["myWeb"], undefined, project);
|
|
33
|
+
if (isTargetError(target)) throw new Error("expected a resolved target");
|
|
34
|
+
expect(target.selector).toEqual({ apiVersion: "apps/v1", kind: "Deployment" });
|
|
35
|
+
expect(target.name).toBe("web");
|
|
36
|
+
expect(target.namespace).toBe("prod");
|
|
37
|
+
expect(target.declaredMatch?.entityName).toBe("myWeb");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an explicit -n overrides the declared namespace", () => {
|
|
41
|
+
const project = fakeProjectContext({
|
|
42
|
+
myWeb: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }),
|
|
43
|
+
});
|
|
44
|
+
const target = resolveKubeTarget(["myWeb"], "staging", project);
|
|
45
|
+
if (isTargetError(target)) throw new Error("expected a resolved target");
|
|
46
|
+
expect(target.namespace).toBe("staging");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no arguments is an error, not a crash", () => {
|
|
50
|
+
const target = resolveKubeTarget([], undefined, undefined);
|
|
51
|
+
expect(isTargetError(target)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("matchLiveObject", () => {
|
|
56
|
+
test("undefined project means no match, cheaply (no iteration)", () => {
|
|
57
|
+
expect(matchLiveObject(undefined, { apiVersion: "v1", kind: "Pod", name: "a" })).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("matches on apiVersion + kind + name + namespace, all four", () => {
|
|
61
|
+
const project = fakeProjectContext({
|
|
62
|
+
web: fakeDeclarable("K8s::Apps::Deployment", { metadata: { name: "web", namespace: "prod" } }),
|
|
63
|
+
});
|
|
64
|
+
expect(matchLiveObject(project, { apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" })?.entityName).toBe("web");
|
|
65
|
+
expect(matchLiveObject(project, { apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "staging" })).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving `chant kube <verb> <target...>`'s positional arguments to an
|
|
3
|
+
* addressable resource (chant #1079).
|
|
4
|
+
*
|
|
5
|
+
* "Every verb resolves entity names through chant's own vocabulary where
|
|
6
|
+
* applicable ... while also accepting raw kind/name kubectl-style." Both
|
|
7
|
+
* forms share one positional shape, disambiguated by arity:
|
|
8
|
+
*
|
|
9
|
+
* - One token, and it names a declared entity in this project → chant's own
|
|
10
|
+
* vocabulary: the entity's `metadata.name`(/`namespace`) is looked up in
|
|
11
|
+
* its own source, not typed twice.
|
|
12
|
+
* - One or two tokens otherwise → kubectl's own shape, `<kind> [name]`; a
|
|
13
|
+
* bare kind with no name is a list, matching `kubectl get <kind>`.
|
|
14
|
+
*
|
|
15
|
+
* `<kind>` is never parsed here beyond splitting the `.group` suffix — that
|
|
16
|
+
* job belongs to the typed client's own `resolve()` (kubectl's own
|
|
17
|
+
* plural/singular/kind/short-name precedence, `packages/k8s-client/src/client.ts`),
|
|
18
|
+
* so a raw kind string reaches the cluster exactly as `kubectl get raycluster.ray.io`
|
|
19
|
+
* would.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ResourceSelector } from "@intentius/chant-k8s-client";
|
|
23
|
+
import { findDeclaredMatch, type DeclaredMatch, type KubeProjectContext } from "./project";
|
|
24
|
+
import { operationFor } from "../api/operation-surface";
|
|
25
|
+
|
|
26
|
+
export interface KubeTarget {
|
|
27
|
+
selector: ResourceSelector;
|
|
28
|
+
/** Object name. Undefined means "list every object this selector addresses". */
|
|
29
|
+
name?: string;
|
|
30
|
+
namespace?: string;
|
|
31
|
+
/** Set when resolution went through chant's own vocabulary rather than a kubectl kind string. */
|
|
32
|
+
declaredMatch?: DeclaredMatch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KubeTargetError {
|
|
36
|
+
error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isTargetError(t: KubeTarget | KubeTargetError): t is KubeTargetError {
|
|
40
|
+
return "error" in t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve `positional` (whatever's left after flags) plus an explicit
|
|
45
|
+
* `-n/--namespace`, against `project`'s declared entities when available.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveKubeTarget(
|
|
48
|
+
positional: readonly string[],
|
|
49
|
+
namespaceFlag: string | undefined,
|
|
50
|
+
project: KubeProjectContext | undefined,
|
|
51
|
+
): KubeTarget | KubeTargetError {
|
|
52
|
+
if (positional.length === 0) {
|
|
53
|
+
return { error: "a resource kind (and, usually, a name) is required — e.g. `chant kube get deployments` or `chant kube get deployment web`" };
|
|
54
|
+
}
|
|
55
|
+
const [first, second] = positional;
|
|
56
|
+
|
|
57
|
+
if (project && second === undefined) {
|
|
58
|
+
const entity = project.entities.get(first);
|
|
59
|
+
if (entity && entity.lexicon === "k8s") {
|
|
60
|
+
const operation = operationFor(entity.entityType);
|
|
61
|
+
const props = "props" in entity ? ((entity as { props: unknown }).props as Record<string, unknown>) : undefined;
|
|
62
|
+
const metadata = props?.metadata as { name?: string; namespace?: string } | undefined;
|
|
63
|
+
if (operation && metadata?.name) {
|
|
64
|
+
return {
|
|
65
|
+
selector: { apiVersion: operation.apiVersion, kind: operation.kind },
|
|
66
|
+
name: metadata.name,
|
|
67
|
+
namespace: namespaceFlag ?? metadata.namespace,
|
|
68
|
+
declaredMatch: { entityName: first, entity, props },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { selector: { resource: first }, name: second, namespace: namespaceFlag };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* After a live read resolves an object's real `apiVersion`/`kind` (a kubectl
|
|
79
|
+
* kind string like `deploy` isn't one), reverse-map it against the project
|
|
80
|
+
* for the verdict column / `source` — reusing the exact match `resolveKubeTarget`
|
|
81
|
+
* would have made had the caller spelled out the entity name.
|
|
82
|
+
*/
|
|
83
|
+
export function matchLiveObject(
|
|
84
|
+
project: KubeProjectContext | undefined,
|
|
85
|
+
ref: { apiVersion: string; kind: string; name: string; namespace?: string },
|
|
86
|
+
): DeclaredMatch | undefined {
|
|
87
|
+
if (!project) return undefined;
|
|
88
|
+
return findDeclaredMatch(project.entities, ref);
|
|
89
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test doubles for `chant kube` (chant #1079).
|
|
3
|
+
*
|
|
4
|
+
* `./project.ts`'s real `loadKubeProjectContext` builds an actual project
|
|
5
|
+
* (`chant.config.ts` read, source discovered, serialized) — every verb takes
|
|
6
|
+
* it as an injectable dependency for exactly this reason: a unit test builds
|
|
7
|
+
* a `KubeProjectContext` by hand instead, the same way `../api/fake-cluster.ts`
|
|
8
|
+
* lets a test build a client without a socket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { setProvenance, type EntityProvenance } from "@intentius/chant/provenance";
|
|
13
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
14
|
+
import type { KubeProjectContext } from "./project";
|
|
15
|
+
|
|
16
|
+
export type FakeDeclarable = Declarable & { props: Record<string, unknown> };
|
|
17
|
+
|
|
18
|
+
/** A minimal `Declarable` carrying `props`, optionally stamped with build provenance. */
|
|
19
|
+
export function fakeDeclarable(
|
|
20
|
+
entityType: string,
|
|
21
|
+
props: Record<string, unknown>,
|
|
22
|
+
provenance?: EntityProvenance,
|
|
23
|
+
): FakeDeclarable {
|
|
24
|
+
const entity: FakeDeclarable = {
|
|
25
|
+
lexicon: "k8s",
|
|
26
|
+
entityType,
|
|
27
|
+
kind: "resource",
|
|
28
|
+
props,
|
|
29
|
+
[DECLARABLE_MARKER]: true,
|
|
30
|
+
};
|
|
31
|
+
if (provenance) setProvenance(entity, provenance);
|
|
32
|
+
return entity;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `KubeProjectContext` built from a plain name → entity map, no disk I/O. */
|
|
36
|
+
export function fakeProjectContext(
|
|
37
|
+
entities: Record<string, Declarable>,
|
|
38
|
+
config: ChantConfig = {},
|
|
39
|
+
cwd = "/project",
|
|
40
|
+
): KubeProjectContext {
|
|
41
|
+
return { cwd, config, entities: new Map(Object.entries(entities)) };
|
|
42
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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 { runTop } from "./top";
|
|
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 METRICS_DISCOVERY = {
|
|
15
|
+
path: "/apis/metrics.k8s.io/v1beta1",
|
|
16
|
+
body: {
|
|
17
|
+
kind: "APIResourceList",
|
|
18
|
+
apiVersion: "v1",
|
|
19
|
+
groupVersion: "metrics.k8s.io/v1beta1",
|
|
20
|
+
resources: [
|
|
21
|
+
{ name: "pods", singularName: "", namespaced: true, kind: "PodMetrics", verbs: ["get", "list"] },
|
|
22
|
+
{ name: "nodes", singularName: "", namespaced: false, kind: "NodeMetrics", verbs: ["get", "list"] },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function withMetricsGroup(respond: (req: { path: string }) => { status?: number; body?: unknown } | undefined) {
|
|
28
|
+
return (req: { path: string }) => {
|
|
29
|
+
if (req.path === "/apis") {
|
|
30
|
+
return {
|
|
31
|
+
body: {
|
|
32
|
+
kind: "APIGroupList",
|
|
33
|
+
groups: [
|
|
34
|
+
{
|
|
35
|
+
name: "metrics.k8s.io",
|
|
36
|
+
preferredVersion: { groupVersion: "metrics.k8s.io/v1beta1" },
|
|
37
|
+
versions: [{ groupVersion: "metrics.k8s.io/v1beta1" }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (req.path === METRICS_DISCOVERY.path) return { body: METRICS_DISCOVERY.body };
|
|
44
|
+
return respond(req);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("chant kube top (#1079)", () => {
|
|
49
|
+
test("happy path: pods usage, per container, unsummed", async () => {
|
|
50
|
+
const cluster = fakeCluster({
|
|
51
|
+
serves: [],
|
|
52
|
+
respond: withMetricsGroup((req) =>
|
|
53
|
+
req.path === "/apis/metrics.k8s.io/v1beta1/namespaces/prod/pods"
|
|
54
|
+
? {
|
|
55
|
+
body: {
|
|
56
|
+
items: [
|
|
57
|
+
{
|
|
58
|
+
metadata: { name: "web", namespace: "prod" },
|
|
59
|
+
containers: [{ name: "app", usage: { cpu: "250m", memory: "128Mi" } }],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: undefined,
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
const { log } = spyConsole();
|
|
68
|
+
|
|
69
|
+
const code = await runTop(["pods", "-n", "prod"], { connect: cluster.connector });
|
|
70
|
+
|
|
71
|
+
expect(code).toBe(0);
|
|
72
|
+
const out = log.mock.calls[0][0] as string;
|
|
73
|
+
expect(out).toContain("web");
|
|
74
|
+
expect(out).toContain("app:250m");
|
|
75
|
+
expect(out).toContain("app:128Mi");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("nodes usage", async () => {
|
|
79
|
+
const cluster = fakeCluster({
|
|
80
|
+
serves: [],
|
|
81
|
+
respond: withMetricsGroup((req) =>
|
|
82
|
+
req.path === "/apis/metrics.k8s.io/v1beta1/nodes"
|
|
83
|
+
? { body: { items: [{ metadata: { name: "node-1" }, usage: { cpu: "500m", memory: "2Gi" } }] } }
|
|
84
|
+
: undefined,
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
const { log } = spyConsole();
|
|
88
|
+
|
|
89
|
+
const code = await runTop(["nodes"], { connect: cluster.connector });
|
|
90
|
+
|
|
91
|
+
expect(code).toBe(0);
|
|
92
|
+
const out = log.mock.calls[0][0] as string;
|
|
93
|
+
expect(out).toContain("node-1");
|
|
94
|
+
expect(out).toContain("500m");
|
|
95
|
+
expect(out).toContain("2Gi");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("metrics-server absent renders a clear message, not an empty table", async () => {
|
|
99
|
+
const cluster = fakeCluster({ serves: [] });
|
|
100
|
+
const { error, log } = spyConsole();
|
|
101
|
+
|
|
102
|
+
const code = await runTop(["pods"], { connect: cluster.connector });
|
|
103
|
+
|
|
104
|
+
expect(code).toBe(1);
|
|
105
|
+
expect(error.mock.calls[0][0]).toContain("metrics-server");
|
|
106
|
+
expect(log).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("binding mismatch refuses loudly", async () => {
|
|
110
|
+
const { error } = spyConsole();
|
|
111
|
+
const connect = async () => {
|
|
112
|
+
throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const code = await runTop(["pods", "--env", "prod"], { connect });
|
|
116
|
+
|
|
117
|
+
expect(code).toBe(1);
|
|
118
|
+
expect(error.mock.calls[0][0]).toContain("no binding for this environment");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("unknown flag is rejected before any connection", async () => {
|
|
122
|
+
const { error } = spyConsole();
|
|
123
|
+
const connect = vi.fn();
|
|
124
|
+
|
|
125
|
+
const code = await runTop(["pods", "--bogus"], { connect });
|
|
126
|
+
|
|
127
|
+
expect(code).toBe(1);
|
|
128
|
+
expect(error.mock.calls[0][0]).toContain("Unknown flag");
|
|
129
|
+
expect(connect).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("tri-state: an RBAC-denied metrics read renders NOT-OBSERVED", async () => {
|
|
133
|
+
const cluster = fakeCluster({
|
|
134
|
+
serves: [],
|
|
135
|
+
respond: withMetricsGroup((req) =>
|
|
136
|
+
req.path === "/apis/metrics.k8s.io/v1beta1/namespaces/prod/pods"
|
|
137
|
+
? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "forbidden" } }
|
|
138
|
+
: undefined,
|
|
139
|
+
),
|
|
140
|
+
});
|
|
141
|
+
const { error, log } = spyConsole();
|
|
142
|
+
|
|
143
|
+
const code = await runTop(["pods", "-n", "prod"], { connect: cluster.connector });
|
|
144
|
+
|
|
145
|
+
expect(code).toBe(1);
|
|
146
|
+
expect(error.mock.calls[0][0]).toContain("no credentials");
|
|
147
|
+
expect(log).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|