@ornncompute/cli 0.2.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -420
- package/package.json +12 -6
- package/src/admin-capacity-reservations.mjs +280 -0
- package/src/admin-command-handlers.mjs +57 -0
- package/src/admin-enrollment.mjs +680 -0
- package/src/admin-mutations.mjs +187 -0
- package/src/admin-node-diagnostics.mjs +412 -0
- package/src/admin-node-lifecycle.mjs +908 -0
- package/src/admin-shell.mjs +322 -0
- package/src/api-client.mjs +81 -28
- package/src/atomic-output.mjs +112 -0
- package/src/auth-store.mjs +182 -11
- package/src/capability-catalog.mjs +7 -0
- package/src/catalog-shell.mjs +1350 -0
- package/src/cli.mjs +123 -10266
- package/src/connect-mode-handlers.mjs +170 -0
- package/src/device-auth.mjs +178 -61
- package/src/evidence-commands.mjs +1340 -0
- package/src/external-bucket-commands.mjs +1374 -0
- package/src/interaction-runtime.mjs +310 -0
- package/src/kubernetes-commands.mjs +2229 -0
- package/src/launch-mode-handlers.mjs +119 -0
- package/src/machine-reference.mjs +277 -0
- package/src/mount-commands.mjs +1146 -0
- package/src/session-commands.mjs +565 -0
- package/src/slurm-commands.mjs +263 -0
- package/src/startup.mjs +34 -11
- package/src/storage-commands.mjs +1144 -0
- package/src/update.mjs +172 -24
- package/src/vm-commands.mjs +4273 -0
- package/src/vpn-secret-output.mjs +150 -0
- package/src/wait-runtime.mjs +231 -0
- package/vendor/capabilities/capabilities/admin-capacity-reservations.js +126 -0
- package/vendor/capabilities/capabilities/admin-enrollment.js +321 -0
- package/vendor/capabilities/capabilities/admin-node-diagnostics.js +88 -0
- package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +235 -0
- package/vendor/capabilities/capabilities/admin.js +89 -0
- package/vendor/capabilities/capabilities/capacity-offers.js +368 -0
- package/vendor/capabilities/capabilities/cli.js +156 -0
- package/vendor/capabilities/capabilities/clusters.js +150 -1
- package/vendor/capabilities/capabilities/compute.js +522 -0
- package/vendor/capabilities/capabilities/evidence.js +145 -0
- package/vendor/capabilities/capabilities/external-buckets.js +191 -0
- package/vendor/capabilities/capabilities/fleet.js +21 -5
- package/vendor/capabilities/capabilities/identity.js +114 -3
- package/vendor/capabilities/capabilities/listings.js +14 -8
- package/vendor/capabilities/capabilities/mounts.js +176 -0
- package/vendor/capabilities/capabilities/networks.js +1257 -0
- package/vendor/capabilities/capabilities/observability.js +38 -8
- package/vendor/capabilities/capabilities/tokens.js +20 -8
- package/vendor/capabilities/capabilities/volumes.js +178 -0
- package/vendor/capabilities/capability.js +19 -13
- package/vendor/capabilities/cli-catalog.js +65 -0
- package/vendor/capabilities/cli-migrations.js +288 -0
- package/vendor/capabilities/cli.js +7 -0
- package/vendor/capabilities/software-catalog.js +36 -0
- package/vendor/capabilities/spec-catalog.js +2 -2
- package/src/catalog-dispatch.mjs +0 -605
- package/src/color.mjs +0 -71
- package/vendor/capabilities/capabilities/clusters.d.ts +0 -2
- package/vendor/capabilities/capabilities/fleet.d.ts +0 -8
- package/vendor/capabilities/capabilities/identity.d.ts +0 -3
- package/vendor/capabilities/capabilities/listings.d.ts +0 -51
- package/vendor/capabilities/capabilities/observability.d.ts +0 -5
- package/vendor/capabilities/capabilities/staff-access.d.ts +0 -29
- package/vendor/capabilities/capabilities/staff-access.js +0 -278
- package/vendor/capabilities/capabilities/staff-commerce.d.ts +0 -160
- package/vendor/capabilities/capabilities/staff-commerce.js +0 -689
- package/vendor/capabilities/capabilities/staff-fleet.d.ts +0 -140
- package/vendor/capabilities/capabilities/staff-fleet.js +0 -538
- package/vendor/capabilities/capabilities/staff-gtm.d.ts +0 -142
- package/vendor/capabilities/capabilities/staff-gtm.js +0 -521
- package/vendor/capabilities/capabilities/staff-shared.d.ts +0 -47
- package/vendor/capabilities/capabilities/staff-shared.js +0 -98
- package/vendor/capabilities/capabilities/tokens.d.ts +0 -10
- package/vendor/capabilities/capabilities/users.d.ts +0 -2
- package/vendor/capabilities/capability.d.ts +0 -93
- package/vendor/capabilities/catalog.d.ts +0 -3
- package/vendor/capabilities/catalog.js +0 -26
- package/vendor/capabilities/index.d.ts +0 -16
- package/vendor/capabilities/index.js +0 -16
- package/vendor/capabilities/names.d.ts +0 -6
- package/vendor/capabilities/role.d.ts +0 -6
- package/vendor/capabilities/role.js +0 -17
- package/vendor/capabilities/spec-catalog.d.ts +0 -78
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
|
|
2
|
+
import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
|
|
3
|
+
import { waitForGoal as waitForReadGoal } from "./wait-runtime.mjs";
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
const DEFAULT_WAIT_SECONDS = 600;
|
|
7
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
8
|
+
|
|
9
|
+
const COMMAND_BY_CAPABILITY = Object.freeze({
|
|
10
|
+
"nodes.reboot": "reboot",
|
|
11
|
+
"nodes.hard_reset": "hard-reset",
|
|
12
|
+
"nodes.suspend_management": "suspend-management",
|
|
13
|
+
"nodes.resume_management": "resume-management",
|
|
14
|
+
"nodes.terminate": "terminate",
|
|
15
|
+
"nodes.assign": "assign",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const CAPABILITY_BY_COMMAND = Object.freeze(
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
Object.entries(COMMAND_BY_CAPABILITY).map(([capability, command]) => [command, capability])
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const USAGE = Object.freeze({
|
|
25
|
+
reboot: "Usage: ornn admin nodes reboot <node-ref> [--yes] [--no-wait] [--timeout SECONDS]",
|
|
26
|
+
"hard-reset":
|
|
27
|
+
"Usage: ornn admin nodes hard-reset <node-ref> --erase-local-data [--yes] [--no-wait] [--timeout SECONDS]",
|
|
28
|
+
"suspend-management":
|
|
29
|
+
"Usage: ornn admin nodes suspend-management <node-ref> [--reason TEXT] [--yes] [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
30
|
+
"resume-management":
|
|
31
|
+
"Usage: ornn admin nodes resume-management <node-ref> [--yes] [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
32
|
+
terminate:
|
|
33
|
+
"Usage: ornn admin nodes terminate <node-ref> --delete-node-record [--skip-cleanup] [--reason TEXT] [--yes] [--idempotency-key KEY]",
|
|
34
|
+
assign:
|
|
35
|
+
"Usage: ornn admin nodes assign <node-ref> --reservation RESERVATION [--yes] [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const BOOLEAN_OPTIONS = new Set([
|
|
39
|
+
"delete-node-record",
|
|
40
|
+
"erase-local-data",
|
|
41
|
+
"no-wait",
|
|
42
|
+
"skip-cleanup",
|
|
43
|
+
"wait",
|
|
44
|
+
"yes",
|
|
45
|
+
]);
|
|
46
|
+
const VALUE_OPTIONS = new Set(["idempotency-key", "reason", "reservation", "timeout"]);
|
|
47
|
+
const ALLOWED_OPTIONS = Object.freeze({
|
|
48
|
+
reboot: new Set(["yes", "wait", "no-wait", "timeout"]),
|
|
49
|
+
"hard-reset": new Set(["erase-local-data", "yes", "wait", "no-wait", "timeout"]),
|
|
50
|
+
"suspend-management": new Set(["reason", "yes", "idempotency-key", "wait", "no-wait", "timeout"]),
|
|
51
|
+
"resume-management": new Set(["yes", "idempotency-key", "wait", "no-wait", "timeout"]),
|
|
52
|
+
terminate: new Set(["delete-node-record", "skip-cleanup", "reason", "yes", "idempotency-key"]),
|
|
53
|
+
assign: new Set(["reservation", "yes", "idempotency-key", "wait", "no-wait", "timeout"]),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function mutationError(message, { code = "usage_error", field } = {}) {
|
|
57
|
+
return new AdminMutationError(message, { code, field });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commandName(command) {
|
|
61
|
+
const normalized = String(command ?? "").trim();
|
|
62
|
+
if (COMMAND_BY_CAPABILITY[normalized]) return COMMAND_BY_CAPABILITY[normalized];
|
|
63
|
+
const leaf = normalized.replace(/^admin nodes /, "");
|
|
64
|
+
if (CAPABILITY_BY_COMMAND[leaf]) return leaf;
|
|
65
|
+
throw mutationError("Unknown admin node lifecycle command.", {
|
|
66
|
+
code: "admin_command_unavailable",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseOptions(command, argv) {
|
|
71
|
+
const usage = USAGE[command];
|
|
72
|
+
const values = {};
|
|
73
|
+
const positionals = [];
|
|
74
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
75
|
+
const argument = argv[index];
|
|
76
|
+
if (!argument.startsWith("--")) {
|
|
77
|
+
positionals.push(argument);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const separator = argument.indexOf("=");
|
|
81
|
+
const name = argument.slice(2, separator === -1 ? undefined : separator);
|
|
82
|
+
if (!ALLOWED_OPTIONS[command].has(name)) {
|
|
83
|
+
throw mutationError(`${usage}\nUnknown option: --${name}`, { field: name });
|
|
84
|
+
}
|
|
85
|
+
if (values[name] !== undefined) {
|
|
86
|
+
throw mutationError(`${usage}\n--${name} may be supplied only once.`, { field: name });
|
|
87
|
+
}
|
|
88
|
+
if (BOOLEAN_OPTIONS.has(name)) {
|
|
89
|
+
if (separator !== -1) {
|
|
90
|
+
throw mutationError(`${usage}\n--${name} does not accept a value.`, { field: name });
|
|
91
|
+
}
|
|
92
|
+
values[name] = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const value = separator === -1 ? argv[index + 1] : argument.slice(separator + 1);
|
|
96
|
+
if (!value || (separator === -1 && value.startsWith("--"))) {
|
|
97
|
+
throw mutationError(`${usage}\n--${name} requires a value.`, { field: name });
|
|
98
|
+
}
|
|
99
|
+
values[name] = value;
|
|
100
|
+
if (separator === -1) index += 1;
|
|
101
|
+
}
|
|
102
|
+
if (positionals.length !== 1) throw mutationError(usage);
|
|
103
|
+
return { options: values, nodeRef: positionals[0] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function durationSeconds(value, usage) {
|
|
107
|
+
if (value === undefined) return undefined;
|
|
108
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value);
|
|
109
|
+
if (!match)
|
|
110
|
+
throw mutationError(`${usage}\n--timeout requires a positive duration.`, { field: "timeout" });
|
|
111
|
+
const amount = Number(match[1]);
|
|
112
|
+
const multiplier = { ms: 0.001, s: 1, m: 60, h: 3_600 }[(match[2] ?? "s").toLowerCase()];
|
|
113
|
+
const seconds = amount * multiplier;
|
|
114
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 86_400) {
|
|
115
|
+
throw mutationError(
|
|
116
|
+
`${usage}\n--timeout must be greater than zero and no more than 24 hours.`,
|
|
117
|
+
{
|
|
118
|
+
field: "timeout",
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return seconds;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseAdminNodeLifecycleInput(command, argv) {
|
|
126
|
+
const name = commandName(command);
|
|
127
|
+
const usage = USAGE[name];
|
|
128
|
+
const { options, nodeRef } = parseOptions(name, argv);
|
|
129
|
+
if (options.wait && options["no-wait"]) {
|
|
130
|
+
throw mutationError(`${usage}\n--wait and --no-wait cannot be combined.`, { field: "wait" });
|
|
131
|
+
}
|
|
132
|
+
if (options.timeout && options["no-wait"]) {
|
|
133
|
+
throw mutationError(`${usage}\n--timeout cannot be combined with --no-wait.`, {
|
|
134
|
+
field: "timeout",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (name === "hard-reset" && options["erase-local-data"] !== true) {
|
|
138
|
+
throw mutationError(
|
|
139
|
+
`${usage}\nHard reset requires --erase-local-data; --yes cannot imply data loss.`,
|
|
140
|
+
{
|
|
141
|
+
code: "erase_local_data_required",
|
|
142
|
+
field: "erase-local-data",
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (name === "terminate" && options["delete-node-record"] !== true) {
|
|
147
|
+
throw mutationError(
|
|
148
|
+
`${usage}\nTermination requires --delete-node-record; --yes cannot imply deletion.`,
|
|
149
|
+
{
|
|
150
|
+
code: "delete_node_record_required",
|
|
151
|
+
field: "delete-node-record",
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (name === "assign" && !options.reservation) {
|
|
156
|
+
throw mutationError(`${usage}\nAssignment requires --reservation.`, {
|
|
157
|
+
code: "reservation_required",
|
|
158
|
+
field: "reservation",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (name !== "hard-reset" && options["erase-local-data"]) throw mutationError(usage);
|
|
162
|
+
if (name !== "terminate" && (options["delete-node-record"] || options["skip-cleanup"])) {
|
|
163
|
+
throw mutationError(usage);
|
|
164
|
+
}
|
|
165
|
+
if (name !== "assign" && options.reservation) throw mutationError(usage);
|
|
166
|
+
if (!name.includes("management") && name !== "terminate" && options.reason) {
|
|
167
|
+
throw mutationError(usage);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const idempotencyKey = options["idempotency-key"]?.trim();
|
|
171
|
+
const safety =
|
|
172
|
+
name === "reboot" || name === "hard-reset"
|
|
173
|
+
? ADMIN_MUTATION_SAFETY.eligibleHumanOnly
|
|
174
|
+
: ADMIN_MUTATION_SAFETY.reconcile;
|
|
175
|
+
return {
|
|
176
|
+
input: {
|
|
177
|
+
node_ref: nodeRef,
|
|
178
|
+
...(options.yes ? { yes: true } : {}),
|
|
179
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
180
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
181
|
+
...(options.wait ? { wait: true } : options["no-wait"] ? { wait: false } : {}),
|
|
182
|
+
...(options.timeout ? { timeout_seconds: durationSeconds(options.timeout, usage) } : {}),
|
|
183
|
+
...(name === "hard-reset" ? { erase_local_data: true } : {}),
|
|
184
|
+
...(name === "terminate"
|
|
185
|
+
? {
|
|
186
|
+
delete_node_record: true,
|
|
187
|
+
...(options["skip-cleanup"] ? { skip_cleanup: true } : {}),
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
...(name === "assign" ? { reservation_id: options.reservation } : {}),
|
|
191
|
+
},
|
|
192
|
+
mutation: {
|
|
193
|
+
idempotencyKey,
|
|
194
|
+
safety,
|
|
195
|
+
yes: options.yes === true,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function text(value) {
|
|
201
|
+
if (value === null || value === undefined) return "";
|
|
202
|
+
return String(value).trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function nodeId(node) {
|
|
206
|
+
return text(node?.node_id ?? node?.id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reservationId(node) {
|
|
210
|
+
return text(node?.reservation_id ?? node?.reservation_commerce_reservation_id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function nodeSnapshot(node) {
|
|
214
|
+
return {
|
|
215
|
+
node_id: nodeId(node),
|
|
216
|
+
hostname: text(node?.hostname ?? node?.k8s_node_name),
|
|
217
|
+
status: text(node?.status),
|
|
218
|
+
operator_id: text(node?.operator_id ?? node?.operatorId),
|
|
219
|
+
facility_id: text(node?.facility_id ?? node?.facilityId),
|
|
220
|
+
reservation_id: reservationId(node),
|
|
221
|
+
off_grid: node?.off_grid === true,
|
|
222
|
+
cleanup_state: text(node?.cleanup_state),
|
|
223
|
+
reachability:
|
|
224
|
+
node?.reachable === false || node?.health_readiness?.reachable === false
|
|
225
|
+
? "unreachable"
|
|
226
|
+
: text(
|
|
227
|
+
node?.reachability?.status ??
|
|
228
|
+
node?.off_grid_reachability?.status ??
|
|
229
|
+
node?.health_readiness?.reachability
|
|
230
|
+
),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sameSnapshot(left, right) {
|
|
235
|
+
return JSON.stringify(nodeSnapshot(left)) === JSON.stringify(nodeSnapshot(right));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isOffGrid(node) {
|
|
239
|
+
const snapshot = nodeSnapshot(node);
|
|
240
|
+
return snapshot.off_grid || ["off-grid", "off_grid"].includes(snapshot.status.toLowerCase());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isUnreachable(node) {
|
|
244
|
+
const snapshot = nodeSnapshot(node);
|
|
245
|
+
return (
|
|
246
|
+
isOffGrid(node) || ["unreachable", "offline"].includes(snapshot.reachability.toLowerCase())
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function nodeRows(payload) {
|
|
251
|
+
if (Array.isArray(payload)) return payload;
|
|
252
|
+
return Array.isArray(payload?.nodes) ? payload.nodes : [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isNotFound(error) {
|
|
256
|
+
return (
|
|
257
|
+
error?.status === 404 ||
|
|
258
|
+
error?.detail?.code === "node_not_found" ||
|
|
259
|
+
error?.detail?.detail === "node_not_found"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function readNodeById(fetch, id, { allowMissing = false, signal } = {}) {
|
|
264
|
+
try {
|
|
265
|
+
return await fetch(`/internal/nodes/${encodeURIComponent(id)}`, {
|
|
266
|
+
method: "GET",
|
|
267
|
+
...(signal ? { signal } : {}),
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (allowMissing && isNotFound(error)) return null;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function resolveNode(fetch, reference, { allowMissing = false, machine = false } = {}) {
|
|
276
|
+
if (UUID.test(reference)) {
|
|
277
|
+
const node = await readNodeById(fetch, reference, { allowMissing });
|
|
278
|
+
if (!node) return null;
|
|
279
|
+
if (!nodeId(node)) {
|
|
280
|
+
throw mutationError("The gateway returned a node without a canonical identifier.", {
|
|
281
|
+
code: "invalid_node_projection",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return node;
|
|
285
|
+
}
|
|
286
|
+
if (machine) {
|
|
287
|
+
throw mutationError("Machine mode requires a canonical node ID, not a hostname.", {
|
|
288
|
+
code: "canonical_node_id_required",
|
|
289
|
+
field: "node-ref",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const folded = reference.toLocaleLowerCase("en-US");
|
|
293
|
+
const matches = nodeRows(await fetch("/nodes", { method: "GET" })).filter((node) => {
|
|
294
|
+
const names = [node?.hostname, node?.k8s_node_name]
|
|
295
|
+
.map(text)
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.map((value) => value.toLocaleLowerCase("en-US"));
|
|
298
|
+
return names.includes(folded);
|
|
299
|
+
});
|
|
300
|
+
const unique = [...new Map(matches.map((node) => [nodeId(node), node])).values()].filter((node) =>
|
|
301
|
+
nodeId(node)
|
|
302
|
+
);
|
|
303
|
+
if (unique.length === 0) {
|
|
304
|
+
throw mutationError(`No node has the exact hostname ${JSON.stringify(reference)}.`, {
|
|
305
|
+
code: "node_not_found",
|
|
306
|
+
field: "node-ref",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (unique.length !== 1) {
|
|
310
|
+
throw mutationError(
|
|
311
|
+
`Hostname ${JSON.stringify(reference)} matches more than one node; use a canonical node ID.`,
|
|
312
|
+
{
|
|
313
|
+
code: "ambiguous_node_reference",
|
|
314
|
+
field: "node-ref",
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return readNodeById(fetch, nodeId(unique[0]));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function machineRows(payload) {
|
|
322
|
+
return Array.isArray(payload?.nodes) ? payload.nodes : [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function resolveLiveMachine(fetch, node) {
|
|
326
|
+
const id = nodeId(node);
|
|
327
|
+
const reservation = reservationId(node);
|
|
328
|
+
if (!reservation) {
|
|
329
|
+
throw mutationError(
|
|
330
|
+
`Node ${id} has no capacity reservation and therefore no live machine to reboot.`,
|
|
331
|
+
{
|
|
332
|
+
code: "node_has_no_live_machine",
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const rows = machineRows(
|
|
337
|
+
await fetch(`/nodes?reservation_id=${encodeURIComponent(reservation)}`, {
|
|
338
|
+
method: "GET",
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
const refs = new Set([id, text(node?.id), text(node?.k8s_node_name)].filter(Boolean));
|
|
342
|
+
const matches = rows.filter((machine) =>
|
|
343
|
+
refs.has(text(machine?.node_id))
|
|
344
|
+
);
|
|
345
|
+
if (matches.length > 1) {
|
|
346
|
+
throw mutationError(`Multiple live machines resolve to node ${id}; no reboot was submitted.`, {
|
|
347
|
+
code: "ambiguous_live_machine",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const machineId = text(matches[0]?.node_id);
|
|
351
|
+
if (!machineId) {
|
|
352
|
+
throw mutationError(`No live machine resolves to node ${id}; no reboot was submitted.`, {
|
|
353
|
+
code: "live_machine_not_found",
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return { id: machineId, reservation_id: reservation };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function reservationSnapshot(reservation, requestedId) {
|
|
360
|
+
return {
|
|
361
|
+
reservation_id:
|
|
362
|
+
text(reservation?.reservationId ?? reservation?.reservation_id ?? reservation?.id) ||
|
|
363
|
+
requestedId,
|
|
364
|
+
organization_id: text(
|
|
365
|
+
reservation?.organizationId ?? reservation?.organization_id ?? reservation?.tenant_id
|
|
366
|
+
),
|
|
367
|
+
listing_id: text(reservation?.listingId ?? reservation?.listing_id),
|
|
368
|
+
status: text(reservation?.status),
|
|
369
|
+
node_count: Number(reservation?.nodeCount ?? reservation?.node_count ?? 0) || 0,
|
|
370
|
+
start_at: text(reservation?.startAt ?? reservation?.start_at),
|
|
371
|
+
end_at: text(reservation?.endAt ?? reservation?.end_at),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readReservation(fetch, id) {
|
|
376
|
+
const reservation = await fetch(`/internal/v1/reservations/${encodeURIComponent(id)}`, {
|
|
377
|
+
method: "GET",
|
|
378
|
+
});
|
|
379
|
+
return reservationSnapshot(reservation, id);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sameReservation(left, right) {
|
|
383
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function label(value, fallback = "not set") {
|
|
387
|
+
return text(value) || fallback;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function capacityLabel(node) {
|
|
391
|
+
return reservationId(node) || "unassigned";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function commonReviewFields(node) {
|
|
395
|
+
const snapshot = nodeSnapshot(node);
|
|
396
|
+
return [
|
|
397
|
+
{ label: "Node", value: `${snapshot.hostname || "unnamed"} (${snapshot.node_id})` },
|
|
398
|
+
{ label: "Operator", value: label(snapshot.operator_id) },
|
|
399
|
+
{ label: "Facility", value: label(snapshot.facility_id) },
|
|
400
|
+
{ label: "Capacity reservation", value: capacityLabel(node) },
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function inspectionAction(id) {
|
|
405
|
+
return {
|
|
406
|
+
kind: "inspect",
|
|
407
|
+
command: `ornn admin nodes health ${id}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function resumeReference(id) {
|
|
412
|
+
return { kind: "node", ref: id };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function safeOwnerResult(result) {
|
|
416
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return {};
|
|
417
|
+
const safe = {};
|
|
418
|
+
for (const key of [
|
|
419
|
+
"deleted",
|
|
420
|
+
"forced",
|
|
421
|
+
"id",
|
|
422
|
+
"mode",
|
|
423
|
+
"node_id",
|
|
424
|
+
"off_grid",
|
|
425
|
+
"packet_id",
|
|
426
|
+
"request_id",
|
|
427
|
+
"reservation_id",
|
|
428
|
+
"state",
|
|
429
|
+
"status",
|
|
430
|
+
]) {
|
|
431
|
+
if (result[key] !== undefined) safe[key] = result[key];
|
|
432
|
+
}
|
|
433
|
+
return safe;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function safeNodeState(node) {
|
|
437
|
+
if (!node) return { status: "absent" };
|
|
438
|
+
return nodeSnapshot(node);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function prepareLifecycle({ command, fetch, input, machine }) {
|
|
442
|
+
const allowMissing = command === "terminate";
|
|
443
|
+
const node = await resolveNode(fetch, input.node_ref, { allowMissing, machine });
|
|
444
|
+
if (!node) {
|
|
445
|
+
return {
|
|
446
|
+
review: {
|
|
447
|
+
title: "Review node termination",
|
|
448
|
+
fields: [
|
|
449
|
+
{ label: "Node", value: input.node_ref },
|
|
450
|
+
{ label: "Node record", value: "already absent" },
|
|
451
|
+
{ label: "Cleanup", value: "not applicable" },
|
|
452
|
+
{ label: "Access consequence", value: "managed access is already ended" },
|
|
453
|
+
],
|
|
454
|
+
consequence:
|
|
455
|
+
"The exact node record is already absent; no owner mutation will be submitted.",
|
|
456
|
+
},
|
|
457
|
+
value: { command, node: null, node_id: input.node_ref },
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const id = nodeId(node);
|
|
462
|
+
const fields = commonReviewFields(node);
|
|
463
|
+
const value = { command, node, node_id: id };
|
|
464
|
+
if (command === "reboot" || command === "hard-reset") {
|
|
465
|
+
const liveMachine = await resolveLiveMachine(fetch, node);
|
|
466
|
+
value.live_machine = liveMachine;
|
|
467
|
+
fields.push({ label: "Live machine", value: liveMachine.id });
|
|
468
|
+
fields.push({
|
|
469
|
+
label: "Local data",
|
|
470
|
+
value: command === "hard-reset" ? "ERASE tenant users and local home data" : "preserve",
|
|
471
|
+
});
|
|
472
|
+
fields.push({
|
|
473
|
+
label: "Access consequence",
|
|
474
|
+
value:
|
|
475
|
+
command === "hard-reset"
|
|
476
|
+
? "access is interrupted; tenant keys are restored after reconnect"
|
|
477
|
+
: "access is interrupted until the machine reconnects",
|
|
478
|
+
});
|
|
479
|
+
return {
|
|
480
|
+
review: {
|
|
481
|
+
title: command === "hard-reset" ? "Review hard reset" : "Review reboot",
|
|
482
|
+
fields,
|
|
483
|
+
consequence:
|
|
484
|
+
command === "hard-reset"
|
|
485
|
+
? "This irreversibly erases local tenant data on the resolved live machine."
|
|
486
|
+
: "This interrupts active work on the resolved live machine without erasing local data.",
|
|
487
|
+
},
|
|
488
|
+
value,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (command === "suspend-management") {
|
|
493
|
+
fields.push({ label: "Host power", value: "leave powered on" });
|
|
494
|
+
fields.push({ label: "Managed access", value: "remove agent management and managed SSH keys" });
|
|
495
|
+
if (input.reason) fields.push({ label: "Reason", value: input.reason });
|
|
496
|
+
return {
|
|
497
|
+
review: {
|
|
498
|
+
title: "Review management suspension",
|
|
499
|
+
fields,
|
|
500
|
+
consequence: "Ornn management and managed access end while the host remains powered on.",
|
|
501
|
+
},
|
|
502
|
+
value,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (command === "resume-management") {
|
|
507
|
+
fields.push({ label: "Management", value: "remotely re-enroll the node" });
|
|
508
|
+
fields.push({ label: "Managed access", value: "restore after successful enrollment" });
|
|
509
|
+
return {
|
|
510
|
+
review: {
|
|
511
|
+
title: "Review management resumption",
|
|
512
|
+
fields,
|
|
513
|
+
consequence: "Ornn uses the stored admin access to reinstall management on this host.",
|
|
514
|
+
},
|
|
515
|
+
value,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (command === "terminate") {
|
|
520
|
+
if (input.skip_cleanup && !isUnreachable(node)) {
|
|
521
|
+
throw mutationError(
|
|
522
|
+
"--skip-cleanup is allowed only when the authoritative node read says the node is unreachable or off-grid.",
|
|
523
|
+
{ code: "skip_cleanup_requires_unreachable", field: "skip-cleanup" }
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
fields.push({
|
|
527
|
+
label: "Host cleanup",
|
|
528
|
+
value: input.skip_cleanup
|
|
529
|
+
? "SKIP; the node is authoritatively unreachable/off-grid"
|
|
530
|
+
: "not dispatched by termination; complete any required host wipe separately before reuse",
|
|
531
|
+
});
|
|
532
|
+
fields.push({ label: "Node record", value: "PERMANENTLY DELETE" });
|
|
533
|
+
fields.push({
|
|
534
|
+
label: "Reservation/capacity consequence",
|
|
535
|
+
value: reservationId(node)
|
|
536
|
+
? `node is removed from reservation ${reservationId(node)}; capacity ownership is not cancelled`
|
|
537
|
+
: "no reservation is assigned; no capacity purchase is cancelled",
|
|
538
|
+
});
|
|
539
|
+
fields.push({ label: "Access consequence", value: "end all Ornn-managed access to this node" });
|
|
540
|
+
if (input.reason) fields.push({ label: "Reason", value: input.reason });
|
|
541
|
+
return {
|
|
542
|
+
review: {
|
|
543
|
+
title: "Review node termination",
|
|
544
|
+
fields,
|
|
545
|
+
consequence:
|
|
546
|
+
"The node record is permanently deleted; this does not cancel its capacity reservation.",
|
|
547
|
+
},
|
|
548
|
+
value,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const requested = await readReservation(fetch, input.reservation_id);
|
|
553
|
+
value.requested_reservation = requested;
|
|
554
|
+
fields.push({ label: "Previous capacity reservation", value: capacityLabel(node) });
|
|
555
|
+
fields.push({
|
|
556
|
+
label: "Requested capacity reservation",
|
|
557
|
+
value: `${requested.reservation_id} (${label(requested.status, "unknown status")}, ${requested.node_count || "unknown"} machines)`,
|
|
558
|
+
});
|
|
559
|
+
fields.push({
|
|
560
|
+
label: "Reservation/capacity consequence",
|
|
561
|
+
value: reservationId(node)
|
|
562
|
+
? `replace assignment to ${reservationId(node)}; neither reservation is cancelled`
|
|
563
|
+
: "attach this node; the reservation remains the commercial capacity record",
|
|
564
|
+
});
|
|
565
|
+
fields.push({
|
|
566
|
+
label: "Access consequence",
|
|
567
|
+
value: `provision reservation ${requested.reservation_id}'s managed host login`,
|
|
568
|
+
});
|
|
569
|
+
return {
|
|
570
|
+
review: {
|
|
571
|
+
title: "Review node assignment",
|
|
572
|
+
fields,
|
|
573
|
+
consequence:
|
|
574
|
+
"Assignment changes which reservation receives managed access; it does not create or cancel capacity.",
|
|
575
|
+
},
|
|
576
|
+
value,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function reconcileLifecycle({ fetch, input, value }) {
|
|
581
|
+
const { command, node: reviewedNode, node_id: id } = value;
|
|
582
|
+
const current = await readNodeById(fetch, id, { allowMissing: command === "terminate" });
|
|
583
|
+
if (command === "terminate") {
|
|
584
|
+
if (!current) {
|
|
585
|
+
return {
|
|
586
|
+
status: "satisfied",
|
|
587
|
+
data: { node_id: id, status: "already_absent", deleted: false },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (input.skip_cleanup && !isUnreachable(current)) {
|
|
591
|
+
return {
|
|
592
|
+
status: "conflict",
|
|
593
|
+
message:
|
|
594
|
+
"The node is no longer authoritatively unreachable/off-grid; renew review without --skip-cleanup.",
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (!sameSnapshot(reviewedNode, current)) {
|
|
598
|
+
return {
|
|
599
|
+
status: "conflict",
|
|
600
|
+
message: "The node's termination consequences changed after review.",
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { status: "apply" };
|
|
604
|
+
}
|
|
605
|
+
if (!current) return { status: "conflict", message: "The exact node no longer exists." };
|
|
606
|
+
|
|
607
|
+
if (command === "suspend-management") {
|
|
608
|
+
if (isOffGrid(current)) {
|
|
609
|
+
return { status: "satisfied", data: { node_id: id, status: "off-grid", off_grid: true } };
|
|
610
|
+
}
|
|
611
|
+
if (!sameSnapshot(reviewedNode, current)) {
|
|
612
|
+
return { status: "conflict", message: "The node's management goal changed after review." };
|
|
613
|
+
}
|
|
614
|
+
return { status: "apply" };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (command === "resume-management") {
|
|
618
|
+
if (!isOffGrid(current)) {
|
|
619
|
+
return {
|
|
620
|
+
status: "satisfied",
|
|
621
|
+
data: { node_id: id, status: nodeSnapshot(current).status, off_grid: false },
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (!sameSnapshot(reviewedNode, current)) {
|
|
625
|
+
return { status: "conflict", message: "The node's management goal changed after review." };
|
|
626
|
+
}
|
|
627
|
+
return { status: "apply" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (command === "assign") {
|
|
631
|
+
const currentReservation = reservationId(current);
|
|
632
|
+
if (currentReservation === input.reservation_id) {
|
|
633
|
+
return {
|
|
634
|
+
status: "satisfied",
|
|
635
|
+
data: { node_id: id, reservation_id: input.reservation_id, status: "assigned" },
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (currentReservation && currentReservation !== reservationId(reviewedNode)) {
|
|
639
|
+
return {
|
|
640
|
+
status: "conflict",
|
|
641
|
+
message: `The node is now assigned to reservation ${currentReservation}; no assignment was submitted.`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (!sameSnapshot(reviewedNode, current)) {
|
|
645
|
+
return {
|
|
646
|
+
status: "conflict",
|
|
647
|
+
message: "The node's assignment consequences changed after review.",
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const requested = await readReservation(fetch, input.reservation_id);
|
|
651
|
+
if (!sameReservation(value.requested_reservation, requested)) {
|
|
652
|
+
return {
|
|
653
|
+
status: "conflict",
|
|
654
|
+
message: "The destination reservation or capacity terms changed after review.",
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return { status: "apply" };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!sameSnapshot(reviewedNode, current)) {
|
|
661
|
+
return {
|
|
662
|
+
status: "conflict",
|
|
663
|
+
message: "The node changed after review; resolve the target and review again.",
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const liveMachine = await resolveLiveMachine(fetch, current);
|
|
667
|
+
if (
|
|
668
|
+
liveMachine.id !== value.live_machine.id ||
|
|
669
|
+
liveMachine.reservation_id !== value.live_machine.reservation_id
|
|
670
|
+
) {
|
|
671
|
+
return { status: "conflict", message: "The live machine target changed after review." };
|
|
672
|
+
}
|
|
673
|
+
return { status: "apply" };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function goalSatisfied(command, node, input) {
|
|
677
|
+
if (command === "terminate") return node === null;
|
|
678
|
+
if (!node) return false;
|
|
679
|
+
if (command === "suspend-management") return isOffGrid(node);
|
|
680
|
+
if (command === "resume-management") return !isOffGrid(node);
|
|
681
|
+
if (command === "assign") return reservationId(node) === input.reservation_id;
|
|
682
|
+
if (["pending", "rebooting"].includes(text(node?.cleanup_state).toLowerCase())) return false;
|
|
683
|
+
const readiness = node?.health_readiness;
|
|
684
|
+
return (
|
|
685
|
+
readiness?.ready === true ||
|
|
686
|
+
["ready", "running"].includes(text(readiness?.status).toLowerCase())
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function waitResult(kind, command, id, accepted, lastState) {
|
|
691
|
+
const timeout = kind === "timeout";
|
|
692
|
+
return {
|
|
693
|
+
data: {
|
|
694
|
+
...accepted,
|
|
695
|
+
node_id: id,
|
|
696
|
+
last_durable_state: safeNodeState(lastState),
|
|
697
|
+
},
|
|
698
|
+
exitCode: timeout ? 124 : 130,
|
|
699
|
+
outcome: { class: "working", phase: timeout ? "local_timeout" : "local_interruption" },
|
|
700
|
+
resume: resumeReference(id),
|
|
701
|
+
action: inspectionAction(id),
|
|
702
|
+
errors: [
|
|
703
|
+
{
|
|
704
|
+
code: timeout ? "local_timeout" : "local_interruption",
|
|
705
|
+
message: timeout
|
|
706
|
+
? "The local wait timed out; the accepted node operation was not cancelled."
|
|
707
|
+
: "The local wait was interrupted; the accepted node operation was not cancelled.",
|
|
708
|
+
retryable: true,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function waitForGoal({ command, fetch, input, nodeId: id, runtime, accepted }) {
|
|
715
|
+
let lastState = null;
|
|
716
|
+
let outcome;
|
|
717
|
+
try {
|
|
718
|
+
outcome = await waitForReadGoal(
|
|
719
|
+
{
|
|
720
|
+
command: `ornn admin nodes ${command}`,
|
|
721
|
+
initial: null,
|
|
722
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
723
|
+
isReady: (node) => goalSatisfied(command, node, input),
|
|
724
|
+
message: () => `Waiting for node ${id} to reach the requested state.`,
|
|
725
|
+
onObserve: (node) => {
|
|
726
|
+
lastState = node;
|
|
727
|
+
},
|
|
728
|
+
poll: ({ signal }) =>
|
|
729
|
+
readNodeById(fetch, id, {
|
|
730
|
+
allowMissing: command === "terminate",
|
|
731
|
+
signal,
|
|
732
|
+
}),
|
|
733
|
+
pollImmediately: true,
|
|
734
|
+
timeoutMs: (input.timeout_seconds ?? DEFAULT_WAIT_SECONDS) * 1_000,
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
format: "json",
|
|
738
|
+
now: runtime.now,
|
|
739
|
+
signal: runtime.signal,
|
|
740
|
+
sleep: runtime.sleep,
|
|
741
|
+
stderr: runtime.stderr ?? { write() {} },
|
|
742
|
+
stdout: runtime.stdout ?? { write() {} },
|
|
743
|
+
}
|
|
744
|
+
);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (!isRecoverableWaitFailure(error)) throw error;
|
|
747
|
+
const status = Number(error?.status);
|
|
748
|
+
const authorization = status === 401 || status === 403;
|
|
749
|
+
const authentication = status === 401;
|
|
750
|
+
const action = authorization
|
|
751
|
+
? {
|
|
752
|
+
command: "ornn login",
|
|
753
|
+
description: "Restore an authorized session, then inspect the accepted node operation.",
|
|
754
|
+
type: "command",
|
|
755
|
+
}
|
|
756
|
+
: inspectionAction(id);
|
|
757
|
+
return {
|
|
758
|
+
action,
|
|
759
|
+
data: {
|
|
760
|
+
...accepted,
|
|
761
|
+
last_durable_state: safeNodeState(lastState),
|
|
762
|
+
node_id: id,
|
|
763
|
+
remote_work_cancelled: false,
|
|
764
|
+
},
|
|
765
|
+
errors: [
|
|
766
|
+
{
|
|
767
|
+
code: authentication
|
|
768
|
+
? "authentication_required"
|
|
769
|
+
: authorization
|
|
770
|
+
? "authorization_denied"
|
|
771
|
+
: "service_unavailable",
|
|
772
|
+
message: authorization
|
|
773
|
+
? "The session could not continue waiting; the accepted node operation continues."
|
|
774
|
+
: "The local wait lost contact with the service; the accepted node operation continues.",
|
|
775
|
+
retryable: status !== 403,
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
exitCode: authorization ? 5 : 6,
|
|
779
|
+
outcome: { class: "working", phase: "operation_accepted" },
|
|
780
|
+
resume: resumeReference(id),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (outcome.kind === "ready") {
|
|
784
|
+
return {
|
|
785
|
+
data: {
|
|
786
|
+
...accepted,
|
|
787
|
+
node_id: id,
|
|
788
|
+
state: safeNodeState(outcome.value),
|
|
789
|
+
},
|
|
790
|
+
outcome: { class: "ready", phase: "complete" },
|
|
791
|
+
resume: resumeReference(id),
|
|
792
|
+
action: inspectionAction(id),
|
|
793
|
+
errors: [],
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
return waitResult(outcome.kind, command, id, accepted, outcome.value);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export async function runAdminNodeLifecycle({
|
|
800
|
+
capability,
|
|
801
|
+
input,
|
|
802
|
+
mutation,
|
|
803
|
+
runtime,
|
|
804
|
+
execute,
|
|
805
|
+
fetch,
|
|
806
|
+
}) {
|
|
807
|
+
const command = commandName(capability.id);
|
|
808
|
+
if (runtime.machine && !UUID.test(input.node_ref)) {
|
|
809
|
+
throw mutationError("Machine mode requires a canonical node ID, not a hostname.", {
|
|
810
|
+
code: "canonical_node_id_required",
|
|
811
|
+
field: "node-ref",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
if (command === "assign" && !UUID.test(input.reservation_id)) {
|
|
815
|
+
throw mutationError("Assignment requires a canonical capacity reservation ID.", {
|
|
816
|
+
code: "canonical_reservation_id_required",
|
|
817
|
+
field: "reservation",
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
if (runtime.machine && input.wait === true && input.timeout_seconds === undefined) {
|
|
821
|
+
throw mutationError("Machine --wait requires an explicit --timeout.", {
|
|
822
|
+
code: "timeout_required",
|
|
823
|
+
field: "timeout",
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
let preparedValue;
|
|
827
|
+
let lateReconciled = false;
|
|
828
|
+
const result = await runAdminMutation({
|
|
829
|
+
...runtime,
|
|
830
|
+
...mutation,
|
|
831
|
+
stdout: runtime.stderr,
|
|
832
|
+
prepare: async () => {
|
|
833
|
+
const prepared = await prepareLifecycle({ command, fetch, input, machine: runtime.machine });
|
|
834
|
+
preparedValue = prepared.value;
|
|
835
|
+
return prepared;
|
|
836
|
+
},
|
|
837
|
+
reconcile: (value) => reconcileLifecycle({ fetch, input, value }),
|
|
838
|
+
execute: async (value) => {
|
|
839
|
+
preparedValue = value;
|
|
840
|
+
const confirmation = await reconcileLifecycle({ fetch, input, value });
|
|
841
|
+
if (confirmation?.status === "conflict") {
|
|
842
|
+
throw mutationError(
|
|
843
|
+
confirmation.message ?? "Current node state conflicts with the reviewed lifecycle goal.",
|
|
844
|
+
{ code: confirmation.code ?? "admin_goal_conflict" }
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
if (confirmation?.status === "satisfied") {
|
|
848
|
+
lateReconciled = true;
|
|
849
|
+
return confirmation.data;
|
|
850
|
+
}
|
|
851
|
+
if (confirmation?.status !== "apply") {
|
|
852
|
+
throw mutationError("Node lifecycle reconciliation returned an invalid state.", {
|
|
853
|
+
code: "admin_reconciliation_invalid",
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const executionInput = {
|
|
857
|
+
...input,
|
|
858
|
+
node_id: value.node_id,
|
|
859
|
+
...(value.live_machine ? { machine_id: value.live_machine.id } : {}),
|
|
860
|
+
};
|
|
861
|
+
return execute(executionInput, { confirmed: true });
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
if (result.status === "declined") {
|
|
866
|
+
return {
|
|
867
|
+
...result,
|
|
868
|
+
action: null,
|
|
869
|
+
resume: null,
|
|
870
|
+
errors: [],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const accepted = {
|
|
875
|
+
operation: command,
|
|
876
|
+
reconciled: result.reconciled === true || lateReconciled,
|
|
877
|
+
...safeOwnerResult(result.data),
|
|
878
|
+
};
|
|
879
|
+
const id = preparedValue?.node_id ?? input.node_ref;
|
|
880
|
+
const shouldWait = runtime.machine ? input.wait === true : input.wait !== false;
|
|
881
|
+
const reconciled = result.reconciled === true || lateReconciled;
|
|
882
|
+
if (!shouldWait || command === "terminate" || reconciled) {
|
|
883
|
+
return {
|
|
884
|
+
data: { ...accepted, node_id: id },
|
|
885
|
+
outcome: {
|
|
886
|
+
class: reconciled || command === "terminate" ? "ready" : "working",
|
|
887
|
+
phase: reconciled ? "reconciled" : command === "terminate" ? "complete" : "accepted",
|
|
888
|
+
},
|
|
889
|
+
resume: resumeReference(id),
|
|
890
|
+
action: inspectionAction(id),
|
|
891
|
+
errors: [],
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return waitForGoal({ command, fetch, input, nodeId: id, runtime, accepted });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export const adminNodeLifecycleHandlers = Object.keys(COMMAND_BY_CAPABILITY).map(
|
|
898
|
+
(capabilityId) => ({
|
|
899
|
+
capabilityId,
|
|
900
|
+
parse: (argv) => parseAdminNodeLifecycleInput(capabilityId, argv),
|
|
901
|
+
safety:
|
|
902
|
+
COMMAND_BY_CAPABILITY[capabilityId] === "reboot" ||
|
|
903
|
+
COMMAND_BY_CAPABILITY[capabilityId] === "hard-reset"
|
|
904
|
+
? ADMIN_MUTATION_SAFETY.eligibleHumanOnly
|
|
905
|
+
: ADMIN_MUTATION_SAFETY.reconcile,
|
|
906
|
+
run: runAdminNodeLifecycle,
|
|
907
|
+
})
|
|
908
|
+
);
|