@ornncompute/cli 1.0.123 → 1.0.125
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 +4 -1
- package/package.json +1 -1
- package/src/catalog-shell.mjs +2 -0
- package/src/network-commands.mjs +1187 -0
- package/src/vm-commands.mjs +2 -2
- package/vendor/api-client/orchestrator.js +72 -0
- package/vendor/capabilities/cli.js +141 -994
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import { getReservation } from "../vendor/api-client/commerce.js";
|
|
2
|
+
import {
|
|
3
|
+
createNetwork,
|
|
4
|
+
createNetworkPeer,
|
|
5
|
+
deleteNetwork,
|
|
6
|
+
deleteNetworkPeer,
|
|
7
|
+
getNetwork,
|
|
8
|
+
listNetworkPeerConfigs,
|
|
9
|
+
listNetworks,
|
|
10
|
+
updateNetwork,
|
|
11
|
+
} from "../vendor/api-client/orchestrator.js";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
const CANONICAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
|
+
const DEFAULT_LIMIT = 50;
|
|
16
|
+
const unknownRecordSchema = z.record(z.string(), z.unknown());
|
|
17
|
+
const MAX_LIMIT = 500;
|
|
18
|
+
|
|
19
|
+
function responseStatus(error) {
|
|
20
|
+
if (!error || typeof error !== "object" || !("status" in error)) return null;
|
|
21
|
+
return typeof error.status === "number" ? error.status : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function interaction(context) {
|
|
25
|
+
// The app-owned runtime extends the shared catalog context only for CLI execution.
|
|
26
|
+
const cli = context.cli;
|
|
27
|
+
const runtime = cli?.interaction;
|
|
28
|
+
if (!runtime)
|
|
29
|
+
throw new Error("This capability must execute through the Ornn CLI interaction runtime.");
|
|
30
|
+
return runtime;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function text(value) {
|
|
34
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shellQuote(value) {
|
|
38
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function record(value) {
|
|
42
|
+
const parsed = unknownRecordSchema.safeParse(value);
|
|
43
|
+
return parsed.success ? parsed.data : {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function integer(value) {
|
|
47
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizedName(value) {
|
|
51
|
+
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizedCidr(value) {
|
|
55
|
+
const [address, rawPrefix, ...extra] = value.trim().split("/");
|
|
56
|
+
const octets = address?.split(".").map(Number) ?? [];
|
|
57
|
+
const prefix = Number(rawPrefix);
|
|
58
|
+
if (
|
|
59
|
+
extra.length ||
|
|
60
|
+
octets.length !== 4 ||
|
|
61
|
+
octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) ||
|
|
62
|
+
!Number.isInteger(prefix) ||
|
|
63
|
+
prefix < 0 ||
|
|
64
|
+
prefix > 32
|
|
65
|
+
) {
|
|
66
|
+
return value.trim();
|
|
67
|
+
}
|
|
68
|
+
const numeric = (((octets[0] * 256 + octets[1]) * 256 + octets[2]) * 256 + octets[3]) >>> 0;
|
|
69
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
70
|
+
const network = (numeric & mask) >>> 0;
|
|
71
|
+
return `${network >>> 24}.${(network >>> 16) & 0xff}.${(network >>> 8) & 0xff}.${network & 0xff}/${prefix}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requiredOption(runtime, value, option, usage) {
|
|
75
|
+
const parsed = text(value);
|
|
76
|
+
if (!parsed) runtime.usage(`${option} is required.\n${usage}`, { field: option.slice(2) });
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function exactPositionals(runtime, positionals, count, usage) {
|
|
81
|
+
const values = positionals ?? [];
|
|
82
|
+
if (values.length !== count || values.some((value) => !value.trim())) runtime.usage(usage);
|
|
83
|
+
return values.map((value) => value.trim());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requireCanonicalTarget(runtime, value) {
|
|
87
|
+
if (!CANONICAL_ID.test(value)) {
|
|
88
|
+
runtime.usage(
|
|
89
|
+
"Private targets currently require a canonical capacity-reservation identifier so connect cannot select an ambiguous resource.",
|
|
90
|
+
{ code: "canonical_reference_required", field: "target" }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireMutationKey(runtime, input) {
|
|
97
|
+
if (!runtime.machine) return text(input.idempotencyKey);
|
|
98
|
+
const key = text(input.idempotencyKey);
|
|
99
|
+
if (!key) {
|
|
100
|
+
runtime.usage(
|
|
101
|
+
"Machine mutations require --idempotency-key <caller-stable-key> before API I/O.",
|
|
102
|
+
{
|
|
103
|
+
code: "idempotency_key_required",
|
|
104
|
+
field: "idempotencyKey",
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mutationHeaders(key) {
|
|
112
|
+
return key ? { "Idempotency-Key": key } : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function lifecycle(statusValue) {
|
|
116
|
+
const status = text(statusValue)?.toLowerCase() ?? "unknown";
|
|
117
|
+
if (["active", "ready", "applied", "connected"].includes(status)) {
|
|
118
|
+
return { class: "ready", phase: "available" };
|
|
119
|
+
}
|
|
120
|
+
if (["failed", "degraded", "error"].includes(status)) {
|
|
121
|
+
return { class: "failed", phase: "needs_attention" };
|
|
122
|
+
}
|
|
123
|
+
if (["deleted", "revoked", "disconnected"].includes(status)) {
|
|
124
|
+
return { class: "ended", phase: "disconnected" };
|
|
125
|
+
}
|
|
126
|
+
if (["deleting", "revoking"].includes(status)) {
|
|
127
|
+
return { class: "working", phase: "disconnecting" };
|
|
128
|
+
}
|
|
129
|
+
return { class: "pending", phase: "configuring" };
|
|
130
|
+
}
|
|
131
|
+
function reservationTargetLifecycle(statusValue) {
|
|
132
|
+
const status = text(statusValue)?.toLowerCase() ?? "unknown";
|
|
133
|
+
if (status === "active") return { class: "ready", phase: "active" };
|
|
134
|
+
if (["failed", "payment_failed", "error"].includes(status)) {
|
|
135
|
+
return { class: "failed", phase: status };
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
["cancelled", "canceled", "completed", "ended", "expired", "rejected", "terminated"].includes(
|
|
139
|
+
status
|
|
140
|
+
)
|
|
141
|
+
) {
|
|
142
|
+
return { class: "ended", phase: status };
|
|
143
|
+
}
|
|
144
|
+
return { class: "pending", phase: status };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function projectNetwork(value) {
|
|
148
|
+
const source = record(value);
|
|
149
|
+
const attachmentIds = Array.isArray(source.attached_reservation_ids)
|
|
150
|
+
? source.attached_reservation_ids
|
|
151
|
+
.map((value) => text(value))
|
|
152
|
+
.filter((value) => Boolean(value && CANONICAL_ID.test(value)))
|
|
153
|
+
: [];
|
|
154
|
+
return {
|
|
155
|
+
id: text(source.id),
|
|
156
|
+
name: text(source.name) ?? "Unnamed private network",
|
|
157
|
+
cidr: text(source.cidr),
|
|
158
|
+
lifecycle: lifecycle(source.status),
|
|
159
|
+
attachment_count: integer(source.attached_reservation_count) ?? attachmentIds.length,
|
|
160
|
+
attached_reservation_ids: [...new Set(attachmentIds)],
|
|
161
|
+
created_at: text(source.created_at),
|
|
162
|
+
updated_at: text(source.updated_at),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function networkRows(payload) {
|
|
167
|
+
const source = record(payload);
|
|
168
|
+
const values = Array.isArray(payload)
|
|
169
|
+
? payload
|
|
170
|
+
: Array.isArray(source.networks)
|
|
171
|
+
? source.networks
|
|
172
|
+
: [];
|
|
173
|
+
return values
|
|
174
|
+
.flatMap((value) => {
|
|
175
|
+
const parsed = unknownRecordSchema.safeParse(value);
|
|
176
|
+
return parsed.success ? [parsed.data] : [];
|
|
177
|
+
})
|
|
178
|
+
.sort((left, right) => {
|
|
179
|
+
const byName = normalizedName(text(left.name) ?? "").localeCompare(
|
|
180
|
+
normalizedName(text(right.name) ?? "")
|
|
181
|
+
);
|
|
182
|
+
return byName || (text(left.id) ?? "").localeCompare(text(right.id) ?? "");
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function networkFromMutation(payload) {
|
|
187
|
+
const source = record(payload);
|
|
188
|
+
return projectNetwork(source.network ?? source);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function fetchNetworks(context) {
|
|
192
|
+
return networkRows(await listNetworks(context.fetch));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveNetwork(context, runtime, reference) {
|
|
196
|
+
if (CANONICAL_ID.test(reference)) {
|
|
197
|
+
return record(await getNetwork(context.fetch, reference));
|
|
198
|
+
}
|
|
199
|
+
const wanted = normalizedName(reference);
|
|
200
|
+
const matches = (await fetchNetworks(context)).filter(
|
|
201
|
+
(candidate) => normalizedName(text(candidate.name) ?? "") === wanted
|
|
202
|
+
);
|
|
203
|
+
if (matches.length === 0) {
|
|
204
|
+
runtime.error(`Private network not found: ${reference}.`, {
|
|
205
|
+
code: "private_network_not_found",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (matches.length > 1) {
|
|
209
|
+
runtime.usage(
|
|
210
|
+
`More than one private network has the exact name "${reference}"; use a canonical identifier.`,
|
|
211
|
+
{
|
|
212
|
+
code: "private_network_ambiguous",
|
|
213
|
+
field: "network",
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return matches[0];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function encodeCursor(command, offset) {
|
|
221
|
+
return encodeURIComponent(JSON.stringify({ command, offset }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decodeCursor(runtime, command, cursor) {
|
|
225
|
+
if (!cursor) return 0;
|
|
226
|
+
try {
|
|
227
|
+
const parsed = record(JSON.parse(decodeURIComponent(cursor)));
|
|
228
|
+
if (
|
|
229
|
+
parsed.command === command &&
|
|
230
|
+
Number.isInteger(parsed.offset) &&
|
|
231
|
+
Number(parsed.offset) >= 0
|
|
232
|
+
) {
|
|
233
|
+
return Number(parsed.offset);
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// The stable usage error below intentionally hides cursor internals.
|
|
237
|
+
}
|
|
238
|
+
runtime.usage("The pagination cursor is invalid or belongs to another command.", {
|
|
239
|
+
code: "invalid_cursor",
|
|
240
|
+
field: "cursor",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function pageLimit(runtime, raw) {
|
|
245
|
+
if (raw === undefined) return DEFAULT_LIMIT;
|
|
246
|
+
const limit = Number(raw);
|
|
247
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
248
|
+
runtime.usage(`--limit must be an integer from 1 to ${MAX_LIMIT}.`, { field: "limit" });
|
|
249
|
+
}
|
|
250
|
+
return limit;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function page(runtime, command, rows, input) {
|
|
254
|
+
const limit = pageLimit(runtime, input.limit);
|
|
255
|
+
const offset = decodeCursor(runtime, command, input.cursor);
|
|
256
|
+
const items = rows.slice(offset, offset + limit);
|
|
257
|
+
const nextOffset = offset + items.length;
|
|
258
|
+
return {
|
|
259
|
+
items,
|
|
260
|
+
page: {
|
|
261
|
+
limit,
|
|
262
|
+
returned: items.length,
|
|
263
|
+
next_cursor: nextOffset < rows.length ? encodeCursor(command, nextOffset) : null,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function declined(runtime) {
|
|
269
|
+
return runtime.result({
|
|
270
|
+
data: null,
|
|
271
|
+
errors: [{ code: "action_declined", message: "No changes were made.", retryable: false }],
|
|
272
|
+
exitCode: 1,
|
|
273
|
+
outcome: { class: "ended", phase: "declined" },
|
|
274
|
+
presentation: { lines: ["No changes were made."] },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function networkLines(network) {
|
|
279
|
+
const state = record(network.lifecycle);
|
|
280
|
+
return [
|
|
281
|
+
`${String(network.name)} (${String(network.id ?? "unknown")})`,
|
|
282
|
+
` CIDR: ${String(network.cidr ?? "pending")}`,
|
|
283
|
+
` State: ${state.class} (${state.phase})`,
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function networkMutationExecution(runtime, network, replayed) {
|
|
288
|
+
const state = record(network.lifecycle);
|
|
289
|
+
const stateClass = String(state.class);
|
|
290
|
+
const ready = stateClass === "ready";
|
|
291
|
+
const failed = stateClass === "failed";
|
|
292
|
+
const command = ready ? "ornn connect" : `ornn private-networks show ${String(network.id)}`;
|
|
293
|
+
return runtime.result({
|
|
294
|
+
action: {
|
|
295
|
+
command,
|
|
296
|
+
description: ready
|
|
297
|
+
? "Continue to an exact compute target."
|
|
298
|
+
: "Inspect private-network readiness before attaching or connecting.",
|
|
299
|
+
type: "command",
|
|
300
|
+
},
|
|
301
|
+
data: { private_network: network, replayed },
|
|
302
|
+
exitCode: ready ? 0 : failed ? 4 : 3,
|
|
303
|
+
outcome: ready
|
|
304
|
+
? { class: "ready", phase: "active" }
|
|
305
|
+
: { class: String(state.class), phase: String(state.phase) },
|
|
306
|
+
presentation: {
|
|
307
|
+
lines: [
|
|
308
|
+
ready
|
|
309
|
+
? replayed
|
|
310
|
+
? "Private network already satisfies this request."
|
|
311
|
+
: "Private network created."
|
|
312
|
+
: failed
|
|
313
|
+
? "Private network exists, but provisioning failed."
|
|
314
|
+
: "Private network creation was accepted and is still provisioning.",
|
|
315
|
+
...networkLines(network),
|
|
316
|
+
],
|
|
317
|
+
next: command,
|
|
318
|
+
},
|
|
319
|
+
resume: ready ? null : { kind: "private_network", ref: String(network.id) },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function listPrivateNetworks(context, input) {
|
|
324
|
+
const runtime = interaction(context);
|
|
325
|
+
exactPositionals(
|
|
326
|
+
runtime,
|
|
327
|
+
input.$positionals,
|
|
328
|
+
0,
|
|
329
|
+
"Usage: ornn private-networks list [--limit <1-500>] [--cursor <cursor>]"
|
|
330
|
+
);
|
|
331
|
+
const rows = (await fetchNetworks(context)).map(projectNetwork);
|
|
332
|
+
const selected = page(runtime, "private-networks list", rows, input);
|
|
333
|
+
return runtime.result({
|
|
334
|
+
data: { private_networks: selected.items },
|
|
335
|
+
page: selected.page,
|
|
336
|
+
presentation: {
|
|
337
|
+
lines: selected.items.length
|
|
338
|
+
? selected.items.flatMap(networkLines)
|
|
339
|
+
: ["No private networks found."],
|
|
340
|
+
...(selected.page.next_cursor
|
|
341
|
+
? { next: `ornn private-networks list --cursor ${selected.page.next_cursor}` }
|
|
342
|
+
: {}),
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function showPrivateNetwork(context, input) {
|
|
348
|
+
const runtime = interaction(context);
|
|
349
|
+
const [reference] = exactPositionals(
|
|
350
|
+
runtime,
|
|
351
|
+
input.$positionals,
|
|
352
|
+
1,
|
|
353
|
+
"Usage: ornn private-networks show <exact-name-or-id>"
|
|
354
|
+
);
|
|
355
|
+
const network = projectNetwork(await resolveNetwork(context, runtime, reference));
|
|
356
|
+
return runtime.result({
|
|
357
|
+
data: { private_network: network },
|
|
358
|
+
presentation: { lines: networkLines(network) },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function createPrivateNetwork(context, input) {
|
|
363
|
+
const runtime = interaction(context);
|
|
364
|
+
const usage = "Usage: ornn private-networks create --name <name> [--cidr <cidr>]";
|
|
365
|
+
exactPositionals(runtime, input.$positionals, 0, usage);
|
|
366
|
+
if (!text(input.name) && input.guided === true && !runtime.machine) {
|
|
367
|
+
input.name = await runtime.ask({ label: "Private network name" });
|
|
368
|
+
}
|
|
369
|
+
const name = requiredOption(runtime, input.name, "--name", usage)
|
|
370
|
+
.normalize("NFKC")
|
|
371
|
+
.trim()
|
|
372
|
+
.replace(/\s+/g, " ");
|
|
373
|
+
const cidr = text(input.cidr) ? normalizedCidr(String(input.cidr)) : null;
|
|
374
|
+
const idempotencyKey = requireMutationKey(runtime, input);
|
|
375
|
+
const matches = (await fetchNetworks(context)).filter(
|
|
376
|
+
(candidate) => normalizedName(text(candidate.name) ?? "") === normalizedName(name)
|
|
377
|
+
);
|
|
378
|
+
if (matches.length > 1) {
|
|
379
|
+
runtime.usage(
|
|
380
|
+
`More than one private network has the exact name "${name}"; use a different name.`,
|
|
381
|
+
{
|
|
382
|
+
code: "private_network_ambiguous",
|
|
383
|
+
field: "name",
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (matches.length === 1) {
|
|
388
|
+
const existingCidr = text(matches[0].cidr);
|
|
389
|
+
if (cidr && (!existingCidr || normalizedCidr(existingCidr) !== cidr)) {
|
|
390
|
+
runtime.usage(
|
|
391
|
+
`Private network "${name}" already exists with different effective input. Use a new idempotency key and name after review.`,
|
|
392
|
+
{ code: "private_network_replay_conflict", field: "cidr" }
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return networkMutationExecution(runtime, projectNetwork(matches[0]), true);
|
|
396
|
+
}
|
|
397
|
+
if (
|
|
398
|
+
!(await runtime.review({
|
|
399
|
+
title: "Create private network",
|
|
400
|
+
terms: [
|
|
401
|
+
{ label: "Name", value: name },
|
|
402
|
+
{ label: "CIDR", value: cidr ?? "Automatically assigned" },
|
|
403
|
+
],
|
|
404
|
+
}))
|
|
405
|
+
)
|
|
406
|
+
return declined(runtime);
|
|
407
|
+
const created = await createNetwork(
|
|
408
|
+
context.fetch,
|
|
409
|
+
{ name, ...(cidr ? { cidr } : {}) },
|
|
410
|
+
{
|
|
411
|
+
headers: mutationHeaders(idempotencyKey),
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
return networkMutationExecution(runtime, networkFromMutation(created), false);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function updatePrivateNetwork(context, input) {
|
|
418
|
+
const runtime = interaction(context);
|
|
419
|
+
const usage = "Usage: ornn private-networks update <exact-name-or-id> --name <new-name>";
|
|
420
|
+
const [reference] = exactPositionals(runtime, input.$positionals, 1, usage);
|
|
421
|
+
const name = requiredOption(runtime, input.name, "--name", usage)
|
|
422
|
+
.normalize("NFKC")
|
|
423
|
+
.trim()
|
|
424
|
+
.replace(/\s+/g, " ");
|
|
425
|
+
const idempotencyKey = requireMutationKey(runtime, input);
|
|
426
|
+
const current = await resolveNetwork(context, runtime, reference);
|
|
427
|
+
if (normalizedName(text(current.name) ?? "") === normalizedName(name)) {
|
|
428
|
+
const network = projectNetwork(current);
|
|
429
|
+
return runtime.result({
|
|
430
|
+
data: { private_network: network, replayed: true },
|
|
431
|
+
presentation: {
|
|
432
|
+
lines: ["Private network already has the requested name.", ...networkLines(network)],
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
(await fetchNetworks(context)).some(
|
|
438
|
+
(candidate) => normalizedName(text(candidate.name) ?? "") === normalizedName(name)
|
|
439
|
+
)
|
|
440
|
+
) {
|
|
441
|
+
runtime.usage(`Another private network already has the exact name "${name}".`, {
|
|
442
|
+
code: "private_network_name_conflict",
|
|
443
|
+
field: "name",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (
|
|
447
|
+
!(await runtime.review({
|
|
448
|
+
title: "Update private network",
|
|
449
|
+
terms: [
|
|
450
|
+
{
|
|
451
|
+
label: "Private network",
|
|
452
|
+
value: `${text(current.name) ?? reference} (${text(current.id) ?? reference})`,
|
|
453
|
+
},
|
|
454
|
+
{ label: "New name", value: name },
|
|
455
|
+
],
|
|
456
|
+
}))
|
|
457
|
+
)
|
|
458
|
+
return declined(runtime);
|
|
459
|
+
const updated = await updateNetwork(
|
|
460
|
+
context.fetch,
|
|
461
|
+
String(current.id),
|
|
462
|
+
{ name },
|
|
463
|
+
{
|
|
464
|
+
headers: mutationHeaders(idempotencyKey),
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
const network = networkFromMutation(updated);
|
|
468
|
+
return runtime.result({
|
|
469
|
+
data: { private_network: network, replayed: false },
|
|
470
|
+
presentation: { lines: ["Private network updated.", ...networkLines(network)] },
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function deletePrivateNetwork(context, input) {
|
|
475
|
+
const runtime = interaction(context);
|
|
476
|
+
const [reference] = exactPositionals(
|
|
477
|
+
runtime,
|
|
478
|
+
input.$positionals,
|
|
479
|
+
1,
|
|
480
|
+
"Usage: ornn private-networks delete <exact-name-or-id>"
|
|
481
|
+
);
|
|
482
|
+
const idempotencyKey = requireMutationKey(runtime, input);
|
|
483
|
+
const projected = projectNetwork(await resolveNetwork(context, runtime, reference));
|
|
484
|
+
if (
|
|
485
|
+
!(await runtime.review({
|
|
486
|
+
title: "Delete private network",
|
|
487
|
+
terms: [
|
|
488
|
+
{ label: "Private network", value: `${String(projected.name)} (${String(projected.id)})` },
|
|
489
|
+
{
|
|
490
|
+
label: "Consequence",
|
|
491
|
+
value:
|
|
492
|
+
"Permanently delete this organization private network when the owner permits deletion.",
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
prompt: "Delete this private network?",
|
|
496
|
+
}))
|
|
497
|
+
)
|
|
498
|
+
return declined(runtime);
|
|
499
|
+
await deleteNetwork(context.fetch, String(projected.id), {
|
|
500
|
+
headers: mutationHeaders(idempotencyKey),
|
|
501
|
+
});
|
|
502
|
+
return runtime.result({
|
|
503
|
+
data: { deleted: true, private_network: projected },
|
|
504
|
+
outcome: { class: "ended", phase: "deleted" },
|
|
505
|
+
presentation: {
|
|
506
|
+
lines: [`Private network deleted: ${String(projected.name)} (${String(projected.id)})`],
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function vpnPayloadRows(payload) {
|
|
512
|
+
const source = record(payload);
|
|
513
|
+
const peers = Array.isArray(source.peers) ? source.peers : [];
|
|
514
|
+
return peers.flatMap((value) => {
|
|
515
|
+
const parsed = unknownRecordSchema.safeParse(value);
|
|
516
|
+
return parsed.success ? [parsed.data] : [];
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function vpnPeer(payload) {
|
|
521
|
+
const peer = record(payload.peer);
|
|
522
|
+
return Object.keys(peer).length ? peer : payload;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function projectVpnConnection(payload, reservationId) {
|
|
526
|
+
const peer = vpnPeer(payload);
|
|
527
|
+
const network = record(payload.network);
|
|
528
|
+
return {
|
|
529
|
+
id: text(peer.id),
|
|
530
|
+
name: text(peer.label) ?? "VPN connection",
|
|
531
|
+
reservation_id: reservationId,
|
|
532
|
+
private_network_id: text(network.network_id) ?? text(network.id),
|
|
533
|
+
assigned_address: text(peer.assigned_ip),
|
|
534
|
+
lifecycle: lifecycle(peer.status),
|
|
535
|
+
created_at: text(peer.created_at),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function vpnLines(connection) {
|
|
540
|
+
const state = record(connection.lifecycle);
|
|
541
|
+
return [
|
|
542
|
+
`${String(connection.name)} (${String(connection.id ?? "unknown")})`,
|
|
543
|
+
` Private target: ${String(connection.reservation_id)}`,
|
|
544
|
+
` State: ${state.class} (${state.phase})`,
|
|
545
|
+
];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function listVpnPayloads(context) {
|
|
549
|
+
return vpnPayloadRows(await listNetworkPeerConfigs(context.fetch, "shared"));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function listVpnConnections(context, input) {
|
|
553
|
+
const runtime = interaction(context);
|
|
554
|
+
const [rawTarget] = exactPositionals(
|
|
555
|
+
runtime,
|
|
556
|
+
input.$positionals,
|
|
557
|
+
1,
|
|
558
|
+
"Usage: ornn vpn-connections list <canonical-reservation-id> [--limit <1-500>] [--cursor <cursor>]"
|
|
559
|
+
);
|
|
560
|
+
const target = requireCanonicalTarget(runtime, rawTarget);
|
|
561
|
+
const connections = (await listVpnPayloads(context))
|
|
562
|
+
.map((payload) => projectVpnConnection(payload, target))
|
|
563
|
+
.sort((left, right) =>
|
|
564
|
+
normalizedName(String(left.name)).localeCompare(normalizedName(String(right.name)))
|
|
565
|
+
);
|
|
566
|
+
const selected = page(runtime, "vpn-connections list", connections, input);
|
|
567
|
+
return runtime.result({
|
|
568
|
+
data: { vpn_connections: selected.items },
|
|
569
|
+
page: selected.page,
|
|
570
|
+
presentation: {
|
|
571
|
+
lines: selected.items.length
|
|
572
|
+
? selected.items.flatMap(vpnLines)
|
|
573
|
+
: [`No VPN connections found for private target ${target}.`],
|
|
574
|
+
...(selected.page.next_cursor
|
|
575
|
+
? { next: `ornn vpn-connections list ${target} --cursor ${selected.page.next_cursor}` }
|
|
576
|
+
: {}),
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function validateSecretExport(runtime, input) {
|
|
582
|
+
const hasOutput = Boolean(text(input.output));
|
|
583
|
+
const shouldPrint = input.print === true;
|
|
584
|
+
if (hasOutput === shouldPrint) {
|
|
585
|
+
runtime.usage(
|
|
586
|
+
"Choose exactly one explicit VPN configuration destination: --print or --output <path>.",
|
|
587
|
+
{
|
|
588
|
+
code: "secret_destination_required",
|
|
589
|
+
field: "output",
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
if (shouldPrint && runtime.formatProvided) {
|
|
594
|
+
runtime.usage("--print is incompatible with an explicit --format.", {
|
|
595
|
+
code: "secret_print_format_conflict",
|
|
596
|
+
field: "format",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (runtime.machine && shouldPrint) {
|
|
600
|
+
runtime.usage(
|
|
601
|
+
"Machine mode cannot mix secret bytes with a result envelope; use --output <path>.",
|
|
602
|
+
{
|
|
603
|
+
code: "secret_print_unsupported_in_machine_mode",
|
|
604
|
+
field: "print",
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (
|
|
609
|
+
!runtime.exportSecret ||
|
|
610
|
+
!runtime.prepareSecretExport ||
|
|
611
|
+
!runtime.wireGuardPublicKeyFromFile
|
|
612
|
+
) {
|
|
613
|
+
runtime.error("The CLI secret-output service is unavailable.", {
|
|
614
|
+
code: "secret_output_unavailable",
|
|
615
|
+
exitCode: 6,
|
|
616
|
+
retryable: true,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function prepareVpnExport(runtime, input) {
|
|
621
|
+
try {
|
|
622
|
+
await runtime.prepareSecretExport({
|
|
623
|
+
outputPath: text(input.output) ?? undefined,
|
|
624
|
+
overwrite: input.overwrite === true,
|
|
625
|
+
print: input.print === true,
|
|
626
|
+
});
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const code = error && typeof error === "object" && "code" in error ? text(error.code) : null;
|
|
629
|
+
const message =
|
|
630
|
+
error instanceof Error && error.message
|
|
631
|
+
? error.message
|
|
632
|
+
: "Could not prepare the VPN configuration destination.";
|
|
633
|
+
runtime.error(message, { code: code ?? "vpn_configuration_export_failed" });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function exportVpnConfig(runtime, input, content, privateKeyPath, publicKey) {
|
|
638
|
+
const config = typeof content === "string" && content.length > 0 ? content : null;
|
|
639
|
+
if (!config) {
|
|
640
|
+
runtime.error("Ornn did not return a usable VPN configuration.", {
|
|
641
|
+
code: "vpn_configuration_unavailable",
|
|
642
|
+
exitCode: 6,
|
|
643
|
+
retryable: true,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
return await runtime.exportSecret({
|
|
648
|
+
content: config,
|
|
649
|
+
outputPath: text(input.output) ?? undefined,
|
|
650
|
+
overwrite: input.overwrite === true,
|
|
651
|
+
print: input.print === true,
|
|
652
|
+
privateKeyPath,
|
|
653
|
+
publicKey,
|
|
654
|
+
});
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const code = error && typeof error === "object" && "code" in error ? text(error.code) : null;
|
|
657
|
+
const message =
|
|
658
|
+
error instanceof Error && error.message
|
|
659
|
+
? error.message
|
|
660
|
+
: "Could not export the VPN configuration.";
|
|
661
|
+
runtime.error(message, { code: code ?? "vpn_configuration_export_failed" });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function vpnConfigureExecution(runtime, target, connection, exported, replayed) {
|
|
666
|
+
const state = record(connection.lifecycle);
|
|
667
|
+
const stateClass = String(state.class);
|
|
668
|
+
const ready = stateClass === "ready";
|
|
669
|
+
const failed = stateClass === "failed";
|
|
670
|
+
const activationCommand = exported.path
|
|
671
|
+
? `sudo wg-quick up ${shellQuote(exported.path)}`
|
|
672
|
+
: "sudo wg-quick up <saved-configuration-path>";
|
|
673
|
+
const action = ready
|
|
674
|
+
? {
|
|
675
|
+
command: activationCommand,
|
|
676
|
+
description: "Activate the completed local WireGuard configuration before connecting.",
|
|
677
|
+
type: "command",
|
|
678
|
+
}
|
|
679
|
+
: {
|
|
680
|
+
command: `ornn vpn-connections configure ${target} --guided`,
|
|
681
|
+
description: failed
|
|
682
|
+
? "Retry applying this exact VPN peer with its original private key."
|
|
683
|
+
: "Reapply this pending VPN peer after gateway readiness is restored.",
|
|
684
|
+
type: "command",
|
|
685
|
+
};
|
|
686
|
+
return runtime.result({
|
|
687
|
+
action,
|
|
688
|
+
data: { export: exported, replayed, vpn_connection: connection },
|
|
689
|
+
exitCode: ready ? 3 : failed ? 4 : 3,
|
|
690
|
+
outcome: ready
|
|
691
|
+
? { class: "action_required", phase: "local_vpn_activation_required" }
|
|
692
|
+
: { class: String(state.class), phase: String(state.phase) },
|
|
693
|
+
presentation: {
|
|
694
|
+
lines: [
|
|
695
|
+
ready
|
|
696
|
+
? replayed
|
|
697
|
+
? "VPN configuration exported again; local tunnel activation is still required."
|
|
698
|
+
: "VPN peer is active and the complete local configuration was exported."
|
|
699
|
+
: failed
|
|
700
|
+
? "VPN connection was saved, but the gateway rejected or could not apply it."
|
|
701
|
+
: "VPN connection was saved and is waiting for gateway application.",
|
|
702
|
+
...vpnLines(connection),
|
|
703
|
+
`Configuration fingerprint: ${exported.fingerprint}`,
|
|
704
|
+
...(exported.path ? [`Configuration written to ${exported.path} (${exported.mode}).`] : []),
|
|
705
|
+
],
|
|
706
|
+
next: action.command,
|
|
707
|
+
},
|
|
708
|
+
resume: ready
|
|
709
|
+
? { kind: "connect", ref: target }
|
|
710
|
+
: { kind: "vpn_connection", ref: String(connection.id) },
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function configureVpnConnection(context, input) {
|
|
715
|
+
const runtime = interaction(context);
|
|
716
|
+
const usage =
|
|
717
|
+
"Usage: ornn vpn-connections configure <canonical-reservation-id> --name <name> --private-key <path> (--print | --output <path>)";
|
|
718
|
+
const [rawTarget] = exactPositionals(runtime, input.$positionals, 1, usage);
|
|
719
|
+
const target = requireCanonicalTarget(runtime, rawTarget);
|
|
720
|
+
if (input.guided === true && !runtime.machine) {
|
|
721
|
+
input.name ??= await runtime.ask({ label: "VPN connection name" });
|
|
722
|
+
input.privateKey ??= await runtime.ask({ label: "WireGuard private-key path" });
|
|
723
|
+
if (input.print !== true && !text(input.output)) {
|
|
724
|
+
input.output = await runtime.ask({ label: "VPN configuration output path" });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const name = requiredOption(runtime, input.name, "--name", usage)
|
|
728
|
+
.normalize("NFKC")
|
|
729
|
+
.trim()
|
|
730
|
+
.replace(/\s+/g, " ");
|
|
731
|
+
const privateKeyPath = requiredOption(runtime, input.privateKey, "--private-key", usage);
|
|
732
|
+
validateSecretExport(runtime, input);
|
|
733
|
+
await prepareVpnExport(runtime, input);
|
|
734
|
+
let publicKey = "";
|
|
735
|
+
try {
|
|
736
|
+
publicKey = await runtime.wireGuardPublicKeyFromFile(privateKeyPath);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
const code = error && typeof error === "object" && "code" in error ? text(error.code) : null;
|
|
739
|
+
const message =
|
|
740
|
+
error instanceof Error && error.message
|
|
741
|
+
? error.message
|
|
742
|
+
: "Could not read the WireGuard private key.";
|
|
743
|
+
runtime.error(message, { code: code ?? "invalid_wireguard_private_key", field: "privateKey" });
|
|
744
|
+
}
|
|
745
|
+
const idempotencyKey = requireMutationKey(runtime, input);
|
|
746
|
+
const existing = await listVpnPayloads(context);
|
|
747
|
+
const sameKey = existing.find((payload) => text(vpnPeer(payload).public_key) === publicKey);
|
|
748
|
+
let effectiveName = name;
|
|
749
|
+
if (sameKey) {
|
|
750
|
+
const existingName = text(vpnPeer(sameKey).label) ?? "VPN connection";
|
|
751
|
+
effectiveName = existingName;
|
|
752
|
+
if (normalizedName(existingName) !== normalizedName(name)) {
|
|
753
|
+
runtime.usage(
|
|
754
|
+
"This public key already identifies a VPN connection with a different name. Use the original effective input or a new key after review.",
|
|
755
|
+
{ code: "vpn_connection_replay_conflict", field: "name" }
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
} else if (
|
|
759
|
+
existing.some(
|
|
760
|
+
(payload) =>
|
|
761
|
+
normalizedName(text(vpnPeer(payload).label) ?? "VPN connection") === normalizedName(name)
|
|
762
|
+
)
|
|
763
|
+
) {
|
|
764
|
+
runtime.usage(`A VPN connection named "${name}" already exists with different key material.`, {
|
|
765
|
+
code: "vpn_connection_name_conflict",
|
|
766
|
+
field: "name",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (sameKey && lifecycle(text(vpnPeer(sameKey).status)).class === "ready") {
|
|
770
|
+
const exported = await exportVpnConfig(
|
|
771
|
+
runtime,
|
|
772
|
+
input,
|
|
773
|
+
sameKey.config,
|
|
774
|
+
privateKeyPath,
|
|
775
|
+
publicKey
|
|
776
|
+
);
|
|
777
|
+
return vpnConfigureExecution(
|
|
778
|
+
runtime,
|
|
779
|
+
target,
|
|
780
|
+
projectVpnConnection(sameKey, target),
|
|
781
|
+
exported,
|
|
782
|
+
true
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
if (
|
|
786
|
+
!(await runtime.review({
|
|
787
|
+
title: sameKey ? "Retry VPN connection" : "Configure VPN connection",
|
|
788
|
+
terms: [
|
|
789
|
+
{ label: "Private target", value: target },
|
|
790
|
+
{ label: "VPN connection", value: name },
|
|
791
|
+
{ label: "WireGuard public key", value: publicKey },
|
|
792
|
+
{
|
|
793
|
+
label: "Configuration destination",
|
|
794
|
+
value: input.print ? "Terminal (explicit --print)" : String(input.output),
|
|
795
|
+
},
|
|
796
|
+
],
|
|
797
|
+
}))
|
|
798
|
+
)
|
|
799
|
+
return declined(runtime);
|
|
800
|
+
const applied = record(
|
|
801
|
+
await createNetworkPeer(
|
|
802
|
+
context.fetch,
|
|
803
|
+
"shared",
|
|
804
|
+
{ label: effectiveName, public_key: publicKey },
|
|
805
|
+
{
|
|
806
|
+
headers: mutationHeaders(idempotencyKey),
|
|
807
|
+
}
|
|
808
|
+
)
|
|
809
|
+
);
|
|
810
|
+
const exported = await exportVpnConfig(runtime, input, applied.config, privateKeyPath, publicKey);
|
|
811
|
+
const connection = projectVpnConnection(applied, target);
|
|
812
|
+
return vpnConfigureExecution(runtime, target, connection, exported, Boolean(sameKey));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function disconnectVpnConnection(context, input) {
|
|
816
|
+
const runtime = interaction(context);
|
|
817
|
+
const [rawTarget, reference] = exactPositionals(
|
|
818
|
+
runtime,
|
|
819
|
+
input.$positionals,
|
|
820
|
+
2,
|
|
821
|
+
"Usage: ornn vpn-connections disconnect <canonical-reservation-id> <exact-name-or-id>"
|
|
822
|
+
);
|
|
823
|
+
const target = requireCanonicalTarget(runtime, rawTarget);
|
|
824
|
+
const idempotencyKey = requireMutationKey(runtime, input);
|
|
825
|
+
const matches = (await listVpnPayloads(context)).filter((payload) => {
|
|
826
|
+
const peer = vpnPeer(payload);
|
|
827
|
+
return CANONICAL_ID.test(reference)
|
|
828
|
+
? text(peer.id) === reference
|
|
829
|
+
: normalizedName(text(peer.label) ?? "VPN connection") === normalizedName(reference);
|
|
830
|
+
});
|
|
831
|
+
if (matches.length === 0)
|
|
832
|
+
runtime.error(`VPN connection not found: ${reference}.`, { code: "vpn_connection_not_found" });
|
|
833
|
+
if (matches.length > 1) {
|
|
834
|
+
runtime.usage(
|
|
835
|
+
`More than one VPN connection has the exact name "${reference}"; use a canonical identifier.`,
|
|
836
|
+
{
|
|
837
|
+
code: "vpn_connection_ambiguous",
|
|
838
|
+
field: "connection",
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
const connection = projectVpnConnection(matches[0], target);
|
|
843
|
+
if (
|
|
844
|
+
!(await runtime.review({
|
|
845
|
+
title: "Disconnect VPN connection",
|
|
846
|
+
terms: [
|
|
847
|
+
{ label: "Private target", value: target },
|
|
848
|
+
{ label: "VPN connection", value: `${String(connection.name)} (${String(connection.id)})` },
|
|
849
|
+
{
|
|
850
|
+
label: "Consequence",
|
|
851
|
+
value: "Private access through this VPN connection ends; the private network remains.",
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
}))
|
|
855
|
+
)
|
|
856
|
+
return declined(runtime);
|
|
857
|
+
await deleteNetworkPeer(context.fetch, "shared", String(connection.id), {
|
|
858
|
+
headers: mutationHeaders(idempotencyKey),
|
|
859
|
+
});
|
|
860
|
+
return runtime.result({
|
|
861
|
+
data: { disconnected: true, vpn_connection: connection },
|
|
862
|
+
outcome: { class: "ended", phase: "disconnected" },
|
|
863
|
+
presentation: {
|
|
864
|
+
lines: [`VPN connection disconnected: ${String(connection.name)} (${String(connection.id)})`],
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function normalizedConnectAccessMode(value) {
|
|
870
|
+
const mode = text(value)?.toLowerCase();
|
|
871
|
+
return mode === "virtual-machine" ? "vm" : (mode ?? null);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function validateKnownConnectMode(runtime, input, mode) {
|
|
875
|
+
if (mode === "vm" || mode === "bare-metal" || mode === "slurm") {
|
|
876
|
+
if (input.output || input.overwrite) {
|
|
877
|
+
runtime.usage(
|
|
878
|
+
`${mode === "slurm" ? "Slurm" : mode === "vm" ? "VM" : "Bare Metal"} connect uses native SSH and does not write an output file.`,
|
|
879
|
+
{
|
|
880
|
+
code: mode === "slurm" ? "slurm_output_unsupported" : "direct_machine_output_unsupported",
|
|
881
|
+
field: input.output ? "output" : "overwrite",
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (mode === "kubernetes" && (input.identityFile || input.user || input.print)) {
|
|
888
|
+
runtime.usage(
|
|
889
|
+
"Kubernetes connect writes a kubeconfig and does not accept SSH identity, user, or print options.",
|
|
890
|
+
{
|
|
891
|
+
code: input.print ? "kubeconfig_output_required" : "kubernetes_ssh_options_unsupported",
|
|
892
|
+
field: input.identityFile ? "identityFile" : input.user ? "user" : "output",
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function blockedPrivateTransport(runtime, lifecycleValue, action, data, reservationId) {
|
|
899
|
+
const state = record(lifecycleValue);
|
|
900
|
+
const outcome = {
|
|
901
|
+
class: text(state.class) ?? "pending",
|
|
902
|
+
phase: text(state.phase) ?? "configuring",
|
|
903
|
+
};
|
|
904
|
+
return runtime.result({
|
|
905
|
+
action,
|
|
906
|
+
data,
|
|
907
|
+
exitCode: ["failed", "ended"].includes(outcome.class) ? 1 : 3,
|
|
908
|
+
outcome,
|
|
909
|
+
resume: { kind: "connect", ref: reservationId },
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function compareVpnConnections(left, right) {
|
|
914
|
+
return (
|
|
915
|
+
(text(left.created_at) ?? "").localeCompare(text(right.created_at) ?? "") ||
|
|
916
|
+
(text(left.id) ?? "").localeCompare(text(right.id) ?? "") ||
|
|
917
|
+
normalizedName(text(left.name) ?? "").localeCompare(normalizedName(text(right.name) ?? ""))
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function connectPrivateTarget(context, input) {
|
|
922
|
+
const runtime = interaction(context);
|
|
923
|
+
const values = input.$positionals ?? [];
|
|
924
|
+
if (values.length > 1) {
|
|
925
|
+
runtime.usage("Usage: ornn connect [<exact-hostname-or-canonical-machine-id>]", {
|
|
926
|
+
code: "usage_error",
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
const rawTarget = text(values[0]);
|
|
930
|
+
let resolved = null;
|
|
931
|
+
let target = rawTarget;
|
|
932
|
+
if (runtime.resolveConnectTarget) {
|
|
933
|
+
resolved = await runtime.resolveConnectTarget({
|
|
934
|
+
context,
|
|
935
|
+
input,
|
|
936
|
+
interaction: runtime,
|
|
937
|
+
reference: rawTarget,
|
|
938
|
+
});
|
|
939
|
+
if (resolved) {
|
|
940
|
+
validateKnownConnectMode(runtime, input, normalizedConnectAccessMode(resolved.mode));
|
|
941
|
+
}
|
|
942
|
+
if (resolved && resolved.privateTransportRequired !== true && runtime.continueConnect) {
|
|
943
|
+
const direct = await runtime.continueConnect(resolved);
|
|
944
|
+
if (direct) return runtime.result(direct);
|
|
945
|
+
}
|
|
946
|
+
if (resolved) target = resolved.reservationId;
|
|
947
|
+
}
|
|
948
|
+
if (!resolved && rawTarget && CANONICAL_ID.test(rawTarget)) {
|
|
949
|
+
try {
|
|
950
|
+
const reservation = await getReservation(context.fetch, rawTarget);
|
|
951
|
+
const reservationId = text(reservation.reservationId);
|
|
952
|
+
if (reservationId && reservationId !== rawTarget) {
|
|
953
|
+
runtime.error("The gateway returned a different capacity reservation.", {
|
|
954
|
+
code: "reservation_correlation_changed",
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
const status = text(reservation.status)?.toLowerCase() ?? "unknown";
|
|
958
|
+
const mode = normalizedConnectAccessMode(reservation.provisioningType);
|
|
959
|
+
if (status !== "active") {
|
|
960
|
+
const state = reservationTargetLifecycle(status);
|
|
961
|
+
const waiting = state.class === "pending" || state.class === "working";
|
|
962
|
+
return runtime.result({
|
|
963
|
+
action: waiting
|
|
964
|
+
? {
|
|
965
|
+
command: `ornn reservations show ${reservationId ?? rawTarget}`,
|
|
966
|
+
description:
|
|
967
|
+
"Wait for the capacity reservation to become active before launching native compute.",
|
|
968
|
+
type: "command",
|
|
969
|
+
}
|
|
970
|
+
: null,
|
|
971
|
+
data: {
|
|
972
|
+
capacity_reservation: {
|
|
973
|
+
id: reservationId ?? rawTarget,
|
|
974
|
+
access_mode: mode,
|
|
975
|
+
lifecycle: state,
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
exitCode: waiting ? 3 : 1,
|
|
979
|
+
outcome: state,
|
|
980
|
+
presentation: {
|
|
981
|
+
lines: [
|
|
982
|
+
waiting
|
|
983
|
+
? `Capacity reservation ${reservationId ?? rawTarget} is not active yet; it has no native-access target.`
|
|
984
|
+
: `Capacity reservation ${reservationId ?? rawTarget} is ${state.phase}; it has no launchable native-access target.`,
|
|
985
|
+
],
|
|
986
|
+
},
|
|
987
|
+
resume: waiting ? { kind: "reservation", ref: reservationId ?? rawTarget } : null,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
validateKnownConnectMode(runtime, input, mode);
|
|
991
|
+
return runtime.actionRequired({
|
|
992
|
+
action: {
|
|
993
|
+
command: "ornn launch",
|
|
994
|
+
description:
|
|
995
|
+
"Launch compute for this capacity reservation before requesting native access.",
|
|
996
|
+
type: "command",
|
|
997
|
+
},
|
|
998
|
+
data: {
|
|
999
|
+
capacity_reservation: {
|
|
1000
|
+
id: reservationId ?? rawTarget,
|
|
1001
|
+
access_mode: mode,
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
resume: { kind: "reservation", ref: reservationId ?? rawTarget },
|
|
1005
|
+
});
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
if (![400, 404, 422].includes(responseStatus(error) ?? 0)) throw error;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const privateTarget = target ?? "";
|
|
1011
|
+
if (!privateTarget || !CANONICAL_ID.test(privateTarget)) {
|
|
1012
|
+
runtime.usage(
|
|
1013
|
+
"Connect requires an exact unique hostname, canonical machine ID, or eligible guided choice.",
|
|
1014
|
+
{
|
|
1015
|
+
code: "invalid_reference",
|
|
1016
|
+
field: "machine",
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// A UUID and an organization network do not prove that this target exists.
|
|
1022
|
+
// Only a resolved native target may continue into private transport setup.
|
|
1023
|
+
if (!resolved) {
|
|
1024
|
+
runtime.error("No accessible machine, cluster, or capacity reservation matches this target.", {
|
|
1025
|
+
code: "connect_target_not_found",
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const shared = record(await getNetwork(context.fetch, "shared"));
|
|
1030
|
+
const privateNetwork = projectNetwork({
|
|
1031
|
+
...record(shared.network),
|
|
1032
|
+
id: "shared",
|
|
1033
|
+
name: "Organization private network",
|
|
1034
|
+
});
|
|
1035
|
+
const networkState = record(privateNetwork.lifecycle);
|
|
1036
|
+
if (networkState.class !== "ready") {
|
|
1037
|
+
const recoverable = ["failed", "ended"].includes(String(networkState.class));
|
|
1038
|
+
return blockedPrivateTransport(
|
|
1039
|
+
runtime,
|
|
1040
|
+
networkState,
|
|
1041
|
+
{
|
|
1042
|
+
command: `ornn vpn-connections list ${privateTarget}`,
|
|
1043
|
+
description: recoverable
|
|
1044
|
+
? "Inspect organization VPN connectivity before retrying."
|
|
1045
|
+
: "Wait for organization networking to become ready, then retry connect.",
|
|
1046
|
+
type: "command",
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
private_network: privateNetwork,
|
|
1050
|
+
private_target: { reservation_id: privateTarget },
|
|
1051
|
+
vpn_connection: null,
|
|
1052
|
+
},
|
|
1053
|
+
privateTarget
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
const connections = (await listVpnPayloads(context))
|
|
1057
|
+
.map((payload) => projectVpnConnection(payload, privateTarget))
|
|
1058
|
+
.sort(compareVpnConnections);
|
|
1059
|
+
if (connections.length === 0) {
|
|
1060
|
+
return runtime.actionRequired({
|
|
1061
|
+
action: {
|
|
1062
|
+
command: `ornn vpn-connections configure ${privateTarget} --guided`,
|
|
1063
|
+
description:
|
|
1064
|
+
"Provide one VPN peer and explicit configuration destination, then retry connect.",
|
|
1065
|
+
type: "command",
|
|
1066
|
+
},
|
|
1067
|
+
data: {
|
|
1068
|
+
private_network: privateNetwork,
|
|
1069
|
+
private_target: { reservation_id: privateTarget },
|
|
1070
|
+
vpn_connection: null,
|
|
1071
|
+
},
|
|
1072
|
+
resume: { kind: "connect", ref: privateTarget },
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
const readyConnections = connections.filter(
|
|
1076
|
+
(connection) => record(connection.lifecycle).class === "ready"
|
|
1077
|
+
);
|
|
1078
|
+
if (readyConnections.length === 0) {
|
|
1079
|
+
const connection =
|
|
1080
|
+
connections.find((candidate) =>
|
|
1081
|
+
["pending", "working"].includes(String(record(candidate.lifecycle).class))
|
|
1082
|
+
) ??
|
|
1083
|
+
connections.find((candidate) => record(candidate.lifecycle).class === "failed") ??
|
|
1084
|
+
connections[0];
|
|
1085
|
+
const state = record(connection.lifecycle);
|
|
1086
|
+
const stateClass = String(state.class);
|
|
1087
|
+
const id = text(connection.id);
|
|
1088
|
+
const failed = stateClass === "failed";
|
|
1089
|
+
const ended = stateClass === "ended";
|
|
1090
|
+
const action =
|
|
1091
|
+
failed && id
|
|
1092
|
+
? {
|
|
1093
|
+
command: `ornn vpn-connections disconnect ${privateTarget} ${id}`,
|
|
1094
|
+
description: "Remove the failed VPN connection before configuring a replacement.",
|
|
1095
|
+
type: "command",
|
|
1096
|
+
}
|
|
1097
|
+
: ended
|
|
1098
|
+
? {
|
|
1099
|
+
command: `ornn vpn-connections configure ${privateTarget} --guided`,
|
|
1100
|
+
description: "Configure a new VPN connection, then retry connect.",
|
|
1101
|
+
type: "command",
|
|
1102
|
+
}
|
|
1103
|
+
: {
|
|
1104
|
+
command: `ornn vpn-connections configure ${privateTarget} --guided`,
|
|
1105
|
+
description: "Reapply the pending VPN connection, then retry connect.",
|
|
1106
|
+
type: "command",
|
|
1107
|
+
};
|
|
1108
|
+
return blockedPrivateTransport(
|
|
1109
|
+
runtime,
|
|
1110
|
+
state,
|
|
1111
|
+
action,
|
|
1112
|
+
{
|
|
1113
|
+
private_network: privateNetwork,
|
|
1114
|
+
private_target: { reservation_id: privateTarget },
|
|
1115
|
+
vpn_connection: connection,
|
|
1116
|
+
},
|
|
1117
|
+
privateTarget
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
const connection =
|
|
1121
|
+
readyConnections.find((candidate) => {
|
|
1122
|
+
const address = text(candidate.assigned_address);
|
|
1123
|
+
return Boolean(address && runtime.hasLocalNetworkAddress?.(address) === true);
|
|
1124
|
+
}) ?? readyConnections[0];
|
|
1125
|
+
const assignedAddress = text(connection.assigned_address);
|
|
1126
|
+
if (!assignedAddress || runtime.hasLocalNetworkAddress?.(assignedAddress) !== true) {
|
|
1127
|
+
return runtime.actionRequired({
|
|
1128
|
+
action: {
|
|
1129
|
+
command: `ornn vpn-connections configure ${privateTarget} --guided`,
|
|
1130
|
+
description:
|
|
1131
|
+
"Export a complete WireGuard configuration, activate it locally, then retry connect.",
|
|
1132
|
+
type: "command",
|
|
1133
|
+
},
|
|
1134
|
+
data: {
|
|
1135
|
+
private_network: privateNetwork,
|
|
1136
|
+
private_target: { reservation_id: privateTarget },
|
|
1137
|
+
vpn_connection: connection,
|
|
1138
|
+
},
|
|
1139
|
+
resume: { kind: "connect", ref: privateTarget },
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
if (runtime.continueConnect) {
|
|
1143
|
+
const continuation = await runtime.continueConnect({
|
|
1144
|
+
...(resolved ?? {}),
|
|
1145
|
+
privateNetwork,
|
|
1146
|
+
reservationId: privateTarget,
|
|
1147
|
+
vpnConnection: connection,
|
|
1148
|
+
});
|
|
1149
|
+
if (continuation) return runtime.result(continuation);
|
|
1150
|
+
}
|
|
1151
|
+
return runtime.result({
|
|
1152
|
+
data: {
|
|
1153
|
+
private_network: privateNetwork,
|
|
1154
|
+
private_target: { reservation_id: privateTarget },
|
|
1155
|
+
vpn_connection: connection,
|
|
1156
|
+
},
|
|
1157
|
+
outcome: { class: "ready", phase: "private_transport_ready" },
|
|
1158
|
+
presentation: {
|
|
1159
|
+
lines: ["VPN connection is ready for private access.", ...vpnLines(connection)],
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export function executeNetworkCommand(command, input, context) {
|
|
1165
|
+
switch (command) {
|
|
1166
|
+
case "private-networks list":
|
|
1167
|
+
return listPrivateNetworks(context, input);
|
|
1168
|
+
case "private-networks show":
|
|
1169
|
+
return showPrivateNetwork(context, input);
|
|
1170
|
+
case "private-networks create":
|
|
1171
|
+
return createPrivateNetwork(context, input);
|
|
1172
|
+
case "private-networks update":
|
|
1173
|
+
return updatePrivateNetwork(context, input);
|
|
1174
|
+
case "private-networks delete":
|
|
1175
|
+
return deletePrivateNetwork(context, input);
|
|
1176
|
+
case "vpn-connections list":
|
|
1177
|
+
return listVpnConnections(context, input);
|
|
1178
|
+
case "vpn-connections configure":
|
|
1179
|
+
return configureVpnConnection(context, input);
|
|
1180
|
+
case "vpn-connections disconnect":
|
|
1181
|
+
return disconnectVpnConnection(context, input);
|
|
1182
|
+
case "connect":
|
|
1183
|
+
return connectPrivateTarget(context, input);
|
|
1184
|
+
default:
|
|
1185
|
+
throw new Error(`Unsupported network command: ${command}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|