@ornncompute/cli 0.2.24 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +351 -426
- package/package.json +12 -6
- package/src/admin-capacity-reservations.mjs +305 -0
- package/src/admin-command-handlers.mjs +57 -0
- package/src/admin-enrollment.mjs +681 -0
- package/src/admin-mutations.mjs +187 -0
- package/src/admin-node-diagnostics.mjs +412 -0
- package/src/admin-node-lifecycle.mjs +905 -0
- package/src/admin-shell.mjs +456 -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 +1363 -0
- package/src/cli.mjs +123 -10266
- package/src/connect-mode-handlers.mjs +170 -0
- package/src/device-auth.mjs +179 -62
- package/src/evidence-commands.mjs +1354 -0
- package/src/external-bucket-commands.mjs +1374 -0
- package/src/gpu-catalog.mjs +21 -0
- package/src/identity-file.mjs +28 -0
- package/src/interaction-runtime.mjs +310 -0
- package/src/kubernetes-commands.mjs +2235 -0
- package/src/launch-mode-handlers.mjs +119 -0
- package/src/machine-reference.mjs +285 -0
- package/src/mount-commands.mjs +1148 -0
- package/src/session-commands.mjs +565 -0
- package/src/slurm-commands.mjs +274 -0
- package/src/startup.mjs +34 -11
- package/src/storage-commands.mjs +1164 -0
- package/src/update.mjs +172 -24
- package/src/vm-commands.mjs +4308 -0
- package/src/vpn-secret-output.mjs +150 -0
- package/src/wait-runtime.mjs +231 -0
- package/vendor/capabilities/capabilities/admin-capacity-reservations.js +96 -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/cli.js +156 -0
- package/vendor/capabilities/capabilities/clusters.js +150 -1
- package/vendor/capabilities/capabilities/commerce-market.js +330 -0
- 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 +77 -25
- package/vendor/capabilities/capabilities/identity.js +114 -3
- package/vendor/capabilities/capabilities/listings.js +141 -144
- package/vendor/capabilities/capabilities/market-shared.js +44 -0
- package/vendor/capabilities/capabilities/markets.js +449 -0
- package/vendor/capabilities/capabilities/mounts.js +176 -0
- package/vendor/capabilities/capabilities/networks.js +1264 -0
- package/vendor/capabilities/capabilities/observability.js +38 -8
- package/vendor/capabilities/capabilities/tokens.js +32 -17
- package/vendor/capabilities/capabilities/volumes.js +178 -0
- package/vendor/capabilities/capability.js +21 -14
- package/vendor/capabilities/cli-catalog.js +67 -0
- package/vendor/capabilities/cli-migrations.js +261 -0
- package/vendor/capabilities/cli.js +7 -0
- package/vendor/capabilities/software-catalog.js +36 -0
- package/vendor/capabilities/spec-catalog.js +28 -5
- package/vendor/capabilities/supplier-directory.js +20 -0
- 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,681 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { CliApiError, resolveInstallerBaseUrl } from "./api-client.mjs";
|
|
5
|
+
import { AtomicOutputExistsError, writeAtomicOutput } from "./atomic-output.mjs";
|
|
6
|
+
import { ADMIN_MUTATION_SAFETY, AdminMutationError, runAdminMutation } from "./admin-mutations.mjs";
|
|
7
|
+
|
|
8
|
+
const SECRET_OUTPUT = new Set(["tokens.create", "nodes.rotate-key"]);
|
|
9
|
+
const ENROLLMENT_LOG_POLL_MS = 750;
|
|
10
|
+
const ENROLLMENT_LOG_READ_TIMEOUT_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
const USAGE = {
|
|
13
|
+
"tokens.create":
|
|
14
|
+
"Usage: ornn admin enrollment-tokens create --supplier UUID --expires-in SECONDS (--print | --output PATH) [--overwrite] [--yes]",
|
|
15
|
+
"tokens.revoke":
|
|
16
|
+
"Usage: ornn admin enrollment-tokens revoke <token-id> [--expected-created-at TIME] [--yes] [--idempotency-key KEY]",
|
|
17
|
+
"nodes.enroll":
|
|
18
|
+
"Usage: ornn admin nodes enroll <node-id> --host HOST --supplier UUID --facility UUID [--filter COMPARTMENT_IDS] --identity-file PATH --token-ttl SECONDS --replace-existing-management [--verbose] [--yes]",
|
|
19
|
+
"nodes.export-key":
|
|
20
|
+
"Usage: ornn admin nodes export-key <node-id> (--print | --output PATH) [--overwrite]",
|
|
21
|
+
"nodes.rotate-key":
|
|
22
|
+
"Usage: ornn admin nodes rotate-key <node-id> (--print | --output PATH) [--overwrite] [--yes]",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function parseOptions(argv, { booleans = [], positionals, values }, usage) {
|
|
26
|
+
const options = {};
|
|
27
|
+
const positionalValues = [];
|
|
28
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
+
const argument = argv[index];
|
|
30
|
+
if (!argument.startsWith("--")) {
|
|
31
|
+
positionalValues.push(argument);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const separator = argument.indexOf("=");
|
|
35
|
+
const name = argument.slice(2, separator === -1 ? undefined : separator);
|
|
36
|
+
if (booleans.includes(name)) {
|
|
37
|
+
if (separator !== -1 || options[name] !== undefined) {
|
|
38
|
+
throw new AdminMutationError(
|
|
39
|
+
`${usage}\n--${name} does not accept a value and may be supplied only once.`,
|
|
40
|
+
{
|
|
41
|
+
field: name,
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
options[name] = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!values.includes(name)) {
|
|
49
|
+
throw new AdminMutationError(`${usage}\nUnknown option: --${name}`);
|
|
50
|
+
}
|
|
51
|
+
const value = separator === -1 ? argv[index + 1] : argument.slice(separator + 1);
|
|
52
|
+
if (!value || (separator === -1 && value.startsWith("--"))) {
|
|
53
|
+
throw new AdminMutationError(`${usage}\n--${name} requires a value.`, { field: name });
|
|
54
|
+
}
|
|
55
|
+
if (options[name] !== undefined) {
|
|
56
|
+
throw new AdminMutationError(`${usage}\n--${name} may be supplied only once.`, {
|
|
57
|
+
field: name,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
options[name] = value;
|
|
61
|
+
if (separator === -1) index += 1;
|
|
62
|
+
}
|
|
63
|
+
if (positionalValues.length !== positionals) throw new AdminMutationError(usage);
|
|
64
|
+
return { options, positionals: positionalValues };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function required(options, name, usage) {
|
|
68
|
+
const value = options[name]?.trim();
|
|
69
|
+
if (!value) throw new AdminMutationError(`${usage}\n--${name} is required.`, { field: name });
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function uuid(value, name) {
|
|
74
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
75
|
+
throw new AdminMutationError(`${name} must be a canonical UUID.`, { field: name });
|
|
76
|
+
}
|
|
77
|
+
return value.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function boundedInteger(value, name, minimum, maximum) {
|
|
81
|
+
const parsed = Number(value);
|
|
82
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
83
|
+
throw new AdminMutationError(`--${name} must be an integer from ${minimum} to ${maximum}.`, {
|
|
84
|
+
field: name,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function secretExport(options, usage) {
|
|
91
|
+
const print = options.print === true;
|
|
92
|
+
const output = options.output?.trim();
|
|
93
|
+
if (print === Boolean(output)) {
|
|
94
|
+
throw new AdminMutationError(`${usage}\nChoose exactly one of --print or --output PATH.`, {
|
|
95
|
+
field: "output",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (options.overwrite && !output) {
|
|
99
|
+
throw new AdminMutationError(`${usage}\n--overwrite requires --output PATH.`, {
|
|
100
|
+
field: "overwrite",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { output, overwrite: options.overwrite === true, print };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseTokenCreate(argv) {
|
|
107
|
+
const usage = USAGE["tokens.create"];
|
|
108
|
+
const { options } = parseOptions(
|
|
109
|
+
argv,
|
|
110
|
+
{
|
|
111
|
+
booleans: ["overwrite", "print", "yes"],
|
|
112
|
+
positionals: 0,
|
|
113
|
+
values: ["supplier", "expires-in", "output"],
|
|
114
|
+
},
|
|
115
|
+
usage
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
input: {
|
|
119
|
+
organization_id: uuid(required(options, "supplier", usage), "supplier"),
|
|
120
|
+
expires_in_seconds: boundedInteger(
|
|
121
|
+
required(options, "expires-in", usage),
|
|
122
|
+
"expires-in",
|
|
123
|
+
60,
|
|
124
|
+
86_400
|
|
125
|
+
),
|
|
126
|
+
export: secretExport(options, usage),
|
|
127
|
+
},
|
|
128
|
+
mutation: {
|
|
129
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
130
|
+
yes: options.yes === true,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseTokenRevoke(argv) {
|
|
136
|
+
const usage = USAGE["tokens.revoke"];
|
|
137
|
+
const { options, positionals } = parseOptions(
|
|
138
|
+
argv,
|
|
139
|
+
{ booleans: ["yes"], positionals: 1, values: ["expected-created-at", "idempotency-key"] },
|
|
140
|
+
usage
|
|
141
|
+
);
|
|
142
|
+
return {
|
|
143
|
+
input: {
|
|
144
|
+
token_id: uuid(positionals[0], "token-id"),
|
|
145
|
+
...(options["expected-created-at"]?.trim()
|
|
146
|
+
? { expected_created_at: options["expected-created-at"].trim() }
|
|
147
|
+
: {}),
|
|
148
|
+
...(options["idempotency-key"]?.trim()
|
|
149
|
+
? { idempotency_key: options["idempotency-key"].trim() }
|
|
150
|
+
: {}),
|
|
151
|
+
},
|
|
152
|
+
mutation: {
|
|
153
|
+
idempotencyKey: options["idempotency-key"]?.trim(),
|
|
154
|
+
safety: ADMIN_MUTATION_SAFETY.reconcile,
|
|
155
|
+
yes: options.yes === true,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseNodeEnroll(argv) {
|
|
161
|
+
const usage = USAGE["nodes.enroll"];
|
|
162
|
+
const { options, positionals } = parseOptions(
|
|
163
|
+
argv,
|
|
164
|
+
{
|
|
165
|
+
booleans: ["replace-existing-management", "verbose", "yes"],
|
|
166
|
+
positionals: 1,
|
|
167
|
+
values: [
|
|
168
|
+
"facility",
|
|
169
|
+
"filter",
|
|
170
|
+
"host",
|
|
171
|
+
"identity-file",
|
|
172
|
+
"port",
|
|
173
|
+
"supplier",
|
|
174
|
+
"token-ttl",
|
|
175
|
+
"username",
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
usage
|
|
179
|
+
);
|
|
180
|
+
const filter = options.filter?.trim();
|
|
181
|
+
if (!options["replace-existing-management"]) {
|
|
182
|
+
throw new AdminMutationError(
|
|
183
|
+
`${usage}\n--replace-existing-management is required and cannot be inferred by --yes.`,
|
|
184
|
+
{
|
|
185
|
+
field: "replace-existing-management",
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
input: {
|
|
191
|
+
node_id: uuid(positionals[0], "node-id"),
|
|
192
|
+
host: required(options, "host", usage),
|
|
193
|
+
organization_id: uuid(required(options, "supplier", usage), "supplier"),
|
|
194
|
+
facility_id: uuid(required(options, "facility", usage), "facility"),
|
|
195
|
+
...(filter ? { filter } : {}),
|
|
196
|
+
identity_file: required(options, "identity-file", usage),
|
|
197
|
+
expires_in_seconds: boundedInteger(
|
|
198
|
+
required(options, "token-ttl", usage),
|
|
199
|
+
"token-ttl",
|
|
200
|
+
60,
|
|
201
|
+
86_400
|
|
202
|
+
),
|
|
203
|
+
username: options.username?.trim() || "root",
|
|
204
|
+
port: options.port ? boundedInteger(options.port, "port", 1, 65_535) : 22,
|
|
205
|
+
replace_existing_management: true,
|
|
206
|
+
verbose: options.verbose === true,
|
|
207
|
+
},
|
|
208
|
+
mutation: {
|
|
209
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
210
|
+
yes: options.yes === true,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseNodeKey(argv, rotate) {
|
|
216
|
+
const capabilityId = rotate ? "nodes.rotate-key" : "nodes.export-key";
|
|
217
|
+
const usage = USAGE[capabilityId];
|
|
218
|
+
const { options, positionals } = parseOptions(
|
|
219
|
+
argv,
|
|
220
|
+
{
|
|
221
|
+
booleans: rotate ? ["overwrite", "print", "yes"] : ["overwrite", "print"],
|
|
222
|
+
positionals: 1,
|
|
223
|
+
values: ["output"],
|
|
224
|
+
},
|
|
225
|
+
usage
|
|
226
|
+
);
|
|
227
|
+
return {
|
|
228
|
+
input: {
|
|
229
|
+
node_id: uuid(positionals[0], "node-id"),
|
|
230
|
+
export: secretExport(options, usage),
|
|
231
|
+
},
|
|
232
|
+
...(rotate
|
|
233
|
+
? {
|
|
234
|
+
mutation: {
|
|
235
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
236
|
+
yes: options.yes === true,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function text(value, fallback = "") {
|
|
244
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function safeTokenMetadata(payload) {
|
|
248
|
+
return {
|
|
249
|
+
id: text(payload?.id) || null,
|
|
250
|
+
organization_id: text(payload?.organization_id) || null,
|
|
251
|
+
status: text(payload?.status, "active"),
|
|
252
|
+
expires_at: text(payload?.expires_at) || null,
|
|
253
|
+
created_at: text(payload?.created_at) || null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function secretMaterial(capabilityId, payload) {
|
|
258
|
+
if (capabilityId === "tokens.create") {
|
|
259
|
+
return text(payload?.token) || text(payload?.plaintext);
|
|
260
|
+
}
|
|
261
|
+
const key = payload?.private_key;
|
|
262
|
+
if (typeof key !== "string" || !key.length) return "";
|
|
263
|
+
// OpenSSH rejects otherwise valid private keys without a terminating newline.
|
|
264
|
+
return key.endsWith("\n") ? key : `${key}\n`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function keyMetadata(input, payload) {
|
|
268
|
+
return {
|
|
269
|
+
node_id: input.node_id,
|
|
270
|
+
fingerprint: text(payload?.fingerprint) || null,
|
|
271
|
+
...(typeof payload?.host_dispatched === "boolean"
|
|
272
|
+
? { host_dispatched: payload.host_dispatched }
|
|
273
|
+
: {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function secretFingerprint(content) {
|
|
278
|
+
return `SHA256:${createHash("sha256").update(content).digest("base64url")}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function refuseExistingOutput(options) {
|
|
282
|
+
if (!options.output || options.overwrite) return;
|
|
283
|
+
try {
|
|
284
|
+
await lstat(options.output);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error?.code === "ENOENT") return;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
throw new AdminMutationError(
|
|
290
|
+
`Refusing to overwrite ${options.output}. Re-run with --overwrite to replace it deliberately.`,
|
|
291
|
+
{ code: "output_exists", field: "output" }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function exportSecret({ capabilityId, input, options, payload, stdout }) {
|
|
296
|
+
const material = secretMaterial(capabilityId, payload);
|
|
297
|
+
if (!material) {
|
|
298
|
+
throw new CliApiError("The owner did not return exportable secret material.", {
|
|
299
|
+
detail: { code: "secret_material_unavailable", retryable: false },
|
|
300
|
+
status: 502,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const metadata =
|
|
304
|
+
capabilityId === "tokens.create"
|
|
305
|
+
? { token_metadata: safeTokenMetadata(payload) }
|
|
306
|
+
: { node_key: keyMetadata(input, payload) };
|
|
307
|
+
if (options.print) {
|
|
308
|
+
stdout.write(material);
|
|
309
|
+
return { raw: true, data: null };
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const output = await writeAtomicOutput(options.output, material, {
|
|
313
|
+
mode: 0o600,
|
|
314
|
+
overwrite: options.overwrite,
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
raw: false,
|
|
318
|
+
data: {
|
|
319
|
+
...metadata,
|
|
320
|
+
output: {
|
|
321
|
+
path: output.path,
|
|
322
|
+
mode: "0600",
|
|
323
|
+
fingerprint:
|
|
324
|
+
capabilityId === "tokens.create"
|
|
325
|
+
? secretFingerprint(material)
|
|
326
|
+
: text(payload?.fingerprint) || secretFingerprint(material),
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error instanceof AtomicOutputExistsError) {
|
|
332
|
+
throw new AdminMutationError(
|
|
333
|
+
`Refusing to overwrite ${options.output}. Re-run with --overwrite to replace it deliberately.`,
|
|
334
|
+
{ code: "output_exists", field: "output" }
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function reviewFor(capabilityId, input, prepared) {
|
|
342
|
+
if (capabilityId === "tokens.create") {
|
|
343
|
+
return {
|
|
344
|
+
title: "Create enrollment token",
|
|
345
|
+
fields: [
|
|
346
|
+
{ label: "Supplier", value: input.organization_id },
|
|
347
|
+
{ label: "Expires in", value: `${input.expires_in_seconds} seconds` },
|
|
348
|
+
{
|
|
349
|
+
label: "Secret destination",
|
|
350
|
+
value: input.export.print ? "stdout (--print)" : input.export.output,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
consequence: "Creates a bearer credential that can enroll a node until it expires.",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (capabilityId === "tokens.revoke") {
|
|
357
|
+
const target = prepared?.target;
|
|
358
|
+
return {
|
|
359
|
+
title: "Revoke enrollment token",
|
|
360
|
+
fields: [
|
|
361
|
+
{ label: "Token", value: input.token_id },
|
|
362
|
+
{ label: "Created", value: target?.created_at ?? input.expected_created_at ?? "absent" },
|
|
363
|
+
{ label: "Supplier", value: target?.organization_id ?? "unknown" },
|
|
364
|
+
{ label: "Expires", value: target?.expires_at ?? "unknown" },
|
|
365
|
+
],
|
|
366
|
+
consequence: "Prevents this exact token generation from enrolling another node.",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (capabilityId === "nodes.enroll") {
|
|
370
|
+
return {
|
|
371
|
+
title: "Enroll node",
|
|
372
|
+
fields: [
|
|
373
|
+
{ label: "Node", value: input.node_id },
|
|
374
|
+
{ label: "SSH target", value: `${input.username}@${input.host}:${input.port}` },
|
|
375
|
+
{ label: "Supplier", value: input.organization_id },
|
|
376
|
+
{ label: "Facility", value: input.facility_id },
|
|
377
|
+
...(input.filter
|
|
378
|
+
? [{ label: "Compartment filter", value: input.filter }]
|
|
379
|
+
: []),
|
|
380
|
+
{ label: "Token lifetime", value: `${input.expires_in_seconds} seconds` },
|
|
381
|
+
],
|
|
382
|
+
consequence:
|
|
383
|
+
"Replaces existing Ornn management and claims this host for the stated assignment.",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
title: "Rotate node admin key",
|
|
388
|
+
fields: [
|
|
389
|
+
{ label: "Node", value: input.node_id },
|
|
390
|
+
{
|
|
391
|
+
label: "Secret destination",
|
|
392
|
+
value: input.export.print ? "stdout (--print)" : input.export.output,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
consequence:
|
|
396
|
+
"Revokes access through the previous private key and dispatches the replacement public key.",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function enrollmentIdentityInput(input) {
|
|
401
|
+
try {
|
|
402
|
+
const privateKey = await readFile(input.identity_file, "utf8");
|
|
403
|
+
if (!privateKey.trim()) {
|
|
404
|
+
throw new Error("file is empty");
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
...input,
|
|
408
|
+
installer_base: resolveInstallerBaseUrl({ env: input.env }) ?? undefined,
|
|
409
|
+
private_key: privateKey,
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
413
|
+
throw new AdminMutationError(`Could not read --identity-file: ${reason}`, {
|
|
414
|
+
code: "identity_file_unreadable",
|
|
415
|
+
field: "identity-file",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function enrollmentRuns(payload) {
|
|
421
|
+
if (!payload || typeof payload !== "object") return [];
|
|
422
|
+
if (Array.isArray(payload.logs)) return payload.logs;
|
|
423
|
+
return Array.isArray(payload.runs) ? payload.runs : [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function redactEnrollmentText(value, secrets) {
|
|
427
|
+
let text = typeof value === "string" ? value : String(value ?? "");
|
|
428
|
+
for (const secret of secrets) {
|
|
429
|
+
if (!secret) continue;
|
|
430
|
+
text = text.replaceAll(secret, "[redacted]");
|
|
431
|
+
text = text.replaceAll(encodeURIComponent(secret), "[redacted]");
|
|
432
|
+
}
|
|
433
|
+
return text;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function freshEnrollmentLogLines(payload, tokenId, secrets, seen) {
|
|
437
|
+
const session = enrollmentRuns(payload).find((candidate) => candidate?.token_id === tokenId);
|
|
438
|
+
if (!session || typeof session !== "object") return [];
|
|
439
|
+
const compartments = Array.isArray(session.compartments) ? session.compartments : [];
|
|
440
|
+
const total = Number.isInteger(session.progress_total)
|
|
441
|
+
? session.progress_total
|
|
442
|
+
: compartments.length;
|
|
443
|
+
const lines = [];
|
|
444
|
+
for (const [index, compartment] of compartments.entries()) {
|
|
445
|
+
if (!compartment || typeof compartment !== "object") continue;
|
|
446
|
+
const id = String(compartment.id ?? index);
|
|
447
|
+
const numericId = Number(id);
|
|
448
|
+
const current = Number.isInteger(numericId) ? numericId + 1 : session.progress_index;
|
|
449
|
+
const title =
|
|
450
|
+
typeof compartment.title === "string" && compartment.title ? compartment.title : `Step ${id}`;
|
|
451
|
+
const state = typeof compartment.state === "string" ? compartment.state : "";
|
|
452
|
+
const stateKey = `${id}:state:${state}`;
|
|
453
|
+
if (state && !seen.has(stateKey)) {
|
|
454
|
+
seen.add(stateKey);
|
|
455
|
+
lines.push(`[enroll ${current}/${total}] ${title}: ${state}`);
|
|
456
|
+
}
|
|
457
|
+
const output = Array.isArray(compartment.output) ? compartment.output : [];
|
|
458
|
+
for (const [outputIndex, entry] of output.entries()) {
|
|
459
|
+
const stream =
|
|
460
|
+
entry && typeof entry === "object" && typeof entry.stream === "string"
|
|
461
|
+
? entry.stream
|
|
462
|
+
: "stdout";
|
|
463
|
+
const seq =
|
|
464
|
+
entry && typeof entry === "object" && Number.isInteger(entry.seq) ? entry.seq : outputIndex;
|
|
465
|
+
const key = `${id}:output:${seq}:${stream}`;
|
|
466
|
+
if (seen.has(key)) continue;
|
|
467
|
+
seen.add(key);
|
|
468
|
+
const raw = entry && typeof entry === "object" ? entry.text : entry;
|
|
469
|
+
const text = redactEnrollmentText(raw, secrets);
|
|
470
|
+
if (text) lines.push(` ${stream === "stderr" ? "[stderr] " : ""}${text}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return lines;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function enrollmentProgressTransport(fetch, runtime, privateKey) {
|
|
477
|
+
let tokenId = "";
|
|
478
|
+
const secrets = [privateKey];
|
|
479
|
+
const seen = new Set();
|
|
480
|
+
const request = (path, init = {}) =>
|
|
481
|
+
fetch(
|
|
482
|
+
path,
|
|
483
|
+
init.signal === undefined && runtime.signal ? { ...init, signal: runtime.signal } : init
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
return async (path, init = {}) => {
|
|
487
|
+
if (path === "/enrollment-tokens") {
|
|
488
|
+
const minted = await request(path, init);
|
|
489
|
+
if (minted && typeof minted === "object") {
|
|
490
|
+
tokenId = typeof minted.id === "string" ? minted.id : "";
|
|
491
|
+
const token =
|
|
492
|
+
typeof minted.token === "string"
|
|
493
|
+
? minted.token
|
|
494
|
+
: typeof minted.plaintext === "string"
|
|
495
|
+
? minted.plaintext
|
|
496
|
+
: "";
|
|
497
|
+
if (token) secrets.push(token);
|
|
498
|
+
}
|
|
499
|
+
return minted;
|
|
500
|
+
}
|
|
501
|
+
if (path !== "/internal/remote" || !tokenId) return request(path, init);
|
|
502
|
+
|
|
503
|
+
const remote = request(path, init).then(
|
|
504
|
+
(value) => ({ ok: true, value }),
|
|
505
|
+
(error) => ({ error, ok: false })
|
|
506
|
+
);
|
|
507
|
+
let stopped = false;
|
|
508
|
+
let timer;
|
|
509
|
+
let currentPoll = Promise.resolve();
|
|
510
|
+
const poll = async () => {
|
|
511
|
+
try {
|
|
512
|
+
const payload = await fetch("/internal/enrollment-logs", {
|
|
513
|
+
method: "GET",
|
|
514
|
+
signal: AbortSignal.timeout(ENROLLMENT_LOG_READ_TIMEOUT_MS),
|
|
515
|
+
});
|
|
516
|
+
const lines = freshEnrollmentLogLines(payload, tokenId, secrets, seen);
|
|
517
|
+
for (const line of lines) runtime.stderr.write(`${line}\n`);
|
|
518
|
+
} catch {
|
|
519
|
+
// Progress is best-effort; the authoritative remote request still determines success.
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const schedule = () => {
|
|
523
|
+
timer = setTimeout(() => {
|
|
524
|
+
currentPoll = poll().finally(() => {
|
|
525
|
+
if (!stopped) schedule();
|
|
526
|
+
});
|
|
527
|
+
}, ENROLLMENT_LOG_POLL_MS);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
await poll();
|
|
531
|
+
schedule();
|
|
532
|
+
const result = await remote;
|
|
533
|
+
stopped = true;
|
|
534
|
+
clearTimeout(timer);
|
|
535
|
+
await currentPoll;
|
|
536
|
+
await poll();
|
|
537
|
+
if (!result.ok) throw result.error;
|
|
538
|
+
return result.value;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function assertSecretOutputContext(input, runtime) {
|
|
543
|
+
if (input.export?.print && runtime.formatProvided) {
|
|
544
|
+
throw new AdminMutationError("--print cannot be combined with --format.", {
|
|
545
|
+
code: "print_format_conflict",
|
|
546
|
+
field: "print",
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function runNodeKeyExport({ capability, execute, input, runtime }) {
|
|
552
|
+
assertSecretOutputContext(input, runtime);
|
|
553
|
+
await refuseExistingOutput(input.export);
|
|
554
|
+
const payload = await execute(input);
|
|
555
|
+
const exported = await exportSecret({
|
|
556
|
+
capabilityId: capability.id,
|
|
557
|
+
input,
|
|
558
|
+
options: input.export,
|
|
559
|
+
payload,
|
|
560
|
+
stdout: runtime.stdout,
|
|
561
|
+
});
|
|
562
|
+
return {
|
|
563
|
+
...exported,
|
|
564
|
+
exitCode: 0,
|
|
565
|
+
outcome: { class: "ready", phase: "complete" },
|
|
566
|
+
status: "complete",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function runEnrollmentMutation({ capability, execute, fetch, input, mutation, runtime }) {
|
|
571
|
+
const capabilityId = capability.id;
|
|
572
|
+
if (SECRET_OUTPUT.has(capabilityId)) {
|
|
573
|
+
assertSecretOutputContext(input, runtime);
|
|
574
|
+
await refuseExistingOutput(input.export);
|
|
575
|
+
}
|
|
576
|
+
if (capabilityId === "tokens.revoke" && runtime.machine && !input.expected_created_at) {
|
|
577
|
+
throw new AdminMutationError(
|
|
578
|
+
"Machine revocation requires --expected-created-at from the listed token generation.",
|
|
579
|
+
{ code: "exact_token_identity_required", field: "expected-created-at" }
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const execution = await runAdminMutation({
|
|
584
|
+
...runtime,
|
|
585
|
+
...mutation,
|
|
586
|
+
stdout: runtime.stderr ?? runtime.stdout,
|
|
587
|
+
prepare: async () => {
|
|
588
|
+
if (capabilityId === "tokens.revoke") {
|
|
589
|
+
const prepared = await execute(input);
|
|
590
|
+
return {
|
|
591
|
+
review: reviewFor(capabilityId, input, prepared),
|
|
592
|
+
value: {
|
|
593
|
+
...input,
|
|
594
|
+
expected_created_at: prepared.target?.created_at ?? input.expected_created_at,
|
|
595
|
+
prepared,
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
return { review: reviewFor(capabilityId, input), value: input };
|
|
600
|
+
},
|
|
601
|
+
reconcile:
|
|
602
|
+
capabilityId === "tokens.revoke"
|
|
603
|
+
? async (value) => {
|
|
604
|
+
if (value.prepared.disposition === "satisfied") {
|
|
605
|
+
return {
|
|
606
|
+
status: "satisfied",
|
|
607
|
+
data: {
|
|
608
|
+
id: value.token_id,
|
|
609
|
+
created_at: value.expected_created_at ?? null,
|
|
610
|
+
status: "revoked",
|
|
611
|
+
reconciled: true,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (value.prepared.disposition === "conflict") {
|
|
616
|
+
return {
|
|
617
|
+
status: "conflict",
|
|
618
|
+
code: "token_identity_changed",
|
|
619
|
+
message: value.prepared.message,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return { status: "apply" };
|
|
623
|
+
}
|
|
624
|
+
: undefined,
|
|
625
|
+
execute: async (value) => {
|
|
626
|
+
const executionInput =
|
|
627
|
+
capabilityId === "nodes.enroll"
|
|
628
|
+
? await enrollmentIdentityInput({ ...value, env: runtime.env })
|
|
629
|
+
: value;
|
|
630
|
+
const transport =
|
|
631
|
+
capabilityId === "nodes.enroll" && executionInput.verbose
|
|
632
|
+
? enrollmentProgressTransport(fetch, runtime, executionInput.private_key)
|
|
633
|
+
: undefined;
|
|
634
|
+
return execute(executionInput, { confirmed: true, transport });
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (execution.status === "declined") return { ...execution, suppressResult: true };
|
|
639
|
+
if (!SECRET_OUTPUT.has(capabilityId)) return execution;
|
|
640
|
+
const exported = await exportSecret({
|
|
641
|
+
capabilityId,
|
|
642
|
+
input,
|
|
643
|
+
options: input.export,
|
|
644
|
+
payload: execution.data,
|
|
645
|
+
stdout: runtime.stdout,
|
|
646
|
+
});
|
|
647
|
+
return { ...execution, ...exported };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const adminEnrollmentHandlers = [
|
|
651
|
+
{
|
|
652
|
+
capabilityId: "tokens.create",
|
|
653
|
+
parse: parseTokenCreate,
|
|
654
|
+
run: runEnrollmentMutation,
|
|
655
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
capabilityId: "tokens.revoke",
|
|
659
|
+
parse: parseTokenRevoke,
|
|
660
|
+
run: runEnrollmentMutation,
|
|
661
|
+
safety: ADMIN_MUTATION_SAFETY.reconcile,
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
capabilityId: "nodes.enroll",
|
|
665
|
+
parse: parseNodeEnroll,
|
|
666
|
+
run: runEnrollmentMutation,
|
|
667
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
capabilityId: "nodes.export-key",
|
|
671
|
+
parse: (argv) => parseNodeKey(argv, false),
|
|
672
|
+
run: runNodeKeyExport,
|
|
673
|
+
safety: null,
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
capabilityId: "nodes.rotate-key",
|
|
677
|
+
parse: (argv) => parseNodeKey(argv, true),
|
|
678
|
+
run: runEnrollmentMutation,
|
|
679
|
+
safety: ADMIN_MUTATION_SAFETY.eligibleHumanOnly,
|
|
680
|
+
},
|
|
681
|
+
];
|