@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.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +171 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +54 -4
  14. package/src/cli/build.ts +204 -88
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +184 -0
  35. package/src/live-stack.ts +145 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +83 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +201 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
@@ -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 steers away from `docker image prune`', () => {
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.detail).toContain('docker image prune');
208
- expect(check.remedy).toContain('name=celilo-e2e');
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
- 'celilo-e2e/management:latest',
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 — do NOT run \`docker image prune\`; it deletes the base images the next build needs and costs a full rebuild`,
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 ps -aq --filter name=celilo-e2e | xargs -r docker rm -f && docker volume prune -f',
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
  }