disk-space-saviour 0.15.1 → 0.16.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 +20 -0
- package/README.md +35 -1
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +124 -6
- package/src/cli.js +24 -2
- package/src/docker/cli.js +11 -7
- package/src/docker/scan.js +25 -4
- package/src/docker/upperdir.js +44 -0
- package/src/docker/writable.js +90 -13
- package/src/env/batches.js +20 -0
- package/src/env/cache-roots.js +138 -0
- package/src/env/inspection.js +100 -0
- package/src/env/local.js +69 -32
- package/src/env/proc.js +13 -1
- package/src/env/process-scripts.js +24 -0
- package/src/env/scope.js +95 -0
- package/src/env/shell.js +105 -107
- package/src/exec.js +79 -8
- package/src/health.js +13 -2
- package/src/index.d.ts +25 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +6 -0
- package/src/report.js +8 -2
- package/src/rules/toolchains.js +66 -0
- package/src/scan.js +112 -7
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +72 -20
- package/src/scanners/projects.js +27 -2
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +18 -9
package/src/liveness.js
CHANGED
|
@@ -20,6 +20,46 @@ import { formatDuration } from './units.js';
|
|
|
20
20
|
|
|
21
21
|
const PROC_COMM_LIMIT = 15;
|
|
22
22
|
|
|
23
|
+
function uncertaintyRoots(proc) {
|
|
24
|
+
const roots = processArgs(proc).flatMap((arg) => {
|
|
25
|
+
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
|
|
26
|
+
return value.startsWith('/') ? [value] : [];
|
|
27
|
+
});
|
|
28
|
+
if (proc.name === 'dockerd') {
|
|
29
|
+
roots.push('/var/lib/docker', '/run');
|
|
30
|
+
}
|
|
31
|
+
if (proc.name === 'containerd') {
|
|
32
|
+
roots.push('/var/lib/containerd', '/run/containerd');
|
|
33
|
+
}
|
|
34
|
+
return roots;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function couldAccess(proc, stat) {
|
|
38
|
+
return (
|
|
39
|
+
proc.uid === 0 ||
|
|
40
|
+
typeof proc.uid !== 'number' ||
|
|
41
|
+
!stat ||
|
|
42
|
+
stat.uid === undefined ||
|
|
43
|
+
stat.uid === proc.uid ||
|
|
44
|
+
stat.mode === undefined ||
|
|
45
|
+
(stat.mode & 0o007) !== 0 ||
|
|
46
|
+
// Group bits can also be an ACL mask granting another named identity.
|
|
47
|
+
(stat.mode & 0o070) !== 0
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ancestors(target, pathApi) {
|
|
52
|
+
const result = [];
|
|
53
|
+
for (
|
|
54
|
+
let dir = pathApi.dirname(target);
|
|
55
|
+
dir !== pathApi.dirname(dir);
|
|
56
|
+
dir = pathApi.dirname(dir)
|
|
57
|
+
) {
|
|
58
|
+
result.push(dir);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
23
63
|
function nameMatches(name, candidate) {
|
|
24
64
|
return (
|
|
25
65
|
name === candidate ||
|
|
@@ -131,6 +171,7 @@ export class LivenessProbe {
|
|
|
131
171
|
this.probeError = false;
|
|
132
172
|
this.realPaths = new Map();
|
|
133
173
|
this.pathOwners = new Map();
|
|
174
|
+
this.pathStats = new Map();
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
async refresh(force = false) {
|
|
@@ -145,6 +186,9 @@ export class LivenessProbe {
|
|
|
145
186
|
(processes === null || this.openPaths === null) &&
|
|
146
187
|
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
147
188
|
this.refreshedAt = this.now();
|
|
189
|
+
this.realPaths.clear();
|
|
190
|
+
this.pathStats.clear();
|
|
191
|
+
this.pathOwners.clear();
|
|
148
192
|
}
|
|
149
193
|
|
|
150
194
|
/** Processes of the last refresh, or null when they could not be listed. */
|
|
@@ -159,18 +203,46 @@ export class LivenessProbe {
|
|
|
159
203
|
* @param {string[]} paths
|
|
160
204
|
*/
|
|
161
205
|
async resolve(paths) {
|
|
162
|
-
if (this.env.unreadableProcesses?.length
|
|
163
|
-
|
|
164
|
-
|
|
206
|
+
if (this.env.unreadableProcesses?.length) {
|
|
207
|
+
const missing = [
|
|
208
|
+
...new Set(
|
|
209
|
+
paths.flatMap((target) => [
|
|
210
|
+
target,
|
|
211
|
+
...ancestors(target, this.env.path),
|
|
212
|
+
])
|
|
213
|
+
),
|
|
214
|
+
].filter((target) => !this.pathStats.has(target));
|
|
215
|
+
const stats = this.env.statMany
|
|
216
|
+
? await this.env.statMany(missing)
|
|
217
|
+
: new Map(
|
|
218
|
+
await Promise.all(
|
|
219
|
+
missing.map(async (target) => [
|
|
220
|
+
target,
|
|
221
|
+
await this.env.stat?.(target),
|
|
222
|
+
])
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
for (const target of missing) {
|
|
226
|
+
this.pathStats.set(target, stats.get(target));
|
|
227
|
+
this.pathOwners.set(target, stats.get(target)?.uid);
|
|
165
228
|
}
|
|
166
229
|
}
|
|
167
|
-
if (
|
|
230
|
+
if (!this.env.realPath && !this.env.realPathMany) {
|
|
168
231
|
return;
|
|
169
232
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
233
|
+
const missing = paths.filter((target) => !this.realPaths.has(target));
|
|
234
|
+
const resolved = this.env.realPathMany
|
|
235
|
+
? await this.env.realPathMany(missing)
|
|
236
|
+
: new Map(
|
|
237
|
+
await Promise.all(
|
|
238
|
+
missing.map(async (target) => [
|
|
239
|
+
target,
|
|
240
|
+
await this.env.realPath(target),
|
|
241
|
+
])
|
|
242
|
+
)
|
|
243
|
+
);
|
|
244
|
+
for (const target of missing) {
|
|
245
|
+
this.realPaths.set(target, resolved.get(target) ?? null);
|
|
174
246
|
}
|
|
175
247
|
}
|
|
176
248
|
|
|
@@ -271,29 +343,25 @@ export class LivenessProbe {
|
|
|
271
343
|
uncertainUsage(paths) {
|
|
272
344
|
for (const proc of this.env.unreadableProcesses ?? []) {
|
|
273
345
|
const args = processArgs(proc);
|
|
274
|
-
const roots =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
roots.push('/var/lib/docker', '/run');
|
|
280
|
-
}
|
|
281
|
-
if (proc.name === 'containerd') {
|
|
282
|
-
roots.push('/var/lib/containerd', '/run/containerd');
|
|
283
|
-
}
|
|
346
|
+
const roots = uncertaintyRoots(proc);
|
|
347
|
+
const scopedDaemon =
|
|
348
|
+
proc.uid === 0 &&
|
|
349
|
+
['dockerd', 'containerd'].includes(proc.name) &&
|
|
350
|
+
args.length > 0;
|
|
284
351
|
for (const target of paths) {
|
|
285
352
|
const intersects = roots.some(
|
|
286
353
|
(root) =>
|
|
287
354
|
isWithin(root, target, this.env.path) ||
|
|
288
355
|
isWithin(target, root, this.env.path)
|
|
289
356
|
);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
357
|
+
if (scopedDaemon && !intersects) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!intersects && this.inaccessible(proc, target)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const stat = this.pathStats.get(target);
|
|
364
|
+
if (intersects || couldAccess(proc, stat)) {
|
|
297
365
|
return `unreadable process ${proc.name || 'unknown'} (pid ${proc.pid}, uid ${proc.uid ?? 'unknown'}) may use ${target}`;
|
|
298
366
|
}
|
|
299
367
|
}
|
|
@@ -301,6 +369,25 @@ export class LivenessProbe {
|
|
|
301
369
|
return null;
|
|
302
370
|
}
|
|
303
371
|
|
|
372
|
+
inaccessible(proc, target) {
|
|
373
|
+
if (proc.uid === 0 || typeof proc.uid !== 'number') {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
return ancestors(target, this.env.path).some((dir) => {
|
|
377
|
+
const stat = this.pathStats.get(dir);
|
|
378
|
+
if (
|
|
379
|
+
!stat ||
|
|
380
|
+
stat.mode === undefined ||
|
|
381
|
+
stat.uid === proc.uid ||
|
|
382
|
+
stat.mode & 0o001
|
|
383
|
+
) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const groupAccess = stat.mode & 0o010;
|
|
387
|
+
return !groupAccess;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
304
391
|
/**
|
|
305
392
|
* First reason the item is busy, or null when it is idle. Items with
|
|
306
393
|
* `checks.perPath` leave open files to `openPathsOf`: the caller skips
|
package/src/options.js
CHANGED
|
@@ -32,6 +32,7 @@ export const DEFAULTS = {
|
|
|
32
32
|
yes: false,
|
|
33
33
|
backupDir: null,
|
|
34
34
|
auditDir: null,
|
|
35
|
+
scanBudget: '2m',
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
function toBytes(value) {
|
|
@@ -70,6 +71,10 @@ export function resolveOptions(input = {}) {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
const staleAge = input.olderThan ?? merged.staleAge;
|
|
74
|
+
const scanBudgetMs = toMs(merged.scanBudget);
|
|
75
|
+
if (!Number.isFinite(scanBudgetMs) || scanBudgetMs <= 0) {
|
|
76
|
+
throw new Error('scanBudget must be a positive finite duration');
|
|
77
|
+
}
|
|
73
78
|
const roots = toList(merged.roots);
|
|
74
79
|
return {
|
|
75
80
|
...merged,
|
|
@@ -83,6 +88,7 @@ export function resolveOptions(input = {}) {
|
|
|
83
88
|
? null
|
|
84
89
|
: toMs(merged.investigationMaxAge),
|
|
85
90
|
minSizeBytes: toBytes(merged.minSize),
|
|
91
|
+
scanBudgetMs,
|
|
86
92
|
journalKeepBytes: toBytes(merged.journalKeep),
|
|
87
93
|
containerFilter: toList(merged.containers),
|
|
88
94
|
allowDirtyContainers: toList(merged.allowDirtyContainers),
|
package/src/report.js
CHANGED
|
@@ -52,7 +52,7 @@ function itemLine(item, showEnv) {
|
|
|
52
52
|
? ` (needs ${consentFlag(item)})`
|
|
53
53
|
: '';
|
|
54
54
|
const storage = item.sizeUnknown
|
|
55
|
-
?
|
|
55
|
+
? ` (${formatBytes(item.totalBytes ?? 0)} visible; may be in an image layer; freeable bytes unknown, excluded from totals)`
|
|
56
56
|
: item.imageBytes
|
|
57
57
|
? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
|
|
58
58
|
: '';
|
|
@@ -102,7 +102,7 @@ function blockedSection(report, options) {
|
|
|
102
102
|
}
|
|
103
103
|
return [
|
|
104
104
|
'',
|
|
105
|
-
`BLOCKED ${plural(items.length, 'item')} (kept)`,
|
|
105
|
+
`BLOCKED ${plural(items.length, 'item')} (kept), ${formatBytes(report.totals.blocked.bytes)} known writable, ${formatBytes(report.totals.blocked.totalBytes ?? report.totals.blocked.bytes)} visible${report.totals.blocked.unknownBytes ? ` (${formatBytes(report.totals.blocked.unknownBytes)} with unknown writable size)` : ''}`,
|
|
106
106
|
...limited(lines, options.limit * 2),
|
|
107
107
|
];
|
|
108
108
|
}
|
|
@@ -322,6 +322,12 @@ export function formatReport(report, options = {}) {
|
|
|
322
322
|
lines.push(...tierSection(report, tier, { limit }));
|
|
323
323
|
}
|
|
324
324
|
for (const env of report.environments) {
|
|
325
|
+
if (env.layerSource) {
|
|
326
|
+
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
327
|
+
}
|
|
328
|
+
if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
329
|
+
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
330
|
+
}
|
|
325
331
|
if (env.hint) {
|
|
326
332
|
lines.push(` ${env.label}: ${env.hint}`);
|
|
327
333
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Download leftovers of language version managers; installed runtimes are kept. */
|
|
2
|
+
export const TOOLCHAIN_CACHE_RULES = [
|
|
3
|
+
{
|
|
4
|
+
id: 'perlbrew-build',
|
|
5
|
+
ecosystem: 'scripting',
|
|
6
|
+
description: 'perlbrew build trees and source archives',
|
|
7
|
+
paths: [
|
|
8
|
+
'~/perl5/perlbrew/build',
|
|
9
|
+
'~/perl5/perlbrew/dists',
|
|
10
|
+
'~/.perl5/build',
|
|
11
|
+
'~/.perl5/dists',
|
|
12
|
+
],
|
|
13
|
+
aggregate: true,
|
|
14
|
+
busy: ['perlbrew', 'perl', 'make'],
|
|
15
|
+
native: {
|
|
16
|
+
tool: 'perlbrew',
|
|
17
|
+
argv: ['perlbrew', 'clean'],
|
|
18
|
+
cacheRoot: '~/perl5/perlbrew',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'sdkman-tmp',
|
|
23
|
+
ecosystem: 'jvm',
|
|
24
|
+
description: 'SDKMAN download leftovers',
|
|
25
|
+
paths: ['~/.sdkman/tmp/*'],
|
|
26
|
+
aggregate: true,
|
|
27
|
+
busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
|
|
28
|
+
native: {
|
|
29
|
+
tool: 'bash',
|
|
30
|
+
init: '~/.sdkman/bin/sdkman-init.sh',
|
|
31
|
+
command: 'sdk flush tmp',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'sdkman-archives',
|
|
36
|
+
ecosystem: 'jvm',
|
|
37
|
+
description: 'SDKMAN downloaded archives',
|
|
38
|
+
paths: ['~/.sdkman/archives/*'],
|
|
39
|
+
aggregate: true,
|
|
40
|
+
busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'nvm-cache',
|
|
44
|
+
ecosystem: 'javascript',
|
|
45
|
+
description: 'nvm downloaded Node archives',
|
|
46
|
+
paths: ['~/.nvm/.cache'],
|
|
47
|
+
busy: ['nvm', 'node', 'curl'],
|
|
48
|
+
native: { tool: 'sh', init: '~/.nvm/nvm.sh', command: 'nvm cache clear' },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'ruby-gem-cache',
|
|
52
|
+
ecosystem: 'ruby',
|
|
53
|
+
description: 'downloaded Ruby gem archives',
|
|
54
|
+
paths: [
|
|
55
|
+
'~/.rbenv/versions/*/lib/ruby/gems/*/cache/*.gem',
|
|
56
|
+
'~/.rvm/gems/*/cache/*.gem',
|
|
57
|
+
'~/.local/share/gem/ruby/*/cache/*.gem',
|
|
58
|
+
'~/.gem/ruby/*/cache/*.gem',
|
|
59
|
+
'{GEM_HOME}/cache/*.gem',
|
|
60
|
+
'{GEM_PATH}/cache/*.gem',
|
|
61
|
+
],
|
|
62
|
+
aggregate: true,
|
|
63
|
+
fileOnly: true,
|
|
64
|
+
busy: ['gem', 'bundle', 'ruby'],
|
|
65
|
+
},
|
|
66
|
+
];
|
package/src/scan.js
CHANGED
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
configureContainerHost,
|
|
8
8
|
resetWritableLayers,
|
|
9
9
|
} from './docker/writable.js';
|
|
10
|
+
import { setInterval, clearInterval } from 'node:timers';
|
|
10
11
|
import { scanDocker } from './docker/scan.js';
|
|
11
12
|
import { LocalEnv } from './env/local.js';
|
|
12
13
|
import { configureScanPolicy } from './env/policy.js';
|
|
14
|
+
import { configureCacheRoots } from './env/cache-roots.js';
|
|
15
|
+
import { checkSignal, environmentScope } from './env/scope.js';
|
|
13
16
|
import { block, tierTotals } from './items.js';
|
|
14
17
|
import { hostExecutor, trace } from './exec.js';
|
|
15
18
|
import { GitInspector } from './git.js';
|
|
@@ -59,6 +62,9 @@ const RESCAN_OPTIONS = new Set([
|
|
|
59
62
|
'journalKeep',
|
|
60
63
|
'env',
|
|
61
64
|
'now',
|
|
65
|
+
'signal',
|
|
66
|
+
'scanBudget',
|
|
67
|
+
'onScanProgress',
|
|
62
68
|
]);
|
|
63
69
|
|
|
64
70
|
function safetySnapshot(report) {
|
|
@@ -133,6 +139,7 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
133
139
|
});
|
|
134
140
|
await liveness.refresh(true);
|
|
135
141
|
const homes = await env.homeDirs();
|
|
142
|
+
await configureCacheRoots(env, homes);
|
|
136
143
|
const tmpDirs = await env.tmpDirs();
|
|
137
144
|
const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
|
|
138
145
|
return {
|
|
@@ -152,16 +159,71 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
152
159
|
* @returns {Promise<object[]>} items
|
|
153
160
|
*/
|
|
154
161
|
export async function scanEnvironment(env, options, extra = {}) {
|
|
155
|
-
const
|
|
156
|
-
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const abort = () => controller.abort(options.signal.reason);
|
|
164
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
165
|
+
if (options.signal?.aborted) {
|
|
166
|
+
abort();
|
|
167
|
+
}
|
|
168
|
+
const started = Date.now(),
|
|
169
|
+
deadline = started + options.scanBudgetMs;
|
|
170
|
+
const timer = setTimeout(
|
|
171
|
+
() => controller.abort(new Error('scan time budget exceeded')),
|
|
172
|
+
options.scanBudgetMs
|
|
173
|
+
);
|
|
174
|
+
const found = new Map();
|
|
175
|
+
let scanner = 'context';
|
|
176
|
+
const progress = () =>
|
|
177
|
+
options.onScanProgress?.({
|
|
178
|
+
env: env.id,
|
|
179
|
+
scanner,
|
|
180
|
+
elapsedMs: Date.now() - started,
|
|
181
|
+
items: found.size,
|
|
182
|
+
status: env.scanStatus,
|
|
183
|
+
});
|
|
184
|
+
env.scanStatus = 'scanning';
|
|
185
|
+
const interval = setInterval(progress, 5000);
|
|
157
186
|
try {
|
|
158
|
-
|
|
187
|
+
return await environmentScope(
|
|
188
|
+
env,
|
|
189
|
+
controller.signal,
|
|
190
|
+
() => {
|
|
191
|
+
progress();
|
|
192
|
+
return scanEnvironmentScoped(env, options, extra, found, (name) => {
|
|
193
|
+
scanner = name;
|
|
194
|
+
progress();
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
deadline
|
|
198
|
+
);
|
|
159
199
|
} catch (error) {
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
env.scanStatus = options.signal?.aborted
|
|
201
|
+
? 'aborted'
|
|
202
|
+
: controller.signal.aborted
|
|
203
|
+
? 'budget-exceeded'
|
|
204
|
+
: 'error';
|
|
205
|
+
(extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
|
|
206
|
+
const items = [...found.values()];
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
209
|
+
}
|
|
210
|
+
return items;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
clearInterval(interval);
|
|
214
|
+
options.signal?.removeEventListener('abort', abort);
|
|
215
|
+
progress();
|
|
162
216
|
}
|
|
163
|
-
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function scanEnvironmentScoped(env, options, extra, found, stage) {
|
|
220
|
+
const errors = extra.errors ?? [];
|
|
221
|
+
const context = await environmentContext(env, options, extra);
|
|
222
|
+
context.emit = (item) => found.set(item.id, item);
|
|
223
|
+
let incomplete = false;
|
|
164
224
|
for (const name of options.scanners) {
|
|
225
|
+
checkSignal(env.signal);
|
|
226
|
+
stage(name);
|
|
165
227
|
const scanner = SCANNERS[name];
|
|
166
228
|
if (!scanner) {
|
|
167
229
|
throw new Error(
|
|
@@ -169,13 +231,26 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
169
231
|
);
|
|
170
232
|
}
|
|
171
233
|
const started = Date.now();
|
|
234
|
+
const previous = new Set(found.keys());
|
|
172
235
|
try {
|
|
173
|
-
|
|
236
|
+
const entries = await scanner(context);
|
|
237
|
+
for (const item of entries) {
|
|
238
|
+
context.emit(item);
|
|
239
|
+
}
|
|
174
240
|
} catch (error) {
|
|
241
|
+
checkSignal(env.signal);
|
|
242
|
+
incomplete = true;
|
|
243
|
+
for (const [id, item] of found) {
|
|
244
|
+
if (!previous.has(id)) {
|
|
245
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
175
248
|
errors.push({ env: env.id, scanner: name, message: error.message });
|
|
176
249
|
}
|
|
177
250
|
trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
|
|
178
251
|
}
|
|
252
|
+
stage('storage');
|
|
253
|
+
const items = [...found.values()];
|
|
179
254
|
await attributeStorage(env, items);
|
|
180
255
|
for (const item of items) {
|
|
181
256
|
for (const target of item.paths) {
|
|
@@ -185,6 +260,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
185
260
|
for (const message of env.accessErrors?.values() ?? []) {
|
|
186
261
|
errors.push({ env: env.id, scanner: 'filesystem', message });
|
|
187
262
|
}
|
|
263
|
+
env.scanStatus = incomplete ? 'error' : 'complete';
|
|
188
264
|
return items;
|
|
189
265
|
}
|
|
190
266
|
|
|
@@ -307,6 +383,8 @@ function envDescriptor(env, depth, chain) {
|
|
|
307
383
|
depth,
|
|
308
384
|
chain,
|
|
309
385
|
hint: env.probeHint ?? null,
|
|
386
|
+
scanStatus: env.scanStatus ?? null,
|
|
387
|
+
layerSource: env.writableLayer?.source ?? null,
|
|
310
388
|
};
|
|
311
389
|
}
|
|
312
390
|
|
|
@@ -373,6 +451,30 @@ function blockMountedHostItems(items, mounts, pathApi) {
|
|
|
373
451
|
}
|
|
374
452
|
|
|
375
453
|
export async function scan(input = {}) {
|
|
454
|
+
const env = input.env ?? new LocalEnv();
|
|
455
|
+
try {
|
|
456
|
+
return await environmentScope(env, input.signal, () =>
|
|
457
|
+
scanScoped({ ...input, env })
|
|
458
|
+
);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (!input.signal?.aborted) {
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
env.scanStatus = 'aborted';
|
|
464
|
+
const options = resolveOptions(input);
|
|
465
|
+
return buildReport({
|
|
466
|
+
options,
|
|
467
|
+
env,
|
|
468
|
+
items: [],
|
|
469
|
+
environments: [envDescriptor(env, 0, [])],
|
|
470
|
+
docker: null,
|
|
471
|
+
errors: [{ env: env.id, scanner: 'context', message: error.message }],
|
|
472
|
+
startedAt: options.now(),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function scanScoped(input) {
|
|
376
478
|
const options = resolveOptions(input);
|
|
377
479
|
const env = input.env ?? new LocalEnv();
|
|
378
480
|
const startedAt = options.now();
|
|
@@ -420,6 +522,8 @@ function reportOptions(options) {
|
|
|
420
522
|
const serializable = { ...options };
|
|
421
523
|
delete serializable.now;
|
|
422
524
|
delete serializable.env;
|
|
525
|
+
delete serializable.signal;
|
|
526
|
+
delete serializable.onScanProgress;
|
|
423
527
|
return serializable;
|
|
424
528
|
}
|
|
425
529
|
|
|
@@ -440,6 +544,7 @@ export function buildReport({
|
|
|
440
544
|
tool: 'disk-space-saviour',
|
|
441
545
|
createdAt: new Date(options.now()).toISOString(),
|
|
442
546
|
durationMs: options.now() - startedAt,
|
|
547
|
+
aborted: Boolean(options.signal?.aborted),
|
|
443
548
|
host: { env: env.id, label: env.label, platform: env.platform },
|
|
444
549
|
options: reportOptions(options),
|
|
445
550
|
environments,
|
package/src/scanners/agents.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scanItems } from '../env/scope.js';
|
|
1
2
|
/**
|
|
2
3
|
* Snapshot stores of AI coding agents built on the opencode storage layout
|
|
3
4
|
* (`@link-assistant/agent`, opencode).
|
|
@@ -112,7 +113,7 @@ async function appItems(context, app, root) {
|
|
|
112
113
|
mtimeMs: usages.get(state.entry.path)?.newestMtimeMs ?? 0,
|
|
113
114
|
}))
|
|
114
115
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].state;
|
|
115
|
-
const items =
|
|
116
|
+
const items = scanItems(context);
|
|
116
117
|
for (const state of states) {
|
|
117
118
|
const usage = usages.get(state.entry.path);
|
|
118
119
|
const verdict =
|
|
@@ -148,7 +149,7 @@ async function appItems(context, app, root) {
|
|
|
148
149
|
export async function scanAgents(context, { apps = AGENT_APPS } = {}) {
|
|
149
150
|
const { env, options } = context;
|
|
150
151
|
const homes = options.homes ?? (await env.homeDirs());
|
|
151
|
-
const items =
|
|
152
|
+
const items = scanItems(context);
|
|
152
153
|
const seen = new Set();
|
|
153
154
|
for (const home of homes) {
|
|
154
155
|
for (const dataDir of dataDirs(env, home)) {
|
package/src/scanners/common.js
CHANGED
|
@@ -26,10 +26,15 @@ export async function listMany(env, dirs) {
|
|
|
26
26
|
* @returns {string|null}
|
|
27
27
|
*/
|
|
28
28
|
export async function scanTimeBusy(context, item) {
|
|
29
|
-
await context
|
|
29
|
+
await resolvePaths(context, item.paths);
|
|
30
30
|
return context.liveness ? context.liveness.busyReason(item) : null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Prime one batch before constructing items, so later per-item probes are cached. */
|
|
34
|
+
export async function resolvePaths(context, paths) {
|
|
35
|
+
await context.liveness?.resolve(paths);
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* Whether `now - timestamp` exceeds `windowMs` (unknown timestamps count as
|
|
35
40
|
* recent, so they are never treated as abandoned).
|