@tikoci/quickchr 0.4.8 → 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,49 @@
1
+ /** Bounded guest shutdown, independently observing process exit (#143). */
2
+ import { QuickCHRError } from "./types.ts";
3
+ import type { ShutdownResult } from "./types.ts";
4
+
5
+ export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
6
+
7
+ export function validateShutdownTimeout(timeoutMs: number): number {
8
+ // node:http timers cannot represent a larger delay; overflow becomes a 1 ms timer.
9
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
10
+ throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeoutMs must be a positive integer no greater than 2147483647 milliseconds");
11
+ }
12
+ return timeoutMs;
13
+ }
14
+
15
+ /** The process observer is separate from the request: RouterOS can return HTTP 400
16
+ * `interrupted` while successfully halting. Request must enforce its timeout (exec does). */
17
+ export async function shutdownGuest(
18
+ timeoutMs: number,
19
+ request: (timeoutMs: number) => Promise<unknown>,
20
+ isRunning: () => boolean,
21
+ forceStop: () => Promise<unknown>,
22
+ ): Promise<ShutdownResult> {
23
+ const deadline = performance.now() + timeoutMs;
24
+ let requestError: string | undefined;
25
+ const pending = Promise.resolve().then(() => request(Math.max(1, Math.ceil(deadline - performance.now())))).catch((error: unknown) => {
26
+ requestError = error instanceof Error ? error.message : String(error);
27
+ });
28
+ while (isRunning() && performance.now() < deadline) {
29
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
30
+ }
31
+ const guestExited = !isRunning();
32
+ // exec's HTTP timer settles this within the same grace budget, including when
33
+ // QEMU exits while its HTTP response is still pending. Never replay the request.
34
+ await pending;
35
+ if (guestExited || !isRunning()) {
36
+ return { outcome: "guest-exited", ...(requestError === undefined ? {} : { requestError }) };
37
+ }
38
+
39
+ try {
40
+ await forceStop();
41
+ } catch {
42
+ if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not terminate QEMU; machine remains running");
43
+ }
44
+ // stopQemu sends SIGKILL after its SIGTERM wait but does not await that exit.
45
+ const killDeadline = performance.now() + 1000;
46
+ while (isRunning() && performance.now() < killDeadline) await Bun.sleep(50);
47
+ if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not confirm QEMU exit; machine remains running");
48
+ return { outcome: "forced", ...(requestError === undefined ? {} : { requestError }) };
49
+ }
@@ -142,8 +142,8 @@ const LOCK_TIMEOUT_MS = 5_000;
142
142
  * A two-process repro lost an endpoint on 12 of 12 rounds.
143
143
  *
144
144
  * `quickchr start a & quickchr start b &` is the shape that hits this, and it is
145
- * exactly how the field report drove its multi-CHR lab. The per-machine
146
- * `.start-lock` cannot help: the two contenders are different machines. */
145
+ * exactly how the field report drove its multi-CHR lab. The per-machine lock
146
+ * cannot help: the two contenders are different machines. */
147
147
  const _heldLocks = new Set<string>();
148
148
 
149
149
  /** How long a lock file may be unreadable before it counts as abandoned.
package/src/lib/state.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
3
3
  */
4
4
 
5
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
6
6
  import { join, } from "node:path";
7
7
  import type { MachineState, } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -285,9 +285,71 @@ export function removeMachine(name: string): void {
285
285
  if (!existsSync(dir)) {
286
286
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
287
287
  }
288
- rmSync(dir, { recursive: true, force: true });
288
+ // Out of `machines/` in one step, then deleted. A recursive delete in place removes
289
+ // entries one by one, so there is a window where the machine lock (`.lock/`, inside
290
+ // the directory) is gone while the directory is not, and a waiter or a same-named
291
+ // create could take a fresh lock in a directory that is being deleted. After the
292
+ // rename the name is simply free.
293
+ //
294
+ // There is no in-place fallback, because it would reopen exactly that window. A
295
+ // rename can fail briefly while a scanner or indexer holds a handle (Windows refuses
296
+ // to rename a directory with one open), so it is retried. If it still fails, the
297
+ // directory is left alone and the failure is reported.
298
+ const trashDir = join(getDataDir(), ".removing");
299
+ const trash = join(trashDir, `${name}.${process.pid}.${crypto.randomUUID()}`);
300
+ ensureDir(trashDir);
301
+ sweepAbandonedRemovals(trashDir);
302
+ for (let attempt = 1; ; attempt++) {
303
+ try {
304
+ renameSync(dir, trash);
305
+ break;
306
+ } catch (e) {
307
+ if (attempt >= REMOVE_RENAME_ATTEMPTS) {
308
+ throw new QuickCHRError(
309
+ "STATE_ERROR",
310
+ `Could not remove machine "${name}": moving ${dir} aside failed (${(e as Error).message}). ` +
311
+ `The directory was left in place — close anything using it and run 'quickchr remove ${name}' again.`,
312
+ );
313
+ }
314
+ Bun.sleepSync(REMOVE_RENAME_RETRY_MS);
315
+ }
316
+ }
317
+ // The name is already free, so a failed delete does not fail the removal. The
318
+ // files stay under `.removing/` and the next removal sweeps them.
319
+ try {
320
+ rmSync(trash, { recursive: true, force: true });
321
+ } catch (e) {
322
+ console.warn(`Warning: machine "${name}" is removed, but deleting its files at ${trash} failed (${(e as Error).message}). The next 'quickchr remove' retries it, or delete that directory by hand.`);
323
+ }
289
324
  }
290
325
 
326
+ /** Delete what earlier removals moved aside and did not finish deleting: a delete that
327
+ * failed, or a process that stopped after its rename. Each entry is
328
+ * `<name>.<pid>.<uuid>`. One whose pid is alive in another process is still being
329
+ * deleted, so it is left alone. Best effort: a failure here never fails a removal. */
330
+ function sweepAbandonedRemovals(trashDir: string): void {
331
+ let entries: string[];
332
+ try { entries = readdirSync(trashDir); } catch { return; }
333
+ for (const entry of entries) {
334
+ const pid = Number(entry.split(".").at(-2));
335
+ if (!Number.isInteger(pid) || pid <= 0) continue;
336
+ if (pid !== process.pid) {
337
+ try {
338
+ process.kill(pid, 0);
339
+ continue; // still being deleted by that process
340
+ } catch (e) {
341
+ if ((e as NodeJS.ErrnoException).code === "EPERM") continue;
342
+ }
343
+ }
344
+ try { rmSync(join(trashDir, entry), { recursive: true, force: true }); } catch { /* next time */ }
345
+ }
346
+ }
347
+
348
+ /** How long `removeMachine()` keeps trying to move a directory aside: about 2 s in
349
+ * all, enough for a scanner to let go of a handle. */
350
+ const REMOVE_RENAME_ATTEMPTS = 10;
351
+ const REMOVE_RENAME_RETRY_MS = 200;
352
+
291
353
  /** Update the status of a machine. */
292
354
  export function updateMachineStatus(
293
355
  name: string,
package/src/lib/types.ts CHANGED
@@ -234,7 +234,8 @@ export interface MachineConfig {
234
234
  deviceMode?: DeviceModeOptions;
235
235
  user?: { name: string; password: string };
236
236
  disableAdmin?: boolean;
237
- /** Create a 'quickchr' managed account with a generated password. Defaults to true. */
237
+ /** Managed-login intent, recorded at `add()`/`start()`. Same rule as
238
+ * {@link StartOptions.secureLogin}: only an explicit `true` creates the account. */
238
239
  secureLogin?: boolean;
239
240
  portBase: number;
240
241
  excludePorts: ServiceName[];
@@ -250,6 +251,34 @@ export interface MachineConfig {
250
251
  bootDiskFormat?: BootDiskFormat;
251
252
  }
252
253
 
254
+ /** One provisioning step, as recorded on {@link ProvisioningRecord.steps}. */
255
+ export type ProvisioningStep =
256
+ | "packages"
257
+ | "deviceMode"
258
+ | "license"
259
+ | "user"
260
+ | "disableAdmin"
261
+ | "secureLogin";
262
+
263
+ /**
264
+ * What provisioning actually landed on the *current* guest disk (#176).
265
+ *
266
+ * Absent means the guest has never been provisioned. `clean()` clears it along with
267
+ * the disk, which is what reopens the provisioning window — `lastStartedAt` cannot
268
+ * carry this, because it records that QEMU was launched, not that the guest was
269
+ * configured, and the two come apart at every `clean()`.
270
+ */
271
+ export interface ProvisioningRecord {
272
+ /** ISO timestamp of the last time quickchr applied provisioning to this disk —
273
+ * a first-boot run, or a post-boot step such as `setDeviceMode()`. */
274
+ at: string;
275
+ /** Steps that have run on this disk, accumulated: a first-boot run stamps the set
276
+ * it applied, and a post-boot step adds itself. Not a full inventory of the guest —
277
+ * a step absent here was not applied *by quickchr*, which is a weaker claim than
278
+ * "not present". */
279
+ steps: ProvisioningStep[];
280
+ }
281
+
253
282
  /**
254
283
  * Facts about the SSH keypair quickchr generated and installed for its managed
255
284
  * user during provisioning. Persisted on {@link MachineState} so consumers (the
@@ -272,7 +301,14 @@ export interface ManagedSshKey {
272
301
 
273
302
  export interface MachineState extends MachineConfig {
274
303
  createdAt: string;
304
+ /** Last QEMU spawn. Stamped before the guest is known to have booted, so it says
305
+ * nothing about whether the guest was configured — see {@link provisioning}.
306
+ * Cleared by `clean()`, which replaces the disk that boot belonged to. */
275
307
  lastStartedAt?: string;
308
+ /** Provisioning that landed on the current disk, if any (#176). */
309
+ provisioning?: ProvisioningRecord;
310
+ /** Last `clean()`, which reset the disk to the factory image. */
311
+ cleanedAt?: string;
276
312
  status: "stopped" | "running" | "error";
277
313
  pid?: number;
278
314
  /** Path to the machine directory. */
@@ -298,7 +334,8 @@ export interface MachineState extends MachineConfig {
298
334
  * requires SCP upload + reboot cycle, typically 60–120s extra on native acceleration).
299
335
  */
300
336
  export interface StartOptions {
301
- /** RouterOS version to use (e.g. "7.22.1"). Mutually exclusive with `channel`.
337
+ /** RouterOS version to use (e.g. "7.22.1"). Takes precedence over `channel`:
338
+ * passing both uses `version` and warns that `channel` was ignored.
302
339
  * As a convenience, a channel name (e.g. "long-term") is also accepted here and
303
340
  * resolves the same as `channel: "long-term"` — but this emits a warning, since
304
341
  * `channel` is the self-documenting field for that case. */
@@ -315,12 +352,27 @@ export interface StartOptions {
315
352
  cpu?: number;
316
353
  /** Memory in MiB. Default: 512 (1024 for cross-arch TCG emulation). */
317
354
  mem?: number;
318
- /** Run QEMU in background (true, default) or foreground with serial console on stdio (false). */
355
+ /** Run QEMU in background (true, default) or foreground with serial console on stdio (false).
356
+ * This only decides where the serial console goes — it never made `start()` return
357
+ * sooner. That is {@link StartOptions.wait}. */
319
358
  background?: boolean;
359
+ /** Wait for the guest to become ready before resolving (true, default). `false`
360
+ * resolves as soon as QEMU is confirmed running — past `spawnQemu`'s early-exit
361
+ * check, with state and ports persisted — and leaves readiness to the caller, e.g.
362
+ * `centrs retrieve --quickchr <name> /system/resource --wait 5m` (#159).
363
+ *
364
+ * Provisioning runs against the booted guest, so it cannot be combined with
365
+ * `wait: false` — including first-boot options `add()` recorded. Both are refused
366
+ * (`INVALID_ARGUMENT`) rather than silently skipped, as is `background: false`,
367
+ * which blocks on the serial console regardless. */
368
+ wait?: boolean;
320
369
  /** Extra packages to install after boot.
321
370
  * This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
322
371
  packages?: string[];
323
372
  /** Install all packages from the all_packages ZIP (overrides packages[]).
373
+ * On the first start of an `add()`-created machine, leaving this unset keeps what
374
+ * `add()` recorded; an explicit `false` overrides it. The same holds for
375
+ * `disableAdmin` and `secureLogin`.
324
376
  * This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
325
377
  installAllPackages?: boolean;
326
378
  /** Create a user after boot.
@@ -329,8 +381,12 @@ export interface StartOptions {
329
381
  /** Disable the default admin account after boot.
330
382
  * This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
331
383
  disableAdmin?: boolean;
332
- /** Create a 'quickchr' managed account with a generated password. Defaults to true.
333
- * Set to false to keep admin with no password (exec still works).
384
+ /** Create a 'quickchr' managed account with a generated password. **Only an explicit
385
+ * `true` does this.** Unset and `false` are the same: the machine keeps the factory
386
+ * `admin` login with an empty password (exec still works). The CLI resolves the
387
+ * `secure-login` setting into this field, so it can be `true` without the flag.
388
+ * This is the one statement of the rule — `MachineState.secureLogin` and
389
+ * `provision()` point here.
334
390
  * Enabling the managed login path is quickchr provisioning, so it is validated/tested
335
391
  * on RouterOS 7.20.8+ only. */
336
392
  secureLogin?: boolean;
@@ -560,6 +616,20 @@ export interface ChrLoadSample {
560
616
  memUsedMb: number;
561
617
  }
562
618
 
619
+ /** Options for an explicit guest shutdown. Library values do not read environment settings. */
620
+ export interface ShutdownOptions {
621
+ /** Guest request + exit-wait budget in milliseconds (default 10,000).
622
+ * Lock acquisition and hard-stop escalation have separate bounded waits. */
623
+ timeoutMs?: number;
624
+ }
625
+
626
+ export interface ShutdownResult {
627
+ /** Observed guest exit is not a guarantee of application/storage durability. */
628
+ outcome: "guest-exited" | "forced" | "already-stopped";
629
+ /** Request failure can accompany successful guest exit (HTTP 400 interrupted). */
630
+ requestError?: string;
631
+ }
632
+
563
633
  /**
564
634
  * Runtime handle for a CHR virtual machine.
565
635
  *
@@ -568,7 +638,9 @@ export interface ChrLoadSample {
568
638
  * snapshots, licensing, and lifecycle management.
569
639
  *
570
640
  * After `QuickCHR.start()` resolves, the instance is REST-ready — all requested
571
- * provisioning (packages, license, device-mode, user creation) has completed.
641
+ * provisioning (packages, license, device-mode, user creation) has completed —
642
+ * unless it was started with `wait: false`, or is a RouterOS 6 guest (no REST:
643
+ * `rest()` and REST `exec()` throw `REST_UNAVAILABLE`).
572
644
  */
573
645
  export interface ChrInstance {
574
646
  name: string;
@@ -606,20 +678,12 @@ export interface ChrInstance {
606
678
  * await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
607
679
  */
608
680
  hostGatewayIp: string;
609
- /** @deprecated Renamed to {@link ChrInstance.hostGatewayIp} — the value was never
610
- * TZSP-specific, it is the host as seen from inside the guest and carries any
611
- * guest→host UDP (remote syslog, NetFlow, a plain socket). The old name led
612
- * readers to assume a sniffer-only primitive.
613
- *
614
- * Still populated with the identical value, and will be removed no earlier than
615
- * the next minor. Migration is a rename at the call site — nothing else changes. */
616
- tzspGatewayIp: string;
617
-
618
- /** Check or wait for REST API readiness.
619
- * After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
620
- * the returned instance is already REST-reachable — this returns immediately.
621
- * Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
622
- /** @param stats Optional out-param; each REST readiness probe is classified
681
+
682
+ /** Check or wait for readiness — REST, or a native-API login on RouterOS 6 (#182).
683
+ * After `QuickCHR.start()` the returned instance is already ready, so this returns
684
+ * immediately. Call it after `start({ wait: false })`, or as a safety check after a
685
+ * manual stop/restart. */
686
+ /** @param stats Optional out-param; each REST or native-API readiness probe is classified
623
687
  * into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
624
688
  * whether the forwarded port ever answered. */
625
689
  waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
@@ -641,7 +705,12 @@ export interface ChrInstance {
641
705
  * }, 30_000);
642
706
  */
643
707
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
708
+ /** Power cut: terminate QEMU with SIGTERM, then SIGKILL if needed.
709
+ * Pending guest configuration may be lost. Use shutdown() to request guest shutdown. */
644
710
  stop(): Promise<void>;
711
+ /** Request /system/shutdown once over REST, wait for QEMU exit, then hard-stop
712
+ * if needed. Reports forced fallback explicitly; preserves disks and credentials. */
713
+ shutdown(options?: ShutdownOptions): Promise<ShutdownResult>;
645
714
  remove(): Promise<void>;
646
715
  clean(): Promise<void>;
647
716
  /** Stop and permanently delete this instance (disk + state). Equivalent to stop() then remove(). */
@@ -838,6 +907,13 @@ export type ErrorCode =
838
907
  | "INVALID_NETWORK"
839
908
  | "INVALID_FORWARD_SPEC"
840
909
  | "PROVISIONING_VERSION_UNSUPPORTED"
910
+ // A provisioning option handed to a machine whose provisioning window has closed:
911
+ // the guest has booted, so quickchr cannot assume it still holds the default
912
+ // configuration provisioning expects. Never silently dropped (#176).
913
+ | "PROVISIONING_WINDOW_CLOSED"
914
+ // An operation that needs the REST API, on a guest that has none — RouterOS 6, a
915
+ // boot-only target (#182). Thrown up front instead of letting the request time out.
916
+ | "REST_UNAVAILABLE"
841
917
  | "INSUFFICIENT_DISK_SPACE"
842
918
  | "STATE_ERROR"
843
919
  | "INVALID_SIZE_STRING"
@@ -35,6 +35,11 @@ export const ROUTEROS_COMPATIBILITY_MATRIX: RouterOsCompatibilityRow[] = [
35
35
  minimumVersion: "any RouterOS 7.x",
36
36
  notes: "QEMU networking setup before or during boot.",
37
37
  },
38
+ {
39
+ feature: "Boot / start / stop on RouterOS 6",
40
+ minimumVersion: "6.x (x86 only)",
41
+ notes: "Best-effort boot-only target for v6→v7 migration testing (#182). CLI only; readiness is a native-API login; no provisioning, no REST operations.",
42
+ },
38
43
  {
39
44
  feature: "Managed login, custom user, disable-admin",
40
45
  minimumVersion: MIN_PROVISION_VERSION,
@@ -242,12 +247,61 @@ export function isProvisioningSupportedVersion(
242
247
  return compareRouterOsVersion(version, minimumVersion) >= 0;
243
248
  }
244
249
 
250
+ /**
251
+ * RouterOS 6 is a best-effort **boot-only** target (#182): quickchr downloads, boots and
252
+ * tracks a 6.x CHR, and does nothing else to it. It exists so a tool driving a v6→v7
253
+ * migration (tikoci/centrs#386) has a disposable 6.x router to test against — it is not
254
+ * RouterOS 6 support. CLI only: the wizard does not offer it.
255
+ *
256
+ * 6.x has no REST API, so readiness is a native-API `/login` (see `readinessProbeFor()`
257
+ * in qemu.ts), every provisioning step is refused, and anything that reads or writes
258
+ * through REST fails with `REST_UNAVAILABLE` rather than timing out.
259
+ */
260
+ export function isRouterOs6(version: string): boolean {
261
+ return /^6\./.test(version);
262
+ }
263
+
264
+ export const ROUTEROS6_BOOT_ONLY =
265
+ "RouterOS 6 is a best-effort boot-only target: quickchr downloads, boots and tracks it, and runs no provisioning or REST operations on it.";
266
+
267
+ /** MikroTik publishes RouterOS 6 CHR images for x86 only — `chr-6.49.23-arm64.img.zip`
268
+ * is a 404 (checked 2026-10-21). Refused before the download that would say so less
269
+ * clearly. */
270
+ export function assertRouterOs6Arch(version: string, arch: Arch): void {
271
+ if (!isRouterOs6(version) || arch === "x86") return;
272
+ throw new QuickCHRError(
273
+ "INVALID_ARCH",
274
+ `RouterOS ${version} has no ${arch} CHR image — MikroTik publishes RouterOS 6 CHR for x86 only. Pass --arch x86 (arch: "x86").`,
275
+ );
276
+ }
277
+
278
+ /** Refuse an operation that needs REST on a guest that cannot have it. The message
279
+ * leads with "RouterOS 6: not available" so a caller scanning for the reason finds it
280
+ * first, and names the transport that does work. */
281
+ export function assertRestAvailable(machine: { name: string; version: string }, operation: string): void {
282
+ if (!isRouterOs6(machine.version)) return;
283
+ throw new QuickCHRError(
284
+ "REST_UNAVAILABLE",
285
+ `RouterOS 6: not available — ${operation} uses the REST API, which RouterOS ${machine.version} does not have. ` +
286
+ `${ROUTEROS6_BOOT_ONLY} Reach it over the native API instead, e.g. ` +
287
+ `centrs retrieve --quickchr ${machine.name} /system/resource --via native-api --no-validate ` +
288
+ "(centrs validates against the 7.x command tree, so a 6.x read skips it)",
289
+ );
290
+ }
291
+
245
292
  /** Guard provisioning operations to reduce version-dependent failure modes. */
246
293
  export function assertProvisioningSupportedVersion(
247
294
  version: string,
248
295
  operation: string,
249
296
  minimumVersion = MIN_PROVISION_VERSION,
250
297
  ): void {
298
+ if (isRouterOs6(version)) {
299
+ throw new QuickCHRError(
300
+ "PROVISIONING_VERSION_UNSUPPORTED",
301
+ `Cannot ${operation} on RouterOS ${version}. ${ROUTEROS6_BOOT_ONLY}`,
302
+ `Drop the provisioning options to boot ${version} as it ships, or use RouterOS ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}.`,
303
+ );
304
+ }
251
305
  if (isProvisioningSupportedVersion(version, minimumVersion)) return;
252
306
 
253
307
  throw new QuickCHRError(