@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.
- package/DESIGN.md +162 -7
- package/MANUAL.md +273 -46
- package/README.md +13 -6
- package/package.json +1 -1
- package/src/cli/flags.ts +15 -1
- package/src/cli/index.ts +405 -76
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- 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/lock.ts +497 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- 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 +888 -327
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +64 -2
- package/src/lib/types.ts +96 -20
- 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
|
@@ -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
|
-
//
|
|
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
|
|
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
|
+
}
|