disk-space-saviour 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +587 -0
- package/LICENSE +24 -0
- package/README.md +538 -2
- package/bin/dss.js +5 -0
- package/package.json +96 -4
- package/src/audit.js +84 -0
- package/src/clean.js +716 -0
- package/src/cli.js +448 -0
- package/src/docker/cli.js +289 -0
- package/src/docker/containers.js +334 -0
- package/src/docker/images.js +122 -0
- package/src/docker/scan.js +531 -0
- package/src/emergency.js +169 -0
- package/src/env/local.js +715 -0
- package/src/env/resolve.js +36 -0
- package/src/env/shell.js +433 -0
- package/src/exec.js +185 -0
- package/src/git.js +338 -0
- package/src/health.js +174 -0
- package/src/index.d.ts +255 -0
- package/src/index.js +52 -0
- package/src/items.js +166 -0
- package/src/liveness.js +190 -0
- package/src/options.js +112 -0
- package/src/paths.js +182 -0
- package/src/report.js +376 -0
- package/src/rules/ecosystems.js +916 -0
- package/src/rules/other.js +375 -0
- package/src/rules/versions.js +256 -0
- package/src/scan.js +384 -0
- package/src/scanners/agents.js +166 -0
- package/src/scanners/common.js +38 -0
- package/src/scanners/global.js +217 -0
- package/src/scanners/projects.js +335 -0
- package/src/scanners/rust.js +404 -0
- package/src/scanners/system.js +171 -0
- package/src/scanners/versions.js +230 -0
- package/src/units.js +130 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Superseded Rust build artifacts inside a cargo `target` directory.
|
|
3
|
+
*
|
|
4
|
+
* Every compilation unit leaves entries named `<crate>-<16 hex hash>` in
|
|
5
|
+
* `<profile>/deps`, `<profile>/build` and `<profile>/.fingerprint`, and
|
|
6
|
+
* incremental state in `<profile>/incremental/<crate>-<hash>`. Changing
|
|
7
|
+
* features, flags, the toolchain or dependency versions creates a new hash
|
|
8
|
+
* next to the old one, so old hashes pile up.
|
|
9
|
+
*
|
|
10
|
+
* The result has two parts:
|
|
11
|
+
*
|
|
12
|
+
* - **library**: `.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`, `.d`, `build/*`
|
|
13
|
+
* and `.fingerprint` entries. A unit's identity is read from its
|
|
14
|
+
* fingerprint (`<kind>-<target>.json` with the `target` and `profile`
|
|
15
|
+
* hashes, which separate lib/bin/test and check/build units). Units
|
|
16
|
+
* without a fingerprint fall back to crate name, artifact kind and crate
|
|
17
|
+
* root source (first entry of the `.d` file). For each identity the
|
|
18
|
+
* newest hash is kept, together with every hash built within the same
|
|
19
|
+
* generation window (one build); older hashes are reported only when all
|
|
20
|
+
* of their files are outside the activity window. A running cargo may
|
|
21
|
+
* already have judged these fresh, so callers must not touch this part
|
|
22
|
+
* while cargo or rustc runs in the project.
|
|
23
|
+
* - **leaf**: nothing links against these, so they stay removable while
|
|
24
|
+
* cargo runs (callers still skip files a process has open):
|
|
25
|
+
* - incremental session directories whose newest file is older than
|
|
26
|
+
* `leafIdleMs` and that hold no `-working` session. Each directory is
|
|
27
|
+
* judged by its own age: lib, test and bin sessions of one crate live
|
|
28
|
+
* side by side and never supersede each other;
|
|
29
|
+
* - extension-less (or `.exe`) executables `<name>-<hash>` in `deps` and
|
|
30
|
+
* `examples` (test and example binaries) older than `leafIdleMs` when a
|
|
31
|
+
* newer build of the same unit exists.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { listMany } from './common.js';
|
|
35
|
+
|
|
36
|
+
const HASHED = /^(.+)-([0-9a-f]{16})(\..*)?$/;
|
|
37
|
+
const INCREMENTAL = /^(.+)-([0-9a-z]{8,20})$/;
|
|
38
|
+
const LIB_PREFIXED = new Set(['.rlib', '.rmeta', '.so', '.dylib', '.a']);
|
|
39
|
+
const KIND_ORDER = ['lib', 'dylib', 'staticlib', 'bin', 'rmeta', 'other'];
|
|
40
|
+
const NON_PROFILES = new Set(['doc', 'package', 'tmp', 'flycheck0']);
|
|
41
|
+
const EXECUTABLE = new Set(['', '.exe']);
|
|
42
|
+
export const DEFAULT_GENERATION_MS = 10 * 60 * 1000;
|
|
43
|
+
export const DEFAULT_LEAF_IDLE_MS = 3 * 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
function fileKind(extension) {
|
|
46
|
+
const kinds = {
|
|
47
|
+
'.rlib': 'lib',
|
|
48
|
+
'.rmeta': 'rmeta',
|
|
49
|
+
'.so': 'dylib',
|
|
50
|
+
'.dylib': 'dylib',
|
|
51
|
+
'.dll': 'dylib',
|
|
52
|
+
'.a': 'staticlib',
|
|
53
|
+
'.lib': 'staticlib',
|
|
54
|
+
'': 'bin',
|
|
55
|
+
'.exe': 'bin',
|
|
56
|
+
};
|
|
57
|
+
return kinds[extension] ?? 'other';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Splits `libfoo_bar-0123456789abcdef.rlib` into crate, hash, extension.
|
|
62
|
+
* @returns {{crate: string, hash: string, extension: string}|null}
|
|
63
|
+
*/
|
|
64
|
+
export function parseArtifactName(name) {
|
|
65
|
+
const match = HASHED.exec(name);
|
|
66
|
+
if (!match) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const extension = match[3] ?? '';
|
|
70
|
+
let crate = match[1];
|
|
71
|
+
if (LIB_PREFIXED.has(extension) && crate.startsWith('lib')) {
|
|
72
|
+
crate = crate.slice(3);
|
|
73
|
+
}
|
|
74
|
+
return { crate, hash: match[2], extension };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* First dependency of a Makefile-style `.d` file: the crate root source.
|
|
79
|
+
*/
|
|
80
|
+
export function rootSourceOf(depInfo) {
|
|
81
|
+
const line = (depInfo ?? '').split('\n')[0];
|
|
82
|
+
const separator = line.search(/:\s/);
|
|
83
|
+
if (separator === -1) {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
const deps = line
|
|
87
|
+
.slice(separator + 1)
|
|
88
|
+
.trim()
|
|
89
|
+
.split(/(?<!\\)\s+/);
|
|
90
|
+
return deps[0] ?? '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Unit identity from a fingerprint JSON file name and its beginning.
|
|
95
|
+
*/
|
|
96
|
+
export function fingerprintIdentity(jsonName, head) {
|
|
97
|
+
const target = /"target":(\d+)/.exec(head ?? '');
|
|
98
|
+
const profile = /"profile":(\d+)/.exec(head ?? '');
|
|
99
|
+
return `${jsonName}:${target?.[1] ?? '?'}:${profile?.[1] ?? '?'}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Profile directories (`target/debug`, `target/<triple>/release`, ...)
|
|
104
|
+
* identified by their `deps` subdirectory.
|
|
105
|
+
* @returns {Promise<string[]>}
|
|
106
|
+
*/
|
|
107
|
+
export async function findRustProfiles(env, targetDir) {
|
|
108
|
+
const top = (await env.list(targetDir)).filter(
|
|
109
|
+
(entry) => entry.type === 'dir' && !NON_PROFILES.has(entry.name)
|
|
110
|
+
);
|
|
111
|
+
const hasDeps = (entries) => entries.some((entry) => entry.name === 'deps');
|
|
112
|
+
const listings = await listMany(
|
|
113
|
+
env,
|
|
114
|
+
top.map((entry) => entry.path)
|
|
115
|
+
);
|
|
116
|
+
const profiles = [];
|
|
117
|
+
const nested = [];
|
|
118
|
+
for (const entry of top) {
|
|
119
|
+
const children = listings.get(entry.path) ?? [];
|
|
120
|
+
if (hasDeps(children)) {
|
|
121
|
+
profiles.push(entry.path);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const child of children) {
|
|
125
|
+
if (child.type === 'dir' && !NON_PROFILES.has(child.name)) {
|
|
126
|
+
nested.push(env.path.join(entry.path, child.name));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const nestedListings = await listMany(env, nested);
|
|
131
|
+
profiles.push(...nested.filter((dir) => hasDeps(nestedListings.get(dir))));
|
|
132
|
+
return profiles.sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function withDirUsage(env, entries) {
|
|
136
|
+
const dirs = entries.filter((entry) => entry.type === 'dir');
|
|
137
|
+
if (dirs.length === 0) {
|
|
138
|
+
return entries;
|
|
139
|
+
}
|
|
140
|
+
const usages = await env.usageMany(dirs.map((entry) => entry.path));
|
|
141
|
+
return entries.map((entry) => {
|
|
142
|
+
const usage = entry.type === 'dir' ? usages.get(entry.path) : null;
|
|
143
|
+
return usage
|
|
144
|
+
? {
|
|
145
|
+
...entry,
|
|
146
|
+
bytes: usage.bytes,
|
|
147
|
+
mtimeMs: Math.max(entry.mtimeMs, usage.newestMtimeMs ?? 0),
|
|
148
|
+
}
|
|
149
|
+
: entry;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hashedEntries(entries) {
|
|
154
|
+
return (entries ?? []).filter((entry) => parseArtifactName(entry.name));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isExecutable(entry) {
|
|
158
|
+
return (
|
|
159
|
+
entry.type !== 'dir' &&
|
|
160
|
+
EXECUTABLE.has(parseArtifactName(entry.name).extension)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function summarize(stale, kept) {
|
|
165
|
+
const entries = stale
|
|
166
|
+
.flatMap((unit) => unit.entries)
|
|
167
|
+
.map(({ path, bytes, mtimeMs }) => ({ path, bytes, mtimeMs }));
|
|
168
|
+
return {
|
|
169
|
+
entries,
|
|
170
|
+
bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
171
|
+
kept,
|
|
172
|
+
removed: stale.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class UnitSet {
|
|
177
|
+
constructor() {
|
|
178
|
+
this.units = new Map();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
add(key, hash, entry) {
|
|
182
|
+
if (!this.units.has(key)) {
|
|
183
|
+
this.units.set(key, new Map());
|
|
184
|
+
}
|
|
185
|
+
const hashes = this.units.get(key);
|
|
186
|
+
if (!hashes.has(hash)) {
|
|
187
|
+
hashes.set(hash, { hash, entries: [], mtimeMs: 0 });
|
|
188
|
+
}
|
|
189
|
+
const unit = hashes.get(hash);
|
|
190
|
+
unit.entries.push(entry);
|
|
191
|
+
unit.mtimeMs = Math.max(unit.mtimeMs, entry.mtimeMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
superseded({ staleAgeMs, now, generationMs }) {
|
|
195
|
+
const stale = [];
|
|
196
|
+
let kept = 0;
|
|
197
|
+
for (const hashes of this.units.values()) {
|
|
198
|
+
const ordered = [...hashes.values()].sort(
|
|
199
|
+
(a, b) => b.mtimeMs - a.mtimeMs
|
|
200
|
+
);
|
|
201
|
+
const newest = ordered[0].mtimeMs;
|
|
202
|
+
for (const unit of ordered) {
|
|
203
|
+
const sameGeneration = newest - unit.mtimeMs <= generationMs;
|
|
204
|
+
const idle = now - unit.mtimeMs >= staleAgeMs;
|
|
205
|
+
if (sameGeneration || !idle) {
|
|
206
|
+
kept++;
|
|
207
|
+
} else {
|
|
208
|
+
stale.push(unit);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { stale, kept };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function fingerprintKeys(env, fingerprints) {
|
|
217
|
+
const listings = await listMany(
|
|
218
|
+
env,
|
|
219
|
+
fingerprints.map((entry) => entry.path)
|
|
220
|
+
);
|
|
221
|
+
const jsonFiles = new Map();
|
|
222
|
+
for (const entry of fingerprints) {
|
|
223
|
+
const json = (listings.get(entry.path) ?? []).find(
|
|
224
|
+
(child) => child.name.endsWith('.json') && !child.name.startsWith('dep-')
|
|
225
|
+
);
|
|
226
|
+
if (json) {
|
|
227
|
+
jsonFiles.set(entry, json);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const heads = await env.readHeads(
|
|
231
|
+
[...jsonFiles.values()].map((json) => json.path),
|
|
232
|
+
8192
|
|
233
|
+
);
|
|
234
|
+
const keys = new Map();
|
|
235
|
+
for (const [entry, json] of jsonFiles) {
|
|
236
|
+
const { crate, hash } = parseArtifactName(entry.name);
|
|
237
|
+
keys.set(
|
|
238
|
+
hash,
|
|
239
|
+
`fp:${crate}:${fingerprintIdentity(json.name, heads.get(json.path))}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return keys;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function fallbackDepsKeys(env, depsDir, entries, known) {
|
|
246
|
+
const groups = new Map();
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const parsed = parseArtifactName(entry.name);
|
|
249
|
+
if (known.has(parsed.hash)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!groups.has(parsed.hash)) {
|
|
253
|
+
groups.set(parsed.hash, { crate: parsed.crate, kinds: new Set() });
|
|
254
|
+
}
|
|
255
|
+
groups.get(parsed.hash).kinds.add(fileKind(parsed.extension));
|
|
256
|
+
}
|
|
257
|
+
const depFile = (hash, crate) => env.path.join(depsDir, `${crate}-${hash}.d`);
|
|
258
|
+
const heads = await env.readHeads(
|
|
259
|
+
[...groups].map(([hash, group]) => depFile(hash, group.crate)),
|
|
260
|
+
4096
|
|
261
|
+
);
|
|
262
|
+
const keys = new Map();
|
|
263
|
+
for (const [hash, group] of groups) {
|
|
264
|
+
const kind = KIND_ORDER.find((k) => group.kinds.has(k)) ?? 'other';
|
|
265
|
+
const root = rootSourceOf(heads.get(depFile(hash, group.crate)));
|
|
266
|
+
keys.set(hash, `deps:${group.crate}:${kind}:${root}`);
|
|
267
|
+
}
|
|
268
|
+
return keys;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildRole(listing) {
|
|
272
|
+
return (listing ?? []).some((c) => c.name.startsWith('build-script-'))
|
|
273
|
+
? 'script'
|
|
274
|
+
: 'run';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function libraryPart(settings, keys, fallback, buildListings, entries) {
|
|
278
|
+
const units = new UnitSet();
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
const { crate, hash } = parseArtifactName(entry.name);
|
|
281
|
+
const key =
|
|
282
|
+
keys.get(hash) ??
|
|
283
|
+
fallback.get(hash) ??
|
|
284
|
+
`build:${crate}:${buildRole(buildListings.get(entry.path))}`;
|
|
285
|
+
units.add(key, hash, entry);
|
|
286
|
+
}
|
|
287
|
+
const { stale, kept } = units.superseded(settings);
|
|
288
|
+
return summarize(stale, kept);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function executablePart(settings, keys, executables) {
|
|
292
|
+
const units = new UnitSet();
|
|
293
|
+
for (const { dir, entry } of executables) {
|
|
294
|
+
const { crate, hash } = parseArtifactName(entry.name);
|
|
295
|
+
units.add(`${dir}:${keys.get(hash) ?? crate}`, hash, entry);
|
|
296
|
+
}
|
|
297
|
+
return units.superseded({ ...settings, staleAgeMs: settings.leafIdleMs });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function incrementalPart(env, settings, listing) {
|
|
301
|
+
const dirs = await withDirUsage(
|
|
302
|
+
env,
|
|
303
|
+
(listing ?? []).filter(
|
|
304
|
+
(entry) => entry.type === 'dir' && INCREMENTAL.test(entry.name)
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
const sessions = await listMany(
|
|
308
|
+
env,
|
|
309
|
+
dirs.map((entry) => entry.path)
|
|
310
|
+
);
|
|
311
|
+
const stale = [];
|
|
312
|
+
let kept = 0;
|
|
313
|
+
for (const entry of dirs) {
|
|
314
|
+
const working = (sessions.get(entry.path) ?? []).some((child) =>
|
|
315
|
+
child.name.endsWith('-working')
|
|
316
|
+
);
|
|
317
|
+
if (working || settings.now - entry.mtimeMs < settings.leafIdleMs) {
|
|
318
|
+
kept++;
|
|
319
|
+
} else {
|
|
320
|
+
stale.push({ entries: [entry] });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { stale, kept };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Superseded artifacts of one profile directory.
|
|
328
|
+
* @param {object} env
|
|
329
|
+
* @param {string} profileDir
|
|
330
|
+
* @param {{staleAgeMs: number, now: number, generationMs?: number,
|
|
331
|
+
* leafIdleMs?: number}} options `leafIdleMs` defaults to the larger of
|
|
332
|
+
* 3 hours and `staleAgeMs`
|
|
333
|
+
* @returns {Promise<{entries: Array<{path: string, bytes: number,
|
|
334
|
+
* mtimeMs: number}>, bytes: number, kept: number, removed: number,
|
|
335
|
+
* library: object, leaf: object}>} both parts together plus `library`
|
|
336
|
+
* and `leaf` with the same shape
|
|
337
|
+
*/
|
|
338
|
+
export async function analyzeRustProfile(env, profileDir, options) {
|
|
339
|
+
const settings = {
|
|
340
|
+
generationMs: DEFAULT_GENERATION_MS,
|
|
341
|
+
...options,
|
|
342
|
+
leafIdleMs:
|
|
343
|
+
options.leafIdleMs ??
|
|
344
|
+
Math.max(DEFAULT_LEAF_IDLE_MS, options.staleAgeMs ?? 0),
|
|
345
|
+
};
|
|
346
|
+
const sub = (name) => env.path.join(profileDir, name);
|
|
347
|
+
const listings = await listMany(env, [
|
|
348
|
+
sub('deps'),
|
|
349
|
+
sub('build'),
|
|
350
|
+
sub('incremental'),
|
|
351
|
+
sub('.fingerprint'),
|
|
352
|
+
sub('examples'),
|
|
353
|
+
]);
|
|
354
|
+
const fingerprints = await withDirUsage(
|
|
355
|
+
env,
|
|
356
|
+
hashedEntries(listings.get(sub('.fingerprint')))
|
|
357
|
+
);
|
|
358
|
+
const deps = await withDirUsage(
|
|
359
|
+
env,
|
|
360
|
+
hashedEntries(listings.get(sub('deps')))
|
|
361
|
+
);
|
|
362
|
+
const builds = await withDirUsage(
|
|
363
|
+
env,
|
|
364
|
+
hashedEntries(listings.get(sub('build')))
|
|
365
|
+
);
|
|
366
|
+
const keys = await fingerprintKeys(env, fingerprints);
|
|
367
|
+
const fallback = await fallbackDepsKeys(env, sub('deps'), deps, keys);
|
|
368
|
+
const buildListings = await listMany(
|
|
369
|
+
env,
|
|
370
|
+
builds
|
|
371
|
+
.filter((entry) => !keys.has(parseArtifactName(entry.name).hash))
|
|
372
|
+
.map((entry) => entry.path)
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const library = libraryPart(settings, keys, fallback, buildListings, [
|
|
376
|
+
...deps.filter((entry) => !isExecutable(entry)),
|
|
377
|
+
...builds,
|
|
378
|
+
...fingerprints,
|
|
379
|
+
]);
|
|
380
|
+
const executables = executablePart(settings, keys, [
|
|
381
|
+
...deps.filter(isExecutable).map((entry) => ({ dir: 'deps', entry })),
|
|
382
|
+
...hashedEntries(listings.get(sub('examples')))
|
|
383
|
+
.filter(isExecutable)
|
|
384
|
+
.map((entry) => ({ dir: 'examples', entry })),
|
|
385
|
+
]);
|
|
386
|
+
const incremental = await incrementalPart(
|
|
387
|
+
env,
|
|
388
|
+
settings,
|
|
389
|
+
listings.get(sub('incremental'))
|
|
390
|
+
);
|
|
391
|
+
const leaf = summarize(
|
|
392
|
+
[...incremental.stale, ...executables.stale],
|
|
393
|
+
incremental.kept + executables.kept
|
|
394
|
+
);
|
|
395
|
+
const entries = [...library.entries, ...leaf.entries];
|
|
396
|
+
return {
|
|
397
|
+
entries,
|
|
398
|
+
bytes: library.bytes + leaf.bytes,
|
|
399
|
+
kept: library.kept + leaf.kept,
|
|
400
|
+
removed: library.removed + leaf.removed,
|
|
401
|
+
library,
|
|
402
|
+
leaf,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System reclaimers that only the owning tool can clean correctly:
|
|
3
|
+
* journald archives (`journalctl --vacuum-size`), disabled snap revisions
|
|
4
|
+
* (`snap remove --revision`) and Homebrew's old versions and downloads
|
|
5
|
+
* (`brew cleanup`). Plain cache directories live in ../rules/other.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { block, makeItem } from '../items.js';
|
|
9
|
+
import { parseSize } from '../units.js';
|
|
10
|
+
import { scanTimeBusy } from './common.js';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_JOURNAL_KEEP = 512 * 1024 ** 2;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bytes reported by `journalctl --disk-usage`.
|
|
16
|
+
* @returns {number|null}
|
|
17
|
+
*/
|
|
18
|
+
export function parseJournalUsage(output) {
|
|
19
|
+
const match = /take up ([\d.]+\s*[A-Za-z]*) in the file system/.exec(
|
|
20
|
+
output ?? ''
|
|
21
|
+
);
|
|
22
|
+
return match ? parseSize(match[1]) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Disabled revisions from `snap list --all`.
|
|
27
|
+
* @returns {Array<{name: string, revision: string}>}
|
|
28
|
+
*/
|
|
29
|
+
export function parseDisabledSnaps(output) {
|
|
30
|
+
const lines = (output ?? '').split('\n').filter(Boolean);
|
|
31
|
+
const header = lines.shift() ?? '';
|
|
32
|
+
if (!/^Name\s+Version\s+Rev\b/.test(header)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return lines
|
|
36
|
+
.map((line) => line.trim().split(/\s+/))
|
|
37
|
+
.filter((cols) => cols.length >= 6 && /\bdisabled\b/.test(cols.at(-1)))
|
|
38
|
+
.map((cols) => ({ name: cols[0], revision: cols[2] }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Size freed according to `brew cleanup --dry-run`.
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function parseBrewCleanup(output) {
|
|
46
|
+
const total = /would free approximately ([\d.]+\s*[A-Za-z]+)/.exec(
|
|
47
|
+
output ?? ''
|
|
48
|
+
);
|
|
49
|
+
if (total) {
|
|
50
|
+
return parseSize(total[1]);
|
|
51
|
+
}
|
|
52
|
+
return [...(output ?? '').matchAll(/,\s*([\d.]+\s*[KMGT]?B)\)$/gm)].reduce(
|
|
53
|
+
(sum, match) => sum + parseSize(match[1]),
|
|
54
|
+
0
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withBusy(context, item) {
|
|
59
|
+
const busy = scanTimeBusy(context, item);
|
|
60
|
+
if (busy) {
|
|
61
|
+
block(item, `busy: ${busy}`);
|
|
62
|
+
}
|
|
63
|
+
return item;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function journalItems(context, isRoot) {
|
|
67
|
+
const { env, options } = context;
|
|
68
|
+
if (!(await env.which('journalctl'))) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const result = await env.run(['journalctl', '--disk-usage']);
|
|
72
|
+
const total = parseJournalUsage(`${result.stdout}\n${result.stderr}`);
|
|
73
|
+
const keep = options.journalKeepBytes ?? DEFAULT_JOURNAL_KEEP;
|
|
74
|
+
if (total === null || total <= keep) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const item = makeItem(env, {
|
|
78
|
+
rule: 'journald-archives',
|
|
79
|
+
kind: 'logs',
|
|
80
|
+
ecosystem: 'system',
|
|
81
|
+
description: 'systemd journal archives above the kept size',
|
|
82
|
+
path: '/var/log/journal',
|
|
83
|
+
bytes: total - keep,
|
|
84
|
+
tier: 'moderate',
|
|
85
|
+
reason: `journals take ${total} bytes, vacuuming keeps the newest ${keep}`,
|
|
86
|
+
action: {
|
|
87
|
+
type: 'command',
|
|
88
|
+
argv: ['journalctl', `--vacuum-size=${keep}`],
|
|
89
|
+
measure: ['/var/log/journal', '/run/log/journal'],
|
|
90
|
+
},
|
|
91
|
+
checks: { busy: [], cwd: null, mtime: false },
|
|
92
|
+
});
|
|
93
|
+
if (!isRoot) {
|
|
94
|
+
block(item, 'system location, run as root to clean it');
|
|
95
|
+
}
|
|
96
|
+
return [item];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function snapItems(context, isRoot) {
|
|
100
|
+
const { env } = context;
|
|
101
|
+
if (!(await env.which('snap'))) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const result = await env.run(['snap', 'list', '--all']);
|
|
105
|
+
const items = [];
|
|
106
|
+
for (const { name, revision } of parseDisabledSnaps(result.stdout)) {
|
|
107
|
+
const file = `/var/lib/snapd/snaps/${name}_${revision}.snap`;
|
|
108
|
+
const stat = await env.stat(file);
|
|
109
|
+
const item = makeItem(env, {
|
|
110
|
+
rule: 'snap-disabled-revision',
|
|
111
|
+
kind: 'package',
|
|
112
|
+
ecosystem: 'system',
|
|
113
|
+
description: `disabled snap revision ${name} ${revision}`,
|
|
114
|
+
path: file,
|
|
115
|
+
bytes: stat?.bytes ?? 0,
|
|
116
|
+
newestMtimeMs: stat?.mtimeMs ?? 0,
|
|
117
|
+
tier: 'safe',
|
|
118
|
+
reason: 'superseded revision kept only for rollback',
|
|
119
|
+
action: {
|
|
120
|
+
type: 'command',
|
|
121
|
+
argv: ['snap', 'remove', name, `--revision=${revision}`],
|
|
122
|
+
measure: [file],
|
|
123
|
+
},
|
|
124
|
+
checks: { busy: ['snap', 'snapd'], cwd: null, mtime: false },
|
|
125
|
+
});
|
|
126
|
+
if (!isRoot) {
|
|
127
|
+
block(item, 'system location, run as root to clean it');
|
|
128
|
+
}
|
|
129
|
+
items.push(withBusy(context, item));
|
|
130
|
+
}
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function brewItems(context, isRoot) {
|
|
135
|
+
const { env } = context;
|
|
136
|
+
if (isRoot || !(await env.which('brew'))) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const result = await env.run(['brew', 'cleanup', '--dry-run']);
|
|
140
|
+
const bytes = result.code === 0 ? parseBrewCleanup(result.stdout) : 0;
|
|
141
|
+
if (bytes === 0) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const prefix = (await env.run(['brew', '--prefix'])).stdout.trim();
|
|
145
|
+
const item = makeItem(env, {
|
|
146
|
+
rule: 'brew-cleanup',
|
|
147
|
+
kind: 'package',
|
|
148
|
+
ecosystem: 'system',
|
|
149
|
+
description: 'Homebrew outdated versions and stale downloads',
|
|
150
|
+
path: prefix || 'brew',
|
|
151
|
+
bytes,
|
|
152
|
+
tier: 'moderate',
|
|
153
|
+
reason: 'Homebrew may remove old kegs and unneeded dependencies',
|
|
154
|
+
action: { type: 'command', argv: ['brew', 'cleanup'], measure: [] },
|
|
155
|
+
checks: { busy: ['brew'], cwd: null, mtime: false },
|
|
156
|
+
});
|
|
157
|
+
return [withBusy(context, item)];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scans journald, snap and Homebrew.
|
|
162
|
+
* @returns {Promise<object[]>} report items
|
|
163
|
+
*/
|
|
164
|
+
export async function scanSystem(context) {
|
|
165
|
+
const isRoot = await context.env.isRoot();
|
|
166
|
+
return [
|
|
167
|
+
...(await journalItems(context, isRoot)),
|
|
168
|
+
...(await snapItems(context, isRoot)),
|
|
169
|
+
...(await brewItems(context, isRoot)),
|
|
170
|
+
];
|
|
171
|
+
}
|