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/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.
|
|
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
|
-
'
|
|
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
|
-
|
|
244
|
-
|
|
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. */
|
package/src/docker/containers.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/src/docker/scan.js
CHANGED
|
@@ -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) =>
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
315
|
-
if (!filters
|
|
316
|
-
return
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
474
|
+
inventory,
|
|
382
475
|
df,
|
|
383
476
|
this.options,
|
|
384
|
-
await this.users(
|
|
477
|
+
await this.users(inventory)
|
|
385
478
|
)
|
|
386
479
|
);
|
|
387
480
|
if (this.options.includeVolumes) {
|
|
388
481
|
this.result.volumes.push(
|
|
389
|
-
...(
|
|
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,
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|