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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dddd5f6: Batch remote filesystem operations, add scan budgets and partial reports on
|
|
8
|
+
cancellation, discover containerd upperdirs, batch safe container deletions with
|
|
9
|
+
allocated-byte accounting, and retry process inspection by owner while reporting
|
|
10
|
+
blocked sizes and environment hints.
|
|
11
|
+
|
|
12
|
+
Find version-manager download caches and idle semver baseline builds, honor
|
|
13
|
+
configured cache roots, and watch host task health within its mount namespace.
|
|
14
|
+
Preserve scan and interrupted cleanup pre-scan reports when audit storage is
|
|
15
|
+
unwritable, and avoid root-owned default state under another user's home.
|
|
16
|
+
|
|
17
|
+
## 0.15.2
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 841a999: Fix the remaining CI/CD false positives, false negatives and warnings: tests that cannot run on a runner are reported as skipped with the reason instead of passing vacuously or vanishing from the summary, the Deno leg runs with `-A --parallel`, the pedantic zizmor pass runs online and triggers on every file it reads, jscpd 5 receives the comment-skipping setting it understands, the example app reviews its locked install scripts, and release logs no longer warn about `deno.lock` or print the changeset publish output twice.
|
|
22
|
+
|
|
3
23
|
## 0.15.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -118,6 +118,7 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
118
118
|
macOS) are skipped. So are projects with a running `cargo`, `gradle`,
|
|
119
119
|
`npm`, `node` or similar tool, and anything modified within
|
|
120
120
|
`--older-than` (1h).
|
|
121
|
+
Container paths are checked individually before each bounded deletion batch.
|
|
121
122
|
- **Rust builds are pruned in two parts.** `cargo-superseded` removes
|
|
122
123
|
older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
|
|
123
124
|
`build/*`, `.fingerprint`) that a newer build replaced, but never while
|
|
@@ -135,6 +136,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
135
136
|
environment stops at once, and the audit log and summary say which
|
|
136
137
|
process was lost. A build that simply finishes during the cleanup also
|
|
137
138
|
stops it: an exit cannot be told apart from a crash.
|
|
139
|
+
Host cleanup watches processes in its own mount namespace; each container
|
|
140
|
+
watches its own tasks. Processes with unknown namespace membership remain
|
|
141
|
+
watched.
|
|
138
142
|
- **Git awareness.** Whole projects and containers are protected while they
|
|
139
143
|
have unsaved changes, unpushed commits or stashes. Ignored dependency and
|
|
140
144
|
build outputs can be cleaned despite unrelated source changes; tracked or
|
|
@@ -152,6 +156,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
152
156
|
and left per environment, and disk usage before and after. Scan audits
|
|
153
157
|
include every candidate and its blockers; blocked candidates do not
|
|
154
158
|
contribute to reclaimable tier totals.
|
|
159
|
+
If a read-only scan cannot save its audit, it warns and still prints the
|
|
160
|
+
report. Root scans with another user's `HOME` use a private temporary audit
|
|
161
|
+
directory. Set `--audit-dir` to choose a persistent writable location.
|
|
155
162
|
|
|
156
163
|
Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
|
|
157
164
|
header caches to be cleaned. Active npx hashes remain protected through
|
|
@@ -165,6 +172,21 @@ and produces one environment hint. Old isolation logs and sanitized upload stagi
|
|
|
165
172
|
are moderate-tier candidates. Opam download cleanup removes its cache
|
|
166
173
|
directory directly and does not invoke `opam clean` or migrate the root.
|
|
167
174
|
|
|
175
|
+
Scans have a two-minute budget per filesystem environment, configurable with
|
|
176
|
+
`--scan-budget 5m` (library: `scanBudget`). Progress goes to stderr, leaving
|
|
177
|
+
JSON stdout clean. SIGINT and SIGTERM stop new work, cancel subprocesses and
|
|
178
|
+
print completed findings marked `aborted`; incomplete findings remain blocked.
|
|
179
|
+
The CLI exits 130 or 143 respectively. A second signal exits immediately.
|
|
180
|
+
Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
|
|
181
|
+
|
|
182
|
+
Cache discovery honors tool configuration before default paths, including
|
|
183
|
+
`GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
|
|
184
|
+
`GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
|
|
185
|
+
Go, npm, pip, Yarn and pnpm cache queries are bounded and cached per environment.
|
|
186
|
+
Safe rules also cover perlbrew build/download trees, SDKMAN tmp/archive contents,
|
|
187
|
+
nvm downloads, idle `target/semver-checks`, and downloaded Ruby `.gem` files.
|
|
188
|
+
SDKMAN parent directories and installed gems are preserved.
|
|
189
|
+
|
|
168
190
|
## Docker
|
|
169
191
|
|
|
170
192
|
With `--docker`, or automatically when a daemon is reachable, `dss` handles
|
|
@@ -188,6 +210,11 @@ three kinds of Docker data:
|
|
|
188
210
|
this is denied or incomplete, cleanup stays blocked and names the
|
|
189
211
|
unreadable processes. This also applies when dss runs inside a container
|
|
190
212
|
with access to its Docker daemon.
|
|
213
|
+
Root probes also retry unreadable processes as their owning UID/GID with
|
|
214
|
+
`setpriv` when available. Readable evidence is retained after failed retries;
|
|
215
|
+
unresolved inspection produces one environment hint. Blocked totals show
|
|
216
|
+
measured bytes and separately identify visible bytes whose freeable size is
|
|
217
|
+
unknown.
|
|
191
218
|
- **Stopped containers** are listed with their size, image, command, owner
|
|
192
219
|
session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
|
|
193
220
|
environment variables, else a session id in the container name), task
|
|
@@ -223,6 +250,13 @@ memory headroom, and skips excessive candidate counts. Fresh Git checks before
|
|
|
223
250
|
stopped-container removal still inspect new work. Verbose mode reports storage
|
|
224
251
|
measurement progress. Host scans prune runtime, nested overlay, read-only and
|
|
225
252
|
unselected mount points; explicitly selected ordinary mount roots are allowed.
|
|
253
|
+
For containerd-backed running containers, the daemon-side init process's
|
|
254
|
+
`/proc/<pid>/mountinfo` supplies the overlay upperdir when `GraphDriver` is absent.
|
|
255
|
+
JSON environments and the text summary show the storage source. Paths are
|
|
256
|
+
measured on the daemon host and removed inside the container so overlay
|
|
257
|
+
whiteouts are respected; cleanup reports the measured change in allocated
|
|
258
|
+
upperdir blocks when available. Remote filesystem operations use bounded
|
|
259
|
+
POSIX-shell batches and do not require Node inside the target container.
|
|
226
260
|
|
|
227
261
|
Docker discovery uses a size-free container listing and measures each
|
|
228
262
|
container independently. Failed size queries are retried once; objects
|
|
@@ -343,7 +377,7 @@ Configured in `.prettierrc`:
|
|
|
343
377
|
npm install
|
|
344
378
|
npm test # Node.js, 30s per test
|
|
345
379
|
bun test --timeout 30000 # Bun
|
|
346
|
-
deno test --
|
|
380
|
+
deno test -A --parallel # Deno (fixtures need -A)
|
|
347
381
|
npm run check # lint + format + duplication
|
|
348
382
|
npm run test:dind # Docker-in-Docker integration (needs --privileged)
|
|
349
383
|
node bin/dss.js scan --verbose
|
package/bin/dss.js
CHANGED
|
@@ -3,14 +3,31 @@
|
|
|
3
3
|
import { runCli } from '../src/cli.js';
|
|
4
4
|
|
|
5
5
|
const controller = new AbortController();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
let received = null,
|
|
7
|
+
fallback;
|
|
8
|
+
const abort = (signal) => {
|
|
9
|
+
const code = signal === 'SIGTERM' ? 143 : 130;
|
|
10
|
+
if (received) {
|
|
11
|
+
process.exit(code);
|
|
12
|
+
}
|
|
13
|
+
received = code;
|
|
14
|
+
controller.abort(new Error(`interrupted by ${signal}`));
|
|
15
|
+
fallback = setTimeout(() => process.exit(code), 5000);
|
|
16
|
+
fallback.unref();
|
|
17
|
+
};
|
|
18
|
+
const interrupt = () => abort('SIGINT'),
|
|
19
|
+
terminate = () => abort('SIGTERM');
|
|
20
|
+
process.on('SIGINT', interrupt);
|
|
21
|
+
process.on('SIGTERM', terminate);
|
|
9
22
|
try {
|
|
10
23
|
process.exitCode = await runCli(process.argv.slice(2), {
|
|
11
24
|
signal: controller.signal,
|
|
12
25
|
});
|
|
26
|
+
if (received) {
|
|
27
|
+
process.exitCode = received;
|
|
28
|
+
}
|
|
13
29
|
} finally {
|
|
14
|
-
|
|
15
|
-
process.removeListener('
|
|
30
|
+
clearTimeout(fallback);
|
|
31
|
+
process.removeListener('SIGINT', interrupt);
|
|
32
|
+
process.removeListener('SIGTERM', terminate);
|
|
16
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/audit.js
CHANGED
|
@@ -12,6 +12,7 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
export const AUDIT_SCHEMA = 1;
|
|
15
|
+
let privateAuditDirectory;
|
|
15
16
|
|
|
16
17
|
export function throwIfAborted(options) {
|
|
17
18
|
if (options.signal?.aborted) {
|
|
@@ -74,8 +75,24 @@ export function startAudit(command, fields = {}) {
|
|
|
74
75
|
* @returns {Promise<string>}
|
|
75
76
|
*/
|
|
76
77
|
export async function writeAudit(audit, options = {}) {
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
let dir = auditDirectory(options);
|
|
79
|
+
// sudo/docker exec may retain another user's HOME. Do not leave root-owned
|
|
80
|
+
// default state there; explicit audit locations remain the caller's choice.
|
|
81
|
+
if (
|
|
82
|
+
!options.auditDir &&
|
|
83
|
+
!process.env.DSS_AUDIT_DIR &&
|
|
84
|
+
process.getuid?.() === 0
|
|
85
|
+
) {
|
|
86
|
+
const home = os.homedir();
|
|
87
|
+
const owner = await fsp.stat(home).catch(() => null);
|
|
88
|
+
if (owner && owner.uid !== 0 && dir.startsWith(`${home}${path.sep}`)) {
|
|
89
|
+
privateAuditDirectory ??= fsp.mkdtemp(
|
|
90
|
+
path.join(os.tmpdir(), 'dss-audit-0-')
|
|
91
|
+
);
|
|
92
|
+
dir = await privateAuditDirectory;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
79
96
|
if (!options.inProgress) {
|
|
80
97
|
audit.finishedAt ??= new Date().toISOString();
|
|
81
98
|
}
|
package/src/clean.js
CHANGED
|
@@ -31,6 +31,9 @@ import {
|
|
|
31
31
|
import { imageNamed } from './docker/images.js';
|
|
32
32
|
import { resolveEnvironment } from './env/resolve.js';
|
|
33
33
|
import { configureScanPolicy } from './env/policy.js';
|
|
34
|
+
import { configureCacheRoots } from './env/cache-roots.js';
|
|
35
|
+
import { environmentScope, signalExecutor } from './env/scope.js';
|
|
36
|
+
import { batches } from './env/batches.js';
|
|
34
37
|
import { GitInspector } from './git.js';
|
|
35
38
|
import { HealthWatch } from './health.js';
|
|
36
39
|
import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
|
|
@@ -219,6 +222,18 @@ export class Cleaner {
|
|
|
219
222
|
return;
|
|
220
223
|
}
|
|
221
224
|
const ctx = this.context(item.env);
|
|
225
|
+
const executor = ctx.executor;
|
|
226
|
+
ctx.executor = signalExecutor(executor, this.options.signal);
|
|
227
|
+
try {
|
|
228
|
+
return await environmentScope(ctx.env, this.options.signal, () =>
|
|
229
|
+
this.#processScoped(ctx, item, entry)
|
|
230
|
+
);
|
|
231
|
+
} finally {
|
|
232
|
+
ctx.executor = executor;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async #processScoped(ctx, item, entry) {
|
|
222
237
|
const fresh = await this.#recheck(ctx, item);
|
|
223
238
|
if (fresh.reason) {
|
|
224
239
|
entry.reason = fresh.reason;
|
|
@@ -357,6 +372,10 @@ export class Cleaner {
|
|
|
357
372
|
|
|
358
373
|
async #staticBlocker(ctx, item) {
|
|
359
374
|
if (item.recheck?.type === 'version') {
|
|
375
|
+
if (!ctx.cacheRootsConfigured) {
|
|
376
|
+
await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
|
|
377
|
+
ctx.cacheRootsConfigured = true;
|
|
378
|
+
}
|
|
360
379
|
const rule = VERSION_RULES.find(
|
|
361
380
|
(candidate) => candidate.id === item.rule
|
|
362
381
|
);
|
|
@@ -469,6 +488,9 @@ export class Cleaner {
|
|
|
469
488
|
* item stops; an item checked path by path skips just the busy path.
|
|
470
489
|
*/
|
|
471
490
|
async #removePaths(ctx, item, entry) {
|
|
491
|
+
if (ctx.env.kind === 'container' && item.checks?.perPath) {
|
|
492
|
+
return this.#removeBatches(ctx, item, entry);
|
|
493
|
+
}
|
|
472
494
|
for (const target of item.action.paths) {
|
|
473
495
|
if (this.options.signal?.aborted) {
|
|
474
496
|
throw new Error('cleanup aborted');
|
|
@@ -490,18 +512,114 @@ export class Cleaner {
|
|
|
490
512
|
entry.reason = boundary;
|
|
491
513
|
return;
|
|
492
514
|
}
|
|
493
|
-
await ctx
|
|
494
|
-
ctx.env.writableLayer?.forget?.(target, ctx.env.path);
|
|
495
|
-
entry.status = 'removed';
|
|
496
|
-
entry.deletedPaths.push(target);
|
|
497
|
-
entry.freedBytes += usage?.bytes ?? 0;
|
|
498
|
-
await this.options.onProgress?.(entry);
|
|
515
|
+
await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
|
|
499
516
|
}
|
|
500
517
|
if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
|
|
501
518
|
entry.reason = 'every path was in use or recently written';
|
|
502
519
|
}
|
|
503
520
|
}
|
|
504
521
|
|
|
522
|
+
async #removeBatches(ctx, item, entry) {
|
|
523
|
+
for (const batch of batches(item.action.paths)) {
|
|
524
|
+
if (!(await this.#removeBatch(ctx, item, entry, batch))) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** One short remote deletion per batch, after fresh health/liveness/boundary checks. */
|
|
531
|
+
async #removeBatch(ctx, item, entry, batch) {
|
|
532
|
+
await configureScanPolicy(ctx.env, this.options);
|
|
533
|
+
await ctx.liveness.refresh(true);
|
|
534
|
+
const stopped = this.health.verify(item, ctx.liveness.processList());
|
|
535
|
+
if (stopped) {
|
|
536
|
+
entry.reason = stopped;
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
await ctx.liveness.resolve(batch);
|
|
540
|
+
const usages = await ctx.env.usageMany(batch);
|
|
541
|
+
const { selected, reason } = this.#batchSelection(
|
|
542
|
+
ctx,
|
|
543
|
+
item,
|
|
544
|
+
entry,
|
|
545
|
+
batch,
|
|
546
|
+
usages
|
|
547
|
+
);
|
|
548
|
+
if (reason) {
|
|
549
|
+
entry.reason = reason;
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
if (!selected.length) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
|
|
556
|
+
const lost = await this.health.check(ctx.env, item);
|
|
557
|
+
if (lost) {
|
|
558
|
+
entry.reason = lost;
|
|
559
|
+
}
|
|
560
|
+
return !lost;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
#batchSelection(ctx, item, entry, batch, usages) {
|
|
564
|
+
const selected = [];
|
|
565
|
+
for (const target of batch) {
|
|
566
|
+
const blocker =
|
|
567
|
+
ctx.env.scanPolicy.removalReason(target) ??
|
|
568
|
+
ctx.liveness.busyReason({ ...item, paths: [target] });
|
|
569
|
+
if (blocker) {
|
|
570
|
+
return { selected: [], reason: blocker };
|
|
571
|
+
}
|
|
572
|
+
const usage = usages.get(target);
|
|
573
|
+
const busy = this.#pathActivity(ctx, item, target, usage);
|
|
574
|
+
const unavailable = this.#unavailableUsage(usage);
|
|
575
|
+
if (busy || unavailable) {
|
|
576
|
+
entry.skippedPaths.push({ path: target, reason: busy ?? unavailable });
|
|
577
|
+
} else {
|
|
578
|
+
selected.push(target);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return { selected };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#unavailableUsage(usage) {
|
|
585
|
+
if (!usage) {
|
|
586
|
+
return 'path disappeared';
|
|
587
|
+
}
|
|
588
|
+
if (usage.sizeUnknown) {
|
|
589
|
+
return 'writable-layer size unknown';
|
|
590
|
+
}
|
|
591
|
+
return usage.bytes === 0 ? 'no writable bytes remain' : null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async #deletePaths(ctx, entry, selected, estimate) {
|
|
595
|
+
const before = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
596
|
+
selected,
|
|
597
|
+
ctx.env.path
|
|
598
|
+
);
|
|
599
|
+
await ctx.env.remove(selected);
|
|
600
|
+
const after = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
601
|
+
selected,
|
|
602
|
+
ctx.env.path
|
|
603
|
+
);
|
|
604
|
+
for (const target of selected) {
|
|
605
|
+
ctx.env.writableLayer?.forget?.(target, ctx.env.path);
|
|
606
|
+
}
|
|
607
|
+
entry.status = 'removed';
|
|
608
|
+
entry.deletedPaths.push(...selected);
|
|
609
|
+
entry.freedBytes += this.#freedBytes(ctx, entry, before, after, estimate);
|
|
610
|
+
await this.options.onProgress?.(entry);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#freedBytes(ctx, entry, before, after, fallback) {
|
|
614
|
+
if (typeof before === 'number' && typeof after === 'number') {
|
|
615
|
+
return Math.max(0, before - after);
|
|
616
|
+
}
|
|
617
|
+
if (ctx.env.writableLayer) {
|
|
618
|
+
entry.freedEstimated = true;
|
|
619
|
+
}
|
|
620
|
+
return fallback;
|
|
621
|
+
}
|
|
622
|
+
|
|
505
623
|
#pathActivity(ctx, item, target, usage) {
|
|
506
624
|
return (
|
|
507
625
|
item.checks?.perPath &&
|
package/src/cli.js
CHANGED
|
@@ -37,6 +37,7 @@ Scope:
|
|
|
37
37
|
--recursive docker: recurse into nested daemons
|
|
38
38
|
--container ID|NAME limit Docker work to these containers (repeat)
|
|
39
39
|
--max-depth N directory depth for project search (default 6)
|
|
40
|
+
--scan-budget AGE time budget per filesystem environment (2m)
|
|
40
41
|
--only NAME only these ecosystems, rules or kinds (repeat)
|
|
41
42
|
--exclude PATH|GLOB never touch matching paths (repeat)
|
|
42
43
|
--scanner NAME projects, global, versions, agents, system
|
|
@@ -80,6 +81,7 @@ const OPTIONS = {
|
|
|
80
81
|
recursive: { type: 'boolean' },
|
|
81
82
|
container: { type: 'string', multiple: true },
|
|
82
83
|
'max-depth': { type: 'string' },
|
|
84
|
+
'scan-budget': { type: 'string' },
|
|
83
85
|
only: { type: 'string', multiple: true },
|
|
84
86
|
exclude: { type: 'string', multiple: true },
|
|
85
87
|
scanner: { type: 'string', multiple: true },
|
|
@@ -158,6 +160,7 @@ export function toOptions(values, paths = []) {
|
|
|
158
160
|
dockerDepth: integer(values.depth, '--depth'),
|
|
159
161
|
containers: values.container,
|
|
160
162
|
maxDepth: integer(values['max-depth'], '--max-depth'),
|
|
163
|
+
scanBudget: values['scan-budget'],
|
|
161
164
|
only: values.only,
|
|
162
165
|
exclude: values.exclude,
|
|
163
166
|
scanners: values.scanner,
|
|
@@ -294,9 +297,18 @@ class Cli {
|
|
|
294
297
|
|
|
295
298
|
async scan(options, values) {
|
|
296
299
|
const report = await this.api.scan(options);
|
|
300
|
+
return this.printScan(report, options, values);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async printScan(report, options, values) {
|
|
297
304
|
const audit = scanAudit(report);
|
|
298
305
|
if (options.audit !== false) {
|
|
299
|
-
|
|
306
|
+
try {
|
|
307
|
+
await writeAudit(audit, options);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
report.audit = { file: null, error: error.message, code: error.code };
|
|
310
|
+
this.io.stderr(`Audit log: not written (${error.message})`);
|
|
311
|
+
}
|
|
300
312
|
}
|
|
301
313
|
this.print(values, report, () =>
|
|
302
314
|
[
|
|
@@ -305,7 +317,7 @@ class Cli {
|
|
|
305
317
|
'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
|
|
306
318
|
].join('\n')
|
|
307
319
|
);
|
|
308
|
-
return report.errors.length > 0 ? 1 : 0;
|
|
320
|
+
return report.aborted ? 130 : report.errors.length > 0 ? 1 : 0;
|
|
309
321
|
}
|
|
310
322
|
|
|
311
323
|
async loadReport(options, values) {
|
|
@@ -360,6 +372,9 @@ class Cli {
|
|
|
360
372
|
|
|
361
373
|
async clean(options, values) {
|
|
362
374
|
const report = await this.loadReport(options, values);
|
|
375
|
+
if (report.aborted) {
|
|
376
|
+
return this.printScan(report, options, values);
|
|
377
|
+
}
|
|
363
378
|
const audit = await this.consent(values, (extra) =>
|
|
364
379
|
this.api.clean(report, { ...options, ...extra })
|
|
365
380
|
);
|
|
@@ -379,6 +394,9 @@ class Cli {
|
|
|
379
394
|
throw new UsageError(error.message);
|
|
380
395
|
}
|
|
381
396
|
const report = await this.loadReport(options, values);
|
|
397
|
+
if (report.aborted) {
|
|
398
|
+
return this.printScan(report, options, values);
|
|
399
|
+
}
|
|
382
400
|
const audit = await this.consent(values, (extra) =>
|
|
383
401
|
this.api.emergency({ ...options, report, ...extra })
|
|
384
402
|
);
|
|
@@ -441,6 +459,10 @@ export async function runCli(argv, deps = {}) {
|
|
|
441
459
|
if (deps.signal) {
|
|
442
460
|
options.signal = deps.signal;
|
|
443
461
|
}
|
|
462
|
+
options.onScanProgress = (progress) =>
|
|
463
|
+
io.stderr(
|
|
464
|
+
`[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.items} items (${progress.status})`
|
|
465
|
+
);
|
|
444
466
|
return await dispatch(
|
|
445
467
|
new Cli(io, deps.api ?? API),
|
|
446
468
|
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
|
+
}
|