@tikoci/quickchr 0.4.7 → 0.5.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,356 @@
1
+ /**
2
+ * The provisioning window: when quickchr may run provisioning against a guest,
3
+ * and what happens to an option that arrives after it has closed (#176).
4
+ *
5
+ * Provisioning expects the guest to hold RouterOS' default configuration — it
6
+ * creates the first account, flips device-mode, installs packages onto a factory
7
+ * image. Once the guest has booted, quickchr no longer knows what is in it, so the
8
+ * window closes and a provisioning option can no longer be honoured blindly.
9
+ *
10
+ * What it must never do is accept the option and drop it. Until #176 every
11
+ * provisioning option handed to `start()` on a machine past its first boot was
12
+ * parsed, accepted and discarded — no warning, no error, normal boot time, and the
13
+ * only way to find out was to read the value back. This module is the refusal:
14
+ * an option either applies, is already satisfied, or is rejected by name with the
15
+ * route that would work.
16
+ */
17
+
18
+ import { getInstanceCredentials } from "./credentials.ts";
19
+ import { shellQuote } from "./names.ts";
20
+ import {
21
+ formatDeviceModeFlags,
22
+ formatDeviceModeSelection,
23
+ mergeDeviceModeOptions,
24
+ type ResolvedDeviceModeOptions,
25
+ resolveDeviceModeOptions,
26
+ shouldApplyDeviceMode,
27
+ } from "./device-mode.ts";
28
+ import {
29
+ type DeviceModeOptions,
30
+ type LicenseInput,
31
+ type MachineState,
32
+ type ProvisioningStep,
33
+ QuickCHRError,
34
+ } from "./types.ts";
35
+
36
+ /** The provisioning options `start()` accepts, in one shape. */
37
+ export interface ProvisioningRequest {
38
+ installAllPackages?: boolean;
39
+ packages?: string[];
40
+ deviceMode?: DeviceModeOptions;
41
+ user?: { name: string; password: string };
42
+ disableAdmin?: boolean;
43
+ license?: LicenseInput;
44
+ secureLogin?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Is the guest still in the state provisioning expects?
49
+ *
50
+ * Deliberately **not** `!state.lastStartedAt`. That field is stamped immediately
51
+ * after `spawnQemu`, before the guest is known to have booted and before
52
+ * provisioning runs, so it answers "has QEMU been launched" — a different question
53
+ * that happens to have the same answer most of the time. `clean()` is where the two
54
+ * come apart: it replaces the disk with the factory image, so the guest genuinely is
55
+ * fresh again, and gating on `lastStartedAt` left such a machine unable to be
56
+ * provisioned ever again. `clean()` now clears both fields, and this reads both:
57
+ * `provisioning` is the positive record, `lastStartedAt` the conservative fallback
58
+ * for a guest that booted at least once and may hold configuration quickchr did not
59
+ * put there — including machines created before `provisioning` existed.
60
+ */
61
+ export function isProvisioningWindowOpen(state: MachineState): boolean {
62
+ return !state.provisioning && !state.lastStartedAt;
63
+ }
64
+
65
+ /** One provisioning option that was asked for on a machine past its first boot. */
66
+ export interface ProvisioningAsk {
67
+ step: ProvisioningStep;
68
+ /** What was asked for, as the refusal should name it. */
69
+ description: string;
70
+ /** True when machine state already records this exact value, making it a no-op. */
71
+ satisfied: boolean;
72
+ }
73
+
74
+ /** A device-mode request flattened to `setting -> value`: `mode` plus one entry per
75
+ * feature, normalized and order-independent. Empty when nothing is being asked for. */
76
+ function deviceModeSelection(options?: DeviceModeOptions): Record<string, string> {
77
+ const resolved = resolveDeviceModeOptions(options);
78
+ if (!shouldApplyDeviceMode(resolved)) return {};
79
+ const selection: Record<string, string> = {};
80
+ if (resolved.mode) selection.mode = resolved.mode;
81
+ for (const [feature, value] of Object.entries(resolved.features)) {
82
+ selection[feature] = value;
83
+ }
84
+ return selection;
85
+ }
86
+
87
+ /** Does the applied record already carry every setting this request names?
88
+ *
89
+ * **Subset, not equality**, because the record is cumulative and the request is not.
90
+ * A device-mode update moves only the settings it names, so a machine that took
91
+ * `--device-mode-enable container` and later `--device-mode-disable smb` has a record
92
+ * of all three settings — and an exact comparison then refuses the very
93
+ * `--device-mode-enable container` that a script has been passing on every start
94
+ * since before the second change, naming a `quickchr set` command that is a no-op.
95
+ * Every field the caller asked about matches; the fields they did not mention are
96
+ * not theirs to match.
97
+ *
98
+ * A field that is absent from the record, or present with a different value, is
99
+ * still pending — this loosens what counts as equal, not what counts as applied. */
100
+ function deviceModeSatisfiedBy(
101
+ applied: Record<string, string>,
102
+ requested: Record<string, string>,
103
+ ): boolean {
104
+ const entries = Object.entries(requested);
105
+ return entries.length > 0 && entries.every(([setting, value]) => applied[setting] === value);
106
+ }
107
+
108
+ function licenseLevelOf(license: LicenseInput): string {
109
+ return (typeof license === "string" ? license : license.level) ?? "p1";
110
+ }
111
+
112
+ /** Did the request's password actually reach the guest? The persisted `state.user`
113
+ * carries a placeholder, never the password, so the comparison goes to the secret
114
+ * store. An unreadable store answers "no": a name match alone would let a password
115
+ * *change* pass as a no-op and be dropped in silence, which is the whole bug. */
116
+ function userCredentialMatches(state: MachineState, user: { name: string; password: string }): boolean {
117
+ if (state.user?.name !== user.name) return false;
118
+ try {
119
+ const stored = getInstanceCredentials(state.name);
120
+ return stored?.user === user.name && stored.password === user.password;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Which provisioning steps this request actually asks for, and which of them already
128
+ * landed on the current guest.
129
+ *
130
+ * **Satisfaction requires the step in `provisioning.steps`, not just a matching value
131
+ * in `machine.json`.** Most of those fields — `installAllPackages`, `packages`,
132
+ * `deviceMode`, `secureLogin` — are *desired config*, written at `add()` before
133
+ * anything ran. Comparing against them alone would call a first boot that threw
134
+ * half-way through "already applied" and drop the retry in silence, which is exactly
135
+ * the failure this module exists to prevent. The step record is the only evidence that
136
+ * a step ran, so it gates every row.
137
+ *
138
+ * Even then this is a claim about what quickchr applied, not about what is in the
139
+ * guest — quickchr cannot read the guest without booting it, which is the thing being
140
+ * decided. It exists so a caller that passes the same options on every start (the
141
+ * shape a script naturally takes) is not punished for asking for what it already got.
142
+ */
143
+ export function classifyProvisioningRequest(
144
+ state: MachineState,
145
+ request: ProvisioningRequest,
146
+ ): ProvisioningAsk[] {
147
+ const asks: ProvisioningAsk[] = [];
148
+ const applied = new Set(state.provisioning?.steps ?? []);
149
+
150
+ if (request.installAllPackages) {
151
+ asks.push({
152
+ step: "packages",
153
+ description: "--install-all-packages",
154
+ satisfied: applied.has("packages") && state.installAllPackages === true,
155
+ });
156
+ } else if (request.packages?.length) {
157
+ const installed = new Set(state.packages ?? []);
158
+ asks.push({
159
+ step: "packages",
160
+ description: `packages ${request.packages.join(", ")}`,
161
+ satisfied: applied.has("packages") && request.packages.every((pkg) => installed.has(pkg)),
162
+ });
163
+ }
164
+
165
+ const requestedDeviceMode = deviceModeSelection(request.deviceMode);
166
+ if (Object.keys(requestedDeviceMode).length > 0) {
167
+ asks.push({
168
+ step: "deviceMode",
169
+ description: `device-mode (${formatDeviceModeSelection(resolveDeviceModeOptions(request.deviceMode))})`,
170
+ satisfied: applied.has("deviceMode")
171
+ && deviceModeSatisfiedBy(deviceModeSelection(state.deviceMode), requestedDeviceMode),
172
+ });
173
+ }
174
+
175
+ if (request.license) {
176
+ const level = licenseLevelOf(request.license);
177
+ asks.push({
178
+ step: "license",
179
+ description: `license (${level})`,
180
+ satisfied: applied.has("license") && state.licenseLevel === level,
181
+ });
182
+ }
183
+
184
+ if (request.user) {
185
+ asks.push({
186
+ step: "user",
187
+ description: `user "${request.user.name}"`,
188
+ satisfied: applied.has("user") && userCredentialMatches(state, request.user),
189
+ });
190
+ }
191
+
192
+ if (request.disableAdmin) {
193
+ asks.push({
194
+ step: "disableAdmin",
195
+ description: "--disable-admin",
196
+ satisfied: applied.has("disableAdmin") && state.disableAdmin === true,
197
+ });
198
+ }
199
+
200
+ if (request.secureLogin === true) {
201
+ asks.push({
202
+ step: "secureLogin",
203
+ description: "managed login (--secure-login)",
204
+ satisfied: applied.has("secureLogin") && state.secureLogin === true,
205
+ });
206
+ }
207
+
208
+ return asks;
209
+ }
210
+
211
+ /**
212
+ * The device-mode record to persist after a post-boot apply.
213
+ *
214
+ * Cumulative, because the guest is: a device-mode update moves only the settings it
215
+ * names, so replacing the record with the request alone would leave `machine.json`
216
+ * claiming an earlier feature had never been asked for while the guest still had it.
217
+ *
218
+ * **But it only carries forward what actually ran.** `state.deviceMode` is desired
219
+ * config, written at `add()` before anything happens. A first boot that threw before
220
+ * the device-mode step leaves that intent sitting in state having never reached the
221
+ * guest — and folding it in here, then stamping `deviceMode` as applied, would make a
222
+ * later `start` treat settings the guest never received as already satisfied and drop
223
+ * them in silence. That is this module's own bug wearing a new hat, so it gets this
224
+ * module's own rule: the step record is the only evidence that a step ran.
225
+ */
226
+ export function appliedDeviceModeRecord(
227
+ state: MachineState,
228
+ applied: ResolvedDeviceModeOptions,
229
+ ): DeviceModeOptions {
230
+ const priorRan = state.provisioning?.steps.includes("deviceMode") ?? false;
231
+ return mergeDeviceModeOptions(priorRan ? state.deviceMode : undefined, applied);
232
+ }
233
+
234
+ /**
235
+ * What the first boot of an `add()`-created machine should provision: the caller's
236
+ * options where given, the intent `add()` recorded where not.
237
+ *
238
+ * **`undefined` means "not given", and only `undefined` falls through.** An explicit
239
+ * `false` is an instruction — `--no-install-all-packages` on `start` overrides the
240
+ * `add`-time intent, as `--no-device-mode` does. The CLI used to send `false` for every
241
+ * absent boolean flag, so `false ?? existing.installAllPackages` discarded what `add`
242
+ * stored and a plain `quickchr start <name>` booted without it (#185). The CLI now
243
+ * sends `undefined` for an absent flag; this function is where the rule is enforced.
244
+ *
245
+ * `license` is not merged: quickchr never persists license credentials, so there is
246
+ * no stored intent to fall back to.
247
+ */
248
+ export function pendingProvisioningFor(
249
+ existing: MachineState,
250
+ request: ProvisioningRequest,
251
+ ): ProvisioningRequest {
252
+ return {
253
+ installAllPackages: request.installAllPackages ?? existing.installAllPackages,
254
+ packages: request.packages?.length ? request.packages : (existing.packages.length > 0 ? existing.packages : undefined),
255
+ deviceMode: request.deviceMode ?? existing.deviceMode,
256
+ user: request.user ?? existing.user,
257
+ disableAdmin: request.disableAdmin ?? existing.disableAdmin,
258
+ license: request.license,
259
+ secureLogin: request.secureLogin ?? existing.secureLogin,
260
+ };
261
+ }
262
+
263
+ /** True when any step in this request would change something. */
264
+ export function hasProvisioningRequest(request: ProvisioningRequest): boolean {
265
+ return !!(
266
+ request.installAllPackages ||
267
+ (request.packages?.length ?? 0) > 0 ||
268
+ shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)) ||
269
+ request.license ||
270
+ request.user ||
271
+ request.disableAdmin ||
272
+ request.secureLogin === true
273
+ );
274
+ }
275
+
276
+ /** Where a step can still be applied once the window has closed. Every step has a
277
+ * route — `clean()` resets the disk and reopens the window for all of them — but
278
+ * the cheaper one is named first where it exists.
279
+ *
280
+ * **Every route is a line you can paste and run**, with its caveat as a comment in
281
+ * that line's own language — `#` for a shell command, `//` for the library API. This
282
+ * is not cosmetic: the caveats used to be parenthetical prose appended to the command
283
+ * (`quickchr set lab … (power-cycles the machine)`), and `(` opens a subshell, so the
284
+ * advertised recovery was a bash syntax error. A route that cannot be run as printed
285
+ * is a route that does not work, which is the whole thing this module exists to stop.
286
+ *
287
+ * The `clean()` route says **repeat the original command**, not "start it again".
288
+ * A refused request is never persisted, and `clean()` clears `user` and
289
+ * `disableAdmin` because they are guest state, so a plain start after the reset
290
+ * would quietly drop exactly the options that were just refused. (`packages`,
291
+ * `deviceMode` and `secureLogin` are retained intent and would come back on their
292
+ * own — repeating the command is simply correct for all of them.)
293
+ *
294
+ * It also takes the request, not just the step, because the route depends on what
295
+ * was asked for: `installPackage()` takes package names and cannot express
296
+ * `--install-all-packages`, so that one goes through `clean()` as well. */
297
+ function postBootRoute(ask: ProvisioningAsk, request: ProvisioningRequest, name: string): string {
298
+ // Every interpolation is quoted: `assertValidResourceName()` only guards names at
299
+ // creation, so a real machine can still be called `lab old`, and the advertised
300
+ // `quickchr set lab old …` addresses a machine called `lab`.
301
+ const quoted = shellQuote(name);
302
+ const replay = `quickchr clean ${quoted} # resets the disk, then repeat the original start command`;
303
+ switch (ask.step) {
304
+ case "license":
305
+ return `quickchr set ${quoted} --license`;
306
+ case "deviceMode":
307
+ // Spelled out with the requested flags, because the route only helps if it
308
+ // can be run as printed — and `set` takes the same flag names `start` does.
309
+ return `quickchr set ${quoted} ${formatDeviceModeFlags(resolveDeviceModeOptions(request.deviceMode))} # power-cycles the machine`;
310
+ case "packages":
311
+ // installPackage() has no install-all form — availablePackages() would have
312
+ // to be enumerated first — so the honest route for that request is a reset.
313
+ if (request.installAllPackages) return replay;
314
+ return `instance.installPackage(${JSON.stringify(request.packages ?? [])}) // library API; no CLI verb yet — tikoci/quickchr#24`;
315
+ default:
316
+ return replay;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Refuse a provisioning option that arrived after the window closed.
322
+ *
323
+ * Returns the asks that are already satisfied, so the caller can say so rather than
324
+ * staying silent about them; throws `PROVISIONING_WINDOW_CLOSED` naming every ask
325
+ * that is not.
326
+ */
327
+ export function assertProvisioningWindow(
328
+ state: MachineState,
329
+ request: ProvisioningRequest,
330
+ ): ProvisioningAsk[] {
331
+ if (isProvisioningWindowOpen(state)) return [];
332
+
333
+ const asks = classifyProvisioningRequest(state, request);
334
+ const pending = asks.filter((ask) => !ask.satisfied);
335
+ if (pending.length === 0) return asks;
336
+
337
+ // Commands go on their own line, indented, so they can be copied without dragging
338
+ // the surrounding prose along with them.
339
+ const lines = pending.flatMap((ask) => [
340
+ ` ${ask.description} — apply it with:`,
341
+ ` ${postBootRoute(ask, request, state.name)}`,
342
+ ]);
343
+ const satisfied = asks.filter((ask) => ask.satisfied);
344
+ if (satisfied.length > 0) {
345
+ lines.push(` (already applied, unchanged: ${satisfied.map((a) => a.description).join("; ")})`);
346
+ }
347
+
348
+ throw new QuickCHRError(
349
+ "PROVISIONING_WINDOW_CLOSED",
350
+ `Machine "${state.name}" has already booted, so provisioning cannot run against it — ` +
351
+ "the guest may no longer hold the default configuration provisioning expects.\n" +
352
+ lines.join("\n") +
353
+ "\nTo provision from scratch, reset the disk to the factory image and reopen the window:\n" +
354
+ ` quickchr clean ${shellQuote(state.name)} # then repeat the original start command — a refused request is not remembered`,
355
+ );
356
+ }
package/src/lib/qemu.ts CHANGED
@@ -10,9 +10,11 @@ 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 { apiLogin } from "./api-login.ts";
17
+ import { isRouterOs6 } from "./versions.ts";
16
18
  import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
17
19
 
18
20
  export interface QemuLaunchConfig {
@@ -137,8 +139,15 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
137
139
  return args;
138
140
  }
139
141
 
140
- /** Build networking arguments from NetworkConfig array. */
141
- function buildNetworkArgs(
142
+ /** Build networking arguments from a `NetworkConfig` array.
143
+ *
144
+ * Exported because it is one of the two places a NIC reaches QEMU (the other is
145
+ * `resolveAllNetworks()` in `network.ts`, whose output the `net.resolved` branch
146
+ * below passes straight through). Testing it through {@link buildQemuArgs} would
147
+ * require a QEMU binary on the host, which the unit tier does not have — and a
148
+ * test that skips itself when QEMU is missing would assert nothing on CI, which
149
+ * is how a MAC-less NIC got shipped in the first place (#154). */
150
+ export function buildNetworkArgs(
142
151
  args: string[],
143
152
  ports: Record<string, PortMapping>,
144
153
  networks: NetworkConfig[],
@@ -157,39 +166,39 @@ function buildNetworkArgs(
157
166
  if (spec === "vmnet-shared") {
158
167
  args.push(
159
168
  "-netdev", `vmnet-shared,id=${id}`,
160
- "-device", `virtio-net-pci,netdev=${id}`,
169
+ ...deviceArgs(net),
161
170
  );
162
171
  } else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
163
172
  args.push(
164
173
  "-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
165
- "-device", `virtio-net-pci,netdev=${id}`,
174
+ ...deviceArgs(net),
166
175
  );
167
176
  } else if (typeof spec === "object" && spec.type === "socket-listen") {
168
177
  args.push(
169
178
  "-netdev", `socket,id=${id},listen=:${spec.port}`,
170
- "-device", `virtio-net-pci,netdev=${id}`,
179
+ ...deviceArgs(net),
171
180
  );
172
181
  } else if (typeof spec === "object" && spec.type === "socket-connect") {
173
182
  args.push(
174
183
  "-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
175
- "-device", `virtio-net-pci,netdev=${id}`,
184
+ ...deviceArgs(net),
176
185
  );
177
186
  } else if (typeof spec === "object" && spec.type === "socket-mcast") {
178
187
  args.push(
179
188
  "-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
180
- "-device", `virtio-net-pci,netdev=${id}`,
189
+ ...deviceArgs(net),
181
190
  );
182
191
  } else if (typeof spec === "object" && spec.type === "tap") {
183
192
  args.push(
184
193
  "-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
185
- "-device", `virtio-net-pci,netdev=${id}`,
194
+ ...deviceArgs(net),
186
195
  );
187
196
  } else {
188
197
  // Default: user-mode networking with port forwarding (only on the first user NIC)
189
198
  const hostfwd = buildHostfwdString(ports);
190
199
  args.push(
191
200
  "-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
192
- "-device", `virtio-net-pci,netdev=${id}`,
201
+ ...deviceArgs(net),
193
202
  );
194
203
  }
195
204
  }
@@ -379,6 +388,20 @@ export async function spawnQemu(
379
388
  // After spawn, close parent's copy; child process inherits its own copy.
380
389
  const logFd = openSync(logPath, "a");
381
390
 
391
+ // A backgrounded QEMU outlives the CLI invocation that started it, so it must
392
+ // leave the caller's process group. `detached: true` is `setsid()` on POSIX;
393
+ // `unref()` alone is not enough, because it only covers a *voluntary* parent
394
+ // exit. It leaves QEMU in the caller's process group, where a group signal
395
+ // reaches it: Ctrl-C in the terminal, a shell `timeout`, a CI step teardown,
396
+ // an agent harness killing a stuck command (#159).
397
+ //
398
+ // Killing a run's VMs deliberately is by QEMU process name, not by group
399
+ // (`scripts/ci-file-watchdog.ts`, and the integration workflow's cleanup
400
+ // steps), so detaching costs no cleanup reach.
401
+ //
402
+ // Windows keeps its own spawn below for a different failure — a Job Object
403
+ // takes everything in it when the parent goes — and the two are not merged
404
+ // because only this one can be verified here.
382
405
  let spawnedPid: number;
383
406
 
384
407
  if (process.platform === "win32") {
@@ -394,6 +417,11 @@ export async function spawnQemu(
394
417
  stdio: ["ignore", logFd, logFd],
395
418
  windowsHide: true,
396
419
  });
420
+ // A failed exec surfaces asynchronously here, as an `error` event with no
421
+ // pid — and an unhandled `error` event is a crash, not a thrown
422
+ // QuickCHRError. Nothing awaits the child, so this listener is what keeps
423
+ // a qemu binary that is missing, or cannot be executed, reportable.
424
+ child.on("error", () => { /* surfaced by the liveness check below */ });
397
425
  closeSync(logFd);
398
426
  if (child.pid === undefined) {
399
427
  throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
@@ -402,13 +430,14 @@ export async function spawnQemu(
402
430
  spawnedPid = child.pid;
403
431
  } else {
404
432
  const proc = Bun.spawn(spawnCmd, {
433
+ detached: true,
405
434
  stdout: logFd,
406
435
  stderr: logFd,
407
436
  stdin: "ignore",
408
437
  });
409
438
  closeSync(logFd);
410
- // unref() lets the parent Bun process exit without waiting for QEMU.
411
- // QEMU becomes an orphan adopted by init/launchd and keeps running.
439
+ // unref() lets the parent exit without waiting for QEMU; detached keeps
440
+ // QEMU alive when the parent is signalled rather than exiting.
412
441
  proc.unref();
413
442
  spawnedPid = proc.pid;
414
443
  }
@@ -455,10 +484,10 @@ export async function stopQemu(pid: number): Promise<boolean> {
455
484
  return false; // Already dead
456
485
  }
457
486
 
458
- // Try graceful shutdown
487
+ // Terminate the host process (a guest power cut, not /system/shutdown)
459
488
  process.kill(pid, "SIGTERM");
460
489
 
461
- // Wait up to 5s for graceful exit
490
+ // Wait up to 5s for QEMU to exit before escalating
462
491
  for (let i = 0; i < 50; i++) {
463
492
  await Bun.sleep(100);
464
493
  try {
@@ -573,3 +602,53 @@ export async function waitForBoot(
573
602
 
574
603
  return false;
575
604
  }
605
+
606
+ /** Which readiness signal a guest can give. REST everywhere it exists; the native-API
607
+ * login on RouterOS 6, which has no REST and would otherwise run the REST probe to its
608
+ * timeout against a guest that booted in seconds (#182). The one place the choice is
609
+ * made — `waitForApiReady()` itself is not version-specific. */
610
+ export function readinessProbeFor(version: string): "rest" | "api" {
611
+ return isRouterOs6(version) ? "api" : "rest";
612
+ }
613
+
614
+ /**
615
+ * Wait for the native API (guest 8728) to answer a `/login` — the readiness probe for a
616
+ * guest without REST. Same contract as {@link waitForBoot}: two consecutive answers,
617
+ * a credential refusal (`!trap`) counts as an answer, and every probe is classified into
618
+ * `stats` so a timeout explains itself.
619
+ */
620
+ export async function waitForApiReady(
621
+ apiPort: number,
622
+ timeoutMs: number,
623
+ creds: { user: string; password: string },
624
+ stats?: BootProbeStats,
625
+ ): Promise<boolean> {
626
+ if (stats) stats.kind = "api";
627
+ const start = Date.now();
628
+ const deadline = start + timeoutMs;
629
+ let consecutiveReady = 0;
630
+
631
+ while (Date.now() < deadline) {
632
+ try {
633
+ const result = await apiLogin(apiPort, creds.user, creds.password, 3000);
634
+ if (result.outcome === "done") {
635
+ consecutiveReady++;
636
+ recordBootProbe(stats, Date.now() - start, "ready");
637
+ } else if (result.outcome === "trap") {
638
+ consecutiveReady++;
639
+ recordBootProbe(stats, Date.now() - start, "unauthorized", result.message);
640
+ } else {
641
+ consecutiveReady = 0;
642
+ recordBootProbe(stats, Date.now() - start, "other", `!fatal ${result.message}`);
643
+ }
644
+ if (consecutiveReady >= 2) return true;
645
+ } catch (e) {
646
+ consecutiveReady = 0;
647
+ const { outcome, detail } = classifyProbeError(e);
648
+ recordBootProbe(stats, Date.now() - start, outcome, detail);
649
+ }
650
+ await Bun.sleep(2000);
651
+ }
652
+
653
+ return false;
654
+ }