@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
package/src/lib/state.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Machine state persistence — JSON files in ~/.local/share/quickchr/machines/.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { join, } from "node:path";
|
|
7
7
|
import type { MachineState, } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
9
9
|
import { networkModeToConfigs } from "./network.ts";
|
|
10
|
+
import { assertPathSafeName, isPathSafeName } from "./names.ts";
|
|
10
11
|
|
|
11
12
|
/** Get the quickchr data directory root. Override with QUICKCHR_DATA_DIR env var. */
|
|
12
13
|
export function getDataDir(): string {
|
|
@@ -91,12 +92,72 @@ export function saveMachine(state: MachineState): void {
|
|
|
91
92
|
writeFileSync(machineJsonPath(state.name), JSON.stringify(state, null, "\t") + "\n");
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
/**
|
|
95
|
+
/** The fields every caller dereferences without guarding, with the `typeof` each must
|
|
96
|
+
* answer. Deliberately not a schema: it is the set that turns a bad file into a crash
|
|
97
|
+
* somewhere else, which is the thing being prevented. `cpu`/`mem` are absent because a
|
|
98
|
+
* missing one prints oddly rather than throwing, and a narrow check cannot wrongly
|
|
99
|
+
* condemn a machine that works today. `networks` is absent because `loadMachine()`
|
|
100
|
+
* migrates it just below.
|
|
101
|
+
*
|
|
102
|
+
* Every field listed here has been required on `MachineState` since the commit that
|
|
103
|
+
* introduced it (5cfe052), so no `machine.json` any version of quickchr wrote can be
|
|
104
|
+
* condemned by this check — which is the thing that would make it worse than the bug
|
|
105
|
+
* it fixes. */
|
|
106
|
+
const REQUIRED_STATE_FIELDS: ReadonlyArray<[keyof MachineState, "string" | "number" | "object"]> = [
|
|
107
|
+
["name", "string"],
|
|
108
|
+
["version", "string"],
|
|
109
|
+
["arch", "string"],
|
|
110
|
+
["status", "string"],
|
|
111
|
+
["machineDir", "string"],
|
|
112
|
+
["portBase", "number"],
|
|
113
|
+
["ports", "object"],
|
|
114
|
+
["packages", "object"],
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/** Why `parsed` is not usable as a `MachineState`, or `undefined` when it is.
|
|
118
|
+
*
|
|
119
|
+
* Parsing successfully is not the same as being state. `{"name":"lab"}` parses, and
|
|
120
|
+
* `JSON.parse` is happy with `null` and `[]` too; casting any of them to `MachineState`
|
|
121
|
+
* only moves the failure — `{"name":"lab"}` reached `formatPorts(m.ports)` and aborted
|
|
122
|
+
* `quickchr list` exactly as an unparseable file used to (#165). */
|
|
123
|
+
function stateShapeProblem(parsed: unknown): string | undefined {
|
|
124
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
125
|
+
return "not a JSON object";
|
|
126
|
+
}
|
|
127
|
+
const record = parsed as Record<string, unknown>;
|
|
128
|
+
const bad = REQUIRED_STATE_FIELDS
|
|
129
|
+
.filter(([key, kind]) => typeof record[key] !== kind || record[key] === null)
|
|
130
|
+
.map(([key]) => key);
|
|
131
|
+
return bad.length > 0 ? `missing or invalid: ${bad.join(", ")}` : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Load machine state from disk. Returns undefined if not found.
|
|
135
|
+
*
|
|
136
|
+
* Throws `STATE_ERROR` when the file is there but unusable. A targeted lookup by name
|
|
137
|
+
* has to fail: the caller named *this* machine, and answering `undefined` would report
|
|
138
|
+
* a machine that exists on disk as one that never did. `loadAllMachines()` is the
|
|
139
|
+
* enumeration and takes the other policy — see `tryLoadMachine()` (#165). */
|
|
95
140
|
export function loadMachine(name: string): MachineState | undefined {
|
|
96
141
|
const path = machineJsonPath(name);
|
|
97
142
|
if (!existsSync(path)) return undefined;
|
|
98
|
-
|
|
99
|
-
|
|
143
|
+
let state: MachineState;
|
|
144
|
+
try {
|
|
145
|
+
state = JSON.parse(readFileSync(path, "utf-8")) as MachineState;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new QuickCHRError(
|
|
148
|
+
"STATE_ERROR",
|
|
149
|
+
`Machine "${name}" has an unreadable machine.json (${e instanceof Error ? e.message : String(e)})`,
|
|
150
|
+
`Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const shapeProblem = stateShapeProblem(state);
|
|
154
|
+
if (shapeProblem) {
|
|
155
|
+
throw new QuickCHRError(
|
|
156
|
+
"STATE_ERROR",
|
|
157
|
+
`Machine "${name}" has an unreadable machine.json (${shapeProblem})`,
|
|
158
|
+
`Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
100
161
|
// Migrate legacy `network` field → `networks` array
|
|
101
162
|
if (!state.networks && (state as unknown as Record<string, unknown>).network) {
|
|
102
163
|
const legacy = (state as unknown as Record<string, unknown>).network as MachineState["networks"][0]["specifier"] | "user" | "vmnet-shared" | { type: "vmnet-bridge"; iface: string };
|
|
@@ -107,7 +168,33 @@ export function loadMachine(name: string): MachineState | undefined {
|
|
|
107
168
|
return state;
|
|
108
169
|
}
|
|
109
170
|
|
|
110
|
-
/**
|
|
171
|
+
/** The three things a machine directory can turn out to be.
|
|
172
|
+
*
|
|
173
|
+
* `loadMachine()` collapses `missing` and `unreadable` into "undefined or throw", which
|
|
174
|
+
* is the right contract for a lookup and the wrong one for an enumeration. This keeps
|
|
175
|
+
* them apart so each caller can pick (#165). Internal for now: promoting it to public
|
|
176
|
+
* API is a rename if #58 lands. */
|
|
177
|
+
type MachineLoad =
|
|
178
|
+
| { status: "ok"; state: MachineState }
|
|
179
|
+
| { status: "missing" }
|
|
180
|
+
| { status: "unreadable"; error: string };
|
|
181
|
+
|
|
182
|
+
/** `loadMachine()` without the throw. */
|
|
183
|
+
function tryLoadMachine(name: string): MachineLoad {
|
|
184
|
+
try {
|
|
185
|
+
const state = loadMachine(name);
|
|
186
|
+
return state ? { status: "ok", state } : { status: "missing" };
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { status: "unreadable", error: e instanceof Error ? e.message : String(e) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Load all machine states, skipping directories with no usable `machine.json`.
|
|
193
|
+
*
|
|
194
|
+
* An enumeration is best-effort across many: one corrupt file used to abort the whole
|
|
195
|
+
* listing, which loses the state of every healthy machine as well. Nothing is hidden by
|
|
196
|
+
* the skip — `listUnreadableMachines()` names what was left out, and `quickchr list`
|
|
197
|
+
* shows it as a row (#165). */
|
|
111
198
|
export function loadAllMachines(): MachineState[] {
|
|
112
199
|
const dir = getMachinesDir();
|
|
113
200
|
if (!existsSync(dir)) return [];
|
|
@@ -117,13 +204,27 @@ export function loadAllMachines(): MachineState[] {
|
|
|
117
204
|
|
|
118
205
|
for (const entry of entries) {
|
|
119
206
|
if (!entry.isDirectory()) continue;
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
207
|
+
const loaded = tryLoadMachine(entry.name);
|
|
208
|
+
if (loaded.status === "ok") machines.push(loaded.state);
|
|
122
209
|
}
|
|
123
210
|
|
|
124
211
|
return machines;
|
|
125
212
|
}
|
|
126
213
|
|
|
214
|
+
/** Every machine directory whose `machine.json` exists but cannot be read.
|
|
215
|
+
*
|
|
216
|
+
* Distinct from `listOrphanMachineDirs()`, which also counts a directory with no
|
|
217
|
+
* `machine.json` at all — a create that never finished. This one is a machine that did
|
|
218
|
+
* exist and whose state went bad, so it still holds a disk image worth naming. */
|
|
219
|
+
export function listUnreadableMachines(): Array<{ name: string; error: string }> {
|
|
220
|
+
const out: Array<{ name: string; error: string }> = [];
|
|
221
|
+
for (const name of listMachineNames()) {
|
|
222
|
+
const loaded = tryLoadMachine(name);
|
|
223
|
+
if (loaded.status === "unreadable") out.push({ name, error: loaded.error });
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
127
228
|
/** Get all existing machine names. */
|
|
128
229
|
export function listMachineNames(): string[] {
|
|
129
230
|
const dir = getMachinesDir();
|
|
@@ -133,20 +234,122 @@ export function listMachineNames(): string[] {
|
|
|
133
234
|
.map((e) => e.name);
|
|
134
235
|
}
|
|
135
236
|
|
|
237
|
+
/** True when `name` has a machine directory but no readable `machine.json` — a create
|
|
238
|
+
* that did not finish. `list` cannot show it and `start` cannot boot it, but
|
|
239
|
+
* `listMachineNames()` (directory-based) still counts it, so it blocks re-add (#155). */
|
|
240
|
+
export function isOrphanMachineDir(name: string): boolean {
|
|
241
|
+
// A traversal name resolves to a directory that exists and holds no machine.json —
|
|
242
|
+
// the data dir itself, for ".." — so the path check has to come first or the
|
|
243
|
+
// predicate answers "orphan" about something that is not a machine directory.
|
|
244
|
+
if (!isPathSafeName(name)) return false;
|
|
245
|
+
const dir = getMachineDir(name);
|
|
246
|
+
if (!existsSync(dir)) return false;
|
|
247
|
+
return !isReadableMachine(name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Every machine directory with no readable `machine.json`. */
|
|
251
|
+
export function listOrphanMachineDirs(): string[] {
|
|
252
|
+
return listMachineNames().filter((name) => !isReadableMachine(name));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A truncated or corrupt `machine.json` is as unusable as a missing one, and both make
|
|
256
|
+
* the directory an orphan. */
|
|
257
|
+
function isReadableMachine(name: string): boolean {
|
|
258
|
+
return tryLoadMachine(name).status === "ok";
|
|
259
|
+
}
|
|
260
|
+
|
|
136
261
|
/** Get all port bases currently in use by existing machines. */
|
|
137
262
|
export function getUsedPortBases(): number[] {
|
|
138
263
|
return loadAllMachines().map((m) => m.portBase);
|
|
139
264
|
}
|
|
140
265
|
|
|
266
|
+
/** Every NIC MAC currently assigned across all machines.
|
|
267
|
+
* Passed to `assignMacs()` so a new machine cannot be handed an address another
|
|
268
|
+
* machine already presents on a shared L2 segment (#154). */
|
|
269
|
+
export function getUsedMacs(): Set<string> {
|
|
270
|
+
const macs = new Set<string>();
|
|
271
|
+
for (const m of loadAllMachines()) {
|
|
272
|
+
for (const n of m.networks ?? []) {
|
|
273
|
+
if (n.mac) macs.add(n.mac);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return macs;
|
|
277
|
+
}
|
|
278
|
+
|
|
141
279
|
/** Delete a machine and all its files. */
|
|
142
280
|
export function removeMachine(name: string): void {
|
|
281
|
+
// This is an `rmSync(..., { recursive: true })`; the name must never be able to
|
|
282
|
+
// point outside machines/<name>/.
|
|
283
|
+
assertPathSafeName(name, "machine");
|
|
143
284
|
const dir = getMachineDir(name);
|
|
144
285
|
if (!existsSync(dir)) {
|
|
145
286
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
|
146
287
|
}
|
|
147
|
-
|
|
288
|
+
// Out of `machines/` in one step, then deleted. A recursive delete in place removes
|
|
289
|
+
// entries one by one, so there is a window where the machine lock (`.lock/`, inside
|
|
290
|
+
// the directory) is gone while the directory is not, and a waiter or a same-named
|
|
291
|
+
// create could take a fresh lock in a directory that is being deleted. After the
|
|
292
|
+
// rename the name is simply free.
|
|
293
|
+
//
|
|
294
|
+
// There is no in-place fallback, because it would reopen exactly that window. A
|
|
295
|
+
// rename can fail briefly while a scanner or indexer holds a handle (Windows refuses
|
|
296
|
+
// to rename a directory with one open), so it is retried. If it still fails, the
|
|
297
|
+
// directory is left alone and the failure is reported.
|
|
298
|
+
const trashDir = join(getDataDir(), ".removing");
|
|
299
|
+
const trash = join(trashDir, `${name}.${process.pid}.${crypto.randomUUID()}`);
|
|
300
|
+
ensureDir(trashDir);
|
|
301
|
+
sweepAbandonedRemovals(trashDir);
|
|
302
|
+
for (let attempt = 1; ; attempt++) {
|
|
303
|
+
try {
|
|
304
|
+
renameSync(dir, trash);
|
|
305
|
+
break;
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (attempt >= REMOVE_RENAME_ATTEMPTS) {
|
|
308
|
+
throw new QuickCHRError(
|
|
309
|
+
"STATE_ERROR",
|
|
310
|
+
`Could not remove machine "${name}": moving ${dir} aside failed (${(e as Error).message}). ` +
|
|
311
|
+
`The directory was left in place — close anything using it and run 'quickchr remove ${name}' again.`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
Bun.sleepSync(REMOVE_RENAME_RETRY_MS);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// The name is already free, so a failed delete does not fail the removal. The
|
|
318
|
+
// files stay under `.removing/` and the next removal sweeps them.
|
|
319
|
+
try {
|
|
320
|
+
rmSync(trash, { recursive: true, force: true });
|
|
321
|
+
} catch (e) {
|
|
322
|
+
console.warn(`Warning: machine "${name}" is removed, but deleting its files at ${trash} failed (${(e as Error).message}). The next 'quickchr remove' retries it, or delete that directory by hand.`);
|
|
323
|
+
}
|
|
148
324
|
}
|
|
149
325
|
|
|
326
|
+
/** Delete what earlier removals moved aside and did not finish deleting: a delete that
|
|
327
|
+
* failed, or a process that stopped after its rename. Each entry is
|
|
328
|
+
* `<name>.<pid>.<uuid>`. One whose pid is alive in another process is still being
|
|
329
|
+
* deleted, so it is left alone. Best effort: a failure here never fails a removal. */
|
|
330
|
+
function sweepAbandonedRemovals(trashDir: string): void {
|
|
331
|
+
let entries: string[];
|
|
332
|
+
try { entries = readdirSync(trashDir); } catch { return; }
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
const pid = Number(entry.split(".").at(-2));
|
|
335
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
336
|
+
if (pid !== process.pid) {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, 0);
|
|
339
|
+
continue; // still being deleted by that process
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if ((e as NodeJS.ErrnoException).code === "EPERM") continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
try { rmSync(join(trashDir, entry), { recursive: true, force: true }); } catch { /* next time */ }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** How long `removeMachine()` keeps trying to move a directory aside: about 2 s in
|
|
349
|
+
* all, enough for a scanner to let go of a handle. */
|
|
350
|
+
const REMOVE_RENAME_ATTEMPTS = 10;
|
|
351
|
+
const REMOVE_RENAME_RETRY_MS = 200;
|
|
352
|
+
|
|
150
353
|
/** Update the status of a machine. */
|
|
151
354
|
export function updateMachineStatus(
|
|
152
355
|
name: string,
|
package/src/lib/types.ts
CHANGED
|
@@ -55,13 +55,14 @@ export type NetworkMode =
|
|
|
55
55
|
* - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
|
|
56
56
|
* broadcasts) → `{type:"socket-connect", port}` against a host TCP server
|
|
57
57
|
* (rootless, cross-platform; recipe in `docs/mndp.md`).
|
|
58
|
-
* - **L2 link between two VMs** → `{type:"socket", name}
|
|
59
|
-
* `
|
|
58
|
+
* - **L2 link between two VMs** → `{type:"socket", name}`, a named link whose
|
|
59
|
+
* transport (`dgram`, `listen-connect`, `mcast`) is fixed when the name is created;
|
|
60
|
+
* or `socket-listen`/`socket-connect`/`socket-mcast` for a raw netdev.
|
|
60
61
|
* - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
|
|
61
62
|
* (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
|
|
62
63
|
*
|
|
63
64
|
* Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
|
|
64
|
-
* needs no extra NIC and no forward — see {@link ChrInstance.
|
|
65
|
+
* needs no extra NIC and no forward — see {@link ChrInstance.hostGatewayIp}.
|
|
65
66
|
*/
|
|
66
67
|
export type NetworkSpecifier =
|
|
67
68
|
/** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
|
|
@@ -106,6 +107,12 @@ export interface NetworkConfig {
|
|
|
106
107
|
specifier: NetworkSpecifier;
|
|
107
108
|
/** QEMU netdev id (net0, net1, ...). */
|
|
108
109
|
id: string;
|
|
110
|
+
/** Locally-administered MAC, assigned at creation and persisted so it survives
|
|
111
|
+
* restart. Without it QEMU hands every guest the same default sequence and two
|
|
112
|
+
* machines on one L2 segment collide — see `deriveMac` in `network.ts` (#154).
|
|
113
|
+
* Never reassigned: changing it on a booted machine orphans RouterOS's interface
|
|
114
|
+
* identity. Absent on machines created before #154, which keep QEMU's defaults. */
|
|
115
|
+
mac?: string;
|
|
109
116
|
/** Resolved QEMU args (populated at launch time, not persisted). */
|
|
110
117
|
resolved?: ResolvedNetwork;
|
|
111
118
|
}
|
|
@@ -170,6 +177,11 @@ export const SERVICE_NAMES = Object.keys(SERVICE_PORTS) as ServiceName[];
|
|
|
170
177
|
export const PORTS_PER_BLOCK = 10;
|
|
171
178
|
export const DEFAULT_PORT_BASE = 9100;
|
|
172
179
|
|
|
180
|
+
/** QEMU user-mode (SLIRP) gateway — the host's address as seen from inside a guest.
|
|
181
|
+
* Fixed by QEMU's built-in 10.0.2.0/24 user network; surfaced on a running instance
|
|
182
|
+
* as {@link ChrInstance.hostGatewayIp}. */
|
|
183
|
+
export const HOST_GATEWAY_IP = "10.0.2.2";
|
|
184
|
+
|
|
173
185
|
/**
|
|
174
186
|
* TCP endpoint for QEMU IPC channels on Windows.
|
|
175
187
|
* Used instead of named pipes when QEMU's Winsock bind() can't handle \\.\pipe\ paths.
|
|
@@ -222,7 +234,8 @@ export interface MachineConfig {
|
|
|
222
234
|
deviceMode?: DeviceModeOptions;
|
|
223
235
|
user?: { name: string; password: string };
|
|
224
236
|
disableAdmin?: boolean;
|
|
225
|
-
/**
|
|
237
|
+
/** Managed-login intent, recorded at `add()`/`start()`. Same rule as
|
|
238
|
+
* {@link StartOptions.secureLogin}: only an explicit `true` creates the account. */
|
|
226
239
|
secureLogin?: boolean;
|
|
227
240
|
portBase: number;
|
|
228
241
|
excludePorts: ServiceName[];
|
|
@@ -238,6 +251,34 @@ export interface MachineConfig {
|
|
|
238
251
|
bootDiskFormat?: BootDiskFormat;
|
|
239
252
|
}
|
|
240
253
|
|
|
254
|
+
/** One provisioning step, as recorded on {@link ProvisioningRecord.steps}. */
|
|
255
|
+
export type ProvisioningStep =
|
|
256
|
+
| "packages"
|
|
257
|
+
| "deviceMode"
|
|
258
|
+
| "license"
|
|
259
|
+
| "user"
|
|
260
|
+
| "disableAdmin"
|
|
261
|
+
| "secureLogin";
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What provisioning actually landed on the *current* guest disk (#176).
|
|
265
|
+
*
|
|
266
|
+
* Absent means the guest has never been provisioned. `clean()` clears it along with
|
|
267
|
+
* the disk, which is what reopens the provisioning window — `lastStartedAt` cannot
|
|
268
|
+
* carry this, because it records that QEMU was launched, not that the guest was
|
|
269
|
+
* configured, and the two come apart at every `clean()`.
|
|
270
|
+
*/
|
|
271
|
+
export interface ProvisioningRecord {
|
|
272
|
+
/** ISO timestamp of the last time quickchr applied provisioning to this disk —
|
|
273
|
+
* a first-boot run, or a post-boot step such as `setDeviceMode()`. */
|
|
274
|
+
at: string;
|
|
275
|
+
/** Steps that have run on this disk, accumulated: a first-boot run stamps the set
|
|
276
|
+
* it applied, and a post-boot step adds itself. Not a full inventory of the guest —
|
|
277
|
+
* a step absent here was not applied *by quickchr*, which is a weaker claim than
|
|
278
|
+
* "not present". */
|
|
279
|
+
steps: ProvisioningStep[];
|
|
280
|
+
}
|
|
281
|
+
|
|
241
282
|
/**
|
|
242
283
|
* Facts about the SSH keypair quickchr generated and installed for its managed
|
|
243
284
|
* user during provisioning. Persisted on {@link MachineState} so consumers (the
|
|
@@ -260,7 +301,14 @@ export interface ManagedSshKey {
|
|
|
260
301
|
|
|
261
302
|
export interface MachineState extends MachineConfig {
|
|
262
303
|
createdAt: string;
|
|
304
|
+
/** Last QEMU spawn. Stamped before the guest is known to have booted, so it says
|
|
305
|
+
* nothing about whether the guest was configured — see {@link provisioning}.
|
|
306
|
+
* Cleared by `clean()`, which replaces the disk that boot belonged to. */
|
|
263
307
|
lastStartedAt?: string;
|
|
308
|
+
/** Provisioning that landed on the current disk, if any (#176). */
|
|
309
|
+
provisioning?: ProvisioningRecord;
|
|
310
|
+
/** Last `clean()`, which reset the disk to the factory image. */
|
|
311
|
+
cleanedAt?: string;
|
|
264
312
|
status: "stopped" | "running" | "error";
|
|
265
313
|
pid?: number;
|
|
266
314
|
/** Path to the machine directory. */
|
|
@@ -286,7 +334,8 @@ export interface MachineState extends MachineConfig {
|
|
|
286
334
|
* requires SCP upload + reboot cycle, typically 60–120s extra on native acceleration).
|
|
287
335
|
*/
|
|
288
336
|
export interface StartOptions {
|
|
289
|
-
/** RouterOS version to use (e.g. "7.22.1").
|
|
337
|
+
/** RouterOS version to use (e.g. "7.22.1"). Takes precedence over `channel`:
|
|
338
|
+
* passing both uses `version` and warns that `channel` was ignored.
|
|
290
339
|
* As a convenience, a channel name (e.g. "long-term") is also accepted here and
|
|
291
340
|
* resolves the same as `channel: "long-term"` — but this emits a warning, since
|
|
292
341
|
* `channel` is the self-documenting field for that case. */
|
|
@@ -303,12 +352,27 @@ export interface StartOptions {
|
|
|
303
352
|
cpu?: number;
|
|
304
353
|
/** Memory in MiB. Default: 512 (1024 for cross-arch TCG emulation). */
|
|
305
354
|
mem?: number;
|
|
306
|
-
/** Run QEMU in background (true, default) or foreground with serial console on stdio (false).
|
|
355
|
+
/** Run QEMU in background (true, default) or foreground with serial console on stdio (false).
|
|
356
|
+
* This only decides where the serial console goes — it never made `start()` return
|
|
357
|
+
* sooner. That is {@link StartOptions.wait}. */
|
|
307
358
|
background?: boolean;
|
|
359
|
+
/** Wait for the guest to become ready before resolving (true, default). `false`
|
|
360
|
+
* resolves as soon as QEMU is confirmed running — past `spawnQemu`'s early-exit
|
|
361
|
+
* check, with state and ports persisted — and leaves readiness to the caller, e.g.
|
|
362
|
+
* `centrs retrieve --quickchr <name> /system/resource --wait 5m` (#159).
|
|
363
|
+
*
|
|
364
|
+
* Provisioning runs against the booted guest, so it cannot be combined with
|
|
365
|
+
* `wait: false` — including first-boot options `add()` recorded. Both are refused
|
|
366
|
+
* (`INVALID_ARGUMENT`) rather than silently skipped, as is `background: false`,
|
|
367
|
+
* which blocks on the serial console regardless. */
|
|
368
|
+
wait?: boolean;
|
|
308
369
|
/** Extra packages to install after boot.
|
|
309
370
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
310
371
|
packages?: string[];
|
|
311
372
|
/** Install all packages from the all_packages ZIP (overrides packages[]).
|
|
373
|
+
* On the first start of an `add()`-created machine, leaving this unset keeps what
|
|
374
|
+
* `add()` recorded; an explicit `false` overrides it. The same holds for
|
|
375
|
+
* `disableAdmin` and `secureLogin`.
|
|
312
376
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
313
377
|
installAllPackages?: boolean;
|
|
314
378
|
/** Create a user after boot.
|
|
@@ -317,8 +381,12 @@ export interface StartOptions {
|
|
|
317
381
|
/** Disable the default admin account after boot.
|
|
318
382
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
319
383
|
disableAdmin?: boolean;
|
|
320
|
-
/** Create a 'quickchr' managed account with a generated password.
|
|
321
|
-
*
|
|
384
|
+
/** Create a 'quickchr' managed account with a generated password. **Only an explicit
|
|
385
|
+
* `true` does this.** Unset and `false` are the same: the machine keeps the factory
|
|
386
|
+
* `admin` login with an empty password (exec still works). The CLI resolves the
|
|
387
|
+
* `secure-login` setting into this field, so it can be `true` without the flag.
|
|
388
|
+
* This is the one statement of the rule — `MachineState.secureLogin` and
|
|
389
|
+
* `provision()` point here.
|
|
322
390
|
* Enabling the managed login path is quickchr provisioning, so it is validated/tested
|
|
323
391
|
* on RouterOS 7.20.8+ only. */
|
|
324
392
|
secureLogin?: boolean;
|
|
@@ -341,7 +409,7 @@ export interface StartOptions {
|
|
|
341
409
|
* {@link lookupGuestPort} resolve guest port + proto for common service names.
|
|
342
410
|
*
|
|
343
411
|
* For *receiving* guest-originated UDP (no forward needed), see
|
|
344
|
-
* {@link ChrInstance.
|
|
412
|
+
* {@link ChrInstance.hostGatewayIp} and `docs/networking-recipes.md`.
|
|
345
413
|
* @example
|
|
346
414
|
* extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
|
|
347
415
|
* @example
|
|
@@ -548,6 +616,20 @@ export interface ChrLoadSample {
|
|
|
548
616
|
memUsedMb: number;
|
|
549
617
|
}
|
|
550
618
|
|
|
619
|
+
/** Options for an explicit guest shutdown. Library values do not read environment settings. */
|
|
620
|
+
export interface ShutdownOptions {
|
|
621
|
+
/** Guest request + exit-wait budget in milliseconds (default 10,000).
|
|
622
|
+
* Lock acquisition and hard-stop escalation have separate bounded waits. */
|
|
623
|
+
timeoutMs?: number;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export interface ShutdownResult {
|
|
627
|
+
/** Observed guest exit is not a guarantee of application/storage durability. */
|
|
628
|
+
outcome: "guest-exited" | "forced" | "already-stopped";
|
|
629
|
+
/** Request failure can accompany successful guest exit (HTTP 400 interrupted). */
|
|
630
|
+
requestError?: string;
|
|
631
|
+
}
|
|
632
|
+
|
|
551
633
|
/**
|
|
552
634
|
* Runtime handle for a CHR virtual machine.
|
|
553
635
|
*
|
|
@@ -556,7 +638,9 @@ export interface ChrLoadSample {
|
|
|
556
638
|
* snapshots, licensing, and lifecycle management.
|
|
557
639
|
*
|
|
558
640
|
* After `QuickCHR.start()` resolves, the instance is REST-ready — all requested
|
|
559
|
-
* provisioning (packages, license, device-mode, user creation) has completed
|
|
641
|
+
* provisioning (packages, license, device-mode, user creation) has completed —
|
|
642
|
+
* unless it was started with `wait: false`, or is a RouterOS 6 guest (no REST:
|
|
643
|
+
* `rest()` and REST `exec()` throw `REST_UNAVAILABLE`).
|
|
560
644
|
*/
|
|
561
645
|
export interface ChrInstance {
|
|
562
646
|
name: string;
|
|
@@ -591,15 +675,15 @@ export interface ChrInstance {
|
|
|
591
675
|
* `docs/networking-recipes.md`, `examples/udp-gateway/`.
|
|
592
676
|
* @example
|
|
593
677
|
* // RouterOS TZSP sniffer streaming to the host:
|
|
594
|
-
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.
|
|
678
|
+
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
|
|
595
679
|
*/
|
|
596
|
-
|
|
680
|
+
hostGatewayIp: string;
|
|
597
681
|
|
|
598
|
-
/** Check or wait for REST API
|
|
599
|
-
* After `QuickCHR.start()`
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
/** @param stats Optional out-param; each REST readiness probe is classified
|
|
682
|
+
/** Check or wait for readiness — REST, or a native-API login on RouterOS 6 (#182).
|
|
683
|
+
* After `QuickCHR.start()` the returned instance is already ready, so this returns
|
|
684
|
+
* immediately. Call it after `start({ wait: false })`, or as a safety check after a
|
|
685
|
+
* manual stop/restart. */
|
|
686
|
+
/** @param stats Optional out-param; each REST or native-API readiness probe is classified
|
|
603
687
|
* into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
|
|
604
688
|
* whether the forwarded port ever answered. */
|
|
605
689
|
waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
|
|
@@ -621,7 +705,12 @@ export interface ChrInstance {
|
|
|
621
705
|
* }, 30_000);
|
|
622
706
|
*/
|
|
623
707
|
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
708
|
+
/** Power cut: terminate QEMU with SIGTERM, then SIGKILL if needed.
|
|
709
|
+
* Pending guest configuration may be lost. Use shutdown() to request guest shutdown. */
|
|
624
710
|
stop(): Promise<void>;
|
|
711
|
+
/** Request /system/shutdown once over REST, wait for QEMU exit, then hard-stop
|
|
712
|
+
* if needed. Reports forced fallback explicitly; preserves disks and credentials. */
|
|
713
|
+
shutdown(options?: ShutdownOptions): Promise<ShutdownResult>;
|
|
625
714
|
remove(): Promise<void>;
|
|
626
715
|
clean(): Promise<void>;
|
|
627
716
|
/** Stop and permanently delete this instance (disk + state). Equivalent to stop() then remove(). */
|
|
@@ -805,6 +894,10 @@ export type ErrorCode =
|
|
|
805
894
|
| "INVALID_VERSION"
|
|
806
895
|
| "INVALID_ARCH"
|
|
807
896
|
| "INVALID_NAME"
|
|
897
|
+
// A flag or argument the CLI cannot honour as written — a removed flag name, or a
|
|
898
|
+
// value it will not accept. Distinct from INVALID_SETTING_VALUE, which is about a
|
|
899
|
+
// stored setting rather than what was typed.
|
|
900
|
+
| "INVALID_ARGUMENT"
|
|
808
901
|
| "INVALID_DISK_SIZE"
|
|
809
902
|
| "MACHINE_LOCKED"
|
|
810
903
|
| "EXEC_FAILED"
|
|
@@ -814,6 +907,13 @@ export type ErrorCode =
|
|
|
814
907
|
| "INVALID_NETWORK"
|
|
815
908
|
| "INVALID_FORWARD_SPEC"
|
|
816
909
|
| "PROVISIONING_VERSION_UNSUPPORTED"
|
|
910
|
+
// A provisioning option handed to a machine whose provisioning window has closed:
|
|
911
|
+
// the guest has booted, so quickchr cannot assume it still holds the default
|
|
912
|
+
// configuration provisioning expects. Never silently dropped (#176).
|
|
913
|
+
| "PROVISIONING_WINDOW_CLOSED"
|
|
914
|
+
// An operation that needs the REST API, on a guest that has none — RouterOS 6, a
|
|
915
|
+
// boot-only target (#182). Thrown up front instead of letting the request time out.
|
|
916
|
+
| "REST_UNAVAILABLE"
|
|
817
917
|
| "INSUFFICIENT_DISK_SPACE"
|
|
818
918
|
| "STATE_ERROR"
|
|
819
919
|
| "INVALID_SIZE_STRING"
|
package/src/lib/versions.ts
CHANGED
|
@@ -35,6 +35,11 @@ export const ROUTEROS_COMPATIBILITY_MATRIX: RouterOsCompatibilityRow[] = [
|
|
|
35
35
|
minimumVersion: "any RouterOS 7.x",
|
|
36
36
|
notes: "QEMU networking setup before or during boot.",
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
feature: "Boot / start / stop on RouterOS 6",
|
|
40
|
+
minimumVersion: "6.x (x86 only)",
|
|
41
|
+
notes: "Best-effort boot-only target for v6→v7 migration testing (#182). CLI only; readiness is a native-API login; no provisioning, no REST operations.",
|
|
42
|
+
},
|
|
38
43
|
{
|
|
39
44
|
feature: "Managed login, custom user, disable-admin",
|
|
40
45
|
minimumVersion: MIN_PROVISION_VERSION,
|
|
@@ -242,12 +247,61 @@ export function isProvisioningSupportedVersion(
|
|
|
242
247
|
return compareRouterOsVersion(version, minimumVersion) >= 0;
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
/**
|
|
251
|
+
* RouterOS 6 is a best-effort **boot-only** target (#182): quickchr downloads, boots and
|
|
252
|
+
* tracks a 6.x CHR, and does nothing else to it. It exists so a tool driving a v6→v7
|
|
253
|
+
* migration (tikoci/centrs#386) has a disposable 6.x router to test against — it is not
|
|
254
|
+
* RouterOS 6 support. CLI only: the wizard does not offer it.
|
|
255
|
+
*
|
|
256
|
+
* 6.x has no REST API, so readiness is a native-API `/login` (see `readinessProbeFor()`
|
|
257
|
+
* in qemu.ts), every provisioning step is refused, and anything that reads or writes
|
|
258
|
+
* through REST fails with `REST_UNAVAILABLE` rather than timing out.
|
|
259
|
+
*/
|
|
260
|
+
export function isRouterOs6(version: string): boolean {
|
|
261
|
+
return /^6\./.test(version);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const ROUTEROS6_BOOT_ONLY =
|
|
265
|
+
"RouterOS 6 is a best-effort boot-only target: quickchr downloads, boots and tracks it, and runs no provisioning or REST operations on it.";
|
|
266
|
+
|
|
267
|
+
/** MikroTik publishes RouterOS 6 CHR images for x86 only — `chr-6.49.23-arm64.img.zip`
|
|
268
|
+
* is a 404 (checked 2026-10-21). Refused before the download that would say so less
|
|
269
|
+
* clearly. */
|
|
270
|
+
export function assertRouterOs6Arch(version: string, arch: Arch): void {
|
|
271
|
+
if (!isRouterOs6(version) || arch === "x86") return;
|
|
272
|
+
throw new QuickCHRError(
|
|
273
|
+
"INVALID_ARCH",
|
|
274
|
+
`RouterOS ${version} has no ${arch} CHR image — MikroTik publishes RouterOS 6 CHR for x86 only. Pass --arch x86 (arch: "x86").`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Refuse an operation that needs REST on a guest that cannot have it. The message
|
|
279
|
+
* leads with "RouterOS 6: not available" so a caller scanning for the reason finds it
|
|
280
|
+
* first, and names the transport that does work. */
|
|
281
|
+
export function assertRestAvailable(machine: { name: string; version: string }, operation: string): void {
|
|
282
|
+
if (!isRouterOs6(machine.version)) return;
|
|
283
|
+
throw new QuickCHRError(
|
|
284
|
+
"REST_UNAVAILABLE",
|
|
285
|
+
`RouterOS 6: not available — ${operation} uses the REST API, which RouterOS ${machine.version} does not have. ` +
|
|
286
|
+
`${ROUTEROS6_BOOT_ONLY} Reach it over the native API instead, e.g. ` +
|
|
287
|
+
`centrs retrieve --quickchr ${machine.name} /system/resource --via native-api --no-validate ` +
|
|
288
|
+
"(centrs validates against the 7.x command tree, so a 6.x read skips it)",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
245
292
|
/** Guard provisioning operations to reduce version-dependent failure modes. */
|
|
246
293
|
export function assertProvisioningSupportedVersion(
|
|
247
294
|
version: string,
|
|
248
295
|
operation: string,
|
|
249
296
|
minimumVersion = MIN_PROVISION_VERSION,
|
|
250
297
|
): void {
|
|
298
|
+
if (isRouterOs6(version)) {
|
|
299
|
+
throw new QuickCHRError(
|
|
300
|
+
"PROVISIONING_VERSION_UNSUPPORTED",
|
|
301
|
+
`Cannot ${operation} on RouterOS ${version}. ${ROUTEROS6_BOOT_ONLY}`,
|
|
302
|
+
`Drop the provisioning options to boot ${version} as it ships, or use RouterOS ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
251
305
|
if (isProvisioningSupportedVersion(version, minimumVersion)) return;
|
|
252
306
|
|
|
253
307
|
throw new QuickCHRError(
|