@tikoci/quickchr 0.4.5 → 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 +260 -7
- package/MANUAL.md +44 -12
- package/package.json +1 -1
- package/src/cli/index.ts +31 -0
- 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/platform.ts +153 -19
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +266 -26
- package/src/lib/settings-file.ts +52 -0
- package/src/lib/settings.ts +20 -42
- package/src/lib/types.ts +37 -1
- package/src/lib/versions.ts +99 -22
package/src/lib/quickchr.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
} from "./types.ts";
|
|
27
27
|
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
28
28
|
import packageJson from "../../package.json";
|
|
29
|
-
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
29
|
+
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
30
30
|
import {
|
|
31
31
|
resolveVersion,
|
|
32
32
|
isValidVersion,
|
|
@@ -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,
|
|
@@ -1526,6 +1730,8 @@ export class QuickCHR {
|
|
|
1526
1730
|
// Build QEMU args and spawn
|
|
1527
1731
|
const platform = await detectPlatform();
|
|
1528
1732
|
const accel = await detectAccel(arch);
|
|
1733
|
+
const note = accelNote(arch, accel);
|
|
1734
|
+
if (note) logger.warn(note);
|
|
1529
1735
|
registerSocketMembers(state);
|
|
1530
1736
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1531
1737
|
const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
|
|
@@ -1567,7 +1773,8 @@ export class QuickCHR {
|
|
|
1567
1773
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1568
1774
|
|
|
1569
1775
|
// Always wait for boot in background mode — the JSDoc promises "REST-ready".
|
|
1570
|
-
|
|
1776
|
+
const probe = newBootProbeStats();
|
|
1777
|
+
let booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1571
1778
|
|
|
1572
1779
|
// Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
|
|
1573
1780
|
// runners) occasionally produce a single QEMU process that boots but never
|
|
@@ -1584,22 +1791,35 @@ export class QuickCHR {
|
|
|
1584
1791
|
({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
|
|
1585
1792
|
state.pid = pid;
|
|
1586
1793
|
saveMachine(state);
|
|
1587
|
-
booted = await instance.waitForBoot(bootTimeout);
|
|
1794
|
+
booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1588
1795
|
}
|
|
1589
1796
|
|
|
1590
1797
|
if (!booted) {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
+
});
|
|
1598
1817
|
throw new QuickCHRError(
|
|
1599
1818
|
"BOOT_TIMEOUT",
|
|
1600
1819
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1601
1820
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1602
|
-
|
|
1821
|
+
await cleanupAfterBootFailure(instance, name, failure) +
|
|
1822
|
+
`\n${failure.summary}`,
|
|
1603
1823
|
);
|
|
1604
1824
|
}
|
|
1605
1825
|
|
|
@@ -1838,6 +2058,8 @@ export class QuickCHR {
|
|
|
1838
2058
|
|
|
1839
2059
|
const platform = await detectPlatform();
|
|
1840
2060
|
const accel = await detectAccel(state.arch);
|
|
2061
|
+
const note = accelNote(state.arch, accel);
|
|
2062
|
+
if (note) (logger ?? createLogger()).warn(note);
|
|
1841
2063
|
registerSocketMembers(state);
|
|
1842
2064
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1843
2065
|
const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
|
|
@@ -1878,20 +2100,34 @@ export class QuickCHR {
|
|
|
1878
2100
|
|
|
1879
2101
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
1880
2102
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
1881
|
-
const
|
|
2103
|
+
const probe = newBootProbeStats();
|
|
2104
|
+
const booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1882
2105
|
if (!booted) {
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
+
});
|
|
1890
2125
|
throw new QuickCHRError(
|
|
1891
2126
|
"BOOT_TIMEOUT",
|
|
1892
2127
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1893
2128
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1894
|
-
|
|
2129
|
+
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2130
|
+
`\n${failure.summary}`,
|
|
1895
2131
|
);
|
|
1896
2132
|
}
|
|
1897
2133
|
|
|
@@ -2001,10 +2237,14 @@ export class QuickCHR {
|
|
|
2001
2237
|
try {
|
|
2002
2238
|
const platform = await detectPlatform();
|
|
2003
2239
|
if (platform.accelAvailable.length > 0) {
|
|
2240
|
+
// Under an override, accelAvailable is just the forced mode for every
|
|
2241
|
+
// arch — say so, or the row reads as a capability report it isn't.
|
|
2242
|
+
const { mode: forced, source } = resolveAccelOverrideWithSource();
|
|
2243
|
+
const suffix = forced === "auto" ? "" : ` — configured override via ${accelSourceLabel(source)}`;
|
|
2004
2244
|
checks.push({
|
|
2005
2245
|
label: "Acceleration",
|
|
2006
2246
|
status: "ok",
|
|
2007
|
-
detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})`,
|
|
2247
|
+
detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})${suffix}`,
|
|
2008
2248
|
});
|
|
2009
2249
|
} else {
|
|
2010
2250
|
checks.push({
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `quickchr.env` file tier of the settings system — path, raw read, and
|
|
3
|
+
* dotenv-style parse.
|
|
4
|
+
*
|
|
5
|
+
* Split out of settings.ts as a **leaf** module (node:fs + paths.ts only) so
|
|
6
|
+
* platform.ts can resolve the `accel` override's file tier without pulling in
|
|
7
|
+
* settings.ts's transitive graph (settings → cache → state → network →
|
|
8
|
+
* platform). That cycle loads eagerly at CLI startup and measurably slows every
|
|
9
|
+
* `quickchr` invocation. settings.ts re-exports everything here, so this split
|
|
10
|
+
* is invisible to callers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { quickchrConfigDir } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
export const SETTINGS_FILE_NAME = "quickchr.env";
|
|
18
|
+
|
|
19
|
+
/** ~/.config/quickchr/quickchr.env */
|
|
20
|
+
export function settingsFilePath(): string {
|
|
21
|
+
return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Raw non-empty lines, in file order. [] if the file doesn't exist. */
|
|
25
|
+
export function readSettingsFileLines(): string[] {
|
|
26
|
+
const path = settingsFilePath();
|
|
27
|
+
if (!existsSync(path)) return [];
|
|
28
|
+
const text = readFileSync(path, "utf-8");
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
|
|
35
|
+
export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
|
|
36
|
+
const result: Record<string, string> = {};
|
|
37
|
+
for (const rawLine of lines) {
|
|
38
|
+
const line = rawLine.trim();
|
|
39
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
40
|
+
const eq = line.indexOf("=");
|
|
41
|
+
if (eq <= 0) continue;
|
|
42
|
+
const key = line.slice(0, eq).trim();
|
|
43
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
44
|
+
result[key] = line.slice(eq + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `file` tier every resolver call reads. {} when the file is absent. */
|
|
50
|
+
export function loadSettingsFileDefaults(): Record<string, string> {
|
|
51
|
+
return parseSettingsFileLines(readSettingsFileLines());
|
|
52
|
+
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
* input resolution, never per-machine config.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync,
|
|
16
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
|
|
17
17
|
import { basename, dirname, join } from "node:path";
|
|
18
|
-
import { ARCHES, CHANNELS, QuickCHRError } from "./types.ts";
|
|
19
|
-
import type { Channel } from "./types.ts";
|
|
18
|
+
import { ARCHES, CHANNELS, QuickCHRError, parseAccelMode } from "./types.ts";
|
|
19
|
+
import type { AccelMode, Channel } from "./types.ts";
|
|
20
20
|
import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { loadSettingsFileDefaults, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
|
|
22
|
+
|
|
23
|
+
// The quickchr.env file tier lives in the leaf module settings-file.ts (see its
|
|
24
|
+
// header for why); re-exported here so settings.ts stays the single public entry.
|
|
25
|
+
export { SETTINGS_FILE_NAME, loadSettingsFileDefaults, parseSettingsFileLines, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
|
|
22
26
|
|
|
23
27
|
export type SettingsValue = string | number | boolean;
|
|
24
28
|
export type SettingsSource = "env" | "file" | "default";
|
|
@@ -93,6 +97,17 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
93
97
|
parse: parseArchSetting,
|
|
94
98
|
serialize: (v) => String(v),
|
|
95
99
|
},
|
|
100
|
+
{
|
|
101
|
+
// "auto" = let detectAccel() choose, including its platform fallbacks
|
|
102
|
+
// (notably arm64-on-Apple-Silicon → TCG, see DESIGN.md #10). Any other
|
|
103
|
+
// value is passed to QEMU verbatim and skips detection entirely — the
|
|
104
|
+
// escape hatch for testing HVF against a future AArch64-only arm64 image.
|
|
105
|
+
attr: "accel",
|
|
106
|
+
envKey: "QUICKCHR_ACCEL",
|
|
107
|
+
builtinDefault: "auto" satisfies AccelMode,
|
|
108
|
+
parse: (raw) => parseAccelMode(raw),
|
|
109
|
+
serialize: (v) => String(v),
|
|
110
|
+
},
|
|
96
111
|
{
|
|
97
112
|
attr: "cache-max-size",
|
|
98
113
|
envKey: "QUICKCHR_CACHE_MAX_SIZE",
|
|
@@ -108,7 +123,7 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
108
123
|
serialize: (v) => String(v),
|
|
109
124
|
},
|
|
110
125
|
{
|
|
111
|
-
// No builtinDefault: unlike the other
|
|
126
|
+
// No builtinDefault: unlike the other 5 keys, the wizard needs to distinguish
|
|
112
127
|
// "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
|
|
113
128
|
// initialValue only pre-highlights "admin" when the setting is concretely
|
|
114
129
|
// false) — a builtinDefault here would collapse that distinction and always
|
|
@@ -123,13 +138,6 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
|
|
|
123
138
|
},
|
|
124
139
|
];
|
|
125
140
|
|
|
126
|
-
export const SETTINGS_FILE_NAME = "quickchr.env";
|
|
127
|
-
|
|
128
|
-
/** ~/.config/quickchr/quickchr.env */
|
|
129
|
-
export function settingsFilePath(): string {
|
|
130
|
-
return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
141
|
/** Accepts "default-channel" | "QUICKCHR_DEFAULT_CHANNEL" | "DEFAULT_CHANNEL" | "default_channel". */
|
|
134
142
|
export function lookupSettingsKey(input: string): SettingsKeyDef | undefined {
|
|
135
143
|
let name = input.trim();
|
|
@@ -150,36 +158,6 @@ function unknownKeyError(input: string): QuickCHRError {
|
|
|
150
158
|
|
|
151
159
|
// --- File IO ---
|
|
152
160
|
|
|
153
|
-
/** Raw non-empty lines, in file order. [] if the file doesn't exist. */
|
|
154
|
-
export function readSettingsFileLines(): string[] {
|
|
155
|
-
const path = settingsFilePath();
|
|
156
|
-
if (!existsSync(path)) return [];
|
|
157
|
-
const text = readFileSync(path, "utf-8");
|
|
158
|
-
const lines = text.split("\n");
|
|
159
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
160
|
-
return lines;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
|
|
164
|
-
export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
|
|
165
|
-
const result: Record<string, string> = {};
|
|
166
|
-
for (const rawLine of lines) {
|
|
167
|
-
const line = rawLine.trim();
|
|
168
|
-
if (line.length === 0 || line.startsWith("#")) continue;
|
|
169
|
-
const eq = line.indexOf("=");
|
|
170
|
-
if (eq <= 0) continue;
|
|
171
|
-
const key = line.slice(0, eq).trim();
|
|
172
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
173
|
-
result[key] = line.slice(eq + 1).trim();
|
|
174
|
-
}
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** The `file` tier every resolver call reads. {} when the file is absent. */
|
|
179
|
-
export function loadSettingsFileDefaults(): Record<string, string> {
|
|
180
|
-
return parseSettingsFileLines(readSettingsFileLines());
|
|
181
|
-
}
|
|
182
|
-
|
|
183
161
|
function lineEnvKey(line: string): string | undefined {
|
|
184
162
|
const trimmed = line.trim();
|
|
185
163
|
if (trimmed.length === 0 || trimmed.startsWith("#")) return undefined;
|
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
|
|
|
@@ -13,6 +14,28 @@ export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term"
|
|
|
13
14
|
export type Arch = "arm64" | "x86";
|
|
14
15
|
export const ARCHES: Arch[] = ["arm64", "x86"];
|
|
15
16
|
|
|
17
|
+
// --- Acceleration ---
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* QEMU accelerator selection. "auto" means detectAccel() picks, including its
|
|
21
|
+
* platform safety fallbacks; the other three force `-accel <mode>` verbatim and
|
|
22
|
+
* bypass detection entirely (see DESIGN.md #10).
|
|
23
|
+
*/
|
|
24
|
+
export type AccelMode = "auto" | "tcg" | "hvf" | "kvm";
|
|
25
|
+
export const ACCEL_MODES: readonly AccelMode[] = Object.freeze(["auto", "tcg", "hvf", "kvm"] as AccelMode[]);
|
|
26
|
+
|
|
27
|
+
/** Validate an accelerator name from a CLI flag, env var, or settings file. */
|
|
28
|
+
export function parseAccelMode(raw: string): AccelMode {
|
|
29
|
+
const v = raw.trim().toLowerCase();
|
|
30
|
+
if (!(ACCEL_MODES as readonly string[]).includes(v)) {
|
|
31
|
+
throw new QuickCHRError(
|
|
32
|
+
"INVALID_SETTING_VALUE",
|
|
33
|
+
`Invalid value "${raw}" for accel — must be one of ${ACCEL_MODES.join(", ")}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return v as AccelMode;
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
// --- Networking ---
|
|
17
40
|
|
|
18
41
|
/** @deprecated Use NetworkSpecifier instead. Kept for backward compatibility with old machine.json files. */
|
|
@@ -576,7 +599,10 @@ export interface ChrInstance {
|
|
|
576
599
|
* After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
|
|
577
600
|
* the returned instance is already REST-reachable — this returns immediately.
|
|
578
601
|
* Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
|
|
579
|
-
|
|
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>;
|
|
580
606
|
/** Poll a condition until it returns true or the timeout elapses.
|
|
581
607
|
* Retries every 2 s, swallowing errors from the condition function.
|
|
582
608
|
* Returns `true` when the condition passes, `false` on timeout.
|
|
@@ -762,6 +788,16 @@ export type ErrorCode =
|
|
|
762
788
|
| "QGA_UNSUPPORTED"
|
|
763
789
|
| "QGA_TIMEOUT"
|
|
764
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"
|
|
765
801
|
| "MACHINE_EXISTS"
|
|
766
802
|
| "MACHINE_NOT_FOUND"
|
|
767
803
|
| "MACHINE_RUNNING"
|