@tikoci/quickchr 0.4.6 → 0.4.8
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 +604 -3
- package/MANUAL.md +222 -19
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- 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 +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
package/src/lib/quickchr.ts
CHANGED
|
@@ -16,7 +16,9 @@ import type {
|
|
|
16
16
|
LicenseInput,
|
|
17
17
|
LicenseLevel,
|
|
18
18
|
MachineState,
|
|
19
|
+
NetworkConfig,
|
|
19
20
|
NetworkTopologyEntry,
|
|
21
|
+
PlatformInfo,
|
|
20
22
|
PortMapping,
|
|
21
23
|
QgaCommand,
|
|
22
24
|
ServiceEndpoint,
|
|
@@ -24,7 +26,8 @@ import type {
|
|
|
24
26
|
SshServiceEndpoint,
|
|
25
27
|
StartOptions,
|
|
26
28
|
} from "./types.ts";
|
|
27
|
-
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
29
|
+
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP } from "./types.ts";
|
|
30
|
+
import { assertValidResourceName, assertPathSafeName } from "./names.ts";
|
|
28
31
|
import packageJson from "../../package.json";
|
|
29
32
|
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
30
33
|
import {
|
|
@@ -34,16 +37,20 @@ import {
|
|
|
34
37
|
assertProvisioningSupportedVersion,
|
|
35
38
|
PROVISIONING_FEATURE_LABEL,
|
|
36
39
|
} from "./versions.ts";
|
|
37
|
-
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
|
|
40
|
+
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
|
|
38
41
|
import {
|
|
39
42
|
getUsedPortBases,
|
|
43
|
+
getUsedMacs,
|
|
40
44
|
saveMachine,
|
|
41
45
|
loadMachine,
|
|
42
46
|
loadAllMachines,
|
|
47
|
+
listUnreadableMachines,
|
|
43
48
|
removeMachine as removeState,
|
|
44
49
|
getMachineDir,
|
|
45
50
|
getMachinesDir,
|
|
46
51
|
listMachineNames,
|
|
52
|
+
isOrphanMachineDir,
|
|
53
|
+
listOrphanMachineDirs,
|
|
47
54
|
refreshAllStatuses,
|
|
48
55
|
isMachineRunning,
|
|
49
56
|
ensureDir,
|
|
@@ -56,7 +63,8 @@ import { autoPruneIfOverCap } from "./cache.ts";
|
|
|
56
63
|
import { resolveSetting } from "./settings.ts";
|
|
57
64
|
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
58
65
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
59
|
-
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
66
|
+
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
67
|
+
import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
|
|
60
68
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
61
69
|
import { provision } from "./provision.ts";
|
|
62
70
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
@@ -65,9 +73,9 @@ import { scpPush, scpPull } from "./scp.ts";
|
|
|
65
73
|
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
66
74
|
import { restExecute } from "./exec.ts";
|
|
67
75
|
import { qgaExec } from "./qga.ts";
|
|
68
|
-
import { consoleExec } from "./console.ts";
|
|
76
|
+
import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
|
|
69
77
|
import { restRequest, restGet, restPost } from "./rest.ts";
|
|
70
|
-
import {
|
|
78
|
+
import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
|
|
71
79
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
72
80
|
import {
|
|
73
81
|
formatDeviceModeSelection,
|
|
@@ -79,9 +87,10 @@ import {
|
|
|
79
87
|
waitForDeviceModeApi,
|
|
80
88
|
} from "./device-mode.ts";
|
|
81
89
|
import type { LicenseOptions } from "./types.ts";
|
|
90
|
+
import type { GuestExec } from "./guest-snapshot.ts";
|
|
82
91
|
import { toChrPorts } from "./network.ts";
|
|
83
92
|
import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
84
|
-
import { existsSync,
|
|
93
|
+
import { existsSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
|
|
85
94
|
import { join } from "node:path";
|
|
86
95
|
|
|
87
96
|
// --- Architecture-aware defaults ---
|
|
@@ -184,19 +193,97 @@ function getSocketNamedNetworks(state: MachineState): string[] {
|
|
|
184
193
|
.map((n) => (n.specifier as { type: "socket"; name: string }).name);
|
|
185
194
|
}
|
|
186
195
|
|
|
196
|
+
/** Refuse a start that a named socket has no room for, before any I/O.
|
|
197
|
+
*
|
|
198
|
+
* Only a *new* member is refused: a machine already holding an endpoint is simply
|
|
199
|
+
* restarting into its own slot. An unknown socket is left alone — `start()`
|
|
200
|
+
* auto-creates it further down. */
|
|
201
|
+
function assertNamedSocketsHaveRoom(opts: StartOptions, logger: ProgressLogger): void {
|
|
202
|
+
const named = resolveStartNetworks(opts.networks, opts.network)
|
|
203
|
+
.map((n) => n.specifier)
|
|
204
|
+
.filter((spec): spec is { type: "socket"; name: string } =>
|
|
205
|
+
typeof spec === "object" && spec !== null && spec.type === "socket");
|
|
206
|
+
for (const { name } of named) {
|
|
207
|
+
const entry = getNamedSocket(name);
|
|
208
|
+
if (!entry?.endpoints) continue;
|
|
209
|
+
if (opts.name && entry.endpoints.includes(opts.name)) continue;
|
|
210
|
+
if (entry.endpoints.includes(null)) continue;
|
|
211
|
+
const held = entry.endpoints.filter((m): m is string => m !== null);
|
|
212
|
+
logger.debug(`socket::${name} endpoints held by ${held.join(", ")}`);
|
|
213
|
+
throw new QuickCHRError(
|
|
214
|
+
"NETWORK_UNAVAILABLE",
|
|
215
|
+
`Named socket "${name}" is a ${entry.mode} link and carries 2 machines; ` +
|
|
216
|
+
`${held.join(" and ")} already hold both ends. ` +
|
|
217
|
+
`Stop one of them, or create an N-way segment with 'quickchr networks sockets create <name> --mode mcast'.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** QEMU version for the binary this machine's arch will run on, when it can be read.
|
|
223
|
+
* `-netdev dgram` needs 7.2, and a named socket should say so rather than failing at
|
|
224
|
+
* spawn with a netdev QEMU does not recognize. */
|
|
225
|
+
function qemuVersionForArch(platform: PlatformInfo, arch: Arch): string | undefined {
|
|
226
|
+
const bin = arch === "arm64" ? platform.qemuBinArm64 : platform.qemuBinX86;
|
|
227
|
+
return bin ? getQemuVersion(bin) : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
187
230
|
function registerSocketMembers(state: MachineState): void {
|
|
188
231
|
for (const name of getSocketNamedNetworks(state)) {
|
|
189
232
|
try {
|
|
190
|
-
if
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
addSocketMember(name, state.name);
|
|
233
|
+
// Create-if-missing and join under one registry lock: two concurrent starts
|
|
234
|
+
// both pass a test-then-create, and both then claim the same endpoint slot.
|
|
235
|
+
joinNamedSocket(name, state.name, { autoCreated: true });
|
|
194
236
|
} catch (e) {
|
|
237
|
+
// A full two-member link is a refusal, not a warning: carrying on would spawn
|
|
238
|
+
// QEMU with no endpoint to bind, and on a `dgram` link a second machine
|
|
239
|
+
// binding the same path unlinks the first one's socket and steals the link
|
|
240
|
+
// with nothing logged on either side.
|
|
241
|
+
if (e instanceof QuickCHRError && e.code === "NETWORK_UNAVAILABLE") throw e;
|
|
195
242
|
console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
196
243
|
}
|
|
197
244
|
}
|
|
198
245
|
}
|
|
199
246
|
|
|
247
|
+
/** Report the transport each named socket resolved to.
|
|
248
|
+
*
|
|
249
|
+
* #158's acceptance bar: nothing about a named socket should require opening a file
|
|
250
|
+
* under the data dir. The field report that produced these issues had to read
|
|
251
|
+
* `machine.json` to discover its link was UDP multicast, after ping had already
|
|
252
|
+
* failed silently. */
|
|
253
|
+
function reportSocketTransports(state: MachineState, logger: ProgressLogger): void {
|
|
254
|
+
for (const name of getSocketNamedNetworks(state)) {
|
|
255
|
+
const entry = getNamedSocket(name);
|
|
256
|
+
if (!entry) continue;
|
|
257
|
+
logger.status(` Network socket::${name}: ${describeSocketTransport(entry, getSocketSlot(entry, state.name))}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Claim each named socket's endpoint, then resolve — releasing the claims if
|
|
262
|
+
* resolution fails.
|
|
263
|
+
*
|
|
264
|
+
* Membership is persisted *before* networks resolve, because the resolver needs to
|
|
265
|
+
* know which end this machine holds. So a resolution that throws — a `dgram` link on
|
|
266
|
+
* Windows, or on a QEMU older than 7.2 — would otherwise leave the machine holding an
|
|
267
|
+
* endpoint it never used, and two failed starts would fill a link with machines that
|
|
268
|
+
* are not running. */
|
|
269
|
+
export function registerAndResolveNetworks(
|
|
270
|
+
state: MachineState,
|
|
271
|
+
ctx: { platform: PlatformInfo; qemuVersion?: string },
|
|
272
|
+
hostfwd: string,
|
|
273
|
+
): NetworkConfig[] {
|
|
274
|
+
try {
|
|
275
|
+
// Inside the try, not before it: this claims one endpoint per named socket, so a
|
|
276
|
+
// machine on two links whose second link is full would otherwise keep the first
|
|
277
|
+
// claim — and the caller cannot clean it either, since it only learns about the
|
|
278
|
+
// claim once this returns.
|
|
279
|
+
registerSocketMembers(state);
|
|
280
|
+
return resolveAllNetworks(state.networks, { ...ctx, machine: state.name }, hostfwd);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
unregisterSocketMembers(state);
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
200
287
|
function unregisterSocketMembers(state: MachineState): void {
|
|
201
288
|
for (const name of getSocketNamedNetworks(state)) {
|
|
202
289
|
try {
|
|
@@ -410,13 +497,16 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
410
497
|
sshPort: ports.ssh,
|
|
411
498
|
portBase: state.portBase,
|
|
412
499
|
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
413
|
-
|
|
500
|
+
hostGatewayIp: HOST_GATEWAY_IP,
|
|
501
|
+
// Deprecated alias for hostGatewayIp (#26) — same value, removed no earlier
|
|
502
|
+
// than the next minor.
|
|
503
|
+
tzspGatewayIp: HOST_GATEWAY_IP,
|
|
414
504
|
|
|
415
|
-
async waitForBoot(timeoutMs?: number): Promise<boolean> {
|
|
505
|
+
async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
|
|
416
506
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
417
507
|
// on authenticated machines (post-provisioning, post-install reboots).
|
|
418
508
|
const auth = resolveAuth(state);
|
|
419
|
-
return waitForBoot(ports.http, timeoutMs, auth.header);
|
|
509
|
+
return waitForBoot(ports.http, timeoutMs, auth.header, stats);
|
|
420
510
|
},
|
|
421
511
|
|
|
422
512
|
async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
|
|
@@ -462,6 +552,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
462
552
|
if (state.pid && isMachineRunning(state)) {
|
|
463
553
|
await stopQemu(state.pid);
|
|
464
554
|
}
|
|
555
|
+
// Same invariant as stop()/remove(): a machine that is not running holds no
|
|
556
|
+
// endpoint. clean() stops QEMU directly rather than through stop(), so without
|
|
557
|
+
// this a cleaned machine keeps a pair link occupied and blocks its removal.
|
|
558
|
+
unregisterSocketMembers(state);
|
|
465
559
|
// NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
466
560
|
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
467
561
|
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
@@ -487,18 +581,43 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
487
581
|
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
488
582
|
);
|
|
489
583
|
|
|
490
|
-
//
|
|
584
|
+
// Every guest-side credential went with the old disk, so the credential
|
|
585
|
+
// facts in machine.json now describe a machine that no longer exists.
|
|
586
|
+
// Nothing re-provisions them either: a post-clean() start() takes the
|
|
587
|
+
// `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
|
|
588
|
+
// erased account is never recreated. Left in place, `resolveAuth()` /
|
|
589
|
+
// `resolveCreds()` prefer `state.user` and authenticate every REST call,
|
|
590
|
+
// exec, and SCP as a deleted user — the confound #79 removes here.
|
|
591
|
+
// Clearing them restores the documented fallback: factory `admin` with an
|
|
592
|
+
// empty password, which is what a fresh CHR image actually answers to
|
|
593
|
+
// (anchored by the "clean() resets disk to factory defaults" integration
|
|
594
|
+
// test). `disableAdmin` goes with them because it is read as a live fact
|
|
595
|
+
// about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
|
|
596
|
+
// image has admin enabled again. Provisioning *intent* — packages,
|
|
597
|
+
// deviceMode, secureLogin — is not guest state and survives.
|
|
491
598
|
deleteInstanceCredentials(state.name);
|
|
599
|
+
// The managed keypair authenticated to the account factory reset erased,
|
|
600
|
+
// so it is dead credential material sitting in the machine dir. Removing
|
|
601
|
+
// it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
|
|
602
|
+
// existing file exits 1 with "already exists" rather than overwriting
|
|
603
|
+
// (verified locally), so a leftover key would break `installSshKey()`.
|
|
604
|
+
rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
|
|
492
605
|
|
|
493
606
|
// Update state
|
|
494
607
|
const current = loadMachine(state.name);
|
|
495
608
|
if (current) {
|
|
496
609
|
current.status = "stopped";
|
|
497
610
|
current.pid = undefined;
|
|
611
|
+
current.user = undefined;
|
|
612
|
+
current.managedSshKey = undefined;
|
|
613
|
+
current.disableAdmin = undefined;
|
|
498
614
|
saveMachine(current);
|
|
499
615
|
}
|
|
500
616
|
state.status = "stopped";
|
|
501
617
|
state.pid = undefined;
|
|
618
|
+
state.user = undefined;
|
|
619
|
+
state.managedSshKey = undefined;
|
|
620
|
+
state.disableAdmin = undefined;
|
|
502
621
|
},
|
|
503
622
|
|
|
504
623
|
async monitor(command: string): Promise<string> {
|
|
@@ -950,6 +1069,183 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
|
|
|
950
1069
|
return false;
|
|
951
1070
|
}
|
|
952
1071
|
|
|
1072
|
+
/** Where boot-failure reports land: `<dataDir>/failures/`, deliberately outside
|
|
1073
|
+
* any machine directory so `remove()` — which every integration test calls from
|
|
1074
|
+
* its `finally` — cannot delete the evidence it was written to explain (#79). */
|
|
1075
|
+
function bootFailureReportDir(): string {
|
|
1076
|
+
return join(getDataDir(), "failures");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* How long one credential candidate gets inside a single guest-exec call.
|
|
1081
|
+
*
|
|
1082
|
+
* `timeoutMs` is a budget for the whole call, not per attempt: `captureGuestSnapshot()`
|
|
1083
|
+
* sizes its own budget assuming one exec costs at most what it passed in, so N
|
|
1084
|
+
* candidates × the full timeout would spend the snapshot's entire budget on the
|
|
1085
|
+
* first query. Untried candidates therefore share it, and once a credential is
|
|
1086
|
+
* known to work it gets all of it — which is every query after the first.
|
|
1087
|
+
*
|
|
1088
|
+
* The floor is {@link CONSOLE_LOGIN_COST_MS} rather than a round number, because
|
|
1089
|
+
* a share below the cost of a login cannot succeed **by construction** — and a
|
|
1090
|
+
* candidate that can never succeed also never becomes the working one, so every
|
|
1091
|
+
* later query repeats the same doomed division. The floor used to be 5 s against
|
|
1092
|
+
* a measured 11.4 s login, which is why forensics reported "console unreachable"
|
|
1093
|
+
* for guests that were answering serial in 10 ms (#69). A share this large is
|
|
1094
|
+
* affordable only because `captureGuestSnapshot()` adds a login allowance to its
|
|
1095
|
+
* query budget until the console answers; see `GUEST_LOGIN_ALLOWANCE_MS`.
|
|
1096
|
+
*
|
|
1097
|
+
* Exported for tests: this is the arithmetic the #69/B10 fix turns on, and it is
|
|
1098
|
+
* otherwise reachable only through a live serial socket.
|
|
1099
|
+
*/
|
|
1100
|
+
export function credentialShareMs(timeoutMs: number, candidateCount: number, haveWorking: boolean): number {
|
|
1101
|
+
if (haveWorking) return timeoutMs;
|
|
1102
|
+
return Math.max(CONSOLE_LOGIN_COST_MS, Math.floor(timeoutMs / Math.max(1, candidateCount)));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Serial-console executor for boot forensics, or null when there is no serial
|
|
1106
|
+
* channel to talk to (foreground runs, or QEMU never got far enough).
|
|
1107
|
+
*
|
|
1108
|
+
* Which account works depends on how far the failed boot got: a machine that
|
|
1109
|
+
* timed out before provisioning — and any machine after `clean()` — is factory
|
|
1110
|
+
* fresh and answers only to `admin` with an empty password, while a relaunch of
|
|
1111
|
+
* an already-provisioned machine needs its stored credentials. Rather than
|
|
1112
|
+
* guess, candidates are tried in order and the first that reaches a prompt is
|
|
1113
|
+
* reused for the rest of the snapshot. */
|
|
1114
|
+
function bootFailureGuestExec(state: MachineState): { exec: GuestExec; users: string[] } | null {
|
|
1115
|
+
if (!channelFileExists(channelPath(state.machineDir, "serial"))) return null;
|
|
1116
|
+
|
|
1117
|
+
// Order matters only for speed — a wrong candidate costs a ~15 s login timeout
|
|
1118
|
+
// before the next is tried. Stored credentials are the strongest evidence the
|
|
1119
|
+
// machine is provisioned (provisioning stores them on success), so they lead;
|
|
1120
|
+
// factory admin comes next because a cleaned machine has neither (`clean()`
|
|
1121
|
+
// clears both the stored credentials and `state.user`), leaving `state.user`
|
|
1122
|
+
// as the weakest case: a user configured on the machine but never provisioned
|
|
1123
|
+
// onto the guest.
|
|
1124
|
+
const candidates: { user: string; password: string }[] = [];
|
|
1125
|
+
// Guarded even though secrets.ts already swallows unreadable/corrupt stores:
|
|
1126
|
+
// this runs on the BOOT_TIMEOUT path *before* captureBootFailure(), so
|
|
1127
|
+
// anything thrown here would replace the forensics report with an unrelated
|
|
1128
|
+
// error — the exact failure mode the diagnostics modules exist to avoid.
|
|
1129
|
+
let stored: { user: string; password: string } | null = null;
|
|
1130
|
+
try {
|
|
1131
|
+
stored = getInstanceCredentials(state.name);
|
|
1132
|
+
} catch { /* no stored credentials — fall through to the other candidates */ }
|
|
1133
|
+
if (stored) candidates.push(stored);
|
|
1134
|
+
candidates.push({ user: "admin", password: "" });
|
|
1135
|
+
if (state.user && state.user.name !== "admin" && state.user.password !== STORED_IN_SECRETS_PASSWORD) {
|
|
1136
|
+
candidates.push({ user: state.user.name, password: state.user.password });
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
let working: { user: string; password: string } | undefined;
|
|
1140
|
+
const exec: GuestExec = async (command, timeoutMs) => {
|
|
1141
|
+
const order = working ? [working, ...candidates.filter((c) => c !== working)] : candidates;
|
|
1142
|
+
const deadline = Date.now() + timeoutMs;
|
|
1143
|
+
const share = credentialShareMs(timeoutMs, order.length, working !== undefined);
|
|
1144
|
+
let lastError: unknown = new Error("no credential candidates");
|
|
1145
|
+
for (const cred of order) {
|
|
1146
|
+
const remaining = deadline - Date.now();
|
|
1147
|
+
if (remaining <= 0) break;
|
|
1148
|
+
try {
|
|
1149
|
+
const { output, framed } = await consoleExec(
|
|
1150
|
+
state.machineDir, command, cred.user, cred.password, Math.min(share, remaining), state.portBase,
|
|
1151
|
+
);
|
|
1152
|
+
working = cred;
|
|
1153
|
+
return { output, framed };
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
lastError = e;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
return { exec, users: candidates.map((c) => c.user) };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Capture the boot-failure evidence set for a machine that is **already
|
|
1166
|
+
* running** — a request that failed after the machine was declared REST-ready.
|
|
1167
|
+
*
|
|
1168
|
+
* Same instruments as the BOOT_TIMEOUT path (guest snapshot, per-port slirp
|
|
1169
|
+
* classification, monitor state, QEMU liveness, embedded logs), because #69's
|
|
1170
|
+
* one-line `ECONNRESET` has never been enough to say whether the reset came from
|
|
1171
|
+
* the client, RouterOS `www`, or slirp — and every one of those instruments
|
|
1172
|
+
* answers a different part of that. What it does *not* share is the boot
|
|
1173
|
+
* budget's excuse for the machine looking healthy: {@link FailureTrigger} is
|
|
1174
|
+
* required here, so the report always records which request died and what
|
|
1175
|
+
* changed just before it.
|
|
1176
|
+
*
|
|
1177
|
+
* Does not stop, remove, or otherwise touch the machine — the caller owns its
|
|
1178
|
+
* lifecycle and may well want to keep testing against it. Never throws;
|
|
1179
|
+
* `captureBootFailure()` swallows its own failures.
|
|
1180
|
+
*
|
|
1181
|
+
* One trace it does leave, and the reason that matters more here than on the
|
|
1182
|
+
* boot path: the guest snapshot logs in over the serial console, and a RouterOS
|
|
1183
|
+
* console login survives the host socket closing. A boot-timeout capture is
|
|
1184
|
+
* followed by `stop()`/`remove()`, so nothing outlives it; here the machine
|
|
1185
|
+
* keeps running. Harmless — `consoleExec()` `/quit`s a session belonging to a
|
|
1186
|
+
* different user before it logs in — but a later console interaction on this
|
|
1187
|
+
* machine starts from an already-logged-in prompt, not a `Login:` one.
|
|
1188
|
+
*/
|
|
1189
|
+
export async function captureRunningFailure(
|
|
1190
|
+
state: MachineState,
|
|
1191
|
+
phase: string,
|
|
1192
|
+
trigger: FailureTrigger,
|
|
1193
|
+
): Promise<BootFailureReport> {
|
|
1194
|
+
// Approximate, and labelled as such in the field docs: lastStartedAt is
|
|
1195
|
+
// stamped at spawn and lastBootMs measured from just before it, so their sum
|
|
1196
|
+
// is REST-ready to within the few ms between those two statements. Both are
|
|
1197
|
+
// absent on a machine that never completed a timed boot, in which case the
|
|
1198
|
+
// field is simply omitted rather than guessed at.
|
|
1199
|
+
const readyAt = state.lastStartedAt !== undefined && state.lastBootMs !== undefined
|
|
1200
|
+
? Date.parse(state.lastStartedAt) + state.lastBootMs
|
|
1201
|
+
: undefined;
|
|
1202
|
+
const guest = bootFailureGuestExec(state);
|
|
1203
|
+
return await captureBootFailure({
|
|
1204
|
+
name: state.name,
|
|
1205
|
+
machineDir: state.machineDir,
|
|
1206
|
+
arch: state.arch,
|
|
1207
|
+
accel: state.lastAccel ?? "unknown",
|
|
1208
|
+
pid: state.pid,
|
|
1209
|
+
httpPort: state.ports.http?.host,
|
|
1210
|
+
portBase: state.portBase,
|
|
1211
|
+
phase,
|
|
1212
|
+
trigger: {
|
|
1213
|
+
...trigger,
|
|
1214
|
+
sinceReadyMs: trigger.sinceReadyMs
|
|
1215
|
+
?? (readyAt !== undefined && Number.isFinite(readyAt) ? Date.now() - readyAt : undefined),
|
|
1216
|
+
},
|
|
1217
|
+
reportDir: bootFailureReportDir(),
|
|
1218
|
+
monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
|
|
1219
|
+
forwards: Object.values(state.ports),
|
|
1220
|
+
guestExec: guest?.exec,
|
|
1221
|
+
guestUser: guest?.users.join(" → "),
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** Tear down a machine that failed to boot, and describe what was done.
|
|
1226
|
+
*
|
|
1227
|
+
* Default is full cleanup — a failed `start()` must not leave a half-dead
|
|
1228
|
+
* machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
|
|
1229
|
+
* inverts that for **local** debugging: the QEMU process is still stopped (it is
|
|
1230
|
+
* wedged, and its port block must be released), but the machine directory
|
|
1231
|
+
* survives so it can be inspected by hand.
|
|
1232
|
+
*
|
|
1233
|
+
* Evidence does not depend on that flag, and CI deliberately does not set it —
|
|
1234
|
+
* `captureBootFailure()` has already written its report outside the machine dir
|
|
1235
|
+
* with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
|
|
1236
|
+
async function cleanupAfterBootFailure(
|
|
1237
|
+
instance: ChrInstance,
|
|
1238
|
+
name: string,
|
|
1239
|
+
failure: BootFailureReport,
|
|
1240
|
+
): Promise<string> {
|
|
1241
|
+
try { await instance.stop(); } catch { /* ignore */ }
|
|
1242
|
+
if (failure.preserved) {
|
|
1243
|
+
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
|
|
1244
|
+
}
|
|
1245
|
+
try { await instance.remove(); } catch { /* ignore */ }
|
|
1246
|
+
return ` Machine "${name}" has been cleaned up automatically.`;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
953
1249
|
/** Wait for CHR to boot with periodic progress status updates every 20s. */
|
|
954
1250
|
async function waitForBootWithProgress(
|
|
955
1251
|
instance: ChrInstance,
|
|
@@ -1022,7 +1318,11 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1022
1318
|
);
|
|
1023
1319
|
const platform = await detectPlatform();
|
|
1024
1320
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1025
|
-
const resolvedNetworks = resolveAllNetworks(
|
|
1321
|
+
const resolvedNetworks = resolveAllNetworks(
|
|
1322
|
+
state.networks,
|
|
1323
|
+
{ platform, machine: state.name, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
1324
|
+
hostfwd,
|
|
1325
|
+
);
|
|
1026
1326
|
return {
|
|
1027
1327
|
arch: state.arch,
|
|
1028
1328
|
machineDir: state.machineDir,
|
|
@@ -1185,9 +1485,8 @@ export class QuickCHR {
|
|
|
1185
1485
|
* `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
|
|
1186
1486
|
static async add(opts: StartOptions = {}): Promise<MachineState> {
|
|
1187
1487
|
opts = normalizeStartOptions(opts);
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
}
|
|
1488
|
+
// add() always creates, so the full name rules apply before any I/O.
|
|
1489
|
+
if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
|
|
1191
1490
|
|
|
1192
1491
|
let version: string;
|
|
1193
1492
|
if (opts.version) {
|
|
@@ -1230,7 +1529,15 @@ export class QuickCHR {
|
|
|
1230
1529
|
const existingNames = listMachineNames();
|
|
1231
1530
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
1232
1531
|
if (existingNames.includes(name)) {
|
|
1233
|
-
|
|
1532
|
+
// A directory with no readable machine.json is a half-created machine, not a
|
|
1533
|
+
// machine — `list` cannot show it and `start` cannot boot it, so saying
|
|
1534
|
+
// "already exists" sends the user looking for something that is not there (#155).
|
|
1535
|
+
throw isOrphanMachineDir(name)
|
|
1536
|
+
? new QuickCHRError(
|
|
1537
|
+
"MACHINE_EXISTS",
|
|
1538
|
+
`Machine "${name}" has a leftover directory with no readable machine.json — a create that did not finish. Clear it with 'quickchr remove ${name}', then retry.`,
|
|
1539
|
+
)
|
|
1540
|
+
: new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
|
|
1234
1541
|
}
|
|
1235
1542
|
|
|
1236
1543
|
const usedBases = getUsedPortBases();
|
|
@@ -1259,7 +1566,7 @@ export class QuickCHR {
|
|
|
1259
1566
|
arch,
|
|
1260
1567
|
cpu: opts.cpu ?? 1,
|
|
1261
1568
|
mem: defaultMem(arch, opts.mem),
|
|
1262
|
-
networks: resolveStartNetworks(opts.networks, opts.network),
|
|
1569
|
+
networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
|
|
1263
1570
|
ports,
|
|
1264
1571
|
packages: opts.packages ?? [],
|
|
1265
1572
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1279,6 +1586,20 @@ export class QuickCHR {
|
|
|
1279
1586
|
};
|
|
1280
1587
|
saveMachine(state);
|
|
1281
1588
|
return state;
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
// A failed create must leave nothing behind: the directory exists from this
|
|
1591
|
+
// call's ensureDir(), and without readable state it is invisible to `list`
|
|
1592
|
+
// and unremovable by `remove` while still blocking re-add (#155).
|
|
1593
|
+
//
|
|
1594
|
+
// The test is "no *readable* machine.json", not "no machine.json" — a
|
|
1595
|
+
// saveMachine() that died mid-write (disk full) leaves a truncated file, and
|
|
1596
|
+
// a file-exists check would take that for a finished machine and strand the
|
|
1597
|
+
// directory it was supposed to clean up. Nothing runs after saveMachine()
|
|
1598
|
+
// but `return state`, so a readable file here means the create succeeded.
|
|
1599
|
+
if (isOrphanMachineDir(name)) {
|
|
1600
|
+
try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
1601
|
+
}
|
|
1602
|
+
throw err;
|
|
1282
1603
|
} finally {
|
|
1283
1604
|
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
1284
1605
|
}
|
|
@@ -1295,13 +1616,20 @@ export class QuickCHR {
|
|
|
1295
1616
|
*/
|
|
1296
1617
|
static async start(opts: StartOptions = {}): Promise<ChrInstance> {
|
|
1297
1618
|
opts = normalizeStartOptions(opts);
|
|
1298
|
-
//
|
|
1619
|
+
// Cheap guard before any I/O: a name that reads as a flag is always a mistake.
|
|
1620
|
+
// The full charset rules are applied below, but only when this call creates the
|
|
1621
|
+
// machine — a machine named under the older, looser rules must still start.
|
|
1299
1622
|
if (opts.name?.startsWith("-")) {
|
|
1300
|
-
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
|
|
1623
|
+
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
|
|
1301
1624
|
}
|
|
1302
1625
|
|
|
1303
1626
|
const logger = createLogger(opts.onProgress);
|
|
1304
1627
|
|
|
1628
|
+
// Before any download or disk work: a named socket whose two ends are already
|
|
1629
|
+
// taken cannot carry this machine, and #156's rule is that a start that cannot
|
|
1630
|
+
// succeed should not first fetch 43 MB.
|
|
1631
|
+
assertNamedSocketsHaveRoom(opts, logger);
|
|
1632
|
+
|
|
1305
1633
|
const requestedDeviceMode = opts.deviceMode;
|
|
1306
1634
|
const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
|
|
1307
1635
|
for (const warning of resolvedDeviceMode.warnings) {
|
|
@@ -1362,6 +1690,8 @@ export class QuickCHR {
|
|
|
1362
1690
|
// Resolve name
|
|
1363
1691
|
const existingNames = listMachineNames();
|
|
1364
1692
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
1693
|
+
// Only a *new* name has to satisfy the current rules — see the guard at the top.
|
|
1694
|
+
if (!existingNames.includes(name)) assertValidResourceName(name, "machine");
|
|
1365
1695
|
|
|
1366
1696
|
// Check if machine already exists
|
|
1367
1697
|
const existing = loadMachine(name);
|
|
@@ -1430,7 +1760,7 @@ export class QuickCHR {
|
|
|
1430
1760
|
arch,
|
|
1431
1761
|
cpu: opts.cpu ?? 1,
|
|
1432
1762
|
mem: defaultMem(arch, opts.mem),
|
|
1433
|
-
networks: resolveStartNetworks(opts.networks, opts.network),
|
|
1763
|
+
networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
|
|
1434
1764
|
ports,
|
|
1435
1765
|
packages: opts.packages ?? [],
|
|
1436
1766
|
deviceMode: requestedDeviceMode,
|
|
@@ -1453,9 +1783,16 @@ export class QuickCHR {
|
|
|
1453
1783
|
// Acquire a lock to prevent concurrent starts of the same machine
|
|
1454
1784
|
const machineDir = getMachineDir(name);
|
|
1455
1785
|
assertSufficientQuickchrStorage(`start CHR ${name}`);
|
|
1786
|
+
const dirCreatedHere = !existsSync(machineDir);
|
|
1456
1787
|
ensureDir(machineDir);
|
|
1457
1788
|
const lockPath = join(machineDir, ".start-lock");
|
|
1458
1789
|
acquireLock(lockPath);
|
|
1790
|
+
// Endpoints are claimed before QEMU is spawned, because resolution needs to know
|
|
1791
|
+
// which end this machine holds. A launch that fails before its state is persisted
|
|
1792
|
+
// has no instance lifecycle to release them later, so a failed start would leave a
|
|
1793
|
+
// machine holding an end of a link it never used — and two of those fill the link.
|
|
1794
|
+
// Cleared once saveMachine() has committed the claim.
|
|
1795
|
+
let unpersistedSocketClaim: MachineState | undefined;
|
|
1459
1796
|
try {
|
|
1460
1797
|
|
|
1461
1798
|
// Download and prepare image
|
|
@@ -1489,7 +1826,7 @@ export class QuickCHR {
|
|
|
1489
1826
|
opts.secureLogin === true
|
|
1490
1827
|
);
|
|
1491
1828
|
|
|
1492
|
-
const networkConfigs = resolveStartNetworks(opts.networks, opts.network);
|
|
1829
|
+
const networkConfigs = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs());
|
|
1493
1830
|
if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
|
|
1494
1831
|
throw new QuickCHRError(
|
|
1495
1832
|
"NETWORK_UNAVAILABLE",
|
|
@@ -1528,9 +1865,14 @@ export class QuickCHR {
|
|
|
1528
1865
|
const accel = await detectAccel(arch);
|
|
1529
1866
|
const note = accelNote(arch, accel);
|
|
1530
1867
|
if (note) logger.warn(note);
|
|
1531
|
-
registerSocketMembers(state);
|
|
1532
1868
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1533
|
-
const resolvedNetworks =
|
|
1869
|
+
const resolvedNetworks = registerAndResolveNetworks(
|
|
1870
|
+
state,
|
|
1871
|
+
{ platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
1872
|
+
hostfwd,
|
|
1873
|
+
);
|
|
1874
|
+
unpersistedSocketClaim = state;
|
|
1875
|
+
reportSocketTransports(state, logger);
|
|
1534
1876
|
|
|
1535
1877
|
const launchConfig: QemuLaunchConfig = {
|
|
1536
1878
|
arch,
|
|
@@ -1553,9 +1895,15 @@ export class QuickCHR {
|
|
|
1553
1895
|
|
|
1554
1896
|
// Foreground (no provisioning): spawnQemu blocks until QEMU exits
|
|
1555
1897
|
if (!background && !hasProvisioning) {
|
|
1898
|
+
// QEMU has already exited here — spawnQemu() blocks in foreground. A machine
|
|
1899
|
+
// that is not running must not keep holding an end of a link: stop() and
|
|
1900
|
+
// remove() unregister, and this path reaches "stopped" without going through
|
|
1901
|
+
// either of them.
|
|
1902
|
+
unregisterSocketMembers(state);
|
|
1556
1903
|
state.status = "stopped";
|
|
1557
1904
|
state.lastStartedAt = new Date().toISOString();
|
|
1558
1905
|
saveMachine(state);
|
|
1906
|
+
unpersistedSocketClaim = undefined;
|
|
1559
1907
|
return createInstance(state);
|
|
1560
1908
|
}
|
|
1561
1909
|
|
|
@@ -1563,13 +1911,15 @@ export class QuickCHR {
|
|
|
1563
1911
|
state.lastStartedAt = new Date().toISOString();
|
|
1564
1912
|
|
|
1565
1913
|
saveMachine(state);
|
|
1914
|
+
unpersistedSocketClaim = undefined;
|
|
1566
1915
|
|
|
1567
1916
|
const instance = createInstance(state);
|
|
1568
1917
|
|
|
1569
1918
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1570
1919
|
|
|
1571
1920
|
// Always wait for boot in background mode — the JSDoc promises "REST-ready".
|
|
1572
|
-
|
|
1921
|
+
const probe = newBootProbeStats();
|
|
1922
|
+
let booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1573
1923
|
|
|
1574
1924
|
// Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
|
|
1575
1925
|
// runners) occasionally produce a single QEMU process that boots but never
|
|
@@ -1586,22 +1936,35 @@ export class QuickCHR {
|
|
|
1586
1936
|
({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
|
|
1587
1937
|
state.pid = pid;
|
|
1588
1938
|
saveMachine(state);
|
|
1589
|
-
booted = await instance.waitForBoot(bootTimeout);
|
|
1939
|
+
booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1590
1940
|
}
|
|
1591
1941
|
|
|
1592
1942
|
if (!booted) {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1943
|
+
const guest = bootFailureGuestExec(state);
|
|
1944
|
+
const failure = await captureBootFailure({
|
|
1945
|
+
name,
|
|
1946
|
+
machineDir,
|
|
1947
|
+
arch,
|
|
1948
|
+
accel,
|
|
1949
|
+
bootTimeoutMs: bootTimeout,
|
|
1950
|
+
pid,
|
|
1951
|
+
httpPort: instance.ports.http,
|
|
1952
|
+
portBase: state.portBase,
|
|
1953
|
+
qemuArgs,
|
|
1954
|
+
probe,
|
|
1955
|
+
phase: "start",
|
|
1956
|
+
reportDir: bootFailureReportDir(),
|
|
1957
|
+
monitorQuery: (cmd) => monitorCommand(machineDir, cmd, 3000, state.portBase),
|
|
1958
|
+
forwards: Object.values(state.ports),
|
|
1959
|
+
guestExec: guest?.exec,
|
|
1960
|
+
guestUser: guest?.users.join(" → "),
|
|
1961
|
+
});
|
|
1600
1962
|
throw new QuickCHRError(
|
|
1601
1963
|
"BOOT_TIMEOUT",
|
|
1602
1964
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1603
1965
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1604
|
-
|
|
1966
|
+
await cleanupAfterBootFailure(instance, name, failure) +
|
|
1967
|
+
`\n${failure.summary}`,
|
|
1605
1968
|
);
|
|
1606
1969
|
}
|
|
1607
1970
|
|
|
@@ -1651,9 +2014,18 @@ export class QuickCHR {
|
|
|
1651
2014
|
|
|
1652
2015
|
return instance;
|
|
1653
2016
|
} catch (err) {
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1656
|
-
|
|
2017
|
+
// Release an endpoint this launch claimed but never committed. stop()/remove()
|
|
2018
|
+
// would do it for a machine that reached persisted state; one that did not has
|
|
2019
|
+
// no instance to do it.
|
|
2020
|
+
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2021
|
+
// Clean up an orphaned machine directory if the spawn failed before readable
|
|
2022
|
+
// state was saved — same predicate as add()'s cleanup, so a truncated
|
|
2023
|
+
// machine.json is not mistaken for a finished machine.
|
|
2024
|
+
//
|
|
2025
|
+
// `dirCreatedHere` is the difference from add(): start() also starts machines
|
|
2026
|
+
// that already exist, and a directory this call did not create is never ours
|
|
2027
|
+
// to delete, whatever state it is in.
|
|
2028
|
+
if (dirCreatedHere && isOrphanMachineDir(name)) {
|
|
1657
2029
|
try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
1658
2030
|
}
|
|
1659
2031
|
throw err;
|
|
@@ -1808,6 +2180,9 @@ export class QuickCHR {
|
|
|
1808
2180
|
): Promise<ChrInstance> {
|
|
1809
2181
|
const lockPath = join(state.machineDir, ".start-lock");
|
|
1810
2182
|
acquireLock(lockPath);
|
|
2183
|
+
// Same pre-persistence window as start(): a relaunch that claims an endpoint and
|
|
2184
|
+
// then fails to spawn would keep it, with no instance to release it.
|
|
2185
|
+
let unpersistedSocketClaim: MachineState | undefined;
|
|
1811
2186
|
try {
|
|
1812
2187
|
|
|
1813
2188
|
const diskPath = join(state.machineDir, "disk.img");
|
|
@@ -1842,9 +2217,14 @@ export class QuickCHR {
|
|
|
1842
2217
|
const accel = await detectAccel(state.arch);
|
|
1843
2218
|
const note = accelNote(state.arch, accel);
|
|
1844
2219
|
if (note) (logger ?? createLogger()).warn(note);
|
|
1845
|
-
registerSocketMembers(state);
|
|
1846
2220
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1847
|
-
const resolvedNetworks =
|
|
2221
|
+
const resolvedNetworks = registerAndResolveNetworks(
|
|
2222
|
+
state,
|
|
2223
|
+
{ platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
2224
|
+
hostfwd,
|
|
2225
|
+
);
|
|
2226
|
+
unpersistedSocketClaim = state;
|
|
2227
|
+
reportSocketTransports(state, logger ?? createLogger());
|
|
1848
2228
|
|
|
1849
2229
|
const launchConfig: QemuLaunchConfig = {
|
|
1850
2230
|
arch: state.arch,
|
|
@@ -1867,9 +2247,12 @@ export class QuickCHR {
|
|
|
1867
2247
|
|
|
1868
2248
|
// Foreground without provisioning: spawnQemu blocks until QEMU exits
|
|
1869
2249
|
if (!background && !hasProvisioning) {
|
|
2250
|
+
// See the same branch in start(): QEMU has exited, so the endpoint goes back.
|
|
2251
|
+
unregisterSocketMembers(state);
|
|
1870
2252
|
state.status = "stopped";
|
|
1871
2253
|
state.lastStartedAt = new Date().toISOString();
|
|
1872
2254
|
saveMachine(state);
|
|
2255
|
+
unpersistedSocketClaim = undefined;
|
|
1873
2256
|
return createInstance(state);
|
|
1874
2257
|
}
|
|
1875
2258
|
|
|
@@ -1877,25 +2260,40 @@ export class QuickCHR {
|
|
|
1877
2260
|
state.status = "running";
|
|
1878
2261
|
state.lastStartedAt = new Date().toISOString();
|
|
1879
2262
|
saveMachine(state);
|
|
2263
|
+
unpersistedSocketClaim = undefined;
|
|
1880
2264
|
|
|
1881
2265
|
const instance = createInstance(state);
|
|
1882
2266
|
|
|
1883
2267
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
1884
2268
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
1885
|
-
const
|
|
2269
|
+
const probe = newBootProbeStats();
|
|
2270
|
+
const booted = await instance.waitForBoot(bootTimeout, probe);
|
|
1886
2271
|
if (!booted) {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2272
|
+
const guest = bootFailureGuestExec(state);
|
|
2273
|
+
const failure = await captureBootFailure({
|
|
2274
|
+
name: state.name,
|
|
2275
|
+
machineDir: state.machineDir,
|
|
2276
|
+
arch: state.arch,
|
|
2277
|
+
accel,
|
|
2278
|
+
bootTimeoutMs: bootTimeout,
|
|
2279
|
+
pid,
|
|
2280
|
+
httpPort: instance.ports.http,
|
|
2281
|
+
portBase: state.portBase,
|
|
2282
|
+
qemuArgs,
|
|
2283
|
+
probe,
|
|
2284
|
+
phase: "_launchExisting",
|
|
2285
|
+
reportDir: bootFailureReportDir(),
|
|
2286
|
+
monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
|
|
2287
|
+
forwards: Object.values(state.ports),
|
|
2288
|
+
guestExec: guest?.exec,
|
|
2289
|
+
guestUser: guest?.users.join(" → "),
|
|
2290
|
+
});
|
|
1894
2291
|
throw new QuickCHRError(
|
|
1895
2292
|
"BOOT_TIMEOUT",
|
|
1896
2293
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1897
2294
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1898
|
-
|
|
2295
|
+
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2296
|
+
`\n${failure.summary}`,
|
|
1899
2297
|
);
|
|
1900
2298
|
}
|
|
1901
2299
|
|
|
@@ -1928,6 +2326,9 @@ export class QuickCHR {
|
|
|
1928
2326
|
} catch { /* never propagate */ }
|
|
1929
2327
|
|
|
1930
2328
|
return instance;
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2331
|
+
throw err;
|
|
1931
2332
|
} finally {
|
|
1932
2333
|
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
1933
2334
|
}
|
|
@@ -1953,6 +2354,54 @@ export class QuickCHR {
|
|
|
1953
2354
|
return createInstance(state);
|
|
1954
2355
|
}
|
|
1955
2356
|
|
|
2357
|
+
/** Machines whose `machine.json` is present but cannot be turned into state, each
|
|
2358
|
+
* with the reason: the file would not read, the JSON is invalid, or it parsed into
|
|
2359
|
+
* something that is not a machine.
|
|
2360
|
+
*
|
|
2361
|
+
* `list()` skips these so one corrupt file cannot abort the enumeration; this is
|
|
2362
|
+
* where they stay visible. `get(name)` still throws for such a machine — a lookup
|
|
2363
|
+
* by name is not an enumeration (#165). */
|
|
2364
|
+
static listUnreadable(): Array<{ name: string; error: string }> {
|
|
2365
|
+
return listUnreadableMachines();
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/** Names of machine directories with no readable `machine.json` — half-created
|
|
2369
|
+
* machines that `list()` cannot show and `get()` cannot resolve (#155). */
|
|
2370
|
+
static listOrphans(): string[] {
|
|
2371
|
+
return listOrphanMachineDirs();
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/** Delete a half-created machine directory. Returns false when `name` is a real
|
|
2375
|
+
* machine (use `get(name)?.remove()`) or does not exist at all.
|
|
2376
|
+
*
|
|
2377
|
+
* A crash or SIGKILL can strand a directory even with add()'s cleanup in place, and
|
|
2378
|
+
* a stranded directory blocks re-add while being invisible to `list` — so clearing
|
|
2379
|
+
* one must not require knowing that the data dir exists. */
|
|
2380
|
+
static removeOrphan(name: string): boolean {
|
|
2381
|
+
// Explicit, before the predicate: this deletes a directory tree, and `join()`
|
|
2382
|
+
// turns ".." into the data dir itself. Say why rather than answering `false`.
|
|
2383
|
+
assertPathSafeName(name, "machine");
|
|
2384
|
+
if (!isOrphanMachineDir(name)) return false;
|
|
2385
|
+
|
|
2386
|
+
// A create still in flight is indistinguishable from an orphan by directory
|
|
2387
|
+
// contents alone — add()/start() write machine.json last, so the window between
|
|
2388
|
+
// ensureDir() and saveMachine() looks exactly like a half-made machine. The
|
|
2389
|
+
// start-lock is what tells them apart, so take it: acquireLock() throws
|
|
2390
|
+
// MACHINE_LOCKED while a live creator holds it, and replaces one whose owner is
|
|
2391
|
+
// gone — which is the case this recovery exists for.
|
|
2392
|
+
const lockPath = join(getMachineDir(name), ".start-lock");
|
|
2393
|
+
acquireLock(lockPath);
|
|
2394
|
+
|
|
2395
|
+
// The creator may have finished between the check above and the lock.
|
|
2396
|
+
if (!isOrphanMachineDir(name)) {
|
|
2397
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
2398
|
+
return false;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
removeState(name);
|
|
2402
|
+
return true;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
1956
2405
|
/** Run doctor checks for prerequisites. */
|
|
1957
2406
|
static async doctor(): Promise<DoctorResult> {
|
|
1958
2407
|
const checks: DoctorResult["checks"] = [];
|
|
@@ -2141,29 +2590,20 @@ export class QuickCHR {
|
|
|
2141
2590
|
}
|
|
2142
2591
|
|
|
2143
2592
|
// Orphaned machine directories (have files but no machine.json)
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
const dirs = readdirSync(machinesDir, { withFileTypes: true })
|
|
2147
|
-
.filter(e => e.isDirectory());
|
|
2148
|
-
const orphans: string[] = [];
|
|
2149
|
-
for (const dir of dirs) {
|
|
2150
|
-
const mjPath = join(machinesDir, dir.name, "machine.json");
|
|
2151
|
-
if (!existsSync(mjPath)) {
|
|
2152
|
-
orphans.push(dir.name);
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2593
|
+
if (existsSync(getMachinesDir())) {
|
|
2594
|
+
const orphans = listOrphanMachineDirs();
|
|
2155
2595
|
if (orphans.length > 0) {
|
|
2156
|
-
const removeLines = orphans.map((o) => `
|
|
2596
|
+
const removeLines = orphans.map((o) => ` quickchr remove ${o}`).join("\n");
|
|
2157
2597
|
checks.push({
|
|
2158
2598
|
label: "Orphaned machine dirs",
|
|
2159
2599
|
status: "warn",
|
|
2160
|
-
detail: `${orphans.length} dir(s)
|
|
2600
|
+
detail: `${orphans.length} dir(s) with no readable machine.json: ${orphans.join(", ")}\nClear them with:\n${removeLines}`,
|
|
2161
2601
|
});
|
|
2162
2602
|
} else {
|
|
2163
2603
|
checks.push({
|
|
2164
2604
|
label: "Machine state",
|
|
2165
2605
|
status: "ok",
|
|
2166
|
-
detail: `${
|
|
2606
|
+
detail: `${listMachineNames().length} machine(s), no orphans`,
|
|
2167
2607
|
});
|
|
2168
2608
|
}
|
|
2169
2609
|
}
|