@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/cli/index.ts
CHANGED
|
@@ -1390,7 +1390,13 @@ function printReadinessTip(name: string, version: string): void {
|
|
|
1390
1390
|
tip(`wait for readiness with: centrs retrieve --quickchr ${name} /system/resource${via} --wait 5m`);
|
|
1391
1391
|
}
|
|
1392
1392
|
|
|
1393
|
-
|
|
1393
|
+
/** Single-target start's status word: a machine this call created says so (#194). */
|
|
1394
|
+
function startStatusLine(created: boolean, noWait: boolean): string {
|
|
1395
|
+
if (noWait) return `${created ? "created" : "launched"} — not waiting for RouterOS (--no-wait)`;
|
|
1396
|
+
return created ? "created and started" : "started";
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
export async function cmdStart(argv: string[]) {
|
|
1394
1400
|
const { flags, positional } = parseFlags(argv);
|
|
1395
1401
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1396
1402
|
rejectUnknownFlags(flags, START_FLAGS, "start");
|
|
@@ -1474,7 +1480,6 @@ async function cmdStart(argv: string[]) {
|
|
|
1474
1480
|
bootSize: flag(flags, "boot-size"),
|
|
1475
1481
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
1476
1482
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
1477
|
-
installDeps: flagBool(flags, "install-deps"),
|
|
1478
1483
|
dryRun: flagBool(flags, "dry-run"),
|
|
1479
1484
|
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1480
1485
|
};
|
|
@@ -1505,10 +1510,10 @@ async function cmdStart(argv: string[]) {
|
|
|
1505
1510
|
}
|
|
1506
1511
|
|
|
1507
1512
|
opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
|
|
1513
|
+
// No name, or a name no machine has: this call creates the machine (#194).
|
|
1514
|
+
const target = opts.name ? QuickCHR.get(opts.name) : null;
|
|
1508
1515
|
{
|
|
1509
|
-
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1510
1516
|
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1511
|
-
const target = opts.name ? QuickCHR.get(opts.name) : null;
|
|
1512
1517
|
// No machine yet, or one that was added and never booted: still a first boot,
|
|
1513
1518
|
// so the setting applies. Only a closed window suppresses it — otherwise a
|
|
1514
1519
|
// plain `quickchr start <booted-name>` would ask to provision a booted guest
|
|
@@ -1573,7 +1578,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1573
1578
|
} else {
|
|
1574
1579
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1575
1580
|
const v6 = isRouterOs6(instance.state.version);
|
|
1576
|
-
console.log(`${statusIcon("running")} ${bold(instance.name)} ${opts.wait === false
|
|
1581
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${startStatusLine(!target, opts.wait === false)}`);
|
|
1577
1582
|
console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
|
|
1578
1583
|
console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
|
|
1579
1584
|
console.log(` Ports: ${formatPorts(instance.state.ports)}`);
|
|
@@ -2236,7 +2241,7 @@ async function cmdDisk(argv: string[]) {
|
|
|
2236
2241
|
|
|
2237
2242
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2238
2243
|
const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
|
|
2239
|
-
const { getDiskInfo } = await import("../lib/disk.ts");
|
|
2244
|
+
const { bootDiskFormatOf, bootDiskPath, getDiskInfo } = await import("../lib/disk.ts");
|
|
2240
2245
|
const { join } = await import("node:path");
|
|
2241
2246
|
const { existsSync } = await import("node:fs");
|
|
2242
2247
|
const { findQemuImg } = await import("../lib/platform.ts");
|
|
@@ -2259,10 +2264,8 @@ async function cmdDisk(argv: string[]) {
|
|
|
2259
2264
|
console.log(bold(`Disks for ${name}\n`));
|
|
2260
2265
|
|
|
2261
2266
|
// Boot disk
|
|
2262
|
-
const
|
|
2263
|
-
|
|
2264
|
-
: join(state.machineDir, "disk.img");
|
|
2265
|
-
const bootFormat = state.bootDiskFormat ?? "raw";
|
|
2267
|
+
const bootFormat = bootDiskFormatOf(state);
|
|
2268
|
+
const bootPath = bootDiskPath(state.machineDir, bootFormat);
|
|
2266
2269
|
|
|
2267
2270
|
if (qemuImg && existsSync(bootPath)) {
|
|
2268
2271
|
try {
|
|
@@ -2835,7 +2838,7 @@ async function cmdCache(argv: string[]) {
|
|
|
2835
2838
|
return;
|
|
2836
2839
|
}
|
|
2837
2840
|
|
|
2838
|
-
const {
|
|
2841
|
+
const { cacheInventory, countEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
|
|
2839
2842
|
|
|
2840
2843
|
const parseDuration = (s: string): number => {
|
|
2841
2844
|
const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
|
|
@@ -2847,6 +2850,11 @@ async function cmdCache(argv: string[]) {
|
|
|
2847
2850
|
};
|
|
2848
2851
|
const parseSize = parseSizeString;
|
|
2849
2852
|
const formatSize = formatSizeBytes;
|
|
2853
|
+
const printFailedDeletes = (failed: Array<{ entry: { basename: string }; error: string }>) => {
|
|
2854
|
+
if (failed.length === 0) return;
|
|
2855
|
+
console.error(`Could not delete ${countEntries(failed.length)} (kept, not counted as freed):`);
|
|
2856
|
+
for (const { entry, error } of failed) console.error(` - ${entry.basename}: ${error}`);
|
|
2857
|
+
};
|
|
2850
2858
|
const formatAge = (ms: number): string => {
|
|
2851
2859
|
const s = Math.floor(ms / 1000);
|
|
2852
2860
|
if (s < 60) return `${s}s`;
|
|
@@ -2897,13 +2905,14 @@ async function cmdCache(argv: string[]) {
|
|
|
2897
2905
|
}
|
|
2898
2906
|
case "list":
|
|
2899
2907
|
case "ls": {
|
|
2900
|
-
const entries =
|
|
2908
|
+
const { entries, unmanagedBytes } = cacheInventory();
|
|
2901
2909
|
if (flags.json === true) {
|
|
2902
2910
|
console.log(JSON.stringify(entries, null, 2));
|
|
2903
2911
|
return;
|
|
2904
2912
|
}
|
|
2905
2913
|
if (entries.length === 0) {
|
|
2906
|
-
console.log("No
|
|
2914
|
+
console.log("No cache entries.");
|
|
2915
|
+
if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
|
|
2907
2916
|
return;
|
|
2908
2917
|
}
|
|
2909
2918
|
const now = Date.now();
|
|
@@ -2930,7 +2939,8 @@ async function cmdCache(argv: string[]) {
|
|
|
2930
2939
|
);
|
|
2931
2940
|
}
|
|
2932
2941
|
const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
2933
|
-
console.log(`\nTotal: ${entries.length}
|
|
2942
|
+
console.log(`\nTotal: ${countEntries(entries.length)}, ${formatSize(total)}`);
|
|
2943
|
+
if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
|
|
2934
2944
|
break;
|
|
2935
2945
|
}
|
|
2936
2946
|
case "prune": {
|
|
@@ -2944,29 +2954,33 @@ async function cmdCache(argv: string[]) {
|
|
|
2944
2954
|
}
|
|
2945
2955
|
const result = pruneCache(opts);
|
|
2946
2956
|
const verb = opts.dryRun ? "Would evict" : "Evicted";
|
|
2947
|
-
if (result.evicted.length === 0) {
|
|
2957
|
+
if (result.evicted.length === 0 && result.failed.length === 0) {
|
|
2948
2958
|
console.log("Nothing to evict.");
|
|
2949
|
-
} else {
|
|
2950
|
-
console.log(`${verb} ${result.evicted.length}
|
|
2959
|
+
} else if (result.evicted.length > 0) {
|
|
2960
|
+
console.log(`${verb} ${countEntries(result.evicted.length)}:`);
|
|
2951
2961
|
for (const e of result.evicted) {
|
|
2952
2962
|
const reason = result.reasons[e.basename] ?? "policy";
|
|
2953
2963
|
console.log(` - ${e.basename} (${formatSize(e.sizeBytes)}) — ${reason}`);
|
|
2954
2964
|
}
|
|
2955
|
-
console.log(
|
|
2965
|
+
console.log(`${opts.dryRun ? "Would free" : "Freed"}: ${formatSize(result.freedBytes)}`);
|
|
2956
2966
|
}
|
|
2967
|
+
printFailedDeletes(result.failed);
|
|
2968
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
2957
2969
|
break;
|
|
2958
2970
|
}
|
|
2959
2971
|
case "clear": {
|
|
2960
2972
|
if (flags.all !== true) {
|
|
2961
|
-
console.error("cache clear: refuses without --all (deletes all non-in-use
|
|
2973
|
+
console.error("cache clear: refuses without --all (deletes all non-in-use cache entries)");
|
|
2962
2974
|
process.exit(1);
|
|
2963
2975
|
}
|
|
2964
2976
|
const result = pruneCache({ maxSizeBytes: 0, dryRun: flags["dry-run"] === true });
|
|
2965
2977
|
const verb = flags["dry-run"] === true ? "Would delete" : "Deleted";
|
|
2966
|
-
console.log(`${verb} ${result.evicted.length}
|
|
2978
|
+
console.log(`${verb} ${countEntries(result.evicted.length)}, ${flags["dry-run"] === true ? "would free" : "freed"} ${formatSize(result.freedBytes)}`);
|
|
2979
|
+
printFailedDeletes(result.failed);
|
|
2980
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
2967
2981
|
if (result.kept.some((e) => e.inUse)) {
|
|
2968
2982
|
const inUse = result.kept.filter((e) => e.inUse);
|
|
2969
|
-
console.log(`Kept ${inUse.length} in-use
|
|
2983
|
+
console.log(`Kept ${inUse.length} in-use cache ${inUse.length === 1 ? "entry" : "entries"}:`);
|
|
2970
2984
|
for (const e of inUse) console.log(` - ${e.basename}`);
|
|
2971
2985
|
}
|
|
2972
2986
|
break;
|
|
@@ -2992,7 +3006,7 @@ async function cmdSettings(argv: string[]) {
|
|
|
2992
3006
|
|
|
2993
3007
|
const displayValue = (key: string, value: unknown): string => {
|
|
2994
3008
|
if (value === undefined) return "(unset)";
|
|
2995
|
-
if (key === "cache-max-size" && typeof value === "number") return formatSizeBytes(value);
|
|
3009
|
+
if ((key === "cache-max-size" || key === "storage-reserve") && typeof value === "number") return formatSizeBytes(value);
|
|
2996
3010
|
if (key === "timeout-extra" && typeof value === "number") return `${value}s`;
|
|
2997
3011
|
return String(value);
|
|
2998
3012
|
};
|
|
@@ -3079,7 +3093,7 @@ async function cmdDoctor(argv: string[] = []) {
|
|
|
3079
3093
|
const staleImages = await findStaleCacheImages();
|
|
3080
3094
|
if (staleImages.length > 0) {
|
|
3081
3095
|
const longTerm = staleImages[0]?.olderThan;
|
|
3082
|
-
console.log(`Note: ${staleImages.length}
|
|
3096
|
+
console.log(`Note: ${staleImages.length} cache item(s) older than current long-term (${longTerm}):`);
|
|
3083
3097
|
for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
|
|
3084
3098
|
console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
|
|
3085
3099
|
console.log();
|
|
@@ -3174,6 +3188,7 @@ Commands:
|
|
|
3174
3188
|
console <name> Attach to serial console of a running instance
|
|
3175
3189
|
exec <name> <command> Run a RouterOS CLI command on a running instance
|
|
3176
3190
|
(raw /rest/execute — see 'quickchr exec --help' for centrs)
|
|
3191
|
+
qga <name> <operation> Query the QEMU Guest Agent (x86 only)
|
|
3177
3192
|
remove [<name>|--all] Remove instance(s) and disk
|
|
3178
3193
|
clean [<name>|--all] Reset instance disk to fresh image
|
|
3179
3194
|
get <name> [group] Show machine config (license, device-mode, admin)
|
|
@@ -3182,7 +3197,7 @@ Commands:
|
|
|
3182
3197
|
disk <name> Show disk details for an instance
|
|
3183
3198
|
snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
|
|
3184
3199
|
networks Network discovery & socket management
|
|
3185
|
-
cache [add|key|list|prune|clear] Manage cached CHR images
|
|
3200
|
+
cache [add|key|list|prune|clear] Manage cached CHR images and packages
|
|
3186
3201
|
settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
|
|
3187
3202
|
completions Manage shell completions (Tab completion)
|
|
3188
3203
|
logs <name> Tail the QEMU log for an instance
|
|
@@ -3292,6 +3307,13 @@ Examples:
|
|
|
3292
3307
|
quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
|
|
3293
3308
|
quickchr exec my-chr "/log/info message=hello"
|
|
3294
3309
|
|
|
3310
|
+
Exit status:
|
|
3311
|
+
0 means the command was delivered, not that RouterOS accepted it. A rejected
|
|
3312
|
+
command (e.g. "failure: invalid user name") still exits 0, with
|
|
3313
|
+
RouterOS's message as the output. For a pass/fail exit code, use centrs
|
|
3314
|
+
(a write command needs --yes when not run at a TTY):
|
|
3315
|
+
centrs execute --quickchr <name> --yes <command>
|
|
3316
|
+
|
|
3295
3317
|
${CENTRS_SEE_ALSO}`);
|
|
3296
3318
|
break;
|
|
3297
3319
|
case "start":
|
|
@@ -3606,7 +3628,9 @@ Options:
|
|
|
3606
3628
|
case "cache":
|
|
3607
3629
|
console.log(`quickchr cache <add|key|list|prune|clear> [options]
|
|
3608
3630
|
|
|
3609
|
-
Manage cached CHR images in the data dir cache directory.
|
|
3631
|
+
Manage cached CHR images and package archives in the data dir cache directory.
|
|
3632
|
+
An all-packages archive and its extraction are one entry; an interrupted download
|
|
3633
|
+
is a partial entry. Files quickchr did not write are reported, never deleted.
|
|
3610
3634
|
|
|
3611
3635
|
Subcommands:
|
|
3612
3636
|
add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
@@ -3614,14 +3638,16 @@ Subcommands:
|
|
|
3614
3638
|
key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
3615
3639
|
Print cache directory, resolved version, and architecture for CI.
|
|
3616
3640
|
Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
|
|
3617
|
-
list [--json] List
|
|
3618
|
-
prune [options] Evict
|
|
3619
|
-
--older-than <ver> Evict
|
|
3620
|
-
--max-age <dur> Evict
|
|
3621
|
-
--max-size <size> Evict
|
|
3641
|
+
list [--json] List cache entries (basename, version, arch, size, age, in-use).
|
|
3642
|
+
prune [options] Evict cache entries per policy. Never evicts in-use entries.
|
|
3643
|
+
--older-than <ver> Evict entries with version < <ver>.
|
|
3644
|
+
--max-age <dur> Evict entries not used for longer than duration (e.g. 7d, 24h).
|
|
3645
|
+
--max-size <size> Evict entries until total size <= <size>: partial downloads
|
|
3646
|
+
first, then oldest RouterOS version.
|
|
3622
3647
|
--dry-run Show what would be evicted without deleting.
|
|
3623
|
-
clear --all Delete all non-in-use
|
|
3648
|
+
clear --all Delete all non-in-use cache entries.
|
|
3624
3649
|
|
|
3650
|
+
A failed delete is listed on stderr, is not counted as freed, and exits 1.
|
|
3625
3651
|
Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
|
|
3626
3652
|
|
|
3627
3653
|
Default auto-prune cap: 2 GiB — override via 'quickchr settings set cache-max-size <size>'
|
|
@@ -3641,6 +3667,8 @@ Managed keys:
|
|
|
3641
3667
|
auto|tcg|hvf|kvm (default: auto = detect)
|
|
3642
3668
|
cache-max-size Auto-prune cap applied after each successful start.
|
|
3643
3669
|
Size string (e.g. 2G, 512M). (default: 2G)
|
|
3670
|
+
storage-reserve Free space creating a machine or filling the cache must
|
|
3671
|
+
leave, beyond what it writes. Size string. (default: 2G)
|
|
3644
3672
|
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
|
3645
3673
|
Non-negative integer seconds. (default: 0)
|
|
3646
3674
|
secure-login Default for 'add'/'start's --secure-login when neither
|
package/src/lib/cache.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cache retention policy for downloaded CHR images.
|
|
2
|
+
* Cache retention policy for downloaded CHR images and package archives.
|
|
3
3
|
*
|
|
4
|
-
* - Default cap: 2 GB total cache size (size-based eviction)
|
|
4
|
+
* - Default cap: 2 GB total cache size (size-based eviction), counting images, package
|
|
5
|
+
* archives with their extractions, and interrupted downloads.
|
|
5
6
|
* - Eviction order: oldest RouterOS version first, mtime tiebreaker.
|
|
6
7
|
* - Never evicts an image referenced by any machine (running or stopped).
|
|
7
8
|
* - Callers can pin extra versions via `protectVersions` (e.g. current long-term).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { existsSync, readdirSync,
|
|
11
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
|
-
import { getCacheDir, loadAllMachines } from "./state.ts";
|
|
13
|
+
import { directorySize, getCacheDir, loadAllMachines } from "./state.ts";
|
|
14
|
+
import { isPidAlive, liveLeases, pinnedEntries, withStorageLock } from "./storage-lease.ts";
|
|
13
15
|
import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
|
|
14
16
|
import { QuickCHRError, type Arch } from "./types.ts";
|
|
15
17
|
|
|
16
18
|
export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
17
19
|
|
|
20
|
+
/** Free space quickchr keeps on the data dir's filesystem beyond what an operation
|
|
21
|
+
* writes — the `storage-reserve` setting's default. Lives here beside the cache cap
|
|
22
|
+
* so settings.ts can read both without importing storage.ts (which imports settings). */
|
|
23
|
+
export const DEFAULT_STORAGE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
24
|
+
|
|
18
25
|
/** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
|
|
19
26
|
export function parseSizeString(s: string): number {
|
|
20
27
|
const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
|
|
@@ -25,6 +32,11 @@ export function parseSizeString(s: string): number {
|
|
|
25
32
|
return Math.floor(n * (mult[unit] ?? 1));
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
/** "1 entry" / "3 entries". */
|
|
36
|
+
export function countEntries(n: number): string {
|
|
37
|
+
return `${n} ${n === 1 ? "entry" : "entries"}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/** Format a byte count as a human size (IEC binary), matching cache list's display. */
|
|
29
41
|
export function formatSizeBytes(b: number): string {
|
|
30
42
|
if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
|
|
@@ -33,34 +45,72 @@ export function formatSizeBytes(b: number): string {
|
|
|
33
45
|
return `${b} B`;
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
/** What a cache entry is. `packages` is one archive + its extraction, counted and
|
|
49
|
+
* deleted together: the extraction is only a cache hit while the archive it was
|
|
50
|
+
* made from is still there. `partial` is an interrupted download or extraction. */
|
|
51
|
+
export type CacheEntryKind = "image" | "image-zip" | "packages" | "partial";
|
|
52
|
+
|
|
36
53
|
export interface CacheEntry {
|
|
54
|
+
/** The entry's main file or directory. */
|
|
37
55
|
path: string;
|
|
56
|
+
/** Everything deleting this entry removes. */
|
|
57
|
+
paths: string[];
|
|
38
58
|
basename: string;
|
|
59
|
+
kind: CacheEntryKind;
|
|
39
60
|
version: string;
|
|
40
61
|
arch: Arch | "unknown";
|
|
41
62
|
sizeBytes: number;
|
|
63
|
+
/** Last write, or last use: a cache hit touches the image or package archive. */
|
|
42
64
|
mtimeMs: number;
|
|
43
65
|
inUse: boolean;
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
export interface CacheInventory {
|
|
69
|
+
entries: CacheEntry[];
|
|
70
|
+
/** Bytes in files quickchr did not write. Listed, never deleted. */
|
|
71
|
+
unmanagedBytes: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
46
74
|
export interface PruneOptions {
|
|
47
75
|
cacheDir?: string;
|
|
48
76
|
maxSizeBytes?: number;
|
|
49
77
|
maxAgeMs?: number;
|
|
50
78
|
olderThan?: string;
|
|
51
79
|
protectVersions?: string[];
|
|
80
|
+
/** Evict eligible entries, oldest version first, until this many bytes are gone. */
|
|
81
|
+
reclaimBytes?: number;
|
|
82
|
+
/** Keep entries written or used this recently. Pins are what protect a reader; this
|
|
83
|
+
* also spares what a just-finished `cache add` fetched for a later process. */
|
|
84
|
+
skipRecentMs?: number;
|
|
85
|
+
/** Leave these entries alone (a reclaim loop's earlier failed deletes). */
|
|
86
|
+
skipEntries?: string[];
|
|
52
87
|
dryRun?: boolean;
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
export interface PruneResult {
|
|
56
91
|
evicted: CacheEntry[];
|
|
57
92
|
kept: CacheEntry[];
|
|
93
|
+
/** Entries chosen for eviction whose delete failed. Neither evicted nor kept, and not
|
|
94
|
+
* counted in `freedBytes`. */
|
|
95
|
+
failed: Array<{ entry: CacheEntry; error: string }>;
|
|
96
|
+
/** Logical size of what was deleted. Not a measured change in free space: sparse
|
|
97
|
+
* files, clones and concurrent writers all make the two differ. */
|
|
58
98
|
freedBytes: number;
|
|
59
99
|
reasons: Record<string, string>;
|
|
60
100
|
}
|
|
61
101
|
|
|
102
|
+
/** Anything an automatic eviction leaves alone because another operation may be using
|
|
103
|
+
* it right now — a create converting from the image, a first boot installing packages. */
|
|
104
|
+
export const RECENT_USE_MS = 30 * 60 * 1000;
|
|
105
|
+
|
|
62
106
|
/** Parse a cached file basename into version + arch. Returns "unknown" parts when unparseable. */
|
|
63
107
|
export function parseCacheBasename(basename: string): { version: string; arch: Arch | "unknown" } {
|
|
108
|
+
const packages = basename.match(/^(?:all_)?packages-(x86|arm64)-(.+?)(?:\.zip)?$/);
|
|
109
|
+
if (packages) {
|
|
110
|
+
const version = packages[2] ?? "unknown";
|
|
111
|
+
return { version: isValidVersion(version) ? version : "unknown", arch: packages[1] as Arch };
|
|
112
|
+
}
|
|
113
|
+
|
|
64
114
|
// Strip recognized cache extensions (.img, .img.zip).
|
|
65
115
|
let stem = basename;
|
|
66
116
|
if (stem.endsWith(".img.zip")) stem = stem.slice(0, -".img.zip".length);
|
|
@@ -76,6 +126,9 @@ export function parseCacheBasename(basename: string): { version: string; arch: A
|
|
|
76
126
|
return { version, arch };
|
|
77
127
|
}
|
|
78
128
|
|
|
129
|
+
/** Cache names machines still need. Every machine keeps its factory image: `clean` and a
|
|
130
|
+
* failed first boot's rollback rebuild from it. A machine keeps its package cache only
|
|
131
|
+
* until its first-boot provisioning is stamped done. */
|
|
79
132
|
function inUseSet(): Set<string> {
|
|
80
133
|
const set = new Set<string>();
|
|
81
134
|
let machines: ReturnType<typeof loadAllMachines>;
|
|
@@ -88,16 +141,24 @@ function inUseSet(): Set<string> {
|
|
|
88
141
|
if (!m.version || !m.arch) continue;
|
|
89
142
|
set.add(`${chrImageBasename(m.version, m.arch)}.img`);
|
|
90
143
|
set.add(`${chrImageBasename(m.version, m.arch)}.img.zip`);
|
|
144
|
+
const wantsPackages = m.installAllPackages === true || (m.packages?.length ?? 0) > 0;
|
|
145
|
+
if (wantsPackages && !m.provisioning) set.add(`packages-${m.arch}-${m.version}`);
|
|
91
146
|
}
|
|
92
147
|
return set;
|
|
93
148
|
}
|
|
94
149
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
150
|
+
/** Inventory the cache: every entry quickchr manages, plus the bytes it does not.
|
|
151
|
+
* An entry is in use when a machine needs it or a live operation has pinned it. */
|
|
152
|
+
export function cacheInventory(cacheDir?: string, pins: Set<string> = pinnedEntries()): CacheInventory {
|
|
97
153
|
const dir = cacheDir ?? getCacheDir();
|
|
98
|
-
if (!existsSync(dir)) return [];
|
|
154
|
+
if (!existsSync(dir)) return { entries: [], unmanagedBytes: 0 };
|
|
99
155
|
const used = inUseSet();
|
|
100
|
-
const
|
|
156
|
+
for (const pin of pins) used.add(pin);
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const entries: CacheEntry[] = [];
|
|
159
|
+
const packageGroups = new Map<string, CacheEntry>();
|
|
160
|
+
let unmanagedBytes = 0;
|
|
161
|
+
|
|
101
162
|
for (const name of readdirSync(dir)) {
|
|
102
163
|
const full = join(dir, name);
|
|
103
164
|
let stat: ReturnType<typeof statSync>;
|
|
@@ -106,20 +167,94 @@ export function listCacheEntries(cacheDir?: string): CacheEntry[] {
|
|
|
106
167
|
} catch {
|
|
107
168
|
continue;
|
|
108
169
|
}
|
|
109
|
-
|
|
110
|
-
|
|
170
|
+
const sizeBytes = stat.isDirectory() ? directorySize(full) : stat.size;
|
|
171
|
+
|
|
172
|
+
// Only exact quickchr names are managed: `all_packages-<arch>-<ver>.zip` (a file)
|
|
173
|
+
// and `packages-<arch>-<ver>` (a directory), with a real RouterOS version.
|
|
174
|
+
const packages = name.match(/^(all_)?packages-(x86|arm64)-(.+?)(\.zip)?$/);
|
|
175
|
+
const isPackageArtifact = packages !== null && isValidVersion(packages[3] ?? "") &&
|
|
176
|
+
(packages[1] && packages[4] ? stat.isFile() : !packages[1] && !packages[4] && stat.isDirectory());
|
|
177
|
+
if (packages && isPackageArtifact) {
|
|
178
|
+
const key = `packages-${packages[2]}-${packages[3]}`;
|
|
179
|
+
const group = packageGroups.get(key);
|
|
180
|
+
if (group) {
|
|
181
|
+
group.paths.push(full);
|
|
182
|
+
group.sizeBytes += sizeBytes;
|
|
183
|
+
group.mtimeMs = Math.max(group.mtimeMs, stat.mtimeMs);
|
|
184
|
+
} else {
|
|
185
|
+
const { version, arch } = parseCacheBasename(key);
|
|
186
|
+
packageGroups.set(key, {
|
|
187
|
+
path: join(dir, key),
|
|
188
|
+
paths: [full],
|
|
189
|
+
basename: key,
|
|
190
|
+
kind: "packages",
|
|
191
|
+
version,
|
|
192
|
+
arch,
|
|
193
|
+
sizeBytes,
|
|
194
|
+
mtimeMs: stat.mtimeMs,
|
|
195
|
+
inUse: used.has(key),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `<target>.<pid>-<id>.part` (download.ts) is live while its writer is.
|
|
202
|
+
// `.quickchr-image-*` / `.quickchr-packages-*` extraction dirs have no owner
|
|
203
|
+
// recorded, so they count as live for RECENT_USE_MS after their last write.
|
|
204
|
+
const part = name.match(/^(.+)\.(\d+)-[0-9a-f]+\.part$/);
|
|
205
|
+
const extraction = stat.isDirectory() && (name.startsWith(".quickchr-image-") || name.startsWith(".quickchr-packages-"));
|
|
206
|
+
if ((part && isManagedDownloadTarget(part[1] ?? "")) || extraction) {
|
|
207
|
+
const { version, arch } = parseCacheBasename(part?.[1] ?? name);
|
|
208
|
+
entries.push({
|
|
209
|
+
path: full,
|
|
210
|
+
paths: [full],
|
|
211
|
+
basename: name,
|
|
212
|
+
kind: "partial",
|
|
213
|
+
version,
|
|
214
|
+
arch,
|
|
215
|
+
sizeBytes,
|
|
216
|
+
mtimeMs: stat.mtimeMs,
|
|
217
|
+
inUse: part ? isPidAlive(Number(part[2])) : now - stat.mtimeMs < RECENT_USE_MS,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const kind: CacheEntryKind | undefined = !stat.isFile()
|
|
223
|
+
? undefined
|
|
224
|
+
: name.endsWith(".img.zip") ? "image-zip" : name.endsWith(".img") ? "image" : undefined;
|
|
111
225
|
const { version, arch } = parseCacheBasename(name);
|
|
112
|
-
|
|
226
|
+
if (!kind || version === "unknown") {
|
|
227
|
+
unmanagedBytes += sizeBytes;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
entries.push({
|
|
113
231
|
path: full,
|
|
232
|
+
paths: [full],
|
|
114
233
|
basename: name,
|
|
234
|
+
kind,
|
|
115
235
|
version,
|
|
116
236
|
arch,
|
|
117
|
-
sizeBytes
|
|
237
|
+
sizeBytes,
|
|
118
238
|
mtimeMs: stat.mtimeMs,
|
|
119
239
|
inUse: used.has(name),
|
|
120
240
|
});
|
|
121
241
|
}
|
|
122
|
-
|
|
242
|
+
|
|
243
|
+
entries.push(...packageGroups.values());
|
|
244
|
+
return { entries, unmanagedBytes };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True for the files download.ts writes into the cache: an image ZIP or an
|
|
248
|
+
* all-packages ZIP, with a real RouterOS version. Anything else is not ours. */
|
|
249
|
+
function isManagedDownloadTarget(name: string): boolean {
|
|
250
|
+
if (name.endsWith(".img.zip")) return parseCacheBasename(name).version !== "unknown";
|
|
251
|
+
const packages = name.match(/^all_packages-(x86|arm64)-(.+)\.zip$/);
|
|
252
|
+
return packages !== null && isValidVersion(packages[2] ?? "");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** List cache entries with parsed metadata and in-use status. */
|
|
256
|
+
export function listCacheEntries(cacheDir?: string): CacheEntry[] {
|
|
257
|
+
return cacheInventory(cacheDir).entries;
|
|
123
258
|
}
|
|
124
259
|
|
|
125
260
|
function compareVersionsSafe(a: string, b: string): number {
|
|
@@ -140,11 +275,24 @@ function compareVersionsSafe(a: string, b: string): number {
|
|
|
140
275
|
* - `maxSizeBytes`: total bytes exceed cap
|
|
141
276
|
* - `maxAgeMs`: any entry older than threshold
|
|
142
277
|
* - `olderThan`: any entry's parsed version older than threshold
|
|
278
|
+
* - `reclaimBytes`: evict until this many bytes are gone
|
|
143
279
|
*
|
|
144
|
-
* In-use entries
|
|
280
|
+
* In-use entries, `protectVersions` entries and (with `skipRecentMs`) recently used
|
|
281
|
+
* entries are never evicted. Interrupted downloads go first, then oldest version.
|
|
145
282
|
*/
|
|
146
283
|
export function pruneCache(opts: PruneOptions = {}): PruneResult {
|
|
147
|
-
|
|
284
|
+
// A dry run deletes nothing, so it reads the pins without taking the lock.
|
|
285
|
+
return opts.dryRun ? pruneCacheUnderLock(opts, pinnedEntries()) : withStorageLock(() => pruneCacheUnderLock(opts));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `pruneCache` for a caller already holding the storage lock (storage.ts admission).
|
|
289
|
+
* Entries pinned by a live lease count as in use. */
|
|
290
|
+
export function pruneCacheUnderLock(
|
|
291
|
+
opts: PruneOptions = {},
|
|
292
|
+
pins: Set<string> = new Set(liveLeases().flatMap((lease) => lease.pins)),
|
|
293
|
+
): PruneResult {
|
|
294
|
+
const entries = cacheInventory(opts.cacheDir, pins).entries;
|
|
295
|
+
const skip = new Set(opts.skipEntries ?? []);
|
|
148
296
|
const protect = new Set(opts.protectVersions ?? []);
|
|
149
297
|
const dryRun = opts.dryRun ?? false;
|
|
150
298
|
const now = Date.now();
|
|
@@ -152,6 +300,7 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
|
|
|
152
300
|
const reasons: Record<string, string> = {};
|
|
153
301
|
const evicted: CacheEntry[] = [];
|
|
154
302
|
const kept: CacheEntry[] = [];
|
|
303
|
+
const failed: PruneResult["failed"] = [];
|
|
155
304
|
|
|
156
305
|
const protectedEntries: CacheEntry[] = [];
|
|
157
306
|
const candidates: CacheEntry[] = [];
|
|
@@ -159,27 +308,44 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
|
|
|
159
308
|
for (const e of entries) {
|
|
160
309
|
if (e.inUse) {
|
|
161
310
|
protectedEntries.push(e);
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
if (protect.has(e.version)) {
|
|
311
|
+
reasons[e.basename] = e.kind === "partial" ? "in progress" : "in use";
|
|
312
|
+
} else if (protect.has(e.version)) {
|
|
165
313
|
protectedEntries.push(e);
|
|
166
|
-
|
|
314
|
+
reasons[e.basename] = `protected (version ${e.version})`;
|
|
315
|
+
} else if (skip.has(e.basename)) {
|
|
316
|
+
protectedEntries.push(e);
|
|
317
|
+
reasons[e.basename] = "skipped";
|
|
318
|
+
} else if (opts.skipRecentMs !== undefined && now - e.mtimeMs < opts.skipRecentMs) {
|
|
319
|
+
protectedEntries.push(e);
|
|
320
|
+
reasons[e.basename] = "used recently";
|
|
321
|
+
} else {
|
|
322
|
+
candidates.push(e);
|
|
167
323
|
}
|
|
168
|
-
candidates.push(e);
|
|
169
324
|
}
|
|
170
325
|
|
|
171
326
|
candidates.sort((a, b) => {
|
|
327
|
+
const partial = Number(b.kind === "partial") - Number(a.kind === "partial");
|
|
328
|
+
if (partial !== 0) return partial;
|
|
172
329
|
const v = compareVersionsSafe(a.version, b.version);
|
|
173
330
|
if (v !== 0) return v;
|
|
174
331
|
return a.mtimeMs - b.mtimeMs;
|
|
175
332
|
});
|
|
176
333
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
reasons[e.basename] = reason;
|
|
334
|
+
/** True when the entry is gone (or, in a dry run, would be). */
|
|
335
|
+
const evict = (e: CacheEntry, reason: string): boolean => {
|
|
180
336
|
if (!dryRun) {
|
|
181
|
-
try {
|
|
337
|
+
try {
|
|
338
|
+
for (const path of e.paths) rmSync(path, { recursive: true, force: true });
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
341
|
+
failed.push({ entry: e, error: message });
|
|
342
|
+
reasons[e.basename] = `delete failed: ${message}`;
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
182
345
|
}
|
|
346
|
+
evicted.push(e);
|
|
347
|
+
reasons[e.basename] = reason;
|
|
348
|
+
return true;
|
|
183
349
|
};
|
|
184
350
|
|
|
185
351
|
const remaining: CacheEntry[] = [];
|
|
@@ -212,31 +378,34 @@ export function pruneCache(opts: PruneOptions = {}): PruneResult {
|
|
|
212
378
|
}
|
|
213
379
|
|
|
214
380
|
const keptSoFar: CacheEntry[] = [...remainingAfterAge];
|
|
381
|
+
const totalSize = (es: CacheEntry[]) => es.reduce((s, x) => s + x.sizeBytes, 0);
|
|
215
382
|
|
|
216
383
|
if (opts.maxSizeBytes !== undefined) {
|
|
217
384
|
const cap = opts.maxSizeBytes;
|
|
218
|
-
|
|
219
|
-
let total = totalSize(protectedEntries) + totalSize(keptSoFar);
|
|
385
|
+
let total = totalSize(protectedEntries) + totalSize(keptSoFar) + totalSize(failed.map((f) => f.entry));
|
|
220
386
|
while (total > cap && keptSoFar.length > 0) {
|
|
221
387
|
const victim = keptSoFar.shift();
|
|
222
388
|
if (!victim) break;
|
|
223
|
-
evict(victim, `size cap ${cap} exceeded (oldest version)`);
|
|
224
|
-
total -= victim.sizeBytes;
|
|
389
|
+
if (evict(victim, `size cap ${formatSizeBytes(cap)} exceeded (oldest version)`)) total -= victim.sizeBytes;
|
|
225
390
|
}
|
|
226
391
|
}
|
|
227
392
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
393
|
+
if (opts.reclaimBytes !== undefined) {
|
|
394
|
+
const target = opts.reclaimBytes;
|
|
395
|
+
while (totalSize(evicted) < target && keptSoFar.length > 0) {
|
|
396
|
+
const victim = keptSoFar.shift();
|
|
397
|
+
if (!victim) break;
|
|
398
|
+
evict(victim, `reclaimed for space (oldest version)`);
|
|
399
|
+
}
|
|
232
400
|
}
|
|
401
|
+
|
|
402
|
+
for (const e of protectedEntries) kept.push(e);
|
|
233
403
|
for (const e of keptSoFar) {
|
|
234
404
|
kept.push(e);
|
|
235
405
|
reasons[e.basename] ??= "kept";
|
|
236
406
|
}
|
|
237
407
|
|
|
238
|
-
|
|
239
|
-
return { evicted, kept, freedBytes, reasons };
|
|
408
|
+
return { evicted, kept, failed, freedBytes: totalSize(evicted), reasons };
|
|
240
409
|
}
|
|
241
410
|
|
|
242
411
|
/**
|
|
@@ -249,21 +418,34 @@ export function autoPruneIfOverCap(opts: { logger?: (msg: string) => void; cache
|
|
|
249
418
|
const entries = listCacheEntries(opts.cacheDir);
|
|
250
419
|
const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
251
420
|
if (total <= cap) {
|
|
252
|
-
return { evicted: [], kept: entries, freedBytes: 0, reasons: {} };
|
|
421
|
+
return { evicted: [], kept: entries, failed: [], freedBytes: 0, reasons: {} };
|
|
253
422
|
}
|
|
254
423
|
const result = pruneCache({
|
|
255
424
|
cacheDir: opts.cacheDir,
|
|
256
425
|
maxSizeBytes: cap,
|
|
257
426
|
protectVersions: opts.protectVersions,
|
|
427
|
+
skipRecentMs: RECENT_USE_MS,
|
|
258
428
|
});
|
|
259
|
-
if (opts.logger
|
|
260
|
-
|
|
429
|
+
if (opts.logger) {
|
|
430
|
+
for (const line of describePrune(result)) opts.logger(line);
|
|
261
431
|
}
|
|
262
432
|
return result;
|
|
263
433
|
} catch (e) {
|
|
264
434
|
if (opts.logger) {
|
|
265
435
|
opts.logger(`cache: auto-prune skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
266
436
|
}
|
|
267
|
-
return { evicted: [], kept: [], freedBytes: 0, reasons: {} };
|
|
437
|
+
return { evicted: [], kept: [], failed: [], freedBytes: 0, reasons: {} };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** One-line log summary of an eviction, naming what went, plus one line per failure. */
|
|
442
|
+
export function describePrune(result: PruneResult): string[] {
|
|
443
|
+
const lines: string[] = [];
|
|
444
|
+
if (result.evicted.length > 0) {
|
|
445
|
+
lines.push(`cache: evicted ${result.evicted.map((e) => e.basename).join(", ")} — freed ${formatSizeBytes(result.freedBytes)}`);
|
|
446
|
+
}
|
|
447
|
+
for (const { entry, error } of result.failed) {
|
|
448
|
+
lines.push(`cache: could not delete ${entry.basename}: ${error}`);
|
|
268
449
|
}
|
|
450
|
+
return lines;
|
|
269
451
|
}
|