@ornncompute/cli 0.2.23 → 1.0.25
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 +304 -420
- package/package.json +12 -6
- package/src/admin-capacity-reservations.mjs +280 -0
- package/src/admin-command-handlers.mjs +57 -0
- package/src/admin-enrollment.mjs +680 -0
- package/src/admin-mutations.mjs +187 -0
- package/src/admin-node-diagnostics.mjs +412 -0
- package/src/admin-node-lifecycle.mjs +908 -0
- package/src/admin-shell.mjs +322 -0
- package/src/api-client.mjs +81 -28
- package/src/atomic-output.mjs +112 -0
- package/src/auth-store.mjs +182 -11
- package/src/capability-catalog.mjs +7 -0
- package/src/catalog-shell.mjs +1350 -0
- package/src/cli.mjs +123 -10266
- package/src/connect-mode-handlers.mjs +170 -0
- package/src/device-auth.mjs +178 -61
- package/src/evidence-commands.mjs +1340 -0
- package/src/external-bucket-commands.mjs +1374 -0
- package/src/interaction-runtime.mjs +310 -0
- package/src/kubernetes-commands.mjs +2229 -0
- package/src/launch-mode-handlers.mjs +119 -0
- package/src/machine-reference.mjs +277 -0
- package/src/mount-commands.mjs +1146 -0
- package/src/session-commands.mjs +565 -0
- package/src/slurm-commands.mjs +263 -0
- package/src/startup.mjs +34 -11
- package/src/storage-commands.mjs +1144 -0
- package/src/update.mjs +172 -24
- package/src/vm-commands.mjs +4273 -0
- package/src/vpn-secret-output.mjs +150 -0
- package/src/wait-runtime.mjs +231 -0
- package/vendor/capabilities/capabilities/admin-capacity-reservations.js +126 -0
- package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
- package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
- package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
- package/vendor/capabilities/capabilities/admin.js +89 -0
- package/vendor/capabilities/capabilities/capacity-offers.js +368 -0
- package/vendor/capabilities/capabilities/cli.js +156 -0
- package/vendor/capabilities/capabilities/clusters.js +150 -1
- package/vendor/capabilities/capabilities/compute.js +522 -0
- package/vendor/capabilities/capabilities/evidence.js +145 -0
- package/vendor/capabilities/capabilities/external-buckets.js +191 -0
- package/vendor/capabilities/capabilities/fleet.js +21 -5
- package/vendor/capabilities/capabilities/identity.js +114 -3
- package/vendor/capabilities/capabilities/listings.js +14 -8
- package/vendor/capabilities/capabilities/mounts.js +176 -0
- package/vendor/capabilities/capabilities/networks.js +1257 -0
- package/vendor/capabilities/capabilities/observability.js +38 -8
- package/vendor/capabilities/capabilities/tokens.js +20 -8
- package/vendor/capabilities/capabilities/volumes.js +178 -0
- package/vendor/capabilities/capability.js +19 -13
- package/vendor/capabilities/cli-catalog.js +65 -0
- package/vendor/capabilities/cli-migrations.js +288 -0
- package/vendor/capabilities/cli.js +7 -0
- package/vendor/capabilities/software-catalog.js +36 -0
- package/vendor/capabilities/spec-catalog.js +2 -2
- package/src/catalog-dispatch.mjs +0 -605
- package/src/color.mjs +0 -71
- package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
- package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
- package/vendor/capabilities/capabilities/identity.d.ts +0 -3
- package/vendor/capabilities/capabilities/listings.d.ts +0 -51
- package/vendor/capabilities/capabilities/observability.d.ts +0 -5
- package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
- package/vendor/capabilities/capabilities/staff-access.js +0 -278
- package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
- package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
- package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
- package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
- package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
- package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
- package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
- package/vendor/capabilities/capabilities/staff-shared.js +0 -98
- package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
- package/vendor/capabilities/capabilities/users.d.ts +0 -2
- package/vendor/capabilities/capability.d.ts +0 -93
- package/vendor/capabilities/catalog.d.ts +0 -3
- package/vendor/capabilities/catalog.js +0 -26
- package/vendor/capabilities/index.d.ts +0 -16
- package/vendor/capabilities/index.js +0 -16
- package/vendor/capabilities/names.d.ts +0 -6
- package/vendor/capabilities/role.d.ts +0 -6
- package/vendor/capabilities/role.js +0 -17
- package/vendor/capabilities/spec-catalog.d.ts +0 -78
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { adminCommandHandler } from "./admin-command-handlers.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_PAGE_LIMIT = 50;
|
|
4
|
+
const MAX_PAGE_LIMIT = 500;
|
|
5
|
+
|
|
6
|
+
export class AdminUsageError extends Error {
|
|
7
|
+
constructor(message, { code = "usage_error", field } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "AdminUsageError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.field = field;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function vnextCommand(capability) {
|
|
16
|
+
return capability.cli?.vnext?.command ?? capability.cli?.command ?? [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function adminCommands(catalog) {
|
|
20
|
+
return catalog
|
|
21
|
+
.filter((capability) => capability.cli?.vnext?.audience === "admin")
|
|
22
|
+
.sort((left, right) =>
|
|
23
|
+
vnextCommand(left).join(" ").localeCompare(vnextCommand(right).join(" "))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commandMatch(catalog, argv) {
|
|
28
|
+
return adminCommands(catalog)
|
|
29
|
+
.filter((capability) => vnextCommand(capability).every((part, index) => argv[index] === part))
|
|
30
|
+
.sort((left, right) => vnextCommand(right).length - vnextCommand(left).length)[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function adminCommandData(capability) {
|
|
34
|
+
const help = capability.cli.vnext;
|
|
35
|
+
return {
|
|
36
|
+
command: vnextCommand(capability).join(" "),
|
|
37
|
+
outcome: help.outcome,
|
|
38
|
+
human: help.human,
|
|
39
|
+
automation: help.automation,
|
|
40
|
+
accepted_references: help.acceptedReferences ?? [],
|
|
41
|
+
review: help.review ?? null,
|
|
42
|
+
resumable_states: help.resumableStates ?? [],
|
|
43
|
+
related: help.related ?? [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function adminRootData(catalog, roots, selectedRoot) {
|
|
48
|
+
const commands = adminCommands(catalog);
|
|
49
|
+
return {
|
|
50
|
+
roots: roots
|
|
51
|
+
.filter((root) => selectedRoot === undefined || root.name === selectedRoot)
|
|
52
|
+
.map((root) => ({
|
|
53
|
+
name: root.name,
|
|
54
|
+
commands: commands
|
|
55
|
+
.filter((capability) => vnextCommand(capability)[1] === root.name)
|
|
56
|
+
.map(adminCommandData),
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveAdminRequest(catalog, roots, argv) {
|
|
62
|
+
if (
|
|
63
|
+
argv.length === 0 ||
|
|
64
|
+
(argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help"))
|
|
65
|
+
) {
|
|
66
|
+
return { kind: "root", command: "admin" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (argv[0] === "help") {
|
|
70
|
+
const targetArgv = ["admin", ...argv.slice(1)];
|
|
71
|
+
const target = commandMatch(catalog, targetArgv);
|
|
72
|
+
if (target && targetArgv.length === vnextCommand(target).length) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "help",
|
|
75
|
+
command: `help ${vnextCommand(target).join(" ")}`,
|
|
76
|
+
capability: target,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (argv.length === 2) {
|
|
80
|
+
const root = roots.find((candidate) => candidate.name === argv[1]);
|
|
81
|
+
if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
|
|
82
|
+
}
|
|
83
|
+
throw new AdminUsageError(
|
|
84
|
+
"The requested admin command is unavailable for the current authorization.",
|
|
85
|
+
{
|
|
86
|
+
code: "admin_command_unavailable",
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (argv.length === 1) {
|
|
92
|
+
const root = roots.find((candidate) => candidate.name === argv[0]);
|
|
93
|
+
if (root) return { kind: "root", command: `admin ${root.name}`, root: root.name };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const capability = commandMatch(catalog, ["admin", ...argv]);
|
|
97
|
+
if (!capability) {
|
|
98
|
+
throw new AdminUsageError(
|
|
99
|
+
"The requested admin command is unavailable for the current authorization.",
|
|
100
|
+
{
|
|
101
|
+
code: "admin_command_unavailable",
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const command = vnextCommand(capability).join(" ");
|
|
106
|
+
const rest = argv.slice(vnextCommand(capability).length - 1);
|
|
107
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
108
|
+
if (rest.length !== 1) throw new AdminUsageError(`Usage: ${capability.cli.vnext.human}`);
|
|
109
|
+
return { kind: "help", command: `help ${command}`, capability };
|
|
110
|
+
}
|
|
111
|
+
const handler = adminCommandHandler(capability);
|
|
112
|
+
return {
|
|
113
|
+
kind: "command",
|
|
114
|
+
command,
|
|
115
|
+
capability,
|
|
116
|
+
handler,
|
|
117
|
+
...(handler ? handler.parse(rest, capability) : parseAdminInput(capability, rest)),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseOptions(argv, allowed, usage) {
|
|
122
|
+
const options = {};
|
|
123
|
+
const positionals = [];
|
|
124
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
125
|
+
const argument = argv[index];
|
|
126
|
+
if (!argument.startsWith("--")) {
|
|
127
|
+
positionals.push(argument);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const separator = argument.indexOf("=");
|
|
131
|
+
const name = argument.slice(2, separator === -1 ? undefined : separator);
|
|
132
|
+
if (!allowed.includes(name)) throw new AdminUsageError(`${usage}\nUnknown option: --${name}`);
|
|
133
|
+
const value = separator === -1 ? argv[index + 1] : argument.slice(separator + 1);
|
|
134
|
+
if (!value || (separator === -1 && value.startsWith("--"))) {
|
|
135
|
+
throw new AdminUsageError(`${usage}\n--${name} requires a value.`, { field: name });
|
|
136
|
+
}
|
|
137
|
+
if (options[name] !== undefined) {
|
|
138
|
+
throw new AdminUsageError(`${usage}\n--${name} may be supplied only once.`, { field: name });
|
|
139
|
+
}
|
|
140
|
+
options[name] = value;
|
|
141
|
+
if (separator === -1) index += 1;
|
|
142
|
+
}
|
|
143
|
+
return { options, positionals };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function positiveInteger(value, name, maximum) {
|
|
147
|
+
if (value === undefined) return undefined;
|
|
148
|
+
const parsed = Number(value);
|
|
149
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
150
|
+
throw new AdminUsageError(`--${name} must be an integer from 1 to ${maximum}.`, {
|
|
151
|
+
field: name,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseAdminInput(capability, argv) {
|
|
158
|
+
const command = vnextCommand(capability).join(" ");
|
|
159
|
+
const help = capability.cli.vnext;
|
|
160
|
+
const usage = `Usage: ${help.human}`;
|
|
161
|
+
const allowed = [...help.human.matchAll(/--([a-z0-9-]+)/g)].map((match) => match[1]);
|
|
162
|
+
const { options, positionals } = parseOptions(argv, allowed, usage);
|
|
163
|
+
const nodeCommand = (help.acceptedReferences ?? []).includes("canonical node ID");
|
|
164
|
+
if ((nodeCommand && positionals.length !== 1) || (!nodeCommand && positionals.length !== 0)) {
|
|
165
|
+
throw new AdminUsageError(usage);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const limit = positiveInteger(options.limit, "limit", MAX_PAGE_LIMIT) ?? DEFAULT_PAGE_LIMIT;
|
|
169
|
+
const input = {};
|
|
170
|
+
if (nodeCommand) input.node_id = positionals[0];
|
|
171
|
+
if (options.operator) input.operator = options.operator;
|
|
172
|
+
if (options.facility) input.facility_id = options.facility;
|
|
173
|
+
if (options.status) input.status = options.status;
|
|
174
|
+
if (options.email) input.email = options.email;
|
|
175
|
+
if (options.organization) input.organization = options.organization;
|
|
176
|
+
if (options.name) input.name = options.name;
|
|
177
|
+
if (options.span) input.span = options.span;
|
|
178
|
+
if (options.start) input.start = options.start;
|
|
179
|
+
if (options.end) input.end = options.end;
|
|
180
|
+
if (options.before) input.before = options.before;
|
|
181
|
+
if (options.stream) input.stream = options.stream;
|
|
182
|
+
if (options.cursor && command === "admin nodes logs") input.cursor = options.cursor;
|
|
183
|
+
if (command === "admin users search") input.max = 1000;
|
|
184
|
+
const maxPoints = positiveInteger(options["max-points"], "max-points", 500);
|
|
185
|
+
if (maxPoints !== undefined) input.max_points = maxPoints;
|
|
186
|
+
const count = positiveInteger(options.count, "count", 500);
|
|
187
|
+
if (count !== undefined) input.count = count;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
input,
|
|
191
|
+
pagination: {
|
|
192
|
+
cursor: options.cursor,
|
|
193
|
+
limit: command === "admin nodes logs" ? (count ?? 200) : limit,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function collectionKey(capability) {
|
|
199
|
+
const [, root, leaf] = vnextCommand(capability);
|
|
200
|
+
if (root === "enrollment-tokens") return "enrollment_tokens";
|
|
201
|
+
if (root === "nodes" && leaf === "logs") return "logs";
|
|
202
|
+
return ["facilities", "nodes", "operators", "reservations", "users"].includes(root) ? root : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isSensitiveAdminField(key) {
|
|
206
|
+
const normalized = key.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
207
|
+
return (
|
|
208
|
+
normalized === "authorization" ||
|
|
209
|
+
normalized === "session" ||
|
|
210
|
+
normalized === "token" ||
|
|
211
|
+
normalized.endsWith("_session") ||
|
|
212
|
+
normalized.endsWith("_token") ||
|
|
213
|
+
normalized.includes("access_token") ||
|
|
214
|
+
normalized.includes("api_key") ||
|
|
215
|
+
normalized.includes("credential") ||
|
|
216
|
+
normalized.includes("password") ||
|
|
217
|
+
normalized.includes("plaintext") ||
|
|
218
|
+
normalized.includes("private_key") ||
|
|
219
|
+
normalized.includes("secret")
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sanitizeAdminValue(value) {
|
|
224
|
+
if (Array.isArray(value)) return value.map(sanitizeAdminValue);
|
|
225
|
+
if (!value || typeof value !== "object") return value;
|
|
226
|
+
const safe = {};
|
|
227
|
+
for (const [key, child] of Object.entries(value)) {
|
|
228
|
+
if (isSensitiveAdminField(key)) continue;
|
|
229
|
+
safe[key] = sanitizeAdminValue(child);
|
|
230
|
+
}
|
|
231
|
+
return safe;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function collectionRows(payload, key) {
|
|
235
|
+
if (Array.isArray(payload)) return payload;
|
|
236
|
+
if (!payload || typeof payload !== "object") return [];
|
|
237
|
+
for (const candidate of [key, "items", "results", "data", "entries", "events", "records"]) {
|
|
238
|
+
if (Array.isArray(payload[candidate])) return payload[candidate];
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function encodeCursor(command, offset) {
|
|
244
|
+
return Buffer.from(JSON.stringify({ command, offset }), "utf8").toString("base64url");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function decodeCursor(command, cursor) {
|
|
248
|
+
if (!cursor) return 0;
|
|
249
|
+
try {
|
|
250
|
+
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
251
|
+
if (decoded.command === command && Number.isInteger(decoded.offset) && decoded.offset >= 0) {
|
|
252
|
+
return decoded.offset;
|
|
253
|
+
}
|
|
254
|
+
if (decoded.command !== undefined || decoded.offset !== undefined) {
|
|
255
|
+
throw new AdminUsageError("The pagination cursor belongs to a different admin command.", {
|
|
256
|
+
code: "invalid_cursor",
|
|
257
|
+
field: "cursor",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof AdminUsageError) throw error;
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function finalizeAdminResult(capability, payload, pagination) {
|
|
267
|
+
if (!pagination) return { data: sanitizeAdminValue(payload), page: null };
|
|
268
|
+
const command = vnextCommand(capability).join(" ");
|
|
269
|
+
const key = collectionKey(capability);
|
|
270
|
+
if (!key) return { data: sanitizeAdminValue(payload), page: null };
|
|
271
|
+
const rows = collectionRows(payload, key);
|
|
272
|
+
const offset = decodeCursor(command, pagination.cursor);
|
|
273
|
+
const selected = rows.slice(offset, offset + pagination.limit).map(sanitizeAdminValue);
|
|
274
|
+
const ownerNextCursor =
|
|
275
|
+
payload && !Array.isArray(payload)
|
|
276
|
+
? (payload.next_cursor ?? payload.nextCursor ?? payload.page?.next_cursor ?? null)
|
|
277
|
+
: null;
|
|
278
|
+
const nextCursor =
|
|
279
|
+
offset + selected.length < rows.length
|
|
280
|
+
? encodeCursor(command, offset + selected.length)
|
|
281
|
+
: (ownerNextCursor ?? null);
|
|
282
|
+
return {
|
|
283
|
+
data: { [key]: selected },
|
|
284
|
+
page: {
|
|
285
|
+
limit: pagination.limit,
|
|
286
|
+
returned: selected.length,
|
|
287
|
+
next_cursor: nextCursor,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function writeAdminRootHuman(stdout, data) {
|
|
293
|
+
stdout.write("Ornn admin\n\nAuthorized resources:\n");
|
|
294
|
+
for (const root of data.roots) {
|
|
295
|
+
stdout.write(` ${root.name}\n`);
|
|
296
|
+
for (const command of root.commands)
|
|
297
|
+
stdout.write(` ${command.command} ${command.outcome}\n`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function writeAdminHelpHuman(stdout, capability) {
|
|
302
|
+
const data = adminCommandData(capability);
|
|
303
|
+
stdout.write(`${data.command}\n\nOutcome: ${data.outcome}\n\n`);
|
|
304
|
+
stdout.write(`Usage:\n ${data.human}\n\nAutomation:\n ${data.automation}\n`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function writeAdminResultHuman(stdout, command, result) {
|
|
308
|
+
const key = result.page ? Object.keys(result.data)[0] : null;
|
|
309
|
+
if (!key) {
|
|
310
|
+
stdout.write(`${JSON.stringify(result.data, null, 2)}\n`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const rows = result.data[key];
|
|
314
|
+
if (rows.length === 0) {
|
|
315
|
+
stdout.write(`No ${key.replaceAll("_", " ")} found.\n`);
|
|
316
|
+
} else {
|
|
317
|
+
for (const row of rows) stdout.write(`${JSON.stringify(row)}\n`);
|
|
318
|
+
}
|
|
319
|
+
if (result.page.next_cursor) {
|
|
320
|
+
stdout.write(`Next: ornn ${command} --cursor ${result.page.next_cursor}\n`);
|
|
321
|
+
}
|
|
322
|
+
}
|
package/src/api-client.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clearAuthCredentials,
|
|
5
|
+
configuredApiBaseUrl,
|
|
6
|
+
configuredInstallerBaseUrl,
|
|
7
|
+
loadAuthSession,
|
|
8
|
+
} from "./auth-store.mjs";
|
|
2
9
|
import { resolveAuthBaseUrl } from "./device-auth.mjs";
|
|
3
10
|
|
|
11
|
+
const { version: CLI_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
12
|
+
|
|
4
13
|
export class CliApiError extends Error {
|
|
5
14
|
constructor(message, { status, detail, genericFallback = false } = {}) {
|
|
6
15
|
super(message);
|
|
@@ -52,10 +61,7 @@ function gatewayOriginForAuthOrigin(value, sourceName) {
|
|
|
52
61
|
if (url.hostname === "compute.ornn.com") {
|
|
53
62
|
return "https://api.ornn.com";
|
|
54
63
|
}
|
|
55
|
-
if (
|
|
56
|
-
url.hostname === "staging.compute.ornn.com" ||
|
|
57
|
-
url.hostname === "staging.fabric.ornn.com"
|
|
58
|
-
) {
|
|
64
|
+
if (url.hostname === "staging.compute.ornn.com" || url.hostname === "staging.fabric.ornn.com") {
|
|
59
65
|
return "https://staging.api.ornn.com";
|
|
60
66
|
}
|
|
61
67
|
const dynamicMatch = url.hostname.match(/^([a-z0-9-]+)\.fabric\.ornn\.com$/i);
|
|
@@ -72,16 +78,32 @@ export async function loadRequiredSession({ env = process.env } = {}) {
|
|
|
72
78
|
}
|
|
73
79
|
return session;
|
|
74
80
|
}
|
|
81
|
+
export function userCredentialHeaders(session) {
|
|
82
|
+
if (!session?.accessToken) return {};
|
|
83
|
+
const headers = {
|
|
84
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
85
|
+
// This marker and the organization selector grant nothing by themselves.
|
|
86
|
+
// The gateway re-resolves the bearer and replaces them with trusted scope.
|
|
87
|
+
"X-Ornn-Credential-Type": "user",
|
|
88
|
+
"X-Ornn-CLI-Version": CLI_VERSION,
|
|
89
|
+
};
|
|
90
|
+
if (typeof session.selectedOrganizationId === "string" && session.selectedOrganizationId.trim()) {
|
|
91
|
+
headers["X-Organization-Id"] = session.selectedOrganizationId.trim();
|
|
92
|
+
}
|
|
93
|
+
return headers;
|
|
94
|
+
}
|
|
75
95
|
|
|
76
96
|
export async function cliRequest({
|
|
77
97
|
authRequired = true,
|
|
78
98
|
body,
|
|
79
99
|
endpoint,
|
|
100
|
+
headers: extraHeaders = {},
|
|
80
101
|
env = process.env,
|
|
81
102
|
fetchImpl = fetch,
|
|
82
103
|
method = "GET",
|
|
83
104
|
raw = false,
|
|
84
105
|
session,
|
|
106
|
+
signal,
|
|
85
107
|
timeoutMs,
|
|
86
108
|
} = {}) {
|
|
87
109
|
const authSession = session ?? (authRequired ? await loadRequiredSession({ env }) : null);
|
|
@@ -89,15 +111,24 @@ export async function cliRequest({
|
|
|
89
111
|
const url = new URL(endpoint, `${baseUrl}/`);
|
|
90
112
|
const headers = {
|
|
91
113
|
Accept: raw ? "*/*" : "application/json",
|
|
114
|
+
...extraHeaders,
|
|
92
115
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
for (const name of Object.keys(headers)) {
|
|
117
|
+
if (
|
|
118
|
+
[
|
|
119
|
+
"authorization",
|
|
120
|
+
"x-auth-tenant-id",
|
|
121
|
+
"x-auth-user-id",
|
|
122
|
+
"x-organization-id",
|
|
123
|
+
"x-ornn-credential-type",
|
|
124
|
+
"x-ornn-cli-version",
|
|
125
|
+
"x-tenant-id",
|
|
126
|
+
].includes(name.toLowerCase())
|
|
127
|
+
) {
|
|
128
|
+
delete headers[name];
|
|
129
|
+
}
|
|
100
130
|
}
|
|
131
|
+
Object.assign(headers, userCredentialHeaders(authSession));
|
|
101
132
|
|
|
102
133
|
let requestBody;
|
|
103
134
|
if (body !== undefined) {
|
|
@@ -107,33 +138,43 @@ export async function cliRequest({
|
|
|
107
138
|
|
|
108
139
|
let response;
|
|
109
140
|
try {
|
|
141
|
+
const requestSignal = signal ?? (timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined);
|
|
110
142
|
response = await fetchImpl(url, {
|
|
111
143
|
body: requestBody,
|
|
112
144
|
headers,
|
|
113
145
|
method,
|
|
114
|
-
...(
|
|
146
|
+
...(requestSignal ? { signal: requestSignal } : {}),
|
|
115
147
|
});
|
|
116
148
|
} catch (error) {
|
|
117
149
|
// Detect the timeout/abort case before the generic network-failure wrapping
|
|
118
150
|
// below, so a wedged compute call (e.g. status blocked behind an
|
|
119
151
|
// unresponsive FUSE mount) reports a customer-facing timeout instead of an
|
|
120
152
|
// opaque "Could not reach Ornn" message.
|
|
153
|
+
if (signal?.aborted && (error?.name === "TimeoutError" || error?.name === "AbortError")) {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
121
156
|
if (timeoutMs && (error?.name === "TimeoutError" || error?.name === "AbortError")) {
|
|
122
157
|
throw new CliApiError(
|
|
123
|
-
`Ornn did not respond within ${Math.round(timeoutMs / 1000)} seconds. The service or a node mount may be unresponsive; try again shortly
|
|
158
|
+
`Ornn did not respond within ${Math.round(timeoutMs / 1000)} seconds. The service or a node mount may be unresponsive; try again shortly.`
|
|
124
159
|
);
|
|
125
160
|
}
|
|
126
161
|
const message = error instanceof Error ? error.message : String(error);
|
|
127
162
|
throw new CliApiError(
|
|
128
|
-
`Could not reach Ornn at ${baseUrl} (${message}). Check your network connection and that ${baseUrl} is the correct Ornn API endpoint, then try again
|
|
163
|
+
`Could not reach Ornn at ${baseUrl} (${message}). Check your network connection and that ${baseUrl} is the correct Ornn API endpoint, then try again.`
|
|
129
164
|
);
|
|
130
165
|
}
|
|
131
166
|
|
|
132
167
|
if (raw) {
|
|
133
168
|
const text = await response.text();
|
|
134
169
|
if (!response.ok) {
|
|
135
|
-
await handleAuthExpiry({ env, response,
|
|
136
|
-
|
|
170
|
+
await handleAuthExpiry({ env, response, accessToken: authSession?.accessToken });
|
|
171
|
+
const message =
|
|
172
|
+
text ||
|
|
173
|
+
response.statusText ||
|
|
174
|
+
(response.status === 403
|
|
175
|
+
? "Your account is not authorized for this command."
|
|
176
|
+
: `Ornn request failed with HTTP ${response.status}.`);
|
|
177
|
+
throw new CliApiError(message, {
|
|
137
178
|
status: response.status,
|
|
138
179
|
genericFallback: true,
|
|
139
180
|
});
|
|
@@ -146,8 +187,8 @@ export async function cliRequest({
|
|
|
146
187
|
// Always throws: handles the 401/expired-session case (clearing the
|
|
147
188
|
// stored token) and the generic detail-surfacing case.
|
|
148
189
|
await raiseForFailedResponse({
|
|
190
|
+
accessToken: authSession?.accessToken,
|
|
149
191
|
env,
|
|
150
|
-
hadToken: Boolean(authSession?.accessToken),
|
|
151
192
|
response,
|
|
152
193
|
text,
|
|
153
194
|
url: url.toString(),
|
|
@@ -172,17 +213,17 @@ export async function cliRequest({
|
|
|
172
213
|
* status text) when the body has no usable detail.
|
|
173
214
|
*/
|
|
174
215
|
export async function raiseForFailedResponse({
|
|
216
|
+
accessToken,
|
|
175
217
|
env = process.env,
|
|
176
218
|
fallbackMessage,
|
|
177
219
|
formatMessage = (message) => message,
|
|
178
|
-
hadToken,
|
|
179
220
|
response,
|
|
180
221
|
text,
|
|
181
222
|
url,
|
|
182
223
|
} = {}) {
|
|
183
224
|
// Clear a dead token before parsing, so a 401 with a non-JSON body
|
|
184
225
|
// (e.g. a gateway/WAF error page) still triggers the re-login flow.
|
|
185
|
-
await handleAuthExpiry({ env, response
|
|
226
|
+
await handleAuthExpiry({ accessToken, env, response });
|
|
186
227
|
const data = text ? parseJson(text, url, response) : null;
|
|
187
228
|
const detail = data?.detail ?? data?.error ?? response.statusText;
|
|
188
229
|
const detailText = detailMessage(detail);
|
|
@@ -203,26 +244,32 @@ export async function raiseForFailedResponse({
|
|
|
203
244
|
}
|
|
204
245
|
|
|
205
246
|
export function resolveInstallerBaseUrl({ env = process.env } = {}) {
|
|
206
|
-
const
|
|
207
|
-
|
|
247
|
+
const [value, source] = env.ORNN_INSTALLER_URL?.trim()
|
|
248
|
+
? [env.ORNN_INSTALLER_URL, "ORNN_INSTALLER_URL"]
|
|
249
|
+
: env.NEXT_PUBLIC_INSTALLER_URL?.trim()
|
|
250
|
+
? [env.NEXT_PUBLIC_INSTALLER_URL, "NEXT_PUBLIC_INSTALLER_URL"]
|
|
251
|
+
: [configuredInstallerBaseUrl(env), "configured installerBaseUrl"];
|
|
252
|
+
if (!value?.trim()) {
|
|
208
253
|
return null;
|
|
209
254
|
}
|
|
210
|
-
return requireValidBaseUrl(
|
|
255
|
+
return requireValidBaseUrl(value.trim(), source, { allowPath: true });
|
|
211
256
|
}
|
|
212
257
|
|
|
213
258
|
function operatorProxyEndpoint(endpoint) {
|
|
214
|
-
return normalizeComputePath(endpoint)
|
|
259
|
+
return `/v1/staff${normalizeComputePath(endpoint)}`;
|
|
215
260
|
}
|
|
216
261
|
|
|
217
262
|
// Staff path: CLI bearer → gateway /v1/orchestrator/ → the owning service.
|
|
218
263
|
// The gateway resolves the bearer and applies staff policy; the CLI never ships
|
|
219
|
-
// a
|
|
264
|
+
// a review secret or service secret.
|
|
220
265
|
export async function operatorRequest({
|
|
221
266
|
body,
|
|
222
267
|
endpoint,
|
|
268
|
+
headers,
|
|
223
269
|
env = process.env,
|
|
224
270
|
fetchImpl = fetch,
|
|
225
271
|
method = "GET",
|
|
272
|
+
signal,
|
|
226
273
|
} = {}) {
|
|
227
274
|
const session = await loadAuthSession({ env });
|
|
228
275
|
if (!session?.accessToken) {
|
|
@@ -235,12 +282,18 @@ export async function operatorRequest({
|
|
|
235
282
|
body,
|
|
236
283
|
endpoint: operatorProxyEndpoint(endpoint),
|
|
237
284
|
env,
|
|
285
|
+
headers,
|
|
238
286
|
fetchImpl,
|
|
239
287
|
method,
|
|
288
|
+
signal,
|
|
240
289
|
authRequired: true,
|
|
241
290
|
});
|
|
242
291
|
}
|
|
243
292
|
|
|
293
|
+
export function computeEndpoint(path) {
|
|
294
|
+
return `/v1/workflows/compute${normalizeComputePath(path)}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
244
297
|
export function normalizeComputePath(path) {
|
|
245
298
|
if (!path || typeof path !== "string") {
|
|
246
299
|
throw new CliApiError("API path is required.");
|
|
@@ -291,11 +344,11 @@ function parseJson(text, url, response) {
|
|
|
291
344
|
}
|
|
292
345
|
}
|
|
293
346
|
|
|
294
|
-
async function handleAuthExpiry({ env, response
|
|
295
|
-
if (response.status !== 401 || !
|
|
347
|
+
async function handleAuthExpiry({ accessToken, env, response }) {
|
|
348
|
+
if (response.status !== 401 || !accessToken) {
|
|
296
349
|
return;
|
|
297
350
|
}
|
|
298
|
-
await
|
|
351
|
+
await clearAuthCredentials({ env, expectedAccessToken: accessToken });
|
|
299
352
|
throw new CliApiError(
|
|
300
353
|
"Your Ornn session has expired or was revoked. Run `ornn login` to sign in again.",
|
|
301
354
|
{
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, link, lstat, open, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
export class AtomicOutputExistsError extends Error {
|
|
8
|
+
constructor(path) {
|
|
9
|
+
super(`Refusing to replace existing file: ${path}`);
|
|
10
|
+
this.name = "AtomicOutputExistsError";
|
|
11
|
+
this.code = "destination_exists";
|
|
12
|
+
this.path = path;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function destinationExists(path) {
|
|
17
|
+
try {
|
|
18
|
+
await lstat(path);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === "ENOENT") return false;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function assertAtomicOutputAvailable(destination, { overwrite = false } = {}) {
|
|
27
|
+
if (typeof destination !== "string" || !destination.trim()) {
|
|
28
|
+
throw new TypeError("An output path is required.");
|
|
29
|
+
}
|
|
30
|
+
if (!overwrite && (await destinationExists(destination))) {
|
|
31
|
+
throw new AtomicOutputExistsError(destination);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nodeReadable(content) {
|
|
36
|
+
if (content && typeof content.getReader === "function") return Readable.fromWeb(content);
|
|
37
|
+
if (content && typeof content.pipe === "function") return content;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async function writeReadable(handle, readable) {
|
|
41
|
+
for await (const chunk of readable) {
|
|
42
|
+
const bytes =
|
|
43
|
+
typeof chunk === "string"
|
|
44
|
+
? Buffer.from(chunk)
|
|
45
|
+
: chunk instanceof Uint8Array || chunk instanceof DataView
|
|
46
|
+
? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
47
|
+
: null;
|
|
48
|
+
if (!bytes) throw new TypeError("Readable atomic output chunks must be bytes or text.");
|
|
49
|
+
let offset = 0;
|
|
50
|
+
while (offset < bytes.byteLength) {
|
|
51
|
+
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
52
|
+
if (result.bytesWritten === 0) throw new Error("Atomic output write made no progress.");
|
|
53
|
+
offset += result.bytesWritten;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Writes bytes through a unique same-directory temporary file and publishes
|
|
60
|
+
* them atomically. Without overwrite, hard-link publication provides an atomic
|
|
61
|
+
* no-replace operation; replacement uses an atomic rename.
|
|
62
|
+
*/
|
|
63
|
+
export async function writeAtomicOutput(
|
|
64
|
+
destination,
|
|
65
|
+
content,
|
|
66
|
+
{ mode = 0o644, overwrite = false } = {}
|
|
67
|
+
) {
|
|
68
|
+
await assertAtomicOutputAvailable(destination, { overwrite });
|
|
69
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
70
|
+
throw new TypeError("Output mode must be an integer between 0000 and 0777.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const directory = dirname(destination);
|
|
74
|
+
const temporary = join(directory, `.${process.pid}.${randomUUID()}.ornn-partial`);
|
|
75
|
+
let handle;
|
|
76
|
+
try {
|
|
77
|
+
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, mode);
|
|
78
|
+
const readable = nodeReadable(content);
|
|
79
|
+
if (readable) {
|
|
80
|
+
await writeReadable(handle, readable);
|
|
81
|
+
} else if (
|
|
82
|
+
typeof content === "string" ||
|
|
83
|
+
content instanceof Uint8Array ||
|
|
84
|
+
content instanceof DataView
|
|
85
|
+
) {
|
|
86
|
+
await handle.writeFile(content);
|
|
87
|
+
} else {
|
|
88
|
+
throw new TypeError("Atomic output content must be bytes, text, or a readable stream.");
|
|
89
|
+
}
|
|
90
|
+
await handle.sync();
|
|
91
|
+
await handle.close();
|
|
92
|
+
handle = undefined;
|
|
93
|
+
await chmod(temporary, mode);
|
|
94
|
+
|
|
95
|
+
if (overwrite) {
|
|
96
|
+
await rename(temporary, destination);
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
await link(temporary, destination);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code === "EEXIST") throw new AtomicOutputExistsError(destination);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
await rm(temporary);
|
|
105
|
+
}
|
|
106
|
+
return { mode, path: destination };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await handle?.close().catch(() => {});
|
|
109
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|