@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/src/lib/cache.ts CHANGED
@@ -1,20 +1,27 @@
1
1
  /**
2
- * Cache retention policy for downloaded CHR images.
2
+ * Cache retention policy for downloaded CHR images and package archives.
3
3
  *
4
- * - Default cap: 2 GB total cache size (size-based eviction).
4
+ * - Default cap: 2 GB total cache size (size-based eviction), counting images, package
5
+ * archives with their extractions, and interrupted downloads.
5
6
  * - Eviction order: oldest RouterOS version first, mtime tiebreaker.
6
7
  * - Never evicts an image referenced by any machine (running or stopped).
7
8
  * - Callers can pin extra versions via `protectVersions` (e.g. current long-term).
8
9
  */
9
10
 
10
- import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
11
12
  import { join } from "node:path";
12
- import { getCacheDir, loadAllMachines } from "./state.ts";
13
+ import { directorySize, getCacheDir, loadAllMachines } from "./state.ts";
14
+ import { isPidAlive, liveLeases, pinnedEntries, withStorageLock } from "./storage-lease.ts";
13
15
  import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
14
16
  import { QuickCHRError, type Arch } from "./types.ts";
15
17
 
16
18
  export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
17
19
 
20
+ /** Free space quickchr keeps on the data dir's filesystem beyond what an operation
21
+ * writes — the `storage-reserve` setting's default. Lives here beside the cache cap
22
+ * so settings.ts can read both without importing storage.ts (which imports settings). */
23
+ export const DEFAULT_STORAGE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024;
24
+
18
25
  /** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
19
26
  export function parseSizeString(s: string): number {
20
27
  const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
@@ -25,6 +32,11 @@ export function parseSizeString(s: string): number {
25
32
  return Math.floor(n * (mult[unit] ?? 1));
26
33
  }
27
34
 
35
+ /** "1 entry" / "3 entries". */
36
+ export function countEntries(n: number): string {
37
+ return `${n} ${n === 1 ? "entry" : "entries"}`;
38
+ }
39
+
28
40
  /** Format a byte count as a human size (IEC binary), matching cache list's display. */
29
41
  export function formatSizeBytes(b: number): string {
30
42
  if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
@@ -33,34 +45,72 @@ export function formatSizeBytes(b: number): string {
33
45
  return `${b} B`;
34
46
  }
35
47
 
48
+ /** What a cache entry is. `packages` is one archive + its extraction, counted and
49
+ * deleted together: the extraction is only a cache hit while the archive it was
50
+ * made from is still there. `partial` is an interrupted download or extraction. */
51
+ export type CacheEntryKind = "image" | "image-zip" | "packages" | "partial";
52
+
36
53
  export interface CacheEntry {
54
+ /** The entry's main file or directory. */
37
55
  path: string;
56
+ /** Everything deleting this entry removes. */
57
+ paths: string[];
38
58
  basename: string;
59
+ kind: CacheEntryKind;
39
60
  version: string;
40
61
  arch: Arch | "unknown";
41
62
  sizeBytes: number;
63
+ /** Last write, or last use: a cache hit touches the image or package archive. */
42
64
  mtimeMs: number;
43
65
  inUse: boolean;
44
66
  }
45
67
 
68
+ export interface CacheInventory {
69
+ entries: CacheEntry[];
70
+ /** Bytes in files quickchr did not write. Listed, never deleted. */
71
+ unmanagedBytes: number;
72
+ }
73
+
46
74
  export interface PruneOptions {
47
75
  cacheDir?: string;
48
76
  maxSizeBytes?: number;
49
77
  maxAgeMs?: number;
50
78
  olderThan?: string;
51
79
  protectVersions?: string[];
80
+ /** Evict eligible entries, oldest version first, until this many bytes are gone. */
81
+ reclaimBytes?: number;
82
+ /** Keep entries written or used this recently. Pins are what protect a reader; this
83
+ * also spares what a just-finished `cache add` fetched for a later process. */
84
+ skipRecentMs?: number;
85
+ /** Leave these entries alone (a reclaim loop's earlier failed deletes). */
86
+ skipEntries?: string[];
52
87
  dryRun?: boolean;
53
88
  }
54
89
 
55
90
  export interface PruneResult {
56
91
  evicted: CacheEntry[];
57
92
  kept: CacheEntry[];
93
+ /** Entries chosen for eviction whose delete failed. Neither evicted nor kept, and not
94
+ * counted in `freedBytes`. */
95
+ failed: Array<{ entry: CacheEntry; error: string }>;
96
+ /** Logical size of what was deleted. Not a measured change in free space: sparse
97
+ * files, clones and concurrent writers all make the two differ. */
58
98
  freedBytes: number;
59
99
  reasons: Record<string, string>;
60
100
  }
61
101
 
102
+ /** Anything an automatic eviction leaves alone because another operation may be using
103
+ * it right now — a create converting from the image, a first boot installing packages. */
104
+ export const RECENT_USE_MS = 30 * 60 * 1000;
105
+
62
106
  /** Parse a cached file basename into version + arch. Returns "unknown" parts when unparseable. */
63
107
  export function parseCacheBasename(basename: string): { version: string; arch: Arch | "unknown" } {
108
+ const packages = basename.match(/^(?:all_)?packages-(x86|arm64)-(.+?)(?:\.zip)?$/);
109
+ if (packages) {
110
+ const version = packages[2] ?? "unknown";
111
+ return { version: isValidVersion(version) ? version : "unknown", arch: packages[1] as Arch };
112
+ }
113
+
64
114
  // Strip recognized cache extensions (.img, .img.zip).
65
115
  let stem = basename;
66
116
  if (stem.endsWith(".img.zip")) stem = stem.slice(0, -".img.zip".length);
@@ -76,6 +126,9 @@ export function parseCacheBasename(basename: string): { version: string; arch: A
76
126
  return { version, arch };
77
127
  }
78
128
 
129
+ /** Cache names machines still need. Every machine keeps its factory image: `clean` and a
130
+ * failed first boot's rollback rebuild from it. A machine keeps its package cache only
131
+ * until its first-boot provisioning is stamped done. */
79
132
  function inUseSet(): Set<string> {
80
133
  const set = new Set<string>();
81
134
  let machines: ReturnType<typeof loadAllMachines>;
@@ -88,16 +141,24 @@ function inUseSet(): Set<string> {
88
141
  if (!m.version || !m.arch) continue;
89
142
  set.add(`${chrImageBasename(m.version, m.arch)}.img`);
90
143
  set.add(`${chrImageBasename(m.version, m.arch)}.img.zip`);
144
+ const wantsPackages = m.installAllPackages === true || (m.packages?.length ?? 0) > 0;
145
+ if (wantsPackages && !m.provisioning) set.add(`packages-${m.arch}-${m.version}`);
91
146
  }
92
147
  return set;
93
148
  }
94
149
 
95
- /** List cache entries with parsed metadata and in-use status. */
96
- export function listCacheEntries(cacheDir?: string): CacheEntry[] {
150
+ /** Inventory the cache: every entry quickchr manages, plus the bytes it does not.
151
+ * An entry is in use when a machine needs it or a live operation has pinned it. */
152
+ export function cacheInventory(cacheDir?: string, pins: Set<string> = pinnedEntries()): CacheInventory {
97
153
  const dir = cacheDir ?? getCacheDir();
98
- if (!existsSync(dir)) return [];
154
+ if (!existsSync(dir)) return { entries: [], unmanagedBytes: 0 };
99
155
  const used = inUseSet();
100
- const out: CacheEntry[] = [];
156
+ for (const pin of pins) used.add(pin);
157
+ const now = Date.now();
158
+ const entries: CacheEntry[] = [];
159
+ const packageGroups = new Map<string, CacheEntry>();
160
+ let unmanagedBytes = 0;
161
+
101
162
  for (const name of readdirSync(dir)) {
102
163
  const full = join(dir, name);
103
164
  let stat: ReturnType<typeof statSync>;
@@ -106,20 +167,94 @@ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
106
167
  } catch {
107
168
  continue;
108
169
  }
109
- if (!stat.isFile()) continue;
110
- if (!name.endsWith(".img") && !name.endsWith(".img.zip")) continue;
170
+ const sizeBytes = stat.isDirectory() ? directorySize(full) : stat.size;
171
+
172
+ // Only exact quickchr names are managed: `all_packages-<arch>-<ver>.zip` (a file)
173
+ // and `packages-<arch>-<ver>` (a directory), with a real RouterOS version.
174
+ const packages = name.match(/^(all_)?packages-(x86|arm64)-(.+?)(\.zip)?$/);
175
+ const isPackageArtifact = packages !== null && isValidVersion(packages[3] ?? "") &&
176
+ (packages[1] && packages[4] ? stat.isFile() : !packages[1] && !packages[4] && stat.isDirectory());
177
+ if (packages && isPackageArtifact) {
178
+ const key = `packages-${packages[2]}-${packages[3]}`;
179
+ const group = packageGroups.get(key);
180
+ if (group) {
181
+ group.paths.push(full);
182
+ group.sizeBytes += sizeBytes;
183
+ group.mtimeMs = Math.max(group.mtimeMs, stat.mtimeMs);
184
+ } else {
185
+ const { version, arch } = parseCacheBasename(key);
186
+ packageGroups.set(key, {
187
+ path: join(dir, key),
188
+ paths: [full],
189
+ basename: key,
190
+ kind: "packages",
191
+ version,
192
+ arch,
193
+ sizeBytes,
194
+ mtimeMs: stat.mtimeMs,
195
+ inUse: used.has(key),
196
+ });
197
+ }
198
+ continue;
199
+ }
200
+
201
+ // `<target>.<pid>-<id>.part` (download.ts) is live while its writer is.
202
+ // `.quickchr-image-*` / `.quickchr-packages-*` extraction dirs have no owner
203
+ // recorded, so they count as live for RECENT_USE_MS after their last write.
204
+ const part = name.match(/^(.+)\.(\d+)-[0-9a-f]+\.part$/);
205
+ const extraction = stat.isDirectory() && (name.startsWith(".quickchr-image-") || name.startsWith(".quickchr-packages-"));
206
+ if ((part && isManagedDownloadTarget(part[1] ?? "")) || extraction) {
207
+ const { version, arch } = parseCacheBasename(part?.[1] ?? name);
208
+ entries.push({
209
+ path: full,
210
+ paths: [full],
211
+ basename: name,
212
+ kind: "partial",
213
+ version,
214
+ arch,
215
+ sizeBytes,
216
+ mtimeMs: stat.mtimeMs,
217
+ inUse: part ? isPidAlive(Number(part[2])) : now - stat.mtimeMs < RECENT_USE_MS,
218
+ });
219
+ continue;
220
+ }
221
+
222
+ const kind: CacheEntryKind | undefined = !stat.isFile()
223
+ ? undefined
224
+ : name.endsWith(".img.zip") ? "image-zip" : name.endsWith(".img") ? "image" : undefined;
111
225
  const { version, arch } = parseCacheBasename(name);
112
- out.push({
226
+ if (!kind || version === "unknown") {
227
+ unmanagedBytes += sizeBytes;
228
+ continue;
229
+ }
230
+ entries.push({
113
231
  path: full,
232
+ paths: [full],
114
233
  basename: name,
234
+ kind,
115
235
  version,
116
236
  arch,
117
- sizeBytes: stat.size,
237
+ sizeBytes,
118
238
  mtimeMs: stat.mtimeMs,
119
239
  inUse: used.has(name),
120
240
  });
121
241
  }
122
- return out;
242
+
243
+ entries.push(...packageGroups.values());
244
+ return { entries, unmanagedBytes };
245
+ }
246
+
247
+ /** True for the files download.ts writes into the cache: an image ZIP or an
248
+ * all-packages ZIP, with a real RouterOS version. Anything else is not ours. */
249
+ function isManagedDownloadTarget(name: string): boolean {
250
+ if (name.endsWith(".img.zip")) return parseCacheBasename(name).version !== "unknown";
251
+ const packages = name.match(/^all_packages-(x86|arm64)-(.+)\.zip$/);
252
+ return packages !== null && isValidVersion(packages[2] ?? "");
253
+ }
254
+
255
+ /** List cache entries with parsed metadata and in-use status. */
256
+ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
257
+ return cacheInventory(cacheDir).entries;
123
258
  }
124
259
 
125
260
  function compareVersionsSafe(a: string, b: string): number {
@@ -140,11 +275,24 @@ function compareVersionsSafe(a: string, b: string): number {
140
275
  * - `maxSizeBytes`: total bytes exceed cap
141
276
  * - `maxAgeMs`: any entry older than threshold
142
277
  * - `olderThan`: any entry's parsed version older than threshold
278
+ * - `reclaimBytes`: evict until this many bytes are gone
143
279
  *
144
- * In-use entries and `protectVersions` entries are never evicted.
280
+ * In-use entries, `protectVersions` entries and (with `skipRecentMs`) recently used
281
+ * entries are never evicted. Interrupted downloads go first, then oldest version.
145
282
  */
146
283
  export function pruneCache(opts: PruneOptions = {}): PruneResult {
147
- const entries = listCacheEntries(opts.cacheDir);
284
+ // A dry run deletes nothing, so it reads the pins without taking the lock.
285
+ return opts.dryRun ? pruneCacheUnderLock(opts, pinnedEntries()) : withStorageLock(() => pruneCacheUnderLock(opts));
286
+ }
287
+
288
+ /** `pruneCache` for a caller already holding the storage lock (storage.ts admission).
289
+ * Entries pinned by a live lease count as in use. */
290
+ export function pruneCacheUnderLock(
291
+ opts: PruneOptions = {},
292
+ pins: Set<string> = new Set(liveLeases().flatMap((lease) => lease.pins)),
293
+ ): PruneResult {
294
+ const entries = cacheInventory(opts.cacheDir, pins).entries;
295
+ const skip = new Set(opts.skipEntries ?? []);
148
296
  const protect = new Set(opts.protectVersions ?? []);
149
297
  const dryRun = opts.dryRun ?? false;
150
298
  const now = Date.now();
@@ -152,6 +300,7 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
152
300
  const reasons: Record<string, string> = {};
153
301
  const evicted: CacheEntry[] = [];
154
302
  const kept: CacheEntry[] = [];
303
+ const failed: PruneResult["failed"] = [];
155
304
 
156
305
  const protectedEntries: CacheEntry[] = [];
157
306
  const candidates: CacheEntry[] = [];
@@ -159,27 +308,44 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
159
308
  for (const e of entries) {
160
309
  if (e.inUse) {
161
310
  protectedEntries.push(e);
162
- continue;
163
- }
164
- if (protect.has(e.version)) {
311
+ reasons[e.basename] = e.kind === "partial" ? "in progress" : "in use";
312
+ } else if (protect.has(e.version)) {
165
313
  protectedEntries.push(e);
166
- continue;
314
+ reasons[e.basename] = `protected (version ${e.version})`;
315
+ } else if (skip.has(e.basename)) {
316
+ protectedEntries.push(e);
317
+ reasons[e.basename] = "skipped";
318
+ } else if (opts.skipRecentMs !== undefined && now - e.mtimeMs < opts.skipRecentMs) {
319
+ protectedEntries.push(e);
320
+ reasons[e.basename] = "used recently";
321
+ } else {
322
+ candidates.push(e);
167
323
  }
168
- candidates.push(e);
169
324
  }
170
325
 
171
326
  candidates.sort((a, b) => {
327
+ const partial = Number(b.kind === "partial") - Number(a.kind === "partial");
328
+ if (partial !== 0) return partial;
172
329
  const v = compareVersionsSafe(a.version, b.version);
173
330
  if (v !== 0) return v;
174
331
  return a.mtimeMs - b.mtimeMs;
175
332
  });
176
333
 
177
- const evict = (e: CacheEntry, reason: string) => {
178
- evicted.push(e);
179
- reasons[e.basename] = reason;
334
+ /** True when the entry is gone (or, in a dry run, would be). */
335
+ const evict = (e: CacheEntry, reason: string): boolean => {
180
336
  if (!dryRun) {
181
- try { unlinkSync(e.path); } catch { /* best-effort */ }
337
+ try {
338
+ for (const path of e.paths) rmSync(path, { recursive: true, force: true });
339
+ } catch (error) {
340
+ const message = error instanceof Error ? error.message : String(error);
341
+ failed.push({ entry: e, error: message });
342
+ reasons[e.basename] = `delete failed: ${message}`;
343
+ return false;
344
+ }
182
345
  }
346
+ evicted.push(e);
347
+ reasons[e.basename] = reason;
348
+ return true;
183
349
  };
184
350
 
185
351
  const remaining: CacheEntry[] = [];
@@ -212,31 +378,34 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
212
378
  }
213
379
 
214
380
  const keptSoFar: CacheEntry[] = [...remainingAfterAge];
381
+ const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
215
382
 
216
383
  if (opts.maxSizeBytes !== undefined) {
217
384
  const cap = opts.maxSizeBytes;
218
- const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
219
- let total = totalSize(protectedEntries) + totalSize(keptSoFar);
385
+ let total = totalSize(protectedEntries) + totalSize(keptSoFar) + totalSize(failed.map((f) => f.entry));
220
386
  while (total > cap && keptSoFar.length > 0) {
221
387
  const victim = keptSoFar.shift();
222
388
  if (!victim) break;
223
- evict(victim, `size cap ${cap} exceeded (oldest version)`);
224
- total -= victim.sizeBytes;
389
+ if (evict(victim, `size cap ${formatSizeBytes(cap)} exceeded (oldest version)`)) total -= victim.sizeBytes;
225
390
  }
226
391
  }
227
392
 
228
- for (const e of protectedEntries) {
229
- kept.push(e);
230
- if (e.inUse) reasons[e.basename] ??= "in use";
231
- else reasons[e.basename] ??= `protected (version ${e.version})`;
393
+ if (opts.reclaimBytes !== undefined) {
394
+ const target = opts.reclaimBytes;
395
+ while (totalSize(evicted) < target && keptSoFar.length > 0) {
396
+ const victim = keptSoFar.shift();
397
+ if (!victim) break;
398
+ evict(victim, `reclaimed for space (oldest version)`);
399
+ }
232
400
  }
401
+
402
+ for (const e of protectedEntries) kept.push(e);
233
403
  for (const e of keptSoFar) {
234
404
  kept.push(e);
235
405
  reasons[e.basename] ??= "kept";
236
406
  }
237
407
 
238
- const freedBytes = evicted.reduce((s, e) => s + e.sizeBytes, 0);
239
- return { evicted, kept, freedBytes, reasons };
408
+ return { evicted, kept, failed, freedBytes: totalSize(evicted), reasons };
240
409
  }
241
410
 
242
411
  /**
@@ -249,21 +418,34 @@ export function autoPruneIfOverCap(opts: { logger?: (msg: string) => void; cache
249
418
  const entries = listCacheEntries(opts.cacheDir);
250
419
  const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
251
420
  if (total <= cap) {
252
- return { evicted: [], kept: entries, freedBytes: 0, reasons: {} };
421
+ return { evicted: [], kept: entries, failed: [], freedBytes: 0, reasons: {} };
253
422
  }
254
423
  const result = pruneCache({
255
424
  cacheDir: opts.cacheDir,
256
425
  maxSizeBytes: cap,
257
426
  protectVersions: opts.protectVersions,
427
+ skipRecentMs: RECENT_USE_MS,
258
428
  });
259
- if (opts.logger && result.evicted.length > 0) {
260
- opts.logger(`cache: evicted ${result.evicted.length} image(s), freed ${(result.freedBytes / 1024 / 1024).toFixed(1)} MiB`);
429
+ if (opts.logger) {
430
+ for (const line of describePrune(result)) opts.logger(line);
261
431
  }
262
432
  return result;
263
433
  } catch (e) {
264
434
  if (opts.logger) {
265
435
  opts.logger(`cache: auto-prune skipped: ${e instanceof Error ? e.message : String(e)}`);
266
436
  }
267
- return { evicted: [], kept: [], freedBytes: 0, reasons: {} };
437
+ return { evicted: [], kept: [], failed: [], freedBytes: 0, reasons: {} };
438
+ }
439
+ }
440
+
441
+ /** One-line log summary of an eviction, naming what went, plus one line per failure. */
442
+ export function describePrune(result: PruneResult): string[] {
443
+ const lines: string[] = [];
444
+ if (result.evicted.length > 0) {
445
+ lines.push(`cache: evicted ${result.evicted.map((e) => e.basename).join(", ")} — freed ${formatSizeBytes(result.freedBytes)}`);
446
+ }
447
+ for (const { entry, error } of result.failed) {
448
+ lines.push(`cache: could not delete ${entry.basename}: ${error}`);
268
449
  }
450
+ return lines;
269
451
  }
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) {