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