@celilo/e2e 0.10.3 → 0.10.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@celilo/cli-display": "^0.1.9",
41
- "@celilo/event-bus": "^0.1.9",
41
+ "@celilo/event-bus": "^0.1.10",
42
42
  "yaml": "^2.8.0",
43
43
  "zod": "^3.24.1"
44
44
  },
@@ -93,14 +93,23 @@ const activeProjects: Set<string> = new Set();
93
93
  export function projectTeardownCommands(project: string): {
94
94
  listContainers: string;
95
95
  listNetworks: string;
96
+ listVolumes: string;
96
97
  } {
97
98
  return {
98
99
  listContainers: `docker ps -aq --filter name=${project}`,
99
100
  listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
101
+ // Volumes were missing here, and the compose path that would have removed
102
+ // them (`down --volumes`) is the same best-effort call that silently does
103
+ // nothing without a resolvable compose file. So every run leaked its
104
+ // volumes: 58 `celilo-e2e-<ts>_ssh-keys` volumes were found on the builder,
105
+ // the oldest months old. Nothing breaks from a leaked volume the way a
106
+ // leaked network breaks routing — it just accumulates unbounded, and the
107
+ // pile is the evidence that teardown has not been running.
108
+ listVolumes: `docker volume ls -q --filter name=${project}`,
100
109
  };
101
110
  }
102
111
 
103
- /** Force-remove a project's containers and networks by name. Never throws. */
112
+ /** Force-remove a project's containers, networks and volumes by name. Never throws. */
104
113
  function forceRemoveProject(project: string): void {
105
114
  const cmds = projectTeardownCommands(project);
106
115
  try {
@@ -123,6 +132,19 @@ function forceRemoveProject(project: string): void {
123
132
  } catch {}
124
133
  }
125
134
  } catch {}
135
+ // Volumes last: a volume still attached to a container cannot be removed, so
136
+ // this must follow the container sweep above.
137
+ try {
138
+ const vols = execSync(cmds.listVolumes, { timeout: 15_000, stdio: 'pipe' })
139
+ .toString()
140
+ .split('\n')
141
+ .filter(Boolean);
142
+ for (const vol of vols) {
143
+ try {
144
+ execSync(`docker volume rm ${vol}`, { timeout: 10_000, stdio: 'pipe' });
145
+ } catch {}
146
+ }
147
+ } catch {}
126
148
  }
127
149
 
128
150
  function cleanupOnExit() {
@@ -271,9 +293,7 @@ export async function scrubDnsZones(): Promise<void> {
271
293
  }
272
294
  if (served !== null && !served.split(/\s+/).includes(expected)) {
273
295
  throw new Error(
274
- `[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected} ` +
275
- `(website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". ` +
276
- `The DNS reset did not take effect — shared DNS would bleed across tests.`,
296
+ `[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected} (website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". The DNS reset did not take effect — shared DNS would bleed across tests.`,
277
297
  );
278
298
  }
279
299
  }
@@ -330,9 +350,7 @@ function assertCliVersion(projectName: string, composeDir: string): void {
330
350
  const result = dockerExec(projectName, composeDir, 'management', 'celilo --version', 8_000);
331
351
  if (result.exitCode !== 0) {
332
352
  throw new Error(
333
- `Could not read celilo version from the management image ` +
334
- `(harness requires >=${MIN_CLI_VERSION}). ` +
335
- `\`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
353
+ `Could not read celilo version from the management image (harness requires >=${MIN_CLI_VERSION}). \`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
336
354
  );
337
355
  }
338
356
  const problem = checkCliVersion(result.stdout);
@@ -564,11 +582,7 @@ function buildNetworkHandle(
564
582
  );
565
583
  // Spawn detached. nohup + & + disown cleanly survives the
566
584
  // dockerExec session ending.
567
- const spawnCmd =
568
- `nohup celilo events respond --values ${valuesPath} ` +
569
- '--idle-timeout 1h --max-duration 2h ' +
570
- '--emittedBy cele2e-responder ' +
571
- '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
585
+ const spawnCmd = `nohup celilo events respond --values ${valuesPath} --idle-timeout 1h --max-duration 2h --emittedBy cele2e-responder > /tmp/cele2e-responder.log 2>&1 < /dev/null & disown`;
572
586
  const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
573
587
  if (spawnResult.exitCode !== 0) {
574
588
  throw new Error(
@@ -772,9 +786,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
772
786
  if (heavy.length > 0) {
773
787
  const names = heavy.map((c) => ` - ${c}`).join('\n');
774
788
  throw new Error(
775
- `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
776
- 'Live and e2e environments are mutually exclusive.\n' +
777
- 'Stop live containers first: cele2e down --all\n',
789
+ `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\nLive and e2e environments are mutually exclusive.\nStop live containers first: cele2e down --all\n`,
778
790
  );
779
791
  }
780
792
  } catch (e) {
@@ -803,25 +815,33 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
803
815
  }
804
816
  } catch {}
805
817
 
806
- // Remove stale per-test networks
818
+ // Remove stale per-test networks AND their volumes, via the same
819
+ // by-name teardown the exit handler uses. This used to lean on
820
+ // `docker compose -p <project> down --volumes` for the volume half, which
821
+ // has no `-f` and no reliable cwd here either — compose exits "no
822
+ // configuration file provided" and the catch swallows it, so volumes were
823
+ // never removed on this path either. Reusing forceRemoveProject keeps the
824
+ // crash-recovery sweep and the exit handler from drifting apart.
807
825
  try {
808
826
  const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
809
827
  .split('\n')
810
828
  .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
811
- if (networks.length > 0) {
812
- const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
813
- for (const project of projects) {
814
- try {
815
- run(`docker compose -p ${project} down --volumes --remove-orphans`, {
816
- timeout: 30_000,
817
- });
818
- } catch {}
819
- }
820
- for (const net of networks) {
821
- try {
822
- run(`docker network rm ${net}`, { timeout: 5_000 });
823
- } catch {}
824
- }
829
+ const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
830
+ for (const project of projects) {
831
+ forceRemoveProject(project);
832
+ }
833
+ } catch {}
834
+ // Volumes can outlive every container and network of their project — that
835
+ // is exactly the 58-volume pile — so sweep by prefix too, not only for
836
+ // projects that still have a network to be discovered by.
837
+ try {
838
+ const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
839
+ .split('\n')
840
+ .filter(Boolean);
841
+ for (const vol of vols) {
842
+ try {
843
+ run(`docker volume rm ${vol}`, { timeout: 5_000 });
844
+ } catch {}
825
845
  }
826
846
  } catch {}
827
847
  } catch {}
@@ -1023,8 +1043,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1023
1043
  // The control plane needs its subnet declared if celilo-mgr lives
1024
1044
  // there OR if a machine does — infrastructure selection for a module
1025
1045
  // declaring `zone: secure-mgmt` reads it either way (#436).
1026
- config.managementZone === 'secure-mgmt' ||
1027
- (config.secureMgmtMachines ?? []).length > 0
1046
+ config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
1028
1047
  ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
1029
1048
  : ''
1030
1049
  }dns.primary=100.100.0.1 \
@@ -1084,8 +1103,7 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
1084
1103
  }
1085
1104
  } catch (err) {
1086
1105
  throw new Error(
1087
- `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
1088
- `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
1106
+ `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\nFix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
1089
1107
  );
1090
1108
  }
1091
1109
 
@@ -11,10 +11,10 @@
11
11
  * The leak's mechanism was a wiring bug, so the gate is on the wiring.
12
12
  */
13
13
 
14
+ import { expect, test } from 'bun:test';
14
15
  import { readFileSync } from 'node:fs';
15
16
  import { dirname, join } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
- import { expect, test } from 'bun:test';
18
18
  import { projectTeardownCommands } from './container-manager';
19
19
 
20
20
  const SRC = readFileSync(
@@ -34,7 +34,7 @@ test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
34
34
  // an operator asked to keep, since --keep matters most after a failure.
35
35
  const body = SRC.slice(
36
36
  SRC.indexOf('function cleanupOnExit'),
37
- SRC.indexOf('process.on(\'SIGTERM\''),
37
+ SRC.indexOf("process.on('SIGTERM'"),
38
38
  );
39
39
  expect(body).toContain('CELILO_E2E_KEEP');
40
40
  expect(body).toContain('CELILO_E2E_REUSE');
@@ -54,6 +54,20 @@ test('teardown does not depend on a compose file being findable', () => {
54
54
  }
55
55
  });
56
56
 
57
+ // Containers and networks were removed; volumes were not. The only thing that
58
+ // would have removed them was `docker compose down --volumes`, which is the
59
+ // same best-effort call that silently does nothing without a resolvable compose
60
+ // file — so in practice they were never removed at all. 58 leaked
61
+ // `celilo-e2e-<ts>_ssh-keys` volumes were found on the builder, the oldest
62
+ // months old. A leaked volume breaks nothing on its own, which is why it went
63
+ // unnoticed for months; the pile is the evidence teardown was not running.
64
+ test('teardown removes volumes, not just containers and networks', () => {
65
+ const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
66
+ expect(cmds.listVolumes).toContain('docker volume ls');
67
+ expect(cmds.listVolumes).toContain('celilo-e2e-1785546029645');
68
+ expect(cmds.listVolumes).not.toContain('docker compose');
69
+ });
70
+
57
71
  test('teardown cannot match the shared project', () => {
58
72
  // Per-test projects carry a timestamp; `celilo-e2e-shared` does not, so a
59
73
  // name filter for one can never match the other. Shared infra must survive —