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
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker CLI access through an executor (the host, or a container running
|
|
3
|
+
* its own daemon for Docker-in-Docker).
|
|
4
|
+
*
|
|
5
|
+
* Every invocation passes `assertAllowed`: dss never stops, kills,
|
|
6
|
+
* restarts, pauses or force-removes anything, and `docker rm` is only used
|
|
7
|
+
* for containers that are verified stopped right before removal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { pipeInto } from '../exec.js';
|
|
11
|
+
|
|
12
|
+
const DECIMAL_UNITS = {
|
|
13
|
+
b: 1,
|
|
14
|
+
kb: 1e3,
|
|
15
|
+
mb: 1e6,
|
|
16
|
+
gb: 1e9,
|
|
17
|
+
tb: 1e12,
|
|
18
|
+
pb: 1e15,
|
|
19
|
+
kib: 1024,
|
|
20
|
+
mib: 1024 ** 2,
|
|
21
|
+
gib: 1024 ** 3,
|
|
22
|
+
tib: 1024 ** 4,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ALLOWED = new Map([
|
|
26
|
+
['version', null],
|
|
27
|
+
['info', null],
|
|
28
|
+
['ps', null],
|
|
29
|
+
['inspect', null],
|
|
30
|
+
['diff', null],
|
|
31
|
+
['logs', null],
|
|
32
|
+
['cp', null],
|
|
33
|
+
['history', null],
|
|
34
|
+
['exec', null],
|
|
35
|
+
['rm', 'rm'],
|
|
36
|
+
['system', new Set(['df'])],
|
|
37
|
+
['image', new Set(['prune', 'rm', 'ls', 'inspect', 'history'])],
|
|
38
|
+
['builder', new Set(['prune', 'du'])],
|
|
39
|
+
['volume', new Set(['ls', 'rm', 'inspect'])],
|
|
40
|
+
['container', new Set(['inspect', 'ls', 'diff', 'logs'])],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const FORCE_FLAGS = new Set(['-f', '--force', '-v', '--volumes', '-l']);
|
|
44
|
+
|
|
45
|
+
function assertContainerRemoval(args) {
|
|
46
|
+
if (args.slice(1).some((arg) => FORCE_FLAGS.has(arg))) {
|
|
47
|
+
throw new Error('dss never force-removes containers or their volumes');
|
|
48
|
+
}
|
|
49
|
+
if (args.length !== 2 || args[1].startsWith('-')) {
|
|
50
|
+
throw new Error('dss only removes one container by id');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertImageMutation(sub, args) {
|
|
55
|
+
if (sub === 'rm' && (args.length !== 3 || args[2].startsWith('-'))) {
|
|
56
|
+
throw new Error('dss only removes one image by id');
|
|
57
|
+
}
|
|
58
|
+
if (sub === 'prune' && (args.length !== 3 || args[2] !== '--force')) {
|
|
59
|
+
throw new Error('dss only prunes dangling images');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Throws unless `args` (the arguments after `docker`) is a command dss may
|
|
65
|
+
* run. `docker exec` payloads are not inspected: they run inside a
|
|
66
|
+
* container, never against its lifecycle.
|
|
67
|
+
* @param {string[]} args
|
|
68
|
+
*/
|
|
69
|
+
export function assertAllowed(args) {
|
|
70
|
+
const [command, sub] = args;
|
|
71
|
+
if (!ALLOWED.has(command)) {
|
|
72
|
+
throw new Error(`dss refuses to run \`docker ${command}\``);
|
|
73
|
+
}
|
|
74
|
+
const rule = ALLOWED.get(command);
|
|
75
|
+
if (rule instanceof Set && !rule.has(sub)) {
|
|
76
|
+
throw new Error(`dss refuses to run \`docker ${command} ${sub}\``);
|
|
77
|
+
}
|
|
78
|
+
if (rule === 'rm') {
|
|
79
|
+
assertContainerRemoval(args);
|
|
80
|
+
}
|
|
81
|
+
if (command === 'volume' && sub === 'rm') {
|
|
82
|
+
throw new Error('dss never removes Docker volumes automatically');
|
|
83
|
+
}
|
|
84
|
+
if (command === 'image') {
|
|
85
|
+
assertImageMutation(sub, args);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parses sizes printed by Docker (`19.1MB`, `0B`, `1.2GB (virtual 3GB)`),
|
|
91
|
+
* which use decimal multiples.
|
|
92
|
+
* @returns {number} bytes
|
|
93
|
+
*/
|
|
94
|
+
export function parseDockerSize(value) {
|
|
95
|
+
const match = /^\s*([\d.]+)\s*([a-z]*)/i.exec(String(value ?? ''));
|
|
96
|
+
if (!match) {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
const unit = DECIMAL_UNITS[(match[2] || 'b').toLowerCase()] ?? 1;
|
|
100
|
+
return Math.round(Number(match[1]) * unit);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parses newline-delimited JSON (`--format '{{json .}}'`).
|
|
105
|
+
* @returns {object[]}
|
|
106
|
+
*/
|
|
107
|
+
export function jsonLines(text) {
|
|
108
|
+
return (text ?? '')
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter((line) => line.startsWith('{'))
|
|
112
|
+
.map((line) => JSON.parse(line));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parses the comma-separated `Labels` column of `docker ps`.
|
|
117
|
+
* @returns {Record<string, string>}
|
|
118
|
+
*/
|
|
119
|
+
export function parseLabels(text) {
|
|
120
|
+
const labels = {};
|
|
121
|
+
for (const pair of (text ?? '').split(',')) {
|
|
122
|
+
const index = pair.indexOf('=');
|
|
123
|
+
if (index > 0) {
|
|
124
|
+
labels[pair.slice(0, index)] = pair.slice(index + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return labels;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Paths of Git directories changed in a container's writable layer, from
|
|
132
|
+
* `docker diff` output, mapped to their work trees.
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
export function repoRootsFromDiff(text) {
|
|
136
|
+
const roots = new Set();
|
|
137
|
+
for (const line of (text ?? '').split('\n')) {
|
|
138
|
+
const match = /^[ACD] (.+)$/.exec(line.trim());
|
|
139
|
+
if (!match) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const gitIndex = match[1].search(/\/\.git(\/|$)/);
|
|
143
|
+
if (gitIndex !== -1) {
|
|
144
|
+
roots.add(match[1].slice(0, gitIndex) || '/');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...roots].sort();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class DockerCli {
|
|
151
|
+
/**
|
|
152
|
+
* @param {object} executor where the docker CLI runs
|
|
153
|
+
* @param {{docker?: string}} [options]
|
|
154
|
+
*/
|
|
155
|
+
constructor(executor, options = {}) {
|
|
156
|
+
this.executor = executor;
|
|
157
|
+
this.docker = options.docker ?? 'docker';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
argv(args) {
|
|
161
|
+
assertAllowed(args);
|
|
162
|
+
return [this.docker, ...args];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
run(args, options = {}) {
|
|
166
|
+
return this.executor.run(this.argv(args), options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async json(args) {
|
|
170
|
+
const result = await this.run(args);
|
|
171
|
+
if (result.code !== 0) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`docker ${args.join(' ')} failed: ${result.stderr.trim() || result.code}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return result.stdout;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Daemon facts, or null when no daemon is reachable.
|
|
181
|
+
* @returns {Promise<object|null>}
|
|
182
|
+
*/
|
|
183
|
+
async info() {
|
|
184
|
+
const result = await this.run(['info', '--format', '{{json .}}'], {
|
|
185
|
+
timeoutMs: 30000,
|
|
186
|
+
});
|
|
187
|
+
if (result.code !== 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const info = JSON.parse(result.stdout);
|
|
192
|
+
return info.ID || info.ServerVersion ? info : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async systemDf() {
|
|
199
|
+
return jsonLines(
|
|
200
|
+
await this.json(['system', 'df', '--format', '{{json .}}'])
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async systemDfVerbose() {
|
|
205
|
+
return JSON.parse(
|
|
206
|
+
await this.json(['system', 'df', '-v', '--format', '{{json .}}'])
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async containers() {
|
|
211
|
+
return jsonLines(
|
|
212
|
+
await this.json([
|
|
213
|
+
'ps',
|
|
214
|
+
'--all',
|
|
215
|
+
'--size',
|
|
216
|
+
'--no-trunc',
|
|
217
|
+
'--format',
|
|
218
|
+
'{{json .}}',
|
|
219
|
+
])
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async inspect(ids) {
|
|
224
|
+
if (ids.length === 0) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(
|
|
229
|
+
await this.json(['inspect', '--type', 'container', ...ids])
|
|
230
|
+
);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (ids.length === 1) {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
const found = [];
|
|
236
|
+
for (const id of ids) {
|
|
237
|
+
found.push(...(await this.inspect([id]).catch(() => [])));
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async diff(id) {
|
|
244
|
+
const result = await this.run(['diff', id]);
|
|
245
|
+
return result.code === 0 ? result.stdout : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Check a small file in a stopped container without starting it. */
|
|
249
|
+
async pathExists(id, target) {
|
|
250
|
+
const result = await this.run(['cp', `${id}:${target}`, '-']);
|
|
251
|
+
if (result.code === 0) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (/could not find the file|no such file|not found/i.test(result.stderr)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async history(image) {
|
|
261
|
+
return jsonLines(
|
|
262
|
+
await this.json([
|
|
263
|
+
'history',
|
|
264
|
+
'--no-trunc',
|
|
265
|
+
'--format',
|
|
266
|
+
'{{json .}}',
|
|
267
|
+
image,
|
|
268
|
+
])
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Streams `docker cp <id>:<path> -` into `tar -x` on the machine running
|
|
274
|
+
* dss, skipping `excludes`. Works through nested executors because only
|
|
275
|
+
* the tar stream crosses them.
|
|
276
|
+
*/
|
|
277
|
+
copyOut(id, source, destination, excludes = []) {
|
|
278
|
+
const stream = this.executor.spawn(
|
|
279
|
+
this.argv(['cp', `${id}:${source}`, '-'])
|
|
280
|
+
);
|
|
281
|
+
return pipeInto(stream, [
|
|
282
|
+
'tar',
|
|
283
|
+
'-x',
|
|
284
|
+
'-C',
|
|
285
|
+
destination,
|
|
286
|
+
...excludes.map((pattern) => `--exclude=${pattern}`),
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stopped container inspection without starting the container.
|
|
3
|
+
*
|
|
4
|
+
* `docker rm` (without `-v`) deletes only the container's writable layer,
|
|
5
|
+
* so only work written there can be lost. `docker diff` lists that layer;
|
|
6
|
+
* every changed `.git` directory marks a repository, which is copied out
|
|
7
|
+
* with `docker cp` (dependency directories skipped) and checked with the
|
|
8
|
+
* host's Git.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fsp } from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { GitInspector, stateBlockers } from '../git.js';
|
|
16
|
+
import { LocalEnv } from '../env/local.js';
|
|
17
|
+
import { repoRootsFromDiff } from './cli.js';
|
|
18
|
+
|
|
19
|
+
export const COPY_EXCLUDES = [
|
|
20
|
+
'*/node_modules',
|
|
21
|
+
'*/target',
|
|
22
|
+
'*/.venv',
|
|
23
|
+
'*/venv',
|
|
24
|
+
'*/__pycache__',
|
|
25
|
+
'*/.gradle',
|
|
26
|
+
'*/.next',
|
|
27
|
+
'*/.nuxt',
|
|
28
|
+
'*/.tox',
|
|
29
|
+
'*/.mypy_cache',
|
|
30
|
+
'*/.pytest_cache',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MAX_REPOS = 20;
|
|
34
|
+
const OWNER_HINT = /session|issue|pull|task|owner/i;
|
|
35
|
+
const SECRET = /token|secret|password|key$/i;
|
|
36
|
+
const TASK_URL_KEY = /(issue|pull|task)[\w.-]*url|url[\w.-]*(issue|pull|task)/i;
|
|
37
|
+
const TASK_URL = /^https:\/\/[\w.-]+(\/[\w.~%#/-]*)?$/;
|
|
38
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
39
|
+
const SHORT_ID = 12;
|
|
40
|
+
const ENDED = new Set(['exited', 'dead']);
|
|
41
|
+
|
|
42
|
+
function safeOwnerHint(key, value) {
|
|
43
|
+
const text = String(value);
|
|
44
|
+
return (
|
|
45
|
+
OWNER_HINT.test(key) &&
|
|
46
|
+
!SECRET.test(key) &&
|
|
47
|
+
!/https?:\/\/|ghp_|github_pat_|sk-/.test(text)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Only an exact container id can waive verified unsaved Git work. */
|
|
52
|
+
export function gitBlockersForRemoval(git, id, options = {}) {
|
|
53
|
+
if (!(options.allowDirtyContainers ?? []).includes(id)) {
|
|
54
|
+
return git.blockers;
|
|
55
|
+
}
|
|
56
|
+
return git.blockers.filter(
|
|
57
|
+
(reason) => !/^Git repository .+ has /.test(reason)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bind mounts of running containers in the current Docker daemon. */
|
|
62
|
+
export function isHostBindSource(source) {
|
|
63
|
+
return Boolean(
|
|
64
|
+
source && (path.posix.isAbsolute(source) || path.win32.isAbsolute(source))
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runningBindMounts(docker) {
|
|
69
|
+
const running = (await docker.containers()).filter(
|
|
70
|
+
(row) => row.State === 'running'
|
|
71
|
+
);
|
|
72
|
+
const inspected = await docker.inspect(running.map((row) => row.ID));
|
|
73
|
+
return inspected.flatMap((container) =>
|
|
74
|
+
(container.Mounts ?? [])
|
|
75
|
+
.filter(
|
|
76
|
+
(mount) => mount.Type === 'bind' && isHostBindSource(mount.Source)
|
|
77
|
+
)
|
|
78
|
+
.map((mount) => ({ source: mount.Source, containerId: container.Id }))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Who a container belongs to: labels and environment variables that look
|
|
84
|
+
* like session, issue or task identifiers (`HIVE_MIND_PARENT_SESSION_ID`
|
|
85
|
+
* first among them), else a session UUID in the container name; plus its
|
|
86
|
+
* command, task URL and how it ended.
|
|
87
|
+
* @param {object} ps row of `docker ps --format json`
|
|
88
|
+
* @param {object} [inspected] `docker inspect` object
|
|
89
|
+
*/
|
|
90
|
+
export function ownerOf(ps, inspected) {
|
|
91
|
+
const hints = ownerHints(inspected);
|
|
92
|
+
const name = String(ps.Names ?? '').split(',')[0];
|
|
93
|
+
const [sessionSource, session] = sessionOf(hints, name);
|
|
94
|
+
const state = ENDED.has(inspected?.State?.Status) ? inspected.State : null;
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
image: ps.Image,
|
|
98
|
+
command: ps.Command ? ps.Command.replace(/^"|"$/g, '') : null,
|
|
99
|
+
createdAt: ps.CreatedAt ?? null,
|
|
100
|
+
finishedAt: inspected?.State?.FinishedAt ?? null,
|
|
101
|
+
exitCode: typeof state?.ExitCode === 'number' ? state.ExitCode : null,
|
|
102
|
+
oomKilled: Boolean(state?.OOMKilled),
|
|
103
|
+
exitReason: exitReason(state),
|
|
104
|
+
taskUrl: taskUrlOf(inspected),
|
|
105
|
+
session,
|
|
106
|
+
sessionSource,
|
|
107
|
+
hints,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sessionOf(hints, name) {
|
|
112
|
+
const entries = Object.entries(hints);
|
|
113
|
+
const found =
|
|
114
|
+
entries.find(([key]) => /HIVE_MIND_PARENT_SESSION_ID$/.test(key)) ??
|
|
115
|
+
entries.find(([key]) => /session/i.test(key)) ??
|
|
116
|
+
entries[0];
|
|
117
|
+
if (found) {
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
const fromName = UUID.exec(name)?.[0];
|
|
121
|
+
return fromName ? ['name', fromName] : [null, null];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How a stopped container ended, from `docker inspect` State.
|
|
126
|
+
* @returns {string|null}
|
|
127
|
+
*/
|
|
128
|
+
export function exitReason(state) {
|
|
129
|
+
if (!ENDED.has(state?.Status) || typeof state.ExitCode !== 'number') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (state.Error) {
|
|
133
|
+
return state.Error;
|
|
134
|
+
}
|
|
135
|
+
if (state.OOMKilled) {
|
|
136
|
+
return 'killed: out of memory';
|
|
137
|
+
}
|
|
138
|
+
if (state.ExitCode === 0) {
|
|
139
|
+
return 'completed';
|
|
140
|
+
}
|
|
141
|
+
return state.ExitCode > 128
|
|
142
|
+
? `killed by signal ${state.ExitCode - 128}`
|
|
143
|
+
: `failed with exit code ${state.ExitCode}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Why a task runner keeps a stopped container for investigation (it failed
|
|
148
|
+
* or was OOM killed), or null.
|
|
149
|
+
*/
|
|
150
|
+
export function investigationReason(state) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (state?.ExitCode) {
|
|
153
|
+
parts.push(`exit ${state.ExitCode}`);
|
|
154
|
+
}
|
|
155
|
+
if (state?.OOMKilled) {
|
|
156
|
+
parts.push('OOM killed');
|
|
157
|
+
}
|
|
158
|
+
return parts.length > 0
|
|
159
|
+
? `kept for investigation (${parts.join(', ')})`
|
|
160
|
+
: null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether the operator named this container with `--remove-container`:
|
|
165
|
+
* its name, full id or an id prefix of at least 12 characters.
|
|
166
|
+
*/
|
|
167
|
+
export function containerNamed(names, id, name) {
|
|
168
|
+
return (names ?? []).some(
|
|
169
|
+
(entry) =>
|
|
170
|
+
entry === name ||
|
|
171
|
+
entry === `/${name}` ||
|
|
172
|
+
(entry.length >= SHORT_ID && id.startsWith(entry))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Blocker of a container kept for investigation that was not named. */
|
|
177
|
+
export function investigationBlocker(state, id, name, options = {}) {
|
|
178
|
+
const reason = investigationReason(state);
|
|
179
|
+
if (!reason || containerNamed(options.removeContainers, id, name)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return `${reason}; remove it only with --remove-container ${id.slice(0, SHORT_ID)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function taskUrlOf(inspected) {
|
|
186
|
+
const pairs = [
|
|
187
|
+
...Object.entries(inspected?.Config?.Labels ?? {}),
|
|
188
|
+
...(inspected?.Config?.Env ?? []).map((pair) => {
|
|
189
|
+
const index = pair.indexOf('=');
|
|
190
|
+
return [pair.slice(0, index), pair.slice(index + 1)];
|
|
191
|
+
}),
|
|
192
|
+
];
|
|
193
|
+
const found = pairs.find(
|
|
194
|
+
([key, value]) =>
|
|
195
|
+
TASK_URL_KEY.test(key) &&
|
|
196
|
+
!SECRET.test(key) &&
|
|
197
|
+
TASK_URL.test(value) &&
|
|
198
|
+
!/ghp_|github_pat_|sk-/.test(value)
|
|
199
|
+
);
|
|
200
|
+
return found?.[1] ?? null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ownerHints(inspected) {
|
|
204
|
+
const hints = {};
|
|
205
|
+
for (const [key, value] of Object.entries(inspected?.Config?.Labels ?? {})) {
|
|
206
|
+
if (safeOwnerHint(key, value)) {
|
|
207
|
+
hints[`label:${key}`] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const pair of inspected?.Config?.Env ?? []) {
|
|
211
|
+
const index = pair.indexOf('=');
|
|
212
|
+
const key = pair.slice(0, index);
|
|
213
|
+
if (index > 0 && safeOwnerHint(key, pair.slice(index + 1))) {
|
|
214
|
+
hints[`env:${key}`] = pair.slice(index + 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return hints;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function copyAndCheck(docker, id, root, git) {
|
|
221
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-container-git-'));
|
|
222
|
+
try {
|
|
223
|
+
const copied = await docker.copyOut(id, root, dir, COPY_EXCLUDES);
|
|
224
|
+
if (copied.code !== 0) {
|
|
225
|
+
return {
|
|
226
|
+
root,
|
|
227
|
+
error: `docker cp failed: ${copied.stderr.trim() || copied.code}`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const local = path.join(dir, path.basename(root));
|
|
231
|
+
const state = await git.repoState(local);
|
|
232
|
+
return { ...state, root };
|
|
233
|
+
} finally {
|
|
234
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Git state of every repository written in a stopped container.
|
|
240
|
+
* @returns {Promise<{repos: object[], blockers: string[]}>}
|
|
241
|
+
*/
|
|
242
|
+
async function probeRepoRoots(docker, id, changed, roots, blockers) {
|
|
243
|
+
if (changed.length > 500) {
|
|
244
|
+
blockers.push('Git repository inspection limit exceeded');
|
|
245
|
+
}
|
|
246
|
+
const probed = new Map();
|
|
247
|
+
for (const target of changed.slice(0, 500)) {
|
|
248
|
+
for (let dir = target; dir !== '/'; dir = path.posix.dirname(dir)) {
|
|
249
|
+
if (probed.has(dir)) {
|
|
250
|
+
if (probed.get(dir)) {
|
|
251
|
+
roots.add(dir);
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const exists = await docker.pathExists(
|
|
256
|
+
id,
|
|
257
|
+
path.posix.join(dir, '.git', 'HEAD')
|
|
258
|
+
);
|
|
259
|
+
if (exists === null) {
|
|
260
|
+
blockers.push(`cannot inspect Git metadata near ${target}`);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
probed.set(dir, exists);
|
|
264
|
+
if (exists) {
|
|
265
|
+
roots.add(dir);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function excludedWorkBlockers(checked, changed, blockers) {
|
|
273
|
+
const excluded = new Set(COPY_EXCLUDES.map((pattern) => pattern.slice(2)));
|
|
274
|
+
for (const root of checked) {
|
|
275
|
+
for (const target of changed) {
|
|
276
|
+
if (!target.startsWith(`${root}/`)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const parts = target.slice(root.length + 1).split('/');
|
|
280
|
+
if (parts.slice(0, -1).some((part) => excluded.has(part))) {
|
|
281
|
+
blockers.push(
|
|
282
|
+
`changed work under excluded folder ${target} cannot be verified`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function containerGitState(docker, id, options = {}) {
|
|
290
|
+
const diff = await docker.diff(id);
|
|
291
|
+
if (diff === null) {
|
|
292
|
+
return {
|
|
293
|
+
repos: [],
|
|
294
|
+
blockers: ['cannot list the writable layer (`docker diff` failed)'],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const roots = new Set(repoRootsFromDiff(diff));
|
|
298
|
+
const maxRepos = options.maxRepos ?? DEFAULT_MAX_REPOS;
|
|
299
|
+
const blockers = [];
|
|
300
|
+
if (roots.has('/')) {
|
|
301
|
+
blockers.push('the container root is a Git repository');
|
|
302
|
+
}
|
|
303
|
+
const changed = diff
|
|
304
|
+
.split('\n')
|
|
305
|
+
.map((line) => /^[ACD] (\/.+)$/.exec(line.trim())?.[1])
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
await probeRepoRoots(docker, id, changed, roots, blockers);
|
|
308
|
+
const checked = [...roots].filter((root) => root !== '/').slice(0, maxRepos);
|
|
309
|
+
excludedWorkBlockers(checked, changed, blockers);
|
|
310
|
+
if (roots.size > checked.length + (roots.has('/') ? 1 : 0)) {
|
|
311
|
+
blockers.push(
|
|
312
|
+
`${roots.size} repositories changed, only ${maxRepos} were verified`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const repos = [];
|
|
316
|
+
if (checked.length === 0) {
|
|
317
|
+
return { repos, blockers };
|
|
318
|
+
}
|
|
319
|
+
// `docker cp` copies to this machine, so Git always runs locally.
|
|
320
|
+
const git = new GitInspector(options.env ?? new LocalEnv());
|
|
321
|
+
for (const root of checked) {
|
|
322
|
+
const state = await copyAndCheck(docker, id, root, git);
|
|
323
|
+
repos.push(state);
|
|
324
|
+
blockers.push(...stateBlockers(`${root} (in container)`, state));
|
|
325
|
+
}
|
|
326
|
+
return { repos, blockers };
|
|
327
|
+
}
|