@tikoci/quickchr 0.4.8 → 0.5.1
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 +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- package/src/lib/versions.ts +54 -0
package/DESIGN.md
CHANGED
|
@@ -97,6 +97,19 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
97
97
|
|
|
98
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
99
|
|
|
100
|
+
12. **Two locks, one primitive** (`src/lib/lock.ts`; #140, #181, #180).
|
|
101
|
+
- **Machine lock** — `machines/<name>/.lock/`, so it goes with the machine. One mutex for every lifecycle operation: `add`, `start`, relaunch, `setDeviceMode`, `stop`, `clean`, `remove`, orphan removal. It was `.start-lock` and taken only by starts, so `stop`/`clean`/`remove` raced every relaunch. For one release a live `.start-lock` is still honoured; a dead one is ignored, not deleted.
|
|
102
|
+
- **Allocation lock** — `<dataDir>/.allocate.lock/`, held across read → probe → claim when a machine is created. Without it two creations probe the same free ports and pick the same block. The claim itself has to outlive the lock, because `start()` persists `machine.json` only after the download and the spawn, so the creator's machine lock carries `{ portBase, macs }` as a reservation and `listReservations()` reads it. A reservation ends with its lock, and dies with a crashed creator's pid.
|
|
103
|
+
- **Who waits.** `stop`/`clean`/`remove` wait, bounded by the machine's boot budget, so a `stop` issued during a `start` runs after it. A second `start`, `setDeviceMode` and orphan removal fail fast with `MACHINE_LOCKED`: waiting would only boot the machine twice, or wait out a create that proves the directory is not an orphan. Nobody waits for a `foreground` holder, which keeps the lock until its VM exits. `remove` has no override for a live holder: deleting a directory under a live spawn is the bug the lock exists to prevent. A broken machine has no live holder, so the lock never blocks clearing it.
|
|
104
|
+
- **Not reentrant.** Two async operations in one process conflict as much as two processes do, so a lock held by this pid counts as held. Code inside a locked operation calls `stopUnderLock()`/`removeUnderLock()`/`cleanUnderLock()`, never the public `ChrInstance` methods.
|
|
105
|
+
- **State is re-read under the lock.** A handle's snapshot predates whatever the previous holder did, and a stale `state.pid` is a process that has exited or one the OS has reused.
|
|
106
|
+
- **An append-only log, so nothing is deleted from under a holder.** A lock is a directory of numbered records, and the highest decides the state: a live holder means held; a released marker, a dead holder's record or nothing means free. Acquiring creates record top+1 with `link()`, so exactly one contender wins and the file never exists half-written. Releasing appends a released marker, and records below the top are collected. A claim stands only if nothing higher exists and its predecessor is still there, which turns away a contender that slept through other claims, or through a remove and re-create of the machine.
|
|
107
|
+
- **Why not delete a stale lock?** A path has no compare-and-delete. Every deleting recovery tried here had a window between judging a holder dead and deleting its file, in which a successor could be installed and then deleted: rename-aside-and-compare, then a reap guard recovered by age. The Codex review of #190 reproduced both. Taking over a dead holder is now just claiming the next number. A cross-process stress run (6 processes, 360 critical sections, about 15% simulated crashes taken over mid-run) showed no overlap.
|
|
108
|
+
- **Records are named for the lock's incarnation, so a log can be deleted.** A machine's lock lives in `machines/<name>/` and goes with it. A machine can be removed and created again under the same name while some process is paused holding paths into the old lock, so every record is named `<id>.<gen>`, with an id published once per lock directory. A stale process cannot reach the new incarnation's records: not with a claim, a collect, a rejected-claim cleanup or a released marker. Codex reproduced two such cases while records were named by number alone: a claim matching the new record 1, and a collect run after the released marker that deleted the new holder's record. Before that, the logs were briefly pushed out to a never-deleted `locks/<name>/`, which kept a file for every name ever used.
|
|
109
|
+
- **A claim stands only on what it judged, and a holder cleans up while it still holds.** After creating its record, a claimer checks three things: nothing higher exists, its predecessor is byte-for-byte the record it judged free, and its id is still the directory's incarnation. Release collects old records *before* publishing its marker, because from that moment it is no longer the holder and nothing may be deleted by path. A released lock therefore keeps two records: the last holder's record and its marker.
|
|
110
|
+
- **`remove()` renames, then deletes, with no in-place fallback.** `removeMachine()` moves `machines/<name>/` to `<dataDir>/.removing/` in one step before deleting it. A recursive delete in place has a window where `.lock/` is gone but the directory is not, and a waiter or same-named create could take a fresh lock in a directory being deleted. A rename that fails is retried for about 2 s, which covers a scanner holding a handle on Windows. After that, `remove` fails with `STATE_ERROR` and leaves the directory in place, rather than falling back to the unsafe delete (Codex review of #190). Once the rename succeeds the name is free, so a failed delete only warns, giving the staged path. Each removal sweeps `.removing/` for entries left by a delete that failed or a process that stopped after its rename, skipping any whose pid is still alive in another process. A waiter never re-creates a removed machine's directory, because the lock directory is created with a non-recursive `mkdir`: it gets `MACHINE_NOT_FOUND`. `assertSameMachine()` catches one that waited out a remove and re-create.
|
|
111
|
+
- **Unreadable means held.** A record or legacy `.start-lock` that cannot be read, or that does not parse and is younger than 500 ms, blocks. It is never treated as absent.
|
|
112
|
+
|
|
100
113
|
## Port Layout
|
|
101
114
|
|
|
102
115
|
| Offset | Service | Guest Port |
|
|
@@ -131,7 +144,8 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
|
|
|
131
144
|
├── machines/
|
|
132
145
|
│ └── 7.22.1-arm64-1/
|
|
133
146
|
│ ├── machine.json # Config + state
|
|
134
|
-
│ ├── disk.img
|
|
147
|
+
│ ├── boot.qcow2 # Boot disk, qcow2 — or disk.img when raw (see below)
|
|
148
|
+
│ ├── disk<N>.qcow2 # Extra disks
|
|
135
149
|
│ ├── efi-vars.qcow2 # UEFI vars (arm64; qcow2 so savevm works — #31)
|
|
136
150
|
│ ├── monitor.sock # QEMU monitor
|
|
137
151
|
│ ├── serial.sock # Serial console
|
|
@@ -143,12 +157,60 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
|
|
|
143
157
|
└── boot-failure-<machine>-<iso8601>.json
|
|
144
158
|
```
|
|
145
159
|
|
|
160
|
+
**One boot disk per machine.** A raw machine boots `disk.img`, a copy of the cached
|
|
161
|
+
image. A qcow2 machine boots `boot.qcow2`, converted straight from the cached image
|
|
162
|
+
into a `.tmp` file, checked to be a standalone qcow2 (no backing file), then renamed
|
|
163
|
+
into place. Machines created before 0.5.1 also kept the raw `disk.img` the qcow2 came
|
|
164
|
+
from: a full-size copy (128 MiB x86) that nothing reads. Restart no longer requires it
|
|
165
|
+
(#200). `clean` removes it, and nothing else deletes it implicitly (#199). The shared
|
|
166
|
+
cache image stays: `clean` and a failed first boot's rollback rebuild from it.
|
|
167
|
+
|
|
168
|
+
A missing disk is rebuilt only while the machine has never booted. A booted machine's
|
|
169
|
+
disks hold guest state, so a missing `boot.qcow2`, `disk.img` or extra disk is
|
|
170
|
+
`MACHINE_NOT_FOUND`, never a silent rebuild from factory. The way out is to restore the
|
|
171
|
+
file or run `quickchr clean`.
|
|
172
|
+
|
|
173
|
+
**Storage admission (#199).** An operation that writes new data (creating a machine,
|
|
174
|
+
an image or package download) is admitted when **each filesystem it writes to** has
|
|
175
|
+
`estimate + storage-reserve` free there. It replaced a flat 10 GiB floor that refused
|
|
176
|
+
small hosts which had room. The operation's writes are parts with paths (boot disk →
|
|
177
|
+
`machines/`, downloads → the cache it writes to), grouped by `stat().dev`. The estimate
|
|
178
|
+
is a per-stage constant (`STORAGE_ESTIMATES` in `storage.ts`), rounded up from one
|
|
179
|
+
host's measurements and written down there. The reserve (default 2 GiB) absorbs the
|
|
180
|
+
estimate's error and keeps the host usable. Restart is never gated. Virtual disk
|
|
181
|
+
capacity is not charged: a sparse disk's growth is the guest's, after admission.
|
|
182
|
+
|
|
183
|
+
Under pressure, eligible cache entries are evicted, but only from a cache on the short
|
|
184
|
+
filesystem, and only when everything eligible could cover the gap. Free space is then
|
|
185
|
+
**measured again**, in passes, until the measurement admits or nothing more can be
|
|
186
|
+
evicted. Logical sizes are not proof of room (sparse files, APFS clones).
|
|
187
|
+
|
|
188
|
+
**Coordination** (`storage-lease.ts`, after Codex's review of #203 reproduced both
|
|
189
|
+
races). One data-dir lock, held for milliseconds: a check-and-record, or an eviction
|
|
190
|
+
pass. Under it, an operation records a **lease** naming the cache entries it is about
|
|
191
|
+
to read (pins) and the bytes it is about to write, per filesystem. Admission subtracts
|
|
192
|
+
other live leases' bytes from measured free space, so two creators cannot spend the
|
|
193
|
+
same bytes. Eviction skips pinned entries. A pin is recorded before the reader checks
|
|
194
|
+
the entry exists, so an eviction either sees it or has already finished, and then the
|
|
195
|
+
reader fetches the entry again. A create's lease lasts until its `machine.json` is
|
|
196
|
+
saved, which protects the image from then on. A download's lease lasts the download.
|
|
197
|
+
A dead pid's lease is ignored and deleted. A create's own downloads are checked up
|
|
198
|
+
front, so it refuses early, but are reserved only by the download itself, so they are
|
|
199
|
+
not counted twice. Automatic eviction also skips anything used in the last 30
|
|
200
|
+
minutes (a cache hit bumps its mtime). That rule is not what protects a reader; pins
|
|
201
|
+
are. It spares what a finished `cache add` fetched for a later process.
|
|
202
|
+
|
|
203
|
+
The cache cap (`cache-max-size`) is retention, separate from admission. It counts
|
|
204
|
+
images, package archives with their extractions (one entry: the extraction is only a
|
|
205
|
+
hit while its archive exists) and interrupted downloads. Files quickchr did not write
|
|
206
|
+
are reported and never deleted. A failed delete is reported as failed, not freed.
|
|
207
|
+
|
|
146
208
|
**Global settings** live separately, under the XDG **config** tier (not the data tree
|
|
147
209
|
above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
|
|
148
210
|
Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
|
|
149
|
-
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The
|
|
150
|
-
`default-channel`, `default-arch`, `accel`, `cache-max-size`, `
|
|
151
|
-
`secure-login`.
|
|
211
|
+
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 7 managed keys:
|
|
212
|
+
`default-channel`, `default-arch`, `accel`, `cache-max-size`, `storage-reserve`,
|
|
213
|
+
`timeout-extra`, `secure-login`.
|
|
152
214
|
See MANUAL.md's CLI reference and environment-variables sections for the full surface.
|
|
153
215
|
|
|
154
216
|
## Platform Support
|
|
@@ -216,9 +278,10 @@ with a layered design:
|
|
|
216
278
|
- **`release.yml`** — one-click publish from committed release state: freshness gate
|
|
217
279
|
(no suite re-run — main is kept continuously release-able) + `package.json` version
|
|
218
280
|
with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
|
|
219
|
-
--provenance`
|
|
220
|
-
|
|
221
|
-
|
|
281
|
+
--provenance` under the dispatch-time `npm-tag` (default `next`; promoted to `latest`
|
|
282
|
+
after QA — not derived from the version number; a `latest` release is also tagged
|
|
283
|
+
`next`, since there is no separately maintained pre-release line and `next` must not
|
|
284
|
+
point at something older than `latest`). CI never bumps versions or pushes
|
|
222
285
|
to protected `main`.
|
|
223
286
|
- **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
|
|
224
287
|
record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
|
|
@@ -579,13 +642,57 @@ Always read back what we write. One extra REST API call after a provisioning act
|
|
|
579
642
|
|
|
580
643
|
### Provisioning Scope
|
|
581
644
|
|
|
582
|
-
quickchr provisions at first boot
|
|
645
|
+
quickchr provisions at a machine's **first boot** — see "The provisioning window"
|
|
646
|
+
below for why, and for what happens to an option that arrives later:
|
|
583
647
|
- **User creation** — create user, set password, optionally disable admin
|
|
584
648
|
- **Package install** — SCP `.npk` files, reboot to activate
|
|
585
649
|
- **License** — `/system/license/renew` for trial
|
|
586
650
|
- **Device-mode** — `/system/device-mode/update mode=rose container=yes ...` for restricted features (containers, traffic-gen, routerboard). Opt-in: not configured unless explicitly requested via CLI `--device-mode` or API `deviceMode` option. CHR ships with `mode=advanced` which is sufficient for most use cases. Device-mode requires a hard QEMU power-cycle to confirm changes — this is the MikroTik-mandated confirmation mechanism (physical button press on real hardware, cold reboot on VM). The wizard defaults to `rose` when the user opts in, since it enables containers. See: https://help.mikrotik.com/docs/spaces/ROS/pages/93749258/Device-mode
|
|
587
651
|
- **Config import** — planned: load `.rsc` or `.backup` at creation time
|
|
588
652
|
|
|
653
|
+
#### The provisioning window — and why `lastStartedAt` could not be the gate
|
|
654
|
+
|
|
655
|
+
Every provisioning step assumes the guest holds RouterOS' default configuration, so
|
|
656
|
+
provisioning runs in a window that closes at **first boot** — not at `add`. A machine
|
|
657
|
+
created with no provisioning flags can still take them on its first `start`, which is
|
|
658
|
+
the behaviour agents actually rely on.
|
|
659
|
+
|
|
660
|
+
The gate was `!state.lastStartedAt`, and that field does not mean what the gate needed.
|
|
661
|
+
It is stamped immediately after `spawnQemu`, before the guest is known to have booted
|
|
662
|
+
and before provisioning runs, so it records "QEMU has been launched" rather than "the
|
|
663
|
+
guest has been configured". The two agree most of the time, which is why the
|
|
664
|
+
conflation survived — and come apart in three ways, each a defect (#176):
|
|
665
|
+
|
|
666
|
+
- **`clean()`** replaces the disk with the factory image but left `lastStartedAt`
|
|
667
|
+
behind, so a genuinely fresh guest could never be provisioned again. `clean()` now
|
|
668
|
+
clears the gate along with the credentials it already cleared, keeping `cleanedAt`
|
|
669
|
+
for the forensic clue. A cleaned machine's next start re-applies the retained
|
|
670
|
+
provisioning *intent* and costs what a first boot costs.
|
|
671
|
+
- **Provisioning that throws after a successful boot** leaves a running,
|
|
672
|
+
half-provisioned machine with the gate closed. The record is stamped only on
|
|
673
|
+
success, so this case is at least *visible* now; making it retryable — re-running
|
|
674
|
+
only the steps that did not land — is the remaining half of #176 and is what
|
|
675
|
+
`provisioning.steps` exists to support.
|
|
676
|
+
- **A first start with no provisioning options** closed the gate although nothing had
|
|
677
|
+
been configured.
|
|
678
|
+
|
|
679
|
+
`MachineState.provisioning` is the positive record: when provisioning last completed
|
|
680
|
+
and which steps ran. `isProvisioningWindowOpen()` reads it and falls back to
|
|
681
|
+
`lastStartedAt` for machines that predate the field, so no existing machine
|
|
682
|
+
re-provisions itself on its next start. This is the desired-config/runtime-state split
|
|
683
|
+
of #58, applied to the one field where the conflation was already producing bugs
|
|
684
|
+
rather than to `machine.json` wholesale.
|
|
685
|
+
|
|
686
|
+
**Nothing may drop a provisioning option in silence.** `start()` used to pass
|
|
687
|
+
`undefined` in place of the caller's options on every path but a first boot, so all
|
|
688
|
+
seven were discarded with no warning, no error and a normal-length boot — the only way
|
|
689
|
+
to notice was to read the value back. An option outside the window now throws
|
|
690
|
+
`PROVISIONING_WINDOW_CLOSED`, naming each option and the route that still works
|
|
691
|
+
(`quickchr set <name> --license`, the library's `setDeviceMode()` / `installPackage()`,
|
|
692
|
+
or `clean()` for anything else). An option state already records is treated as a
|
|
693
|
+
no-op, so passing the same flags on every start — the shape a script naturally takes —
|
|
694
|
+
is not an error.
|
|
695
|
+
|
|
589
696
|
Provisioning via REST API is preferred (simple HTTP calls). Serial console provisioning (prompt detection + buffer tracking, as in chr-armed) is a fallback for locked environments. Key lessons from chr-armed serial work: use `\r` not `\r\n` on PTY; accumulate buffer with offset tracking to prevent re-matching; detect prompts dynamically, don't use fixed delays.
|
|
590
697
|
|
|
591
698
|
### Exec Transport Design
|
|
@@ -946,12 +1053,88 @@ Detaching does not weaken cleanup, because nothing that sweeps QEMU sweeps by gr
|
|
|
946
1053
|
is worth stating rather than assuming: "the child outlives its parent's signal" and "a
|
|
947
1054
|
wedged run leaks VMs" would otherwise look like the same change.
|
|
948
1055
|
|
|
949
|
-
The naming half
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1056
|
+
The naming half was not fixed quietly. `--bg` is already the default and only ever meant
|
|
1057
|
+
"serial console goes to a log file instead of stdio" — it does not make `start` return
|
|
1058
|
+
early, because `start` waits for readiness by contract. Both an external agent and the
|
|
1059
|
+
maintainer believed it bought them something. The repair (#159, 0.5.0) is a separate flag
|
|
1060
|
+
rather than a redefinition of `--bg` that would change what existing scripts get:
|
|
1061
|
+
|
|
1062
|
+
- **`start --no-wait`** (`StartOptions.wait: false`) returns once QEMU is confirmed
|
|
1063
|
+
running — past `spawnQemu()`'s 1.5 s early-exit check, with state and ports persisted —
|
|
1064
|
+
and leaves readiness to the caller. Measured on CHR 7.24.5 under HVF: the CLI returns in
|
|
1065
|
+
~2 s, REST answers ~17 s later on its own.
|
|
1066
|
+
- **Spelled `--no-wait`, not `--detach`.** QEMU *is* detached (above), so `--detach` would
|
|
1067
|
+
name something every start already does. What changes is whether the CLI waits, and
|
|
1068
|
+
`--no-wait` is the other half of centrs' `--wait <deadline>`:
|
|
1069
|
+
`centrs retrieve --quickchr <name> /system/resource --wait 5m` is the documented way to
|
|
1070
|
+
own readiness afterwards, and the CLI prints it as a tip.
|
|
1071
|
+
- **Provisioning is refused with `--no-wait`, never skipped.** Provisioning runs against
|
|
1072
|
+
the booted guest, so the pair cannot both be honoured. That includes first-boot intent
|
|
1073
|
+
`add` recorded, which the caller did not retype — the case where skipping would be
|
|
1074
|
+
silent. `--fg` is refused too: a foreground start holds the console until QEMU exits.
|
|
1075
|
+
- **`--bg` stays accepted as a deprecated no-op** that warns on stderr, so existing scripts
|
|
1076
|
+
keep working and learn the flag was never doing what they thought. The library default
|
|
1077
|
+
is unchanged: `start()` resolves ready.
|
|
1078
|
+
|
|
1079
|
+
### RouterOS 6 is a boot-only target, not supported RouterOS (#182)
|
|
1080
|
+
|
|
1081
|
+
A 6.x CHR downloads and boots under quickchr, but the REST readiness probe can never
|
|
1082
|
+
succeed against it (6.x has no `/rest`), so `start` ran to its 180 s timeout and then
|
|
1083
|
+
deleted a guest that had answered www, ssh and api from 15 s in. The decision is a narrow
|
|
1084
|
+
**"track and boot"** contract, for one consumer: centrs needs a disposable 6.x router to
|
|
1085
|
+
test the operations an agent needs for a v6→v7 migration (tikoci/centrs#386).
|
|
1086
|
+
|
|
1087
|
+
- **Readiness is a native-API `/login`** on guest 8728, built as a general alternative
|
|
1088
|
+
probe (`waitForApiReady()`, chosen by `readinessProbeFor(version)`) rather than a v6
|
|
1089
|
+
special case — a 7.x guest with REST disabled has the same problem. Two consecutive
|
|
1090
|
+
answers; a credential `!trap` counts as an answer, as a REST 401 does. Grounded on CHR
|
|
1091
|
+
6.49.23 under HVF (2026-10-21): empty `admin` gets `!done`; a bad password gets
|
|
1092
|
+
`!trap =message=invalid user name or password (6)` then `!done`; before RouterOS is up,
|
|
1093
|
+
slirp accepts and the probe times out (~20 s), then ready at ~22–25 s.
|
|
1094
|
+
- **Not a bare TCP accept.** Under user-mode networking slirp accepts on the host side
|
|
1095
|
+
whatever the guest is doing (see `probeTcpPort()`), so a connect proves nothing.
|
|
1096
|
+
- **Everything else is refused loudly, before download where possible**: every
|
|
1097
|
+
provisioning option and the post-boot `set` routes (`PROVISIONING_VERSION_UNSUPPORTED`,
|
|
1098
|
+
checked before the provisioning-window refusal, whose `clean` route would only lead to a
|
|
1099
|
+
second refusal), `arm64` (MikroTik publishes no arm64 CHR for 6.x), and every REST
|
|
1100
|
+
operation (`REST_UNAVAILABLE`, instead of a request that times out). The `secure-login`
|
|
1101
|
+
setting is a default, not a request, so it is not applied to 6.x.
|
|
1102
|
+
- **CLI only.** The wizard refuses 6.x — every step it offers is provisioning.
|
|
1103
|
+
|
|
1104
|
+
### A failed first boot resets an `add()`-created machine, whatever failed (#182)
|
|
1105
|
+
|
|
1106
|
+
#187 made a first boot whose *provisioning* threw reset the machine instead of removing
|
|
1107
|
+
it: disk back to the factory image, `machine.json` back to the record `add()` wrote. A
|
|
1108
|
+
*boot timeout* on the same first boot still called `remove()`, so the two failure modes of
|
|
1109
|
+
one boot disagreed, and the timeout threw away the options `add` recorded. Now
|
|
1110
|
+
`_launchExisting()` snapshots the record whenever the provisioning window is open — not
|
|
1111
|
+
only when there are provisioning options — and a timeout resets from it. A machine
|
|
1112
|
+
`start()` created in the same call is still removed, and one that has booted before is
|
|
1113
|
+
unchanged. The first-boot cleanup messages name `QUICKCHR_PRESERVE_ON_FAILURE=1`, because an
|
|
1114
|
+
auto-removed machine is what hid #182's report.
|
|
1115
|
+
|
|
1116
|
+
### A restart timeout preserves the configured guest (#189)
|
|
1117
|
+
|
|
1118
|
+
Boot-timeout cleanup carries explicit intent: remove a machine created by this
|
|
1119
|
+
`start()`, factory-reset and restore an existing never-booted machine, or stop and
|
|
1120
|
+
preserve an existing machine whose provisioning window was closed. `_launchExisting()`
|
|
1121
|
+
classifies from the refreshed authoritative record under the lifecycle lock,
|
|
1122
|
+
before launch stamps `lastStartedAt`, PID or status. Supplying a restore record
|
|
1123
|
+
for every restart would be destructive too: that branch resets the disk.
|
|
1124
|
+
|
|
1125
|
+
Preservation retains disks, credentials, provisioning history and persistent
|
|
1126
|
+
configuration, while the shared stop path clears PID/status and releases socket
|
|
1127
|
+
memberships. Older records with only `lastStartedAt` remain outside the provisioning
|
|
1128
|
+
window. Diagnostics survive; recovery is retry or explicit removal, never an
|
|
1129
|
+
implicit factory reset. The environment override still suppresses destructive
|
|
1130
|
+
first-boot cleanup. Provisioning rollback is unchanged.
|
|
1131
|
+
|
|
1132
|
+
Synthetic launch tests cover all three cases, the override, stale snapshots in
|
|
1133
|
+
both directions, disk sentinels and credential retention. The focused live
|
|
1134
|
+
`restart-timeout.test.ts` injects a zero readiness-probe budget after a real
|
|
1135
|
+
configured boot, exercises real launch/diagnostics/cleanup, then restarts and
|
|
1136
|
+
reads back the guest identity with retained credentials. Whole-disk equality is
|
|
1137
|
+
only asserted using synthetic sentinels; a live guest can write during boot.
|
|
955
1138
|
|
|
956
1139
|
### Out of Scope (decided)
|
|
957
1140
|
|
|
@@ -969,3 +1152,25 @@ Explicitly rejected, with rationale, so they aren't re-proposed:
|
|
|
969
1152
|
### Document Maintenance
|
|
970
1153
|
|
|
971
1154
|
DESIGN.md is the living home for design decisions and rationale. At the end of any significant work session, agents should review whether new implementation details, design decisions, or discovered constraints belong here. Open/close work in **GitHub Issues** — not BACKLOG.md (see CONTRIBUTING.md "Tracking work"); record grounded RouterOS/QEMU behaviour facts in the narrowest scoped doc (`.github/instructions/*.md`, `docs/`, or `test/lab/<topic>/REPORT.md`); add a CHANGELOG.md entry for user-facing changes. Treat this as a lightweight checklist, not a gate.
|
|
1155
|
+
|
|
1156
|
+
## Explicit guest shutdown (#143)
|
|
1157
|
+
|
|
1158
|
+
`stop()` remains a power cut (QEMU SIGTERM, then SIGKILL), the primary teardown
|
|
1159
|
+
primitive and the mechanism used for device-mode confirmation. `shutdown()` is a
|
|
1160
|
+
separate bounded operation: one `/system/shutdown` via REST and independent process
|
|
1161
|
+
exit observation share a 10-second default grace budget, followed by the existing
|
|
1162
|
+
hard-stop escalation if needed. CLI seconds and library milliseconds are explicit;
|
|
1163
|
+
the library does not resolve the CLI's environment override.
|
|
1164
|
+
|
|
1165
|
+
The HTTP result is diagnostic evidence, not the completion signal. On CHR 7.24.5,
|
|
1166
|
+
x86/HVF and arm64/TCG returned HTTP 400 `interrupted` while successfully halting
|
|
1167
|
+
(see `test/lab/shutdown/REPORT.md`). No QGA/ACPI/console selection framework is
|
|
1168
|
+
needed. Request failure still waits out the grace budget and reports forced
|
|
1169
|
+
fallback if QEMU survives. `guest-exited` means no host stop signal was required;
|
|
1170
|
+
it does not promise ROSE, container or filesystem durability.
|
|
1171
|
+
|
|
1172
|
+
The entire operation holds the existing per-machine lock, refreshes the machine
|
|
1173
|
+
before acting, and uses shared stopped-state cleanup without recursively taking
|
|
1174
|
+
the non-reentrant stop lock. It confirms QEMU exit before clearing the PID or
|
|
1175
|
+
releasing named-network membership. Lock wait, hard-stop escalation and final
|
|
1176
|
+
exit verification are separate bounded phases outside the guest grace budget.
|