@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,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
@@ -13,6 +13,8 @@ import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./pla
13
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 {
@@ -482,10 +484,10 @@ export async function stopQemu(pid: number): Promise<boolean> {
482
484
  return false; // Already dead
483
485
  }
484
486
 
485
- // Try graceful shutdown
487
+ // Terminate the host process (a guest power cut, not /system/shutdown)
486
488
  process.kill(pid, "SIGTERM");
487
489
 
488
- // Wait up to 5s for graceful exit
490
+ // Wait up to 5s for QEMU to exit before escalating
489
491
  for (let i = 0; i < 50; i++) {
490
492
  await Bun.sleep(100);
491
493
  try {
@@ -600,3 +602,53 @@ export async function waitForBoot(
600
602
 
601
603
  return false;
602
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
+ }