@tikoci/quickchr 0.5.0 → 0.5.2
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/.bun-version +1 -0
- package/DESIGN.md +60 -8
- package/MANUAL.md +150 -43
- package/README.md +26 -11
- package/package.json +3 -2
- package/src/cli/flags.ts +7 -2
- package/src/cli/index.ts +63 -33
- package/src/cli/wizard.ts +54 -58
- package/src/lib/cache.ts +220 -38
- 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 +2 -1
- package/src/lib/packages.ts +66 -40
- package/src/lib/platform.ts +13 -1
- package/src/lib/quickchr.ts +87 -62
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/state.ts +26 -1
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +13 -3
package/src/lib/images.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Image download, ZIP extraction, and cache management.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync,
|
|
5
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import type { Arch } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
@@ -11,7 +11,18 @@ import { downloadToFile } from "./download.ts";
|
|
|
11
11
|
import { getCacheDir, ensureDir } from "./state.ts";
|
|
12
12
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
13
13
|
import { extractZip } from "./zip.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
|
|
15
|
+
import { renameWithRetry } from "./rename.ts";
|
|
16
|
+
|
|
17
|
+
/** A cache hit is a use: bump the mtime so automatic eviction counts the entry as
|
|
18
|
+
* recently used while this operation reads it (#199). Best-effort: a read-only cache
|
|
19
|
+
* still works. */
|
|
20
|
+
export function touchCacheEntry(path: string): void {
|
|
21
|
+
try {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
utimesSync(path, now, now);
|
|
24
|
+
} catch { /* read-only cache */ }
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
function finalizeExtractedImage(zipPath: string, imgPath: string): string {
|
|
17
28
|
if (existsSync(imgPath) && existsSync(zipPath)) {
|
|
@@ -118,7 +129,7 @@ export async function extractImage(
|
|
|
118
129
|
if (!(await isUsableCachedImage(extractedPath))) {
|
|
119
130
|
throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
|
|
120
131
|
}
|
|
121
|
-
|
|
132
|
+
renameWithRetry(extractedPath, imgPath);
|
|
122
133
|
} finally {
|
|
123
134
|
rmSync(tempDir, { recursive: true, force: true });
|
|
124
135
|
}
|
|
@@ -138,21 +149,29 @@ export async function ensureCachedImage(
|
|
|
138
149
|
if (await isUsableCachedImage(imgPath)) {
|
|
139
150
|
const log = logger ?? createLogger();
|
|
140
151
|
log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
|
|
152
|
+
touchCacheEntry(imgPath);
|
|
141
153
|
return imgPath;
|
|
142
154
|
}
|
|
143
155
|
if (existsSync(imgPath)) unlinkSync(imgPath);
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const log = logger ?? createLogger();
|
|
157
|
+
const base = chrImageBasename(version, arch);
|
|
158
|
+
const lease = admitStorage(
|
|
159
|
+
`cache CHR ${version} (${arch})`,
|
|
160
|
+
{
|
|
161
|
+
parts: [{ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache }],
|
|
162
|
+
protectVersions: [version],
|
|
163
|
+
pins: [`${base}.img`, `${base}.img.zip`],
|
|
164
|
+
cacheDir: cache,
|
|
165
|
+
},
|
|
166
|
+
{ logger: (message: string) => log.status(message) },
|
|
167
|
+
);
|
|
168
|
+
try {
|
|
169
|
+
const zipPath = await downloadImage(version, arch, cacheDir, logger);
|
|
170
|
+
const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
|
|
171
|
+
return finalizeExtractedImage(zipPath, extractedImgPath);
|
|
172
|
+
} finally {
|
|
173
|
+
lease.release();
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
/** List cached images. */
|
package/src/lib/lock.ts
CHANGED
|
@@ -65,7 +65,8 @@ export type LockOperation =
|
|
|
65
65
|
| "remove"
|
|
66
66
|
| "device-mode"
|
|
67
67
|
| "remove-orphan"
|
|
68
|
-
| "allocate"
|
|
68
|
+
| "allocate"
|
|
69
|
+
| "storage";
|
|
69
70
|
|
|
70
71
|
/** Resources a machine creation has claimed but not yet persisted in `machine.json`.
|
|
71
72
|
* Carried in the creator's machine lock, so it lives exactly as long as the create
|
package/src/lib/packages.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Extra package download and installation for CHR instances.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdtempSync, readdirSync,
|
|
5
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { QuickCHRError, type Arch } from "./types.ts";
|
|
8
8
|
import { packagesDownloadUrl } from "./versions.ts";
|
|
@@ -12,6 +12,10 @@ import { createLogger, type ProgressLogger } from "./log.ts";
|
|
|
12
12
|
import { restPost } from "./rest.ts";
|
|
13
13
|
import { scpPush } from "./scp.ts";
|
|
14
14
|
import { extractZip } from "./zip.ts";
|
|
15
|
+
import { renameWithRetry } from "./rename.ts";
|
|
16
|
+
import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
|
|
17
|
+
import { touchCacheEntry } from "./images.ts";
|
|
18
|
+
import { pinCacheEntries } from "./storage-lease.ts";
|
|
15
19
|
|
|
16
20
|
const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
|
|
17
21
|
|
|
@@ -76,50 +80,65 @@ export async function downloadPackages(
|
|
|
76
80
|
if (await isCompletePackageCache(version, arch, cache)) {
|
|
77
81
|
const log = logger ?? createLogger();
|
|
78
82
|
log.status(` Using cached packages: ${version} (${arch})`);
|
|
83
|
+
touchCacheEntry(zipPath);
|
|
79
84
|
return extractDir;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Extract into a sibling directory, record the exact complete set, then
|
|
93
|
-
// rename it into place. A killed extraction can never look like a cache hit.
|
|
94
|
-
const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
|
|
87
|
+
const lease = admitStorage(
|
|
88
|
+
`cache packages ${version} (${arch})`,
|
|
89
|
+
{
|
|
90
|
+
parts: [{ label: "packages download", bytes: STORAGE_ESTIMATES.packagesDownload, path: cache }],
|
|
91
|
+
protectVersions: [version],
|
|
92
|
+
pins: [`packages-${arch}-${version}`],
|
|
93
|
+
cacheDir: cache,
|
|
94
|
+
},
|
|
95
|
+
{ logger: (message: string) => (logger ?? createLogger()).status(message) },
|
|
96
|
+
);
|
|
95
97
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Download if needed. Same bounded path as the image download (#116) — this
|
|
99
|
+
// call site previously had neither a deadline nor retries, so its only bound
|
|
100
|
+
// was whatever the calling test happened to impose.
|
|
101
|
+
if (!existsSync(zipPath)) {
|
|
102
|
+
const log = logger ?? createLogger();
|
|
103
|
+
log.status(`Downloading packages for ${version} (${arch})...`);
|
|
104
|
+
log.status(` ${url}`);
|
|
105
|
+
await downloadToFile(url, zipPath, { logger: log });
|
|
100
106
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
|
|
108
|
+
// Extract into a sibling directory, record the exact complete set, then
|
|
109
|
+
// rename it into place. A killed extraction can never look like a cache hit.
|
|
110
|
+
const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
|
|
111
|
+
try {
|
|
112
|
+
extractZip(zipPath, tempDir);
|
|
113
|
+
const packages = packageFiles(tempDir);
|
|
114
|
+
if (packages.length === 0) {
|
|
115
|
+
throw new Error(`No non-empty .npk files found in ${zipName}`);
|
|
116
|
+
}
|
|
117
|
+
const manifest: PackageCacheManifest = {
|
|
118
|
+
version,
|
|
119
|
+
arch,
|
|
120
|
+
archiveSize: statSync(zipPath).size,
|
|
121
|
+
packages,
|
|
122
|
+
};
|
|
123
|
+
await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
124
|
+
if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
|
|
125
|
+
renameWithRetry(tempDir, extractDir);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
128
|
+
if (error instanceof Error && error.message.startsWith("No non-empty")) {
|
|
129
|
+
throw new QuickCHRError("PROCESS_FAILED", error.message);
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
114
132
|
}
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
133
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
if (!(await isCompletePackageCache(version, arch, cache))) {
|
|
135
|
+
throw new QuickCHRError(
|
|
136
|
+
"PROCESS_FAILED",
|
|
137
|
+
`Package cache verification failed for ${version} (${arch})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
lease.release();
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
return extractDir;
|
|
@@ -225,8 +244,15 @@ export async function downloadAndListPackages(
|
|
|
225
244
|
version: string,
|
|
226
245
|
arch: Arch,
|
|
227
246
|
): Promise<string[]> {
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
// Pinned through the listing: once downloadPackages() returns, nothing else stops a
|
|
248
|
+
// concurrent eviction from removing the extraction mid-read.
|
|
249
|
+
const pin = pinCacheEntries(`list packages ${version} (${arch})`, [`packages-${arch}-${version}`]);
|
|
250
|
+
try {
|
|
251
|
+
const extractDir = await downloadPackages(version, arch);
|
|
252
|
+
return listAvailablePackages(extractDir);
|
|
253
|
+
} finally {
|
|
254
|
+
pin.release();
|
|
255
|
+
}
|
|
230
256
|
}
|
|
231
257
|
|
|
232
258
|
/** Download all packages and install every one of them. Useful for API schema generation.
|
package/src/lib/platform.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
6
|
-
import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
|
|
6
|
+
import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo, DoctorCheck } from "./types.ts";
|
|
7
7
|
import { QuickCHRError as QError, parseAccelMode } from "./types.ts";
|
|
8
8
|
// settings-file.ts, not settings.ts: the latter's graph (cache → state → network
|
|
9
9
|
// → platform) is a cycle that would load eagerly on every CLI start.
|
|
@@ -590,3 +590,15 @@ export function resolveInterfaceAlias(
|
|
|
590
590
|
`No interface found for alias '${alias}' — run 'quickchr doctor' to see available interfaces`,
|
|
591
591
|
);
|
|
592
592
|
}
|
|
593
|
+
|
|
594
|
+
/** Compare the running Bun against the same pin CI installs. */
|
|
595
|
+
export async function bunRuntimeCheck(version = Bun.version): Promise<DoctorCheck> {
|
|
596
|
+
const pin = (await Bun.file(new URL("../../.bun-version", import.meta.url)).text()).trim();
|
|
597
|
+
// Runtimes predating the Semver API are also older than the tested pin.
|
|
598
|
+
const older = typeof Bun.semver?.order !== "function" || Bun.semver.order(version, pin) < 0;
|
|
599
|
+
return {
|
|
600
|
+
label: "Bun runtime",
|
|
601
|
+
status: older ? "warn" : "ok",
|
|
602
|
+
detail: `bun ${version}; tested with Bun ${pin}${older ? ` — upgrade to ${pin} or newer` : ""}`,
|
|
603
|
+
};
|
|
604
|
+
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type {
|
|
|
30
30
|
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP, PORTS_PER_BLOCK } from "./types.ts";
|
|
31
31
|
import { assertValidResourceName, assertPathSafeName, shellQuote } from "./names.ts";
|
|
32
32
|
import packageJson from "../../package.json";
|
|
33
|
-
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
33
|
+
import { bunRuntimeCheck, detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
34
34
|
import {
|
|
35
35
|
resolveVersion,
|
|
36
36
|
isValidVersion,
|
|
@@ -62,11 +62,11 @@ import {
|
|
|
62
62
|
getDataDir,
|
|
63
63
|
appendBootLog,
|
|
64
64
|
} from "./state.ts";
|
|
65
|
-
import { ensureCachedImage,
|
|
65
|
+
import { ensureCachedImage, listCachedImages } from "./images.ts";
|
|
66
66
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
67
67
|
import { resolveSetting } from "./settings.ts";
|
|
68
68
|
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
69
|
-
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
69
|
+
import { bootDiskFormatOf, bootDiskPath, cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, writeBootDisk, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
70
70
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
71
71
|
import { captureBootFailure, newBootProbeStats, preserveOnFailure, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
|
|
72
72
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
@@ -103,8 +103,9 @@ import {
|
|
|
103
103
|
import type { LicenseOptions } from "./types.ts";
|
|
104
104
|
import type { GuestExec } from "./guest-snapshot.ts";
|
|
105
105
|
import { toChrPorts } from "./network.ts";
|
|
106
|
-
import {
|
|
107
|
-
import {
|
|
106
|
+
import { admitStorage, estimateMachineCreation, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
|
|
107
|
+
import { pinCacheEntries } from "./storage-lease.ts";
|
|
108
|
+
import { existsSync, rmSync } from "node:fs";
|
|
108
109
|
import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
|
|
109
110
|
import { join } from "node:path";
|
|
110
111
|
|
|
@@ -373,19 +374,15 @@ async function resetGuestToFactory(state: MachineState): Promise<void> {
|
|
|
373
374
|
if (!existsSync(imgPath)) {
|
|
374
375
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
|
|
375
376
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
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);
|
|
380
382
|
cleanDiskFiles(state.machineDir);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
await ensureConfiguredDisks(
|
|
384
|
-
state.machineDir,
|
|
385
|
-
state.bootSize,
|
|
386
|
-
state.extraDisks,
|
|
387
|
-
state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
|
|
388
|
-
);
|
|
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 });
|
|
389
386
|
|
|
390
387
|
deleteInstanceCredentials(state.name);
|
|
391
388
|
// The managed keypair authenticated to the account factory reset erased,
|
|
@@ -1001,21 +998,28 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1001
998
|
const names = typeof packages === "string" ? [packages] : packages;
|
|
1002
999
|
if (names.length === 0) return [];
|
|
1003
1000
|
|
|
1004
|
-
|
|
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}`]);
|
|
1005
1004
|
const packagePaths: string[] = [];
|
|
1006
1005
|
const installed: string[] = [];
|
|
1007
|
-
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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);
|
|
1012
1016
|
}
|
|
1013
|
-
packagePaths.
|
|
1014
|
-
installed.push(pkg);
|
|
1015
|
-
}
|
|
1016
|
-
if (packagePaths.length === 0) return [];
|
|
1017
|
+
if (packagePaths.length === 0) return [];
|
|
1017
1018
|
|
|
1018
|
-
|
|
1019
|
+
await uploadPackages(packagePaths, ports.ssh);
|
|
1020
|
+
} finally {
|
|
1021
|
+
pin.release();
|
|
1022
|
+
}
|
|
1019
1023
|
|
|
1020
1024
|
// Reboot to activate packages
|
|
1021
1025
|
let rebootAuth: string;
|
|
@@ -1119,7 +1123,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1119
1123
|
|
|
1120
1124
|
snapshot: {
|
|
1121
1125
|
async list(): Promise<SnapshotInfo[]> {
|
|
1122
|
-
const format =
|
|
1126
|
+
const format = bootDiskFormatOf(state);
|
|
1123
1127
|
if (format !== "qcow2") return [];
|
|
1124
1128
|
|
|
1125
1129
|
// Try monitor first (works on running machines, gives live state)
|
|
@@ -1131,7 +1135,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1131
1135
|
}
|
|
1132
1136
|
|
|
1133
1137
|
// Fall back to qemu-img info (works on stopped machines too)
|
|
1134
|
-
const bootPath =
|
|
1138
|
+
const bootPath = bootDiskPath(state.machineDir, "qcow2");
|
|
1135
1139
|
if (existsSync(bootPath)) {
|
|
1136
1140
|
return listSnapshots(bootPath);
|
|
1137
1141
|
}
|
|
@@ -1139,7 +1143,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1139
1143
|
},
|
|
1140
1144
|
|
|
1141
1145
|
async save(name?: string): Promise<SnapshotInfo> {
|
|
1142
|
-
const format =
|
|
1146
|
+
const format = bootDiskFormatOf(state);
|
|
1143
1147
|
if (format !== "qcow2") {
|
|
1144
1148
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk. Recreate with bootDiskFormat: \"qcow2\".");
|
|
1145
1149
|
}
|
|
@@ -1176,7 +1180,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1176
1180
|
},
|
|
1177
1181
|
|
|
1178
1182
|
async load(name: string): Promise<void> {
|
|
1179
|
-
const format =
|
|
1183
|
+
const format = bootDiskFormatOf(state);
|
|
1180
1184
|
if (format !== "qcow2") {
|
|
1181
1185
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
|
|
1182
1186
|
}
|
|
@@ -1202,7 +1206,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
1202
1206
|
},
|
|
1203
1207
|
|
|
1204
1208
|
async delete(name: string): Promise<void> {
|
|
1205
|
-
const format =
|
|
1209
|
+
const format = bootDiskFormatOf(state);
|
|
1206
1210
|
if (format !== "qcow2") {
|
|
1207
1211
|
throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
|
|
1208
1212
|
}
|
|
@@ -1584,7 +1588,8 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1584
1588
|
state.machineDir,
|
|
1585
1589
|
state.bootSize,
|
|
1586
1590
|
state.extraDisks,
|
|
1587
|
-
|
|
1591
|
+
bootDiskFormatOf(state),
|
|
1592
|
+
{ booted: true },
|
|
1588
1593
|
);
|
|
1589
1594
|
const platform = await detectPlatform();
|
|
1590
1595
|
const hostfwd = buildHostfwdString(state.ports);
|
|
@@ -2029,16 +2034,28 @@ export class QuickCHR {
|
|
|
2029
2034
|
}
|
|
2030
2035
|
|
|
2031
2036
|
const machineDir = getMachineDir(name);
|
|
2032
|
-
|
|
2033
|
-
|
|
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;
|
|
2034
2051
|
try {
|
|
2035
2052
|
const cachedImg = await ensureCachedImage(version, arch);
|
|
2036
|
-
copyImageToMachine(cachedImg, machineDir);
|
|
2037
2053
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
2038
2054
|
machineDir,
|
|
2039
2055
|
diskOpts.bootSize,
|
|
2040
2056
|
diskOpts.extraDisks,
|
|
2041
2057
|
diskOpts.bootDiskFormat,
|
|
2058
|
+
{ booted: false, factoryImage: async () => cachedImg },
|
|
2042
2059
|
);
|
|
2043
2060
|
|
|
2044
2061
|
const state: MachineState = {
|
|
@@ -2083,6 +2100,7 @@ export class QuickCHR {
|
|
|
2083
2100
|
throw err;
|
|
2084
2101
|
} finally {
|
|
2085
2102
|
lock.release();
|
|
2103
|
+
storageLease.release();
|
|
2086
2104
|
}
|
|
2087
2105
|
}
|
|
2088
2106
|
|
|
@@ -2282,7 +2300,13 @@ export class QuickCHR {
|
|
|
2282
2300
|
}
|
|
2283
2301
|
|
|
2284
2302
|
const machineDir = getMachineDir(name);
|
|
2285
|
-
|
|
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
|
+
);
|
|
2286
2310
|
const dirCreatedHere = !existsSync(machineDir);
|
|
2287
2311
|
|
|
2288
2312
|
const background = opts.background ?? true;
|
|
@@ -2301,11 +2325,14 @@ export class QuickCHR {
|
|
|
2301
2325
|
opts.secureLogin === true
|
|
2302
2326
|
);
|
|
2303
2327
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
!background && !hasProvisioning ? "foreground" : "start",
|
|
2307
|
-
|
|
2308
|
-
|
|
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;
|
|
2309
2336
|
// Endpoints are claimed before QEMU is spawned, because resolution needs to know
|
|
2310
2337
|
// which end this machine holds. A launch that fails before its state is persisted
|
|
2311
2338
|
// has no instance lifecycle to release them later, so a failed start would leave a
|
|
@@ -2316,14 +2343,14 @@ export class QuickCHR {
|
|
|
2316
2343
|
|
|
2317
2344
|
// Download and prepare image
|
|
2318
2345
|
const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
|
|
2319
|
-
copyImageToMachine(cachedImg, machineDir);
|
|
2320
2346
|
|
|
2321
|
-
//
|
|
2347
|
+
// Build the boot disk from the cache (resized if asked) and any extra disks
|
|
2322
2348
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
2323
2349
|
machineDir,
|
|
2324
2350
|
diskOpts.bootSize,
|
|
2325
2351
|
diskOpts.extraDisks,
|
|
2326
2352
|
diskOpts.bootDiskFormat,
|
|
2353
|
+
{ booted: false, factoryImage: async () => cachedImg },
|
|
2327
2354
|
);
|
|
2328
2355
|
const bootDisk = diskArtifacts.bootDisk;
|
|
2329
2356
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
@@ -2414,6 +2441,7 @@ export class QuickCHR {
|
|
|
2414
2441
|
|
|
2415
2442
|
saveMachine(state);
|
|
2416
2443
|
unpersistedSocketClaim = undefined;
|
|
2444
|
+
storageLease.release();
|
|
2417
2445
|
|
|
2418
2446
|
const instance = createInstance(state);
|
|
2419
2447
|
if (opts.wait === false) return instance;
|
|
@@ -2539,6 +2567,7 @@ export class QuickCHR {
|
|
|
2539
2567
|
throw err;
|
|
2540
2568
|
} finally {
|
|
2541
2569
|
lock.release();
|
|
2570
|
+
storageLease.release();
|
|
2542
2571
|
}
|
|
2543
2572
|
}
|
|
2544
2573
|
|
|
@@ -2743,16 +2772,17 @@ export class QuickCHR {
|
|
|
2743
2772
|
hasProvisioning = false;
|
|
2744
2773
|
}
|
|
2745
2774
|
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
|
|
2749
|
-
}
|
|
2750
|
-
|
|
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.
|
|
2751
2777
|
const diskArtifacts = await ensureConfiguredDisks(
|
|
2752
2778
|
state.machineDir,
|
|
2753
2779
|
state.bootSize,
|
|
2754
2780
|
state.extraDisks,
|
|
2755
|
-
|
|
2781
|
+
bootDiskFormatOf(state),
|
|
2782
|
+
{
|
|
2783
|
+
booted: !isProvisioningWindowOpen(state),
|
|
2784
|
+
factoryImage: () => ensureCachedImage(state.version, state.arch, undefined, logger),
|
|
2785
|
+
},
|
|
2756
2786
|
);
|
|
2757
2787
|
const bootDisk = diskArtifacts.bootDisk;
|
|
2758
2788
|
const extraDiskConfigs = diskArtifacts.extraDisks;
|
|
@@ -2967,12 +2997,7 @@ export class QuickCHR {
|
|
|
2967
2997
|
static async doctor(): Promise<DoctorResult> {
|
|
2968
2998
|
const checks: DoctorResult["checks"] = [];
|
|
2969
2999
|
|
|
2970
|
-
|
|
2971
|
-
checks.push({
|
|
2972
|
-
label: "Bun runtime",
|
|
2973
|
-
status: "ok",
|
|
2974
|
-
detail: `bun ${Bun.version}`,
|
|
2975
|
-
});
|
|
3000
|
+
checks.push(await bunRuntimeCheck());
|
|
2976
3001
|
|
|
2977
3002
|
// QEMU for each arch
|
|
2978
3003
|
for (const arch of ARCHES) {
|
|
@@ -3067,14 +3092,14 @@ export class QuickCHR {
|
|
|
3067
3092
|
detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
|
|
3068
3093
|
});
|
|
3069
3094
|
|
|
3070
|
-
let storageDetail = `${storage.
|
|
3095
|
+
let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
|
|
3071
3096
|
if (storage.status === "error") {
|
|
3072
|
-
storageDetail += ` — below
|
|
3097
|
+
storageDetail += ` — below the ${formatDiskSize(storage.reserveBytes)} reserve: new machines and image downloads are refused (after evicting what the cache can spare)`;
|
|
3073
3098
|
} else if (storage.status === "warn") {
|
|
3074
3099
|
storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
|
|
3075
3100
|
}
|
|
3076
3101
|
checks.push({
|
|
3077
|
-
label:
|
|
3102
|
+
label: "Storage",
|
|
3078
3103
|
status: storage.status,
|
|
3079
3104
|
detail: storageDetail,
|
|
3080
3105
|
});
|
|
@@ -3095,7 +3120,7 @@ export class QuickCHR {
|
|
|
3095
3120
|
});
|
|
3096
3121
|
}
|
|
3097
3122
|
|
|
3098
|
-
// qemu-img (
|
|
3123
|
+
// qemu-img (default qcow2 creation and disk features)
|
|
3099
3124
|
const qemuImgPath = findQemuImg();
|
|
3100
3125
|
if (qemuImgPath) {
|
|
3101
3126
|
checks.push({
|
|
@@ -3107,7 +3132,7 @@ export class QuickCHR {
|
|
|
3107
3132
|
checks.push({
|
|
3108
3133
|
label: "qemu-img",
|
|
3109
3134
|
status: "warn",
|
|
3110
|
-
detail: `not found — required for --boot-size and --add-disk (${getQemuInstallHint()})`,
|
|
3135
|
+
detail: `not found — required for default qcow2 creation, setup, --boot-size and --add-disk (${getQemuInstallHint()})`,
|
|
3111
3136
|
});
|
|
3112
3137
|
}
|
|
3113
3138
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish-by-rename that survives Windows' transient sharing violations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renameSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
/** Codes Windows returns while another process (Defender, the Search indexer) still
|
|
8
|
+
* holds a handle on a file it is scanning — typically one we just wrote. */
|
|
9
|
+
const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
10
|
+
const RETRY_DELAYS_MS = [25, 50, 100, 200, 400, 800];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `renameSync`, retried on Windows while the failure looks like a sharing violation
|
|
14
|
+
* (#202). Every quickchr publish writes a temp file or directory and renames it into
|
|
15
|
+
* place, which is exactly when a scanner has the fresh file open. libuv does not retry
|
|
16
|
+
* this case. The budget is bounded (about 1.6 s); after it, the last error is thrown
|
|
17
|
+
* unchanged. Elsewhere this is a plain `renameSync`.
|
|
18
|
+
*/
|
|
19
|
+
export function renameWithRetry(
|
|
20
|
+
from: string,
|
|
21
|
+
to: string,
|
|
22
|
+
opts: { platform?: NodeJS.Platform; rename?: (from: string, to: string) => void; sleep?: (ms: number) => void } = {},
|
|
23
|
+
): void {
|
|
24
|
+
const rename = opts.rename ?? renameSync;
|
|
25
|
+
if ((opts.platform ?? process.platform) !== "win32") {
|
|
26
|
+
rename(from, to);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const sleep = opts.sleep ?? Bun.sleepSync;
|
|
30
|
+
for (const delay of RETRY_DELAYS_MS) {
|
|
31
|
+
try {
|
|
32
|
+
rename(from, to);
|
|
33
|
+
return;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
36
|
+
if (!code || !TRANSIENT_WINDOWS_CODES.has(code)) throw error;
|
|
37
|
+
sleep(delay);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
rename(from, to);
|
|
41
|
+
}
|