@celilo/e2e 0.19.3 → 0.20.0
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 +30 -13
- package/bin/e2e-bake-management +171 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +54 -4
- package/src/cli/build.ts +204 -88
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +184 -0
- package/src/live-stack.ts +145 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +83 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +201 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- package/src/wait-for-run.ts +1 -0
package/src/doctor.test.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* one.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
13
17
|
import {
|
|
14
18
|
BAKED_MANAGEMENT_IMAGES,
|
|
15
19
|
type DockerfileBases,
|
|
@@ -17,22 +21,35 @@ import {
|
|
|
17
21
|
EXPECTED_MANAGEMENT_CMD,
|
|
18
22
|
checkBaseImages,
|
|
19
23
|
checkDiskPressure,
|
|
24
|
+
checkHostVm,
|
|
25
|
+
checkImageFreshness,
|
|
26
|
+
checkLeakedStacks,
|
|
20
27
|
checkManagementImage,
|
|
21
28
|
checkRunLock,
|
|
22
29
|
checkStaleContainers,
|
|
23
30
|
diagnose,
|
|
24
31
|
explainBuildFailure,
|
|
25
32
|
parseBaseImages,
|
|
33
|
+
stackStartedAt,
|
|
26
34
|
} from './doctor';
|
|
35
|
+
import type { LeakedStack } from './doctor';
|
|
36
|
+
import type { HostFacts, HostVmFacts } from './host-vm';
|
|
27
37
|
import type { LockHolder, LockStatus } from './run-lock';
|
|
38
|
+
import {
|
|
39
|
+
PUBLISHED_FINGERPRINT_PREFIX,
|
|
40
|
+
SOURCE_LABEL,
|
|
41
|
+
computeSourceFingerprint,
|
|
42
|
+
} from './source-fingerprint';
|
|
28
43
|
|
|
29
44
|
/** A probe where everything is healthy; each test breaks exactly one thing. */
|
|
30
45
|
function healthyProbe(overrides: Partial<DoctorProbe> = {}): DoctorProbe {
|
|
31
46
|
return {
|
|
32
47
|
imageExists: () => true,
|
|
33
48
|
imageCmd: () => [EXPECTED_MANAGEMENT_CMD],
|
|
49
|
+
imageLabel: () => null,
|
|
34
50
|
staleContainers: () => [],
|
|
35
51
|
reclaimableImageBytes: () => 0,
|
|
52
|
+
leakedStacks: () => [],
|
|
36
53
|
...overrides,
|
|
37
54
|
};
|
|
38
55
|
}
|
|
@@ -201,11 +218,18 @@ describe('stale containers and disk pressure', () => {
|
|
|
201
218
|
expect(check.status).toBe('ok');
|
|
202
219
|
});
|
|
203
220
|
|
|
204
|
-
test('large reclaimable space
|
|
221
|
+
test('large reclaimable space offers the safe prune and names the destructive one', () => {
|
|
222
|
+
// The two forms are not interchangeable and the difference is the whole
|
|
223
|
+
// point: bare `docker image prune` removes UNTAGGED images (superseded
|
|
224
|
+
// management bakes, the bulk of this pile), while `-a` and `system prune`
|
|
225
|
+
// also remove the TAGGED base images the next build needs — which is the
|
|
226
|
+
// reflex that costs a full 27-image rebuild and then reads as a network
|
|
227
|
+
// failure. The remedy must say which is which.
|
|
205
228
|
const check = checkDiskPressure(healthyProbe({ reclaimableImageBytes: () => 40 * 1024 ** 3 }));
|
|
206
229
|
expect(check.status).toBe('warn');
|
|
207
|
-
expect(check.
|
|
208
|
-
expect(check.remedy).toContain('
|
|
230
|
+
expect(check.remedy).toContain('docker image prune -f');
|
|
231
|
+
expect(check.remedy).toContain('NEVER `-a`');
|
|
232
|
+
expect(check.remedy).toContain('docker system prune');
|
|
209
233
|
});
|
|
210
234
|
|
|
211
235
|
test('disk pressure never blocks a run', () => {
|
|
@@ -219,6 +243,86 @@ describe('stale containers and disk pressure', () => {
|
|
|
219
243
|
});
|
|
220
244
|
});
|
|
221
245
|
|
|
246
|
+
describe('leaked per-test stacks (ce-ywix: a crashed suite held a subnet for 2.5h)', () => {
|
|
247
|
+
// The mechanism: a Proxmox guest (celilo-e2e-lxc-<vmid>) carries no project
|
|
248
|
+
// name, survives every project-scoped sweep, and keeps the network attached —
|
|
249
|
+
// so every later run dies at compose up with "Pool overlaps". The check names
|
|
250
|
+
// the leak, its suite-of-origin timestamp, and the exact removal commands.
|
|
251
|
+
const leaked: LeakedStack[] = [
|
|
252
|
+
{
|
|
253
|
+
network: 'celilo-e2e-1788615872911_dmz',
|
|
254
|
+
containers: ['celilo-e2e-lxc-104'],
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
test('a leaked stack with no live run warns and names the exact removal commands', () => {
|
|
259
|
+
const check = checkLeakedStacks(healthyProbe({ leakedStacks: () => leaked }), lockState());
|
|
260
|
+
expect(check.status).toBe('warn');
|
|
261
|
+
expect(check.detail).toContain('celilo-e2e-1788615872911_dmz');
|
|
262
|
+
expect(check.detail).toContain('celilo-e2e-lxc-104');
|
|
263
|
+
expect(check.remedy).toContain('docker rm -f celilo-e2e-lxc-104');
|
|
264
|
+
expect(check.remedy).toContain('docker network rm celilo-e2e-1788615872911_dmz');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('the suite-of-origin is attributable from the project timestamp', () => {
|
|
268
|
+
// The bead's attribution note: the project name embeds epoch-ms, so the
|
|
269
|
+
// leaker is identifiable after the fact without any other record.
|
|
270
|
+
const check = checkLeakedStacks(healthyProbe({ leakedStacks: () => leaked }), lockState());
|
|
271
|
+
expect(check.detail).toContain('suite started');
|
|
272
|
+
expect(check.detail).toMatch(/suite started \d+[smh]/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('a stack blocked by a NON-e2e container fails — the sweep cannot self-heal it', () => {
|
|
276
|
+
const check = checkLeakedStacks(
|
|
277
|
+
healthyProbe({
|
|
278
|
+
leakedStacks: () => [
|
|
279
|
+
{ network: 'celilo-e2e-1788615872911_dmz', containers: ['my-dev-box'] },
|
|
280
|
+
],
|
|
281
|
+
}),
|
|
282
|
+
lockState(),
|
|
283
|
+
);
|
|
284
|
+
expect(check.status).toBe('fail');
|
|
285
|
+
expect(check.detail).toContain('my-dev-box');
|
|
286
|
+
expect(check.remedy).toContain(
|
|
287
|
+
'docker network disconnect -f celilo-e2e-1788615872911_dmz my-dev-box',
|
|
288
|
+
);
|
|
289
|
+
expect(check.remedy).toContain('docker network rm celilo-e2e-1788615872911_dmz');
|
|
290
|
+
// Never instruct removing a container the sweep does not own.
|
|
291
|
+
expect(check.remedy).not.toContain('docker rm -f my-dev-box');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("stacks belonging to a live holder are that run's, not debris", () => {
|
|
295
|
+
const check = checkLeakedStacks(
|
|
296
|
+
healthyProbe({ leakedStacks: () => leaked }),
|
|
297
|
+
lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
|
|
298
|
+
);
|
|
299
|
+
expect(check.status).toBe('ok');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('the run\'s own preflight treats its held lock as "no legitimate holder"', () => {
|
|
303
|
+
// skipLock in the runner means WE hold the lock, so at preflight time any
|
|
304
|
+
// stack in sight is debris even though lockStatus() reads busy (our own
|
|
305
|
+
// holder record). Attributing it to "the holder" would hide the leak from
|
|
306
|
+
// the exact surface that exists to name it.
|
|
307
|
+
const check = checkLeakedStacks(
|
|
308
|
+
healthyProbe({ leakedStacks: () => leaked }),
|
|
309
|
+
lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
|
|
310
|
+
true,
|
|
311
|
+
);
|
|
312
|
+
expect(check.status).toBe('warn');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('no stacks reads clean', () => {
|
|
316
|
+
expect(checkLeakedStacks(healthyProbe(), lockState()).status).toBe('ok');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('the shared and interactive stacks are never mistaken for per-test debris', () => {
|
|
320
|
+
expect(stackStartedAt('celilo-e2e-shared_real-internet')).toBeNull();
|
|
321
|
+
expect(stackStartedAt('celilo-e2e-interactive_dmz')).toBeNull();
|
|
322
|
+
expect(stackStartedAt('celilo-e2e-1788615872911_dmz')).toBe(1788615872911);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
222
326
|
describe('Dockerfile base parsing', () => {
|
|
223
327
|
test("ignores references to the Dockerfile's own build stages", () => {
|
|
224
328
|
const bases = parseBaseImages(
|
|
@@ -277,3 +381,154 @@ describe('diagnose', () => {
|
|
|
277
381
|
expect(report.ok).toBe(true);
|
|
278
382
|
});
|
|
279
383
|
});
|
|
384
|
+
|
|
385
|
+
// ─── The Docker host VM ──────────────────────────────────────────────
|
|
386
|
+
//
|
|
387
|
+
// Two settings, each measured on 2026-09-05 (see host-vm.ts). Warnings rather
|
|
388
|
+
// than failures: a badly-shaped VM makes a run slow, not impossible, and this
|
|
389
|
+
// check's job is to stop that being invisible.
|
|
390
|
+
|
|
391
|
+
const M1_PRO: HostFacts = { cpus: 10, memoryGiB: 32 };
|
|
392
|
+
|
|
393
|
+
function vm(overrides: Partial<HostVmFacts> = {}): HostVmFacts {
|
|
394
|
+
return {
|
|
395
|
+
profile: 'default',
|
|
396
|
+
cpus: 8,
|
|
397
|
+
memoryGiB: 12,
|
|
398
|
+
mountType: 'virtiofs',
|
|
399
|
+
vmType: 'vz',
|
|
400
|
+
...overrides,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
describe('checkHostVm', () => {
|
|
405
|
+
test('a VM inside the policy is ok and says what it found', () => {
|
|
406
|
+
const check = checkHostVm(vm(), M1_PRO);
|
|
407
|
+
expect(check.status).toBe('ok');
|
|
408
|
+
expect(check.detail).toContain('12 GiB');
|
|
409
|
+
expect(check.detail).toContain('virtiofs');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('no VM at all is ok — docker runs natively on Linux', () => {
|
|
413
|
+
const check = checkHostVm(null, M1_PRO);
|
|
414
|
+
expect(check.status).toBe('ok');
|
|
415
|
+
expect(check.detail).toContain('no colima VM');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('an oversized VM warns, because the host pays for the guest page cache', () => {
|
|
419
|
+
// The measured state: 24 of 32 GiB, host swapping 21.9 GiB.
|
|
420
|
+
const check = checkHostVm(vm({ memoryGiB: 24 }), M1_PRO);
|
|
421
|
+
expect(check.status).toBe('warn');
|
|
422
|
+
expect(check.detail).toContain('24 GiB');
|
|
423
|
+
// Restarting fixes memory, so it must NOT demand the destructive path.
|
|
424
|
+
expect(check.remedy).toContain('cele2e host up');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('an sshfs VM warns AND routes to reset, because colima cannot change it in place', () => {
|
|
428
|
+
const check = checkHostVm(vm({ mountType: 'sshfs' }), M1_PRO);
|
|
429
|
+
expect(check.status).toBe('warn');
|
|
430
|
+
expect(check.detail).toContain('sshfs');
|
|
431
|
+
expect(check.remedy).toContain('cele2e host reset');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('a qemu VM is not asked for virtiofs, which only Apple Virtualization has', () => {
|
|
435
|
+
const check = checkHostVm(vm({ vmType: 'qemu', mountType: 'sshfs' }), M1_PRO);
|
|
436
|
+
expect(check.status).toBe('ok');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('more VM CPUs than the host has warns', () => {
|
|
440
|
+
const check = checkHostVm(vm({ cpus: 16 }), M1_PRO);
|
|
441
|
+
expect(check.status).toBe('warn');
|
|
442
|
+
expect(check.detail).toContain('16 CPUs');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ─── Image freshness ─────────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// The baked management image carries the celilo CLI every test exercises. A
|
|
449
|
+
// stale one fails for reasons the working tree cannot explain, and says
|
|
450
|
+
// nothing about it — so the fingerprint is compared, never assumed.
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A throwaway checkout, so these tests assert on a tree they control rather
|
|
454
|
+
* than on whatever the ambient worktree happens to hold. A test whose subject
|
|
455
|
+
* is "the repo I am running in" passes or fails for reasons unrelated to the
|
|
456
|
+
* code under test.
|
|
457
|
+
*/
|
|
458
|
+
function makeCheckout(): string {
|
|
459
|
+
const root = mkdtempSync(join(tmpdir(), 'fingerprint-'));
|
|
460
|
+
mkdirSync(join(root, 'apps', 'celilo', 'src'), { recursive: true });
|
|
461
|
+
writeFileSync(join(root, 'apps', 'celilo', 'package.json'), '{"name":"@celilo/cli"}\n');
|
|
462
|
+
writeFileSync(join(root, 'apps', 'celilo', 'src', 'index.ts'), 'export const a = 1;\n');
|
|
463
|
+
const git = (...args: string[]) =>
|
|
464
|
+
execFileSync('git', ['-C', root, ...args], { stdio: 'ignore' });
|
|
465
|
+
git('init', '-q');
|
|
466
|
+
git('config', 'user.email', 'test@celilo.invalid');
|
|
467
|
+
git('config', 'user.name', 'test');
|
|
468
|
+
git('add', '-A');
|
|
469
|
+
git('commit', '-qm', 'initial');
|
|
470
|
+
return root;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const REPO_ROOT = makeCheckout();
|
|
474
|
+
afterAll(() => rmSync(REPO_ROOT, { recursive: true, force: true }));
|
|
475
|
+
|
|
476
|
+
describe('checkImageFreshness', () => {
|
|
477
|
+
test('no checkout means nothing to be stale against', () => {
|
|
478
|
+
const check = checkImageFreshness(healthyProbe(), null);
|
|
479
|
+
expect(check.status).toBe('ok');
|
|
480
|
+
expect(check.detail).toContain('no celilo checkout');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('an unstamped image warns, because its age cannot be established', () => {
|
|
484
|
+
// repoRoot is this checkout, so a fingerprint IS computed; the image has no label.
|
|
485
|
+
const check = checkImageFreshness(healthyProbe({ imageLabel: () => null }), REPO_ROOT);
|
|
486
|
+
expect(check.status).toBe('warn');
|
|
487
|
+
expect(check.detail).toContain('no source stamp');
|
|
488
|
+
expect(check.remedy).toBe('cele2e build-infra');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('a stamp from other source warns and names both fingerprints', () => {
|
|
492
|
+
const check = checkImageFreshness(
|
|
493
|
+
healthyProbe({ imageLabel: () => 'deadbeefdeadbeef' }),
|
|
494
|
+
REPO_ROOT,
|
|
495
|
+
);
|
|
496
|
+
expect(check.status).toBe('warn');
|
|
497
|
+
expect(check.detail).toContain('deadbeefdeadbeef');
|
|
498
|
+
expect(check.detail).toContain('NOT the code in this checkout');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('a published bake is ok — it was never meant to match this tree', () => {
|
|
502
|
+
const check = checkImageFreshness(
|
|
503
|
+
healthyProbe({ imageLabel: () => `${PUBLISHED_FINGERPRINT_PREFIX}celilo 2.1.0` }),
|
|
504
|
+
REPO_ROOT,
|
|
505
|
+
);
|
|
506
|
+
expect(check.status).toBe('ok');
|
|
507
|
+
expect(check.detail).toContain('real npm');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('a matching stamp is ok', () => {
|
|
511
|
+
const expected = computeSourceFingerprint(REPO_ROOT);
|
|
512
|
+
expect(expected).not.toBeNull();
|
|
513
|
+
const check = checkImageFreshness(
|
|
514
|
+
healthyProbe({ imageLabel: () => expected as string }),
|
|
515
|
+
REPO_ROOT,
|
|
516
|
+
);
|
|
517
|
+
expect(check.status).toBe('ok');
|
|
518
|
+
expect(check.detail).toContain('baked from this tree');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('it reads the label the bake actually writes', () => {
|
|
522
|
+
let asked = '';
|
|
523
|
+
checkImageFreshness(
|
|
524
|
+
healthyProbe({
|
|
525
|
+
imageLabel: (_ref, label) => {
|
|
526
|
+
asked = label;
|
|
527
|
+
return null;
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
REPO_ROOT,
|
|
531
|
+
);
|
|
532
|
+
expect(asked).toBe(SOURCE_LABEL);
|
|
533
|
+
});
|
|
534
|
+
});
|
package/src/doctor.ts
CHANGED
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
type HostFacts,
|
|
22
|
+
type HostVmFacts,
|
|
23
|
+
evaluateHostVm,
|
|
24
|
+
readHostFacts,
|
|
25
|
+
readHostVmFacts,
|
|
26
|
+
recommendedBudget,
|
|
27
|
+
} from './host-vm';
|
|
28
|
+
import { CONTAINER_PREFIX } from './proxmox-provisioner';
|
|
29
|
+
import { findMonorepoRoot } from './repo-root';
|
|
20
30
|
import {
|
|
21
31
|
type LockHolder,
|
|
22
32
|
type LockStatus,
|
|
@@ -26,14 +36,21 @@ import {
|
|
|
26
36
|
isSuspect,
|
|
27
37
|
lockStatus,
|
|
28
38
|
} from './run-lock';
|
|
39
|
+
import {
|
|
40
|
+
PUBLISHED_FINGERPRINT_PREFIX,
|
|
41
|
+
SOURCE_LABEL,
|
|
42
|
+
computeSourceFingerprint,
|
|
43
|
+
} from './source-fingerprint';
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
46
|
* Images the per-test compose references by `image:` alone — it has no `build:`
|
|
32
47
|
* for them, so docker CANNOT produce them on demand. `cele2e build-infra` bakes
|
|
33
48
|
* both (install.sh, then `docker commit`). Missing → the run is doomed.
|
|
34
49
|
*/
|
|
50
|
+
export const MANAGEMENT_LATEST = 'celilo-e2e/management:latest';
|
|
51
|
+
|
|
35
52
|
export const BAKED_MANAGEMENT_IMAGES = [
|
|
36
|
-
|
|
53
|
+
MANAGEMENT_LATEST,
|
|
37
54
|
'celilo-e2e/management:vanilla',
|
|
38
55
|
] as const;
|
|
39
56
|
|
|
@@ -68,16 +85,32 @@ export interface DoctorReport {
|
|
|
68
85
|
ok: boolean;
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A leaked per-test compose stack: one timestamped-project network plus whatever
|
|
90
|
+
* is still attached to it. The project name embeds the epoch-ms the suite
|
|
91
|
+
* started, which is what makes the leaker attributable after the fact.
|
|
92
|
+
*/
|
|
93
|
+
export interface LeakedStack {
|
|
94
|
+
/** e.g. `celilo-e2e-1788615872911_dmz` */
|
|
95
|
+
network: string;
|
|
96
|
+
/** Attached container names, e.g. `celilo-e2e-lxc-104`. */
|
|
97
|
+
containers: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
/** Every Docker fact doctor needs, injectable so the checks are testable. */
|
|
72
101
|
export interface DoctorProbe {
|
|
73
102
|
/** Is this image present in the local store? An untagged ref means `:latest`. */
|
|
74
103
|
imageExists(ref: string): boolean;
|
|
75
104
|
/** The image's configured Cmd, or null when the image is absent. */
|
|
76
105
|
imageCmd(ref: string): string[] | null;
|
|
106
|
+
/** One label off the image, or null when the image or the label is absent. */
|
|
107
|
+
imageLabel(ref: string, label: string): string | null;
|
|
77
108
|
/** Names of leftover `celilo-e2e-*` containers. */
|
|
78
109
|
staleContainers(): string[];
|
|
79
110
|
/** Bytes docker reports as reclaimable image space. */
|
|
80
111
|
reclaimableImageBytes(): number;
|
|
112
|
+
/** Per-test compose networks still up, with their attached containers. */
|
|
113
|
+
leakedStacks(): LeakedStack[];
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
// ─── Dockerfile parsing ──────────────────────────────────────────────
|
|
@@ -233,6 +266,89 @@ export function checkBaseImages(probe: DoctorProbe, dockerfiles: DockerfileBases
|
|
|
233
266
|
};
|
|
234
267
|
}
|
|
235
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Epoch-ms a per-test stack started, parsed from its network's project name —
|
|
271
|
+
* the attribution `docker network ls` carries for free. Null when the name is
|
|
272
|
+
* not a per-test project (the shared and interactive projects hold no digits).
|
|
273
|
+
*/
|
|
274
|
+
export function stackStartedAt(network: string): number | null {
|
|
275
|
+
const ms = network.match(/^celilo-e2e-(\d{13})_/)?.[1];
|
|
276
|
+
return ms ? Number(ms) : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Networks from a crashed run that hold the sim's subnets.
|
|
281
|
+
*
|
|
282
|
+
* An active run holds the machine-global run-lock, so outside one there is no
|
|
283
|
+
* legitimate holder of a `celilo-e2e-<ts>_*` network — it is always teardown
|
|
284
|
+
* debris. Left in place it blocks EVERY later run with
|
|
285
|
+
* `invalid pool request: Pool overlaps` at compose up (ce-ywix), a failure that
|
|
286
|
+
* arrived only after minutes of image builds. The start-of-run sweep removes
|
|
287
|
+
* these networks and their e2e-attached containers, so a cleanable leak is a
|
|
288
|
+
* warning; a network blocked by a NON-e2e container is a failure, because the
|
|
289
|
+
* sweep deliberately never touches foreign containers and the run cannot
|
|
290
|
+
* succeed.
|
|
291
|
+
*/
|
|
292
|
+
export function checkLeakedStacks(
|
|
293
|
+
probe: DoctorProbe,
|
|
294
|
+
lock: LockStatus,
|
|
295
|
+
callerHoldsLock = false,
|
|
296
|
+
): DoctorCheck {
|
|
297
|
+
const stacks = probe.leakedStacks();
|
|
298
|
+
if (stacks.length === 0) {
|
|
299
|
+
return { name: 'leaked-stacks', status: 'ok', detail: 'no leaked per-test stack networks' };
|
|
300
|
+
}
|
|
301
|
+
// A live holder owns whatever per-test stack exists right now. `run` passes
|
|
302
|
+
// callerHoldsLock instead: it already owns the lock, so at preflight time
|
|
303
|
+
// every stack it can see is by definition somebody's debris, never its own.
|
|
304
|
+
if (!callerHoldsLock && !lock.free && !lock.ownKept) {
|
|
305
|
+
return {
|
|
306
|
+
name: 'leaked-stacks',
|
|
307
|
+
status: 'ok',
|
|
308
|
+
detail: `${stacks.length} per-test network(s) belonging to the current lock holder`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const describe = (s: LeakedStack): string => {
|
|
313
|
+
const startedAt = stackStartedAt(s.network);
|
|
314
|
+
const age = startedAt ? ` (suite started ${formatAge(Date.now() - startedAt)} ago)` : '';
|
|
315
|
+
const attached = s.containers.length > 0 ? `, attached: ${s.containers.join(', ')}` : '';
|
|
316
|
+
return `${s.network}${age}${attached}`;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const foreign = stacks.flatMap((s) =>
|
|
320
|
+
s.containers.filter((c) => !c.startsWith(CONTAINER_PREFIX)),
|
|
321
|
+
);
|
|
322
|
+
if (foreign.length > 0) {
|
|
323
|
+
const blocked = stacks.filter((s) => s.containers.some((c) => !c.startsWith(CONTAINER_PREFIX)));
|
|
324
|
+
return {
|
|
325
|
+
name: 'leaked-stacks',
|
|
326
|
+
status: 'fail',
|
|
327
|
+
detail: `leaked per-test network(s) with a NON-e2e container attached — the cleanup sweep never touches foreign containers, so this stack cannot self-heal: ${blocked.map(describe).join('; ')}`,
|
|
328
|
+
remedy: blocked
|
|
329
|
+
.flatMap((s) =>
|
|
330
|
+
s.containers
|
|
331
|
+
.filter((c) => !c.startsWith(CONTAINER_PREFIX))
|
|
332
|
+
.map((c) => `docker network disconnect -f ${s.network} ${c}`),
|
|
333
|
+
)
|
|
334
|
+
.concat(blocked.map((s) => `docker network rm ${s.network}`))
|
|
335
|
+
.join('\n '),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
name: 'leaked-stacks',
|
|
341
|
+
status: 'warn',
|
|
342
|
+
detail: `leaked per-test stack(s) holding the sim's subnets — always teardown debris, a later run's start-of-run cleanup removes them: ${stacks.slice(0, 3).map(describe).join('; ')}${stacks.length > 3 ? '; …' : ''}`,
|
|
343
|
+
remedy: [
|
|
344
|
+
...stacks.flatMap((s) =>
|
|
345
|
+
s.containers.length > 0 ? [`docker rm -f ${s.containers.join(' ')}`] : [],
|
|
346
|
+
),
|
|
347
|
+
...stacks.map((s) => `docker network rm ${s.network}`),
|
|
348
|
+
].join('\n '),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
236
352
|
/** Leftover containers from a crashed run, which start-of-run cleanup will wipe. */
|
|
237
353
|
export function checkStaleContainers(probe: DoctorProbe, lock: LockStatus): DoctorCheck {
|
|
238
354
|
const names = probe.staleContainers();
|
|
@@ -259,6 +375,93 @@ export function checkStaleContainers(probe: DoctorProbe, lock: LockStatus): Doct
|
|
|
259
375
|
};
|
|
260
376
|
}
|
|
261
377
|
|
|
378
|
+
/**
|
|
379
|
+
* The Docker host's virtual machine, on the two settings that dominate run
|
|
380
|
+
* time. Never a failure: a run on a badly-shaped VM is slow, not doomed, and
|
|
381
|
+
* refusing to run would be a worse trade than saying so. Silent on Linux and
|
|
382
|
+
* anywhere without a colima VM, where there is nothing to shape.
|
|
383
|
+
*
|
|
384
|
+
* See `host-vm.ts` for the measurements behind each rule.
|
|
385
|
+
*/
|
|
386
|
+
export function checkHostVm(facts: HostVmFacts | null, host: HostFacts): DoctorCheck {
|
|
387
|
+
if (!facts) {
|
|
388
|
+
return {
|
|
389
|
+
name: 'docker-host',
|
|
390
|
+
status: 'ok',
|
|
391
|
+
detail: 'no colima VM — docker runs natively, no host-VM policy applies',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const budget = recommendedBudget(host, facts.vmType);
|
|
395
|
+
const { problems, needsRecreate } = evaluateHostVm(facts, host, budget);
|
|
396
|
+
if (problems.length === 0) {
|
|
397
|
+
return {
|
|
398
|
+
name: 'docker-host',
|
|
399
|
+
status: 'ok',
|
|
400
|
+
detail: `colima/${facts.profile}: ${facts.cpus} CPU, ${facts.memoryGiB} GiB, ${facts.mountType} mounts`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
name: 'docker-host',
|
|
405
|
+
status: 'warn',
|
|
406
|
+
detail: problems.join(' '),
|
|
407
|
+
remedy: needsRecreate
|
|
408
|
+
? 'cele2e host reset (colima discards a mount-type change on an existing VM, so this DESTROYS the VM and its image cache and rebuilds — budget one `cele2e build-infra`)'
|
|
409
|
+
: 'cele2e host up (restarts the VM with the recommended budget; images survive)',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Was the baked management image built from the source in this working tree?
|
|
415
|
+
*
|
|
416
|
+
* The image carries a real installed `celilo`, and that CLI is what every test
|
|
417
|
+
* exercises. A stale one produces a run that passes or fails for reasons the
|
|
418
|
+
* working tree cannot explain, with nothing in the output to say so. Comparing
|
|
419
|
+
* a fingerprint stamped at bake time against one computed now turns that into a
|
|
420
|
+
* sentence.
|
|
421
|
+
*
|
|
422
|
+
* A warning, not a failure — deliberately testing an older image is a real
|
|
423
|
+
* thing to do, and this check's job is to make sure it is deliberate.
|
|
424
|
+
*/
|
|
425
|
+
export function checkImageFreshness(probe: DoctorProbe, repoRoot: string | null): DoctorCheck {
|
|
426
|
+
const expected = computeSourceFingerprint(repoRoot ?? undefined);
|
|
427
|
+
if (!expected) {
|
|
428
|
+
return {
|
|
429
|
+
name: 'image-freshness',
|
|
430
|
+
status: 'ok',
|
|
431
|
+
detail: 'no celilo checkout to compare the baked image against',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const stamped = probe.imageLabel(MANAGEMENT_LATEST, SOURCE_LABEL);
|
|
435
|
+
if (stamped === null) {
|
|
436
|
+
return {
|
|
437
|
+
name: 'image-freshness',
|
|
438
|
+
status: 'warn',
|
|
439
|
+
detail: `${MANAGEMENT_LATEST} carries no source stamp, so it predates this check and its age cannot be established`,
|
|
440
|
+
remedy: 'cele2e build-infra',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (stamped.startsWith(PUBLISHED_FINGERPRINT_PREFIX)) {
|
|
444
|
+
return {
|
|
445
|
+
name: 'image-freshness',
|
|
446
|
+
status: 'ok',
|
|
447
|
+
detail: `${MANAGEMENT_LATEST} was baked from ${stamped} (real npm), not from this tree — nothing to be stale against`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (stamped !== expected) {
|
|
451
|
+
return {
|
|
452
|
+
name: 'image-freshness',
|
|
453
|
+
status: 'warn',
|
|
454
|
+
detail: `${MANAGEMENT_LATEST} was baked from source ${stamped}; this tree is ${expected}. The celilo CLI under test is NOT the code in this checkout`,
|
|
455
|
+
remedy: 'cele2e build-infra',
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
name: 'image-freshness',
|
|
460
|
+
status: 'ok',
|
|
461
|
+
detail: `${MANAGEMENT_LATEST} was baked from this tree (${stamped})`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
262
465
|
/**
|
|
263
466
|
* Reclaimable image space. A warning, never a failure — the point is to steer
|
|
264
467
|
* the reflex: under disk pressure people reach for `docker image prune`, which
|
|
@@ -274,9 +477,9 @@ export function checkDiskPressure(probe: DoctorProbe): DoctorCheck {
|
|
|
274
477
|
return {
|
|
275
478
|
name: 'disk',
|
|
276
479
|
status: 'warn',
|
|
277
|
-
detail: `${gib} GiB reclaimable image space —
|
|
480
|
+
detail: `${gib} GiB reclaimable image space — most of it is superseded management images, which \`cele2e build-infra\` now removes as it goes`,
|
|
278
481
|
remedy:
|
|
279
|
-
'docker
|
|
482
|
+
'docker image prune -f (UNTAGGED images only — safe. NEVER `-a` or `docker system prune`, which delete the TAGGED base images the next build needs and cost a full 27-image rebuild)',
|
|
280
483
|
};
|
|
281
484
|
}
|
|
282
485
|
|
|
@@ -347,6 +550,21 @@ export function createDockerProbe(): DoctorProbe {
|
|
|
347
550
|
return null;
|
|
348
551
|
}
|
|
349
552
|
},
|
|
553
|
+
imageLabel(ref, label) {
|
|
554
|
+
try {
|
|
555
|
+
const out = docker([
|
|
556
|
+
'image',
|
|
557
|
+
'inspect',
|
|
558
|
+
ref,
|
|
559
|
+
'--format',
|
|
560
|
+
`{{index .Config.Labels "${label}"}}`,
|
|
561
|
+
]);
|
|
562
|
+
// Go's text/template renders a missing map key as this literal.
|
|
563
|
+
return out === '' || out === '<no value>' ? null : out;
|
|
564
|
+
} catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
},
|
|
350
568
|
staleContainers() {
|
|
351
569
|
try {
|
|
352
570
|
return docker(['ps', '-a', '--filter', 'name=celilo-e2e', '--format', '{{.Names}}'])
|
|
@@ -357,6 +575,38 @@ export function createDockerProbe(): DoctorProbe {
|
|
|
357
575
|
return [];
|
|
358
576
|
}
|
|
359
577
|
},
|
|
578
|
+
leakedStacks() {
|
|
579
|
+
// Per-test compose networks only: the project carries the epoch-ms the
|
|
580
|
+
// suite started, so a digits-then-underscore name cannot match the
|
|
581
|
+
// shared (`celilo-e2e-shared_*`) or interactive (`celilo-e2e-interactive`)
|
|
582
|
+
// stacks, both of which can be legitimately up outside a run.
|
|
583
|
+
const perTest = (n: string): boolean => /^celilo-e2e-\d{13}_/.test(n);
|
|
584
|
+
try {
|
|
585
|
+
const nets = docker(['network', 'ls', '--format', '{{.Name}}'])
|
|
586
|
+
.split('\n')
|
|
587
|
+
.map((l) => l.trim())
|
|
588
|
+
.filter(perTest);
|
|
589
|
+
const stacks: LeakedStack[] = [];
|
|
590
|
+
for (const net of nets) {
|
|
591
|
+
try {
|
|
592
|
+
const containers = JSON.parse(
|
|
593
|
+
docker(['network', 'inspect', net, '--format', '{{json .Containers}}']),
|
|
594
|
+
) as Record<string, { Name?: string }>;
|
|
595
|
+
stacks.push({
|
|
596
|
+
network: net,
|
|
597
|
+
containers: Object.values(containers)
|
|
598
|
+
.map((c) => c.Name ?? '')
|
|
599
|
+
.filter(Boolean),
|
|
600
|
+
});
|
|
601
|
+
} catch {
|
|
602
|
+
stacks.push({ network: net, containers: [] });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return stacks;
|
|
606
|
+
} catch {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
},
|
|
360
610
|
reclaimableImageBytes() {
|
|
361
611
|
try {
|
|
362
612
|
for (const line of docker(['system', 'df', '--format', '{{json .}}']).split('\n')) {
|
|
@@ -384,6 +634,16 @@ export interface DiagnoseOptions {
|
|
|
384
634
|
lock?: LockStatus;
|
|
385
635
|
/** Skip the run-lock check — `run` acquires the lock itself and reports contention its own way. */
|
|
386
636
|
skipLock?: boolean;
|
|
637
|
+
/**
|
|
638
|
+
* The celilo checkout to fingerprint against. Defaults to a walk-up from
|
|
639
|
+
* `pkgDir`, so a caller cannot silently disable the freshness check by
|
|
640
|
+
* forgetting to pass it — the failure mode this repo calls "a check that
|
|
641
|
+
* cannot reach the thing it is checking".
|
|
642
|
+
*/
|
|
643
|
+
repoRoot?: string | null;
|
|
644
|
+
/** The running VM and the host it sits on. Injected so both are testable. */
|
|
645
|
+
hostVm?: HostVmFacts | null;
|
|
646
|
+
host?: HostFacts;
|
|
387
647
|
}
|
|
388
648
|
|
|
389
649
|
export function diagnose(options: DiagnoseOptions): DoctorReport {
|
|
@@ -394,6 +654,19 @@ export function diagnose(options: DiagnoseOptions): DoctorReport {
|
|
|
394
654
|
checks.push(checkManagementImage(probe));
|
|
395
655
|
checks.push(checkBaseImages(probe, readDockerfileBases(options.pkgDir)));
|
|
396
656
|
checks.push(checkStaleContainers(probe, lock));
|
|
657
|
+
checks.push(
|
|
658
|
+
checkImageFreshness(
|
|
659
|
+
probe,
|
|
660
|
+
options.repoRoot !== undefined ? options.repoRoot : findMonorepoRoot(options.pkgDir),
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
checks.push(
|
|
664
|
+
checkHostVm(
|
|
665
|
+
options.hostVm !== undefined ? options.hostVm : readHostVmFacts(),
|
|
666
|
+
options.host ?? readHostFacts(),
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
checks.push(checkLeakedStacks(probe, lock, options.skipLock === true));
|
|
397
670
|
checks.push(checkDiskPressure(probe));
|
|
398
671
|
return { checks, ok: !checks.some((c) => c.status === 'fail') };
|
|
399
672
|
}
|