@oasissys/k8s-cli 0.1.2
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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/bin/oasis-k8s.d.ts +2 -0
- package/dist/bin/oasis-k8s.js +4 -0
- package/dist/bin/oasis-k8s.js.map +1 -0
- package/dist/src/axi.d.ts +122 -0
- package/dist/src/axi.js +406 -0
- package/dist/src/axi.js.map +1 -0
- package/dist/src/cli.d.ts +16 -0
- package/dist/src/cli.js +768 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/client.d.ts +158 -0
- package/dist/src/client.js +185 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/config.js +20 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/storage.d.ts +22 -0
- package/dist/src/storage.js +88 -0
- package/dist/src/storage.js.map +1 -0
- package/dist/src/toon.d.ts +19 -0
- package/dist/src/toon.js +148 -0
- package/dist/src/toon.js.map +1 -0
- package/package.json +55 -0
- package/skills/k8s-cli/SKILL.md +63 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oasis-k8s — AXI CLI for read-only Kubernetes access across the Oasis clusters.
|
|
3
|
+
*
|
|
4
|
+
* Talks straight to each cluster's kube-apiserver with a ServiceAccount bearer
|
|
5
|
+
* token (no kubeconfig, no certs). Multiple clusters are saved by name in
|
|
6
|
+
* ~/.oasis.json (shared store) with one marked active — same model as
|
|
7
|
+
* oasis-sqlcl connections. Every command is read-only.
|
|
8
|
+
*
|
|
9
|
+
* Output is TOON on stdout; diagnostics on stderr; exit codes 0/1/2
|
|
10
|
+
* (success/error/usage). See ./axi and `oasis-k8s --help`.
|
|
11
|
+
*/
|
|
12
|
+
import { K8sClient, K8sError, normalizeKind, SUPPORTED_KINDS, } from "./client.js";
|
|
13
|
+
import { loadSection, saveSection } from "./storage.js";
|
|
14
|
+
import { EXIT, emit, emitRaw, fail, binPath, parseArgs, flagStr, flagBool, flagInt, renderSkillDoc, runSetup, } from "./axi.js";
|
|
15
|
+
const DESCRIPTION = "Read-only Kubernetes access across the Oasis clusters (token-only, multi-cluster)";
|
|
16
|
+
const BIN = "oasis-k8s";
|
|
17
|
+
const LOGS_TAIL_DEFAULT = 200; // log lines shown when --tail is omitted
|
|
18
|
+
function loadClusters() {
|
|
19
|
+
return loadSection("k8s").clusters ?? {};
|
|
20
|
+
}
|
|
21
|
+
function getActive() {
|
|
22
|
+
return loadSection("k8s").active;
|
|
23
|
+
}
|
|
24
|
+
function setActive(name) {
|
|
25
|
+
return saveSection("k8s", s => {
|
|
26
|
+
s.active = name;
|
|
27
|
+
return s;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function saveCluster(name, cluster) {
|
|
31
|
+
return saveSection("k8s", s => {
|
|
32
|
+
s.clusters = { ...s.clusters, [name]: cluster };
|
|
33
|
+
return s;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function deleteCluster(name) {
|
|
37
|
+
return saveSection("k8s", s => {
|
|
38
|
+
if (s.clusters)
|
|
39
|
+
delete s.clusters[name];
|
|
40
|
+
if (s.active === name)
|
|
41
|
+
delete s.active;
|
|
42
|
+
return s;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Resolve which cluster to target: --cluster > active > sole cluster > error. */
|
|
46
|
+
function resolveCluster(parsed) {
|
|
47
|
+
const clusters = loadClusters();
|
|
48
|
+
const names = Object.keys(clusters);
|
|
49
|
+
const explicit = flagStr(parsed, "--cluster");
|
|
50
|
+
if (explicit) {
|
|
51
|
+
if (!clusters[explicit]) {
|
|
52
|
+
fail(`cluster '${explicit}' not found`, `Run \`${BIN} clusters\` to list saved clusters`);
|
|
53
|
+
}
|
|
54
|
+
return { name: explicit, cluster: clusters[explicit] };
|
|
55
|
+
}
|
|
56
|
+
const active = getActive();
|
|
57
|
+
if (active && clusters[active])
|
|
58
|
+
return { name: active, cluster: clusters[active] };
|
|
59
|
+
if (names.length === 1)
|
|
60
|
+
return { name: names[0], cluster: clusters[names[0]] };
|
|
61
|
+
if (names.length === 0) {
|
|
62
|
+
fail("no saved clusters", `Add one with \`${BIN} add <name> --url <apiserver> --token <token>\``);
|
|
63
|
+
}
|
|
64
|
+
fail("no active cluster selected", `Run \`${BIN} use <name>\` to pick one, or pass \`--cluster <name>\``);
|
|
65
|
+
}
|
|
66
|
+
function getClient(parsed) {
|
|
67
|
+
const { name, cluster } = resolveCluster(parsed);
|
|
68
|
+
return { name, client: new K8sClient(cluster.url, cluster.token) };
|
|
69
|
+
}
|
|
70
|
+
/** Map a K8sError into an AXI error and exit — 401/403 get actionable advice. */
|
|
71
|
+
function bail(err, ctx) {
|
|
72
|
+
if (err instanceof K8sError) {
|
|
73
|
+
if (err.forbidden) {
|
|
74
|
+
fail(`forbidden: the '${ctx.cluster}' token lacks RBAC to ${ctx.what}`, "This ServiceAccount is scoped — use a token with broader read access, or try a resource it can read");
|
|
75
|
+
}
|
|
76
|
+
if (err.unauthorized) {
|
|
77
|
+
fail(`unauthorized on '${ctx.cluster}' (token rejected or expired)`, `Re-add the cluster with a fresh token: \`${BIN} add ${ctx.cluster} --url <apiserver> --token <token>\``);
|
|
78
|
+
}
|
|
79
|
+
if (err.notFound)
|
|
80
|
+
fail(`not found: ${ctx.what} on '${ctx.cluster}'`);
|
|
81
|
+
fail(`k8s error on '${ctx.cluster}': ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
84
|
+
}
|
|
85
|
+
// ─── Formatting helpers ──────────────────────────────────────────────
|
|
86
|
+
/** kubectl-style compact age from a creation timestamp (e.g. 5d, 3h, 20m, 45s). */
|
|
87
|
+
function age(iso) {
|
|
88
|
+
if (!iso)
|
|
89
|
+
return "-";
|
|
90
|
+
const then = Date.parse(iso);
|
|
91
|
+
if (!Number.isFinite(then))
|
|
92
|
+
return "-";
|
|
93
|
+
const sec = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
94
|
+
if (sec < 60)
|
|
95
|
+
return `${sec}s`;
|
|
96
|
+
const min = Math.floor(sec / 60);
|
|
97
|
+
if (min < 60)
|
|
98
|
+
return `${min}m`;
|
|
99
|
+
const hr = Math.floor(min / 60);
|
|
100
|
+
if (hr < 48)
|
|
101
|
+
return `${hr}h`;
|
|
102
|
+
return `${Math.floor(hr / 24)}d`;
|
|
103
|
+
}
|
|
104
|
+
function podReady(pod) {
|
|
105
|
+
const cs = pod.status?.containerStatuses ?? [];
|
|
106
|
+
const total = pod.spec?.containers?.length ?? cs.length;
|
|
107
|
+
const ready = cs.filter(c => c.ready).length;
|
|
108
|
+
return `${ready}/${total}`;
|
|
109
|
+
}
|
|
110
|
+
function podRestarts(pod) {
|
|
111
|
+
return (pod.status?.containerStatuses ?? []).reduce((n, c) => n + (c.restartCount ?? 0), 0);
|
|
112
|
+
}
|
|
113
|
+
/** Refine a pod's phase with the real waiting/terminated reason (CrashLoopBackOff etc). */
|
|
114
|
+
function podStatus(pod) {
|
|
115
|
+
const cs = pod.status?.containerStatuses ?? [];
|
|
116
|
+
for (const c of cs) {
|
|
117
|
+
const waiting = c.state?.waiting?.reason;
|
|
118
|
+
const terminated = c.state?.terminated?.reason;
|
|
119
|
+
if (waiting && waiting !== "ContainerCreating")
|
|
120
|
+
return waiting;
|
|
121
|
+
if (terminated && terminated !== "Completed")
|
|
122
|
+
return terminated;
|
|
123
|
+
}
|
|
124
|
+
return pod.status?.phase ?? "Unknown";
|
|
125
|
+
}
|
|
126
|
+
function nodeReady(node) {
|
|
127
|
+
const cond = (node.status?.conditions ?? []).find(c => c.type === "Ready");
|
|
128
|
+
if (!cond)
|
|
129
|
+
return "Unknown";
|
|
130
|
+
return cond.status === "True" ? "Ready" : "NotReady";
|
|
131
|
+
}
|
|
132
|
+
function deployReady(d) {
|
|
133
|
+
const want = d.spec?.replicas ?? d.status?.replicas ?? 0;
|
|
134
|
+
const ready = d.status?.readyReplicas ?? 0;
|
|
135
|
+
return `${ready}/${want}`;
|
|
136
|
+
}
|
|
137
|
+
function svcPorts(s) {
|
|
138
|
+
const ports = s.spec?.ports ?? [];
|
|
139
|
+
if (ports.length === 0)
|
|
140
|
+
return "-";
|
|
141
|
+
return ports
|
|
142
|
+
.map(p => (p.nodePort ? `${p.port}:${p.nodePort}/${p.protocol ?? "TCP"}` : `${p.port}/${p.protocol ?? "TCP"}`))
|
|
143
|
+
.join(",");
|
|
144
|
+
}
|
|
145
|
+
function eventTime(e) {
|
|
146
|
+
return e.lastTimestamp ?? e.eventTime ?? e.metadata?.creationTimestamp ?? "-";
|
|
147
|
+
}
|
|
148
|
+
/** Namespace flag shared by list commands: `--namespace <ns>`; absent = all namespaces. */
|
|
149
|
+
function nsOf(parsed) {
|
|
150
|
+
return flagStr(parsed, "--namespace");
|
|
151
|
+
}
|
|
152
|
+
function withTimeout(p, ms) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const t = setTimeout(() => reject(new Error("timeout")), ms);
|
|
155
|
+
p.then(v => {
|
|
156
|
+
clearTimeout(t);
|
|
157
|
+
resolve(v);
|
|
158
|
+
}, e => {
|
|
159
|
+
clearTimeout(t);
|
|
160
|
+
reject(e);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// ─── Cluster-management commands ─────────────────────────────────────
|
|
165
|
+
function clusterRows(clusters) {
|
|
166
|
+
return Object.entries(clusters).map(([name, c]) => ({ name, url: c.url }));
|
|
167
|
+
}
|
|
168
|
+
async function cmdHome() {
|
|
169
|
+
const clusters = loadClusters();
|
|
170
|
+
const names = Object.keys(clusters);
|
|
171
|
+
const active = getActive();
|
|
172
|
+
const doc = {
|
|
173
|
+
bin: binPath(),
|
|
174
|
+
description: DESCRIPTION,
|
|
175
|
+
active: active && clusters[active] ? active : names.length === 1 ? `${names[0]} (only cluster)` : "none",
|
|
176
|
+
count: names.length,
|
|
177
|
+
};
|
|
178
|
+
if (names.length === 0) {
|
|
179
|
+
doc.clusters = [];
|
|
180
|
+
doc.help = [`Run \`${BIN} add <name> --url <apiserver> --token <token>\` to register a cluster`];
|
|
181
|
+
emit(doc);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
doc.clusters = clusterRows(clusters);
|
|
185
|
+
doc.help = [
|
|
186
|
+
`Run \`${BIN} use <name>\` to switch the active cluster`,
|
|
187
|
+
`Run \`${BIN} pods\` / \`${BIN} nodes\` / \`${BIN} namespaces\` to inspect the active cluster`,
|
|
188
|
+
`Run \`${BIN} logs <pod> --namespace <ns>\` to read container logs`,
|
|
189
|
+
];
|
|
190
|
+
emit(doc);
|
|
191
|
+
}
|
|
192
|
+
async function cmdClusters() {
|
|
193
|
+
const clusters = loadClusters();
|
|
194
|
+
const active = getActive();
|
|
195
|
+
if (Object.keys(clusters).length === 0) {
|
|
196
|
+
emit({ clusters: [] });
|
|
197
|
+
emitRaw("clusters: 0 saved clusters");
|
|
198
|
+
emit({ help: [`Run \`${BIN} add <name> --url <apiserver> --token <token>\` to add one`] });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
emit({
|
|
202
|
+
active: active && clusters[active] ? active : "none",
|
|
203
|
+
count: Object.keys(clusters).length,
|
|
204
|
+
clusters: clusterRows(clusters),
|
|
205
|
+
help: [`Run \`${BIN} use <name>\` to set the active cluster`],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function cmdUse(parsed) {
|
|
209
|
+
const name = parsed.positionals[0];
|
|
210
|
+
if (!name)
|
|
211
|
+
fail("cluster name is required", `${BIN} use <name>`, EXIT.USAGE);
|
|
212
|
+
if (!loadClusters()[name])
|
|
213
|
+
fail(`cluster '${name}' not found`, `Run \`${BIN} clusters\` to list saved clusters`);
|
|
214
|
+
if (getActive() === name) {
|
|
215
|
+
emit({ active: name, note: "already active (no-op)" });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await setActive(name);
|
|
219
|
+
emit({ active: name });
|
|
220
|
+
}
|
|
221
|
+
async function cmdAdd(parsed) {
|
|
222
|
+
const name = parsed.positionals[0];
|
|
223
|
+
if (!name)
|
|
224
|
+
fail("cluster name is required", `${BIN} add <name> --url <apiserver> --token <token>`, EXIT.USAGE);
|
|
225
|
+
const url = flagStr(parsed, "--url");
|
|
226
|
+
const token = flagStr(parsed, "--token");
|
|
227
|
+
const missing = [!url && "--url", !token && "--token"].filter(Boolean);
|
|
228
|
+
if (missing.length) {
|
|
229
|
+
fail(`missing required flag(s): ${missing.join(", ")}`, `${BIN} add ${name} --url <apiserver> --token <token>`, EXIT.USAGE);
|
|
230
|
+
}
|
|
231
|
+
if (!/^https?:\/\//.test(url)) {
|
|
232
|
+
fail(`--url must be the kube-apiserver base URL, e.g. https://<host>:6443 (got '${url}')`, "Do NOT use the Dashboard URL (the :3xxxx /#/login page) — that is the web UI, not the API", EXIT.USAGE);
|
|
233
|
+
}
|
|
234
|
+
// Verify the token reaches the apiserver before persisting (fail with the real reason).
|
|
235
|
+
const client = new K8sClient(url, token);
|
|
236
|
+
let version;
|
|
237
|
+
try {
|
|
238
|
+
version = await client.version();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const reason = err instanceof K8sError ? err.message : err instanceof Error ? err.message : String(err);
|
|
242
|
+
fail(`could not reach '${name}' at ${url}: ${reason}`, "Check the apiserver URL and that the token is valid");
|
|
243
|
+
}
|
|
244
|
+
await saveCluster(name, { url: url, token: token });
|
|
245
|
+
if (!getActive())
|
|
246
|
+
await setActive(name);
|
|
247
|
+
emit({ added: name, url, server: version.gitVersion ?? "?", active: getActive() === name });
|
|
248
|
+
}
|
|
249
|
+
async function cmdRm(parsed) {
|
|
250
|
+
const name = parsed.positionals[0];
|
|
251
|
+
if (!name)
|
|
252
|
+
fail("cluster name is required", `${BIN} rm <name>`, EXIT.USAGE);
|
|
253
|
+
if (!loadClusters()[name]) {
|
|
254
|
+
emit({ removed: name, note: "not found (no-op)" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
await deleteCluster(name);
|
|
258
|
+
emit({ removed: name });
|
|
259
|
+
}
|
|
260
|
+
// ─── Read commands ───────────────────────────────────────────────────
|
|
261
|
+
async function cmdNamespaces(parsed) {
|
|
262
|
+
const { name, client } = getClient(parsed);
|
|
263
|
+
let list;
|
|
264
|
+
try {
|
|
265
|
+
list = await client.namespaces();
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
bail(err, { cluster: name, what: "list namespaces" });
|
|
269
|
+
}
|
|
270
|
+
emit({
|
|
271
|
+
cluster: name,
|
|
272
|
+
count: list.items.length,
|
|
273
|
+
namespaces: list.items.map(n => ({
|
|
274
|
+
name: n.metadata?.name ?? "?",
|
|
275
|
+
status: n.status?.phase ?? "?",
|
|
276
|
+
age: age(n.metadata?.creationTimestamp),
|
|
277
|
+
})),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function cmdNodes(parsed) {
|
|
281
|
+
const { name, client } = getClient(parsed);
|
|
282
|
+
let list;
|
|
283
|
+
try {
|
|
284
|
+
list = await client.nodes();
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
bail(err, { cluster: name, what: "list nodes" });
|
|
288
|
+
}
|
|
289
|
+
emit({
|
|
290
|
+
cluster: name,
|
|
291
|
+
count: list.items.length,
|
|
292
|
+
nodes: list.items.map(n => ({
|
|
293
|
+
name: n.metadata?.name ?? "?",
|
|
294
|
+
status: nodeReady(n),
|
|
295
|
+
version: n.status?.nodeInfo?.kubeletVersion ?? "-",
|
|
296
|
+
age: age(n.metadata?.creationTimestamp),
|
|
297
|
+
})),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function cmdPods(parsed) {
|
|
301
|
+
const ns = nsOf(parsed);
|
|
302
|
+
const { name, client } = getClient(parsed);
|
|
303
|
+
let list;
|
|
304
|
+
try {
|
|
305
|
+
list = await client.pods(ns);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
bail(err, { cluster: name, what: ns ? `list pods in '${ns}'` : "list pods (all namespaces)" });
|
|
309
|
+
}
|
|
310
|
+
const rows = list.items.map(p => ({
|
|
311
|
+
namespace: p.metadata?.namespace ?? "-",
|
|
312
|
+
name: p.metadata?.name ?? "?",
|
|
313
|
+
ready: podReady(p),
|
|
314
|
+
status: podStatus(p),
|
|
315
|
+
restarts: podRestarts(p),
|
|
316
|
+
age: age(p.metadata?.creationTimestamp),
|
|
317
|
+
node: p.spec?.nodeName ?? "-",
|
|
318
|
+
}));
|
|
319
|
+
emit({
|
|
320
|
+
cluster: name,
|
|
321
|
+
scope: ns ? `namespace '${ns}'` : "all namespaces",
|
|
322
|
+
count: rows.length,
|
|
323
|
+
pods: rows,
|
|
324
|
+
help: rows.length > 0
|
|
325
|
+
? [`Run \`${BIN} logs <pod> --namespace <ns>\` to read a pod's logs`]
|
|
326
|
+
: undefined,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async function cmdDeployments(parsed) {
|
|
330
|
+
const ns = nsOf(parsed);
|
|
331
|
+
const { name, client } = getClient(parsed);
|
|
332
|
+
let list;
|
|
333
|
+
try {
|
|
334
|
+
list = await client.deployments(ns);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
bail(err, { cluster: name, what: ns ? `list deployments in '${ns}'` : "list deployments (all namespaces)" });
|
|
338
|
+
}
|
|
339
|
+
emit({
|
|
340
|
+
cluster: name,
|
|
341
|
+
scope: ns ? `namespace '${ns}'` : "all namespaces",
|
|
342
|
+
count: list.items.length,
|
|
343
|
+
deployments: list.items.map(d => ({
|
|
344
|
+
namespace: d.metadata?.namespace ?? "-",
|
|
345
|
+
name: d.metadata?.name ?? "?",
|
|
346
|
+
ready: deployReady(d),
|
|
347
|
+
upToDate: d.status?.updatedReplicas ?? 0,
|
|
348
|
+
available: d.status?.availableReplicas ?? 0,
|
|
349
|
+
age: age(d.metadata?.creationTimestamp),
|
|
350
|
+
})),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
async function cmdServices(parsed) {
|
|
354
|
+
const ns = nsOf(parsed);
|
|
355
|
+
const { name, client } = getClient(parsed);
|
|
356
|
+
let list;
|
|
357
|
+
try {
|
|
358
|
+
list = await client.services(ns);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
bail(err, { cluster: name, what: ns ? `list services in '${ns}'` : "list services (all namespaces)" });
|
|
362
|
+
}
|
|
363
|
+
emit({
|
|
364
|
+
cluster: name,
|
|
365
|
+
scope: ns ? `namespace '${ns}'` : "all namespaces",
|
|
366
|
+
count: list.items.length,
|
|
367
|
+
services: list.items.map(s => ({
|
|
368
|
+
namespace: s.metadata?.namespace ?? "-",
|
|
369
|
+
name: s.metadata?.name ?? "?",
|
|
370
|
+
type: s.spec?.type ?? "ClusterIP",
|
|
371
|
+
clusterIP: s.spec?.clusterIP ?? "-",
|
|
372
|
+
ports: svcPorts(s),
|
|
373
|
+
age: age(s.metadata?.creationTimestamp),
|
|
374
|
+
})),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
async function cmdEvents(parsed) {
|
|
378
|
+
const ns = nsOf(parsed);
|
|
379
|
+
const { name, client } = getClient(parsed);
|
|
380
|
+
let list;
|
|
381
|
+
try {
|
|
382
|
+
list = await client.events(ns);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
bail(err, { cluster: name, what: ns ? `list events in '${ns}'` : "list events (all namespaces)" });
|
|
386
|
+
}
|
|
387
|
+
// Newest last — sort ascending by timestamp so the tail is the most recent.
|
|
388
|
+
const sorted = [...list.items].sort((a, b) => eventTime(a).localeCompare(eventTime(b)));
|
|
389
|
+
emit({
|
|
390
|
+
cluster: name,
|
|
391
|
+
scope: ns ? `namespace '${ns}'` : "all namespaces",
|
|
392
|
+
count: sorted.length,
|
|
393
|
+
events: sorted.map(e => ({
|
|
394
|
+
when: eventTime(e),
|
|
395
|
+
type: e.type ?? "-",
|
|
396
|
+
reason: e.reason ?? "-",
|
|
397
|
+
object: `${e.involvedObject?.kind ?? "?"}/${e.involvedObject?.name ?? "?"}`,
|
|
398
|
+
namespace: e.involvedObject?.namespace ?? e.metadata?.namespace ?? "-",
|
|
399
|
+
message: (e.message ?? "").replace(/\s+/g, " ").trim(),
|
|
400
|
+
})),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async function cmdLogs(parsed) {
|
|
404
|
+
const pod = parsed.positionals[0];
|
|
405
|
+
if (!pod)
|
|
406
|
+
fail("pod name is required", `${BIN} logs <pod> --namespace <ns>`, EXIT.USAGE);
|
|
407
|
+
const ns = nsOf(parsed);
|
|
408
|
+
if (!ns)
|
|
409
|
+
fail("--namespace is required for logs", `${BIN} logs ${pod} --namespace <ns>`, EXIT.USAGE);
|
|
410
|
+
const container = flagStr(parsed, "--container");
|
|
411
|
+
const previous = flagBool(parsed, "--previous");
|
|
412
|
+
const tail = flagInt(parsed, "--tail") ?? LOGS_TAIL_DEFAULT;
|
|
413
|
+
const { name, client } = getClient(parsed);
|
|
414
|
+
let text;
|
|
415
|
+
try {
|
|
416
|
+
text = await client.podLogs(ns, pod, { container, tailLines: tail, previous });
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
bail(err, { cluster: name, what: `read logs of pod '${pod}' in '${ns}'` });
|
|
420
|
+
}
|
|
421
|
+
const lines = text.replace(/\s+$/, "").split(/\r?\n/);
|
|
422
|
+
const nonEmpty = lines.length === 1 && lines[0] === "" ? 0 : lines.length;
|
|
423
|
+
emit({
|
|
424
|
+
cluster: name,
|
|
425
|
+
namespace: ns,
|
|
426
|
+
pod,
|
|
427
|
+
container: container ?? "(default)",
|
|
428
|
+
lines: nonEmpty,
|
|
429
|
+
tail: previous ? `${tail} (previous instance)` : String(tail),
|
|
430
|
+
});
|
|
431
|
+
emitRaw("");
|
|
432
|
+
emitRaw(nonEmpty === 0 ? "(no log output)" : lines.join("\n"));
|
|
433
|
+
}
|
|
434
|
+
async function cmdDescribe(parsed) {
|
|
435
|
+
const kindRaw = parsed.positionals[0];
|
|
436
|
+
const objName = parsed.positionals[1];
|
|
437
|
+
if (!kindRaw || !objName) {
|
|
438
|
+
fail("kind and name are required", `${BIN} describe <kind> <name> [--namespace <ns>]`, EXIT.USAGE);
|
|
439
|
+
}
|
|
440
|
+
const kind = normalizeKind(kindRaw);
|
|
441
|
+
if (!kind) {
|
|
442
|
+
fail(`unsupported kind '${kindRaw}'`, `supported kinds: ${SUPPORTED_KINDS.join(", ")}`, EXIT.USAGE);
|
|
443
|
+
}
|
|
444
|
+
const ns = nsOf(parsed);
|
|
445
|
+
const { name, client } = getClient(parsed);
|
|
446
|
+
let obj;
|
|
447
|
+
try {
|
|
448
|
+
obj = await client.getObject(kind, objName, ns);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (err instanceof K8sError && err.status === 400) {
|
|
452
|
+
fail(err.message, `${BIN} describe ${kind} ${objName} --namespace <ns>`, EXIT.USAGE);
|
|
453
|
+
}
|
|
454
|
+
bail(err, { cluster: name, what: `describe ${kind} '${objName}'` });
|
|
455
|
+
}
|
|
456
|
+
const meta = (obj.metadata ?? {});
|
|
457
|
+
emit({
|
|
458
|
+
cluster: name,
|
|
459
|
+
kind,
|
|
460
|
+
name: meta.name ?? objName,
|
|
461
|
+
namespace: meta.namespace ?? ns ?? "-",
|
|
462
|
+
// Full object as raw JSON below — TOON would lose fidelity on deep k8s specs.
|
|
463
|
+
});
|
|
464
|
+
emitRaw("");
|
|
465
|
+
emitRaw(JSON.stringify(obj, null, 2));
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Ambient session-start snapshot (AXI §7). Never fails loudly: on no clusters
|
|
469
|
+
* or an unreachable apiserver it prints nothing and exits 0, so session start
|
|
470
|
+
* is never blocked or polluted.
|
|
471
|
+
*/
|
|
472
|
+
async function cmdAmbient() {
|
|
473
|
+
const clusters = loadClusters();
|
|
474
|
+
const names = Object.keys(clusters);
|
|
475
|
+
if (names.length === 0)
|
|
476
|
+
return;
|
|
477
|
+
const active = getActive();
|
|
478
|
+
const bin = binPath();
|
|
479
|
+
emitRaw(`oasis-k8s CLI ready — invoke: ${bin} (append \`help\` for all commands)`);
|
|
480
|
+
const doc = {
|
|
481
|
+
active: active && clusters[active] ? active : names.length === 1 ? names[0] : "none",
|
|
482
|
+
count: names.length,
|
|
483
|
+
clusters: clusterRows(clusters),
|
|
484
|
+
help: [
|
|
485
|
+
`Run \`${bin} use <name>\` to switch clusters`,
|
|
486
|
+
`Run \`${bin} pods\` / \`${bin} nodes\` to inspect the active cluster`,
|
|
487
|
+
],
|
|
488
|
+
};
|
|
489
|
+
// Best-effort reachability of the active cluster — silent on any failure.
|
|
490
|
+
const target = active && clusters[active] ? active : names.length === 1 ? names[0] : undefined;
|
|
491
|
+
if (target) {
|
|
492
|
+
try {
|
|
493
|
+
const v = await withTimeout(new K8sClient(clusters[target].url, clusters[target].token).version(), 2500);
|
|
494
|
+
doc.activeVersion = v.gitVersion ?? "?";
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
/* unreachable — omit version, still show the registry */
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
emit(doc);
|
|
501
|
+
}
|
|
502
|
+
// ─── Help ────────────────────────────────────────────────────────────
|
|
503
|
+
const HELP = {
|
|
504
|
+
add: [
|
|
505
|
+
`${BIN} add <name> — register a cluster (verified against /version before saving)`,
|
|
506
|
+
"",
|
|
507
|
+
"flags:",
|
|
508
|
+
" --url <apiserver> kube-apiserver base URL, e.g. https://128.55.0.212:6443",
|
|
509
|
+
" --token <token> ServiceAccount bearer token",
|
|
510
|
+
"",
|
|
511
|
+
"Note: use the API server (:6443), NOT the Dashboard UI (:3xxxx /#/login).",
|
|
512
|
+
"TLS verification is skipped (self-signed internal CAs).",
|
|
513
|
+
"",
|
|
514
|
+
"examples:",
|
|
515
|
+
` ${BIN} add jeddah --url https://128.55.0.212:6443 --token eyJhbGci...`,
|
|
516
|
+
].join("\n"),
|
|
517
|
+
clusters: [`${BIN} clusters — list saved clusters and the active one`, "", "examples:", ` ${BIN} clusters`].join("\n"),
|
|
518
|
+
use: [`${BIN} use <name> — set the active cluster`, "", "examples:", ` ${BIN} use jordan`].join("\n"),
|
|
519
|
+
rm: [`${BIN} rm <name> — remove a saved cluster`, "", "examples:", ` ${BIN} rm jeddah`].join("\n"),
|
|
520
|
+
pods: [
|
|
521
|
+
`${BIN} pods — list pods on the active cluster`,
|
|
522
|
+
"",
|
|
523
|
+
"flags:",
|
|
524
|
+
" --namespace <ns> restrict to one namespace (default: all namespaces)",
|
|
525
|
+
" --cluster <name> target a specific cluster instead of the active one",
|
|
526
|
+
"",
|
|
527
|
+
"examples:",
|
|
528
|
+
` ${BIN} pods`,
|
|
529
|
+
` ${BIN} pods --namespace kube-system`,
|
|
530
|
+
].join("\n"),
|
|
531
|
+
namespaces: [`${BIN} namespaces — list namespaces on the active cluster`, "", "examples:", ` ${BIN} namespaces`].join("\n"),
|
|
532
|
+
nodes: [`${BIN} nodes — list nodes (status + kubelet version)`, "", "examples:", ` ${BIN} nodes`].join("\n"),
|
|
533
|
+
deployments: [
|
|
534
|
+
`${BIN} deployments — list deployments (ready/available replicas)`,
|
|
535
|
+
"",
|
|
536
|
+
"flags:",
|
|
537
|
+
" --namespace <ns> restrict to one namespace (default: all namespaces)",
|
|
538
|
+
"",
|
|
539
|
+
"examples:",
|
|
540
|
+
` ${BIN} deployments --namespace default`,
|
|
541
|
+
].join("\n"),
|
|
542
|
+
services: [
|
|
543
|
+
`${BIN} services — list services (type, clusterIP, ports)`,
|
|
544
|
+
"",
|
|
545
|
+
"flags:",
|
|
546
|
+
" --namespace <ns> restrict to one namespace (default: all namespaces)",
|
|
547
|
+
"",
|
|
548
|
+
"examples:",
|
|
549
|
+
` ${BIN} services --namespace default`,
|
|
550
|
+
].join("\n"),
|
|
551
|
+
events: [
|
|
552
|
+
`${BIN} events — list events (newest last)`,
|
|
553
|
+
"",
|
|
554
|
+
"flags:",
|
|
555
|
+
" --namespace <ns> restrict to one namespace (default: all namespaces)",
|
|
556
|
+
"",
|
|
557
|
+
"examples:",
|
|
558
|
+
` ${BIN} events --namespace default`,
|
|
559
|
+
].join("\n"),
|
|
560
|
+
logs: [
|
|
561
|
+
`${BIN} logs <pod> — print a pod's container logs (tail by default)`,
|
|
562
|
+
"",
|
|
563
|
+
"flags:",
|
|
564
|
+
" --namespace <ns> namespace of the pod (required)",
|
|
565
|
+
" --container <name> container to read (default: first container)",
|
|
566
|
+
` --tail <n> show the last n lines (default ${LOGS_TAIL_DEFAULT})`,
|
|
567
|
+
" --previous logs from the previous (crashed) instance",
|
|
568
|
+
"",
|
|
569
|
+
"examples:",
|
|
570
|
+
` ${BIN} logs my-pod --namespace default`,
|
|
571
|
+
` ${BIN} logs my-pod --namespace default --container app --tail 50`,
|
|
572
|
+
].join("\n"),
|
|
573
|
+
describe: [
|
|
574
|
+
`${BIN} describe <kind> <name> — dump one object as JSON`,
|
|
575
|
+
"",
|
|
576
|
+
`kinds: ${SUPPORTED_KINDS.join(", ")} (plurals/aliases ok: po, svc, deploy, ns, no)`,
|
|
577
|
+
"",
|
|
578
|
+
"flags:",
|
|
579
|
+
" --namespace <ns> namespace (required for namespaced kinds)",
|
|
580
|
+
"",
|
|
581
|
+
"examples:",
|
|
582
|
+
` ${BIN} describe pod my-pod --namespace default`,
|
|
583
|
+
` ${BIN} describe node worker-1`,
|
|
584
|
+
].join("\n"),
|
|
585
|
+
};
|
|
586
|
+
const SKILL_SPEC = {
|
|
587
|
+
name: "k8s-cli",
|
|
588
|
+
binName: BIN,
|
|
589
|
+
description: "Read-only Kubernetes access across the Oasis clusters from the shell — list namespaces/nodes/pods/deployments/services/events, read pod logs, and describe objects, against multiple clusters selected by name. Use when the user asks about Kubernetes/k8s pods, nodes, deployments, cluster status, pod logs, why a pod is crashing, or the Oasis Jeddah/Jordan clusters.",
|
|
590
|
+
intro: `Shell CLI for read-only Kubernetes access to the Oasis clusters. Talks straight to each kube-apiserver with a ServiceAccount token — no kubeconfig, no certs. Add a cluster: \`${BIN} add <name> --url https://<host>:6443 --token <token>\`.`,
|
|
591
|
+
extra: [
|
|
592
|
+
"## Multiple clusters",
|
|
593
|
+
"",
|
|
594
|
+
"Clusters are saved by name (like `oasis-sqlcl` connections), with one",
|
|
595
|
+
"**active**. Commands run against the active cluster; override per-command",
|
|
596
|
+
"with `--cluster <name>`. Switch the default with `use <name>`.",
|
|
597
|
+
"",
|
|
598
|
+
"## Token-only, read-only",
|
|
599
|
+
"",
|
|
600
|
+
"Every command is a GET — the CLI never mutates cluster state. A scoped",
|
|
601
|
+
"token may lack RBAC for some resources; those calls report a clear",
|
|
602
|
+
"`forbidden` error rather than crashing. TLS verification is skipped",
|
|
603
|
+
"(internal self-signed CAs) — point it only at clusters you control.",
|
|
604
|
+
],
|
|
605
|
+
commands: [
|
|
606
|
+
{ cmd: "(no args)", summary: "live view: saved clusters + which is active" },
|
|
607
|
+
{ cmd: "clusters", summary: "list saved clusters and the active one" },
|
|
608
|
+
{ cmd: "use <name>", summary: "set the active cluster" },
|
|
609
|
+
{ cmd: "add <name> --url <apiserver> --token <t>", summary: "register a cluster (verified before saving)" },
|
|
610
|
+
{ cmd: "rm <name>", summary: "remove a saved cluster" },
|
|
611
|
+
{ cmd: "namespaces", summary: "list namespaces" },
|
|
612
|
+
{ cmd: "nodes", summary: "list nodes (status + kubelet version)" },
|
|
613
|
+
{ cmd: "pods [--namespace <ns>]", summary: "list pods (all namespaces by default)" },
|
|
614
|
+
{ cmd: "deployments [--namespace <ns>]", summary: "list deployments (ready/available replicas)" },
|
|
615
|
+
{ cmd: "services [--namespace <ns>]", summary: "list services (type, clusterIP, ports)" },
|
|
616
|
+
{ cmd: "events [--namespace <ns>]", summary: "list events, newest last" },
|
|
617
|
+
{ cmd: "logs <pod> --namespace <ns> [--container <c>] [--tail <n>] [--previous]", summary: "print a pod's container logs" },
|
|
618
|
+
{ cmd: "describe <kind> <name> [--namespace <ns>]", summary: "dump one object as JSON" },
|
|
619
|
+
],
|
|
620
|
+
examples: [
|
|
621
|
+
"# Which clusters are configured, and which is active?",
|
|
622
|
+
`${BIN} clusters`,
|
|
623
|
+
"",
|
|
624
|
+
"# Inspect the active cluster",
|
|
625
|
+
`${BIN} pods --namespace default`,
|
|
626
|
+
`${BIN} nodes`,
|
|
627
|
+
"",
|
|
628
|
+
"# Why is a pod crashing? tail its logs, then the previous instance",
|
|
629
|
+
`${BIN} logs my-pod --namespace default --tail 80`,
|
|
630
|
+
`${BIN} logs my-pod --namespace default --previous`,
|
|
631
|
+
"",
|
|
632
|
+
"# Target a specific cluster without switching the active one",
|
|
633
|
+
`${BIN} deployments --cluster jordan --namespace default`,
|
|
634
|
+
],
|
|
635
|
+
notes: [
|
|
636
|
+
"`--cluster <name>` targets one cluster per-command; `use <name>` sets the default.",
|
|
637
|
+
"Pod `status` reflects the real container reason (CrashLoopBackOff, ImagePullBackOff, …), not just the phase.",
|
|
638
|
+
"A `forbidden` error means the cluster's token lacks RBAC for that resource — try another resource or a broader token.",
|
|
639
|
+
],
|
|
640
|
+
};
|
|
641
|
+
function topHelp() {
|
|
642
|
+
return [
|
|
643
|
+
`${BIN} — ${DESCRIPTION}`,
|
|
644
|
+
`bin: ${binPath()}`,
|
|
645
|
+
"",
|
|
646
|
+
"commands:",
|
|
647
|
+
" (no args) live view: saved clusters + active",
|
|
648
|
+
" clusters list saved clusters",
|
|
649
|
+
" use <name> set the active cluster",
|
|
650
|
+
" add <name> --url --token register a cluster",
|
|
651
|
+
" rm <name> remove a saved cluster",
|
|
652
|
+
" namespaces list namespaces",
|
|
653
|
+
" nodes list nodes",
|
|
654
|
+
" pods [--namespace] list pods",
|
|
655
|
+
" deployments [--namespace] list deployments",
|
|
656
|
+
" services [--namespace] list services",
|
|
657
|
+
" events [--namespace] list events",
|
|
658
|
+
" logs <pod> --namespace <ns> print pod logs",
|
|
659
|
+
" describe <kind> <name> dump one object as JSON",
|
|
660
|
+
" setup register ambient context for Codex + OpenCode",
|
|
661
|
+
"",
|
|
662
|
+
"Every read command accepts `--cluster <name>` to target a specific cluster.",
|
|
663
|
+
`Run \`${BIN} <command> --help\` for flags and examples.`,
|
|
664
|
+
].join("\n");
|
|
665
|
+
}
|
|
666
|
+
// ─── Dispatch ────────────────────────────────────────────────────────
|
|
667
|
+
const NS_CLUSTER = { "--namespace": "value", "--cluster": "value" };
|
|
668
|
+
const COMMAND_FLAGS = {
|
|
669
|
+
clusters: {},
|
|
670
|
+
use: {},
|
|
671
|
+
add: { "--url": "value", "--token": "value" },
|
|
672
|
+
rm: {},
|
|
673
|
+
namespaces: { "--cluster": "value" },
|
|
674
|
+
nodes: { "--cluster": "value" },
|
|
675
|
+
pods: NS_CLUSTER,
|
|
676
|
+
deployments: NS_CLUSTER,
|
|
677
|
+
services: NS_CLUSTER,
|
|
678
|
+
events: NS_CLUSTER,
|
|
679
|
+
logs: { "--namespace": "value", "--cluster": "value", "--container": "value", "--tail": "value", "--previous": "bool" },
|
|
680
|
+
describe: NS_CLUSTER,
|
|
681
|
+
};
|
|
682
|
+
// Command aliases → canonical command.
|
|
683
|
+
const ALIASES = {
|
|
684
|
+
ns: "namespaces",
|
|
685
|
+
no: "nodes",
|
|
686
|
+
po: "pods",
|
|
687
|
+
pod: "pods",
|
|
688
|
+
deploy: "deployments",
|
|
689
|
+
deployment: "deployments",
|
|
690
|
+
svc: "services",
|
|
691
|
+
service: "services",
|
|
692
|
+
event: "events",
|
|
693
|
+
ls: "clusters",
|
|
694
|
+
remove: "rm",
|
|
695
|
+
log: "logs",
|
|
696
|
+
};
|
|
697
|
+
export async function main() {
|
|
698
|
+
const [, , rawCommand, ...rest] = process.argv;
|
|
699
|
+
if (rawCommand === "--ambient") {
|
|
700
|
+
await cmdAmbient().catch(() => { });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (rawCommand === "--skill-doc") {
|
|
704
|
+
emitRaw(renderSkillDoc(SKILL_SPEC));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (rawCommand === "setup") {
|
|
708
|
+
runSetup(BIN);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (rawCommand === undefined) {
|
|
712
|
+
await cmdHome();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (rawCommand === "help" || rawCommand === "--help" || rawCommand === "-h") {
|
|
716
|
+
const topic = rest[0];
|
|
717
|
+
const canonical = topic ? ALIASES[topic] ?? topic : undefined;
|
|
718
|
+
emitRaw(canonical && HELP[canonical] ? HELP[canonical] : topHelp());
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const command = (ALIASES[rawCommand] ?? rawCommand);
|
|
722
|
+
if (!(command in COMMAND_FLAGS)) {
|
|
723
|
+
fail(`unknown command '${rawCommand}'`, `valid commands: ${Object.keys(COMMAND_FLAGS).join(", ")} (run \`${BIN} help\`)`, EXIT.USAGE);
|
|
724
|
+
}
|
|
725
|
+
const known = COMMAND_FLAGS[command];
|
|
726
|
+
const parsed = parseArgs(rest, known, `${BIN} ${command}`);
|
|
727
|
+
if (flagBool(parsed, "--help")) {
|
|
728
|
+
emitRaw(HELP[command] ?? topHelp());
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
switch (command) {
|
|
732
|
+
case "clusters":
|
|
733
|
+
return cmdClusters();
|
|
734
|
+
case "use":
|
|
735
|
+
return cmdUse(parsed);
|
|
736
|
+
case "add":
|
|
737
|
+
return cmdAdd(parsed);
|
|
738
|
+
case "rm":
|
|
739
|
+
return cmdRm(parsed);
|
|
740
|
+
case "namespaces":
|
|
741
|
+
return cmdNamespaces(parsed);
|
|
742
|
+
case "nodes":
|
|
743
|
+
return cmdNodes(parsed);
|
|
744
|
+
case "pods":
|
|
745
|
+
return cmdPods(parsed);
|
|
746
|
+
case "deployments":
|
|
747
|
+
return cmdDeployments(parsed);
|
|
748
|
+
case "services":
|
|
749
|
+
return cmdServices(parsed);
|
|
750
|
+
case "events":
|
|
751
|
+
return cmdEvents(parsed);
|
|
752
|
+
case "logs":
|
|
753
|
+
return cmdLogs(parsed);
|
|
754
|
+
case "describe":
|
|
755
|
+
return cmdDescribe(parsed);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** Entry used by bin/: run main() and convert any escaped error into an AXI error (§6). */
|
|
759
|
+
export function run() {
|
|
760
|
+
main().catch(err => {
|
|
761
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/** Single source of truth for skills/k8s-cli/SKILL.md (§7). */
|
|
765
|
+
export function skillMarkdown() {
|
|
766
|
+
return renderSkillDoc(SKILL_SPEC);
|
|
767
|
+
}
|
|
768
|
+
//# sourceMappingURL=cli.js.map
|