@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/cli/wizard.ts
CHANGED
|
@@ -81,6 +81,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
81
81
|
if (!/^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(v)) {
|
|
82
82
|
return "Invalid version format";
|
|
83
83
|
}
|
|
84
|
+
// RouterOS 6 is a CLI-only boot target (#182) — the wizard offers
|
|
85
|
+
// provisioning at every step, none of which 6.x can take.
|
|
86
|
+
if (/^6\./.test(v)) {
|
|
87
|
+
return "RouterOS 6 is boot-only and not offered here — use: quickchr add --version <6.x> --arch x86";
|
|
88
|
+
}
|
|
84
89
|
},
|
|
85
90
|
});
|
|
86
91
|
if (clack.isCancel(ver)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
@@ -557,7 +562,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
557
562
|
const background = await clack.select({
|
|
558
563
|
message: "Run mode:",
|
|
559
564
|
options: [
|
|
560
|
-
|
|
565
|
+
// Not "returns immediately": a background start waits for RouterOS to be ready (#159).
|
|
566
|
+
{ value: true, label: "Background", hint: "console logged; returns when RouterOS is ready" },
|
|
561
567
|
{ value: false, label: "Foreground", hint: "serial console on stdio" },
|
|
562
568
|
],
|
|
563
569
|
initialValue: true,
|
|
@@ -566,8 +572,10 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
566
572
|
|
|
567
573
|
// 11. Confirm
|
|
568
574
|
const opts: StartOptions = {
|
|
575
|
+
// The channel has already been resolved into `version` above; passing both reads
|
|
576
|
+
// to start() as a caller asking for two releases.
|
|
569
577
|
version,
|
|
570
|
-
channel,
|
|
578
|
+
channel: version ? undefined : channel,
|
|
571
579
|
arch,
|
|
572
580
|
name: name || undefined,
|
|
573
581
|
cpu: Number(cpu),
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native API (`/login` on guest port 8728) as a readiness signal.
|
|
3
|
+
*
|
|
4
|
+
* Built for RouterOS 6, which has no REST API, so the REST probe can never succeed
|
|
5
|
+
* against it — the field report behind #182 watched a 6.49 guest answer www, ssh and
|
|
6
|
+
* api from 15 s in, then get removed at 180 s for not answering `/rest`. It is written
|
|
7
|
+
* as a general alternative to the REST probe rather than a v6 special case: a 7.x
|
|
8
|
+
* guest with REST disabled is the same problem.
|
|
9
|
+
*
|
|
10
|
+
* Only the post-6.43 plain login is spoken (`=name=` + `=password=` in one sentence).
|
|
11
|
+
* Grounded against CHR 6.49.23 under HVF, 2026-10-21:
|
|
12
|
+
*
|
|
13
|
+
* - `admin` / empty password → `!done`, as one 7-byte reply.
|
|
14
|
+
* - wrong user or password → `!trap =message=invalid user name or password (6)`
|
|
15
|
+
* followed by `!done`, in the same read.
|
|
16
|
+
*
|
|
17
|
+
* Either answer means the API service is up, the way a REST 401 does — the probe is a
|
|
18
|
+
* readiness check, not a credential check.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { connect } from "node:net";
|
|
22
|
+
|
|
23
|
+
/** Encode one API word length (RouterOS API "length" prefix, 1–5 bytes). */
|
|
24
|
+
export function encodeApiLength(length: number): Uint8Array {
|
|
25
|
+
if (length < 0x80) return Uint8Array.of(length);
|
|
26
|
+
if (length < 0x4000) return Uint8Array.of((length >> 8) | 0x80, length & 0xff);
|
|
27
|
+
if (length < 0x200000) return Uint8Array.of((length >> 16) | 0xc0, (length >> 8) & 0xff, length & 0xff);
|
|
28
|
+
if (length < 0x10000000) {
|
|
29
|
+
return Uint8Array.of((length >>> 24) | 0xe0, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
30
|
+
}
|
|
31
|
+
return Uint8Array.of(0xf0, (length >>> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A sentence is its words, each length-prefixed, then a zero-length word. */
|
|
35
|
+
export function encodeApiSentence(words: string[]): Uint8Array {
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
const parts: Uint8Array[] = [];
|
|
38
|
+
for (const word of words) {
|
|
39
|
+
const bytes = encoder.encode(word);
|
|
40
|
+
parts.push(encodeApiLength(bytes.length), bytes);
|
|
41
|
+
}
|
|
42
|
+
parts.push(Uint8Array.of(0));
|
|
43
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
44
|
+
let offset = 0;
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
out.set(part, offset);
|
|
47
|
+
offset += part.length;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decode a length prefix at `offset`. `null` when the buffer ends inside it. */
|
|
53
|
+
function decodeApiLength(buf: Uint8Array, offset: number): { length: number; size: number } | null {
|
|
54
|
+
const first = buf[offset];
|
|
55
|
+
if (first === undefined) return null;
|
|
56
|
+
const size = (first & 0x80) === 0 ? 1
|
|
57
|
+
: (first & 0xc0) === 0x80 ? 2
|
|
58
|
+
: (first & 0xe0) === 0xc0 ? 3
|
|
59
|
+
: (first & 0xf0) === 0xe0 ? 4
|
|
60
|
+
: 5;
|
|
61
|
+
if (offset + size > buf.length) return null;
|
|
62
|
+
let length = size === 5 ? 0 : first & (0xff >> size);
|
|
63
|
+
for (let i = 1; i < size; i++) length = length * 256 + (buf[offset + i] ?? 0);
|
|
64
|
+
return { length, size };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every complete sentence in `buf`; a trailing partial sentence is left out. */
|
|
68
|
+
export function parseApiSentences(buf: Uint8Array): string[][] {
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
const sentences: string[][] = [];
|
|
71
|
+
let words: string[] = [];
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buf.length) {
|
|
74
|
+
const prefix = decodeApiLength(buf, offset);
|
|
75
|
+
if (!prefix || offset + prefix.size + prefix.length > buf.length) break;
|
|
76
|
+
offset += prefix.size;
|
|
77
|
+
if (prefix.length === 0) {
|
|
78
|
+
sentences.push(words);
|
|
79
|
+
words = [];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
words.push(decoder.decode(buf.subarray(offset, offset + prefix.length)));
|
|
83
|
+
offset += prefix.length;
|
|
84
|
+
}
|
|
85
|
+
return sentences;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ApiLoginResult =
|
|
89
|
+
/** `!done` with no `!trap` — logged in. */
|
|
90
|
+
| { outcome: "done" }
|
|
91
|
+
/** `!trap` then `!done` — the service answered and refused the credentials. */
|
|
92
|
+
| { outcome: "trap"; message: string }
|
|
93
|
+
/** `!fatal` — the service answered and closed the session (e.g. too many sessions). */
|
|
94
|
+
| { outcome: "fatal"; message: string };
|
|
95
|
+
|
|
96
|
+
/** What the reply sentences say, once a `!done` or `!fatal` has arrived. `null` while
|
|
97
|
+
* the login is still unanswered. */
|
|
98
|
+
export function classifyLoginReply(sentences: string[][]): ApiLoginResult | null {
|
|
99
|
+
const messageOf = (sentence: string[]) =>
|
|
100
|
+
sentence.find((w) => w.startsWith("=message="))?.slice("=message=".length) ?? sentence.slice(1).join(" ");
|
|
101
|
+
const fatal = sentences.find((s) => s[0] === "!fatal");
|
|
102
|
+
if (fatal) return { outcome: "fatal", message: messageOf(fatal) };
|
|
103
|
+
if (!sentences.some((s) => s[0] === "!done")) return null;
|
|
104
|
+
const trap = sentences.find((s) => s[0] === "!trap");
|
|
105
|
+
return trap ? { outcome: "trap", message: messageOf(trap) } : { outcome: "done" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One `/login` round trip on `127.0.0.1:<port>`. Rejects on a socket error, on a close
|
|
109
|
+
* before the reply completes ("closed before reply"), and on the deadline
|
|
110
|
+
* ("apiLogin timeout") — the shapes `classifyProbeError()` sorts into outcomes. */
|
|
111
|
+
export function apiLogin(port: number, user: string, password: string, timeoutMs = 3000): Promise<ApiLoginResult> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const socket = connect(port, "127.0.0.1");
|
|
114
|
+
let received = new Uint8Array(0);
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (fn: () => void) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
socket.destroy();
|
|
121
|
+
fn();
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(() => finish(() => reject(new Error("apiLogin timeout"))), timeoutMs);
|
|
124
|
+
socket.on("connect", () => {
|
|
125
|
+
socket.write(encodeApiSentence(["/login", `=name=${user}`, `=password=${password}`]));
|
|
126
|
+
});
|
|
127
|
+
socket.on("data", (chunk: Uint8Array) => {
|
|
128
|
+
const next = new Uint8Array(received.length + chunk.length);
|
|
129
|
+
next.set(received);
|
|
130
|
+
next.set(chunk, received.length);
|
|
131
|
+
received = next;
|
|
132
|
+
const result = classifyLoginReply(parseApiSentences(received));
|
|
133
|
+
if (result) finish(() => resolve(result));
|
|
134
|
+
});
|
|
135
|
+
socket.on("error", (err) => finish(() => reject(err)));
|
|
136
|
+
socket.on("close", () => finish(() => reject(new Error("closed before reply"))));
|
|
137
|
+
});
|
|
138
|
+
}
|
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
|
}
|