@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 +2 -2
- package/src/container-manager.ts +52 -34
- package/src/exit-cleanup.test.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.10.
|
|
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.
|
|
41
|
+
"@celilo/event-bus": "^0.1.10",
|
|
42
42
|
"yaml": "^2.8.0",
|
|
43
43
|
"zod": "^3.24.1"
|
|
44
44
|
},
|
package/src/container-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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)}\
|
|
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
|
|
package/src/exit-cleanup.test.ts
CHANGED
|
@@ -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(
|
|
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 —
|