@tikoci/quickchr 0.4.6 → 0.4.7
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 +241 -1
- package/MANUAL.md +23 -5
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +3 -39
- package/src/lib/net.ts +59 -3
- package/src/lib/packages.ts +6 -12
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +256 -24
- package/src/lib/types.ts +15 -1
- package/src/lib/versions.ts +99 -22
package/src/lib/qemu.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./pla
|
|
|
13
13
|
import { buildHostfwdString } from "./network.ts";
|
|
14
14
|
import { ensureDir } from "./state.ts";
|
|
15
15
|
import { restGet } from "./rest.ts";
|
|
16
|
+
import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
|
|
16
17
|
|
|
17
18
|
export interface QemuLaunchConfig {
|
|
18
19
|
arch: Arch;
|
|
@@ -221,12 +222,20 @@ function buildChannelArgs(
|
|
|
221
222
|
qgaSock = `path=${join(machineDir, "qga.sock")}`;
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
// QUICKCHR_SERIAL_LOG=1 tees the serial console to <machineDir>/serial.log via
|
|
226
|
+
// the QEMU chardev `logfile=` option. Opt-in, never default: serial-console
|
|
227
|
+
// provisioning types the generated user password in cleartext (provision.ts
|
|
228
|
+
// provisionViaConsole), so this log is secret-bearing. It is the only record of
|
|
229
|
+
// what RouterOS printed when a boot never reaches REST — the socket chardev
|
|
230
|
+
// keeps no history, so connecting after the failure shows nothing (#79).
|
|
231
|
+
const serialLog = serialLogEnabled() ? `,logfile=${join(machineDir, "serial.log")},logappend=on` : "";
|
|
232
|
+
|
|
224
233
|
if (background) {
|
|
225
234
|
// Background: all channels on sockets/pipes
|
|
226
235
|
args.push(
|
|
227
236
|
"-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
|
|
228
237
|
"-mon", "chardev=monitor0,mode=readline",
|
|
229
|
-
"-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
|
|
238
|
+
"-chardev", `socket,id=serial0,${serialSock},server=on,wait=off${serialLog}`,
|
|
230
239
|
"-serial", "chardev:serial0",
|
|
231
240
|
);
|
|
232
241
|
} else {
|
|
@@ -517,9 +526,11 @@ export async function waitForBoot(
|
|
|
517
526
|
httpPort: number,
|
|
518
527
|
timeoutMs: number = 120_000,
|
|
519
528
|
auth: string = `Basic ${btoa("admin:")}`,
|
|
529
|
+
stats?: BootProbeStats,
|
|
520
530
|
): Promise<boolean> {
|
|
521
531
|
const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
|
|
522
|
-
const
|
|
532
|
+
const start = Date.now();
|
|
533
|
+
const deadline = start + timeoutMs;
|
|
523
534
|
let consecutiveReady = 0;
|
|
524
535
|
|
|
525
536
|
while (Date.now() < deadline) {
|
|
@@ -530,6 +541,7 @@ export async function waitForBoot(
|
|
|
530
541
|
// Auth rejected — the REST layer is up and responded, even though
|
|
531
542
|
// we cannot validate the body. Count as ready.
|
|
532
543
|
consecutiveReady++;
|
|
544
|
+
recordBootProbe(stats, Date.now() - start, "unauthorized", String(status));
|
|
533
545
|
} else if (status >= 200 && status < 300) {
|
|
534
546
|
// RouterOS REST may return wrong data (array body) briefly after boot —
|
|
535
547
|
// a startup race. Only accept once the body is the expected singleton object.
|
|
@@ -537,17 +549,24 @@ export async function waitForBoot(
|
|
|
537
549
|
try { parsed = JSON.parse(body); } catch { /* ignore */ }
|
|
538
550
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
|
|
539
551
|
consecutiveReady++;
|
|
552
|
+
recordBootProbe(stats, Date.now() - start, "ready");
|
|
540
553
|
} else {
|
|
541
554
|
consecutiveReady = 0; // wrong body — REST not fully initialized yet
|
|
555
|
+
recordBootProbe(stats, Date.now() - start, "wrong-body", body.slice(0, 80));
|
|
542
556
|
}
|
|
543
557
|
} else {
|
|
544
558
|
consecutiveReady = 0;
|
|
559
|
+
recordBootProbe(stats, Date.now() - start, "http-error", String(status));
|
|
545
560
|
}
|
|
546
561
|
|
|
547
562
|
if (consecutiveReady >= 2) return true;
|
|
548
|
-
} catch {
|
|
549
|
-
// ECONNRESET, connection refused, timeout — not ready yet
|
|
563
|
+
} catch (e) {
|
|
564
|
+
// ECONNRESET, connection refused, timeout — not ready yet. Which one it
|
|
565
|
+
// was is the whole diagnosis when a boot never completes (#79), so it is
|
|
566
|
+
// classified rather than swallowed.
|
|
550
567
|
consecutiveReady = 0;
|
|
568
|
+
const { outcome, detail } = classifyProbeError(e);
|
|
569
|
+
recordBootProbe(stats, Date.now() - start, outcome, detail);
|
|
551
570
|
}
|
|
552
571
|
await Bun.sleep(2000);
|
|
553
572
|
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -56,7 +56,8 @@ import { autoPruneIfOverCap } from "./cache.ts";
|
|
|
56
56
|
import { resolveSetting } from "./settings.ts";
|
|
57
57
|
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
58
58
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
59
|
-
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
59
|
+
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
60
|
+
import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
|
|
60
61
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
61
62
|
import { provision } from "./provision.ts";
|
|
62
63
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
@@ -65,7 +66,7 @@ import { scpPush, scpPull } from "./scp.ts";
|
|
|
65
66
|
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
66
67
|
import { restExecute } from "./exec.ts";
|
|
67
68
|
import { qgaExec } from "./qga.ts";
|
|
68
|
-
import { consoleExec } from "./console.ts";
|
|
69
|
+
import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
|
|
69
70
|
import { restRequest, restGet, restPost } from "./rest.ts";
|
|
70
71
|
import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
|
|
71
72
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
@@ -79,6 +80,7 @@ import {
|
|
|
79
80
|
waitForDeviceModeApi,
|
|
80
81
|
} from "./device-mode.ts";
|
|
81
82
|
import type { LicenseOptions } from "./types.ts";
|
|
83
|
+
import type { GuestExec } from "./guest-snapshot.ts";
|
|
82
84
|
import { toChrPorts } from "./network.ts";
|
|
83
85
|
import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
84
86
|
import { existsSync, readdirSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
|
|
@@ -412,11 +414,11 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
412
414
|
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
413
415
|
tzspGatewayIp: "10.0.2.2",
|
|
414
416
|
|
|
415
|
-
async waitForBoot(timeoutMs?: number): Promise<boolean> {
|
|
417
|
+
async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
|
|
416
418
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
417
419
|
// on authenticated machines (post-provisioning, post-install reboots).
|
|
418
420
|
const auth = resolveAuth(state);
|
|
419
|
-
return waitForBoot(ports.http, timeoutMs, auth.header);
|
|
421
|
+
return waitForBoot(ports.http, timeoutMs, auth.header, stats);
|
|
420
422
|
},
|
|
421
423
|
|
|
422
424
|
async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
|
|
@@ -487,18 +489,43 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
487
489
|
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
488
490
|
);
|
|
489
491
|
|
|
490
|
-
//
|
|
492
|
+
// Every guest-side credential went with the old disk, so the credential
|
|
493
|
+
// facts in machine.json now describe a machine that no longer exists.
|
|
494
|
+
// Nothing re-provisions them either: a post-clean() start() takes the
|
|
495
|
+
// `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
|
|
496
|
+
// erased account is never recreated. Left in place, `resolveAuth()` /
|
|
497
|
+
// `resolveCreds()` prefer `state.user` and authenticate every REST call,
|
|
498
|
+
// exec, and SCP as a deleted user — the confound #79 removes here.
|
|
499
|
+
// Clearing them restores the documented fallback: factory `admin` with an
|
|
500
|
+
// empty password, which is what a fresh CHR image actually answers to
|
|
501
|
+
// (anchored by the "clean() resets disk to factory defaults" integration
|
|
502
|
+
// test). `disableAdmin` goes with them because it is read as a live fact
|
|
503
|
+
// about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
|
|
504
|
+
// image has admin enabled again. Provisioning *intent* — packages,
|
|
505
|
+
// deviceMode, secureLogin — is not guest state and survives.
|
|
491
506
|
deleteInstanceCredentials(state.name);
|
|
507
|
+
// The managed keypair authenticated to the account factory reset erased,
|
|
508
|
+
// so it is dead credential material sitting in the machine dir. Removing
|
|
509
|
+
// it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
|
|
510
|
+
// existing file exits 1 with "already exists" rather than overwriting
|
|
511
|
+
// (verified locally), so a leftover key would break `installSshKey()`.
|
|
512
|
+
rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
|
|
492
513
|
|
|
493
514
|
// Update state
|
|
494
515
|
const current = loadMachine(state.name);
|
|
495
516
|
if (current) {
|
|
496
517
|
current.status = "stopped";
|
|
497
518
|
current.pid = undefined;
|
|
519
|
+
current.user = undefined;
|
|
520
|
+
current.managedSshKey = undefined;
|
|
521
|
+
current.disableAdmin = undefined;
|
|
498
522
|
saveMachine(current);
|
|
499
523
|
}
|
|
500
524
|
state.status = "stopped";
|
|
501
525
|
state.pid = undefined;
|
|
526
|
+
state.user = undefined;
|
|
527
|
+
state.managedSshKey = undefined;
|
|
528
|
+
state.disableAdmin = undefined;
|
|
502
529
|
},
|
|
503
530
|
|
|
504
531
|
async monitor(command: string): Promise<string> {
|
|
@@ -950,6 +977,183 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
|
|
|
950
977
|
return false;
|
|
951
978
|
}
|
|
952
979
|
|
|
980
|
+
/** Where boot-failure reports land: `<dataDir>/failures/`, deliberately outside
|
|
981
|
+
* any machine directory so `remove()` — which every integration test calls from
|
|
982
|
+
* its `finally` — cannot delete the evidence it was written to explain (#79). */
|
|
983
|
+
function bootFailureReportDir(): string {
|
|
984
|
+
return join(getDataDir(), "failures");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* How long one credential candidate gets inside a single guest-exec call.
|
|
989
|
+
*
|
|
990
|
+
* `timeoutMs` is a budget for the whole call, not per attempt: `captureGuestSnapshot()`
|
|
991
|
+
* sizes its own budget assuming one exec costs at most what it passed in, so N
|
|
992
|
+
* candidates × the full timeout would spend the snapshot's entire budget on the
|
|
993
|
+
* first query. Untried candidates therefore share it, and once a credential is
|
|
994
|
+
* known to work it gets all of it — which is every query after the first.
|
|
995
|
+
*
|
|
996
|
+
* The floor is {@link CONSOLE_LOGIN_COST_MS} rather than a round number, because
|
|
997
|
+
* a share below the cost of a login cannot succeed **by construction** — and a
|
|
998
|
+
* candidate that can never succeed also never becomes the working one, so every
|
|
999
|
+
* later query repeats the same doomed division. The floor used to be 5 s against
|
|
1000
|
+
* a measured 11.4 s login, which is why forensics reported "console unreachable"
|
|
1001
|
+
* for guests that were answering serial in 10 ms (#69). A share this large is
|
|
1002
|
+
* affordable only because `captureGuestSnapshot()` adds a login allowance to its
|
|
1003
|
+
* query budget until the console answers; see `GUEST_LOGIN_ALLOWANCE_MS`.
|
|
1004
|
+
*
|
|
1005
|
+
* Exported for tests: this is the arithmetic the #69/B10 fix turns on, and it is
|
|
1006
|
+
* otherwise reachable only through a live serial socket.
|
|
1007
|
+
*/
|
|
1008
|
+
export function credentialShareMs(timeoutMs: number, candidateCount: number, haveWorking: boolean): number {
|
|
1009
|
+
if (haveWorking) return timeoutMs;
|
|
1010
|
+
return Math.max(CONSOLE_LOGIN_COST_MS, Math.floor(timeoutMs / Math.max(1, candidateCount)));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Serial-console executor for boot forensics, or null when there is no serial
|
|
1014
|
+
* channel to talk to (foreground runs, or QEMU never got far enough).
|
|
1015
|
+
*
|
|
1016
|
+
* Which account works depends on how far the failed boot got: a machine that
|
|
1017
|
+
* timed out before provisioning — and any machine after `clean()` — is factory
|
|
1018
|
+
* fresh and answers only to `admin` with an empty password, while a relaunch of
|
|
1019
|
+
* an already-provisioned machine needs its stored credentials. Rather than
|
|
1020
|
+
* guess, candidates are tried in order and the first that reaches a prompt is
|
|
1021
|
+
* reused for the rest of the snapshot. */
|
|
1022
|
+
function bootFailureGuestExec(state: MachineState): { exec: GuestExec; users: string[] } | null {
|
|
1023
|
+
if (!channelFileExists(channelPath(state.machineDir, "serial"))) return null;
|
|
1024
|
+
|
|
1025
|
+
// Order matters only for speed — a wrong candidate costs a ~15 s login timeout
|
|
1026
|
+
// before the next is tried. Stored credentials are the strongest evidence the
|
|
1027
|
+
// machine is provisioned (provisioning stores them on success), so they lead;
|
|
1028
|
+
// factory admin comes next because a cleaned machine has neither (`clean()`
|
|
1029
|
+
// clears both the stored credentials and `state.user`), leaving `state.user`
|
|
1030
|
+
// as the weakest case: a user configured on the machine but never provisioned
|
|
1031
|
+
// onto the guest.
|
|
1032
|
+
const candidates: { user: string; password: string }[] = [];
|
|
1033
|
+
// Guarded even though secrets.ts already swallows unreadable/corrupt stores:
|
|
1034
|
+
// this runs on the BOOT_TIMEOUT path *before* captureBootFailure(), so
|
|
1035
|
+
// anything thrown here would replace the forensics report with an unrelated
|
|
1036
|
+
// error — the exact failure mode the diagnostics modules exist to avoid.
|
|
1037
|
+
let stored: { user: string; password: string } | null = null;
|
|
1038
|
+
try {
|
|
1039
|
+
stored = getInstanceCredentials(state.name);
|
|
1040
|
+
} catch { /* no stored credentials — fall through to the other candidates */ }
|
|
1041
|
+
if (stored) candidates.push(stored);
|
|
1042
|
+
candidates.push({ user: "admin", password: "" });
|
|
1043
|
+
if (state.user && state.user.name !== "admin" && state.user.password !== STORED_IN_SECRETS_PASSWORD) {
|
|
1044
|
+
candidates.push({ user: state.user.name, password: state.user.password });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
let working: { user: string; password: string } | undefined;
|
|
1048
|
+
const exec: GuestExec = async (command, timeoutMs) => {
|
|
1049
|
+
const order = working ? [working, ...candidates.filter((c) => c !== working)] : candidates;
|
|
1050
|
+
const deadline = Date.now() + timeoutMs;
|
|
1051
|
+
const share = credentialShareMs(timeoutMs, order.length, working !== undefined);
|
|
1052
|
+
let lastError: unknown = new Error("no credential candidates");
|
|
1053
|
+
for (const cred of order) {
|
|
1054
|
+
const remaining = deadline - Date.now();
|
|
1055
|
+
if (remaining <= 0) break;
|
|
1056
|
+
try {
|
|
1057
|
+
const { output, framed } = await consoleExec(
|
|
1058
|
+
state.machineDir, command, cred.user, cred.password, Math.min(share, remaining), state.portBase,
|
|
1059
|
+
);
|
|
1060
|
+
working = cred;
|
|
1061
|
+
return { output, framed };
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
lastError = e;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
return { exec, users: candidates.map((c) => c.user) };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Capture the boot-failure evidence set for a machine that is **already
|
|
1074
|
+
* running** — a request that failed after the machine was declared REST-ready.
|
|
1075
|
+
*
|
|
1076
|
+
* Same instruments as the BOOT_TIMEOUT path (guest snapshot, per-port slirp
|
|
1077
|
+
* classification, monitor state, QEMU liveness, embedded logs), because #69's
|
|
1078
|
+
* one-line `ECONNRESET` has never been enough to say whether the reset came from
|
|
1079
|
+
* the client, RouterOS `www`, or slirp — and every one of those instruments
|
|
1080
|
+
* answers a different part of that. What it does *not* share is the boot
|
|
1081
|
+
* budget's excuse for the machine looking healthy: {@link FailureTrigger} is
|
|
1082
|
+
* required here, so the report always records which request died and what
|
|
1083
|
+
* changed just before it.
|
|
1084
|
+
*
|
|
1085
|
+
* Does not stop, remove, or otherwise touch the machine — the caller owns its
|
|
1086
|
+
* lifecycle and may well want to keep testing against it. Never throws;
|
|
1087
|
+
* `captureBootFailure()` swallows its own failures.
|
|
1088
|
+
*
|
|
1089
|
+
* One trace it does leave, and the reason that matters more here than on the
|
|
1090
|
+
* boot path: the guest snapshot logs in over the serial console, and a RouterOS
|
|
1091
|
+
* console login survives the host socket closing. A boot-timeout capture is
|
|
1092
|
+
* followed by `stop()`/`remove()`, so nothing outlives it; here the machine
|
|
1093
|
+
* keeps running. Harmless — `consoleExec()` `/quit`s a session belonging to a
|
|
1094
|
+
* different user before it logs in — but a later console interaction on this
|
|
1095
|
+
* machine starts from an already-logged-in prompt, not a `Login:` one.
|
|
1096
|
+
*/
|
|
1097
|
+
export async function captureRunningFailure(
|
|
1098
|
+
state: MachineState,
|
|
1099
|
+
phase: string,
|
|
1100
|
+
trigger: FailureTrigger,
|
|
1101
|
+
): Promise<BootFailureReport> {
|
|
1102
|
+
// Approximate, and labelled as such in the field docs: lastStartedAt is
|
|
1103
|
+
// stamped at spawn and lastBootMs measured from just before it, so their sum
|
|
1104
|
+
// is REST-ready to within the few ms between those two statements. Both are
|
|
1105
|
+
// absent on a machine that never completed a timed boot, in which case the
|
|
1106
|
+
// field is simply omitted rather than guessed at.
|
|
1107
|
+
const readyAt = state.lastStartedAt !== undefined && state.lastBootMs !== undefined
|
|
1108
|
+
? Date.parse(state.lastStartedAt) + state.lastBootMs
|
|
1109
|
+
: undefined;
|
|
1110
|
+
const guest = bootFailureGuestExec(state);
|
|
1111
|
+
return await captureBootFailure({
|
|
1112
|
+
name: state.name,
|
|
1113
|
+
machineDir: state.machineDir,
|
|
1114
|
+
arch: state.arch,
|
|
1115
|
+
accel: state.lastAccel ?? "unknown",
|
|
1116
|
+
pid: state.pid,
|
|
1117
|
+
httpPort: state.ports.http?.host,
|
|
1118
|
+
portBase: state.portBase,
|
|
1119
|
+
phase,
|
|
1120
|
+
trigger: {
|
|
1121
|
+
...trigger,
|
|
1122
|
+
sinceReadyMs: trigger.sinceReadyMs
|
|
1123
|
+
?? (readyAt !== undefined && Number.isFinite(readyAt) ? Date.now() - readyAt : undefined),
|
|
1124
|
+
},
|
|
1125
|
+
reportDir: bootFailureReportDir(),
|
|
1126
|
+
monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
|
|
1127
|
+
forwards: Object.values(state.ports),
|
|
1128
|
+
guestExec: guest?.exec,
|
|
1129
|
+
guestUser: guest?.users.join(" → "),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Tear down a machine that failed to boot, and describe what was done.
|
|
1134
|
+
*
|
|
1135
|
+
* Default is full cleanup — a failed `start()` must not leave a half-dead
|
|
1136
|
+
* machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
|
|
1137
|
+
* inverts that for **local** debugging: the QEMU process is still stopped (it is
|
|
1138
|
+
* wedged, and its port block must be released), but the machine directory
|
|
1139
|
+
* survives so it can be inspected by hand.
|
|
1140
|
+
*
|
|
1141
|
+
* Evidence does not depend on that flag, and CI deliberately does not set it —
|
|
1142
|
+
* `captureBootFailure()` has already written its report outside the machine dir
|
|
1143
|
+
* with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
|
|
1144
|
+
async function cleanupAfterBootFailure(
|
|
1145
|
+
instance: ChrInstance,
|
|
1146
|
+
name: string,
|
|
1147
|
+
failure: BootFailureReport,
|
|
1148
|
+
): Promise<string> {
|
|
1149
|
+
try { await instance.stop(); } catch { /* ignore */ }
|
|
1150
|
+
if (failure.preserved) {
|
|
1151
|
+
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
|
|
1152
|
+
}
|
|
1153
|
+
try { await instance.remove(); } catch { /* ignore */ }
|
|
1154
|
+
return ` Machine "${name}" has been cleaned up automatically.`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
953
1157
|
/** Wait for CHR to boot with periodic progress status updates every 20s. */
|
|
954
1158
|
async function waitForBootWithProgress(
|
|
955
1159
|
instance: ChrInstance,
|
|
@@ -1569,7 +1773,8 @@ export class QuickCHR {
|
|
|
1569
1773
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1570
1774
|
|
|
1571
1775
|
// Always wait for boot in background mode — the JSDoc promises "REST-ready".
|
|
1572
|
-
|
|
1776
|
+
const probe = newBootProbeStats();
|
|
1777
|
+
let booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1573
1778
|
|
|
1574
1779
|
// Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
|
|
1575
1780
|
// runners) occasionally produce a single QEMU process that boots but never
|
|
@@ -1586,22 +1791,35 @@ export class QuickCHR {
|
|
|
1586
1791
|
({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
|
|
1587
1792
|
state.pid = pid;
|
|
1588
1793
|
saveMachine(state);
|
|
1589
|
-
booted = await instance.waitForBoot(bootTimeout);
|
|
1794
|
+
booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1590
1795
|
}
|
|
1591
1796
|
|
|
1592
1797
|
if (!booted) {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1798
|
+
const guest = bootFailureGuestExec(state);
|
|
1799
|
+
const failure = await captureBootFailure({
|
|
1800
|
+
name,
|
|
1801
|
+
machineDir,
|
|
1802
|
+
arch,
|
|
1803
|
+
accel,
|
|
1804
|
+
bootTimeoutMs: bootTimeout,
|
|
1805
|
+
pid,
|
|
1806
|
+
httpPort: instance.ports.http,
|
|
1807
|
+
portBase: state.portBase,
|
|
1808
|
+
qemuArgs,
|
|
1809
|
+
probe,
|
|
1810
|
+
phase: "start",
|
|
1811
|
+
reportDir: bootFailureReportDir(),
|
|
1812
|
+
monitorQuery: (cmd) => monitorCommand(machineDir, cmd, 3000, state.portBase),
|
|
1813
|
+
forwards: Object.values(state.ports),
|
|
1814
|
+
guestExec: guest?.exec,
|
|
1815
|
+
guestUser: guest?.users.join(" → "),
|
|
1816
|
+
});
|
|
1600
1817
|
throw new QuickCHRError(
|
|
1601
1818
|
"BOOT_TIMEOUT",
|
|
1602
1819
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1603
1820
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1604
|
-
|
|
1821
|
+
await cleanupAfterBootFailure(instance, name, failure) +
|
|
1822
|
+
`\n${failure.summary}`,
|
|
1605
1823
|
);
|
|
1606
1824
|
}
|
|
1607
1825
|
|
|
@@ -1882,20 +2100,34 @@ export class QuickCHR {
|
|
|
1882
2100
|
|
|
1883
2101
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
1884
2102
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
1885
|
-
const
|
|
2103
|
+
const probe = newBootProbeStats();
|
|
2104
|
+
const booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1886
2105
|
if (!booted) {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2106
|
+
const guest = bootFailureGuestExec(state);
|
|
2107
|
+
const failure = await captureBootFailure({
|
|
2108
|
+
name: state.name,
|
|
2109
|
+
machineDir: state.machineDir,
|
|
2110
|
+
arch: state.arch,
|
|
2111
|
+
accel,
|
|
2112
|
+
bootTimeoutMs: bootTimeout,
|
|
2113
|
+
pid,
|
|
2114
|
+
httpPort: instance.ports.http,
|
|
2115
|
+
portBase: state.portBase,
|
|
2116
|
+
qemuArgs,
|
|
2117
|
+
probe,
|
|
2118
|
+
phase: "_launchExisting",
|
|
2119
|
+
reportDir: bootFailureReportDir(),
|
|
2120
|
+
monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
|
|
2121
|
+
forwards: Object.values(state.ports),
|
|
2122
|
+
guestExec: guest?.exec,
|
|
2123
|
+
guestUser: guest?.users.join(" → "),
|
|
2124
|
+
});
|
|
1894
2125
|
throw new QuickCHRError(
|
|
1895
2126
|
"BOOT_TIMEOUT",
|
|
1896
2127
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1897
2128
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1898
|
-
|
|
2129
|
+
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2130
|
+
`\n${failure.summary}`,
|
|
1899
2131
|
);
|
|
1900
2132
|
}
|
|
1901
2133
|
|
package/src/lib/types.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ProgressLogger } from "./log.ts";
|
|
6
|
+
import type { BootProbeStats } from "./diagnostics.ts";
|
|
6
7
|
|
|
7
8
|
// --- Version & Architecture ---
|
|
8
9
|
|
|
@@ -598,7 +599,10 @@ export interface ChrInstance {
|
|
|
598
599
|
* After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
|
|
599
600
|
* the returned instance is already REST-reachable — this returns immediately.
|
|
600
601
|
* Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
|
|
601
|
-
|
|
602
|
+
/** @param stats Optional out-param; each REST readiness probe is classified
|
|
603
|
+
* into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
|
|
604
|
+
* whether the forwarded port ever answered. */
|
|
605
|
+
waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
|
|
602
606
|
/** Poll a condition until it returns true or the timeout elapses.
|
|
603
607
|
* Retries every 2 s, swallowing errors from the condition function.
|
|
604
608
|
* Returns `true` when the condition passes, `false` on timeout.
|
|
@@ -784,6 +788,16 @@ export type ErrorCode =
|
|
|
784
788
|
| "QGA_UNSUPPORTED"
|
|
785
789
|
| "QGA_TIMEOUT"
|
|
786
790
|
| "DOWNLOAD_FAILED"
|
|
791
|
+
// A transfer that received nothing for DOWNLOAD_STALL_MS. Retriable, and
|
|
792
|
+
// retried before this surfaces — a wedged socket usually moves on the next
|
|
793
|
+
// attempt (#116).
|
|
794
|
+
| "DOWNLOAD_STALLED"
|
|
795
|
+
// A transfer that kept moving but could not finish inside a budget derived
|
|
796
|
+
// from its content-length and COLD_DOWNLOAD_FLOOR_BYTES_PER_S. Terminal on
|
|
797
|
+
// purpose: retrying re-downloads from zero on a link already measured slower
|
|
798
|
+
// than the floor, which is the "one slow transfer becomes three" behavior
|
|
799
|
+
// #116 exists to remove.
|
|
800
|
+
| "DOWNLOAD_TOO_SLOW"
|
|
787
801
|
| "MACHINE_EXISTS"
|
|
788
802
|
| "MACHINE_NOT_FOUND"
|
|
789
803
|
| "MACHINE_RUNNING"
|
package/src/lib/versions.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Arch, Channel } from "./types.ts";
|
|
6
6
|
import { CHANNELS, QuickCHRError } from "./types.ts";
|
|
7
|
-
import { fetchResilient } from "./net.ts";
|
|
7
|
+
import { fetchResilient, isConnectionFailure } from "./net.ts";
|
|
8
8
|
|
|
9
9
|
const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
|
10
10
|
const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
|
|
@@ -65,35 +65,112 @@ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION):
|
|
|
65
65
|
return `Use --channel long-term or --version ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}, or keep the older version without provisioning options.`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
export
|
|
70
|
-
|
|
68
|
+
/** Attempts for a connection-class failure while resolving a channel version. */
|
|
69
|
+
export const VERSION_RESOLVE_MAX_ATTEMPTS = 3;
|
|
70
|
+
|
|
71
|
+
/** Backoff base: attempt N waits N × this before N+1. Mirrors `downloadToFile`. */
|
|
72
|
+
const VERSION_RESOLVE_RETRY_MS = 2000;
|
|
73
|
+
|
|
74
|
+
export interface ResolveVersionOptions {
|
|
75
|
+
/** Attempts for connection-class failures. Defaults to {@link VERSION_RESOLVE_MAX_ATTEMPTS}. */
|
|
76
|
+
maxAttempts?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Override the backoff base. A test lever, like `downloadToFile`'s `stallMs`:
|
|
79
|
+
* the real backoff spends 6 s of wall clock exhausting the default attempts.
|
|
80
|
+
* Production callers should not set it.
|
|
81
|
+
*/
|
|
82
|
+
retryDelayMs?: number;
|
|
83
|
+
}
|
|
71
84
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
/** A whole count of at least 1, or `fallback` for anything that isn't one. */
|
|
86
|
+
function positiveInt(value: number | undefined, fallback: number): number {
|
|
87
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
88
|
+
return Math.max(1, Math.floor(value));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A finite, non-negative delay in ms, or `fallback` for anything that isn't one. */
|
|
92
|
+
function nonNegativeMs(value: number | undefined, fallback: number): number {
|
|
93
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
94
|
+
return Math.max(0, value);
|
|
95
|
+
}
|
|
79
96
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Fetch the latest version for a given channel from MikroTik's upgrade server.
|
|
99
|
+
*
|
|
100
|
+
* Retried on a connection-class failure only — a transient refusal at
|
|
101
|
+
* `upgrade.mikrotik.com` used to fail a whole `quickchr start`, since this sits
|
|
102
|
+
* on the critical path of `start()`, the wizard and the CLI while only the
|
|
103
|
+
* download path had a retry policy (#121). An HTTP answer is the server's verdict
|
|
104
|
+
* and stays terminal on the first attempt: a 404 for a bad channel must fail fast.
|
|
105
|
+
*/
|
|
106
|
+
export async function resolveVersion(
|
|
107
|
+
channel: Channel,
|
|
108
|
+
opts: ResolveVersionOptions = {},
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const url = `${UPGRADE_BASE}.${channel}`;
|
|
111
|
+
// Sanitized, not just clamped: these are public options, and `Math.max(1, NaN)`
|
|
112
|
+
// is NaN — the loop would never run, and the failure would read "all NaN
|
|
113
|
+
// attempts: undefined" instead of doing anything.
|
|
114
|
+
const maxAttempts = positiveInt(opts.maxAttempts, VERSION_RESOLVE_MAX_ATTEMPTS);
|
|
115
|
+
const retryDelayMs = nonNegativeMs(opts.retryDelayMs, VERSION_RESOLVE_RETRY_MS);
|
|
116
|
+
let lastError: unknown;
|
|
117
|
+
|
|
118
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
119
|
+
let text: string;
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetchResilient(url);
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
// An HTTP answer is the server's verdict. Thrown as a QuickCHRError so
|
|
124
|
+
// the catch below can tell it from a transport failure and let it out.
|
|
125
|
+
throw new QuickCHRError(
|
|
126
|
+
"DOWNLOAD_FAILED",
|
|
127
|
+
`Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// Inside the try: the body can fail *after* the headers arrive (a reset
|
|
131
|
+
// mid-response), which is a transport failure like any other and must be
|
|
132
|
+
// retried rather than escaping the loop unwrapped.
|
|
133
|
+
text = await response.text();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Only "could not reach the host at all" is retried. An HTTP verdict, an
|
|
136
|
+
// abort (the caller gave up) or a real bug surfaces immediately.
|
|
137
|
+
if (err instanceof QuickCHRError) throw err;
|
|
138
|
+
if (!isConnectionFailure(err)) throw err;
|
|
139
|
+
lastError = err;
|
|
140
|
+
if (attempt < maxAttempts) await Bun.sleep(attempt * retryDelayMs);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Parsing is terminal: the server answered, and answering with something
|
|
145
|
+
// unparseable is not a condition another attempt can improve.
|
|
146
|
+
// Response format: "7.22.1 1774276515" (version + unix timestamp)
|
|
147
|
+
const version = text.trim().split(/\s+/)[0]?.trim();
|
|
148
|
+
if (!version || !isValidVersion(version)) {
|
|
149
|
+
throw new QuickCHRError(
|
|
150
|
+
"INVALID_VERSION",
|
|
151
|
+
`Unexpected version format for channel "${channel}": "${text.trim()}"`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return version;
|
|
88
156
|
}
|
|
89
157
|
|
|
90
|
-
|
|
158
|
+
// Says "1 attempt" rather than "all 1 attempts" — these land in CI logs.
|
|
159
|
+
const tries = maxAttempts === 1 ? "1 attempt" : `all ${maxAttempts} attempts`;
|
|
160
|
+
throw new QuickCHRError(
|
|
161
|
+
"DOWNLOAD_FAILED",
|
|
162
|
+
`Failed to fetch version for channel "${channel}" on ${tries}: ` +
|
|
163
|
+
`${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
164
|
+
`Check network access to ${new URL(url).hostname}, or pass an explicit --version to skip channel resolution.`,
|
|
165
|
+
);
|
|
91
166
|
}
|
|
92
167
|
|
|
93
168
|
/** Fetch latest versions for all channels in parallel. */
|
|
94
|
-
export async function resolveAllVersions(
|
|
169
|
+
export async function resolveAllVersions(
|
|
170
|
+
opts: ResolveVersionOptions = {},
|
|
171
|
+
): Promise<Record<Channel, string>> {
|
|
95
172
|
const results = await Promise.all(
|
|
96
|
-
CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
|
|
173
|
+
CHANNELS.map(async (ch) => [ch, await resolveVersion(ch, opts)] as const),
|
|
97
174
|
);
|
|
98
175
|
return Object.fromEntries(results) as Record<Channel, string>;
|
|
99
176
|
}
|