disk-space-saviour 0.0.0-stage → 0.11.30
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 +506 -0
- package/LICENSE +24 -0
- package/README.md +536 -2
- package/bin/dss.js +5 -0
- package/package.json +96 -4
- package/src/audit.js +84 -0
- package/src/clean.js +716 -0
- package/src/cli.js +448 -0
- package/src/docker/cli.js +289 -0
- package/src/docker/containers.js +327 -0
- package/src/docker/images.js +122 -0
- package/src/docker/scan.js +495 -0
- package/src/emergency.js +169 -0
- package/src/env/local.js +715 -0
- package/src/env/resolve.js +36 -0
- package/src/env/shell.js +433 -0
- package/src/exec.js +185 -0
- package/src/git.js +338 -0
- package/src/health.js +174 -0
- package/src/index.d.ts +254 -0
- package/src/index.js +52 -0
- package/src/items.js +157 -0
- package/src/liveness.js +190 -0
- package/src/options.js +112 -0
- package/src/paths.js +182 -0
- package/src/report.js +373 -0
- package/src/rules/ecosystems.js +916 -0
- package/src/rules/other.js +375 -0
- package/src/rules/versions.js +256 -0
- package/src/scan.js +384 -0
- package/src/scanners/agents.js +166 -0
- package/src/scanners/common.js +38 -0
- package/src/scanners/global.js +217 -0
- package/src/scanners/projects.js +335 -0
- package/src/scanners/rust.js +404 -0
- package/src/scanners/system.js +171 -0
- package/src/scanners/versions.js +230 -0
- package/src/units.js +130 -0
package/src/options.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option defaults and normalization shared by `scan()`, `clean()` and
|
|
3
|
+
* `emergency()`. Sizes and durations accept numbers (bytes, milliseconds)
|
|
4
|
+
* or strings (`512M`, `1h`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { parseDuration, parsePercent, parseSize } from './units.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULTS = {
|
|
10
|
+
roots: null,
|
|
11
|
+
maxDepth: 6,
|
|
12
|
+
staleAge: '1h',
|
|
13
|
+
inactive: '30d',
|
|
14
|
+
minSize: '1M',
|
|
15
|
+
host: true,
|
|
16
|
+
docker: null,
|
|
17
|
+
dockerDepth: 3,
|
|
18
|
+
containers: [],
|
|
19
|
+
scanners: ['projects', 'global', 'versions', 'agents', 'system'],
|
|
20
|
+
only: [],
|
|
21
|
+
exclude: [],
|
|
22
|
+
includeVolumes: false,
|
|
23
|
+
removeStoppedContainers: false,
|
|
24
|
+
removeContainers: [],
|
|
25
|
+
removeImages: [],
|
|
26
|
+
allowDirtyRepos: false,
|
|
27
|
+
allowDirtyContainers: [],
|
|
28
|
+
noNative: false,
|
|
29
|
+
journalKeep: '512M',
|
|
30
|
+
tier: 'safe',
|
|
31
|
+
yes: false,
|
|
32
|
+
backupDir: null,
|
|
33
|
+
auditDir: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function toBytes(value) {
|
|
37
|
+
return typeof value === 'number' ? value : parseSize(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toMs(value) {
|
|
41
|
+
return typeof value === 'number' ? value : parseDuration(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toList(value) {
|
|
45
|
+
if (value === null || value === undefined || value === '') {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return (Array.isArray(value) ? value : [value])
|
|
49
|
+
.flatMap((entry) => String(entry).split(','))
|
|
50
|
+
.map((entry) => entry.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalizes user options. `olderThan` is an alias of `staleAge`.
|
|
56
|
+
* @param {object} [input]
|
|
57
|
+
* @returns {object}
|
|
58
|
+
*/
|
|
59
|
+
export function resolveOptions(input = {}) {
|
|
60
|
+
if (input.removeUnusedImages) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'--remove-unused-images is not supported: approve each image with --remove-image REF'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const merged = { ...DEFAULTS };
|
|
66
|
+
for (const [key, value] of Object.entries(input)) {
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
merged[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const staleAge = input.olderThan ?? merged.staleAge;
|
|
72
|
+
const roots = toList(merged.roots);
|
|
73
|
+
return {
|
|
74
|
+
...merged,
|
|
75
|
+
roots: roots.length > 0 ? roots : null,
|
|
76
|
+
maxDepth: Number(merged.maxDepth),
|
|
77
|
+
dockerDepth: Number(merged.dockerDepth),
|
|
78
|
+
staleAgeMs: toMs(staleAge),
|
|
79
|
+
inactiveMs: toMs(merged.inactive),
|
|
80
|
+
minSizeBytes: toBytes(merged.minSize),
|
|
81
|
+
journalKeepBytes: toBytes(merged.journalKeep),
|
|
82
|
+
containerFilter: toList(merged.containers),
|
|
83
|
+
allowDirtyContainers: toList(merged.allowDirtyContainers),
|
|
84
|
+
removeContainers: toList(merged.removeContainers),
|
|
85
|
+
removeImages: toList(merged.removeImages),
|
|
86
|
+
scanners: toList(merged.scanners),
|
|
87
|
+
only: toList(merged.only),
|
|
88
|
+
exclude: toList(merged.exclude),
|
|
89
|
+
now: merged.now ?? (() => Date.now()),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses an emergency goal: `free` (bytes to free, `20G`) and/or `until`
|
|
95
|
+
* (target used percentage, `80%`).
|
|
96
|
+
* @returns {{freeBytes: number|null, untilPercent: number|null}}
|
|
97
|
+
*/
|
|
98
|
+
export function resolveGoal({ free, until } = {}) {
|
|
99
|
+
const goal = {
|
|
100
|
+
freeBytes: free === undefined || free === null ? null : toBytes(free),
|
|
101
|
+
untilPercent:
|
|
102
|
+
until === undefined || until === null
|
|
103
|
+
? null
|
|
104
|
+
: typeof until === 'number'
|
|
105
|
+
? until
|
|
106
|
+
: parsePercent(until),
|
|
107
|
+
};
|
|
108
|
+
if (goal.freeBytes === null && goal.untilPercent === null) {
|
|
109
|
+
throw new Error('emergency needs a goal: --free <size> or --until <N%>');
|
|
110
|
+
}
|
|
111
|
+
return goal;
|
|
112
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path helpers shared by the local and remote (container) environments.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const globCache = new Map();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts a basename glob (`*`, `?`, `[...]`) to a RegExp.
|
|
9
|
+
* @param {string} glob
|
|
10
|
+
* @returns {RegExp}
|
|
11
|
+
*/
|
|
12
|
+
export function globToRegExp(glob) {
|
|
13
|
+
let cached = globCache.get(glob);
|
|
14
|
+
if (!cached) {
|
|
15
|
+
let source = '';
|
|
16
|
+
for (const char of glob) {
|
|
17
|
+
if (char === '*') {
|
|
18
|
+
source += '[^/\\\\]*';
|
|
19
|
+
} else if (char === '?') {
|
|
20
|
+
source += '[^/\\\\]';
|
|
21
|
+
} else if (char === '[' || char === ']') {
|
|
22
|
+
source += char;
|
|
23
|
+
} else {
|
|
24
|
+
source += char.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
cached = new RegExp(`^${source}$`);
|
|
28
|
+
globCache.set(glob, cached);
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} name
|
|
35
|
+
* @param {string} glob
|
|
36
|
+
*/
|
|
37
|
+
export function matchesGlob(name, glob) {
|
|
38
|
+
return globToRegExp(glob).test(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether `child` equals `parent` or lies below it.
|
|
43
|
+
* @param {string} child
|
|
44
|
+
* @param {string} parent
|
|
45
|
+
* @param {{sep: string}} pathApi node:path flavour used by the environment
|
|
46
|
+
*/
|
|
47
|
+
export function isWithin(child, parent, pathApi) {
|
|
48
|
+
if (!child || !parent) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const normalize = (value) =>
|
|
52
|
+
pathApi.sep === '\\' ? value.toLowerCase() : value;
|
|
53
|
+
const c = normalize(pathApi.resolve(child));
|
|
54
|
+
const p = normalize(pathApi.resolve(parent));
|
|
55
|
+
if (c === p) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const prefix = p.endsWith(pathApi.sep) ? p : p + pathApi.sep;
|
|
59
|
+
return c.startsWith(prefix);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Expands `~` and `{VAR}` tokens of a rule path for one home directory.
|
|
64
|
+
* Returns null when a referenced variable is not set.
|
|
65
|
+
* @param {string} pattern
|
|
66
|
+
* @param {{home: string, vars?: Record<string,string|undefined>, pathApi: object}} context
|
|
67
|
+
*/
|
|
68
|
+
export function expandRulePath(pattern, { home, vars = {}, pathApi }) {
|
|
69
|
+
let missing = false;
|
|
70
|
+
let expanded = pattern.replace(/\{([A-Z_]+)\}/g, (_, name) => {
|
|
71
|
+
const value = vars[name];
|
|
72
|
+
if (!value) {
|
|
73
|
+
missing = true;
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
});
|
|
78
|
+
if (missing) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (expanded === '~') {
|
|
82
|
+
expanded = home;
|
|
83
|
+
} else if (expanded.startsWith('~/')) {
|
|
84
|
+
expanded = pathApi.join(home, expanded.slice(2));
|
|
85
|
+
}
|
|
86
|
+
return pathApi.normalize(expanded);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Expands glob segments (`*`) in an absolute path by listing directories.
|
|
91
|
+
* @param {object} env environment adapter
|
|
92
|
+
* @param {string} pattern absolute path that may contain glob segments
|
|
93
|
+
* @returns {Promise<string[]>}
|
|
94
|
+
*/
|
|
95
|
+
export async function expandGlobPath(env, pattern) {
|
|
96
|
+
return (await expandGlobPaths(env, [pattern])).get(pattern) ?? [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const GLOB_CHARS = /[*?[]/;
|
|
100
|
+
|
|
101
|
+
async function listDirs(env, dirs) {
|
|
102
|
+
if (typeof env.listMany === 'function') {
|
|
103
|
+
return env.listMany(dirs);
|
|
104
|
+
}
|
|
105
|
+
const listings = await Promise.all(dirs.map((dir) => env.list(dir)));
|
|
106
|
+
return new Map(dirs.map((dir, index) => [dir, listings[index]]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Paths among `targets` that exist, checked in one batch when the
|
|
111
|
+
* environment supports it.
|
|
112
|
+
* @returns {Promise<Set<string>>}
|
|
113
|
+
*/
|
|
114
|
+
export async function existingPaths(env, targets) {
|
|
115
|
+
if (typeof env.existsMany === 'function') {
|
|
116
|
+
return env.existsMany(targets);
|
|
117
|
+
}
|
|
118
|
+
const flags = await Promise.all(targets.map((target) => env.exists(target)));
|
|
119
|
+
return new Set(targets.filter((_, index) => flags[index]));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function globState(pathApi, pattern) {
|
|
123
|
+
if (!GLOB_CHARS.test(pattern)) {
|
|
124
|
+
return { pattern, segments: [], current: [pattern] };
|
|
125
|
+
}
|
|
126
|
+
const root = pathApi.parse(pattern).root;
|
|
127
|
+
const segments = pattern
|
|
128
|
+
.slice(root.length)
|
|
129
|
+
.split(/[\\/]+/)
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
return { pattern, segments, current: [root] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function advance(state, segment, listings, pathApi) {
|
|
135
|
+
if (!GLOB_CHARS.test(segment)) {
|
|
136
|
+
return state.current.map((base) => pathApi.join(base, segment));
|
|
137
|
+
}
|
|
138
|
+
return state.current.flatMap((base) =>
|
|
139
|
+
(listings.get(base) ?? [])
|
|
140
|
+
.filter((entry) => matchesGlob(entry.name, segment))
|
|
141
|
+
.map((entry) => pathApi.join(base, entry.name))
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Expands many absolute path patterns (glob segments allowed) with one
|
|
147
|
+
* listing round per path depth and one existence check.
|
|
148
|
+
* @param {object} env environment adapter
|
|
149
|
+
* @param {string[]} patterns
|
|
150
|
+
* @returns {Promise<Map<string, string[]>>} existing matches per pattern
|
|
151
|
+
*/
|
|
152
|
+
export async function expandGlobPaths(env, patterns) {
|
|
153
|
+
const pathApi = env.path;
|
|
154
|
+
const states = [...new Set(patterns)].map((p) => globState(pathApi, p));
|
|
155
|
+
for (let step = 0; ; step++) {
|
|
156
|
+
const active = states.filter(
|
|
157
|
+
(state) => step < state.segments.length && state.current.length > 0
|
|
158
|
+
);
|
|
159
|
+
if (active.length === 0) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const toList = new Set(
|
|
163
|
+
active
|
|
164
|
+
.filter((state) => GLOB_CHARS.test(state.segments[step]))
|
|
165
|
+
.flatMap((state) => state.current)
|
|
166
|
+
);
|
|
167
|
+
const listings =
|
|
168
|
+
toList.size > 0 ? await listDirs(env, [...toList]) : new Map();
|
|
169
|
+
for (const state of active) {
|
|
170
|
+
state.current = advance(state, state.segments[step], listings, pathApi);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const existing = await existingPaths(env, [
|
|
174
|
+
...new Set(states.flatMap((state) => state.current)),
|
|
175
|
+
]);
|
|
176
|
+
return new Map(
|
|
177
|
+
states.map((state) => [
|
|
178
|
+
state.pattern,
|
|
179
|
+
state.current.filter((target) => existing.has(target)),
|
|
180
|
+
])
|
|
181
|
+
);
|
|
182
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable rendering of scan reports and audit logs. The JSON forms
|
|
3
|
+
* (`--json`) are the report and audit objects themselves.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { TIERS, consentFlag } from './items.js';
|
|
7
|
+
import { formatBytes } from './units.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LIMIT = 25;
|
|
10
|
+
|
|
11
|
+
function plural(count, noun) {
|
|
12
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function manyEnvironments(report) {
|
|
16
|
+
return new Set(report.items.map((item) => item.env)).size > 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pad(text, width) {
|
|
20
|
+
return String(text).padStart(width);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function percentUsed(disk) {
|
|
24
|
+
const total = disk.used + disk.free;
|
|
25
|
+
return total > 0 ? Math.round((disk.used / total) * 100) : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One line describing the free space of a volume.
|
|
30
|
+
*/
|
|
31
|
+
export function formatDisk(disk, label = '') {
|
|
32
|
+
if (!disk) {
|
|
33
|
+
return `${label}free space unknown`;
|
|
34
|
+
}
|
|
35
|
+
const where = disk.path ? `${disk.path}: ` : '';
|
|
36
|
+
return `${label}${where}${formatBytes(disk.free)} free of ${formatBytes(
|
|
37
|
+
disk.used + disk.free
|
|
38
|
+
)} (${percentUsed(disk)}% used)`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function itemLocation(item) {
|
|
42
|
+
if (item.path) {
|
|
43
|
+
const more = item.paths.length > 1 ? ` (+${item.paths.length - 1})` : '';
|
|
44
|
+
return `${item.path}${more}`;
|
|
45
|
+
}
|
|
46
|
+
return item.description;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function itemLine(item, showEnv) {
|
|
50
|
+
const env = showEnv ? `[${item.envLabel}] ` : '';
|
|
51
|
+
const confirm = item.requiresConfirmation
|
|
52
|
+
? ` (needs ${consentFlag(item)})`
|
|
53
|
+
: '';
|
|
54
|
+
return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function limited(lines, limit) {
|
|
58
|
+
if (lines.length <= limit) {
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
...lines.slice(0, limit),
|
|
63
|
+
` … ${lines.length - limit} more (use --verbose or --json)`,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tierSection(report, tier, options) {
|
|
68
|
+
const items = report.items
|
|
69
|
+
.filter((item) => item.tier === tier && item.blockers.length === 0)
|
|
70
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
71
|
+
if (items.length === 0) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const showEnv = manyEnvironments(report);
|
|
75
|
+
const bytes = items.reduce((sum, item) => sum + item.bytes, 0);
|
|
76
|
+
return [
|
|
77
|
+
'',
|
|
78
|
+
`${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
|
|
79
|
+
...limited(
|
|
80
|
+
items.map((item) => itemLine(item, showEnv)),
|
|
81
|
+
options.limit
|
|
82
|
+
),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function blockedSection(report, options) {
|
|
87
|
+
const items = report.items.filter((item) => item.blockers.length > 0);
|
|
88
|
+
if (items.length === 0) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const lines = [];
|
|
92
|
+
for (const item of items.sort((a, b) => b.bytes - a.bytes)) {
|
|
93
|
+
lines.push(itemLine(item, manyEnvironments(report)));
|
|
94
|
+
for (const blocker of item.blockers) {
|
|
95
|
+
lines.push(` blocked: ${blocker}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
'',
|
|
100
|
+
`BLOCKED ${plural(items.length, 'item')} (kept)`,
|
|
101
|
+
...limited(lines, options.limit * 2),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function repoSummary(repo) {
|
|
106
|
+
if (repo.error) {
|
|
107
|
+
return `${repo.root}: ${repo.error}`;
|
|
108
|
+
}
|
|
109
|
+
const parts = [];
|
|
110
|
+
if (repo.dirty) {
|
|
111
|
+
parts.push(`${repo.dirty} uncommitted`);
|
|
112
|
+
}
|
|
113
|
+
if (repo.unpushed) {
|
|
114
|
+
parts.push(`${repo.unpushed} unpushed`);
|
|
115
|
+
}
|
|
116
|
+
if (repo.stashes) {
|
|
117
|
+
parts.push(`${repo.stashes} stashed`);
|
|
118
|
+
}
|
|
119
|
+
return `${repo.root}: ${parts.length > 0 ? parts.join(', ') : 'clean and pushed'}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Exit code, OOM flag, end time and task of a stopped container. */
|
|
123
|
+
function endedLine(owner) {
|
|
124
|
+
if (!owner || owner.exitCode === null || owner.exitCode === undefined) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const parts = [
|
|
128
|
+
`exit ${owner.exitCode}${owner.exitReason ? ` (${owner.exitReason})` : ''}`,
|
|
129
|
+
];
|
|
130
|
+
if (owner.oomKilled) {
|
|
131
|
+
parts.push('OOM killed');
|
|
132
|
+
}
|
|
133
|
+
if (owner.finishedAt) {
|
|
134
|
+
parts.push(`ended ${owner.finishedAt}`);
|
|
135
|
+
}
|
|
136
|
+
if (owner.taskUrl) {
|
|
137
|
+
parts.push(`task ${owner.taskUrl}`);
|
|
138
|
+
}
|
|
139
|
+
return parts.join(', ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function containerLines(container, stoppedItems) {
|
|
143
|
+
const session = container.owner?.session
|
|
144
|
+
? `session ${container.owner.session}`
|
|
145
|
+
: 'no session label';
|
|
146
|
+
const scanned = container.scanned ? ', scanned via exec' : '';
|
|
147
|
+
const lines = [
|
|
148
|
+
` ${container.name} (${container.id.slice(0, 12)}) ${container.state}, ${container.image}, ${formatBytes(container.bytes)} written, ${session}${scanned}`,
|
|
149
|
+
];
|
|
150
|
+
if (container.note) {
|
|
151
|
+
lines.push(` ${container.note}`);
|
|
152
|
+
}
|
|
153
|
+
const ended = endedLine(container.owner);
|
|
154
|
+
if (ended) {
|
|
155
|
+
lines.push(` ${ended}`);
|
|
156
|
+
}
|
|
157
|
+
const item = stoppedItems.get(container.id);
|
|
158
|
+
if (item?.container?.investigation) {
|
|
159
|
+
lines.push(` ${item.container.investigation}`);
|
|
160
|
+
}
|
|
161
|
+
for (const repo of item?.container?.repos ?? []) {
|
|
162
|
+
lines.push(` git ${repoSummary(repo)}`);
|
|
163
|
+
}
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function lastUseText(user) {
|
|
168
|
+
if (!user) {
|
|
169
|
+
return 'last use unknown';
|
|
170
|
+
}
|
|
171
|
+
const details = [
|
|
172
|
+
user.session && `session ${user.session}`,
|
|
173
|
+
user.taskUrl && `task ${user.taskUrl}`,
|
|
174
|
+
user.finishedAt && `ended ${user.finishedAt}`,
|
|
175
|
+
].filter(Boolean);
|
|
176
|
+
const more = details.length > 0 ? ` (${details.join(', ')})` : '';
|
|
177
|
+
return `last used by container ${user.container}${more}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Size, creation date, last user and the flag of an unused image. */
|
|
181
|
+
function imageLine(item) {
|
|
182
|
+
const { image } = item;
|
|
183
|
+
const created = image.createdAt ? `created ${image.createdAt}, ` : '';
|
|
184
|
+
const consent = item.requiresConfirmation
|
|
185
|
+
? `, needs ${consentFlag(item)}`
|
|
186
|
+
: '';
|
|
187
|
+
return ` image ${image.ref} (${String(image.id)
|
|
188
|
+
.replace(/^sha256:/, '')
|
|
189
|
+
.slice(
|
|
190
|
+
0,
|
|
191
|
+
12
|
|
192
|
+
)}) unused, ${formatBytes(image.bytes)}, ${created}${lastUseText(image.lastUsedBy)}${consent}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function dockerSection(report) {
|
|
196
|
+
const docker = report.docker;
|
|
197
|
+
if (!docker || docker.daemons.length === 0) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
const stoppedItems = new Map(
|
|
201
|
+
report.items
|
|
202
|
+
.filter((item) => item.container)
|
|
203
|
+
.map((item) => [item.container.id, item])
|
|
204
|
+
);
|
|
205
|
+
const lines = ['', 'DOCKER'];
|
|
206
|
+
for (const daemon of docker.daemons) {
|
|
207
|
+
lines.push(
|
|
208
|
+
` daemon ${daemon.name} (depth ${daemon.depth}, ${daemon.serverVersion}, ${daemon.driver}) via ${daemon.env}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
for (const container of docker.containers) {
|
|
212
|
+
lines.push(...containerLines(container, stoppedItems));
|
|
213
|
+
}
|
|
214
|
+
for (const item of report.items.filter((entry) => entry.image)) {
|
|
215
|
+
lines.push(imageLine(item));
|
|
216
|
+
}
|
|
217
|
+
for (const hint of docker.hints ?? []) {
|
|
218
|
+
lines.push(
|
|
219
|
+
` hint ${hint.image}: ${formatBytes(hint.bytes)} layer, ${hint.hint}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
for (const volume of docker.volumes ?? []) {
|
|
223
|
+
lines.push(
|
|
224
|
+
` volume ${volume.name}: ${formatBytes(volume.bytes)}, ${volume.note}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function totalsSection(report) {
|
|
231
|
+
const parts = TIERS.map(
|
|
232
|
+
(tier) => `${tier} ${formatBytes(report.totals[tier].bytes)}`
|
|
233
|
+
);
|
|
234
|
+
return [
|
|
235
|
+
'',
|
|
236
|
+
`Reclaimable (cumulative): ${parts.join(' · ')}; blocked ${formatBytes(report.totals.blocked.bytes)}`,
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function errorsSection(errors) {
|
|
241
|
+
if (!errors || errors.length === 0) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
return [
|
|
245
|
+
'',
|
|
246
|
+
'ERRORS',
|
|
247
|
+
...errors.map(
|
|
248
|
+
(error) => ` ${error.env} ${error.scanner}: ${error.message}`
|
|
249
|
+
),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Renders a scan report.
|
|
255
|
+
* @param {object} report
|
|
256
|
+
* @param {{verbose?: boolean}} [options]
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
export function formatReport(report, options = {}) {
|
|
260
|
+
const limit = options.verbose ? Infinity : DEFAULT_LIMIT;
|
|
261
|
+
const host = report.environments[0];
|
|
262
|
+
const lines = [
|
|
263
|
+
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
264
|
+
formatDisk(host.disk),
|
|
265
|
+
];
|
|
266
|
+
for (const tier of TIERS) {
|
|
267
|
+
lines.push(...tierSection(report, tier, { limit }));
|
|
268
|
+
}
|
|
269
|
+
lines.push(...blockedSection(report, { limit }));
|
|
270
|
+
lines.push(...dockerSection(report));
|
|
271
|
+
lines.push(...errorsSection(report.errors));
|
|
272
|
+
lines.push(...totalsSection(report));
|
|
273
|
+
return lines.join('\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function entryLine(entry) {
|
|
277
|
+
const bytes =
|
|
278
|
+
entry.status === 'removed' ? entry.freedBytes : entry.plannedBytes;
|
|
279
|
+
const size = entry.status === 'skipped' ? '' : formatBytes(bytes ?? 0);
|
|
280
|
+
const reason = entry.reason ? ` (${entry.reason})` : '';
|
|
281
|
+
const location = entry.path ?? entry.description;
|
|
282
|
+
return ` ${entry.status.padEnd(7)} ${pad(size, 10)} ${entry.rule} ${location}${reason}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function statusCounts(env) {
|
|
286
|
+
return ['removed', 'planned', 'skipped', 'failed']
|
|
287
|
+
.filter((status) => env[status] > 0)
|
|
288
|
+
.map((status) => `${env[status]} ${status}`)
|
|
289
|
+
.join(', ');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function environmentLines(environments) {
|
|
293
|
+
return Object.values(environments ?? {}).map(
|
|
294
|
+
(env) =>
|
|
295
|
+
` ${env.label}: freed ${formatBytes(env.freedBytes)}, left ${formatBytes(env.leftBytes)} (${statusCounts(env)})`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether the watched long-running processes survived, per environment.
|
|
301
|
+
*/
|
|
302
|
+
function healthLines(records) {
|
|
303
|
+
const lines = (records ?? []).map((record) => {
|
|
304
|
+
if (record.stopped) {
|
|
305
|
+
return ` ${record.envLabel}: STOPPED cleaning: ${record.stopped}`;
|
|
306
|
+
}
|
|
307
|
+
if (!record.available) {
|
|
308
|
+
return ` ${record.envLabel}: processes could not be listed`;
|
|
309
|
+
}
|
|
310
|
+
return ` ${record.envLabel}: ${record.watched.length} watched processes still running`;
|
|
311
|
+
});
|
|
312
|
+
return lines.length > 0 ? ['', 'Task health:', ...lines] : [];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function emergencyLines(audit) {
|
|
316
|
+
if (audit.command !== 'emergency') {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const goal = [
|
|
320
|
+
audit.goal.freeBytes === null
|
|
321
|
+
? null
|
|
322
|
+
: `${formatBytes(audit.goal.freeBytes)} free`,
|
|
323
|
+
audit.goal.untilPercent === null
|
|
324
|
+
? null
|
|
325
|
+
: `at most ${audit.goal.untilPercent}% used`,
|
|
326
|
+
]
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
.join(' and ');
|
|
329
|
+
return [
|
|
330
|
+
`goal: ${goal} on ${audit.path}`,
|
|
331
|
+
formatDisk(audit.diskBefore, 'before: '),
|
|
332
|
+
formatDisk(
|
|
333
|
+
audit.diskAfter,
|
|
334
|
+
audit.dryRun ? 'after (estimated): ' : 'after: '
|
|
335
|
+
),
|
|
336
|
+
`goal ${audit.goalMet ? 'met' : 'NOT met'}, highest tier used: ${audit.reachedTier ?? 'none'}`,
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Renders the audit log of `clean` or `emergency`.
|
|
342
|
+
* @param {object} audit
|
|
343
|
+
* @param {{verbose?: boolean}} [options]
|
|
344
|
+
*/
|
|
345
|
+
export function formatAudit(audit, options = {}) {
|
|
346
|
+
const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
|
|
347
|
+
const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
|
|
348
|
+
const shown = options.verbose
|
|
349
|
+
? audit.entries
|
|
350
|
+
: audit.entries.filter((entry) => entry.status !== 'skipped');
|
|
351
|
+
const hidden = audit.entries.length - shown.length;
|
|
352
|
+
const lines = [
|
|
353
|
+
`disk-space-saviour ${audit.command} (${mode})`,
|
|
354
|
+
...emergencyLines(audit),
|
|
355
|
+
'',
|
|
356
|
+
...limited(shown.map(entryLine), limit),
|
|
357
|
+
];
|
|
358
|
+
if (hidden > 0) {
|
|
359
|
+
lines.push(` ${hidden} skipped (use --verbose to list them)`);
|
|
360
|
+
}
|
|
361
|
+
lines.push('', 'Per environment:', ...environmentLines(audit.environments));
|
|
362
|
+
lines.push(...healthLines(audit.health));
|
|
363
|
+
lines.push(
|
|
364
|
+
'',
|
|
365
|
+
audit.dryRun
|
|
366
|
+
? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
|
|
367
|
+
: `Freed ${formatBytes(audit.freedBytes)}`
|
|
368
|
+
);
|
|
369
|
+
if (audit.file) {
|
|
370
|
+
lines.push(`Audit log: ${audit.file}`);
|
|
371
|
+
}
|
|
372
|
+
return lines.join('\n');
|
|
373
|
+
}
|