@ornncompute/cli 0.2.24 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +298 -426
- package/package.json +12 -6
- package/src/admin-capacity-reservations.mjs +280 -0
- package/src/admin-command-handlers.mjs +57 -0
- package/src/admin-enrollment.mjs +680 -0
- package/src/admin-mutations.mjs +187 -0
- package/src/admin-node-diagnostics.mjs +412 -0
- package/src/admin-node-lifecycle.mjs +908 -0
- package/src/admin-shell.mjs +322 -0
- package/src/api-client.mjs +81 -28
- package/src/atomic-output.mjs +112 -0
- package/src/auth-store.mjs +182 -11
- package/src/capability-catalog.mjs +7 -0
- package/src/catalog-shell.mjs +1350 -0
- package/src/cli.mjs +123 -10266
- package/src/connect-mode-handlers.mjs +170 -0
- package/src/device-auth.mjs +178 -61
- package/src/evidence-commands.mjs +1340 -0
- package/src/external-bucket-commands.mjs +1374 -0
- package/src/interaction-runtime.mjs +310 -0
- package/src/kubernetes-commands.mjs +2229 -0
- package/src/launch-mode-handlers.mjs +119 -0
- package/src/machine-reference.mjs +277 -0
- package/src/mount-commands.mjs +1146 -0
- package/src/session-commands.mjs +565 -0
- package/src/slurm-commands.mjs +263 -0
- package/src/startup.mjs +34 -11
- package/src/storage-commands.mjs +1144 -0
- package/src/update.mjs +172 -24
- package/src/vm-commands.mjs +4273 -0
- package/src/vpn-secret-output.mjs +150 -0
- package/src/wait-runtime.mjs +231 -0
- package/vendor/capabilities/capabilities/admin-capacity-reservations.js +126 -0
- package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
- package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
- package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
- package/vendor/capabilities/capabilities/admin.js +89 -0
- package/vendor/capabilities/capabilities/capacity-offers.js +368 -0
- package/vendor/capabilities/capabilities/cli.js +156 -0
- package/vendor/capabilities/capabilities/clusters.js +150 -1
- package/vendor/capabilities/capabilities/compute.js +522 -0
- package/vendor/capabilities/capabilities/evidence.js +145 -0
- package/vendor/capabilities/capabilities/external-buckets.js +191 -0
- package/vendor/capabilities/capabilities/fleet.js +21 -5
- package/vendor/capabilities/capabilities/identity.js +114 -3
- package/vendor/capabilities/capabilities/listings.js +14 -8
- package/vendor/capabilities/capabilities/mounts.js +176 -0
- package/vendor/capabilities/capabilities/networks.js +1257 -0
- package/vendor/capabilities/capabilities/observability.js +38 -8
- package/vendor/capabilities/capabilities/tokens.js +20 -8
- package/vendor/capabilities/capabilities/volumes.js +178 -0
- package/vendor/capabilities/capability.js +19 -13
- package/vendor/capabilities/cli-catalog.js +65 -0
- package/vendor/capabilities/cli-migrations.js +288 -0
- package/vendor/capabilities/cli.js +7 -0
- package/vendor/capabilities/software-catalog.js +36 -0
- package/vendor/capabilities/spec-catalog.js +2 -2
- package/src/catalog-dispatch.mjs +0 -605
- package/src/color.mjs +0 -71
- package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
- package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
- package/vendor/capabilities/capabilities/identity.d.ts +0 -3
- package/vendor/capabilities/capabilities/listings.d.ts +0 -51
- package/vendor/capabilities/capabilities/observability.d.ts +0 -5
- package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
- package/vendor/capabilities/capabilities/staff-access.js +0 -278
- package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
- package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
- package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
- package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
- package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
- package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
- package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
- package/vendor/capabilities/capabilities/staff-shared.js +0 -98
- package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
- package/vendor/capabilities/capabilities/users.d.ts +0 -2
- package/vendor/capabilities/capability.d.ts +0 -93
- package/vendor/capabilities/catalog.d.ts +0 -3
- package/vendor/capabilities/catalog.js +0 -26
- package/vendor/capabilities/index.d.ts +0 -16
- package/vendor/capabilities/index.js +0 -16
- package/vendor/capabilities/names.d.ts +0 -6
- package/vendor/capabilities/role.d.ts +0 -6
- package/vendor/capabilities/role.js +0 -17
- package/vendor/capabilities/spec-catalog.d.ts +0 -78
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
import { CliApiError, computeEndpoint } from "./api-client.mjs";
|
|
2
|
+
import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
|
|
3
|
+
import { resolveVolume, safeVolume } from "./storage-commands.mjs";
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const DEFAULT_LIMIT = 50;
|
|
7
|
+
const MAX_LIMIT = 500;
|
|
8
|
+
const DEFAULT_MOUNT_TIMEOUT = "30m";
|
|
9
|
+
const TERMINAL_ATTACHMENT_PHASES = new Set(["detached", "ended", "failed"]);
|
|
10
|
+
|
|
11
|
+
function record(value) {
|
|
12
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function text(value) {
|
|
16
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function integer(value) {
|
|
20
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
21
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonical(value, field, interaction) {
|
|
25
|
+
const candidate = text(value);
|
|
26
|
+
if (!candidate || !UUID.test(candidate)) {
|
|
27
|
+
interaction.usage(`--${field} must be one canonical identifier.`, {
|
|
28
|
+
code: "invalid_reference",
|
|
29
|
+
field,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function positionals(input, expected, usage, interaction) {
|
|
36
|
+
const values = Array.isArray(input.$positionals) ? input.$positionals : [];
|
|
37
|
+
if (values.length !== expected) interaction.usage(`Usage: ${usage}`, { code: "usage_error" });
|
|
38
|
+
return values;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function required(value, field, interaction) {
|
|
42
|
+
const candidate = text(value);
|
|
43
|
+
if (!candidate) interaction.usage(`--${field} is required.`, { code: "missing_input", field });
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function accessMode(value, interaction) {
|
|
48
|
+
const candidate = required(value, "access", interaction).toLowerCase();
|
|
49
|
+
if (candidate !== "read-only" && candidate !== "read-write") {
|
|
50
|
+
interaction.usage("--access must be read-only or read-write.", {
|
|
51
|
+
code: "invalid_access_mode",
|
|
52
|
+
field: "access",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function positiveInteger(value, field, interaction) {
|
|
59
|
+
const parsed = integer(value);
|
|
60
|
+
if (parsed === null || parsed < 1) {
|
|
61
|
+
interaction.usage(`--${field} must be a positive integer.`, {
|
|
62
|
+
code: "invalid_input",
|
|
63
|
+
field,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function timeoutMilliseconds(value, interaction, fallback = DEFAULT_MOUNT_TIMEOUT) {
|
|
70
|
+
const candidate = text(value) ?? fallback;
|
|
71
|
+
const match = /^(\d+)(ms|s|m|h)$/.exec(candidate);
|
|
72
|
+
if (!match) {
|
|
73
|
+
interaction.usage("--timeout must be a positive duration such as 30s, 10m, or 1h.", {
|
|
74
|
+
code: "invalid_timeout",
|
|
75
|
+
field: "timeout",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const count = Number(match[1]);
|
|
79
|
+
const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]];
|
|
80
|
+
if (!Number.isSafeInteger(count) || count < 1 || count * scale > 2_147_483_647) {
|
|
81
|
+
interaction.usage("--timeout is outside the supported range.", {
|
|
82
|
+
code: "invalid_timeout",
|
|
83
|
+
field: "timeout",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return count * scale;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requireMachineMutation(input, interaction) {
|
|
90
|
+
if (!interaction.machine) return;
|
|
91
|
+
if (!text(input.idempotencyKey)) {
|
|
92
|
+
interaction.usage("Machine mutations require --idempotency-key <caller-stable-key>.", {
|
|
93
|
+
code: "missing_idempotency_key",
|
|
94
|
+
field: "idempotency-key",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function idempotencyHeaders(input) {
|
|
100
|
+
const key = text(input.idempotencyKey);
|
|
101
|
+
return key ? { "Idempotency-Key": key } : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ownerCode(error) {
|
|
105
|
+
const top = record(error?.detail);
|
|
106
|
+
const detail = record(top.detail ?? top.error ?? top);
|
|
107
|
+
const code = text(detail.code);
|
|
108
|
+
return code && /^[a-z][a-z0-9_]*$/.test(code) ? code : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeFailure(
|
|
112
|
+
interaction,
|
|
113
|
+
error,
|
|
114
|
+
message,
|
|
115
|
+
{ action, fallbackCode = "mount_operation_failed" } = {}
|
|
116
|
+
) {
|
|
117
|
+
if (error?.name === "InteractionError" || error?.name === "StorageCommandError") throw error;
|
|
118
|
+
if (error instanceof CliApiError && (error.status === 401 || error.status === 403)) throw error;
|
|
119
|
+
const code =
|
|
120
|
+
ownerCode(error) ??
|
|
121
|
+
(error instanceof CliApiError && (error.status === undefined || error.status >= 500)
|
|
122
|
+
? "service_unavailable"
|
|
123
|
+
: fallbackCode);
|
|
124
|
+
interaction.error(message, {
|
|
125
|
+
action,
|
|
126
|
+
code,
|
|
127
|
+
exitCode: code === "service_unavailable" ? 6 : 1,
|
|
128
|
+
retryable: code === "service_unavailable",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function reservationAttachmentPath(reservationId) {
|
|
133
|
+
return computeEndpoint(
|
|
134
|
+
`/nodes/reservations/${encodeURIComponent(reservationId)}/storage-attachment`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reservationDeploymentPath(reservationId) {
|
|
139
|
+
return computeEndpoint(
|
|
140
|
+
`/nodes/reservations/${encodeURIComponent(reservationId)}/storage-deployment`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function reservationFilesystemPath(reservationId) {
|
|
145
|
+
return computeEndpoint(
|
|
146
|
+
`/nodes/reservations/${encodeURIComponent(reservationId)}/filesystem-deployment`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function stableAttachmentPhase(value) {
|
|
151
|
+
const state = text(value)?.toLowerCase() ?? "pending";
|
|
152
|
+
if (["attached", "ready"].includes(state)) return state;
|
|
153
|
+
if (["failed", "detached", "detaching"].includes(state)) return state;
|
|
154
|
+
if (["pre_positioning", "provisioning", "mounting", "accepted"].includes(state)) return state;
|
|
155
|
+
return "pending";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stableMachineMountPhase(value) {
|
|
159
|
+
const state = text(value)?.toLowerCase() ?? "pending";
|
|
160
|
+
if (state === "mounted") return "mounted";
|
|
161
|
+
if (["mount_failed", "mount_failed_unverified", "failed", "error"].includes(state))
|
|
162
|
+
return "failed";
|
|
163
|
+
if (["release_pending", "releasing", "detached"].includes(state)) return "detaching";
|
|
164
|
+
if (["reserved", "provisioning", "mount_pending", "mounting", "pending"].includes(state))
|
|
165
|
+
return "pending";
|
|
166
|
+
return "unknown";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function safeMountPath(value) {
|
|
170
|
+
const candidate = text(value);
|
|
171
|
+
return candidate && candidate.startsWith("/") && !/[\p{Cc}]/u.test(candidate) ? candidate : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function projectVolumeMount(payload, requestedReservationId) {
|
|
175
|
+
const source = record(payload);
|
|
176
|
+
const attachment = record(
|
|
177
|
+
source.attachment ??
|
|
178
|
+
source.filesystem_deployment ??
|
|
179
|
+
source.deployment ??
|
|
180
|
+
(source.id ? source : null)
|
|
181
|
+
);
|
|
182
|
+
if (!text(attachment.id)) return null;
|
|
183
|
+
const drive = record(attachment.drive ?? attachment.volume);
|
|
184
|
+
const filesystem = record(drive.nfs_backend ?? attachment.filesystem);
|
|
185
|
+
const rawMounts = Array.isArray(drive.active_mounts)
|
|
186
|
+
? drive.active_mounts
|
|
187
|
+
: drive.active_mount
|
|
188
|
+
? [drive.active_mount]
|
|
189
|
+
: [];
|
|
190
|
+
const machines = rawMounts.map((value) => {
|
|
191
|
+
const mount = record(value);
|
|
192
|
+
const phase = stableMachineMountPhase(mount.state);
|
|
193
|
+
return {
|
|
194
|
+
machine_id: text(mount.node_id ?? mount.instance_id),
|
|
195
|
+
machine_label: text(mount.node_label),
|
|
196
|
+
reservation_id: text(mount.reservation_id) ?? requestedReservationId,
|
|
197
|
+
phase,
|
|
198
|
+
ready: phase === "mounted",
|
|
199
|
+
mount_path: safeMountPath(mount.mount_path ?? attachment.mount_path),
|
|
200
|
+
issue: phase === "failed" ? "The machine did not confirm this volume mount." : null,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
const expected = Math.max(integer(record(attachment.scope).node_count) ?? 0, machines.length);
|
|
204
|
+
const ready = machines.filter((machine) => machine.ready).length;
|
|
205
|
+
const failed = machines.filter((machine) => machine.phase === "failed").length;
|
|
206
|
+
const phase = stableAttachmentPhase(attachment.state);
|
|
207
|
+
const lifecycleClass =
|
|
208
|
+
ready === expected && expected > 0 && ["attached", "ready"].includes(phase)
|
|
209
|
+
? "ready"
|
|
210
|
+
: failed > 0 || phase === "failed"
|
|
211
|
+
? "partial"
|
|
212
|
+
: phase === "detached"
|
|
213
|
+
? "ended"
|
|
214
|
+
: "working";
|
|
215
|
+
return {
|
|
216
|
+
id: text(attachment.id),
|
|
217
|
+
reservation_id: requestedReservationId ?? text(attachment.reservation_id),
|
|
218
|
+
volume: {
|
|
219
|
+
id: text(attachment.drive_id ?? drive.id),
|
|
220
|
+
name: text(drive.name),
|
|
221
|
+
},
|
|
222
|
+
mount_path: safeMountPath(attachment.mount_path),
|
|
223
|
+
access: ["read-only", "read-write"].includes(text(attachment.access_mode)?.toLowerCase())
|
|
224
|
+
? text(attachment.access_mode).toLowerCase()
|
|
225
|
+
: null,
|
|
226
|
+
scope: {
|
|
227
|
+
all_nodes:
|
|
228
|
+
record(attachment.scope).all_nodes === true ||
|
|
229
|
+
record(attachment.scope).origin === "cluster",
|
|
230
|
+
machine_count: expected,
|
|
231
|
+
},
|
|
232
|
+
lifecycle: { class: lifecycleClass, phase },
|
|
233
|
+
filesystem: {
|
|
234
|
+
performance_tier: text(filesystem.performance_tier ?? attachment.performance_tier),
|
|
235
|
+
capacity_gib: integer(filesystem.capacity_gib ?? attachment.capacity_gib),
|
|
236
|
+
phase: text(filesystem.state)?.toLowerCase() ?? null,
|
|
237
|
+
mount_ready: filesystem.mount_ready === true,
|
|
238
|
+
},
|
|
239
|
+
readiness: {
|
|
240
|
+
expected,
|
|
241
|
+
ready,
|
|
242
|
+
failed,
|
|
243
|
+
pending: Math.max(0, expected - ready - failed),
|
|
244
|
+
},
|
|
245
|
+
machines,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function mountSatisfied(mount, goal) {
|
|
250
|
+
return Boolean(
|
|
251
|
+
mount &&
|
|
252
|
+
!["detached", "detaching", "ended"].includes(mount.lifecycle.phase) &&
|
|
253
|
+
mount.volume.id === goal.volumeId &&
|
|
254
|
+
mount.access === goal.access
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function filesystemSatisfied(mount, performanceTier, capacityGib) {
|
|
259
|
+
return Boolean(
|
|
260
|
+
mount &&
|
|
261
|
+
mount.filesystem.performance_tier === performanceTier &&
|
|
262
|
+
mount.filesystem.capacity_gib === capacityGib
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function mountReady(mount) {
|
|
267
|
+
return Boolean(
|
|
268
|
+
mount && mount.readiness.expected > 0 && mount.readiness.ready === mount.readiness.expected
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mountTerminal(mount) {
|
|
273
|
+
return Boolean(
|
|
274
|
+
mount && (mount.readiness.failed > 0 || TERMINAL_ATTACHMENT_PHASES.has(mount.lifecycle.phase))
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function resolvedMountPath(requestedPath, machines) {
|
|
279
|
+
const paths = new Set(
|
|
280
|
+
(Array.isArray(machines) ? machines : [])
|
|
281
|
+
.map((machine) => text(record(machine).mount_path))
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
);
|
|
284
|
+
if (paths.size === 1) return [...paths][0];
|
|
285
|
+
if (paths.size === 0) return text(requestedPath);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function mountLines(mount, reservationId) {
|
|
290
|
+
if (!mount) return [`Capacity reservation ${reservationId} has no volume mount.`];
|
|
291
|
+
const lines = [
|
|
292
|
+
`Volume mount ${mount.id}`,
|
|
293
|
+
` Capacity reservation: ${mount.reservation_id}`,
|
|
294
|
+
` Volume: ${mount.volume.name ?? "Managed volume"} (${mount.volume.id})`,
|
|
295
|
+
` Path: ${resolvedMountPath(mount.mount_path, mount.machines) ?? "per machine"}`,
|
|
296
|
+
` Access: ${mount.access ?? "pending"}`,
|
|
297
|
+
` Ready machines: ${mount.readiness.ready} of ${mount.readiness.expected}`,
|
|
298
|
+
];
|
|
299
|
+
for (const machine of mount.machines) {
|
|
300
|
+
lines.push(
|
|
301
|
+
` ${machine.machine_label ?? machine.machine_id ?? "machine"}: ${machine.phase}${machine.mount_path ? ` at ${machine.mount_path}` : ""}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return lines;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function mountResult(interaction, mount, reservationId, { replayed = false } = {}) {
|
|
308
|
+
if (!mount) {
|
|
309
|
+
return interaction.result({
|
|
310
|
+
data: { reservation_id: reservationId, volume_mount: null, replayed },
|
|
311
|
+
outcome: { class: "ended", phase: "not_attached" },
|
|
312
|
+
presentation: { lines: mountLines(null, reservationId) },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const partial = mount.readiness.failed > 0 || mount.lifecycle.class === "partial";
|
|
316
|
+
return interaction.result({
|
|
317
|
+
data: { reservation_id: reservationId, volume_mount: mount, replayed },
|
|
318
|
+
errors: partial
|
|
319
|
+
? [
|
|
320
|
+
{
|
|
321
|
+
code: "volume_mount_partial",
|
|
322
|
+
message: "One or more machines did not confirm the requested volume mount.",
|
|
323
|
+
retryable: true,
|
|
324
|
+
},
|
|
325
|
+
]
|
|
326
|
+
: [],
|
|
327
|
+
exitCode: partial ? 4 : 0,
|
|
328
|
+
outcome: partial ? { class: "partial", phase: "machine_mounts" } : mount.lifecycle,
|
|
329
|
+
resume: { kind: "volume_mount", ref: reservationId },
|
|
330
|
+
action: partial
|
|
331
|
+
? {
|
|
332
|
+
command: `ornn volume-mounts wait ${reservationId} --timeout ${DEFAULT_MOUNT_TIMEOUT}`,
|
|
333
|
+
description: "Resume the exact per-machine mount wait.",
|
|
334
|
+
type: "command",
|
|
335
|
+
}
|
|
336
|
+
: null,
|
|
337
|
+
presentation: {
|
|
338
|
+
lines: mountLines(mount, reservationId),
|
|
339
|
+
...(partial
|
|
340
|
+
? { next: `ornn volume-mounts wait ${reservationId} --timeout ${DEFAULT_MOUNT_TIMEOUT}` }
|
|
341
|
+
: {}),
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The attachment payload carries no reservation machine count, so a mount
|
|
347
|
+
// still short of its per-machine rows has no denominator of its own.
|
|
348
|
+
async function reservationMachineCount(runtime, reservationId, signal) {
|
|
349
|
+
const cache = (runtime.machineCounts ??= new Map());
|
|
350
|
+
if (cache.has(reservationId)) return cache.get(reservationId);
|
|
351
|
+
let count = 0;
|
|
352
|
+
try {
|
|
353
|
+
const rows = candidateRows(
|
|
354
|
+
await runtime.fetch("/v1/cli/storage-targets", {
|
|
355
|
+
method: "GET",
|
|
356
|
+
...(signal ? { signal } : {}),
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
const row = rows.find(
|
|
360
|
+
(candidate) =>
|
|
361
|
+
candidate.id === reservationId || candidate.reservation_ids.includes(reservationId)
|
|
362
|
+
);
|
|
363
|
+
count = row?.machine_count ?? 0;
|
|
364
|
+
} catch {
|
|
365
|
+
// A missing denominator must never fail the mount it describes.
|
|
366
|
+
}
|
|
367
|
+
cache.set(reservationId, count);
|
|
368
|
+
return count;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function withMachineCount(runtime, mount, reservationId, signal) {
|
|
372
|
+
if (!mount || mount.readiness.expected > 0) return mount;
|
|
373
|
+
const expected = await reservationMachineCount(runtime, reservationId, signal);
|
|
374
|
+
if (expected < 1) return mount;
|
|
375
|
+
mount.readiness.expected = expected;
|
|
376
|
+
mount.readiness.pending = Math.max(0, expected - mount.readiness.ready - mount.readiness.failed);
|
|
377
|
+
mount.scope.machine_count = expected;
|
|
378
|
+
return mount;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function fetchMount(runtime, reservationId, { preserveFailure = false, signal } = {}) {
|
|
382
|
+
try {
|
|
383
|
+
const payload = await runtime.fetch(reservationAttachmentPath(reservationId), {
|
|
384
|
+
method: "GET",
|
|
385
|
+
...(signal ? { signal } : {}),
|
|
386
|
+
});
|
|
387
|
+
return withMachineCount(
|
|
388
|
+
runtime,
|
|
389
|
+
projectVolumeMount(payload, reservationId),
|
|
390
|
+
reservationId,
|
|
391
|
+
signal
|
|
392
|
+
);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error instanceof CliApiError && error.status === 404) return null;
|
|
395
|
+
if (preserveFailure) throw error;
|
|
396
|
+
safeFailure(
|
|
397
|
+
runtime.interaction,
|
|
398
|
+
error,
|
|
399
|
+
"The volume-mount relationship could not be loaded safely."
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function shouldWait(input, interaction) {
|
|
405
|
+
if (input.noWait && input.wait) {
|
|
406
|
+
interaction.usage("Choose only one of --wait or --no-wait.", { code: "usage_error" });
|
|
407
|
+
}
|
|
408
|
+
if (interaction.machine && input.wait && !text(input.timeout)) {
|
|
409
|
+
interaction.usage("Machine waiting requires --timeout <duration>.", {
|
|
410
|
+
code: "missing_timeout",
|
|
411
|
+
field: "timeout",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (!input.wait && text(input.timeout)) {
|
|
415
|
+
interaction.usage("--timeout requires --wait for a mutation command.", {
|
|
416
|
+
code: "usage_error",
|
|
417
|
+
field: "timeout",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return input.noWait
|
|
421
|
+
? false
|
|
422
|
+
: input.wait
|
|
423
|
+
? true
|
|
424
|
+
: interaction.eligibleHuman && !interaction.machine;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function waitForMount(input, runtime, reservationId, initial, command) {
|
|
428
|
+
const timeout = text(input.timeout) ?? DEFAULT_MOUNT_TIMEOUT;
|
|
429
|
+
const next = `ornn volume-mounts wait ${reservationId} --timeout ${timeout}`;
|
|
430
|
+
let outcome;
|
|
431
|
+
try {
|
|
432
|
+
outcome = await runtime.interaction.waitForGoal({
|
|
433
|
+
command,
|
|
434
|
+
initial,
|
|
435
|
+
timeoutMs: timeoutMilliseconds(timeout, runtime.interaction),
|
|
436
|
+
isReady: mountReady,
|
|
437
|
+
isTerminal: mountTerminal,
|
|
438
|
+
message: (mount, elapsedMs) =>
|
|
439
|
+
mountReady(mount)
|
|
440
|
+
? `Every requested machine has mounted the volume.`
|
|
441
|
+
: `Volume mount has ${mount?.readiness.ready ?? 0} of ${mount?.readiness.expected ?? 0} machines ready (${Math.floor(elapsedMs / 1000)}s).`,
|
|
442
|
+
projectProgress: (mount) => ({ reservation_id: reservationId, volume_mount: mount }),
|
|
443
|
+
poll: ({ signal }) => fetchMount(runtime, reservationId, { preserveFailure: true, signal }),
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
const status = Number(error?.status);
|
|
447
|
+
if (!isRecoverableWaitFailure(error)) throw error;
|
|
448
|
+
const authorization = status === 401 || status === 403;
|
|
449
|
+
const authentication = status === 401;
|
|
450
|
+
const action = authorization
|
|
451
|
+
? {
|
|
452
|
+
command: "ornn login",
|
|
453
|
+
description: "Restore an authorized session, then resume the exact accepted mount.",
|
|
454
|
+
type: "command",
|
|
455
|
+
}
|
|
456
|
+
: { command: next, description: "Retry the exact accepted mount wait.", type: "command" };
|
|
457
|
+
return runtime.interaction.result({
|
|
458
|
+
action,
|
|
459
|
+
data: {
|
|
460
|
+
remote_work_cancelled: false,
|
|
461
|
+
replayed: false,
|
|
462
|
+
reservation_id: reservationId,
|
|
463
|
+
volume_mount: initial,
|
|
464
|
+
},
|
|
465
|
+
errors: [
|
|
466
|
+
{
|
|
467
|
+
code: authentication
|
|
468
|
+
? "authentication_required"
|
|
469
|
+
: authorization
|
|
470
|
+
? "authorization_denied"
|
|
471
|
+
: "service_unavailable",
|
|
472
|
+
message: authorization
|
|
473
|
+
? "The session could not continue waiting; the accepted remote mount continues."
|
|
474
|
+
: "The local wait lost contact with the service; the accepted remote mount continues.",
|
|
475
|
+
retryable: status !== 403,
|
|
476
|
+
resource_ref: reservationId,
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
exitCode: authorization ? 5 : 6,
|
|
480
|
+
outcome: initial?.lifecycle ?? { class: "working", phase: "mounting" },
|
|
481
|
+
resume: { kind: "volume_mount", ref: reservationId },
|
|
482
|
+
presentation: {
|
|
483
|
+
lines: ["The accepted remote mount continues; no remote cancellation was sent."],
|
|
484
|
+
next: action.command,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (outcome.kind === "ready" || outcome.kind === "terminal") {
|
|
489
|
+
return mountResult(runtime.interaction, outcome.value, reservationId);
|
|
490
|
+
}
|
|
491
|
+
const interrupted = outcome.kind === "interrupted";
|
|
492
|
+
const mount = outcome.value;
|
|
493
|
+
return runtime.interaction.result({
|
|
494
|
+
action: { command: next, description: "Resume the exact local mount wait.", type: "command" },
|
|
495
|
+
data: { reservation_id: reservationId, volume_mount: mount, replayed: false },
|
|
496
|
+
errors: [
|
|
497
|
+
{
|
|
498
|
+
code: interrupted ? "interrupted" : "wait_timeout",
|
|
499
|
+
message: interrupted
|
|
500
|
+
? "Local waiting stopped; the remote mount continues."
|
|
501
|
+
: "Local waiting timed out; the remote mount continues.",
|
|
502
|
+
retryable: true,
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
exitCode: interrupted ? 130 : 124,
|
|
506
|
+
outcome: mount?.lifecycle ?? { class: "working", phase: "mounting" },
|
|
507
|
+
resume: { kind: "volume_mount", ref: reservationId },
|
|
508
|
+
presentation: {
|
|
509
|
+
lines: [
|
|
510
|
+
...mountLines(mount, reservationId),
|
|
511
|
+
interrupted
|
|
512
|
+
? "Local waiting stopped; no remote cancellation was sent."
|
|
513
|
+
: "Local waiting timed out; no remote cancellation was sent.",
|
|
514
|
+
],
|
|
515
|
+
next,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function candidateRows(payload) {
|
|
521
|
+
const source = record(payload);
|
|
522
|
+
const rows = Array.isArray(payload)
|
|
523
|
+
? payload
|
|
524
|
+
: Array.isArray(source.targets)
|
|
525
|
+
? source.targets
|
|
526
|
+
: [];
|
|
527
|
+
return rows
|
|
528
|
+
.flatMap((value) => {
|
|
529
|
+
const target = record(value);
|
|
530
|
+
const id = text(target.id);
|
|
531
|
+
if (!id) return [];
|
|
532
|
+
const reservationIds = Array.isArray(target.reservationIds ?? target.reservation_ids)
|
|
533
|
+
? (target.reservationIds ?? target.reservation_ids).filter(
|
|
534
|
+
(item) => typeof item === "string"
|
|
535
|
+
)
|
|
536
|
+
: [];
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
id,
|
|
540
|
+
label: machineWording(text(target.label)),
|
|
541
|
+
detail: text(target.detail),
|
|
542
|
+
facility: facilityLabel(target.facilityLabel ?? target.facility_label),
|
|
543
|
+
all_nodes: target.allNodes === true || target.all_nodes === true,
|
|
544
|
+
machine_count: integer(target.nodeCount ?? target.node_count) ?? 0,
|
|
545
|
+
reservation_ids: reservationIds,
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
})
|
|
549
|
+
.sort((left, right) => (left.label ?? left.id).localeCompare(right.label ?? right.id));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function machineWording(value) {
|
|
553
|
+
return value?.replace(/\bnodes\b/g, "machines").replace(/\bnode\b/g, "machine") ?? null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function facilityLabel(value) {
|
|
557
|
+
const candidate = text(value);
|
|
558
|
+
return candidate === "\u2014" ? null : candidate;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function cursorOffset(cursor, interaction) {
|
|
562
|
+
if (!text(cursor)) return 0;
|
|
563
|
+
try {
|
|
564
|
+
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
565
|
+
if (
|
|
566
|
+
decoded.kind === "volume-mount-candidates" &&
|
|
567
|
+
Number.isSafeInteger(decoded.offset) &&
|
|
568
|
+
decoded.offset >= 0
|
|
569
|
+
) {
|
|
570
|
+
return decoded.offset;
|
|
571
|
+
}
|
|
572
|
+
} catch {}
|
|
573
|
+
interaction.usage("The volume-mount candidate cursor is invalid or stale.", {
|
|
574
|
+
code: "invalid_cursor",
|
|
575
|
+
field: "cursor",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function pageLimit(value, interaction) {
|
|
580
|
+
if (value === undefined) return DEFAULT_LIMIT;
|
|
581
|
+
const limit = integer(value);
|
|
582
|
+
if (limit === null || limit < 1 || limit > MAX_LIMIT) {
|
|
583
|
+
interaction.usage("--limit must be an integer from 1 through 500.", {
|
|
584
|
+
code: "invalid_limit",
|
|
585
|
+
field: "limit",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return limit;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function candidates(input, runtime) {
|
|
592
|
+
positionals(
|
|
593
|
+
input,
|
|
594
|
+
0,
|
|
595
|
+
"ornn volume-mounts candidates [--limit <1-500>] [--cursor <cursor>]",
|
|
596
|
+
runtime.interaction
|
|
597
|
+
);
|
|
598
|
+
const rows = candidateRows(await runtime.fetch("/v1/cli/storage-targets", { method: "GET" }));
|
|
599
|
+
const offset = cursorOffset(input.cursor, runtime.interaction);
|
|
600
|
+
const limit = pageLimit(input.limit, runtime.interaction);
|
|
601
|
+
if (offset > rows.length) {
|
|
602
|
+
runtime.interaction.usage("The volume-mount candidate cursor is invalid or stale.", {
|
|
603
|
+
code: "invalid_cursor",
|
|
604
|
+
field: "cursor",
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const selected = rows.slice(offset, offset + limit);
|
|
608
|
+
const next =
|
|
609
|
+
offset + limit < rows.length
|
|
610
|
+
? Buffer.from(
|
|
611
|
+
JSON.stringify({ kind: "volume-mount-candidates", offset: offset + limit })
|
|
612
|
+
).toString("base64url")
|
|
613
|
+
: null;
|
|
614
|
+
return runtime.interaction.result({
|
|
615
|
+
data: { candidates: selected },
|
|
616
|
+
page: { limit, returned: selected.length, next_cursor: next },
|
|
617
|
+
outcome: { class: "ready", phase: selected.length ? "listed" : "empty" },
|
|
618
|
+
presentation: {
|
|
619
|
+
lines: selected.length
|
|
620
|
+
? selected.map(
|
|
621
|
+
(target) =>
|
|
622
|
+
`${target.label ?? `${target.machine_count} machines`} (${target.id})${target.facility ? ` in ${target.facility}` : ""}`
|
|
623
|
+
)
|
|
624
|
+
: ["No volume-mount candidates found."],
|
|
625
|
+
...(next ? { next: `ornn volume-mounts candidates --cursor ${next}` } : {}),
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function show(input, runtime, command) {
|
|
631
|
+
const [rawReservation] = positionals(
|
|
632
|
+
input,
|
|
633
|
+
1,
|
|
634
|
+
`ornn ${command} <canonical-reservation-id>`,
|
|
635
|
+
runtime.interaction
|
|
636
|
+
);
|
|
637
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
638
|
+
return mountResult(runtime.interaction, await fetchMount(runtime, reservationId), reservationId);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function attachVolumeMount(input, runtime, { skipReview = false } = {}) {
|
|
642
|
+
const [rawReservation] = positionals(
|
|
643
|
+
input,
|
|
644
|
+
1,
|
|
645
|
+
"ornn volume-mounts attach <canonical-reservation-id> --volume <exact-name-or-id> --access read-only|read-write",
|
|
646
|
+
runtime.interaction
|
|
647
|
+
);
|
|
648
|
+
requireMachineMutation(input, runtime.interaction);
|
|
649
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
650
|
+
if (
|
|
651
|
+
runtime.interaction.machine &&
|
|
652
|
+
!UUID.test(required(input.volume, "volume", runtime.interaction))
|
|
653
|
+
) {
|
|
654
|
+
runtime.interaction.usage("Automation must use a canonical managed-volume identifier.", {
|
|
655
|
+
code: "invalid_reference",
|
|
656
|
+
field: "volume",
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
const access = accessMode(input.access, runtime.interaction);
|
|
660
|
+
const wait = shouldWait(input, runtime.interaction);
|
|
661
|
+
const volume = await resolveVolume(runtime, input.volume);
|
|
662
|
+
const projectedVolume = safeVolume(volume);
|
|
663
|
+
const goal = { volumeId: projectedVolume.id, access };
|
|
664
|
+
let current = await fetchMount(runtime, reservationId);
|
|
665
|
+
if (current && ["detached", "ended"].includes(current.lifecycle.phase)) current = null;
|
|
666
|
+
if (mountSatisfied(current, goal)) {
|
|
667
|
+
return wait && !mountReady(current)
|
|
668
|
+
? waitForMount(input, runtime, reservationId, current, "volume-mounts attach")
|
|
669
|
+
: mountResult(runtime.interaction, current, reservationId, { replayed: true });
|
|
670
|
+
}
|
|
671
|
+
if (current) {
|
|
672
|
+
runtime.interaction.error(
|
|
673
|
+
"This capacity reservation already has a different volume-mount relationship.",
|
|
674
|
+
{
|
|
675
|
+
action: {
|
|
676
|
+
command: `ornn volume-mounts detach ${reservationId}`,
|
|
677
|
+
description:
|
|
678
|
+
"Review and detach the existing relationship before choosing a different mount.",
|
|
679
|
+
type: "command",
|
|
680
|
+
},
|
|
681
|
+
code: "volume_mount_conflict",
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (!skipReview) {
|
|
686
|
+
const accepted = await runtime.interaction.review({
|
|
687
|
+
title: "Attach managed volume",
|
|
688
|
+
terms: [
|
|
689
|
+
{ label: "Capacity reservation", value: reservationId },
|
|
690
|
+
{ label: "Managed volume", value: `${projectedVolume.name} (${projectedVolume.id})` },
|
|
691
|
+
{ label: "Access", value: access },
|
|
692
|
+
{ label: "Machine scope", value: "Every machine in the reservation" },
|
|
693
|
+
{
|
|
694
|
+
label: "Volume lifecycle",
|
|
695
|
+
value: "The volume remains independent from this relationship",
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
prompt: "Attach this volume?",
|
|
699
|
+
});
|
|
700
|
+
if (!accepted) {
|
|
701
|
+
return runtime.interaction.result({
|
|
702
|
+
errors: [{ code: "declined", message: "Volume attachment declined.", retryable: false }],
|
|
703
|
+
exitCode: 1,
|
|
704
|
+
outcome: { class: "ended", phase: "declined" },
|
|
705
|
+
presentation: { lines: ["Volume attachment declined. No relationship was created."] },
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
const refreshedVolume = safeVolume(await resolveVolume(runtime, projectedVolume.id));
|
|
709
|
+
if (
|
|
710
|
+
refreshedVolume.id !== projectedVolume.id ||
|
|
711
|
+
refreshedVolume.name !== projectedVolume.name
|
|
712
|
+
) {
|
|
713
|
+
runtime.interaction.error("The selected managed volume changed after review.", {
|
|
714
|
+
code: "volume_selection_changed",
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
current = await fetchMount(runtime, reservationId);
|
|
718
|
+
if (current && ["detached", "ended"].includes(current.lifecycle.phase)) current = null;
|
|
719
|
+
if (mountSatisfied(current, goal)) {
|
|
720
|
+
return wait && !mountReady(current)
|
|
721
|
+
? waitForMount(input, runtime, reservationId, current, "volume-mounts attach")
|
|
722
|
+
: mountResult(runtime.interaction, current, reservationId, { replayed: true });
|
|
723
|
+
}
|
|
724
|
+
if (current) {
|
|
725
|
+
runtime.interaction.error("The volume-mount relationship changed after review.", {
|
|
726
|
+
code: "volume_mount_conflict",
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const payload = await runtime.fetch(reservationDeploymentPath(reservationId), {
|
|
733
|
+
method: "POST",
|
|
734
|
+
headers: idempotencyHeaders(input),
|
|
735
|
+
body: {
|
|
736
|
+
drive_id: projectedVolume.id,
|
|
737
|
+
mount_mode: "fuse",
|
|
738
|
+
access_mode: access,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
current = await withMachineCount(
|
|
742
|
+
runtime,
|
|
743
|
+
projectVolumeMount(payload, reservationId),
|
|
744
|
+
reservationId
|
|
745
|
+
);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error instanceof CliApiError && (error.status === undefined || error.status >= 500)) {
|
|
748
|
+
try {
|
|
749
|
+
current = await fetchMount(runtime, reservationId);
|
|
750
|
+
if (!mountSatisfied(current, goal)) throw error;
|
|
751
|
+
} catch {
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
safeFailure(runtime.interaction, error, "The volume could not be attached safely.", {
|
|
756
|
+
action: {
|
|
757
|
+
command: `ornn volume-mounts show ${reservationId}`,
|
|
758
|
+
description: "Inspect the exact relationship before retrying.",
|
|
759
|
+
type: "command",
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (!mountSatisfied(current, goal)) {
|
|
765
|
+
runtime.interaction.error("The accepted volume mount did not match the reviewed goal.", {
|
|
766
|
+
code: "volume_mount_goal_mismatch",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return wait && !mountReady(current)
|
|
770
|
+
? waitForMount(input, runtime, reservationId, current, "volume-mounts attach")
|
|
771
|
+
: mountResult(runtime.interaction, current, reservationId);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function waitCommand(input, runtime, command) {
|
|
775
|
+
const [rawReservation] = positionals(
|
|
776
|
+
input,
|
|
777
|
+
1,
|
|
778
|
+
`ornn ${command} <canonical-reservation-id> --timeout <duration>`,
|
|
779
|
+
runtime.interaction
|
|
780
|
+
);
|
|
781
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
782
|
+
if (runtime.interaction.machine && !text(input.timeout)) {
|
|
783
|
+
runtime.interaction.usage("Machine waiting requires --timeout <duration>.", {
|
|
784
|
+
code: "missing_timeout",
|
|
785
|
+
field: "timeout",
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const initial = await fetchMount(runtime, reservationId);
|
|
789
|
+
if (!initial) {
|
|
790
|
+
runtime.interaction.error(
|
|
791
|
+
"No volume-mount relationship exists for this capacity reservation.",
|
|
792
|
+
{
|
|
793
|
+
code: "volume_mount_not_found",
|
|
794
|
+
}
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
return waitForMount(
|
|
798
|
+
{ ...input, timeout: text(input.timeout) ?? DEFAULT_MOUNT_TIMEOUT },
|
|
799
|
+
runtime,
|
|
800
|
+
reservationId,
|
|
801
|
+
initial,
|
|
802
|
+
command
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export async function detachVolumeMount(input, runtime, { skipReview = false } = {}) {
|
|
807
|
+
const [rawReservation] = positionals(
|
|
808
|
+
input,
|
|
809
|
+
1,
|
|
810
|
+
"ornn volume-mounts detach <canonical-reservation-id>",
|
|
811
|
+
runtime.interaction
|
|
812
|
+
);
|
|
813
|
+
requireMachineMutation(input, runtime.interaction);
|
|
814
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
815
|
+
let current = await fetchMount(runtime, reservationId);
|
|
816
|
+
if (!current || current.lifecycle.phase === "detached") {
|
|
817
|
+
return runtime.interaction.result({
|
|
818
|
+
data: {
|
|
819
|
+
reservation_id: reservationId,
|
|
820
|
+
volume_mount: current,
|
|
821
|
+
replayed: true,
|
|
822
|
+
volume_preserved: current?.volume ?? null,
|
|
823
|
+
},
|
|
824
|
+
outcome: { class: "ended", phase: "detached" },
|
|
825
|
+
presentation: {
|
|
826
|
+
lines: [
|
|
827
|
+
`Capacity reservation ${reservationId} is already detached; no volume was deleted.`,
|
|
828
|
+
],
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (!skipReview) {
|
|
833
|
+
const accepted = await runtime.interaction.review({
|
|
834
|
+
title: "Detach managed volume",
|
|
835
|
+
terms: [
|
|
836
|
+
{ label: "Capacity reservation", value: reservationId },
|
|
837
|
+
{
|
|
838
|
+
label: "Managed volume",
|
|
839
|
+
value: `${current.volume.name ?? "Managed volume"} (${current.volume.id})`,
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
label: "Mounted machines",
|
|
843
|
+
value: `${current.readiness.ready} of ${current.readiness.expected}`,
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
label: "Consequence",
|
|
847
|
+
value: "Mounted access ends; the managed volume and its data remain",
|
|
848
|
+
},
|
|
849
|
+
],
|
|
850
|
+
prompt: "Detach this volume?",
|
|
851
|
+
});
|
|
852
|
+
if (!accepted) {
|
|
853
|
+
return runtime.interaction.result({
|
|
854
|
+
errors: [{ code: "declined", message: "Volume detachment declined.", retryable: false }],
|
|
855
|
+
exitCode: 1,
|
|
856
|
+
outcome: { class: "ended", phase: "declined" },
|
|
857
|
+
presentation: { lines: ["Volume detachment declined. No relationship was changed."] },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
const refreshed = await fetchMount(runtime, reservationId);
|
|
861
|
+
if (!refreshed || refreshed.id !== current.id || refreshed.volume.id !== current.volume.id) {
|
|
862
|
+
runtime.interaction.error("The volume-mount relationship changed after review.", {
|
|
863
|
+
code: "volume_mount_changed",
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
current = refreshed;
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
await runtime.fetch(reservationAttachmentPath(reservationId), {
|
|
870
|
+
method: "DELETE",
|
|
871
|
+
headers: idempotencyHeaders(input),
|
|
872
|
+
});
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (error instanceof CliApiError && (error.status === undefined || error.status >= 500)) {
|
|
875
|
+
const reconciled = await fetchMount(runtime, reservationId);
|
|
876
|
+
if (reconciled && reconciled.lifecycle.phase !== "detached") throw error;
|
|
877
|
+
} else {
|
|
878
|
+
safeFailure(runtime.interaction, error, "The volume could not be detached safely.");
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return runtime.interaction.result({
|
|
882
|
+
data: {
|
|
883
|
+
reservation_id: reservationId,
|
|
884
|
+
volume_mount: { ...current, lifecycle: { class: "ended", phase: "detaching" } },
|
|
885
|
+
replayed: false,
|
|
886
|
+
volume_preserved: current.volume,
|
|
887
|
+
},
|
|
888
|
+
outcome: { class: "working", phase: "detaching" },
|
|
889
|
+
resume: { kind: "volume_mount", ref: reservationId },
|
|
890
|
+
presentation: {
|
|
891
|
+
lines: [
|
|
892
|
+
`Volume detachment accepted for ${reservationId}.`,
|
|
893
|
+
`Managed volume ${current.volume.name ?? current.volume.id} was preserved.`,
|
|
894
|
+
],
|
|
895
|
+
next: `ornn volume-mounts wait ${reservationId} --timeout ${DEFAULT_MOUNT_TIMEOUT}`,
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function deployFilesystem(input, runtime) {
|
|
901
|
+
const [rawReservation] = positionals(
|
|
902
|
+
input,
|
|
903
|
+
1,
|
|
904
|
+
"ornn filesystems deploy <canonical-reservation-id> --performance-tier <tier> --capacity-gib <gib>",
|
|
905
|
+
runtime.interaction
|
|
906
|
+
);
|
|
907
|
+
requireMachineMutation(input, runtime.interaction);
|
|
908
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
909
|
+
const performanceTier = required(input.performanceTier, "performance-tier", runtime.interaction);
|
|
910
|
+
const capacityGib = positiveInteger(input.capacityGib, "capacity-gib", runtime.interaction);
|
|
911
|
+
const wait = shouldWait(input, runtime.interaction);
|
|
912
|
+
let current = await fetchMount(runtime, reservationId);
|
|
913
|
+
if (current && ["detached", "ended"].includes(current.lifecycle.phase)) current = null;
|
|
914
|
+
if (current) {
|
|
915
|
+
const sameGoal = filesystemSatisfied(current, performanceTier, capacityGib);
|
|
916
|
+
if (!sameGoal) {
|
|
917
|
+
runtime.interaction.error(
|
|
918
|
+
"This capacity reservation already has a different managed filesystem relationship.",
|
|
919
|
+
{
|
|
920
|
+
action: {
|
|
921
|
+
command: `ornn filesystems show ${reservationId}`,
|
|
922
|
+
description:
|
|
923
|
+
"Inspect the current deployment before changing its exact capacity or performance goal.",
|
|
924
|
+
type: "command",
|
|
925
|
+
},
|
|
926
|
+
code: "filesystem_deployment_conflict",
|
|
927
|
+
}
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
return wait && !mountReady(current)
|
|
931
|
+
? waitForMount(input, runtime, reservationId, current, "filesystems deploy")
|
|
932
|
+
: mountResult(runtime.interaction, current, reservationId, { replayed: true });
|
|
933
|
+
}
|
|
934
|
+
const accepted = await runtime.interaction.review({
|
|
935
|
+
title: "Deploy managed filesystem",
|
|
936
|
+
terms: [
|
|
937
|
+
{ label: "Capacity reservation", value: reservationId },
|
|
938
|
+
{ label: "Performance tier", value: performanceTier },
|
|
939
|
+
{ label: "Capacity", value: `${capacityGib} GiB` },
|
|
940
|
+
{ label: "Readiness", value: "Reported independently for every machine" },
|
|
941
|
+
],
|
|
942
|
+
prompt: "Deploy this filesystem?",
|
|
943
|
+
});
|
|
944
|
+
if (!accepted) {
|
|
945
|
+
return runtime.interaction.result({
|
|
946
|
+
errors: [{ code: "declined", message: "Filesystem deployment declined.", retryable: false }],
|
|
947
|
+
exitCode: 1,
|
|
948
|
+
outcome: { class: "ended", phase: "declined" },
|
|
949
|
+
presentation: { lines: ["Filesystem deployment declined. No deployment was created."] },
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
current = await fetchMount(runtime, reservationId);
|
|
953
|
+
if (current && ["detached", "ended"].includes(current.lifecycle.phase)) current = null;
|
|
954
|
+
if (current && !filesystemSatisfied(current, performanceTier, capacityGib)) {
|
|
955
|
+
runtime.interaction.error("The managed filesystem relationship changed after review.", {
|
|
956
|
+
code: "filesystem_deployment_conflict",
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
if (!current) {
|
|
960
|
+
try {
|
|
961
|
+
current = await withMachineCount(
|
|
962
|
+
runtime,
|
|
963
|
+
projectVolumeMount(
|
|
964
|
+
await runtime.fetch(reservationFilesystemPath(reservationId), {
|
|
965
|
+
method: "POST",
|
|
966
|
+
headers: idempotencyHeaders(input),
|
|
967
|
+
body: { performance_tier: performanceTier, capacity_gib: capacityGib },
|
|
968
|
+
}),
|
|
969
|
+
reservationId
|
|
970
|
+
),
|
|
971
|
+
reservationId
|
|
972
|
+
);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (error instanceof CliApiError && (error.status === undefined || error.status >= 500)) {
|
|
975
|
+
current = await fetchMount(runtime, reservationId);
|
|
976
|
+
if (!filesystemSatisfied(current, performanceTier, capacityGib)) throw error;
|
|
977
|
+
} else {
|
|
978
|
+
safeFailure(runtime.interaction, error, "The filesystem could not be deployed safely.");
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!filesystemSatisfied(current, performanceTier, capacityGib)) {
|
|
983
|
+
runtime.interaction.error(
|
|
984
|
+
"The accepted filesystem deployment did not match the reviewed goal.",
|
|
985
|
+
{
|
|
986
|
+
code: "filesystem_deployment_goal_mismatch",
|
|
987
|
+
}
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return wait && !mountReady(current)
|
|
991
|
+
? waitForMount(input, runtime, reservationId, current, "filesystems deploy")
|
|
992
|
+
: mountResult(runtime.interaction, current, reservationId);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function filesystemExecution(result) {
|
|
996
|
+
if (!result?.data || !Object.hasOwn(result.data, "volume_mount")) return result;
|
|
997
|
+
const { volume_mount: filesystemDeployment, ...data } = result.data;
|
|
998
|
+
return {
|
|
999
|
+
...result,
|
|
1000
|
+
data: {
|
|
1001
|
+
...data,
|
|
1002
|
+
filesystem_deployment: filesystemDeployment,
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function deleteFilesystem(input, runtime) {
|
|
1008
|
+
const [rawReservation] = positionals(
|
|
1009
|
+
input,
|
|
1010
|
+
1,
|
|
1011
|
+
"ornn filesystems delete <canonical-reservation-id>",
|
|
1012
|
+
runtime.interaction
|
|
1013
|
+
);
|
|
1014
|
+
requireMachineMutation(input, runtime.interaction);
|
|
1015
|
+
const reservationId = canonical(rawReservation, "reservation", runtime.interaction);
|
|
1016
|
+
const current = await fetchMount(runtime, reservationId);
|
|
1017
|
+
if (!current || ["detached", "ended"].includes(current.lifecycle.phase)) {
|
|
1018
|
+
return runtime.interaction.result({
|
|
1019
|
+
data: {
|
|
1020
|
+
filesystem_deployment: current,
|
|
1021
|
+
replayed: true,
|
|
1022
|
+
reservation_id: reservationId,
|
|
1023
|
+
volume_preserved: current?.volume ?? null,
|
|
1024
|
+
},
|
|
1025
|
+
outcome: { class: "ended", phase: "deleted" },
|
|
1026
|
+
presentation: {
|
|
1027
|
+
lines: [
|
|
1028
|
+
`Capacity reservation ${reservationId} already has no managed filesystem deployment.`,
|
|
1029
|
+
],
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const accepted = await runtime.interaction.review({
|
|
1034
|
+
title: "Delete managed filesystem deployment",
|
|
1035
|
+
terms: [
|
|
1036
|
+
{ label: "Capacity reservation", value: reservationId },
|
|
1037
|
+
{
|
|
1038
|
+
label: "Managed volume",
|
|
1039
|
+
value: `${current.volume.name ?? "Managed volume"} (${current.volume.id})`,
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
label: "Mounted machines",
|
|
1043
|
+
value: `${current.readiness.ready} of ${current.readiness.expected}`,
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
label: "Consequence",
|
|
1047
|
+
value: "Filesystem access ends; the managed volume and its data remain",
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
prompt: "Delete this filesystem deployment?",
|
|
1051
|
+
});
|
|
1052
|
+
if (!accepted) {
|
|
1053
|
+
return runtime.interaction.result({
|
|
1054
|
+
data: {
|
|
1055
|
+
filesystem_deployment: current,
|
|
1056
|
+
reservation_id: reservationId,
|
|
1057
|
+
volume_preserved: current.volume,
|
|
1058
|
+
},
|
|
1059
|
+
errors: [{ code: "declined", message: "Filesystem deletion declined.", retryable: false }],
|
|
1060
|
+
exitCode: 1,
|
|
1061
|
+
outcome: { class: "ended", phase: "declined" },
|
|
1062
|
+
presentation: { lines: ["Filesystem deletion declined. No relationship was changed."] },
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
const refreshed = await fetchMount(runtime, reservationId);
|
|
1066
|
+
if (!refreshed || refreshed.id !== current.id || refreshed.volume.id !== current.volume.id) {
|
|
1067
|
+
runtime.interaction.error("The managed filesystem relationship changed after review.", {
|
|
1068
|
+
code: "filesystem_deployment_changed",
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
let deleting;
|
|
1072
|
+
try {
|
|
1073
|
+
deleting = await withMachineCount(
|
|
1074
|
+
runtime,
|
|
1075
|
+
projectVolumeMount(
|
|
1076
|
+
await runtime.fetch(reservationFilesystemPath(reservationId), {
|
|
1077
|
+
headers: idempotencyHeaders(input),
|
|
1078
|
+
method: "DELETE",
|
|
1079
|
+
}),
|
|
1080
|
+
reservationId
|
|
1081
|
+
),
|
|
1082
|
+
reservationId
|
|
1083
|
+
);
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error instanceof CliApiError && (error.status === undefined || error.status >= 500)) {
|
|
1086
|
+
const reconciled = await fetchMount(runtime, reservationId);
|
|
1087
|
+
const deletingState = reconciled?.filesystem?.phase;
|
|
1088
|
+
if (
|
|
1089
|
+
reconciled &&
|
|
1090
|
+
!["deleting", "deleted", "ended"].includes(deletingState) &&
|
|
1091
|
+
!["detached", "ended"].includes(reconciled.lifecycle.phase)
|
|
1092
|
+
) {
|
|
1093
|
+
throw error;
|
|
1094
|
+
}
|
|
1095
|
+
deleting = reconciled;
|
|
1096
|
+
} else {
|
|
1097
|
+
safeFailure(
|
|
1098
|
+
runtime.interaction,
|
|
1099
|
+
error,
|
|
1100
|
+
"The filesystem deployment could not be deleted safely."
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return runtime.interaction.result({
|
|
1105
|
+
data: {
|
|
1106
|
+
filesystem_deployment: deleting,
|
|
1107
|
+
replayed: false,
|
|
1108
|
+
reservation_id: reservationId,
|
|
1109
|
+
volume_preserved: current.volume,
|
|
1110
|
+
},
|
|
1111
|
+
outcome: { class: "working", phase: "deleting" },
|
|
1112
|
+
resume: { kind: "filesystem_deployment", ref: reservationId },
|
|
1113
|
+
presentation: {
|
|
1114
|
+
lines: [
|
|
1115
|
+
`Filesystem deployment removal accepted for ${reservationId}.`,
|
|
1116
|
+
`Managed volume ${current.volume.name ?? current.volume.id} was preserved.`,
|
|
1117
|
+
],
|
|
1118
|
+
next: `ornn filesystems show ${reservationId}`,
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export async function executeMountCommand(command, input, runtime) {
|
|
1124
|
+
switch (command) {
|
|
1125
|
+
case "storage.volume-mounts.candidates":
|
|
1126
|
+
return candidates(input, runtime);
|
|
1127
|
+
case "storage.volume-mounts.show":
|
|
1128
|
+
return show(input, runtime, "volume-mounts show");
|
|
1129
|
+
case "storage.volume-mounts.attach":
|
|
1130
|
+
return attachVolumeMount(input, runtime);
|
|
1131
|
+
case "storage.volume-mounts.wait":
|
|
1132
|
+
return waitCommand(input, runtime, "volume-mounts wait");
|
|
1133
|
+
case "storage.volume-mounts.detach":
|
|
1134
|
+
return detachVolumeMount(input, runtime);
|
|
1135
|
+
case "storage.filesystems.deploy":
|
|
1136
|
+
return filesystemExecution(await deployFilesystem(input, runtime));
|
|
1137
|
+
case "storage.filesystems.show":
|
|
1138
|
+
return filesystemExecution(await show(input, runtime, "filesystems show"));
|
|
1139
|
+
case "storage.filesystems.wait":
|
|
1140
|
+
return filesystemExecution(await waitCommand(input, runtime, "filesystems wait"));
|
|
1141
|
+
case "storage.filesystems.delete":
|
|
1142
|
+
return deleteFilesystem(input, runtime);
|
|
1143
|
+
default:
|
|
1144
|
+
runtime.interaction.error("Unknown mount command.", { code: "unknown_command" });
|
|
1145
|
+
}
|
|
1146
|
+
}
|