@tikoci/quickchr 0.4.8 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +219 -14
- package/MANUAL.md +396 -71
- package/README.md +33 -8
- package/package.json +2 -2
- package/src/cli/flags.ts +22 -3
- package/src/cli/index.ts +461 -104
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache.ts +220 -38
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +498 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/packages.ts +66 -40
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +950 -359
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +89 -2
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +109 -23
- package/src/lib/versions.ts +54 -0
package/src/lib/storage.ts
CHANGED
|
@@ -1,41 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storage
|
|
2
|
+
* Storage admission and usage helpers for quickchr-managed files.
|
|
3
|
+
*
|
|
4
|
+
* An operation that writes is admitted when each filesystem it writes to has room for
|
|
5
|
+
* what the operation itself will write there (its estimated peak) plus the configured
|
|
6
|
+
* host reserve. When one does not, eligible cache entries are evicted first and free
|
|
7
|
+
* space is measured again; only then is the operation refused (#199).
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
|
-
import { existsSync,
|
|
10
|
+
import { existsSync, statfsSync } from "node:fs";
|
|
6
11
|
import { dirname, join } from "node:path";
|
|
12
|
+
import { type CacheEntry, countEntries, describePrune, type PruneResult, pruneCacheUnderLock, RECENT_USE_MS } from "./cache.ts";
|
|
13
|
+
import { createLease, filesystemOf, liveLeases, type StorageLease, withStorageLock } from "./storage-lease.ts";
|
|
7
14
|
import { formatDiskSize } from "./disk.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
15
|
+
import { resolveSetting } from "./settings.ts";
|
|
16
|
+
import { directorySize, getCacheDir, getDataDir, getMachinesDir } from "./state.ts";
|
|
17
|
+
import { chrImageBasename } from "./versions.ts";
|
|
18
|
+
import { type Arch, QuickCHRError } from "./types.ts";
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
const MiB = 1024 ** 2;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Peak new bytes per stage, rounded up from one host's measurements (#199, macOS x86,
|
|
24
|
+
* CHR 7.20–7.26): a 128 MiB extracted image from a ~45 MiB ZIP, a qcow2 boot disk of
|
|
25
|
+
* ~50 MiB after first boot (never more than the raw image it is converted from), and
|
|
26
|
+
* 29–52 MiB package archives that extract to about their own size. Estimates, not
|
|
27
|
+
* bounds: they are what admission charges, and the reserve absorbs the error.
|
|
28
|
+
*/
|
|
29
|
+
export const STORAGE_ESTIMATES = {
|
|
30
|
+
/** Image ZIP plus its extraction, which coexist until the ZIP is removed. */
|
|
31
|
+
imageDownload: 256 * MiB,
|
|
32
|
+
/** A raw copy or a qcow2 conversion of the image, plus first-boot writes. */
|
|
33
|
+
bootDisk: 192 * MiB,
|
|
34
|
+
/** The all-packages archive plus its extraction; both are kept. */
|
|
35
|
+
packagesDownload: 192 * MiB,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/** doctor warns while free space is under the reserve plus this much. */
|
|
39
|
+
const WARN_MARGIN_BYTES = 2 * 1024 ** 3;
|
|
13
40
|
|
|
14
41
|
export interface QuickchrStorageReport {
|
|
15
|
-
|
|
42
|
+
/** The data dir: where the cache and machines are written. */
|
|
16
43
|
path: string;
|
|
44
|
+
/** The nearest existing directory to `path`, whose filesystem is measured. */
|
|
17
45
|
statPath: string;
|
|
18
46
|
freeBytes: number;
|
|
19
47
|
quickchrBytes: number;
|
|
20
48
|
cacheBytes: number;
|
|
21
49
|
machinesBytes: number;
|
|
22
50
|
otherBytes: number;
|
|
23
|
-
|
|
51
|
+
reserveBytes: number;
|
|
24
52
|
warningFreeBytes: number;
|
|
25
53
|
status: "ok" | "warn" | "error";
|
|
26
54
|
}
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
/** One stage of an operation's writes. */
|
|
57
|
+
export interface StoragePart {
|
|
58
|
+
/** For the refusal message, e.g. "boot disk". */
|
|
59
|
+
label: string;
|
|
60
|
+
/** Estimated peak new bytes. */
|
|
61
|
+
bytes: number;
|
|
62
|
+
/** Where they are written: the part is checked on that path's filesystem. */
|
|
63
|
+
path: string;
|
|
64
|
+
/** Reserve the bytes in the operation's lease (the default). False for a stage that
|
|
65
|
+
* admits and reserves for itself when it runs, such as a download a create will
|
|
66
|
+
* trigger: checked now, so the create refuses early, but not held twice. */
|
|
67
|
+
hold?: boolean;
|
|
68
|
+
}
|
|
37
69
|
|
|
38
|
-
|
|
70
|
+
/** What an operation is about to write and read, for admission. */
|
|
71
|
+
export interface StorageNeed {
|
|
72
|
+
parts: StoragePart[];
|
|
73
|
+
/** Versions the operation reads from the cache: reclamation never evicts them. */
|
|
74
|
+
protectVersions?: string[];
|
|
75
|
+
/** Cache entries the operation reads; pinned in its lease. */
|
|
76
|
+
pins?: string[];
|
|
77
|
+
/** The cache reclamation evicts from. Default: the data dir's cache. */
|
|
78
|
+
cacheDir?: string;
|
|
39
79
|
}
|
|
40
80
|
|
|
41
81
|
function nearestExistingPath(path: string): string {
|
|
@@ -48,30 +88,6 @@ function nearestExistingPath(path: string): string {
|
|
|
48
88
|
return current;
|
|
49
89
|
}
|
|
50
90
|
|
|
51
|
-
function directorySize(path: string): number {
|
|
52
|
-
if (!existsSync(path)) return 0;
|
|
53
|
-
|
|
54
|
-
const stat = lstatSync(path);
|
|
55
|
-
if (!stat.isDirectory()) {
|
|
56
|
-
return stat.size;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
let total = 0;
|
|
60
|
-
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
61
|
-
const fullPath = join(path, entry.name);
|
|
62
|
-
if (entry.isDirectory()) {
|
|
63
|
-
total += directorySize(fullPath);
|
|
64
|
-
} else {
|
|
65
|
-
try {
|
|
66
|
-
total += lstatSync(fullPath).size;
|
|
67
|
-
} catch {
|
|
68
|
-
// Skip entries that can't be stat'd (e.g. Windows named pipes throw EACCES)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return total;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
91
|
function freeBytesForPath(path: string): number {
|
|
76
92
|
if (process.platform !== "win32") {
|
|
77
93
|
const result = Bun.spawnSync(["df", "-kP", path], {
|
|
@@ -95,15 +111,23 @@ function freeBytesForPath(path: string): number {
|
|
|
95
111
|
return fsStats.bavail * (fsStats.bsize || 4096);
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/** The free-space measurement and filesystem identity, as an object so tests can
|
|
115
|
+
* replace them (a unit test cannot mount a second filesystem). */
|
|
116
|
+
export const storageProbe = { freeBytes: freeBytesForPath, filesystem: filesystemOf };
|
|
117
|
+
|
|
118
|
+
/** The configured host reserve: the `storage-reserve` setting (default 2 GiB). */
|
|
119
|
+
export function resolveStorageReserve(): number {
|
|
120
|
+
return resolveSetting("storage-reserve").value as number;
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
export function getQuickchrStorageReport(
|
|
99
|
-
|
|
100
|
-
warningFreeBytes: number =
|
|
124
|
+
reserveBytes: number = resolveStorageReserve(),
|
|
125
|
+
warningFreeBytes: number = reserveBytes + WARN_MARGIN_BYTES,
|
|
101
126
|
): QuickchrStorageReport {
|
|
102
|
-
const storageRoot = resolveStorageRoot();
|
|
103
|
-
const statPath = nearestExistingPath(storageRoot.path);
|
|
104
|
-
const freeBytes = freeBytesForPath(statPath);
|
|
105
|
-
|
|
106
127
|
const dataDir = getDataDir();
|
|
128
|
+
const statPath = nearestExistingPath(dataDir);
|
|
129
|
+
const freeBytes = storageProbe.freeBytes(statPath);
|
|
130
|
+
|
|
107
131
|
const cacheBytes = directorySize(getCacheDir());
|
|
108
132
|
const machinesBytes = directorySize(getMachinesDir());
|
|
109
133
|
const dataDirBytes = directorySize(dataDir);
|
|
@@ -111,22 +135,21 @@ export function getQuickchrStorageReport(
|
|
|
111
135
|
const otherBytes = Math.max(0, dataDirBytes - cacheBytes - machinesBytes);
|
|
112
136
|
|
|
113
137
|
let status: QuickchrStorageReport["status"] = "ok";
|
|
114
|
-
if (freeBytes <
|
|
138
|
+
if (freeBytes < reserveBytes) {
|
|
115
139
|
status = "error";
|
|
116
140
|
} else if (freeBytes < warningFreeBytes) {
|
|
117
141
|
status = "warn";
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
return {
|
|
121
|
-
|
|
122
|
-
path: storageRoot.path,
|
|
145
|
+
path: dataDir,
|
|
123
146
|
statPath,
|
|
124
147
|
freeBytes,
|
|
125
148
|
quickchrBytes,
|
|
126
149
|
cacheBytes,
|
|
127
150
|
machinesBytes,
|
|
128
151
|
otherBytes,
|
|
129
|
-
|
|
152
|
+
reserveBytes,
|
|
130
153
|
warningFreeBytes,
|
|
131
154
|
status,
|
|
132
155
|
};
|
|
@@ -143,21 +166,155 @@ export function formatQuickchrUsage(report: QuickchrStorageReport): string {
|
|
|
143
166
|
return `quickchr uses ${formatDiskSize(report.quickchrBytes)} (${parts.join(", ")})`;
|
|
144
167
|
}
|
|
145
168
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
);
|
|
169
|
+
/** What creating a machine will write: its boot disk, plus the image and package
|
|
170
|
+
* downloads it still needs. Extra disks are sparse qcow2 and start near zero.
|
|
171
|
+
* Only the boot disk is held in the create's lease: each download admits and
|
|
172
|
+
* reserves for itself when it runs, so holding it here would count it twice. */
|
|
173
|
+
export function estimateMachineCreation(opts: { version: string; arch: Arch; packages: boolean }): StorageNeed {
|
|
174
|
+
const cache = getCacheDir();
|
|
175
|
+
const image = chrImageBasename(opts.version, opts.arch);
|
|
176
|
+
const parts: StoragePart[] = [{ label: "boot disk", bytes: STORAGE_ESTIMATES.bootDisk, path: getMachinesDir() }];
|
|
177
|
+
if (!existsSync(join(cache, `${image}.img`))) {
|
|
178
|
+
parts.push({ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache, hold: false });
|
|
179
|
+
}
|
|
180
|
+
if (opts.packages && !existsSync(join(cache, `packages-${opts.arch}-${opts.version}`))) {
|
|
181
|
+
parts.push({ label: "packages download", bytes: STORAGE_ESTIMATES.packagesDownload, path: cache, hold: false });
|
|
182
|
+
}
|
|
183
|
+
return { parts, protectVersions: [opts.version], pins: [`${image}.img`, `${image}.img.zip`] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface FilesystemGroup {
|
|
187
|
+
dev: number;
|
|
188
|
+
statPath: string;
|
|
189
|
+
parts: StoragePart[];
|
|
190
|
+
bytes: number;
|
|
191
|
+
}
|
|
154
192
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
193
|
+
function groupByFilesystem(parts: StoragePart[]): FilesystemGroup[] {
|
|
194
|
+
const groups = new Map<number, FilesystemGroup>();
|
|
195
|
+
for (const part of parts.length > 0 ? parts : [{ label: "", bytes: 0, path: getDataDir() }]) {
|
|
196
|
+
const { dev, statPath } = storageProbe.filesystem(part.path);
|
|
197
|
+
const group = groups.get(dev) ?? { dev, statPath, parts: [], bytes: 0 };
|
|
198
|
+
if (part.label) group.parts.push(part);
|
|
199
|
+
group.bytes += part.bytes;
|
|
200
|
+
groups.set(dev, group);
|
|
160
201
|
}
|
|
202
|
+
return [...groups.values()];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Admit an operation, or throw `INSUFFICIENT_DISK_SPACE`. Returns a lease the caller
|
|
207
|
+
* releases once its writes are done (see storage-lease.ts).
|
|
208
|
+
*
|
|
209
|
+
* Each filesystem the operation writes to is checked on its own: free space, less
|
|
210
|
+
* what other in-flight operations have reserved there, must cover that filesystem's
|
|
211
|
+
* parts plus the reserve. When it does not, and the cache being reclaimed lives on that
|
|
212
|
+
* filesystem, eligible entries are evicted (oldest version first; never in-use, pinned,
|
|
213
|
+
* protected or recently used ones) and free space is measured again — repeatedly,
|
|
214
|
+
* because logical sizes are no proof of room (sparse files, APFS clones), until the
|
|
215
|
+
* measurement admits or nothing more can be evicted. When everything eligible could
|
|
216
|
+
* not cover the shortage, nothing is evicted.
|
|
217
|
+
*
|
|
218
|
+
* The check and the lease are made under the storage lock, so two creators cannot
|
|
219
|
+
* spend the same free bytes, and an eviction cannot remove what this operation pins.
|
|
220
|
+
*/
|
|
221
|
+
export function admitStorage(
|
|
222
|
+
action: string,
|
|
223
|
+
need: StorageNeed = { parts: [] },
|
|
224
|
+
opts: { reclaim?: boolean; logger?: (message: string) => void } = {},
|
|
225
|
+
): StorageLease {
|
|
226
|
+
return withStorageLock(() => {
|
|
227
|
+
const reserveBytes = resolveStorageReserve();
|
|
228
|
+
const leases = liveLeases();
|
|
229
|
+
const pins = new Set([...leases.flatMap((lease) => lease.pins), ...(need.pins ?? [])]);
|
|
230
|
+
const cacheDir = need.cacheDir ?? getCacheDir();
|
|
231
|
+
const cacheDev = storageProbe.filesystem(cacheDir).dev;
|
|
161
232
|
|
|
162
|
-
|
|
233
|
+
for (const group of groupByFilesystem(need.parts)) {
|
|
234
|
+
const reservedByOthers = leases
|
|
235
|
+
.flatMap((lease) => lease.reservations)
|
|
236
|
+
.filter((r) => r.dev === group.dev)
|
|
237
|
+
.reduce((sum, r) => sum + r.bytes, 0);
|
|
238
|
+
const measure = () => storageProbe.freeBytes(group.statPath) - reservedByOthers;
|
|
239
|
+
const required = group.bytes + reserveBytes;
|
|
240
|
+
let free = measure();
|
|
241
|
+
if (free >= required) continue;
|
|
242
|
+
|
|
243
|
+
const reclaim = opts.reclaim !== false && cacheDev === group.dev;
|
|
244
|
+
const evicted: CacheEntry[] = [];
|
|
245
|
+
const failed: PruneResult["failed"] = [];
|
|
246
|
+
let kept: PruneResult | undefined;
|
|
247
|
+
let wouldNotCover = false;
|
|
248
|
+
if (reclaim) {
|
|
249
|
+
const policy = { cacheDir, protectVersions: need.protectVersions, skipRecentMs: RECENT_USE_MS };
|
|
250
|
+
// Evicting cannot close a gap larger than everything eligible: refuse with the
|
|
251
|
+
// cache intact rather than empty it for nothing.
|
|
252
|
+
kept = pruneCacheUnderLock({ ...policy, reclaimBytes: required - free, dryRun: true }, pins);
|
|
253
|
+
wouldNotCover = kept.freedBytes < required - free;
|
|
254
|
+
while (!wouldNotCover && free < required) {
|
|
255
|
+
const pass = pruneCacheUnderLock({
|
|
256
|
+
...policy,
|
|
257
|
+
reclaimBytes: required - free,
|
|
258
|
+
skipEntries: failed.map((f) => f.entry.basename),
|
|
259
|
+
}, pins);
|
|
260
|
+
for (const line of describePrune(pass)) opts.logger?.(line);
|
|
261
|
+
evicted.push(...pass.evicted);
|
|
262
|
+
failed.push(...pass.failed);
|
|
263
|
+
kept = pass;
|
|
264
|
+
free = measure();
|
|
265
|
+
if (pass.evicted.length === 0) break;
|
|
266
|
+
}
|
|
267
|
+
if (free >= required) continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
throw insufficientSpace(action, group, { free, required, reserveBytes, reservedByOthers, reclaimed: kept && { evicted, failed, kept, wouldNotCover } });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const reservations = groupByFilesystem(need.parts.filter((part) => part.hold !== false))
|
|
274
|
+
.filter((group) => group.bytes > 0)
|
|
275
|
+
.map((group) => ({ dev: group.dev, bytes: group.bytes }));
|
|
276
|
+
if (reservations.length === 0 && (need.pins ?? []).length === 0) return { record: { pid: process.pid, at: "", action, pins: [], reservations: [] }, release() {} };
|
|
277
|
+
return createLease(action, need.pins ?? [], reservations);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function insufficientSpace(
|
|
282
|
+
action: string,
|
|
283
|
+
group: FilesystemGroup,
|
|
284
|
+
facts: {
|
|
285
|
+
free: number;
|
|
286
|
+
required: number;
|
|
287
|
+
reserveBytes: number;
|
|
288
|
+
reservedByOthers: number;
|
|
289
|
+
reclaimed?: { evicted: CacheEntry[]; failed: PruneResult["failed"]; kept: PruneResult; wouldNotCover: boolean };
|
|
290
|
+
},
|
|
291
|
+
): QuickCHRError {
|
|
292
|
+
const detail = group.parts.map((part) => `${part.label} ${formatDiskSize(part.bytes)}`).join(" + ");
|
|
293
|
+
const needs = detail ? `${detail} + ${formatDiskSize(facts.reserveBytes)} reserve` : `${formatDiskSize(facts.reserveBytes)} reserve`;
|
|
294
|
+
const available = facts.reservedByOthers > 0
|
|
295
|
+
? `${formatDiskSize(facts.free)} available after ${formatDiskSize(facts.reservedByOthers)} held by other operations in progress`
|
|
296
|
+
: `${formatDiskSize(facts.free)} available`;
|
|
297
|
+
const sentences = [
|
|
298
|
+
`Not enough free space to ${action} in ${group.statPath}: ${available}, need ${formatDiskSize(facts.required)} (${needs}), ${formatDiskSize(facts.required - facts.free)} short.`,
|
|
299
|
+
];
|
|
300
|
+
const r = facts.reclaimed;
|
|
301
|
+
if (r) {
|
|
302
|
+
const freed = r.evicted.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
303
|
+
let line = r.wouldNotCover
|
|
304
|
+
? r.kept.evicted.length > 0
|
|
305
|
+
? `The cache could spare only ${formatDiskSize(r.kept.freedBytes)} (${countEntries(r.kept.evicted.length)}), not enough, so nothing was evicted.`
|
|
306
|
+
: "Nothing in the cache could be evicted."
|
|
307
|
+
: `Evicted ${countEntries(r.evicted.length)} from the cache (${formatDiskSize(freed)}) first.`;
|
|
308
|
+
if (r.failed.length > 0) line += ` ${r.failed.length} delete(s) failed.`;
|
|
309
|
+
const keptBytes = r.kept.kept
|
|
310
|
+
.filter((e) => r.kept.reasons[e.basename] !== "kept")
|
|
311
|
+
.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
312
|
+
if (keptBytes > 0) line += ` ${formatDiskSize(keptBytes)} is kept: in use by a machine or an operation in progress, needed by this operation, or used in the last ${RECENT_USE_MS / 60_000} minutes.`;
|
|
313
|
+
sentences.push(line);
|
|
314
|
+
}
|
|
315
|
+
return new QuickCHRError(
|
|
316
|
+
"INSUFFICIENT_DISK_SPACE",
|
|
317
|
+
sentences.join(" "),
|
|
318
|
+
"Free space on that filesystem, remove machines you no longer need, or lower the reserve with 'quickchr settings set storage-reserve <size>' (QUICKCHR_STORAGE_RESERVE).",
|
|
319
|
+
);
|
|
163
320
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -234,7 +234,8 @@ export interface MachineConfig {
|
|
|
234
234
|
deviceMode?: DeviceModeOptions;
|
|
235
235
|
user?: { name: string; password: string };
|
|
236
236
|
disableAdmin?: boolean;
|
|
237
|
-
/**
|
|
237
|
+
/** Managed-login intent, recorded at `add()`/`start()`. Same rule as
|
|
238
|
+
* {@link StartOptions.secureLogin}: only an explicit `true` creates the account. */
|
|
238
239
|
secureLogin?: boolean;
|
|
239
240
|
portBase: number;
|
|
240
241
|
excludePorts: ServiceName[];
|
|
@@ -250,6 +251,34 @@ export interface MachineConfig {
|
|
|
250
251
|
bootDiskFormat?: BootDiskFormat;
|
|
251
252
|
}
|
|
252
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
|
+
|
|
253
282
|
/**
|
|
254
283
|
* Facts about the SSH keypair quickchr generated and installed for its managed
|
|
255
284
|
* user during provisioning. Persisted on {@link MachineState} so consumers (the
|
|
@@ -272,7 +301,14 @@ export interface ManagedSshKey {
|
|
|
272
301
|
|
|
273
302
|
export interface MachineState extends MachineConfig {
|
|
274
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. */
|
|
275
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;
|
|
276
312
|
status: "stopped" | "running" | "error";
|
|
277
313
|
pid?: number;
|
|
278
314
|
/** Path to the machine directory. */
|
|
@@ -298,7 +334,8 @@ export interface MachineState extends MachineConfig {
|
|
|
298
334
|
* requires SCP upload + reboot cycle, typically 60–120s extra on native acceleration).
|
|
299
335
|
*/
|
|
300
336
|
export interface StartOptions {
|
|
301
|
-
/** 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.
|
|
302
339
|
* As a convenience, a channel name (e.g. "long-term") is also accepted here and
|
|
303
340
|
* resolves the same as `channel: "long-term"` — but this emits a warning, since
|
|
304
341
|
* `channel` is the self-documenting field for that case. */
|
|
@@ -315,12 +352,27 @@ export interface StartOptions {
|
|
|
315
352
|
cpu?: number;
|
|
316
353
|
/** Memory in MiB. Default: 512 (1024 for cross-arch TCG emulation). */
|
|
317
354
|
mem?: number;
|
|
318
|
-
/** 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}. */
|
|
319
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;
|
|
320
369
|
/** Extra packages to install after boot.
|
|
321
370
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
322
371
|
packages?: string[];
|
|
323
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`.
|
|
324
376
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
325
377
|
installAllPackages?: boolean;
|
|
326
378
|
/** Create a user after boot.
|
|
@@ -329,8 +381,12 @@ export interface StartOptions {
|
|
|
329
381
|
/** Disable the default admin account after boot.
|
|
330
382
|
* This is quickchr provisioning, so it is validated/tested on RouterOS 7.20.8+ only. */
|
|
331
383
|
disableAdmin?: boolean;
|
|
332
|
-
/** Create a 'quickchr' managed account with a generated password.
|
|
333
|
-
*
|
|
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.
|
|
334
390
|
* Enabling the managed login path is quickchr provisioning, so it is validated/tested
|
|
335
391
|
* on RouterOS 7.20.8+ only. */
|
|
336
392
|
secureLogin?: boolean;
|
|
@@ -376,7 +432,8 @@ export interface StartOptions {
|
|
|
376
432
|
* networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
|
|
377
433
|
*/
|
|
378
434
|
networks?: NetworkSpecifier[];
|
|
379
|
-
/**
|
|
435
|
+
/** @deprecated Ignored — nothing ever read it, so it never installed anything (#193).
|
|
436
|
+
* `quickchr doctor` lists missing host dependencies. To be removed in a later minor. */
|
|
380
437
|
installDeps?: boolean;
|
|
381
438
|
/** Print QEMU command and config without actually starting the VM. */
|
|
382
439
|
dryRun?: boolean;
|
|
@@ -560,6 +617,20 @@ export interface ChrLoadSample {
|
|
|
560
617
|
memUsedMb: number;
|
|
561
618
|
}
|
|
562
619
|
|
|
620
|
+
/** Options for an explicit guest shutdown. Library values do not read environment settings. */
|
|
621
|
+
export interface ShutdownOptions {
|
|
622
|
+
/** Guest request + exit-wait budget in milliseconds (default 10,000).
|
|
623
|
+
* Lock acquisition and hard-stop escalation have separate bounded waits. */
|
|
624
|
+
timeoutMs?: number;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export interface ShutdownResult {
|
|
628
|
+
/** Observed guest exit is not a guarantee of application/storage durability. */
|
|
629
|
+
outcome: "guest-exited" | "forced" | "already-stopped";
|
|
630
|
+
/** Request failure can accompany successful guest exit (HTTP 400 interrupted). */
|
|
631
|
+
requestError?: string;
|
|
632
|
+
}
|
|
633
|
+
|
|
563
634
|
/**
|
|
564
635
|
* Runtime handle for a CHR virtual machine.
|
|
565
636
|
*
|
|
@@ -568,7 +639,9 @@ export interface ChrLoadSample {
|
|
|
568
639
|
* snapshots, licensing, and lifecycle management.
|
|
569
640
|
*
|
|
570
641
|
* After `QuickCHR.start()` resolves, the instance is REST-ready — all requested
|
|
571
|
-
* provisioning (packages, license, device-mode, user creation) has completed
|
|
642
|
+
* provisioning (packages, license, device-mode, user creation) has completed —
|
|
643
|
+
* unless it was started with `wait: false`, or is a RouterOS 6 guest (no REST:
|
|
644
|
+
* `rest()` and REST `exec()` throw `REST_UNAVAILABLE`).
|
|
572
645
|
*/
|
|
573
646
|
export interface ChrInstance {
|
|
574
647
|
name: string;
|
|
@@ -606,20 +679,12 @@ export interface ChrInstance {
|
|
|
606
679
|
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
|
|
607
680
|
*/
|
|
608
681
|
hostGatewayIp: string;
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
|
|
615
|
-
* the next minor. Migration is a rename at the call site — nothing else changes. */
|
|
616
|
-
tzspGatewayIp: string;
|
|
617
|
-
|
|
618
|
-
/** Check or wait for REST API readiness.
|
|
619
|
-
* After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
|
|
620
|
-
* the returned instance is already REST-reachable — this returns immediately.
|
|
621
|
-
* Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
|
|
622
|
-
/** @param stats Optional out-param; each REST readiness probe is classified
|
|
682
|
+
|
|
683
|
+
/** Check or wait for readiness — REST, or a native-API login on RouterOS 6 (#182).
|
|
684
|
+
* After `QuickCHR.start()` the returned instance is already ready, so this returns
|
|
685
|
+
* immediately. Call it after `start({ wait: false })`, or as a safety check after a
|
|
686
|
+
* manual stop/restart. */
|
|
687
|
+
/** @param stats Optional out-param; each REST or native-API readiness probe is classified
|
|
623
688
|
* into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
|
|
624
689
|
* whether the forwarded port ever answered. */
|
|
625
690
|
waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
|
|
@@ -641,7 +706,12 @@ export interface ChrInstance {
|
|
|
641
706
|
* }, 30_000);
|
|
642
707
|
*/
|
|
643
708
|
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
709
|
+
/** Power cut: terminate QEMU with SIGTERM, then SIGKILL if needed.
|
|
710
|
+
* Pending guest configuration may be lost. Use shutdown() to request guest shutdown. */
|
|
644
711
|
stop(): Promise<void>;
|
|
712
|
+
/** Request /system/shutdown once over REST, wait for QEMU exit, then hard-stop
|
|
713
|
+
* if needed. Reports forced fallback explicitly; preserves disks and credentials. */
|
|
714
|
+
shutdown(options?: ShutdownOptions): Promise<ShutdownResult>;
|
|
645
715
|
remove(): Promise<void>;
|
|
646
716
|
clean(): Promise<void>;
|
|
647
717
|
/** Stop and permanently delete this instance (disk + state). Equivalent to stop() then remove(). */
|
|
@@ -671,7 +741,8 @@ export interface ChrInstance {
|
|
|
671
741
|
* **Soft errors.** Some RouterOS commands return HTTP 200 with an error
|
|
672
742
|
* string in `output` (e.g. `/dude/agent/add` may resolve OK with
|
|
673
743
|
* `"doAdd Agent not implemented"`). `exec()` does not parse output for these
|
|
674
|
-
* patterns — inspect `result.output` if you need to assert success.
|
|
744
|
+
* patterns — inspect `result.output` if you need to assert success. For a
|
|
745
|
+
* pass/fail result, use `centrs execute --quickchr <name> --yes <command>`.
|
|
675
746
|
*/
|
|
676
747
|
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
677
748
|
/** Apply or renew a CHR trial license.
|
|
@@ -838,6 +909,13 @@ export type ErrorCode =
|
|
|
838
909
|
| "INVALID_NETWORK"
|
|
839
910
|
| "INVALID_FORWARD_SPEC"
|
|
840
911
|
| "PROVISIONING_VERSION_UNSUPPORTED"
|
|
912
|
+
// A provisioning option handed to a machine whose provisioning window has closed:
|
|
913
|
+
// the guest has booted, so quickchr cannot assume it still holds the default
|
|
914
|
+
// configuration provisioning expects. Never silently dropped (#176).
|
|
915
|
+
| "PROVISIONING_WINDOW_CLOSED"
|
|
916
|
+
// An operation that needs the REST API, on a guest that has none — RouterOS 6, a
|
|
917
|
+
// boot-only target (#182). Thrown up front instead of letting the request time out.
|
|
918
|
+
| "REST_UNAVAILABLE"
|
|
841
919
|
| "INSUFFICIENT_DISK_SPACE"
|
|
842
920
|
| "STATE_ERROR"
|
|
843
921
|
| "INVALID_SIZE_STRING"
|
|
@@ -873,7 +951,15 @@ export interface ExecOptions {
|
|
|
873
951
|
timeout?: number;
|
|
874
952
|
}
|
|
875
953
|
|
|
876
|
-
/** Result of a ChrInstance.exec() call.
|
|
954
|
+
/** Result of a ChrInstance.exec() call.
|
|
955
|
+
*
|
|
956
|
+
* A resolved `exec()` means the command was **delivered**, not that RouterOS
|
|
957
|
+
* accepted it. `/rest/execute` answers HTTP 200 with the rejection text (e.g.
|
|
958
|
+
* `failure: invalid user name`) in the same envelope as a success, so there is no
|
|
959
|
+
* failure field here, and quickchr does not pattern-match RouterOS error text
|
|
960
|
+
* (#142). For a pass/fail result, run the command through
|
|
961
|
+
* `centrs execute --quickchr <name> --yes <command>`, which validates it first and
|
|
962
|
+
* exits non-zero on a rejection (`--yes` confirms a write command outside a TTY). */
|
|
877
963
|
export interface ExecResult {
|
|
878
964
|
/** Command output as a string (or parsed JSON when output="json"). */
|
|
879
965
|
output: 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(
|