disk-space-saviour 0.15.2 → 0.17.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 +74 -1
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +153 -11
- package/src/cli.js +102 -14
- 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/emergency.js +37 -6
- 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 +314 -96
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- 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 +146 -108
- package/src/exec.js +79 -8
- package/src/fast-clean.js +39 -0
- package/src/health.js +16 -5
- package/src/index.d.ts +52 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +9 -1
- package/src/report.js +25 -3
- package/src/rules/toolchains.js +66 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +240 -19
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +91 -22
- package/src/scanners/projects.js +114 -20
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +25 -13
- package/src/swap.js +69 -0
package/src/cli.js
CHANGED
|
@@ -15,9 +15,11 @@ import { startAudit, writeAudit } from './audit.js';
|
|
|
15
15
|
import { clean } from './clean.js';
|
|
16
16
|
import { emergency } from './emergency.js';
|
|
17
17
|
import { setTrace } from './exec.js';
|
|
18
|
-
import { resolveGoal } from './options.js';
|
|
18
|
+
import { resolveGoal, resolveOptions } from './options.js';
|
|
19
19
|
import { formatAudit, formatReport } from './report.js';
|
|
20
20
|
import { scan } from './scan.js';
|
|
21
|
+
import { cleanDuringScan } from './fast-clean.js';
|
|
22
|
+
import { defaultStateDirectory, loadScan } from './scan-state.js';
|
|
21
23
|
import { formatBytes } from './units.js';
|
|
22
24
|
|
|
23
25
|
export const USAGE = `Usage: dss <command> [paths...] [options]
|
|
@@ -37,6 +39,7 @@ Scope:
|
|
|
37
39
|
--recursive docker: recurse into nested daemons
|
|
38
40
|
--container ID|NAME limit Docker work to these containers (repeat)
|
|
39
41
|
--max-depth N directory depth for project search (default 6)
|
|
42
|
+
--scan-budget AGE time budget per filesystem environment (2m)
|
|
40
43
|
--only NAME only these ecosystems, rules or kinds (repeat)
|
|
41
44
|
--exclude PATH|GLOB never touch matching paths (repeat)
|
|
42
45
|
--scanner NAME projects, global, versions, agents, system
|
|
@@ -66,6 +69,9 @@ Consent:
|
|
|
66
69
|
Output:
|
|
67
70
|
--json print the report or audit log as JSON
|
|
68
71
|
--report FILE clean: use a report saved by scan --json
|
|
72
|
+
--from FILE clean/emergency: reuse a scan or audit JSON
|
|
73
|
+
--fresh bypass the recent scan and persistent index
|
|
74
|
+
--cache-dir DIR scan state (default: XDG state directory)
|
|
69
75
|
--audit-dir DIR audit logs ($DSS_AUDIT_DIR)
|
|
70
76
|
--backup-dir DIR container log backups ($DSS_BACKUP_DIR)
|
|
71
77
|
--verbose list everything and trace commands (DSS_DEBUG=1)
|
|
@@ -80,6 +86,7 @@ const OPTIONS = {
|
|
|
80
86
|
recursive: { type: 'boolean' },
|
|
81
87
|
container: { type: 'string', multiple: true },
|
|
82
88
|
'max-depth': { type: 'string' },
|
|
89
|
+
'scan-budget': { type: 'string' },
|
|
83
90
|
only: { type: 'string', multiple: true },
|
|
84
91
|
exclude: { type: 'string', multiple: true },
|
|
85
92
|
scanner: { type: 'string', multiple: true },
|
|
@@ -104,6 +111,9 @@ const OPTIONS = {
|
|
|
104
111
|
'allow-dirty-container': { type: 'string', multiple: true },
|
|
105
112
|
'no-native': { type: 'boolean' },
|
|
106
113
|
report: { type: 'string' },
|
|
114
|
+
from: { type: 'string' },
|
|
115
|
+
fresh: { type: 'boolean' },
|
|
116
|
+
'cache-dir': { type: 'string' },
|
|
107
117
|
'audit-dir': { type: 'string' },
|
|
108
118
|
'backup-dir': { type: 'string' },
|
|
109
119
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -158,6 +168,7 @@ export function toOptions(values, paths = []) {
|
|
|
158
168
|
dockerDepth: integer(values.depth, '--depth'),
|
|
159
169
|
containers: values.container,
|
|
160
170
|
maxDepth: integer(values['max-depth'], '--max-depth'),
|
|
171
|
+
scanBudget: values['scan-budget'],
|
|
161
172
|
only: values.only,
|
|
162
173
|
exclude: values.exclude,
|
|
163
174
|
scanners: values.scanner,
|
|
@@ -179,6 +190,9 @@ export function toOptions(values, paths = []) {
|
|
|
179
190
|
noNative: values['no-native'],
|
|
180
191
|
auditDir: values['audit-dir'],
|
|
181
192
|
backupDir: values['backup-dir'],
|
|
193
|
+
cacheDir: values.fresh
|
|
194
|
+
? false
|
|
195
|
+
: (values['cache-dir'] ?? defaultStateDirectory()),
|
|
182
196
|
};
|
|
183
197
|
}
|
|
184
198
|
|
|
@@ -237,14 +251,7 @@ function dockerOnly(options, values) {
|
|
|
237
251
|
export function scanAudit(report) {
|
|
238
252
|
return startAudit('scan', {
|
|
239
253
|
dryRun: true,
|
|
240
|
-
report
|
|
241
|
-
createdAt: report.createdAt,
|
|
242
|
-
durationMs: report.durationMs,
|
|
243
|
-
totals: report.totals,
|
|
244
|
-
environments: report.environments.map((env) => env.id),
|
|
245
|
-
errors: report.errors,
|
|
246
|
-
items: report.items,
|
|
247
|
-
},
|
|
254
|
+
report,
|
|
248
255
|
entries: report.items.map((item) => ({
|
|
249
256
|
id: item.id,
|
|
250
257
|
env: item.env,
|
|
@@ -280,7 +287,7 @@ export function processIo() {
|
|
|
280
287
|
};
|
|
281
288
|
}
|
|
282
289
|
|
|
283
|
-
const API = { scan, clean, emergency };
|
|
290
|
+
const API = { scan, clean, emergency, cleanDuringScan };
|
|
284
291
|
|
|
285
292
|
class Cli {
|
|
286
293
|
constructor(io, api) {
|
|
@@ -294,9 +301,18 @@ class Cli {
|
|
|
294
301
|
|
|
295
302
|
async scan(options, values) {
|
|
296
303
|
const report = await this.api.scan(options);
|
|
304
|
+
return this.printScan(report, options, values);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async printScan(report, options, values) {
|
|
297
308
|
const audit = scanAudit(report);
|
|
298
309
|
if (options.audit !== false) {
|
|
299
|
-
|
|
310
|
+
try {
|
|
311
|
+
await writeAudit(audit, options);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
report.audit = { file: null, error: error.message, code: error.code };
|
|
314
|
+
this.io.stderr(`Audit log: not written (${error.message})`);
|
|
315
|
+
}
|
|
300
316
|
}
|
|
301
317
|
this.print(values, report, () =>
|
|
302
318
|
[
|
|
@@ -305,14 +321,23 @@ class Cli {
|
|
|
305
321
|
'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
|
|
306
322
|
].join('\n')
|
|
307
323
|
);
|
|
308
|
-
return report.errors.length > 0 ? 1 : 0;
|
|
324
|
+
return report.aborted ? 130 : report.errors.length > 0 ? 1 : 0;
|
|
309
325
|
}
|
|
310
326
|
|
|
311
327
|
async loadReport(options, values) {
|
|
312
|
-
|
|
328
|
+
const file = values.from ?? values.report;
|
|
329
|
+
if (!file) {
|
|
330
|
+
const recent = await loadScan(resolveOptions(options));
|
|
331
|
+
if (recent) {
|
|
332
|
+
this.io.stderr(
|
|
333
|
+
`Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
|
|
334
|
+
);
|
|
335
|
+
return recent;
|
|
336
|
+
}
|
|
313
337
|
return this.api.scan(options);
|
|
314
338
|
}
|
|
315
|
-
|
|
339
|
+
const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
340
|
+
return saved.report?.schema ? saved.report : saved;
|
|
316
341
|
}
|
|
317
342
|
|
|
318
343
|
confirmItem() {
|
|
@@ -359,7 +384,37 @@ class Cli {
|
|
|
359
384
|
}
|
|
360
385
|
|
|
361
386
|
async clean(options, values) {
|
|
387
|
+
if (
|
|
388
|
+
this.api.cleanDuringScan &&
|
|
389
|
+
!values.report &&
|
|
390
|
+
!values.from &&
|
|
391
|
+
!this.io.interactive
|
|
392
|
+
) {
|
|
393
|
+
const recent = await loadScan(resolveOptions(options));
|
|
394
|
+
if (!recent) {
|
|
395
|
+
const result = await this.consent(values, (extra) =>
|
|
396
|
+
this.api.cleanDuringScan({
|
|
397
|
+
...options,
|
|
398
|
+
...extra,
|
|
399
|
+
streaming: options.docker !== false,
|
|
400
|
+
})
|
|
401
|
+
);
|
|
402
|
+
if (result.report.aborted) {
|
|
403
|
+
return this.printScan(result.report, options, values);
|
|
404
|
+
}
|
|
405
|
+
this.print(values, result.audit, () =>
|
|
406
|
+
formatAudit(result.audit, { verbose: values.verbose })
|
|
407
|
+
);
|
|
408
|
+
return result.audit.aborted ||
|
|
409
|
+
result.audit.entries.some((entry) => entry.status === 'failed')
|
|
410
|
+
? 1
|
|
411
|
+
: 0;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
362
414
|
const report = await this.loadReport(options, values);
|
|
415
|
+
if (report.aborted) {
|
|
416
|
+
return this.printScan(report, options, values);
|
|
417
|
+
}
|
|
363
418
|
const audit = await this.consent(values, (extra) =>
|
|
364
419
|
this.api.clean(report, { ...options, ...extra })
|
|
365
420
|
);
|
|
@@ -378,7 +433,36 @@ class Cli {
|
|
|
378
433
|
} catch (error) {
|
|
379
434
|
throw new UsageError(error.message);
|
|
380
435
|
}
|
|
436
|
+
if (
|
|
437
|
+
this.api === API &&
|
|
438
|
+
!values.report &&
|
|
439
|
+
!values.from &&
|
|
440
|
+
!this.io.interactive
|
|
441
|
+
) {
|
|
442
|
+
const recent = await loadScan(resolveOptions(options));
|
|
443
|
+
const audit = await this.consent(values, (extra) =>
|
|
444
|
+
this.api.emergency({
|
|
445
|
+
...options,
|
|
446
|
+
...(recent ? { report: recent } : {}),
|
|
447
|
+
...extra,
|
|
448
|
+
})
|
|
449
|
+
);
|
|
450
|
+
if (audit.scanReport?.aborted) {
|
|
451
|
+
return this.printScan(audit.scanReport, options, values);
|
|
452
|
+
}
|
|
453
|
+
this.print(values, audit, () =>
|
|
454
|
+
formatAudit(audit, { verbose: values.verbose })
|
|
455
|
+
);
|
|
456
|
+
return audit.entries.some((entry) => entry.status === 'failed')
|
|
457
|
+
? 1
|
|
458
|
+
: audit.goalMet
|
|
459
|
+
? 0
|
|
460
|
+
: 3;
|
|
461
|
+
}
|
|
381
462
|
const report = await this.loadReport(options, values);
|
|
463
|
+
if (report.aborted) {
|
|
464
|
+
return this.printScan(report, options, values);
|
|
465
|
+
}
|
|
382
466
|
const audit = await this.consent(values, (extra) =>
|
|
383
467
|
this.api.emergency({ ...options, report, ...extra })
|
|
384
468
|
);
|
|
@@ -441,6 +525,10 @@ export async function runCli(argv, deps = {}) {
|
|
|
441
525
|
if (deps.signal) {
|
|
442
526
|
options.signal = deps.signal;
|
|
443
527
|
}
|
|
528
|
+
options.onScanProgress = (progress) =>
|
|
529
|
+
io.stderr(
|
|
530
|
+
`[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.candidates ?? progress.items} candidates, ${progress.filesSized ?? 0} files sized, ${progress.directoriesSized ?? 0} directories sized (${progress.status})`
|
|
531
|
+
);
|
|
444
532
|
return await dispatch(
|
|
445
533
|
new Cli(io, deps.api ?? API),
|
|
446
534
|
command,
|
package/src/docker/cli.js
CHANGED
|
@@ -306,12 +306,16 @@ export class DockerCli {
|
|
|
306
306
|
const stream = this.executor.spawn(
|
|
307
307
|
this.argv(['cp', `${id}:${source}`, '-'])
|
|
308
308
|
);
|
|
309
|
-
return pipeInto(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
309
|
+
return pipeInto(
|
|
310
|
+
stream,
|
|
311
|
+
[
|
|
312
|
+
'tar',
|
|
313
|
+
'-x',
|
|
314
|
+
'-C',
|
|
315
|
+
destination,
|
|
316
|
+
...excludes.map((pattern) => `--exclude=${pattern}`),
|
|
317
|
+
],
|
|
318
|
+
{ signal: this.executor.signal }
|
|
319
|
+
);
|
|
316
320
|
}
|
|
317
321
|
}
|
package/src/docker/scan.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { backupDirectory } from '../audit.js';
|
|
24
24
|
import { ShellEnv } from '../env/shell.js';
|
|
25
|
+
import { checkSignal, signalExecutor } from '../env/scope.js';
|
|
25
26
|
import { containerExecutor, trace } from '../exec.js';
|
|
26
27
|
import { block, makeItem } from '../items.js';
|
|
27
28
|
import { DockerCli, parseDockerSize } from './cli.js';
|
|
@@ -380,6 +381,7 @@ class DockerScan {
|
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
async daemon(record, depth, inherited) {
|
|
384
|
+
checkSignal(this.options.signal);
|
|
383
385
|
const docker = new DockerCli(record.executor);
|
|
384
386
|
const info = await docker.info();
|
|
385
387
|
if (!info) {
|
|
@@ -443,6 +445,7 @@ class DockerScan {
|
|
|
443
445
|
})),
|
|
444
446
|
};
|
|
445
447
|
for (const ps of all) {
|
|
448
|
+
checkSignal(this.options.signal);
|
|
446
449
|
ps.sizes = await this.bestEffort(
|
|
447
450
|
record.env.id,
|
|
448
451
|
() => docker.containerSize(ps.ID),
|
|
@@ -544,6 +547,7 @@ class DockerScan {
|
|
|
544
547
|
}))
|
|
545
548
|
);
|
|
546
549
|
for (const ps of all) {
|
|
550
|
+
checkSignal(this.options.signal);
|
|
547
551
|
const selected = selection.ids.has(ps.ID);
|
|
548
552
|
if (!selected) {
|
|
549
553
|
continue;
|
|
@@ -597,7 +601,10 @@ class DockerScan {
|
|
|
597
601
|
|
|
598
602
|
async running(record, depth, ps, entry, inspected) {
|
|
599
603
|
const label = `${record.env.label}/${entry.name}`;
|
|
600
|
-
const executor =
|
|
604
|
+
const executor = signalExecutor(
|
|
605
|
+
containerExecutor(record.executor, ps.ID, { label }),
|
|
606
|
+
this.options.signal
|
|
607
|
+
);
|
|
601
608
|
const env = new ShellEnv(executor, {
|
|
602
609
|
id: `${record.env.id}/${entry.name}`,
|
|
603
610
|
label,
|
|
@@ -614,14 +621,20 @@ class DockerScan {
|
|
|
614
621
|
entry.env = env.id;
|
|
615
622
|
entry.scanned = true;
|
|
616
623
|
const chain = [...record.chain, { containerId: ps.ID, name: entry.name }];
|
|
617
|
-
|
|
624
|
+
const descriptor = {
|
|
618
625
|
id: env.id,
|
|
619
626
|
label,
|
|
620
627
|
kind: 'container',
|
|
621
628
|
depth,
|
|
622
629
|
chain,
|
|
623
|
-
}
|
|
630
|
+
};
|
|
631
|
+
this.result.environments.push(descriptor);
|
|
624
632
|
this.result.items.push(...(await this.scanEnvironment(env)));
|
|
633
|
+
Object.assign(descriptor, {
|
|
634
|
+
scanStatus: env.scanStatus,
|
|
635
|
+
hint: env.probeHint ?? null,
|
|
636
|
+
layerSource: env.writableLayer?.source ?? null,
|
|
637
|
+
});
|
|
625
638
|
if (await env.which('docker')) {
|
|
626
639
|
try {
|
|
627
640
|
await this.daemon({ env, executor, chain }, depth, true);
|
|
@@ -650,6 +663,14 @@ export async function scanDocker(hostRecord, options, scanEnvironment) {
|
|
|
650
663
|
if (!(await hostRecord.env.which('docker'))) {
|
|
651
664
|
return scan.result;
|
|
652
665
|
}
|
|
653
|
-
|
|
666
|
+
try {
|
|
667
|
+
await scan.daemon(hostRecord, 0, false);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
scan.result.errors.push({
|
|
670
|
+
env: hostRecord.env.id,
|
|
671
|
+
scanner: 'docker',
|
|
672
|
+
message: error.message,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
654
675
|
return scan.result;
|
|
655
676
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { batches } from '../env/batches.js';
|
|
2
|
+
|
|
3
|
+
/** Root overlay from the running container's mount namespace, read by its daemon. */
|
|
4
|
+
export function rootUpperdir(mountinfo) {
|
|
5
|
+
for (const line of mountinfo.split('\n')) {
|
|
6
|
+
const [mount, filesystem] = line.split(' - ');
|
|
7
|
+
if (mount.split(' ')[4] !== '/' || !filesystem?.startsWith('overlay ')) {
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
const options = filesystem.split(' ').slice(2).join(' ');
|
|
11
|
+
const upper = /(?:^|,)upperdir=([^,]+)/.exec(options)?.[1];
|
|
12
|
+
if (upper) {
|
|
13
|
+
return upper.replace(/\\([0-7]{3})/g, (_, code) =>
|
|
14
|
+
String.fromCharCode(Number.parseInt(code, 8))
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One transport call per bounded batch; null means inaccessible, never guessed. */
|
|
22
|
+
export async function upperdirBytes(executor, paths) {
|
|
23
|
+
const measured = new Map();
|
|
24
|
+
const script = `for t do
|
|
25
|
+
printf '\\001%s\\n' "$t"
|
|
26
|
+
value=$(LC_ALL=C du -skx -- "$t" 2>&1)
|
|
27
|
+
code=$?
|
|
28
|
+
if [ "$code" = 0 ]; then printf '%s\\n' "$value" | cut -f1
|
|
29
|
+
else case "$value" in *'No such file or directory'*) echo 0 ;; *) echo unknown ;; esac; fi
|
|
30
|
+
done`;
|
|
31
|
+
for (const batch of batches(paths)) {
|
|
32
|
+
const result = await executor.run(['sh', '-c', script, 'dss', ...batch]);
|
|
33
|
+
for (const record of result.stdout.split('\u0001').slice(1)) {
|
|
34
|
+
const newline = record.indexOf('\n'),
|
|
35
|
+
target = record.slice(0, newline),
|
|
36
|
+
value = record.slice(newline + 1).trim();
|
|
37
|
+
measured.set(
|
|
38
|
+
target,
|
|
39
|
+
result.code === 0 && /^\d+$/.test(value) ? Number(value) * 1024 : null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return measured;
|
|
44
|
+
}
|
package/src/docker/writable.js
CHANGED
|
@@ -3,11 +3,13 @@ import { DockerCli } from './cli.js';
|
|
|
3
3
|
import { isWithin } from '../paths.js';
|
|
4
4
|
import { trace } from '../exec.js';
|
|
5
5
|
import { resetDiffSnapshots } from './snapshots.js';
|
|
6
|
+
import { rootUpperdir, upperdirBytes } from './upperdir.js';
|
|
6
7
|
|
|
7
8
|
let measurementQueue = Promise.resolve();
|
|
8
9
|
const layers = new WeakMap();
|
|
9
10
|
|
|
10
11
|
export function sharedWritableLayer(executor, id) {
|
|
12
|
+
executor = executor.unscoped ?? executor;
|
|
11
13
|
if (!layers.has(executor)) {
|
|
12
14
|
layers.set(executor, new Map());
|
|
13
15
|
}
|
|
@@ -19,12 +21,14 @@ export function sharedWritableLayer(executor, id) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function resetWritableLayers(executor) {
|
|
24
|
+
executor = executor.unscoped ?? executor;
|
|
22
25
|
layers.delete(executor);
|
|
23
26
|
resetDiffSnapshots(executor);
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** Overlay without an authoritative upper layer must never count merged bytes. */
|
|
27
30
|
export class UnknownWritableLayer {
|
|
31
|
+
source = 'unknown-overlay';
|
|
28
32
|
measure(env, usages) {
|
|
29
33
|
return Promise.resolve(
|
|
30
34
|
new Map(
|
|
@@ -67,14 +71,14 @@ export class WritableLayer {
|
|
|
67
71
|
if (!container) {
|
|
68
72
|
return { diff: null, container };
|
|
69
73
|
}
|
|
70
|
-
const upperdir =
|
|
71
|
-
container.GraphDriver?.Data?.UpperDir ?? this.upperdirCandidate;
|
|
74
|
+
const { upperdir, source } = await this.upperdir(container);
|
|
72
75
|
if (upperdir?.startsWith('/')) {
|
|
73
76
|
const result = await this.docker.executor
|
|
74
77
|
.run(['stat', '-c', '%F', '--', upperdir])
|
|
75
78
|
.catch(() => null);
|
|
76
79
|
if (result?.code === 0 && result.stdout.trim() === 'directory') {
|
|
77
80
|
trace('using authoritative upperdir', this.id);
|
|
81
|
+
this.source = source;
|
|
78
82
|
return { container, upperdir, diff: null };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -86,6 +90,7 @@ export class WritableLayer {
|
|
|
86
90
|
return { diff: null, container };
|
|
87
91
|
}
|
|
88
92
|
const diff = await this.docker.diff(this.id);
|
|
93
|
+
this.source = diff === null ? 'unknown' : 'docker-diff';
|
|
89
94
|
trace('finished measuring container', this.id);
|
|
90
95
|
// Bound the retained change set; unknown is safer than a partial result.
|
|
91
96
|
return {
|
|
@@ -102,6 +107,27 @@ export class WritableLayer {
|
|
|
102
107
|
return this.snapshotPromise;
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
async upperdir(container) {
|
|
111
|
+
if (container.GraphDriver?.Data?.UpperDir) {
|
|
112
|
+
return {
|
|
113
|
+
upperdir: container.GraphDriver.Data.UpperDir,
|
|
114
|
+
source: 'graphdriver-upperdir',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const pid = container.State?.Pid;
|
|
118
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
119
|
+
const info = await this.docker.executor.run([
|
|
120
|
+
'cat',
|
|
121
|
+
`/proc/${pid}/mountinfo`,
|
|
122
|
+
]);
|
|
123
|
+
const upperdir = info.code === 0 ? rootUpperdir(info.stdout) : null;
|
|
124
|
+
if (upperdir) {
|
|
125
|
+
return { upperdir, source: 'mountinfo-upperdir' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { upperdir: this.upperdirCandidate, source: 'overlay-upperdir' };
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
forget(target, pathApi) {
|
|
106
132
|
this.removed.add(target);
|
|
107
133
|
for (const file of this.stats.keys()) {
|
|
@@ -111,11 +137,55 @@ export class WritableLayer {
|
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
139
|
|
|
140
|
+
/** Allocated upper blocks before/after deletion, independent of merged data. */
|
|
141
|
+
async allocatedBytes(paths, pathApi) {
|
|
142
|
+
const { container, upperdir } = await this.snapshot();
|
|
143
|
+
if (
|
|
144
|
+
!upperdir ||
|
|
145
|
+
(container.Mounts ?? []).some((mount) =>
|
|
146
|
+
paths.some(
|
|
147
|
+
(target) =>
|
|
148
|
+
isWithin(target, mount.Destination, pathApi) ||
|
|
149
|
+
isWithin(mount.Destination, target, pathApi)
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const measured = await upperdirBytes(
|
|
156
|
+
this.docker.executor,
|
|
157
|
+
paths.map((target) => `${upperdir}/${target.replace(/^\/+/, '')}`)
|
|
158
|
+
);
|
|
159
|
+
const values = [...measured.values()];
|
|
160
|
+
return values.length === paths.length &&
|
|
161
|
+
values.every((value) => value !== null)
|
|
162
|
+
? values.reduce((sum, value) => sum + value, 0)
|
|
163
|
+
: null;
|
|
164
|
+
}
|
|
165
|
+
|
|
114
166
|
async measureUpperdir(env, usages, container, upperdir) {
|
|
115
167
|
const result = new Map();
|
|
116
168
|
const mounts = (container.Mounts ?? [])
|
|
117
169
|
.map((mount) => mount.Destination)
|
|
118
170
|
.filter(Boolean);
|
|
171
|
+
const upperPath = (target) => `${upperdir}/${target.replace(/^\/+/, '')}`;
|
|
172
|
+
const targets = [...usages.keys()].filter(
|
|
173
|
+
(target) => !mounts.some((mount) => isWithin(target, mount, env.path))
|
|
174
|
+
);
|
|
175
|
+
const measurements = await upperdirBytes(
|
|
176
|
+
this.docker.executor,
|
|
177
|
+
targets.map(upperPath)
|
|
178
|
+
);
|
|
179
|
+
const mountRoots = mounts.filter(
|
|
180
|
+
(mount) =>
|
|
181
|
+
targets.some((target) => isWithin(mount, target, env.path)) &&
|
|
182
|
+
!mounts.some(
|
|
183
|
+
(other) => other !== mount && isWithin(mount, other, env.path)
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
const mountedUsage = mountRoots.length
|
|
187
|
+
? await env.rawUsageMany(mountRoots)
|
|
188
|
+
: new Map();
|
|
119
189
|
for (const [target, usage] of usages) {
|
|
120
190
|
if (mounts.some((mount) => isWithin(target, mount, env.path))) {
|
|
121
191
|
result.set(target, {
|
|
@@ -125,17 +195,24 @@ export class WritableLayer {
|
|
|
125
195
|
});
|
|
126
196
|
continue;
|
|
127
197
|
}
|
|
128
|
-
const measured =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
198
|
+
const measured = measurements.get(upperPath(target));
|
|
199
|
+
const contained = mountRoots.filter((mount) =>
|
|
200
|
+
isWithin(mount, target, env.path)
|
|
201
|
+
);
|
|
202
|
+
const known =
|
|
203
|
+
measured !== null &&
|
|
204
|
+
measured !== undefined &&
|
|
205
|
+
contained.every((mount) => mountedUsage.has(mount));
|
|
206
|
+
const bytes = known
|
|
207
|
+
? Math.min(
|
|
208
|
+
usage.bytes,
|
|
209
|
+
measured +
|
|
210
|
+
contained.reduce(
|
|
211
|
+
(sum, mount) => sum + mountedUsage.get(mount).bytes,
|
|
212
|
+
0
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
: 0;
|
|
139
216
|
result.set(target, {
|
|
140
217
|
...usage,
|
|
141
218
|
bytes,
|
package/src/emergency.js
CHANGED
|
@@ -89,12 +89,40 @@ export async function emergency(input = {}) {
|
|
|
89
89
|
});
|
|
90
90
|
const report = input.report
|
|
91
91
|
? await revalidateReport(input.report, { ...input, env })
|
|
92
|
-
:
|
|
93
|
-
|
|
92
|
+
: goalMet(goal, diskBefore)
|
|
93
|
+
? {
|
|
94
|
+
createdAt: new Date(options.now()).toISOString(),
|
|
95
|
+
items: [],
|
|
96
|
+
environments: [{ id: env.id, label: env.label }],
|
|
97
|
+
totals: {},
|
|
98
|
+
}
|
|
99
|
+
: await scan({
|
|
100
|
+
...input,
|
|
101
|
+
env,
|
|
102
|
+
onScanPhase: async (phase) => {
|
|
103
|
+
audit.diskAfter = await escalate({
|
|
104
|
+
report: phase,
|
|
105
|
+
options: {
|
|
106
|
+
...options,
|
|
107
|
+
tier: 'safe',
|
|
108
|
+
streaming: options.docker !== false,
|
|
109
|
+
},
|
|
110
|
+
env,
|
|
111
|
+
target,
|
|
112
|
+
goal,
|
|
113
|
+
audit,
|
|
114
|
+
});
|
|
115
|
+
return !goalMet(goal, audit.diskAfter);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
audit.report = report;
|
|
94
119
|
if (options.audit !== false) {
|
|
95
120
|
await writeAudit(audit, { ...options, inProgress: true });
|
|
96
121
|
}
|
|
97
|
-
let disk = diskBefore;
|
|
122
|
+
let disk = audit.diskAfter ?? diskBefore;
|
|
123
|
+
if (report.aborted) {
|
|
124
|
+
audit.scanReport = report;
|
|
125
|
+
}
|
|
98
126
|
try {
|
|
99
127
|
if (!goalMet(goal, disk)) {
|
|
100
128
|
disk = await escalate({ report, options, env, target, goal, audit });
|
|
@@ -121,9 +149,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
121
149
|
...options,
|
|
122
150
|
env,
|
|
123
151
|
onProgress: persistProgress,
|
|
152
|
+
priorHealth: audit.health,
|
|
124
153
|
});
|
|
125
154
|
audit.health = cleaner.health.records;
|
|
126
|
-
const done = new Set();
|
|
155
|
+
const done = new Set(audit.entries.map((entry) => entry.id));
|
|
127
156
|
const targetDevice = await env.deviceId?.(target);
|
|
128
157
|
const scoped = await Promise.all(
|
|
129
158
|
wantedItems(report, options).map(async (item) => {
|
|
@@ -141,8 +170,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
|
|
|
141
170
|
})
|
|
142
171
|
);
|
|
143
172
|
const wanted = scoped.filter(Boolean);
|
|
144
|
-
let disk = audit.diskBefore;
|
|
145
|
-
let freed =
|
|
173
|
+
let disk = audit.diskAfter ?? audit.diskBefore;
|
|
174
|
+
let freed = audit.entries
|
|
175
|
+
.filter((entry) => entry.status === 'planned')
|
|
176
|
+
.reduce((sum, entry) => sum + entry.plannedBytes, 0);
|
|
146
177
|
for (const tier of TIERS.slice(0, tierRank(options.tier) + 1)) {
|
|
147
178
|
audit.reachedTier = tier;
|
|
148
179
|
const items = selectByTier(wanted, tier)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bound argument count and encoded size below common exec limits. */
|
|
2
|
+
export function batches(items, size = 200, maxBytes = 48000) {
|
|
3
|
+
const result = [];
|
|
4
|
+
let batch = [],
|
|
5
|
+
bytes = 0;
|
|
6
|
+
for (const item of items) {
|
|
7
|
+
const length = Buffer.byteLength(String(item)) + 1;
|
|
8
|
+
if (batch.length && (batch.length >= size || bytes + length > maxBytes)) {
|
|
9
|
+
result.push(batch);
|
|
10
|
+
batch = [];
|
|
11
|
+
bytes = 0;
|
|
12
|
+
}
|
|
13
|
+
batch.push(item);
|
|
14
|
+
bytes += length;
|
|
15
|
+
}
|
|
16
|
+
if (batch.length) {
|
|
17
|
+
result.push(batch);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|