@tikoci/quickchr 0.4.6 → 0.4.8

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.
@@ -67,12 +67,82 @@ async function waitForRest(
67
67
  }
68
68
  }
69
69
 
70
+ /** Wait until RouterOS actually accepts `auth`, returning how long that took.
71
+ *
72
+ * Deliberately *not* `waitForBoot()`: that one counts 401/403 as ready, because
73
+ * for a liveness probe an auth rejection still proves `www` is answering. Here
74
+ * a 401 is the thing being waited out, so it needs the opposite predicate.
75
+ *
76
+ * **This is hardening and an instrument, not a proven fix for #69.** The known
77
+ * symptom is a 401 on the first request made with freshly created credentials —
78
+ * four Windows CI legs across runs 35135008624 and 35386692051, always on the
79
+ * path that does no work between `createUser()` and that request. A local
80
+ * attempt to reproduce it found no window to close: on an Intel host against
81
+ * CHR 7.24.4 (x86 guest), a new user authenticated on attempt #1, 6/6, under
82
+ * both TCG and HVF. So the cause of the CI 401 is still open, and this wait
83
+ * does not claim to explain it.
84
+ *
85
+ * What it does buy: `createUser()` can no longer resolve while a `full`-group
86
+ * user's own credentials are rejected, and the outcome is measured either way.
87
+ * (Limited groups are exempt — see the table at the call site: a valid user
88
+ * without `rest-api` answers 401, which is indistinguishable from the symptom.) If the CI
89
+ * 401 is any kind of timing window, this closes it *and* the returned figure
90
+ * says how wide it was on that platform — which is the quantification #69 asks
91
+ * for. If it is not a timing window, the wait exhausts its budget and fails
92
+ * saying so, which is a far stronger signal than today's bare 401.
93
+ *
94
+ * Reports `attempts` alongside `elapsedMs`, and **`attempts` is the load-bearing
95
+ * number**. Elapsed time cannot tell a retry from a slow request: a first
96
+ * authentication with a new password costs a few hundred ms of ordinary request
97
+ * latency on an emulated guest, so an elapsed-only figure reads as a wait when
98
+ * nothing waited. `attempts === 1` means there was no window, whatever the
99
+ * clock says. (This is not hypothetical — it is the error the first pass at
100
+ * this investigation made; see `provisioning.instructions.md`.) */
101
+ export async function waitForAuth(
102
+ httpPort: number,
103
+ auth: string,
104
+ timeoutMs: number = 30_000,
105
+ ): Promise<{ attempts: number; elapsedMs: number }> {
106
+ const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
107
+ const start = Date.now();
108
+ const deadline = start + timeoutMs;
109
+ let lastStatus: number | undefined;
110
+ let attempts = 0;
111
+
112
+ while (Date.now() < deadline) {
113
+ attempts++;
114
+ // Bound each request by what is left of the budget, so a stalled request
115
+ // cannot push the whole wait past the deadline it advertises.
116
+ const remaining = deadline - Date.now();
117
+ try {
118
+ const { status } = await restGet(url, auth, Math.min(5_000, remaining));
119
+ if (status >= 200 && status < 300) return { attempts, elapsedMs: Date.now() - start };
120
+ // 401/403 is the propagation window. 5xx also occurs briefly after a
121
+ // user-database change, and is equally transient, so both are polled
122
+ // out rather than one being singled out as "the" expected status.
123
+ lastStatus = status;
124
+ } catch { /* transport error mid-transition — keep polling */ }
125
+ await Bun.sleep(250);
126
+ }
127
+
128
+ throw new QuickCHRError(
129
+ "PROCESS_FAILED",
130
+ `Credentials were not accepted within ${timeoutMs}ms over ${attempts} attempt(s) ` +
131
+ `(last HTTP status: ${lastStatus ?? "no response"}) — ` +
132
+ "the user record exists but RouterOS is not authenticating it",
133
+ );
134
+ }
135
+
70
136
  /** Create a user via the REST API. */
71
137
  export async function createUser(
72
138
  httpPort: number,
73
139
  name: string,
74
140
  password: string,
75
141
  group: string = "full",
142
+ logger?: ProgressLogger,
143
+ /** Budget for the post-visibility authentication wait. Injectable so tests
144
+ * can exercise the failure path without burning the 30 s default. */
145
+ authTimeoutMs: number = 30_000,
76
146
  ): Promise<void> {
77
147
  await waitForRest(httpPort);
78
148
 
@@ -95,6 +165,7 @@ export async function createUser(
95
165
  // Verify visibility (and group) so callers get deterministic behavior.
96
166
  const expectedGroup = group.trim().toLowerCase();
97
167
  const deadline = Date.now() + 30_000;
168
+ let visible = false;
98
169
  while (Date.now() < deadline) {
99
170
  try {
100
171
  const user = await readUser(httpPort, auth, name);
@@ -110,7 +181,8 @@ export async function createUser(
110
181
  `User "${name}" created with unexpected group (expected=${expectedGroup}, actual=${actualGroup})`,
111
182
  );
112
183
  }
113
- return;
184
+ visible = true;
185
+ break;
114
186
  }
115
187
  } catch (e) {
116
188
  // Rethrow deliberate group mismatch errors; retry transient HTTP failures
@@ -120,9 +192,58 @@ export async function createUser(
120
192
  await Bun.sleep(500);
121
193
  }
122
194
 
123
- throw new QuickCHRError(
124
- "PROCESS_FAILED",
125
- `User "${name}" creation was acknowledged but did not become visible in RouterOS`,
195
+ if (!visible) {
196
+ throw new QuickCHRError(
197
+ "PROCESS_FAILED",
198
+ `User "${name}" creation was acknowledged but did not become visible in RouterOS`,
199
+ );
200
+ }
201
+
202
+ // Only the `full` group is gated, because only there does "the probe answers
203
+ // 2xx" mean "the credentials work". RouterOS separates authentication from
204
+ // authorization, and a correctly created user in a limited group cannot
205
+ // answer this probe at all. Measured on CHR 7.24.4 by creating one user per
206
+ // group and issuing GET /rest/system/resource as that user:
207
+ //
208
+ // group policy | status
209
+ // ------------------------------------------|---------------------------
210
+ // local,ssh,winbox,read (no rest-api) | 401 Unauthorized
211
+ // local,ssh,winbox,rest-api,write (no read) | 500 std failure: not allowed (9)
212
+ // local,winbox,rest-api,read (no web) | 500 std failure: not allowed (9)
213
+ // read / write / full (defaults) | 200
214
+ //
215
+ // The 401 is byte-identical to #69's symptom, so no amount of polling can
216
+ // tell an unauthorized user from a credential that has not propagated, and the 500 is
217
+ // a permanent condition this wait would poll through to its deadline. Gating
218
+ // those would turn a correctly created user into a 30 s stall and a false
219
+ // "not authenticating" failure. `provision()` only ever creates `full`.
220
+ if (expectedGroup !== "full") {
221
+ logger?.debug(
222
+ `user "${name}" created in group "${group}" — authentication gate skipped: ` +
223
+ "only the full group is known to be REST-verifiable",
224
+ );
225
+ return;
226
+ }
227
+
228
+ // Deliberately outside the loop above. waitForAuth() throws a QuickCHRError,
229
+ // and that catch swallows every QuickCHRError except the group mismatch — so
230
+ // running it inside would discard the one diagnostic this wait exists to
231
+ // produce and report "did not become visible" for a user that plainly is.
232
+ //
233
+ // "Visible in /rest/user" was the whole done-when here, which is a weaker
234
+ // promise than every caller relies on: they use the credentials next.
235
+ const { attempts, elapsedMs } = await waitForAuth(
236
+ httpPort,
237
+ `Basic ${btoa(`${name}:${password}`)}`,
238
+ authTimeoutMs,
239
+ );
240
+ // attempts is the signal; elapsedMs alone cannot separate a retry from a slow
241
+ // first authentication on an emulated guest. The earlier attempts are only
242
+ // known to have not-succeeded — waitForAuth also polls through 5xx and
243
+ // transport errors — so they are not reported as rejections.
244
+ logger?.debug(
245
+ `user "${name}" accepted after ${attempts} attempt(s), ${elapsedMs}ms` +
246
+ (attempts > 1 ? ` — ${attempts - 1} earlier probe(s) did not succeed (#69)` : ""),
126
247
  );
127
248
  }
128
249
 
@@ -609,7 +730,7 @@ export async function provision(
609
730
  // REST provisioning path. The console fallback above always returns early,
610
731
  // so reaching here means REST came up and we provision over it.
611
732
  if (effectiveUser) {
612
- await createUser(httpPort, effectiveUser.name, effectiveUser.password);
733
+ await createUser(httpPort, effectiveUser.name, effectiveUser.password, "full", log);
613
734
  // Persist to secret store so resolveAuth() picks it up
614
735
  saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
615
736
  }
package/src/lib/qemu.ts CHANGED
@@ -10,9 +10,10 @@ 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
12
  import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
13
- import { buildHostfwdString } from "./network.ts";
13
+ import { buildHostfwdString, deviceArgs } from "./network.ts";
14
14
  import { ensureDir } from "./state.ts";
15
15
  import { restGet } from "./rest.ts";
16
+ import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
16
17
 
17
18
  export interface QemuLaunchConfig {
18
19
  arch: Arch;
@@ -136,8 +137,15 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
136
137
  return args;
137
138
  }
138
139
 
139
- /** Build networking arguments from NetworkConfig array. */
140
- function buildNetworkArgs(
140
+ /** Build networking arguments from a `NetworkConfig` array.
141
+ *
142
+ * Exported because it is one of the two places a NIC reaches QEMU (the other is
143
+ * `resolveAllNetworks()` in `network.ts`, whose output the `net.resolved` branch
144
+ * below passes straight through). Testing it through {@link buildQemuArgs} would
145
+ * require a QEMU binary on the host, which the unit tier does not have — and a
146
+ * test that skips itself when QEMU is missing would assert nothing on CI, which
147
+ * is how a MAC-less NIC got shipped in the first place (#154). */
148
+ export function buildNetworkArgs(
141
149
  args: string[],
142
150
  ports: Record<string, PortMapping>,
143
151
  networks: NetworkConfig[],
@@ -156,39 +164,39 @@ function buildNetworkArgs(
156
164
  if (spec === "vmnet-shared") {
157
165
  args.push(
158
166
  "-netdev", `vmnet-shared,id=${id}`,
159
- "-device", `virtio-net-pci,netdev=${id}`,
167
+ ...deviceArgs(net),
160
168
  );
161
169
  } else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
162
170
  args.push(
163
171
  "-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
164
- "-device", `virtio-net-pci,netdev=${id}`,
172
+ ...deviceArgs(net),
165
173
  );
166
174
  } else if (typeof spec === "object" && spec.type === "socket-listen") {
167
175
  args.push(
168
176
  "-netdev", `socket,id=${id},listen=:${spec.port}`,
169
- "-device", `virtio-net-pci,netdev=${id}`,
177
+ ...deviceArgs(net),
170
178
  );
171
179
  } else if (typeof spec === "object" && spec.type === "socket-connect") {
172
180
  args.push(
173
181
  "-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
174
- "-device", `virtio-net-pci,netdev=${id}`,
182
+ ...deviceArgs(net),
175
183
  );
176
184
  } else if (typeof spec === "object" && spec.type === "socket-mcast") {
177
185
  args.push(
178
186
  "-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
179
- "-device", `virtio-net-pci,netdev=${id}`,
187
+ ...deviceArgs(net),
180
188
  );
181
189
  } else if (typeof spec === "object" && spec.type === "tap") {
182
190
  args.push(
183
191
  "-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
184
- "-device", `virtio-net-pci,netdev=${id}`,
192
+ ...deviceArgs(net),
185
193
  );
186
194
  } else {
187
195
  // Default: user-mode networking with port forwarding (only on the first user NIC)
188
196
  const hostfwd = buildHostfwdString(ports);
189
197
  args.push(
190
198
  "-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
191
- "-device", `virtio-net-pci,netdev=${id}`,
199
+ ...deviceArgs(net),
192
200
  );
193
201
  }
194
202
  }
@@ -221,12 +229,20 @@ function buildChannelArgs(
221
229
  qgaSock = `path=${join(machineDir, "qga.sock")}`;
222
230
  }
223
231
 
232
+ // QUICKCHR_SERIAL_LOG=1 tees the serial console to <machineDir>/serial.log via
233
+ // the QEMU chardev `logfile=` option. Opt-in, never default: serial-console
234
+ // provisioning types the generated user password in cleartext (provision.ts
235
+ // provisionViaConsole), so this log is secret-bearing. It is the only record of
236
+ // what RouterOS printed when a boot never reaches REST — the socket chardev
237
+ // keeps no history, so connecting after the failure shows nothing (#79).
238
+ const serialLog = serialLogEnabled() ? `,logfile=${join(machineDir, "serial.log")},logappend=on` : "";
239
+
224
240
  if (background) {
225
241
  // Background: all channels on sockets/pipes
226
242
  args.push(
227
243
  "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
228
244
  "-mon", "chardev=monitor0,mode=readline",
229
- "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
245
+ "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off${serialLog}`,
230
246
  "-serial", "chardev:serial0",
231
247
  );
232
248
  } else {
@@ -370,6 +386,20 @@ export async function spawnQemu(
370
386
  // After spawn, close parent's copy; child process inherits its own copy.
371
387
  const logFd = openSync(logPath, "a");
372
388
 
389
+ // A backgrounded QEMU outlives the CLI invocation that started it, so it must
390
+ // leave the caller's process group. `detached: true` is `setsid()` on POSIX;
391
+ // `unref()` alone is not enough, because it only covers a *voluntary* parent
392
+ // exit. It leaves QEMU in the caller's process group, where a group signal
393
+ // reaches it: Ctrl-C in the terminal, a shell `timeout`, a CI step teardown,
394
+ // an agent harness killing a stuck command (#159).
395
+ //
396
+ // Killing a run's VMs deliberately is by QEMU process name, not by group
397
+ // (`scripts/ci-file-watchdog.ts`, and the integration workflow's cleanup
398
+ // steps), so detaching costs no cleanup reach.
399
+ //
400
+ // Windows keeps its own spawn below for a different failure — a Job Object
401
+ // takes everything in it when the parent goes — and the two are not merged
402
+ // because only this one can be verified here.
373
403
  let spawnedPid: number;
374
404
 
375
405
  if (process.platform === "win32") {
@@ -385,6 +415,11 @@ export async function spawnQemu(
385
415
  stdio: ["ignore", logFd, logFd],
386
416
  windowsHide: true,
387
417
  });
418
+ // A failed exec surfaces asynchronously here, as an `error` event with no
419
+ // pid — and an unhandled `error` event is a crash, not a thrown
420
+ // QuickCHRError. Nothing awaits the child, so this listener is what keeps
421
+ // a qemu binary that is missing, or cannot be executed, reportable.
422
+ child.on("error", () => { /* surfaced by the liveness check below */ });
388
423
  closeSync(logFd);
389
424
  if (child.pid === undefined) {
390
425
  throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
@@ -393,13 +428,14 @@ export async function spawnQemu(
393
428
  spawnedPid = child.pid;
394
429
  } else {
395
430
  const proc = Bun.spawn(spawnCmd, {
431
+ detached: true,
396
432
  stdout: logFd,
397
433
  stderr: logFd,
398
434
  stdin: "ignore",
399
435
  });
400
436
  closeSync(logFd);
401
- // unref() lets the parent Bun process exit without waiting for QEMU.
402
- // QEMU becomes an orphan adopted by init/launchd and keeps running.
437
+ // unref() lets the parent exit without waiting for QEMU; detached keeps
438
+ // QEMU alive when the parent is signalled rather than exiting.
403
439
  proc.unref();
404
440
  spawnedPid = proc.pid;
405
441
  }
@@ -517,9 +553,11 @@ export async function waitForBoot(
517
553
  httpPort: number,
518
554
  timeoutMs: number = 120_000,
519
555
  auth: string = `Basic ${btoa("admin:")}`,
556
+ stats?: BootProbeStats,
520
557
  ): Promise<boolean> {
521
558
  const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
522
- const deadline = Date.now() + timeoutMs;
559
+ const start = Date.now();
560
+ const deadline = start + timeoutMs;
523
561
  let consecutiveReady = 0;
524
562
 
525
563
  while (Date.now() < deadline) {
@@ -530,6 +568,7 @@ export async function waitForBoot(
530
568
  // Auth rejected — the REST layer is up and responded, even though
531
569
  // we cannot validate the body. Count as ready.
532
570
  consecutiveReady++;
571
+ recordBootProbe(stats, Date.now() - start, "unauthorized", String(status));
533
572
  } else if (status >= 200 && status < 300) {
534
573
  // RouterOS REST may return wrong data (array body) briefly after boot —
535
574
  // a startup race. Only accept once the body is the expected singleton object.
@@ -537,17 +576,24 @@ export async function waitForBoot(
537
576
  try { parsed = JSON.parse(body); } catch { /* ignore */ }
538
577
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
539
578
  consecutiveReady++;
579
+ recordBootProbe(stats, Date.now() - start, "ready");
540
580
  } else {
541
581
  consecutiveReady = 0; // wrong body — REST not fully initialized yet
582
+ recordBootProbe(stats, Date.now() - start, "wrong-body", body.slice(0, 80));
542
583
  }
543
584
  } else {
544
585
  consecutiveReady = 0;
586
+ recordBootProbe(stats, Date.now() - start, "http-error", String(status));
545
587
  }
546
588
 
547
589
  if (consecutiveReady >= 2) return true;
548
- } catch {
549
- // ECONNRESET, connection refused, timeout — not ready yet
590
+ } catch (e) {
591
+ // ECONNRESET, connection refused, timeout — not ready yet. Which one it
592
+ // was is the whole diagnosis when a boot never completes (#79), so it is
593
+ // classified rather than swallowed.
550
594
  consecutiveReady = 0;
595
+ const { outcome, detail } = classifyProbeError(e);
596
+ recordBootProbe(stats, Date.now() - start, outcome, detail);
551
597
  }
552
598
  await Bun.sleep(2000);
553
599
  }