@ornncompute/cli 0.2.24 → 1.0.34

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.
Files changed (90) hide show
  1. package/README.md +351 -426
  2. package/package.json +12 -6
  3. package/src/admin-capacity-reservations.mjs +305 -0
  4. package/src/admin-command-handlers.mjs +57 -0
  5. package/src/admin-enrollment.mjs +681 -0
  6. package/src/admin-mutations.mjs +187 -0
  7. package/src/admin-node-diagnostics.mjs +412 -0
  8. package/src/admin-node-lifecycle.mjs +905 -0
  9. package/src/admin-shell.mjs +456 -0
  10. package/src/api-client.mjs +81 -28
  11. package/src/atomic-output.mjs +112 -0
  12. package/src/auth-store.mjs +182 -11
  13. package/src/capability-catalog.mjs +7 -0
  14. package/src/catalog-shell.mjs +1363 -0
  15. package/src/cli.mjs +123 -10266
  16. package/src/connect-mode-handlers.mjs +170 -0
  17. package/src/device-auth.mjs +179 -62
  18. package/src/evidence-commands.mjs +1354 -0
  19. package/src/external-bucket-commands.mjs +1374 -0
  20. package/src/gpu-catalog.mjs +21 -0
  21. package/src/identity-file.mjs +28 -0
  22. package/src/interaction-runtime.mjs +310 -0
  23. package/src/kubernetes-commands.mjs +2235 -0
  24. package/src/launch-mode-handlers.mjs +119 -0
  25. package/src/machine-reference.mjs +285 -0
  26. package/src/mount-commands.mjs +1148 -0
  27. package/src/session-commands.mjs +565 -0
  28. package/src/slurm-commands.mjs +274 -0
  29. package/src/startup.mjs +34 -11
  30. package/src/storage-commands.mjs +1164 -0
  31. package/src/update.mjs +172 -24
  32. package/src/vm-commands.mjs +4308 -0
  33. package/src/vpn-secret-output.mjs +150 -0
  34. package/src/wait-runtime.mjs +231 -0
  35. package/vendor/capabilities/capabilities/admin-capacity-reservations.js +96 -0
  36. package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
  37. package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
  38. package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
  39. package/vendor/capabilities/capabilities/admin.js +89 -0
  40. package/vendor/capabilities/capabilities/cli.js +156 -0
  41. package/vendor/capabilities/capabilities/clusters.js +150 -1
  42. package/vendor/capabilities/capabilities/commerce-market.js +330 -0
  43. package/vendor/capabilities/capabilities/compute.js +522 -0
  44. package/vendor/capabilities/capabilities/evidence.js +145 -0
  45. package/vendor/capabilities/capabilities/external-buckets.js +191 -0
  46. package/vendor/capabilities/capabilities/fleet.js +77 -25
  47. package/vendor/capabilities/capabilities/identity.js +114 -3
  48. package/vendor/capabilities/capabilities/listings.js +141 -144
  49. package/vendor/capabilities/capabilities/market-shared.js +44 -0
  50. package/vendor/capabilities/capabilities/markets.js +449 -0
  51. package/vendor/capabilities/capabilities/mounts.js +176 -0
  52. package/vendor/capabilities/capabilities/networks.js +1264 -0
  53. package/vendor/capabilities/capabilities/observability.js +38 -8
  54. package/vendor/capabilities/capabilities/tokens.js +32 -17
  55. package/vendor/capabilities/capabilities/volumes.js +178 -0
  56. package/vendor/capabilities/capability.js +21 -14
  57. package/vendor/capabilities/cli-catalog.js +67 -0
  58. package/vendor/capabilities/cli-migrations.js +261 -0
  59. package/vendor/capabilities/cli.js +7 -0
  60. package/vendor/capabilities/software-catalog.js +36 -0
  61. package/vendor/capabilities/spec-catalog.js +28 -5
  62. package/vendor/capabilities/supplier-directory.js +20 -0
  63. package/src/catalog-dispatch.mjs +0 -605
  64. package/src/color.mjs +0 -71
  65. package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
  66. package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
  67. package/vendor/capabilities/capabilities/identity.d.ts +0 -3
  68. package/vendor/capabilities/capabilities/listings.d.ts +0 -51
  69. package/vendor/capabilities/capabilities/observability.d.ts +0 -5
  70. package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
  71. package/vendor/capabilities/capabilities/staff-access.js +0 -278
  72. package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
  73. package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
  74. package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
  75. package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
  76. package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
  77. package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
  78. package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
  79. package/vendor/capabilities/capabilities/staff-shared.js +0 -98
  80. package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
  81. package/vendor/capabilities/capabilities/users.d.ts +0 -2
  82. package/vendor/capabilities/capability.d.ts +0 -93
  83. package/vendor/capabilities/catalog.d.ts +0 -3
  84. package/vendor/capabilities/catalog.js +0 -26
  85. package/vendor/capabilities/index.d.ts +0 -16
  86. package/vendor/capabilities/index.js +0 -16
  87. package/vendor/capabilities/names.d.ts +0 -6
  88. package/vendor/capabilities/role.d.ts +0 -6
  89. package/vendor/capabilities/role.js +0 -17
  90. package/vendor/capabilities/spec-catalog.d.ts +0 -78
@@ -0,0 +1,1374 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ import { CliApiError, computeEndpoint } from "./api-client.mjs";
5
+ import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
6
+ import {
7
+ codeOf,
8
+ fetchDrives,
9
+ fetchDrivePage,
10
+ idempotencyHeaders,
11
+ normalizedNameKey,
12
+ safeIdentifier,
13
+ safeTimestamp,
14
+ StorageCommandError,
15
+ } from "./storage-commands.mjs";
16
+
17
+ const IMPORT_POLL_INTERVAL_MS = 10_000;
18
+ const DEFAULT_IMPORT_TIMEOUT_MS = 30 * 60 * 1_000;
19
+ const STORAGE_PREFIX = "/nodes/storage-drives";
20
+ const PROVIDERS = new Set(["gcs", "s3", "r2"]);
21
+
22
+ function usage(message, field, code = "usage_error") {
23
+ throw new StorageCommandError(message, { code, exitCode: 2, field });
24
+ }
25
+
26
+ function requiredText(value, label, field) {
27
+ const normalized = String(value ?? "")
28
+ .normalize("NFKC")
29
+ .trim();
30
+ if (!normalized) usage(`${label} is required.`, field);
31
+ if (/\p{Cc}/u.test(normalized)) usage(`${label} cannot contain control characters.`, field);
32
+ return normalized;
33
+ }
34
+
35
+ function positions(input, count, usageText) {
36
+ const values = input.$positionals ?? [];
37
+ if (values.length !== count) usage(`Usage: ${usageText}`);
38
+ return values;
39
+ }
40
+
41
+ function externalKind(drive) {
42
+ const source = drive?.source ?? {};
43
+ const sourceProvider = String(source.provider ?? drive?.source_provider ?? "").toLowerCase();
44
+ if (sourceProvider === "external_gcs") return "gcs";
45
+ if (sourceProvider !== "s3") return null;
46
+ return String(source.s3_provider ?? drive?.external_s3_provider ?? "").toLowerCase() ===
47
+ "cloudflare_r2"
48
+ ? "r2"
49
+ : "s3";
50
+ }
51
+
52
+ function bucketRows(rows) {
53
+ return rows.filter((drive) => externalKind(drive) !== null);
54
+ }
55
+
56
+ function safeName(value) {
57
+ const name = String(value ?? "")
58
+ .normalize("NFKC")
59
+ .trim();
60
+ if (!name || /\p{Cc}/u.test(name)) {
61
+ throw new StorageCommandError("Ornn returned an unexpected external bucket name.", {
62
+ code: "invalid_gateway_response",
63
+ });
64
+ }
65
+ return name;
66
+ }
67
+
68
+ function safeOptionalText(value) {
69
+ if (typeof value !== "string") return null;
70
+ const normalized = value.normalize("NFKC").trim();
71
+ return normalized && !/\p{Cc}/u.test(normalized) ? normalized : null;
72
+ }
73
+
74
+ function verificationPhase(source) {
75
+ const status = String(source?.connection_status ?? "registered").toLowerCase();
76
+ if (status === "verified") return "verified";
77
+ if (status === "verification_failed" || status === "failed") return "failed";
78
+ return "unverified";
79
+ }
80
+
81
+ function verificationCode(source) {
82
+ const code = source?.verification_error?.code;
83
+ return typeof code === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(code) ? code : null;
84
+ }
85
+
86
+ function safeExternalBucket(drive, { forceUnverified = false } = {}) {
87
+ const source = drive?.source ?? {};
88
+ const kind = externalKind(drive);
89
+ if (!kind) {
90
+ throw new StorageCommandError("The selected resource is not an external bucket.", {
91
+ code: "external_bucket_not_found",
92
+ });
93
+ }
94
+ const bucket = safeOptionalText(source.bucket ?? drive?.external_bucket);
95
+ if (!bucket) {
96
+ throw new StorageCommandError("Ornn returned an incomplete external bucket record.", {
97
+ code: "invalid_gateway_response",
98
+ });
99
+ }
100
+ const phase = forceUnverified ? "unverified" : verificationPhase(source);
101
+ return {
102
+ id: safeIdentifier(drive?.id),
103
+ name: safeName(drive?.name),
104
+ kind,
105
+ location: {
106
+ bucket,
107
+ prefix: safeOptionalText(source.prefix ?? drive?.external_prefix),
108
+ region: safeOptionalText(source.region ?? drive?.external_region),
109
+ },
110
+ access: {
111
+ mode: source.read_only === false ? "read-write" : "read-only",
112
+ credentials_configured: kind === "gcs" ? false : source.credentials_configured === true,
113
+ },
114
+ verification: {
115
+ phase,
116
+ checked_at: phase === "unverified" ? null : safeTimestamp(source.last_verified_at),
117
+ ...(phase === "failed" && verificationCode(source)
118
+ ? { error_code: verificationCode(source) }
119
+ : {}),
120
+ },
121
+ created_at: safeTimestamp(drive?.created_at),
122
+ updated_at: safeTimestamp(drive?.updated_at),
123
+ };
124
+ }
125
+
126
+ function activeMountReservations(drive) {
127
+ const mounts = Array.isArray(drive?.active_mounts) ? drive.active_mounts : [];
128
+ return [
129
+ ...new Set(
130
+ mounts
131
+ .filter(
132
+ (mount) =>
133
+ !["detached", "ended", "failed", "error", "cancelled"].includes(
134
+ String(mount?.state ?? "").toLowerCase()
135
+ )
136
+ )
137
+ .map((mount) => safeIdentifier(mount?.reservation_id))
138
+ ),
139
+ ];
140
+ }
141
+
142
+ function resolveExternalBucket(rows, reference) {
143
+ const ref = requiredText(reference, "An external bucket reference", "external-bucket");
144
+ const external = bucketRows(rows);
145
+ const byId = external.filter((drive) => String(drive?.id) === ref);
146
+ if (byId.length === 1) return byId[0];
147
+ const key = normalizedNameKey(ref);
148
+ const byName = external.filter((drive) => normalizedNameKey(drive?.name) === key);
149
+ if (byName.length === 1) return byName[0];
150
+ if (byName.length > 1) {
151
+ throw new StorageCommandError(
152
+ `More than one external bucket has the exact name “${ref}”. Use its canonical ID.`,
153
+ { code: "ambiguous_external_bucket", field: "external-bucket" }
154
+ );
155
+ }
156
+ throw new StorageCommandError(`External bucket not found: ${ref}`, {
157
+ code: "external_bucket_not_found",
158
+ field: "external-bucket",
159
+ });
160
+ }
161
+
162
+ function providerName(kind) {
163
+ if (kind === "gcs") return "GCS";
164
+ if (kind === "r2") return "Cloudflare R2";
165
+ return "AWS S3";
166
+ }
167
+
168
+ function providerFrom(value) {
169
+ const provider = String(value ?? "")
170
+ .trim()
171
+ .toLowerCase();
172
+ if (!PROVIDERS.has(provider)) usage("Choose one external bucket kind: gcs, s3, or r2.", "kind");
173
+ return provider;
174
+ }
175
+
176
+ function normalizePrefix(value) {
177
+ if (value === undefined || value === null || String(value).trim() === "") return null;
178
+ const normalized = String(value)
179
+ .normalize("NFKC")
180
+ .trim()
181
+ .replace(/^\/+|\/+$/g, "");
182
+ const segments = normalized.split("/");
183
+ if (
184
+ !normalized ||
185
+ segments.some(
186
+ (segment) => !segment || segment === "." || segment === ".." || /\p{Cc}/u.test(segment)
187
+ )
188
+ ) {
189
+ usage("--prefix must be a relative bucket prefix without empty, . or .. segments.", "prefix");
190
+ }
191
+ return normalized;
192
+ }
193
+
194
+ function validateBucketName(value) {
195
+ const bucket = requiredText(value, "A bucket name", "bucket");
196
+ if (
197
+ bucket.length > 222 ||
198
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/.test(bucket) ||
199
+ bucket.includes("..")
200
+ ) {
201
+ usage("--bucket must name one bucket without a path.", "bucket");
202
+ }
203
+ return bucket;
204
+ }
205
+
206
+ function locationFromBucket(kind, raw) {
207
+ const normalized = requiredText(raw, "--bucket", "bucket");
208
+ const scheme = normalized.match(/^([a-z0-9]+):\/\//i)?.[1]?.toLowerCase();
209
+ const allowedScheme =
210
+ scheme === kind || (kind === "gcs" && scheme === "gs") || (kind === "r2" && scheme === "s3");
211
+ if (scheme && !allowedScheme) {
212
+ usage(`The bucket URI does not match the selected ${providerName(kind)} kind.`, "bucket");
213
+ }
214
+ const withoutScheme = normalized.replace(/^[a-z0-9]+:\/\//i, "").replace(/^\/+|\/+$/g, "");
215
+ const [bucket, ...prefix] = withoutScheme.split("/");
216
+ return { bucket: validateBucketName(bucket), prefix: normalizePrefix(prefix.join("/")) };
217
+ }
218
+
219
+ function safeDecodedPath(value) {
220
+ try {
221
+ return decodeURIComponent(value);
222
+ } catch {
223
+ usage("--url contains an invalid escaped path.", "url");
224
+ }
225
+ }
226
+
227
+ function locationFromConsoleUrl(kind, raw) {
228
+ let url;
229
+ try {
230
+ url = new URL(requiredText(raw, "--url", "url"));
231
+ } catch {
232
+ usage("--url must be a valid HTTPS cloud-console URL.", "url");
233
+ }
234
+ if (url.protocol !== "https:") usage("--url must use HTTPS.", "url");
235
+ const host = url.hostname.toLowerCase();
236
+ const parts = url.pathname.split("/").filter(Boolean).map(safeDecodedPath);
237
+ if (kind === "gcs") {
238
+ if (host !== "console.cloud.google.com") usage("Use a GCS console URL for kind gcs.", "url");
239
+ const browser = parts.indexOf("browser");
240
+ if (browser < 0 || !parts[browser + 1]) usage("GCS console URL must name one bucket.", "url");
241
+ return {
242
+ bucket: validateBucketName(parts[browser + 1]),
243
+ prefix: normalizePrefix(url.searchParams.get("prefix")),
244
+ region: null,
245
+ accountId: null,
246
+ };
247
+ }
248
+ if (kind === "s3") {
249
+ if (!host.endsWith(".console.aws.amazon.com") && host !== "console.aws.amazon.com") {
250
+ usage("Use an AWS S3 console URL for kind s3.", "url");
251
+ }
252
+ const buckets = parts.indexOf("buckets");
253
+ if (buckets < 0 || !parts[buckets + 1])
254
+ usage("AWS S3 console URL must name one bucket.", "url");
255
+ const hostRegion = host.split(".console.aws.amazon.com")[0];
256
+ return {
257
+ bucket: validateBucketName(parts[buckets + 1]),
258
+ prefix: normalizePrefix(url.searchParams.get("prefix")),
259
+ region: safeOptionalText(url.searchParams.get("region")) ?? safeOptionalText(hostRegion),
260
+ accountId: null,
261
+ };
262
+ }
263
+ if (host !== "dash.cloudflare.com") usage("Use a Cloudflare R2 console URL for kind r2.", "url");
264
+ const r2 = parts.indexOf("r2");
265
+ const buckets = parts.indexOf("buckets", r2 + 1);
266
+ if (r2 < 1 || buckets < 0 || !parts[buckets + 1]) {
267
+ usage("Cloudflare R2 console URL must name one account and bucket.", "url");
268
+ }
269
+ return {
270
+ bucket: validateBucketName(parts[buckets + 1]),
271
+ prefix: null,
272
+ region: "auto",
273
+ accountId: requiredText(parts[0], "A Cloudflare account ID", "account-id"),
274
+ };
275
+ }
276
+
277
+ function endpointForR2(accountId, endpointUrl) {
278
+ if (accountId && endpointUrl) usage("Use only one of --account-id or --endpoint-url.");
279
+ if (endpointUrl) {
280
+ let parsed;
281
+ try {
282
+ parsed = new URL(requiredText(endpointUrl, "--endpoint-url", "endpoint-url"));
283
+ } catch {
284
+ usage("--endpoint-url must be a valid Cloudflare R2 HTTPS URL.", "endpoint-url");
285
+ }
286
+ const host = parsed.hostname.toLowerCase();
287
+ if (
288
+ parsed.protocol !== "https:" ||
289
+ (host !== "r2.cloudflarestorage.com" && !host.endsWith(".r2.cloudflarestorage.com")) ||
290
+ parsed.username ||
291
+ parsed.password ||
292
+ parsed.search ||
293
+ parsed.hash
294
+ ) {
295
+ usage("--endpoint-url must be a credential-free Cloudflare R2 HTTPS origin.", "endpoint-url");
296
+ }
297
+ return `${parsed.protocol}//${parsed.host}`;
298
+ }
299
+ const account = requiredText(
300
+ accountId,
301
+ "Cloudflare R2 requires --account-id or --endpoint-url",
302
+ "account-id"
303
+ );
304
+ if (!/^[A-Za-z0-9_-]{3,128}$/.test(account)) usage("--account-id is invalid.", "account-id");
305
+ return `https://${account}.r2.cloudflarestorage.com`;
306
+ }
307
+
308
+ async function readCredentialFile(pathValue) {
309
+ const path = requiredText(pathValue, "--secret-access-key-file", "secret-access-key-file");
310
+ let secret;
311
+ try {
312
+ secret = (await readFile(path, "utf8")).trim();
313
+ } catch {
314
+ usage("The secret access key file could not be read.", "secret-access-key-file");
315
+ }
316
+ if (!secret || secret.length > 16_384 || /[\r\n]/.test(secret)) {
317
+ usage(
318
+ "The secret access key file must contain exactly one non-empty credential.",
319
+ "secret-access-key-file"
320
+ );
321
+ }
322
+ return secret;
323
+ }
324
+
325
+ async function connectionIntent(input) {
326
+ const [rawKind] = positions(
327
+ input,
328
+ 1,
329
+ "ornn external-buckets connect <gcs|s3|r2> --bucket <bucket> [provider options]"
330
+ );
331
+ const kind = providerFrom(rawKind);
332
+ if (Boolean(input.bucket) === Boolean(input.url)) {
333
+ usage("Choose exactly one of --bucket or --url.");
334
+ }
335
+ if (input.readOnly && input.readWrite) usage("Choose only one of --read-only or --read-write.");
336
+ const parsed = input.bucket
337
+ ? locationFromBucket(kind, input.bucket)
338
+ : locationFromConsoleUrl(kind, input.url);
339
+ const suppliedPrefix = normalizePrefix(input.prefix);
340
+ if (suppliedPrefix && parsed.prefix && suppliedPrefix !== parsed.prefix) {
341
+ usage("The URI prefix and --prefix differ. Supply the intended prefix once.", "prefix");
342
+ }
343
+ const prefix = suppliedPrefix ?? parsed.prefix;
344
+ const region = safeOptionalText(input.region) ?? parsed.region ?? (kind === "r2" ? "auto" : null);
345
+ let endpoint = null;
346
+ let accessKeyId = null;
347
+ let secretAccessKey = null;
348
+ if (kind === "gcs") {
349
+ if (
350
+ input.accountId ||
351
+ input.endpointUrl ||
352
+ input.region ||
353
+ input.accessKeyId ||
354
+ input.secretAccessKeyFile
355
+ ) {
356
+ usage("GCS connect does not accept S3 or R2 region, endpoint, or credential options.");
357
+ }
358
+ } else {
359
+ accessKeyId = requiredText(input.accessKeyId, "--access-key-id", "access-key-id");
360
+ secretAccessKey = await readCredentialFile(input.secretAccessKeyFile);
361
+ if (kind === "s3" && (input.accountId || input.endpointUrl)) {
362
+ usage("--account-id and --endpoint-url are only valid for Cloudflare R2.");
363
+ }
364
+ if (kind === "r2") {
365
+ endpoint = endpointForR2(input.accountId ?? parsed.accountId, input.endpointUrl);
366
+ }
367
+ }
368
+ const name = safeName(input.name ?? parsed.bucket);
369
+ return {
370
+ accessKeyId,
371
+ bucket: parsed.bucket,
372
+ endpoint,
373
+ kind,
374
+ name,
375
+ prefix,
376
+ readOnly: input.readWrite !== true,
377
+ region,
378
+ secretAccessKey,
379
+ };
380
+ }
381
+
382
+ function credentialFingerprint(accessKeyId, secret) {
383
+ return createHash("sha256").update(accessKeyId).update("\0").update(secret).digest("hex");
384
+ }
385
+
386
+ function keyHintMatches(source, accessKeyId) {
387
+ if (!accessKeyId) return source.credentials_configured !== true;
388
+ const hint = safeOptionalText(source.access_key_id_hint);
389
+ return source.credentials_configured === true && (!hint || hint.endsWith(accessKeyId.slice(-4)));
390
+ }
391
+
392
+ function connectionEquivalent(drive, intent) {
393
+ const source = drive?.source ?? {};
394
+ return (
395
+ externalKind(drive) === intent.kind &&
396
+ safeOptionalText(source.bucket) === intent.bucket &&
397
+ (safeOptionalText(source.prefix) ?? null) === intent.prefix &&
398
+ (safeOptionalText(source.region) ?? null) === intent.region &&
399
+ (source.read_only !== false) === intent.readOnly &&
400
+ (safeOptionalText(source.endpoint_url) ?? null) === intent.endpoint &&
401
+ keyHintMatches(source, intent.accessKeyId)
402
+ );
403
+ }
404
+
405
+ function requireMutationPreflight(input, runtime, { consent = false } = {}) {
406
+ const noninteractive = runtime.interaction.machine || runtime.interaction.redirected;
407
+ if (noninteractive && !String(input.idempotencyKey ?? "").trim()) {
408
+ usage(
409
+ "Noninteractive mutations require --idempotency-key <caller-stable-key>.",
410
+ "idempotency-key"
411
+ );
412
+ }
413
+ if (noninteractive && consent && input.yes !== true) {
414
+ usage(
415
+ "Consent is required. Review the exact intent and supply --yes.",
416
+ "yes",
417
+ "consent_required"
418
+ );
419
+ }
420
+ }
421
+
422
+ async function reviewed(runtime, options) {
423
+ const accepted = await runtime.interaction.review(options);
424
+ if (accepted) return null;
425
+ return runtime.interaction.result({
426
+ data: null,
427
+ exitCode: 1,
428
+ outcome: { class: "ended", phase: "declined" },
429
+ });
430
+ }
431
+
432
+ const SAFE_EXTERNAL_ERRORS = {
433
+ external_bucket_required: "The external bucket record is incomplete.",
434
+ external_endpoint_url_invalid: "The Cloudflare R2 endpoint is invalid.",
435
+ external_endpoint_url_required: "Cloudflare R2 requires an account endpoint.",
436
+ external_s3_credentials_required: "Current S3-compatible credentials are required.",
437
+ storage_drive_attached_elsewhere:
438
+ "This working volume is already attached to a different reservation.",
439
+ storage_drive_attached_to_reservation:
440
+ "Detach the working volume from every reservation before disconnecting the source.",
441
+ storage_location_unavailable:
442
+ "No managed destination is available for this reservation. The source bucket was not changed.",
443
+ storage_regional_bucket_unconfigured:
444
+ "Managed destination storage is temporarily unavailable. The source bucket was not changed.",
445
+ storage_reservation_node_offline:
446
+ "A reservation machine is offline, so the import destination was not changed.",
447
+ storage_source_client_unavailable: "The external bucket client is temporarily unavailable.",
448
+ storage_source_list_failed: "Ornn could not read the external bucket.",
449
+ storage_source_not_verified: "Verify this external bucket before importing it.",
450
+ storage_source_verification_unsupported: "This external bucket kind cannot be verified.",
451
+ };
452
+
453
+ function safeExternalError(error, fallback, { action, forcePartial = false } = {}) {
454
+ if (error instanceof StorageCommandError) return error;
455
+ if (error instanceof CliApiError && (error.status === 401 || error.status === 403)) {
456
+ return new StorageCommandError(
457
+ error.status === 401
458
+ ? "Sign in to continue."
459
+ : "This organization cannot perform that operation.",
460
+ {
461
+ code: error.status === 401 ? "authentication_required" : "authorization_denied",
462
+ exitCode: 5,
463
+ }
464
+ );
465
+ }
466
+ const code = codeOf(error);
467
+ const unavailable = error?.status === undefined || error?.status >= 500;
468
+ return new StorageCommandError(SAFE_EXTERNAL_ERRORS[code] ?? fallback, {
469
+ action,
470
+ code: code ?? (unavailable ? "service_unavailable" : "external_bucket_operation_failed"),
471
+ exitCode: forcePartial ? 4 : unavailable ? 6 : 1,
472
+ outcomeClass: forcePartial ? "partial" : unavailable ? "failed" : "failed",
473
+ retryable: forcePartial || unavailable,
474
+ });
475
+ }
476
+
477
+ async function listBuckets(input, runtime) {
478
+ positions(input, 0, "ornn external-buckets list [--limit <1-100>] [--cursor <cursor>]");
479
+ const limit = Number(input.limit ?? 25);
480
+ const page = await fetchDrivePage(runtime, { limit, cursor: input.cursor, scope: "external" });
481
+ const rows = bucketRows(page.drives).map((drive) => safeExternalBucket(drive));
482
+ return runtime.interaction.result({
483
+ data: { external_buckets: rows },
484
+ page: { limit, returned: rows.length, next_cursor: page.next_cursor },
485
+ });
486
+ }
487
+
488
+ async function showBucket(input, runtime) {
489
+ const [reference] = positions(input, 1, "ornn external-buckets show <external-bucket>");
490
+ const bucket = resolveExternalBucket(await fetchDrives(runtime), reference);
491
+ return runtime.interaction.result({ data: { external_bucket: safeExternalBucket(bucket) } });
492
+ }
493
+
494
+ async function connectBucket(input, runtime) {
495
+ const intent = await connectionIntent(input);
496
+ requireMutationPreflight(input, runtime, { consent: true });
497
+ const fingerprint =
498
+ intent.accessKeyId && intent.secretAccessKey
499
+ ? credentialFingerprint(intent.accessKeyId, intent.secretAccessKey)
500
+ : null;
501
+ const declined = await reviewed(runtime, {
502
+ title: "Connect external bucket",
503
+ terms: [
504
+ { label: "Kind", value: providerName(intent.kind) },
505
+ { label: "Name", value: intent.name },
506
+ { label: "Bucket", value: intent.bucket },
507
+ { label: "Prefix", value: intent.prefix ?? "(bucket root)" },
508
+ { label: "Access", value: intent.readOnly ? "read-only" : "read-write" },
509
+ {
510
+ label: "Credentials",
511
+ value: fingerprint ? `fingerprint ${fingerprint.slice(0, 12)}` : "ambient GCS access",
512
+ },
513
+ { label: "Verification", value: "separate command required" },
514
+ ],
515
+ prompt: "Connect this external bucket?",
516
+ });
517
+ if (declined) return declined;
518
+
519
+ const rows = await fetchDrives(runtime);
520
+ const sameName = bucketRows(rows).filter(
521
+ (drive) => normalizedNameKey(drive?.name) === normalizedNameKey(intent.name)
522
+ );
523
+ if (sameName.length > 1) {
524
+ throw new StorageCommandError(
525
+ `More than one external bucket has the exact name “${intent.name}”. Use a different name.`,
526
+ { code: "ambiguous_external_bucket", field: "name" }
527
+ );
528
+ }
529
+ if (sameName.length === 1) {
530
+ if (!connectionEquivalent(sameName[0], intent)) {
531
+ throw new StorageCommandError(
532
+ "That external bucket name already has different source, access, or credential intent. Review the existing connection or choose a new name.",
533
+ { code: "external_bucket_name_conflict", field: "name" }
534
+ );
535
+ }
536
+ const safe = safeExternalBucket(sameName[0]);
537
+ return runtime.interaction.result({
538
+ data: {
539
+ external_bucket: safe,
540
+ replayed: true,
541
+ verification_required: safe.verification.phase !== "verified",
542
+ },
543
+ outcome: {
544
+ class: safe.verification.phase === "failed" ? "failed" : "ready",
545
+ phase: "replayed",
546
+ },
547
+ presentation: {
548
+ lines: [
549
+ `External bucket connection replayed: ${safe.name} (${safe.id})`,
550
+ "Connection and verification are separate operations.",
551
+ ],
552
+ next: `ornn external-buckets verify ${safe.id}`,
553
+ },
554
+ });
555
+ }
556
+
557
+ let created;
558
+ try {
559
+ created = await runtime.fetch(computeEndpoint(STORAGE_PREFIX), {
560
+ body: {
561
+ name: intent.name,
562
+ provider: "ornn_volume",
563
+ source_provider: intent.kind === "gcs" ? "external_gcs" : "s3",
564
+ external_bucket: intent.bucket,
565
+ external_prefix: intent.prefix,
566
+ external_region: intent.region,
567
+ external_read_only: intent.readOnly,
568
+ ...(intent.kind === "s3" ? { external_s3_provider: "aws_s3" } : {}),
569
+ ...(intent.kind === "r2" ? { external_s3_provider: "cloudflare_r2" } : {}),
570
+ ...(intent.endpoint ? { external_endpoint_url: intent.endpoint } : {}),
571
+ ...(intent.accessKeyId ? { external_access_key_id: intent.accessKeyId } : {}),
572
+ ...(intent.secretAccessKey ? { external_secret_access_key: intent.secretAccessKey } : {}),
573
+ },
574
+ headers: idempotencyHeaders(input),
575
+ method: "POST",
576
+ });
577
+ } catch (error) {
578
+ throw safeExternalError(error, "The external bucket could not be connected safely.");
579
+ }
580
+ const safe = safeExternalBucket(created, { forceUnverified: true });
581
+ return runtime.interaction.result({
582
+ data: { external_bucket: safe, replayed: false, verification_required: true },
583
+ outcome: { class: "ready", phase: "connected" },
584
+ presentation: {
585
+ lines: [
586
+ `External bucket connected: ${safe.name} (${safe.id})`,
587
+ "Connection recorded; access has not been verified.",
588
+ ],
589
+ next: `ornn external-buckets verify ${safe.id}`,
590
+ },
591
+ });
592
+ }
593
+
594
+ async function verifyBucket(input, runtime) {
595
+ const [reference] = positions(input, 1, "ornn external-buckets verify <external-bucket>");
596
+ requireMutationPreflight(input, runtime);
597
+ const source = resolveExternalBucket(await fetchDrives(runtime), reference);
598
+ const id = safeIdentifier(source.id);
599
+ let verified;
600
+ try {
601
+ verified = await runtime.fetch(
602
+ computeEndpoint(`${STORAGE_PREFIX}/${encodeURIComponent(id)}/verify-source`),
603
+ {
604
+ headers: idempotencyHeaders(input),
605
+ method: "POST",
606
+ }
607
+ );
608
+ } catch (error) {
609
+ throw safeExternalError(error, "External bucket verification could not be completed safely.");
610
+ }
611
+ const safe = safeExternalBucket(verified);
612
+ if (safe.verification.phase === "failed") {
613
+ const action = { kind: "retry", command: `ornn external-buckets verify ${safe.id}` };
614
+ return runtime.interaction.result({
615
+ action,
616
+ data: { external_bucket: safe },
617
+ errors: [
618
+ {
619
+ code: "external_bucket_verification_failed",
620
+ message: "Ornn could not verify current access to this external bucket.",
621
+ retryable: true,
622
+ },
623
+ ],
624
+ exitCode: 1,
625
+ outcome: { class: "failed", phase: "verification_failed" },
626
+ presentation: {
627
+ lines: [`External bucket verification failed: ${safe.name} (${safe.id})`],
628
+ next: action.command,
629
+ },
630
+ });
631
+ }
632
+ return runtime.interaction.result({
633
+ data: { external_bucket: safe },
634
+ outcome: { class: "ready", phase: "verified" },
635
+ presentation: { lines: [`External bucket verified: ${safe.name} (${safe.id})`] },
636
+ });
637
+ }
638
+
639
+ async function updateCredentials(input, runtime) {
640
+ const [reference] = positions(
641
+ input,
642
+ 1,
643
+ "ornn external-buckets update-credentials <external-bucket> --access-key-id <id> --secret-access-key-file <path>"
644
+ );
645
+ const accessKeyId = requiredText(input.accessKeyId, "--access-key-id", "access-key-id");
646
+ const secret = await readCredentialFile(input.secretAccessKeyFile);
647
+ requireMutationPreflight(input, runtime, { consent: true });
648
+ const fingerprint = credentialFingerprint(accessKeyId, secret);
649
+ const source = resolveExternalBucket(await fetchDrives(runtime), reference);
650
+ const kind = externalKind(source);
651
+ if (kind === "gcs") {
652
+ usage(
653
+ "GCS connections use ambient access and do not accept S3 credentials.",
654
+ "external-bucket"
655
+ );
656
+ }
657
+ const id = safeIdentifier(source.id);
658
+ const declined = await reviewed(runtime, {
659
+ title: "Update external bucket credentials",
660
+ terms: [
661
+ { label: "External bucket", value: `${safeName(source.name)} (${id})` },
662
+ { label: "Kind", value: providerName(kind) },
663
+ { label: "Credential fingerprint", value: fingerprint.slice(0, 12) },
664
+ { label: "Verification", value: "must be run again" },
665
+ ],
666
+ prompt: "Replace these credentials?",
667
+ });
668
+ if (declined) return declined;
669
+ let updated;
670
+ try {
671
+ updated = await runtime.fetch(
672
+ computeEndpoint(`${STORAGE_PREFIX}/${encodeURIComponent(id)}/source-credentials`),
673
+ {
674
+ body: {
675
+ external_access_key_id: accessKeyId,
676
+ external_secret_access_key: secret,
677
+ },
678
+ headers: idempotencyHeaders(input),
679
+ method: "PATCH",
680
+ }
681
+ );
682
+ } catch (error) {
683
+ throw safeExternalError(error, "External bucket credentials could not be updated safely.");
684
+ }
685
+ const safe = safeExternalBucket(updated, { forceUnverified: true });
686
+ return runtime.interaction.result({
687
+ data: { external_bucket: safe, credentials_updated: true, verification_required: true },
688
+ outcome: { class: "ready", phase: "credentials_updated" },
689
+ presentation: {
690
+ lines: [
691
+ `Credentials updated for external bucket: ${safe.name} (${safe.id})`,
692
+ "Stored credential values are never included in command output.",
693
+ ],
694
+ next: `ornn external-buckets verify ${safe.id}`,
695
+ },
696
+ });
697
+ }
698
+
699
+ async function disconnectBucket(input, runtime) {
700
+ const [reference] = positions(input, 1, "ornn external-buckets disconnect <external-bucket>");
701
+ requireMutationPreflight(input, runtime, { consent: true });
702
+ const source = resolveExternalBucket(await fetchDrives(runtime), reference);
703
+ const safe = safeExternalBucket(source);
704
+ const declined = await reviewed(runtime, {
705
+ title: "Disconnect external bucket",
706
+ terms: [
707
+ { label: "External bucket", value: `${safe.name} (${safe.id})` },
708
+ { label: "Kind", value: providerName(safe.kind) },
709
+ { label: "Source writeback", value: "disabled" },
710
+ { label: "Consequence", value: "source access is removed after all mounts are detached" },
711
+ ],
712
+ prompt: "Disconnect this external bucket?",
713
+ });
714
+ if (declined) return declined;
715
+ const attachedReservations = activeMountReservations(source);
716
+ if (attachedReservations.length > 0) {
717
+ throw new StorageCommandError(
718
+ `Detach this external bucket from ${attachedReservations.join(", ")} before disconnecting it.`,
719
+ {
720
+ action: {
721
+ kind: "detach",
722
+ command: `ornn volume-mounts detach ${attachedReservations[0]}`,
723
+ },
724
+ code: "storage_drive_attached_to_reservation",
725
+ }
726
+ );
727
+ }
728
+ try {
729
+ await runtime.fetch(computeEndpoint(`${STORAGE_PREFIX}/${encodeURIComponent(safe.id)}`), {
730
+ headers: idempotencyHeaders(input),
731
+ method: "DELETE",
732
+ });
733
+ } catch (error) {
734
+ throw safeExternalError(error, "The external bucket could not be disconnected safely.");
735
+ }
736
+ return runtime.interaction.result({
737
+ data: {
738
+ external_bucket: { id: safe.id, name: safe.name, kind: safe.kind },
739
+ disconnected: true,
740
+ source_writeback: false,
741
+ },
742
+ outcome: { class: "ended", phase: "disconnected" },
743
+ presentation: { lines: [`External bucket disconnected: ${safe.name} (${safe.id})`] },
744
+ });
745
+ }
746
+
747
+ function canonicalReservation(value) {
748
+ const reservation = requiredText(value, "--reservation", "reservation");
749
+ if (
750
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reservation)
751
+ ) {
752
+ usage("--reservation must be a canonical reservation ID.", "reservation");
753
+ }
754
+ return reservation.toLowerCase();
755
+ }
756
+
757
+ function parseTimeout(value) {
758
+ if (value === undefined) return DEFAULT_IMPORT_TIMEOUT_MS;
759
+ const match = String(value)
760
+ .trim()
761
+ .match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
762
+ if (!match) usage("--timeout must be a positive duration such as 30s or 20m.", "timeout");
763
+ const amount = Number(match[1]);
764
+ const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
765
+ const milliseconds = amount * units[(match[2] ?? "s").toLowerCase()];
766
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0 || milliseconds > 24 * 3_600_000) {
767
+ usage("--timeout must be greater than zero and no more than 24h.", "timeout");
768
+ }
769
+ return milliseconds;
770
+ }
771
+
772
+ function attachmentFrom(payload) {
773
+ // A detached reservation answers {"attachment": null}; without this the
774
+ // wrapper itself reads as an attachment and every import fails closed.
775
+ if (payload && typeof payload === "object" && "attachment" in payload) {
776
+ return payload.attachment ?? null;
777
+ }
778
+ return payload ?? null;
779
+ }
780
+
781
+ async function fetchImportAttachment(runtime, reservationId, { signal } = {}) {
782
+ try {
783
+ const payload = await runtime.fetch(
784
+ computeEndpoint(
785
+ `/nodes/reservations/${encodeURIComponent(reservationId)}/storage-attachment`
786
+ ),
787
+ { method: "GET", ...(signal ? { signal } : {}) }
788
+ );
789
+ return attachmentFrom(payload);
790
+ } catch (error) {
791
+ if (error instanceof CliApiError && error.status === 404) return null;
792
+ throw error;
793
+ }
794
+ }
795
+
796
+ function safeCount(value) {
797
+ const count = Number(value);
798
+ return Number.isFinite(count) && count >= 0 ? count : 0;
799
+ }
800
+
801
+ function transferProjection(attachment) {
802
+ const placement = attachment?.placement ?? {};
803
+ const transfer = placement.transfer ?? {};
804
+ const legacy = placement.transfer_progress ?? {};
805
+ const rawState = String(
806
+ transfer.state ??
807
+ placement.transfer_status ??
808
+ attachment?.transfer_state ??
809
+ attachment?.state ??
810
+ "queued"
811
+ ).toLowerCase();
812
+ const failed =
813
+ ["failed", "error", "cancelled"].includes(rawState) ||
814
+ ["failed", "error"].includes(String(attachment?.state ?? "").toLowerCase());
815
+ const ready =
816
+ ["ready", "complete", "completed", "succeeded"].includes(rawState) ||
817
+ (!failed && ["ready", "attached"].includes(String(attachment?.state ?? "").toLowerCase()));
818
+ const phase = failed ? "failed" : ready ? "ready" : "working";
819
+ return {
820
+ phase,
821
+ objects: {
822
+ copied: safeCount(transfer.objects_copied ?? legacy.completed_objects),
823
+ total: safeCount(transfer.objects_total ?? legacy.total_objects),
824
+ },
825
+ bytes: {
826
+ copied: safeCount(transfer.bytes_copied ?? legacy.completed_bytes),
827
+ total: safeCount(transfer.bytes_total ?? legacy.total_bytes),
828
+ },
829
+ };
830
+ }
831
+
832
+ function importFailureKind(attachment) {
833
+ const error =
834
+ attachment?.placement?.transfer?.error ?? attachment?.placement?.error ?? attachment?.error;
835
+ const raw = typeof error === "string" ? error : error?.code;
836
+ const normalized = String(raw ?? "").toLowerCase();
837
+ if (normalized.includes("source") || normalized.includes("credential")) return "source";
838
+ if (
839
+ normalized.includes("destination") ||
840
+ normalized.includes("location") ||
841
+ normalized.includes("regional") ||
842
+ normalized.includes("target")
843
+ ) {
844
+ return "destination";
845
+ }
846
+ return "transfer";
847
+ }
848
+
849
+ function importData(bucket, reservationId, attachment) {
850
+ const transfer = transferProjection(attachment);
851
+ return {
852
+ external_bucket: bucket,
853
+ working_volume: {
854
+ id: bucket.id,
855
+ name: bucket.name,
856
+ phase: transfer.phase,
857
+ reservation_id: reservationId,
858
+ isolated: true,
859
+ source_writeback: false,
860
+ },
861
+ transfer,
862
+ };
863
+ }
864
+
865
+ function resumeImport(bucketId, reservationId, kind) {
866
+ const command = `ornn external-buckets import ${kind} ${bucketId} --reservation ${reservationId} --wait --timeout 30m`;
867
+ return {
868
+ action: { kind: "resume", command },
869
+ resume: { kind: "managed_volume", ref: bucketId },
870
+ };
871
+ }
872
+
873
+ function withProgress(runtime, init, progress) {
874
+ const execution = runtime.interaction.result(init);
875
+ execution.progress = progress;
876
+ return execution;
877
+ }
878
+
879
+ function transferProgressMessage(data, phase) {
880
+ const transfer = data.transfer;
881
+ const objects = transfer.objects.total
882
+ ? `${transfer.objects.copied}/${transfer.objects.total} objects`
883
+ : `${transfer.objects.copied} objects`;
884
+ const bytes = transfer.bytes.total
885
+ ? `${transfer.bytes.copied}/${transfer.bytes.total} bytes`
886
+ : `${transfer.bytes.copied} bytes`;
887
+ return `Import ${phase.replaceAll("_", " ")}: ${objects}, ${bytes}`;
888
+ }
889
+
890
+ function progressEvent(runtime, data, phase, sequence) {
891
+ return {
892
+ data,
893
+ message: transferProgressMessage(data, phase),
894
+ observedAt: new Date(runtime.now?.() ?? Date.now()).toISOString(),
895
+ outcome: {
896
+ class: phase === "ready" ? "ready" : phase === "failed" ? "partial" : "working",
897
+ phase,
898
+ },
899
+ resume: { kind: "managed_volume", ref: data.working_volume.id },
900
+ sequence,
901
+ };
902
+ }
903
+
904
+ function reportHumanProgress(runtime, data) {
905
+ if (runtime.interaction.format !== "human" || !runtime.interaction.eligibleHuman) return;
906
+ const progress = data.transfer;
907
+ const objects = progress.objects.total
908
+ ? `${progress.objects.copied}/${progress.objects.total} objects`
909
+ : `${progress.objects.copied} objects`;
910
+ const bytes = progress.bytes.total
911
+ ? `${progress.bytes.copied}/${progress.bytes.total} bytes`
912
+ : `${progress.bytes.copied} bytes`;
913
+ runtime.stderr.write(
914
+ `Import ${progress.phase}: ${objects}, ${bytes}; working volume ${data.working_volume.id}\n`
915
+ );
916
+ }
917
+
918
+ function terminalImportExecution(runtime, bucket, reservationId, attachment, progress) {
919
+ const data = importData(bucket, reservationId, attachment);
920
+ if (data.transfer.phase === "ready") {
921
+ return withProgress(
922
+ runtime,
923
+ {
924
+ data,
925
+ outcome: { class: "ready", phase: "imported" },
926
+ presentation: {
927
+ lines: [
928
+ `Managed working volume ready: ${bucket.name} (${bucket.id})`,
929
+ "Source writeback is disabled; later volume changes stay only in the managed copy.",
930
+ ],
931
+ next: `ornn volume-mounts show ${reservationId}`,
932
+ },
933
+ resume: { kind: "managed_volume", ref: bucket.id },
934
+ },
935
+ progress
936
+ );
937
+ }
938
+ if (data.transfer.phase !== "failed") return null;
939
+ const failureKind = importFailureKind(attachment);
940
+ const recovery = resumeImport(bucket.id, reservationId, bucket.kind);
941
+ return withProgress(
942
+ runtime,
943
+ {
944
+ ...recovery,
945
+ data,
946
+ errors: [
947
+ {
948
+ code: `external_bucket_import_${failureKind}_failed`,
949
+ message:
950
+ failureKind === "source"
951
+ ? "The source could not be read. The managed working-volume reference is preserved."
952
+ : failureKind === "destination"
953
+ ? "The managed destination could not be completed atomically. The working-volume reference is preserved."
954
+ : "The managed copy did not complete. The working-volume reference is preserved.",
955
+ retryable: true,
956
+ },
957
+ ],
958
+ exitCode: 4,
959
+ outcome: { class: "partial", phase: `${failureKind}_failed` },
960
+ presentation: {
961
+ lines: [
962
+ `Managed working volume preserved: ${bucket.name} (${bucket.id})`,
963
+ "Source writeback remains disabled.",
964
+ ],
965
+ next: recovery.action.command,
966
+ },
967
+ },
968
+ progress
969
+ );
970
+ }
971
+
972
+ function stoppedImportExecution(runtime, bucket, reservationId, attachment, progress, kind) {
973
+ const interrupted = kind === "interrupted";
974
+ const data = importData(bucket, reservationId, attachment);
975
+ const recovery = resumeImport(bucket.id, reservationId, bucket.kind);
976
+ if (!interrupted) {
977
+ progress.push(progressEvent(runtime, data, "local_timeout", progress.length + 1));
978
+ }
979
+ return withProgress(
980
+ runtime,
981
+ {
982
+ ...recovery,
983
+ data,
984
+ errors: [
985
+ {
986
+ code: interrupted ? "interrupted" : "local_timeout",
987
+ message: interrupted
988
+ ? "Local waiting stopped; the managed import continues remotely."
989
+ : "Local waiting timed out; the managed import continues remotely.",
990
+ retryable: true,
991
+ },
992
+ ],
993
+ exitCode: interrupted ? 130 : 124,
994
+ outcome: { class: "working", phase: interrupted ? "interrupted" : "local_timeout" },
995
+ presentation: {
996
+ lines: [
997
+ `Managed working volume preserved: ${bucket.name} (${bucket.id})`,
998
+ "Source writeback remains disabled.",
999
+ ],
1000
+ next: recovery.action.command,
1001
+ },
1002
+ },
1003
+ progress
1004
+ );
1005
+ }
1006
+
1007
+ async function waitForImport(runtime, bucket, reservationId, initial, timeoutMs) {
1008
+ let attachment = initial;
1009
+ const progress = [];
1010
+ let outcome;
1011
+ try {
1012
+ outcome = await runtime.interaction.waitForGoal({
1013
+ command: "ornn external-buckets import",
1014
+ emitProgress: false,
1015
+ initial,
1016
+ intervalMs: IMPORT_POLL_INTERVAL_MS,
1017
+ isReady: (current) =>
1018
+ Boolean(
1019
+ current &&
1020
+ String(current.drive_id ?? "") === bucket.id &&
1021
+ transferProjection(current).phase === "ready"
1022
+ ),
1023
+ isTerminal: (current) =>
1024
+ !current ||
1025
+ String(current.drive_id ?? "") !== bucket.id ||
1026
+ transferProjection(current).phase === "failed",
1027
+ message: () => "Waiting for the managed bucket import.",
1028
+ onObserve: (current) => {
1029
+ attachment = current;
1030
+ const data = importData(bucket, reservationId, current);
1031
+ progress.push(progressEvent(runtime, data, data.transfer.phase, progress.length + 1));
1032
+ reportHumanProgress(runtime, data);
1033
+ },
1034
+ poll: ({ signal }) => fetchImportAttachment(runtime, reservationId, { signal }),
1035
+ pollImmediately: true,
1036
+ timeoutMs,
1037
+ });
1038
+ } catch (error) {
1039
+ if (!isRecoverableWaitFailure(error)) throw error;
1040
+ const data = {
1041
+ ...importData(bucket, reservationId, attachment),
1042
+ remote_work_cancelled: false,
1043
+ };
1044
+ const recovery = resumeImport(bucket.id, reservationId, bucket.kind);
1045
+ const status = Number(error?.status);
1046
+ const authentication = status === 401;
1047
+ const authorization = status === 403;
1048
+ const action = authentication
1049
+ ? {
1050
+ kind: "login",
1051
+ command: "ornn login",
1052
+ description: "Restore an authorized session, then resume the exact accepted import.",
1053
+ type: "command",
1054
+ }
1055
+ : recovery.action;
1056
+ const code = authentication
1057
+ ? "authentication_required"
1058
+ : authorization
1059
+ ? "authorization_denied"
1060
+ : "service_unavailable";
1061
+ return withProgress(
1062
+ runtime,
1063
+ {
1064
+ ...recovery,
1065
+ action,
1066
+ data,
1067
+ errors: [
1068
+ {
1069
+ code,
1070
+ message: authentication
1071
+ ? "Sign in to resume status checks for the accepted import."
1072
+ : authorization
1073
+ ? "This organization can no longer read the accepted import."
1074
+ : "Import status could not be loaded. The managed working-volume reference is preserved.",
1075
+ retryable: !authorization,
1076
+ },
1077
+ ],
1078
+ exitCode: authorization || authentication ? 5 : 6,
1079
+ outcome: { class: "working", phase: "status_unavailable" },
1080
+ presentation: {
1081
+ lines: [
1082
+ `Managed working volume preserved: ${bucket.name} (${bucket.id})`,
1083
+ "Source writeback remains disabled.",
1084
+ ],
1085
+ next: action.command,
1086
+ },
1087
+ },
1088
+ progress
1089
+ );
1090
+ }
1091
+
1092
+ attachment = outcome.value;
1093
+ if (outcome.kind === "interrupted" || outcome.kind === "timeout") {
1094
+ return stoppedImportExecution(
1095
+ runtime,
1096
+ bucket,
1097
+ reservationId,
1098
+ attachment,
1099
+ progress,
1100
+ outcome.kind
1101
+ );
1102
+ }
1103
+ if (!attachment || String(attachment.drive_id ?? "") !== bucket.id) {
1104
+ const data = importData(bucket, reservationId, attachment);
1105
+ const recovery = resumeImport(bucket.id, reservationId, bucket.kind);
1106
+ return withProgress(
1107
+ runtime,
1108
+ {
1109
+ ...recovery,
1110
+ data,
1111
+ errors: [
1112
+ {
1113
+ code: "external_bucket_import_destination_missing",
1114
+ message:
1115
+ "The managed destination was not published; the source bucket was not changed.",
1116
+ retryable: true,
1117
+ },
1118
+ ],
1119
+ exitCode: 4,
1120
+ outcome: { class: "partial", phase: "destination_missing" },
1121
+ presentation: {
1122
+ lines: [
1123
+ `Managed working volume preserved: ${bucket.name} (${bucket.id})`,
1124
+ "Source writeback remains disabled.",
1125
+ ],
1126
+ next: recovery.action.command,
1127
+ },
1128
+ },
1129
+ progress
1130
+ );
1131
+ }
1132
+ return terminalImportExecution(runtime, bucket, reservationId, attachment, progress);
1133
+ }
1134
+
1135
+ async function importBucket(input, runtime) {
1136
+ const [rawKind, reference] = positions(
1137
+ input,
1138
+ 2,
1139
+ "ornn external-buckets import <gcs|s3|r2> <external-bucket> --reservation <canonical-reservation-id> [--wait --timeout <duration>]"
1140
+ );
1141
+ const kind = providerFrom(rawKind);
1142
+ if (kind === "r2") {
1143
+ usage(
1144
+ "Cloudflare R2 import is not supported. R2 buckets can be connected, verified, updated, and disconnected.",
1145
+ "kind",
1146
+ "external_bucket_import_unsupported"
1147
+ );
1148
+ }
1149
+ if (input.wait && input.noWait) usage("Choose only one of --wait or --no-wait.");
1150
+ const reservationId = canonicalReservation(input.reservation);
1151
+ const wait = input.wait === true || (runtime.interaction.eligibleHuman && input.noWait !== true);
1152
+ if (!wait && input.timeout !== undefined)
1153
+ usage("--timeout requires waiting for the import.", "timeout");
1154
+ if (
1155
+ (runtime.interaction.machine || runtime.interaction.redirected) &&
1156
+ wait &&
1157
+ input.timeout === undefined
1158
+ ) {
1159
+ usage("Noninteractive --wait requires --timeout <duration>.", "timeout");
1160
+ }
1161
+ const timeoutMs = parseTimeout(input.timeout);
1162
+ requireMutationPreflight(input, runtime, { consent: true });
1163
+
1164
+ const source = resolveExternalBucket(await fetchDrives(runtime), reference);
1165
+ const bucket = safeExternalBucket(source);
1166
+ if (bucket.kind !== kind) {
1167
+ throw new StorageCommandError(
1168
+ `The selected external bucket is ${providerName(bucket.kind)}, not ${providerName(kind)}. Review the import intent.`,
1169
+ { code: "external_bucket_kind_conflict", field: "kind" }
1170
+ );
1171
+ }
1172
+ if (bucket.verification.phase !== "verified") {
1173
+ throw new StorageCommandError("Verify this external bucket before importing it.", {
1174
+ action: { kind: "verify", command: `ornn external-buckets verify ${bucket.id}` },
1175
+ code: "external_bucket_not_verified",
1176
+ exitCode: 3,
1177
+ outcomeClass: "action_required",
1178
+ });
1179
+ }
1180
+ const otherReservation = (Array.isArray(source.active_mounts) ? source.active_mounts : [])
1181
+ .filter(
1182
+ (mount) =>
1183
+ !["detached", "ended", "failed"].includes(String(mount?.state ?? "").toLowerCase()) &&
1184
+ String(mount?.reservation_id ?? "") !== reservationId
1185
+ )
1186
+ .map((mount) => safeIdentifier(mount.reservation_id))[0];
1187
+ if (otherReservation) {
1188
+ throw new StorageCommandError(
1189
+ "This external bucket already has a working volume on another reservation. Detach it before changing import intent.",
1190
+ {
1191
+ action: {
1192
+ kind: "detach",
1193
+ command: `ornn volume-mounts detach ${otherReservation}`,
1194
+ },
1195
+ code: "external_bucket_import_goal_conflict",
1196
+ }
1197
+ );
1198
+ }
1199
+
1200
+ let existing;
1201
+ try {
1202
+ existing = await fetchImportAttachment(runtime, reservationId);
1203
+ } catch (error) {
1204
+ throw safeExternalError(
1205
+ error,
1206
+ "The current managed destination could not be inspected safely."
1207
+ );
1208
+ }
1209
+ if (
1210
+ existing &&
1211
+ String(existing.drive_id ?? "") !== bucket.id &&
1212
+ !["detached", "ended"].includes(String(existing.state ?? "").toLowerCase())
1213
+ ) {
1214
+ throw new StorageCommandError(
1215
+ "This reservation already has a different working volume. Detach it before changing import intent.",
1216
+ {
1217
+ action: { kind: "detach", command: `ornn volume-mounts detach ${reservationId}` },
1218
+ code: "external_bucket_import_goal_conflict",
1219
+ }
1220
+ );
1221
+ }
1222
+
1223
+ const existingState = String(existing?.state ?? "").toLowerCase();
1224
+ let attachment =
1225
+ existing &&
1226
+ String(existing.drive_id ?? "") === bucket.id &&
1227
+ !["detached", "ended", "failed", "error", "cancelled"].includes(existingState)
1228
+ ? existing
1229
+ : null;
1230
+ const replayed = attachment !== null;
1231
+ if (!attachment) {
1232
+ const declined = await reviewed(runtime, {
1233
+ title: "Import external bucket",
1234
+ terms: [
1235
+ { label: "Source", value: `${providerName(bucket.kind)} ${bucket.location.bucket}` },
1236
+ { label: "Working volume", value: `${bucket.name} (${bucket.id})` },
1237
+ { label: "Reservation", value: reservationId },
1238
+ { label: "Isolation", value: "one Ornn-managed working copy" },
1239
+ { label: "Source writeback", value: "disabled" },
1240
+ ],
1241
+ prompt: "Create this managed working copy?",
1242
+ });
1243
+ if (declined) return declined;
1244
+ try {
1245
+ const payload = await runtime.fetch(
1246
+ computeEndpoint(
1247
+ `/nodes/reservations/${encodeURIComponent(reservationId)}/storage-deployment`
1248
+ ),
1249
+ {
1250
+ body: {
1251
+ drive_id: bucket.id,
1252
+ mount_path: "/home/tenant/volume",
1253
+ mount_mode: "fuse",
1254
+ access_mode: "read-write",
1255
+ },
1256
+ headers: idempotencyHeaders(input),
1257
+ method: "POST",
1258
+ signal: runtime.signal,
1259
+ }
1260
+ );
1261
+ attachment = attachmentFrom(payload);
1262
+ if (!attachment || String(attachment.drive_id ?? "") !== bucket.id) {
1263
+ attachment = await fetchImportAttachment(runtime, reservationId).catch(() => null);
1264
+ }
1265
+ } catch (error) {
1266
+ attachment = await fetchImportAttachment(runtime, reservationId).catch(() => null);
1267
+ if (!attachment || String(attachment.drive_id ?? "") !== bucket.id) {
1268
+ const data = importData(bucket, reservationId, {
1269
+ drive_id: bucket.id,
1270
+ state: "pre_positioning",
1271
+ placement: { transfer: { state: "queued" } },
1272
+ });
1273
+ const recovery = resumeImport(bucket.id, reservationId, bucket.kind);
1274
+ return runtime.interaction.result({
1275
+ ...recovery,
1276
+ data,
1277
+ errors: [
1278
+ {
1279
+ code: "external_bucket_import_acceptance_unknown",
1280
+ message:
1281
+ "Import acceptance could not be confirmed. Re-run the exact command to reconcile it.",
1282
+ retryable: true,
1283
+ },
1284
+ ],
1285
+ exitCode: 4,
1286
+ outcome: { class: "partial", phase: "acceptance_unknown" },
1287
+ presentation: {
1288
+ lines: [
1289
+ `Managed working-volume reference preserved: ${bucket.name} (${bucket.id})`,
1290
+ "Source writeback remains disabled.",
1291
+ ],
1292
+ next: recovery.action.command,
1293
+ },
1294
+ });
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ const data = importData(bucket, reservationId, attachment);
1300
+ if (!wait) {
1301
+ const terminal = terminalImportExecution(runtime, bucket, reservationId, attachment, []);
1302
+ if (terminal) return terminal;
1303
+ return runtime.interaction.result({
1304
+ data: { ...data, replayed },
1305
+ outcome: { class: "working", phase: replayed ? "replayed" : "accepted" },
1306
+ presentation: {
1307
+ lines: [
1308
+ `Managed import ${replayed ? "replayed" : "accepted"}: ${bucket.name} (${bucket.id})`,
1309
+ "Source writeback is disabled; later volume changes stay only in the managed copy.",
1310
+ ],
1311
+ next: `ornn external-buckets import ${bucket.kind} ${bucket.id} --reservation ${reservationId} --wait --timeout 30m`,
1312
+ },
1313
+ resume: { kind: "managed_volume", ref: bucket.id },
1314
+ });
1315
+ }
1316
+ return waitForImport(runtime, bucket, reservationId, attachment, timeoutMs);
1317
+ }
1318
+
1319
+ export async function executeExternalBucketCommand(command, input, runtime) {
1320
+ const adapter = {
1321
+ ...runtime,
1322
+ signal: runtime.signal,
1323
+ sleep:
1324
+ runtime.sleep ??
1325
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))),
1326
+ stderr: runtime.stderr ?? process.stderr,
1327
+ };
1328
+ if (command === "storage.external-buckets.list") return listBuckets(input, adapter);
1329
+ if (command === "storage.external-buckets.show") return showBucket(input, adapter);
1330
+ if (command === "storage.external-buckets.connect") return connectBucket(input, adapter);
1331
+ if (command === "storage.external-buckets.update-credentials") {
1332
+ return updateCredentials(input, adapter);
1333
+ }
1334
+ if (command === "storage.external-buckets.verify") return verifyBucket(input, adapter);
1335
+ if (command === "storage.external-buckets.import") return importBucket(input, adapter);
1336
+ if (command === "storage.external-buckets.disconnect") return disconnectBucket(input, adapter);
1337
+ throw new StorageCommandError("Unsupported external bucket command.", {
1338
+ code: "unsupported_capability",
1339
+ exitCode: 2,
1340
+ });
1341
+ }
1342
+
1343
+ export function writeExternalBucketResultHuman(stdout, stderr, capabilityId, execution) {
1344
+ const data = execution.data;
1345
+ if (capabilityId === "storage.external-buckets.list") {
1346
+ const rows = data?.external_buckets ?? [];
1347
+ if (!rows.length) {
1348
+ stdout.write("No external buckets connected.\n");
1349
+ return;
1350
+ }
1351
+ for (const bucket of rows) {
1352
+ stdout.write(
1353
+ `${bucket.name} ${bucket.kind} ${bucket.verification.phase} ${bucket.access.mode} ${bucket.id}\n`
1354
+ );
1355
+ }
1356
+ if (execution.page?.next_cursor) {
1357
+ stdout.write(`Next cursor: ${execution.page.next_cursor}\n`);
1358
+ }
1359
+ return;
1360
+ }
1361
+ if (execution.presentation?.lines) {
1362
+ for (const line of execution.presentation.lines) stdout.write(`${line}\n`);
1363
+ if (execution.presentation.next) stdout.write(`Next: ${execution.presentation.next}\n`);
1364
+ } else if (data?.external_bucket) {
1365
+ const bucket = data.external_bucket;
1366
+ stdout.write(`${bucket.name} (${bucket.id})\n`);
1367
+ stdout.write(`Kind: ${providerName(bucket.kind)}\n`);
1368
+ stdout.write(`Bucket: ${bucket.location.bucket}\n`);
1369
+ if (bucket.location.prefix) stdout.write(`Prefix: ${bucket.location.prefix}\n`);
1370
+ stdout.write(`Access: ${bucket.access.mode}\n`);
1371
+ stdout.write(`Verification: ${bucket.verification.phase}\n`);
1372
+ }
1373
+ for (const error of execution.errors ?? []) stderr.write(`${error.message}\n`);
1374
+ }