@tikoci/quickchr 0.4.8 → 0.5.1
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/DESIGN.md +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- package/src/lib/versions.ts +54 -0
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
331
|
+
local commands="add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -337,12 +337,12 @@ _quickchr() {
|
|
|
337
337
|
|
|
338
338
|
local cmd="${D}{words[1]}"
|
|
339
339
|
case "${D}cmd" in
|
|
340
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
340
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
341
341
|
local machines
|
|
342
342
|
machines=$(quickchr completions --machines 2>/dev/null)
|
|
343
343
|
COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
|
|
344
344
|
;;
|
|
345
|
-
stop)
|
|
345
|
+
stop|shutdown)
|
|
346
346
|
local machines
|
|
347
347
|
machines=$(quickchr completions --running 2>/dev/null)
|
|
348
348
|
[[ -z "${D}machines" ]] && machines=$(quickchr completions --machines 2>/dev/null)
|
|
@@ -387,12 +387,15 @@ _quickchr() {
|
|
|
387
387
|
local commands=(
|
|
388
388
|
'add:Create a new CHR machine (without starting)'
|
|
389
389
|
'start:Start or restart a CHR instance'
|
|
390
|
-
'stop:
|
|
390
|
+
'stop:Power cut instance(s)'
|
|
391
|
+
'shutdown:Request guest shutdown'
|
|
391
392
|
'list:List all instances'
|
|
392
393
|
'ls:List all instances (alias)'
|
|
393
394
|
'status:Detailed status'
|
|
394
395
|
'inspect:Print running machine descriptor'
|
|
395
396
|
'env:Print subprocess environment'
|
|
397
|
+
'get:Show live machine config (license, device-mode, admin)'
|
|
398
|
+
'set:Change a booted machine (license, device-mode)'
|
|
396
399
|
'remove:Remove instance(s) and disk'
|
|
397
400
|
'rm:Remove instance(s) (alias)'
|
|
398
401
|
'clean:Reset instance disk to fresh image'
|
|
@@ -418,12 +421,12 @@ _quickchr() {
|
|
|
418
421
|
args)
|
|
419
422
|
local cmd="${D}{words[2]}"
|
|
420
423
|
case ${D}cmd in
|
|
421
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
424
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
422
425
|
local machines
|
|
423
426
|
machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
424
427
|
_describe 'machine' machines
|
|
425
428
|
;;
|
|
426
|
-
stop)
|
|
429
|
+
stop|shutdown)
|
|
427
430
|
local machines
|
|
428
431
|
machines=(${D}{(f)"${D}(quickchr completions --running 2>/dev/null)"})
|
|
429
432
|
(( ${D}{#machines} == 0 )) && machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
@@ -444,7 +447,7 @@ _quickchr() {
|
|
|
444
447
|
_describe 'cache subcommand' subcommands
|
|
445
448
|
;;
|
|
446
449
|
help)
|
|
447
|
-
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
450
|
+
local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
448
451
|
_describe 'command' commands
|
|
449
452
|
;;
|
|
450
453
|
esac
|
|
@@ -460,7 +463,7 @@ function generateFishCompletion(): string {
|
|
|
460
463
|
return `# quickchr fish completion
|
|
461
464
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
462
465
|
|
|
463
|
-
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
466
|
+
set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
464
467
|
|
|
465
468
|
# Disable file completions globally for quickchr
|
|
466
469
|
complete -c quickchr -f
|
|
@@ -471,7 +474,9 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
471
474
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
472
475
|
-a "start" -d "Start or restart a CHR instance"
|
|
473
476
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
474
|
-
-a "stop" -d "
|
|
477
|
+
-a "stop" -d "Power cut instance(s)"
|
|
478
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
479
|
+
-a "shutdown" -d "Request guest shutdown"
|
|
475
480
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
476
481
|
-a "list ls" -d "List all instances"
|
|
477
482
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -491,6 +496,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
491
496
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
492
497
|
-a "qga" -d "Guest agent command"
|
|
493
498
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
499
|
+
-a "get" -d "Show live machine config (license, device-mode, admin)"
|
|
500
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
501
|
+
-a "set" -d "Change a booted machine (license, device-mode)"
|
|
502
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
494
503
|
-a "license" -d "Apply/renew CHR trial license"
|
|
495
504
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
496
505
|
-a "disk" -d "Show disk details"
|
|
@@ -514,12 +523,12 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
514
523
|
-a "help" -d "Show help"
|
|
515
524
|
|
|
516
525
|
# Machine name completions for commands that take a running machine
|
|
517
|
-
complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
|
|
526
|
+
complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
|
|
518
527
|
-a "(quickchr completions --running 2>/dev/null; or quickchr completions --machines 2>/dev/null)" \\
|
|
519
528
|
-d "machine"
|
|
520
529
|
|
|
521
530
|
# Machine name completions for commands that take any machine
|
|
522
|
-
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
|
|
531
|
+
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
|
|
523
532
|
-a "(quickchr completions --machines 2>/dev/null)" \\
|
|
524
533
|
-d "machine"
|
|
525
534
|
|
package/src/lib/device-mode.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { DeviceModeOptions } from "./types.ts";
|
|
6
6
|
import { QuickCHRError } from "./types.ts";
|
|
7
|
+
import { shellQuote } from "./names.ts";
|
|
7
8
|
import { restGet, restPost } from "./rest.ts";
|
|
8
9
|
|
|
9
10
|
export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
|
|
@@ -63,6 +64,26 @@ function normalizeFeatureList(list: string[] | undefined): string[] {
|
|
|
63
64
|
return [...new Set(values)];
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** Keys `/system/device-mode` reports that are not feature flags. `mode` is the
|
|
68
|
+
* device-mode itself; the rest are status RouterOS keeps alongside it. */
|
|
69
|
+
export const DEVICE_MODE_NON_FEATURE_KEYS: ReadonlySet<string> = new Set([
|
|
70
|
+
"mode",
|
|
71
|
+
"allowed-versions",
|
|
72
|
+
"attempt-count",
|
|
73
|
+
"flagged",
|
|
74
|
+
"flagging-enabled",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Is this feature on, as RouterOS reports it?
|
|
78
|
+
*
|
|
79
|
+
* RouterOS answers `/system/device-mode` with **strings**: `"container": "true"`, not
|
|
80
|
+
* `true`. A reader that tests `value === true` therefore finds nothing enabled, ever —
|
|
81
|
+
* which is what `quickchr get <name> device-mode` did, printing a bare mode and no
|
|
82
|
+
* feature line however many were on. Normalize before deciding. */
|
|
83
|
+
export function isDeviceModeFeatureEnabled(value: unknown): boolean {
|
|
84
|
+
return toYesNo(value) === "yes";
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
function toYesNo(value: unknown): string {
|
|
67
88
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
68
89
|
if (typeof value === "number") return value > 0 ? "yes" : "no";
|
|
@@ -148,20 +169,61 @@ export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): s
|
|
|
148
169
|
return parts.join(" ");
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
/** Factory credentials — `admin` with no password, as a fresh CHR ships.
|
|
173
|
+
*
|
|
174
|
+
* The right auth for device-mode during *first-boot* provisioning, where it runs
|
|
175
|
+
* before any account has been created, and the wrong auth afterwards: a machine
|
|
176
|
+
* provisioned with `--disable-admin` answers 401. Post-boot callers resolve the
|
|
177
|
+
* machine's own credentials instead — see `applyDeviceMode`. */
|
|
178
|
+
export const FACTORY_AUTH_HEADER = `Basic ${btoa("admin:")}`;
|
|
179
|
+
|
|
180
|
+
/** Is this response body actually `/system/device-mode`, and not the post-boot race?
|
|
181
|
+
*
|
|
182
|
+
* RouterOS can answer a non-resource endpoint with resource-shaped or otherwise wrong
|
|
183
|
+
* data for a short window after boot — `provisioning.instructions.md` documents this
|
|
184
|
+
* for exactly this endpoint, and `readDeviceMode()` carries a guard for it. A 2xx is
|
|
185
|
+
* therefore not proof the endpoint is ready; a body with `mode` in it is. */
|
|
186
|
+
function isDeviceModeBody(body: string): boolean {
|
|
187
|
+
let data: unknown;
|
|
188
|
+
try { data = JSON.parse(body); } catch { return false; }
|
|
189
|
+
const record = Array.isArray(data) ? data[0] : data;
|
|
190
|
+
if (!record || typeof record !== "object") return false;
|
|
191
|
+
const keys = record as Record<string, unknown>;
|
|
192
|
+
if ("board-name" in keys || "architecture-name" in keys) return false;
|
|
193
|
+
return "mode" in keys;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Wait until the device-mode REST endpoint is reachable **and answering with
|
|
197
|
+
* device-mode data**.
|
|
198
|
+
*
|
|
199
|
+
* Returning on a bare 2xx was a race the next line then paid for: `readDeviceMode()`
|
|
200
|
+
* rejects a resource-shaped body outright, so a guest that came back mid-race failed
|
|
201
|
+
* the whole operation instead of being waited out for one more second. This is the
|
|
202
|
+
* poll-until-the-keys-are-there pattern `provisioning.instructions.md` prescribes for
|
|
203
|
+
* every non-resource endpoint, applied where the waiting already happens.
|
|
204
|
+
*
|
|
205
|
+
* `auth` defaults to factory admin, which is what a guest being provisioned for the
|
|
206
|
+
* first time has. A machine past that point needs its own credentials passed in. */
|
|
207
|
+
export async function waitForDeviceModeApi(
|
|
208
|
+
httpPort: number,
|
|
209
|
+
timeoutMs: number = 60_000,
|
|
210
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
211
|
+
): Promise<void> {
|
|
154
212
|
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
let lastBody = "";
|
|
155
214
|
while (Date.now() < deadline) {
|
|
156
215
|
try {
|
|
157
|
-
const { status } = await restGet(
|
|
216
|
+
const { status, body } = await restGet(
|
|
158
217
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
159
218
|
auth,
|
|
160
219
|
3000,
|
|
161
220
|
);
|
|
162
221
|
|
|
163
222
|
if (status >= 200 && status < 300) {
|
|
164
|
-
return;
|
|
223
|
+
if (isDeviceModeBody(body)) return;
|
|
224
|
+
// Answering, but not with device-mode yet — keep waiting rather than
|
|
225
|
+
// handing the caller a body it will throw on.
|
|
226
|
+
lastBody = body;
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
if (status === 401) {
|
|
@@ -176,7 +238,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
176
238
|
await Bun.sleep(1000);
|
|
177
239
|
}
|
|
178
240
|
|
|
179
|
-
throw new QuickCHRError(
|
|
241
|
+
throw new QuickCHRError(
|
|
242
|
+
"BOOT_TIMEOUT",
|
|
243
|
+
`Device-mode API did not become ready on port ${httpPort}` +
|
|
244
|
+
(lastBody ? ` (last response was not device-mode data: ${lastBody.slice(0, 200)})` : ""),
|
|
245
|
+
);
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
/**
|
|
@@ -189,7 +255,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
189
255
|
* Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
|
|
190
256
|
* The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
|
|
191
257
|
*/
|
|
192
|
-
export function startDeviceModeUpdate(
|
|
258
|
+
export function startDeviceModeUpdate(
|
|
259
|
+
httpPort: number,
|
|
260
|
+
options: ResolvedDeviceModeOptions,
|
|
261
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
262
|
+
): Promise<{ status: number; body: string }> {
|
|
193
263
|
if (!shouldApplyDeviceMode(options)) {
|
|
194
264
|
return Promise.resolve({ status: 200, body: "" });
|
|
195
265
|
}
|
|
@@ -200,7 +270,6 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
200
270
|
payload[name] = value;
|
|
201
271
|
}
|
|
202
272
|
|
|
203
|
-
const auth = `Basic ${btoa("admin:")}`;
|
|
204
273
|
return restPost(
|
|
205
274
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
|
|
206
275
|
auth,
|
|
@@ -209,8 +278,10 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
209
278
|
);
|
|
210
279
|
}
|
|
211
280
|
|
|
212
|
-
export async function readDeviceMode(
|
|
213
|
-
|
|
281
|
+
export async function readDeviceMode(
|
|
282
|
+
httpPort: number,
|
|
283
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
284
|
+
): Promise<Record<string, string>> {
|
|
214
285
|
const { status, body } = await restGet(
|
|
215
286
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
216
287
|
auth,
|
|
@@ -281,3 +352,71 @@ export function verifyDeviceMode(
|
|
|
281
352
|
actual,
|
|
282
353
|
};
|
|
283
354
|
}
|
|
355
|
+
|
|
356
|
+
/** The `quickchr set` flags that would reproduce this selection, shell-quoted.
|
|
357
|
+
*
|
|
358
|
+
* Rendered from the *resolved* options, not the raw ones, so the command it prints
|
|
359
|
+
* is the command that was actually going to run — `mode: "auto"` comes out as the
|
|
360
|
+
* `rose` it resolves to rather than as an alias the reader has to expand.
|
|
361
|
+
*
|
|
362
|
+
* Quoted because `resolveDeviceModeOptions()` deliberately passes unknown modes and
|
|
363
|
+
* features through for forward compatibility (it warns, RouterOS validates), so a
|
|
364
|
+
* value with a space or a `;` in it reaches this function intact. Unquoted, the
|
|
365
|
+
* advertised command would silently become a different command. Ordinary values are
|
|
366
|
+
* returned unquoted, so the common case still reads like something a person typed. */
|
|
367
|
+
export function formatDeviceModeFlags(options: ResolvedDeviceModeOptions): string {
|
|
368
|
+
if (options.skip) return "";
|
|
369
|
+
const parts: string[] = [];
|
|
370
|
+
if (options.mode) parts.push(`--device-mode ${shellQuote(options.mode)}`);
|
|
371
|
+
const enable = Object.entries(options.features).filter(([, v]) => v === "yes").map(([k]) => k);
|
|
372
|
+
const disable = Object.entries(options.features).filter(([, v]) => v === "no").map(([k]) => k);
|
|
373
|
+
if (enable.length > 0) parts.push(`--device-mode-enable ${shellQuote(enable.sort().join(","))}`);
|
|
374
|
+
if (disable.length > 0) parts.push(`--device-mode-disable ${shellQuote(disable.sort().join(","))}`);
|
|
375
|
+
return parts.join(" ");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a device-mode update would change, read against the guest's current record.
|
|
379
|
+
*
|
|
380
|
+
* Only the differences — a change that costs a power cycle should say which settings
|
|
381
|
+
* it is buying, and `expected == actual` rows are noise. Empty means nothing would
|
|
382
|
+
* move, which is the caller's cue that no power cycle is needed. */
|
|
383
|
+
export function describeDeviceModeChange(
|
|
384
|
+
options: ResolvedDeviceModeOptions,
|
|
385
|
+
actual: Record<string, string>,
|
|
386
|
+
): string[] {
|
|
387
|
+
// `verifyDeviceMode` stays the single judge of what counts as a mismatch — only the
|
|
388
|
+
// key is read back out of its message, and the values come from the maps it returns.
|
|
389
|
+
const { expected, mismatches } = verifyDeviceMode(options, actual);
|
|
390
|
+
const changed = new Set(mismatches.map((mismatch) => mismatch.split(":")[0]));
|
|
391
|
+
return Object.entries(expected)
|
|
392
|
+
.filter(([key]) => changed.has(key))
|
|
393
|
+
.map(([key, want]) => `${key}: ${actual[key] ?? "(unset)"} -> ${want}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Fold an applied device-mode selection into what `machine.json` already records.
|
|
397
|
+
*
|
|
398
|
+
* `setDeviceMode()` used to overwrite `state.deviceMode` with the request, so
|
|
399
|
+
* `set <name> --device-mode-enable container` on a machine provisioned with
|
|
400
|
+
* `ipsec=yes` left the record claiming ipsec had never been asked for. The guest
|
|
401
|
+
* still had it: device-mode updates are cumulative, and only the settings named in
|
|
402
|
+
* a request move. The record has to be cumulative too, or it describes a machine
|
|
403
|
+
* that does not exist — and `classifyProvisioningRequest()` reads it. */
|
|
404
|
+
export function mergeDeviceModeOptions(
|
|
405
|
+
current: DeviceModeOptions | undefined,
|
|
406
|
+
applied: ResolvedDeviceModeOptions,
|
|
407
|
+
): DeviceModeOptions {
|
|
408
|
+
if (applied.skip) return current ?? {};
|
|
409
|
+
const enable = new Set(normalizeFeatureList(current?.enable));
|
|
410
|
+
const disable = new Set(normalizeFeatureList(current?.disable));
|
|
411
|
+
for (const [feature, value] of Object.entries(applied.features)) {
|
|
412
|
+
// A feature only ever sits on one side: the newest instruction for it wins.
|
|
413
|
+
enable.delete(feature);
|
|
414
|
+
disable.delete(feature);
|
|
415
|
+
(value === "yes" ? enable : disable).add(feature);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
mode: applied.mode ?? current?.mode,
|
|
419
|
+
enable: enable.size > 0 ? [...enable].sort() : undefined,
|
|
420
|
+
disable: disable.size > 0 ? [...disable].sort() : undefined,
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/lib/diagnostics.ts
CHANGED
|
@@ -54,18 +54,22 @@ export type BootProbeOutcome =
|
|
|
54
54
|
| "reset"
|
|
55
55
|
/** Connect or response exceeded the per-probe budget — socket hung. */
|
|
56
56
|
| "probe-timeout"
|
|
57
|
-
/** 401/403
|
|
57
|
+
/** 401/403 from REST, or a login `!trap` from the native API — the service
|
|
58
|
+
* answered and refused the credentials; its auth layer is up. */
|
|
58
59
|
| "unauthorized"
|
|
59
60
|
/** 2xx but not the expected `board-name` singleton — REST still initializing. */
|
|
60
61
|
| "wrong-body"
|
|
61
62
|
/** Any other HTTP status. */
|
|
62
63
|
| "http-error"
|
|
63
|
-
/** 2xx with the expected body
|
|
64
|
+
/** 2xx with the expected body, or a native-API login `!done`. */
|
|
64
65
|
| "ready"
|
|
65
66
|
/** Anything unclassified — `detail` carries the raw text. */
|
|
66
67
|
| "other";
|
|
67
68
|
|
|
68
69
|
export interface BootProbeStats {
|
|
70
|
+
/** Which readiness probe ran — REST by default, the native-API login for a guest
|
|
71
|
+
* without REST (#182). Absent means REST. */
|
|
72
|
+
kind?: "rest" | "api";
|
|
69
73
|
/** Total probe attempts made. */
|
|
70
74
|
attempts: number;
|
|
71
75
|
/** Attempt count per outcome. */
|
|
@@ -90,9 +94,11 @@ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; d
|
|
|
90
94
|
const message = err instanceof Error ? err.message : String(err);
|
|
91
95
|
if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
|
|
92
96
|
if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
|
|
93
|
-
if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
|
|
97
|
+
if (code === "ETIMEDOUT" || message.includes("restGet timeout") || message.includes("apiLogin timeout")) {
|
|
94
98
|
return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
|
|
95
99
|
}
|
|
100
|
+
// apiLogin(): slirp accepted, then the guest side closed before any reply.
|
|
101
|
+
if (message === "closed before reply") return { outcome: "reset", detail: message };
|
|
96
102
|
return { outcome: "other", detail: code ? `${code}: ${message}` : message };
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -123,9 +129,10 @@ export function summarizeBootProbe(stats: BootProbeStats): string {
|
|
|
123
129
|
.sort((a, b) => b[1] - a[1])
|
|
124
130
|
.map(([k, v]) => `${k}=${v}`)
|
|
125
131
|
.join(" ");
|
|
132
|
+
const answer = stats.kind === "api" ? "API reply" : "HTTP response";
|
|
126
133
|
const reachedHttp = stats.firstHttpAtMs === null
|
|
127
|
-
?
|
|
128
|
-
: `first
|
|
134
|
+
? `forwarded port never produced an ${answer}`
|
|
135
|
+
: `first ${answer} at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
|
|
129
136
|
const last = stats.last
|
|
130
137
|
? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
|
|
131
138
|
: "";
|
|
@@ -673,7 +680,7 @@ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootF
|
|
|
673
680
|
lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
|
|
674
681
|
}
|
|
675
682
|
}
|
|
676
|
-
if (ctx.probe) lines.push(
|
|
683
|
+
if (ctx.probe) lines.push(`${ctx.probe.kind === "api" ? "API login" : "REST"} probe: ${summarizeBootProbe(ctx.probe)}`);
|
|
677
684
|
lines.push(`QEMU process: ${record.host.qemuProcess}`);
|
|
678
685
|
if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
|
|
679
686
|
if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);
|
package/src/lib/disk.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* - {@link formatSnapshotTable} — render snapshot list as a human-readable ANSI table
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { existsSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import { copyFileSync, existsSync, rmSync, unlinkSync } from "node:fs";
|
|
17
17
|
import type { BootDiskFormat, SnapshotInfo } from "./types.ts";
|
|
18
18
|
import { QuickCHRError } from "./types.ts";
|
|
19
19
|
import { requireQemuImg } from "./platform.ts";
|
|
20
|
+
import { renameWithRetry } from "./rename.ts";
|
|
20
21
|
|
|
21
22
|
const DISK_SIZE_RE = /^\d+[KMGT]?$/i;
|
|
22
23
|
|
|
@@ -25,6 +26,8 @@ export interface DiskInfo {
|
|
|
25
26
|
virtualSize: number;
|
|
26
27
|
actualSize: number;
|
|
27
28
|
filename: string;
|
|
29
|
+
/** Set when the image depends on another file (a qcow2 backing chain). */
|
|
30
|
+
backingFilename?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** True when a disk size string is valid for qemu-img (e.g. 512M, 1G, 2048). */
|
|
@@ -139,34 +142,58 @@ export async function getDiskInfo(path: string): Promise<DiskInfo> {
|
|
|
139
142
|
virtualSize: info["virtual-size"],
|
|
140
143
|
actualSize: info["actual-size"],
|
|
141
144
|
filename: info.filename,
|
|
145
|
+
backingFilename: info["backing-filename"],
|
|
142
146
|
};
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/** The boot format a machine record implies. Records from before `bootDiskFormat`
|
|
150
|
+
* existed only had `bootSize`, which always meant qcow2. */
|
|
151
|
+
export function bootDiskFormatOf(state: { bootDiskFormat?: BootDiskFormat; bootSize?: string }): BootDiskFormat {
|
|
152
|
+
return state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Where a machine's boot disk lives: `disk.img` for raw, `boot.qcow2` for qcow2. */
|
|
156
|
+
export function bootDiskPath(machineDir: string, format: BootDiskFormat): string {
|
|
157
|
+
return join(machineDir, format === "qcow2" ? "boot.qcow2" : "disk.img");
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
161
|
+
* Write a machine's boot disk from a factory raw image, replacing any existing one.
|
|
162
|
+
*
|
|
163
|
+
* The disk is built under a `.tmp` name and renamed into place only once complete, so
|
|
164
|
+
* an interrupted copy or conversion never leaves a file the next start would accept.
|
|
165
|
+
* qcow2 is converted straight from `sourceImg` (normally the shared cache image) and
|
|
166
|
+
* checked to be standalone: no machine-local raw copy is kept, and no guest disk
|
|
167
|
+
* depends on the cache through a backing chain.
|
|
149
168
|
*/
|
|
150
|
-
export async function
|
|
169
|
+
export async function writeBootDisk(
|
|
151
170
|
machineDir: string,
|
|
171
|
+
sourceImg: string,
|
|
172
|
+
format: BootDiskFormat,
|
|
152
173
|
bootSize?: string,
|
|
153
|
-
bootDiskFormat?: BootDiskFormat,
|
|
154
174
|
): Promise<{ path: string; format: BootDiskFormat }> {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
const path = bootDiskPath(machineDir, format);
|
|
176
|
+
const tmpPath = `${path}.tmp`;
|
|
177
|
+
rmSync(tmpPath, { force: true });
|
|
178
|
+
try {
|
|
179
|
+
if (format === "raw") {
|
|
180
|
+
copyFileSync(sourceImg, tmpPath);
|
|
181
|
+
} else {
|
|
182
|
+
await convertRawToQcow2(sourceImg, tmpPath);
|
|
183
|
+
if (bootSize) await resizeQcow2(tmpPath, bootSize);
|
|
184
|
+
const info = await getDiskInfo(tmpPath);
|
|
185
|
+
if (info.format !== "qcow2" || info.backingFilename) {
|
|
186
|
+
throw new QuickCHRError(
|
|
187
|
+
"PROCESS_FAILED",
|
|
188
|
+
`Converted boot disk is not a standalone qcow2 (format=${info.format}${info.backingFilename ? `, backing=${info.backingFilename}` : ""})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
renameWithRetry(tmpPath, path);
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(tmpPath, { force: true });
|
|
168
195
|
}
|
|
169
|
-
return { path
|
|
196
|
+
return { path, format };
|
|
170
197
|
}
|
|
171
198
|
|
|
172
199
|
/**
|
|
@@ -186,24 +213,53 @@ export async function prepareExtraDisks(
|
|
|
186
213
|
return results;
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
export interface DiskSources {
|
|
217
|
+
/** True once the guest has booted. Its disks then hold guest state, so a missing one
|
|
218
|
+
* is an error: rebuilding it from the factory image would quietly reset the router. */
|
|
219
|
+
booted: boolean;
|
|
220
|
+
/** The factory raw image a missing boot disk of a never-booted machine is built from.
|
|
221
|
+
* Called only when it is needed. */
|
|
222
|
+
factoryImage?: () => Promise<string>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function missingDiskError(machineDir: string, path: string): QuickCHRError {
|
|
226
|
+
const name = basename(machineDir);
|
|
227
|
+
return new QuickCHRError(
|
|
228
|
+
"MACHINE_NOT_FOUND",
|
|
229
|
+
`Disk image not found for "${name}": ${path}. The machine has booted before, so quickchr will not rebuild it from the factory image — that would silently reset the router. Restore the file, or run 'quickchr clean ${name}' to reset it to factory.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Resolve the disks a machine record calls for, creating any that a never-booted
|
|
234
|
+
* machine is still missing. New machines, and older add()-created states that predate
|
|
235
|
+
* eager disk materialization, are built here; a booted machine's disks never are.
|
|
236
|
+
*
|
|
237
|
+
* A qcow2 machine needs only `boot.qcow2`. Machines created before 0.5.1 also hold a
|
|
238
|
+
* `disk.img`; it is unused once `boot.qcow2` exists, and is the conversion source for a
|
|
239
|
+
* legacy never-booted state that has no `boot.qcow2` yet. */
|
|
192
240
|
export async function ensureConfiguredDisks(
|
|
193
241
|
machineDir: string,
|
|
194
|
-
bootSize
|
|
195
|
-
extraDiskSizes
|
|
196
|
-
bootDiskFormat
|
|
242
|
+
bootSize: string | undefined,
|
|
243
|
+
extraDiskSizes: string[] | undefined,
|
|
244
|
+
bootDiskFormat: BootDiskFormat,
|
|
245
|
+
sources: DiskSources,
|
|
197
246
|
): Promise<{
|
|
198
247
|
bootDisk: { path: string; format: BootDiskFormat };
|
|
199
248
|
extraDisks?: { path: string; format: "qcow2" }[];
|
|
200
249
|
}> {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
250
|
+
const bootPath = bootDiskPath(machineDir, bootDiskFormat);
|
|
251
|
+
let bootDisk = { path: bootPath, format: bootDiskFormat };
|
|
252
|
+
if (!existsSync(bootPath)) {
|
|
253
|
+
if (sources.booted) throw missingDiskError(machineDir, bootPath);
|
|
254
|
+
const legacyRaw = join(machineDir, "disk.img");
|
|
255
|
+
const source = bootDiskFormat === "qcow2" && existsSync(legacyRaw)
|
|
256
|
+
? legacyRaw
|
|
257
|
+
: await sources.factoryImage?.();
|
|
258
|
+
if (!source) {
|
|
259
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${basename(machineDir)}": ${bootPath}`);
|
|
260
|
+
}
|
|
261
|
+
bootDisk = await writeBootDisk(machineDir, source, bootDiskFormat, bootSize);
|
|
262
|
+
}
|
|
207
263
|
|
|
208
264
|
let extraDisks: { path: string; format: "qcow2" }[] | undefined;
|
|
209
265
|
if (extraDiskSizes && extraDiskSizes.length > 0) {
|
|
@@ -211,6 +267,7 @@ export async function ensureConfiguredDisks(
|
|
|
211
267
|
for (let i = 0; i < extraDiskSizes.length; i++) {
|
|
212
268
|
const diskPath = join(machineDir, `disk${i + 1}.qcow2`);
|
|
213
269
|
if (!existsSync(diskPath)) {
|
|
270
|
+
if (sources.booted) throw missingDiskError(machineDir, diskPath);
|
|
214
271
|
await createQcow2Disk(diskPath, extraDiskSizes[i] as string);
|
|
215
272
|
}
|
|
216
273
|
extraDisks.push({ path: diskPath, format: "qcow2" });
|
package/src/lib/download.ts
CHANGED
|
@@ -46,11 +46,12 @@
|
|
|
46
46
|
* a transfer; it does not change the transport).
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import { unlinkSync, existsSync
|
|
49
|
+
import { unlinkSync, existsSync } from "node:fs";
|
|
50
50
|
import { randomUUID } from "node:crypto";
|
|
51
51
|
import { QuickCHRError } from "./types.ts";
|
|
52
52
|
import { fetchResilient } from "./net.ts";
|
|
53
53
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
54
|
+
import { renameWithRetry } from "./rename.ts";
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Throughput to assume when budgeting a COLD download from MikroTik, in bytes
|
|
@@ -257,7 +258,7 @@ export async function downloadToFile(
|
|
|
257
258
|
// so this is a same-filesystem move and the artifact appears at its final
|
|
258
259
|
// path atomically. Copying would re-write every byte (52 MB for the
|
|
259
260
|
// largest artifact) for no benefit.
|
|
260
|
-
|
|
261
|
+
renameWithRetry(partPath, destPath);
|
|
261
262
|
log.status(` Saved (${describeTransfer(outcome)})`);
|
|
262
263
|
return outcome;
|
|
263
264
|
} catch (e) {
|