@tikoci/quickchr 0.4.7 → 0.5.0
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 +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
package/src/lib/quickchr.ts
CHANGED
|
@@ -16,15 +16,19 @@ import type {
|
|
|
16
16
|
LicenseInput,
|
|
17
17
|
LicenseLevel,
|
|
18
18
|
MachineState,
|
|
19
|
+
NetworkConfig,
|
|
19
20
|
NetworkTopologyEntry,
|
|
21
|
+
PlatformInfo,
|
|
20
22
|
PortMapping,
|
|
23
|
+
ProvisioningStep,
|
|
21
24
|
QgaCommand,
|
|
22
25
|
ServiceEndpoint,
|
|
23
26
|
SnapshotInfo,
|
|
24
27
|
SshServiceEndpoint,
|
|
25
28
|
StartOptions,
|
|
26
29
|
} from "./types.ts";
|
|
27
|
-
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
30
|
+
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP, PORTS_PER_BLOCK } from "./types.ts";
|
|
31
|
+
import { assertValidResourceName, assertPathSafeName, shellQuote } from "./names.ts";
|
|
28
32
|
import packageJson from "../../package.json";
|
|
29
33
|
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
30
34
|
import {
|
|
@@ -32,18 +36,25 @@ import {
|
|
|
32
36
|
isValidVersion,
|
|
33
37
|
generateMachineName,
|
|
34
38
|
assertProvisioningSupportedVersion,
|
|
39
|
+
assertRestAvailable,
|
|
40
|
+
assertRouterOs6Arch,
|
|
41
|
+
isRouterOs6,
|
|
35
42
|
PROVISIONING_FEATURE_LABEL,
|
|
36
43
|
} from "./versions.ts";
|
|
37
|
-
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
|
|
44
|
+
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
|
|
38
45
|
import {
|
|
39
46
|
getUsedPortBases,
|
|
47
|
+
getUsedMacs,
|
|
40
48
|
saveMachine,
|
|
41
49
|
loadMachine,
|
|
42
50
|
loadAllMachines,
|
|
51
|
+
listUnreadableMachines,
|
|
43
52
|
removeMachine as removeState,
|
|
44
53
|
getMachineDir,
|
|
45
54
|
getMachinesDir,
|
|
46
55
|
listMachineNames,
|
|
56
|
+
isOrphanMachineDir,
|
|
57
|
+
listOrphanMachineDirs,
|
|
47
58
|
refreshAllStatuses,
|
|
48
59
|
isMachineRunning,
|
|
49
60
|
ensureDir,
|
|
@@ -54,23 +65,26 @@ import {
|
|
|
54
65
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
55
66
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
56
67
|
import { resolveSetting } from "./settings.ts";
|
|
57
|
-
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
68
|
+
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
58
69
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
59
70
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
60
|
-
import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
|
|
71
|
+
import { captureBootFailure, newBootProbeStats, preserveOnFailure, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
|
|
61
72
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
62
73
|
import { provision } from "./provision.ts";
|
|
63
74
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
64
75
|
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
65
76
|
import { scpPush, scpPull } from "./scp.ts";
|
|
66
77
|
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
78
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownGuest, validateShutdownTimeout } from "./shutdown.ts";
|
|
67
79
|
import { restExecute } from "./exec.ts";
|
|
68
80
|
import { qgaExec } from "./qga.ts";
|
|
69
81
|
import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
|
|
70
82
|
import { restRequest, restGet, restPost } from "./rest.ts";
|
|
71
|
-
import {
|
|
83
|
+
import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
|
|
72
84
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
73
85
|
import {
|
|
86
|
+
describeDeviceModeChange,
|
|
87
|
+
FACTORY_AUTH_HEADER,
|
|
74
88
|
formatDeviceModeSelection,
|
|
75
89
|
readDeviceMode,
|
|
76
90
|
resolveDeviceModeOptions,
|
|
@@ -79,11 +93,19 @@ import {
|
|
|
79
93
|
verifyDeviceMode,
|
|
80
94
|
waitForDeviceModeApi,
|
|
81
95
|
} from "./device-mode.ts";
|
|
96
|
+
import {
|
|
97
|
+
appliedDeviceModeRecord,
|
|
98
|
+
assertProvisioningWindow,
|
|
99
|
+
hasProvisioningRequest,
|
|
100
|
+
isProvisioningWindowOpen,
|
|
101
|
+
pendingProvisioningFor,
|
|
102
|
+
} from "./provisioning-window.ts";
|
|
82
103
|
import type { LicenseOptions } from "./types.ts";
|
|
83
104
|
import type { GuestExec } from "./guest-snapshot.ts";
|
|
84
105
|
import { toChrPorts } from "./network.ts";
|
|
85
106
|
import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
86
|
-
import { existsSync,
|
|
107
|
+
import { existsSync, rmSync, copyFileSync } from "node:fs";
|
|
108
|
+
import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
|
|
87
109
|
import { join } from "node:path";
|
|
88
110
|
|
|
89
111
|
// --- Architecture-aware defaults ---
|
|
@@ -133,6 +155,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
|
|
|
133
155
|
return opts;
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
/** `version` and `channel` both name the release, so a caller passing both has asked
|
|
159
|
+
* for two things and gets one: `version` wins. Say so rather than drop `channel` in
|
|
160
|
+
* silence — the TSDoc used to call them "mutually exclusive" while nothing enforced
|
|
161
|
+
* it (#178). A warning, not a throw, matching the channel-name-as-version case. */
|
|
162
|
+
function warnVersionWithChannel(opts: StartOptions, logger: ProgressLogger): void {
|
|
163
|
+
if (opts.version && opts.channel) {
|
|
164
|
+
logger.warn(`Both version "${opts.version}" and channel "${opts.channel}" were given — using version ${opts.version}; channel is ignored.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
136
168
|
function listProvisioningMutations(opts: {
|
|
137
169
|
installAllPackages?: boolean;
|
|
138
170
|
packages?: string[];
|
|
@@ -178,6 +210,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
|
|
|
178
210
|
return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
|
|
179
211
|
}
|
|
180
212
|
|
|
213
|
+
/** `wait: false` returns before the guest has booted, and provisioning needs a booted
|
|
214
|
+
* guest — so the pair is refused by name rather than the provisioning silently
|
|
215
|
+
* skipped (#159). `machine` is set when the provisioning is first-boot intent that
|
|
216
|
+
* `add()` recorded, which the caller may not have typed on this command line. */
|
|
217
|
+
function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
|
|
218
|
+
if (opts.wait !== false) return;
|
|
219
|
+
const source = machine
|
|
220
|
+
? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
|
|
221
|
+
: `provisioning was requested (${operation})`;
|
|
222
|
+
throw new QuickCHRError(
|
|
223
|
+
"INVALID_ARGUMENT",
|
|
224
|
+
`--no-wait (wait: false) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so start has to wait for it. ` +
|
|
225
|
+
"Drop --no-wait for this first boot — later starts can use it.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
181
229
|
// --- Socket registry lifecycle helpers ---
|
|
182
230
|
|
|
183
231
|
function getSocketNamedNetworks(state: MachineState): string[] {
|
|
@@ -186,19 +234,97 @@ function getSocketNamedNetworks(state: MachineState): string[] {
|
|
|
186
234
|
.map((n) => (n.specifier as { type: "socket"; name: string }).name);
|
|
187
235
|
}
|
|
188
236
|
|
|
237
|
+
/** Refuse a start that a named socket has no room for, before any I/O.
|
|
238
|
+
*
|
|
239
|
+
* Only a *new* member is refused: a machine already holding an endpoint is simply
|
|
240
|
+
* restarting into its own slot. An unknown socket is left alone — `start()`
|
|
241
|
+
* auto-creates it further down. */
|
|
242
|
+
function assertNamedSocketsHaveRoom(opts: StartOptions, logger: ProgressLogger): void {
|
|
243
|
+
const named = resolveStartNetworks(opts.networks, opts.network)
|
|
244
|
+
.map((n) => n.specifier)
|
|
245
|
+
.filter((spec): spec is { type: "socket"; name: string } =>
|
|
246
|
+
typeof spec === "object" && spec !== null && spec.type === "socket");
|
|
247
|
+
for (const { name } of named) {
|
|
248
|
+
const entry = getNamedSocket(name);
|
|
249
|
+
if (!entry?.endpoints) continue;
|
|
250
|
+
if (opts.name && entry.endpoints.includes(opts.name)) continue;
|
|
251
|
+
if (entry.endpoints.includes(null)) continue;
|
|
252
|
+
const held = entry.endpoints.filter((m): m is string => m !== null);
|
|
253
|
+
logger.debug(`socket::${name} endpoints held by ${held.join(", ")}`);
|
|
254
|
+
throw new QuickCHRError(
|
|
255
|
+
"NETWORK_UNAVAILABLE",
|
|
256
|
+
`Named socket "${name}" is a ${entry.mode} link and carries 2 machines; ` +
|
|
257
|
+
`${held.join(" and ")} already hold both ends. ` +
|
|
258
|
+
`Stop one of them, or create an N-way segment with 'quickchr networks sockets create <name> --mode mcast'.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** QEMU version for the binary this machine's arch will run on, when it can be read.
|
|
264
|
+
* `-netdev dgram` needs 7.2, and a named socket should say so rather than failing at
|
|
265
|
+
* spawn with a netdev QEMU does not recognize. */
|
|
266
|
+
function qemuVersionForArch(platform: PlatformInfo, arch: Arch): string | undefined {
|
|
267
|
+
const bin = arch === "arm64" ? platform.qemuBinArm64 : platform.qemuBinX86;
|
|
268
|
+
return bin ? getQemuVersion(bin) : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
189
271
|
function registerSocketMembers(state: MachineState): void {
|
|
190
272
|
for (const name of getSocketNamedNetworks(state)) {
|
|
191
273
|
try {
|
|
192
|
-
if
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
addSocketMember(name, state.name);
|
|
274
|
+
// Create-if-missing and join under one registry lock: two concurrent starts
|
|
275
|
+
// both pass a test-then-create, and both then claim the same endpoint slot.
|
|
276
|
+
joinNamedSocket(name, state.name, { autoCreated: true });
|
|
196
277
|
} catch (e) {
|
|
278
|
+
// A full two-member link is a refusal, not a warning: carrying on would spawn
|
|
279
|
+
// QEMU with no endpoint to bind, and on a `dgram` link a second machine
|
|
280
|
+
// binding the same path unlinks the first one's socket and steals the link
|
|
281
|
+
// with nothing logged on either side.
|
|
282
|
+
if (e instanceof QuickCHRError && e.code === "NETWORK_UNAVAILABLE") throw e;
|
|
197
283
|
console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
198
284
|
}
|
|
199
285
|
}
|
|
200
286
|
}
|
|
201
287
|
|
|
288
|
+
/** Report the transport each named socket resolved to.
|
|
289
|
+
*
|
|
290
|
+
* #158's acceptance bar: nothing about a named socket should require opening a file
|
|
291
|
+
* under the data dir. The field report that produced these issues had to read
|
|
292
|
+
* `machine.json` to discover its link was UDP multicast, after ping had already
|
|
293
|
+
* failed silently. */
|
|
294
|
+
function reportSocketTransports(state: MachineState, logger: ProgressLogger): void {
|
|
295
|
+
for (const name of getSocketNamedNetworks(state)) {
|
|
296
|
+
const entry = getNamedSocket(name);
|
|
297
|
+
if (!entry) continue;
|
|
298
|
+
logger.status(` Network socket::${name}: ${describeSocketTransport(entry, getSocketSlot(entry, state.name))}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Claim each named socket's endpoint, then resolve — releasing the claims if
|
|
303
|
+
* resolution fails.
|
|
304
|
+
*
|
|
305
|
+
* Membership is persisted *before* networks resolve, because the resolver needs to
|
|
306
|
+
* know which end this machine holds. So a resolution that throws — a `dgram` link on
|
|
307
|
+
* Windows, or on a QEMU older than 7.2 — would otherwise leave the machine holding an
|
|
308
|
+
* endpoint it never used, and two failed starts would fill a link with machines that
|
|
309
|
+
* are not running. */
|
|
310
|
+
export function registerAndResolveNetworks(
|
|
311
|
+
state: MachineState,
|
|
312
|
+
ctx: { platform: PlatformInfo; qemuVersion?: string },
|
|
313
|
+
hostfwd: string,
|
|
314
|
+
): NetworkConfig[] {
|
|
315
|
+
try {
|
|
316
|
+
// Inside the try, not before it: this claims one endpoint per named socket, so a
|
|
317
|
+
// machine on two links whose second link is full would otherwise keep the first
|
|
318
|
+
// claim — and the caller cannot clean it either, since it only learns about the
|
|
319
|
+
// claim once this returns.
|
|
320
|
+
registerSocketMembers(state);
|
|
321
|
+
return resolveAllNetworks(state.networks, { ...ctx, machine: state.name }, hostfwd);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
unregisterSocketMembers(state);
|
|
324
|
+
throw e;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
202
328
|
function unregisterSocketMembers(state: MachineState): void {
|
|
203
329
|
for (const name of getSocketNamedNetworks(state)) {
|
|
204
330
|
try {
|
|
@@ -231,6 +357,193 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
|
|
|
231
357
|
return null;
|
|
232
358
|
}
|
|
233
359
|
|
|
360
|
+
/** Put a machine's disks back to the factory image and drop the credential material
|
|
361
|
+
* that authenticated to the erased guest. Machine state is the caller's to update:
|
|
362
|
+
* `clean()` clears the guest facts and keeps intent, a provisioning rollback restores
|
|
363
|
+
* the record it had before the failed run. QEMU must already be stopped. */
|
|
364
|
+
async function resetGuestToFactory(state: MachineState): Promise<void> {
|
|
365
|
+
// NOTE: this deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
366
|
+
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
367
|
+
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
368
|
+
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
369
|
+
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
370
|
+
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
371
|
+
// Re-copy fresh image from cache
|
|
372
|
+
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
373
|
+
if (!existsSync(imgPath)) {
|
|
374
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
|
|
375
|
+
}
|
|
376
|
+
const destPath = join(state.machineDir, "disk.img");
|
|
377
|
+
copyFileSync(imgPath, destPath);
|
|
378
|
+
|
|
379
|
+
// Clean up disk files (boot.qcow2, extra disks)
|
|
380
|
+
cleanDiskFiles(state.machineDir);
|
|
381
|
+
|
|
382
|
+
// Re-prepare disks if the machine had disk customizations
|
|
383
|
+
await ensureConfiguredDisks(
|
|
384
|
+
state.machineDir,
|
|
385
|
+
state.bootSize,
|
|
386
|
+
state.extraDisks,
|
|
387
|
+
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
deleteInstanceCredentials(state.name);
|
|
391
|
+
// The managed keypair authenticated to the account factory reset erased,
|
|
392
|
+
// so it is dead credential material sitting in the machine dir. Removing
|
|
393
|
+
// it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
|
|
394
|
+
// existing file exits 1 with "already exists" rather than overwriting
|
|
395
|
+
// (verified locally), so a leftover key would break `installSshKey()`.
|
|
396
|
+
rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Options every machine-lock acquisition passes: the pre-0.5 lock to honour, and the
|
|
400
|
+
* noun for `MACHINE_LOCKED`. */
|
|
401
|
+
function machineLockOptions(name: string, machineDir: string): { legacyPath: string; subject: string } {
|
|
402
|
+
return { legacyPath: legacyMachineLockPath(machineDir), subject: `Machine "${name}"` };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The machine lock outlives its machine (see lock.ts), so a caller that has just
|
|
406
|
+
* taken it must check that the machine it was asked about is still there, and is not
|
|
407
|
+
* another machine created under the same name while it waited. Acting on the old
|
|
408
|
+
* snapshot would mean stopping a stale pid. */
|
|
409
|
+
function assertSameMachine(state: MachineState): void {
|
|
410
|
+
const fresh = loadMachine(state.name);
|
|
411
|
+
if (!fresh || fresh.createdAt !== state.createdAt) {
|
|
412
|
+
throw new QuickCHRError(
|
|
413
|
+
"MACHINE_NOT_FOUND",
|
|
414
|
+
`Machine "${state.name}" was removed${fresh ? " and created again" : ""} while this operation waited for it.`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** How long `stop`/`clean`/`remove` wait for an operation already holding the machine
|
|
420
|
+
* lock. The holder is almost always a start or relaunch waiting for boot, so the wait
|
|
421
|
+
* is that boot budget: a `stop` issued during a `start` runs after it rather than
|
|
422
|
+
* failing, and a holder that never finishes still cannot block it forever (#181). */
|
|
423
|
+
function lifecycleLockWaitMs(state: MachineState): number {
|
|
424
|
+
const withPackages = !!state.installAllPackages || (state.packages?.length ?? 0) > 0;
|
|
425
|
+
return defaultBootTimeout(state.arch, withPackages, state.lastAccel);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Run a lifecycle operation under the machine's lock, waiting for a current holder.
|
|
429
|
+
* State is re-read once the lock is held: a handle's snapshot predates whatever the
|
|
430
|
+
* holder just did, and `state.pid` read from it may name a process that has exited or
|
|
431
|
+
* one the OS has since reused (#180, #181). */
|
|
432
|
+
async function withMachineLock<T>(state: MachineState, op: LockOperation, fn: () => Promise<T>): Promise<T> {
|
|
433
|
+
const lock = await acquireLock(machineLockPath(state.machineDir), op, {
|
|
434
|
+
...machineLockOptions(state.name, state.machineDir),
|
|
435
|
+
waitMs: lifecycleLockWaitMs(state),
|
|
436
|
+
onWait: (holder) => createLogger().warn(
|
|
437
|
+
`Machine "${state.name}" is busy with \`${holder.op}\` (pid ${holder.pid}) — waiting for it to finish before \`${op}\`.`,
|
|
438
|
+
),
|
|
439
|
+
});
|
|
440
|
+
try {
|
|
441
|
+
assertSameMachine(state);
|
|
442
|
+
refreshMachineState(state);
|
|
443
|
+
return await fn();
|
|
444
|
+
} finally {
|
|
445
|
+
lock.release();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The body of `stop()`, for a caller that already holds the machine lock. The lock is
|
|
450
|
+
* not reentrant, so code inside a locked start or relaunch calls this, never
|
|
451
|
+
* `instance.stop()`. */
|
|
452
|
+
async function stopUnderLock(state: MachineState): Promise<void> {
|
|
453
|
+
if (state.pid) {
|
|
454
|
+
await stopQemu(state.pid);
|
|
455
|
+
}
|
|
456
|
+
finalizeStoppedState(state);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
|
|
460
|
+
function finalizeStoppedState(state: MachineState): void {
|
|
461
|
+
unregisterSocketMembers(state);
|
|
462
|
+
// Update persisted state
|
|
463
|
+
const current = loadMachine(state.name);
|
|
464
|
+
if (current) {
|
|
465
|
+
current.status = "stopped";
|
|
466
|
+
current.pid = undefined;
|
|
467
|
+
saveMachine(current);
|
|
468
|
+
}
|
|
469
|
+
state.status = "stopped";
|
|
470
|
+
state.pid = undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The body of `remove()`, for a caller that already holds the machine lock. The lock
|
|
474
|
+
* file goes with the directory. */
|
|
475
|
+
async function removeUnderLock(state: MachineState): Promise<void> {
|
|
476
|
+
if (state.pid && isMachineRunning(state)) {
|
|
477
|
+
await stopQemu(state.pid);
|
|
478
|
+
}
|
|
479
|
+
unregisterSocketMembers(state);
|
|
480
|
+
// Clean up stored instance credentials
|
|
481
|
+
deleteInstanceCredentials(state.name);
|
|
482
|
+
removeState(state.name);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The body of `clean()`, for a caller that already holds the machine lock. */
|
|
486
|
+
async function cleanUnderLock(state: MachineState): Promise<void> {
|
|
487
|
+
if (state.pid && isMachineRunning(state)) {
|
|
488
|
+
await stopQemu(state.pid);
|
|
489
|
+
}
|
|
490
|
+
// Same invariant as stop()/remove(): a machine that is not running holds no
|
|
491
|
+
// endpoint. clean() stops QEMU directly rather than through stop(), so without
|
|
492
|
+
// this a cleaned machine keeps a pair link occupied and blocks its removal.
|
|
493
|
+
unregisterSocketMembers(state);
|
|
494
|
+
await resetGuestToFactory(state);
|
|
495
|
+
|
|
496
|
+
// Every guest-side credential went with the old disk, so the credential
|
|
497
|
+
// facts in machine.json now describe a machine that no longer exists.
|
|
498
|
+
// Left in place, `resolveAuth()` /
|
|
499
|
+
// `resolveCreds()` prefer `state.user` and authenticate every REST call,
|
|
500
|
+
// exec, and SCP as a deleted user — the confound #79 removes here.
|
|
501
|
+
// Clearing them restores the documented fallback: factory `admin` with an
|
|
502
|
+
// empty password, which is what a fresh CHR image actually answers to
|
|
503
|
+
// (anchored by the "clean() resets disk to factory defaults" integration
|
|
504
|
+
// test). `disableAdmin` goes with them because it is read as a live fact
|
|
505
|
+
// about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
|
|
506
|
+
// image has admin enabled again. `licenseLevel` goes with them for the same
|
|
507
|
+
// reason: it is a read-back of the license the erased disk held, not an
|
|
508
|
+
// intent — quickchr never persists the license *input* — so leaving it
|
|
509
|
+
// behind would report a level the fresh image does not have. Provisioning
|
|
510
|
+
// *intent* — packages, deviceMode, secureLogin — is not guest state and
|
|
511
|
+
// survives, and is what the next `start()` re-applies now that the window
|
|
512
|
+
// reopens.
|
|
513
|
+
// (`resetGuestToFactory()` has already dropped the secret-store entry and the
|
|
514
|
+
// managed keypair, which describe the same erased account.)
|
|
515
|
+
|
|
516
|
+
// Update state. `provisioning` and `lastStartedAt` go with the credentials:
|
|
517
|
+
// both describe a guest that no longer exists, and together they are what
|
|
518
|
+
// closes the provisioning window. Leaving them behind is what stopped a cleaned
|
|
519
|
+
// machine from ever being provisioned again — the disk was factory-fresh and
|
|
520
|
+
// `start()` still refused to provision it (#176). `cleanedAt` keeps the
|
|
521
|
+
// forensic clue that the timestamp carried.
|
|
522
|
+
const cleanedAt = new Date().toISOString();
|
|
523
|
+
const current = loadMachine(state.name);
|
|
524
|
+
if (current) {
|
|
525
|
+
current.status = "stopped";
|
|
526
|
+
current.pid = undefined;
|
|
527
|
+
current.user = undefined;
|
|
528
|
+
current.managedSshKey = undefined;
|
|
529
|
+
current.disableAdmin = undefined;
|
|
530
|
+
current.provisioning = undefined;
|
|
531
|
+
current.lastStartedAt = undefined;
|
|
532
|
+
current.licenseLevel = undefined;
|
|
533
|
+
current.cleanedAt = cleanedAt;
|
|
534
|
+
saveMachine(current);
|
|
535
|
+
}
|
|
536
|
+
state.status = "stopped";
|
|
537
|
+
state.pid = undefined;
|
|
538
|
+
state.user = undefined;
|
|
539
|
+
state.managedSshKey = undefined;
|
|
540
|
+
state.disableAdmin = undefined;
|
|
541
|
+
state.provisioning = undefined;
|
|
542
|
+
state.lastStartedAt = undefined;
|
|
543
|
+
state.licenseLevel = undefined;
|
|
544
|
+
state.cleanedAt = cleanedAt;
|
|
545
|
+
}
|
|
546
|
+
|
|
234
547
|
/** Create a ChrInstance handle from persisted MachineState. */
|
|
235
548
|
function createInstance(state: MachineState): ChrInstance {
|
|
236
549
|
const ports = toChrPorts(state.ports);
|
|
@@ -319,14 +632,16 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
319
632
|
// keeps the excludePorts fix: excluding "http" while keeping "https" now
|
|
320
633
|
// resolves rest-api onto the surviving secure port instead of a port that
|
|
321
634
|
// doesn't exist.
|
|
322
|
-
const restApi =
|
|
323
|
-
state.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
635
|
+
const restApi: ServiceEndpoint = isRouterOs6(state.version)
|
|
636
|
+
? { available: false, unavailableReason: `RouterOS ${state.version} has no REST API — use native-api` }
|
|
637
|
+
: buildHttpService(
|
|
638
|
+
state.ports.https,
|
|
639
|
+
state.ports.http,
|
|
640
|
+
"rest-api",
|
|
641
|
+
{ secure: "https", plain: "http" },
|
|
642
|
+
{ username: creds.user, password: creds.password, basic, header: auth.header },
|
|
643
|
+
"/rest",
|
|
644
|
+
);
|
|
330
645
|
const nativeApi = buildHttpService(
|
|
331
646
|
state.ports["api-ssl"],
|
|
332
647
|
state.ports.api,
|
|
@@ -412,9 +727,12 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
412
727
|
sshPort: ports.ssh,
|
|
413
728
|
portBase: state.portBase,
|
|
414
729
|
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
415
|
-
|
|
730
|
+
hostGatewayIp: HOST_GATEWAY_IP,
|
|
416
731
|
|
|
417
732
|
async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
|
|
733
|
+
if (readinessProbeFor(state.version) === "api") {
|
|
734
|
+
return waitForApiReady(ports.api, timeoutMs ?? 120_000, resolveCreds(state), stats);
|
|
735
|
+
}
|
|
418
736
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
419
737
|
// on authenticated machines (post-provisioning, post-install reboots).
|
|
420
738
|
const auth = resolveAuth(state);
|
|
@@ -435,97 +753,32 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
435
753
|
},
|
|
436
754
|
|
|
437
755
|
async stop(): Promise<void> {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
756
|
+
await withMachineLock(state, "stop", () => stopUnderLock(state));
|
|
757
|
+
},
|
|
758
|
+
|
|
759
|
+
async shutdown(options) {
|
|
760
|
+
const timeoutMs = validateShutdownTimeout(options?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
761
|
+
return withMachineLock(state, "shutdown", async () => {
|
|
762
|
+
if (!state.pid || !isMachineRunning(state)) {
|
|
763
|
+
finalizeStoppedState(state);
|
|
764
|
+
return { outcome: "already-stopped" };
|
|
765
|
+
}
|
|
766
|
+
const pid = state.pid;
|
|
767
|
+
const result = await shutdownGuest(timeoutMs, async (requestTimeoutMs) => {
|
|
768
|
+
// Rebuild after the locked refresh: a waiting handle may predate a relaunch.
|
|
769
|
+
return createInstance(state).exec("/system/shutdown", { via: "rest", timeout: requestTimeoutMs });
|
|
770
|
+
}, () => isMachineRunning(state), () => stopQemu(pid));
|
|
771
|
+
finalizeStoppedState(state);
|
|
772
|
+
return result;
|
|
773
|
+
});
|
|
451
774
|
},
|
|
452
775
|
|
|
453
776
|
async remove(): Promise<void> {
|
|
454
|
-
|
|
455
|
-
await stopQemu(state.pid);
|
|
456
|
-
}
|
|
457
|
-
unregisterSocketMembers(state);
|
|
458
|
-
// Clean up stored instance credentials
|
|
459
|
-
deleteInstanceCredentials(state.name);
|
|
460
|
-
removeState(state.name);
|
|
777
|
+
await withMachineLock(state, "remove", () => removeUnderLock(state));
|
|
461
778
|
},
|
|
462
779
|
|
|
463
780
|
async clean(): Promise<void> {
|
|
464
|
-
|
|
465
|
-
await stopQemu(state.pid);
|
|
466
|
-
}
|
|
467
|
-
// NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
468
|
-
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
469
|
-
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
470
|
-
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
471
|
-
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
472
|
-
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
473
|
-
// Re-copy fresh image from cache
|
|
474
|
-
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
475
|
-
if (!existsSync(imgPath)) {
|
|
476
|
-
throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
|
|
477
|
-
}
|
|
478
|
-
const destPath = join(state.machineDir, "disk.img");
|
|
479
|
-
copyFileSync(imgPath, destPath);
|
|
480
|
-
|
|
481
|
-
// Clean up disk files (boot.qcow2, extra disks)
|
|
482
|
-
cleanDiskFiles(state.machineDir);
|
|
483
|
-
|
|
484
|
-
// Re-prepare disks if the machine had disk customizations
|
|
485
|
-
await ensureConfiguredDisks(
|
|
486
|
-
state.machineDir,
|
|
487
|
-
state.bootSize,
|
|
488
|
-
state.extraDisks,
|
|
489
|
-
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
490
|
-
);
|
|
491
|
-
|
|
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.
|
|
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 });
|
|
513
|
-
|
|
514
|
-
// Update state
|
|
515
|
-
const current = loadMachine(state.name);
|
|
516
|
-
if (current) {
|
|
517
|
-
current.status = "stopped";
|
|
518
|
-
current.pid = undefined;
|
|
519
|
-
current.user = undefined;
|
|
520
|
-
current.managedSshKey = undefined;
|
|
521
|
-
current.disableAdmin = undefined;
|
|
522
|
-
saveMachine(current);
|
|
523
|
-
}
|
|
524
|
-
state.status = "stopped";
|
|
525
|
-
state.pid = undefined;
|
|
526
|
-
state.user = undefined;
|
|
527
|
-
state.managedSshKey = undefined;
|
|
528
|
-
state.disableAdmin = undefined;
|
|
781
|
+
await withMachineLock(state, "clean", () => cleanUnderLock(state));
|
|
529
782
|
},
|
|
530
783
|
|
|
531
784
|
async monitor(command: string): Promise<string> {
|
|
@@ -541,6 +794,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
541
794
|
},
|
|
542
795
|
|
|
543
796
|
async rest(path: string, opts?: RequestInit): Promise<unknown> {
|
|
797
|
+
assertRestAvailable(state, "rest()");
|
|
544
798
|
const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
|
|
545
799
|
|
|
546
800
|
// Retry on ECONNRESET — RouterOS can transiently reset connections in the
|
|
@@ -630,6 +884,9 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
630
884
|
`exec transport "${via}" is not yet implemented`,
|
|
631
885
|
);
|
|
632
886
|
}
|
|
887
|
+
// Before the auto→console fallback below: that fallback exists for an
|
|
888
|
+
// unreachable REST endpoint, not for one that cannot exist.
|
|
889
|
+
assertRestAvailable(state, "exec");
|
|
633
890
|
const auth = resolveAuth(state, opts?.user, opts?.password);
|
|
634
891
|
if (via === "rest") {
|
|
635
892
|
return restExecute(restUrl, auth, command, opts);
|
|
@@ -677,15 +934,62 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
677
934
|
const log = logger ?? createLogger();
|
|
678
935
|
const resolved = resolveDeviceModeOptions(options);
|
|
679
936
|
for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
937
|
+
if (!shouldApplyDeviceMode(resolved)) {
|
|
938
|
+
throw new QuickCHRError(
|
|
939
|
+
"INVALID_ARGUMENT",
|
|
940
|
+
`Nothing to set: device-mode options for "${state.name}" resolved to no change ` +
|
|
941
|
+
"(mode=skip disables device-mode entirely; pass a mode or a feature to enable/disable).",
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
// The same machine lock every other relaunch takes. This one is a relaunch:
|
|
945
|
+
// it terminates QEMU, spawns a replacement and rewrites `machine.json`, so a
|
|
946
|
+
// concurrent `start` would otherwise spawn a second QEMU into the power-cycle
|
|
947
|
+
// window and both would persist over each other. Fail-fast like a second
|
|
948
|
+
// `start`: waiting would only power-cycle the machine twice.
|
|
949
|
+
const lock = acquireLockNow(machineLockPath(state.machineDir), "device-mode", machineLockOptions(state.name, state.machineDir));
|
|
950
|
+
try {
|
|
951
|
+
assertSameMachine(state);
|
|
952
|
+
// Re-read under the lock before anything reads `state`. This handle's
|
|
953
|
+
// snapshot is taken once, at `createInstance`, and a library consumer can
|
|
954
|
+
// hold it across another process's stop/start — after which `state.pid`
|
|
955
|
+
// names a process that has exited (a false MACHINE_STOPPED) or, worse, one
|
|
956
|
+
// the OS has since reused, which `hardRebootMachine()` would then
|
|
957
|
+
// terminate. `state.provisioning` and `state.deviceMode` go stale the same
|
|
958
|
+
// way, and both feed `appliedDeviceModeRecord()`.
|
|
959
|
+
refreshMachineState(state);
|
|
960
|
+
// Inside the lock: the applicability check has to hold for the operation,
|
|
961
|
+
// not merely at the moment it was made.
|
|
962
|
+
assertDeviceModeApplicable(state);
|
|
963
|
+
const launchConfig = await buildLaunchConfigFromState(state);
|
|
964
|
+
// Post-boot auth, not factory admin: a machine provisioned with
|
|
965
|
+
// `--disable-admin` answers 401 to `admin:`, and those are exactly the
|
|
966
|
+
// machines this route exists for.
|
|
967
|
+
await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log, resolveAuth(state).header);
|
|
968
|
+
|
|
969
|
+
// Persist what is now true of the guest: the applied selection folded into
|
|
970
|
+
// what was already *applied*, not the request on its own. Device-mode
|
|
971
|
+
// updates are cumulative — only the settings a request names move — so
|
|
972
|
+
// overwriting would leave `machine.json` describing a machine that does
|
|
973
|
+
// not exist.
|
|
974
|
+
//
|
|
975
|
+
// `appliedDeviceModeRecord` gates the carry-forward on the step record, for
|
|
976
|
+
// the reason #176 keeps running into: desired config is not evidence that
|
|
977
|
+
// a step ran.
|
|
978
|
+
const deviceMode = appliedDeviceModeRecord(state, resolved);
|
|
979
|
+
const current = loadMachine(state.name);
|
|
980
|
+
if (current) {
|
|
981
|
+
current.deviceMode = deviceMode;
|
|
982
|
+
saveMachine(current);
|
|
983
|
+
}
|
|
984
|
+
state.deviceMode = deviceMode;
|
|
985
|
+
// This is quickchr applying a provisioning step, so it belongs in the
|
|
986
|
+
// record the window reads (#176). It does not reopen the window — it adds
|
|
987
|
+
// the one step that just ran, so a later `start` passing the same
|
|
988
|
+
// device-mode is recognised as already applied instead of refused.
|
|
989
|
+
recordProvisioningStep(state, "deviceMode");
|
|
990
|
+
} finally {
|
|
991
|
+
lock.release();
|
|
687
992
|
}
|
|
688
|
-
state.deviceMode = options;
|
|
689
993
|
},
|
|
690
994
|
|
|
691
995
|
async availablePackages(): Promise<string[]> {
|
|
@@ -772,8 +1076,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
772
1076
|
},
|
|
773
1077
|
|
|
774
1078
|
async destroy(): Promise<void> {
|
|
775
|
-
await
|
|
776
|
-
|
|
1079
|
+
await withMachineLock(state, "remove", async () => {
|
|
1080
|
+
await stopUnderLock(state);
|
|
1081
|
+
await removeUnderLock(state);
|
|
1082
|
+
});
|
|
777
1083
|
},
|
|
778
1084
|
|
|
779
1085
|
async subprocessEnv(): Promise<Record<string, string>> {
|
|
@@ -913,44 +1219,6 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
913
1219
|
};
|
|
914
1220
|
}
|
|
915
1221
|
|
|
916
|
-
/**
|
|
917
|
-
* Atomically acquire an exclusive start lock for a machine directory.
|
|
918
|
-
* Uses O_CREAT|O_EXCL so concurrent attempts are rejected by the OS — no TOCTOU race.
|
|
919
|
-
* If the lock is stale (owning process is dead) it is silently replaced.
|
|
920
|
-
* Throws MACHINE_LOCKED if the lock is held by a live process.
|
|
921
|
-
*/
|
|
922
|
-
export function acquireLock(lockPath: string): void {
|
|
923
|
-
let fd: number;
|
|
924
|
-
try {
|
|
925
|
-
fd = openSync(lockPath, "wx"); // O_WRONLY | O_CREAT | O_EXCL — atomic
|
|
926
|
-
writeSync(fd, String(process.pid));
|
|
927
|
-
closeSync(fd);
|
|
928
|
-
return;
|
|
929
|
-
} catch {
|
|
930
|
-
// File already exists — check whether owner is still alive
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
let ownerPid: number | undefined;
|
|
934
|
-
try {
|
|
935
|
-
ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
|
|
936
|
-
} catch { /* unreadable — treat as stale */ }
|
|
937
|
-
|
|
938
|
-
if (ownerPid && !Number.isNaN(ownerPid)) {
|
|
939
|
-
try {
|
|
940
|
-
process.kill(ownerPid, 0); // probes liveness; throws ESRCH if process not found
|
|
941
|
-
throw new QuickCHRError("MACHINE_LOCKED", `Machine is already being started (pid ${ownerPid})`);
|
|
942
|
-
} catch (e) {
|
|
943
|
-
if (e instanceof QuickCHRError) throw e;
|
|
944
|
-
// Process is dead — stale lock, overwrite
|
|
945
|
-
writeFileSync(lockPath, String(process.pid));
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Owner unreadable or zero — treat as stale, overwrite
|
|
951
|
-
writeFileSync(lockPath, String(process.pid));
|
|
952
|
-
}
|
|
953
|
-
|
|
954
1222
|
/** Resolve the host architecture to a CHR architecture. */
|
|
955
1223
|
function hostArchToChr(): Arch {
|
|
956
1224
|
const arch = process.arch;
|
|
@@ -1130,28 +1398,122 @@ export async function captureRunningFailure(
|
|
|
1130
1398
|
});
|
|
1131
1399
|
}
|
|
1132
1400
|
|
|
1133
|
-
/**
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
*
|
|
1141
|
-
|
|
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(
|
|
1401
|
+
/** Cleanup intent is captured under the lifecycle lock, before launch mutates state. */
|
|
1402
|
+
export type BootFailureCleanup =
|
|
1403
|
+
| { kind: "remove-new" }
|
|
1404
|
+
| { kind: "reset-first-boot"; restore: MachineState }
|
|
1405
|
+
| { kind: "preserve-existing" };
|
|
1406
|
+
|
|
1407
|
+
/** Stop the failed guest, then apply the launch's cleanup intent.
|
|
1408
|
+
* Diagnostics have already been captured outside the machine directory. */
|
|
1409
|
+
export async function cleanupAfterBootFailure(
|
|
1145
1410
|
instance: ChrInstance,
|
|
1146
1411
|
name: string,
|
|
1147
1412
|
failure: BootFailureReport,
|
|
1413
|
+
cleanup: BootFailureCleanup,
|
|
1148
1414
|
): Promise<string> {
|
|
1149
|
-
|
|
1415
|
+
const quoted = shellQuote(name);
|
|
1416
|
+
// Called from inside a start or relaunch that holds the machine lock.
|
|
1417
|
+
try {
|
|
1418
|
+
await stopUnderLock(instance.state);
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1421
|
+
return ` Machine "${name}" was retained, but stopping QEMU failed (${reason}) — retry with \`quickchr stop ${quoted}\` before starting or removing it.`;
|
|
1422
|
+
}
|
|
1423
|
+
if (cleanup.kind === "preserve-existing") {
|
|
1424
|
+
return ` Machine "${name}" was stopped and preserved with its disks and configuration — ` +
|
|
1425
|
+
`retry with \`quickchr start ${quoted}\`, or explicitly remove it with \`quickchr remove ${quoted}\`.`;
|
|
1426
|
+
}
|
|
1427
|
+
const restore = cleanup.kind === "reset-first-boot" ? cleanup.restore : undefined;
|
|
1150
1428
|
if (failure.preserved) {
|
|
1151
|
-
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) —
|
|
1429
|
+
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — ` +
|
|
1430
|
+
(restore ? `reset it with \`quickchr clean ${quoted}\` and start it again, or ` : "") +
|
|
1431
|
+
`remove it with \`quickchr remove ${quoted}\`.`;
|
|
1432
|
+
}
|
|
1433
|
+
// An add()-created machine on its first boot: the same reset a failed provisioning
|
|
1434
|
+
// run gets (#187), so the retry is `start` again rather than `add` + `start`. The
|
|
1435
|
+
// guest may have written to its disk before stalling, so the disk goes back to the
|
|
1436
|
+
// factory image along with the record — the window is open again, and an open
|
|
1437
|
+
// window promises a factory disk.
|
|
1438
|
+
if (restore) {
|
|
1439
|
+
try {
|
|
1440
|
+
await resetGuestToFactory(restore);
|
|
1441
|
+
saveMachine({ ...restore, status: "stopped", pid: undefined });
|
|
1442
|
+
refreshMachineState(instance.state);
|
|
1443
|
+
return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — ` +
|
|
1444
|
+
`retry with \`quickchr start ${quoted}\`. Set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine as it is for diagnosis.`;
|
|
1445
|
+
} catch (resetErr) {
|
|
1446
|
+
const reason = resetErr instanceof Error ? resetErr.message : String(resetErr);
|
|
1447
|
+
return ` Resetting machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
try { await removeUnderLock(instance.state); } catch { /* ignore */ }
|
|
1451
|
+
return ` Machine "${name}" has been cleaned up automatically — set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine for diagnosis.`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Undo a first boot whose provisioning threw, so that retrying is re-running the same
|
|
1455
|
+
* command (#176).
|
|
1456
|
+
*
|
|
1457
|
+
* Provisioning is transactional — you get the machine you asked for or you don't —
|
|
1458
|
+
* and the throw used to break that: the guest was left running and half-provisioned,
|
|
1459
|
+
* with `lastStartedAt` set, so the window was closed and the retry a user reaches for
|
|
1460
|
+
* was refused. Resuming from the steps that landed is not the fix. A step can throw
|
|
1461
|
+
* half-applied (the login group creates a user and then disables `admin` in one call),
|
|
1462
|
+
* and quickchr cannot tell how far it got without reading the guest. So the rollback
|
|
1463
|
+
* reverts the whole run, and the retry starts again from a factory disk:
|
|
1464
|
+
*
|
|
1465
|
+
* - **`restore` given** — an `add()`-created machine on its first boot. The disk is
|
|
1466
|
+
* reset to the factory image and `machine.json` goes back to the record `add()`
|
|
1467
|
+
* wrote, window open and intent intact, so `quickchr start <name>` tries again.
|
|
1468
|
+
* - **no `restore`** — `start()` created the machine in this call, so it is removed,
|
|
1469
|
+
* exactly as a boot timeout removes it.
|
|
1470
|
+
*
|
|
1471
|
+
* `QUICKCHR_PRESERVE_ON_FAILURE=1` keeps the half-provisioned machine for inspection,
|
|
1472
|
+
* as it does for a boot failure. QEMU is stopped either way.
|
|
1473
|
+
*
|
|
1474
|
+
* Returns the sentence to append to the error. A rollback that fails is reported in
|
|
1475
|
+
* that sentence, never swallowed — the provisioning error stays the primary one. */
|
|
1476
|
+
export async function rollbackFailedProvisioning(
|
|
1477
|
+
instance: ChrInstance,
|
|
1478
|
+
restore: MachineState | undefined,
|
|
1479
|
+
): Promise<string> {
|
|
1480
|
+
const name = instance.state.name;
|
|
1481
|
+
const quoted = shellQuote(name);
|
|
1482
|
+
// Called from inside a start or relaunch that holds the machine lock.
|
|
1483
|
+
try {
|
|
1484
|
+
await stopUnderLock(instance.state);
|
|
1485
|
+
if (preserveOnFailure()) {
|
|
1486
|
+
return ` Machine "${name}" was preserved half-provisioned for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — reset it with \`quickchr clean ${quoted}\` and repeat the start command, or remove it with \`quickchr remove ${quoted}\`.`;
|
|
1487
|
+
}
|
|
1488
|
+
if (!restore) {
|
|
1489
|
+
await removeUnderLock(instance.state);
|
|
1490
|
+
return ` Machine "${name}" has been removed — re-run the same command to retry.`;
|
|
1491
|
+
}
|
|
1492
|
+
await resetGuestToFactory(restore);
|
|
1493
|
+
saveMachine({ ...restore, status: "stopped", pid: undefined });
|
|
1494
|
+
refreshMachineState(instance.state);
|
|
1495
|
+
return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — re-run the same start command to retry.`;
|
|
1496
|
+
} catch (rollbackErr) {
|
|
1497
|
+
const reason = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
1498
|
+
return ` Rolling back machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
|
|
1152
1499
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Append the rollback outcome to a provisioning error and hand it back for rethrow.
|
|
1503
|
+
* The error keeps its own type, code and stack: the rollback is context, not the
|
|
1504
|
+
* failure. */
|
|
1505
|
+
export async function failProvisioning(
|
|
1506
|
+
err: unknown,
|
|
1507
|
+
instance: ChrInstance,
|
|
1508
|
+
restore: MachineState | undefined,
|
|
1509
|
+
): Promise<unknown> {
|
|
1510
|
+
// Its own line: the provisioning error often ends in a RouterOS JSON body.
|
|
1511
|
+
const outcome = (await rollbackFailedProvisioning(instance, restore)).trim();
|
|
1512
|
+
if (err instanceof Error) {
|
|
1513
|
+
err.message += `\n${outcome}`;
|
|
1514
|
+
return err;
|
|
1515
|
+
}
|
|
1516
|
+
return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
|
|
1155
1517
|
}
|
|
1156
1518
|
|
|
1157
1519
|
/** Wait for CHR to boot with periodic progress status updates every 20s. */
|
|
@@ -1226,7 +1588,11 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1226
1588
|
);
|
|
1227
1589
|
const platform = await detectPlatform();
|
|
1228
1590
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1229
|
-
const resolvedNetworks = resolveAllNetworks(
|
|
1591
|
+
const resolvedNetworks = resolveAllNetworks(
|
|
1592
|
+
state.networks,
|
|
1593
|
+
{ platform, machine: state.name, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
1594
|
+
hostfwd,
|
|
1595
|
+
);
|
|
1230
1596
|
return {
|
|
1231
1597
|
arch: state.arch,
|
|
1232
1598
|
machineDir: state.machineDir,
|
|
@@ -1241,26 +1607,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1241
1607
|
};
|
|
1242
1608
|
}
|
|
1243
1609
|
|
|
1610
|
+
/** Persist what provisioning landed on the current disk, in state and on disk.
|
|
1611
|
+
* A run with nothing to do still records — an empty `steps` says "provisioning ran
|
|
1612
|
+
* and had nothing to apply", which is a different fact from never having run. */
|
|
1613
|
+
function recordProvisioning(state: MachineState, steps: ProvisioningStep[]): void {
|
|
1614
|
+
const record = { at: new Date().toISOString(), steps };
|
|
1615
|
+
const current = loadMachine(state.name);
|
|
1616
|
+
if (current) {
|
|
1617
|
+
current.provisioning = record;
|
|
1618
|
+
saveMachine(current);
|
|
1619
|
+
}
|
|
1620
|
+
state.provisioning = record;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** Re-read `machine.json` into an existing state object, in place.
|
|
1624
|
+
*
|
|
1625
|
+
* **In place** because `createInstance` closes over this object and exposes it as
|
|
1626
|
+
* `instance.state`: rebinding a local would leave the caller's view stale, and every
|
|
1627
|
+
* other method on the handle would keep reading the old snapshot.
|
|
1628
|
+
*
|
|
1629
|
+
* Keys the reloaded record does not have are deleted rather than left behind.
|
|
1630
|
+
* `clean()` removes `provisioning`, `user` and `disableAdmin` precisely because they
|
|
1631
|
+
* stopped being true, so a leftover would be read as fact by the next caller —
|
|
1632
|
+
* `appliedDeviceModeRecord()` reads exactly one of them. A plain `Object.assign`
|
|
1633
|
+
* cannot express a field that went away. */
|
|
1634
|
+
export function refreshMachineState(state: MachineState): void {
|
|
1635
|
+
const fresh = loadMachine(state.name);
|
|
1636
|
+
if (!fresh) return;
|
|
1637
|
+
const mutable = state as unknown as Record<string, unknown>;
|
|
1638
|
+
for (const key of Object.keys(mutable)) {
|
|
1639
|
+
if (!(key in fresh)) delete mutable[key];
|
|
1640
|
+
}
|
|
1641
|
+
Object.assign(state, fresh);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Add one step to the provisioning record, keeping whatever is already there.
|
|
1645
|
+
*
|
|
1646
|
+
* `recordProvisioning()` stamps a whole first-boot run; this is for a step applied on
|
|
1647
|
+
* its own afterwards — `setDeviceMode()` today, `installPackage()` when #24 lands.
|
|
1648
|
+
* `steps` accumulates and `at` moves to now, which is what `ProvisioningRecord`
|
|
1649
|
+
* promises: the last time quickchr applied provisioning to this disk.
|
|
1650
|
+
*
|
|
1651
|
+
* It cannot reopen or close a window by accident. The only caller requires a running
|
|
1652
|
+
* machine, and a running machine has `lastStartedAt`, so the window was already shut
|
|
1653
|
+
* before this wrote anything. */
|
|
1654
|
+
function recordProvisioningStep(state: MachineState, step: ProvisioningStep): void {
|
|
1655
|
+
const steps = [...new Set([...(state.provisioning?.steps ?? []), step])];
|
|
1656
|
+
const record = { at: new Date().toISOString(), steps };
|
|
1657
|
+
const current = loadMachine(state.name);
|
|
1658
|
+
if (current) {
|
|
1659
|
+
current.provisioning = record;
|
|
1660
|
+
saveMachine(current);
|
|
1661
|
+
}
|
|
1662
|
+
state.provisioning = record;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** Preconditions a post-boot device-mode change inherits from provisioning.
|
|
1666
|
+
*
|
|
1667
|
+
* Both were undocumented and both fail confusingly when unchecked (#176):
|
|
1668
|
+
* `applyDeviceMode()` polls `waitForDeviceModeApi` before it does anything, so a
|
|
1669
|
+
* stopped machine spends 60 s to report a timeout, and a machine with no user-mode
|
|
1670
|
+
* NIC has no localhost route to REST at all and never recovers.
|
|
1671
|
+
*
|
|
1672
|
+
* This is the same guard `start()` applies to provisioning, moved to the one step
|
|
1673
|
+
* that may also run afterwards. */
|
|
1674
|
+
function assertDeviceModeApplicable(state: MachineState): void {
|
|
1675
|
+
if (!isMachineRunning(state)) {
|
|
1676
|
+
throw new QuickCHRError(
|
|
1677
|
+
"MACHINE_STOPPED",
|
|
1678
|
+
`Device-mode is applied through the guest's REST API, so "${state.name}" has to be running. ` +
|
|
1679
|
+
`Start it with 'quickchr start ${shellQuote(state.name)}', then set device-mode.`,
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
if (!hasUserModeNetwork(state.networks)) {
|
|
1683
|
+
throw new QuickCHRError(
|
|
1684
|
+
"NETWORK_UNAVAILABLE",
|
|
1685
|
+
`Device-mode is applied over localhost REST, which needs a user-mode network interface — ` +
|
|
1686
|
+
`"${state.name}" has none, so quickchr cannot reach its REST API. ` +
|
|
1687
|
+
"Recreate the machine with a user-mode NIC alongside its other networks.",
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1244
1692
|
/** Apply a device-mode change to a running CHR instance (may require hard power-cycle).
|
|
1245
|
-
* Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
|
|
1693
|
+
* Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
|
|
1694
|
+
*
|
|
1695
|
+
* `authHeader` is the caller's, because the two callers genuinely differ and the
|
|
1696
|
+
* difference is not cosmetic. During first-boot provisioning device-mode is step 2 and
|
|
1697
|
+
* the user step is step 4, so factory `admin:` is the *only* credential that exists
|
|
1698
|
+
* yet — `state.user` is already populated at that point but names an account nobody
|
|
1699
|
+
* has created, so resolving it here would 401 the shipped path. Post-boot the reverse
|
|
1700
|
+
* holds: a machine provisioned with `--disable-admin` answers 401 to factory admin,
|
|
1701
|
+
* which is what made `quickchr set <name> --device-mode` fail on exactly the machines
|
|
1702
|
+
* the route was written for. Same field, opposite answers, decided by the caller
|
|
1703
|
+
* rather than guessed here. */
|
|
1246
1704
|
async function applyDeviceMode(
|
|
1247
1705
|
instance: ChrInstance,
|
|
1248
1706
|
machineState: MachineState,
|
|
1249
1707
|
resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
|
|
1250
1708
|
launchConfig: QemuLaunchConfig,
|
|
1251
1709
|
log: ProgressLogger,
|
|
1710
|
+
authHeader: string,
|
|
1252
1711
|
): Promise<void> {
|
|
1253
1712
|
const httpPort = toChrPorts(machineState.ports).http;
|
|
1254
1713
|
const accel = await detectAccel(machineState.arch);
|
|
1255
1714
|
const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
|
|
1256
|
-
await waitForDeviceModeApi(httpPort, 60_000);
|
|
1715
|
+
await waitForDeviceModeApi(httpPort, 60_000, authHeader);
|
|
1257
1716
|
log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
|
|
1258
1717
|
|
|
1259
1718
|
let alreadyActive = false;
|
|
1260
1719
|
try {
|
|
1261
|
-
const beforeMode = await readDeviceMode(httpPort);
|
|
1720
|
+
const beforeMode = await readDeviceMode(httpPort, authHeader);
|
|
1262
1721
|
log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
|
|
1263
1722
|
alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
|
|
1723
|
+
// Say what the power cycle is buying before spending it. `mode` moves as a
|
|
1724
|
+
// side-effect of enabling a feature — `--device-mode-enable container` resolves
|
|
1725
|
+
// `auto` to `rose` — and a change nobody asked for should at least be announced
|
|
1726
|
+
// (#176).
|
|
1727
|
+
for (const change of describeDeviceModeChange(resolvedDeviceMode, beforeMode)) {
|
|
1728
|
+
log.status(` ${change}`);
|
|
1729
|
+
}
|
|
1264
1730
|
} catch { /* CHR unexpectedly unreachable — proceed with update */ }
|
|
1265
1731
|
|
|
1266
1732
|
if (alreadyActive) {
|
|
@@ -1273,7 +1739,7 @@ async function applyDeviceMode(
|
|
|
1273
1739
|
let requiresPowerCycle = false;
|
|
1274
1740
|
|
|
1275
1741
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1276
|
-
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
|
|
1742
|
+
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
|
|
1277
1743
|
// RouterOS blocks this connection while waiting for hard power-cycle confirmation.
|
|
1278
1744
|
// We race against 2s: if still pending at 2s, RouterOS has entered blocking state
|
|
1279
1745
|
// and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
|
|
@@ -1303,6 +1769,22 @@ async function applyDeviceMode(
|
|
|
1303
1769
|
}
|
|
1304
1770
|
|
|
1305
1771
|
log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
|
|
1772
|
+
|
|
1773
|
+
// A rejection is an answer, not a race — say what RouterOS said and stop.
|
|
1774
|
+
// Retrying here used to cost five POSTs and ~33 s and then report a *mismatch*
|
|
1775
|
+
// (`bogus-feature: expected=yes, actual=(missing)`) while RouterOS had replied
|
|
1776
|
+
// `HTTP 400 — unknown parameter bogus-feature` on the first attempt. RouterOS
|
|
1777
|
+
// also counts device-mode update attempts (`attempt-count` on
|
|
1778
|
+
// `/system/device-mode`), so the retries spend a budget that is not ours to
|
|
1779
|
+
// spend. Only a 2xx that came back before activation is worth trying again.
|
|
1780
|
+
const { status, body } = outcome.response;
|
|
1781
|
+
if (status < 200 || status >= 300) {
|
|
1782
|
+
throw new QuickCHRError(
|
|
1783
|
+
"PROCESS_FAILED",
|
|
1784
|
+
`Device-mode update rejected by RouterOS: HTTP ${status} — ${body.trim() || "(empty body)"}`,
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1306
1788
|
await Bun.sleep(5000);
|
|
1307
1789
|
|
|
1308
1790
|
let routerOsOffline = false;
|
|
@@ -1319,10 +1801,10 @@ async function applyDeviceMode(
|
|
|
1319
1801
|
if (!rebooted) {
|
|
1320
1802
|
throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
|
|
1321
1803
|
}
|
|
1322
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1804
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1323
1805
|
}
|
|
1324
1806
|
|
|
1325
|
-
const actualNow = await readDeviceMode(httpPort);
|
|
1807
|
+
const actualNow = await readDeviceMode(httpPort, authHeader);
|
|
1326
1808
|
log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
|
|
1327
1809
|
const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
|
|
1328
1810
|
if (immediateVerification.ok) {
|
|
@@ -1352,10 +1834,10 @@ async function applyDeviceMode(
|
|
|
1352
1834
|
`Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
|
|
1353
1835
|
);
|
|
1354
1836
|
}
|
|
1355
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1837
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1356
1838
|
}
|
|
1357
1839
|
|
|
1358
|
-
const actual = await readDeviceMode(httpPort);
|
|
1840
|
+
const actual = await readDeviceMode(httpPort, authHeader);
|
|
1359
1841
|
log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
|
|
1360
1842
|
const verification = verifyDeviceMode(resolvedDeviceMode, actual);
|
|
1361
1843
|
if (!verification.ok) {
|
|
@@ -1367,6 +1849,112 @@ async function applyDeviceMode(
|
|
|
1367
1849
|
log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
|
|
1368
1850
|
}
|
|
1369
1851
|
|
|
1852
|
+
/** How long a creation waits for another to finish choosing its port block. The
|
|
1853
|
+
* critical section is a handful of loopback connect probes, so this is headroom for a
|
|
1854
|
+
* busy host, not an expected wait. */
|
|
1855
|
+
const ALLOCATION_LOCK_WAIT_MS = 60_000;
|
|
1856
|
+
|
|
1857
|
+
/** An explicit `portBase` is the caller's choice and is never moved: one that overlaps
|
|
1858
|
+
* another machine's block — persisted or still being created — is a `PORT_CONFLICT`
|
|
1859
|
+
* here rather than a `SPAWN_FAILED` from QEMU's bind later. */
|
|
1860
|
+
function assertPortBaseFree(
|
|
1861
|
+
portBase: number,
|
|
1862
|
+
name: string,
|
|
1863
|
+
machines: MachineState[],
|
|
1864
|
+
reserved: Array<{ name: string; portBase: number }>,
|
|
1865
|
+
): void {
|
|
1866
|
+
const others = [
|
|
1867
|
+
...machines.filter((m) => m.name !== name).map((m) => ({ name: m.name, portBase: m.portBase })),
|
|
1868
|
+
...reserved,
|
|
1869
|
+
];
|
|
1870
|
+
const clash = others.find((o) => Math.abs(o.portBase - portBase) < PORTS_PER_BLOCK);
|
|
1871
|
+
if (clash) {
|
|
1872
|
+
throw new QuickCHRError(
|
|
1873
|
+
"PORT_CONFLICT",
|
|
1874
|
+
`portBase ${portBase} overlaps machine "${clash.name}" (portBase ${clash.portBase}) — each machine takes a block of ${PORTS_PER_BLOCK} ports. Pick a base at least ${PORTS_PER_BLOCK} away, or omit portBase to have one allocated.`,
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** Choose a new machine's port block and NIC MACs, avoiding every persisted machine
|
|
1880
|
+
* and every creation still in flight. Choosing is not claiming: only
|
|
1881
|
+
* `claimNewMachine()` makes the result safe from a concurrent creation. */
|
|
1882
|
+
async function pickNewMachineResources(
|
|
1883
|
+
name: string,
|
|
1884
|
+
opts: StartOptions,
|
|
1885
|
+
): Promise<{ portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
|
|
1886
|
+
const machines = loadAllMachines();
|
|
1887
|
+
const reserved = listReservations(name);
|
|
1888
|
+
if (opts.portBase) assertPortBaseFree(opts.portBase, name, machines, reserved);
|
|
1889
|
+
const usedBases = [...getUsedPortBases(), ...reserved.map((r) => r.portBase)];
|
|
1890
|
+
const portBase = opts.portBase || await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
|
|
1891
|
+
validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, machines, name);
|
|
1892
|
+
const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
|
|
1893
|
+
|
|
1894
|
+
const usedMacs = getUsedMacs();
|
|
1895
|
+
for (const r of reserved) for (const mac of r.macs) usedMacs.add(mac);
|
|
1896
|
+
const networks = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), usedMacs);
|
|
1897
|
+
return { portBase, ports, networks };
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/** Choose a new machine's resources and take its machine lock with them recorded in
|
|
1901
|
+
* it, all while holding the data-dir allocation lock (#140).
|
|
1902
|
+
*
|
|
1903
|
+
* Without this, two concurrent creations both read the same machine list, both probe
|
|
1904
|
+
* the same free ports, and both pick the same block; one QEMU then fails its
|
|
1905
|
+
* `hostfwd` bind. Nothing persists the choice until `saveMachine()`, which for
|
|
1906
|
+
* `start()` is after the download and the spawn, so the choice itself has to be
|
|
1907
|
+
* visible: the machine lock carries it as a reservation, `listReservations()` reads
|
|
1908
|
+
* it, and it ends when the lock is released — by which time `machine.json` holds it,
|
|
1909
|
+
* or the creation failed and there is nothing left to hold. A crashed creator's
|
|
1910
|
+
* reservation dies with its pid.
|
|
1911
|
+
*
|
|
1912
|
+
* The machine lock is fail-fast. A live holder is another create of the same name. */
|
|
1913
|
+
export async function claimNewMachine(
|
|
1914
|
+
name: string,
|
|
1915
|
+
op: Extract<LockOperation, "add" | "start" | "foreground">,
|
|
1916
|
+
opts: StartOptions,
|
|
1917
|
+
): Promise<{ lock: HeldLock; portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
|
|
1918
|
+
ensureDir(getDataDir());
|
|
1919
|
+
const allocation = await acquireLock(allocationLockPath(), "allocate", {
|
|
1920
|
+
waitMs: ALLOCATION_LOCK_WAIT_MS,
|
|
1921
|
+
subject: "The port allocator",
|
|
1922
|
+
});
|
|
1923
|
+
try {
|
|
1924
|
+
const picked = await pickNewMachineResources(name, opts);
|
|
1925
|
+
const machineDir = getMachineDir(name);
|
|
1926
|
+
const lockOpts = {
|
|
1927
|
+
...machineLockOptions(name, machineDir),
|
|
1928
|
+
reserve: {
|
|
1929
|
+
portBase: picked.portBase,
|
|
1930
|
+
macs: picked.networks.flatMap((n) => (n.mac ? [n.mac] : [])),
|
|
1931
|
+
},
|
|
1932
|
+
};
|
|
1933
|
+
// The lock lives in the directory, so the directory comes first. A refusal here
|
|
1934
|
+
// means a live holder owns the directory — a create of the same name, or a
|
|
1935
|
+
// remove — and it, not this call, cleans it up. A remove renames the directory
|
|
1936
|
+
// away in one step; if that lands between the two lines, make a fresh one.
|
|
1937
|
+
let lock: HeldLock | undefined;
|
|
1938
|
+
for (let tries = 0; !lock; tries++) {
|
|
1939
|
+
ensureDir(machineDir);
|
|
1940
|
+
try {
|
|
1941
|
+
lock = acquireLockNow(machineLockPath(machineDir), op, lockOpts);
|
|
1942
|
+
} catch (e) {
|
|
1943
|
+
if (!(e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") || tries >= 2) throw e;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
// Another create of this name may have finished between the caller's existence
|
|
1947
|
+
// check and this lock.
|
|
1948
|
+
if (existsSync(join(machineDir, "machine.json"))) {
|
|
1949
|
+
lock.release();
|
|
1950
|
+
throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" was created by another process while this one was starting.`);
|
|
1951
|
+
}
|
|
1952
|
+
return { lock, ...picked };
|
|
1953
|
+
} finally {
|
|
1954
|
+
allocation.release();
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1370
1958
|
/**
|
|
1371
1959
|
* Main entry point for managing MikroTik CHR virtual machines via QEMU.
|
|
1372
1960
|
*
|
|
@@ -1389,10 +1977,10 @@ export class QuickCHR {
|
|
|
1389
1977
|
* `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
|
|
1390
1978
|
static async add(opts: StartOptions = {}): Promise<MachineState> {
|
|
1391
1979
|
opts = normalizeStartOptions(opts);
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
}
|
|
1980
|
+
// add() always creates, so the full name rules apply before any I/O.
|
|
1981
|
+
if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
|
|
1395
1982
|
|
|
1983
|
+
warnVersionWithChannel(opts, createLogger(opts.onProgress));
|
|
1396
1984
|
let version: string;
|
|
1397
1985
|
if (opts.version) {
|
|
1398
1986
|
if (!isValidVersion(opts.version)) {
|
|
@@ -1405,13 +1993,10 @@ export class QuickCHR {
|
|
|
1405
1993
|
}
|
|
1406
1994
|
|
|
1407
1995
|
const arch: Arch = resolveArch(opts.arch);
|
|
1408
|
-
|
|
1409
|
-
if (arch === "arm64") requireFirmware();
|
|
1410
|
-
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1996
|
+
assertRouterOs6Arch(version, arch);
|
|
1411
1997
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
1412
1998
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1413
|
-
|
|
1414
|
-
if (hasProvisioningMutations({
|
|
1999
|
+
const mutations = {
|
|
1415
2000
|
installAllPackages: opts.installAllPackages,
|
|
1416
2001
|
packages: opts.packages,
|
|
1417
2002
|
hasDeviceModeProvisioning,
|
|
@@ -1419,34 +2004,33 @@ export class QuickCHR {
|
|
|
1419
2004
|
disableAdmin: opts.disableAdmin,
|
|
1420
2005
|
license: opts.license,
|
|
1421
2006
|
secureLogin: opts.secureLogin,
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
user: opts.user,
|
|
1428
|
-
disableAdmin: opts.disableAdmin,
|
|
1429
|
-
license: opts.license,
|
|
1430
|
-
secureLogin: opts.secureLogin,
|
|
1431
|
-
}));
|
|
2007
|
+
};
|
|
2008
|
+
// Version refusals before host checks: a request that can never succeed should
|
|
2009
|
+
// say why, not first ask for QEMU to be installed.
|
|
2010
|
+
if (hasProvisioningMutations(mutations)) {
|
|
2011
|
+
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
1432
2012
|
}
|
|
2013
|
+
requireQemu(arch);
|
|
2014
|
+
if (arch === "arm64") requireFirmware();
|
|
2015
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1433
2016
|
|
|
1434
2017
|
const existingNames = listMachineNames();
|
|
1435
2018
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
1436
2019
|
if (existingNames.includes(name)) {
|
|
1437
|
-
|
|
2020
|
+
// A directory with no readable machine.json is a half-created machine, not a
|
|
2021
|
+
// machine — `list` cannot show it and `start` cannot boot it, so saying
|
|
2022
|
+
// "already exists" sends the user looking for something that is not there (#155).
|
|
2023
|
+
throw isOrphanMachineDir(name)
|
|
2024
|
+
? new QuickCHRError(
|
|
2025
|
+
"MACHINE_EXISTS",
|
|
2026
|
+
`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.`,
|
|
2027
|
+
)
|
|
2028
|
+
: new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
|
|
1438
2029
|
}
|
|
1439
2030
|
|
|
1440
|
-
const usedBases = getUsedPortBases();
|
|
1441
|
-
const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
|
|
1442
|
-
validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
|
|
1443
|
-
const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
|
|
1444
|
-
|
|
1445
2031
|
const machineDir = getMachineDir(name);
|
|
1446
2032
|
assertSufficientQuickchrStorage(`prepare CHR ${name}`);
|
|
1447
|
-
|
|
1448
|
-
const lockPath = join(machineDir, ".start-lock");
|
|
1449
|
-
acquireLock(lockPath);
|
|
2033
|
+
const { lock, portBase, ports, networks } = await claimNewMachine(name, "add", opts);
|
|
1450
2034
|
try {
|
|
1451
2035
|
const cachedImg = await ensureCachedImage(version, arch);
|
|
1452
2036
|
copyImageToMachine(cachedImg, machineDir);
|
|
@@ -1463,7 +2047,7 @@ export class QuickCHR {
|
|
|
1463
2047
|
arch,
|
|
1464
2048
|
cpu: opts.cpu ?? 1,
|
|
1465
2049
|
mem: defaultMem(arch, opts.mem),
|
|
1466
|
-
networks
|
|
2050
|
+
networks,
|
|
1467
2051
|
ports,
|
|
1468
2052
|
packages: opts.packages ?? [],
|
|
1469
2053
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1483,8 +2067,22 @@ export class QuickCHR {
|
|
|
1483
2067
|
};
|
|
1484
2068
|
saveMachine(state);
|
|
1485
2069
|
return state;
|
|
2070
|
+
} catch (err) {
|
|
2071
|
+
// A failed create must leave nothing behind: the directory exists from this
|
|
2072
|
+
// call's ensureDir(), and without readable state it is invisible to `list`
|
|
2073
|
+
// and unremovable by `remove` while still blocking re-add (#155).
|
|
2074
|
+
//
|
|
2075
|
+
// The test is "no *readable* machine.json", not "no machine.json" — a
|
|
2076
|
+
// saveMachine() that died mid-write (disk full) leaves a truncated file, and
|
|
2077
|
+
// a file-exists check would take that for a finished machine and strand the
|
|
2078
|
+
// directory it was supposed to clean up. Nothing runs after saveMachine()
|
|
2079
|
+
// but `return state`, so a readable file here means the create succeeded.
|
|
2080
|
+
if (isOrphanMachineDir(name)) {
|
|
2081
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
2082
|
+
}
|
|
2083
|
+
throw err;
|
|
1486
2084
|
} finally {
|
|
1487
|
-
|
|
2085
|
+
lock.release();
|
|
1488
2086
|
}
|
|
1489
2087
|
}
|
|
1490
2088
|
|
|
@@ -1492,20 +2090,36 @@ export class QuickCHR {
|
|
|
1492
2090
|
* Start a new or existing CHR instance.
|
|
1493
2091
|
*
|
|
1494
2092
|
* The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
|
|
1495
|
-
* device-mode, user) has completed before this promise resolves.
|
|
2093
|
+
* device-mode, user) has completed before this promise resolves. Two exceptions:
|
|
2094
|
+
* `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
|
|
2095
|
+
* a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
|
|
1496
2096
|
* Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
|
|
1497
2097
|
* stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
|
|
1498
2098
|
* apply when creating a new machine and require `qemu-img` on the host.
|
|
1499
2099
|
*/
|
|
1500
2100
|
static async start(opts: StartOptions = {}): Promise<ChrInstance> {
|
|
1501
2101
|
opts = normalizeStartOptions(opts);
|
|
1502
|
-
//
|
|
2102
|
+
// Cheap guard before any I/O: a name that reads as a flag is always a mistake.
|
|
2103
|
+
// The full charset rules are applied below, but only when this call creates the
|
|
2104
|
+
// machine — a machine named under the older, looser rules must still start.
|
|
1503
2105
|
if (opts.name?.startsWith("-")) {
|
|
1504
|
-
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
|
|
2106
|
+
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
if (opts.wait === false && opts.background === false) {
|
|
2110
|
+
throw new QuickCHRError(
|
|
2111
|
+
"INVALID_ARGUMENT",
|
|
2112
|
+
"--no-wait (wait: false) cannot be combined with --fg (background: false): a foreground start holds the serial console until QEMU exits, so there is nothing to return early from.",
|
|
2113
|
+
);
|
|
1505
2114
|
}
|
|
1506
2115
|
|
|
1507
2116
|
const logger = createLogger(opts.onProgress);
|
|
1508
2117
|
|
|
2118
|
+
// Before any download or disk work: a named socket whose two ends are already
|
|
2119
|
+
// taken cannot carry this machine, and #156's rule is that a start that cannot
|
|
2120
|
+
// succeed should not first fetch 43 MB.
|
|
2121
|
+
assertNamedSocketsHaveRoom(opts, logger);
|
|
2122
|
+
|
|
1509
2123
|
const requestedDeviceMode = opts.deviceMode;
|
|
1510
2124
|
const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
|
|
1511
2125
|
for (const warning of resolvedDeviceMode.warnings) {
|
|
@@ -1514,6 +2128,7 @@ export class QuickCHR {
|
|
|
1514
2128
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1515
2129
|
|
|
1516
2130
|
// Resolve version
|
|
2131
|
+
warnVersionWithChannel(opts, logger);
|
|
1517
2132
|
let version: string;
|
|
1518
2133
|
if (opts.version) {
|
|
1519
2134
|
if ((CHANNELS as readonly string[]).includes(opts.version)) {
|
|
@@ -1531,7 +2146,7 @@ export class QuickCHR {
|
|
|
1531
2146
|
version = await resolveVersion(channel);
|
|
1532
2147
|
}
|
|
1533
2148
|
|
|
1534
|
-
|
|
2149
|
+
const mutations = {
|
|
1535
2150
|
installAllPackages: opts.installAllPackages,
|
|
1536
2151
|
packages: opts.packages,
|
|
1537
2152
|
hasDeviceModeProvisioning,
|
|
@@ -1539,51 +2154,69 @@ export class QuickCHR {
|
|
|
1539
2154
|
disableAdmin: opts.disableAdmin,
|
|
1540
2155
|
license: opts.license,
|
|
1541
2156
|
secureLogin: opts.secureLogin,
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
hasDeviceModeProvisioning,
|
|
1547
|
-
user: opts.user,
|
|
1548
|
-
disableAdmin: opts.disableAdmin,
|
|
1549
|
-
license: opts.license,
|
|
1550
|
-
secureLogin: opts.secureLogin,
|
|
1551
|
-
}));
|
|
2157
|
+
};
|
|
2158
|
+
if (hasProvisioningMutations(mutations)) {
|
|
2159
|
+
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
2160
|
+
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
|
|
1552
2161
|
}
|
|
1553
2162
|
|
|
1554
2163
|
// Resolve architecture
|
|
1555
2164
|
const arch: Arch = resolveArch(opts.arch);
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
requireFirmware();
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
2165
|
+
// Host prerequisites are checked just before each launch below, after every
|
|
2166
|
+
// refusal: a request that cannot succeed says why before asking for QEMU.
|
|
2167
|
+
const requireHostFor = (target: Arch) => {
|
|
2168
|
+
requireQemu(target);
|
|
2169
|
+
if (target === "arm64") requireFirmware();
|
|
2170
|
+
};
|
|
1565
2171
|
|
|
1566
2172
|
// Resolve name
|
|
1567
2173
|
const existingNames = listMachineNames();
|
|
1568
2174
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
2175
|
+
// Only a *new* name has to satisfy the current rules — see the guard at the top.
|
|
2176
|
+
if (!existingNames.includes(name)) assertValidResourceName(name, "machine");
|
|
1569
2177
|
|
|
1570
2178
|
// Check if machine already exists
|
|
1571
2179
|
const existing = loadMachine(name);
|
|
1572
2180
|
if (existing) {
|
|
2181
|
+
// Both paths below re-launch a guest quickchr did not just create, so neither
|
|
2182
|
+
// can run provisioning. Refuse before either of them: dropping the options in
|
|
2183
|
+
// silence is what #176 removes, and the running-machine path is the quieter of
|
|
2184
|
+
// the two — it returns a handle without so much as a boot.
|
|
2185
|
+
const request = {
|
|
2186
|
+
installAllPackages: opts.installAllPackages,
|
|
2187
|
+
packages: opts.packages,
|
|
2188
|
+
deviceMode: opts.deviceMode,
|
|
2189
|
+
user: opts.user,
|
|
2190
|
+
disableAdmin: opts.disableAdmin,
|
|
2191
|
+
license: opts.license,
|
|
2192
|
+
secureLogin: opts.secureLogin,
|
|
2193
|
+
};
|
|
2194
|
+
// Version before window: on a version that can never be provisioned, the
|
|
2195
|
+
// window refusal's `quickchr clean` route leads to a second refusal (#182).
|
|
2196
|
+
if (hasProvisioningRequest(request)) {
|
|
2197
|
+
assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation({
|
|
2198
|
+
...request,
|
|
2199
|
+
hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)),
|
|
2200
|
+
}));
|
|
2201
|
+
}
|
|
2202
|
+
const satisfied = assertProvisioningWindow(existing, request);
|
|
2203
|
+
for (const ask of satisfied) {
|
|
2204
|
+
(logger ?? createLogger()).status(`${ask.description} is already applied — nothing to do.`);
|
|
2205
|
+
}
|
|
1573
2206
|
if (isMachineRunning(existing)) {
|
|
1574
2207
|
return createInstance(existing);
|
|
1575
2208
|
}
|
|
1576
2209
|
// First boot of add()-created machine: apply pending provisioning from stored state
|
|
1577
|
-
if (
|
|
1578
|
-
const pendingOpts = {
|
|
1579
|
-
installAllPackages: opts.installAllPackages
|
|
1580
|
-
packages: opts.packages
|
|
1581
|
-
deviceMode: opts.deviceMode
|
|
1582
|
-
user: opts.user
|
|
1583
|
-
disableAdmin: opts.disableAdmin
|
|
2210
|
+
if (isProvisioningWindowOpen(existing)) {
|
|
2211
|
+
const pendingOpts = pendingProvisioningFor(existing, {
|
|
2212
|
+
installAllPackages: opts.installAllPackages,
|
|
2213
|
+
packages: opts.packages,
|
|
2214
|
+
deviceMode: opts.deviceMode,
|
|
2215
|
+
user: opts.user,
|
|
2216
|
+
disableAdmin: opts.disableAdmin,
|
|
1584
2217
|
license: opts.license,
|
|
1585
|
-
secureLogin: opts.secureLogin
|
|
1586
|
-
};
|
|
2218
|
+
secureLogin: opts.secureLogin,
|
|
2219
|
+
});
|
|
1587
2220
|
const hasPending = !!(
|
|
1588
2221
|
pendingOpts.installAllPackages ||
|
|
1589
2222
|
(pendingOpts.packages?.length ?? 0) > 0 ||
|
|
@@ -1594,49 +2227,43 @@ export class QuickCHR {
|
|
|
1594
2227
|
pendingOpts.secureLogin === true
|
|
1595
2228
|
);
|
|
1596
2229
|
if (hasPending) {
|
|
2230
|
+
// Before the boot, not after it: `_provisionInstance()` re-checks the
|
|
2231
|
+
// version, but only once the guest is up — on RouterOS 6 that is a
|
|
2232
|
+
// download-free boot spent on a refusal known in advance.
|
|
2233
|
+
const pendingMutations = {
|
|
2234
|
+
...pendingOpts,
|
|
2235
|
+
hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(pendingOpts.deviceMode)),
|
|
2236
|
+
};
|
|
2237
|
+
assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
|
|
2238
|
+
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
|
|
2239
|
+
requireHostFor(existing.arch);
|
|
1597
2240
|
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
1598
2241
|
}
|
|
1599
2242
|
}
|
|
1600
2243
|
// Exists but stopped — restart it
|
|
1601
|
-
|
|
2244
|
+
requireHostFor(existing.arch);
|
|
2245
|
+
return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
|
|
1602
2246
|
}
|
|
1603
2247
|
|
|
1604
|
-
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
? opts.portBase
|
|
1608
|
-
: await findAvailablePortBlock(
|
|
1609
|
-
usedBases,
|
|
1610
|
-
opts.excludePorts,
|
|
1611
|
-
opts.extraPorts,
|
|
1612
|
-
);
|
|
1613
|
-
|
|
1614
|
-
validateExplicitExtraPorts(
|
|
1615
|
-
opts.extraPorts,
|
|
1616
|
-
portBase,
|
|
1617
|
-
opts.excludePorts,
|
|
1618
|
-
loadAllMachines(),
|
|
1619
|
-
name,
|
|
1620
|
-
);
|
|
1621
|
-
|
|
1622
|
-
const ports = buildPortMappings(
|
|
1623
|
-
portBase,
|
|
1624
|
-
opts.excludePorts,
|
|
1625
|
-
opts.extraPorts,
|
|
1626
|
-
);
|
|
2248
|
+
assertRouterOs6Arch(version, arch);
|
|
2249
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
2250
|
+
if (!opts.dryRun) requireHostFor(arch);
|
|
1627
2251
|
|
|
1628
|
-
// Dry run — return instance handle without actually spawning
|
|
2252
|
+
// Dry run — return instance handle without actually spawning. Nothing is
|
|
2253
|
+
// claimed: a dry run persists nothing for another create to collide with.
|
|
1629
2254
|
if (opts.dryRun) {
|
|
1630
2255
|
const machineDir = getMachineDir(name);
|
|
2256
|
+
const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
|
|
1631
2257
|
const state: MachineState = {
|
|
1632
2258
|
name,
|
|
1633
2259
|
version,
|
|
1634
2260
|
arch,
|
|
1635
2261
|
cpu: opts.cpu ?? 1,
|
|
1636
2262
|
mem: defaultMem(arch, opts.mem),
|
|
1637
|
-
networks
|
|
2263
|
+
networks,
|
|
1638
2264
|
ports,
|
|
1639
2265
|
packages: opts.packages ?? [],
|
|
2266
|
+
installAllPackages: opts.installAllPackages,
|
|
1640
2267
|
deviceMode: requestedDeviceMode,
|
|
1641
2268
|
user: opts.user,
|
|
1642
2269
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1654,29 +2281,10 @@ export class QuickCHR {
|
|
|
1654
2281
|
return createInstance(state);
|
|
1655
2282
|
}
|
|
1656
2283
|
|
|
1657
|
-
// Acquire a lock to prevent concurrent starts of the same machine
|
|
1658
2284
|
const machineDir = getMachineDir(name);
|
|
1659
2285
|
assertSufficientQuickchrStorage(`start CHR ${name}`);
|
|
1660
|
-
|
|
1661
|
-
const lockPath = join(machineDir, ".start-lock");
|
|
1662
|
-
acquireLock(lockPath);
|
|
1663
|
-
try {
|
|
1664
|
-
|
|
1665
|
-
// Download and prepare image
|
|
1666
|
-
const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
|
|
1667
|
-
copyImageToMachine(cachedImg, machineDir);
|
|
1668
|
-
|
|
1669
|
-
// Prepare disks (boot resize + extra disks)
|
|
1670
|
-
const diskArtifacts = await ensureConfiguredDisks(
|
|
1671
|
-
machineDir,
|
|
1672
|
-
diskOpts.bootSize,
|
|
1673
|
-
diskOpts.extraDisks,
|
|
1674
|
-
diskOpts.bootDiskFormat,
|
|
1675
|
-
);
|
|
1676
|
-
const bootDisk = diskArtifacts.bootDisk;
|
|
1677
|
-
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
2286
|
+
const dirCreatedHere = !existsSync(machineDir);
|
|
1678
2287
|
|
|
1679
|
-
// Build machine config
|
|
1680
2288
|
const background = opts.background ?? true;
|
|
1681
2289
|
|
|
1682
2290
|
// Provisioning includes any work that requires the machine to have booted first.
|
|
@@ -1693,7 +2301,33 @@ export class QuickCHR {
|
|
|
1693
2301
|
opts.secureLogin === true
|
|
1694
2302
|
);
|
|
1695
2303
|
|
|
1696
|
-
const networkConfigs =
|
|
2304
|
+
const { lock, portBase, ports, networks: networkConfigs } = await claimNewMachine(
|
|
2305
|
+
name,
|
|
2306
|
+
!background && !hasProvisioning ? "foreground" : "start",
|
|
2307
|
+
opts,
|
|
2308
|
+
);
|
|
2309
|
+
// Endpoints are claimed before QEMU is spawned, because resolution needs to know
|
|
2310
|
+
// which end this machine holds. A launch that fails before its state is persisted
|
|
2311
|
+
// has no instance lifecycle to release them later, so a failed start would leave a
|
|
2312
|
+
// machine holding an end of a link it never used — and two of those fill the link.
|
|
2313
|
+
// Cleared once saveMachine() has committed the claim.
|
|
2314
|
+
let unpersistedSocketClaim: MachineState | undefined;
|
|
2315
|
+
try {
|
|
2316
|
+
|
|
2317
|
+
// Download and prepare image
|
|
2318
|
+
const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
|
|
2319
|
+
copyImageToMachine(cachedImg, machineDir);
|
|
2320
|
+
|
|
2321
|
+
// Prepare disks (boot resize + extra disks)
|
|
2322
|
+
const diskArtifacts = await ensureConfiguredDisks(
|
|
2323
|
+
machineDir,
|
|
2324
|
+
diskOpts.bootSize,
|
|
2325
|
+
diskOpts.extraDisks,
|
|
2326
|
+
diskOpts.bootDiskFormat,
|
|
2327
|
+
);
|
|
2328
|
+
const bootDisk = diskArtifacts.bootDisk;
|
|
2329
|
+
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
2330
|
+
|
|
1697
2331
|
if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
|
|
1698
2332
|
throw new QuickCHRError(
|
|
1699
2333
|
"NETWORK_UNAVAILABLE",
|
|
@@ -1712,6 +2346,7 @@ export class QuickCHR {
|
|
|
1712
2346
|
networks: networkConfigs,
|
|
1713
2347
|
ports,
|
|
1714
2348
|
packages: opts.packages ?? [],
|
|
2349
|
+
installAllPackages: opts.installAllPackages,
|
|
1715
2350
|
deviceMode: requestedDeviceMode,
|
|
1716
2351
|
user: opts.user,
|
|
1717
2352
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1732,9 +2367,14 @@ export class QuickCHR {
|
|
|
1732
2367
|
const accel = await detectAccel(arch);
|
|
1733
2368
|
const note = accelNote(arch, accel);
|
|
1734
2369
|
if (note) logger.warn(note);
|
|
1735
|
-
registerSocketMembers(state);
|
|
1736
2370
|
const hostfwd = buildHostfwdString(state.ports);
|
|
1737
|
-
const resolvedNetworks =
|
|
2371
|
+
const resolvedNetworks = registerAndResolveNetworks(
|
|
2372
|
+
state,
|
|
2373
|
+
{ platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
2374
|
+
hostfwd,
|
|
2375
|
+
);
|
|
2376
|
+
unpersistedSocketClaim = state;
|
|
2377
|
+
reportSocketTransports(state, logger);
|
|
1738
2378
|
|
|
1739
2379
|
const launchConfig: QemuLaunchConfig = {
|
|
1740
2380
|
arch,
|
|
@@ -1757,9 +2397,15 @@ export class QuickCHR {
|
|
|
1757
2397
|
|
|
1758
2398
|
// Foreground (no provisioning): spawnQemu blocks until QEMU exits
|
|
1759
2399
|
if (!background && !hasProvisioning) {
|
|
2400
|
+
// QEMU has already exited here — spawnQemu() blocks in foreground. A machine
|
|
2401
|
+
// that is not running must not keep holding an end of a link: stop() and
|
|
2402
|
+
// remove() unregister, and this path reaches "stopped" without going through
|
|
2403
|
+
// either of them.
|
|
2404
|
+
unregisterSocketMembers(state);
|
|
1760
2405
|
state.status = "stopped";
|
|
1761
2406
|
state.lastStartedAt = new Date().toISOString();
|
|
1762
2407
|
saveMachine(state);
|
|
2408
|
+
unpersistedSocketClaim = undefined;
|
|
1763
2409
|
return createInstance(state);
|
|
1764
2410
|
}
|
|
1765
2411
|
|
|
@@ -1767,8 +2413,10 @@ export class QuickCHR {
|
|
|
1767
2413
|
state.lastStartedAt = new Date().toISOString();
|
|
1768
2414
|
|
|
1769
2415
|
saveMachine(state);
|
|
2416
|
+
unpersistedSocketClaim = undefined;
|
|
1770
2417
|
|
|
1771
2418
|
const instance = createInstance(state);
|
|
2419
|
+
if (opts.wait === false) return instance;
|
|
1772
2420
|
|
|
1773
2421
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1774
2422
|
|
|
@@ -1818,7 +2466,7 @@ export class QuickCHR {
|
|
|
1818
2466
|
"BOOT_TIMEOUT",
|
|
1819
2467
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1820
2468
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1821
|
-
await cleanupAfterBootFailure(instance, name, failure) +
|
|
2469
|
+
await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
|
|
1822
2470
|
`\n${failure.summary}`,
|
|
1823
2471
|
);
|
|
1824
2472
|
}
|
|
@@ -1842,15 +2490,20 @@ export class QuickCHR {
|
|
|
1842
2490
|
} catch { /* never propagate */ }
|
|
1843
2491
|
|
|
1844
2492
|
if (hasProvisioning) {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2493
|
+
try {
|
|
2494
|
+
await QuickCHR._provisionInstance(instance, state, {
|
|
2495
|
+
installAllPackages: opts.installAllPackages,
|
|
2496
|
+
packages: opts.packages,
|
|
2497
|
+
deviceMode: requestedDeviceMode,
|
|
2498
|
+
user: opts.user,
|
|
2499
|
+
disableAdmin: opts.disableAdmin,
|
|
2500
|
+
license: opts.license,
|
|
2501
|
+
secureLogin: opts.secureLogin,
|
|
2502
|
+
}, launchConfig, logger);
|
|
2503
|
+
} catch (err) {
|
|
2504
|
+
// This call created the machine, so a failed run takes it away again.
|
|
2505
|
+
throw await failProvisioning(err, instance, undefined);
|
|
2506
|
+
}
|
|
1854
2507
|
}
|
|
1855
2508
|
|
|
1856
2509
|
// Foreground + provisioning: all provisioning is done in background mode.
|
|
@@ -1858,25 +2511,34 @@ export class QuickCHR {
|
|
|
1858
2511
|
// QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
|
|
1859
2512
|
// shortcuts that are well-documented and googleable).
|
|
1860
2513
|
if (!background && hasProvisioning) {
|
|
1861
|
-
await
|
|
2514
|
+
await stopUnderLock(state);
|
|
1862
2515
|
// Brief pause for QEMU to flush and release the disk image
|
|
1863
2516
|
await Bun.sleep(1000);
|
|
1864
|
-
// Release lock before re-launching — _launchExisting acquires its own.
|
|
1865
|
-
// The outer finally
|
|
1866
|
-
|
|
2517
|
+
// Release the lock before re-launching — _launchExisting acquires its own.
|
|
2518
|
+
// The outer finally releases again, which is a no-op.
|
|
2519
|
+
lock.release();
|
|
1867
2520
|
return QuickCHR._launchExisting(state, false, undefined, logger);
|
|
1868
2521
|
}
|
|
1869
2522
|
|
|
1870
2523
|
return instance;
|
|
1871
2524
|
} catch (err) {
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2525
|
+
// Release an endpoint this launch claimed but never committed. stop()/remove()
|
|
2526
|
+
// would do it for a machine that reached persisted state; one that did not has
|
|
2527
|
+
// no instance to do it.
|
|
2528
|
+
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2529
|
+
// Clean up an orphaned machine directory if the spawn failed before readable
|
|
2530
|
+
// state was saved — same predicate as add()'s cleanup, so a truncated
|
|
2531
|
+
// machine.json is not mistaken for a finished machine.
|
|
2532
|
+
//
|
|
2533
|
+
// `dirCreatedHere` is the difference from add(): start() also starts machines
|
|
2534
|
+
// that already exist, and a directory this call did not create is never ours
|
|
2535
|
+
// to delete, whatever state it is in.
|
|
2536
|
+
if (dirCreatedHere && isOrphanMachineDir(name)) {
|
|
2537
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
1876
2538
|
}
|
|
1877
2539
|
throw err;
|
|
1878
2540
|
} finally {
|
|
1879
|
-
|
|
2541
|
+
lock.release();
|
|
1880
2542
|
}
|
|
1881
2543
|
}
|
|
1882
2544
|
|
|
@@ -1898,6 +2560,9 @@ export class QuickCHR {
|
|
|
1898
2560
|
logger?: ProgressLogger,
|
|
1899
2561
|
): Promise<void> {
|
|
1900
2562
|
const log = logger ?? createLogger();
|
|
2563
|
+
// What actually ran, persisted at the end as MachineState.provisioning — the
|
|
2564
|
+
// record that says this guest has been provisioned, and with what (#176).
|
|
2565
|
+
const applied: ProvisioningStep[] = [];
|
|
1901
2566
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
1902
2567
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1903
2568
|
assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
|
|
@@ -1928,6 +2593,7 @@ export class QuickCHR {
|
|
|
1928
2593
|
saveMachine(current);
|
|
1929
2594
|
}
|
|
1930
2595
|
machineState.packages = installed;
|
|
2596
|
+
applied.push("packages");
|
|
1931
2597
|
} else if (opts.packages && opts.packages.length > 0) {
|
|
1932
2598
|
const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
|
|
1933
2599
|
await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
|
|
@@ -1937,10 +2603,14 @@ export class QuickCHR {
|
|
|
1937
2603
|
saveMachine(current);
|
|
1938
2604
|
}
|
|
1939
2605
|
machineState.packages = installed;
|
|
2606
|
+
applied.push("packages");
|
|
1940
2607
|
}
|
|
1941
2608
|
|
|
1942
2609
|
if (hasDeviceModeProvisioning) {
|
|
1943
|
-
|
|
2610
|
+
// Factory admin on purpose: this runs at step 2 and the user step is step 4,
|
|
2611
|
+
// so `machineState.user` names an account that does not exist yet.
|
|
2612
|
+
await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log, FACTORY_AUTH_HEADER);
|
|
2613
|
+
applied.push("deviceMode");
|
|
1944
2614
|
}
|
|
1945
2615
|
|
|
1946
2616
|
if (opts.license) {
|
|
@@ -1969,6 +2639,7 @@ export class QuickCHR {
|
|
|
1969
2639
|
saveMachine(current);
|
|
1970
2640
|
}
|
|
1971
2641
|
machineState.licenseLevel = actualLevel as LicenseLevel;
|
|
2642
|
+
applied.push("license");
|
|
1972
2643
|
log.status(` License applied: free → ${actualLevel}`);
|
|
1973
2644
|
} catch (e) {
|
|
1974
2645
|
if (e instanceof QuickCHRError) throw e;
|
|
@@ -1980,6 +2651,9 @@ export class QuickCHR {
|
|
|
1980
2651
|
}
|
|
1981
2652
|
|
|
1982
2653
|
if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
|
|
2654
|
+
if (opts.user) applied.push("user");
|
|
2655
|
+
if (opts.disableAdmin) applied.push("disableAdmin");
|
|
2656
|
+
if (opts.secureLogin === true) applied.push("secureLogin");
|
|
1983
2657
|
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
|
|
1984
2658
|
if (result.user || result.managedSshKey) {
|
|
1985
2659
|
// Persist user info + managed SSH key fact in state (password placeholder —
|
|
@@ -2007,6 +2681,11 @@ export class QuickCHR {
|
|
|
2007
2681
|
}
|
|
2008
2682
|
}
|
|
2009
2683
|
}
|
|
2684
|
+
|
|
2685
|
+
// Stamped only here, after every requested step returned. A run that throws
|
|
2686
|
+
// leaves the record absent, and the caller rolls the whole run back
|
|
2687
|
+
// (`rollbackFailedProvisioning()`), so the retry starts from a factory disk.
|
|
2688
|
+
recordProvisioning(machineState, applied);
|
|
2010
2689
|
}
|
|
2011
2690
|
|
|
2012
2691
|
/** Re-launch an existing stopped machine. */
|
|
@@ -2023,11 +2702,47 @@ export class QuickCHR {
|
|
|
2023
2702
|
secureLogin?: boolean;
|
|
2024
2703
|
},
|
|
2025
2704
|
logger?: ProgressLogger,
|
|
2705
|
+
wait = true,
|
|
2026
2706
|
): Promise<ChrInstance> {
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2707
|
+
const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
|
|
2708
|
+
opts && (
|
|
2709
|
+
opts.installAllPackages ||
|
|
2710
|
+
(opts.packages?.length ?? 0) > 0 ||
|
|
2711
|
+
opts.deviceMode ||
|
|
2712
|
+
opts.user ||
|
|
2713
|
+
opts.disableAdmin ||
|
|
2714
|
+
opts.license ||
|
|
2715
|
+
opts.secureLogin === true
|
|
2716
|
+
)
|
|
2717
|
+
);
|
|
2718
|
+
let hasProvisioning = wantsProvisioning(provisioningOpts);
|
|
2719
|
+
const lock = acquireLockNow(
|
|
2720
|
+
machineLockPath(state.machineDir),
|
|
2721
|
+
!background && !hasProvisioning ? "foreground" : "start",
|
|
2722
|
+
machineLockOptions(state.name, state.machineDir),
|
|
2723
|
+
);
|
|
2724
|
+
// Same pre-persistence window as start(): a relaunch that claims an endpoint and
|
|
2725
|
+
// then fails to spawn would keep it, with no instance to release it.
|
|
2726
|
+
let unpersistedSocketClaim: MachineState | undefined;
|
|
2029
2727
|
try {
|
|
2030
2728
|
|
|
2729
|
+
// `state` is the caller's snapshot, read before the lock was taken; everything
|
|
2730
|
+
// below acts on it, `state.pid` included (#180). Re-read it, and if another
|
|
2731
|
+
// start got the machine running in between, hand back that one rather than
|
|
2732
|
+
// booting a second QEMU on the same disk.
|
|
2733
|
+
assertSameMachine(state);
|
|
2734
|
+
refreshMachineState(state);
|
|
2735
|
+
if (isMachineRunning(state)) return createInstance(state);
|
|
2736
|
+
// `provisioningOpts` was worked out from that snapshot too. If another start
|
|
2737
|
+
// booted the machine in between, the window is shut and running them would be
|
|
2738
|
+
// a second first boot: check them against the machine as it is now, and drop
|
|
2739
|
+
// whatever that start already applied.
|
|
2740
|
+
if (provisioningOpts && !isProvisioningWindowOpen(state)) {
|
|
2741
|
+
assertProvisioningWindow(state, provisioningOpts);
|
|
2742
|
+
provisioningOpts = undefined;
|
|
2743
|
+
hasProvisioning = false;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2031
2746
|
const diskPath = join(state.machineDir, "disk.img");
|
|
2032
2747
|
if (!existsSync(diskPath)) {
|
|
2033
2748
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
|
|
@@ -2042,27 +2757,30 @@ export class QuickCHR {
|
|
|
2042
2757
|
const bootDisk = diskArtifacts.bootDisk;
|
|
2043
2758
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
2044
2759
|
|
|
2045
|
-
const hasProvisioning = !!(
|
|
2046
|
-
provisioningOpts && (
|
|
2047
|
-
provisioningOpts.installAllPackages ||
|
|
2048
|
-
(provisioningOpts.packages?.length ?? 0) > 0 ||
|
|
2049
|
-
provisioningOpts.deviceMode ||
|
|
2050
|
-
provisioningOpts.user ||
|
|
2051
|
-
provisioningOpts.disableAdmin ||
|
|
2052
|
-
provisioningOpts.license ||
|
|
2053
|
-
provisioningOpts.secureLogin === true
|
|
2054
|
-
)
|
|
2055
|
-
);
|
|
2056
2760
|
// Always boot in background when provisioning is needed
|
|
2057
2761
|
const spawnBackground = hasProvisioning ? true : background;
|
|
2762
|
+
// The record a failed first boot rolls back to — taken before this launch writes
|
|
2763
|
+
// pid, status or lastStartedAt into `state`. Whenever the window is open, not only
|
|
2764
|
+
// when there is provisioning to run: a boot timeout on an add()-created machine is
|
|
2765
|
+
// the same failed first boot as a provisioning error, and resets the same way
|
|
2766
|
+
// instead of deleting what `add` recorded (#182).
|
|
2767
|
+
const beforeLaunch = isProvisioningWindowOpen(state) ? structuredClone(state) : undefined;
|
|
2768
|
+
const bootFailureCleanup: BootFailureCleanup = beforeLaunch
|
|
2769
|
+
? { kind: "reset-first-boot", restore: beforeLaunch }
|
|
2770
|
+
: { kind: "preserve-existing" };
|
|
2058
2771
|
|
|
2059
2772
|
const platform = await detectPlatform();
|
|
2060
2773
|
const accel = await detectAccel(state.arch);
|
|
2061
2774
|
const note = accelNote(state.arch, accel);
|
|
2062
2775
|
if (note) (logger ?? createLogger()).warn(note);
|
|
2063
|
-
registerSocketMembers(state);
|
|
2064
2776
|
const hostfwd = buildHostfwdString(state.ports);
|
|
2065
|
-
const resolvedNetworks =
|
|
2777
|
+
const resolvedNetworks = registerAndResolveNetworks(
|
|
2778
|
+
state,
|
|
2779
|
+
{ platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
|
|
2780
|
+
hostfwd,
|
|
2781
|
+
);
|
|
2782
|
+
unpersistedSocketClaim = state;
|
|
2783
|
+
reportSocketTransports(state, logger ?? createLogger());
|
|
2066
2784
|
|
|
2067
2785
|
const launchConfig: QemuLaunchConfig = {
|
|
2068
2786
|
arch: state.arch,
|
|
@@ -2085,9 +2803,12 @@ export class QuickCHR {
|
|
|
2085
2803
|
|
|
2086
2804
|
// Foreground without provisioning: spawnQemu blocks until QEMU exits
|
|
2087
2805
|
if (!background && !hasProvisioning) {
|
|
2806
|
+
// See the same branch in start(): QEMU has exited, so the endpoint goes back.
|
|
2807
|
+
unregisterSocketMembers(state);
|
|
2088
2808
|
state.status = "stopped";
|
|
2089
2809
|
state.lastStartedAt = new Date().toISOString();
|
|
2090
2810
|
saveMachine(state);
|
|
2811
|
+
unpersistedSocketClaim = undefined;
|
|
2091
2812
|
return createInstance(state);
|
|
2092
2813
|
}
|
|
2093
2814
|
|
|
@@ -2095,8 +2816,10 @@ export class QuickCHR {
|
|
|
2095
2816
|
state.status = "running";
|
|
2096
2817
|
state.lastStartedAt = new Date().toISOString();
|
|
2097
2818
|
saveMachine(state);
|
|
2819
|
+
unpersistedSocketClaim = undefined;
|
|
2098
2820
|
|
|
2099
2821
|
const instance = createInstance(state);
|
|
2822
|
+
if (!wait) return instance;
|
|
2100
2823
|
|
|
2101
2824
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
2102
2825
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
@@ -2126,7 +2849,7 @@ export class QuickCHR {
|
|
|
2126
2849
|
"BOOT_TIMEOUT",
|
|
2127
2850
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
2128
2851
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
2129
|
-
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2852
|
+
await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
|
|
2130
2853
|
`\n${failure.summary}`,
|
|
2131
2854
|
);
|
|
2132
2855
|
}
|
|
@@ -2148,7 +2871,11 @@ export class QuickCHR {
|
|
|
2148
2871
|
} catch { /* never propagate */ }
|
|
2149
2872
|
|
|
2150
2873
|
if (hasProvisioning && provisioningOpts) {
|
|
2151
|
-
|
|
2874
|
+
try {
|
|
2875
|
+
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
throw await failProvisioning(err, instance, beforeLaunch);
|
|
2878
|
+
}
|
|
2152
2879
|
}
|
|
2153
2880
|
|
|
2154
2881
|
try {
|
|
@@ -2160,8 +2887,11 @@ export class QuickCHR {
|
|
|
2160
2887
|
} catch { /* never propagate */ }
|
|
2161
2888
|
|
|
2162
2889
|
return instance;
|
|
2890
|
+
} catch (err) {
|
|
2891
|
+
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2892
|
+
throw err;
|
|
2163
2893
|
} finally {
|
|
2164
|
-
|
|
2894
|
+
lock.release();
|
|
2165
2895
|
}
|
|
2166
2896
|
}
|
|
2167
2897
|
|
|
@@ -2185,6 +2915,54 @@ export class QuickCHR {
|
|
|
2185
2915
|
return createInstance(state);
|
|
2186
2916
|
}
|
|
2187
2917
|
|
|
2918
|
+
/** Machines whose `machine.json` is present but cannot be turned into state, each
|
|
2919
|
+
* with the reason: the file would not read, the JSON is invalid, or it parsed into
|
|
2920
|
+
* something that is not a machine.
|
|
2921
|
+
*
|
|
2922
|
+
* `list()` skips these so one corrupt file cannot abort the enumeration; this is
|
|
2923
|
+
* where they stay visible. `get(name)` still throws for such a machine — a lookup
|
|
2924
|
+
* by name is not an enumeration (#165). */
|
|
2925
|
+
static listUnreadable(): Array<{ name: string; error: string }> {
|
|
2926
|
+
return listUnreadableMachines();
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/** Names of machine directories with no readable `machine.json` — half-created
|
|
2930
|
+
* machines that `list()` cannot show and `get()` cannot resolve (#155). */
|
|
2931
|
+
static listOrphans(): string[] {
|
|
2932
|
+
return listOrphanMachineDirs();
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
/** Delete a half-created machine directory. Returns false when `name` is a real
|
|
2936
|
+
* machine (use `get(name)?.remove()`) or does not exist at all.
|
|
2937
|
+
*
|
|
2938
|
+
* A crash or SIGKILL can strand a directory even with add()'s cleanup in place, and
|
|
2939
|
+
* a stranded directory blocks re-add while being invisible to `list` — so clearing
|
|
2940
|
+
* one must not require knowing that the data dir exists. */
|
|
2941
|
+
static removeOrphan(name: string): boolean {
|
|
2942
|
+
// Explicit, before the predicate: this deletes a directory tree, and `join()`
|
|
2943
|
+
// turns ".." into the data dir itself. Say why rather than answering `false`.
|
|
2944
|
+
assertPathSafeName(name, "machine");
|
|
2945
|
+
if (!isOrphanMachineDir(name)) return false;
|
|
2946
|
+
|
|
2947
|
+
// A create still in flight is indistinguishable from an orphan by directory
|
|
2948
|
+
// contents alone — add()/start() write machine.json last, so the window between
|
|
2949
|
+
// ensureDir() and saveMachine() looks exactly like a half-made machine. The
|
|
2950
|
+
// machine lock is what tells them apart, so take it: it throws MACHINE_LOCKED
|
|
2951
|
+
// while a live creator holds it, and takes over one whose owner is gone — which
|
|
2952
|
+
// is the case this recovery exists for. No waiting: a live creator means this is
|
|
2953
|
+
// not an orphan, and the answer is to let it finish.
|
|
2954
|
+
const lock = acquireLockNow(machineLockPath(getMachineDir(name)), "remove-orphan", machineLockOptions(name, getMachineDir(name)));
|
|
2955
|
+
try {
|
|
2956
|
+
// The creator may have finished between the check above and the lock.
|
|
2957
|
+
if (!isOrphanMachineDir(name)) return false;
|
|
2958
|
+
removeState(name);
|
|
2959
|
+
return true;
|
|
2960
|
+
} finally {
|
|
2961
|
+
// A no-op once the directory has gone; releases it if removal threw.
|
|
2962
|
+
lock.release();
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2188
2966
|
/** Run doctor checks for prerequisites. */
|
|
2189
2967
|
static async doctor(): Promise<DoctorResult> {
|
|
2190
2968
|
const checks: DoctorResult["checks"] = [];
|
|
@@ -2373,29 +3151,20 @@ export class QuickCHR {
|
|
|
2373
3151
|
}
|
|
2374
3152
|
|
|
2375
3153
|
// Orphaned machine directories (have files but no machine.json)
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
const dirs = readdirSync(machinesDir, { withFileTypes: true })
|
|
2379
|
-
.filter(e => e.isDirectory());
|
|
2380
|
-
const orphans: string[] = [];
|
|
2381
|
-
for (const dir of dirs) {
|
|
2382
|
-
const mjPath = join(machinesDir, dir.name, "machine.json");
|
|
2383
|
-
if (!existsSync(mjPath)) {
|
|
2384
|
-
orphans.push(dir.name);
|
|
2385
|
-
}
|
|
2386
|
-
}
|
|
3154
|
+
if (existsSync(getMachinesDir())) {
|
|
3155
|
+
const orphans = listOrphanMachineDirs();
|
|
2387
3156
|
if (orphans.length > 0) {
|
|
2388
|
-
const removeLines = orphans.map((o) => `
|
|
3157
|
+
const removeLines = orphans.map((o) => ` quickchr remove ${o}`).join("\n");
|
|
2389
3158
|
checks.push({
|
|
2390
3159
|
label: "Orphaned machine dirs",
|
|
2391
3160
|
status: "warn",
|
|
2392
|
-
detail: `${orphans.length} dir(s)
|
|
3161
|
+
detail: `${orphans.length} dir(s) with no readable machine.json: ${orphans.join(", ")}\nClear them with:\n${removeLines}`,
|
|
2393
3162
|
});
|
|
2394
3163
|
} else {
|
|
2395
3164
|
checks.push({
|
|
2396
3165
|
label: "Machine state",
|
|
2397
3166
|
status: "ok",
|
|
2398
|
-
detail: `${
|
|
3167
|
+
detail: `${listMachineNames().length} machine(s), no orphans`,
|
|
2399
3168
|
});
|
|
2400
3169
|
}
|
|
2401
3170
|
}
|