disk-space-saviour 0.13.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +88 -27
- package/bin/dss.js +12 -1
- package/package.json +5 -5
- package/src/audit.js +6 -0
- package/src/clean.js +166 -32
- package/src/cli.js +12 -2
- package/src/docker/cli.js +33 -5
- package/src/docker/containers.js +63 -7
- package/src/docker/scan.js +170 -46
- package/src/docker/snapshots.js +65 -0
- package/src/docker/writable.js +288 -0
- package/src/emergency.js +12 -3
- package/src/env/local.js +162 -78
- package/src/env/policy.js +155 -0
- package/src/env/proc.js +53 -0
- package/src/env/shell.js +190 -12
- package/src/exec.js +4 -1
- package/src/git-preserved.js +120 -0
- package/src/git.js +34 -6
- package/src/index.d.ts +14 -2
- package/src/liveness.js +160 -13
- package/src/options.js +5 -0
- package/src/paths.js +34 -2
- package/src/report.js +67 -7
- package/src/rules/apps.js +178 -0
- package/src/rules/ecosystems.js +4 -3
- package/src/rules/other.js +29 -5
- package/src/rules/versions.js +23 -0
- package/src/scan.js +86 -12
- package/src/scanners/agents.js +1 -1
- package/src/scanners/common.js +2 -1
- package/src/scanners/global.js +59 -7
- package/src/scanners/projects.js +4 -8
- package/src/scanners/system.js +4 -4
- package/src/scanners/versions.js +16 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2f4744b: Fix CI/CD false positives, false negatives, warnings and errors: release-preflight now proves npm trusted publishing with the OIDC package exchange, releases wait for every check (including the Docker-in-Docker job) and refuse to run after a failed or cancelled job, registry verification tolerates CDN lag, PR guards compare against a verified merge base, audit findings are cleared by upgrading Changesets, jscpd and lint-staged, and non-blocking advisories are reported as a notice. Runner images and workflow tools are pinned, CodeQL scans only shipped code, a missing GitHub release is recovered for a version already on npm, the link checker re-checks transient failures and no longer warns about an empty HTML glob, deno.lock is kept in step with package.json, and skipped tests name the real reason.
|
|
8
|
+
|
|
9
|
+
## 0.15.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 1613aa2: Protect running package tools, expand safe application caches, respect scan boundaries and exclusions, and bound container writable-layer inspection with honest unknown-size reporting.
|
|
14
|
+
|
|
15
|
+
## 0.14.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Tests: the repository URL check no longer reads the environment under Deno, which runs the suite without env access.
|
|
20
|
+
|
|
21
|
+
The repository moved to link-foundation/disk-space-saviour: package metadata and the example app link to the new location, so npm provenance matches the repository that publishes.
|
|
22
|
+
|
|
23
|
+
## 0.14.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- Make Docker cleanup reports resilient to size-query races and count writable-layer cache bytes separately from immutable image data. Improve process matching, privileged read-only liveness inspection, per-hash npx protection, stopped-container Git inspection and recovery-ref recognition. Add aged isolation-log, sanitized-upload, Claude Code and Copilot CLI cleanup candidates, direct opam download-cache removal, investigation-hold age controls, and complete scan audits with grouped hints and watched processes.
|
|
28
|
+
|
|
29
|
+
Release: npm publishes only through trusted publishing. The workflow no longer reads an `NPM_TOKEN` secret, the credential preflight requires the OIDC permission and flags any npm token, and the first-publish guidance points to package-registry-manager instead of a token.
|
|
30
|
+
|
|
3
31
|
## 0.13.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -70,7 +70,9 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
70
70
|
|
|
71
71
|
- `--only NAME` limits a run to an ecosystem, rule or kind (`--only rust`,
|
|
72
72
|
`--only npm-cache`, `--only docker-stopped-container`).
|
|
73
|
-
- `--exclude PATH|GLOB`
|
|
73
|
+
- `--exclude PATH|GLOB` prunes matching paths and descendants before reading
|
|
74
|
+
them. Patterns containing separators match full paths and their ancestors;
|
|
75
|
+
other patterns also match ancestor basenames.
|
|
74
76
|
- `--scanner NAME` restricts scanning to `projects`, `global`, `versions`,
|
|
75
77
|
`agents` or `system`.
|
|
76
78
|
- `--report FILE` limits cleaning to items in a saved `dss scan --json`
|
|
@@ -81,24 +83,28 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
81
83
|
Tiers are cumulative: `moderate` includes `safe`, and `aggressive` includes
|
|
82
84
|
both.
|
|
83
85
|
|
|
84
|
-
| Tier | What it removes
|
|
85
|
-
| ------------ |
|
|
86
|
-
| `safe` | Download
|
|
87
|
-
| `moderate` | Whole `
|
|
88
|
-
| `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it.
|
|
86
|
+
| Tier | What it removes |
|
|
87
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `safe` | Download and application caches, idle package-runner entries, old browser revisions, inactive lockfile-backed `node_modules`, dangling Docker images and build cache. Includes writable caches inside running containers. |
|
|
89
|
+
| `moderate` | Whole `target/`, `.venv`, `build/` and similar folders of inactive projects; old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable); unused toolchain versions and tagged images; stopped containers. Container and tagged-image removal requires explicit consent. |
|
|
90
|
+
| `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it. |
|
|
89
91
|
|
|
90
92
|
Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
|
|
91
93
|
framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
|
|
92
94
|
Go, C/C++ (CMake, ccache, vcpkg, Conan), .NET, PHP, Ruby, Swift/Xcode, Dart,
|
|
93
95
|
Haskell, Scala, Elixir, OCaml, Lean, Julia, R, Zig, and scripting languages.
|
|
94
96
|
Also covered: version managers (nvm, pyenv, rbenv, SDKMAN!, rustup, elan,
|
|
95
|
-
ghcup, opam, swiftly, VS Code Server), Playwright, playwright-go, Puppeteer
|
|
97
|
+
ghcup, opam, swiftly, VS Code Server, Claude Code and Copilot CLI), Playwright, playwright-go, Puppeteer
|
|
96
98
|
and Cypress browsers, HTTP caches of Chrome, Chromium, Edge, Brave, Opera,
|
|
97
99
|
Opera GX, Vivaldi, Arc, Yandex Browser, Naver Whale, 360, QQ Browser, Firefox,
|
|
98
100
|
LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
|
|
99
101
|
Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
|
|
100
102
|
updates staged by Sparkle and Squirrel, package manager archives, trash, crash
|
|
101
|
-
reports, core dumps and journald logs.
|
|
103
|
+
reports, core dumps and journald logs. Telegram pure caches, Safari/WebKit,
|
|
104
|
+
Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
|
|
105
|
+
caches are included. Telegram databases, browser profiles and Playwright MCP
|
|
106
|
+
profiles are preserved. macOS access failures explain when Full Disk Access
|
|
107
|
+
is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
102
108
|
|
|
103
109
|
## Safety
|
|
104
110
|
|
|
@@ -129,13 +135,35 @@ reports, core dumps and journald logs.
|
|
|
129
135
|
environment stops at once, and the audit log and summary say which
|
|
130
136
|
process was lost. A build that simply finishes during the cleanup also
|
|
131
137
|
stops it: an exit cannot be told apart from a crash.
|
|
132
|
-
- **Git awareness.**
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
- **Git awareness.** Whole projects and containers are protected while they
|
|
139
|
+
have unsaved changes, unpushed commits or stashes. Ignored dependency and
|
|
140
|
+
build outputs can be cleaned despite unrelated source changes; tracked or
|
|
141
|
+
non-ignored files inside an artifact remain protected. Stopped-container scans show
|
|
142
|
+
Git state before cleanup. Unstaged and untracked files can be recognized
|
|
143
|
+
as preserved when their bytes and modes all match one cached remote ref,
|
|
144
|
+
such as `origin/recovery/<branch>`. No remote fetch is performed; missing
|
|
145
|
+
refs, staged work and unreadable files remain blockers. Detached upstream
|
|
146
|
+
checkouts are accepted only when their commit is reachable from a remote
|
|
147
|
+
ref. Nested Docker layer copies and temporary Codex plugin checkouts are
|
|
148
|
+
excluded from this inspection. Unsaved container work requires an exact
|
|
135
149
|
`--allow-dirty-container ID` override.
|
|
136
150
|
- **Audit log on every run.** Each run, dry runs included, writes
|
|
137
151
|
`dss-<command>-<time>-<pid>.json` with every decision, the bytes freed
|
|
138
|
-
and left per environment, and disk usage before and after.
|
|
152
|
+
and left per environment, and disk usage before and after. Scan audits
|
|
153
|
+
include every candidate and its blockers; blocked candidates do not
|
|
154
|
+
contribute to reclaimable tier totals.
|
|
155
|
+
|
|
156
|
+
Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
|
|
157
|
+
header caches to be cleaned. Active npx hashes remain protected through
|
|
158
|
+
their child command paths, working directories and open files; other
|
|
159
|
+
hashes are separate candidates subject to the age window. Install commands
|
|
160
|
+
still block relevant caches. Bunx, pnpm dlx and Yarn dlx entries use the same
|
|
161
|
+
path protection, and dss always protects its own installation. Interrupts
|
|
162
|
+
finish the partial audit and report the abort. Unreadable processes retain
|
|
163
|
+
their readable identity; uncertain activity blocks relevant paths and owners
|
|
164
|
+
and produces one environment hint. Old isolation logs and sanitized upload staging
|
|
165
|
+
are moderate-tier candidates. Opam download cleanup removes its cache
|
|
166
|
+
directory directly and does not invoke `opam clean` or migrate the root.
|
|
139
167
|
|
|
140
168
|
## Docker
|
|
141
169
|
|
|
@@ -155,7 +183,11 @@ three kinds of Docker data:
|
|
|
155
183
|
the tier totals match what `clean --tier … --yes` removes.
|
|
156
184
|
- **Running containers** are never stopped, restarted or removed. They are
|
|
157
185
|
scanned and cleaned from the inside through `docker exec`, with the same
|
|
158
|
-
rules and liveness checks as the host.
|
|
186
|
+
rules and liveness checks as the host. An unreadable `/proc` probe is
|
|
187
|
+
retried with a read-only root `docker exec --privileged` inspection. If
|
|
188
|
+
this is denied or incomplete, cleanup stays blocked and names the
|
|
189
|
+
unreadable processes. This also applies when dss runs inside a container
|
|
190
|
+
with access to its Docker daemon.
|
|
159
191
|
- **Stopped containers** are listed with their size, image, command, owner
|
|
160
192
|
session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
|
|
161
193
|
environment variables, else a session id in the container name), task
|
|
@@ -169,6 +201,34 @@ three kinds of Docker data:
|
|
|
169
201
|
killed) are never removed by a tier or by `--remove-stopped-containers`,
|
|
170
202
|
in emergency mode too. Name each one with `--remove-container ID|NAME`
|
|
171
203
|
(a full name, or an id prefix of at least 12 characters) to remove it.
|
|
204
|
+
Alternatively, `--investigation-max-age 7d` lifts holds for containers
|
|
205
|
+
that ended at least seven days ago; removal still requires consent and
|
|
206
|
+
Git checks. The default keeps investigation holds indefinitely.
|
|
207
|
+
|
|
208
|
+
Container cache sizes count changed writable-layer file blocks and mounted
|
|
209
|
+
data. Immutable image data is reported separately as `imageBytes` and is
|
|
210
|
+
excluded from reclaimable totals, including the host scan when dss runs
|
|
211
|
+
inside an identifiable container. Image-only caches stay visible and blocked;
|
|
212
|
+
rebuild the image to remove that data. These estimates exclude directory
|
|
213
|
+
metadata overhead. A small stopped container pinning a large image is shown
|
|
214
|
+
even below `--min-size`; its potential image bytes require a separate image
|
|
215
|
+
removal after the container is removed.
|
|
216
|
+
|
|
217
|
+
`--no-docker` makes no Docker calls, including for storage accounting and
|
|
218
|
+
saved-report cleanup. An overlay root without authoritative upperdir evidence
|
|
219
|
+
has unknown freeable bytes and contributes zero to reclaimable totals.
|
|
220
|
+
Measurements prefer accessible upperdirs; the diff fallback shares one
|
|
221
|
+
serialized snapshot and cached stats per container/run, checks host and cgroup
|
|
222
|
+
memory headroom, and skips excessive candidate counts. Fresh Git checks before
|
|
223
|
+
stopped-container removal still inspect new work. Verbose mode reports storage
|
|
224
|
+
measurement progress. Host scans prune runtime, nested overlay, read-only and
|
|
225
|
+
unselected mount points; explicitly selected ordinary mount roots are allowed.
|
|
226
|
+
|
|
227
|
+
Docker discovery uses a size-free container listing and measures each
|
|
228
|
+
container independently. Failed size queries are retried once; objects
|
|
229
|
+
remain visible with `sizeUnknown` when measurement fails. Scans continue
|
|
230
|
+
through other objects and nested daemons, report partial errors, and return
|
|
231
|
+
a nonzero CLI exit status when discovery is incomplete.
|
|
172
232
|
|
|
173
233
|
`--recursive` follows Docker-in-Docker: a container that runs its own daemon
|
|
174
234
|
is scanned as a new environment, down to `--depth` levels (default 3). The
|
|
@@ -367,14 +427,14 @@ The release workflow uses [Changesets](https://github.com/changesets/changesets)
|
|
|
367
427
|
5. **Optional Docker Hub publishing**: When configured, waits for the exact npm version and tags the Docker image with that version
|
|
368
428
|
6. **GitHub releases**: Auto-created with formatted release notes
|
|
369
429
|
|
|
370
|
-
> **First release of a brand-new package**:
|
|
371
|
-
>
|
|
372
|
-
>
|
|
373
|
-
>
|
|
374
|
-
>
|
|
375
|
-
>
|
|
376
|
-
>
|
|
377
|
-
>
|
|
430
|
+
> **First release of a brand-new package**: npm publishes only through
|
|
431
|
+
> trusted publishing, and a trusted publisher can only be attached to a package
|
|
432
|
+
> that already exists (the first publish from CI fails with `E404`). Publish the
|
|
433
|
+
> first version once with
|
|
434
|
+
> [package-registry-manager](https://github.com/link-foundation/package-registry-manager):
|
|
435
|
+
> `npx package-registry-manager setup --registry npm --execute`. It signs in
|
|
436
|
+
> through the browser, publishes, attaches this workflow as the trusted
|
|
437
|
+
> publisher and signs out again. No npm token is ever stored.
|
|
378
438
|
|
|
379
439
|
#### Manual Releases
|
|
380
440
|
|
|
@@ -411,7 +471,7 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) implements a fast-
|
|
|
411
471
|
|
|
412
472
|
Every CI job declares an explicit `timeout-minutes` so hung steps fail
|
|
413
473
|
in minutes instead of reaching the GitHub Actions default of six hours.
|
|
414
|
-
Fast checks use 5-10 minute caps, release jobs use
|
|
474
|
+
Fast checks use 5-10 minute caps, release jobs use 50 minutes, and the
|
|
415
475
|
link checker uses 10 minutes for external network variance.
|
|
416
476
|
|
|
417
477
|
That cap is a backstop, never the deadline: GitHub reports a job it
|
|
@@ -485,13 +545,14 @@ variants without script edits.
|
|
|
485
545
|
|
|
486
546
|
The link checker workflow (`.github/workflows/links.yml`) validates all links in Markdown and HTML files:
|
|
487
547
|
|
|
488
|
-
1. **Detection**: Uses [lychee](https://github.com/lycheeverse/lychee-action) to
|
|
489
|
-
2. **
|
|
490
|
-
3. **
|
|
548
|
+
1. **Detection**: Uses [lychee](https://github.com/lycheeverse/lychee-action) to walk the checkout for `.md` and `.html` files (respecting `.gitignore`, including dot directories such as `.changeset`)
|
|
549
|
+
2. **Transient re-check**: Links that failed with a transport error or an HTTP 429/5xx answer are retried with a capped wait before they count as broken; GitHub file links fall back to the contents API. Set `RECHECK_VERBOSE=true` to log every probe
|
|
550
|
+
3. **Web Archive fallback**: For any broken links found, automatically checks the [Wayback Machine](https://web.archive.org) for archived versions
|
|
551
|
+
4. **Actionable suggestions**: Reports one of three outcomes for each broken link:
|
|
491
552
|
- **Archived**: Suggests the Web Archive URL as a replacement
|
|
492
553
|
- **Not archived**: Clearly reports the link is unrecoverable
|
|
493
|
-
|
|
494
|
-
|
|
554
|
+
5. **Scheduled checks**: Runs weekly to catch links that break over time (even if no files changed)
|
|
555
|
+
6. **Issue creation**: On scheduled runs, creates a GitHub Issue with the full broken links report
|
|
495
556
|
|
|
496
557
|
Add regex patterns to `.lycheeignore` to exclude URLs from checks (e.g., local dev URLs, example.com, known rate-limited sites).
|
|
497
558
|
|
package/bin/dss.js
CHANGED
|
@@ -2,4 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { runCli } from '../src/cli.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const abort = () => controller.abort();
|
|
7
|
+
process.once('SIGINT', abort);
|
|
8
|
+
process.once('SIGTERM', abort);
|
|
9
|
+
try {
|
|
10
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
11
|
+
signal: controller.signal,
|
|
12
|
+
});
|
|
13
|
+
} finally {
|
|
14
|
+
process.removeListener('SIGINT', abort);
|
|
15
|
+
process.removeListener('SIGTERM', abort);
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
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",
|
|
@@ -68,19 +68,19 @@
|
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
71
|
-
"url": "git+https://github.com/
|
|
71
|
+
"url": "git+https://github.com/link-foundation/disk-space-saviour.git"
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
|
74
74
|
"node": ">=20.0.0"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@changesets/cli": "^
|
|
77
|
+
"@changesets/cli": "^3.0.3",
|
|
78
78
|
"eslint": "^9.38.0",
|
|
79
79
|
"eslint-config-prettier": "^10.1.8",
|
|
80
80
|
"eslint-plugin-prettier": "^5.5.4",
|
|
81
81
|
"husky": "^9.1.7",
|
|
82
|
-
"jscpd": "^4.0
|
|
83
|
-
"lint-staged": "^16.
|
|
82
|
+
"jscpd": "^5.4.0",
|
|
83
|
+
"lint-staged": "^16.4.0",
|
|
84
84
|
"prettier": "^3.6.2",
|
|
85
85
|
"test-anywhere": "^0.8.48"
|
|
86
86
|
},
|
package/src/audit.js
CHANGED
|
@@ -13,6 +13,12 @@ import path from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
export const AUDIT_SCHEMA = 1;
|
|
15
15
|
|
|
16
|
+
export function throwIfAborted(options) {
|
|
17
|
+
if (options.signal?.aborted) {
|
|
18
|
+
throw new Error('cleanup aborted');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function stateDir(vars) {
|
|
17
23
|
const base =
|
|
18
24
|
vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
package/src/clean.js
CHANGED
|
@@ -18,16 +18,19 @@ import path from 'node:path';
|
|
|
18
18
|
import { finished } from 'node:stream/promises';
|
|
19
19
|
|
|
20
20
|
import { backupDirectory, startAudit, writeAudit } from './audit.js';
|
|
21
|
+
import { configureContainerHost } from './docker/writable.js';
|
|
21
22
|
import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
|
|
22
23
|
import {
|
|
23
24
|
containerGitState,
|
|
24
25
|
containerNamed,
|
|
25
26
|
gitBlockersForRemoval,
|
|
26
27
|
investigationBlocker,
|
|
28
|
+
investigationExpired,
|
|
27
29
|
runningBindMounts,
|
|
28
30
|
} from './docker/containers.js';
|
|
29
31
|
import { imageNamed } from './docker/images.js';
|
|
30
32
|
import { resolveEnvironment } from './env/resolve.js';
|
|
33
|
+
import { configureScanPolicy } from './env/policy.js';
|
|
31
34
|
import { GitInspector } from './git.js';
|
|
32
35
|
import { HealthWatch } from './health.js';
|
|
33
36
|
import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
|
|
@@ -35,9 +38,15 @@ import { LivenessProbe } from './liveness.js';
|
|
|
35
38
|
import { resolveOptions } from './options.js';
|
|
36
39
|
import { isWithin } from './paths.js';
|
|
37
40
|
import { OTHER_RULES } from './rules/other.js';
|
|
38
|
-
import { compareVersions } from './rules/versions.js';
|
|
41
|
+
import { compareVersions, VERSION_RULES } from './rules/versions.js';
|
|
42
|
+
import { scanVersions } from './scanners/versions.js';
|
|
39
43
|
import { analyzeRustProfile } from './scanners/rust.js';
|
|
40
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
filterItems,
|
|
46
|
+
revalidateReport,
|
|
47
|
+
selfContainerIds,
|
|
48
|
+
scannedEnvironment,
|
|
49
|
+
} from './scan.js';
|
|
41
50
|
|
|
42
51
|
const STOPPED = new Set(['exited', 'created', 'dead']);
|
|
43
52
|
const COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -123,6 +132,23 @@ async function browserRevisionBlocker(env, item) {
|
|
|
123
132
|
return null;
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
async function configureStorage(env, options) {
|
|
136
|
+
await configureScanPolicy(env, options);
|
|
137
|
+
if (env.kind === 'host' && !env.writableLayer) {
|
|
138
|
+
await configureContainerHost(
|
|
139
|
+
env,
|
|
140
|
+
options.docker === false ? [] : await selfContainerIds(env),
|
|
141
|
+
options
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function imageOnly(bytes, usages) {
|
|
147
|
+
return (
|
|
148
|
+
bytes === 0 && [...usages.values()].some((usage) => usage.imageBytes > 0)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
export class Cleaner {
|
|
127
153
|
/**
|
|
128
154
|
* @param {object} report output of `scan()`
|
|
@@ -229,7 +255,8 @@ export class Cleaner {
|
|
|
229
255
|
if (type === 'docker-rm') {
|
|
230
256
|
const git = await containerGitState(
|
|
231
257
|
new DockerCli(ctx.executor),
|
|
232
|
-
item.action.containerId
|
|
258
|
+
item.action.containerId,
|
|
259
|
+
{ fresh: true }
|
|
233
260
|
);
|
|
234
261
|
const blockers = gitBlockersForRemoval(
|
|
235
262
|
git,
|
|
@@ -241,6 +268,17 @@ export class Cleaner {
|
|
|
241
268
|
if (type === 'none' || item.paths.length === 0) {
|
|
242
269
|
return { item };
|
|
243
270
|
}
|
|
271
|
+
await configureStorage(ctx.env, this.options);
|
|
272
|
+
for (const target of item.paths) {
|
|
273
|
+
const boundary = ctx.env.scanPolicy.removalReason(target);
|
|
274
|
+
if (boundary) {
|
|
275
|
+
return { reason: boundary };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return this.#remeasure(ctx, item, type);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #remeasure(ctx, item, type) {
|
|
244
282
|
let paths = item.paths;
|
|
245
283
|
if (item.recheck?.type === 'rust') {
|
|
246
284
|
const current = await analyzeRustProfile(ctx.env, item.recheck.profile, {
|
|
@@ -263,10 +301,22 @@ export class Cleaner {
|
|
|
263
301
|
const fresh = {
|
|
264
302
|
...item,
|
|
265
303
|
paths,
|
|
266
|
-
bytes:
|
|
304
|
+
bytes: sumBytes(usages, paths),
|
|
267
305
|
newestMtimeMs: newest || item.newestMtimeMs,
|
|
268
306
|
action: type === 'remove' ? { ...item.action, paths } : item.action,
|
|
269
307
|
};
|
|
308
|
+
if (imageOnly(fresh.bytes, usages)) {
|
|
309
|
+
return {
|
|
310
|
+
reason:
|
|
311
|
+
'all bytes are in the image layer; rebuild the image to reclaim them',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if ([...usages.values()].some((usage) => usage.sizeUnknown)) {
|
|
315
|
+
return {
|
|
316
|
+
reason:
|
|
317
|
+
'writable-layer size unknown; inspect container/image storage before cleanup',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
270
320
|
const staticBlocker = await this.#staticBlocker(ctx, fresh);
|
|
271
321
|
if (staticBlocker) {
|
|
272
322
|
return { reason: staticBlocker };
|
|
@@ -306,6 +356,27 @@ export class Cleaner {
|
|
|
306
356
|
}
|
|
307
357
|
|
|
308
358
|
async #staticBlocker(ctx, item) {
|
|
359
|
+
if (item.recheck?.type === 'version') {
|
|
360
|
+
const rule = VERSION_RULES.find(
|
|
361
|
+
(candidate) => candidate.id === item.rule
|
|
362
|
+
);
|
|
363
|
+
const candidates = await scanVersions(
|
|
364
|
+
{
|
|
365
|
+
...ctx,
|
|
366
|
+
options: {
|
|
367
|
+
...this.options,
|
|
368
|
+
homes: [item.recheck.home],
|
|
369
|
+
roots: this.report.options.roots ?? [],
|
|
370
|
+
},
|
|
371
|
+
now: this.options.now(),
|
|
372
|
+
liveness: null,
|
|
373
|
+
},
|
|
374
|
+
{ rules: [rule] }
|
|
375
|
+
);
|
|
376
|
+
if (!candidates.some((candidate) => candidate.path === item.path)) {
|
|
377
|
+
return `${item.path} is now current, pinned, or the newest installed version`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
309
380
|
if (
|
|
310
381
|
item.recheck?.type === 'pyenv-envs' &&
|
|
311
382
|
(await ctx.env.list(ctx.env.path.join(item.path, 'envs'))).length > 0
|
|
@@ -358,13 +429,20 @@ export class Cleaner {
|
|
|
358
429
|
}
|
|
359
430
|
|
|
360
431
|
async #gitBlocker(ctx, item) {
|
|
361
|
-
if (
|
|
432
|
+
if (
|
|
433
|
+
item.project ||
|
|
434
|
+
item.rule === 'gh-issue-solver-workdirs' ||
|
|
435
|
+
item.recheck?.type === 'scratch'
|
|
436
|
+
) {
|
|
362
437
|
ctx.git.invalidate();
|
|
363
438
|
const blockers =
|
|
364
|
-
item.rule === 'gh-issue-solver-workdirs'
|
|
365
|
-
|
|
439
|
+
item.rule === 'gh-issue-solver-workdirs' ||
|
|
440
|
+
item.recheck?.type === 'scratch'
|
|
441
|
+
? await ctx.git.treeBlockers(item.path, {
|
|
442
|
+
allowNoRepo: item.recheck?.type === 'scratch',
|
|
443
|
+
})
|
|
366
444
|
: await ctx.git.pathBlockers(item.path, {
|
|
367
|
-
requireClean:
|
|
445
|
+
requireClean: false,
|
|
368
446
|
});
|
|
369
447
|
if (blockers.length > 0) {
|
|
370
448
|
return blockers.join('; ');
|
|
@@ -392,28 +470,28 @@ export class Cleaner {
|
|
|
392
470
|
*/
|
|
393
471
|
async #removePaths(ctx, item, entry) {
|
|
394
472
|
for (const target of item.action.paths) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (stopped) {
|
|
398
|
-
entry.reason = `stopped before ${target}: ${stopped}`;
|
|
399
|
-
return;
|
|
473
|
+
if (this.options.signal?.aborted) {
|
|
474
|
+
throw new Error('cleanup aborted');
|
|
400
475
|
}
|
|
401
|
-
await ctx
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
entry.reason = `stopped before ${target}: busy: ${busy}`;
|
|
476
|
+
const blocker = await this.#removalBlocker(ctx, item, target);
|
|
477
|
+
if (blocker) {
|
|
478
|
+
entry.reason = blocker;
|
|
405
479
|
return;
|
|
406
480
|
}
|
|
407
481
|
const usage = await ctx.env.usage(target);
|
|
408
|
-
const inUse =
|
|
409
|
-
item.checks?.perPath &&
|
|
410
|
-
(ctx.liveness.openInside([target]) ??
|
|
411
|
-
ctx.liveness.recentWrite(usage?.newestMtimeMs));
|
|
482
|
+
const inUse = this.#pathActivity(ctx, item, target, usage);
|
|
412
483
|
if (inUse) {
|
|
413
484
|
entry.skippedPaths.push({ path: target, reason: inUse });
|
|
414
485
|
continue;
|
|
415
486
|
}
|
|
487
|
+
// The usage walk may discover an excluded child that was added since scan.
|
|
488
|
+
const boundary = ctx.env.scanPolicy.removalReason(target);
|
|
489
|
+
if (boundary) {
|
|
490
|
+
entry.reason = boundary;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
416
493
|
await ctx.env.remove([target]);
|
|
494
|
+
ctx.env.writableLayer?.forget?.(target, ctx.env.path);
|
|
417
495
|
entry.status = 'removed';
|
|
418
496
|
entry.deletedPaths.push(target);
|
|
419
497
|
entry.freedBytes += usage?.bytes ?? 0;
|
|
@@ -424,6 +502,30 @@ export class Cleaner {
|
|
|
424
502
|
}
|
|
425
503
|
}
|
|
426
504
|
|
|
505
|
+
#pathActivity(ctx, item, target, usage) {
|
|
506
|
+
return (
|
|
507
|
+
item.checks?.perPath &&
|
|
508
|
+
(ctx.liveness.openInside([target]) ??
|
|
509
|
+
ctx.liveness.recentWrite(usage?.newestMtimeMs))
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async #removalBlocker(ctx, item, target) {
|
|
514
|
+
await configureScanPolicy(ctx.env, this.options);
|
|
515
|
+
const boundary = ctx.env.scanPolicy.removalReason(target);
|
|
516
|
+
if (boundary) {
|
|
517
|
+
return boundary;
|
|
518
|
+
}
|
|
519
|
+
await ctx.liveness.refresh(true);
|
|
520
|
+
const stopped = this.health.verify(item, ctx.liveness.processList());
|
|
521
|
+
if (stopped) {
|
|
522
|
+
return `stopped before ${target}: ${stopped}`;
|
|
523
|
+
}
|
|
524
|
+
await ctx.liveness.resolve([target]);
|
|
525
|
+
const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
|
|
526
|
+
return busy ? `stopped before ${target}: busy: ${busy}` : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
427
529
|
async #command(ctx, item, entry) {
|
|
428
530
|
const { action } = item;
|
|
429
531
|
const isDocker = action.argv[0] === 'docker';
|
|
@@ -490,7 +592,7 @@ export class Cleaner {
|
|
|
490
592
|
entry.reason = kept;
|
|
491
593
|
return;
|
|
492
594
|
}
|
|
493
|
-
const git = await containerGitState(docker, containerId);
|
|
595
|
+
const git = await containerGitState(docker, containerId, { fresh: true });
|
|
494
596
|
const blockers = gitBlockersForRemoval(git, containerId, this.options);
|
|
495
597
|
if (blockers.length > 0) {
|
|
496
598
|
entry.reason = blockers.join('; ');
|
|
@@ -589,6 +691,7 @@ export function environmentTotals(report, entries) {
|
|
|
589
691
|
export function wantedItems(report, options) {
|
|
590
692
|
const approved = options.approvedIds ? new Set(options.approvedIds) : null;
|
|
591
693
|
return filterItems(report.items, { ...options, minSizeBytes: 0 }, [], path)
|
|
694
|
+
.filter((item) => options.docker !== false || !dockerItem(item, report))
|
|
592
695
|
.filter((item) => !approved || approved.has(item.id))
|
|
593
696
|
.map((item) => {
|
|
594
697
|
if (item.action?.type === 'docker-rm') {
|
|
@@ -600,6 +703,19 @@ export function wantedItems(report, options) {
|
|
|
600
703
|
});
|
|
601
704
|
}
|
|
602
705
|
|
|
706
|
+
function dockerItem(item, report) {
|
|
707
|
+
return (
|
|
708
|
+
['docker', 'container'].includes(item.kind) ||
|
|
709
|
+
item.action?.argv?.[0] === 'docker' ||
|
|
710
|
+
(report.environments ?? []).some(
|
|
711
|
+
(env) =>
|
|
712
|
+
env.id === item.env &&
|
|
713
|
+
env.kind === 'container' &&
|
|
714
|
+
(env.depth > 0 || env.chain?.length > 0)
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
603
719
|
/**
|
|
604
720
|
* Applies per-container consent: `--remove-container` approves the removal
|
|
605
721
|
* of that container and lifts its investigation hold, an exact
|
|
@@ -609,11 +725,15 @@ function containerConsent(item, options) {
|
|
|
609
725
|
const { containerId, name } = item.action;
|
|
610
726
|
const named = containerNamed(options.removeContainers, containerId, name);
|
|
611
727
|
const dirtyAllowed = options.allowDirtyContainers?.includes(containerId);
|
|
612
|
-
|
|
728
|
+
const expired = investigationExpired(
|
|
729
|
+
{ FinishedAt: item.container?.owner?.finishedAt },
|
|
730
|
+
options
|
|
731
|
+
);
|
|
732
|
+
if (!named && !dirtyAllowed && !expired) {
|
|
613
733
|
return item;
|
|
614
734
|
}
|
|
615
735
|
const lifted = (reason) =>
|
|
616
|
-
(named && reason === item.container?.investigation) ||
|
|
736
|
+
((named || expired) && reason === item.container?.investigation) ||
|
|
617
737
|
(dirtyAllowed &&
|
|
618
738
|
item.container?.gitBlockers?.includes(reason) &&
|
|
619
739
|
/^Git repository .+ has /.test(reason));
|
|
@@ -636,6 +756,7 @@ export function planItems(report, options) {
|
|
|
636
756
|
/** Options that only the cleaning run itself may grant. */
|
|
637
757
|
export const CONFIRMATION_FLAGS = [
|
|
638
758
|
'removeStoppedContainers',
|
|
759
|
+
'investigationMaxAge',
|
|
639
760
|
'removeContainers',
|
|
640
761
|
'removeImages',
|
|
641
762
|
'includeVolumes',
|
|
@@ -680,21 +801,30 @@ export async function clean(report, input = {}) {
|
|
|
680
801
|
};
|
|
681
802
|
const cleaner = new Cleaner(report, {
|
|
682
803
|
...options,
|
|
804
|
+
env: options.env ?? scannedEnvironment(report),
|
|
683
805
|
onProgress: persistProgress,
|
|
684
806
|
});
|
|
685
807
|
audit.health = cleaner.health.records;
|
|
686
808
|
if (options.audit !== false) {
|
|
687
809
|
await writeAudit(audit, { ...options, inProgress: true });
|
|
688
810
|
}
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
811
|
+
try {
|
|
812
|
+
for (const item of planItems(report, options)) {
|
|
813
|
+
if (options.signal?.aborted) {
|
|
814
|
+
throw new Error('cleanup aborted');
|
|
815
|
+
}
|
|
816
|
+
const entry = await cleaner.process(item);
|
|
817
|
+
if (!audit.entries.includes(entry)) {
|
|
818
|
+
audit.entries.push(entry);
|
|
819
|
+
}
|
|
820
|
+
options.onEntry?.(entry, item);
|
|
821
|
+
if (options.audit !== false) {
|
|
822
|
+
await writeAudit(audit, { ...options, inProgress: true });
|
|
823
|
+
}
|
|
697
824
|
}
|
|
825
|
+
} catch (error) {
|
|
826
|
+
audit.aborted = true;
|
|
827
|
+
audit.error = error.message;
|
|
698
828
|
}
|
|
699
829
|
return finishAudit(audit, report, options);
|
|
700
830
|
}
|
|
@@ -703,6 +833,10 @@ export async function clean(report, input = {}) {
|
|
|
703
833
|
* Adds totals and writes the audit log (unless `audit: false`).
|
|
704
834
|
*/
|
|
705
835
|
export async function finishAudit(audit, report, options) {
|
|
836
|
+
if (options.signal?.aborted) {
|
|
837
|
+
audit.aborted = true;
|
|
838
|
+
audit.error ??= 'cleanup aborted';
|
|
839
|
+
}
|
|
706
840
|
audit.finishedAt = new Date().toISOString();
|
|
707
841
|
audit.freedBytes = audit.entries.reduce((sum, e) => sum + e.freedBytes, 0);
|
|
708
842
|
audit.plannedBytes = audit.entries
|