@tikoci/quickchr 0.4.1 → 0.4.3

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.
@@ -364,14 +364,12 @@ export async function provision(
364
364
  // Try REST provisioning. When machineDir is provided, use a shorter timeout
365
365
  // so the console fallback can engage sooner if REST isn't available.
366
366
  const restTimeout = machineDir ? 30_000 : 60_000;
367
- let usedConsolePath = false;
368
367
  try {
369
368
  await waitForRest(httpPort, restTimeout);
370
369
  } catch (e) {
371
370
  if (machineDir) {
372
371
  log.warn("REST API unavailable — falling back to serial console provisioning");
373
372
  const result = await consoleProvision(machineDir, machineName, effectiveUser, !!shouldDisableAdmin, logger, portBase);
374
- usedConsolePath = true;
375
373
  // After console provisioning the machine is booted; REST comes up shortly.
376
374
  // Attempt SSH key install for the managed quickchr user — non-fatal if REST is still unavailable.
377
375
  if (secureLogin && !user && effectiveUser) {
@@ -387,35 +385,35 @@ export async function provision(
387
385
  throw e;
388
386
  }
389
387
 
390
- if (!usedConsolePath) {
391
- if (effectiveUser) {
392
- await createUser(httpPort, effectiveUser.name, effectiveUser.password);
393
- // Persist to secret store so resolveAuth() picks it up
394
- saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
395
- }
388
+ // REST provisioning path. The console fallback above always returns early,
389
+ // so reaching here means REST came up and we provision over it.
390
+ if (effectiveUser) {
391
+ await createUser(httpPort, effectiveUser.name, effectiveUser.password);
392
+ // Persist to secret store so resolveAuth() picks it up
393
+ saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
394
+ }
396
395
 
397
- if (secureLogin && !user && effectiveUser && machineDir) {
398
- // Brief pause so RouterOS propagates the new user to all subsystems
399
- // (including the SSH key store) before we attempt key installation.
400
- await Bun.sleep(1000);
401
- try {
402
- await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
403
- } catch (e) {
404
- log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
405
- }
396
+ if (secureLogin && !user && effectiveUser && machineDir) {
397
+ // Brief pause so RouterOS propagates the new user to all subsystems
398
+ // (including the SSH key store) before we attempt key installation.
399
+ await Bun.sleep(1000);
400
+ try {
401
+ await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
402
+ } catch (e) {
403
+ log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
406
404
  }
405
+ }
407
406
 
408
- if (shouldDisableAdmin) {
409
- if (!effectiveUser) {
410
- log.warn("Warning: disabling admin without creating another user — you may lose access");
411
- }
412
- // Pass the new user's auth for verification — after disabling admin,
413
- // admin creds may stop working for REST queries.
414
- const verifyAuth = effectiveUser
415
- ? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
416
- : undefined;
417
- await disableAdmin(httpPort, verifyAuth);
407
+ if (shouldDisableAdmin) {
408
+ if (!effectiveUser) {
409
+ log.warn("Warning: disabling admin without creating another user — you may lose access");
418
410
  }
411
+ // Pass the new user's auth for verification — after disabling admin,
412
+ // admin creds may stop working for REST queries.
413
+ const verifyAuth = effectiveUser
414
+ ? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
415
+ : undefined;
416
+ await disableAdmin(httpPort, verifyAuth);
419
417
  }
420
418
 
421
419
  return { user: effectiveUser };
package/src/lib/qemu.ts CHANGED
@@ -9,7 +9,7 @@ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSy
9
9
  import { join } from "node:path";
10
10
  import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
11
11
  import { QuickCHRError } from "./types.ts";
12
- import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
12
+ import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
13
13
  import { buildHostfwdString } from "./network.ts";
14
14
  import { ensureDir } from "./state.ts";
15
15
  import { restGet } from "./rest.ts";
@@ -80,16 +80,19 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
80
80
  // UEFI firmware (arm64 only)
81
81
  if (arch === "arm64") {
82
82
  const fw = requireFirmware();
83
- const varsPath = join(machineDir, "efi-vars.fd");
84
-
85
- // Copy and size-match the vars file if not already done
83
+ // The per-machine EFI vars pflash is qcow2, NOT raw: QEMU refuses
84
+ // savevm/loadvm while any writable block device is non-qcow2 ("Device
85
+ // 'pflash1' is writable but does not support snapshots"), which made
86
+ // snapshots impossible on arm64 (#31). Legacy raw efi-vars.fd machines
87
+ // are converted in place, preserving their NVRAM state.
88
+ const varsPath = join(machineDir, "efi-vars.qcow2");
86
89
  if (!existsSync(varsPath)) {
87
- prepareEfiVars(fw.code, fw.vars, varsPath);
90
+ await prepareEfiVars(fw.code, fw.vars, varsPath, join(machineDir, "efi-vars.fd"));
88
91
  }
89
92
 
90
93
  args.push(
91
94
  "-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
92
- "-drive", `if=pflash,format=raw,unit=1,file=${varsPath}`,
95
+ "-drive", `if=pflash,format=qcow2,unit=1,file=${varsPath}`,
93
96
  );
94
97
  }
95
98
 
@@ -248,26 +251,53 @@ function buildChannelArgs(
248
251
  }
249
252
  }
250
253
 
251
- /** Copy and size-match EFI vars file to match code ROM size. */
252
- function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string): void {
254
+ /** Build the per-machine qcow2 EFI vars pflash: start from the legacy raw
255
+ * vars file if the machine has one (preserves NVRAM state), else from the
256
+ * firmware template; size-match to the code ROM; convert raw → qcow2 so
257
+ * savevm/loadvm work (see the pflash comment at the call site / #31). */
258
+ async function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string, legacyRawPath: string): Promise<void> {
253
259
  ensureDir(join(destPath, ".."));
254
- copyFileSync(varsTemplatePath, destPath);
255
260
 
256
- // pflash units must be identical size — pad/truncate vars to match code
261
+ // Stage the raw image (legacy per-machine file wins over the template).
262
+ const rawStage = `${destPath}.raw-stage`;
263
+ copyFileSync(existsSync(legacyRawPath) ? legacyRawPath : varsTemplatePath, rawStage);
264
+
265
+ // pflash units must be identical size — pad/truncate vars to match code.
266
+ // truncateSync extends with zero bytes when target is larger.
257
267
  const codeSize = statSync(codePath).size;
258
- const varsSize = statSync(destPath).size;
268
+ try {
269
+ if (statSync(rawStage).size !== codeSize) truncateSync(rawStage, codeSize);
270
+ } catch (e) {
271
+ try { unlinkSync(rawStage); } catch { /* best effort */ }
272
+ throw new QuickCHRError(
273
+ "PROCESS_FAILED",
274
+ `Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
275
+ );
276
+ }
259
277
 
260
- if (varsSize !== codeSize) {
261
- // Pad or truncate vars to match code ROM size — truncateSync extends with
262
- // zero bytes when target is larger, and truncates when smaller.
263
- try {
264
- truncateSync(destPath, codeSize);
265
- } catch (e) {
266
- throw new QuickCHRError(
267
- "PROCESS_FAILED",
268
- `Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
269
- );
270
- }
278
+ const qemuImg = requireQemuImg();
279
+ const conversion = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawStage, destPath], {
280
+ stdout: "pipe",
281
+ stderr: "pipe",
282
+ });
283
+ const exitCode = await conversion.exited;
284
+ const [stderr, stdout] = await Promise.all([
285
+ new Response(conversion.stderr).text(),
286
+ new Response(conversion.stdout).text(),
287
+ ]);
288
+ try { unlinkSync(rawStage); } catch { /* best effort */ }
289
+ if (exitCode !== 0) {
290
+ // A failed convert can leave a partial destPath behind — remove it so the
291
+ // next launch regenerates instead of booting (and re-failing on) the stub.
292
+ try { unlinkSync(destPath); } catch { /* best effort */ }
293
+ throw new QuickCHRError(
294
+ "PROCESS_FAILED",
295
+ `qemu-img convert of EFI vars failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`,
296
+ );
297
+ }
298
+ // Legacy raw file is superseded — remove so nothing boots the stale copy.
299
+ if (existsSync(legacyRawPath)) {
300
+ try { unlinkSync(legacyRawPath); } catch { /* best effort */ }
271
301
  }
272
302
  }
273
303
 
@@ -43,9 +43,11 @@ import {
43
43
  ensureDir,
44
44
  getCacheDir,
45
45
  getDataDir,
46
+ appendBootLog,
46
47
  } from "./state.ts";
47
48
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
48
49
  import { autoPruneIfOverCap } from "./cache.ts";
50
+ import { resolveSetting } from "./settings.ts";
49
51
  import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
50
52
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
51
53
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
@@ -343,6 +345,12 @@ function createInstance(state: MachineState): ChrInstance {
343
345
  if (state.pid && isMachineRunning(state)) {
344
346
  await stopQemu(state.pid);
345
347
  }
348
+ // NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
349
+ // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
350
+ // disk), not OS state; wiping it forces a full device scan (~480s) on the next
351
+ // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
352
+ // anchor: the "clean() resets disk to factory defaults" arm64 integration test
353
+ // (120s reboot wait would blow past 480s if the wipe were reintroduced).
346
354
  // Re-copy fresh image from cache
347
355
  const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
348
356
  if (!existsSync(imgPath)) {
@@ -362,10 +370,6 @@ function createInstance(state: MachineState): ChrInstance {
362
370
  state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
363
371
  );
364
372
 
365
- // Remove EFI vars to force re-creation
366
- const efiVars = join(state.machineDir, "efi-vars.fd");
367
- if (existsSync(efiVars)) rmSync(efiVars);
368
-
369
373
  // Clean up stored instance credentials (re-provisioned on next start)
370
374
  deleteInstanceCredentials(state.name);
371
375
 
@@ -699,15 +703,26 @@ function createInstance(state: MachineState): ChrInstance {
699
703
  throw new QuickCHRError("PROCESS_FAILED", `savevm failed: ${out.trim()}`);
700
704
  }
701
705
 
702
- // Read back the snapshot list to return the new entry
706
+ // Read back the snapshot list — the new entry MUST be there. A
707
+ // fabricated fallback here once masked savevm failing outright on
708
+ // arm64 (#31): the example printed a snapshot that never existed.
703
709
  const snaps = await this.list();
704
- return snaps.find((s) => s.name === snapName) ?? {
705
- id: "0",
706
- name: snapName,
707
- vmStateSize: 0,
708
- date: new Date().toISOString(),
709
- vmClock: "0000:00:00.000",
710
- };
710
+ const created = snaps.find((s) => s.name === snapName);
711
+ if (!created) {
712
+ // Self-grounding error: include the raw monitor listing so a CI
713
+ // failure carries the evidence (QEMU output formats vary by version
714
+ // and a parse gap here looks identical to a real savevm failure).
715
+ let rawList = "(unavailable)";
716
+ try {
717
+ rawList = await monitorCommand(state.machineDir, "info snapshots", undefined, state.portBase);
718
+ } catch { /* keep placeholder */ }
719
+ throw new QuickCHRError(
720
+ "PROCESS_FAILED",
721
+ `savevm reported no error but snapshot "${snapName}" is absent from the parsed list — ` +
722
+ `treat as failed. savevm said: "${out.trim() || "(empty)"}"; raw 'info snapshots': "${rawList}"`,
723
+ );
724
+ }
725
+ return created;
711
726
  },
712
727
 
713
728
  async load(name: string): Promise<void> {
@@ -721,6 +736,17 @@ function createInstance(state: MachineState): ChrInstance {
721
736
 
722
737
  const out = await monitorCommand(state.machineDir, `loadvm ${name}`, undefined, state.portBase);
723
738
  if (/^error[:\s]/i.test(out)) {
739
+ // A failed loadvm leaves the VM in "paused (restore-vm)" — resume
740
+ // it so the guest isn't wedged (REST dead) on top of the error.
741
+ try {
742
+ await monitorCommand(state.machineDir, "cont", undefined, state.portBase);
743
+ } catch (contErr) {
744
+ // Best effort — the throw below is the real signal, but a failed
745
+ // resume means the VM may still be paused; say so.
746
+ console.warn(
747
+ `quickchr: 'cont' after failed loadvm also failed (${contErr instanceof Error ? contErr.message : String(contErr)}) — machine "${state.name}" may be paused`,
748
+ );
749
+ }
724
750
  throw new QuickCHRError("PROCESS_FAILED", `loadvm failed: ${out.trim()}`);
725
751
  }
726
752
  },
@@ -1169,7 +1195,7 @@ export class QuickCHR {
1169
1195
  // Resolve version
1170
1196
  let version: string;
1171
1197
  if (opts.version) {
1172
- if ((CHANNELS as string[]).includes(opts.version)) {
1198
+ if ((CHANNELS as readonly string[]).includes(opts.version)) {
1173
1199
  logger.warn(
1174
1200
  `'${opts.version}' is a channel name, not a version — passed as 'version' but resolved as a channel. Use 'channel: "${opts.version}"' instead to make this explicit.`,
1175
1201
  );
@@ -1235,7 +1261,7 @@ export class QuickCHR {
1235
1261
  user: opts.user ?? existing.user,
1236
1262
  disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
1237
1263
  license: opts.license,
1238
- secureLogin: opts.secureLogin,
1264
+ secureLogin: opts.secureLogin ?? existing.secureLogin,
1239
1265
  };
1240
1266
  const hasPending = !!(
1241
1267
  pendingOpts.installAllPackages ||
@@ -1243,7 +1269,8 @@ export class QuickCHR {
1243
1269
  pendingOpts.deviceMode ||
1244
1270
  pendingOpts.user ||
1245
1271
  pendingOpts.disableAdmin ||
1246
- pendingOpts.license
1272
+ pendingOpts.license ||
1273
+ pendingOpts.secureLogin === true
1247
1274
  );
1248
1275
  if (hasPending) {
1249
1276
  return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
@@ -1402,6 +1429,7 @@ export class QuickCHR {
1402
1429
 
1403
1430
  const qemuArgs = await buildQemuArgs(launchConfig);
1404
1431
  const wrapper = extractWrapper(resolvedNetworks);
1432
+ const bootStart = Date.now();
1405
1433
  let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1406
1434
 
1407
1435
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
@@ -1458,6 +1486,24 @@ export class QuickCHR {
1458
1486
  );
1459
1487
  }
1460
1488
 
1489
+ // Record boot timing (cumulative from first spawn, incl. the respawn-once
1490
+ // path) — in machine.json for `list`/`get` and in the boot-history log.
1491
+ // Side-band metrics must never fail a boot that succeeded.
1492
+ state.lastAccel = accel;
1493
+ state.lastBootMs = Date.now() - bootStart;
1494
+ saveMachine(state);
1495
+ try {
1496
+ appendBootLog({
1497
+ ts: new Date().toISOString(),
1498
+ name,
1499
+ version,
1500
+ arch,
1501
+ accel,
1502
+ bootMs: state.lastBootMs,
1503
+ host: process.platform,
1504
+ });
1505
+ } catch { /* never propagate */ }
1506
+
1461
1507
  if (hasProvisioning) {
1462
1508
  await QuickCHR._provisionInstance(instance, state, {
1463
1509
  installAllPackages: opts.installAllPackages,
@@ -1684,6 +1730,7 @@ export class QuickCHR {
1684
1730
 
1685
1731
  const qemuArgs = await buildQemuArgs(launchConfig);
1686
1732
  const wrapper = extractWrapper(resolvedNetworks);
1733
+ const bootStart = Date.now();
1687
1734
  const { pid } = await spawnQemu(qemuArgs, state.machineDir, spawnBackground, wrapper);
1688
1735
 
1689
1736
  // Foreground without provisioning: spawnQemu blocks until QEMU exits
@@ -1720,12 +1767,32 @@ export class QuickCHR {
1720
1767
  );
1721
1768
  }
1722
1769
 
1770
+ // Record boot timing — mirrors start(); side-band, never fails the boot.
1771
+ state.lastAccel = accel;
1772
+ state.lastBootMs = Date.now() - bootStart;
1773
+ saveMachine(state);
1774
+ try {
1775
+ appendBootLog({
1776
+ ts: new Date().toISOString(),
1777
+ name: state.name,
1778
+ version: state.version,
1779
+ arch: state.arch,
1780
+ accel,
1781
+ bootMs: state.lastBootMs,
1782
+ host: process.platform,
1783
+ });
1784
+ } catch { /* never propagate */ }
1785
+
1723
1786
  if (hasProvisioning && provisioningOpts) {
1724
1787
  await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
1725
1788
  }
1726
1789
 
1727
1790
  try {
1728
- autoPruneIfOverCap({ logger: logger ? (msg) => logger.status(msg) : undefined, protectVersions: [state.version] });
1791
+ autoPruneIfOverCap({
1792
+ logger: logger ? (msg) => logger.status(msg) : undefined,
1793
+ protectVersions: [state.version],
1794
+ maxSizeBytes: resolveSetting("cache-max-size").value as number | undefined,
1795
+ });
1729
1796
  } catch { /* never propagate */ }
1730
1797
 
1731
1798
  return instance;
@@ -1777,7 +1844,7 @@ export class QuickCHR {
1777
1844
  detail: ver ?? "found (version unknown)",
1778
1845
  });
1779
1846
  } catch (_e) {
1780
- const hint = getQemuInstallHint() ?? "Install QEMU";
1847
+ const hint = getQemuInstallHint();
1781
1848
  checks.push({
1782
1849
  label: qemuName,
1783
1850
  status: arch === hostArchToChr() ? "error" : "warn",
@@ -12,11 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
14
14
  import { join, dirname } from "node:path";
15
- import { homedir } from "node:os";
16
-
17
- function configDir(): string {
18
- return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
19
- }
15
+ import { quickchrConfigDir } from "./paths.ts";
20
16
 
21
17
  /** True when the Bun.secrets API is available at runtime. */
22
18
  function hasBunSecrets(): boolean {
@@ -32,7 +28,7 @@ function hasBunSecrets(): boolean {
32
28
  function configPath(service: string): string {
33
29
  // One JSON file per service, keyed by name
34
30
  const safeName = service.replace(/[^a-zA-Z0-9._-]/g, "_");
35
- return join(configDir(), `${safeName}.json`);
31
+ return join(quickchrConfigDir(), `${safeName}.json`);
36
32
  }
37
33
 
38
34
  function readConfigStore(service: string): Record<string, string> {
@@ -129,5 +125,5 @@ export async function secretDelete(service: string, name: string): Promise<boole
129
125
  /** Human-readable label for where secrets are stored. */
130
126
  export function secretStorageLabel(): string {
131
127
  if (hasBunSecrets()) return "OS credential store (via Bun.secrets)";
132
- return `config file (${configDir()})`;
128
+ return `config file (${quickchrConfigDir()})`;
133
129
  }