@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,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
|
+
});
|
package/src/kube/logs.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant kube logs` (chant #1079) — a Pod's `/log` subresource, over the
|
|
3
|
+
* typed client's new `readLog` (`packages/k8s-client/src/client.ts`). A
|
|
4
|
+
* snapshot only: `--follow`/`-f` would need the transport seam to expose a
|
|
5
|
+
* stream rather than a completed response body, which it does not today —
|
|
6
|
+
* left out deliberately (see `readLog`'s own doc) rather than faked with a
|
|
7
|
+
* poll loop that isn't actually following anything.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { formatUnobserved } from "@intentius/chant/observation";
|
|
11
|
+
import { defaultK8sConnector, type K8sConnector } from "../api/connect";
|
|
12
|
+
import { classifyApiFailure } from "../api/classify";
|
|
13
|
+
import { operationFor } from "../api/operation-surface";
|
|
14
|
+
import { kubeConnect } from "./connect";
|
|
15
|
+
import { parseKubeFlags, connectOptionsFrom, parseDurationSeconds } from "./flags";
|
|
16
|
+
import { loadKubeProjectContext, type KubeProjectContext } from "./project";
|
|
17
|
+
|
|
18
|
+
export interface LogsDeps {
|
|
19
|
+
connect?: K8sConnector;
|
|
20
|
+
loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runLogs(rawArgs: string[], deps: LogsDeps = {}): Promise<number> {
|
|
24
|
+
const connect = deps.connect ?? defaultK8sConnector;
|
|
25
|
+
const loadProject = deps.loadProject ?? loadKubeProjectContext;
|
|
26
|
+
|
|
27
|
+
let flags;
|
|
28
|
+
try {
|
|
29
|
+
flags = parseKubeFlags(rawArgs, {
|
|
30
|
+
value: { "-c": "container", "--container": "container", "--tail": "tail", "--since": "since" },
|
|
31
|
+
boolean: { "-p": "previous", "--previous": "previous", "--timestamps": "timestamps" },
|
|
32
|
+
});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const podArg = flags.positional[0];
|
|
39
|
+
if (!podArg) {
|
|
40
|
+
console.error("error: a pod name (or a declared entity name) is required — e.g. `chant kube logs web-6c8f`");
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const project = await loadProject();
|
|
45
|
+
let podName = podArg;
|
|
46
|
+
let namespace = flags.values.namespace;
|
|
47
|
+
const entity = project?.entities.get(podArg);
|
|
48
|
+
if (entity && entity.lexicon === "k8s") {
|
|
49
|
+
const operation = operationFor(entity.entityType);
|
|
50
|
+
if (operation && operation.kind !== "Pod") {
|
|
51
|
+
console.error(`error: "${podArg}" declares a ${operation.kind}, not a Pod — chant kube logs only reads Pod logs`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
if (operation?.kind === "Pod") {
|
|
55
|
+
const props = "props" in entity ? ((entity as { props: unknown }).props as Record<string, unknown>) : undefined;
|
|
56
|
+
const metadata = props?.metadata as { name?: string; namespace?: string } | undefined;
|
|
57
|
+
if (metadata?.name) {
|
|
58
|
+
podName = metadata.name;
|
|
59
|
+
namespace = namespace ?? metadata.namespace;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
|
|
65
|
+
if (connected.kind === "unobserved") {
|
|
66
|
+
console.error(formatUnobserved(`pod/${podName}`, { reason: connected.reason, detail: connected.detail }));
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const { client } = connected;
|
|
70
|
+
|
|
71
|
+
const tailLines = flags.values.tail !== undefined ? Number(flags.values.tail) : undefined;
|
|
72
|
+
const sinceSeconds = flags.values.since !== undefined ? parseDurationSeconds(flags.values.since) : undefined;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const text = await client.readLog(
|
|
76
|
+
{ apiVersion: "v1", kind: "Pod", name: podName, namespace: namespace ?? client.defaultNamespace },
|
|
77
|
+
{
|
|
78
|
+
...(flags.values.container ? { container: flags.values.container } : {}),
|
|
79
|
+
...(tailLines !== undefined ? { tailLines } : {}),
|
|
80
|
+
...(sinceSeconds !== undefined ? { sinceSeconds } : {}),
|
|
81
|
+
previous: flags.flags.previous ?? false,
|
|
82
|
+
timestamps: flags.flags.timestamps ?? false,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
86
|
+
return 0;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const outcome = classifyApiFailure(err);
|
|
89
|
+
if (outcome.kind === "absent") {
|
|
90
|
+
console.error(`Error from server (NotFound): pods "${podName}" not found`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
console.error(formatUnobserved(`pod/${podName}`, { reason: outcome.reason, detail: outcome.detail }));
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loadKubeProjectContext` exercised for real (chant #1079) — real
|
|
3
|
+
* `chant.config.ts` discovery, a real `build()`, and real `getProvenance()`
|
|
4
|
+
* results. Every verb test elsewhere injects a fake `KubeProjectContext`
|
|
5
|
+
* (`./testing.ts`); this file is what proves the real implementation these
|
|
6
|
+
* tests are all standing in for actually resolves a project and its
|
|
7
|
+
* provenance, and — the specific thing chant #1079 asks for — genuinely
|
|
8
|
+
* returns `undefined` outside one rather than treating an arbitrary
|
|
9
|
+
* directory as a chant project rooted right there.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from "vitest";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { loadKubeProjectContext, findDeclaredMatch, relativeProvenance } from "./project";
|
|
16
|
+
|
|
17
|
+
const GETTING_STARTED = resolve(import.meta.dirname, "../../../../examples/getting-started");
|
|
18
|
+
|
|
19
|
+
describe("loadKubeProjectContext (chant #1079)", () => {
|
|
20
|
+
test("outside a chant project (no chant.config.ts anywhere): undefined, and fast — no build is attempted", async () => {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-kube-no-project-"));
|
|
22
|
+
try {
|
|
23
|
+
const ctx = await loadKubeProjectContext(dir);
|
|
24
|
+
expect(ctx).toBeUndefined();
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a real project: builds, resolves a declared entity, and its provenance names the source file (relative to the project root)", async () => {
|
|
31
|
+
const ctx = await loadKubeProjectContext(GETTING_STARTED);
|
|
32
|
+
expect(ctx).toBeDefined();
|
|
33
|
+
if (!ctx) return;
|
|
34
|
+
|
|
35
|
+
const deploymentEntry = [...ctx.entities].find(([, e]) => e.entityType === "K8s::Apps::Deployment");
|
|
36
|
+
expect(deploymentEntry).toBeDefined();
|
|
37
|
+
const [entityName, entity] = deploymentEntry!;
|
|
38
|
+
|
|
39
|
+
const props = (entity as { props: { metadata?: { name?: string; namespace?: string } } }).props;
|
|
40
|
+
const match = findDeclaredMatch(ctx.entities, {
|
|
41
|
+
apiVersion: "apps/v1",
|
|
42
|
+
kind: "Deployment",
|
|
43
|
+
name: props.metadata!.name!,
|
|
44
|
+
namespace: props.metadata?.namespace,
|
|
45
|
+
});
|
|
46
|
+
expect(match?.entityName).toBe(entityName);
|
|
47
|
+
|
|
48
|
+
// Entity-level provenance (chant #1064): the declaring file, relativized
|
|
49
|
+
// to the project root. This fixture re-exports the composite's own
|
|
50
|
+
// members individually (`export const deployment = web.deployment`)
|
|
51
|
+
// rather than exporting the composite call itself, so `composite` isn't
|
|
52
|
+
// stamped here — `./describe.test.ts` and `./source.test.ts` cover that
|
|
53
|
+
// field directly against a fake entity that does carry it.
|
|
54
|
+
const prov = relativeProvenance(ctx, entity);
|
|
55
|
+
expect(prov?.sourceFile).toBe("src/web.ts");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort chant-project context for `chant kube` (chant #1079).
|
|
3
|
+
*
|
|
4
|
+
* `get`'s chant-verdict column and `source` both need to answer "does a
|
|
5
|
+
* declared entity in this project's source name this live object" — which
|
|
6
|
+
* needs the project built (chant.config.ts read, source discovered and
|
|
7
|
+
* resolved). `chant kube` is also usable against a bare cluster with no
|
|
8
|
+
* chant project in sight (a checkout of someone else's manifests, a
|
|
9
|
+
* throwaway directory) — the acceptance criterion is that reads still work
|
|
10
|
+
* there and only the chant-specific parts degrade, quietly, to
|
|
11
|
+
* "unavailable" rather than erroring. Every failure mode here — no
|
|
12
|
+
* `chant.config.ts`, a project with build errors, an unreadable directory —
|
|
13
|
+
* collapses to `undefined` for exactly that reason.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately does not import anything client-shaped: this runs before a
|
|
16
|
+
* cluster is ever reached (`source`/`get` need it to compute a verdict for
|
|
17
|
+
* objects the client already read), and matching is pure once given a live
|
|
18
|
+
* object's coordinates.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
22
|
+
import { build } from "@intentius/chant/build";
|
|
23
|
+
import { loadChantConfig, type ChantConfig } from "@intentius/chant/config";
|
|
24
|
+
import { getProvenance, type EntityProvenance } from "@intentius/chant/provenance";
|
|
25
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
26
|
+
import { k8sSerializer } from "../serializer";
|
|
27
|
+
import { operationFor } from "../api/operation-surface";
|
|
28
|
+
|
|
29
|
+
export interface KubeProjectContext {
|
|
30
|
+
cwd: string;
|
|
31
|
+
config: ChantConfig;
|
|
32
|
+
entities: Map<string, Declarable>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the project rooted at `cwd` (or its configured `sourceDir`) with the
|
|
37
|
+
* k8s serializer, so `entities` carries resolved props (composites expanded,
|
|
38
|
+
* AttrRefs resolved) and build provenance (chant #1064's entity-level
|
|
39
|
+
* `sourceFile`/`composite`, stamped during discovery). Returns undefined —
|
|
40
|
+
* never throws — for anything that stops the project from resolving: no
|
|
41
|
+
* `chant.config.ts` here or above, a build with errors, an inaccessible
|
|
42
|
+
* directory. This IS the "outside a chant project" signal every caller
|
|
43
|
+
* checks for.
|
|
44
|
+
*/
|
|
45
|
+
export async function loadKubeProjectContext(cwd: string = process.cwd()): Promise<KubeProjectContext | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const { config, configPath } = await loadChantConfig(cwd);
|
|
48
|
+
// `loadChantConfig` does not throw when neither `chant.config.ts` nor
|
|
49
|
+
// `chant.config.json` exists at `cwd` — it returns the default empty
|
|
50
|
+
// config instead (`configPath` absent). Building anyway would treat an
|
|
51
|
+
// arbitrary directory (a bare cluster checkout, chant's own monorepo
|
|
52
|
+
// root run from the wrong cwd) as a chant project rooted right there —
|
|
53
|
+
// exactly the "outside a chant project" case this function exists to
|
|
54
|
+
// detect, not paper over.
|
|
55
|
+
if (!configPath) return undefined;
|
|
56
|
+
const root = resolve(cwd, config.sourceDir ?? ".");
|
|
57
|
+
const result = await build(root, [k8sSerializer]);
|
|
58
|
+
if (result.errors.length > 0) return undefined;
|
|
59
|
+
return { cwd, config, entities: result.entities };
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function propsOf(entity: Declarable): Record<string, unknown> | undefined {
|
|
66
|
+
return "props" in entity ? ((entity as { props: unknown }).props as Record<string, unknown>) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DeclaredMatch {
|
|
70
|
+
entityName: string;
|
|
71
|
+
entity: Declarable;
|
|
72
|
+
props: Record<string, unknown> | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reverse-map a live object's coordinates (apiVersion/kind/name/namespace) to
|
|
77
|
+
* the declared k8s entity that names it, if this project declares one.
|
|
78
|
+
* `operationFor` is the same generated operation surface `describeResources`
|
|
79
|
+
* addresses entities with, so a match here is addressed exactly the way the
|
|
80
|
+
* live read that produced the object was — no separate naming convention.
|
|
81
|
+
*/
|
|
82
|
+
export function findDeclaredMatch(
|
|
83
|
+
entities: Map<string, Declarable>,
|
|
84
|
+
ref: { apiVersion: string; kind: string; name: string; namespace?: string },
|
|
85
|
+
): DeclaredMatch | undefined {
|
|
86
|
+
for (const [entityName, entity] of entities) {
|
|
87
|
+
if (entity.lexicon !== "k8s") continue;
|
|
88
|
+
const operation = operationFor(entity.entityType);
|
|
89
|
+
if (!operation || operation.apiVersion !== ref.apiVersion || operation.kind !== ref.kind) continue;
|
|
90
|
+
const props = propsOf(entity);
|
|
91
|
+
const metadata = props?.metadata as { name?: string; namespace?: string } | undefined;
|
|
92
|
+
if (metadata?.name !== ref.name) continue;
|
|
93
|
+
if ((metadata?.namespace ?? undefined) !== (ref.namespace ?? undefined)) continue;
|
|
94
|
+
return { entityName, entity, props };
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Provenance for a matched entity, relative to the project root when possible. */
|
|
100
|
+
export function relativeProvenance(ctx: KubeProjectContext, entity: Declarable): EntityProvenance | undefined {
|
|
101
|
+
const prov = getProvenance(entity);
|
|
102
|
+
if (!prov) return undefined;
|
|
103
|
+
if (!prov.sourceFile || !isAbsolute(prov.sourceFile)) return prov;
|
|
104
|
+
const rel = relative(ctx.cwd, prov.sourceFile);
|
|
105
|
+
return { ...prov, sourceFile: rel.startsWith("..") ? prov.sourceFile : rel };
|
|
106
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseOutput,
|
|
4
|
+
relativeAge,
|
|
5
|
+
renderTable,
|
|
6
|
+
evalJsonPath,
|
|
7
|
+
renderJsonPath,
|
|
8
|
+
renderCustomColumns,
|
|
9
|
+
renderJson,
|
|
10
|
+
renderYaml,
|
|
11
|
+
renderName,
|
|
12
|
+
} from "./render";
|
|
13
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
14
|
+
|
|
15
|
+
describe("parseOutput (chant #1079)", () => {
|
|
16
|
+
test("defaults to text", () => {
|
|
17
|
+
expect(parseOutput(undefined)).toEqual({ kind: "text" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parses every recognized form", () => {
|
|
21
|
+
expect(parseOutput("wide")).toEqual({ kind: "wide" });
|
|
22
|
+
expect(parseOutput("json")).toEqual({ kind: "json" });
|
|
23
|
+
expect(parseOutput("yaml")).toEqual({ kind: "yaml" });
|
|
24
|
+
expect(parseOutput("name")).toEqual({ kind: "name" });
|
|
25
|
+
expect(parseOutput("chant")).toEqual({ kind: "chant" });
|
|
26
|
+
expect(parseOutput("jsonpath={.metadata.name}")).toEqual({ kind: "jsonpath", expr: "{.metadata.name}" });
|
|
27
|
+
expect(parseOutput("custom-columns=NAME:.metadata.name")).toEqual({
|
|
28
|
+
kind: "custom-columns",
|
|
29
|
+
spec: "NAME:.metadata.name",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("go-template is refused by name, not half-implemented", () => {
|
|
34
|
+
expect(() => parseOutput("go-template={{.metadata.name}}")).toThrow(/go-template/);
|
|
35
|
+
expect(() => parseOutput("go-template-file=x.tmpl")).toThrow(/go-template/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an unrecognized value is refused", () => {
|
|
39
|
+
expect(() => parseOutput("bogus")).toThrow(/unsupported/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("relativeAge", () => {
|
|
44
|
+
test("renders seconds, minutes, hours, days", () => {
|
|
45
|
+
const now = Date.parse("2026-01-02T00:00:00Z");
|
|
46
|
+
expect(relativeAge("2026-01-02T00:00:00Z", now)).toBe("0s");
|
|
47
|
+
expect(relativeAge("2026-01-01T23:59:30Z", now)).toBe("30s");
|
|
48
|
+
expect(relativeAge("2026-01-01T23:50:00Z", now)).toBe("10m");
|
|
49
|
+
expect(relativeAge("2026-01-01T18:00:00Z", now)).toBe("6h");
|
|
50
|
+
expect(relativeAge("2025-12-30T00:00:00Z", now)).toBe("3d");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("undefined/unparseable input is honest, not a crash", () => {
|
|
54
|
+
expect(relativeAge(undefined)).toBe("<unknown>");
|
|
55
|
+
expect(relativeAge("not-a-date")).toBe("<unknown>");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("renderTable", () => {
|
|
60
|
+
test("pads columns to the widest cell and trims trailing whitespace", () => {
|
|
61
|
+
const out = renderTable(["NAME", "AGE"], [["web", "3d"], ["a-much-longer-name", "1h"]]);
|
|
62
|
+
const lines = out.split("\n");
|
|
63
|
+
expect(lines[0]).toBe("NAME AGE");
|
|
64
|
+
expect(lines[2]).toBe("a-much-longer-name 1h");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("jsonpath (subset)", () => {
|
|
69
|
+
const root = { metadata: { name: "web" }, items: [{ metadata: { name: "a" } }, { metadata: { name: "b" } }] };
|
|
70
|
+
|
|
71
|
+
test("a dot path extracts a scalar", () => {
|
|
72
|
+
expect(evalJsonPath("{.metadata.name}", root)).toBe("web");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a wildcard collects and space-joins matches", () => {
|
|
76
|
+
expect(evalJsonPath("{.items[*].metadata.name}", root)).toBe("a b");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("an index selects one element", () => {
|
|
80
|
+
expect(evalJsonPath("{.items[1].metadata.name}", root)).toBe("b");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("literal text and quoted literals pass through untouched", () => {
|
|
84
|
+
expect(evalJsonPath('name={.metadata.name}{"!"}', root)).toBe("name=web!");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("renderJsonPath wraps >1 object in a synthetic List, matching kubectl", () => {
|
|
88
|
+
const objects = [
|
|
89
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "a" } },
|
|
90
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "b" } },
|
|
91
|
+
] as K8sObject[];
|
|
92
|
+
expect(renderJsonPath("{.items[*].metadata.name}", objects)).toBe("a b");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a single object is printed bare, not wrapped in a List", () => {
|
|
96
|
+
const objects = [{ apiVersion: "v1", kind: "Pod", metadata: { name: "a" } }] as K8sObject[];
|
|
97
|
+
expect(renderJsonPath("{.metadata.name}", objects)).toBe("a");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("custom-columns", () => {
|
|
102
|
+
test("renders a header per entry and evaluates its path per object", () => {
|
|
103
|
+
const objects = [
|
|
104
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "a", namespace: "prod" } },
|
|
105
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "b", namespace: "staging" } },
|
|
106
|
+
] as K8sObject[];
|
|
107
|
+
const out = renderCustomColumns("NAME:.metadata.name,NS:.metadata.namespace", objects);
|
|
108
|
+
expect(out).toContain("NAME");
|
|
109
|
+
expect(out).toContain("NS");
|
|
110
|
+
expect(out).toMatch(/a\s+prod/);
|
|
111
|
+
expect(out).toMatch(/b\s+staging/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a missing ':' is a clear parse error", () => {
|
|
115
|
+
expect(() => renderCustomColumns("NAME.metadata.name", [])).toThrow(/":"/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("json / yaml / name", () => {
|
|
120
|
+
const one = [{ apiVersion: "v1", kind: "Pod", metadata: { name: "a" } }] as K8sObject[];
|
|
121
|
+
const many = [
|
|
122
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "a" } },
|
|
123
|
+
{ apiVersion: "v1", kind: "Pod", metadata: { name: "b" } },
|
|
124
|
+
] as K8sObject[];
|
|
125
|
+
|
|
126
|
+
test("a single object prints bare in json/yaml", () => {
|
|
127
|
+
expect(JSON.parse(renderJson(one)).metadata.name).toBe("a");
|
|
128
|
+
expect(renderYaml(one)).toContain("name: a");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("multiple objects wrap in a List", () => {
|
|
132
|
+
expect(JSON.parse(renderJson(many)).kind).toBe("List");
|
|
133
|
+
expect(renderYaml(many)).toContain("kind: List");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("-o name renders <kind>.<group>/<name>, or <kind>/<name> for the core group", () => {
|
|
137
|
+
expect(renderName([{ apiVersion: "v1", kind: "Pod", metadata: { name: "a" } }] as K8sObject[])).toBe("pod/a");
|
|
138
|
+
expect(
|
|
139
|
+
renderName([{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "web" } }] as K8sObject[]),
|
|
140
|
+
).toBe("deployment.apps/web");
|
|
141
|
+
});
|
|
142
|
+
});
|