@ornncompute/cli 0.2.1 → 0.2.3
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/README.md +1 -0
- package/package.json +1 -1
- package/src/catalog-dispatch.mjs +193 -5
- package/src/cli.mjs +59 -18
- package/vendor/capabilities/capabilities/fleet.js +22 -5
- package/vendor/capabilities/capabilities/observability.d.ts +5 -0
- package/vendor/capabilities/capabilities/observability.js +188 -0
- package/vendor/capabilities/capability.d.ts +1 -1
- package/vendor/capabilities/capability.js +1 -0
- package/vendor/capabilities/catalog.js +2 -0
- package/vendor/capabilities/index.d.ts +1 -0
- package/vendor/capabilities/index.js +1 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ ornn whoami [--json]
|
|
|
44
44
|
ornn status [--json]
|
|
45
45
|
ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
|
|
46
46
|
ornn listings show <listing-id> [--open] [--json]
|
|
47
|
+
ornn listings create [--gpu-type <type>] [--node-count <n>] [--yes] [--json]
|
|
47
48
|
ornn buy <listing-id> [--no-open] [--json]
|
|
48
49
|
ornn exchange create <listing-id> --node-count <n> [--min-node-count <n>] --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd> [--no-open] [--json]
|
|
49
50
|
ornn exchange list [--limit <1-500>] [--cursor <last-id>] [--json]
|
package/package.json
CHANGED
package/src/catalog-dispatch.mjs
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
|
|
3
4
|
|
|
4
|
-
import { cliRequest, operatorRequest } from "./api-client.mjs";
|
|
5
|
+
import { cliRequest, loadRequiredSession, operatorRequest, resolveApiBaseUrl } from "./api-client.mjs";
|
|
6
|
+
|
|
7
|
+
async function collectSse(url, headers, durationMs, maxEvents, fetchImpl) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), durationMs);
|
|
10
|
+
const events = [];
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetchImpl(url, { method: "GET", headers, signal: controller.signal });
|
|
13
|
+
if (!res.ok || !res.body) {
|
|
14
|
+
const detail = await res.text().catch(() => "");
|
|
15
|
+
throw new Error(detail.trim() || `http_${res.status}`);
|
|
16
|
+
}
|
|
17
|
+
const reader = res.body.getReader();
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
let buffer = "";
|
|
20
|
+
while (events.length < maxEvents) {
|
|
21
|
+
const { done, value } = await reader.read();
|
|
22
|
+
if (done) break;
|
|
23
|
+
buffer += decoder.decode(value, { stream: true });
|
|
24
|
+
const parts = buffer.split("\n\n");
|
|
25
|
+
buffer = parts.pop() ?? "";
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
const data = part
|
|
28
|
+
.split("\n")
|
|
29
|
+
.filter((line) => line.startsWith("data:"))
|
|
30
|
+
.map((line) => line.slice(5).trim())
|
|
31
|
+
.join("\n");
|
|
32
|
+
if (!data) continue;
|
|
33
|
+
try {
|
|
34
|
+
events.push(JSON.parse(data));
|
|
35
|
+
} catch {
|
|
36
|
+
events.push({ raw: data });
|
|
37
|
+
}
|
|
38
|
+
if (events.length >= maxEvents) break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await reader.cancel();
|
|
43
|
+
} catch {
|
|
44
|
+
// already closed
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!(error instanceof Error) || error.name !== "AbortError") throw error;
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
return { events };
|
|
52
|
+
}
|
|
5
53
|
|
|
6
54
|
async function loadCapabilities() {
|
|
7
55
|
try {
|
|
@@ -34,18 +82,56 @@ export async function confirmCatalogMutation({ stdin, stdout, yes, label }) {
|
|
|
34
82
|
function catalogFetch({ env, fetchImpl }) {
|
|
35
83
|
return async (path, init) => {
|
|
36
84
|
const method = init?.method ?? "GET";
|
|
85
|
+
const observabilityPrefix = "/v1/observability";
|
|
86
|
+
const staffObservability =
|
|
87
|
+
path.startsWith(`${observabilityPrefix}/nodes/`) ||
|
|
88
|
+
path.startsWith(`${observabilityPrefix}/nodes?`);
|
|
37
89
|
const staffPath =
|
|
90
|
+
staffObservability ||
|
|
38
91
|
path.startsWith("/internal") ||
|
|
39
92
|
path.startsWith("/provisioning") ||
|
|
40
93
|
path === "/inventory" ||
|
|
41
94
|
path.startsWith("/inventory/");
|
|
42
95
|
const request = staffPath ? operatorRequest : cliRequest;
|
|
96
|
+
const requestPath = staffObservability ? path.slice(observabilityPrefix.length) : path;
|
|
97
|
+
if (init?.headers?.Accept === "text/event-stream") {
|
|
98
|
+
const session = await loadRequiredSession({ env });
|
|
99
|
+
const base = resolveApiBaseUrl({ env, session });
|
|
100
|
+
const parsed = new URL(
|
|
101
|
+
staffObservability ? `/v1/cli/operator${requestPath}` : path,
|
|
102
|
+
`${base.replace(/\/+$/, "")}/`,
|
|
103
|
+
);
|
|
104
|
+
const duration = Number(parsed.searchParams.get("duration") ?? "20") * 1000;
|
|
105
|
+
const maxEvents = Number(parsed.searchParams.get("max_events") ?? "40");
|
|
106
|
+
return collectSse(
|
|
107
|
+
parsed.toString(),
|
|
108
|
+
{
|
|
109
|
+
Accept: "text/event-stream",
|
|
110
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
111
|
+
"X-Ornn-Credential-Type": "user",
|
|
112
|
+
},
|
|
113
|
+
duration,
|
|
114
|
+
maxEvents,
|
|
115
|
+
fetchImpl,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
let body = init?.body;
|
|
119
|
+
if (
|
|
120
|
+
body &&
|
|
121
|
+
typeof body === "object" &&
|
|
122
|
+
typeof body.identity_file === "string" &&
|
|
123
|
+
body.identity_file.trim()
|
|
124
|
+
) {
|
|
125
|
+
const privateKey = readFileSync(body.identity_file.trim(), "utf8");
|
|
126
|
+
body = { ...body, private_key: privateKey };
|
|
127
|
+
delete body.identity_file;
|
|
128
|
+
}
|
|
43
129
|
return request({
|
|
44
|
-
endpoint:
|
|
130
|
+
endpoint: requestPath,
|
|
45
131
|
env,
|
|
46
132
|
fetchImpl,
|
|
47
133
|
method,
|
|
48
|
-
...(
|
|
134
|
+
...(body !== undefined ? { body } : {}),
|
|
49
135
|
});
|
|
50
136
|
};
|
|
51
137
|
}
|
|
@@ -174,11 +260,113 @@ export async function usersQueryCommand(args, context) {
|
|
|
174
260
|
return 0;
|
|
175
261
|
}
|
|
176
262
|
|
|
177
|
-
export async function nodesConsoleCommand(
|
|
263
|
+
export async function nodesConsoleCommand(args, context) {
|
|
178
264
|
const { nodesConsoleCapability } = await loadCapabilities();
|
|
265
|
+
const [nodeId, ...rest] = args;
|
|
266
|
+
if (!nodeId) {
|
|
267
|
+
throw new Error("Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]");
|
|
268
|
+
}
|
|
269
|
+
const options = context.parseCommandOptions(
|
|
270
|
+
rest,
|
|
271
|
+
{ boolean: ["json"], value: ["identity-file"] },
|
|
272
|
+
"Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]",
|
|
273
|
+
);
|
|
179
274
|
const result = await runCatalogCapability(
|
|
180
275
|
nodesConsoleCapability,
|
|
181
|
-
{ node_id: nodeId },
|
|
276
|
+
{ node_id: nodeId, identity_file: options.identityFile },
|
|
277
|
+
{
|
|
278
|
+
...context,
|
|
279
|
+
role: options.identityFile ? "user" : context.staff ? "reviewer" : "user",
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function telemetryLatestCommand(args, context) {
|
|
287
|
+
const { telemetryLatestCapability } = await loadCapabilities();
|
|
288
|
+
const [nodeId, ...rest] = args;
|
|
289
|
+
const options = context.parseCommandOptions(
|
|
290
|
+
rest,
|
|
291
|
+
{ boolean: ["json"], value: ["start", "end", "span", "max-points"] },
|
|
292
|
+
"Usage: ornn telemetry latest <node-id> [--span 15m] [--start <iso>] [--end <iso>] [--max-points <n>]",
|
|
293
|
+
);
|
|
294
|
+
const result = await runCatalogCapability(
|
|
295
|
+
telemetryLatestCapability,
|
|
296
|
+
{
|
|
297
|
+
node_id: nodeId,
|
|
298
|
+
start: options.start,
|
|
299
|
+
end: options.end,
|
|
300
|
+
span: options.span,
|
|
301
|
+
max_points: options.maxPoints != null ? Number(options.maxPoints) : undefined,
|
|
302
|
+
},
|
|
303
|
+
{ ...context, role: context.staff ? "reviewer" : "user" },
|
|
304
|
+
);
|
|
305
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function logsLatestCommand(args, context) {
|
|
310
|
+
const { logsLatestCapability } = await loadCapabilities();
|
|
311
|
+
const [nodeId, ...rest] = args;
|
|
312
|
+
const options = context.parseCommandOptions(
|
|
313
|
+
rest,
|
|
314
|
+
{ boolean: ["json"], value: ["stream", "count", "before", "cursor"] },
|
|
315
|
+
"Usage: ornn logs latest <node-id> [--stream kernel] [--count 200] [--before <iso>] [--cursor <token>]",
|
|
316
|
+
);
|
|
317
|
+
const result = await runCatalogCapability(
|
|
318
|
+
logsLatestCapability,
|
|
319
|
+
{
|
|
320
|
+
node_id: nodeId,
|
|
321
|
+
stream: options.stream,
|
|
322
|
+
count: options.count != null ? Number(options.count) : undefined,
|
|
323
|
+
before: options.before,
|
|
324
|
+
cursor: options.cursor,
|
|
325
|
+
},
|
|
326
|
+
{ ...context, role: context.staff ? "reviewer" : "user" },
|
|
327
|
+
);
|
|
328
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function telemetryTailCommand(args, context) {
|
|
333
|
+
const { telemetryTailCapability } = await loadCapabilities();
|
|
334
|
+
const [nodeId, ...rest] = args;
|
|
335
|
+
const options = context.parseCommandOptions(
|
|
336
|
+
rest,
|
|
337
|
+
{ boolean: ["json"], value: ["duration", "max-events", "fields"] },
|
|
338
|
+
"Usage: ornn telemetry tail <node-id> [--duration 20] [--max-events 40]",
|
|
339
|
+
);
|
|
340
|
+
const result = await runCatalogCapability(
|
|
341
|
+
telemetryTailCapability,
|
|
342
|
+
{
|
|
343
|
+
node_id: nodeId,
|
|
344
|
+
duration: options.duration != null ? Number(options.duration) : undefined,
|
|
345
|
+
max_events: options.maxEvents != null ? Number(options.maxEvents) : undefined,
|
|
346
|
+
fields: options.fields,
|
|
347
|
+
},
|
|
348
|
+
{ ...context, role: context.staff ? "reviewer" : "user" },
|
|
349
|
+
);
|
|
350
|
+
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function logsTailCommand(args, context) {
|
|
355
|
+
const { logsTailCapability } = await loadCapabilities();
|
|
356
|
+
const [nodeId, ...rest] = args;
|
|
357
|
+
const options = context.parseCommandOptions(
|
|
358
|
+
rest,
|
|
359
|
+
{ boolean: ["json"], value: ["stream", "duration", "max-events"] },
|
|
360
|
+
"Usage: ornn logs tail <node-id> [--stream kernel] [--duration 20]",
|
|
361
|
+
);
|
|
362
|
+
const result = await runCatalogCapability(
|
|
363
|
+
logsTailCapability,
|
|
364
|
+
{
|
|
365
|
+
node_id: nodeId,
|
|
366
|
+
stream: options.stream,
|
|
367
|
+
duration: options.duration != null ? Number(options.duration) : undefined,
|
|
368
|
+
max_events: options.maxEvents != null ? Number(options.maxEvents) : undefined,
|
|
369
|
+
},
|
|
182
370
|
{ ...context, role: context.staff ? "reviewer" : "user" },
|
|
183
371
|
);
|
|
184
372
|
context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
package/src/cli.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
4
3
|
import {
|
|
5
|
-
chmod,
|
|
6
|
-
mkdir,
|
|
7
|
-
mkdtemp,
|
|
8
|
-
open,
|
|
9
4
|
readFile,
|
|
10
5
|
rename,
|
|
11
6
|
rm,
|
|
@@ -13,9 +8,8 @@ import {
|
|
|
13
8
|
writeFile,
|
|
14
9
|
} from "node:fs/promises";
|
|
15
10
|
import { createRequire } from "node:module";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
19
13
|
import { Readable } from "node:stream";
|
|
20
14
|
import { pipeline } from "node:stream/promises";
|
|
21
15
|
|
|
@@ -44,8 +38,12 @@ import {
|
|
|
44
38
|
} from "./device-auth.mjs";
|
|
45
39
|
import {
|
|
46
40
|
listingsCreateCommand,
|
|
41
|
+
logsLatestCommand,
|
|
42
|
+
logsTailCommand,
|
|
47
43
|
nodesConsoleCommand,
|
|
48
44
|
operatorListCommand,
|
|
45
|
+
telemetryLatestCommand,
|
|
46
|
+
telemetryTailCommand,
|
|
49
47
|
usersQueryCommand,
|
|
50
48
|
} from "./catalog-dispatch.mjs";
|
|
51
49
|
import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
|
|
@@ -134,7 +132,11 @@ Usage:
|
|
|
134
132
|
ornn gpus checkout <gpu-id> [--no-open] [--json]
|
|
135
133
|
ornn nodes list [--json]
|
|
136
134
|
ornn nodes show <node-id> [--json]
|
|
137
|
-
ornn nodes console <node-id> [--json]
|
|
135
|
+
ornn nodes console <node-id> [--identity-file <path>] [--json]
|
|
136
|
+
ornn telemetry latest <node-id> [--span 15m] [--start <iso>] [--end <iso>] [--max-points <n>] [--json]
|
|
137
|
+
ornn telemetry tail <node-id> [--duration <seconds>] [--json]
|
|
138
|
+
ornn logs latest <node-id> [--stream kernel] [--count <n>] [--before <iso>] [--cursor <token>] [--json]
|
|
139
|
+
ornn logs tail <node-id> [--stream kernel] [--duration <seconds>] [--json]
|
|
138
140
|
ornn nodes launch <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--json]
|
|
139
141
|
ornn nodes switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
|
|
140
142
|
ornn nodes wait <node-or-reservation-id> [--timeout <seconds>] [--json]
|
|
@@ -465,6 +467,43 @@ async function dispatch(argv = [], io = {}) {
|
|
|
465
467
|
});
|
|
466
468
|
}
|
|
467
469
|
|
|
470
|
+
if (command === "telemetry" && args[0] === "latest") {
|
|
471
|
+
return await telemetryLatestCommand(args.slice(1), {
|
|
472
|
+
env,
|
|
473
|
+
fetchImpl,
|
|
474
|
+
parseCommandOptions,
|
|
475
|
+
stdout,
|
|
476
|
+
staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (command === "telemetry" && args[0] === "tail") {
|
|
480
|
+
return await telemetryTailCommand(args.slice(1), {
|
|
481
|
+
env,
|
|
482
|
+
fetchImpl,
|
|
483
|
+
parseCommandOptions,
|
|
484
|
+
stdout,
|
|
485
|
+
staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (command === "logs" && args[0] === "latest") {
|
|
489
|
+
return await logsLatestCommand(args.slice(1), {
|
|
490
|
+
env,
|
|
491
|
+
fetchImpl,
|
|
492
|
+
parseCommandOptions,
|
|
493
|
+
stdout,
|
|
494
|
+
staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (command === "logs" && args[0] === "tail") {
|
|
498
|
+
return await logsTailCommand(args.slice(1), {
|
|
499
|
+
env,
|
|
500
|
+
fetchImpl,
|
|
501
|
+
parseCommandOptions,
|
|
502
|
+
stdout,
|
|
503
|
+
staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
468
507
|
if (LISTINGS_COMMANDS.has(command)) {
|
|
469
508
|
return await availability(args, {
|
|
470
509
|
commandName: command,
|
|
@@ -503,7 +542,16 @@ async function dispatch(argv = [], io = {}) {
|
|
|
503
542
|
}
|
|
504
543
|
|
|
505
544
|
if (command === "nodes") {
|
|
506
|
-
return await nodes(args, {
|
|
545
|
+
return await nodes(args, {
|
|
546
|
+
env,
|
|
547
|
+
fetchImpl,
|
|
548
|
+
openBrowserImpl,
|
|
549
|
+
parseCommandOptions,
|
|
550
|
+
spawnProcess,
|
|
551
|
+
stderr,
|
|
552
|
+
stdout,
|
|
553
|
+
staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
|
|
554
|
+
});
|
|
507
555
|
}
|
|
508
556
|
|
|
509
557
|
if (command === "ssh") {
|
|
@@ -2715,8 +2763,7 @@ async function nodes(args, context) {
|
|
|
2715
2763
|
}
|
|
2716
2764
|
|
|
2717
2765
|
if (subcommand === "console" && id) {
|
|
2718
|
-
|
|
2719
|
-
return await nodesConsoleCommand(id, context);
|
|
2766
|
+
return await nodesConsoleCommand([id, nested, ...rest].filter(Boolean), context);
|
|
2720
2767
|
}
|
|
2721
2768
|
|
|
2722
2769
|
if (subcommand === "show" && id) {
|
|
@@ -8637,12 +8684,6 @@ function validateMinNodeCount(minNodeCount, nodeCount) {
|
|
|
8637
8684
|
}
|
|
8638
8685
|
}
|
|
8639
8686
|
|
|
8640
|
-
function validateMinGpuCount(minGpuCount, gpuCount) {
|
|
8641
|
-
if (minGpuCount > gpuCount) {
|
|
8642
|
-
throw new Error("--min-gpu-count must be less than or equal to --gpu-count.");
|
|
8643
|
-
}
|
|
8644
|
-
}
|
|
8645
|
-
|
|
8646
8687
|
function arrayOption(value) {
|
|
8647
8688
|
if (value === undefined || value === null || value === "") {
|
|
8648
8689
|
return [];
|
|
@@ -25,6 +25,8 @@ const nodesInput = z.object({
|
|
|
25
25
|
const consoleInput = z.object({
|
|
26
26
|
node_id: z.string().min(1),
|
|
27
27
|
nodeId: z.string().optional(),
|
|
28
|
+
identity_file: z.string().optional(),
|
|
29
|
+
commands: z.array(z.string()).optional(),
|
|
28
30
|
});
|
|
29
31
|
export const listOperatorsCapability = defineCapability({
|
|
30
32
|
id: "operator.list",
|
|
@@ -106,14 +108,19 @@ export const nodesConsoleCapability = defineCapability({
|
|
|
106
108
|
id: "nodes.console",
|
|
107
109
|
domain: "fleet",
|
|
108
110
|
roles: ["user", "reviewer", "admin"],
|
|
109
|
-
description: "
|
|
111
|
+
description: "Run an allowlisted host snapshot (ps -ef, nvidia-smi) on one node. Pass the orchestrator node UUID, never a hostname. Users must target a reserved node and pass identity_file (local PEM, same as ornn ssh --identity-file). Reviewers and admins may target any enrolled node; the orchestrator loads the stored admin SSH key. Slack staff never pass a key.",
|
|
110
112
|
mutation: "read",
|
|
111
|
-
http: { method: "POST", path: "/
|
|
113
|
+
http: { method: "POST", path: "/internal/nodes/{id}/console" },
|
|
112
114
|
slack: {
|
|
113
115
|
parameters: {
|
|
114
116
|
type: "object",
|
|
115
117
|
properties: {
|
|
116
|
-
node_id: { type: "string", description: "Node UUID from nodes.list." },
|
|
118
|
+
node_id: { type: "string", description: "Node UUID from nodes.list, not a hostname." },
|
|
119
|
+
commands: {
|
|
120
|
+
type: "array",
|
|
121
|
+
items: { type: "string", enum: ["ps", "ps -ef", "nvidia-smi", "nvidia-smi -L"] },
|
|
122
|
+
description: "Optional allowlisted commands. Default is ps and nvidia-smi.",
|
|
123
|
+
},
|
|
117
124
|
},
|
|
118
125
|
required: ["node_id"],
|
|
119
126
|
},
|
|
@@ -122,9 +129,19 @@ export const nodesConsoleCapability = defineCapability({
|
|
|
122
129
|
execute: async (ctx, input) => {
|
|
123
130
|
const parsed = consoleInput.parse(input);
|
|
124
131
|
const id = (parsed.node_id || parsed.nodeId || "").trim();
|
|
125
|
-
|
|
132
|
+
if (ctx.role === "user") {
|
|
133
|
+
const identity = parsed.identity_file?.trim();
|
|
134
|
+
if (!identity) {
|
|
135
|
+
return { error: "identity_file_required", detail: "Users must pass identity_file." };
|
|
136
|
+
}
|
|
137
|
+
return ctx.fetch(`/nodes/${encodeURIComponent(id)}/console`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: { identity_file: identity, commands: parsed.commands },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/console`, {
|
|
126
143
|
method: "POST",
|
|
127
|
-
body: {
|
|
144
|
+
body: { commands: parsed.commands },
|
|
128
145
|
});
|
|
129
146
|
},
|
|
130
147
|
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const telemetryLatestCapability: import("../capability.ts").Capability<unknown, unknown>;
|
|
2
|
+
export declare const logsLatestCapability: import("../capability.ts").Capability<unknown, unknown>;
|
|
3
|
+
export declare const telemetryTailCapability: import("../capability.ts").Capability<unknown, unknown>;
|
|
4
|
+
export declare const logsTailCapability: import("../capability.ts").Capability<unknown, unknown>;
|
|
5
|
+
export declare const observabilityCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCapability } from "../capability.js";
|
|
3
|
+
function queryPath(path, params) {
|
|
4
|
+
const parts = [];
|
|
5
|
+
for (const [key, value] of Object.entries(params)) {
|
|
6
|
+
if (value?.trim())
|
|
7
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.trim())}`);
|
|
8
|
+
}
|
|
9
|
+
return parts.length ? `${path}?${parts.join("&")}` : path;
|
|
10
|
+
}
|
|
11
|
+
function nodePrefix(role, id) {
|
|
12
|
+
const encoded = encodeURIComponent(id);
|
|
13
|
+
return role === "user"
|
|
14
|
+
? `/v1/observability/machines/${encoded}`
|
|
15
|
+
: `/v1/observability/nodes/${encoded}`;
|
|
16
|
+
}
|
|
17
|
+
const latestInput = z.object({
|
|
18
|
+
node_id: z.string().min(1),
|
|
19
|
+
nodeId: z.string().optional(),
|
|
20
|
+
start: z.string().optional(),
|
|
21
|
+
end: z.string().optional(),
|
|
22
|
+
span: z.string().optional(),
|
|
23
|
+
max_points: z.number().int().min(1).max(500).optional(),
|
|
24
|
+
});
|
|
25
|
+
const logsLatestInput = z.object({
|
|
26
|
+
node_id: z.string().min(1),
|
|
27
|
+
nodeId: z.string().optional(),
|
|
28
|
+
stream: z.enum(["serial", "kernel"]).default("kernel"),
|
|
29
|
+
count: z.number().int().min(1).max(1000).optional(),
|
|
30
|
+
before: z.string().optional(),
|
|
31
|
+
cursor: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
const tailInput = z.object({
|
|
34
|
+
node_id: z.string().min(1),
|
|
35
|
+
nodeId: z.string().optional(),
|
|
36
|
+
duration: z.number().int().min(1).max(120).optional(),
|
|
37
|
+
max_events: z.number().int().min(1).max(200).optional(),
|
|
38
|
+
fields: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
const logsTailInput = tailInput.extend({
|
|
41
|
+
stream: z.enum(["serial", "kernel"]).default("kernel"),
|
|
42
|
+
});
|
|
43
|
+
function nodeId(input) {
|
|
44
|
+
return (input.node_id || input.nodeId || "").trim();
|
|
45
|
+
}
|
|
46
|
+
function expandSpan(span, start, end) {
|
|
47
|
+
if (start?.trim() && end?.trim()) {
|
|
48
|
+
return { start: start.trim(), end: end.trim() };
|
|
49
|
+
}
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const match = /^(\d+)(m|h|d)$/.exec((span ?? "15m").trim());
|
|
52
|
+
const amount = match ? Number(match[1]) : 15;
|
|
53
|
+
const unit = match?.[2] ?? "m";
|
|
54
|
+
const ms = unit === "d" ? amount * 86_400_000 : unit === "h" ? amount * 3_600_000 : amount * 60_000;
|
|
55
|
+
return { start: new Date(now - ms).toISOString(), end: new Date(now).toISOString() };
|
|
56
|
+
}
|
|
57
|
+
export const telemetryLatestCapability = defineCapability({
|
|
58
|
+
id: "telemetry.latest",
|
|
59
|
+
domain: "observability",
|
|
60
|
+
roles: ["user", "reviewer", "admin"],
|
|
61
|
+
description: "One-shot telemetry window for one node (start/end or span plus max_points). Users may only query a reserved machine. Reviewers and admins may query any enrolled node. Pass a node UUID. Use this for heartbeat / last-seen / public_ip. Not a live stream.",
|
|
62
|
+
mutation: "read",
|
|
63
|
+
http: { method: "GET", path: "/v1/observability/nodes/{id}/telemetry/query" },
|
|
64
|
+
slack: {
|
|
65
|
+
parameters: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
node_id: { type: "string", description: "Node UUID from nodes.list." },
|
|
69
|
+
span: { type: "string", description: "Lookback like 15m, 1h, 1d. Default 15m." },
|
|
70
|
+
start: { type: "string" },
|
|
71
|
+
end: { type: "string" },
|
|
72
|
+
max_points: { type: "number", description: "Default 240, max 500." },
|
|
73
|
+
},
|
|
74
|
+
required: ["node_id"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
input: latestInput,
|
|
78
|
+
execute: async (ctx, input) => {
|
|
79
|
+
const parsed = latestInput.parse(input);
|
|
80
|
+
const id = nodeId(parsed);
|
|
81
|
+
const range = expandSpan(parsed.span, parsed.start, parsed.end);
|
|
82
|
+
return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/telemetry/query`, {
|
|
83
|
+
start: range.start,
|
|
84
|
+
end: range.end,
|
|
85
|
+
max_points: String(parsed.max_points ?? 240),
|
|
86
|
+
}), { method: "GET" });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
export const logsLatestCapability = defineCapability({
|
|
90
|
+
id: "logs.latest",
|
|
91
|
+
domain: "observability",
|
|
92
|
+
roles: ["user", "reviewer", "admin"],
|
|
93
|
+
description: "One-shot newest kernel or serial log page (before + count + cursor). Users only on a reserved machine. Not a live tail. First call omits cursor; older pages pass next_cursor.",
|
|
94
|
+
mutation: "read",
|
|
95
|
+
http: { method: "GET", path: "/v1/observability/nodes/{id}/logs/{stream}/query" },
|
|
96
|
+
slack: {
|
|
97
|
+
parameters: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
node_id: { type: "string", description: "Node UUID from nodes.list." },
|
|
101
|
+
stream: { type: "string", enum: ["serial", "kernel"] },
|
|
102
|
+
count: { type: "number", description: "Page size. Default 200, max 1000." },
|
|
103
|
+
before: { type: "string", description: "RFC3339 upper bound. Default now." },
|
|
104
|
+
cursor: { type: "string", description: "next_cursor from the previous page." },
|
|
105
|
+
},
|
|
106
|
+
required: ["node_id"],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
input: logsLatestInput,
|
|
110
|
+
execute: async (ctx, input) => {
|
|
111
|
+
const parsed = logsLatestInput.parse(input);
|
|
112
|
+
const id = nodeId(parsed);
|
|
113
|
+
const end = parsed.before?.trim() || new Date().toISOString();
|
|
114
|
+
const start = new Date(Date.parse(end) - 7 * 86_400_000).toISOString();
|
|
115
|
+
return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/logs/${parsed.stream}/query`, {
|
|
116
|
+
start,
|
|
117
|
+
end,
|
|
118
|
+
limit: String(parsed.count ?? 200),
|
|
119
|
+
order: "desc",
|
|
120
|
+
cursor: parsed.cursor,
|
|
121
|
+
}), { method: "GET" });
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
export const telemetryTailCapability = defineCapability({
|
|
125
|
+
id: "telemetry.tail",
|
|
126
|
+
domain: "observability",
|
|
127
|
+
roles: ["user", "reviewer", "admin"],
|
|
128
|
+
description: "Live telemetry follow on the same Observability subscribe feed the graphs use. Users only on a reserved machine. Slack and MCP attach for duration (default 20s) and return events. Not telemetry.latest.",
|
|
129
|
+
mutation: "read",
|
|
130
|
+
http: { method: "GET", path: "/v1/observability/nodes/{id}/telemetry/subscribe" },
|
|
131
|
+
slack: {
|
|
132
|
+
parameters: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
node_id: { type: "string", description: "Node UUID from nodes.list." },
|
|
136
|
+
duration: { type: "number", description: "Seconds to attach. Default 20, max 120." },
|
|
137
|
+
max_events: { type: "number" },
|
|
138
|
+
fields: { type: "string" },
|
|
139
|
+
},
|
|
140
|
+
required: ["node_id"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
input: tailInput,
|
|
144
|
+
execute: async (ctx, input) => {
|
|
145
|
+
const parsed = tailInput.parse(input);
|
|
146
|
+
const id = nodeId(parsed);
|
|
147
|
+
return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/telemetry/subscribe`, {
|
|
148
|
+
fields: parsed.fields,
|
|
149
|
+
duration: String(parsed.duration ?? 20),
|
|
150
|
+
max_events: String(parsed.max_events ?? 40),
|
|
151
|
+
}), { method: "GET", headers: { Accept: "text/event-stream" } });
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
export const logsTailCapability = defineCapability({
|
|
155
|
+
id: "logs.tail",
|
|
156
|
+
domain: "observability",
|
|
157
|
+
roles: ["user", "reviewer", "admin"],
|
|
158
|
+
description: "Live kernel or serial log follow on the same Observability subscribe feed LogViewer uses. Users only on a reserved machine. Slack and MCP attach for duration (default 20s). Not logs.latest.",
|
|
159
|
+
mutation: "read",
|
|
160
|
+
http: { method: "GET", path: "/v1/observability/nodes/{id}/logs/{stream}/subscribe" },
|
|
161
|
+
slack: {
|
|
162
|
+
parameters: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
node_id: { type: "string", description: "Node UUID from nodes.list." },
|
|
166
|
+
stream: { type: "string", enum: ["serial", "kernel"] },
|
|
167
|
+
duration: { type: "number", description: "Seconds to attach. Default 20, max 120." },
|
|
168
|
+
max_events: { type: "number" },
|
|
169
|
+
},
|
|
170
|
+
required: ["node_id"],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
input: logsTailInput,
|
|
174
|
+
execute: async (ctx, input) => {
|
|
175
|
+
const parsed = logsTailInput.parse(input);
|
|
176
|
+
const id = nodeId(parsed);
|
|
177
|
+
return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/logs/${parsed.stream}/subscribe`, {
|
|
178
|
+
duration: String(parsed.duration ?? 20),
|
|
179
|
+
max_events: String(parsed.max_events ?? 40),
|
|
180
|
+
}), { method: "GET", headers: { Accept: "text/event-stream" } });
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
export const observabilityCapabilities = [
|
|
184
|
+
telemetryLatestCapability,
|
|
185
|
+
telemetryTailCapability,
|
|
186
|
+
logsLatestCapability,
|
|
187
|
+
logsTailCapability,
|
|
188
|
+
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ZodType } from "zod";
|
|
2
2
|
import type { ClientKind, Role } from "./role.ts";
|
|
3
|
-
export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing"];
|
|
3
|
+
export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing", "observability"];
|
|
4
4
|
export type Domain = (typeof DOMAINS)[number];
|
|
5
5
|
export type Mutation = "read" | "preview-confirm" | "local";
|
|
6
6
|
export type HttpSpec = {
|
|
@@ -2,6 +2,7 @@ import { kubernetesCapabilities, slurmCapabilities } from "./capabilities/cluste
|
|
|
2
2
|
import { fleetCapabilities } from "./capabilities/fleet.js";
|
|
3
3
|
import { identityCapabilities } from "./capabilities/identity.js";
|
|
4
4
|
import { listingCapabilities } from "./capabilities/listings.js";
|
|
5
|
+
import { observabilityCapabilities } from "./capabilities/observability.js";
|
|
5
6
|
import { userCapabilities } from "./capabilities/users.js";
|
|
6
7
|
/** System of record for CLI, MCP, and Slack verbs. Add capabilities here. */
|
|
7
8
|
export const catalog = [
|
|
@@ -9,6 +10,7 @@ export const catalog = [
|
|
|
9
10
|
...listingCapabilities,
|
|
10
11
|
...fleetCapabilities,
|
|
11
12
|
...userCapabilities,
|
|
13
|
+
...observabilityCapabilities,
|
|
12
14
|
...slurmCapabilities,
|
|
13
15
|
...kubernetesCapabilities,
|
|
14
16
|
];
|
|
@@ -6,5 +6,6 @@ export { identityCapabilities, statusCapability, whoamiCapability } from "./capa
|
|
|
6
6
|
export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.ts";
|
|
7
7
|
export { userCapabilities, usersQueryCapability } from "./capabilities/users.ts";
|
|
8
8
|
export { kubernetesCapabilities, slurmCapabilities } from "./capabilities/clusters.ts";
|
|
9
|
+
export { logsLatestCapability, logsTailCapability, observabilityCapabilities, telemetryLatestCapability, telemetryTailCapability, } from "./capabilities/observability.ts";
|
|
9
10
|
export { CLIENTS, parseRole, roleAtLeast, ROLES, type ClientKind, type Role } from "./role.ts";
|
|
10
11
|
export { bindHardwareFields, HARDWARE_CATALOG_PATH, HARDWARE_KINDS, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, looksLikeSpecDump, normalizeCatalogQuery, parseHardwareCatalog, projectCatalogTerms, proposeListingPayload, resolveCatalogTerm, UNRESOLVED_CATALOG_HINT, type CatalogBindResult, type CatalogTerm, type HardwareCatalog, type HardwareKind, type ResolvedCatalogTerm, } from "./spec-catalog.ts";
|
|
@@ -6,5 +6,6 @@ export { identityCapabilities, statusCapability, whoamiCapability } from "./capa
|
|
|
6
6
|
export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.js";
|
|
7
7
|
export { userCapabilities, usersQueryCapability } from "./capabilities/users.js";
|
|
8
8
|
export { kubernetesCapabilities, slurmCapabilities } from "./capabilities/clusters.js";
|
|
9
|
+
export { logsLatestCapability, logsTailCapability, observabilityCapabilities, telemetryLatestCapability, telemetryTailCapability, } from "./capabilities/observability.js";
|
|
9
10
|
export { CLIENTS, parseRole, roleAtLeast, ROLES } from "./role.js";
|
|
10
11
|
export { bindHardwareFields, HARDWARE_CATALOG_PATH, HARDWARE_KINDS, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, looksLikeSpecDump, normalizeCatalogQuery, parseHardwareCatalog, projectCatalogTerms, proposeListingPayload, resolveCatalogTerm, UNRESOLVED_CATALOG_HINT, } from "./spec-catalog.js";
|