@celilo/e2e 0.12.0 → 0.13.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/README.md +5 -5
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +2 -2
- package/config/dns/tangohost.com.zone +1 -1
- package/config/dns/templates/example.net.zone +3 -3
- package/config/dns/templates/iamtheinternet.org.zone +2 -2
- package/config/routing/fw-ext-routes.sh +8 -8
- package/config/routing/fw-isp-routes.sh +2 -2
- package/config/routing/fw-main-routes.sh +5 -5
- package/config/routing/management-routes.sh +1 -1
- package/config/routing/observer-setup.sh +1 -1
- package/config/routing/public-sim-entrypoint.sh +1 -1
- package/config/routing/resolver-internal-routes.sh +2 -2
- package/config/routing/resolver-routes.sh +1 -1
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +1 -1
- package/config/socks/startup.sh +2 -2
- package/docker/Dockerfile.firewall +12 -1
- package/package.json +5 -4
- package/simulators/greenwave/server.ts +35 -0
- package/simulators/greenwave/state.ts +64 -11
- package/simulators/ip-echo/server.ts +1 -1
- package/src/address-plan.test.ts +4 -4
- package/src/cli/build.ts +30 -0
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +85 -4
- package/src/container-manager.ts +191 -14
- package/src/docker-compose-generator.ts +55 -26
- package/src/doctor.test.ts +279 -0
- package/src/doctor.ts +421 -0
- package/src/extract-failure.ts +41 -0
- package/src/index.ts +12 -0
- package/src/last-run.test.ts +62 -0
- package/src/last-run.ts +54 -0
- package/src/observer.test.ts +2 -2
- package/src/observer.ts +1 -1
- package/src/router-swap.ts +121 -0
- package/src/run-lock.test.ts +73 -2
- package/src/run-lock.ts +110 -14
- package/src/runner.ts +99 -5
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +48 -8
- package/src/vantage.test.ts +1 -1
- package/src/zone-classifier.test.ts +11 -16
- package/src/zone-classifier.ts +16 -31
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gates for the cele2e preflight.
|
|
3
|
+
*
|
|
4
|
+
* Each of these conditions was, at least once, diagnosed as a product bug: the
|
|
5
|
+
* mistake was silent and the failure surfaced later and somewhere unrelated.
|
|
6
|
+
* Every test here breaks ONE condition deliberately and asserts the specific
|
|
7
|
+
* message the operator gets — per Rule 7.6, a gate nobody has seen fail is not
|
|
8
|
+
* a gate, so each assertion is written against the broken state, not the happy
|
|
9
|
+
* one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import {
|
|
14
|
+
BAKED_MANAGEMENT_IMAGES,
|
|
15
|
+
type DockerfileBases,
|
|
16
|
+
type DoctorProbe,
|
|
17
|
+
EXPECTED_MANAGEMENT_CMD,
|
|
18
|
+
checkBaseImages,
|
|
19
|
+
checkDiskPressure,
|
|
20
|
+
checkManagementImage,
|
|
21
|
+
checkRunLock,
|
|
22
|
+
checkStaleContainers,
|
|
23
|
+
diagnose,
|
|
24
|
+
explainBuildFailure,
|
|
25
|
+
parseBaseImages,
|
|
26
|
+
} from './doctor';
|
|
27
|
+
import type { LockHolder, LockStatus } from './run-lock';
|
|
28
|
+
|
|
29
|
+
/** A probe where everything is healthy; each test breaks exactly one thing. */
|
|
30
|
+
function healthyProbe(overrides: Partial<DoctorProbe> = {}): DoctorProbe {
|
|
31
|
+
return {
|
|
32
|
+
imageExists: () => true,
|
|
33
|
+
imageCmd: () => [EXPECTED_MANAGEMENT_CMD],
|
|
34
|
+
staleContainers: () => [],
|
|
35
|
+
reclaimableImageBytes: () => 0,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function holder(overrides: Partial<LockHolder> = {}): LockHolder {
|
|
41
|
+
return {
|
|
42
|
+
pid: 4242,
|
|
43
|
+
hostname: 'testhost',
|
|
44
|
+
session: 'branch (/work/tree)',
|
|
45
|
+
test: 'caddy-direct-internet',
|
|
46
|
+
runId: 'r1',
|
|
47
|
+
startedAt: new Date().toISOString(),
|
|
48
|
+
beatAt: Date.now(),
|
|
49
|
+
state: 'running',
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lockState(overrides: Partial<LockStatus> = {}): LockStatus {
|
|
55
|
+
return {
|
|
56
|
+
free: true,
|
|
57
|
+
holder: null,
|
|
58
|
+
heartbeatAgeMs: null,
|
|
59
|
+
suspect: false,
|
|
60
|
+
ownKept: false,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('management image (problem 1: the bake fails as an SSH error)', () => {
|
|
66
|
+
test('a missing baked image fails preflight naming build-infra as the fix', () => {
|
|
67
|
+
const check = checkManagementImage(
|
|
68
|
+
healthyProbe({ imageExists: (ref) => ref !== 'celilo-e2e/management:latest' }),
|
|
69
|
+
);
|
|
70
|
+
expect(check.status).toBe('fail');
|
|
71
|
+
expect(check.detail).toContain('celilo-e2e/management:latest');
|
|
72
|
+
expect(check.detail).toContain('cannot build');
|
|
73
|
+
expect(check.remedy).toBe('cele2e build-infra');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a bake that lost its Cmd fails, and says so as the SSH error it would become', () => {
|
|
77
|
+
// The bake container runs `sleep infinity`; docker commit persists that Cmd
|
|
78
|
+
// unless overridden. Without /startup.sh the ssh-keys volume is never
|
|
79
|
+
// populated and `machine add` dies with a connection error against a
|
|
80
|
+
// firewall IP — which reads as a network bug and is not one.
|
|
81
|
+
const check = checkManagementImage(healthyProbe({ imageCmd: () => ['sleep', 'infinity'] }));
|
|
82
|
+
expect(check.status).toBe('fail');
|
|
83
|
+
expect(check.detail).toContain(EXPECTED_MANAGEMENT_CMD);
|
|
84
|
+
expect(check.detail).toContain('ssh-keys');
|
|
85
|
+
expect(check.detail).toContain('Cannot connect to root@');
|
|
86
|
+
expect(check.remedy).toBe('cele2e build-infra');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('both images present and correctly baked passes', () => {
|
|
90
|
+
const check = checkManagementImage(healthyProbe());
|
|
91
|
+
expect(check.status).toBe('ok');
|
|
92
|
+
expect(check.detail).toContain(String(BAKED_MANAGEMENT_IMAGES.length));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('base images (problem 3: a missing image reads as a network failure)', () => {
|
|
97
|
+
const dockerfiles: DockerfileBases[] = [
|
|
98
|
+
{ derived: 'celilo-e2e/observer', bases: ['ubuntu:22.04'] },
|
|
99
|
+
{ derived: 'celilo-e2e/pebble', bases: ['ghcr.io/letsencrypt/pebble:latest'] },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
test('a base image missing with its derived image ALSO missing fails, with the pull', () => {
|
|
103
|
+
// Post `docker image prune`: nothing local, so the build must go to a
|
|
104
|
+
// registry — the exact state that produced "failed to solve: ubuntu:22.04:
|
|
105
|
+
// net/http: TLS handshake timeout" 16 images into a rebuild.
|
|
106
|
+
const check = checkBaseImages(healthyProbe({ imageExists: () => false }), dockerfiles);
|
|
107
|
+
expect(check.status).toBe('fail');
|
|
108
|
+
expect(check.detail).toContain('ubuntu:22.04');
|
|
109
|
+
expect(check.remedy).toContain('docker pull ubuntu:22.04');
|
|
110
|
+
expect(check.remedy).toContain('docker pull ghcr.io/letsencrypt/pebble:latest');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a base image missing but its derived image present passes — the build is cached', () => {
|
|
114
|
+
// This is the ordinary steady state on a working machine, and a check that
|
|
115
|
+
// failed here would refuse environments that run fine today.
|
|
116
|
+
const check = checkBaseImages(
|
|
117
|
+
healthyProbe({ imageExists: (ref) => ref.startsWith('celilo-e2e/') }),
|
|
118
|
+
dockerfiles,
|
|
119
|
+
);
|
|
120
|
+
expect(check.status).toBe('ok');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('only the images that actually need fetching are named', () => {
|
|
124
|
+
const check = checkBaseImages(
|
|
125
|
+
healthyProbe({
|
|
126
|
+
imageExists: (ref) => ref !== 'ubuntu:22.04' && ref !== 'celilo-e2e/observer',
|
|
127
|
+
}),
|
|
128
|
+
dockerfiles,
|
|
129
|
+
);
|
|
130
|
+
expect(check.status).toBe('fail');
|
|
131
|
+
expect(check.detail).toContain('ubuntu:22.04');
|
|
132
|
+
expect(check.detail).not.toContain('pebble');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('run-lock (problems 2 and 5)', () => {
|
|
137
|
+
test('a stale heartbeat with a live PID is SUSPECT, not merely busy', () => {
|
|
138
|
+
// The one hang PID-liveness cannot see: build-infra wedged at step [16/27],
|
|
139
|
+
// process alive and sleeping, heartbeat 1913s stale. Nothing surfaced it.
|
|
140
|
+
const check = checkRunLock(
|
|
141
|
+
lockState({
|
|
142
|
+
free: false,
|
|
143
|
+
holder: holder({ beatAt: Date.now() - 1_913_000 }),
|
|
144
|
+
heartbeatAgeMs: 1_913_000,
|
|
145
|
+
suspect: true,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
expect(check.status).toBe('fail');
|
|
149
|
+
expect(check.detail).toContain('SUSPECT');
|
|
150
|
+
expect(check.detail).toContain('31m');
|
|
151
|
+
expect(check.detail).toContain('not progressing');
|
|
152
|
+
expect(check.remedy).toContain('4242');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("this session's own kept lock is a warning, not contention", () => {
|
|
156
|
+
const check = checkRunLock(
|
|
157
|
+
lockState({
|
|
158
|
+
free: false,
|
|
159
|
+
holder: holder({ state: 'kept' }),
|
|
160
|
+
heartbeatAgeMs: 0,
|
|
161
|
+
ownKept: true,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
expect(check.status).toBe('warn');
|
|
165
|
+
expect(check.detail).toContain('auto-releases');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("another session's kept lock still fails", () => {
|
|
169
|
+
const check = checkRunLock(
|
|
170
|
+
lockState({ free: false, holder: holder({ state: 'kept' }), heartbeatAgeMs: 0 }),
|
|
171
|
+
);
|
|
172
|
+
expect(check.status).toBe('fail');
|
|
173
|
+
expect(check.detail).toContain('another session');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a healthy busy lock reports the heartbeat age', () => {
|
|
177
|
+
const check = checkRunLock(
|
|
178
|
+
lockState({ free: false, holder: holder(), heartbeatAgeMs: 11_000 }),
|
|
179
|
+
);
|
|
180
|
+
expect(check.status).toBe('fail');
|
|
181
|
+
expect(check.detail).toContain('11s ago');
|
|
182
|
+
expect(check.detail).not.toContain('SUSPECT');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('stale containers and disk pressure', () => {
|
|
187
|
+
test('leftover containers with no live run warn and point at `cele2e down`', () => {
|
|
188
|
+
const check = checkStaleContainers(
|
|
189
|
+
healthyProbe({ staleContainers: () => ['celilo-e2e-1_management', 'celilo-e2e-1_fw-main'] }),
|
|
190
|
+
lockState(),
|
|
191
|
+
);
|
|
192
|
+
expect(check.status).toBe('warn');
|
|
193
|
+
expect(check.remedy).toContain('cele2e down');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('containers belonging to a live holder are not debris', () => {
|
|
197
|
+
const check = checkStaleContainers(
|
|
198
|
+
healthyProbe({ staleContainers: () => ['celilo-e2e-1_management'] }),
|
|
199
|
+
lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
|
|
200
|
+
);
|
|
201
|
+
expect(check.status).toBe('ok');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('large reclaimable space steers away from `docker image prune`', () => {
|
|
205
|
+
const check = checkDiskPressure(healthyProbe({ reclaimableImageBytes: () => 40 * 1024 ** 3 }));
|
|
206
|
+
expect(check.status).toBe('warn');
|
|
207
|
+
expect(check.detail).toContain('docker image prune');
|
|
208
|
+
expect(check.remedy).toContain('name=celilo-e2e');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('disk pressure never blocks a run', () => {
|
|
212
|
+
const report = diagnose({
|
|
213
|
+
pkgDir: '/nonexistent',
|
|
214
|
+
probe: healthyProbe({ reclaimableImageBytes: () => 99 * 1024 ** 3 }),
|
|
215
|
+
lock: lockState(),
|
|
216
|
+
});
|
|
217
|
+
expect(report.checks.find((c) => c.name === 'disk')?.status).toBe('warn');
|
|
218
|
+
expect(report.ok).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('Dockerfile base parsing', () => {
|
|
223
|
+
test("ignores references to the Dockerfile's own build stages", () => {
|
|
224
|
+
const bases = parseBaseImages(
|
|
225
|
+
[
|
|
226
|
+
'FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS fetch',
|
|
227
|
+
'RUN apt-get update',
|
|
228
|
+
'FROM --platform=linux/amd64 eclipse-temurin:25-jre',
|
|
229
|
+
'COPY --from=fetch /out /out',
|
|
230
|
+
].join('\n'),
|
|
231
|
+
);
|
|
232
|
+
expect(bases).toEqual(['debian:bookworm-slim', 'eclipse-temurin:25-jre']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a plain single-stage Dockerfile yields its one base', () => {
|
|
236
|
+
expect(parseBaseImages('FROM ubuntu:22.04\nRUN echo hi\n')).toEqual(['ubuntu:22.04']);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('build failure translation', () => {
|
|
241
|
+
test('a "failed to solve: <image>" is explained as a missing local image', () => {
|
|
242
|
+
const text = explainBuildFailure(
|
|
243
|
+
'ERROR: failed to solve: ubuntu:22.04: failed to authorize: ... net/http: TLS handshake timeout',
|
|
244
|
+
);
|
|
245
|
+
expect(text).toContain('not in the local image store');
|
|
246
|
+
expect(text).toContain('docker pull ubuntu:22.04');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a genuine RUN-step failure is left alone', () => {
|
|
250
|
+
expect(
|
|
251
|
+
explainBuildFailure(
|
|
252
|
+
'ERROR: failed to solve: process "/bin/sh -c apt-get update" did not complete successfully: exit code: 100',
|
|
253
|
+
),
|
|
254
|
+
).toBe('');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('diagnose', () => {
|
|
259
|
+
test('any failing check makes the whole report not ok', () => {
|
|
260
|
+
const report = diagnose({
|
|
261
|
+
pkgDir: '/nonexistent',
|
|
262
|
+
probe: healthyProbe({ imageExists: () => false }),
|
|
263
|
+
lock: lockState(),
|
|
264
|
+
});
|
|
265
|
+
expect(report.ok).toBe(false);
|
|
266
|
+
expect(report.checks.find((c) => c.name === 'management-image')?.status).toBe('fail');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('skipLock omits the lock check — run holds the lock itself', () => {
|
|
270
|
+
const report = diagnose({
|
|
271
|
+
pkgDir: '/nonexistent',
|
|
272
|
+
probe: healthyProbe(),
|
|
273
|
+
lock: lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
|
|
274
|
+
skipLock: true,
|
|
275
|
+
});
|
|
276
|
+
expect(report.checks.some((c) => c.name === 'run-lock')).toBe(false);
|
|
277
|
+
expect(report.ok).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cele2e doctor — "is my environment sane?" as one mechanical answer.
|
|
3
|
+
*
|
|
4
|
+
* Every check here exists because its absence was diagnosed as a product bug
|
|
5
|
+
* first. They share a shape: the mistake is silent, and the failure surfaces
|
|
6
|
+
* later and somewhere unrelated. A missing bake shows up as an SSH error
|
|
7
|
+
* against a firewall IP; a pruned base image shows up as a TLS handshake
|
|
8
|
+
* timeout 16 images into a build. Each one is cheap to detect BEFORE a run
|
|
9
|
+
* starts, so `run` calls this as its implicit preflight and refuses rather
|
|
10
|
+
* than starting something that cannot succeed.
|
|
11
|
+
*
|
|
12
|
+
* Docker access is behind DoctorProbe (Rule 2.3) so every check is unit-
|
|
13
|
+
* testable with the condition deliberately broken — which is the point: a gate
|
|
14
|
+
* nobody has seen fail is not a gate (Rule 7.6).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
type LockHolder,
|
|
22
|
+
type LockStatus,
|
|
23
|
+
formatAge,
|
|
24
|
+
heartbeatAgeMs,
|
|
25
|
+
isSameSession,
|
|
26
|
+
isSuspect,
|
|
27
|
+
lockStatus,
|
|
28
|
+
} from './run-lock';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Images the per-test compose references by `image:` alone — it has no `build:`
|
|
32
|
+
* for them, so docker CANNOT produce them on demand. `cele2e build-infra` bakes
|
|
33
|
+
* both (install.sh, then `docker commit`). Missing → the run is doomed.
|
|
34
|
+
*/
|
|
35
|
+
export const BAKED_MANAGEMENT_IMAGES = [
|
|
36
|
+
'celilo-e2e/management:latest',
|
|
37
|
+
'celilo-e2e/management:vanilla',
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The bake commits a container that was started with `sleep infinity`, so it
|
|
42
|
+
* must override Cmd back to the management image's real entrypoint. When that
|
|
43
|
+
* override is lost the container comes up without ever running /startup.sh, the
|
|
44
|
+
* ssh-keys volume is never populated, and every machine the test adds fails
|
|
45
|
+
* with "Cannot connect to root@<ip> with provided SSH key" — an error that
|
|
46
|
+
* reads as a network or firewall bug and is neither (ce-um6).
|
|
47
|
+
*/
|
|
48
|
+
export const EXPECTED_MANAGEMENT_CMD = '/startup.sh';
|
|
49
|
+
|
|
50
|
+
/** Warn above this much reclaimable image space — the pressure that makes people prune. */
|
|
51
|
+
const RECLAIMABLE_WARN_BYTES = 20 * 1024 ** 3;
|
|
52
|
+
|
|
53
|
+
export type CheckStatus = 'ok' | 'warn' | 'fail';
|
|
54
|
+
|
|
55
|
+
export interface DoctorCheck {
|
|
56
|
+
/** Stable machine-readable id, e.g. 'management-image'. */
|
|
57
|
+
name: string;
|
|
58
|
+
status: CheckStatus;
|
|
59
|
+
/** What was actually found. */
|
|
60
|
+
detail: string;
|
|
61
|
+
/** The exact command that fixes it. Present whenever status is not 'ok'. */
|
|
62
|
+
remedy?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DoctorReport {
|
|
66
|
+
checks: DoctorCheck[];
|
|
67
|
+
/** True when no check failed. Warnings do not block a run. */
|
|
68
|
+
ok: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every Docker fact doctor needs, injectable so the checks are testable. */
|
|
72
|
+
export interface DoctorProbe {
|
|
73
|
+
/** Is this image present in the local store? An untagged ref means `:latest`. */
|
|
74
|
+
imageExists(ref: string): boolean;
|
|
75
|
+
/** The image's configured Cmd, or null when the image is absent. */
|
|
76
|
+
imageCmd(ref: string): string[] | null;
|
|
77
|
+
/** Names of leftover `celilo-e2e-*` containers. */
|
|
78
|
+
staleContainers(): string[];
|
|
79
|
+
/** Bytes docker reports as reclaimable image space. */
|
|
80
|
+
reclaimableImageBytes(): number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Dockerfile parsing ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* External base images a Dockerfile pulls from, ignoring references to its own
|
|
87
|
+
* earlier build stages (`FROM x AS fetch` … `COPY --from=fetch`). Pure so the
|
|
88
|
+
* multi-stage and `--platform=` forms both stay covered by a unit test.
|
|
89
|
+
*/
|
|
90
|
+
export function parseBaseImages(dockerfile: string): string[] {
|
|
91
|
+
const stages = new Set<string>();
|
|
92
|
+
const bases: string[] = [];
|
|
93
|
+
for (const raw of dockerfile.split('\n')) {
|
|
94
|
+
const line = raw.trim();
|
|
95
|
+
const match = line.match(/^FROM\s+(.*)$/i);
|
|
96
|
+
if (!match) continue;
|
|
97
|
+
const tokens = match[1].split(/\s+/).filter((t) => !t.startsWith('--'));
|
|
98
|
+
const ref = tokens[0];
|
|
99
|
+
if (!ref) continue;
|
|
100
|
+
const asIdx = tokens.findIndex((t) => t.toLowerCase() === 'as');
|
|
101
|
+
if (asIdx >= 0 && tokens[asIdx + 1]) stages.add(tokens[asIdx + 1]);
|
|
102
|
+
if (!stages.has(ref) && !bases.includes(ref)) bases.push(ref);
|
|
103
|
+
}
|
|
104
|
+
return bases;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Map `docker/Dockerfile.observer` → `celilo-e2e/observer` (docker-compose-generator's imageTag). */
|
|
108
|
+
function derivedImageTag(dockerfileName: string): string {
|
|
109
|
+
return `celilo-e2e/${dockerfileName.replace(/^Dockerfile\./, '')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface DockerfileBases {
|
|
113
|
+
/** The `celilo-e2e/<name>` tag this Dockerfile produces. */
|
|
114
|
+
derived: string;
|
|
115
|
+
bases: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read every `docker/Dockerfile.*` and pair its produced tag with its base images. */
|
|
119
|
+
export function readDockerfileBases(pkgDir: string): DockerfileBases[] {
|
|
120
|
+
const dir = join(pkgDir, 'docker');
|
|
121
|
+
if (!existsSync(dir)) return [];
|
|
122
|
+
return readdirSync(dir)
|
|
123
|
+
.filter((f) => f.startsWith('Dockerfile.'))
|
|
124
|
+
.sort()
|
|
125
|
+
.map((f) => ({
|
|
126
|
+
derived: derivedImageTag(f),
|
|
127
|
+
bases: parseBaseImages(readFileSync(join(dir, f), 'utf-8')),
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Checks ──────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The run-lock, including the two states `status` used to render identically to
|
|
135
|
+
* a healthy one: a stale-heartbeat holder (suspect) and this session's own kept
|
|
136
|
+
* stack (friction, not contention — the next run clears it).
|
|
137
|
+
*/
|
|
138
|
+
export function checkRunLock(lock: LockStatus): DoctorCheck {
|
|
139
|
+
if (lock.free || !lock.holder) {
|
|
140
|
+
return { name: 'run-lock', status: 'ok', detail: 'free' };
|
|
141
|
+
}
|
|
142
|
+
const h = lock.holder;
|
|
143
|
+
const age = formatAge(lock.heartbeatAgeMs ?? 0);
|
|
144
|
+
if (lock.suspect) {
|
|
145
|
+
return {
|
|
146
|
+
name: 'run-lock',
|
|
147
|
+
status: 'fail',
|
|
148
|
+
detail: `SUSPECT — ${h.session} running ${h.test} (pid ${h.pid}) has not beaten its heartbeat for ${age}; the process is alive but is not progressing`,
|
|
149
|
+
remedy: `Inspect it (\`docker ps\`, \`kill -0 ${h.pid}\`); if it is wedged, kill ${h.pid} then \`cele2e down\``,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (lock.ownKept) {
|
|
153
|
+
return {
|
|
154
|
+
name: 'run-lock',
|
|
155
|
+
status: 'warn',
|
|
156
|
+
detail: `held by THIS session's own kept stack (${h.test}) — the next run auto-releases it`,
|
|
157
|
+
remedy: 'cele2e release (or `cele2e down` to also tear the stack down)',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (h.state === 'kept') {
|
|
161
|
+
return {
|
|
162
|
+
name: 'run-lock',
|
|
163
|
+
status: 'fail',
|
|
164
|
+
detail: `held by another session's kept stack: ${h.session} (${h.test})`,
|
|
165
|
+
remedy: 'Wait for that session, or `cele2e release` if you know it is abandoned',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
name: 'run-lock',
|
|
170
|
+
status: 'fail',
|
|
171
|
+
detail: `busy — ${h.session} running ${h.test} (pid ${h.pid}), heartbeat ${age} ago`,
|
|
172
|
+
remedy: 'Poll with `cele2e status --json` (exit 0 = free, 3 = busy) and wait',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The baked management images exist AND still run /startup.sh. */
|
|
177
|
+
export function checkManagementImage(probe: DoctorProbe): DoctorCheck {
|
|
178
|
+
const missing = BAKED_MANAGEMENT_IMAGES.filter((ref) => !probe.imageExists(ref));
|
|
179
|
+
if (missing.length > 0) {
|
|
180
|
+
return {
|
|
181
|
+
name: 'management-image',
|
|
182
|
+
status: 'fail',
|
|
183
|
+
detail: `baked image missing: ${missing.join(', ')} — the test compose references these by tag and cannot build them`,
|
|
184
|
+
remedy: 'cele2e build-infra',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const unbaked = BAKED_MANAGEMENT_IMAGES.filter(
|
|
188
|
+
(ref) => !(probe.imageCmd(ref) ?? []).includes(EXPECTED_MANAGEMENT_CMD),
|
|
189
|
+
);
|
|
190
|
+
if (unbaked.length > 0) {
|
|
191
|
+
return {
|
|
192
|
+
name: 'management-image',
|
|
193
|
+
status: 'fail',
|
|
194
|
+
detail: `${unbaked.join(', ')} does not run ${EXPECTED_MANAGEMENT_CMD} — the bake commit lost its Cmd, so the ssh-keys volume will stay empty and every \`machine add\` will fail with "Cannot connect to root@<ip> with provided SSH key"`,
|
|
195
|
+
remedy: 'cele2e build-infra',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
name: 'management-image',
|
|
200
|
+
status: 'ok',
|
|
201
|
+
detail: `${BAKED_MANAGEMENT_IMAGES.length} baked images present, both running ${EXPECTED_MANAGEMENT_CMD}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Base images the next build genuinely has to fetch.
|
|
207
|
+
*
|
|
208
|
+
* A base image absent from the local store is only a problem when the image
|
|
209
|
+
* built FROM it is also absent — with the derived tag present, buildkit serves
|
|
210
|
+
* the build from cache and never resolves the reference. Requiring every base
|
|
211
|
+
* unconditionally would refuse environments that work today, so the check is
|
|
212
|
+
* the conjunction: no derived image AND no base image means the build must go
|
|
213
|
+
* to a registry, which is where `failed to solve: ubuntu:22.04: net/http: TLS
|
|
214
|
+
* handshake timeout` comes from — 16 images deep, blamed on the network.
|
|
215
|
+
*/
|
|
216
|
+
export function checkBaseImages(probe: DoctorProbe, dockerfiles: DockerfileBases[]): DoctorCheck {
|
|
217
|
+
const needPull = new Set<string>();
|
|
218
|
+
for (const { derived, bases } of dockerfiles) {
|
|
219
|
+
if (probe.imageExists(derived)) continue;
|
|
220
|
+
for (const base of bases) {
|
|
221
|
+
if (!probe.imageExists(base)) needPull.add(base);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (needPull.size === 0) {
|
|
225
|
+
return { name: 'base-images', status: 'ok', detail: 'every image the build needs is local' };
|
|
226
|
+
}
|
|
227
|
+
const refs = [...needPull].sort();
|
|
228
|
+
return {
|
|
229
|
+
name: 'base-images',
|
|
230
|
+
status: 'fail',
|
|
231
|
+
detail: `${refs.length} base image(s) absent locally and needed by an image that must be rebuilt: ${refs.join(', ')}`,
|
|
232
|
+
remedy: refs.map((r) => `docker pull ${r}`).join('\n '),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Leftover containers from a crashed run, which start-of-run cleanup will wipe. */
|
|
237
|
+
export function checkStaleContainers(probe: DoctorProbe, lock: LockStatus): DoctorCheck {
|
|
238
|
+
const names = probe.staleContainers();
|
|
239
|
+
if (names.length === 0) {
|
|
240
|
+
return {
|
|
241
|
+
name: 'stale-containers',
|
|
242
|
+
status: 'ok',
|
|
243
|
+
detail: 'no leftover celilo-e2e-* containers',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// With a live holder these are simply that run's containers, not debris.
|
|
247
|
+
if (!lock.free && !lock.ownKept) {
|
|
248
|
+
return {
|
|
249
|
+
name: 'stale-containers',
|
|
250
|
+
status: 'ok',
|
|
251
|
+
detail: `${names.length} celilo-e2e-* container(s) belonging to the current lock holder`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
name: 'stale-containers',
|
|
256
|
+
status: 'warn',
|
|
257
|
+
detail: `${names.length} celilo-e2e-* container(s) with no live run: ${names.slice(0, 4).join(', ')}${names.length > 4 ? ', …' : ''}`,
|
|
258
|
+
remedy: 'cele2e down (start-of-run cleanup also removes these)',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reclaimable image space. A warning, never a failure — the point is to steer
|
|
264
|
+
* the reflex: under disk pressure people reach for `docker image prune`, which
|
|
265
|
+
* deletes the base images the next build needs. Pruning e2e containers by name
|
|
266
|
+
* frees space without costing a 27-image rebuild.
|
|
267
|
+
*/
|
|
268
|
+
export function checkDiskPressure(probe: DoctorProbe): DoctorCheck {
|
|
269
|
+
const bytes = probe.reclaimableImageBytes();
|
|
270
|
+
const gib = (bytes / 1024 ** 3).toFixed(1);
|
|
271
|
+
if (bytes < RECLAIMABLE_WARN_BYTES) {
|
|
272
|
+
return { name: 'disk', status: 'ok', detail: `${gib} GiB reclaimable image space` };
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
name: 'disk',
|
|
276
|
+
status: 'warn',
|
|
277
|
+
detail: `${gib} GiB reclaimable image space — do NOT run \`docker image prune\`; it deletes the base images the next build needs and costs a full rebuild`,
|
|
278
|
+
remedy:
|
|
279
|
+
'docker ps -aq --filter name=celilo-e2e | xargs -r docker rm -f && docker volume prune -f',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Turn buildkit's `failed to solve: <ref>: … TLS handshake timeout` into the
|
|
285
|
+
* one sentence that identifies the cause.
|
|
286
|
+
*
|
|
287
|
+
* That message names the network because buildkit was, technically, on the
|
|
288
|
+
* network — but it only went there because the base image was not in the local
|
|
289
|
+
* store, which is what `docker image prune` (the reflex under disk pressure)
|
|
290
|
+
* removes. Reading it as a connectivity problem cost a full 27-image rebuild.
|
|
291
|
+
* checkBaseImages preflights for this, so reaching here means the image went
|
|
292
|
+
* missing mid-run; either way, say what to pull.
|
|
293
|
+
*
|
|
294
|
+
* Returns '' when the failure names a RUN step rather than an image reference —
|
|
295
|
+
* a genuine build error, which needs no translation.
|
|
296
|
+
*/
|
|
297
|
+
export function explainBuildFailure(stderr: string): string {
|
|
298
|
+
const ref = stderr.match(/failed to solve:\s*([^\s:"]+(?::[^\s:"]+)?)/)?.[1];
|
|
299
|
+
if (!ref || ref === 'process') return '';
|
|
300
|
+
return [
|
|
301
|
+
'',
|
|
302
|
+
'',
|
|
303
|
+
`This names "${ref}" — buildkit had to fetch it, which means it is not in the local image store.`,
|
|
304
|
+
`A \`docker image prune\` removes exactly these. Fix: docker pull ${ref}`,
|
|
305
|
+
'Then re-run. `cele2e doctor` checks for this before a run starts.',
|
|
306
|
+
].join('\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Real probe ──────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
function docker(args: string[]): string {
|
|
312
|
+
return execFileSync('docker', args, {
|
|
313
|
+
encoding: 'utf-8',
|
|
314
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
315
|
+
timeout: 15_000,
|
|
316
|
+
}).trim();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function createDockerProbe(): DoctorProbe {
|
|
320
|
+
// One `docker images` listing answers every existence question; per-image
|
|
321
|
+
// inspect calls would be ~40 process spawns on a preflight that must be fast.
|
|
322
|
+
let tags: Set<string> | null = null;
|
|
323
|
+
const knownTags = (): Set<string> => {
|
|
324
|
+
if (tags) return tags;
|
|
325
|
+
try {
|
|
326
|
+
tags = new Set(
|
|
327
|
+
docker(['images', '--format', '{{.Repository}}:{{.Tag}}'])
|
|
328
|
+
.split('\n')
|
|
329
|
+
.map((l) => l.trim())
|
|
330
|
+
.filter(Boolean),
|
|
331
|
+
);
|
|
332
|
+
} catch {
|
|
333
|
+
tags = new Set();
|
|
334
|
+
}
|
|
335
|
+
return tags;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
imageExists(ref) {
|
|
340
|
+
const withTag = ref.includes(':') ? ref : `${ref}:latest`;
|
|
341
|
+
return knownTags().has(withTag);
|
|
342
|
+
},
|
|
343
|
+
imageCmd(ref) {
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(docker(['image', 'inspect', ref, '--format', '{{json .Config.Cmd}}']));
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
staleContainers() {
|
|
351
|
+
try {
|
|
352
|
+
return docker(['ps', '-a', '--filter', 'name=celilo-e2e', '--format', '{{.Names}}'])
|
|
353
|
+
.split('\n')
|
|
354
|
+
.map((l) => l.trim())
|
|
355
|
+
.filter(Boolean);
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
reclaimableImageBytes() {
|
|
361
|
+
try {
|
|
362
|
+
for (const line of docker(['system', 'df', '--format', '{{json .}}']).split('\n')) {
|
|
363
|
+
const row = JSON.parse(line) as { Type?: string; Reclaimable?: string };
|
|
364
|
+
if (row.Type !== 'Images') continue;
|
|
365
|
+
// Reclaimable looks like "12.3GB (48%)".
|
|
366
|
+
const m = row.Reclaimable?.match(/^([\d.]+)\s*([KMGT]?)B/i);
|
|
367
|
+
if (!m) return 0;
|
|
368
|
+
const scale = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[
|
|
369
|
+
m[2].toUpperCase()
|
|
370
|
+
];
|
|
371
|
+
return Number.parseFloat(m[1]) * (scale ?? 1);
|
|
372
|
+
}
|
|
373
|
+
} catch {}
|
|
374
|
+
return 0;
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── Report ──────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
export interface DiagnoseOptions {
|
|
382
|
+
pkgDir: string;
|
|
383
|
+
probe?: DoctorProbe;
|
|
384
|
+
lock?: LockStatus;
|
|
385
|
+
/** Skip the run-lock check — `run` acquires the lock itself and reports contention its own way. */
|
|
386
|
+
skipLock?: boolean;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function diagnose(options: DiagnoseOptions): DoctorReport {
|
|
390
|
+
const probe = options.probe ?? createDockerProbe();
|
|
391
|
+
const lock = options.lock ?? lockStatus();
|
|
392
|
+
const checks: DoctorCheck[] = [];
|
|
393
|
+
if (!options.skipLock) checks.push(checkRunLock(lock));
|
|
394
|
+
checks.push(checkManagementImage(probe));
|
|
395
|
+
checks.push(checkBaseImages(probe, readDockerfileBases(options.pkgDir)));
|
|
396
|
+
checks.push(checkStaleContainers(probe, lock));
|
|
397
|
+
checks.push(checkDiskPressure(probe));
|
|
398
|
+
return { checks, ok: !checks.some((c) => c.status === 'fail') };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const ICONS: Record<CheckStatus, string> = { ok: '✓', warn: '!', fail: '✗' };
|
|
402
|
+
|
|
403
|
+
/** Render a report as the lines `doctor` prints and `run` prints on refusal. */
|
|
404
|
+
export function formatReport(report: DoctorReport): string[] {
|
|
405
|
+
const lines: string[] = [];
|
|
406
|
+
for (const c of report.checks) {
|
|
407
|
+
lines.push(` ${ICONS[c.status]} ${c.name.padEnd(18)} ${c.detail}`);
|
|
408
|
+
if (c.remedy) lines.push(` fix: ${c.remedy}`);
|
|
409
|
+
}
|
|
410
|
+
return lines;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The holder line `status` prints, with heartbeat age always visible. */
|
|
414
|
+
export function formatHolderLine(h: LockHolder): string {
|
|
415
|
+
const flags = [
|
|
416
|
+
isSuspect(h) ? 'SUSPECT' : null,
|
|
417
|
+
h.state === 'kept' && isSameSession(h) ? 'yours' : null,
|
|
418
|
+
].filter(Boolean);
|
|
419
|
+
const suffix = flags.length ? ` [${flags.join(', ')}]` : '';
|
|
420
|
+
return `${h.session} — ${h.state} ${h.test} (pid ${h.pid}), heartbeat ${formatAge(heartbeatAgeMs(h))} ago${suffix}`;
|
|
421
|
+
}
|