disk-space-saviour 0.13.1 → 0.15.2

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/src/cli.js CHANGED
@@ -54,6 +54,7 @@ Consent:
54
54
  -y, --yes delete without asking (non-interactive use)
55
55
  --dry-run never delete, show the plan
56
56
  --remove-stopped-containers allow docker rm of verified stopped containers
57
+ --investigation-max-age AGE lift investigation holds older than AGE (opt-in)
57
58
  --remove-container ID|NAME remove this stopped container, even one kept
58
59
  for investigation (repeat)
59
60
  --remove-image REF remove this unused image (repo:tag or id;
@@ -94,6 +95,7 @@ const OPTIONS = {
94
95
  yes: { type: 'boolean', short: 'y' },
95
96
  'dry-run': { type: 'boolean' },
96
97
  'remove-stopped-containers': { type: 'boolean' },
98
+ 'investigation-max-age': { type: 'string' },
97
99
  'remove-container': { type: 'string', multiple: true },
98
100
  'remove-image': { type: 'string', multiple: true },
99
101
  'remove-unused-images': { type: 'boolean' },
@@ -168,6 +170,7 @@ export function toOptions(values, paths = []) {
168
170
  until: values.until,
169
171
  path: values.path,
170
172
  removeStoppedContainers: values['remove-stopped-containers'],
173
+ investigationMaxAge: values['investigation-max-age'],
171
174
  removeContainers: values['remove-container'],
172
175
  removeImages: values['remove-image'],
173
176
  includeVolumes: values['include-volumes'],
@@ -240,6 +243,7 @@ export function scanAudit(report) {
240
243
  totals: report.totals,
241
244
  environments: report.environments.map((env) => env.id),
242
245
  errors: report.errors,
246
+ items: report.items,
243
247
  },
244
248
  entries: report.items.map((item) => ({
245
249
  id: item.id,
@@ -301,7 +305,7 @@ class Cli {
301
305
  'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
302
306
  ].join('\n')
303
307
  );
304
- return 0;
308
+ return report.errors.length > 0 ? 1 : 0;
305
309
  }
306
310
 
307
311
  async loadReport(options, values) {
@@ -362,7 +366,10 @@ class Cli {
362
366
  this.print(values, audit, () =>
363
367
  formatAudit(audit, { verbose: values.verbose })
364
368
  );
365
- return audit.entries.some((entry) => entry.status === 'failed') ? 1 : 0;
369
+ return audit.aborted ||
370
+ audit.entries.some((entry) => entry.status === 'failed')
371
+ ? 1
372
+ : 0;
366
373
  }
367
374
 
368
375
  async emergency(options, values) {
@@ -431,6 +438,9 @@ export async function runCli(argv, deps = {}) {
431
438
  setTrace(true);
432
439
  }
433
440
  const options = dropUndefined(toOptions(values, paths));
441
+ if (deps.signal) {
442
+ options.signal = deps.signal;
443
+ }
434
444
  return await dispatch(
435
445
  new Cli(io, deps.api ?? API),
436
446
  command,
package/src/docker/cli.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { pipeInto } from '../exec.js';
11
+ import { diffSnapshot } from './snapshots.js';
11
12
 
12
13
  const DECIMAL_UNITS = {
13
14
  b: 1,
@@ -208,11 +209,29 @@ export class DockerCli {
208
209
  }
209
210
 
210
211
  async containers() {
212
+ const output = await this.json([
213
+ 'ps',
214
+ '--all',
215
+ '--no-trunc',
216
+ '--format',
217
+ '{{.ID}}\\t{{.Names}}\\t{{.State}}\\t{{.Status}}\\t{{.Image}}',
218
+ ]);
219
+ return output
220
+ .trim()
221
+ .split('\n')
222
+ .filter(Boolean)
223
+ .map((line) => {
224
+ const [ID, Names, State, Status, Image] = line.split('\t');
225
+ return { ID, Names, State, Status, Image };
226
+ });
227
+ }
228
+
229
+ async images() {
211
230
  return jsonLines(
212
231
  await this.json([
213
- 'ps',
232
+ 'image',
233
+ 'ls',
214
234
  '--all',
215
- '--size',
216
235
  '--no-trunc',
217
236
  '--format',
218
237
  '{{json .}}',
@@ -220,6 +239,16 @@ export class DockerCli {
220
239
  );
221
240
  }
222
241
 
242
+ async containerSize(id) {
243
+ const [entry] = JSON.parse(
244
+ await this.json(['inspect', '--type', 'container', '--size', id])
245
+ );
246
+ if (!Number.isFinite(entry?.SizeRw) || entry.SizeRw < 0) {
247
+ throw new Error(`size unknown for container ${id}`);
248
+ }
249
+ return { bytes: entry.SizeRw, virtualBytes: entry.SizeRootFs ?? 0 };
250
+ }
251
+
223
252
  async inspect(ids) {
224
253
  if (ids.length === 0) {
225
254
  return [];
@@ -240,9 +269,8 @@ export class DockerCli {
240
269
  }
241
270
  }
242
271
 
243
- async diff(id) {
244
- const result = await this.run(['diff', id]);
245
- return result.code === 0 ? result.stdout : null;
272
+ diff(id, options) {
273
+ return diffSnapshot(this, id, options);
246
274
  }
247
275
 
248
276
  /** Check a small file in a stopped container without starting it. */
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
 
15
15
  import { GitInspector, stateBlockers } from '../git.js';
16
16
  import { LocalEnv } from '../env/local.js';
17
+ import { trace } from '../exec.js';
17
18
  import { repoRootsFromDiff } from './cli.js';
18
19
 
19
20
  export const COPY_EXCLUDES = [
@@ -95,8 +96,7 @@ export function ownerOf(ps, inspected) {
95
96
  return {
96
97
  name,
97
98
  image: ps.Image,
98
- command: ps.Command ? ps.Command.replace(/^"|"$/g, '') : null,
99
- createdAt: ps.CreatedAt ?? null,
99
+ ...containerMetadata(ps, inspected),
100
100
  finishedAt: inspected?.State?.FinishedAt ?? null,
101
101
  exitCode: typeof state?.ExitCode === 'number' ? state.ExitCode : null,
102
102
  oomKilled: Boolean(state?.OOMKilled),
@@ -108,6 +108,18 @@ export function ownerOf(ps, inspected) {
108
108
  };
109
109
  }
110
110
 
111
+ function containerMetadata(ps, inspected) {
112
+ return {
113
+ command: ps.Command
114
+ ? ps.Command.replace(/^"|"$/g, '')
115
+ : [
116
+ ...(inspected?.Config?.Entrypoint ?? []),
117
+ ...(inspected?.Config?.Cmd ?? []),
118
+ ].join(' ') || null,
119
+ createdAt: ps.CreatedAt ?? inspected?.Created ?? null,
120
+ };
121
+ }
122
+
111
123
  /** Counters and flags such as `1` or `true` are not session ids. */
112
124
  function sessionLike(value) {
113
125
  const text = String(value).trim();
@@ -180,10 +192,28 @@ export function containerNamed(names, id, name) {
180
192
  );
181
193
  }
182
194
 
195
+ export function investigationExpired(state, options = {}) {
196
+ const age = options.investigationMaxAgeMs;
197
+ const finished = Date.parse(state?.FinishedAt);
198
+ return (
199
+ age !== null &&
200
+ age !== undefined &&
201
+ Number.isFinite(age) &&
202
+ age >= 0 &&
203
+ Number.isFinite(finished) &&
204
+ finished > 0 &&
205
+ (options.now?.() ?? Date.now()) - finished >= age
206
+ );
207
+ }
208
+
183
209
  /** Blocker of a container kept for investigation that was not named. */
184
210
  export function investigationBlocker(state, id, name, options = {}) {
185
211
  const reason = investigationReason(state);
186
- if (!reason || containerNamed(options.removeContainers, id, name)) {
212
+ if (
213
+ !reason ||
214
+ investigationExpired(state, options) ||
215
+ containerNamed(options.removeContainers, id, name)
216
+ ) {
187
217
  return null;
188
218
  }
189
219
  return `${reason}; remove it only with --remove-container ${id.slice(0, SHORT_ID)}`;
@@ -294,20 +324,36 @@ function excludedWorkBlockers(checked, changed, blockers) {
294
324
  }
295
325
 
296
326
  export async function containerGitState(docker, id, options = {}) {
297
- const diff = await docker.diff(id);
327
+ try {
328
+ return await inspectContainerGitState(docker, id, options);
329
+ } catch (error) {
330
+ trace('container Git inspection failed', id, error.stack ?? error.message);
331
+ return {
332
+ repos: [],
333
+ blockers: [`cannot verify Git work in the container: ${error.message}`],
334
+ };
335
+ }
336
+ }
337
+
338
+ async function inspectContainerGitState(docker, id, options) {
339
+ const diff = await docker.diff(id, { fresh: options.fresh });
298
340
  if (diff === null) {
299
341
  return {
300
342
  repos: [],
301
343
  blockers: ['cannot list the writable layer (`docker diff` failed)'],
302
344
  };
303
345
  }
304
- const roots = new Set(repoRootsFromDiff(diff));
346
+ const relevantDiff = diff
347
+ .split('\n')
348
+ .filter((line) => !excludedRepoPath(line.slice(2)))
349
+ .join('\n');
350
+ const roots = new Set(repoRootsFromDiff(relevantDiff));
305
351
  const maxRepos = options.maxRepos ?? DEFAULT_MAX_REPOS;
306
352
  const blockers = [];
307
353
  if (roots.has('/')) {
308
354
  blockers.push('the container root is a Git repository');
309
355
  }
310
- const changed = diff
356
+ const changed = relevantDiff
311
357
  .split('\n')
312
358
  .map((line) => /^[ACD] (\/.+)$/.exec(line.trim())?.[1])
313
359
  .filter(Boolean);
@@ -324,7 +370,9 @@ export async function containerGitState(docker, id, options = {}) {
324
370
  return { repos, blockers };
325
371
  }
326
372
  // `docker cp` copies to this machine, so Git always runs locally.
327
- const git = new GitInspector(options.env ?? new LocalEnv());
373
+ const git = new GitInspector(options.env ?? new LocalEnv(), {
374
+ recognizePreserved: true,
375
+ });
328
376
  for (const root of checked) {
329
377
  const state = await copyAndCheck(docker, id, root, git);
330
378
  repos.push(state);
@@ -332,3 +380,11 @@ export async function containerGitState(docker, id, options = {}) {
332
380
  }
333
381
  return { repos, blockers };
334
382
  }
383
+
384
+ function excludedRepoPath(target) {
385
+ return (
386
+ /^\/var\/lib\/docker\/(?:overlay2|fuse-overlayfs|btrfs|containerd)\//.test(
387
+ target
388
+ ) || /\/\.codex\/(?:.*\/)?\.tmp\/plugins[^/]*(?:\/|$)/.test(target)
389
+ );
390
+ }
@@ -27,7 +27,6 @@ import { block, makeItem } from '../items.js';
27
27
  import { DockerCli, parseDockerSize } from './cli.js';
28
28
  import {
29
29
  containerGitState,
30
- containerNamed,
31
30
  gitBlockersForRemoval,
32
31
  investigationBlocker,
33
32
  isHostBindSource,
@@ -67,7 +66,9 @@ function daemonItems(daemon, verbose, df, options, users) {
67
66
  const unused = images.filter((image) => image.Containers === '0');
68
67
  const dangling = unused.filter((image) => image.Tag === '<none>');
69
68
  const danglingBytes = dangling.reduce(
70
- (sum, image) => sum + parseDockerSize(image.UniqueSize ?? image.Size),
69
+ (sum, image) =>
70
+ sum +
71
+ (image.sizeUnknown ? 0 : parseDockerSize(image.UniqueSize ?? image.Size)),
71
72
  0
72
73
  );
73
74
  const measure = { volume: daemon.rootDir, parse: 'docker' };
@@ -80,6 +81,7 @@ function daemonItems(daemon, verbose, df, options, users) {
80
81
  description: `${dangling.length} dangling Docker images`,
81
82
  target: `daemon:${daemon.id}:dangling`,
82
83
  bytes: danglingBytes,
84
+ sizeUnknown: dangling.some((image) => image.sizeUnknown),
83
85
  reason: 'untagged images no container uses',
84
86
  action: {
85
87
  type: 'command',
@@ -121,7 +123,9 @@ function daemonItems(daemon, verbose, df, options, users) {
121
123
  }
122
124
  for (const image of taggedImages(unused)) {
123
125
  const name = `${image.Repository}:${image.Tag}`;
124
- const bytes = parseDockerSize(image.UniqueSize ?? image.Size);
126
+ const bytes = image.sizeUnknown
127
+ ? 0
128
+ : parseDockerSize(image.UniqueSize ?? image.Size);
125
129
  const summary = { ref: name, id: image.ID };
126
130
  items.push(
127
131
  makeItem(env, {
@@ -131,6 +135,7 @@ function daemonItems(daemon, verbose, df, options, users) {
131
135
  description: `unused Docker image ${name}`,
132
136
  target: `daemon:${daemon.id}:image:${image.ID}`,
133
137
  bytes,
138
+ sizeUnknown: Boolean(image.sizeUnknown),
134
139
  tier: 'moderate',
135
140
  reason: 'no container uses it; remove it only after deciding per image',
136
141
  requiresConfirmation: 'removeImages',
@@ -256,7 +261,7 @@ export async function imageHints(docker, verbose) {
256
261
  }
257
262
 
258
263
  async function stoppedItem(daemon, ps, inspected, options) {
259
- const sizes = containerSizes(ps.Size);
264
+ const sizes = ps.sizes ?? { bytes: 0, virtualBytes: 0 };
260
265
  const owner = ownerOf(ps, inspected);
261
266
  const kept = investigationBlocker(
262
267
  inspected?.State,
@@ -271,6 +276,7 @@ async function stoppedItem(daemon, ps, inspected, options) {
271
276
  description: `stopped container ${owner.name} (${ps.Image})`,
272
277
  target: `daemon:${daemon.id}:container:${ps.ID}`,
273
278
  bytes: sizes.bytes,
279
+ sizeUnknown: ps.sizeUnknown ?? false,
274
280
  tier: 'moderate',
275
281
  reason: 'stopped container, its writable layer is deleted by `docker rm`',
276
282
  requiresConfirmation: 'removeStoppedContainers',
@@ -280,6 +286,8 @@ async function stoppedItem(daemon, ps, inspected, options) {
280
286
  state: ps.State,
281
287
  owner,
282
288
  investigation: kept,
289
+ pinnedImage: ps.pinnedImage ?? null,
290
+ sizeUnknown: ps.sizeUnknown ?? false,
283
291
  },
284
292
  action: {
285
293
  type: 'docker-rm',
@@ -293,32 +301,65 @@ async function stoppedItem(daemon, ps, inspected, options) {
293
301
  if (kept) {
294
302
  block(item, kept);
295
303
  }
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
- }
304
+ const git = await containerGitState(daemon.docker, ps.ID);
305
+ item.container.repos = git.repos;
306
+ item.container.gitBlockers = git.blockers;
307
+ gitBlockersForRemoval(git, ps.ID, options).forEach((reason) =>
308
+ block(item, reason)
309
+ );
311
310
  return item;
312
311
  }
313
312
 
314
- function matchesFilter(ps, filters) {
315
- if (!filters || filters.length === 0) {
316
- return true;
313
+ export function selectContainers(rows, filters = []) {
314
+ if (!filters.length) {
315
+ return { ids: new Set(rows.map((row) => row.ID)), errors: [] };
316
+ }
317
+ const ids = new Set();
318
+ const errors = [];
319
+ for (const filter of filters) {
320
+ const exact = rows.filter(
321
+ (row) =>
322
+ row.ID === filter ||
323
+ String(row.Names ?? '')
324
+ .split(',')
325
+ .includes(filter)
326
+ );
327
+ const matches = exact.length
328
+ ? exact
329
+ : rows.filter(
330
+ (row) =>
331
+ row.ID.startsWith(filter) ||
332
+ String(row.Names ?? '')
333
+ .split(',')
334
+ .some((name) => name.startsWith(filter))
335
+ );
336
+ if (matches.length === 1) {
337
+ ids.add(matches[0].ID);
338
+ } else {
339
+ errors.push(
340
+ `container filter ${filter}: ${matches.length ? `ambiguous (${matches.length} matches)` : 'no matching container'}`
341
+ );
342
+ }
317
343
  }
318
- const names = String(ps.Names ?? '').split(',');
319
- return filters.some(
320
- (filter) => ps.ID.startsWith(filter) || names.includes(filter)
344
+ return { ids, errors };
345
+ }
346
+
347
+ function pinnedImage(ps, all, inspected, inventory) {
348
+ const imageId = inspected.get(ps.ID)?.Image;
349
+ const image = (inventory.Images ?? []).find(
350
+ (row) => row.ID === imageId || `${row.Repository}:${row.Tag}` === ps.Image
351
+ );
352
+ const users = all.filter((row) =>
353
+ imageId ? inspected.get(row.ID)?.Image === imageId : row.Image === ps.Image
321
354
  );
355
+ return image && users.length === 1 && STOPPED.has(ps.State)
356
+ ? {
357
+ ref: ps.Image,
358
+ id: image.ID,
359
+ bytes: parseDockerSize(image.UniqueSize),
360
+ sizeUnknown: !image.UniqueSize,
361
+ }
362
+ : null;
322
363
  }
323
364
 
324
365
  class DockerScan {
@@ -334,6 +375,7 @@ class DockerScan {
334
375
  hints: [],
335
376
  bindMounts: [],
336
377
  volumes: [],
378
+ errors: [],
337
379
  };
338
380
  }
339
381
 
@@ -356,8 +398,59 @@ class DockerScan {
356
398
  depth,
357
399
  rootDir: info.DockerRootDir ?? null,
358
400
  };
359
- const df = await docker.systemDf();
360
- const verbose = await docker.systemDfVerbose();
401
+ // Discover without a global writable-layer walk. Size failures must
402
+ // never discard the object list or prevent nested daemon discovery.
403
+ const all = await this.bestEffort(
404
+ record.env.id,
405
+ () => docker.containers(),
406
+ []
407
+ );
408
+ const images = await this.bestEffort(
409
+ record.env.id,
410
+ () => docker.images(),
411
+ []
412
+ );
413
+ const inspected = new Map(
414
+ (
415
+ await this.bestEffort(
416
+ record.env.id,
417
+ () => docker.inspect(all.map((ps) => ps.ID)),
418
+ []
419
+ )
420
+ ).map((c) => [c.Id, c])
421
+ );
422
+ const df = await this.bestEffort(
423
+ record.env.id,
424
+ () => docker.systemDf(),
425
+ []
426
+ );
427
+ const verbose = await this.bestEffort(
428
+ record.env.id,
429
+ () => docker.systemDfVerbose(),
430
+ null
431
+ );
432
+ const inventory = verbose ?? {
433
+ Images: images.map((image) => ({
434
+ ...image,
435
+ Containers: String(
436
+ all.filter(
437
+ (ps) =>
438
+ inspected.get(ps.ID)?.Image === image.ID ||
439
+ ps.Image === `${image.Repository}:${image.Tag}`
440
+ ).length
441
+ ),
442
+ sizeUnknown: true,
443
+ })),
444
+ };
445
+ for (const ps of all) {
446
+ ps.sizes = await this.bestEffort(
447
+ record.env.id,
448
+ () => docker.containerSize(ps.ID),
449
+ null
450
+ );
451
+ ps.sizeUnknown = ps.sizes === null;
452
+ ps.pinnedImage = pinnedImage(ps, all, inspected, inventory);
453
+ }
361
454
  this.result.daemons.push({
362
455
  id,
363
456
  env: record.env.id,
@@ -378,15 +471,15 @@ class DockerScan {
378
471
  this.result.items.push(
379
472
  ...daemonItems(
380
473
  daemon,
381
- verbose,
474
+ inventory,
382
475
  df,
383
476
  this.options,
384
- await this.users(verbose)
477
+ await this.users(inventory)
385
478
  )
386
479
  );
387
480
  if (this.options.includeVolumes) {
388
481
  this.result.volumes.push(
389
- ...(verbose.Volumes ?? [])
482
+ ...(inventory.Volumes ?? [])
390
483
  .filter((volume) => volume.Links === '0')
391
484
  .map((volume) => ({
392
485
  daemon: id,
@@ -396,8 +489,8 @@ class DockerScan {
396
489
  }))
397
490
  );
398
491
  }
399
- this.result.hints.push(...(await imageHints(docker, verbose)));
400
- await this.containers(daemon, record, depth, inherited);
492
+ this.result.hints.push(...(await imageHints(docker, inventory)));
493
+ await this.containers(daemon, record, depth, inherited, all, inspected);
401
494
  }
402
495
 
403
496
  /** Last users of images, read once and only when an image needs them. */
@@ -412,14 +505,21 @@ class DockerScan {
412
505
  return this.lastUsers;
413
506
  }
414
507
 
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
- );
508
+ async bestEffort(env, operation, fallback) {
509
+ let error;
510
+ for (let attempt = 0; attempt < 2; attempt++) {
511
+ try {
512
+ return await operation();
513
+ } catch (caught) {
514
+ error = caught;
515
+ trace('Docker inspection retry', env, error.message);
516
+ }
517
+ }
518
+ this.result.errors.push({ env, scanner: 'docker', message: error.message });
519
+ return fallback;
520
+ }
521
+
522
+ async containers(daemon, record, depth, inherited, all, inspected) {
423
523
  if (record.env.kind === 'host') {
424
524
  for (const ps of all.filter((row) => row.State === 'running')) {
425
525
  for (const mount of inspected.get(ps.ID)?.Mounts ?? []) {
@@ -432,9 +532,19 @@ class DockerScan {
432
532
  }
433
533
  }
434
534
  }
535
+ const selection = selectContainers(
536
+ all,
537
+ inherited ? [] : this.options.containerFilter
538
+ );
539
+ this.result.errors.push(
540
+ ...selection.errors.map((message) => ({
541
+ env: record.env.id,
542
+ scanner: 'docker',
543
+ message,
544
+ }))
545
+ );
435
546
  for (const ps of all) {
436
- const selected =
437
- inherited || matchesFilter(ps, this.options.containerFilter);
547
+ const selected = selection.ids.has(ps.ID);
438
548
  if (!selected) {
439
549
  continue;
440
550
  }
@@ -443,7 +553,7 @@ class DockerScan {
443
553
  }
444
554
 
445
555
  async container(daemon, record, depth, ps, inspected) {
446
- const sizes = containerSizes(ps.Size);
556
+ const sizes = ps.sizes ?? { bytes: 0, virtualBytes: 0 };
447
557
  const owner = ownerOf(ps, inspected);
448
558
  const entry = {
449
559
  id: ps.ID,
@@ -455,6 +565,8 @@ class DockerScan {
455
565
  daemonEnv: record.env.id,
456
566
  depth,
457
567
  bytes: sizes.bytes,
568
+ sizeUnknown: ps.sizeUnknown,
569
+ pinnedImage: ps.pinnedImage ?? null,
458
570
  virtualBytes: sizes.virtualBytes,
459
571
  owner,
460
572
  env: null,
@@ -480,10 +592,10 @@ class DockerScan {
480
592
  entry.note = `state ${ps.State}, left alone`;
481
593
  return;
482
594
  }
483
- await this.running(record, depth, ps, entry);
595
+ await this.running(record, depth, ps, entry, inspected);
484
596
  }
485
597
 
486
- async running(record, depth, ps, entry) {
598
+ async running(record, depth, ps, entry, inspected) {
487
599
  const label = `${record.env.label}/${entry.name}`;
488
600
  const executor = containerExecutor(record.executor, ps.ID, { label });
489
601
  const env = new ShellEnv(executor, {
@@ -491,6 +603,7 @@ class DockerScan {
491
603
  label,
492
604
  kind: 'container',
493
605
  });
606
+ env.writableLayer.inspected = inspected;
494
607
  const probe = await executor.run(['sh', '-c', 'true'], {
495
608
  timeoutMs: 30000,
496
609
  });
@@ -510,7 +623,15 @@ class DockerScan {
510
623
  });
511
624
  this.result.items.push(...(await this.scanEnvironment(env)));
512
625
  if (await env.which('docker')) {
513
- await this.daemon({ env, executor, chain }, depth, true);
626
+ try {
627
+ await this.daemon({ env, executor, chain }, depth, true);
628
+ } catch (error) {
629
+ this.result.errors.push({
630
+ env: env.id,
631
+ scanner: 'docker',
632
+ message: error.message,
633
+ });
634
+ }
514
635
  }
515
636
  }
516
637
  }
@@ -523,6 +644,9 @@ class DockerScan {
523
644
  */
524
645
  export async function scanDocker(hostRecord, options, scanEnvironment) {
525
646
  const scan = new DockerScan(options, scanEnvironment);
647
+ if (options.docker === false) {
648
+ return scan.result;
649
+ }
526
650
  if (!(await hostRecord.env.which('docker'))) {
527
651
  return scan.result;
528
652
  }
@@ -0,0 +1,65 @@
1
+ /** One bounded, serialized change snapshot per executor/container per run. */
2
+ import { trace } from '../exec.js';
3
+ let queue = Promise.resolve();
4
+ const snapshots = new WeakMap();
5
+ export function resetDiffSnapshots(executor) {
6
+ snapshots.delete(executor);
7
+ }
8
+ export function diffSnapshot(docker, id, { fresh = false } = {}) {
9
+ const executor = docker.executor;
10
+ if (!snapshots.has(executor)) {
11
+ snapshots.set(executor, new Map());
12
+ }
13
+ const registry = snapshots.get(executor);
14
+ if (fresh) {
15
+ // Safety checks before container removal must see newly copied Git work.
16
+ registry.delete(id);
17
+ }
18
+ if (!registry.has(id)) {
19
+ const acquire = async () => {
20
+ const memory = await executor
21
+ .run(['cat', '/proc/meminfo'])
22
+ .catch(() => null);
23
+ let available =
24
+ Number(/^MemAvailable:\s+(\d+)/m.exec(memory?.stdout ?? '')?.[1]) *
25
+ 1024;
26
+ const cgroup = await executor
27
+ .run([
28
+ 'cat',
29
+ '/sys/fs/cgroup/memory.max',
30
+ '/sys/fs/cgroup/memory.current',
31
+ ])
32
+ .catch(() => null);
33
+ const [limit, used] = (cgroup?.stdout ?? '')
34
+ .trim()
35
+ .split(/\s+/)
36
+ .map(Number);
37
+ if (Number.isFinite(limit) && limit > 0 && Number.isFinite(used)) {
38
+ available = Math.min(available, Math.max(0, limit - used));
39
+ }
40
+ if (!available || available < 512 * 1024 ** 2) {
41
+ trace(
42
+ 'skipping docker diff: memory headroom unavailable or below 512 MiB',
43
+ id
44
+ );
45
+ return null;
46
+ }
47
+ trace('docker diff snapshot', id, '(concurrency 1)');
48
+ const result = await docker.run(['diff', id], {
49
+ maxCaptureBytes: 8 * 1024 ** 2,
50
+ });
51
+ return result.code === 0 &&
52
+ !result.truncated &&
53
+ result.stdout.split('\n').length <= 100000
54
+ ? result.stdout
55
+ : null;
56
+ };
57
+ const pending = queue.then(acquire, acquire);
58
+ queue = pending.then(
59
+ () => {},
60
+ () => {}
61
+ );
62
+ registry.set(id, pending);
63
+ }
64
+ return registry.get(id);
65
+ }