@ornncompute/cli 0.1.3 → 0.1.4
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 +41 -0
- package/package.json +1 -1
- package/src/api-client.mjs +63 -0
- package/src/cli.mjs +911 -20
- package/src/update.mjs +388 -0
package/src/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
CliApiError,
|
|
9
9
|
cliRequest,
|
|
10
10
|
computeEndpoint,
|
|
11
|
+
operatorRequest,
|
|
11
12
|
} from "./api-client.mjs";
|
|
12
13
|
import { clearAuthSession, getAuthConfigPath, loadAuthSession, saveAuthSession } from "./auth-store.mjs";
|
|
13
14
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
startDeviceFlow,
|
|
18
19
|
waitForDeviceApproval,
|
|
19
20
|
} from "./device-auth.mjs";
|
|
21
|
+
import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
|
|
20
22
|
|
|
21
23
|
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
22
24
|
const DEFAULT_BUY_NOW_USD_PER_GPU_HOUR = 10;
|
|
@@ -58,6 +60,14 @@ Examples:
|
|
|
58
60
|
ornn clusters create <reservation-id> --type kubernetes --wait
|
|
59
61
|
ornn networks list
|
|
60
62
|
ornn storage volumes list
|
|
63
|
+
ornn storage deploy <drive-id> --reservation <reservation-id>
|
|
64
|
+
ornn storage deploy status --reservation <reservation-id>
|
|
65
|
+
ornn storage filesystem deploy --reservation <reservation-id>
|
|
66
|
+
ornn storage filesystem delete --reservation <reservation-id>
|
|
67
|
+
ornn storage buckets connect gcs --bucket <bucket>
|
|
68
|
+
ornn storage buckets connect s3 --bucket <bucket> [--access-key-id <id> --secret-access-key-file <path>]
|
|
69
|
+
ornn storage buckets connect r2 --bucket <bucket> --account-id <id> [--access-key-id <id> --secret-access-key-file <path>]
|
|
70
|
+
ornn storage buckets verify <drive-id>
|
|
61
71
|
ornn ssh <machine-id>
|
|
62
72
|
|
|
63
73
|
Run \`ornn --help\` for all commands, or \`ornn help <command>\`.
|
|
@@ -68,6 +78,7 @@ const HELP = `Ornn CLI
|
|
|
68
78
|
Usage:
|
|
69
79
|
ornn help [command]
|
|
70
80
|
ornn login [--auth-base <url>] [--no-browser] [--timeout <seconds>]
|
|
81
|
+
ornn update
|
|
71
82
|
ornn whoami [--json]
|
|
72
83
|
ornn status [--json]
|
|
73
84
|
ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
|
|
@@ -92,6 +103,10 @@ Usage:
|
|
|
92
103
|
ornn nodes ssh-command <node-or-reservation-id> [--json]
|
|
93
104
|
ornn nodes keys attach <node-id> --key <path|id|label> [--json]
|
|
94
105
|
ornn nodes keys list <node-id> [--json]
|
|
106
|
+
ornn node health <node-id> [--json]
|
|
107
|
+
ornn node diagnose <node-id> [--json]
|
|
108
|
+
ornn node drain <node-id> [--reason <text>] [--json]
|
|
109
|
+
ornn node restore <node-id> [--reason <text>] [--json]
|
|
95
110
|
ornn ssh <node-or-reservation-id> [--print] [--identity-file <path>] [--user <name>] [--json]
|
|
96
111
|
ornn metrics nodes [--json]
|
|
97
112
|
ornn metrics node <node-id> [--json]
|
|
@@ -124,6 +139,17 @@ Usage:
|
|
|
124
139
|
ornn storage volumes refresh <drive-id> [--json]
|
|
125
140
|
ornn storage volumes clear <drive-id> [--json]
|
|
126
141
|
ornn storage volumes delete <drive-id> [--json]
|
|
142
|
+
ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]
|
|
143
|
+
ornn storage deploy status --reservation <reservation-id> [--json]
|
|
144
|
+
ornn storage filesystem deploy --reservation <reservation-id> [--performance-tier <tier>] [--capacity-gib <gib>] [--json]
|
|
145
|
+
ornn storage filesystem status --reservation <reservation-id> [--json]
|
|
146
|
+
ornn storage filesystem delete --reservation <reservation-id> [--json]
|
|
147
|
+
ornn storage buckets list [--json]
|
|
148
|
+
ornn storage buckets show <drive-id> [--json]
|
|
149
|
+
ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]
|
|
150
|
+
ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]
|
|
151
|
+
ornn storage buckets verify <drive-id> [--json]
|
|
152
|
+
ornn storage buckets disconnect <drive-id> [--json]
|
|
127
153
|
ornn keys list [--json]
|
|
128
154
|
ornn keys add [<public-key-file>] [--public-key <key>] [--label <label>] [--json]
|
|
129
155
|
ornn keys delete <key-id> [--json]
|
|
@@ -151,10 +177,17 @@ Aliases:
|
|
|
151
177
|
exchange = bid
|
|
152
178
|
gpus = reservations
|
|
153
179
|
|
|
180
|
+
Operator commands:
|
|
181
|
+
The \`ornn node\` commands are for Ornn operators/reviewers. They wrap the
|
|
182
|
+
internal-reviewer-gated compute endpoints directly and require
|
|
183
|
+
ORNN_COMPUTE_BASE_URL and ORNN_INTERNAL_REVIEW_SECRET (see Environment).
|
|
184
|
+
|
|
154
185
|
Environment:
|
|
155
|
-
ORNN_AUTH_BASE_URL
|
|
156
|
-
ORNN_API_BASE_URL
|
|
157
|
-
ORNN_CONFIG_HOME
|
|
186
|
+
ORNN_AUTH_BASE_URL Web/auth server origin. Defaults to http://localhost:3000.
|
|
187
|
+
ORNN_API_BASE_URL Optional web API origin. Defaults to ORNN_AUTH_BASE_URL.
|
|
188
|
+
ORNN_CONFIG_HOME Directory for CLI auth state. Defaults to ~/.config/ornn.
|
|
189
|
+
ORNN_COMPUTE_BASE_URL Internal compute service origin for \`ornn node\` operator commands.
|
|
190
|
+
ORNN_INTERNAL_REVIEW_SECRET Reviewer secret for \`ornn node\` operator commands.
|
|
158
191
|
|
|
159
192
|
Output:
|
|
160
193
|
Commands print human-readable output by default.
|
|
@@ -185,20 +218,37 @@ const HELP_TOPICS = new Set([
|
|
|
185
218
|
"metrics",
|
|
186
219
|
"network",
|
|
187
220
|
"networks",
|
|
221
|
+
"node",
|
|
188
222
|
"nodes",
|
|
189
223
|
"reservations",
|
|
190
224
|
"ssh",
|
|
191
225
|
"ssh-keys",
|
|
192
226
|
"status",
|
|
193
227
|
"storage",
|
|
228
|
+
"update",
|
|
194
229
|
"whoami",
|
|
195
230
|
]);
|
|
196
231
|
|
|
232
|
+
const NODE_OP_SUBCOMMANDS = new Set(["health", "diagnose", "drain", "restore"]);
|
|
197
233
|
const LISTINGS_COMMANDS = new Set(["availability", "listing", "listings"]);
|
|
198
234
|
const EXCHANGE_COMMANDS = new Set(["bid", "bids", "exchange"]);
|
|
199
235
|
const GPU_COMMANDS = new Set(["gpu", "gpus", "reservations"]);
|
|
200
236
|
|
|
201
237
|
export async function run(argv = [], io = {}) {
|
|
238
|
+
const exitCode = await dispatch(argv, io);
|
|
239
|
+
if (argv[0] !== "update") {
|
|
240
|
+
await maybeNotifyUpdate({
|
|
241
|
+
binPath: io.binPath ?? process.argv[1],
|
|
242
|
+
currentVersion: VERSION,
|
|
243
|
+
env: io.env ?? process.env,
|
|
244
|
+
fetchImpl: io.fetch ?? fetch,
|
|
245
|
+
stderr: io.stderr ?? process.stderr,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return exitCode;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function dispatch(argv = [], io = {}) {
|
|
202
252
|
const stdout = io.stdout ?? process.stdout;
|
|
203
253
|
const stderr = io.stderr ?? process.stderr;
|
|
204
254
|
const env = io.env ?? process.env;
|
|
@@ -244,6 +294,19 @@ export async function run(argv = [], io = {}) {
|
|
|
244
294
|
return await login(args, { env, fetchImpl, openBrowserImpl, stderr, stdout });
|
|
245
295
|
}
|
|
246
296
|
|
|
297
|
+
if (command === "update") {
|
|
298
|
+
parseCommandOptions(args, {}, "Usage: ornn update");
|
|
299
|
+
return await updateCommand({
|
|
300
|
+
binPath: io.binPath ?? process.argv[1],
|
|
301
|
+
currentVersion: VERSION,
|
|
302
|
+
env,
|
|
303
|
+
fetchImpl,
|
|
304
|
+
spawnProcess,
|
|
305
|
+
stderr,
|
|
306
|
+
stdout,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
247
310
|
if (command === "whoami" || command === "account") {
|
|
248
311
|
return await whoami(args, { env, fetchImpl, stderr, stdout });
|
|
249
312
|
}
|
|
@@ -272,6 +335,10 @@ export async function run(argv = [], io = {}) {
|
|
|
272
335
|
return await reservations(args, { commandName: command, env, fetchImpl, openBrowserImpl, stdout });
|
|
273
336
|
}
|
|
274
337
|
|
|
338
|
+
if (command === "node") {
|
|
339
|
+
return await nodeOps(args, { env, fetchImpl, stdout });
|
|
340
|
+
}
|
|
341
|
+
|
|
275
342
|
if (command === "nodes") {
|
|
276
343
|
return await nodes(args, { env, fetchImpl, openBrowserImpl, spawnProcess, stdout });
|
|
277
344
|
}
|
|
@@ -354,6 +421,10 @@ function validateHelpInvocation(command, args) {
|
|
|
354
421
|
return validateNoPositionals(args, "Usage: ornn logout");
|
|
355
422
|
}
|
|
356
423
|
|
|
424
|
+
if (command === "update") {
|
|
425
|
+
return validateNoPositionals(args, "Usage: ornn update");
|
|
426
|
+
}
|
|
427
|
+
|
|
357
428
|
if (command === "whoami" || command === "account") {
|
|
358
429
|
return validateNoPositionals(args, "Usage: ornn whoami [--json]", { booleanOptions: ["--json"] });
|
|
359
430
|
}
|
|
@@ -433,6 +504,18 @@ function validateHelpInvocation(command, args) {
|
|
|
433
504
|
return `Usage: ornn ${usageCommand} list|show|checkout`;
|
|
434
505
|
}
|
|
435
506
|
|
|
507
|
+
if (command === "node") {
|
|
508
|
+
const parsed = parseHelpArgs(args, { booleanOptions: ["--json"], valueOptions: ["--reason"] });
|
|
509
|
+
if (parsed.error) {
|
|
510
|
+
return parsed.error;
|
|
511
|
+
}
|
|
512
|
+
const [subcommand, , ...extra] = parsed.positionals;
|
|
513
|
+
if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
|
|
514
|
+
return "Usage: ornn node health|diagnose|drain|restore <node-id>";
|
|
515
|
+
}
|
|
516
|
+
return extra.length ? nodeOpUsage(subcommand) : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
436
519
|
if (command === "nodes") {
|
|
437
520
|
const parsed = parseHelpArgs(args, {
|
|
438
521
|
booleanOptions: ["--json", "--no-open", "--open", "--wait"],
|
|
@@ -587,8 +670,8 @@ function validateHelpInvocation(command, args) {
|
|
|
587
670
|
|
|
588
671
|
if (command === "storage") {
|
|
589
672
|
const parsed = parseHelpArgs(args, {
|
|
590
|
-
booleanOptions: ["--json"],
|
|
591
|
-
valueOptions: ["--name", "--source", "--source-drive-id"],
|
|
673
|
+
booleanOptions: ["--json", "--read-only", "--read-write", "--verify"],
|
|
674
|
+
valueOptions: ["--access-key-id", "--account-id", "--bucket", "--endpoint-url", "--mount-path", "--name", "--prefix", "--region", "--reservation", "--reservation-id", "--secret-access-key", "--secret-access-key-file", "--source", "--source-drive-id", "--url"],
|
|
592
675
|
});
|
|
593
676
|
if (parsed.error) {
|
|
594
677
|
return parsed.error;
|
|
@@ -597,8 +680,38 @@ function validateHelpInvocation(command, args) {
|
|
|
597
680
|
if (!resource) {
|
|
598
681
|
return null;
|
|
599
682
|
}
|
|
683
|
+
if (resource === "deploy") {
|
|
684
|
+
return !subcommand || id || extra.length
|
|
685
|
+
? "Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]"
|
|
686
|
+
: null;
|
|
687
|
+
}
|
|
688
|
+
if (resource === "buckets") {
|
|
689
|
+
if (!subcommand || subcommand === "list") {
|
|
690
|
+
return id || extra.length ? "Usage: ornn storage buckets list [--json]" : null;
|
|
691
|
+
}
|
|
692
|
+
if (subcommand === "show") {
|
|
693
|
+
return !id || extra.length ? "Usage: ornn storage buckets show <drive-id> [--json]" : null;
|
|
694
|
+
}
|
|
695
|
+
if (subcommand === "connect") {
|
|
696
|
+
return !["gcs", "s3", "r2"].includes(id) || extra.length
|
|
697
|
+
? "Usage: ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]"
|
|
698
|
+
: null;
|
|
699
|
+
}
|
|
700
|
+
if (subcommand === "verify") {
|
|
701
|
+
return !id || extra.length ? "Usage: ornn storage buckets verify <drive-id> [--json]" : null;
|
|
702
|
+
}
|
|
703
|
+
if (subcommand === "update-credentials") {
|
|
704
|
+
return !id || extra.length
|
|
705
|
+
? "Usage: ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]"
|
|
706
|
+
: null;
|
|
707
|
+
}
|
|
708
|
+
if (subcommand === "disconnect") {
|
|
709
|
+
return !id || extra.length ? "Usage: ornn storage buckets disconnect <drive-id> [--json]" : null;
|
|
710
|
+
}
|
|
711
|
+
return "Usage: ornn storage buckets list|show|connect|verify|update-credentials|disconnect";
|
|
712
|
+
}
|
|
600
713
|
if (!["drives", "volumes"].includes(resource)) {
|
|
601
|
-
return "Usage: ornn storage volumes list|show|create|refresh|clear|delete";
|
|
714
|
+
return "Usage: ornn storage volumes list|show|create|refresh|clear|delete; ornn storage buckets connect gcs|s3|r2 --bucket <bucket>";
|
|
602
715
|
}
|
|
603
716
|
if (!subcommand || subcommand === "list") {
|
|
604
717
|
return id || extra.length ? "Usage: ornn storage volumes list [--json]" : null;
|
|
@@ -1157,6 +1270,116 @@ async function reservations(args, context) {
|
|
|
1157
1270
|
throw new Error(commandUsage);
|
|
1158
1271
|
}
|
|
1159
1272
|
|
|
1273
|
+
// Operator-only surface (`ornn node`); distinct from tenant `ornn nodes`.
|
|
1274
|
+
async function nodeOps(args, context) {
|
|
1275
|
+
const usage = "Usage: ornn node health|diagnose|drain|restore <node-id>";
|
|
1276
|
+
const [subcommand, id, ...rest] = args;
|
|
1277
|
+
if (!subcommand || !NODE_OP_SUBCOMMANDS.has(subcommand)) {
|
|
1278
|
+
throw new Error(usage);
|
|
1279
|
+
}
|
|
1280
|
+
if (!id) {
|
|
1281
|
+
throw new Error(nodeOpUsage(subcommand));
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (subcommand === "health") {
|
|
1285
|
+
const options = parseCommandOptions(rest, { boolean: ["json"] }, nodeOpUsage(subcommand));
|
|
1286
|
+
const node = await operatorRequest({
|
|
1287
|
+
endpoint: `/provisioning/nodes/${encodeURIComponent(id)}`,
|
|
1288
|
+
env: context.env,
|
|
1289
|
+
fetchImpl: context.fetchImpl,
|
|
1290
|
+
});
|
|
1291
|
+
if (options.json) {
|
|
1292
|
+
writeJson(context.stdout, redactNodeSecrets(node));
|
|
1293
|
+
} else {
|
|
1294
|
+
writeNodeHealth(context.stdout, node);
|
|
1295
|
+
}
|
|
1296
|
+
return 0;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (subcommand === "diagnose") {
|
|
1300
|
+
const options = parseCommandOptions(rest, { boolean: ["json"] }, nodeOpUsage(subcommand));
|
|
1301
|
+
// source.mode tags the validation run as CLI-originated for compute audit.
|
|
1302
|
+
const packet = await operatorRequest({
|
|
1303
|
+
body: { source: { mode: "cli" } },
|
|
1304
|
+
endpoint: `/provisioning/nodes/${encodeURIComponent(id)}/tests`,
|
|
1305
|
+
env: context.env,
|
|
1306
|
+
fetchImpl: context.fetchImpl,
|
|
1307
|
+
method: "POST",
|
|
1308
|
+
});
|
|
1309
|
+
if (options.json) {
|
|
1310
|
+
writeJson(context.stdout, packet);
|
|
1311
|
+
} else {
|
|
1312
|
+
context.stdout.write(`Node validation test run queued for node ${id}.\n`);
|
|
1313
|
+
writeOptionalStatusLine(context.stdout, "Packet", packet?.packet_id ?? packet?.id);
|
|
1314
|
+
writeOptionalStatusLine(context.stdout, "Request", packet?.request_id);
|
|
1315
|
+
}
|
|
1316
|
+
return 0;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// drain / restore share POST body + reason handling.
|
|
1320
|
+
const options = parseCommandOptions(
|
|
1321
|
+
rest,
|
|
1322
|
+
{ boolean: ["json"], value: ["reason"] },
|
|
1323
|
+
nodeOpUsage(subcommand),
|
|
1324
|
+
);
|
|
1325
|
+
const reason = optionalStringOption(options.reason);
|
|
1326
|
+
const node = await operatorRequest({
|
|
1327
|
+
body: reason ? { reason } : {},
|
|
1328
|
+
endpoint: `/provisioning/nodes/${encodeURIComponent(id)}/${subcommand}`,
|
|
1329
|
+
env: context.env,
|
|
1330
|
+
fetchImpl: context.fetchImpl,
|
|
1331
|
+
method: "POST",
|
|
1332
|
+
});
|
|
1333
|
+
if (options.json) {
|
|
1334
|
+
writeJson(context.stdout, redactNodeSecrets(node));
|
|
1335
|
+
} else {
|
|
1336
|
+
context.stdout.write(`Node ${subcommand} requested: ${node?.id ?? id}\n`);
|
|
1337
|
+
writeNodeHealth(context.stdout, node);
|
|
1338
|
+
}
|
|
1339
|
+
return 0;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function nodeOpUsage(subcommand) {
|
|
1343
|
+
if (subcommand === "drain" || subcommand === "restore") {
|
|
1344
|
+
return `Usage: ornn node ${subcommand} <node-id> [--reason <text>] [--json]`;
|
|
1345
|
+
}
|
|
1346
|
+
return `Usage: ornn node ${subcommand} <node-id> [--json]`;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function writeNodeHealth(stdout, node) {
|
|
1350
|
+
stdout.write(`${node?.id ?? "unknown"}\n`);
|
|
1351
|
+
stdout.write(`Status: ${node?.status || "unknown"}\n`);
|
|
1352
|
+
|
|
1353
|
+
const readiness = node?.health_readiness;
|
|
1354
|
+
const reasons = Array.isArray(readiness?.reasons) ? readiness.reasons : [];
|
|
1355
|
+
const readyLabel = readiness?.ready ? "ready" : readiness?.status || "blocked";
|
|
1356
|
+
stdout.write(`Launch readiness: ${readyLabel}\n`);
|
|
1357
|
+
// Compute encodes passive faults as `passive_fault:<name>` in readiness.reasons.
|
|
1358
|
+
const blockers = reasons.filter((reason) => !String(reason).startsWith("passive_fault:"));
|
|
1359
|
+
if (blockers.length) {
|
|
1360
|
+
stdout.write("Blockers:\n");
|
|
1361
|
+
for (const reason of blockers) {
|
|
1362
|
+
stdout.write(` - ${reason}\n`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const passiveFaults = reasons
|
|
1367
|
+
.filter((reason) => String(reason).startsWith("passive_fault:"))
|
|
1368
|
+
.map((reason) => String(reason).slice("passive_fault:".length));
|
|
1369
|
+
if (passiveFaults.length) {
|
|
1370
|
+
stdout.write("Passive faults:\n");
|
|
1371
|
+
for (const fault of passiveFaults) {
|
|
1372
|
+
stdout.write(` - ${fault}\n`);
|
|
1373
|
+
}
|
|
1374
|
+
} else {
|
|
1375
|
+
stdout.write("Passive faults: none\n");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
stdout.write(`Validation: ${node?.validation_status || "unknown"}\n`);
|
|
1379
|
+
writeOptionalStatusLine(stdout, "Validation checked", node?.validation_checked_at);
|
|
1380
|
+
writeOptionalStatusLine(stdout, "Last heartbeat", node?.last_heartbeat_at);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1160
1383
|
async function nodes(args, context) {
|
|
1161
1384
|
const [subcommand = "list", id, nested, ...rest] = args;
|
|
1162
1385
|
|
|
@@ -1837,9 +2060,346 @@ async function networks(args, context) {
|
|
|
1837
2060
|
}
|
|
1838
2061
|
|
|
1839
2062
|
async function storage(args, context) {
|
|
1840
|
-
const [resource = "volumes",
|
|
2063
|
+
const [resource = "volumes", rawSubcommand, id, ...rest] = args;
|
|
2064
|
+
const subcommand =
|
|
2065
|
+
rawSubcommand ?? (resource === "buckets" || ["drives", "volumes"].includes(resource) ? "list" : undefined);
|
|
2066
|
+
if (resource === "filesystem" || resource === "fileshare") {
|
|
2067
|
+
if (subcommand === "deploy") {
|
|
2068
|
+
const options = parseCommandOptions(
|
|
2069
|
+
[id, ...rest].filter(Boolean),
|
|
2070
|
+
{
|
|
2071
|
+
boolean: ["json"],
|
|
2072
|
+
value: ["capacity-gib", "performance-tier", "reservation", "reservation-id"],
|
|
2073
|
+
},
|
|
2074
|
+
"Usage: ornn storage filesystem deploy --reservation <reservation-id> [--performance-tier <tier>] [--capacity-gib <gib>] [--json]",
|
|
2075
|
+
);
|
|
2076
|
+
const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
|
|
2077
|
+
const payload = await cliRequest({
|
|
2078
|
+
body: {
|
|
2079
|
+
...(optionProvided(options.performanceTier) ? { performance_tier: requiredOption(options.performanceTier, "--performance-tier") } : {}),
|
|
2080
|
+
...(optionProvided(options.capacityGib) ? { capacity_gib: positiveIntegerOption(options.capacityGib, "--capacity-gib") } : {}),
|
|
2081
|
+
},
|
|
2082
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/filesystem-deployment`),
|
|
2083
|
+
env: context.env,
|
|
2084
|
+
fetchImpl: context.fetchImpl,
|
|
2085
|
+
method: "POST",
|
|
2086
|
+
});
|
|
2087
|
+
if (options.json) {
|
|
2088
|
+
writeJson(context.stdout, payload);
|
|
2089
|
+
} else {
|
|
2090
|
+
context.stdout.write("Filesystem deployment started.\n");
|
|
2091
|
+
writeReservationStorageAttachment(context.stdout, payload.attachment);
|
|
2092
|
+
}
|
|
2093
|
+
return 0;
|
|
2094
|
+
}
|
|
2095
|
+
if (subcommand === "status") {
|
|
2096
|
+
const options = parseCommandOptions(
|
|
2097
|
+
[id, ...rest].filter(Boolean),
|
|
2098
|
+
{
|
|
2099
|
+
boolean: ["json"],
|
|
2100
|
+
value: ["reservation", "reservation-id"],
|
|
2101
|
+
},
|
|
2102
|
+
"Usage: ornn storage filesystem status --reservation <reservation-id> [--json]",
|
|
2103
|
+
);
|
|
2104
|
+
const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
|
|
2105
|
+
const payload = await cliRequest({
|
|
2106
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-attachment`),
|
|
2107
|
+
env: context.env,
|
|
2108
|
+
fetchImpl: context.fetchImpl,
|
|
2109
|
+
});
|
|
2110
|
+
if (options.json) {
|
|
2111
|
+
writeJson(context.stdout, payload);
|
|
2112
|
+
} else {
|
|
2113
|
+
context.stdout.write("Filesystem deployment status.\n");
|
|
2114
|
+
writeReservationStorageAttachment(context.stdout, payload.attachment);
|
|
2115
|
+
}
|
|
2116
|
+
return 0;
|
|
2117
|
+
}
|
|
2118
|
+
if (subcommand === "delete" || subcommand === "teardown") {
|
|
2119
|
+
const options = parseCommandOptions(
|
|
2120
|
+
[id, ...rest].filter(Boolean),
|
|
2121
|
+
{
|
|
2122
|
+
boolean: ["json"],
|
|
2123
|
+
value: ["reservation", "reservation-id"],
|
|
2124
|
+
},
|
|
2125
|
+
"Usage: ornn storage filesystem delete --reservation <reservation-id> [--json]",
|
|
2126
|
+
);
|
|
2127
|
+
const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
|
|
2128
|
+
const payload = await cliRequest({
|
|
2129
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/filesystem-deployment`),
|
|
2130
|
+
env: context.env,
|
|
2131
|
+
fetchImpl: context.fetchImpl,
|
|
2132
|
+
method: "DELETE",
|
|
2133
|
+
});
|
|
2134
|
+
if (options.json) {
|
|
2135
|
+
writeJson(context.stdout, payload);
|
|
2136
|
+
} else {
|
|
2137
|
+
context.stdout.write("Filesystem deletion requested.\n");
|
|
2138
|
+
writeReservationStorageAttachment(context.stdout, payload.attachment);
|
|
2139
|
+
}
|
|
2140
|
+
return 0;
|
|
2141
|
+
}
|
|
2142
|
+
throw new Error("Usage: ornn storage filesystem deploy|status|delete --reservation <reservation-id> [--json]");
|
|
2143
|
+
}
|
|
2144
|
+
if (resource === "deploy" && subcommand === "status") {
|
|
2145
|
+
const options = parseCommandOptions(
|
|
2146
|
+
[id, ...rest].filter(Boolean),
|
|
2147
|
+
{
|
|
2148
|
+
boolean: ["json"],
|
|
2149
|
+
value: ["reservation", "reservation-id"],
|
|
2150
|
+
},
|
|
2151
|
+
"Usage: ornn storage deploy status --reservation <reservation-id> [--json]",
|
|
2152
|
+
);
|
|
2153
|
+
const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
|
|
2154
|
+
const payload = await cliRequest({
|
|
2155
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-attachment`),
|
|
2156
|
+
env: context.env,
|
|
2157
|
+
fetchImpl: context.fetchImpl,
|
|
2158
|
+
});
|
|
2159
|
+
if (options.json) {
|
|
2160
|
+
writeJson(context.stdout, payload);
|
|
2161
|
+
} else {
|
|
2162
|
+
context.stdout.write("Storage deployment status.\n");
|
|
2163
|
+
writeReservationStorageAttachment(context.stdout, payload.attachment);
|
|
2164
|
+
}
|
|
2165
|
+
return 0;
|
|
2166
|
+
}
|
|
2167
|
+
if (resource === "deploy" && subcommand) {
|
|
2168
|
+
const options = parseCommandOptions(
|
|
2169
|
+
[id, ...rest].filter(Boolean),
|
|
2170
|
+
{
|
|
2171
|
+
boolean: ["json", "read-only", "read-write"],
|
|
2172
|
+
value: ["mount-path", "reservation", "reservation-id"],
|
|
2173
|
+
},
|
|
2174
|
+
"Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]",
|
|
2175
|
+
);
|
|
2176
|
+
if (options.readOnly && options.readWrite) {
|
|
2177
|
+
throw new Error("Choose only one of --read-only or --read-write.");
|
|
2178
|
+
}
|
|
2179
|
+
const reservationId = requiredOption(options.reservation || options.reservationId, "--reservation");
|
|
2180
|
+
const payload = await cliRequest({
|
|
2181
|
+
body: {
|
|
2182
|
+
drive_id: subcommand,
|
|
2183
|
+
...(optionProvided(options.mountPath) ? { mount_path: requiredOption(options.mountPath, "--mount-path") } : {}),
|
|
2184
|
+
access_mode: options.readOnly ? "read-only" : "read-write",
|
|
2185
|
+
},
|
|
2186
|
+
endpoint: computeEndpoint(`/standalone-vms/reservations/${encodeURIComponent(reservationId)}/storage-deployment`),
|
|
2187
|
+
env: context.env,
|
|
2188
|
+
fetchImpl: context.fetchImpl,
|
|
2189
|
+
method: "POST",
|
|
2190
|
+
});
|
|
2191
|
+
if (options.json) {
|
|
2192
|
+
writeJson(context.stdout, payload);
|
|
2193
|
+
} else {
|
|
2194
|
+
context.stdout.write("Storage deployment started.\n");
|
|
2195
|
+
writeReservationStorageAttachment(context.stdout, payload.attachment);
|
|
2196
|
+
}
|
|
2197
|
+
return 0;
|
|
2198
|
+
}
|
|
2199
|
+
if (resource === "deploy") {
|
|
2200
|
+
throw new Error("Usage: ornn storage deploy <drive-id> --reservation <reservation-id> [--mount-path <path>] [--read-only|--read-write] [--json]");
|
|
2201
|
+
}
|
|
2202
|
+
if (resource === "buckets") {
|
|
2203
|
+
if (subcommand === "list") {
|
|
2204
|
+
const options = parseCommandOptions(
|
|
2205
|
+
[id, ...rest].filter(Boolean),
|
|
2206
|
+
{ boolean: ["json"] },
|
|
2207
|
+
"Usage: ornn storage buckets list [--json]",
|
|
2208
|
+
);
|
|
2209
|
+
const payload = await fetchStorageDrives(context);
|
|
2210
|
+
const buckets = storageBucketsFromPayload(payload);
|
|
2211
|
+
if (options.json) {
|
|
2212
|
+
writeJson(context.stdout, { buckets });
|
|
2213
|
+
} else {
|
|
2214
|
+
writeStorageBucketList(context.stdout, buckets);
|
|
2215
|
+
}
|
|
2216
|
+
return 0;
|
|
2217
|
+
}
|
|
2218
|
+
if (subcommand === "show" && id) {
|
|
2219
|
+
const options = parseCommandOptions(
|
|
2220
|
+
rest,
|
|
2221
|
+
{ boolean: ["json"] },
|
|
2222
|
+
"Usage: ornn storage buckets show <drive-id> [--json]",
|
|
2223
|
+
);
|
|
2224
|
+
const bucket = await findStorageBucket(id, context);
|
|
2225
|
+
if (options.json) {
|
|
2226
|
+
writeJson(context.stdout, bucket);
|
|
2227
|
+
} else {
|
|
2228
|
+
writeStorageDriveDetail(context.stdout, bucket);
|
|
2229
|
+
}
|
|
2230
|
+
return 0;
|
|
2231
|
+
}
|
|
2232
|
+
if (subcommand === "verify" && id) {
|
|
2233
|
+
const options = parseCommandOptions(
|
|
2234
|
+
rest,
|
|
2235
|
+
{ boolean: ["json"] },
|
|
2236
|
+
"Usage: ornn storage buckets verify <drive-id> [--json]",
|
|
2237
|
+
);
|
|
2238
|
+
const drive = await cliRequest({
|
|
2239
|
+
endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}/verify-source`),
|
|
2240
|
+
env: context.env,
|
|
2241
|
+
fetchImpl: context.fetchImpl,
|
|
2242
|
+
method: "POST",
|
|
2243
|
+
});
|
|
2244
|
+
const verificationFailed = drive?.source?.connection_status === "verification_failed";
|
|
2245
|
+
if (options.json) {
|
|
2246
|
+
writeJson(context.stdout, drive);
|
|
2247
|
+
} else {
|
|
2248
|
+
context.stdout.write(
|
|
2249
|
+
verificationFailed ? "Bucket source verification failed.\n" : "Bucket source verified.\n",
|
|
2250
|
+
);
|
|
2251
|
+
writeStorageDriveDetail(context.stdout, drive);
|
|
2252
|
+
}
|
|
2253
|
+
return verificationFailed ? 1 : 0;
|
|
2254
|
+
}
|
|
2255
|
+
if (subcommand === "update-credentials" && id) {
|
|
2256
|
+
const options = parseCommandOptions(
|
|
2257
|
+
rest,
|
|
2258
|
+
{
|
|
2259
|
+
boolean: ["json"],
|
|
2260
|
+
value: ["access-key-id", "secret-access-key", "secret-access-key-file"],
|
|
2261
|
+
},
|
|
2262
|
+
"Usage: ornn storage buckets update-credentials <drive-id> --access-key-id <id> --secret-access-key-file <path> [--json]",
|
|
2263
|
+
);
|
|
2264
|
+
if (options.secretAccessKey && options.secretAccessKeyFile) {
|
|
2265
|
+
throw new Error("Use only one of --secret-access-key or --secret-access-key-file.");
|
|
2266
|
+
}
|
|
2267
|
+
const secretAccessKey = options.secretAccessKeyFile
|
|
2268
|
+
? (await readFile(options.secretAccessKeyFile, "utf8")).trim()
|
|
2269
|
+
: optionalStringOption(options.secretAccessKey);
|
|
2270
|
+
if (!secretAccessKey) {
|
|
2271
|
+
throw new Error("--secret-access-key-file or --secret-access-key is required.");
|
|
2272
|
+
}
|
|
2273
|
+
const drive = await cliRequest({
|
|
2274
|
+
body: {
|
|
2275
|
+
external_access_key_id: requiredOption(options.accessKeyId, "--access-key-id"),
|
|
2276
|
+
external_secret_access_key: secretAccessKey,
|
|
2277
|
+
},
|
|
2278
|
+
endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}/source-credentials`),
|
|
2279
|
+
env: context.env,
|
|
2280
|
+
fetchImpl: context.fetchImpl,
|
|
2281
|
+
method: "PATCH",
|
|
2282
|
+
});
|
|
2283
|
+
if (options.json) {
|
|
2284
|
+
writeJson(context.stdout, drive);
|
|
2285
|
+
} else {
|
|
2286
|
+
context.stdout.write("Bucket credentials updated.\n");
|
|
2287
|
+
writeStorageDriveDetail(context.stdout, drive);
|
|
2288
|
+
}
|
|
2289
|
+
return 0;
|
|
2290
|
+
}
|
|
2291
|
+
if (subcommand === "disconnect" && id) {
|
|
2292
|
+
const options = parseCommandOptions(
|
|
2293
|
+
rest,
|
|
2294
|
+
{ boolean: ["json"] },
|
|
2295
|
+
"Usage: ornn storage buckets disconnect <drive-id> [--json]",
|
|
2296
|
+
);
|
|
2297
|
+
await cliRequest({
|
|
2298
|
+
endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(id)}`),
|
|
2299
|
+
env: context.env,
|
|
2300
|
+
fetchImpl: context.fetchImpl,
|
|
2301
|
+
method: "DELETE",
|
|
2302
|
+
});
|
|
2303
|
+
const result = { disconnected: true, drive_id: id };
|
|
2304
|
+
if (options.json) {
|
|
2305
|
+
writeJson(context.stdout, result);
|
|
2306
|
+
} else {
|
|
2307
|
+
context.stdout.write(`Bucket disconnected: ${id}\n`);
|
|
2308
|
+
}
|
|
2309
|
+
return 0;
|
|
2310
|
+
}
|
|
2311
|
+
if (subcommand !== "connect" || !["gcs", "s3", "r2"].includes(id)) {
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
"Usage: ornn storage buckets list|show|connect|verify|update-credentials|disconnect",
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
const options = parseCommandOptions(
|
|
2317
|
+
rest,
|
|
2318
|
+
{
|
|
2319
|
+
boolean: ["json", "read-only", "read-write", "verify"],
|
|
2320
|
+
value: ["access-key-id", "account-id", "bucket", "endpoint-url", "name", "prefix", "region", "secret-access-key", "secret-access-key-file", "url"],
|
|
2321
|
+
},
|
|
2322
|
+
"Usage: ornn storage buckets connect gcs|s3|r2 --bucket <bucket>|--url <url> [--name <name>] [--prefix <prefix>] [--region <region>] [--account-id <id>|--endpoint-url <url>] [--access-key-id <id> --secret-access-key-file <path>] [--read-only|--read-write] [--verify] [--json]",
|
|
2323
|
+
);
|
|
2324
|
+
if (options.readOnly && options.readWrite) {
|
|
2325
|
+
throw new Error("Choose only one of --read-only or --read-write.");
|
|
2326
|
+
}
|
|
2327
|
+
if (options.bucket && options.url) {
|
|
2328
|
+
throw new Error("Use only one of --bucket or --url.");
|
|
2329
|
+
}
|
|
2330
|
+
if (id === "gcs" && options.verify) {
|
|
2331
|
+
throw new Error("--verify is only supported for S3-compatible buckets.");
|
|
2332
|
+
}
|
|
2333
|
+
if (options.secretAccessKey && options.secretAccessKeyFile) {
|
|
2334
|
+
throw new Error("Use only one of --secret-access-key or --secret-access-key-file.");
|
|
2335
|
+
}
|
|
2336
|
+
if (id === "gcs" && (options.accessKeyId || options.secretAccessKey || options.secretAccessKeyFile)) {
|
|
2337
|
+
throw new Error("Access key credentials are only supported for S3-compatible buckets.");
|
|
2338
|
+
}
|
|
2339
|
+
const secretAccessKey = options.secretAccessKeyFile
|
|
2340
|
+
? (await readFile(options.secretAccessKeyFile, "utf8")).trim()
|
|
2341
|
+
: optionalStringOption(options.secretAccessKey);
|
|
2342
|
+
if (Boolean(options.accessKeyId) !== Boolean(secretAccessKey)) {
|
|
2343
|
+
throw new Error("Use --access-key-id with --secret-access-key-file or --secret-access-key.");
|
|
2344
|
+
}
|
|
2345
|
+
const source = storageBucketSourceFromOptions(id, options);
|
|
2346
|
+
const prefix = optionalStringOption(options.prefix) ?? source.prefix;
|
|
2347
|
+
const endpointOptions = { ...options };
|
|
2348
|
+
if (!optionProvided(endpointOptions.accountId) && source.accountId && !optionProvided(endpointOptions.endpointUrl)) {
|
|
2349
|
+
endpointOptions.accountId = source.accountId;
|
|
2350
|
+
}
|
|
2351
|
+
const endpointUrl = storageBucketEndpointUrl(id, endpointOptions);
|
|
2352
|
+
const sourceProvider = id === "gcs" ? "external_gcs" : "s3";
|
|
2353
|
+
const bucketName = source.bucket;
|
|
2354
|
+
let drive = await cliRequest({
|
|
2355
|
+
body: {
|
|
2356
|
+
name: optionalStringOption(options.name) ?? bucketName,
|
|
2357
|
+
provider: "ornn_volume",
|
|
2358
|
+
source_provider: sourceProvider,
|
|
2359
|
+
external_bucket: bucketName,
|
|
2360
|
+
external_prefix: prefix,
|
|
2361
|
+
external_region: optionalStringOption(options.region) ?? source.region ?? (id === "r2" ? "auto" : null),
|
|
2362
|
+
external_read_only: options.readWrite ? false : true,
|
|
2363
|
+
...(id === "r2" ? { external_s3_provider: "cloudflare_r2" } : {}),
|
|
2364
|
+
...(id === "s3" ? { external_s3_provider: "aws_s3" } : {}),
|
|
2365
|
+
...(endpointUrl ? { external_endpoint_url: endpointUrl } : {}),
|
|
2366
|
+
...(options.accessKeyId ? { external_access_key_id: options.accessKeyId } : {}),
|
|
2367
|
+
...(secretAccessKey ? { external_secret_access_key: secretAccessKey } : {}),
|
|
2368
|
+
},
|
|
2369
|
+
endpoint: computeEndpoint("/standalone-vms/storage-drives"),
|
|
2370
|
+
env: context.env,
|
|
2371
|
+
fetchImpl: context.fetchImpl,
|
|
2372
|
+
method: "POST",
|
|
2373
|
+
});
|
|
2374
|
+
if (options.verify) {
|
|
2375
|
+
drive = await verifyStorageBucketSource(drive.id, context);
|
|
2376
|
+
}
|
|
2377
|
+
const commands = storageBucketImportCommands({
|
|
2378
|
+
bucket: bucketName,
|
|
2379
|
+
endpointUrl,
|
|
2380
|
+
prefix,
|
|
2381
|
+
provider: id,
|
|
2382
|
+
region: optionalStringOption(options.region) ?? source.region ?? (id === "r2" ? "auto" : null),
|
|
2383
|
+
target: drive.import_target,
|
|
2384
|
+
});
|
|
2385
|
+
if (options.json) {
|
|
2386
|
+
writeJson(context.stdout, { ...drive, import_commands: commands });
|
|
2387
|
+
} else {
|
|
2388
|
+
context.stdout.write("Bucket connected.\n");
|
|
2389
|
+
if (options.verify) {
|
|
2390
|
+
context.stdout.write(
|
|
2391
|
+
drive?.source?.connection_status === "verification_failed"
|
|
2392
|
+
? "Bucket source verification failed.\n"
|
|
2393
|
+
: "Bucket source verified.\n",
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
writeStorageDriveDetail(context.stdout, drive);
|
|
2397
|
+
writeStorageImportCommands(context.stdout, commands);
|
|
2398
|
+
}
|
|
2399
|
+
return drive?.source?.connection_status === "verification_failed" ? 1 : 0;
|
|
2400
|
+
}
|
|
1841
2401
|
if (!["drives", "volumes"].includes(resource)) {
|
|
1842
|
-
throw new Error("Usage: ornn storage volumes list|show|create|refresh|clear|delete");
|
|
2402
|
+
throw new Error("Usage: ornn storage volumes list|show|create|refresh|clear|delete; ornn storage buckets connect gcs|s3|r2 --bucket <bucket>");
|
|
1843
2403
|
}
|
|
1844
2404
|
|
|
1845
2405
|
if (subcommand === "list") {
|
|
@@ -2725,6 +3285,15 @@ async function fetchStorageDrives(context) {
|
|
|
2725
3285
|
});
|
|
2726
3286
|
}
|
|
2727
3287
|
|
|
3288
|
+
async function verifyStorageBucketSource(driveId, context) {
|
|
3289
|
+
return await cliRequest({
|
|
3290
|
+
endpoint: computeEndpoint(`/standalone-vms/storage-drives/${encodeURIComponent(driveId)}/verify-source`),
|
|
3291
|
+
env: context.env,
|
|
3292
|
+
fetchImpl: context.fetchImpl,
|
|
3293
|
+
method: "POST",
|
|
3294
|
+
});
|
|
3295
|
+
}
|
|
3296
|
+
|
|
2728
3297
|
async function findStorageDrive(driveId, context) {
|
|
2729
3298
|
const payload = await fetchStorageDrives(context);
|
|
2730
3299
|
const drive = storageDrivesFromPayload(payload).find((item) => String(item.id) === String(driveId));
|
|
@@ -2734,6 +3303,15 @@ async function findStorageDrive(driveId, context) {
|
|
|
2734
3303
|
return drive;
|
|
2735
3304
|
}
|
|
2736
3305
|
|
|
3306
|
+
async function findStorageBucket(driveId, context) {
|
|
3307
|
+
const payload = await fetchStorageDrives(context);
|
|
3308
|
+
const bucket = storageBucketsFromPayload(payload).find((item) => String(item.id) === String(driveId));
|
|
3309
|
+
if (!bucket) {
|
|
3310
|
+
throw new Error(`Storage bucket not found: ${driveId}`);
|
|
3311
|
+
}
|
|
3312
|
+
return bucket;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
2737
3315
|
function reservationSshKeysEndpoint(reservationId) {
|
|
2738
3316
|
return computeEndpoint(`/standalone-vms/reservations/${reservationId}/ssh-keys`);
|
|
2739
3317
|
}
|
|
@@ -3611,6 +4189,63 @@ function writeReservationNetwork(stdout, payload = {}) {
|
|
|
3611
4189
|
writeOptionalStatusLine(stdout, "Status", payload.network.status);
|
|
3612
4190
|
}
|
|
3613
4191
|
|
|
4192
|
+
function writeReservationStorageAttachment(stdout, attachment = {}) {
|
|
4193
|
+
if (!attachment) {
|
|
4194
|
+
stdout.write("No reservation storage attachment.\n");
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
4197
|
+
stdout.write(`${attachment.id || "storage-attachment"}\n`);
|
|
4198
|
+
writeOptionalStatusLine(stdout, "Reservation", attachment.reservation_id);
|
|
4199
|
+
writeOptionalStatusLine(stdout, "Drive", attachment.drive_id);
|
|
4200
|
+
writeOptionalStatusLine(stdout, "State", attachment.state);
|
|
4201
|
+
writeOptionalStatusLine(stdout, "Target region", attachment.target_region);
|
|
4202
|
+
writeOptionalStatusLine(stdout, "Mount path", attachment.mount_path);
|
|
4203
|
+
writeOptionalStatusLine(stdout, "Mount mode", attachment.mount_mode);
|
|
4204
|
+
writeOptionalStatusLine(stdout, "Access", attachment.access_mode);
|
|
4205
|
+
if (attachment.placement) {
|
|
4206
|
+
const placement = attachment.placement;
|
|
4207
|
+
stdout.write("Placement:\n");
|
|
4208
|
+
writeOptionalStatusLine(stdout, "State", placement.state);
|
|
4209
|
+
writeOptionalStatusLine(
|
|
4210
|
+
stdout,
|
|
4211
|
+
"Target",
|
|
4212
|
+
placement.target_storage_uri ||
|
|
4213
|
+
placement.storage_uri ||
|
|
4214
|
+
storageObjectUri("gs", placement.bucket, placement.prefix),
|
|
4215
|
+
);
|
|
4216
|
+
writeOptionalStatusLine(stdout, "Transfer", placement.transfer_status);
|
|
4217
|
+
if (
|
|
4218
|
+
typeof placement.transfer_object_count === "number" ||
|
|
4219
|
+
typeof placement.transfer_bytes === "number"
|
|
4220
|
+
) {
|
|
4221
|
+
const objectCount = placement.transfer_object_count;
|
|
4222
|
+
const bytes = placement.transfer_bytes;
|
|
4223
|
+
const objectLabel =
|
|
4224
|
+
typeof objectCount === "number"
|
|
4225
|
+
? `${objectCount} ${objectCount === 1 ? "object" : "objects"}`
|
|
4226
|
+
: null;
|
|
4227
|
+
const bytesLabel = typeof bytes === "number" ? formatBytes(bytes) : null;
|
|
4228
|
+
writeOptionalStatusLine(
|
|
4229
|
+
stdout,
|
|
4230
|
+
"Copied",
|
|
4231
|
+
[objectLabel, bytesLabel].filter(Boolean).join(", "),
|
|
4232
|
+
);
|
|
4233
|
+
}
|
|
4234
|
+
writeOptionalStatusLine(stdout, "Source provider", placement.source_provider);
|
|
4235
|
+
writeOptionalStatusLine(stdout, "Source bucket", placement.source_bucket);
|
|
4236
|
+
writeOptionalStatusLine(stdout, "Source prefix", placement.source_prefix);
|
|
4237
|
+
}
|
|
4238
|
+
const nfsBackend = attachment.drive?.nfs_backend || attachment.drive?.accelerators?.nfs;
|
|
4239
|
+
if (nfsBackend) {
|
|
4240
|
+
stdout.write("Filesystem:\n");
|
|
4241
|
+
writeOptionalStatusLine(stdout, "State", nfsBackend.state);
|
|
4242
|
+
writeOptionalStatusLine(stdout, "Region", nfsBackend.region);
|
|
4243
|
+
writeOptionalStatusLine(stdout, "Tier", nfsBackend.performance_tier);
|
|
4244
|
+
writeOptionalStatusLine(stdout, "Capacity", nfsBackend.capacity_gib ? `${nfsBackend.capacity_gib} GiB` : null);
|
|
4245
|
+
writeOptionalStatusLine(stdout, "Mount ready", nfsBackend.mount_ready ? "yes" : "no");
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
|
|
3614
4249
|
function writeStorageDriveList(stdout, drives) {
|
|
3615
4250
|
if (!drives.length) {
|
|
3616
4251
|
stdout.write("No storage volumes found.\n");
|
|
@@ -3623,6 +4258,12 @@ function writeStorageDriveList(stdout, drives) {
|
|
|
3623
4258
|
if (drive.source_drive_id) {
|
|
3624
4259
|
stdout.write(` source=${drive.source_drive_id}`);
|
|
3625
4260
|
}
|
|
4261
|
+
if (drive.source?.provider && drive.source.provider !== "ornn_object") {
|
|
4262
|
+
stdout.write(` source=${drive.source.provider}`);
|
|
4263
|
+
if (drive.source.bucket) {
|
|
4264
|
+
stdout.write(` bucket=${drive.source.bucket}`);
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
3626
4267
|
if (drive.active_mount) {
|
|
3627
4268
|
stdout.write(` mounted=${drive.active_mount.reservation_id || "yes"}`);
|
|
3628
4269
|
}
|
|
@@ -3630,12 +4271,67 @@ function writeStorageDriveList(stdout, drives) {
|
|
|
3630
4271
|
}
|
|
3631
4272
|
}
|
|
3632
4273
|
|
|
4274
|
+
function writeStorageBucketList(stdout, buckets) {
|
|
4275
|
+
if (!buckets.length) {
|
|
4276
|
+
stdout.write("No storage buckets found.\n");
|
|
4277
|
+
return;
|
|
4278
|
+
}
|
|
4279
|
+
stdout.write("Storage buckets:\n");
|
|
4280
|
+
for (const drive of buckets) {
|
|
4281
|
+
const source = drive.source || {};
|
|
4282
|
+
stdout.write(`- ${drive.id} ${drive.name || "unnamed"} ${source.provider || "unknown"}`);
|
|
4283
|
+
if (source.s3_provider) {
|
|
4284
|
+
stdout.write(`/${source.s3_provider}`);
|
|
4285
|
+
}
|
|
4286
|
+
if (source.bucket) {
|
|
4287
|
+
stdout.write(` bucket=${source.bucket}`);
|
|
4288
|
+
}
|
|
4289
|
+
if (source.prefix) {
|
|
4290
|
+
stdout.write(` prefix=${source.prefix}`);
|
|
4291
|
+
}
|
|
4292
|
+
if (source.connection_status) {
|
|
4293
|
+
stdout.write(` status=${source.connection_status}`);
|
|
4294
|
+
}
|
|
4295
|
+
if (source.access_key_id_hint) {
|
|
4296
|
+
stdout.write(` key=${source.access_key_id_hint}`);
|
|
4297
|
+
}
|
|
4298
|
+
if (source.provider && source.provider !== "ornn_object") {
|
|
4299
|
+
stdout.write(` access=${source.read_only === false ? "read-write" : "read-only"}`);
|
|
4300
|
+
}
|
|
4301
|
+
stdout.write("\n");
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
|
|
3633
4305
|
function writeStorageDriveDetail(stdout, drive = {}) {
|
|
3634
4306
|
stdout.write(`${drive.id || "drive"}\n`);
|
|
3635
4307
|
stdout.write(`Name: ${drive.name || "unnamed"}\n`);
|
|
3636
4308
|
stdout.write(`Status: ${drive.status || "unknown"}\n`);
|
|
3637
4309
|
stdout.write(`Size: ${formatBytes(drive.size_bytes)}\n`);
|
|
3638
4310
|
writeOptionalStatusLine(stdout, "Source", drive.source_drive_id);
|
|
4311
|
+
if (drive.source) {
|
|
4312
|
+
writeOptionalStatusLine(stdout, "Source provider", drive.source.provider);
|
|
4313
|
+
writeOptionalStatusLine(stdout, "Bucket", drive.source.bucket);
|
|
4314
|
+
writeOptionalStatusLine(stdout, "Prefix", drive.source.prefix);
|
|
4315
|
+
writeOptionalStatusLine(stdout, "Region", drive.source.region);
|
|
4316
|
+
writeOptionalStatusLine(stdout, "S3 provider", drive.source.s3_provider);
|
|
4317
|
+
writeOptionalStatusLine(stdout, "Endpoint", drive.source.endpoint_url);
|
|
4318
|
+
if (drive.source.credentials_configured) {
|
|
4319
|
+
stdout.write(`Credentials: configured${drive.source.access_key_id_hint ? ` (${drive.source.access_key_id_hint})` : ""}\n`);
|
|
4320
|
+
}
|
|
4321
|
+
writeOptionalStatusLine(stdout, "Connection", drive.source.connection_status);
|
|
4322
|
+
writeOptionalStatusLine(stdout, "Verified", drive.source.last_verified_at);
|
|
4323
|
+
if (drive.source.verification_error) {
|
|
4324
|
+
const error = drive.source.verification_error;
|
|
4325
|
+
stdout.write(`Verification error: ${error.code || "failed"}`);
|
|
4326
|
+
if (error.message) {
|
|
4327
|
+
stdout.write(` - ${error.message}`);
|
|
4328
|
+
}
|
|
4329
|
+
stdout.write("\n");
|
|
4330
|
+
}
|
|
4331
|
+
if (drive.source.provider && drive.source.provider !== "ornn_object") {
|
|
4332
|
+
stdout.write(`Access: ${drive.source.read_only === false ? "read-write" : "read-only"}\n`);
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
3639
4335
|
writeOptionalStatusLine(stdout, "File tree", drive.file_tree_status);
|
|
3640
4336
|
if (drive.file_tree_truncated) {
|
|
3641
4337
|
stdout.write(" File tree: truncated\n");
|
|
@@ -3652,6 +4348,20 @@ function writeStorageDriveDetail(stdout, drive = {}) {
|
|
|
3652
4348
|
writeOptionalStatusLine(stdout, "Updated", drive.updated_at);
|
|
3653
4349
|
}
|
|
3654
4350
|
|
|
4351
|
+
function writeStorageImportCommands(stdout, commands = []) {
|
|
4352
|
+
if (!commands.length) {
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
stdout.write("\nRun these commands to import data into Ornn object storage:\n");
|
|
4356
|
+
for (const item of commands) {
|
|
4357
|
+
stdout.write(`\n# ${item.label}\n`);
|
|
4358
|
+
if (item.note) {
|
|
4359
|
+
stdout.write(`# ${item.note}\n`);
|
|
4360
|
+
}
|
|
4361
|
+
stdout.write(`${item.command}\n`);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
|
|
3655
4365
|
function writeBillingSummary(stdout, summary = {}) {
|
|
3656
4366
|
stdout.write("Billing summary:\n");
|
|
3657
4367
|
stdout.write(`Open balance: ${formatCents(summary.open_balance_cents)}\n`);
|
|
@@ -3745,6 +4455,188 @@ function storageDrivesFromPayload(payload) {
|
|
|
3745
4455
|
return Array.isArray(payload) ? payload : Array.isArray(payload?.drives) ? payload.drives : [];
|
|
3746
4456
|
}
|
|
3747
4457
|
|
|
4458
|
+
function storageBucketsFromPayload(payload) {
|
|
4459
|
+
return storageDrivesFromPayload(payload).filter(isStorageBucketDrive);
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
function isStorageBucketDrive(drive = {}) {
|
|
4463
|
+
const source = drive.source || {};
|
|
4464
|
+
return (
|
|
4465
|
+
source.kind === "object" ||
|
|
4466
|
+
["ornn_object", "external_gcs", "s3"].includes(String(source.provider || ""))
|
|
4467
|
+
);
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
function storageBucketLocationFromInput(value) {
|
|
4471
|
+
const withoutScheme = String(value || "")
|
|
4472
|
+
.trim()
|
|
4473
|
+
.replace(/^(gs|s3|r2):\/\//i, "")
|
|
4474
|
+
.replace(/^\/+|\/+$/g, "");
|
|
4475
|
+
const [bucket, ...prefixParts] = withoutScheme.split("/");
|
|
4476
|
+
return {
|
|
4477
|
+
bucket,
|
|
4478
|
+
prefix: prefixParts.join("/") || null,
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
function storageBucketSourceFromOptions(provider, options = {}) {
|
|
4483
|
+
const rawBucket = optionalStringOption(options.bucket);
|
|
4484
|
+
const rawUrl = optionalStringOption(options.url);
|
|
4485
|
+
const parsed = rawUrl ? storageBucketSourceFromConsoleUrl(provider, rawUrl) : {};
|
|
4486
|
+
const location = rawBucket ? storageBucketLocationFromInput(rawBucket) : parsed;
|
|
4487
|
+
if (!location.bucket) {
|
|
4488
|
+
throw new Error("--bucket or --url is required.");
|
|
4489
|
+
}
|
|
4490
|
+
return {
|
|
4491
|
+
accountId: parsed.accountId || null,
|
|
4492
|
+
bucket: location.bucket,
|
|
4493
|
+
prefix: location.prefix || null,
|
|
4494
|
+
region: parsed.region || null,
|
|
4495
|
+
};
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
function storageBucketSourceFromConsoleUrl(provider, rawUrl) {
|
|
4499
|
+
let url;
|
|
4500
|
+
try {
|
|
4501
|
+
url = new URL(String(rawUrl || "").trim());
|
|
4502
|
+
} catch {
|
|
4503
|
+
throw new Error("--url must be a valid console URL.");
|
|
4504
|
+
}
|
|
4505
|
+
const hostname = url.hostname.toLowerCase();
|
|
4506
|
+
const pathParts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
4507
|
+
if (provider === "s3") {
|
|
4508
|
+
const bucketIndex = pathParts.findIndex((part, index) => part === "buckets" && pathParts[index - 1] === "s3");
|
|
4509
|
+
const bucket = bucketIndex >= 0 ? storageConsolePathValue(pathParts[bucketIndex + 1]) : null;
|
|
4510
|
+
const rawRegionFromHost = hostname.endsWith(".console.aws.amazon.com")
|
|
4511
|
+
? hostname.slice(0, -".console.aws.amazon.com".length)
|
|
4512
|
+
: null;
|
|
4513
|
+
const regionFromHost = rawRegionFromHost && rawRegionFromHost !== "s3" ? rawRegionFromHost : null;
|
|
4514
|
+
const region = url.searchParams.get("region") || regionFromHost;
|
|
4515
|
+
const prefix = url.searchParams.get("prefix");
|
|
4516
|
+
if (!bucket) {
|
|
4517
|
+
throw new Error("AWS S3 console URL must include /s3/buckets/<bucket>.");
|
|
4518
|
+
}
|
|
4519
|
+
return { bucket, prefix: normalizeStoragePrefix(prefix), region };
|
|
4520
|
+
}
|
|
4521
|
+
if (provider === "r2") {
|
|
4522
|
+
const accountId = hostname === "dash.cloudflare.com" ? pathParts[0] : null;
|
|
4523
|
+
const bucketIndex = pathParts.findIndex((part) => part === "buckets");
|
|
4524
|
+
const bucket = bucketIndex >= 0 ? storageConsolePathValue(pathParts[bucketIndex + 1]) : null;
|
|
4525
|
+
if (!accountId || !bucket) {
|
|
4526
|
+
throw new Error("Cloudflare R2 console URL must include /<account-id>/r2/.../buckets/<bucket>.");
|
|
4527
|
+
}
|
|
4528
|
+
return { accountId, bucket, prefix: null, region: "auto" };
|
|
4529
|
+
}
|
|
4530
|
+
if (provider === "gcs") {
|
|
4531
|
+
const browserIndex = pathParts.findIndex((part) => part === "browser");
|
|
4532
|
+
const bucket = browserIndex >= 0 ? storageConsolePathValue(pathParts[browserIndex + 1]) : null;
|
|
4533
|
+
const prefix = url.searchParams.get("prefix");
|
|
4534
|
+
if (!bucket) {
|
|
4535
|
+
throw new Error("GCS console URL must include /storage/browser/<bucket>.");
|
|
4536
|
+
}
|
|
4537
|
+
return { bucket, prefix: normalizeStoragePrefix(prefix), region: null };
|
|
4538
|
+
}
|
|
4539
|
+
throw new Error("Unsupported bucket provider.");
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
function storageConsolePathValue(value) {
|
|
4543
|
+
return String(value || "").split(";", 1)[0] || null;
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
function normalizeStoragePrefix(value) {
|
|
4547
|
+
const normalized = String(value || "").trim().replace(/^\/+|\/+$/g, "");
|
|
4548
|
+
return normalized || null;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
function storageBucketEndpointUrl(provider, options = {}) {
|
|
4552
|
+
const endpointUrl = optionalStringOption(options.endpointUrl);
|
|
4553
|
+
const accountId = optionalStringOption(options.accountId);
|
|
4554
|
+
if (provider !== "r2") {
|
|
4555
|
+
if (endpointUrl || accountId) {
|
|
4556
|
+
throw new Error("--account-id and --endpoint-url are only supported for Cloudflare R2.");
|
|
4557
|
+
}
|
|
4558
|
+
return null;
|
|
4559
|
+
}
|
|
4560
|
+
if (endpointUrl && accountId) {
|
|
4561
|
+
throw new Error("Use only one of --account-id or --endpoint-url.");
|
|
4562
|
+
}
|
|
4563
|
+
if (endpointUrl) {
|
|
4564
|
+
return endpointUrl.replace(/\/+$/, "");
|
|
4565
|
+
}
|
|
4566
|
+
if (!accountId) {
|
|
4567
|
+
throw new Error("Cloudflare R2 requires --account-id or --endpoint-url.");
|
|
4568
|
+
}
|
|
4569
|
+
return `https://${accountId}.r2.cloudflarestorage.com`;
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
function storageObjectUri(scheme, bucket, prefix) {
|
|
4573
|
+
const normalizedPrefix = String(prefix || "").trim().replace(/^\/+|\/+$/g, "");
|
|
4574
|
+
return normalizedPrefix ? `${scheme}://${bucket}/${normalizedPrefix}` : `${scheme}://${bucket}`;
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
function storageImportTargetUri(target = {}) {
|
|
4578
|
+
if (target.storage_uri) {
|
|
4579
|
+
return String(target.storage_uri);
|
|
4580
|
+
}
|
|
4581
|
+
if (target.bucket_name && target.object_prefix) {
|
|
4582
|
+
return `gs://${target.bucket_name}/${String(target.object_prefix).replace(/^\/+/, "").replace(/\/?$/, "/")}`;
|
|
4583
|
+
}
|
|
4584
|
+
return null;
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4587
|
+
function storageBucketImportCommands({ bucket, endpointUrl, prefix, provider, region, target }) {
|
|
4588
|
+
const targetUri = storageImportTargetUri(target);
|
|
4589
|
+
if (!targetUri) {
|
|
4590
|
+
return [];
|
|
4591
|
+
}
|
|
4592
|
+
const sourceScheme = provider === "gcs" ? "gs" : "s3";
|
|
4593
|
+
const sourceUri = storageObjectUri(sourceScheme, bucket, prefix);
|
|
4594
|
+
if (provider === "gcs") {
|
|
4595
|
+
return [
|
|
4596
|
+
{
|
|
4597
|
+
label: "Import from GCS",
|
|
4598
|
+
note: "Uses your active gcloud credentials.",
|
|
4599
|
+
command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
|
|
4600
|
+
},
|
|
4601
|
+
];
|
|
4602
|
+
}
|
|
4603
|
+
if (provider === "s3") {
|
|
4604
|
+
return [
|
|
4605
|
+
{
|
|
4606
|
+
label: "Check AWS S3 source",
|
|
4607
|
+
note: "Uses your AWS credentials from the AWS CLI environment or ~/.aws/credentials.",
|
|
4608
|
+
command: `aws s3 ls ${shellQuote(sourceUri)}`,
|
|
4609
|
+
},
|
|
4610
|
+
{
|
|
4611
|
+
label: "Import from AWS S3",
|
|
4612
|
+
note: "Uses gcloud storage interoperability with your AWS S3 credentials.",
|
|
4613
|
+
command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
|
|
4614
|
+
},
|
|
4615
|
+
];
|
|
4616
|
+
}
|
|
4617
|
+
return [
|
|
4618
|
+
{
|
|
4619
|
+
label: "Check Cloudflare R2 source",
|
|
4620
|
+
note: "Uses your R2 access key configured for the AWS CLI.",
|
|
4621
|
+
command: `AWS_REGION=${shellQuote(region || "auto")} aws s3 ls ${shellQuote(sourceUri)} --endpoint-url ${shellQuote(endpointUrl)}`,
|
|
4622
|
+
},
|
|
4623
|
+
{
|
|
4624
|
+
label: "Point gcloud at R2 for this import",
|
|
4625
|
+
note: "Run the unset command below after the import if you do not want this endpoint kept in your gcloud config.",
|
|
4626
|
+
command: `gcloud config set storage/s3_endpoint_url ${shellQuote(endpointUrl)}`,
|
|
4627
|
+
},
|
|
4628
|
+
{
|
|
4629
|
+
label: "Import from Cloudflare R2",
|
|
4630
|
+
note: "Uses your R2 S3-compatible credentials and copies into Ornn GCS.",
|
|
4631
|
+
command: `gcloud storage rsync -r ${shellQuote(sourceUri)} ${shellQuote(targetUri)}`,
|
|
4632
|
+
},
|
|
4633
|
+
{
|
|
4634
|
+
label: "Reset gcloud S3 endpoint",
|
|
4635
|
+
command: "gcloud config unset storage/s3_endpoint_url",
|
|
4636
|
+
},
|
|
4637
|
+
];
|
|
4638
|
+
}
|
|
4639
|
+
|
|
3748
4640
|
function metricSnapshotFromLivePayload(fallbackMachine, payload = {}) {
|
|
3749
4641
|
const liveMachine = payload?.machine || null;
|
|
3750
4642
|
const machine = liveMachine
|
|
@@ -4191,18 +5083,6 @@ function activeMachines(machines) {
|
|
|
4191
5083
|
});
|
|
4192
5084
|
}
|
|
4193
5085
|
|
|
4194
|
-
function writeMutationWithOpen(stdout, resource, opened, options, label) {
|
|
4195
|
-
if (options.json) {
|
|
4196
|
-
writeJson(stdout, { resource, opened: opened.opened, url: opened.url });
|
|
4197
|
-
return;
|
|
4198
|
-
}
|
|
4199
|
-
stdout.write(`${label} updated.\n`);
|
|
4200
|
-
if (resource?.id) {
|
|
4201
|
-
stdout.write(`ID: ${resource.id}\n`);
|
|
4202
|
-
}
|
|
4203
|
-
writeCheckoutOpenResult(stdout, opened, label);
|
|
4204
|
-
}
|
|
4205
|
-
|
|
4206
5086
|
function writeSshKeyList(stdout, keys) {
|
|
4207
5087
|
const activeKeys = activeSshKeys(keys);
|
|
4208
5088
|
if (!activeKeys.length) {
|
|
@@ -4646,6 +5526,17 @@ function writeJson(stdout, payload) {
|
|
|
4646
5526
|
stdout.write(`${JSON.stringify(payload ?? { ok: true }, null, 2)}\n`);
|
|
4647
5527
|
}
|
|
4648
5528
|
|
|
5529
|
+
// Strip fields that leak into ticket pastes / CI logs on --json dumps.
|
|
5530
|
+
function redactNodeSecrets(node) {
|
|
5531
|
+
if (!node || typeof node !== "object") {
|
|
5532
|
+
return node;
|
|
5533
|
+
}
|
|
5534
|
+
const redacted = { ...node };
|
|
5535
|
+
delete redacted.admin_private_key;
|
|
5536
|
+
delete redacted.system_variables;
|
|
5537
|
+
return redacted;
|
|
5538
|
+
}
|
|
5539
|
+
|
|
4649
5540
|
function formatCliError(error) {
|
|
4650
5541
|
if (error instanceof CliApiError) {
|
|
4651
5542
|
const structuredDetail = extractStructuredErrorDetail(error.detail);
|