@tikoci/quickchr 0.4.6 → 0.4.7
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.
- package/DESIGN.md +241 -1
- package/MANUAL.md +23 -5
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +3 -39
- package/src/lib/net.ts +59 -3
- package/src/lib/packages.ts +6 -12
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +256 -24
- package/src/lib/types.ts +15 -1
- package/src/lib/versions.ts +99 -22
package/DESIGN.md
CHANGED
|
@@ -62,6 +62,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
62
62
|
|
|
63
63
|
**Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)
|
|
64
64
|
|
|
65
|
+
11. **A download is bounded by whether it is *moving*, not by a flat total** — `src/lib/download.ts` is the one download path for both `images.ts` and `packages.ts`. Before it, each caller had its own bound and they disagreed: images aborted at a flat `120_000` per attempt with three retries, packages had **no** deadline and **no** retries, so its only bound was whatever the calling test imposed.
|
|
66
|
+
|
|
67
|
+
A total-duration deadline cannot distinguish *slow* from *stuck*. It fires on a healthy transfer whose only sin is being large, and the retry path then re-downloads from zero — one slow transfer becomes three.
|
|
68
|
+
|
|
69
|
+
**The deadline sat inside the natural variance of a healthy transfer, which is why it was intermittent.** Measured locally 2026-07-31 against `download.mikrotik.com` — same 52.2 MB `all_packages-arm64-7.22.1.zip`, same link, same client, four consecutive attempts:
|
|
70
|
+
|
|
71
|
+
| attempt | elapsed | throughput | under the old flat 120 s? |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| 1 | 118.4 s | 0.421 MB/s | yes, by 1.6 s |
|
|
74
|
+
| 2 | 94.2 s | 0.528 MB/s | yes |
|
|
75
|
+
| 3 | **123.5 s** | 0.403 MB/s | **no — aborted** |
|
|
76
|
+
| 4 | 82.2 s | 0.606 MB/s | yes |
|
|
77
|
+
|
|
78
|
+
All four completed. **One in four exceeded the deadline**, and a fifth attempt through the new streaming path measured 129.4 s (0.385 MB/s) — so the range on one ordinary link is 82–129 s against a 120 s bound. The deadline was inside the distribution, and an unchanged healthy download therefore passed or failed on link jitter alone.
|
|
79
|
+
|
|
80
|
+
That is a sharper statement of the defect than a clean abort would have been, and it is why CI saw "both images needed two retries" *sometimes* rather than always. CI's cold ~0.35 MB/s (→ ~149 s) sits **below** this whole local range, which is why a hosted runner hit it far more reliably than this laptop does — run 30606079288, and B7's local suite where `provisioning.test.ts` spent 619 s of 992 s downloading with zero QEMU processes alive.
|
|
81
|
+
|
|
82
|
+
⚠️ **Do not restate this as "the old code always aborted that download."** An earlier draft of this entry said so on the strength of a single 129.4 s measurement; the next attempt completed in 101.5 s and disproved it as a general claim. One measurement of an intermittent is a signal, not a fact — the same rule the CI program runs on.
|
|
83
|
+
|
|
84
|
+
**Two deadlines, and the failure says which fired.** Neither alone is sufficient: stall detection alone would let a transfer trickling at one byte per second run forever, and a size-derived budget alone cannot fail a wedge quickly.
|
|
85
|
+
- **Stall** (`DOWNLOAD_STALL_MS`, 30 s) — reset on every received chunk. Bounds *silence*, so a moving transfer is never aborted for being slow.
|
|
86
|
+
- **Transfer budget** — `DOWNLOAD_BUDGET_BASE_MS` (30 s for connect/TLS/headers) plus `content-length ÷ COLD_DOWNLOAD_FLOOR_BYTES_PER_S`, a **floor** throughput of 120 000 B/s ≈ ⅓ of the slowest cold transfer ever measured. The 52.2 MB zip gets 465 s. When the server sends no `content-length` the stated `DOWNLOAD_NO_LENGTH_BUDGET_MS` (15 min) applies and the outcome says so — verified that `download.mikrotik.com` *does* send it on both image and package artifacts, so the size-derived budget is the production path.
|
|
87
|
+
|
|
88
|
+
**Retry policy follows from that split.** `DOWNLOAD_STALLED` is retriable — a wedged socket usually moves on the next attempt. `DOWNLOAD_TOO_SLOW` is **terminal on purpose**: the budget is already ~3× the slowest throughput on record, so retrying re-downloads from zero on a link measured slower than the floor, which is the exact behavior this decision removes. It is a signal that the floor is wrong (or the link genuinely is), not something to paper over with another attempt.
|
|
89
|
+
|
|
90
|
+
**The floor constant has one home.** It lives here and `test/integration/timeouts.ts` re-exports it; `coldDownloadTestTimeout()` is *derived from* `transferBudgetMs()` rather than recomputing it, which makes one of #106's partial orders (`download deadline < test timeout`) structural instead of coincidental. Two constants asked not to drift are how they drift.
|
|
91
|
+
|
|
92
|
+
**Streaming also bounds memory, which the old path did not.** `await response.arrayBuffer()` held the entire artifact in RAM before writing it — 52.2 MB for the largest. Measured: a `Bun.file().writer()` sink writing 40 MiB in 1 MiB chunks keeps the file growing on disk in lockstep with flat RSS (22 MB throughout), so the transient allocation is gone. Recorded as a side effect, **not** as a claim about #76 — a 52 MB transient is not a plausible cause of a runner losing communication, and B8a/B8b should not treat this as having changed that picture.
|
|
93
|
+
|
|
94
|
+
**Partial transfers cannot poison the cache.** Downloads stream to a **per-call** `<dest>.<pid>-<uuid>.part` and are renamed only after a verified transfer. Per-call, not a fixed `<dest>.part`, because both callers guard only with `existsSync(destPath)` — two concurrent downloads of one artifact (two `quickchr start`s, parallel tests) would otherwise interleave writes into one file, publish the corrupt result, and delete each other's in-flight file. The unique suffix removes the collision without deduplicating the work; coalescing concurrent downloads needs a lock at both call sites and is a larger change.
|
|
95
|
+
|
|
96
|
+
Verification is two checks, because either alone leaves a hole: bytes received must equal `content-length` **when one was sent**, and a **zero-byte body is always a failure** regardless of framing. The second exists because Bun's fetch hands back an *unknown-length* response for a malformed `content-length` — measured against a raw stub, `4096.5` and `-5` are passed through as headers but deliver 0 bytes, while a blank value is stripped to `null` — and an unknown-length response has no length to verify against. Without it the empty file would be renamed into the cache and served as a complete artifact forever. No artifact quickchr downloads is empty, so this costs nothing. Both callers gate on `existsSync(zipPath)`, so a truncated file left at the destination would be served as a complete cached artifact forever. Resuming with a `Range` request would be better than either deadline (MikroTik sends `accept-ranges: bytes`) and is deliberately out of scope — this decision bounds and classifies a transfer, it does not change the transport. Covered by `test/unit/download.test.ts`. → [#116](https://github.com/tikoci/quickchr/issues/116)
|
|
97
|
+
|
|
98
|
+
⚠️ **Testing note.** `Bun.serve` **drops an explicit `content-length` when the body is a `ReadableStream`** and frames the response `transfer-encoding: chunked`, so a stub built on it cannot exercise the size-derived budget at all — every response looks unknown-length. The anchor tests use a raw `Bun.listen` HTTP stub for that reason.
|
|
99
|
+
|
|
65
100
|
## Port Layout
|
|
66
101
|
|
|
67
102
|
| Offset | Service | Guest Port |
|
|
@@ -102,7 +137,10 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
|
|
|
102
137
|
│ ├── serial.sock # Serial console
|
|
103
138
|
│ ├── qga.sock # QGA (x86 only)
|
|
104
139
|
│ ├── qemu.pid # PID file
|
|
105
|
-
│
|
|
140
|
+
│ ├── qemu.log # Output log
|
|
141
|
+
│ └── serial.log # Serial console tee (only with QUICKCHR_SERIAL_LOG=1)
|
|
142
|
+
└── failures/ # Boot-failure reports (see below)
|
|
143
|
+
└── boot-failure-<machine>-<iso8601>.json
|
|
106
144
|
```
|
|
107
145
|
|
|
108
146
|
**Global settings** live separately, under the XDG **config** tier (not the data tree
|
|
@@ -189,6 +227,33 @@ incidentally; crediting those suppressed the scheduler for versions no full suit
|
|
|
189
227
|
targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
|
|
190
228
|
rollup after fold-logic changes.
|
|
191
229
|
|
|
230
|
+
**What survives losing a runner (#76, #77)**: measured on run
|
|
231
|
+
[30665449265](https://github.com/tikoci/quickchr/actions/runs/30665449265), whose three
|
|
232
|
+
`macos-x86` legs stopped communicating mid-suite. This is the constraint every
|
|
233
|
+
lost-runner instrument has to be designed around, so it is recorded rather than
|
|
234
|
+
re-derived:
|
|
235
|
+
|
|
236
|
+
| Evidence | Survives? |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| Uploaded artifacts | **No** — `if: always()` steps never run |
|
|
239
|
+
| The archived job log | **No** — the blob returns `BlobNotFound`, even though a *normally failing* leg in the same run keeps its full log. Failure preserves the log; runner loss destroys it |
|
|
240
|
+
| The jobs API step ledger | **Yes** — the test step stays `in_progress` with a start time, later steps `pending`, and the job carries a `completed_at` |
|
|
241
|
+
| A check run PATCHed during the leg | **Yes** — it is server-side the moment it is written, and stays un-closed |
|
|
242
|
+
|
|
243
|
+
So a progress marker has to be *pushed out of the runner as it goes*; nothing collected
|
|
244
|
+
at the end can work. That is what `ci-leg-checkpoint.ts` (per-file check-run updates) and
|
|
245
|
+
`ci-leg-ledger.ts` (planned-vs-completed reconciliation into `ci-data/attempted-legs.json`)
|
|
246
|
+
implement. The ledger is a **separate file from `tested-versions.json` by decision**: the
|
|
247
|
+
version scheduler reads that file as a presence test, so recording an aborted run there
|
|
248
|
+
would make the version look tested and silently stop rescheduling it.
|
|
249
|
+
|
|
250
|
+
The same run also pins down #76's shape: the three legs died **62.0 / 64.1 / 65.0 minutes
|
|
251
|
+
into their own jobs**, staggered with their own start times across 14 minutes of
|
|
252
|
+
wall-clock. So the boundary is per-job elapsed, not a shared external event and not a
|
|
253
|
+
round 60-minute constant. Read `completed_at` as an **upper bound** on the wedge — it is
|
|
254
|
+
when the service gave up on a silent runner, and the interval between the runner going
|
|
255
|
+
quiet and that verdict is uncharacterized.
|
|
256
|
+
|
|
192
257
|
**Merge policy**: squash-only, PR title → main commit subject (write PR titles as
|
|
193
258
|
conventional commits), PR body → commit body, branches auto-delete. Review threads must
|
|
194
259
|
be resolved before merge (`required_conversation_resolution`) and automated reviews must
|
|
@@ -204,6 +269,166 @@ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inpu
|
|
|
204
269
|
- `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
|
|
205
270
|
machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch
|
|
206
271
|
|
|
272
|
+
### Boot-Failure Forensics (`src/lib/diagnostics.ts`)
|
|
273
|
+
|
|
274
|
+
Discovered constraint from the `ci:slow-platform-flake` series (#76 #79 #80 #91):
|
|
275
|
+
**a failure that cleans up after itself is unfixable.** Both `BOOT_TIMEOUT` paths
|
|
276
|
+
used to `stop()` + `remove()` the machine and embed `qemu.log.slice(-1200)`, which
|
|
277
|
+
on every recorded CI failure contained nothing but the cleanup SIGTERM. Four rules
|
|
278
|
+
came out of that:
|
|
279
|
+
|
|
280
|
+
1. **`waitForBoot()` classifies, it does not just return `false`.** A boot that
|
|
281
|
+
never becomes REST-ready is several different bugs wearing the same error:
|
|
282
|
+
`refused` = QEMU never bound the port (host-side); `probe-timeout` = it bound
|
|
283
|
+
and nothing behind it answered (guest-side); `reset`/`wrong-body` = the guest
|
|
284
|
+
answered and RouterOS is still settling (the #69 shape). Collapsing all of
|
|
285
|
+
them to a boolean is why #79 stayed open across two full CI runs. The tally
|
|
286
|
+
rides on an optional `BootProbeStats` out-param, so normal boots pay nothing.
|
|
287
|
+
|
|
288
|
+
**Do not read a live host port as "the guest is up."** Under the default
|
|
289
|
+
`user` (slirp) network mode QEMU's hostfwd `listen()`s for the process's whole
|
|
290
|
+
lifetime and accepts before it tries to reach the guest. Verified locally
|
|
291
|
+
(2026-07-27) by starting CHR with `mem: 32`: 72/72 probes `probe-timeout`,
|
|
292
|
+
`hostfwd: accepting`, `info status: running`, empty `serial.log`. So
|
|
293
|
+
`refused` is much rarer than intuition suggests, and `probe-timeout` — not
|
|
294
|
+
`refused` — is the ordinary signature of a guest that never booted.
|
|
295
|
+
|
|
296
|
+
2. **The report is written outside the machine directory**, to
|
|
297
|
+
`<dataDir>/failures/`. Every integration test wraps its body in
|
|
298
|
+
`finally { cleanupMachine(name) }` → `remove()`, which deletes the machine
|
|
299
|
+
dir. A report written next to `qemu.log` is gone before CI can upload it,
|
|
300
|
+
*regardless* of `QUICKCHR_PRESERVE_ON_FAILURE`. The report therefore embeds
|
|
301
|
+
log contents rather than pointing at them, and stands alone.
|
|
302
|
+
|
|
303
|
+
3. **`QUICKCHR_SERIAL_LOG=1` is opt-in, and must stay opt-in.** It adds
|
|
304
|
+
`logfile=` to the serial chardev, which is the only way to see what RouterOS
|
|
305
|
+
printed on a boot that never reaches REST — the socket chardev keeps no
|
|
306
|
+
history, so connecting *after* the failure shows nothing. But serial-console
|
|
307
|
+
provisioning types the generated user password in cleartext, so the log is
|
|
308
|
+
secret-bearing. CI turns it on because CI passwords are per-run throwaways.
|
|
309
|
+
|
|
310
|
+
4. **Timeouts report where they stalled.** `monitorCommand()` records
|
|
311
|
+
connect → first byte → prompt → command written → response first byte, and
|
|
312
|
+
renders them into the timeout message. "Monitor command timed out" alone
|
|
313
|
+
cannot distinguish #80's hypotheses (monitor never greeted us vs. QEMU took
|
|
314
|
+
the command and went quiet); the phase line can, at the cost of a few
|
|
315
|
+
`Date.now()` calls on the success path.
|
|
316
|
+
|
|
317
|
+
Two more came out of #105/#106, once the reports above proved only *that* #79's
|
|
318
|
+
boot went silent:
|
|
319
|
+
|
|
320
|
+
5. **Evidence has to localize, so the capture is layered by invasiveness.**
|
|
321
|
+
Host-side per-port classification from `info usernet` (read-only, always on)
|
|
322
|
+
→ read-only guest snapshot over the serial console, which stays reachable
|
|
323
|
+
while REST is dead (on whenever a serial channel exists) → the counting-rule
|
|
324
|
+
probe, which writes a mangle rule into the guest and is therefore opt-in via
|
|
325
|
+
`QUICKCHR_DEEP_BOOT_DIAGNOSTICS=1` and skipped under
|
|
326
|
+
`QUICKCHR_PRESERVE_ON_FAILURE=1`. Each layer answers a question the one
|
|
327
|
+
before it cannot: `SYN_SENT` proves a silent drop but not *whose*; only a
|
|
328
|
+
counter inside the guest separates "RouterOS dropped it" from "it never
|
|
329
|
+
arrived". The RouterOS-side details (why mangle `passthrough` and not a
|
|
330
|
+
filter `accept`, why conntrack cannot answer this) live in
|
|
331
|
+
`provisioning.instructions.md`; the `info usernet` reading in
|
|
332
|
+
`qemu.instructions.md`.
|
|
333
|
+
|
|
334
|
+
The same secrecy rule as (3) applies one level up: guest payloads go to the
|
|
335
|
+
JSON report, and only credential-free shapes — row counts, booleans, a
|
|
336
|
+
verdict — reach the thrown error, which CI echoes into public job logs.
|
|
337
|
+
|
|
338
|
+
6. **A timeout that outlives the forensics is not optional.** Integration tests
|
|
339
|
+
hardcoding `300_000` against a 480 s same-arch TCG budget meant bun killed
|
|
340
|
+
the test before `waitForBoot()` gave up, so the capture above never ran —
|
|
341
|
+
four #79 reproductions arrived bare for exactly this reason. Tests derive
|
|
342
|
+
their timeout from `defaultBootTimeout()` plus `BOOT_FORENSICS_BUDGET_MS`
|
|
343
|
+
(`test/integration/timeouts.ts`), and a unit test asserts the invariant
|
|
344
|
+
across every arch/accel combination. Note this is alignment, not tuning: the
|
|
345
|
+
4× same-arch TCG factor is still ~11× the measured 44 s worst case, and
|
|
346
|
+
cutting it needs its own measurement of the package-install and device-mode
|
|
347
|
+
paths (#106).
|
|
348
|
+
|
|
349
|
+
Corollary for CI cache keys: `actions/cache` only saves when the primary key
|
|
350
|
+
*missed*, so a static key means a newly-resolved RouterOS version re-downloads
|
|
351
|
+
every run forever (#91). Primary keys must carry a rotating component, with
|
|
352
|
+
`restore-keys` doing the prefix fallback.
|
|
353
|
+
|
|
354
|
+
7. **The boot probe can break the service it is waiting for.** `restGet()`
|
|
355
|
+
implements its deadline as `req.destroy()` — a mid-flight TCP teardown. On
|
|
356
|
+
its own that is harmless. But an aborted request **followed by a completed
|
|
357
|
+
one** corrupts RouterOS `www`: the guest then resets incoming connections,
|
|
358
|
+
and a surviving connection is answered with the status computed for the
|
|
359
|
+
*aborted* request. An `admin:` probe carrying a valid empty password comes
|
|
360
|
+
back `401`. `waitForBoot()` emits exactly that pattern — overrun a probe,
|
|
361
|
+
then succeed on the next one 2 s later.
|
|
362
|
+
|
|
363
|
+
Reproduced deterministically on RouterOS 7.21.5, x86/HVF **and** arm64
|
|
364
|
+
cross-arch TCG, with `curl` in a separate process confirming it is
|
|
365
|
+
guest-side rather than a Bun `node:http` artifact
|
|
366
|
+
(`test/lab/www-abort-damage/`). Two consequences for design: a slow-but-alive
|
|
367
|
+
REST layer must be **waited for** rather than aborted, and backing off after
|
|
368
|
+
consecutive aborts matters because it is the abort/complete *alternation*,
|
|
369
|
+
not the abort count, that does the damage. It also means a REST status
|
|
370
|
+
received after any timeout in the same conversation cannot be trusted to
|
|
371
|
+
belong to the request that received it — the #69 surface.
|
|
372
|
+
|
|
373
|
+
Bounds, so this is not over-read: locally `www` recovers in 1.3–2.4 s, so the
|
|
374
|
+
*permanent* wedge in #79's CI forensics is **not** reproduced, and neither is
|
|
375
|
+
whatever made CI's very first probe exceed 3 s — locally a failed login
|
|
376
|
+
answers in ~125 ms even at `cpu-load: 100`. Both remain open, and the entry
|
|
377
|
+
point to the CI failure is still the unexplained one.
|
|
378
|
+
|
|
379
|
+
8. **Readiness is not the end of the evidence.** Items 1–7 all instrument a boot
|
|
380
|
+
that never became REST-ready. #69 is the other shape: the machine boots, it
|
|
381
|
+
passes `waitForBoot()`, and a *later* request is reset — so every instrument
|
|
382
|
+
above was skipped and the failure arrived as one line of `ECONNRESET`.
|
|
383
|
+
`captureRunningFailure()` (`quickchr.ts`) runs the same instrument set against
|
|
384
|
+
a machine that is still up, and does not stop, remove, or otherwise touch it —
|
|
385
|
+
the caller owns the lifecycle and may well keep testing against it.
|
|
386
|
+
|
|
387
|
+
What that capture has to add is a `trigger` (`FailureTrigger` in
|
|
388
|
+
`diagnostics.ts`): the failed operation, the error with its `code`/`errno`,
|
|
389
|
+
`sinceReadyMs`, and the credential transition that preceded it. Without it the
|
|
390
|
+
report is a healthy machine with no statement of what broke — a boot-failure
|
|
391
|
+
report explains itself through `restProbe`, and here `restProbe` is null. The
|
|
392
|
+
transition is **stated by the caller, never inferred**: the capture can see
|
|
393
|
+
the auth header, but "who this request authenticates as" and "what just
|
|
394
|
+
changed on the guest" are different facts, and only the caller knows the
|
|
395
|
+
second. An absent field reads as "not recorded", which is true; a guessed one
|
|
396
|
+
would send the next reader down a path nobody actually took.
|
|
397
|
+
|
|
398
|
+
The two report kinds share one directory and one 20-file cap, which is why
|
|
399
|
+
pruning orders by the timestamp *inside* the filename rather than by the
|
|
400
|
+
filename: whole-name sort was chronological only while every report carried
|
|
401
|
+
one prefix, and would now rank every `post-readiness-failure-` above every
|
|
402
|
+
`boot-failure-`, deleting the newest of one kind to keep the oldest of the
|
|
403
|
+
other.
|
|
404
|
+
|
|
405
|
+
Building that capture surfaced a defect in the instrument it reuses. The
|
|
406
|
+
guest snapshot's per-query budget was sized on the ~0.3 s cost of a command on
|
|
407
|
+
an already-open console session, which left the **~11.4 s serial login**
|
|
408
|
+
unbudgeted — and `bootFailureGuestExec()` then *divided* that budget across
|
|
409
|
+
credential candidates, so no attempt could finish logging in. No credential was
|
|
410
|
+
ever marked working, every later query repeated the same split, and the
|
|
411
|
+
snapshot spent its full 60 s to report `consoleReachable: false` about a guest
|
|
412
|
+
answering serial in 10 ms. It read as a broken console, which is why it went
|
|
413
|
+
unnoticed on the boot path: there the guest usually *is* unreachable, so the
|
|
414
|
+
wrong answer looked right. Queries now carry a login allowance until one of
|
|
415
|
+
them answers (`GUEST_LOGIN_ALLOWANCE_MS`), with the per-candidate floor raised
|
|
416
|
+
to the measured login cost; the 60 s snapshot budget and the 180 s forensics budget
|
|
417
|
+
are unchanged, so no test timeout moves. Locally this took a capture from
|
|
418
|
+
61.1 s / 0 of 7 queries to 14.4 s / 7 of 7.
|
|
419
|
+
|
|
420
|
+
The general rule, since it is not obvious from the numbers: a console budget
|
|
421
|
+
has two parts that differ by ~40×, and only one of them is a round-trip.
|
|
422
|
+
`provisioning.instructions.md` has the phase-by-phase measurements.
|
|
423
|
+
|
|
424
|
+
Test-side, the same finding forces a single REST client:
|
|
425
|
+
`test/integration/chr-rest.ts` is the only way integration tests reach CHR.
|
|
426
|
+
Tests previously used Bun `fetch()` and, in one file, a hand-rolled `node:http`
|
|
427
|
+
copy of `restGet` — two extra connection-reuse policies that made "client or
|
|
428
|
+
guest?" unanswerable on every reset. Collapsing them is **confound removal,
|
|
429
|
+
not a fix**: run 30507484030 reset through `restGet` too, and
|
|
430
|
+
`test/lab/bun-pool/` never reproduced the pooling bug it was built to catch.
|
|
431
|
+
|
|
207
432
|
## Design Principles
|
|
208
433
|
|
|
209
434
|
### Scope Boundary — QEMU Expert, Not Orchestrator
|
|
@@ -313,6 +538,21 @@ Provisioning via REST API is preferred (simple HTTP calls). Serial console provi
|
|
|
313
538
|
|
|
314
539
|
**Credential resolution** (`src/lib/auth.ts`): Priority order is (1) explicit `--user`/`--password` override, (2) provisioned user from `machine.json` (`state.user`), (3) CHR default `admin:` (empty password). Both `exec()` and `rest()` on ChrInstance use this.
|
|
315
540
|
|
|
541
|
+
**`machine.json` holds two kinds of fact, and `clean()` is where they diverge.**
|
|
542
|
+
Some fields describe the *guest* — what accounts exist on the disk right now:
|
|
543
|
+
`user`, `managedSshKey`, `disableAdmin`, and the per-instance entry in the secret
|
|
544
|
+
store. Others are *intent* — what to apply the next time provisioning runs:
|
|
545
|
+
`packages`, `deviceMode`, `secureLogin`. `clean()` replaces the disk with a fresh
|
|
546
|
+
image, so every guest fact it recorded is false the moment it returns, and nothing
|
|
547
|
+
restores them: a post-`clean()` `start()` reaches `_launchExisting(…, undefined)`
|
|
548
|
+
because `lastStartedAt` is set, so the erased account is never recreated. `clean()`
|
|
549
|
+
therefore clears the guest facts (and deletes the managed keypair under
|
|
550
|
+
`<machineDir>/ssh/`) and keeps the intent. Without that, credential resolution
|
|
551
|
+
kept preferring `state.user` and authenticated every REST call, exec, and SCP as a
|
|
552
|
+
user RouterOS had deleted — the confound removed ahead of #79's QEMU-version
|
|
553
|
+
experiment. Priority (3) above is what a cleaned machine must land on, and it is
|
|
554
|
+
the truth of a fresh CHR image: `admin` with an empty password.
|
|
555
|
+
|
|
316
556
|
Output formatting via `--json` flag. RouterOS trick for structured output: wrap commands in `[:serialize to=json [<routeros-cmd>]]` to get JSON from any CLI command. For REST-to-CLI mapping, see tikoci/restraml `lookup.html`.
|
|
317
557
|
|
|
318
558
|
### Examples Philosophy
|
package/MANUAL.md
CHANGED
|
@@ -199,9 +199,17 @@ credentials from the secret store.
|
|
|
199
199
|
#### `clean <name>`
|
|
200
200
|
|
|
201
201
|
Resets the machine to a fresh image: stops QEMU, re-copies the boot
|
|
202
|
-
image from cache, recreates extra disks per the saved config,
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
image from cache, recreates extra disks per the saved config, and clears
|
|
203
|
+
every credential the old disk held — the per-instance secret-store entry,
|
|
204
|
+
the provisioned `user`, the managed SSH keypair, and the `disableAdmin`
|
|
205
|
+
flag. The next `start` therefore logs in as factory `admin` with an empty
|
|
206
|
+
password. EFI vars are deliberately **kept** (they store boot order, not
|
|
207
|
+
guest state; wiping them costs an arm64 device rescan).
|
|
208
|
+
|
|
209
|
+
`clean` does not re-provision: the boot options in `machine.json`
|
|
210
|
+
(`packages`, `deviceMode`, `secureLogin`) are re-applied only on a machine
|
|
211
|
+
that has never started, so a cleaned machine comes back factory-fresh. Pass
|
|
212
|
+
the provisioning flags again, or `remove` and re-create, to get them back.
|
|
205
213
|
|
|
206
214
|
### Inspection
|
|
207
215
|
|
|
@@ -437,6 +445,9 @@ mutate any machine's `machine.json`.
|
|
|
437
445
|
| `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
|
|
438
446
|
| `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
|
|
439
447
|
| `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
|
|
448
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` keeps the machine directory after a boot timeout instead of removing it (QEMU is still stopped) |
|
|
449
|
+
| `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
|
|
450
|
+
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `=1` lets a boot-timeout capture write to the guest (a mangle counting rule + host probes) to localize a silent packet drop. Skipped when `QUICKCHR_PRESERVE_ON_FAILURE=1` |
|
|
440
451
|
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
|
|
441
452
|
| `NO_COLOR` | Disable ANSI styling |
|
|
442
453
|
|
|
@@ -1016,6 +1027,9 @@ the 6 managed keys are all structural preferences, not secrets.
|
|
|
1016
1027
|
| `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
|
|
1017
1028
|
| `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
|
|
1018
1029
|
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
|
|
1030
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT` so the machine dir survives for diagnosis |
|
|
1031
|
+
| `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
|
|
1032
|
+
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
|
|
1019
1033
|
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
|
|
1020
1034
|
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
|
|
1021
1035
|
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
|
|
@@ -1162,8 +1176,12 @@ state deliberately) if a built-in service needs a different host port.
|
|
|
1162
1176
|
|
|
1163
1177
|
`quickchr clean <name>` re-copies the boot image from cache and
|
|
1164
1178
|
recreates extra disks per `machine.json`. Snapshots are gone (they live
|
|
1165
|
-
inside the qcow2 file).
|
|
1166
|
-
|
|
1179
|
+
inside the qcow2 file). Every credential the old disk held is wiped —
|
|
1180
|
+
stored instance credentials, `state.user`, the managed SSH keypair under
|
|
1181
|
+
`<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
|
|
1182
|
+
went with the disk. The machine comes back as a factory CHR: `admin` with
|
|
1183
|
+
an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
|
|
1184
|
+
to afterwards. Nothing is re-provisioned automatically.
|
|
1167
1185
|
|
|
1168
1186
|
### Snapshots
|
|
1169
1187
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -108,6 +108,7 @@ export type { QgaCommandInfo } from "./lib/qga.ts";
|
|
|
108
108
|
|
|
109
109
|
// Console exec utilities
|
|
110
110
|
export { consoleExec, isConsoleReady, stripAnsi } from "./lib/console.ts";
|
|
111
|
+
export type { ConsoleExecResult } from "./lib/console.ts";
|
|
111
112
|
|
|
112
113
|
// Channel utilities
|
|
113
114
|
export { isQgaReady } from "./lib/channels.ts";
|
package/src/lib/channels.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Arch, ChannelTcpEndpoint, QgaCommand } from "./types.ts";
|
|
|
15
15
|
import { QuickCHRError } from "./types.ts";
|
|
16
16
|
import { qgaProbe, qgaRawCommand } from "./qga.ts";
|
|
17
17
|
import { qgaKvmWarning } from "./platform.ts";
|
|
18
|
+
import { newMonitorPhaseTimings, summarizeMonitorPhases } from "./diagnostics.ts";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Resolve the IPC path for a QEMU channel (monitor, serial, qga).
|
|
@@ -87,9 +88,19 @@ export async function monitorCommand(
|
|
|
87
88
|
const endpoint = channelEndpoint(machineDir, "monitor", portBase);
|
|
88
89
|
|
|
89
90
|
return new Promise((resolve, reject) => {
|
|
91
|
+
// Per-phase timings so a timeout says *where* the round-trip stalled rather
|
|
92
|
+
// than just that it did (#80). Only read on the timeout path — a successful
|
|
93
|
+
// command pays nothing but a few Date.now() calls.
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
const phases = newMonitorPhaseTimings();
|
|
96
|
+
const since = () => Date.now() - started;
|
|
97
|
+
|
|
90
98
|
const timeout = setTimeout(() => {
|
|
91
99
|
socket.destroy();
|
|
92
|
-
reject(new QuickCHRError(
|
|
100
|
+
reject(new QuickCHRError(
|
|
101
|
+
"BOOT_TIMEOUT",
|
|
102
|
+
`Monitor command timed out after ${timeoutMs}ms: "${command}" — ${summarizeMonitorPhases(phases)}`,
|
|
103
|
+
));
|
|
93
104
|
}, timeoutMs);
|
|
94
105
|
|
|
95
106
|
let buffer = "";
|
|
@@ -99,13 +110,20 @@ export async function monitorCommand(
|
|
|
99
110
|
? connect({ path: endpoint })
|
|
100
111
|
: connect(endpoint.port, endpoint.host);
|
|
101
112
|
|
|
113
|
+
socket.on("connect", () => { phases.connectedAtMs = since(); });
|
|
114
|
+
|
|
102
115
|
socket.on("data", (data) => {
|
|
116
|
+
phases.bytesReceived += data.length;
|
|
117
|
+
phases.firstByteAtMs ??= since();
|
|
118
|
+
if (sentCommand) phases.responseFirstByteAtMs ??= since();
|
|
103
119
|
buffer += data.toString();
|
|
104
120
|
// Wait for the (qemu) prompt before sending command
|
|
105
121
|
if (!sentCommand && buffer.includes("(qemu)")) {
|
|
106
122
|
sentCommand = true;
|
|
123
|
+
phases.promptAtMs = since();
|
|
107
124
|
buffer = "";
|
|
108
125
|
socket.write(command + "\n");
|
|
126
|
+
phases.commandWrittenAtMs = since();
|
|
109
127
|
} else if (sentCommand && buffer.includes("(qemu)")) {
|
|
110
128
|
// Got response — strip the prompt, the command echo, and ANSI noise
|
|
111
129
|
clearTimeout(timeout);
|
|
@@ -122,7 +140,10 @@ export async function monitorCommand(
|
|
|
122
140
|
if (sentCommand) {
|
|
123
141
|
resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
|
|
124
142
|
} else {
|
|
125
|
-
reject(new QuickCHRError(
|
|
143
|
+
reject(new QuickCHRError(
|
|
144
|
+
"MACHINE_STOPPED",
|
|
145
|
+
`Monitor socket closed before command could be sent — ${summarizeMonitorPhases(phases)}`,
|
|
146
|
+
));
|
|
126
147
|
}
|
|
127
148
|
});
|
|
128
149
|
|