@tikoci/quickchr 0.5.0 → 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/src/lib/disk.ts CHANGED
@@ -12,11 +12,12 @@
12
12
  * - {@link formatSnapshotTable} — render snapshot list as a human-readable ANSI table
13
13
  */
14
14
 
15
- import { join } from "node:path";
16
- import { existsSync, unlinkSync } from "node:fs";
15
+ import { basename, join } from "node:path";
16
+ import { copyFileSync, existsSync, rmSync, unlinkSync } from "node:fs";
17
17
  import type { BootDiskFormat, SnapshotInfo } from "./types.ts";
18
18
  import { QuickCHRError } from "./types.ts";
19
19
  import { requireQemuImg } from "./platform.ts";
20
+ import { renameWithRetry } from "./rename.ts";
20
21
 
21
22
  const DISK_SIZE_RE = /^\d+[KMGT]?$/i;
22
23
 
@@ -25,6 +26,8 @@ export interface DiskInfo {
25
26
  virtualSize: number;
26
27
  actualSize: number;
27
28
  filename: string;
29
+ /** Set when the image depends on another file (a qcow2 backing chain). */
30
+ backingFilename?: string;
28
31
  }
29
32
 
30
33
  /** True when a disk size string is valid for qemu-img (e.g. 512M, 1G, 2048). */
@@ -139,34 +142,58 @@ export async function getDiskInfo(path: string): Promise<DiskInfo> {
139
142
  virtualSize: info["virtual-size"],
140
143
  actualSize: info["actual-size"],
141
144
  filename: info.filename,
145
+ backingFilename: info["backing-filename"],
142
146
  };
143
147
  }
144
148
 
149
+ /** The boot format a machine record implies. Records from before `bootDiskFormat`
150
+ * existed only had `bootSize`, which always meant qcow2. */
151
+ export function bootDiskFormatOf(state: { bootDiskFormat?: BootDiskFormat; bootSize?: string }): BootDiskFormat {
152
+ return state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
153
+ }
154
+
155
+ /** Where a machine's boot disk lives: `disk.img` for raw, `boot.qcow2` for qcow2. */
156
+ export function bootDiskPath(machineDir: string, format: BootDiskFormat): string {
157
+ return join(machineDir, format === "qcow2" ? "boot.qcow2" : "disk.img");
158
+ }
159
+
145
160
  /**
146
- * Prepare the boot disk for a machine.
147
- * If bootSize is specified, converts the raw disk.img to boot.qcow2 and resizes it.
148
- * Otherwise returns the raw disk.img as-is.
161
+ * Write a machine's boot disk from a factory raw image, replacing any existing one.
162
+ *
163
+ * The disk is built under a `.tmp` name and renamed into place only once complete, so
164
+ * an interrupted copy or conversion never leaves a file the next start would accept.
165
+ * qcow2 is converted straight from `sourceImg` (normally the shared cache image) and
166
+ * checked to be standalone: no machine-local raw copy is kept, and no guest disk
167
+ * depends on the cache through a backing chain.
149
168
  */
150
- export async function prepareBootDisk(
169
+ export async function writeBootDisk(
151
170
  machineDir: string,
171
+ sourceImg: string,
172
+ format: BootDiskFormat,
152
173
  bootSize?: string,
153
- bootDiskFormat?: BootDiskFormat,
154
174
  ): Promise<{ path: string; format: BootDiskFormat }> {
155
- const format: BootDiskFormat = bootDiskFormat ?? (bootSize ? "qcow2" : "raw");
156
- const rawPath = join(machineDir, "disk.img");
157
-
158
- if (format === "raw") {
159
- return { path: rawPath, format: "raw" };
160
- }
161
-
162
- const qcow2Path = join(machineDir, "boot.qcow2");
163
- if (!existsSync(qcow2Path)) {
164
- await convertRawToQcow2(rawPath, qcow2Path);
165
- }
166
- if (bootSize) {
167
- await resizeQcow2(qcow2Path, bootSize);
175
+ const path = bootDiskPath(machineDir, format);
176
+ const tmpPath = `${path}.tmp`;
177
+ rmSync(tmpPath, { force: true });
178
+ try {
179
+ if (format === "raw") {
180
+ copyFileSync(sourceImg, tmpPath);
181
+ } else {
182
+ await convertRawToQcow2(sourceImg, tmpPath);
183
+ if (bootSize) await resizeQcow2(tmpPath, bootSize);
184
+ const info = await getDiskInfo(tmpPath);
185
+ if (info.format !== "qcow2" || info.backingFilename) {
186
+ throw new QuickCHRError(
187
+ "PROCESS_FAILED",
188
+ `Converted boot disk is not a standalone qcow2 (format=${info.format}${info.backingFilename ? `, backing=${info.backingFilename}` : ""})`,
189
+ );
190
+ }
191
+ }
192
+ renameWithRetry(tmpPath, path);
193
+ } finally {
194
+ rmSync(tmpPath, { force: true });
168
195
  }
169
- return { path: qcow2Path, format: "qcow2" };
196
+ return { path, format };
170
197
  }
171
198
 
172
199
  /**
@@ -186,24 +213,53 @@ export async function prepareExtraDisks(
186
213
  return results;
187
214
  }
188
215
 
189
- /** Ensure disk artifacts referenced by machine state exist on disk.
190
- * Used both for new-machine creation and to heal older add()-created states
191
- * that predate eager disk materialization. */
216
+ export interface DiskSources {
217
+ /** True once the guest has booted. Its disks then hold guest state, so a missing one
218
+ * is an error: rebuilding it from the factory image would quietly reset the router. */
219
+ booted: boolean;
220
+ /** The factory raw image a missing boot disk of a never-booted machine is built from.
221
+ * Called only when it is needed. */
222
+ factoryImage?: () => Promise<string>;
223
+ }
224
+
225
+ function missingDiskError(machineDir: string, path: string): QuickCHRError {
226
+ const name = basename(machineDir);
227
+ return new QuickCHRError(
228
+ "MACHINE_NOT_FOUND",
229
+ `Disk image not found for "${name}": ${path}. The machine has booted before, so quickchr will not rebuild it from the factory image — that would silently reset the router. Restore the file, or run 'quickchr clean ${name}' to reset it to factory.`,
230
+ );
231
+ }
232
+
233
+ /** Resolve the disks a machine record calls for, creating any that a never-booted
234
+ * machine is still missing. New machines, and older add()-created states that predate
235
+ * eager disk materialization, are built here; a booted machine's disks never are.
236
+ *
237
+ * A qcow2 machine needs only `boot.qcow2`. Machines created before 0.5.1 also hold a
238
+ * `disk.img`; it is unused once `boot.qcow2` exists, and is the conversion source for a
239
+ * legacy never-booted state that has no `boot.qcow2` yet. */
192
240
  export async function ensureConfiguredDisks(
193
241
  machineDir: string,
194
- bootSize?: string,
195
- extraDiskSizes?: string[],
196
- bootDiskFormat?: BootDiskFormat,
242
+ bootSize: string | undefined,
243
+ extraDiskSizes: string[] | undefined,
244
+ bootDiskFormat: BootDiskFormat,
245
+ sources: DiskSources,
197
246
  ): Promise<{
198
247
  bootDisk: { path: string; format: BootDiskFormat };
199
248
  extraDisks?: { path: string; format: "qcow2" }[];
200
249
  }> {
201
- const format: BootDiskFormat = bootDiskFormat ?? (bootSize ? "qcow2" : "raw");
202
- const bootDisk = format === "raw"
203
- ? { path: join(machineDir, "disk.img"), format: "raw" as const }
204
- : (existsSync(join(machineDir, "boot.qcow2"))
205
- ? { path: join(machineDir, "boot.qcow2"), format: "qcow2" as const }
206
- : await prepareBootDisk(machineDir, bootSize, "qcow2"));
250
+ const bootPath = bootDiskPath(machineDir, bootDiskFormat);
251
+ let bootDisk = { path: bootPath, format: bootDiskFormat };
252
+ if (!existsSync(bootPath)) {
253
+ if (sources.booted) throw missingDiskError(machineDir, bootPath);
254
+ const legacyRaw = join(machineDir, "disk.img");
255
+ const source = bootDiskFormat === "qcow2" && existsSync(legacyRaw)
256
+ ? legacyRaw
257
+ : await sources.factoryImage?.();
258
+ if (!source) {
259
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${basename(machineDir)}": ${bootPath}`);
260
+ }
261
+ bootDisk = await writeBootDisk(machineDir, source, bootDiskFormat, bootSize);
262
+ }
207
263
 
208
264
  let extraDisks: { path: string; format: "qcow2" }[] | undefined;
209
265
  if (extraDiskSizes && extraDiskSizes.length > 0) {
@@ -211,6 +267,7 @@ export async function ensureConfiguredDisks(
211
267
  for (let i = 0; i < extraDiskSizes.length; i++) {
212
268
  const diskPath = join(machineDir, `disk${i + 1}.qcow2`);
213
269
  if (!existsSync(diskPath)) {
270
+ if (sources.booted) throw missingDiskError(machineDir, diskPath);
214
271
  await createQcow2Disk(diskPath, extraDiskSizes[i] as string);
215
272
  }
216
273
  extraDisks.push({ path: diskPath, format: "qcow2" });
@@ -46,11 +46,12 @@
46
46
  * a transfer; it does not change the transport).
47
47
  */
48
48
 
49
- import { unlinkSync, existsSync, renameSync } from "node:fs";
49
+ import { unlinkSync, existsSync } from "node:fs";
50
50
  import { randomUUID } from "node:crypto";
51
51
  import { QuickCHRError } from "./types.ts";
52
52
  import { fetchResilient } from "./net.ts";
53
53
  import { createLogger, type ProgressLogger } from "./log.ts";
54
+ import { renameWithRetry } from "./rename.ts";
54
55
 
55
56
  /**
56
57
  * Throughput to assume when budgeting a COLD download from MikroTik, in bytes
@@ -257,7 +258,7 @@ export async function downloadToFile(
257
258
  // so this is a same-filesystem move and the artifact appears at its final
258
259
  // path atomically. Copying would re-write every byte (52 MB for the
259
260
  // largest artifact) for no benefit.
260
- renameSync(partPath, destPath);
261
+ renameWithRetry(partPath, destPath);
261
262
  log.status(` Saved (${describeTransfer(outcome)})`);
262
263
  return outcome;
263
264
  } catch (e) {
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, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
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 { assertSufficientQuickchrStorage } from "./storage.ts";
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
- renameSync(extractedPath, imgPath);
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
- assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
145
- const zipPath = await downloadImage(version, arch, cacheDir, logger);
146
- const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
147
- return finalizeExtractedImage(zipPath, extractedImgPath);
148
- }
149
-
150
- /** Copy a cached image to a machine's working directory as disk.img. */
151
- export function copyImageToMachine(cachedImgPath: string, machineDir: string): string {
152
- ensureDir(machineDir);
153
- const dest = join(machineDir, "disk.img");
154
- copyFileSync(cachedImgPath, dest);
155
- return dest;
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
@@ -2,7 +2,7 @@
2
2
  * Extra package download and installation for CHR instances.
3
3
  */
4
4
 
5
- import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
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
- // Download if needed. Same bounded path as the image download (#116) — this
83
- // call site previously had neither a deadline nor retries, so its only bound
84
- // was whatever the calling test happened to impose.
85
- if (!existsSync(zipPath)) {
86
- const log = logger ?? createLogger();
87
- log.status(`Downloading packages for ${version} (${arch})...`);
88
- log.status(` ${url}`);
89
- await downloadToFile(url, zipPath, { logger: log });
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
- extractZip(zipPath, tempDir);
97
- const packages = packageFiles(tempDir);
98
- if (packages.length === 0) {
99
- throw new Error(`No non-empty .npk files found in ${zipName}`);
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
- const manifest: PackageCacheManifest = {
102
- version,
103
- arch,
104
- archiveSize: statSync(zipPath).size,
105
- packages,
106
- };
107
- await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
108
- if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
109
- renameSync(tempDir, extractDir);
110
- } catch (error) {
111
- rmSync(tempDir, { recursive: true, force: true });
112
- if (error instanceof Error && error.message.startsWith("No non-empty")) {
113
- throw new QuickCHRError("PROCESS_FAILED", error.message);
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
- if (!(await isCompletePackageCache(version, arch, cache))) {
119
- throw new QuickCHRError(
120
- "PROCESS_FAILED",
121
- `Package cache verification failed for ${version} (${arch})`,
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
- const extractDir = await downloadPackages(version, arch);
229
- return listAvailablePackages(extractDir);
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.