disk-space-saviour 0.13.0 → 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.
@@ -0,0 +1,288 @@
1
+ /** Reclaimable file blocks in a container, excluding immutable image data. */
2
+ import { DockerCli } from './cli.js';
3
+ import { isWithin } from '../paths.js';
4
+ import { trace } from '../exec.js';
5
+ import { resetDiffSnapshots } from './snapshots.js';
6
+
7
+ let measurementQueue = Promise.resolve();
8
+ const layers = new WeakMap();
9
+
10
+ export function sharedWritableLayer(executor, id) {
11
+ if (!layers.has(executor)) {
12
+ layers.set(executor, new Map());
13
+ }
14
+ const registry = layers.get(executor);
15
+ if (!registry.has(id)) {
16
+ registry.set(id, new WritableLayer(executor, id));
17
+ }
18
+ return registry.get(id);
19
+ }
20
+
21
+ export function resetWritableLayers(executor) {
22
+ layers.delete(executor);
23
+ resetDiffSnapshots(executor);
24
+ }
25
+
26
+ /** Overlay without an authoritative upper layer must never count merged bytes. */
27
+ export class UnknownWritableLayer {
28
+ measure(env, usages) {
29
+ return Promise.resolve(
30
+ new Map(
31
+ [...usages]
32
+ .filter(([, usage]) => usage)
33
+ .map(([target, usage]) => [
34
+ target,
35
+ {
36
+ ...usage,
37
+ totalBytes: usage.bytes,
38
+ bytes: 0,
39
+ imageBytes: 0,
40
+ sizeUnknown: true,
41
+ },
42
+ ])
43
+ )
44
+ );
45
+ }
46
+ }
47
+
48
+ export class WritableLayer {
49
+ constructor(executor, id) {
50
+ this.docker = new DockerCli(executor);
51
+ this.id = id;
52
+ this.stats = new Map();
53
+ this.removed = new Set();
54
+ }
55
+
56
+ snapshot(candidateFiles = 0) {
57
+ if (!this.snapshotPromise) {
58
+ const acquire = async () => {
59
+ trace(
60
+ 'measuring container',
61
+ this.id,
62
+ '(one storage snapshot; concurrency 1)'
63
+ );
64
+ const container =
65
+ this.inspected ??
66
+ (await this.docker.inspect([this.id]).catch(() => []))[0];
67
+ if (!container) {
68
+ return { diff: null, container };
69
+ }
70
+ const upperdir =
71
+ container.GraphDriver?.Data?.UpperDir ?? this.upperdirCandidate;
72
+ if (upperdir?.startsWith('/')) {
73
+ const result = await this.docker.executor
74
+ .run(['stat', '-c', '%F', '--', upperdir])
75
+ .catch(() => null);
76
+ if (result?.code === 0 && result.stdout.trim() === 'directory') {
77
+ trace('using authoritative upperdir', this.id);
78
+ return { container, upperdir, diff: null };
79
+ }
80
+ }
81
+ if (candidateFiles > 100000) {
82
+ trace(
83
+ 'skipping docker diff: candidate file budget exceeds 100000',
84
+ this.id
85
+ );
86
+ return { diff: null, container };
87
+ }
88
+ const diff = await this.docker.diff(this.id);
89
+ trace('finished measuring container', this.id);
90
+ // Bound the retained change set; unknown is safer than a partial result.
91
+ return {
92
+ diff: diff?.split('\n').length > 100000 ? null : diff,
93
+ container,
94
+ };
95
+ };
96
+ this.snapshotPromise = measurementQueue.then(acquire, acquire);
97
+ measurementQueue = this.snapshotPromise.then(
98
+ () => {},
99
+ () => {}
100
+ );
101
+ }
102
+ return this.snapshotPromise;
103
+ }
104
+
105
+ forget(target, pathApi) {
106
+ this.removed.add(target);
107
+ for (const file of this.stats.keys()) {
108
+ if (isWithin(file, target, pathApi)) {
109
+ this.stats.delete(file);
110
+ }
111
+ }
112
+ }
113
+
114
+ async measureUpperdir(env, usages, container, upperdir) {
115
+ const result = new Map();
116
+ const mounts = (container.Mounts ?? [])
117
+ .map((mount) => mount.Destination)
118
+ .filter(Boolean);
119
+ for (const [target, usage] of usages) {
120
+ if (mounts.some((mount) => isWithin(target, mount, env.path))) {
121
+ result.set(target, {
122
+ ...usage,
123
+ totalBytes: usage.bytes,
124
+ imageBytes: 0,
125
+ });
126
+ continue;
127
+ }
128
+ const measured = await this.docker.executor.run([
129
+ 'du',
130
+ '-skx',
131
+ '--',
132
+ `${upperdir}/${target.replace(/^\/+/, '')}`,
133
+ ]);
134
+ const kib = Number(/^([0-9]+)\s/m.exec(measured.stdout)?.[1]);
135
+ // Missing upperdir leaf means image-only. Other errors remain unknown.
136
+ const absent = /no such file|not found/i.test(measured.stderr);
137
+ const known = (measured.code === 0 && Number.isFinite(kib)) || absent;
138
+ const bytes = known ? Math.min(usage.bytes, absent ? 0 : kib * 1024) : 0;
139
+ result.set(target, {
140
+ ...usage,
141
+ bytes,
142
+ totalBytes: usage.bytes,
143
+ imageBytes: known ? Math.max(0, usage.bytes - bytes) : 0,
144
+ sizeUnknown: !known,
145
+ imageRef: container.Config?.Image ?? container.Image ?? null,
146
+ });
147
+ }
148
+ return result;
149
+ }
150
+
151
+ async measure(env, usages) {
152
+ usages = new Map([...usages].filter(([, usage]) => usage));
153
+ if (usages.size === 0) {
154
+ return usages;
155
+ }
156
+ this.upperdirCandidate ??= /(?:^|,)upperdir=([^,]+)/.exec(
157
+ env.scanPolicy?.overlay?.options ?? ''
158
+ )?.[1];
159
+ const candidateFiles = [...usages.values()].reduce(
160
+ (sum, usage) => sum + (usage.files ?? 0),
161
+ 0
162
+ );
163
+ const { diff, container, upperdir } = await this.snapshot(candidateFiles);
164
+ if (upperdir) {
165
+ return this.measureUpperdir(env, usages, container, upperdir);
166
+ }
167
+ if (diff === null || !container) {
168
+ return new Map(
169
+ [...usages].map(([target, usage]) => [
170
+ target,
171
+ {
172
+ ...usage,
173
+ totalBytes: usage.bytes,
174
+ bytes: 0,
175
+ imageBytes: 0,
176
+ sizeUnknown: true,
177
+ },
178
+ ])
179
+ );
180
+ }
181
+ const mounts = (container.Mounts ?? [])
182
+ .map((mount) => mount.Destination)
183
+ .filter(Boolean);
184
+ const inside = (target, root) => isWithin(target, root, env.path);
185
+ const changed = [
186
+ ...new Set(
187
+ diff
188
+ .split('\n')
189
+ .map((line) => /^[AC] (\/.+)$/.exec(line)?.[1])
190
+ .filter(Boolean)
191
+ ),
192
+ ].filter(
193
+ (file) =>
194
+ [...usages.keys()].some((target) => inside(file, target)) &&
195
+ !mounts.some((mount) => inside(file, mount)) &&
196
+ ![...this.removed].some((target) => inside(file, target))
197
+ );
198
+ const missing = changed.filter((file) => !this.stats.has(file));
199
+ const fresh = await env.statMany(missing);
200
+ for (const file of missing) {
201
+ this.stats.set(file, fresh.get(file) ?? null);
202
+ }
203
+ const stats = new Map(
204
+ changed
205
+ .map((file) => [file, this.stats.get(file)])
206
+ .filter(([, stat]) => stat)
207
+ );
208
+ const mountRoots = [
209
+ ...new Set(
210
+ mounts.filter((mount) =>
211
+ [...usages.keys()].some(
212
+ (target) => inside(mount, target) && mount !== target
213
+ )
214
+ )
215
+ ),
216
+ ];
217
+ const mountedUsage = mountRoots.length
218
+ ? await env.rawUsageMany(mountRoots)
219
+ : new Map();
220
+ return new Map(
221
+ [...usages].map(([target, usage]) => {
222
+ if (mounts.some((mount) => inside(target, mount))) {
223
+ return [target, { ...usage, totalBytes: usage.bytes, imageBytes: 0 }];
224
+ }
225
+ let bytes = 0;
226
+ const seen = new Set();
227
+ for (const [file, stat] of stats) {
228
+ // Docker diff's C directories include unchanged lower-layer children.
229
+ // Count file blocks only, with hard-linked inodes counted once.
230
+ if (
231
+ !inside(file, target) ||
232
+ stat.type === 'dir' ||
233
+ seen.has(stat.inode ?? file)
234
+ ) {
235
+ continue;
236
+ }
237
+ seen.add(stat.inode ?? file);
238
+ bytes += stat.bytes;
239
+ }
240
+ for (const mount of mountRoots.filter((mount) =>
241
+ inside(mount, target)
242
+ )) {
243
+ if (
244
+ !mountRoots.some((other) => other !== mount && inside(mount, other))
245
+ ) {
246
+ bytes += mountedUsage.get(mount)?.bytes ?? 0;
247
+ }
248
+ }
249
+ bytes = Math.min(usage.bytes, bytes);
250
+ return [
251
+ target,
252
+ {
253
+ ...usage,
254
+ totalBytes: usage.bytes,
255
+ bytes,
256
+ imageBytes: Math.max(0, usage.bytes - bytes),
257
+ imageRef: container.Config?.Image ?? container.Image ?? null,
258
+ },
259
+ ];
260
+ })
261
+ );
262
+ }
263
+ }
264
+
265
+ /** Attach storage accounting to the host adapter when dss runs in Docker. */
266
+ export async function configureContainerHost(env, ids, options = {}) {
267
+ if (env.scanPolicy?.overlay && !env.writableLayer) {
268
+ env.writableLayer = new UnknownWritableLayer();
269
+ }
270
+ if (options.docker === false) {
271
+ return;
272
+ }
273
+ if (!ids.length || !(await env.which('docker'))) {
274
+ return;
275
+ }
276
+ const docker = new DockerCli(env.executor);
277
+ const containers = await docker.inspect(ids).catch(() => []);
278
+ const own = containers.find((container) => ids.includes(container.Id));
279
+ if (own) {
280
+ env.writableLayer = sharedWritableLayer(env.executor, own.Id);
281
+ env.writableLayer.inspected = own;
282
+ const { ShellEnv } = await import('../env/shell.js');
283
+ const { containerExecutor } = await import('../exec.js');
284
+ env.processInspector = new ShellEnv(
285
+ containerExecutor(env.executor, own.Id)
286
+ );
287
+ }
288
+ }
package/src/emergency.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * kept for investigation needs its own `removeContainers` entry.
13
13
  */
14
14
 
15
- import { startAudit, writeAudit } from './audit.js';
15
+ import { startAudit, writeAudit, throwIfAborted } from './audit.js';
16
16
  import { Cleaner, cleanOrder, finishAudit, wantedItems } from './clean.js';
17
17
  import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
@@ -95,8 +95,13 @@ export async function emergency(input = {}) {
95
95
  await writeAudit(audit, { ...options, inProgress: true });
96
96
  }
97
97
  let disk = diskBefore;
98
- if (!goalMet(goal, disk)) {
99
- disk = await escalate({ report, options, env, target, goal, audit });
98
+ try {
99
+ if (!goalMet(goal, disk)) {
100
+ disk = await escalate({ report, options, env, target, goal, audit });
101
+ }
102
+ } catch (error) {
103
+ audit.aborted = true;
104
+ audit.error = error.message;
100
105
  }
101
106
  audit.diskAfter = disk;
102
107
  audit.goalMet = goalMet(goal, disk);
@@ -143,7 +148,11 @@ async function escalate({ report, options, env, target, goal, audit }) {
143
148
  const items = selectByTier(wanted, tier)
144
149
  .filter((item) => !done.has(item.id))
145
150
  .sort(cleanOrder);
151
+ if (options.signal?.aborted) {
152
+ throw new Error('cleanup aborted');
153
+ }
146
154
  for (const item of items) {
155
+ throwIfAborted(options);
147
156
  done.add(item.id);
148
157
  const entry = await cleaner.process(item);
149
158
  if (!audit.entries.includes(entry)) {