@ornncompute/cli 1.0.75 → 1.0.97
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 +32 -5
- package/package.json +4 -2
- package/src/admin-capacity-reservations.mjs +4 -1
- package/src/admin-command-handlers.mjs +2 -0
- package/src/admin-node-diagnostics.mjs +1 -5
- package/src/admin-node-lifecycle.mjs +23 -125
- package/src/admin-node-sanitize.mjs +243 -0
- package/src/catalog-shell.mjs +6 -6
- package/src/evidence-commands.mjs +4 -6
- package/src/external-bucket-commands.mjs +71 -23
- package/src/interaction-runtime.mjs +3 -0
- package/src/kubernetes-commands.mjs +16 -83
- package/src/machine-reference.mjs +33 -56
- package/src/slurm-commands.mjs +13 -2
- package/src/vm-commands.mjs +39 -59
- package/vendor/api-client/commerce.js +743 -0
- package/vendor/capabilities/cli.js +7688 -7
- package/vendor/capabilities/capabilities/admin-capacity-reservations.js +0 -101
- package/vendor/capabilities/capabilities/admin-enrollment.js +0 -322
- package/vendor/capabilities/capabilities/admin-node-diagnostics.js +0 -88
- package/vendor/capabilities/capabilities/admin-nodes-lifecycle.js +0 -137
- package/vendor/capabilities/capabilities/admin.js +0 -91
- package/vendor/capabilities/capabilities/cli.js +0 -156
- package/vendor/capabilities/capabilities/clusters.js +0 -248
- package/vendor/capabilities/capabilities/commerce-market.js +0 -405
- package/vendor/capabilities/capabilities/compute.js +0 -523
- package/vendor/capabilities/capabilities/credits.js +0 -83
- package/vendor/capabilities/capabilities/evidence.js +0 -145
- package/vendor/capabilities/capabilities/external-buckets.js +0 -191
- package/vendor/capabilities/capabilities/fleet.js +0 -386
- package/vendor/capabilities/capabilities/identity.js +0 -129
- package/vendor/capabilities/capabilities/listings.js +0 -398
- package/vendor/capabilities/capabilities/locality.js +0 -42
- package/vendor/capabilities/capabilities/market-shared.js +0 -46
- package/vendor/capabilities/capabilities/markets.js +0 -482
- package/vendor/capabilities/capabilities/mounts.js +0 -176
- package/vendor/capabilities/capabilities/native-listing.js +0 -99
- package/vendor/capabilities/capabilities/networks.js +0 -1264
- package/vendor/capabilities/capabilities/observability.js +0 -253
- package/vendor/capabilities/capabilities/organization-invites.js +0 -53
- package/vendor/capabilities/capabilities/reservation-list.js +0 -68
- package/vendor/capabilities/capabilities/staff-shared.js +0 -64
- package/vendor/capabilities/capabilities/tokens.js +0 -161
- package/vendor/capabilities/capabilities/users.js +0 -157
- package/vendor/capabilities/capabilities/volumes.js +0 -178
- package/vendor/capabilities/capability.js +0 -99
- package/vendor/capabilities/cli-catalog.js +0 -73
- package/vendor/capabilities/cli-migrations.js +0 -264
- package/vendor/capabilities/names.js +0 -6
- package/vendor/capabilities/software-catalog.js +0 -82
- package/vendor/capabilities/spec-catalog.js +0 -151
- package/vendor/capabilities/supplier-directory.js +0 -20
package/README.md
CHANGED
|
@@ -7,8 +7,17 @@ sends browser and CLI traffic only through the API gateway.
|
|
|
7
7
|
For the customer install and usage guide, see [`docs/cli.md`](../../docs/cli.md).
|
|
8
8
|
The shared capability catalog in `packages/capabilities` is the authority for
|
|
9
9
|
command identity, role visibility, input schemas, help, and resource discovery.
|
|
10
|
-
The CLI imports `@ornn/capabilities/cli`; packaging includes
|
|
11
|
-
|
|
10
|
+
The CLI imports `@ornn/capabilities/cli`; packaging includes its dependencies,
|
|
11
|
+
including `@ornn/api-client`, and excludes MCP-only catalog handlers. These
|
|
12
|
+
packages run without workspace links or React.
|
|
13
|
+
|
|
14
|
+
Native Commerce requests use the caller’s bearer directly through the gateway:
|
|
15
|
+
`/commerce/*` for public APIs and `/internal/v1/*` for staff APIs.
|
|
16
|
+
The gateway and Commerce enforce the caller’s organization and staff permissions.
|
|
17
|
+
All Commerce operations use `@ornn/api-client/commerce`, including the reservation
|
|
18
|
+
reads inside launch, connect, and evidence flows. Customer reservation pickers
|
|
19
|
+
explicitly scope every page to the selected organization, including for staff.
|
|
20
|
+
The gateway retains its Commerce workflow adapters for already-published CLI versions.
|
|
12
21
|
|
|
13
22
|
For GCS imports, start with the [source-bucket permissions and version-specific CLI guide](https://docs.ornn.com/cli#gcs-imports-and-storage-transfer-service).
|
|
14
23
|
It documents the current CLI 1.x GCS verification limitation. Scoped
|
|
@@ -82,6 +91,9 @@ filesystems deploy | show | wait | delete
|
|
|
82
91
|
```
|
|
83
92
|
|
|
84
93
|
`ssh-keys list` uses backend cursors; see [pagination](../../docs/cli.md#advanced-customer-resources).
|
|
94
|
+
Organization key deletion saves removal on reservation lists before revoking the
|
|
95
|
+
account key. If preparation fails, retry the deletion; re-registering the key
|
|
96
|
+
does not restore its removed reservation access.
|
|
85
97
|
|
|
86
98
|
Market commands call one gateway route each and print that route's JSON.
|
|
87
99
|
`capacity-offers list|show|terms` reads native Commerce capacity
|
|
@@ -104,7 +116,9 @@ CLI does not send them yet), end a fill with
|
|
|
104
116
|
on a whole primary listing for one of its terms (`--listing-term-id`, optional
|
|
105
117
|
`--duration-seconds`), list the offers your organization made or received, and
|
|
106
118
|
accept or cancel offers by `--offer-id`.
|
|
107
|
-
Prices are integer micro-USD per GPU-hour (`1000000` is $1.00)
|
|
119
|
+
Prices are integer micro-USD per GPU-hour (`1000000` is $1.00); CPU capacity
|
|
120
|
+
(a null GPU type, traded on the `cpu` book) is priced and counted per vCPU in
|
|
121
|
+
the same fields. Writes execute
|
|
108
122
|
immediately; use `--format json` for machine-readable output and
|
|
109
123
|
`--idempotency-key` for safe retries. Customer payment methods and
|
|
110
124
|
billing stay in the web app. Launch consumes already allocated
|
|
@@ -125,8 +139,10 @@ Reuse the key and payload on retries. Creation requires an eligible human
|
|
|
125
139
|
terminal. Native capacity creation takes
|
|
126
140
|
`--gpu-slug`, `--gpus-per-node`, `--facility-id`, `--provisioning-type`,
|
|
127
141
|
`--state`, `--terms` (a JSON array) and `--idempotency-key`, plus optional
|
|
128
|
-
`--organization-id`.
|
|
129
|
-
|
|
142
|
+
`--organization-id`. CPU capacity takes `--gpu-slug cpu` and
|
|
143
|
+
`--cores-per-node` (a whole number of at least 1; each core is 2 vCPUs) instead
|
|
144
|
+
of `--gpus-per-node`; its term prices are per vCPU-hour. Give
|
|
145
|
+
`--capacity-node-count`, or `--node-ids` (a JSON array) to attach nodes at creation; the count then defaults to the number of nodes.
|
|
130
146
|
Primary capacity takes `--days-to-rfs` (0 when ready now) or both
|
|
131
147
|
`--available-at` and `--available-until`, not both. Reuse the key and the same
|
|
132
148
|
body on retries. Prices are integer micro-USD inside terms, with explicit
|
|
@@ -187,6 +203,17 @@ include the final newline required by OpenSSH, both in files and with `--print`.
|
|
|
187
203
|
`ornn admin nodes enroll` takes optional `--filter COMPARTMENT_IDS`
|
|
188
204
|
(omit to run every compartment step); the retired `--mode` flag is gone. The
|
|
189
205
|
gateway, not CLI metadata, authoritatively enforces the admin role.
|
|
206
|
+
`ornn admin nodes sanitize`, `sanitize-status`, and `sanitize-decide` drive one
|
|
207
|
+
node's held sanitize job, sharing the capability with MCP
|
|
208
|
+
`admin_node_sanitize_*` and Slack `node_sanitize_*`. `sanitize` needs
|
|
209
|
+
`--operation`, its exact `--confirmation` phrase, and `--recovery-uuid` for
|
|
210
|
+
`ram_roundtrip` (refused for `data_wipe`); the job then waits for staff approval.
|
|
211
|
+
`sanitize-decide --confirmation "ACCEPT MANUAL CLEANUP"` also needs
|
|
212
|
+
`--reservation`, `--release-operation` (the Orchestrator release operation),
|
|
213
|
+
`--reason`, `--evidence-file` (only its SHA-256 is sent), and each attestation
|
|
214
|
+
flag: `--tenant-access-revoked`, `--data-drives-sanitized`, and
|
|
215
|
+
`--acknowledge-retained-os`. Sanitize writes run only in an eligible human
|
|
216
|
+
terminal. See [manual cleanup with the boot OS retained](../../docs/provisioning/dedicated-machine-runbook.md#manual-cleanup-with-the-boot-os-retained).
|
|
190
217
|
Suppliers are Authorization supplier organizations: `ornn admin suppliers list`
|
|
191
218
|
returns their `id` and `name`, and `--supplier UUID` on `facilities list`,
|
|
192
219
|
`nodes list`, `enrollment-tokens create`, and `nodes enroll` takes that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ornncompute/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.97",
|
|
4
4
|
"description": "Command-line interface for Ornn compute access workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"test": "node --test 'test/*.test.mjs'"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@ornn/
|
|
38
|
+
"@ornn/api-client": "0.1.0",
|
|
39
|
+
"@ornn/capabilities": "0.1.0",
|
|
40
|
+
"esbuild": "^0.28.2"
|
|
39
41
|
},
|
|
40
42
|
"engines": {
|
|
41
43
|
"node": ">=20"
|
|
@@ -153,7 +153,10 @@ function reservationCreateReview(input) {
|
|
|
153
153
|
{ label: "Nodes", value: input.nodes },
|
|
154
154
|
{ label: "Starts at", value: input.startsAt },
|
|
155
155
|
{ label: "Ends at", value: input.endsAt },
|
|
156
|
-
{
|
|
156
|
+
{
|
|
157
|
+
label: "Price per unit-hour (micro-USD; per vCPU on CPU capacity)",
|
|
158
|
+
value: input.pricePerGpuHourMicroUsd,
|
|
159
|
+
},
|
|
157
160
|
{ label: "Idempotency key", value: input.idempotencyKey },
|
|
158
161
|
],
|
|
159
162
|
consequence:
|
|
@@ -3,6 +3,7 @@ import { adminCreditHandlers } from "./admin-credits.mjs";
|
|
|
3
3
|
import { adminEnrollmentHandlers } from "./admin-enrollment.mjs";
|
|
4
4
|
import { adminNodeDiagnosticsHandlers } from "./admin-node-diagnostics.mjs";
|
|
5
5
|
import { adminNodeLifecycleHandlers } from "./admin-node-lifecycle.mjs";
|
|
6
|
+
import { adminNodeSanitizeHandlers } from "./admin-node-sanitize.mjs";
|
|
6
7
|
import { ADMIN_MUTATION_SAFETY, assertAdminMutationContext } from "./admin-mutations.mjs";
|
|
7
8
|
|
|
8
9
|
const handlers = [
|
|
@@ -11,6 +12,7 @@ const handlers = [
|
|
|
11
12
|
...adminEnrollmentHandlers,
|
|
12
13
|
...adminNodeDiagnosticsHandlers,
|
|
13
14
|
...adminNodeLifecycleHandlers,
|
|
15
|
+
...adminNodeSanitizeHandlers,
|
|
14
16
|
];
|
|
15
17
|
|
|
16
18
|
export function adminCommandHandler(capability) {
|
|
@@ -112,7 +112,6 @@ function nodeContext(payload, expectedNodeId) {
|
|
|
112
112
|
}
|
|
113
113
|
const explicitReachability =
|
|
114
114
|
presentString(payload.reachability?.status) ??
|
|
115
|
-
presentString(payload.off_grid_reachability?.status) ??
|
|
116
115
|
(typeof payload.reachable === "boolean"
|
|
117
116
|
? payload.reachable
|
|
118
117
|
? "reachable"
|
|
@@ -127,10 +126,7 @@ function nodeContext(payload, expectedNodeId) {
|
|
|
127
126
|
presentString(payload.status) ??
|
|
128
127
|
"unknown",
|
|
129
128
|
reachability: explicitReachability ?? "unknown",
|
|
130
|
-
observed_at:
|
|
131
|
-
presentString(payload.observed_at) ??
|
|
132
|
-
presentString(payload.last_heartbeat_at) ??
|
|
133
|
-
presentString(payload.off_grid_reachability?.last_checked_at),
|
|
129
|
+
observed_at: presentString(payload.observed_at) ?? presentString(payload.last_heartbeat_at),
|
|
134
130
|
};
|
|
135
131
|
}
|
|
136
132
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getReservation } from "../vendor/api-client/commerce.js";
|
|
1
2
|
import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
|
|
2
3
|
import { isRecoverableWaitFailure } from "./interaction-runtime.mjs";
|
|
3
4
|
import { waitForGoal as waitForReadGoal } from "./wait-runtime.mjs";
|
|
@@ -9,8 +10,6 @@ const POLL_INTERVAL_MS = 1_000;
|
|
|
9
10
|
const COMMAND_BY_CAPABILITY = Object.freeze({
|
|
10
11
|
"nodes.reboot": "reboot",
|
|
11
12
|
"nodes.hard_reset": "hard-reset",
|
|
12
|
-
"node.off-grid": "suspend-management",
|
|
13
|
-
"node.on-grid": "resume-management",
|
|
14
13
|
"node.terminate": "terminate",
|
|
15
14
|
"nodes.assign": "assign",
|
|
16
15
|
});
|
|
@@ -25,30 +24,18 @@ const USAGE = Object.freeze({
|
|
|
25
24
|
reboot: "Usage: ornn admin nodes reboot <node-ref> [--no-wait] [--timeout SECONDS]",
|
|
26
25
|
"hard-reset":
|
|
27
26
|
"Usage: ornn admin nodes hard-reset <node-ref> --erase-local-data [--no-wait] [--timeout SECONDS]",
|
|
28
|
-
"suspend-management":
|
|
29
|
-
"Usage: ornn admin nodes suspend-management <node-ref> [--reason TEXT] [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
30
|
-
"resume-management":
|
|
31
|
-
"Usage: ornn admin nodes resume-management <node-ref> [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
32
27
|
terminate:
|
|
33
|
-
"Usage: ornn admin nodes terminate <node-ref> --delete-node-record [--
|
|
28
|
+
"Usage: ornn admin nodes terminate <node-ref> --delete-node-record [--reason TEXT] [--idempotency-key KEY]",
|
|
34
29
|
assign:
|
|
35
30
|
"Usage: ornn admin nodes assign <node-ref> --reservation RESERVATION [--idempotency-key KEY] [--wait|--no-wait] [--timeout SECONDS]",
|
|
36
31
|
});
|
|
37
32
|
|
|
38
|
-
const BOOLEAN_OPTIONS = new Set([
|
|
39
|
-
"delete-node-record",
|
|
40
|
-
"erase-local-data",
|
|
41
|
-
"no-wait",
|
|
42
|
-
"skip-cleanup",
|
|
43
|
-
"wait",
|
|
44
|
-
]);
|
|
33
|
+
const BOOLEAN_OPTIONS = new Set(["delete-node-record", "erase-local-data", "no-wait", "wait"]);
|
|
45
34
|
const VALUE_OPTIONS = new Set(["idempotency-key", "reason", "reservation", "timeout"]);
|
|
46
35
|
const ALLOWED_OPTIONS = Object.freeze({
|
|
47
36
|
reboot: new Set(["wait", "no-wait", "timeout"]),
|
|
48
37
|
"hard-reset": new Set(["erase-local-data", "wait", "no-wait", "timeout"]),
|
|
49
|
-
|
|
50
|
-
"resume-management": new Set(["idempotency-key", "wait", "no-wait", "timeout"]),
|
|
51
|
-
terminate: new Set(["delete-node-record", "skip-cleanup", "reason", "idempotency-key"]),
|
|
38
|
+
terminate: new Set(["delete-node-record", "reason", "idempotency-key"]),
|
|
52
39
|
assign: new Set(["reservation", "idempotency-key", "wait", "no-wait", "timeout"]),
|
|
53
40
|
});
|
|
54
41
|
|
|
@@ -152,13 +139,9 @@ export function parseAdminNodeLifecycleInput(command, argv) {
|
|
|
152
139
|
});
|
|
153
140
|
}
|
|
154
141
|
if (name !== "hard-reset" && options["erase-local-data"]) throw mutationError(usage);
|
|
155
|
-
if (name !== "terminate" &&
|
|
156
|
-
throw mutationError(usage);
|
|
157
|
-
}
|
|
142
|
+
if (name !== "terminate" && options["delete-node-record"]) throw mutationError(usage);
|
|
158
143
|
if (name !== "assign" && options.reservation) throw mutationError(usage);
|
|
159
|
-
if (
|
|
160
|
-
throw mutationError(usage);
|
|
161
|
-
}
|
|
144
|
+
if (name !== "terminate" && options.reason) throw mutationError(usage);
|
|
162
145
|
|
|
163
146
|
const idempotencyKey = options["idempotency-key"]?.trim();
|
|
164
147
|
const safety =
|
|
@@ -173,12 +156,7 @@ export function parseAdminNodeLifecycleInput(command, argv) {
|
|
|
173
156
|
...(options.wait ? { wait: true } : options["no-wait"] ? { wait: false } : {}),
|
|
174
157
|
...(options.timeout ? { timeout_seconds: durationSeconds(options.timeout, usage) } : {}),
|
|
175
158
|
...(name === "hard-reset" ? { erase_local_data: true } : {}),
|
|
176
|
-
...(name === "terminate"
|
|
177
|
-
? {
|
|
178
|
-
delete_node_record: true,
|
|
179
|
-
...(options["skip-cleanup"] ? { skip_cleanup: true } : {}),
|
|
180
|
-
}
|
|
181
|
-
: {}),
|
|
159
|
+
...(name === "terminate" ? { delete_node_record: true } : {}),
|
|
182
160
|
...(name === "assign" ? { reservation_id: options.reservation } : {}),
|
|
183
161
|
},
|
|
184
162
|
mutation: {
|
|
@@ -209,16 +187,7 @@ function nodeSnapshot(node) {
|
|
|
209
187
|
organization_id: text(node?.organization_id),
|
|
210
188
|
facility_id: text(node?.facility_id ?? node?.facilityId),
|
|
211
189
|
reservation_id: reservationId(node),
|
|
212
|
-
off_grid: node?.off_grid === true,
|
|
213
190
|
cleanup_state: text(node?.cleanup_state),
|
|
214
|
-
reachability:
|
|
215
|
-
node?.reachable === false || node?.health_readiness?.reachable === false
|
|
216
|
-
? "unreachable"
|
|
217
|
-
: text(
|
|
218
|
-
node?.reachability?.status ??
|
|
219
|
-
node?.off_grid_reachability?.status ??
|
|
220
|
-
node?.health_readiness?.reachability
|
|
221
|
-
),
|
|
222
191
|
};
|
|
223
192
|
}
|
|
224
193
|
|
|
@@ -226,18 +195,6 @@ function sameSnapshot(left, right) {
|
|
|
226
195
|
return JSON.stringify(nodeSnapshot(left)) === JSON.stringify(nodeSnapshot(right));
|
|
227
196
|
}
|
|
228
197
|
|
|
229
|
-
function isOffGrid(node) {
|
|
230
|
-
const snapshot = nodeSnapshot(node);
|
|
231
|
-
return snapshot.off_grid || ["off-grid", "off_grid"].includes(snapshot.status.toLowerCase());
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function isUnreachable(node) {
|
|
235
|
-
const snapshot = nodeSnapshot(node);
|
|
236
|
-
return (
|
|
237
|
-
isOffGrid(node) || ["unreachable", "offline"].includes(snapshot.reachability.toLowerCase())
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
198
|
function nodeRows(payload) {
|
|
242
199
|
if (Array.isArray(payload)) return payload;
|
|
243
200
|
return Array.isArray(payload?.nodes) ? payload.nodes : [];
|
|
@@ -348,20 +305,18 @@ async function resolveLiveMachine(fetch, node) {
|
|
|
348
305
|
function reservationSnapshot(reservation, requestedId) {
|
|
349
306
|
return {
|
|
350
307
|
reservation_id: text(reservation?.reservationId) || requestedId,
|
|
351
|
-
organization_id: text(reservation?.organizationId),
|
|
308
|
+
organization_id: text(reservation?.customerId ?? reservation?.organizationId),
|
|
352
309
|
listing_id: text(reservation?.listingId),
|
|
353
310
|
kind: text(reservation?.kind),
|
|
354
311
|
status: text(reservation?.status),
|
|
355
|
-
node_count: Number(reservation?.
|
|
312
|
+
node_count: Number(reservation?.reservedNodeCount ?? 0) || 0,
|
|
356
313
|
starts_at: text(reservation?.startsAt),
|
|
357
314
|
ends_at: text(reservation?.endsAt),
|
|
358
315
|
};
|
|
359
316
|
}
|
|
360
317
|
|
|
361
318
|
async function readReservation(fetch, id) {
|
|
362
|
-
const reservation = await fetch
|
|
363
|
-
method: "GET",
|
|
364
|
-
});
|
|
319
|
+
const reservation = await getReservation(fetch, id);
|
|
365
320
|
return reservationSnapshot(reservation, id);
|
|
366
321
|
}
|
|
367
322
|
|
|
@@ -403,11 +358,9 @@ function safeOwnerResult(result) {
|
|
|
403
358
|
const safe = {};
|
|
404
359
|
for (const key of [
|
|
405
360
|
"deleted",
|
|
406
|
-
"forced",
|
|
407
361
|
"id",
|
|
408
362
|
"mode",
|
|
409
363
|
"node_id",
|
|
410
|
-
"off_grid",
|
|
411
364
|
"packet_id",
|
|
412
365
|
"request_id",
|
|
413
366
|
"reservation_id",
|
|
@@ -475,47 +428,13 @@ async function prepareLifecycle({ command, fetch, input, machine }) {
|
|
|
475
428
|
};
|
|
476
429
|
}
|
|
477
430
|
|
|
478
|
-
if (command === "suspend-management") {
|
|
479
|
-
fields.push({ label: "Host power", value: "leave powered on" });
|
|
480
|
-
fields.push({ label: "Managed access", value: "remove agent management and managed SSH keys" });
|
|
481
|
-
if (input.reason) fields.push({ label: "Reason", value: input.reason });
|
|
482
|
-
return {
|
|
483
|
-
review: {
|
|
484
|
-
title: "Review management suspension",
|
|
485
|
-
fields,
|
|
486
|
-
consequence: "Ornn management and managed access end while the host remains powered on.",
|
|
487
|
-
},
|
|
488
|
-
value,
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (command === "resume-management") {
|
|
493
|
-
fields.push({ label: "Management", value: "remotely re-enroll the node" });
|
|
494
|
-
fields.push({ label: "Managed access", value: "restore after successful enrollment" });
|
|
495
|
-
return {
|
|
496
|
-
review: {
|
|
497
|
-
title: "Review management resumption",
|
|
498
|
-
fields,
|
|
499
|
-
consequence: "Ornn uses the stored admin access to reinstall management on this host.",
|
|
500
|
-
},
|
|
501
|
-
value,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
|
|
505
431
|
if (command === "terminate") {
|
|
506
|
-
if (input.skip_cleanup && !isUnreachable(node)) {
|
|
507
|
-
throw mutationError(
|
|
508
|
-
"--skip-cleanup is allowed only when the authoritative node read says the node is unreachable or off-grid.",
|
|
509
|
-
{ code: "skip_cleanup_requires_unreachable", field: "skip-cleanup" }
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
432
|
fields.push({
|
|
513
433
|
label: "Host cleanup",
|
|
514
|
-
value:
|
|
515
|
-
|
|
516
|
-
: "not dispatched by termination; complete any required host wipe separately before reuse",
|
|
434
|
+
value:
|
|
435
|
+
"not dispatched by termination; complete any required host wipe separately before reuse",
|
|
517
436
|
});
|
|
518
|
-
fields.push({ label: "Node record", value: "
|
|
437
|
+
fields.push({ label: "Node record", value: "MARK TERMINATED" });
|
|
519
438
|
fields.push({
|
|
520
439
|
label: "Reservation/capacity consequence",
|
|
521
440
|
value: reservationId(node)
|
|
@@ -529,7 +448,7 @@ async function prepareLifecycle({ command, fetch, input, machine }) {
|
|
|
529
448
|
title: "Review node termination",
|
|
530
449
|
fields,
|
|
531
450
|
consequence:
|
|
532
|
-
"The node record is
|
|
451
|
+
"The node record is marked terminated; this does not cancel its capacity reservation.",
|
|
533
452
|
},
|
|
534
453
|
value,
|
|
535
454
|
};
|
|
@@ -573,11 +492,11 @@ async function reconcileLifecycle({ fetch, input, value }) {
|
|
|
573
492
|
data: { node_id: id, status: "already_absent", deleted: false },
|
|
574
493
|
};
|
|
575
494
|
}
|
|
576
|
-
|
|
495
|
+
// Terminate keeps the record, so a re-run finds it terminated, not gone.
|
|
496
|
+
if (isTerminated(current)) {
|
|
577
497
|
return {
|
|
578
|
-
status: "
|
|
579
|
-
|
|
580
|
-
"The node is no longer authoritatively unreachable/off-grid; renew review without --skip-cleanup.",
|
|
498
|
+
status: "satisfied",
|
|
499
|
+
data: { node_id: id, status: "terminated", deleted: true },
|
|
581
500
|
};
|
|
582
501
|
}
|
|
583
502
|
if (!sameSnapshot(reviewedNode, current)) {
|
|
@@ -590,29 +509,6 @@ async function reconcileLifecycle({ fetch, input, value }) {
|
|
|
590
509
|
}
|
|
591
510
|
if (!current) return { status: "conflict", message: "The exact node no longer exists." };
|
|
592
511
|
|
|
593
|
-
if (command === "suspend-management") {
|
|
594
|
-
if (isOffGrid(current)) {
|
|
595
|
-
return { status: "satisfied", data: { node_id: id, status: "off-grid", off_grid: true } };
|
|
596
|
-
}
|
|
597
|
-
if (!sameSnapshot(reviewedNode, current)) {
|
|
598
|
-
return { status: "conflict", message: "The node's management goal changed after review." };
|
|
599
|
-
}
|
|
600
|
-
return { status: "apply" };
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (command === "resume-management") {
|
|
604
|
-
if (!isOffGrid(current)) {
|
|
605
|
-
return {
|
|
606
|
-
status: "satisfied",
|
|
607
|
-
data: { node_id: id, status: nodeSnapshot(current).status, off_grid: false },
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
if (!sameSnapshot(reviewedNode, current)) {
|
|
611
|
-
return { status: "conflict", message: "The node's management goal changed after review." };
|
|
612
|
-
}
|
|
613
|
-
return { status: "apply" };
|
|
614
|
-
}
|
|
615
|
-
|
|
616
512
|
if (command === "assign") {
|
|
617
513
|
const currentReservation = reservationId(current);
|
|
618
514
|
if (currentReservation === input.reservation_id) {
|
|
@@ -660,15 +556,17 @@ async function reconcileLifecycle({ fetch, input, value }) {
|
|
|
660
556
|
}
|
|
661
557
|
|
|
662
558
|
function goalSatisfied(command, node, input) {
|
|
663
|
-
if (command === "terminate") return node === null;
|
|
559
|
+
if (command === "terminate") return node === null || isTerminated(node);
|
|
664
560
|
if (!node) return false;
|
|
665
|
-
if (command === "suspend-management") return isOffGrid(node);
|
|
666
|
-
if (command === "resume-management") return !isOffGrid(node);
|
|
667
561
|
if (command === "assign") return reservationId(node) === input.reservation_id;
|
|
668
562
|
if (cleanupState(node) === "failed") return false;
|
|
669
563
|
return !restartInProgress(node);
|
|
670
564
|
}
|
|
671
565
|
|
|
566
|
+
function isTerminated(node) {
|
|
567
|
+
return text(node?.status).toLowerCase() === "terminated";
|
|
568
|
+
}
|
|
569
|
+
|
|
672
570
|
function cleanupState(node) {
|
|
673
571
|
return text(node?.cleanup_state).toLowerCase();
|
|
674
572
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
|
|
5
|
+
import { parseSemanticArguments } from "./interaction-runtime.mjs";
|
|
6
|
+
|
|
7
|
+
const MANUAL_CLEANUP = "ACCEPT MANUAL CLEANUP";
|
|
8
|
+
|
|
9
|
+
function usageError(capability, detail, field) {
|
|
10
|
+
return new AdminMutationError(
|
|
11
|
+
[`Usage: ${capability.cli.vnext.human}`, detail].filter(Boolean).join("\n"),
|
|
12
|
+
{ code: "usage_error", ...(field ? { field } : {}) }
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function semanticArguments(argv, capability) {
|
|
17
|
+
const { $positionals, ...options } = parseSemanticArguments(argv);
|
|
18
|
+
if ($positionals.length !== 1 || !$positionals[0]) {
|
|
19
|
+
throw usageError(capability, "Pass exactly one canonical node ID.", "node-id");
|
|
20
|
+
}
|
|
21
|
+
return { nodeId: $positionals[0], options };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rejectExtra(capability, extra) {
|
|
25
|
+
const [name] = Object.keys(extra);
|
|
26
|
+
if (name) throw usageError(capability, `Unknown option: --${name}`, name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validated(capability, input) {
|
|
30
|
+
const parsed = capability.input.safeParse(input);
|
|
31
|
+
if (!parsed.success) throw usageError(capability, parsed.error.message);
|
|
32
|
+
return parsed.data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evidenceSha256(capability, path) {
|
|
36
|
+
try {
|
|
37
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw usageError(
|
|
40
|
+
capability,
|
|
41
|
+
`Could not read --evidence-file: ${error instanceof Error ? error.message : String(error)}`,
|
|
42
|
+
"evidence-file"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseStart(argv, capability) {
|
|
48
|
+
const {
|
|
49
|
+
nodeId,
|
|
50
|
+
options: { operation, confirmation, jobId, batch, recoveryUuid, ...extra },
|
|
51
|
+
} = semanticArguments(argv, capability);
|
|
52
|
+
rejectExtra(capability, extra);
|
|
53
|
+
if (typeof operation !== "string") throw usageError(capability, "--operation is required.");
|
|
54
|
+
const input = validated(capability, {
|
|
55
|
+
node_id: nodeId,
|
|
56
|
+
job_id: jobId ?? randomUUID(),
|
|
57
|
+
operation,
|
|
58
|
+
...(batch ? { maintenance_batch_id: batch } : {}),
|
|
59
|
+
...(recoveryUuid ? { recovery_uuid: recoveryUuid } : {}),
|
|
60
|
+
confirmation,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
input,
|
|
64
|
+
mutation: { idempotencyKey: input.job_id, safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseStatus(argv, capability) {
|
|
69
|
+
const {
|
|
70
|
+
nodeId,
|
|
71
|
+
options: { job, ...extra },
|
|
72
|
+
} = semanticArguments(argv, capability);
|
|
73
|
+
rejectExtra(capability, extra);
|
|
74
|
+
return { input: validated(capability, { node_id: nodeId, job_id: job }) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseDecision(argv, capability) {
|
|
78
|
+
const {
|
|
79
|
+
nodeId,
|
|
80
|
+
options: {
|
|
81
|
+
job,
|
|
82
|
+
confirmation,
|
|
83
|
+
reservation,
|
|
84
|
+
releaseOperation,
|
|
85
|
+
reason,
|
|
86
|
+
evidenceFile,
|
|
87
|
+
tenantAccessRevoked,
|
|
88
|
+
dataDrivesSanitized,
|
|
89
|
+
acknowledgeRetainedOs,
|
|
90
|
+
...extra
|
|
91
|
+
},
|
|
92
|
+
} = semanticArguments(argv, capability);
|
|
93
|
+
rejectExtra(capability, extra);
|
|
94
|
+
const cleanupOptions = {
|
|
95
|
+
reservation,
|
|
96
|
+
releaseOperation,
|
|
97
|
+
reason,
|
|
98
|
+
evidenceFile,
|
|
99
|
+
tenantAccessRevoked,
|
|
100
|
+
dataDrivesSanitized,
|
|
101
|
+
acknowledgeRetainedOs,
|
|
102
|
+
};
|
|
103
|
+
if (confirmation !== MANUAL_CLEANUP) {
|
|
104
|
+
const [supplied] =
|
|
105
|
+
Object.entries(cleanupOptions).find(([, value]) => value !== undefined) ?? [];
|
|
106
|
+
if (supplied) {
|
|
107
|
+
throw usageError(
|
|
108
|
+
capability,
|
|
109
|
+
`Manual cleanup options require --confirmation "${MANUAL_CLEANUP}".`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
input: validated(capability, { node_id: nodeId, job_id: job, confirmation }),
|
|
114
|
+
mutation: { safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Each attestation is the operator's own flag, never a default.
|
|
118
|
+
for (const [name, value] of Object.entries(cleanupOptions)) {
|
|
119
|
+
if (value === undefined || value === "") {
|
|
120
|
+
const flag = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
121
|
+
throw usageError(capability, `--${flag} is required for manual cleanup.`, flag);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const input = validated(capability, {
|
|
125
|
+
node_id: nodeId,
|
|
126
|
+
job_id: job,
|
|
127
|
+
confirmation,
|
|
128
|
+
manual_cleanup: {
|
|
129
|
+
reservation_id: reservation,
|
|
130
|
+
lifecycle_operation_id: releaseOperation,
|
|
131
|
+
reason,
|
|
132
|
+
evidence_sha256: evidenceSha256(capability, evidenceFile),
|
|
133
|
+
acknowledge_retained_os: true,
|
|
134
|
+
tenant_access_revoked: true,
|
|
135
|
+
data_drives_sanitized: true,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return { input, mutation: { safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const startConsequence = {
|
|
142
|
+
data_wipe: "Erases every local data drive; the OS drive is preserved.",
|
|
143
|
+
ram_roundtrip: "Boots the node through RAM and back without erasing drives.",
|
|
144
|
+
node_turnover: "Erases the whole node, including the OS drive, and restores a host image.",
|
|
145
|
+
host_restore: "Restores the host OS from its trusted image.",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function decisionReview(input) {
|
|
149
|
+
const fields = [
|
|
150
|
+
{ label: "Node", value: input.node_id },
|
|
151
|
+
{ label: "Sanitize job", value: input.job_id },
|
|
152
|
+
{ label: "Decision", value: input.confirmation },
|
|
153
|
+
];
|
|
154
|
+
if (input.confirmation === "APPROVE SANITIZATION") {
|
|
155
|
+
return { fields, consequence: "Starts the held destructive maintenance on this node." };
|
|
156
|
+
}
|
|
157
|
+
if (input.confirmation === "SKIP SANITIZATION") {
|
|
158
|
+
return {
|
|
159
|
+
fields,
|
|
160
|
+
consequence: "Skips an eligible first-use wipe; nothing is erased or certified.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const cleanup = input.manual_cleanup;
|
|
164
|
+
return {
|
|
165
|
+
fields: [
|
|
166
|
+
...fields,
|
|
167
|
+
{ label: "Reservation", value: cleanup.reservation_id },
|
|
168
|
+
{ label: "Release operation", value: cleanup.lifecycle_operation_id },
|
|
169
|
+
{ label: "Reason", value: cleanup.reason },
|
|
170
|
+
{ label: "Evidence SHA-256", value: cleanup.evidence_sha256 },
|
|
171
|
+
{
|
|
172
|
+
label: "You attest",
|
|
173
|
+
value:
|
|
174
|
+
"tenant access revoked; every local data drive sanitized; existing OS retained and not certified",
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
consequence:
|
|
178
|
+
"Lets the held release finish without a full wipe. The boot disk and prior root exposure remain.",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runStart({ execute, input, mutation, runtime }) {
|
|
183
|
+
return runAdminMutation({
|
|
184
|
+
...runtime,
|
|
185
|
+
...mutation,
|
|
186
|
+
prepare: async () => ({
|
|
187
|
+
review: {
|
|
188
|
+
title: "Start node sanitization",
|
|
189
|
+
fields: [
|
|
190
|
+
{ label: "Node", value: input.node_id },
|
|
191
|
+
{ label: "Operation", value: input.operation ?? "data_wipe" },
|
|
192
|
+
...(input.recovery_uuid ? [{ label: "Recovery UUID", value: input.recovery_uuid }] : []),
|
|
193
|
+
{ label: "Sanitize job", value: input.job_id },
|
|
194
|
+
{ label: "Confirmation", value: input.confirmation },
|
|
195
|
+
],
|
|
196
|
+
consequence: `${startConsequence[input.operation ?? "data_wipe"]} The job waits for staff approval.`,
|
|
197
|
+
},
|
|
198
|
+
value: input,
|
|
199
|
+
}),
|
|
200
|
+
execute: (value) => execute(value, { confirmed: true }),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runStatus({ execute, input }) {
|
|
205
|
+
return {
|
|
206
|
+
data: await execute(input),
|
|
207
|
+
exitCode: 0,
|
|
208
|
+
outcome: { class: "ready", phase: "complete" },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runDecision({ execute, input, mutation, runtime }) {
|
|
213
|
+
return runAdminMutation({
|
|
214
|
+
...runtime,
|
|
215
|
+
...mutation,
|
|
216
|
+
prepare: async () => ({
|
|
217
|
+
review: { title: "Decide node sanitize job", ...decisionReview(input) },
|
|
218
|
+
value: input,
|
|
219
|
+
}),
|
|
220
|
+
execute: (value) => execute(value, { confirmed: true }),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const adminNodeSanitizeHandlers = [
|
|
225
|
+
{
|
|
226
|
+
capabilityId: "node.sanitize.start",
|
|
227
|
+
parse: parseStart,
|
|
228
|
+
run: runStart,
|
|
229
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
capabilityId: "node.sanitize.show",
|
|
233
|
+
parse: parseStatus,
|
|
234
|
+
run: runStatus,
|
|
235
|
+
safety: ADMIN_MUTATION_SAFETY.direct,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
capabilityId: "node.sanitize.update",
|
|
239
|
+
parse: parseDecision,
|
|
240
|
+
run: runDecision,
|
|
241
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
242
|
+
},
|
|
243
|
+
];
|