@tikoci/quickchr 0.2.0

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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * QEMU argument builder and process lifecycle management.
3
+ *
4
+ * Builds QEMU command-line arguments for x86_64 and aarch64 CHR,
5
+ * manages process spawn/stop, and handles PID tracking.
6
+ */
7
+
8
+ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSync, closeSync, unlinkSync, truncateSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
11
+ import { QuickCHRError } from "./types.ts";
12
+ import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
13
+ import { buildHostfwdString } from "./network.ts";
14
+ import { ensureDir } from "./state.ts";
15
+ import { restGet } from "./rest.ts";
16
+
17
+ export interface QemuLaunchConfig {
18
+ arch: Arch;
19
+ machineDir: string;
20
+ bootDisk: { path: string; format: BootDiskFormat };
21
+ extraDisks?: { path: string; format: "qcow2" }[];
22
+ mem: number;
23
+ cpu: number;
24
+ ports: Record<string, PortMapping>;
25
+ /** @deprecated Use `networks` instead. */
26
+ network?: NetworkMode;
27
+ /** Network interfaces with resolved QEMU args. */
28
+ networks: NetworkConfig[];
29
+ background: boolean;
30
+ /** Port block base for this machine (portBase+6=monitor, +7=serial, +8=qga on Windows TCP). */
31
+ portBase: number;
32
+ /**
33
+ * Pre-detected QEMU accelerator (kvm/hvf/tcg).
34
+ * If provided, skips the internal detectAccel() call so the same value
35
+ * can be used for both QEMU args and boot timeout calculation.
36
+ */
37
+ accel?: string;
38
+ }
39
+
40
+ /** Build the full QEMU command-line arguments array. */
41
+ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]> {
42
+ const { arch, machineDir, bootDisk, extraDisks, mem, cpu, ports, networks, background, portBase } = config;
43
+
44
+ const qemuBin = requireQemu(arch);
45
+ const accel = config.accel ?? await detectAccel(arch);
46
+ const args: string[] = [qemuBin];
47
+
48
+ // Machine type
49
+ if (arch === "x86") {
50
+ args.push("-M", "pc");
51
+ } else {
52
+ args.push("-M", "virt");
53
+ }
54
+
55
+ // CPU and memory
56
+ args.push("-m", String(mem), "-smp", String(cpu));
57
+
58
+ // Acceleration
59
+ if (accel === "tcg") {
60
+ args.push("-accel", "tcg,tb-size=256");
61
+ } else {
62
+ args.push("-accel", accel);
63
+ }
64
+
65
+ // Use -cpu host for hardware-backed acceleration (KVM / HVF) to expose
66
+ // the real host CPU features. This eliminates CPUID mismatch warnings
67
+ // (e.g. SVM on nested-KVM CI runners) and improves guest performance.
68
+ if (arch === "x86") {
69
+ if (accel === "kvm" || accel === "hvf") {
70
+ args.push("-cpu", "host");
71
+ }
72
+ } else {
73
+ if (accel === "hvf") {
74
+ args.push("-cpu", "host");
75
+ } else {
76
+ args.push("-cpu", "cortex-a710");
77
+ }
78
+ }
79
+
80
+ // UEFI firmware (arm64 only)
81
+ if (arch === "arm64") {
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
86
+ if (!existsSync(varsPath)) {
87
+ prepareEfiVars(fw.code, fw.vars, varsPath);
88
+ }
89
+
90
+ args.push(
91
+ "-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
92
+ "-drive", `if=pflash,format=raw,unit=1,file=${varsPath}`,
93
+ );
94
+ }
95
+
96
+ // Boot disk drive
97
+ if (arch === "arm64") {
98
+ // ARM64: MUST use explicit virtio-blk-pci (not if=virtio which maps to MMIO)
99
+ args.push(
100
+ "-drive", `file=${bootDisk.path},format=${bootDisk.format},if=none,id=drive0`,
101
+ "-device", "virtio-blk-pci,drive=drive0",
102
+ );
103
+ } else {
104
+ // x86: if=virtio is fine (maps to PCI on q35/pc)
105
+ args.push("-drive", `file=${bootDisk.path},format=${bootDisk.format},if=virtio`);
106
+ }
107
+
108
+ // Extra disks
109
+ if (extraDisks && extraDisks.length > 0) {
110
+ for (let i = 0; i < extraDisks.length; i++) {
111
+ const disk = extraDisks[i] as { path: string; format: "qcow2" };
112
+ const driveId = `drive${i + 1}`;
113
+ if (arch === "arm64") {
114
+ args.push(
115
+ "-drive", `file=${disk.path},format=${disk.format},if=none,id=${driveId}`,
116
+ "-device", `virtio-blk-pci,drive=${driveId}`,
117
+ );
118
+ } else {
119
+ args.push("-drive", `file=${disk.path},format=${disk.format},if=virtio`);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Networking
125
+ buildNetworkArgs(args, ports, networks);
126
+
127
+ // Display
128
+ args.push("-display", "none");
129
+
130
+ // Serial, monitor, QGA channels
131
+ buildChannelArgs(args, arch, machineDir, background, portBase);
132
+
133
+ return args;
134
+ }
135
+
136
+ /** Build networking arguments from NetworkConfig array. */
137
+ function buildNetworkArgs(
138
+ args: string[],
139
+ ports: Record<string, PortMapping>,
140
+ networks: NetworkConfig[],
141
+ ): void {
142
+ for (const net of networks) {
143
+ // If resolution produced QEMU args, use those directly
144
+ if (net.resolved?.qemuNetdevArgs) {
145
+ args.push(...net.resolved.qemuNetdevArgs);
146
+ continue;
147
+ }
148
+
149
+ // Fallback: build args from specifier (legacy path and simple cases)
150
+ const spec = net.specifier;
151
+ const id = net.id;
152
+
153
+ if (spec === "vmnet-shared") {
154
+ args.push(
155
+ "-netdev", `vmnet-shared,id=${id}`,
156
+ "-device", `virtio-net-pci,netdev=${id}`,
157
+ );
158
+ } else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
159
+ args.push(
160
+ "-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
161
+ "-device", `virtio-net-pci,netdev=${id}`,
162
+ );
163
+ } else if (typeof spec === "object" && spec.type === "socket-listen") {
164
+ args.push(
165
+ "-netdev", `socket,id=${id},listen=:${spec.port}`,
166
+ "-device", `virtio-net-pci,netdev=${id}`,
167
+ );
168
+ } else if (typeof spec === "object" && spec.type === "socket-connect") {
169
+ args.push(
170
+ "-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
171
+ "-device", `virtio-net-pci,netdev=${id}`,
172
+ );
173
+ } else if (typeof spec === "object" && spec.type === "socket-mcast") {
174
+ args.push(
175
+ "-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
176
+ "-device", `virtio-net-pci,netdev=${id}`,
177
+ );
178
+ } else if (typeof spec === "object" && spec.type === "tap") {
179
+ args.push(
180
+ "-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
181
+ "-device", `virtio-net-pci,netdev=${id}`,
182
+ );
183
+ } else {
184
+ // Default: user-mode networking with port forwarding (only on the first user NIC)
185
+ const hostfwd = buildHostfwdString(ports);
186
+ args.push(
187
+ "-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
188
+ "-device", `virtio-net-pci,netdev=${id}`,
189
+ );
190
+ }
191
+ }
192
+ }
193
+
194
+ /** Build serial/monitor/QGA channel arguments. */
195
+ function buildChannelArgs(
196
+ args: string[],
197
+ arch: Arch,
198
+ machineDir: string,
199
+ background: boolean,
200
+ portBase: number,
201
+ ): void {
202
+ const isWin = process.platform === "win32";
203
+
204
+ let monitorSock: string;
205
+ let serialSock: string;
206
+ let qgaSock: string;
207
+
208
+ if (isWin) {
209
+ // QEMU on Windows: chardev socket bind() cannot handle \\.\pipe\ paths
210
+ // on all builds. Use TCP localhost instead; the port block reserves +6/+7/+8
211
+ // for monitor/serial/qga IPC.
212
+ monitorSock = `host=127.0.0.1,port=${portBase + 6}`;
213
+ serialSock = `host=127.0.0.1,port=${portBase + 7}`;
214
+ qgaSock = `host=127.0.0.1,port=${portBase + 8}`;
215
+ } else {
216
+ monitorSock = `path=${join(machineDir, "monitor.sock")}`;
217
+ serialSock = `path=${join(machineDir, "serial.sock")}`;
218
+ qgaSock = `path=${join(machineDir, "qga.sock")}`;
219
+ }
220
+
221
+ if (background) {
222
+ // Background: all channels on sockets/pipes
223
+ args.push(
224
+ "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
225
+ "-mon", "chardev=monitor0,mode=readline",
226
+ "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
227
+ "-serial", "chardev:serial0",
228
+ );
229
+ } else {
230
+ // Foreground: serial on stdio with mux (Ctrl-A C for monitor).
231
+ // Also bind a socket monitor so monitorCommand() works while in fg mode.
232
+ args.push(
233
+ "-chardev", "stdio,id=serial0,mux=on,signal=off",
234
+ "-mon", "chardev=serial0,mode=readline",
235
+ "-serial", "chardev:serial0",
236
+ "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
237
+ "-mon", "chardev=monitor0,mode=readline",
238
+ );
239
+ }
240
+
241
+ // QGA channel — x86 only. RouterOS QGA requires KVM; arm64 CHR does not implement QGA.
242
+ if (arch === "x86") {
243
+ args.push(
244
+ "-device", "virtio-serial-pci,id=virtio-serial-qga",
245
+ "-chardev", `socket,id=qga0,${qgaSock},server=on,wait=off`,
246
+ "-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0,id=qga-port0",
247
+ );
248
+ }
249
+ }
250
+
251
+ /** Copy and size-match EFI vars file to match code ROM size. */
252
+ function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string): void {
253
+ ensureDir(join(destPath, ".."));
254
+ copyFileSync(varsTemplatePath, destPath);
255
+
256
+ // pflash units must be identical size — pad/truncate vars to match code
257
+ const codeSize = statSync(codePath).size;
258
+ const varsSize = statSync(destPath).size;
259
+
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
+ }
271
+ }
272
+ }
273
+
274
+ /** Extract socket_vmnet wrapper from resolved networks (at most one allowed). */
275
+ export function extractWrapper(
276
+ networks: NetworkConfig[],
277
+ ): { command: string; args: string[] } | undefined {
278
+ const wrappers: string[][] = [];
279
+ for (const n of networks) {
280
+ if (n.resolved?.wrapper) {
281
+ wrappers.push(n.resolved.wrapper);
282
+ }
283
+ }
284
+ if (wrappers.length > 1) {
285
+ throw new QuickCHRError(
286
+ "INVALID_NETWORK",
287
+ "Multiple networks require socket_vmnet wrapper — only one is supported per VM",
288
+ );
289
+ }
290
+ const w = wrappers[0];
291
+ if (!w || w.length === 0) return undefined;
292
+ const [command, ...wrapperArgs] = w;
293
+ if (!command) return undefined;
294
+ return { command, args: wrapperArgs };
295
+ }
296
+
297
+ /** Parse QEMU log output and produce a human-friendly error message. */
298
+ export function buildQemuErrorMessage(log: string): string {
299
+ if (log.includes("Permission denied")) {
300
+ return `QEMU exited immediately (permission denied — check image file permissions):\n${log}`;
301
+ }
302
+ if (log.includes("pflash") || (log.includes("EFI") && log.includes("size"))) {
303
+ return `QEMU exited immediately (EFI firmware size mismatch — try 'quickchr clean <name>' to reset):\n${log}`;
304
+ }
305
+ if (log.includes("Address already in use")) {
306
+ return `QEMU exited immediately (port already in use — another process may be using the same ports):\n${log}`;
307
+ }
308
+ return `QEMU exited immediately:\n${log}`;
309
+ }
310
+
311
+ /** Spawn QEMU process. Returns the process or PID. */
312
+ export async function spawnQemu(
313
+ qemuArgs: string[],
314
+ machineDir: string,
315
+ background: boolean,
316
+ wrapper?: { command: string; args: string[] },
317
+ ): Promise<{ pid: number; process?: ReturnType<typeof Bun.spawn> }> {
318
+ ensureDir(machineDir);
319
+
320
+ const logPath = join(machineDir, "qemu.log");
321
+ const pidPath = join(machineDir, "qemu.pid");
322
+
323
+ const [bin, ...args] = qemuArgs;
324
+
325
+ if (!bin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args");
326
+
327
+ let spawnCmd: string[];
328
+ if (wrapper) {
329
+ // socket_vmnet wrapper path logged at debug level — full paths belong in `doctor`
330
+ if (process.env.QUICKCHR_DEBUG === "1") {
331
+ process.stderr.write(`[quickchr] Using socket_vmnet wrapper: ${wrapper.command} ${wrapper.args.join(" ")}\n`);
332
+ }
333
+ spawnCmd = [wrapper.command, ...wrapper.args, bin, ...args];
334
+ } else {
335
+ spawnCmd = [bin, ...args];
336
+ }
337
+
338
+ if (background) {
339
+ // Open log file as an fd — more reliable than BunFile for subprocess stdio.
340
+ // After spawn, close parent's copy; child process inherits its own copy.
341
+ const logFd = openSync(logPath, "a");
342
+
343
+ let spawnedPid: number;
344
+
345
+ if (process.platform === "win32") {
346
+ // On Windows, Bun.spawn().unref() does NOT escape the Windows Job Object.
347
+ // When the parent CLI process exits, Windows terminates all processes in
348
+ // the same job, killing QEMU. Use node:child_process with detached: true
349
+ // to create QEMU in a new process group outside the parent job object.
350
+ const { spawn: cpSpawn } = await import("node:child_process");
351
+ const [spawnBin, ...spawnArgs] = spawnCmd;
352
+ if (!spawnBin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args");
353
+ const child = cpSpawn(spawnBin, spawnArgs, {
354
+ detached: true,
355
+ stdio: ["ignore", logFd, logFd],
356
+ windowsHide: true,
357
+ });
358
+ closeSync(logFd);
359
+ if (child.pid === undefined) {
360
+ throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
361
+ }
362
+ child.unref();
363
+ spawnedPid = child.pid;
364
+ } else {
365
+ const proc = Bun.spawn(spawnCmd, {
366
+ stdout: logFd,
367
+ stderr: logFd,
368
+ stdin: "ignore",
369
+ });
370
+ closeSync(logFd);
371
+ // unref() lets the parent Bun process exit without waiting for QEMU.
372
+ // QEMU becomes an orphan adopted by init/launchd and keeps running.
373
+ proc.unref();
374
+ spawnedPid = proc.pid;
375
+ }
376
+
377
+ // Give QEMU 1.5 s to fully initialise — if it exits immediately the
378
+ // arguments were bad (e.g. port already in use). Detect this early so
379
+ // the caller gets a clear error instead of a 120-second waitForBoot.
380
+ await Bun.sleep(1500);
381
+ try {
382
+ process.kill(spawnedPid, 0);
383
+ } catch {
384
+ let logTail = "(no log)";
385
+ try {
386
+ if (existsSync(logPath)) {
387
+ logTail = readFileSync(logPath, "utf-8").slice(-800);
388
+ }
389
+ } catch { /* ignore */ }
390
+ throw new QuickCHRError("SPAWN_FAILED", buildQemuErrorMessage(logTail));
391
+ }
392
+
393
+ writeFileSync(pidPath, String(spawnedPid));
394
+ return { pid: spawnedPid };
395
+ }
396
+
397
+ // Foreground — stdin/stdout connected to terminal. Blocks until QEMU exits.
398
+ const proc = Bun.spawn(spawnCmd, {
399
+ stdout: "inherit",
400
+ stderr: "inherit",
401
+ stdin: "inherit",
402
+ });
403
+
404
+ const pid = proc.pid;
405
+ writeFileSync(pidPath, String(pid));
406
+ await proc.exited;
407
+ try { unlinkSync(pidPath); } catch { /* ignore */ }
408
+ return { pid };
409
+ }
410
+
411
+ /** Stop a QEMU process by PID. Tries SIGTERM first, then SIGKILL. */
412
+ export async function stopQemu(pid: number): Promise<boolean> {
413
+ try {
414
+ process.kill(pid, 0); // Check if alive
415
+ } catch {
416
+ return false; // Already dead
417
+ }
418
+
419
+ // Try graceful shutdown
420
+ process.kill(pid, "SIGTERM");
421
+
422
+ // Wait up to 5s for graceful exit
423
+ for (let i = 0; i < 50; i++) {
424
+ await Bun.sleep(100);
425
+ try {
426
+ process.kill(pid, 0);
427
+ } catch {
428
+ return true; // Exited
429
+ }
430
+ }
431
+
432
+ // Force kill
433
+ try {
434
+ process.kill(pid, "SIGKILL");
435
+ } catch { /* already dead */ }
436
+
437
+ return true;
438
+ }
439
+
440
+ /** Stop a machine by name. */
441
+ export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
442
+ if (!state.pid) return false;
443
+ const stopped = await stopQemu(state.pid);
444
+
445
+ // Clean up socket files
446
+ const machineDir = state.machineDir;
447
+ for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
448
+ const path = join(machineDir, sock);
449
+ try {
450
+ if (existsSync(path)) {
451
+ const { unlinkSync } = await import("node:fs");
452
+ unlinkSync(path);
453
+ }
454
+ } catch { /* ignore */ }
455
+ }
456
+
457
+ return stopped;
458
+ }
459
+
460
+ /**
461
+ * Wait for the RouterOS REST layer to be fully stable.
462
+ *
463
+ * RouterOS startup is staged from our perspective:
464
+ * 1. Connection refused — QEMU still booting
465
+ * 2. ECONNRESET — HTTP server started but REST subsystem not yet accepting
466
+ * 3. 401 from /rest — auth middleware is up; REST handler may still be initializing
467
+ * 4. 200 but wrong body — startup race: REST initializes before routing tables settle; /system/resource may return an array (e.g. /user list) for a brief window
468
+ * 5. 200 with correct body — REST is fully operational
469
+ *
470
+ * With proper credentials this function reaches stage 5 before returning. With
471
+ * unauthenticated or admin-disabled instances (where we get 401) it accepts
472
+ * stage 3 — auth rejection proves the REST layer responded, which is sufficient
473
+ * for callers that do not need body validation.
474
+ *
475
+ * Requires 2 consecutive successful probes to prevent false-positives during
476
+ * the brief window where RouterOS intermittently resets new connections
477
+ * immediately after coming up.
478
+ *
479
+ * @param httpPort HTTP port of the CHR instance.
480
+ * @param timeoutMs Overall deadline (default 120 s).
481
+ * @param auth HTTP Basic Authorization header to use for probes.
482
+ * Defaults to `admin:` (empty password) which works on fresh
483
+ * CHR images; pass instance credentials for post-reboot probes
484
+ * on provisioned machines so body validation can be performed.
485
+ */
486
+ export async function waitForBoot(
487
+ httpPort: number,
488
+ timeoutMs: number = 120_000,
489
+ auth: string = `Basic ${btoa("admin:")}`,
490
+ ): Promise<boolean> {
491
+ const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
492
+ const deadline = Date.now() + timeoutMs;
493
+ let consecutiveReady = 0;
494
+
495
+ while (Date.now() < deadline) {
496
+ try {
497
+ const { status, body } = await restGet(url, auth, 3000);
498
+
499
+ if (status === 401 || status === 403) {
500
+ // Auth rejected — the REST layer is up and responded, even though
501
+ // we cannot validate the body. Count as ready.
502
+ consecutiveReady++;
503
+ } else if (status >= 200 && status < 300) {
504
+ // RouterOS REST may return wrong data (array body) briefly after boot —
505
+ // a startup race. Only accept once the body is the expected singleton object.
506
+ let parsed: unknown = null;
507
+ try { parsed = JSON.parse(body); } catch { /* ignore */ }
508
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
509
+ consecutiveReady++;
510
+ } else {
511
+ consecutiveReady = 0; // wrong body — REST not fully initialized yet
512
+ }
513
+ } else {
514
+ consecutiveReady = 0;
515
+ }
516
+
517
+ if (consecutiveReady >= 2) return true;
518
+ } catch {
519
+ // ECONNRESET, connection refused, timeout — not ready yet
520
+ consecutiveReady = 0;
521
+ }
522
+ await Bun.sleep(2000);
523
+ }
524
+
525
+ return false;
526
+ }