@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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output rendering for `chant kube get`/`describe` (chant #1079).
|
|
3
|
+
*
|
|
4
|
+
* kubectl's own `-o` vocabulary, minus the one form the issue says to drop
|
|
5
|
+
* openly rather than half-implement: `-o go-template[-file]` is Go template
|
|
6
|
+
* syntax, and chant does not carry a Go template engine — {@link parseOutput}
|
|
7
|
+
* refuses it by name rather than pretending a partial implementation covers
|
|
8
|
+
* it. `-o jsonpath` and `-o custom-columns` are "tractable" per the issue's
|
|
9
|
+
* own notes, and are implemented here as a deliberately small subset: dot
|
|
10
|
+
* paths, bracket indices, and a `[*]` wildcard — the paths kubectl one-liners
|
|
11
|
+
* actually use — not the full jsonpath grammar (filters, `range`/`end`
|
|
12
|
+
* templates). `-o chant` is chant's own addition: a live object rendered as
|
|
13
|
+
* the typed source that would declare it, reusing `./import/live-export.ts`
|
|
14
|
+
* and the lexicon's own `K8sGenerator` — the same machinery `chant import`
|
|
15
|
+
* uses, so every read is an adoption path, per the issue's framing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
19
|
+
import { dump } from "js-yaml";
|
|
20
|
+
import { buildExportFromObjects } from "../import/live-export";
|
|
21
|
+
import { K8sGenerator } from "../import/generator";
|
|
22
|
+
import type { KubeVerdict } from "./verdict";
|
|
23
|
+
|
|
24
|
+
export type OutputFormat =
|
|
25
|
+
| { kind: "text" }
|
|
26
|
+
| { kind: "wide" }
|
|
27
|
+
| { kind: "json" }
|
|
28
|
+
| { kind: "yaml" }
|
|
29
|
+
| { kind: "name" }
|
|
30
|
+
| { kind: "chant" }
|
|
31
|
+
| { kind: "jsonpath"; expr: string }
|
|
32
|
+
| { kind: "custom-columns"; spec: string };
|
|
33
|
+
|
|
34
|
+
/** Parse an `-o`/`--output` value. Throws on `go-template[-file]`. */
|
|
35
|
+
export function parseOutput(value: string | undefined): OutputFormat {
|
|
36
|
+
if (value === undefined || value === "" || value === "text") return { kind: "text" };
|
|
37
|
+
if (value === "wide") return { kind: "wide" };
|
|
38
|
+
if (value === "json") return { kind: "json" };
|
|
39
|
+
if (value === "yaml") return { kind: "yaml" };
|
|
40
|
+
if (value === "name") return { kind: "name" };
|
|
41
|
+
if (value === "chant") return { kind: "chant" };
|
|
42
|
+
if (value.startsWith("jsonpath=")) return { kind: "jsonpath", expr: value.slice("jsonpath=".length) };
|
|
43
|
+
if (value.startsWith("custom-columns=")) return { kind: "custom-columns", spec: value.slice("custom-columns=".length) };
|
|
44
|
+
if (value === "go-template" || value === "go-template-file" || value.startsWith("go-template")) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"-o go-template is not supported — chant kube does not carry a Go template engine, and a partial " +
|
|
47
|
+
"implementation would be worse than none. Use -o jsonpath=, -o custom-columns=, or -o chant instead.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unsupported -o value: ${value}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── table rendering (text / wide) ──────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface KubeRow {
|
|
56
|
+
namespace?: string;
|
|
57
|
+
name: string;
|
|
58
|
+
kind: string;
|
|
59
|
+
status: string;
|
|
60
|
+
age?: string;
|
|
61
|
+
verdict?: KubeVerdict;
|
|
62
|
+
labels?: Record<string, string>;
|
|
63
|
+
object: K8sObject;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** kubectl-style relative age, e.g. "45s", "12m", "3h", "5d". */
|
|
67
|
+
export function relativeAge(creationTimestamp: string | undefined, now: number = Date.now()): string {
|
|
68
|
+
if (!creationTimestamp) return "<unknown>";
|
|
69
|
+
const then = Date.parse(creationTimestamp);
|
|
70
|
+
if (Number.isNaN(then)) return "<unknown>";
|
|
71
|
+
const seconds = Math.max(0, Math.floor((now - then) / 1000));
|
|
72
|
+
if (seconds < 60) return `${seconds}s`;
|
|
73
|
+
const minutes = Math.floor(seconds / 60);
|
|
74
|
+
if (minutes < 60) return `${minutes}m`;
|
|
75
|
+
const hours = Math.floor(minutes / 60);
|
|
76
|
+
if (hours < 24) return `${hours}h`;
|
|
77
|
+
const days = Math.floor(hours / 24);
|
|
78
|
+
if (days < 365) return `${days}d`;
|
|
79
|
+
return `${Math.floor(days / 365)}y`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Render a fixed-width table. `columns` is header → per-row value. */
|
|
83
|
+
export function renderTable(headers: string[], rows: string[][]): string {
|
|
84
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
85
|
+
const line = (cells: string[]): string => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
|
|
86
|
+
return [line(headers), ...rows.map(line)].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderKubeTable(rows: KubeRow[], opts: { allNamespaces: boolean; wide: boolean; showVerdict: boolean }): string {
|
|
90
|
+
if (rows.length === 0) return "No resources found.";
|
|
91
|
+
const headers: string[] = [];
|
|
92
|
+
if (opts.allNamespaces) headers.push("NAMESPACE");
|
|
93
|
+
headers.push("NAME", "STATUS", "AGE");
|
|
94
|
+
if (opts.showVerdict) headers.push("CHANT");
|
|
95
|
+
if (opts.wide) headers.push("LABELS");
|
|
96
|
+
|
|
97
|
+
const cells = rows.map((r) => {
|
|
98
|
+
const row: string[] = [];
|
|
99
|
+
if (opts.allNamespaces) row.push(r.namespace ?? "");
|
|
100
|
+
row.push(r.name, r.status, r.age ?? "<unknown>");
|
|
101
|
+
if (opts.showVerdict) row.push(r.verdict ?? "unavailable");
|
|
102
|
+
if (opts.wide) row.push(labelsText(r.labels));
|
|
103
|
+
return row;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return renderTable(headers, cells);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function labelsText(labels: Record<string, string> | undefined): string {
|
|
110
|
+
if (!labels || Object.keys(labels).length === 0) return "<none>";
|
|
111
|
+
return Object.entries(labels)
|
|
112
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
113
|
+
.join(",");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── json / yaml / name ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** kubectl wraps >1 item in a synthetic List; a single item is printed bare. */
|
|
119
|
+
function asPrintable(objects: readonly K8sObject[]): unknown {
|
|
120
|
+
if (objects.length === 1) return objects[0];
|
|
121
|
+
return { apiVersion: "v1", kind: "List", items: objects, metadata: {} };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderJson(objects: readonly K8sObject[]): string {
|
|
125
|
+
return JSON.stringify(asPrintable(objects), null, 2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderYaml(objects: readonly K8sObject[]): string {
|
|
129
|
+
return dump(asPrintable(objects));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `kubectl get -o name`: `<lowercased-kind>.<group>/<name>` (`<lowercased-kind>/<name>` for the core group). */
|
|
133
|
+
export function renderName(objects: readonly K8sObject[]): string {
|
|
134
|
+
return objects
|
|
135
|
+
.map((o) => {
|
|
136
|
+
const [group] = (o.apiVersion ?? "").includes("/") ? [o.apiVersion!.split("/")[0]] : [""];
|
|
137
|
+
const kind = (o.kind ?? "").toLowerCase();
|
|
138
|
+
const prefix = group ? `${kind}.${group}` : kind;
|
|
139
|
+
return `${prefix}/${o.metadata?.name ?? ""}`;
|
|
140
|
+
})
|
|
141
|
+
.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── -o chant ────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Render live objects as the typed chant source that would declare them. */
|
|
147
|
+
export function renderChantSource(objects: readonly K8sObject[]): string {
|
|
148
|
+
const ir = buildExportFromObjects(objects as unknown[]);
|
|
149
|
+
const files = new K8sGenerator().generate(ir);
|
|
150
|
+
return files.map((f) => f.content).join("\n\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── jsonpath (subset) ───────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
type PathSegment = { key: string } | { index: number } | { wildcard: true };
|
|
156
|
+
|
|
157
|
+
function parsePath(path: string): PathSegment[] {
|
|
158
|
+
// ".a.b[0].c[*]" → segments; a leading "." (or none) both mean "from root".
|
|
159
|
+
const segments: PathSegment[] = [];
|
|
160
|
+
const re = /\.([^.[\]]+)|\[(\*|\d+)\]/g;
|
|
161
|
+
let match: RegExpExecArray | null;
|
|
162
|
+
while ((match = re.exec(path))) {
|
|
163
|
+
if (match[1] !== undefined) segments.push({ key: match[1] });
|
|
164
|
+
else if (match[2] === "*") segments.push({ wildcard: true });
|
|
165
|
+
else segments.push({ index: Number(match[2]) });
|
|
166
|
+
}
|
|
167
|
+
return segments;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function evalPath(root: unknown, segments: readonly PathSegment[]): unknown[] {
|
|
171
|
+
let current: unknown[] = [root];
|
|
172
|
+
for (const seg of segments) {
|
|
173
|
+
const next: unknown[] = [];
|
|
174
|
+
for (const value of current) {
|
|
175
|
+
if (value === null || value === undefined) continue;
|
|
176
|
+
if ("wildcard" in seg) {
|
|
177
|
+
if (Array.isArray(value)) next.push(...value);
|
|
178
|
+
else if (typeof value === "object") next.push(...Object.values(value as Record<string, unknown>));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if ("index" in seg) {
|
|
182
|
+
if (Array.isArray(value)) next.push(value[seg.index]);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === "object") next.push((value as Record<string, unknown>)[seg.key]);
|
|
186
|
+
}
|
|
187
|
+
current = next;
|
|
188
|
+
}
|
|
189
|
+
return current;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function scalarText(v: unknown): string {
|
|
193
|
+
if (v === undefined || v === null) return "";
|
|
194
|
+
if (typeof v === "string") return v;
|
|
195
|
+
return JSON.stringify(v);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Evaluate a kubectl-style jsonpath template against one root value: literal
|
|
200
|
+
* text passes through, `{"..."}` is a quoted literal, `{.a.b[*].c}` extracts
|
|
201
|
+
* and space-joins matches. Multiple `{...}` blocks concatenate directly, as
|
|
202
|
+
* kubectl's own `-o jsonpath` does.
|
|
203
|
+
*/
|
|
204
|
+
export function evalJsonPath(template: string, root: unknown): string {
|
|
205
|
+
let out = "";
|
|
206
|
+
const re = /\{([^}]*)\}/g;
|
|
207
|
+
let lastIndex = 0;
|
|
208
|
+
let match: RegExpExecArray | null;
|
|
209
|
+
while ((match = re.exec(template))) {
|
|
210
|
+
out += template.slice(lastIndex, match.index);
|
|
211
|
+
const inner = match[1];
|
|
212
|
+
if (inner.startsWith('"') && inner.endsWith('"')) {
|
|
213
|
+
out += inner.slice(1, -1);
|
|
214
|
+
} else {
|
|
215
|
+
const segments = parsePath(inner);
|
|
216
|
+
out += evalPath(root, segments).map(scalarText).join(" ");
|
|
217
|
+
}
|
|
218
|
+
lastIndex = match.index + match[0].length;
|
|
219
|
+
}
|
|
220
|
+
out += template.slice(lastIndex);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `-o jsonpath=`: kubectl runs the whole template once per top-level object printed. */
|
|
225
|
+
export function renderJsonPath(expr: string, objects: readonly K8sObject[]): string {
|
|
226
|
+
const root = asPrintable(objects);
|
|
227
|
+
return evalJsonPath(expr, root);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── custom-columns ──────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
interface ColumnSpec {
|
|
233
|
+
header: string;
|
|
234
|
+
path: PathSegment[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseCustomColumns(spec: string): ColumnSpec[] {
|
|
238
|
+
return spec.split(",").map((entry) => {
|
|
239
|
+
const colon = entry.indexOf(":");
|
|
240
|
+
if (colon === -1) throw new Error(`custom-columns entry "${entry}" is missing ":" — expected HEADER:.json.path`);
|
|
241
|
+
const header = entry.slice(0, colon);
|
|
242
|
+
const path = entry.slice(colon + 1);
|
|
243
|
+
return { header, path: parsePath(path.startsWith(".") ? path : `.${path}`) };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function renderCustomColumns(spec: string, objects: readonly K8sObject[]): string {
|
|
248
|
+
const columns = parseCustomColumns(spec);
|
|
249
|
+
const headers = columns.map((c) => c.header);
|
|
250
|
+
const rows = objects.map((o) => columns.map((c) => evalPath(o, c.path).map(scalarText).join(",") || "<none>"));
|
|
251
|
+
return renderTable(headers, rows);
|
|
252
|
+
}
|
|
@@ -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
|
+
}
|