@tikoci/quickchr 0.4.8 → 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 +162 -7
- package/MANUAL.md +273 -46
- package/README.md +13 -6
- package/package.json +1 -1
- package/src/cli/flags.ts +15 -1
- package/src/cli/index.ts +405 -76
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +888 -327
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +64 -2
- package/src/lib/types.ts +96 -20
- package/src/lib/versions.ts +54 -0
package/src/lib/quickchr.ts
CHANGED
|
@@ -20,14 +20,15 @@ import type {
|
|
|
20
20
|
NetworkTopologyEntry,
|
|
21
21
|
PlatformInfo,
|
|
22
22
|
PortMapping,
|
|
23
|
+
ProvisioningStep,
|
|
23
24
|
QgaCommand,
|
|
24
25
|
ServiceEndpoint,
|
|
25
26
|
SnapshotInfo,
|
|
26
27
|
SshServiceEndpoint,
|
|
27
28
|
StartOptions,
|
|
28
29
|
} from "./types.ts";
|
|
29
|
-
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP } from "./types.ts";
|
|
30
|
-
import { assertValidResourceName, assertPathSafeName } from "./names.ts";
|
|
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";
|
|
31
32
|
import packageJson from "../../package.json";
|
|
32
33
|
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
33
34
|
import {
|
|
@@ -35,6 +36,9 @@ import {
|
|
|
35
36
|
isValidVersion,
|
|
36
37
|
generateMachineName,
|
|
37
38
|
assertProvisioningSupportedVersion,
|
|
39
|
+
assertRestAvailable,
|
|
40
|
+
assertRouterOs6Arch,
|
|
41
|
+
isRouterOs6,
|
|
38
42
|
PROVISIONING_FEATURE_LABEL,
|
|
39
43
|
} from "./versions.ts";
|
|
40
44
|
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
|
|
@@ -61,16 +65,17 @@ import {
|
|
|
61
65
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
62
66
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
63
67
|
import { resolveSetting } from "./settings.ts";
|
|
64
|
-
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";
|
|
65
69
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
66
70
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
67
|
-
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";
|
|
68
72
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
69
73
|
import { provision } from "./provision.ts";
|
|
70
74
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
71
75
|
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
72
76
|
import { scpPush, scpPull } from "./scp.ts";
|
|
73
77
|
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
78
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownGuest, validateShutdownTimeout } from "./shutdown.ts";
|
|
74
79
|
import { restExecute } from "./exec.ts";
|
|
75
80
|
import { qgaExec } from "./qga.ts";
|
|
76
81
|
import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
|
|
@@ -78,6 +83,8 @@ import { restRequest, restGet, restPost } from "./rest.ts";
|
|
|
78
83
|
import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
|
|
79
84
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
80
85
|
import {
|
|
86
|
+
describeDeviceModeChange,
|
|
87
|
+
FACTORY_AUTH_HEADER,
|
|
81
88
|
formatDeviceModeSelection,
|
|
82
89
|
readDeviceMode,
|
|
83
90
|
resolveDeviceModeOptions,
|
|
@@ -86,11 +93,19 @@ import {
|
|
|
86
93
|
verifyDeviceMode,
|
|
87
94
|
waitForDeviceModeApi,
|
|
88
95
|
} from "./device-mode.ts";
|
|
96
|
+
import {
|
|
97
|
+
appliedDeviceModeRecord,
|
|
98
|
+
assertProvisioningWindow,
|
|
99
|
+
hasProvisioningRequest,
|
|
100
|
+
isProvisioningWindowOpen,
|
|
101
|
+
pendingProvisioningFor,
|
|
102
|
+
} from "./provisioning-window.ts";
|
|
89
103
|
import type { LicenseOptions } from "./types.ts";
|
|
90
104
|
import type { GuestExec } from "./guest-snapshot.ts";
|
|
91
105
|
import { toChrPorts } from "./network.ts";
|
|
92
106
|
import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
93
|
-
import { existsSync, rmSync, copyFileSync
|
|
107
|
+
import { existsSync, rmSync, copyFileSync } from "node:fs";
|
|
108
|
+
import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
|
|
94
109
|
import { join } from "node:path";
|
|
95
110
|
|
|
96
111
|
// --- Architecture-aware defaults ---
|
|
@@ -140,6 +155,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
|
|
|
140
155
|
return opts;
|
|
141
156
|
}
|
|
142
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
|
+
|
|
143
168
|
function listProvisioningMutations(opts: {
|
|
144
169
|
installAllPackages?: boolean;
|
|
145
170
|
packages?: string[];
|
|
@@ -185,6 +210,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
|
|
|
185
210
|
return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
|
|
186
211
|
}
|
|
187
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
|
+
|
|
188
229
|
// --- Socket registry lifecycle helpers ---
|
|
189
230
|
|
|
190
231
|
function getSocketNamedNetworks(state: MachineState): string[] {
|
|
@@ -316,6 +357,193 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
|
|
|
316
357
|
return null;
|
|
317
358
|
}
|
|
318
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
|
+
|
|
319
547
|
/** Create a ChrInstance handle from persisted MachineState. */
|
|
320
548
|
function createInstance(state: MachineState): ChrInstance {
|
|
321
549
|
const ports = toChrPorts(state.ports);
|
|
@@ -404,14 +632,16 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
404
632
|
// keeps the excludePorts fix: excluding "http" while keeping "https" now
|
|
405
633
|
// resolves rest-api onto the surviving secure port instead of a port that
|
|
406
634
|
// doesn't exist.
|
|
407
|
-
const restApi =
|
|
408
|
-
state.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
+
);
|
|
415
645
|
const nativeApi = buildHttpService(
|
|
416
646
|
state.ports["api-ssl"],
|
|
417
647
|
state.ports.api,
|
|
@@ -498,11 +728,11 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
498
728
|
portBase: state.portBase,
|
|
499
729
|
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
500
730
|
hostGatewayIp: HOST_GATEWAY_IP,
|
|
501
|
-
// Deprecated alias for hostGatewayIp (#26) — same value, removed no earlier
|
|
502
|
-
// than the next minor.
|
|
503
|
-
tzspGatewayIp: HOST_GATEWAY_IP,
|
|
504
731
|
|
|
505
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
|
+
}
|
|
506
736
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
507
737
|
// on authenticated machines (post-provisioning, post-install reboots).
|
|
508
738
|
const auth = resolveAuth(state);
|
|
@@ -523,101 +753,32 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
523
753
|
},
|
|
524
754
|
|
|
525
755
|
async stop(): Promise<void> {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
+
});
|
|
539
774
|
},
|
|
540
775
|
|
|
541
776
|
async remove(): Promise<void> {
|
|
542
|
-
|
|
543
|
-
await stopQemu(state.pid);
|
|
544
|
-
}
|
|
545
|
-
unregisterSocketMembers(state);
|
|
546
|
-
// Clean up stored instance credentials
|
|
547
|
-
deleteInstanceCredentials(state.name);
|
|
548
|
-
removeState(state.name);
|
|
777
|
+
await withMachineLock(state, "remove", () => removeUnderLock(state));
|
|
549
778
|
},
|
|
550
779
|
|
|
551
780
|
async clean(): Promise<void> {
|
|
552
|
-
|
|
553
|
-
await stopQemu(state.pid);
|
|
554
|
-
}
|
|
555
|
-
// Same invariant as stop()/remove(): a machine that is not running holds no
|
|
556
|
-
// endpoint. clean() stops QEMU directly rather than through stop(), so without
|
|
557
|
-
// this a cleaned machine keeps a pair link occupied and blocks its removal.
|
|
558
|
-
unregisterSocketMembers(state);
|
|
559
|
-
// NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
560
|
-
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
561
|
-
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
562
|
-
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
563
|
-
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
564
|
-
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
565
|
-
// Re-copy fresh image from cache
|
|
566
|
-
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
567
|
-
if (!existsSync(imgPath)) {
|
|
568
|
-
throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
|
|
569
|
-
}
|
|
570
|
-
const destPath = join(state.machineDir, "disk.img");
|
|
571
|
-
copyFileSync(imgPath, destPath);
|
|
572
|
-
|
|
573
|
-
// Clean up disk files (boot.qcow2, extra disks)
|
|
574
|
-
cleanDiskFiles(state.machineDir);
|
|
575
|
-
|
|
576
|
-
// Re-prepare disks if the machine had disk customizations
|
|
577
|
-
await ensureConfiguredDisks(
|
|
578
|
-
state.machineDir,
|
|
579
|
-
state.bootSize,
|
|
580
|
-
state.extraDisks,
|
|
581
|
-
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
// Every guest-side credential went with the old disk, so the credential
|
|
585
|
-
// facts in machine.json now describe a machine that no longer exists.
|
|
586
|
-
// Nothing re-provisions them either: a post-clean() start() takes the
|
|
587
|
-
// `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
|
|
588
|
-
// erased account is never recreated. Left in place, `resolveAuth()` /
|
|
589
|
-
// `resolveCreds()` prefer `state.user` and authenticate every REST call,
|
|
590
|
-
// exec, and SCP as a deleted user — the confound #79 removes here.
|
|
591
|
-
// Clearing them restores the documented fallback: factory `admin` with an
|
|
592
|
-
// empty password, which is what a fresh CHR image actually answers to
|
|
593
|
-
// (anchored by the "clean() resets disk to factory defaults" integration
|
|
594
|
-
// test). `disableAdmin` goes with them because it is read as a live fact
|
|
595
|
-
// about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
|
|
596
|
-
// image has admin enabled again. Provisioning *intent* — packages,
|
|
597
|
-
// deviceMode, secureLogin — is not guest state and survives.
|
|
598
|
-
deleteInstanceCredentials(state.name);
|
|
599
|
-
// The managed keypair authenticated to the account factory reset erased,
|
|
600
|
-
// so it is dead credential material sitting in the machine dir. Removing
|
|
601
|
-
// it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
|
|
602
|
-
// existing file exits 1 with "already exists" rather than overwriting
|
|
603
|
-
// (verified locally), so a leftover key would break `installSshKey()`.
|
|
604
|
-
rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
|
|
605
|
-
|
|
606
|
-
// Update state
|
|
607
|
-
const current = loadMachine(state.name);
|
|
608
|
-
if (current) {
|
|
609
|
-
current.status = "stopped";
|
|
610
|
-
current.pid = undefined;
|
|
611
|
-
current.user = undefined;
|
|
612
|
-
current.managedSshKey = undefined;
|
|
613
|
-
current.disableAdmin = undefined;
|
|
614
|
-
saveMachine(current);
|
|
615
|
-
}
|
|
616
|
-
state.status = "stopped";
|
|
617
|
-
state.pid = undefined;
|
|
618
|
-
state.user = undefined;
|
|
619
|
-
state.managedSshKey = undefined;
|
|
620
|
-
state.disableAdmin = undefined;
|
|
781
|
+
await withMachineLock(state, "clean", () => cleanUnderLock(state));
|
|
621
782
|
},
|
|
622
783
|
|
|
623
784
|
async monitor(command: string): Promise<string> {
|
|
@@ -633,6 +794,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
633
794
|
},
|
|
634
795
|
|
|
635
796
|
async rest(path: string, opts?: RequestInit): Promise<unknown> {
|
|
797
|
+
assertRestAvailable(state, "rest()");
|
|
636
798
|
const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
|
|
637
799
|
|
|
638
800
|
// Retry on ECONNRESET — RouterOS can transiently reset connections in the
|
|
@@ -722,6 +884,9 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
722
884
|
`exec transport "${via}" is not yet implemented`,
|
|
723
885
|
);
|
|
724
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");
|
|
725
890
|
const auth = resolveAuth(state, opts?.user, opts?.password);
|
|
726
891
|
if (via === "rest") {
|
|
727
892
|
return restExecute(restUrl, auth, command, opts);
|
|
@@ -769,15 +934,62 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
769
934
|
const log = logger ?? createLogger();
|
|
770
935
|
const resolved = resolveDeviceModeOptions(options);
|
|
771
936
|
for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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();
|
|
779
992
|
}
|
|
780
|
-
state.deviceMode = options;
|
|
781
993
|
},
|
|
782
994
|
|
|
783
995
|
async availablePackages(): Promise<string[]> {
|
|
@@ -864,8 +1076,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
864
1076
|
},
|
|
865
1077
|
|
|
866
1078
|
async destroy(): Promise<void> {
|
|
867
|
-
await
|
|
868
|
-
|
|
1079
|
+
await withMachineLock(state, "remove", async () => {
|
|
1080
|
+
await stopUnderLock(state);
|
|
1081
|
+
await removeUnderLock(state);
|
|
1082
|
+
});
|
|
869
1083
|
},
|
|
870
1084
|
|
|
871
1085
|
async subprocessEnv(): Promise<Record<string, string>> {
|
|
@@ -1005,44 +1219,6 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1005
1219
|
};
|
|
1006
1220
|
}
|
|
1007
1221
|
|
|
1008
|
-
/**
|
|
1009
|
-
* Atomically acquire an exclusive start lock for a machine directory.
|
|
1010
|
-
* Uses O_CREAT|O_EXCL so concurrent attempts are rejected by the OS — no TOCTOU race.
|
|
1011
|
-
* If the lock is stale (owning process is dead) it is silently replaced.
|
|
1012
|
-
* Throws MACHINE_LOCKED if the lock is held by a live process.
|
|
1013
|
-
*/
|
|
1014
|
-
export function acquireLock(lockPath: string): void {
|
|
1015
|
-
let fd: number;
|
|
1016
|
-
try {
|
|
1017
|
-
fd = openSync(lockPath, "wx"); // O_WRONLY | O_CREAT | O_EXCL — atomic
|
|
1018
|
-
writeSync(fd, String(process.pid));
|
|
1019
|
-
closeSync(fd);
|
|
1020
|
-
return;
|
|
1021
|
-
} catch {
|
|
1022
|
-
// File already exists — check whether owner is still alive
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
let ownerPid: number | undefined;
|
|
1026
|
-
try {
|
|
1027
|
-
ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
|
|
1028
|
-
} catch { /* unreadable — treat as stale */ }
|
|
1029
|
-
|
|
1030
|
-
if (ownerPid && !Number.isNaN(ownerPid)) {
|
|
1031
|
-
try {
|
|
1032
|
-
process.kill(ownerPid, 0); // probes liveness; throws ESRCH if process not found
|
|
1033
|
-
throw new QuickCHRError("MACHINE_LOCKED", `Machine is already being started (pid ${ownerPid})`);
|
|
1034
|
-
} catch (e) {
|
|
1035
|
-
if (e instanceof QuickCHRError) throw e;
|
|
1036
|
-
// Process is dead — stale lock, overwrite
|
|
1037
|
-
writeFileSync(lockPath, String(process.pid));
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// Owner unreadable or zero — treat as stale, overwrite
|
|
1043
|
-
writeFileSync(lockPath, String(process.pid));
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
1222
|
/** Resolve the host architecture to a CHR architecture. */
|
|
1047
1223
|
function hostArchToChr(): Arch {
|
|
1048
1224
|
const arch = process.arch;
|
|
@@ -1222,28 +1398,122 @@ export async function captureRunningFailure(
|
|
|
1222
1398
|
});
|
|
1223
1399
|
}
|
|
1224
1400
|
|
|
1225
|
-
/**
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
*
|
|
1233
|
-
|
|
1234
|
-
* `captureBootFailure()` has already written its report outside the machine dir
|
|
1235
|
-
* with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
|
|
1236
|
-
async function cleanupAfterBootFailure(
|
|
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(
|
|
1237
1410
|
instance: ChrInstance,
|
|
1238
1411
|
name: string,
|
|
1239
1412
|
failure: BootFailureReport,
|
|
1413
|
+
cleanup: BootFailureCleanup,
|
|
1240
1414
|
): Promise<string> {
|
|
1241
|
-
|
|
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;
|
|
1242
1428
|
if (failure.preserved) {
|
|
1243
|
-
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}\`.`;
|
|
1499
|
+
}
|
|
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;
|
|
1244
1515
|
}
|
|
1245
|
-
|
|
1246
|
-
return ` Machine "${name}" has been cleaned up automatically.`;
|
|
1516
|
+
return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
|
|
1247
1517
|
}
|
|
1248
1518
|
|
|
1249
1519
|
/** Wait for CHR to boot with periodic progress status updates every 20s. */
|
|
@@ -1337,26 +1607,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1337
1607
|
};
|
|
1338
1608
|
}
|
|
1339
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
|
+
|
|
1340
1692
|
/** Apply a device-mode change to a running CHR instance (may require hard power-cycle).
|
|
1341
|
-
* 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. */
|
|
1342
1704
|
async function applyDeviceMode(
|
|
1343
1705
|
instance: ChrInstance,
|
|
1344
1706
|
machineState: MachineState,
|
|
1345
1707
|
resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
|
|
1346
1708
|
launchConfig: QemuLaunchConfig,
|
|
1347
1709
|
log: ProgressLogger,
|
|
1710
|
+
authHeader: string,
|
|
1348
1711
|
): Promise<void> {
|
|
1349
1712
|
const httpPort = toChrPorts(machineState.ports).http;
|
|
1350
1713
|
const accel = await detectAccel(machineState.arch);
|
|
1351
1714
|
const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
|
|
1352
|
-
await waitForDeviceModeApi(httpPort, 60_000);
|
|
1715
|
+
await waitForDeviceModeApi(httpPort, 60_000, authHeader);
|
|
1353
1716
|
log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
|
|
1354
1717
|
|
|
1355
1718
|
let alreadyActive = false;
|
|
1356
1719
|
try {
|
|
1357
|
-
const beforeMode = await readDeviceMode(httpPort);
|
|
1720
|
+
const beforeMode = await readDeviceMode(httpPort, authHeader);
|
|
1358
1721
|
log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
|
|
1359
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
|
+
}
|
|
1360
1730
|
} catch { /* CHR unexpectedly unreachable — proceed with update */ }
|
|
1361
1731
|
|
|
1362
1732
|
if (alreadyActive) {
|
|
@@ -1369,7 +1739,7 @@ async function applyDeviceMode(
|
|
|
1369
1739
|
let requiresPowerCycle = false;
|
|
1370
1740
|
|
|
1371
1741
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1372
|
-
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
|
|
1742
|
+
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
|
|
1373
1743
|
// RouterOS blocks this connection while waiting for hard power-cycle confirmation.
|
|
1374
1744
|
// We race against 2s: if still pending at 2s, RouterOS has entered blocking state
|
|
1375
1745
|
// and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
|
|
@@ -1399,6 +1769,22 @@ async function applyDeviceMode(
|
|
|
1399
1769
|
}
|
|
1400
1770
|
|
|
1401
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
|
+
|
|
1402
1788
|
await Bun.sleep(5000);
|
|
1403
1789
|
|
|
1404
1790
|
let routerOsOffline = false;
|
|
@@ -1415,10 +1801,10 @@ async function applyDeviceMode(
|
|
|
1415
1801
|
if (!rebooted) {
|
|
1416
1802
|
throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
|
|
1417
1803
|
}
|
|
1418
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1804
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1419
1805
|
}
|
|
1420
1806
|
|
|
1421
|
-
const actualNow = await readDeviceMode(httpPort);
|
|
1807
|
+
const actualNow = await readDeviceMode(httpPort, authHeader);
|
|
1422
1808
|
log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
|
|
1423
1809
|
const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
|
|
1424
1810
|
if (immediateVerification.ok) {
|
|
@@ -1448,10 +1834,10 @@ async function applyDeviceMode(
|
|
|
1448
1834
|
`Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
|
|
1449
1835
|
);
|
|
1450
1836
|
}
|
|
1451
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1837
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1452
1838
|
}
|
|
1453
1839
|
|
|
1454
|
-
const actual = await readDeviceMode(httpPort);
|
|
1840
|
+
const actual = await readDeviceMode(httpPort, authHeader);
|
|
1455
1841
|
log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
|
|
1456
1842
|
const verification = verifyDeviceMode(resolvedDeviceMode, actual);
|
|
1457
1843
|
if (!verification.ok) {
|
|
@@ -1463,6 +1849,112 @@ async function applyDeviceMode(
|
|
|
1463
1849
|
log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
|
|
1464
1850
|
}
|
|
1465
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
|
+
|
|
1466
1958
|
/**
|
|
1467
1959
|
* Main entry point for managing MikroTik CHR virtual machines via QEMU.
|
|
1468
1960
|
*
|
|
@@ -1488,6 +1980,7 @@ export class QuickCHR {
|
|
|
1488
1980
|
// add() always creates, so the full name rules apply before any I/O.
|
|
1489
1981
|
if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
|
|
1490
1982
|
|
|
1983
|
+
warnVersionWithChannel(opts, createLogger(opts.onProgress));
|
|
1491
1984
|
let version: string;
|
|
1492
1985
|
if (opts.version) {
|
|
1493
1986
|
if (!isValidVersion(opts.version)) {
|
|
@@ -1500,13 +1993,10 @@ export class QuickCHR {
|
|
|
1500
1993
|
}
|
|
1501
1994
|
|
|
1502
1995
|
const arch: Arch = resolveArch(opts.arch);
|
|
1503
|
-
|
|
1504
|
-
if (arch === "arm64") requireFirmware();
|
|
1505
|
-
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1996
|
+
assertRouterOs6Arch(version, arch);
|
|
1506
1997
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
1507
1998
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1508
|
-
|
|
1509
|
-
if (hasProvisioningMutations({
|
|
1999
|
+
const mutations = {
|
|
1510
2000
|
installAllPackages: opts.installAllPackages,
|
|
1511
2001
|
packages: opts.packages,
|
|
1512
2002
|
hasDeviceModeProvisioning,
|
|
@@ -1514,17 +2004,15 @@ export class QuickCHR {
|
|
|
1514
2004
|
disableAdmin: opts.disableAdmin,
|
|
1515
2005
|
license: opts.license,
|
|
1516
2006
|
secureLogin: opts.secureLogin,
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
user: opts.user,
|
|
1523
|
-
disableAdmin: opts.disableAdmin,
|
|
1524
|
-
license: opts.license,
|
|
1525
|
-
secureLogin: opts.secureLogin,
|
|
1526
|
-
}));
|
|
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));
|
|
1527
2012
|
}
|
|
2013
|
+
requireQemu(arch);
|
|
2014
|
+
if (arch === "arm64") requireFirmware();
|
|
2015
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1528
2016
|
|
|
1529
2017
|
const existingNames = listMachineNames();
|
|
1530
2018
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
@@ -1540,16 +2028,9 @@ export class QuickCHR {
|
|
|
1540
2028
|
: new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
|
|
1541
2029
|
}
|
|
1542
2030
|
|
|
1543
|
-
const usedBases = getUsedPortBases();
|
|
1544
|
-
const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
|
|
1545
|
-
validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
|
|
1546
|
-
const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
|
|
1547
|
-
|
|
1548
2031
|
const machineDir = getMachineDir(name);
|
|
1549
2032
|
assertSufficientQuickchrStorage(`prepare CHR ${name}`);
|
|
1550
|
-
|
|
1551
|
-
const lockPath = join(machineDir, ".start-lock");
|
|
1552
|
-
acquireLock(lockPath);
|
|
2033
|
+
const { lock, portBase, ports, networks } = await claimNewMachine(name, "add", opts);
|
|
1553
2034
|
try {
|
|
1554
2035
|
const cachedImg = await ensureCachedImage(version, arch);
|
|
1555
2036
|
copyImageToMachine(cachedImg, machineDir);
|
|
@@ -1566,7 +2047,7 @@ export class QuickCHR {
|
|
|
1566
2047
|
arch,
|
|
1567
2048
|
cpu: opts.cpu ?? 1,
|
|
1568
2049
|
mem: defaultMem(arch, opts.mem),
|
|
1569
|
-
networks
|
|
2050
|
+
networks,
|
|
1570
2051
|
ports,
|
|
1571
2052
|
packages: opts.packages ?? [],
|
|
1572
2053
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1597,11 +2078,11 @@ export class QuickCHR {
|
|
|
1597
2078
|
// directory it was supposed to clean up. Nothing runs after saveMachine()
|
|
1598
2079
|
// but `return state`, so a readable file here means the create succeeded.
|
|
1599
2080
|
if (isOrphanMachineDir(name)) {
|
|
1600
|
-
try {
|
|
2081
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
1601
2082
|
}
|
|
1602
2083
|
throw err;
|
|
1603
2084
|
} finally {
|
|
1604
|
-
|
|
2085
|
+
lock.release();
|
|
1605
2086
|
}
|
|
1606
2087
|
}
|
|
1607
2088
|
|
|
@@ -1609,7 +2090,9 @@ export class QuickCHR {
|
|
|
1609
2090
|
* Start a new or existing CHR instance.
|
|
1610
2091
|
*
|
|
1611
2092
|
* The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
|
|
1612
|
-
* 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.
|
|
1613
2096
|
* Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
|
|
1614
2097
|
* stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
|
|
1615
2098
|
* apply when creating a new machine and require `qemu-img` on the host.
|
|
@@ -1623,6 +2106,13 @@ export class QuickCHR {
|
|
|
1623
2106
|
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
|
|
1624
2107
|
}
|
|
1625
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
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1626
2116
|
const logger = createLogger(opts.onProgress);
|
|
1627
2117
|
|
|
1628
2118
|
// Before any download or disk work: a named socket whose two ends are already
|
|
@@ -1638,6 +2128,7 @@ export class QuickCHR {
|
|
|
1638
2128
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1639
2129
|
|
|
1640
2130
|
// Resolve version
|
|
2131
|
+
warnVersionWithChannel(opts, logger);
|
|
1641
2132
|
let version: string;
|
|
1642
2133
|
if (opts.version) {
|
|
1643
2134
|
if ((CHANNELS as readonly string[]).includes(opts.version)) {
|
|
@@ -1655,7 +2146,7 @@ export class QuickCHR {
|
|
|
1655
2146
|
version = await resolveVersion(channel);
|
|
1656
2147
|
}
|
|
1657
2148
|
|
|
1658
|
-
|
|
2149
|
+
const mutations = {
|
|
1659
2150
|
installAllPackages: opts.installAllPackages,
|
|
1660
2151
|
packages: opts.packages,
|
|
1661
2152
|
hasDeviceModeProvisioning,
|
|
@@ -1663,29 +2154,20 @@ export class QuickCHR {
|
|
|
1663
2154
|
disableAdmin: opts.disableAdmin,
|
|
1664
2155
|
license: opts.license,
|
|
1665
2156
|
secureLogin: opts.secureLogin,
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
hasDeviceModeProvisioning,
|
|
1671
|
-
user: opts.user,
|
|
1672
|
-
disableAdmin: opts.disableAdmin,
|
|
1673
|
-
license: opts.license,
|
|
1674
|
-
secureLogin: opts.secureLogin,
|
|
1675
|
-
}));
|
|
2157
|
+
};
|
|
2158
|
+
if (hasProvisioningMutations(mutations)) {
|
|
2159
|
+
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
2160
|
+
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
|
|
1676
2161
|
}
|
|
1677
2162
|
|
|
1678
2163
|
// Resolve architecture
|
|
1679
2164
|
const arch: Arch = resolveArch(opts.arch);
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
requireFirmware();
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
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
|
+
};
|
|
1689
2171
|
|
|
1690
2172
|
// Resolve name
|
|
1691
2173
|
const existingNames = listMachineNames();
|
|
@@ -1696,20 +2178,45 @@ export class QuickCHR {
|
|
|
1696
2178
|
// Check if machine already exists
|
|
1697
2179
|
const existing = loadMachine(name);
|
|
1698
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
|
+
}
|
|
1699
2206
|
if (isMachineRunning(existing)) {
|
|
1700
2207
|
return createInstance(existing);
|
|
1701
2208
|
}
|
|
1702
2209
|
// First boot of add()-created machine: apply pending provisioning from stored state
|
|
1703
|
-
if (
|
|
1704
|
-
const pendingOpts = {
|
|
1705
|
-
installAllPackages: opts.installAllPackages
|
|
1706
|
-
packages: opts.packages
|
|
1707
|
-
deviceMode: opts.deviceMode
|
|
1708
|
-
user: opts.user
|
|
1709
|
-
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,
|
|
1710
2217
|
license: opts.license,
|
|
1711
|
-
secureLogin: opts.secureLogin
|
|
1712
|
-
};
|
|
2218
|
+
secureLogin: opts.secureLogin,
|
|
2219
|
+
});
|
|
1713
2220
|
const hasPending = !!(
|
|
1714
2221
|
pendingOpts.installAllPackages ||
|
|
1715
2222
|
(pendingOpts.packages?.length ?? 0) > 0 ||
|
|
@@ -1720,49 +2227,43 @@ export class QuickCHR {
|
|
|
1720
2227
|
pendingOpts.secureLogin === true
|
|
1721
2228
|
);
|
|
1722
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);
|
|
1723
2240
|
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
1724
2241
|
}
|
|
1725
2242
|
}
|
|
1726
2243
|
// Exists but stopped — restart it
|
|
1727
|
-
|
|
2244
|
+
requireHostFor(existing.arch);
|
|
2245
|
+
return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
|
|
1728
2246
|
}
|
|
1729
2247
|
|
|
1730
|
-
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
? opts.portBase
|
|
1734
|
-
: await findAvailablePortBlock(
|
|
1735
|
-
usedBases,
|
|
1736
|
-
opts.excludePorts,
|
|
1737
|
-
opts.extraPorts,
|
|
1738
|
-
);
|
|
1739
|
-
|
|
1740
|
-
validateExplicitExtraPorts(
|
|
1741
|
-
opts.extraPorts,
|
|
1742
|
-
portBase,
|
|
1743
|
-
opts.excludePorts,
|
|
1744
|
-
loadAllMachines(),
|
|
1745
|
-
name,
|
|
1746
|
-
);
|
|
1747
|
-
|
|
1748
|
-
const ports = buildPortMappings(
|
|
1749
|
-
portBase,
|
|
1750
|
-
opts.excludePorts,
|
|
1751
|
-
opts.extraPorts,
|
|
1752
|
-
);
|
|
2248
|
+
assertRouterOs6Arch(version, arch);
|
|
2249
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
2250
|
+
if (!opts.dryRun) requireHostFor(arch);
|
|
1753
2251
|
|
|
1754
|
-
// 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.
|
|
1755
2254
|
if (opts.dryRun) {
|
|
1756
2255
|
const machineDir = getMachineDir(name);
|
|
2256
|
+
const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
|
|
1757
2257
|
const state: MachineState = {
|
|
1758
2258
|
name,
|
|
1759
2259
|
version,
|
|
1760
2260
|
arch,
|
|
1761
2261
|
cpu: opts.cpu ?? 1,
|
|
1762
2262
|
mem: defaultMem(arch, opts.mem),
|
|
1763
|
-
networks
|
|
2263
|
+
networks,
|
|
1764
2264
|
ports,
|
|
1765
2265
|
packages: opts.packages ?? [],
|
|
2266
|
+
installAllPackages: opts.installAllPackages,
|
|
1766
2267
|
deviceMode: requestedDeviceMode,
|
|
1767
2268
|
user: opts.user,
|
|
1768
2269
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1780,13 +2281,31 @@ export class QuickCHR {
|
|
|
1780
2281
|
return createInstance(state);
|
|
1781
2282
|
}
|
|
1782
2283
|
|
|
1783
|
-
// Acquire a lock to prevent concurrent starts of the same machine
|
|
1784
2284
|
const machineDir = getMachineDir(name);
|
|
1785
2285
|
assertSufficientQuickchrStorage(`start CHR ${name}`);
|
|
1786
2286
|
const dirCreatedHere = !existsSync(machineDir);
|
|
1787
|
-
|
|
1788
|
-
const
|
|
1789
|
-
|
|
2287
|
+
|
|
2288
|
+
const background = opts.background ?? true;
|
|
2289
|
+
|
|
2290
|
+
// Provisioning includes any work that requires the machine to have booted first.
|
|
2291
|
+
// When foreground mode is requested WITH provisioning, we must boot in background,
|
|
2292
|
+
// provision (packages/user/license), then attach the serial socket to stdio.
|
|
2293
|
+
// Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
|
|
2294
|
+
const hasProvisioning = !!(
|
|
2295
|
+
opts.installAllPackages ||
|
|
2296
|
+
(opts.packages && opts.packages.length > 0) ||
|
|
2297
|
+
opts.user ||
|
|
2298
|
+
opts.disableAdmin ||
|
|
2299
|
+
opts.license ||
|
|
2300
|
+
hasDeviceModeProvisioning ||
|
|
2301
|
+
opts.secureLogin === true
|
|
2302
|
+
);
|
|
2303
|
+
|
|
2304
|
+
const { lock, portBase, ports, networks: networkConfigs } = await claimNewMachine(
|
|
2305
|
+
name,
|
|
2306
|
+
!background && !hasProvisioning ? "foreground" : "start",
|
|
2307
|
+
opts,
|
|
2308
|
+
);
|
|
1790
2309
|
// Endpoints are claimed before QEMU is spawned, because resolution needs to know
|
|
1791
2310
|
// which end this machine holds. A launch that fails before its state is persisted
|
|
1792
2311
|
// has no instance lifecycle to release them later, so a failed start would leave a
|
|
@@ -1809,24 +2328,6 @@ export class QuickCHR {
|
|
|
1809
2328
|
const bootDisk = diskArtifacts.bootDisk;
|
|
1810
2329
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
1811
2330
|
|
|
1812
|
-
// Build machine config
|
|
1813
|
-
const background = opts.background ?? true;
|
|
1814
|
-
|
|
1815
|
-
// Provisioning includes any work that requires the machine to have booted first.
|
|
1816
|
-
// When foreground mode is requested WITH provisioning, we must boot in background,
|
|
1817
|
-
// provision (packages/user/license), then attach the serial socket to stdio.
|
|
1818
|
-
// Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
|
|
1819
|
-
const hasProvisioning = !!(
|
|
1820
|
-
opts.installAllPackages ||
|
|
1821
|
-
(opts.packages && opts.packages.length > 0) ||
|
|
1822
|
-
opts.user ||
|
|
1823
|
-
opts.disableAdmin ||
|
|
1824
|
-
opts.license ||
|
|
1825
|
-
hasDeviceModeProvisioning ||
|
|
1826
|
-
opts.secureLogin === true
|
|
1827
|
-
);
|
|
1828
|
-
|
|
1829
|
-
const networkConfigs = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs());
|
|
1830
2331
|
if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
|
|
1831
2332
|
throw new QuickCHRError(
|
|
1832
2333
|
"NETWORK_UNAVAILABLE",
|
|
@@ -1845,6 +2346,7 @@ export class QuickCHR {
|
|
|
1845
2346
|
networks: networkConfigs,
|
|
1846
2347
|
ports,
|
|
1847
2348
|
packages: opts.packages ?? [],
|
|
2349
|
+
installAllPackages: opts.installAllPackages,
|
|
1848
2350
|
deviceMode: requestedDeviceMode,
|
|
1849
2351
|
user: opts.user,
|
|
1850
2352
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1914,6 +2416,7 @@ export class QuickCHR {
|
|
|
1914
2416
|
unpersistedSocketClaim = undefined;
|
|
1915
2417
|
|
|
1916
2418
|
const instance = createInstance(state);
|
|
2419
|
+
if (opts.wait === false) return instance;
|
|
1917
2420
|
|
|
1918
2421
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1919
2422
|
|
|
@@ -1963,7 +2466,7 @@ export class QuickCHR {
|
|
|
1963
2466
|
"BOOT_TIMEOUT",
|
|
1964
2467
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1965
2468
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1966
|
-
await cleanupAfterBootFailure(instance, name, failure) +
|
|
2469
|
+
await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
|
|
1967
2470
|
`\n${failure.summary}`,
|
|
1968
2471
|
);
|
|
1969
2472
|
}
|
|
@@ -1987,15 +2490,20 @@ export class QuickCHR {
|
|
|
1987
2490
|
} catch { /* never propagate */ }
|
|
1988
2491
|
|
|
1989
2492
|
if (hasProvisioning) {
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
+
}
|
|
1999
2507
|
}
|
|
2000
2508
|
|
|
2001
2509
|
// Foreground + provisioning: all provisioning is done in background mode.
|
|
@@ -2003,12 +2511,12 @@ export class QuickCHR {
|
|
|
2003
2511
|
// QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
|
|
2004
2512
|
// shortcuts that are well-documented and googleable).
|
|
2005
2513
|
if (!background && hasProvisioning) {
|
|
2006
|
-
await
|
|
2514
|
+
await stopUnderLock(state);
|
|
2007
2515
|
// Brief pause for QEMU to flush and release the disk image
|
|
2008
2516
|
await Bun.sleep(1000);
|
|
2009
|
-
// Release lock before re-launching — _launchExisting acquires its own.
|
|
2010
|
-
// The outer finally
|
|
2011
|
-
|
|
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();
|
|
2012
2520
|
return QuickCHR._launchExisting(state, false, undefined, logger);
|
|
2013
2521
|
}
|
|
2014
2522
|
|
|
@@ -2026,11 +2534,11 @@ export class QuickCHR {
|
|
|
2026
2534
|
// that already exist, and a directory this call did not create is never ours
|
|
2027
2535
|
// to delete, whatever state it is in.
|
|
2028
2536
|
if (dirCreatedHere && isOrphanMachineDir(name)) {
|
|
2029
|
-
try {
|
|
2537
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
2030
2538
|
}
|
|
2031
2539
|
throw err;
|
|
2032
2540
|
} finally {
|
|
2033
|
-
|
|
2541
|
+
lock.release();
|
|
2034
2542
|
}
|
|
2035
2543
|
}
|
|
2036
2544
|
|
|
@@ -2052,6 +2560,9 @@ export class QuickCHR {
|
|
|
2052
2560
|
logger?: ProgressLogger,
|
|
2053
2561
|
): Promise<void> {
|
|
2054
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[] = [];
|
|
2055
2566
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
2056
2567
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
2057
2568
|
assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
|
|
@@ -2082,6 +2593,7 @@ export class QuickCHR {
|
|
|
2082
2593
|
saveMachine(current);
|
|
2083
2594
|
}
|
|
2084
2595
|
machineState.packages = installed;
|
|
2596
|
+
applied.push("packages");
|
|
2085
2597
|
} else if (opts.packages && opts.packages.length > 0) {
|
|
2086
2598
|
const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
|
|
2087
2599
|
await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
|
|
@@ -2091,10 +2603,14 @@ export class QuickCHR {
|
|
|
2091
2603
|
saveMachine(current);
|
|
2092
2604
|
}
|
|
2093
2605
|
machineState.packages = installed;
|
|
2606
|
+
applied.push("packages");
|
|
2094
2607
|
}
|
|
2095
2608
|
|
|
2096
2609
|
if (hasDeviceModeProvisioning) {
|
|
2097
|
-
|
|
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");
|
|
2098
2614
|
}
|
|
2099
2615
|
|
|
2100
2616
|
if (opts.license) {
|
|
@@ -2123,6 +2639,7 @@ export class QuickCHR {
|
|
|
2123
2639
|
saveMachine(current);
|
|
2124
2640
|
}
|
|
2125
2641
|
machineState.licenseLevel = actualLevel as LicenseLevel;
|
|
2642
|
+
applied.push("license");
|
|
2126
2643
|
log.status(` License applied: free → ${actualLevel}`);
|
|
2127
2644
|
} catch (e) {
|
|
2128
2645
|
if (e instanceof QuickCHRError) throw e;
|
|
@@ -2134,6 +2651,9 @@ export class QuickCHR {
|
|
|
2134
2651
|
}
|
|
2135
2652
|
|
|
2136
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");
|
|
2137
2657
|
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
|
|
2138
2658
|
if (result.user || result.managedSshKey) {
|
|
2139
2659
|
// Persist user info + managed SSH key fact in state (password placeholder —
|
|
@@ -2161,6 +2681,11 @@ export class QuickCHR {
|
|
|
2161
2681
|
}
|
|
2162
2682
|
}
|
|
2163
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);
|
|
2164
2689
|
}
|
|
2165
2690
|
|
|
2166
2691
|
/** Re-launch an existing stopped machine. */
|
|
@@ -2177,14 +2702,47 @@ export class QuickCHR {
|
|
|
2177
2702
|
secureLogin?: boolean;
|
|
2178
2703
|
},
|
|
2179
2704
|
logger?: ProgressLogger,
|
|
2705
|
+
wait = true,
|
|
2180
2706
|
): Promise<ChrInstance> {
|
|
2181
|
-
const
|
|
2182
|
-
|
|
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
|
+
);
|
|
2183
2724
|
// Same pre-persistence window as start(): a relaunch that claims an endpoint and
|
|
2184
2725
|
// then fails to spawn would keep it, with no instance to release it.
|
|
2185
2726
|
let unpersistedSocketClaim: MachineState | undefined;
|
|
2186
2727
|
try {
|
|
2187
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
|
+
|
|
2188
2746
|
const diskPath = join(state.machineDir, "disk.img");
|
|
2189
2747
|
if (!existsSync(diskPath)) {
|
|
2190
2748
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
|
|
@@ -2199,19 +2757,17 @@ export class QuickCHR {
|
|
|
2199
2757
|
const bootDisk = diskArtifacts.bootDisk;
|
|
2200
2758
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
2201
2759
|
|
|
2202
|
-
const hasProvisioning = !!(
|
|
2203
|
-
provisioningOpts && (
|
|
2204
|
-
provisioningOpts.installAllPackages ||
|
|
2205
|
-
(provisioningOpts.packages?.length ?? 0) > 0 ||
|
|
2206
|
-
provisioningOpts.deviceMode ||
|
|
2207
|
-
provisioningOpts.user ||
|
|
2208
|
-
provisioningOpts.disableAdmin ||
|
|
2209
|
-
provisioningOpts.license ||
|
|
2210
|
-
provisioningOpts.secureLogin === true
|
|
2211
|
-
)
|
|
2212
|
-
);
|
|
2213
2760
|
// Always boot in background when provisioning is needed
|
|
2214
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" };
|
|
2215
2771
|
|
|
2216
2772
|
const platform = await detectPlatform();
|
|
2217
2773
|
const accel = await detectAccel(state.arch);
|
|
@@ -2263,6 +2819,7 @@ export class QuickCHR {
|
|
|
2263
2819
|
unpersistedSocketClaim = undefined;
|
|
2264
2820
|
|
|
2265
2821
|
const instance = createInstance(state);
|
|
2822
|
+
if (!wait) return instance;
|
|
2266
2823
|
|
|
2267
2824
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
2268
2825
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
@@ -2292,7 +2849,7 @@ export class QuickCHR {
|
|
|
2292
2849
|
"BOOT_TIMEOUT",
|
|
2293
2850
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
2294
2851
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
2295
|
-
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2852
|
+
await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
|
|
2296
2853
|
`\n${failure.summary}`,
|
|
2297
2854
|
);
|
|
2298
2855
|
}
|
|
@@ -2314,7 +2871,11 @@ export class QuickCHR {
|
|
|
2314
2871
|
} catch { /* never propagate */ }
|
|
2315
2872
|
|
|
2316
2873
|
if (hasProvisioning && provisioningOpts) {
|
|
2317
|
-
|
|
2874
|
+
try {
|
|
2875
|
+
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
throw await failProvisioning(err, instance, beforeLaunch);
|
|
2878
|
+
}
|
|
2318
2879
|
}
|
|
2319
2880
|
|
|
2320
2881
|
try {
|
|
@@ -2330,7 +2891,7 @@ export class QuickCHR {
|
|
|
2330
2891
|
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2331
2892
|
throw err;
|
|
2332
2893
|
} finally {
|
|
2333
|
-
|
|
2894
|
+
lock.release();
|
|
2334
2895
|
}
|
|
2335
2896
|
}
|
|
2336
2897
|
|
|
@@ -2386,20 +2947,20 @@ export class QuickCHR {
|
|
|
2386
2947
|
// A create still in flight is indistinguishable from an orphan by directory
|
|
2387
2948
|
// contents alone — add()/start() write machine.json last, so the window between
|
|
2388
2949
|
// ensureDir() and saveMachine() looks exactly like a half-made machine. The
|
|
2389
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
return
|
|
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();
|
|
2399
2963
|
}
|
|
2400
|
-
|
|
2401
|
-
removeState(name);
|
|
2402
|
-
return true;
|
|
2403
2964
|
}
|
|
2404
2965
|
|
|
2405
2966
|
/** Run doctor checks for prerequisites. */
|