@tikoci/quickchr 0.4.8 → 0.5.1
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 +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- 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/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- 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 +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- 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";
|
|
@@ -58,19 +62,20 @@ import {
|
|
|
58
62
|
getDataDir,
|
|
59
63
|
appendBootLog,
|
|
60
64
|
} from "./state.ts";
|
|
61
|
-
import { ensureCachedImage,
|
|
65
|
+
import { ensureCachedImage, 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";
|
|
65
|
-
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
68
|
+
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
69
|
+
import { bootDiskFormatOf, bootDiskPath, cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, writeBootDisk, 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,20 @@ 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
|
-
import {
|
|
93
|
-
import {
|
|
106
|
+
import { admitStorage, estimateMachineCreation, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
107
|
+
import { pinCacheEntries } from "./storage-lease.ts";
|
|
108
|
+
import { existsSync, rmSync } from "node:fs";
|
|
109
|
+
import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
|
|
94
110
|
import { join } from "node:path";
|
|
95
111
|
|
|
96
112
|
// --- Architecture-aware defaults ---
|
|
@@ -140,6 +156,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
|
|
|
140
156
|
return opts;
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
/** `version` and `channel` both name the release, so a caller passing both has asked
|
|
160
|
+
* for two things and gets one: `version` wins. Say so rather than drop `channel` in
|
|
161
|
+
* silence — the TSDoc used to call them "mutually exclusive" while nothing enforced
|
|
162
|
+
* it (#178). A warning, not a throw, matching the channel-name-as-version case. */
|
|
163
|
+
function warnVersionWithChannel(opts: StartOptions, logger: ProgressLogger): void {
|
|
164
|
+
if (opts.version && opts.channel) {
|
|
165
|
+
logger.warn(`Both version "${opts.version}" and channel "${opts.channel}" were given — using version ${opts.version}; channel is ignored.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
143
169
|
function listProvisioningMutations(opts: {
|
|
144
170
|
installAllPackages?: boolean;
|
|
145
171
|
packages?: string[];
|
|
@@ -185,6 +211,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
|
|
|
185
211
|
return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
|
|
186
212
|
}
|
|
187
213
|
|
|
214
|
+
/** `wait: false` returns before the guest has booted, and provisioning needs a booted
|
|
215
|
+
* guest — so the pair is refused by name rather than the provisioning silently
|
|
216
|
+
* skipped (#159). `machine` is set when the provisioning is first-boot intent that
|
|
217
|
+
* `add()` recorded, which the caller may not have typed on this command line. */
|
|
218
|
+
function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
|
|
219
|
+
if (opts.wait !== false) return;
|
|
220
|
+
const source = machine
|
|
221
|
+
? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
|
|
222
|
+
: `provisioning was requested (${operation})`;
|
|
223
|
+
throw new QuickCHRError(
|
|
224
|
+
"INVALID_ARGUMENT",
|
|
225
|
+
`--no-wait (wait: false) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so start has to wait for it. ` +
|
|
226
|
+
"Drop --no-wait for this first boot — later starts can use it.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
188
230
|
// --- Socket registry lifecycle helpers ---
|
|
189
231
|
|
|
190
232
|
function getSocketNamedNetworks(state: MachineState): string[] {
|
|
@@ -316,6 +358,189 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
|
|
|
316
358
|
return null;
|
|
317
359
|
}
|
|
318
360
|
|
|
361
|
+
/** Put a machine's disks back to the factory image and drop the credential material
|
|
362
|
+
* that authenticated to the erased guest. Machine state is the caller's to update:
|
|
363
|
+
* `clean()` clears the guest facts and keeps intent, a provisioning rollback restores
|
|
364
|
+
* the record it had before the failed run. QEMU must already be stopped. */
|
|
365
|
+
async function resetGuestToFactory(state: MachineState): Promise<void> {
|
|
366
|
+
// NOTE: this deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
|
|
367
|
+
// machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
|
|
368
|
+
// disk), not OS state; wiping it forces a full device scan (~480s) on the next
|
|
369
|
+
// boot. The fresh disk re-copy below is a sufficient factory reset. Regression
|
|
370
|
+
// anchor: the "clean() resets disk to factory defaults" arm64 integration test
|
|
371
|
+
// (120s reboot wait would blow past 480s if the wipe were reintroduced).
|
|
372
|
+
// Re-copy fresh image from cache
|
|
373
|
+
const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
|
|
374
|
+
if (!existsSync(imgPath)) {
|
|
375
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
|
|
376
|
+
}
|
|
377
|
+
// Drop boot.qcow2 and the extra disks, then rebuild the boot disk from the cache.
|
|
378
|
+
// A raw disk.img is replaced atomically. On a qcow2 machine, disk.img is the unused
|
|
379
|
+
// raw copy machines created before 0.5.1 kept: the new boot.qcow2 is converted from
|
|
380
|
+
// the cache, so the copy goes too (#199).
|
|
381
|
+
const format = bootDiskFormatOf(state);
|
|
382
|
+
cleanDiskFiles(state.machineDir);
|
|
383
|
+
await writeBootDisk(state.machineDir, imgPath, format, state.bootSize);
|
|
384
|
+
if (format === "qcow2") rmSync(join(state.machineDir, "disk.img"), { force: true });
|
|
385
|
+
await ensureConfiguredDisks(state.machineDir, state.bootSize, state.extraDisks, format, { booted: false });
|
|
386
|
+
|
|
387
|
+
deleteInstanceCredentials(state.name);
|
|
388
|
+
// The managed keypair authenticated to the account factory reset erased,
|
|
389
|
+
// so it is dead credential material sitting in the machine dir. Removing
|
|
390
|
+
// it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
|
|
391
|
+
// existing file exits 1 with "already exists" rather than overwriting
|
|
392
|
+
// (verified locally), so a leftover key would break `installSshKey()`.
|
|
393
|
+
rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Options every machine-lock acquisition passes: the pre-0.5 lock to honour, and the
|
|
397
|
+
* noun for `MACHINE_LOCKED`. */
|
|
398
|
+
function machineLockOptions(name: string, machineDir: string): { legacyPath: string; subject: string } {
|
|
399
|
+
return { legacyPath: legacyMachineLockPath(machineDir), subject: `Machine "${name}"` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The machine lock outlives its machine (see lock.ts), so a caller that has just
|
|
403
|
+
* taken it must check that the machine it was asked about is still there, and is not
|
|
404
|
+
* another machine created under the same name while it waited. Acting on the old
|
|
405
|
+
* snapshot would mean stopping a stale pid. */
|
|
406
|
+
function assertSameMachine(state: MachineState): void {
|
|
407
|
+
const fresh = loadMachine(state.name);
|
|
408
|
+
if (!fresh || fresh.createdAt !== state.createdAt) {
|
|
409
|
+
throw new QuickCHRError(
|
|
410
|
+
"MACHINE_NOT_FOUND",
|
|
411
|
+
`Machine "${state.name}" was removed${fresh ? " and created again" : ""} while this operation waited for it.`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** How long `stop`/`clean`/`remove` wait for an operation already holding the machine
|
|
417
|
+
* lock. The holder is almost always a start or relaunch waiting for boot, so the wait
|
|
418
|
+
* is that boot budget: a `stop` issued during a `start` runs after it rather than
|
|
419
|
+
* failing, and a holder that never finishes still cannot block it forever (#181). */
|
|
420
|
+
function lifecycleLockWaitMs(state: MachineState): number {
|
|
421
|
+
const withPackages = !!state.installAllPackages || (state.packages?.length ?? 0) > 0;
|
|
422
|
+
return defaultBootTimeout(state.arch, withPackages, state.lastAccel);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Run a lifecycle operation under the machine's lock, waiting for a current holder.
|
|
426
|
+
* State is re-read once the lock is held: a handle's snapshot predates whatever the
|
|
427
|
+
* holder just did, and `state.pid` read from it may name a process that has exited or
|
|
428
|
+
* one the OS has since reused (#180, #181). */
|
|
429
|
+
async function withMachineLock<T>(state: MachineState, op: LockOperation, fn: () => Promise<T>): Promise<T> {
|
|
430
|
+
const lock = await acquireLock(machineLockPath(state.machineDir), op, {
|
|
431
|
+
...machineLockOptions(state.name, state.machineDir),
|
|
432
|
+
waitMs: lifecycleLockWaitMs(state),
|
|
433
|
+
onWait: (holder) => createLogger().warn(
|
|
434
|
+
`Machine "${state.name}" is busy with \`${holder.op}\` (pid ${holder.pid}) — waiting for it to finish before \`${op}\`.`,
|
|
435
|
+
),
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
assertSameMachine(state);
|
|
439
|
+
refreshMachineState(state);
|
|
440
|
+
return await fn();
|
|
441
|
+
} finally {
|
|
442
|
+
lock.release();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The body of `stop()`, for a caller that already holds the machine lock. The lock is
|
|
447
|
+
* not reentrant, so code inside a locked start or relaunch calls this, never
|
|
448
|
+
* `instance.stop()`. */
|
|
449
|
+
async function stopUnderLock(state: MachineState): Promise<void> {
|
|
450
|
+
if (state.pid) {
|
|
451
|
+
await stopQemu(state.pid);
|
|
452
|
+
}
|
|
453
|
+
finalizeStoppedState(state);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
|
|
457
|
+
function finalizeStoppedState(state: MachineState): void {
|
|
458
|
+
unregisterSocketMembers(state);
|
|
459
|
+
// Update persisted state
|
|
460
|
+
const current = loadMachine(state.name);
|
|
461
|
+
if (current) {
|
|
462
|
+
current.status = "stopped";
|
|
463
|
+
current.pid = undefined;
|
|
464
|
+
saveMachine(current);
|
|
465
|
+
}
|
|
466
|
+
state.status = "stopped";
|
|
467
|
+
state.pid = undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The body of `remove()`, for a caller that already holds the machine lock. The lock
|
|
471
|
+
* file goes with the directory. */
|
|
472
|
+
async function removeUnderLock(state: MachineState): Promise<void> {
|
|
473
|
+
if (state.pid && isMachineRunning(state)) {
|
|
474
|
+
await stopQemu(state.pid);
|
|
475
|
+
}
|
|
476
|
+
unregisterSocketMembers(state);
|
|
477
|
+
// Clean up stored instance credentials
|
|
478
|
+
deleteInstanceCredentials(state.name);
|
|
479
|
+
removeState(state.name);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The body of `clean()`, for a caller that already holds the machine lock. */
|
|
483
|
+
async function cleanUnderLock(state: MachineState): Promise<void> {
|
|
484
|
+
if (state.pid && isMachineRunning(state)) {
|
|
485
|
+
await stopQemu(state.pid);
|
|
486
|
+
}
|
|
487
|
+
// Same invariant as stop()/remove(): a machine that is not running holds no
|
|
488
|
+
// endpoint. clean() stops QEMU directly rather than through stop(), so without
|
|
489
|
+
// this a cleaned machine keeps a pair link occupied and blocks its removal.
|
|
490
|
+
unregisterSocketMembers(state);
|
|
491
|
+
await resetGuestToFactory(state);
|
|
492
|
+
|
|
493
|
+
// Every guest-side credential went with the old disk, so the credential
|
|
494
|
+
// facts in machine.json now describe a machine that no longer exists.
|
|
495
|
+
// Left in place, `resolveAuth()` /
|
|
496
|
+
// `resolveCreds()` prefer `state.user` and authenticate every REST call,
|
|
497
|
+
// exec, and SCP as a deleted user — the confound #79 removes here.
|
|
498
|
+
// Clearing them restores the documented fallback: factory `admin` with an
|
|
499
|
+
// empty password, which is what a fresh CHR image actually answers to
|
|
500
|
+
// (anchored by the "clean() resets disk to factory defaults" integration
|
|
501
|
+
// test). `disableAdmin` goes with them because it is read as a live fact
|
|
502
|
+
// about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
|
|
503
|
+
// image has admin enabled again. `licenseLevel` goes with them for the same
|
|
504
|
+
// reason: it is a read-back of the license the erased disk held, not an
|
|
505
|
+
// intent — quickchr never persists the license *input* — so leaving it
|
|
506
|
+
// behind would report a level the fresh image does not have. Provisioning
|
|
507
|
+
// *intent* — packages, deviceMode, secureLogin — is not guest state and
|
|
508
|
+
// survives, and is what the next `start()` re-applies now that the window
|
|
509
|
+
// reopens.
|
|
510
|
+
// (`resetGuestToFactory()` has already dropped the secret-store entry and the
|
|
511
|
+
// managed keypair, which describe the same erased account.)
|
|
512
|
+
|
|
513
|
+
// Update state. `provisioning` and `lastStartedAt` go with the credentials:
|
|
514
|
+
// both describe a guest that no longer exists, and together they are what
|
|
515
|
+
// closes the provisioning window. Leaving them behind is what stopped a cleaned
|
|
516
|
+
// machine from ever being provisioned again — the disk was factory-fresh and
|
|
517
|
+
// `start()` still refused to provision it (#176). `cleanedAt` keeps the
|
|
518
|
+
// forensic clue that the timestamp carried.
|
|
519
|
+
const cleanedAt = new Date().toISOString();
|
|
520
|
+
const current = loadMachine(state.name);
|
|
521
|
+
if (current) {
|
|
522
|
+
current.status = "stopped";
|
|
523
|
+
current.pid = undefined;
|
|
524
|
+
current.user = undefined;
|
|
525
|
+
current.managedSshKey = undefined;
|
|
526
|
+
current.disableAdmin = undefined;
|
|
527
|
+
current.provisioning = undefined;
|
|
528
|
+
current.lastStartedAt = undefined;
|
|
529
|
+
current.licenseLevel = undefined;
|
|
530
|
+
current.cleanedAt = cleanedAt;
|
|
531
|
+
saveMachine(current);
|
|
532
|
+
}
|
|
533
|
+
state.status = "stopped";
|
|
534
|
+
state.pid = undefined;
|
|
535
|
+
state.user = undefined;
|
|
536
|
+
state.managedSshKey = undefined;
|
|
537
|
+
state.disableAdmin = undefined;
|
|
538
|
+
state.provisioning = undefined;
|
|
539
|
+
state.lastStartedAt = undefined;
|
|
540
|
+
state.licenseLevel = undefined;
|
|
541
|
+
state.cleanedAt = cleanedAt;
|
|
542
|
+
}
|
|
543
|
+
|
|
319
544
|
/** Create a ChrInstance handle from persisted MachineState. */
|
|
320
545
|
function createInstance(state: MachineState): ChrInstance {
|
|
321
546
|
const ports = toChrPorts(state.ports);
|
|
@@ -404,14 +629,16 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
404
629
|
// keeps the excludePorts fix: excluding "http" while keeping "https" now
|
|
405
630
|
// resolves rest-api onto the surviving secure port instead of a port that
|
|
406
631
|
// doesn't exist.
|
|
407
|
-
const restApi =
|
|
408
|
-
state.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
632
|
+
const restApi: ServiceEndpoint = isRouterOs6(state.version)
|
|
633
|
+
? { available: false, unavailableReason: `RouterOS ${state.version} has no REST API — use native-api` }
|
|
634
|
+
: buildHttpService(
|
|
635
|
+
state.ports.https,
|
|
636
|
+
state.ports.http,
|
|
637
|
+
"rest-api",
|
|
638
|
+
{ secure: "https", plain: "http" },
|
|
639
|
+
{ username: creds.user, password: creds.password, basic, header: auth.header },
|
|
640
|
+
"/rest",
|
|
641
|
+
);
|
|
415
642
|
const nativeApi = buildHttpService(
|
|
416
643
|
state.ports["api-ssl"],
|
|
417
644
|
state.ports.api,
|
|
@@ -498,11 +725,11 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
498
725
|
portBase: state.portBase,
|
|
499
726
|
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
500
727
|
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
728
|
|
|
505
729
|
async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
|
|
730
|
+
if (readinessProbeFor(state.version) === "api") {
|
|
731
|
+
return waitForApiReady(ports.api, timeoutMs ?? 120_000, resolveCreds(state), stats);
|
|
732
|
+
}
|
|
506
733
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
507
734
|
// on authenticated machines (post-provisioning, post-install reboots).
|
|
508
735
|
const auth = resolveAuth(state);
|
|
@@ -523,101 +750,32 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
523
750
|
},
|
|
524
751
|
|
|
525
752
|
async stop(): Promise<void> {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
753
|
+
await withMachineLock(state, "stop", () => stopUnderLock(state));
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
async shutdown(options) {
|
|
757
|
+
const timeoutMs = validateShutdownTimeout(options?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
758
|
+
return withMachineLock(state, "shutdown", async () => {
|
|
759
|
+
if (!state.pid || !isMachineRunning(state)) {
|
|
760
|
+
finalizeStoppedState(state);
|
|
761
|
+
return { outcome: "already-stopped" };
|
|
762
|
+
}
|
|
763
|
+
const pid = state.pid;
|
|
764
|
+
const result = await shutdownGuest(timeoutMs, async (requestTimeoutMs) => {
|
|
765
|
+
// Rebuild after the locked refresh: a waiting handle may predate a relaunch.
|
|
766
|
+
return createInstance(state).exec("/system/shutdown", { via: "rest", timeout: requestTimeoutMs });
|
|
767
|
+
}, () => isMachineRunning(state), () => stopQemu(pid));
|
|
768
|
+
finalizeStoppedState(state);
|
|
769
|
+
return result;
|
|
770
|
+
});
|
|
539
771
|
},
|
|
540
772
|
|
|
541
773
|
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);
|
|
774
|
+
await withMachineLock(state, "remove", () => removeUnderLock(state));
|
|
549
775
|
},
|
|
550
776
|
|
|
551
777
|
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;
|
|
778
|
+
await withMachineLock(state, "clean", () => cleanUnderLock(state));
|
|
621
779
|
},
|
|
622
780
|
|
|
623
781
|
async monitor(command: string): Promise<string> {
|
|
@@ -633,6 +791,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
633
791
|
},
|
|
634
792
|
|
|
635
793
|
async rest(path: string, opts?: RequestInit): Promise<unknown> {
|
|
794
|
+
assertRestAvailable(state, "rest()");
|
|
636
795
|
const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
|
|
637
796
|
|
|
638
797
|
// Retry on ECONNRESET — RouterOS can transiently reset connections in the
|
|
@@ -722,6 +881,9 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
722
881
|
`exec transport "${via}" is not yet implemented`,
|
|
723
882
|
);
|
|
724
883
|
}
|
|
884
|
+
// Before the auto→console fallback below: that fallback exists for an
|
|
885
|
+
// unreachable REST endpoint, not for one that cannot exist.
|
|
886
|
+
assertRestAvailable(state, "exec");
|
|
725
887
|
const auth = resolveAuth(state, opts?.user, opts?.password);
|
|
726
888
|
if (via === "rest") {
|
|
727
889
|
return restExecute(restUrl, auth, command, opts);
|
|
@@ -769,15 +931,62 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
769
931
|
const log = logger ?? createLogger();
|
|
770
932
|
const resolved = resolveDeviceModeOptions(options);
|
|
771
933
|
for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
934
|
+
if (!shouldApplyDeviceMode(resolved)) {
|
|
935
|
+
throw new QuickCHRError(
|
|
936
|
+
"INVALID_ARGUMENT",
|
|
937
|
+
`Nothing to set: device-mode options for "${state.name}" resolved to no change ` +
|
|
938
|
+
"(mode=skip disables device-mode entirely; pass a mode or a feature to enable/disable).",
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
// The same machine lock every other relaunch takes. This one is a relaunch:
|
|
942
|
+
// it terminates QEMU, spawns a replacement and rewrites `machine.json`, so a
|
|
943
|
+
// concurrent `start` would otherwise spawn a second QEMU into the power-cycle
|
|
944
|
+
// window and both would persist over each other. Fail-fast like a second
|
|
945
|
+
// `start`: waiting would only power-cycle the machine twice.
|
|
946
|
+
const lock = acquireLockNow(machineLockPath(state.machineDir), "device-mode", machineLockOptions(state.name, state.machineDir));
|
|
947
|
+
try {
|
|
948
|
+
assertSameMachine(state);
|
|
949
|
+
// Re-read under the lock before anything reads `state`. This handle's
|
|
950
|
+
// snapshot is taken once, at `createInstance`, and a library consumer can
|
|
951
|
+
// hold it across another process's stop/start — after which `state.pid`
|
|
952
|
+
// names a process that has exited (a false MACHINE_STOPPED) or, worse, one
|
|
953
|
+
// the OS has since reused, which `hardRebootMachine()` would then
|
|
954
|
+
// terminate. `state.provisioning` and `state.deviceMode` go stale the same
|
|
955
|
+
// way, and both feed `appliedDeviceModeRecord()`.
|
|
956
|
+
refreshMachineState(state);
|
|
957
|
+
// Inside the lock: the applicability check has to hold for the operation,
|
|
958
|
+
// not merely at the moment it was made.
|
|
959
|
+
assertDeviceModeApplicable(state);
|
|
960
|
+
const launchConfig = await buildLaunchConfigFromState(state);
|
|
961
|
+
// Post-boot auth, not factory admin: a machine provisioned with
|
|
962
|
+
// `--disable-admin` answers 401 to `admin:`, and those are exactly the
|
|
963
|
+
// machines this route exists for.
|
|
964
|
+
await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log, resolveAuth(state).header);
|
|
965
|
+
|
|
966
|
+
// Persist what is now true of the guest: the applied selection folded into
|
|
967
|
+
// what was already *applied*, not the request on its own. Device-mode
|
|
968
|
+
// updates are cumulative — only the settings a request names move — so
|
|
969
|
+
// overwriting would leave `machine.json` describing a machine that does
|
|
970
|
+
// not exist.
|
|
971
|
+
//
|
|
972
|
+
// `appliedDeviceModeRecord` gates the carry-forward on the step record, for
|
|
973
|
+
// the reason #176 keeps running into: desired config is not evidence that
|
|
974
|
+
// a step ran.
|
|
975
|
+
const deviceMode = appliedDeviceModeRecord(state, resolved);
|
|
976
|
+
const current = loadMachine(state.name);
|
|
977
|
+
if (current) {
|
|
978
|
+
current.deviceMode = deviceMode;
|
|
979
|
+
saveMachine(current);
|
|
980
|
+
}
|
|
981
|
+
state.deviceMode = deviceMode;
|
|
982
|
+
// This is quickchr applying a provisioning step, so it belongs in the
|
|
983
|
+
// record the window reads (#176). It does not reopen the window — it adds
|
|
984
|
+
// the one step that just ran, so a later `start` passing the same
|
|
985
|
+
// device-mode is recognised as already applied instead of refused.
|
|
986
|
+
recordProvisioningStep(state, "deviceMode");
|
|
987
|
+
} finally {
|
|
988
|
+
lock.release();
|
|
779
989
|
}
|
|
780
|
-
state.deviceMode = options;
|
|
781
990
|
},
|
|
782
991
|
|
|
783
992
|
async availablePackages(): Promise<string[]> {
|
|
@@ -789,21 +998,28 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
789
998
|
const names = typeof packages === "string" ? [packages] : packages;
|
|
790
999
|
if (names.length === 0) return [];
|
|
791
1000
|
|
|
792
|
-
|
|
1001
|
+
// A booted machine no longer holds its package cache in use, so pin it until the
|
|
1002
|
+
// upload is done: a concurrent eviction must not remove it mid-read.
|
|
1003
|
+
const pin = pinCacheEntries(`install packages on ${state.name}`, [`packages-${state.arch}-${state.version}`]);
|
|
793
1004
|
const packagePaths: string[] = [];
|
|
794
1005
|
const installed: string[] = [];
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1006
|
+
try {
|
|
1007
|
+
const extractDir = await downloadPackages(state.version, state.arch);
|
|
1008
|
+
for (const pkg of names) {
|
|
1009
|
+
const pkgPath = findPackageFile(extractDir, pkg);
|
|
1010
|
+
if (!pkgPath) {
|
|
1011
|
+
console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
packagePaths.push(pkgPath);
|
|
1015
|
+
installed.push(pkg);
|
|
800
1016
|
}
|
|
801
|
-
packagePaths.
|
|
802
|
-
installed.push(pkg);
|
|
803
|
-
}
|
|
804
|
-
if (packagePaths.length === 0) return [];
|
|
1017
|
+
if (packagePaths.length === 0) return [];
|
|
805
1018
|
|
|
806
|
-
|
|
1019
|
+
await uploadPackages(packagePaths, ports.ssh);
|
|
1020
|
+
} finally {
|
|
1021
|
+
pin.release();
|
|
1022
|
+
}
|
|
807
1023
|
|
|
808
1024
|
// Reboot to activate packages
|
|
809
1025
|
let rebootAuth: string;
|
|
@@ -864,8 +1080,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
864
1080
|
},
|
|
865
1081
|
|
|
866
1082
|
async destroy(): Promise<void> {
|
|
867
|
-
await
|
|
868
|
-
|
|
1083
|
+
await withMachineLock(state, "remove", async () => {
|
|
1084
|
+
await stopUnderLock(state);
|
|
1085
|
+
await removeUnderLock(state);
|
|
1086
|
+
});
|
|
869
1087
|
},
|
|
870
1088
|
|
|
871
1089
|
async subprocessEnv(): Promise<Record<string, string>> {
|
|
@@ -905,7 +1123,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
905
1123
|
|
|
906
1124
|
snapshot: {
|
|
907
1125
|
async list(): Promise<SnapshotInfo[]> {
|
|
908
|
-
const format =
|
|
1126
|
+
const format = bootDiskFormatOf(state);
|
|
909
1127
|
if (format !== "qcow2") return [];
|
|
910
1128
|
|
|
911
1129
|
// Try monitor first (works on running machines, gives live state)
|
|
@@ -917,7 +1135,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
917
1135
|
}
|
|
918
1136
|
|
|
919
1137
|
// Fall back to qemu-img info (works on stopped machines too)
|
|
920
|
-
const bootPath =
|
|
1138
|
+
const bootPath = bootDiskPath(state.machineDir, "qcow2");
|
|
921
1139
|
if (existsSync(bootPath)) {
|
|
922
1140
|
return listSnapshots(bootPath);
|
|
923
1141
|
}
|
|
@@ -925,7 +1143,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
925
1143
|
},
|
|
926
1144
|
|
|
927
1145
|
async save(name?: string): Promise<SnapshotInfo> {
|
|
928
|
-
const format =
|
|
1146
|
+
const format = bootDiskFormatOf(state);
|
|
929
1147
|
if (format !== "qcow2") {
|
|
930
1148
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk. Recreate with bootDiskFormat: \"qcow2\".");
|
|
931
1149
|
}
|
|
@@ -962,7 +1180,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
962
1180
|
},
|
|
963
1181
|
|
|
964
1182
|
async load(name: string): Promise<void> {
|
|
965
|
-
const format =
|
|
1183
|
+
const format = bootDiskFormatOf(state);
|
|
966
1184
|
if (format !== "qcow2") {
|
|
967
1185
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
|
|
968
1186
|
}
|
|
@@ -988,7 +1206,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
988
1206
|
},
|
|
989
1207
|
|
|
990
1208
|
async delete(name: string): Promise<void> {
|
|
991
|
-
const format =
|
|
1209
|
+
const format = bootDiskFormatOf(state);
|
|
992
1210
|
if (format !== "qcow2") {
|
|
993
1211
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
|
|
994
1212
|
}
|
|
@@ -1005,44 +1223,6 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1005
1223
|
};
|
|
1006
1224
|
}
|
|
1007
1225
|
|
|
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
1226
|
/** Resolve the host architecture to a CHR architecture. */
|
|
1047
1227
|
function hostArchToChr(): Arch {
|
|
1048
1228
|
const arch = process.arch;
|
|
@@ -1222,28 +1402,122 @@ export async function captureRunningFailure(
|
|
|
1222
1402
|
});
|
|
1223
1403
|
}
|
|
1224
1404
|
|
|
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(
|
|
1405
|
+
/** Cleanup intent is captured under the lifecycle lock, before launch mutates state. */
|
|
1406
|
+
export type BootFailureCleanup =
|
|
1407
|
+
| { kind: "remove-new" }
|
|
1408
|
+
| { kind: "reset-first-boot"; restore: MachineState }
|
|
1409
|
+
| { kind: "preserve-existing" };
|
|
1410
|
+
|
|
1411
|
+
/** Stop the failed guest, then apply the launch's cleanup intent.
|
|
1412
|
+
* Diagnostics have already been captured outside the machine directory. */
|
|
1413
|
+
export async function cleanupAfterBootFailure(
|
|
1237
1414
|
instance: ChrInstance,
|
|
1238
1415
|
name: string,
|
|
1239
1416
|
failure: BootFailureReport,
|
|
1417
|
+
cleanup: BootFailureCleanup,
|
|
1240
1418
|
): Promise<string> {
|
|
1241
|
-
|
|
1419
|
+
const quoted = shellQuote(name);
|
|
1420
|
+
// Called from inside a start or relaunch that holds the machine lock.
|
|
1421
|
+
try {
|
|
1422
|
+
await stopUnderLock(instance.state);
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1425
|
+
return ` Machine "${name}" was retained, but stopping QEMU failed (${reason}) — retry with \`quickchr stop ${quoted}\` before starting or removing it.`;
|
|
1426
|
+
}
|
|
1427
|
+
if (cleanup.kind === "preserve-existing") {
|
|
1428
|
+
return ` Machine "${name}" was stopped and preserved with its disks and configuration — ` +
|
|
1429
|
+
`retry with \`quickchr start ${quoted}\`, or explicitly remove it with \`quickchr remove ${quoted}\`.`;
|
|
1430
|
+
}
|
|
1431
|
+
const restore = cleanup.kind === "reset-first-boot" ? cleanup.restore : undefined;
|
|
1242
1432
|
if (failure.preserved) {
|
|
1243
|
-
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) —
|
|
1433
|
+
return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — ` +
|
|
1434
|
+
(restore ? `reset it with \`quickchr clean ${quoted}\` and start it again, or ` : "") +
|
|
1435
|
+
`remove it with \`quickchr remove ${quoted}\`.`;
|
|
1244
1436
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1437
|
+
// An add()-created machine on its first boot: the same reset a failed provisioning
|
|
1438
|
+
// run gets (#187), so the retry is `start` again rather than `add` + `start`. The
|
|
1439
|
+
// guest may have written to its disk before stalling, so the disk goes back to the
|
|
1440
|
+
// factory image along with the record — the window is open again, and an open
|
|
1441
|
+
// window promises a factory disk.
|
|
1442
|
+
if (restore) {
|
|
1443
|
+
try {
|
|
1444
|
+
await resetGuestToFactory(restore);
|
|
1445
|
+
saveMachine({ ...restore, status: "stopped", pid: undefined });
|
|
1446
|
+
refreshMachineState(instance.state);
|
|
1447
|
+
return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — ` +
|
|
1448
|
+
`retry with \`quickchr start ${quoted}\`. Set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine as it is for diagnosis.`;
|
|
1449
|
+
} catch (resetErr) {
|
|
1450
|
+
const reason = resetErr instanceof Error ? resetErr.message : String(resetErr);
|
|
1451
|
+
return ` Resetting machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
try { await removeUnderLock(instance.state); } catch { /* ignore */ }
|
|
1455
|
+
return ` Machine "${name}" has been cleaned up automatically — set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine for diagnosis.`;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** Undo a first boot whose provisioning threw, so that retrying is re-running the same
|
|
1459
|
+
* command (#176).
|
|
1460
|
+
*
|
|
1461
|
+
* Provisioning is transactional — you get the machine you asked for or you don't —
|
|
1462
|
+
* and the throw used to break that: the guest was left running and half-provisioned,
|
|
1463
|
+
* with `lastStartedAt` set, so the window was closed and the retry a user reaches for
|
|
1464
|
+
* was refused. Resuming from the steps that landed is not the fix. A step can throw
|
|
1465
|
+
* half-applied (the login group creates a user and then disables `admin` in one call),
|
|
1466
|
+
* and quickchr cannot tell how far it got without reading the guest. So the rollback
|
|
1467
|
+
* reverts the whole run, and the retry starts again from a factory disk:
|
|
1468
|
+
*
|
|
1469
|
+
* - **`restore` given** — an `add()`-created machine on its first boot. The disk is
|
|
1470
|
+
* reset to the factory image and `machine.json` goes back to the record `add()`
|
|
1471
|
+
* wrote, window open and intent intact, so `quickchr start <name>` tries again.
|
|
1472
|
+
* - **no `restore`** — `start()` created the machine in this call, so it is removed,
|
|
1473
|
+
* exactly as a boot timeout removes it.
|
|
1474
|
+
*
|
|
1475
|
+
* `QUICKCHR_PRESERVE_ON_FAILURE=1` keeps the half-provisioned machine for inspection,
|
|
1476
|
+
* as it does for a boot failure. QEMU is stopped either way.
|
|
1477
|
+
*
|
|
1478
|
+
* Returns the sentence to append to the error. A rollback that fails is reported in
|
|
1479
|
+
* that sentence, never swallowed — the provisioning error stays the primary one. */
|
|
1480
|
+
export async function rollbackFailedProvisioning(
|
|
1481
|
+
instance: ChrInstance,
|
|
1482
|
+
restore: MachineState | undefined,
|
|
1483
|
+
): Promise<string> {
|
|
1484
|
+
const name = instance.state.name;
|
|
1485
|
+
const quoted = shellQuote(name);
|
|
1486
|
+
// Called from inside a start or relaunch that holds the machine lock.
|
|
1487
|
+
try {
|
|
1488
|
+
await stopUnderLock(instance.state);
|
|
1489
|
+
if (preserveOnFailure()) {
|
|
1490
|
+
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}\`.`;
|
|
1491
|
+
}
|
|
1492
|
+
if (!restore) {
|
|
1493
|
+
await removeUnderLock(instance.state);
|
|
1494
|
+
return ` Machine "${name}" has been removed — re-run the same command to retry.`;
|
|
1495
|
+
}
|
|
1496
|
+
await resetGuestToFactory(restore);
|
|
1497
|
+
saveMachine({ ...restore, status: "stopped", pid: undefined });
|
|
1498
|
+
refreshMachineState(instance.state);
|
|
1499
|
+
return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — re-run the same start command to retry.`;
|
|
1500
|
+
} catch (rollbackErr) {
|
|
1501
|
+
const reason = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
1502
|
+
return ` Rolling back machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** Append the rollback outcome to a provisioning error and hand it back for rethrow.
|
|
1507
|
+
* The error keeps its own type, code and stack: the rollback is context, not the
|
|
1508
|
+
* failure. */
|
|
1509
|
+
export async function failProvisioning(
|
|
1510
|
+
err: unknown,
|
|
1511
|
+
instance: ChrInstance,
|
|
1512
|
+
restore: MachineState | undefined,
|
|
1513
|
+
): Promise<unknown> {
|
|
1514
|
+
// Its own line: the provisioning error often ends in a RouterOS JSON body.
|
|
1515
|
+
const outcome = (await rollbackFailedProvisioning(instance, restore)).trim();
|
|
1516
|
+
if (err instanceof Error) {
|
|
1517
|
+
err.message += `\n${outcome}`;
|
|
1518
|
+
return err;
|
|
1519
|
+
}
|
|
1520
|
+
return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
|
|
1247
1521
|
}
|
|
1248
1522
|
|
|
1249
1523
|
/** Wait for CHR to boot with periodic progress status updates every 20s. */
|
|
@@ -1314,7 +1588,8 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1314
1588
|
state.machineDir,
|
|
1315
1589
|
state.bootSize,
|
|
1316
1590
|
state.extraDisks,
|
|
1317
|
-
|
|
1591
|
+
bootDiskFormatOf(state),
|
|
1592
|
+
{ booted: true },
|
|
1318
1593
|
);
|
|
1319
1594
|
const platform = await detectPlatform();
|
|
1320
1595
|
const hostfwd = buildHostfwdString(state.ports);
|
|
@@ -1337,26 +1612,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1337
1612
|
};
|
|
1338
1613
|
}
|
|
1339
1614
|
|
|
1615
|
+
/** Persist what provisioning landed on the current disk, in state and on disk.
|
|
1616
|
+
* A run with nothing to do still records — an empty `steps` says "provisioning ran
|
|
1617
|
+
* and had nothing to apply", which is a different fact from never having run. */
|
|
1618
|
+
function recordProvisioning(state: MachineState, steps: ProvisioningStep[]): void {
|
|
1619
|
+
const record = { at: new Date().toISOString(), steps };
|
|
1620
|
+
const current = loadMachine(state.name);
|
|
1621
|
+
if (current) {
|
|
1622
|
+
current.provisioning = record;
|
|
1623
|
+
saveMachine(current);
|
|
1624
|
+
}
|
|
1625
|
+
state.provisioning = record;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/** Re-read `machine.json` into an existing state object, in place.
|
|
1629
|
+
*
|
|
1630
|
+
* **In place** because `createInstance` closes over this object and exposes it as
|
|
1631
|
+
* `instance.state`: rebinding a local would leave the caller's view stale, and every
|
|
1632
|
+
* other method on the handle would keep reading the old snapshot.
|
|
1633
|
+
*
|
|
1634
|
+
* Keys the reloaded record does not have are deleted rather than left behind.
|
|
1635
|
+
* `clean()` removes `provisioning`, `user` and `disableAdmin` precisely because they
|
|
1636
|
+
* stopped being true, so a leftover would be read as fact by the next caller —
|
|
1637
|
+
* `appliedDeviceModeRecord()` reads exactly one of them. A plain `Object.assign`
|
|
1638
|
+
* cannot express a field that went away. */
|
|
1639
|
+
export function refreshMachineState(state: MachineState): void {
|
|
1640
|
+
const fresh = loadMachine(state.name);
|
|
1641
|
+
if (!fresh) return;
|
|
1642
|
+
const mutable = state as unknown as Record<string, unknown>;
|
|
1643
|
+
for (const key of Object.keys(mutable)) {
|
|
1644
|
+
if (!(key in fresh)) delete mutable[key];
|
|
1645
|
+
}
|
|
1646
|
+
Object.assign(state, fresh);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Add one step to the provisioning record, keeping whatever is already there.
|
|
1650
|
+
*
|
|
1651
|
+
* `recordProvisioning()` stamps a whole first-boot run; this is for a step applied on
|
|
1652
|
+
* its own afterwards — `setDeviceMode()` today, `installPackage()` when #24 lands.
|
|
1653
|
+
* `steps` accumulates and `at` moves to now, which is what `ProvisioningRecord`
|
|
1654
|
+
* promises: the last time quickchr applied provisioning to this disk.
|
|
1655
|
+
*
|
|
1656
|
+
* It cannot reopen or close a window by accident. The only caller requires a running
|
|
1657
|
+
* machine, and a running machine has `lastStartedAt`, so the window was already shut
|
|
1658
|
+
* before this wrote anything. */
|
|
1659
|
+
function recordProvisioningStep(state: MachineState, step: ProvisioningStep): void {
|
|
1660
|
+
const steps = [...new Set([...(state.provisioning?.steps ?? []), step])];
|
|
1661
|
+
const record = { at: new Date().toISOString(), steps };
|
|
1662
|
+
const current = loadMachine(state.name);
|
|
1663
|
+
if (current) {
|
|
1664
|
+
current.provisioning = record;
|
|
1665
|
+
saveMachine(current);
|
|
1666
|
+
}
|
|
1667
|
+
state.provisioning = record;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/** Preconditions a post-boot device-mode change inherits from provisioning.
|
|
1671
|
+
*
|
|
1672
|
+
* Both were undocumented and both fail confusingly when unchecked (#176):
|
|
1673
|
+
* `applyDeviceMode()` polls `waitForDeviceModeApi` before it does anything, so a
|
|
1674
|
+
* stopped machine spends 60 s to report a timeout, and a machine with no user-mode
|
|
1675
|
+
* NIC has no localhost route to REST at all and never recovers.
|
|
1676
|
+
*
|
|
1677
|
+
* This is the same guard `start()` applies to provisioning, moved to the one step
|
|
1678
|
+
* that may also run afterwards. */
|
|
1679
|
+
function assertDeviceModeApplicable(state: MachineState): void {
|
|
1680
|
+
if (!isMachineRunning(state)) {
|
|
1681
|
+
throw new QuickCHRError(
|
|
1682
|
+
"MACHINE_STOPPED",
|
|
1683
|
+
`Device-mode is applied through the guest's REST API, so "${state.name}" has to be running. ` +
|
|
1684
|
+
`Start it with 'quickchr start ${shellQuote(state.name)}', then set device-mode.`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
if (!hasUserModeNetwork(state.networks)) {
|
|
1688
|
+
throw new QuickCHRError(
|
|
1689
|
+
"NETWORK_UNAVAILABLE",
|
|
1690
|
+
`Device-mode is applied over localhost REST, which needs a user-mode network interface — ` +
|
|
1691
|
+
`"${state.name}" has none, so quickchr cannot reach its REST API. ` +
|
|
1692
|
+
"Recreate the machine with a user-mode NIC alongside its other networks.",
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1340
1697
|
/** 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.
|
|
1698
|
+
* Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
|
|
1699
|
+
*
|
|
1700
|
+
* `authHeader` is the caller's, because the two callers genuinely differ and the
|
|
1701
|
+
* difference is not cosmetic. During first-boot provisioning device-mode is step 2 and
|
|
1702
|
+
* the user step is step 4, so factory `admin:` is the *only* credential that exists
|
|
1703
|
+
* yet — `state.user` is already populated at that point but names an account nobody
|
|
1704
|
+
* has created, so resolving it here would 401 the shipped path. Post-boot the reverse
|
|
1705
|
+
* holds: a machine provisioned with `--disable-admin` answers 401 to factory admin,
|
|
1706
|
+
* which is what made `quickchr set <name> --device-mode` fail on exactly the machines
|
|
1707
|
+
* the route was written for. Same field, opposite answers, decided by the caller
|
|
1708
|
+
* rather than guessed here. */
|
|
1342
1709
|
async function applyDeviceMode(
|
|
1343
1710
|
instance: ChrInstance,
|
|
1344
1711
|
machineState: MachineState,
|
|
1345
1712
|
resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
|
|
1346
1713
|
launchConfig: QemuLaunchConfig,
|
|
1347
1714
|
log: ProgressLogger,
|
|
1715
|
+
authHeader: string,
|
|
1348
1716
|
): Promise<void> {
|
|
1349
1717
|
const httpPort = toChrPorts(machineState.ports).http;
|
|
1350
1718
|
const accel = await detectAccel(machineState.arch);
|
|
1351
1719
|
const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
|
|
1352
|
-
await waitForDeviceModeApi(httpPort, 60_000);
|
|
1720
|
+
await waitForDeviceModeApi(httpPort, 60_000, authHeader);
|
|
1353
1721
|
log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
|
|
1354
1722
|
|
|
1355
1723
|
let alreadyActive = false;
|
|
1356
1724
|
try {
|
|
1357
|
-
const beforeMode = await readDeviceMode(httpPort);
|
|
1725
|
+
const beforeMode = await readDeviceMode(httpPort, authHeader);
|
|
1358
1726
|
log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
|
|
1359
1727
|
alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
|
|
1728
|
+
// Say what the power cycle is buying before spending it. `mode` moves as a
|
|
1729
|
+
// side-effect of enabling a feature — `--device-mode-enable container` resolves
|
|
1730
|
+
// `auto` to `rose` — and a change nobody asked for should at least be announced
|
|
1731
|
+
// (#176).
|
|
1732
|
+
for (const change of describeDeviceModeChange(resolvedDeviceMode, beforeMode)) {
|
|
1733
|
+
log.status(` ${change}`);
|
|
1734
|
+
}
|
|
1360
1735
|
} catch { /* CHR unexpectedly unreachable — proceed with update */ }
|
|
1361
1736
|
|
|
1362
1737
|
if (alreadyActive) {
|
|
@@ -1369,7 +1744,7 @@ async function applyDeviceMode(
|
|
|
1369
1744
|
let requiresPowerCycle = false;
|
|
1370
1745
|
|
|
1371
1746
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1372
|
-
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
|
|
1747
|
+
const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
|
|
1373
1748
|
// RouterOS blocks this connection while waiting for hard power-cycle confirmation.
|
|
1374
1749
|
// We race against 2s: if still pending at 2s, RouterOS has entered blocking state
|
|
1375
1750
|
// and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
|
|
@@ -1399,6 +1774,22 @@ async function applyDeviceMode(
|
|
|
1399
1774
|
}
|
|
1400
1775
|
|
|
1401
1776
|
log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
|
|
1777
|
+
|
|
1778
|
+
// A rejection is an answer, not a race — say what RouterOS said and stop.
|
|
1779
|
+
// Retrying here used to cost five POSTs and ~33 s and then report a *mismatch*
|
|
1780
|
+
// (`bogus-feature: expected=yes, actual=(missing)`) while RouterOS had replied
|
|
1781
|
+
// `HTTP 400 — unknown parameter bogus-feature` on the first attempt. RouterOS
|
|
1782
|
+
// also counts device-mode update attempts (`attempt-count` on
|
|
1783
|
+
// `/system/device-mode`), so the retries spend a budget that is not ours to
|
|
1784
|
+
// spend. Only a 2xx that came back before activation is worth trying again.
|
|
1785
|
+
const { status, body } = outcome.response;
|
|
1786
|
+
if (status < 200 || status >= 300) {
|
|
1787
|
+
throw new QuickCHRError(
|
|
1788
|
+
"PROCESS_FAILED",
|
|
1789
|
+
`Device-mode update rejected by RouterOS: HTTP ${status} — ${body.trim() || "(empty body)"}`,
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1402
1793
|
await Bun.sleep(5000);
|
|
1403
1794
|
|
|
1404
1795
|
let routerOsOffline = false;
|
|
@@ -1415,10 +1806,10 @@ async function applyDeviceMode(
|
|
|
1415
1806
|
if (!rebooted) {
|
|
1416
1807
|
throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
|
|
1417
1808
|
}
|
|
1418
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1809
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1419
1810
|
}
|
|
1420
1811
|
|
|
1421
|
-
const actualNow = await readDeviceMode(httpPort);
|
|
1812
|
+
const actualNow = await readDeviceMode(httpPort, authHeader);
|
|
1422
1813
|
log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
|
|
1423
1814
|
const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
|
|
1424
1815
|
if (immediateVerification.ok) {
|
|
@@ -1448,10 +1839,10 @@ async function applyDeviceMode(
|
|
|
1448
1839
|
`Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
|
|
1449
1840
|
);
|
|
1450
1841
|
}
|
|
1451
|
-
await waitForDeviceModeApi(httpPort, bootTimeout);
|
|
1842
|
+
await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
|
|
1452
1843
|
}
|
|
1453
1844
|
|
|
1454
|
-
const actual = await readDeviceMode(httpPort);
|
|
1845
|
+
const actual = await readDeviceMode(httpPort, authHeader);
|
|
1455
1846
|
log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
|
|
1456
1847
|
const verification = verifyDeviceMode(resolvedDeviceMode, actual);
|
|
1457
1848
|
if (!verification.ok) {
|
|
@@ -1463,6 +1854,112 @@ async function applyDeviceMode(
|
|
|
1463
1854
|
log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
|
|
1464
1855
|
}
|
|
1465
1856
|
|
|
1857
|
+
/** How long a creation waits for another to finish choosing its port block. The
|
|
1858
|
+
* critical section is a handful of loopback connect probes, so this is headroom for a
|
|
1859
|
+
* busy host, not an expected wait. */
|
|
1860
|
+
const ALLOCATION_LOCK_WAIT_MS = 60_000;
|
|
1861
|
+
|
|
1862
|
+
/** An explicit `portBase` is the caller's choice and is never moved: one that overlaps
|
|
1863
|
+
* another machine's block — persisted or still being created — is a `PORT_CONFLICT`
|
|
1864
|
+
* here rather than a `SPAWN_FAILED` from QEMU's bind later. */
|
|
1865
|
+
function assertPortBaseFree(
|
|
1866
|
+
portBase: number,
|
|
1867
|
+
name: string,
|
|
1868
|
+
machines: MachineState[],
|
|
1869
|
+
reserved: Array<{ name: string; portBase: number }>,
|
|
1870
|
+
): void {
|
|
1871
|
+
const others = [
|
|
1872
|
+
...machines.filter((m) => m.name !== name).map((m) => ({ name: m.name, portBase: m.portBase })),
|
|
1873
|
+
...reserved,
|
|
1874
|
+
];
|
|
1875
|
+
const clash = others.find((o) => Math.abs(o.portBase - portBase) < PORTS_PER_BLOCK);
|
|
1876
|
+
if (clash) {
|
|
1877
|
+
throw new QuickCHRError(
|
|
1878
|
+
"PORT_CONFLICT",
|
|
1879
|
+
`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.`,
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/** Choose a new machine's port block and NIC MACs, avoiding every persisted machine
|
|
1885
|
+
* and every creation still in flight. Choosing is not claiming: only
|
|
1886
|
+
* `claimNewMachine()` makes the result safe from a concurrent creation. */
|
|
1887
|
+
async function pickNewMachineResources(
|
|
1888
|
+
name: string,
|
|
1889
|
+
opts: StartOptions,
|
|
1890
|
+
): Promise<{ portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
|
|
1891
|
+
const machines = loadAllMachines();
|
|
1892
|
+
const reserved = listReservations(name);
|
|
1893
|
+
if (opts.portBase) assertPortBaseFree(opts.portBase, name, machines, reserved);
|
|
1894
|
+
const usedBases = [...getUsedPortBases(), ...reserved.map((r) => r.portBase)];
|
|
1895
|
+
const portBase = opts.portBase || await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
|
|
1896
|
+
validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, machines, name);
|
|
1897
|
+
const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
|
|
1898
|
+
|
|
1899
|
+
const usedMacs = getUsedMacs();
|
|
1900
|
+
for (const r of reserved) for (const mac of r.macs) usedMacs.add(mac);
|
|
1901
|
+
const networks = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), usedMacs);
|
|
1902
|
+
return { portBase, ports, networks };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** Choose a new machine's resources and take its machine lock with them recorded in
|
|
1906
|
+
* it, all while holding the data-dir allocation lock (#140).
|
|
1907
|
+
*
|
|
1908
|
+
* Without this, two concurrent creations both read the same machine list, both probe
|
|
1909
|
+
* the same free ports, and both pick the same block; one QEMU then fails its
|
|
1910
|
+
* `hostfwd` bind. Nothing persists the choice until `saveMachine()`, which for
|
|
1911
|
+
* `start()` is after the download and the spawn, so the choice itself has to be
|
|
1912
|
+
* visible: the machine lock carries it as a reservation, `listReservations()` reads
|
|
1913
|
+
* it, and it ends when the lock is released — by which time `machine.json` holds it,
|
|
1914
|
+
* or the creation failed and there is nothing left to hold. A crashed creator's
|
|
1915
|
+
* reservation dies with its pid.
|
|
1916
|
+
*
|
|
1917
|
+
* The machine lock is fail-fast. A live holder is another create of the same name. */
|
|
1918
|
+
export async function claimNewMachine(
|
|
1919
|
+
name: string,
|
|
1920
|
+
op: Extract<LockOperation, "add" | "start" | "foreground">,
|
|
1921
|
+
opts: StartOptions,
|
|
1922
|
+
): Promise<{ lock: HeldLock; portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
|
|
1923
|
+
ensureDir(getDataDir());
|
|
1924
|
+
const allocation = await acquireLock(allocationLockPath(), "allocate", {
|
|
1925
|
+
waitMs: ALLOCATION_LOCK_WAIT_MS,
|
|
1926
|
+
subject: "The port allocator",
|
|
1927
|
+
});
|
|
1928
|
+
try {
|
|
1929
|
+
const picked = await pickNewMachineResources(name, opts);
|
|
1930
|
+
const machineDir = getMachineDir(name);
|
|
1931
|
+
const lockOpts = {
|
|
1932
|
+
...machineLockOptions(name, machineDir),
|
|
1933
|
+
reserve: {
|
|
1934
|
+
portBase: picked.portBase,
|
|
1935
|
+
macs: picked.networks.flatMap((n) => (n.mac ? [n.mac] : [])),
|
|
1936
|
+
},
|
|
1937
|
+
};
|
|
1938
|
+
// The lock lives in the directory, so the directory comes first. A refusal here
|
|
1939
|
+
// means a live holder owns the directory — a create of the same name, or a
|
|
1940
|
+
// remove — and it, not this call, cleans it up. A remove renames the directory
|
|
1941
|
+
// away in one step; if that lands between the two lines, make a fresh one.
|
|
1942
|
+
let lock: HeldLock | undefined;
|
|
1943
|
+
for (let tries = 0; !lock; tries++) {
|
|
1944
|
+
ensureDir(machineDir);
|
|
1945
|
+
try {
|
|
1946
|
+
lock = acquireLockNow(machineLockPath(machineDir), op, lockOpts);
|
|
1947
|
+
} catch (e) {
|
|
1948
|
+
if (!(e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") || tries >= 2) throw e;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
// Another create of this name may have finished between the caller's existence
|
|
1952
|
+
// check and this lock.
|
|
1953
|
+
if (existsSync(join(machineDir, "machine.json"))) {
|
|
1954
|
+
lock.release();
|
|
1955
|
+
throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" was created by another process while this one was starting.`);
|
|
1956
|
+
}
|
|
1957
|
+
return { lock, ...picked };
|
|
1958
|
+
} finally {
|
|
1959
|
+
allocation.release();
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1466
1963
|
/**
|
|
1467
1964
|
* Main entry point for managing MikroTik CHR virtual machines via QEMU.
|
|
1468
1965
|
*
|
|
@@ -1488,6 +1985,7 @@ export class QuickCHR {
|
|
|
1488
1985
|
// add() always creates, so the full name rules apply before any I/O.
|
|
1489
1986
|
if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
|
|
1490
1987
|
|
|
1988
|
+
warnVersionWithChannel(opts, createLogger(opts.onProgress));
|
|
1491
1989
|
let version: string;
|
|
1492
1990
|
if (opts.version) {
|
|
1493
1991
|
if (!isValidVersion(opts.version)) {
|
|
@@ -1500,13 +1998,10 @@ export class QuickCHR {
|
|
|
1500
1998
|
}
|
|
1501
1999
|
|
|
1502
2000
|
const arch: Arch = resolveArch(opts.arch);
|
|
1503
|
-
|
|
1504
|
-
if (arch === "arm64") requireFirmware();
|
|
1505
|
-
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
2001
|
+
assertRouterOs6Arch(version, arch);
|
|
1506
2002
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
1507
2003
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1508
|
-
|
|
1509
|
-
if (hasProvisioningMutations({
|
|
2004
|
+
const mutations = {
|
|
1510
2005
|
installAllPackages: opts.installAllPackages,
|
|
1511
2006
|
packages: opts.packages,
|
|
1512
2007
|
hasDeviceModeProvisioning,
|
|
@@ -1514,17 +2009,15 @@ export class QuickCHR {
|
|
|
1514
2009
|
disableAdmin: opts.disableAdmin,
|
|
1515
2010
|
license: opts.license,
|
|
1516
2011
|
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
|
-
}));
|
|
2012
|
+
};
|
|
2013
|
+
// Version refusals before host checks: a request that can never succeed should
|
|
2014
|
+
// say why, not first ask for QEMU to be installed.
|
|
2015
|
+
if (hasProvisioningMutations(mutations)) {
|
|
2016
|
+
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
1527
2017
|
}
|
|
2018
|
+
requireQemu(arch);
|
|
2019
|
+
if (arch === "arm64") requireFirmware();
|
|
2020
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1528
2021
|
|
|
1529
2022
|
const existingNames = listMachineNames();
|
|
1530
2023
|
const name = opts.name ?? generateMachineName(version, arch, existingNames);
|
|
@@ -1540,24 +2033,29 @@ export class QuickCHR {
|
|
|
1540
2033
|
: new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
|
|
1541
2034
|
}
|
|
1542
2035
|
|
|
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
2036
|
const machineDir = getMachineDir(name);
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
const
|
|
1552
|
-
|
|
2037
|
+
// Held until machine.json is saved: it reserves the boot disk's bytes and pins the
|
|
2038
|
+
// image against a concurrent eviction (storage-lease.ts).
|
|
2039
|
+
const storageLease = admitStorage(
|
|
2040
|
+
`prepare CHR ${name}`,
|
|
2041
|
+
estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
|
|
2042
|
+
);
|
|
2043
|
+
let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
|
|
2044
|
+
try {
|
|
2045
|
+
claimed = await claimNewMachine(name, "add", opts);
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
storageLease.release();
|
|
2048
|
+
throw err;
|
|
2049
|
+
}
|
|
2050
|
+
const { lock, portBase, ports, networks } = claimed;
|
|
1553
2051
|
try {
|
|
1554
2052
|
const cachedImg = await ensureCachedImage(version, arch);
|
|
1555
|
-
copyImageToMachine(cachedImg, machineDir);
|
|
1556
2053
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
1557
2054
|
machineDir,
|
|
1558
2055
|
diskOpts.bootSize,
|
|
1559
2056
|
diskOpts.extraDisks,
|
|
1560
2057
|
diskOpts.bootDiskFormat,
|
|
2058
|
+
{ booted: false, factoryImage: async () => cachedImg },
|
|
1561
2059
|
);
|
|
1562
2060
|
|
|
1563
2061
|
const state: MachineState = {
|
|
@@ -1566,7 +2064,7 @@ export class QuickCHR {
|
|
|
1566
2064
|
arch,
|
|
1567
2065
|
cpu: opts.cpu ?? 1,
|
|
1568
2066
|
mem: defaultMem(arch, opts.mem),
|
|
1569
|
-
networks
|
|
2067
|
+
networks,
|
|
1570
2068
|
ports,
|
|
1571
2069
|
packages: opts.packages ?? [],
|
|
1572
2070
|
installAllPackages: opts.installAllPackages,
|
|
@@ -1597,11 +2095,12 @@ export class QuickCHR {
|
|
|
1597
2095
|
// directory it was supposed to clean up. Nothing runs after saveMachine()
|
|
1598
2096
|
// but `return state`, so a readable file here means the create succeeded.
|
|
1599
2097
|
if (isOrphanMachineDir(name)) {
|
|
1600
|
-
try {
|
|
2098
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
1601
2099
|
}
|
|
1602
2100
|
throw err;
|
|
1603
2101
|
} finally {
|
|
1604
|
-
|
|
2102
|
+
lock.release();
|
|
2103
|
+
storageLease.release();
|
|
1605
2104
|
}
|
|
1606
2105
|
}
|
|
1607
2106
|
|
|
@@ -1609,7 +2108,9 @@ export class QuickCHR {
|
|
|
1609
2108
|
* Start a new or existing CHR instance.
|
|
1610
2109
|
*
|
|
1611
2110
|
* The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
|
|
1612
|
-
* device-mode, user) has completed before this promise resolves.
|
|
2111
|
+
* device-mode, user) has completed before this promise resolves. Two exceptions:
|
|
2112
|
+
* `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
|
|
2113
|
+
* a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
|
|
1613
2114
|
* Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
|
|
1614
2115
|
* stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
|
|
1615
2116
|
* apply when creating a new machine and require `qemu-img` on the host.
|
|
@@ -1623,6 +2124,13 @@ export class QuickCHR {
|
|
|
1623
2124
|
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
|
|
1624
2125
|
}
|
|
1625
2126
|
|
|
2127
|
+
if (opts.wait === false && opts.background === false) {
|
|
2128
|
+
throw new QuickCHRError(
|
|
2129
|
+
"INVALID_ARGUMENT",
|
|
2130
|
+
"--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.",
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1626
2134
|
const logger = createLogger(opts.onProgress);
|
|
1627
2135
|
|
|
1628
2136
|
// Before any download or disk work: a named socket whose two ends are already
|
|
@@ -1638,6 +2146,7 @@ export class QuickCHR {
|
|
|
1638
2146
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
1639
2147
|
|
|
1640
2148
|
// Resolve version
|
|
2149
|
+
warnVersionWithChannel(opts, logger);
|
|
1641
2150
|
let version: string;
|
|
1642
2151
|
if (opts.version) {
|
|
1643
2152
|
if ((CHANNELS as readonly string[]).includes(opts.version)) {
|
|
@@ -1655,7 +2164,7 @@ export class QuickCHR {
|
|
|
1655
2164
|
version = await resolveVersion(channel);
|
|
1656
2165
|
}
|
|
1657
2166
|
|
|
1658
|
-
|
|
2167
|
+
const mutations = {
|
|
1659
2168
|
installAllPackages: opts.installAllPackages,
|
|
1660
2169
|
packages: opts.packages,
|
|
1661
2170
|
hasDeviceModeProvisioning,
|
|
@@ -1663,29 +2172,20 @@ export class QuickCHR {
|
|
|
1663
2172
|
disableAdmin: opts.disableAdmin,
|
|
1664
2173
|
license: opts.license,
|
|
1665
2174
|
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
|
-
}));
|
|
2175
|
+
};
|
|
2176
|
+
if (hasProvisioningMutations(mutations)) {
|
|
2177
|
+
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
2178
|
+
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
|
|
1676
2179
|
}
|
|
1677
2180
|
|
|
1678
2181
|
// Resolve architecture
|
|
1679
2182
|
const arch: Arch = resolveArch(opts.arch);
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
requireFirmware();
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
2183
|
+
// Host prerequisites are checked just before each launch below, after every
|
|
2184
|
+
// refusal: a request that cannot succeed says why before asking for QEMU.
|
|
2185
|
+
const requireHostFor = (target: Arch) => {
|
|
2186
|
+
requireQemu(target);
|
|
2187
|
+
if (target === "arm64") requireFirmware();
|
|
2188
|
+
};
|
|
1689
2189
|
|
|
1690
2190
|
// Resolve name
|
|
1691
2191
|
const existingNames = listMachineNames();
|
|
@@ -1696,20 +2196,45 @@ export class QuickCHR {
|
|
|
1696
2196
|
// Check if machine already exists
|
|
1697
2197
|
const existing = loadMachine(name);
|
|
1698
2198
|
if (existing) {
|
|
2199
|
+
// Both paths below re-launch a guest quickchr did not just create, so neither
|
|
2200
|
+
// can run provisioning. Refuse before either of them: dropping the options in
|
|
2201
|
+
// silence is what #176 removes, and the running-machine path is the quieter of
|
|
2202
|
+
// the two — it returns a handle without so much as a boot.
|
|
2203
|
+
const request = {
|
|
2204
|
+
installAllPackages: opts.installAllPackages,
|
|
2205
|
+
packages: opts.packages,
|
|
2206
|
+
deviceMode: opts.deviceMode,
|
|
2207
|
+
user: opts.user,
|
|
2208
|
+
disableAdmin: opts.disableAdmin,
|
|
2209
|
+
license: opts.license,
|
|
2210
|
+
secureLogin: opts.secureLogin,
|
|
2211
|
+
};
|
|
2212
|
+
// Version before window: on a version that can never be provisioned, the
|
|
2213
|
+
// window refusal's `quickchr clean` route leads to a second refusal (#182).
|
|
2214
|
+
if (hasProvisioningRequest(request)) {
|
|
2215
|
+
assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation({
|
|
2216
|
+
...request,
|
|
2217
|
+
hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)),
|
|
2218
|
+
}));
|
|
2219
|
+
}
|
|
2220
|
+
const satisfied = assertProvisioningWindow(existing, request);
|
|
2221
|
+
for (const ask of satisfied) {
|
|
2222
|
+
(logger ?? createLogger()).status(`${ask.description} is already applied — nothing to do.`);
|
|
2223
|
+
}
|
|
1699
2224
|
if (isMachineRunning(existing)) {
|
|
1700
2225
|
return createInstance(existing);
|
|
1701
2226
|
}
|
|
1702
2227
|
// 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
|
|
2228
|
+
if (isProvisioningWindowOpen(existing)) {
|
|
2229
|
+
const pendingOpts = pendingProvisioningFor(existing, {
|
|
2230
|
+
installAllPackages: opts.installAllPackages,
|
|
2231
|
+
packages: opts.packages,
|
|
2232
|
+
deviceMode: opts.deviceMode,
|
|
2233
|
+
user: opts.user,
|
|
2234
|
+
disableAdmin: opts.disableAdmin,
|
|
1710
2235
|
license: opts.license,
|
|
1711
|
-
secureLogin: opts.secureLogin
|
|
1712
|
-
};
|
|
2236
|
+
secureLogin: opts.secureLogin,
|
|
2237
|
+
});
|
|
1713
2238
|
const hasPending = !!(
|
|
1714
2239
|
pendingOpts.installAllPackages ||
|
|
1715
2240
|
(pendingOpts.packages?.length ?? 0) > 0 ||
|
|
@@ -1720,49 +2245,43 @@ export class QuickCHR {
|
|
|
1720
2245
|
pendingOpts.secureLogin === true
|
|
1721
2246
|
);
|
|
1722
2247
|
if (hasPending) {
|
|
2248
|
+
// Before the boot, not after it: `_provisionInstance()` re-checks the
|
|
2249
|
+
// version, but only once the guest is up — on RouterOS 6 that is a
|
|
2250
|
+
// download-free boot spent on a refusal known in advance.
|
|
2251
|
+
const pendingMutations = {
|
|
2252
|
+
...pendingOpts,
|
|
2253
|
+
hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(pendingOpts.deviceMode)),
|
|
2254
|
+
};
|
|
2255
|
+
assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
|
|
2256
|
+
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
|
|
2257
|
+
requireHostFor(existing.arch);
|
|
1723
2258
|
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
1724
2259
|
}
|
|
1725
2260
|
}
|
|
1726
2261
|
// Exists but stopped — restart it
|
|
1727
|
-
|
|
2262
|
+
requireHostFor(existing.arch);
|
|
2263
|
+
return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
|
|
1728
2264
|
}
|
|
1729
2265
|
|
|
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
|
-
);
|
|
2266
|
+
assertRouterOs6Arch(version, arch);
|
|
2267
|
+
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
2268
|
+
if (!opts.dryRun) requireHostFor(arch);
|
|
1753
2269
|
|
|
1754
|
-
// Dry run — return instance handle without actually spawning
|
|
2270
|
+
// Dry run — return instance handle without actually spawning. Nothing is
|
|
2271
|
+
// claimed: a dry run persists nothing for another create to collide with.
|
|
1755
2272
|
if (opts.dryRun) {
|
|
1756
2273
|
const machineDir = getMachineDir(name);
|
|
2274
|
+
const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
|
|
1757
2275
|
const state: MachineState = {
|
|
1758
2276
|
name,
|
|
1759
2277
|
version,
|
|
1760
2278
|
arch,
|
|
1761
2279
|
cpu: opts.cpu ?? 1,
|
|
1762
2280
|
mem: defaultMem(arch, opts.mem),
|
|
1763
|
-
networks
|
|
2281
|
+
networks,
|
|
1764
2282
|
ports,
|
|
1765
2283
|
packages: opts.packages ?? [],
|
|
2284
|
+
installAllPackages: opts.installAllPackages,
|
|
1766
2285
|
deviceMode: requestedDeviceMode,
|
|
1767
2286
|
user: opts.user,
|
|
1768
2287
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1780,13 +2299,40 @@ export class QuickCHR {
|
|
|
1780
2299
|
return createInstance(state);
|
|
1781
2300
|
}
|
|
1782
2301
|
|
|
1783
|
-
// Acquire a lock to prevent concurrent starts of the same machine
|
|
1784
2302
|
const machineDir = getMachineDir(name);
|
|
1785
|
-
|
|
2303
|
+
// Held until machine.json is saved — after the spawn, or in the foreground once
|
|
2304
|
+
// QEMU exits — and released on failure: see add().
|
|
2305
|
+
const storageLease = admitStorage(
|
|
2306
|
+
`start CHR ${name}`,
|
|
2307
|
+
estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
|
|
2308
|
+
{ logger: logger ? (message: string) => logger.status(message) : undefined },
|
|
2309
|
+
);
|
|
1786
2310
|
const dirCreatedHere = !existsSync(machineDir);
|
|
1787
|
-
|
|
1788
|
-
const
|
|
1789
|
-
|
|
2311
|
+
|
|
2312
|
+
const background = opts.background ?? true;
|
|
2313
|
+
|
|
2314
|
+
// Provisioning includes any work that requires the machine to have booted first.
|
|
2315
|
+
// When foreground mode is requested WITH provisioning, we must boot in background,
|
|
2316
|
+
// provision (packages/user/license), then attach the serial socket to stdio.
|
|
2317
|
+
// Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
|
|
2318
|
+
const hasProvisioning = !!(
|
|
2319
|
+
opts.installAllPackages ||
|
|
2320
|
+
(opts.packages && opts.packages.length > 0) ||
|
|
2321
|
+
opts.user ||
|
|
2322
|
+
opts.disableAdmin ||
|
|
2323
|
+
opts.license ||
|
|
2324
|
+
hasDeviceModeProvisioning ||
|
|
2325
|
+
opts.secureLogin === true
|
|
2326
|
+
);
|
|
2327
|
+
|
|
2328
|
+
let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
|
|
2329
|
+
try {
|
|
2330
|
+
claimed = await claimNewMachine(name, !background && !hasProvisioning ? "foreground" : "start", opts);
|
|
2331
|
+
} catch (err) {
|
|
2332
|
+
storageLease.release();
|
|
2333
|
+
throw err;
|
|
2334
|
+
}
|
|
2335
|
+
const { lock, portBase, ports, networks: networkConfigs } = claimed;
|
|
1790
2336
|
// Endpoints are claimed before QEMU is spawned, because resolution needs to know
|
|
1791
2337
|
// which end this machine holds. A launch that fails before its state is persisted
|
|
1792
2338
|
// has no instance lifecycle to release them later, so a failed start would leave a
|
|
@@ -1797,36 +2343,18 @@ export class QuickCHR {
|
|
|
1797
2343
|
|
|
1798
2344
|
// Download and prepare image
|
|
1799
2345
|
const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
|
|
1800
|
-
copyImageToMachine(cachedImg, machineDir);
|
|
1801
2346
|
|
|
1802
|
-
//
|
|
2347
|
+
// Build the boot disk from the cache (resized if asked) and any extra disks
|
|
1803
2348
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
1804
2349
|
machineDir,
|
|
1805
2350
|
diskOpts.bootSize,
|
|
1806
2351
|
diskOpts.extraDisks,
|
|
1807
2352
|
diskOpts.bootDiskFormat,
|
|
2353
|
+
{ booted: false, factoryImage: async () => cachedImg },
|
|
1808
2354
|
);
|
|
1809
2355
|
const bootDisk = diskArtifacts.bootDisk;
|
|
1810
2356
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
1811
2357
|
|
|
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
2358
|
if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
|
|
1831
2359
|
throw new QuickCHRError(
|
|
1832
2360
|
"NETWORK_UNAVAILABLE",
|
|
@@ -1845,6 +2373,7 @@ export class QuickCHR {
|
|
|
1845
2373
|
networks: networkConfigs,
|
|
1846
2374
|
ports,
|
|
1847
2375
|
packages: opts.packages ?? [],
|
|
2376
|
+
installAllPackages: opts.installAllPackages,
|
|
1848
2377
|
deviceMode: requestedDeviceMode,
|
|
1849
2378
|
user: opts.user,
|
|
1850
2379
|
disableAdmin: opts.disableAdmin,
|
|
@@ -1912,8 +2441,10 @@ export class QuickCHR {
|
|
|
1912
2441
|
|
|
1913
2442
|
saveMachine(state);
|
|
1914
2443
|
unpersistedSocketClaim = undefined;
|
|
2444
|
+
storageLease.release();
|
|
1915
2445
|
|
|
1916
2446
|
const instance = createInstance(state);
|
|
2447
|
+
if (opts.wait === false) return instance;
|
|
1917
2448
|
|
|
1918
2449
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1919
2450
|
|
|
@@ -1963,7 +2494,7 @@ export class QuickCHR {
|
|
|
1963
2494
|
"BOOT_TIMEOUT",
|
|
1964
2495
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
1965
2496
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
1966
|
-
await cleanupAfterBootFailure(instance, name, failure) +
|
|
2497
|
+
await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
|
|
1967
2498
|
`\n${failure.summary}`,
|
|
1968
2499
|
);
|
|
1969
2500
|
}
|
|
@@ -1987,15 +2518,20 @@ export class QuickCHR {
|
|
|
1987
2518
|
} catch { /* never propagate */ }
|
|
1988
2519
|
|
|
1989
2520
|
if (hasProvisioning) {
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2521
|
+
try {
|
|
2522
|
+
await QuickCHR._provisionInstance(instance, state, {
|
|
2523
|
+
installAllPackages: opts.installAllPackages,
|
|
2524
|
+
packages: opts.packages,
|
|
2525
|
+
deviceMode: requestedDeviceMode,
|
|
2526
|
+
user: opts.user,
|
|
2527
|
+
disableAdmin: opts.disableAdmin,
|
|
2528
|
+
license: opts.license,
|
|
2529
|
+
secureLogin: opts.secureLogin,
|
|
2530
|
+
}, launchConfig, logger);
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
// This call created the machine, so a failed run takes it away again.
|
|
2533
|
+
throw await failProvisioning(err, instance, undefined);
|
|
2534
|
+
}
|
|
1999
2535
|
}
|
|
2000
2536
|
|
|
2001
2537
|
// Foreground + provisioning: all provisioning is done in background mode.
|
|
@@ -2003,12 +2539,12 @@ export class QuickCHR {
|
|
|
2003
2539
|
// QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
|
|
2004
2540
|
// shortcuts that are well-documented and googleable).
|
|
2005
2541
|
if (!background && hasProvisioning) {
|
|
2006
|
-
await
|
|
2542
|
+
await stopUnderLock(state);
|
|
2007
2543
|
// Brief pause for QEMU to flush and release the disk image
|
|
2008
2544
|
await Bun.sleep(1000);
|
|
2009
|
-
// Release lock before re-launching — _launchExisting acquires its own.
|
|
2010
|
-
// The outer finally
|
|
2011
|
-
|
|
2545
|
+
// Release the lock before re-launching — _launchExisting acquires its own.
|
|
2546
|
+
// The outer finally releases again, which is a no-op.
|
|
2547
|
+
lock.release();
|
|
2012
2548
|
return QuickCHR._launchExisting(state, false, undefined, logger);
|
|
2013
2549
|
}
|
|
2014
2550
|
|
|
@@ -2026,11 +2562,12 @@ export class QuickCHR {
|
|
|
2026
2562
|
// that already exist, and a directory this call did not create is never ours
|
|
2027
2563
|
// to delete, whatever state it is in.
|
|
2028
2564
|
if (dirCreatedHere && isOrphanMachineDir(name)) {
|
|
2029
|
-
try {
|
|
2565
|
+
try { removeState(name); } catch { /* best effort */ }
|
|
2030
2566
|
}
|
|
2031
2567
|
throw err;
|
|
2032
2568
|
} finally {
|
|
2033
|
-
|
|
2569
|
+
lock.release();
|
|
2570
|
+
storageLease.release();
|
|
2034
2571
|
}
|
|
2035
2572
|
}
|
|
2036
2573
|
|
|
@@ -2052,6 +2589,9 @@ export class QuickCHR {
|
|
|
2052
2589
|
logger?: ProgressLogger,
|
|
2053
2590
|
): Promise<void> {
|
|
2054
2591
|
const log = logger ?? createLogger();
|
|
2592
|
+
// What actually ran, persisted at the end as MachineState.provisioning — the
|
|
2593
|
+
// record that says this guest has been provisioned, and with what (#176).
|
|
2594
|
+
const applied: ProvisioningStep[] = [];
|
|
2055
2595
|
const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
|
|
2056
2596
|
const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
|
|
2057
2597
|
assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
|
|
@@ -2082,6 +2622,7 @@ export class QuickCHR {
|
|
|
2082
2622
|
saveMachine(current);
|
|
2083
2623
|
}
|
|
2084
2624
|
machineState.packages = installed;
|
|
2625
|
+
applied.push("packages");
|
|
2085
2626
|
} else if (opts.packages && opts.packages.length > 0) {
|
|
2086
2627
|
const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
|
|
2087
2628
|
await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
|
|
@@ -2091,10 +2632,14 @@ export class QuickCHR {
|
|
|
2091
2632
|
saveMachine(current);
|
|
2092
2633
|
}
|
|
2093
2634
|
machineState.packages = installed;
|
|
2635
|
+
applied.push("packages");
|
|
2094
2636
|
}
|
|
2095
2637
|
|
|
2096
2638
|
if (hasDeviceModeProvisioning) {
|
|
2097
|
-
|
|
2639
|
+
// Factory admin on purpose: this runs at step 2 and the user step is step 4,
|
|
2640
|
+
// so `machineState.user` names an account that does not exist yet.
|
|
2641
|
+
await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log, FACTORY_AUTH_HEADER);
|
|
2642
|
+
applied.push("deviceMode");
|
|
2098
2643
|
}
|
|
2099
2644
|
|
|
2100
2645
|
if (opts.license) {
|
|
@@ -2123,6 +2668,7 @@ export class QuickCHR {
|
|
|
2123
2668
|
saveMachine(current);
|
|
2124
2669
|
}
|
|
2125
2670
|
machineState.licenseLevel = actualLevel as LicenseLevel;
|
|
2671
|
+
applied.push("license");
|
|
2126
2672
|
log.status(` License applied: free → ${actualLevel}`);
|
|
2127
2673
|
} catch (e) {
|
|
2128
2674
|
if (e instanceof QuickCHRError) throw e;
|
|
@@ -2134,6 +2680,9 @@ export class QuickCHR {
|
|
|
2134
2680
|
}
|
|
2135
2681
|
|
|
2136
2682
|
if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
|
|
2683
|
+
if (opts.user) applied.push("user");
|
|
2684
|
+
if (opts.disableAdmin) applied.push("disableAdmin");
|
|
2685
|
+
if (opts.secureLogin === true) applied.push("secureLogin");
|
|
2137
2686
|
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
|
|
2138
2687
|
if (result.user || result.managedSshKey) {
|
|
2139
2688
|
// Persist user info + managed SSH key fact in state (password placeholder —
|
|
@@ -2161,6 +2710,11 @@ export class QuickCHR {
|
|
|
2161
2710
|
}
|
|
2162
2711
|
}
|
|
2163
2712
|
}
|
|
2713
|
+
|
|
2714
|
+
// Stamped only here, after every requested step returned. A run that throws
|
|
2715
|
+
// leaves the record absent, and the caller rolls the whole run back
|
|
2716
|
+
// (`rollbackFailedProvisioning()`), so the retry starts from a factory disk.
|
|
2717
|
+
recordProvisioning(machineState, applied);
|
|
2164
2718
|
}
|
|
2165
2719
|
|
|
2166
2720
|
/** Re-launch an existing stopped machine. */
|
|
@@ -2177,41 +2731,73 @@ export class QuickCHR {
|
|
|
2177
2731
|
secureLogin?: boolean;
|
|
2178
2732
|
},
|
|
2179
2733
|
logger?: ProgressLogger,
|
|
2734
|
+
wait = true,
|
|
2180
2735
|
): Promise<ChrInstance> {
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2736
|
+
const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
|
|
2737
|
+
opts && (
|
|
2738
|
+
opts.installAllPackages ||
|
|
2739
|
+
(opts.packages?.length ?? 0) > 0 ||
|
|
2740
|
+
opts.deviceMode ||
|
|
2741
|
+
opts.user ||
|
|
2742
|
+
opts.disableAdmin ||
|
|
2743
|
+
opts.license ||
|
|
2744
|
+
opts.secureLogin === true
|
|
2745
|
+
)
|
|
2746
|
+
);
|
|
2747
|
+
let hasProvisioning = wantsProvisioning(provisioningOpts);
|
|
2748
|
+
const lock = acquireLockNow(
|
|
2749
|
+
machineLockPath(state.machineDir),
|
|
2750
|
+
!background && !hasProvisioning ? "foreground" : "start",
|
|
2751
|
+
machineLockOptions(state.name, state.machineDir),
|
|
2752
|
+
);
|
|
2183
2753
|
// Same pre-persistence window as start(): a relaunch that claims an endpoint and
|
|
2184
2754
|
// then fails to spawn would keep it, with no instance to release it.
|
|
2185
2755
|
let unpersistedSocketClaim: MachineState | undefined;
|
|
2186
2756
|
try {
|
|
2187
2757
|
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2758
|
+
// `state` is the caller's snapshot, read before the lock was taken; everything
|
|
2759
|
+
// below acts on it, `state.pid` included (#180). Re-read it, and if another
|
|
2760
|
+
// start got the machine running in between, hand back that one rather than
|
|
2761
|
+
// booting a second QEMU on the same disk.
|
|
2762
|
+
assertSameMachine(state);
|
|
2763
|
+
refreshMachineState(state);
|
|
2764
|
+
if (isMachineRunning(state)) return createInstance(state);
|
|
2765
|
+
// `provisioningOpts` was worked out from that snapshot too. If another start
|
|
2766
|
+
// booted the machine in between, the window is shut and running them would be
|
|
2767
|
+
// a second first boot: check them against the machine as it is now, and drop
|
|
2768
|
+
// whatever that start already applied.
|
|
2769
|
+
if (provisioningOpts && !isProvisioningWindowOpen(state)) {
|
|
2770
|
+
assertProvisioningWindow(state, provisioningOpts);
|
|
2771
|
+
provisioningOpts = undefined;
|
|
2772
|
+
hasProvisioning = false;
|
|
2191
2773
|
}
|
|
2192
2774
|
|
|
2775
|
+
// Checks the disk this machine boots from — a qcow2 machine needs only boot.qcow2
|
|
2776
|
+
// (#200) — and refuses, rather than rebuilds, a booted machine's missing disk.
|
|
2193
2777
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
2194
2778
|
state.machineDir,
|
|
2195
2779
|
state.bootSize,
|
|
2196
2780
|
state.extraDisks,
|
|
2197
|
-
|
|
2781
|
+
bootDiskFormatOf(state),
|
|
2782
|
+
{
|
|
2783
|
+
booted: !isProvisioningWindowOpen(state),
|
|
2784
|
+
factoryImage: () => ensureCachedImage(state.version, state.arch, undefined, logger),
|
|
2785
|
+
},
|
|
2198
2786
|
);
|
|
2199
2787
|
const bootDisk = diskArtifacts.bootDisk;
|
|
2200
2788
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
2201
2789
|
|
|
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
2790
|
// Always boot in background when provisioning is needed
|
|
2214
2791
|
const spawnBackground = hasProvisioning ? true : background;
|
|
2792
|
+
// The record a failed first boot rolls back to — taken before this launch writes
|
|
2793
|
+
// pid, status or lastStartedAt into `state`. Whenever the window is open, not only
|
|
2794
|
+
// when there is provisioning to run: a boot timeout on an add()-created machine is
|
|
2795
|
+
// the same failed first boot as a provisioning error, and resets the same way
|
|
2796
|
+
// instead of deleting what `add` recorded (#182).
|
|
2797
|
+
const beforeLaunch = isProvisioningWindowOpen(state) ? structuredClone(state) : undefined;
|
|
2798
|
+
const bootFailureCleanup: BootFailureCleanup = beforeLaunch
|
|
2799
|
+
? { kind: "reset-first-boot", restore: beforeLaunch }
|
|
2800
|
+
: { kind: "preserve-existing" };
|
|
2215
2801
|
|
|
2216
2802
|
const platform = await detectPlatform();
|
|
2217
2803
|
const accel = await detectAccel(state.arch);
|
|
@@ -2263,6 +2849,7 @@ export class QuickCHR {
|
|
|
2263
2849
|
unpersistedSocketClaim = undefined;
|
|
2264
2850
|
|
|
2265
2851
|
const instance = createInstance(state);
|
|
2852
|
+
if (!wait) return instance;
|
|
2266
2853
|
|
|
2267
2854
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
2268
2855
|
const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
|
|
@@ -2292,7 +2879,7 @@ export class QuickCHR {
|
|
|
2292
2879
|
"BOOT_TIMEOUT",
|
|
2293
2880
|
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
|
|
2294
2881
|
(hasProvisioning ? " — provisioning could not run." : ".") +
|
|
2295
|
-
await cleanupAfterBootFailure(instance, state.name, failure) +
|
|
2882
|
+
await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
|
|
2296
2883
|
`\n${failure.summary}`,
|
|
2297
2884
|
);
|
|
2298
2885
|
}
|
|
@@ -2314,7 +2901,11 @@ export class QuickCHR {
|
|
|
2314
2901
|
} catch { /* never propagate */ }
|
|
2315
2902
|
|
|
2316
2903
|
if (hasProvisioning && provisioningOpts) {
|
|
2317
|
-
|
|
2904
|
+
try {
|
|
2905
|
+
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
throw await failProvisioning(err, instance, beforeLaunch);
|
|
2908
|
+
}
|
|
2318
2909
|
}
|
|
2319
2910
|
|
|
2320
2911
|
try {
|
|
@@ -2330,7 +2921,7 @@ export class QuickCHR {
|
|
|
2330
2921
|
if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
|
|
2331
2922
|
throw err;
|
|
2332
2923
|
} finally {
|
|
2333
|
-
|
|
2924
|
+
lock.release();
|
|
2334
2925
|
}
|
|
2335
2926
|
}
|
|
2336
2927
|
|
|
@@ -2386,20 +2977,20 @@ export class QuickCHR {
|
|
|
2386
2977
|
// A create still in flight is indistinguishable from an orphan by directory
|
|
2387
2978
|
// contents alone — add()/start() write machine.json last, so the window between
|
|
2388
2979
|
// ensureDir() and saveMachine() looks exactly like a half-made machine. The
|
|
2389
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
return
|
|
2980
|
+
// machine lock is what tells them apart, so take it: it throws MACHINE_LOCKED
|
|
2981
|
+
// while a live creator holds it, and takes over one whose owner is gone — which
|
|
2982
|
+
// is the case this recovery exists for. No waiting: a live creator means this is
|
|
2983
|
+
// not an orphan, and the answer is to let it finish.
|
|
2984
|
+
const lock = acquireLockNow(machineLockPath(getMachineDir(name)), "remove-orphan", machineLockOptions(name, getMachineDir(name)));
|
|
2985
|
+
try {
|
|
2986
|
+
// The creator may have finished between the check above and the lock.
|
|
2987
|
+
if (!isOrphanMachineDir(name)) return false;
|
|
2988
|
+
removeState(name);
|
|
2989
|
+
return true;
|
|
2990
|
+
} finally {
|
|
2991
|
+
// A no-op once the directory has gone; releases it if removal threw.
|
|
2992
|
+
lock.release();
|
|
2399
2993
|
}
|
|
2400
|
-
|
|
2401
|
-
removeState(name);
|
|
2402
|
-
return true;
|
|
2403
2994
|
}
|
|
2404
2995
|
|
|
2405
2996
|
/** Run doctor checks for prerequisites. */
|
|
@@ -2506,14 +3097,14 @@ export class QuickCHR {
|
|
|
2506
3097
|
detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
|
|
2507
3098
|
});
|
|
2508
3099
|
|
|
2509
|
-
let storageDetail = `${storage.
|
|
3100
|
+
let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
|
|
2510
3101
|
if (storage.status === "error") {
|
|
2511
|
-
storageDetail += ` — below
|
|
3102
|
+
storageDetail += ` — below the ${formatDiskSize(storage.reserveBytes)} reserve: new machines and image downloads are refused (after evicting what the cache can spare)`;
|
|
2512
3103
|
} else if (storage.status === "warn") {
|
|
2513
3104
|
storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
|
|
2514
3105
|
}
|
|
2515
3106
|
checks.push({
|
|
2516
|
-
label:
|
|
3107
|
+
label: "Storage",
|
|
2517
3108
|
status: storage.status,
|
|
2518
3109
|
detail: storageDetail,
|
|
2519
3110
|
});
|