disk-space-saviour 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unused tagged images: who used them last and per-image consent.
3
+ *
4
+ * An unused tagged image is never removed by a tier, emergency mode
5
+ * included: only an operator naming it with `--remove-image REF` (or an
6
+ * interactive yes for that image) approves `docker image rm`.
7
+ *
8
+ * "Last used by" comes from the `inspect.json` backups dss writes before it
9
+ * removes a stopped container (`<backupDir>/<run>/<env>/<container>/`).
10
+ */
11
+
12
+ import { promises as fsp } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import { ownerOf } from './containers.js';
16
+
17
+ const SHORT_ID = 12;
18
+ const MAX_BACKUPS = 200;
19
+
20
+ const bareId = (id) => String(id ?? '').replace(/^sha256:/, '');
21
+
22
+ /**
23
+ * Whether the operator named this image: `repo:tag`, `repo` for
24
+ * `repo:latest`, or an image id (with or without `sha256:`) prefix of at
25
+ * least 12 characters.
26
+ * @param {string[]} names
27
+ * @param {{ref: string, id: string}} image
28
+ */
29
+ export function imageNamed(names, image) {
30
+ const id = bareId(image.id);
31
+ return (names ?? []).some((entry) => {
32
+ const bare = bareId(entry);
33
+ return (
34
+ entry === image.ref ||
35
+ `${entry}:latest` === image.ref ||
36
+ (bare.length >= SHORT_ID &&
37
+ id.length >= SHORT_ID &&
38
+ /^[0-9a-f]+$/i.test(bare) &&
39
+ (id.startsWith(bare) || bare.startsWith(id)))
40
+ );
41
+ });
42
+ }
43
+
44
+ async function subdirs(dir) {
45
+ const entries = await fsp
46
+ .readdir(dir, { withFileTypes: true })
47
+ .catch(() => []);
48
+ return entries
49
+ .filter((entry) => entry.isDirectory())
50
+ .map((entry) => path.join(dir, entry.name));
51
+ }
52
+
53
+ async function inspectBackups(backupDir) {
54
+ const runs = (await subdirs(backupDir)).sort().reverse();
55
+ const files = [];
56
+ for (const run of runs) {
57
+ for (const env of await subdirs(run)) {
58
+ for (const container of await subdirs(env)) {
59
+ files.push(path.join(container, 'inspect.json'));
60
+ if (files.length >= MAX_BACKUPS) {
61
+ return files;
62
+ }
63
+ }
64
+ }
65
+ }
66
+ return files;
67
+ }
68
+
69
+ function lastUser(inspected) {
70
+ const name = String(inspected.Name ?? '').replace(/^\//, '');
71
+ const owner = ownerOf({ Names: name }, inspected);
72
+ return {
73
+ container: name,
74
+ session: owner.session,
75
+ taskUrl: owner.taskUrl,
76
+ finishedAt: owner.finishedAt,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The last removed container of every image, keyed by image id and by
82
+ * `ref:<image reference>`.
83
+ * @param {string} backupDir
84
+ * @returns {Promise<Map<string, object>>}
85
+ */
86
+ export async function lastUsers(backupDir) {
87
+ const users = new Map();
88
+ const remember = (key, user) => {
89
+ const known = users.get(key);
90
+ if (!known || String(user.finishedAt) > String(known.finishedAt)) {
91
+ users.set(key, user);
92
+ }
93
+ };
94
+ for (const file of await inspectBackups(backupDir)) {
95
+ try {
96
+ const inspected = JSON.parse(await fsp.readFile(file, 'utf8'));
97
+ const user = lastUser(inspected);
98
+ if (inspected.Image) {
99
+ remember(bareId(inspected.Image), user);
100
+ }
101
+ if (inspected.Config?.Image) {
102
+ remember(`ref:${inspected.Config.Image}`, user);
103
+ }
104
+ } catch {
105
+ // A partial or foreign backup says nothing about image use.
106
+ }
107
+ }
108
+ return users;
109
+ }
110
+
111
+ /**
112
+ * Looks up the last user of an image by id prefix, then by reference.
113
+ */
114
+ export function lastUserOf(users, image) {
115
+ const id = bareId(image.id);
116
+ for (const [key, user] of users) {
117
+ if (id.length >= SHORT_ID && key.startsWith(id)) {
118
+ return user;
119
+ }
120
+ }
121
+ return users.get(`ref:${image.ref}`) ?? null;
122
+ }
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Recursive Docker scan.
3
+ *
4
+ * For every reachable daemon (the host's, then daemons running inside
5
+ * containers, up to `dockerDepth` levels):
6
+ * - daemon objects: dangling images and build cache (`safe`), unused tagged
7
+ * images (`moderate`, each one named in `removeImages`; ./images.js),
8
+ * unused volumes (reported with `includeVolumes`, never removed
9
+ * automatically);
10
+ * - running containers are never stopped, restarted or removed: their
11
+ * filesystems are scanned through `docker exec` with the same rules as
12
+ * the host, and a Docker CLI inside them is followed (Docker-in-Docker);
13
+ * - stopped containers are reported with their size, owner session, task,
14
+ * exit code and the Git state of repositories in their writable layer,
15
+ * and are removed only with `removeStoppedContainers` (after a logs
16
+ * backup, at clean time). A container that failed or was OOM killed is
17
+ * kept for investigation until it is named in `removeContainers`;
18
+ * - image layer hints (report only).
19
+ *
20
+ * The same daemon reached twice (a mounted socket) is scanned once.
21
+ */
22
+
23
+ import { backupDirectory } from '../audit.js';
24
+ import { ShellEnv } from '../env/shell.js';
25
+ import { containerExecutor, trace } from '../exec.js';
26
+ import { block, makeItem } from '../items.js';
27
+ import { DockerCli, parseDockerSize } from './cli.js';
28
+ import {
29
+ containerGitState,
30
+ containerNamed,
31
+ gitBlockersForRemoval,
32
+ investigationBlocker,
33
+ isHostBindSource,
34
+ ownerOf,
35
+ } from './containers.js';
36
+ import { lastUserOf, lastUsers } from './images.js';
37
+
38
+ const RUNNING = new Set(['running']);
39
+ const STOPPED = new Set(['exited', 'created', 'dead']);
40
+ const LAYER_HINT_IMAGES = 5;
41
+ const LAYER_HINT_BYTES = 50e6;
42
+
43
+ function dfRow(rows, type) {
44
+ return rows.find((row) => row.Type === type) ?? {};
45
+ }
46
+
47
+ function imageStore(info) {
48
+ const status = JSON.stringify(info.DriverStatus ?? []);
49
+ return /containerd\.snapshotter/.test(status) ? 'containerd' : 'graphdriver';
50
+ }
51
+
52
+ /**
53
+ * Size columns of `docker ps --size` (`19.1MB (virtual 27.5MB)`).
54
+ */
55
+ export function containerSizes(sizeText) {
56
+ const virtual = /virtual ([^)]+)\)/.exec(sizeText ?? '');
57
+ return {
58
+ bytes: parseDockerSize(sizeText),
59
+ virtualBytes: virtual ? parseDockerSize(virtual[1]) : 0,
60
+ };
61
+ }
62
+
63
+ function daemonItems(daemon, verbose, df, options, users) {
64
+ const { env } = daemon;
65
+ const items = [];
66
+ const images = verbose.Images ?? [];
67
+ const unused = images.filter((image) => image.Containers === '0');
68
+ const dangling = unused.filter((image) => image.Tag === '<none>');
69
+ const danglingBytes = dangling.reduce(
70
+ (sum, image) => sum + parseDockerSize(image.UniqueSize ?? image.Size),
71
+ 0
72
+ );
73
+ const measure = { volume: daemon.rootDir, parse: 'docker' };
74
+ if (dangling.length > 0) {
75
+ items.push(
76
+ makeItem(env, {
77
+ rule: 'docker-dangling-images',
78
+ kind: 'docker',
79
+ ecosystem: 'docker',
80
+ description: `${dangling.length} dangling Docker images`,
81
+ target: `daemon:${daemon.id}:dangling`,
82
+ bytes: danglingBytes,
83
+ reason: 'untagged images no container uses',
84
+ action: {
85
+ type: 'command',
86
+ argv: ['docker', 'image', 'prune', '--force'],
87
+ ...measure,
88
+ },
89
+ checks: { busy: [], cwd: null, mtime: false },
90
+ })
91
+ );
92
+ }
93
+ const buildBytes = parseDockerSize(dfRow(df, 'Build Cache').Reclaimable);
94
+ if (buildBytes > 0) {
95
+ const seconds = Math.round(options.staleAgeMs / 1000);
96
+ items.push(
97
+ makeItem(env, {
98
+ rule: 'docker-build-cache',
99
+ kind: 'docker',
100
+ ecosystem: 'docker',
101
+ description: 'Docker build cache not used by running builds',
102
+ target: `daemon:${daemon.id}:build-cache`,
103
+ bytes: buildBytes,
104
+ reason: `unused build cache older than ${seconds}s is re-created by the next build`,
105
+ action: {
106
+ type: 'command',
107
+ argv: [
108
+ 'docker',
109
+ 'builder',
110
+ 'prune',
111
+ '--all',
112
+ '--force',
113
+ '--filter',
114
+ `until=${seconds}s`,
115
+ ],
116
+ ...measure,
117
+ },
118
+ checks: { busy: ['buildkitd'], cwd: null, mtime: false },
119
+ })
120
+ );
121
+ }
122
+ for (const image of taggedImages(unused)) {
123
+ const name = `${image.Repository}:${image.Tag}`;
124
+ const bytes = parseDockerSize(image.UniqueSize ?? image.Size);
125
+ const summary = { ref: name, id: image.ID };
126
+ items.push(
127
+ makeItem(env, {
128
+ rule: 'docker-unused-image',
129
+ kind: 'docker',
130
+ ecosystem: 'docker',
131
+ description: `unused Docker image ${name}`,
132
+ target: `daemon:${daemon.id}:image:${image.ID}`,
133
+ bytes,
134
+ tier: 'moderate',
135
+ reason: 'no container uses it; remove it only after deciding per image',
136
+ requiresConfirmation: 'removeImages',
137
+ image: {
138
+ ...summary,
139
+ bytes,
140
+ createdAt: image.CreatedAt ?? null,
141
+ lastUsedBy: lastUserOf(users, summary),
142
+ },
143
+ action: {
144
+ type: 'command',
145
+ argv: ['docker', 'image', 'rm', image.ID],
146
+ ...measure,
147
+ },
148
+ checks: { busy: [], cwd: null, mtime: false },
149
+ })
150
+ );
151
+ }
152
+ return items;
153
+ }
154
+
155
+ function taggedImages(unused) {
156
+ return unused.filter((image) => image.Tag !== '<none>');
157
+ }
158
+
159
+ /**
160
+ * Report-only hints about image layers that keep package caches.
161
+ */
162
+ export function layerHints(image, history) {
163
+ const hints = [];
164
+ for (const layer of history) {
165
+ const bytes = parseDockerSize(layer.Size);
166
+ const command = layer.CreatedBy ?? '';
167
+ const hint = layerAdvice(command, bytes);
168
+ if (hint && bytes >= LAYER_HINT_BYTES) {
169
+ hints.push({
170
+ image,
171
+ bytes,
172
+ createdBy: command.slice(0, 300),
173
+ hint,
174
+ });
175
+ }
176
+ }
177
+ return hints;
178
+ }
179
+
180
+ function layerAdvice(command, bytes) {
181
+ if (
182
+ /apt-get install/.test(command) &&
183
+ !/\/var\/lib\/apt\/lists/.test(command)
184
+ ) {
185
+ return 'apt lists stay in the layer: add `rm -rf /var/lib/apt/lists/*` to the same RUN';
186
+ }
187
+ if (
188
+ /\b(npm (ci|install)|yarn install)\b/.test(command) &&
189
+ !/cache clean/.test(command)
190
+ ) {
191
+ return 'the npm/yarn cache stays in the layer: clean it in the same RUN or use a cache mount';
192
+ }
193
+ if (/pip3? install/.test(command) && !/--no-cache-dir/.test(command)) {
194
+ return 'the pip cache stays in the layer: use `pip install --no-cache-dir`';
195
+ }
196
+ if (/\bcargo (build|install)\b/.test(command)) {
197
+ return 'cargo registry and target stay in the layer: use a multi-stage build or cache mounts';
198
+ }
199
+ return bytes >= 500e6
200
+ ? 'very large layer: consider a multi-stage build'
201
+ : null;
202
+ }
203
+
204
+ /** Every tag of each image id, largest images first. */
205
+ function imagesById(rows) {
206
+ const byId = new Map();
207
+ for (const image of rows) {
208
+ const name =
209
+ image.Tag === '<none>' ? image.ID : `${image.Repository}:${image.Tag}`;
210
+ const entry = byId.get(image.ID) ?? { id: image.ID, names: [], image };
211
+ entry.names.push(name);
212
+ byId.set(image.ID, entry);
213
+ }
214
+ return [...byId.values()];
215
+ }
216
+
217
+ /** The same layer, whichever image's history it was read from. */
218
+ function layerKey(layer) {
219
+ return layer.ID && layer.ID !== '<missing>'
220
+ ? layer.ID
221
+ : `${layer.CreatedBy ?? ''}\0${layer.Size ?? ''}`;
222
+ }
223
+
224
+ export async function imageHints(docker, verbose) {
225
+ const largest = imagesById(
226
+ [...(verbose.Images ?? [])].filter((image) => image.Containers !== '0')
227
+ )
228
+ .sort(
229
+ (a, b) => parseDockerSize(b.image.Size) - parseDockerSize(a.image.Size)
230
+ )
231
+ .slice(0, LAYER_HINT_IMAGES);
232
+ const byLayer = new Map();
233
+ for (const { id, names } of largest) {
234
+ let history;
235
+ try {
236
+ history = await docker.history(id);
237
+ } catch (error) {
238
+ trace('history failed', names[0], error.message);
239
+ continue;
240
+ }
241
+ for (const layer of history) {
242
+ const [hint] = layerHints(names[0], [layer]);
243
+ if (!hint) {
244
+ continue;
245
+ }
246
+ const key = layerKey(layer);
247
+ const shared = byLayer.get(key);
248
+ if (shared) {
249
+ shared.images.push(...names.filter((n) => !shared.images.includes(n)));
250
+ } else {
251
+ byLayer.set(key, { ...hint, images: [...names] });
252
+ }
253
+ }
254
+ }
255
+ return [...byLayer.values()];
256
+ }
257
+
258
+ async function stoppedItem(daemon, ps, inspected, options) {
259
+ const sizes = containerSizes(ps.Size);
260
+ const owner = ownerOf(ps, inspected);
261
+ const kept = investigationBlocker(
262
+ inspected?.State,
263
+ ps.ID,
264
+ owner.name,
265
+ options
266
+ );
267
+ const item = makeItem(daemon.env, {
268
+ rule: 'docker-stopped-container',
269
+ kind: 'container',
270
+ ecosystem: 'docker',
271
+ description: `stopped container ${owner.name} (${ps.Image})`,
272
+ target: `daemon:${daemon.id}:container:${ps.ID}`,
273
+ bytes: sizes.bytes,
274
+ tier: 'moderate',
275
+ reason: 'stopped container, its writable layer is deleted by `docker rm`',
276
+ requiresConfirmation: 'removeStoppedContainers',
277
+ container: {
278
+ id: ps.ID,
279
+ name: owner.name,
280
+ state: ps.State,
281
+ owner,
282
+ investigation: kept,
283
+ },
284
+ action: {
285
+ type: 'docker-rm',
286
+ containerId: ps.ID,
287
+ name: owner.name,
288
+ finishedAt: inspected?.State?.FinishedAt ?? null,
289
+ volume: daemon.rootDir,
290
+ },
291
+ checks: { busy: [], cwd: null, mtime: false },
292
+ });
293
+ if (kept) {
294
+ block(item, kept);
295
+ }
296
+ if (
297
+ options.removeStoppedContainers ||
298
+ containerNamed(options.removeContainers, ps.ID, owner.name)
299
+ ) {
300
+ const git = await containerGitState(daemon.docker, ps.ID);
301
+ item.container.repos = git.repos;
302
+ item.container.gitBlockers = git.blockers;
303
+ gitBlockersForRemoval(git, ps.ID, options).forEach((reason) =>
304
+ block(item, reason)
305
+ );
306
+ } else {
307
+ item.container.repos = [];
308
+ item.container.gitBlockers = [];
309
+ item.container.gitCheckDeferred = true;
310
+ }
311
+ return item;
312
+ }
313
+
314
+ function matchesFilter(ps, filters) {
315
+ if (!filters || filters.length === 0) {
316
+ return true;
317
+ }
318
+ const names = String(ps.Names ?? '').split(',');
319
+ return filters.some(
320
+ (filter) => ps.ID.startsWith(filter) || names.includes(filter)
321
+ );
322
+ }
323
+
324
+ class DockerScan {
325
+ constructor(options, scanEnvironment) {
326
+ this.options = options;
327
+ this.scanEnvironment = scanEnvironment;
328
+ this.seen = new Set();
329
+ this.result = {
330
+ daemons: [],
331
+ containers: [],
332
+ environments: [],
333
+ items: [],
334
+ hints: [],
335
+ bindMounts: [],
336
+ volumes: [],
337
+ };
338
+ }
339
+
340
+ async daemon(record, depth, inherited) {
341
+ const docker = new DockerCli(record.executor);
342
+ const info = await docker.info();
343
+ if (!info) {
344
+ return;
345
+ }
346
+ const id = info.ID || `${record.env.id}:docker`;
347
+ if (this.seen.has(id)) {
348
+ trace('daemon already scanned', id, 'via', record.env.id);
349
+ return;
350
+ }
351
+ this.seen.add(id);
352
+ const daemon = {
353
+ id,
354
+ env: record.env,
355
+ docker,
356
+ depth,
357
+ rootDir: info.DockerRootDir ?? null,
358
+ };
359
+ const df = await docker.systemDf();
360
+ const verbose = await docker.systemDfVerbose();
361
+ this.result.daemons.push({
362
+ id,
363
+ env: record.env.id,
364
+ depth,
365
+ name: info.Name ?? null,
366
+ serverVersion: info.ServerVersion ?? null,
367
+ driver: info.Driver ?? null,
368
+ imageStore: imageStore(info),
369
+ rootDir: daemon.rootDir,
370
+ df: df.map((row) => ({
371
+ type: row.Type,
372
+ count: Number(row.TotalCount),
373
+ active: Number(row.Active),
374
+ bytes: parseDockerSize(row.Size),
375
+ reclaimableBytes: parseDockerSize(row.Reclaimable),
376
+ })),
377
+ });
378
+ this.result.items.push(
379
+ ...daemonItems(
380
+ daemon,
381
+ verbose,
382
+ df,
383
+ this.options,
384
+ await this.users(verbose)
385
+ )
386
+ );
387
+ if (this.options.includeVolumes) {
388
+ this.result.volumes.push(
389
+ ...(verbose.Volumes ?? [])
390
+ .filter((volume) => volume.Links === '0')
391
+ .map((volume) => ({
392
+ daemon: id,
393
+ name: volume.Name,
394
+ bytes: parseDockerSize(volume.Size),
395
+ note: 'inspect contents before removing this volume manually',
396
+ }))
397
+ );
398
+ }
399
+ this.result.hints.push(...(await imageHints(docker, verbose)));
400
+ await this.containers(daemon, record, depth, inherited);
401
+ }
402
+
403
+ /** Last users of images, read once and only when an image needs them. */
404
+ users(verbose) {
405
+ const unused = (verbose.Images ?? []).filter(
406
+ (image) => image.Containers === '0'
407
+ );
408
+ if (taggedImages(unused).length === 0) {
409
+ return Promise.resolve(new Map());
410
+ }
411
+ this.lastUsers ??= lastUsers(backupDirectory(this.options));
412
+ return this.lastUsers;
413
+ }
414
+
415
+ async containers(daemon, record, depth, inherited) {
416
+ const all = await daemon.docker.containers();
417
+ const inspected = new Map(
418
+ (await daemon.docker.inspect(all.map((ps) => ps.ID))).map((c) => [
419
+ c.Id,
420
+ c,
421
+ ])
422
+ );
423
+ if (record.env.kind === 'host') {
424
+ for (const ps of all.filter((row) => row.State === 'running')) {
425
+ for (const mount of inspected.get(ps.ID)?.Mounts ?? []) {
426
+ if (mount.Type === 'bind' && isHostBindSource(mount.Source)) {
427
+ this.result.bindMounts.push({
428
+ source: mount.Source,
429
+ containerId: ps.ID,
430
+ });
431
+ }
432
+ }
433
+ }
434
+ }
435
+ for (const ps of all) {
436
+ const selected =
437
+ inherited || matchesFilter(ps, this.options.containerFilter);
438
+ if (!selected) {
439
+ continue;
440
+ }
441
+ await this.container(daemon, record, depth + 1, ps, inspected.get(ps.ID));
442
+ }
443
+ }
444
+
445
+ async container(daemon, record, depth, ps, inspected) {
446
+ const sizes = containerSizes(ps.Size);
447
+ const owner = ownerOf(ps, inspected);
448
+ const entry = {
449
+ id: ps.ID,
450
+ name: owner.name,
451
+ image: ps.Image,
452
+ state: ps.State,
453
+ status: ps.Status,
454
+ daemon: daemon.id,
455
+ daemonEnv: record.env.id,
456
+ depth,
457
+ bytes: sizes.bytes,
458
+ virtualBytes: sizes.virtualBytes,
459
+ owner,
460
+ env: null,
461
+ scanned: false,
462
+ note: null,
463
+ };
464
+ this.result.containers.push(entry);
465
+ if ((this.options.selfContainerIds ?? []).includes(ps.ID)) {
466
+ entry.note = 'this is the container dss runs in, scanned as the host';
467
+ return;
468
+ }
469
+ if (depth > this.options.dockerDepth) {
470
+ entry.note = `deeper than the Docker depth limit ${this.options.dockerDepth} (--depth)`;
471
+ return;
472
+ }
473
+ if (STOPPED.has(ps.State)) {
474
+ this.result.items.push(
475
+ await stoppedItem(daemon, ps, inspected, this.options)
476
+ );
477
+ return;
478
+ }
479
+ if (!RUNNING.has(ps.State)) {
480
+ entry.note = `state ${ps.State}, left alone`;
481
+ return;
482
+ }
483
+ await this.running(record, depth, ps, entry);
484
+ }
485
+
486
+ async running(record, depth, ps, entry) {
487
+ const label = `${record.env.label}/${entry.name}`;
488
+ const executor = containerExecutor(record.executor, ps.ID, { label });
489
+ const env = new ShellEnv(executor, {
490
+ id: `${record.env.id}/${entry.name}`,
491
+ label,
492
+ kind: 'container',
493
+ });
494
+ const probe = await executor.run(['sh', '-c', 'true'], {
495
+ timeoutMs: 30000,
496
+ });
497
+ if (probe.code !== 0) {
498
+ entry.note = `cannot run sh inside: ${probe.stderr.trim() || probe.code}`;
499
+ return;
500
+ }
501
+ entry.env = env.id;
502
+ entry.scanned = true;
503
+ const chain = [...record.chain, { containerId: ps.ID, name: entry.name }];
504
+ this.result.environments.push({
505
+ id: env.id,
506
+ label,
507
+ kind: 'container',
508
+ depth,
509
+ chain,
510
+ });
511
+ this.result.items.push(...(await this.scanEnvironment(env)));
512
+ if (await env.which('docker')) {
513
+ await this.daemon({ env, executor, chain }, depth, true);
514
+ }
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Scans every daemon reachable from `hostRecord`.
520
+ * @param {{env: object, executor: object, chain: object[]}} hostRecord
521
+ * @param {object} options resolved scan options
522
+ * @param {(env: object) => Promise<object[]>} scanEnvironment
523
+ */
524
+ export async function scanDocker(hostRecord, options, scanEnvironment) {
525
+ const scan = new DockerScan(options, scanEnvironment);
526
+ if (!(await hostRecord.env.which('docker'))) {
527
+ return scan.result;
528
+ }
529
+ await scan.daemon(hostRecord, 0, false);
530
+ return scan.result;
531
+ }