@tikoci/quickchr 0.5.0 → 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 +57 -7
- package/MANUAL.md +124 -26
- package/README.md +20 -2
- package/package.json +2 -2
- package/src/cli/flags.ts +7 -2
- package/src/cli/index.ts +59 -31
- package/src/lib/cache.ts +220 -38
- 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 +2 -1
- package/src/lib/packages.ts +66 -40
- package/src/lib/quickchr.ts +83 -53
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/state.ts +26 -1
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +13 -3
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
|
@@ -432,7 +432,8 @@ export interface StartOptions {
|
|
|
432
432
|
* networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
|
|
433
433
|
*/
|
|
434
434
|
networks?: NetworkSpecifier[];
|
|
435
|
-
/**
|
|
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. */
|
|
436
437
|
installDeps?: boolean;
|
|
437
438
|
/** Print QEMU command and config without actually starting the VM. */
|
|
438
439
|
dryRun?: boolean;
|
|
@@ -740,7 +741,8 @@ export interface ChrInstance {
|
|
|
740
741
|
* **Soft errors.** Some RouterOS commands return HTTP 200 with an error
|
|
741
742
|
* string in `output` (e.g. `/dude/agent/add` may resolve OK with
|
|
742
743
|
* `"doAdd Agent not implemented"`). `exec()` does not parse output for these
|
|
743
|
-
* 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>`.
|
|
744
746
|
*/
|
|
745
747
|
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
746
748
|
/** Apply or renew a CHR trial license.
|
|
@@ -949,7 +951,15 @@ export interface ExecOptions {
|
|
|
949
951
|
timeout?: number;
|
|
950
952
|
}
|
|
951
953
|
|
|
952
|
-
/** 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). */
|
|
953
963
|
export interface ExecResult {
|
|
954
964
|
/** Command output as a string (or parsed JSON when output="json"). */
|
|
955
965
|
output: string;
|