@celilo/e2e 0.19.2 → 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
@@ -15,7 +15,7 @@ import { expect, test } from 'bun:test';
15
15
  import { readFileSync } from 'node:fs';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { projectTeardownCommands } from './container-manager';
18
+ import { networkContainersInspectCommand, projectTeardownCommands } from './container-manager';
19
19
 
20
20
  const SRC = readFileSync(
21
21
  join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
@@ -68,6 +68,64 @@ test('teardown removes volumes, not just containers and networks', () => {
68
68
  expect(cmds.listVolumes).not.toContain('docker compose');
69
69
  });
70
70
 
71
+ // A sim-created LXC guest is named celilo-e2e-lxc-<vmid>, which neither the
72
+ // per-test name filters (`celilo-e2e-1<ts>` in the startNetwork pre-sweep nor
73
+ // `celilo-e2e-<ts>` in forceRemoveProject) can match. A guest left running
74
+ // holds its zone network's endpoint: compose down fails that network with
75
+ // "has active endpoints", the error is swallowed as best-effort, and the
76
+ // subnet stays allocated. Every later suite in the run then dies in ~2s
77
+ // creating that network — 44 of 54 suites in one --all run (celilo#1247).
78
+ test('project teardown lists sim-created guests by label, not only by name (celilo#1247)', () => {
79
+ const cmds = projectTeardownCommands('celilo-e2e-1788449289917');
80
+ expect(cmds.listGuests).toContain('docker ps -aq --filter label=celilo-e2e.project=');
81
+ expect(cmds.listGuests).toContain('celilo-e2e-1788449289917');
82
+ expect(cmds.listGuests).not.toContain('docker compose');
83
+ });
84
+
85
+ test('forceRemoveProject unions the guest listing into its container sweep (celilo#1247)', () => {
86
+ // The name-only sweep is what let the guest survive: the network rm that
87
+ // follows ran against an endpoint that was still attached. The union must
88
+ // come BEFORE the network removal in the same function.
89
+ const body = SRC.slice(
90
+ SRC.indexOf('function forceRemoveProject'),
91
+ SRC.indexOf('function cleanupOnExit'),
92
+ );
93
+ expect(body).toContain('listGuests');
94
+ expect(body.indexOf('listGuests')).toBeLessThan(body.indexOf('listNetworks'));
95
+ });
96
+
97
+ test('handle.stop kills sim guests BEFORE compose down (celilo#1247)', () => {
98
+ // With the guest alive, compose down fails the network ("has active
99
+ // endpoints") AND the ssh-keys volume (in use), both swallowed. Removing
100
+ // the guest first is what makes the normal teardown path complete.
101
+ const stop = SRC.slice(
102
+ SRC.indexOf('async stop(): Promise<void>'),
103
+ SRC.indexOf('export async function startNetwork'),
104
+ );
105
+ expect(stop).toContain('listGuests');
106
+ expect(stop.indexOf('listGuests')).toBeLessThan(stop.indexOf('docker compose -f'));
107
+ });
108
+
109
+ test('startNetwork pre-sweep sweeps guests of ANY project by label (celilo#1247)', () => {
110
+ // The crash-recovery backstop must not depend on a network surviving for
111
+ // the project derivation to find the guest: a guest whose network is
112
+ // already gone is debris all the same. The label carries no project value
113
+ // here — only the provisioner ever sets it, and the run-lock guarantees no
114
+ // concurrent run owns any of them.
115
+ //
116
+ // The sweep lives in sweepStaleTestResources() since ce-yuzi extracted it
117
+ // out of startNetwork for testability; startNetwork calls it. Assert both
118
+ // halves: the function carries the label filter, startNetwork invokes it.
119
+ const sweep = SRC.slice(
120
+ SRC.indexOf('export function sweepStaleTestResources'),
121
+ SRC.indexOf('Remove stale per-test networks'),
122
+ );
123
+ expect(sweep).toContain('docker ps -aq --filter label=${GUEST_PROJECT_LABEL}`');
124
+ expect(SRC.slice(SRC.indexOf('export async function startNetwork'))).toContain(
125
+ 'sweepStaleTestResources()',
126
+ );
127
+ });
128
+
71
129
  test('teardown cannot match the shared project', () => {
72
130
  // Per-test projects carry a timestamp; `celilo-e2e-shared` does not, so a
73
131
  // name filter for one can never match the other. Shared infra must survive —
@@ -79,6 +137,30 @@ test('teardown cannot match the shared project', () => {
79
137
  }
80
138
  });
81
139
 
140
+ test('teardown removes a Proxmox guest blocking a network before removing the network', () => {
141
+ // ce-ywix: a guest is named celilo-e2e-lxc-<vmid> and carries NO project
142
+ // name, so the project-scoped container sweep never removes it. A live guest
143
+ // keeps `docker network rm` failing forever, and the leaked network held the
144
+ // sim's subnet — every later run then died at compose up with "Pool
145
+ // overlaps". The network loop must inspect each network and remove the e2e
146
+ // containers attached to it BEFORE the network rm.
147
+ const cmd = networkContainersInspectCommand('celilo-e2e-1788615872911_dmz');
148
+ expect(cmd).toContain('docker network inspect');
149
+ expect(cmd).toContain('celilo-e2e-1788615872911_dmz');
150
+
151
+ const teardown = SRC.slice(
152
+ SRC.indexOf('function forceRemoveProject'),
153
+ SRC.indexOf('function cleanupOnExit'),
154
+ );
155
+ expect(teardown).toContain('networkContainersInspectCommand');
156
+ expect(teardown).toContain('CONTAINER_PREFIX');
157
+ const inspectAt = teardown.indexOf('networkContainersInspectCommand');
158
+ const rmAt = teardown.indexOf('`docker network rm ${net}`');
159
+ expect(inspectAt).toBeGreaterThan(-1);
160
+ expect(rmAt).toBeGreaterThan(-1);
161
+ expect(inspectAt).toBeLessThan(rmAt);
162
+ });
163
+
82
164
  test("the premise holds: process.exit() fires 'exit' but NOT 'beforeExit'", async () => {
83
165
  // The whole fix rests on this runtime behavior. If it ever changes, the
84
166
  // reasoning above is void and this should fail loudly rather than silently
@@ -0,0 +1,45 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { fleetNameserverProblems } from './container-manager';
3
+
4
+ // Recurrence gate for celilo#1290: a nameserver the sim cannot route
5
+ // blackholes the lookups ansible's Gathering Facts performs, and the failure
6
+ // used to surface as an unrelated 600s command timeout. This gate fails at
7
+ // init instead, naming the address.
8
+
9
+ test('accepts the sim-hosted primary with no fallback set', () => {
10
+ const out = ['dns.primary = 203.0.113.1', 'System config key not found: dns.fallback'].join('\n');
11
+ expect(fleetNameserverProblems(out)).toEqual([]);
12
+ });
13
+
14
+ test('accepts nameservers in every simulated subnet', () => {
15
+ const out = ['dns.primary = 203.0.113.1', 'dns.fallback = 100.64.0.64,10.226.10.10'].join('\n');
16
+ expect(fleetNameserverProblems(out)).toEqual([]);
17
+ });
18
+
19
+ test('rejects the unroutable public resolvers celilo#1290 shipped', () => {
20
+ const out = ['dns.primary = 203.0.113.1', 'dns.fallback = 1.0.0.1,8.8.8.8'].join('\n');
21
+ const problems = fleetNameserverProblems(out);
22
+ expect(problems).toHaveLength(2);
23
+ expect(problems[0]).toContain('1.0.0.1');
24
+ expect(problems[0]).toContain('no simulated subnet');
25
+ expect(problems[1]).toContain('8.8.8.8');
26
+ });
27
+
28
+ test('rejects core discovery last-resort 1.1.1.1', () => {
29
+ const out = ['dns.primary = 203.0.113.1', 'dns.fallback = 1.1.1.1'].join('\n');
30
+ expect(fleetNameserverProblems(out)).toHaveLength(1);
31
+ });
32
+
33
+ test('flags a missing primary — the birth list would be empty', () => {
34
+ const out = 'No system configuration set';
35
+ const problems = fleetNameserverProblems(out);
36
+ expect(problems).toHaveLength(1);
37
+ expect(problems[0]).toContain('dns.primary is not set');
38
+ });
39
+
40
+ test('flags a non-IP entry instead of silently passing it', () => {
41
+ const out = 'dns.primary = not-an-ip';
42
+ const problems = fleetNameserverProblems(out);
43
+ expect(problems).toHaveLength(1);
44
+ expect(problems[0]).toContain('not an IPv4 address');
45
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The host-VM policy, with each rule broken deliberately.
3
+ *
4
+ * Per Rule 7.6 a gate nobody has seen fail is not a gate, so every assertion
5
+ * here is written against the out-of-policy state. The happy cases exist only
6
+ * to prove the rules do not fire on a VM that is fine — a check that warns
7
+ * about everything teaches people to ignore it, which is the same outcome as
8
+ * having no check.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import {
13
+ type HostFacts,
14
+ type HostVmFacts,
15
+ evaluateHostVm,
16
+ parseLimaConfig,
17
+ parseLimaMemory,
18
+ readHostVmFacts,
19
+ recommendedBudget,
20
+ } from './host-vm';
21
+
22
+ const M1_PRO: HostFacts = { cpus: 10, memoryGiB: 32 };
23
+
24
+ function vm(overrides: Partial<HostVmFacts> = {}): HostVmFacts {
25
+ return {
26
+ profile: 'default',
27
+ cpus: 8,
28
+ memoryGiB: 12,
29
+ mountType: 'virtiofs',
30
+ vmType: 'vz',
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe('recommendedBudget', () => {
36
+ test('leaves the host two cores and about a third of its memory', () => {
37
+ expect(recommendedBudget(M1_PRO, 'vz')).toEqual({
38
+ cpus: 8,
39
+ memoryGiB: 12,
40
+ mountType: 'virtiofs',
41
+ });
42
+ });
43
+
44
+ test('scales to a small laptop rather than encoding one machine', () => {
45
+ expect(recommendedBudget({ cpus: 4, memoryGiB: 16 }, 'vz')).toEqual({
46
+ cpus: 2,
47
+ memoryGiB: 6,
48
+ mountType: 'virtiofs',
49
+ });
50
+ });
51
+
52
+ test('caps memory on a large host — the guest has no use for 48 GiB of page cache', () => {
53
+ expect(recommendedBudget({ cpus: 32, memoryGiB: 128 }, 'vz').memoryGiB).toBe(16);
54
+ });
55
+
56
+ test('never proposes fewer than two CPUs', () => {
57
+ expect(recommendedBudget({ cpus: 2, memoryGiB: 8 }, 'vz').cpus).toBe(2);
58
+ });
59
+
60
+ test('qemu cannot have virtiofs, so it is not asked for it', () => {
61
+ expect(recommendedBudget(M1_PRO, 'qemu').mountType).toBe('sshfs');
62
+ });
63
+ });
64
+
65
+ describe('parseLimaMemory', () => {
66
+ test('reads the form lima actually writes', () => {
67
+ expect(parseLimaMemory('12288MiB')).toBe(12);
68
+ });
69
+
70
+ test('reads GiB and raw byte counts too', () => {
71
+ expect(parseLimaMemory('24GiB')).toBe(24);
72
+ expect(parseLimaMemory(12 * 1024 ** 3)).toBe(12);
73
+ });
74
+
75
+ test('an unreadable value is 0, not a guess', () => {
76
+ expect(parseLimaMemory('lots')).toBe(0);
77
+ expect(parseLimaMemory(undefined)).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe('evaluateHostVm', () => {
82
+ const budget = recommendedBudget(M1_PRO, 'vz');
83
+
84
+ test('a VM inside the policy has nothing to say', () => {
85
+ expect(evaluateHostVm(vm(), M1_PRO, budget)).toEqual({ problems: [], needsRecreate: false });
86
+ });
87
+
88
+ test('24 of 32 GiB is flagged — the measured state that had the host swapping 21.9 GiB', () => {
89
+ const verdict = evaluateHostVm(vm({ memoryGiB: 24 }), M1_PRO, budget);
90
+ expect(verdict.problems).toHaveLength(1);
91
+ expect(verdict.problems[0]).toContain('24 GiB');
92
+ // Memory takes on a restart, so this must not demand the destructive path.
93
+ expect(verdict.needsRecreate).toBe(false);
94
+ });
95
+
96
+ test('exactly half the host is allowed — the line is above it, not at it', () => {
97
+ expect(evaluateHostVm(vm({ memoryGiB: 16 }), M1_PRO, budget).problems).toEqual([]);
98
+ });
99
+
100
+ test('sshfs is flagged AND demands a recreate, because colima discards the change', () => {
101
+ const verdict = evaluateHostVm(vm({ mountType: 'sshfs' }), M1_PRO, budget);
102
+ expect(verdict.problems[0]).toContain('sshfs');
103
+ expect(verdict.needsRecreate).toBe(true);
104
+ });
105
+
106
+ test('an empty mount type reads as unknown rather than as a missing sentence', () => {
107
+ expect(evaluateHostVm(vm({ mountType: '' }), M1_PRO, budget).problems[0]).toContain(
108
+ 'an unknown transport',
109
+ );
110
+ });
111
+
112
+ test('overcommitted CPUs are flagged', () => {
113
+ expect(evaluateHostVm(vm({ cpus: 16 }), M1_PRO, budget).problems[0]).toContain('16 CPUs');
114
+ });
115
+
116
+ test('several problems are all reported, not just the first', () => {
117
+ const verdict = evaluateHostVm(vm({ memoryGiB: 24, mountType: 'sshfs', cpus: 16 }), M1_PRO, {
118
+ ...budget,
119
+ });
120
+ expect(verdict.problems).toHaveLength(3);
121
+ });
122
+ });
123
+
124
+ describe('parseLimaConfig', () => {
125
+ test("normalizes lima's reverse-sshfs to the name colima's own flag uses", () => {
126
+ // Reading lima's config rather than colima's saved profile is load-bearing:
127
+ // colima accepts `--mount-type` on an existing VM, warns that it discarded
128
+ // it, and rewrites its own profile — so the profile records the request and
129
+ // lima records what happened. The two disagreed while this was written.
130
+ expect(
131
+ parseLimaConfig('vmType: vz\ncpus: 8\nmemory: 12288MiB\nmountType: reverse-sshfs\n', 'probe'),
132
+ ).toEqual({
133
+ profile: 'probe',
134
+ cpus: 8,
135
+ memoryGiB: 12,
136
+ mountType: 'sshfs',
137
+ vmType: 'vz',
138
+ });
139
+ });
140
+
141
+ test('missing fields read as absent rather than as a plausible default', () => {
142
+ expect(parseLimaConfig('vmType: vz\n', 'probe')).toEqual({
143
+ profile: 'probe',
144
+ cpus: 0,
145
+ memoryGiB: 0,
146
+ mountType: '',
147
+ vmType: 'vz',
148
+ });
149
+ });
150
+ });
151
+
152
+ describe('readHostVmFacts', () => {
153
+ test('no lima instance means no VM to shape, not an error', () => {
154
+ expect(readHostVmFacts('a-profile-that-does-not-exist')).toBeNull();
155
+ });
156
+ });
package/src/host-vm.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The rig's contract with the Docker host it runs on.
3
+ *
4
+ * On Linux, docker runs on the host kernel and there is no contract to state.
5
+ * On macOS it runs inside a virtual machine, and two of that VM's settings
6
+ * dominate how long a run takes. Both were measured on 2026-09-05, on an M1 Pro
7
+ * with 32 GiB, while a suite was running:
8
+ *
9
+ * **Memory.** The VM held 24 GiB of the host's 32. It used 1.9 GiB and filled
10
+ * the rest with page cache, which is what a Linux guest is supposed to do. But
11
+ * the guest's idea of "cached in RAM" is the host's idea of "anonymous memory I
12
+ * may compress or swap", so the host sat at 21.9 GiB of swap used with
13
+ * `kernel_task` (the memory compressor) at 60% of a core, and every image-layer
14
+ * read the guest thought was free became a host decompress or an SSD read. The
15
+ * guest cannot give the memory back — there is no balloon driver — so the only
16
+ * lever is not to hand it over in the first place.
17
+ *
18
+ * **Mount type.** The celilo checkout is bind-mounted into the management
19
+ * container. Under colima's default `sshfs` every file operation crosses FUSE,
20
+ * an SSH channel, a userspace TCP stack on the host, an ssh multiplexer and
21
+ * `sftp-server`. `virtiofs` is a shared-memory transport with none of that
22
+ * chain.
23
+ *
24
+ * The two costs had to be separated, because measuring them together
25
+ * attributed nearly all of it to the wrong one. Same probe, same image, three
26
+ * VM configurations:
27
+ *
28
+ * | VM | CLI on image disk | CLI over mount |
29
+ * |-------------------------------------|-------------------|----------------|
30
+ * | 24 GiB sshfs (host swapping 21.9 G) | 0.27s | 2.50s |
31
+ * | 12 GiB sshfs | 0.16s | 0.56s |
32
+ * | 12 GiB virtiofs | 0.14s | 0.31s |
33
+ *
34
+ * So the 2.3-second gap in the first row was about 1.9s of host swap and 0.4s
35
+ * of transport. Memory is the big lever; the mount is real but secondary, and
36
+ * shows up most on bulk reads — reading 200 source files took 0.24s on sshfs
37
+ * and 0.073s on virtiofs, a factor of 3.3.
38
+ *
39
+ * Nothing here can fix either one — colima refuses to change a VM's mount type
40
+ * after creation, and shrinking memory needs a restart. So this module's job is
41
+ * to READ what the VM is actually running and say plainly when it disagrees
42
+ * with the policy, which is what `cele2e doctor` and `cele2e host` do with it.
43
+ *
44
+ * It reads lima's own instance config rather than colima's saved profile,
45
+ * because those two can disagree and only one of them is the running VM. They
46
+ * disagreed while this was being written: colima accepts `--mount-type` on the
47
+ * command line, prints `'volume mount type' cannot be updated after initial
48
+ * setup, discarded`, and rewrites its saved profile back — so the profile is a
49
+ * record of what was asked for, and lima's is a record of what happened.
50
+ */
51
+
52
+ import { existsSync, readFileSync } from 'node:fs';
53
+ import { cpus, homedir, totalmem } from 'node:os';
54
+ import { join } from 'node:path';
55
+ import { parse as parseYaml } from 'yaml';
56
+
57
+ /** What the VM is actually running, read from lima's instance config. */
58
+ export interface HostVmFacts {
59
+ /** colima profile name, e.g. `default`. */
60
+ profile: string;
61
+ cpus: number;
62
+ memoryGiB: number;
63
+ /** Normalized: lima's `reverse-sshfs` is colima's `sshfs`. */
64
+ mountType: string;
65
+ /** `vz` (Apple Virtualization) or `qemu`. virtiofs requires `vz`. */
66
+ vmType: string;
67
+ }
68
+
69
+ /** The host the VM is carved out of. */
70
+ export interface HostFacts {
71
+ cpus: number;
72
+ memoryGiB: number;
73
+ }
74
+
75
+ /** What the VM should be given. */
76
+ export interface HostVmBudget {
77
+ cpus: number;
78
+ memoryGiB: number;
79
+ mountType: 'virtiofs' | 'sshfs';
80
+ }
81
+
82
+ /**
83
+ * The share of host RAM above which the host starts paying for the guest's
84
+ * page cache in swap. Not a tuning knob so much as a line: at 24 of 32 GiB
85
+ * (75%) the host swapped 21.9 GiB; the policy below lands at 37.5%, which
86
+ * leaves the guest more than it uses and the host its own working set.
87
+ */
88
+ export const MAX_HOST_MEMORY_SHARE = 0.5;
89
+ const TARGET_HOST_MEMORY_SHARE = 0.375;
90
+
91
+ /** Floor and ceiling on the VM's memory, in GiB. */
92
+ const MIN_VM_MEMORY_GIB = 4;
93
+ const MAX_VM_MEMORY_GIB = 16;
94
+
95
+ /** Cores left to the host, so the Mac stays usable while a suite runs. */
96
+ const HOST_RESERVED_CPUS = 2;
97
+
98
+ /**
99
+ * The VM settings this rig wants on a given host.
100
+ *
101
+ * Pure, and expressed as a fraction rather than a constant, so it says
102
+ * something true on a 16 GiB laptop and a 64 GiB desktop rather than encoding
103
+ * one machine's answer.
104
+ */
105
+ export function recommendedBudget(host: HostFacts, vmType: string): HostVmBudget {
106
+ const memoryGiB = Math.min(
107
+ MAX_VM_MEMORY_GIB,
108
+ Math.max(MIN_VM_MEMORY_GIB, Math.floor(host.memoryGiB * TARGET_HOST_MEMORY_SHARE)),
109
+ );
110
+ return {
111
+ cpus: Math.max(2, host.cpus - HOST_RESERVED_CPUS),
112
+ memoryGiB,
113
+ // virtiofs is an Apple Virtualization feature; a qemu VM cannot have it.
114
+ mountType: vmType === 'vz' ? 'virtiofs' : 'sshfs',
115
+ };
116
+ }
117
+
118
+ export function readHostFacts(): HostFacts {
119
+ return { cpus: cpus().length, memoryGiB: Math.round(totalmem() / 1024 ** 3) };
120
+ }
121
+
122
+ /** colima's lima instance directory: `colima` for the default profile, `colima-<p>` otherwise. */
123
+ export function limaInstanceDir(profile: string): string {
124
+ const instance = profile === 'default' ? 'colima' : `colima-${profile}`;
125
+ return join(homedir(), '.colima', '_lima', instance);
126
+ }
127
+
128
+ export function activeProfile(): string {
129
+ return process.env.COLIMA_PROFILE || 'default';
130
+ }
131
+
132
+ /** `12288MiB` / `12GiB` / `12884901888` → GiB. */
133
+ export function parseLimaMemory(raw: unknown): number {
134
+ if (typeof raw === 'number') return Math.round(raw / 1024 ** 3);
135
+ if (typeof raw !== 'string') return 0;
136
+ const match = raw.trim().match(/^([\d.]+)\s*([KMGT]i?B)?$/i);
137
+ if (!match) return 0;
138
+ const value = Number.parseFloat(match[1]);
139
+ const unit = (match[2] ?? 'B').toUpperCase();
140
+ const scale: Record<string, number> = {
141
+ B: 1,
142
+ KB: 1024,
143
+ KIB: 1024,
144
+ MB: 1024 ** 2,
145
+ MIB: 1024 ** 2,
146
+ GB: 1024 ** 3,
147
+ GIB: 1024 ** 3,
148
+ TB: 1024 ** 4,
149
+ TIB: 1024 ** 4,
150
+ };
151
+ return Math.round((value * (scale[unit] ?? 1)) / 1024 ** 3);
152
+ }
153
+
154
+ /** Parse a lima instance config. Pure, so the normalization is testable without a VM. */
155
+ export function parseLimaConfig(yamlText: string, profile: string): HostVmFacts | null {
156
+ try {
157
+ const parsed = parseYaml(yamlText) as {
158
+ cpus?: number;
159
+ memory?: string | number;
160
+ mountType?: string;
161
+ vmType?: string;
162
+ };
163
+ if (!parsed || typeof parsed !== 'object') return null;
164
+ return {
165
+ profile,
166
+ cpus: parsed.cpus ?? 0,
167
+ memoryGiB: parseLimaMemory(parsed.memory),
168
+ // lima calls it reverse-sshfs; colima's flag and its docs call it sshfs.
169
+ mountType: (parsed.mountType ?? '').replace(/^reverse-/, ''),
170
+ vmType: parsed.vmType ?? '',
171
+ };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * What the VM is running, or null when this host has no colima VM — which is
179
+ * the normal case on Linux and in CI, and means there is no policy to enforce.
180
+ */
181
+ export function readHostVmFacts(profile = activeProfile()): HostVmFacts | null {
182
+ const limaYaml = join(limaInstanceDir(profile), 'lima.yaml');
183
+ if (!existsSync(limaYaml)) return null;
184
+ try {
185
+ return parseLimaConfig(readFileSync(limaYaml, 'utf-8'), profile);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export interface HostVmVerdict {
192
+ /** Every way the VM disagrees with the budget, in plain sentences. */
193
+ problems: string[];
194
+ /** True when a fix requires destroying and recreating the VM. */
195
+ needsRecreate: boolean;
196
+ }
197
+
198
+ /**
199
+ * Compare the running VM against the budget.
200
+ *
201
+ * Pure, so each rule is testable with the condition deliberately broken —
202
+ * a check nobody has seen fail is not a check (Rule 7.6).
203
+ */
204
+ export function evaluateHostVm(
205
+ facts: HostVmFacts,
206
+ host: HostFacts,
207
+ budget: HostVmBudget,
208
+ ): HostVmVerdict {
209
+ const problems: string[] = [];
210
+ let needsRecreate = false;
211
+
212
+ if (facts.memoryGiB > host.memoryGiB * MAX_HOST_MEMORY_SHARE) {
213
+ problems.push(
214
+ `VM memory is ${facts.memoryGiB} GiB of the host's ${host.memoryGiB} GiB — the guest fills the surplus with page cache the host then compresses or swaps, so every image read becomes host I/O. Recommended: ${budget.memoryGiB} GiB.`,
215
+ );
216
+ }
217
+ if (facts.mountType !== budget.mountType) {
218
+ problems.push(
219
+ `VM mounts the host filesystem over ${facts.mountType || 'an unknown transport'}; ${budget.mountType} is a shared-memory transport with no SSH hop. Measured on the mounted checkout: reading 200 source files took 0.24s over sshfs and 0.073s over virtiofs.`,
220
+ );
221
+ // colima discards a mount-type change on an existing VM.
222
+ needsRecreate = true;
223
+ }
224
+ if (facts.cpus > host.cpus) {
225
+ problems.push(
226
+ `VM is configured with ${facts.cpus} CPUs but the host has ${host.cpus}. Recommended: ${budget.cpus}.`,
227
+ );
228
+ }
229
+ return { problems, needsRecreate };
230
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,17 @@ export { reconnectNetwork } from './container-manager';
30
30
  // Progress reporting (emit signals the test runner displays)
31
31
  export { progress } from './progress';
32
32
 
33
+ // Staged-suite mechanism: cascade-skip later stages after a failure OR a
34
+ // timeout (celilo#1272)
35
+ export { createStageGuard, createStages } from './stages';
36
+ export type { StageGuard, Stages } from './stages';
37
+
38
+ // Block-timing: static cap extraction shared with the e2e-timeout-cap gate
39
+ // (scripts/e2e-timeout-cap.test.ts), so the manifest and the runner read
40
+ // budgets through one parser
41
+ export { declaredBlockCaps, declaredCapEntries, tightBlocks } from './block-timing';
42
+ export type { BlockTiming, DeclaredCapEntry, TightBlock } from './block-timing';
43
+
33
44
  // Types
34
45
  export type {
35
46
  NetworkConfig,