@celilo/e2e 0.1.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 (71) hide show
  1. package/README.md +360 -0
  2. package/bin/cache-docker-images +46 -0
  3. package/bin/e2e-build +66 -0
  4. package/bin/e2e-down +67 -0
  5. package/bin/e2e-infra +92 -0
  6. package/bin/e2e-load +40 -0
  7. package/bin/e2e-run +354 -0
  8. package/bin/e2e-shell +27 -0
  9. package/bin/e2e-status +29 -0
  10. package/bin/e2e-up +160 -0
  11. package/config/dhcp/dnsmasq.conf +27 -0
  12. package/config/dns/com.zone +11 -0
  13. package/config/dns/iamtheinternet.org.zone +15 -0
  14. package/config/dns/knot-namecheap.conf +11 -0
  15. package/config/dns/knot-root.conf +7 -0
  16. package/config/dns/knot-tld.conf +11 -0
  17. package/config/dns/org.zone +14 -0
  18. package/config/dns/park-your-domain.com.zone +10 -0
  19. package/config/dns/root.zone +14 -0
  20. package/config/pebble/pebble-ca.crt +20 -0
  21. package/config/pebble/pebble-config.json +11 -0
  22. package/config/pebble/pebble-tls.crt +21 -0
  23. package/config/pebble/pebble-tls.key +28 -0
  24. package/config/proxy/generate-ca.sh +20 -0
  25. package/config/proxy/squid-ca.crt +22 -0
  26. package/config/proxy/squid-ca.pem +50 -0
  27. package/config/proxy/squid.conf +38 -0
  28. package/config/proxy/startup.sh +11 -0
  29. package/config/resolver/root.hints +2 -0
  30. package/config/resolver/unbound.conf +28 -0
  31. package/config/routing/apt-cache-routes.sh +21 -0
  32. package/config/routing/dhcp-client-setup.sh +17 -0
  33. package/config/routing/dns-routes.sh +12 -0
  34. package/config/routing/fw-ext-routes.sh +50 -0
  35. package/config/routing/fw-isp-routes.sh +37 -0
  36. package/config/routing/fw-main-routes.sh +31 -0
  37. package/config/routing/management-routes.sh +96 -0
  38. package/config/routing/namecheap-startup.sh +17 -0
  39. package/config/routing/pebble-startup.sh +9 -0
  40. package/config/routing/resolver-routes.sh +15 -0
  41. package/config/routing/target-routes.sh +23 -0
  42. package/config/routing/target-setup.service +13 -0
  43. package/config/routing/target-setup.sh +36 -0
  44. package/config/ssh/generate-keys.sh +17 -0
  45. package/docker/Dockerfile.apt-cache +16 -0
  46. package/docker/Dockerfile.authoritative-dns +15 -0
  47. package/docker/Dockerfile.dhcp-client +16 -0
  48. package/docker/Dockerfile.firewall +28 -0
  49. package/docker/Dockerfile.forward-proxy +28 -0
  50. package/docker/Dockerfile.greenwave-sim +29 -0
  51. package/docker/Dockerfile.management +30 -0
  52. package/docker/Dockerfile.namecheap-dns +25 -0
  53. package/docker/Dockerfile.pebble +18 -0
  54. package/docker/Dockerfile.resolver +20 -0
  55. package/docker/Dockerfile.router +33 -0
  56. package/docker/Dockerfile.target-machine +58 -0
  57. package/docker/Dockerfile.target-machine-docker +105 -0
  58. package/package.json +45 -0
  59. package/simulators/greenwave/server.ts +164 -0
  60. package/simulators/greenwave/state.ts +206 -0
  61. package/simulators/greenwave/types.ts +31 -0
  62. package/simulators/namecheap-ddns/server.ts +97 -0
  63. package/simulators/namecheap-ddns/types.ts +12 -0
  64. package/simulators/namecheap-ddns/zone-updater.ts +49 -0
  65. package/src/container-manager.ts +324 -0
  66. package/src/docker-compose-generator.ts +499 -0
  67. package/src/fixtures.ts +40 -0
  68. package/src/index.ts +59 -0
  69. package/src/network-builder.ts +91 -0
  70. package/src/shared-infra.ts +163 -0
  71. package/src/types.ts +74 -0
@@ -0,0 +1,324 @@
1
+ import { execSync, spawn, type ExecSyncOptions } from 'node:child_process';
2
+ import { existsSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import type { ExecResult, NetworkConfig, NetworkHandle } from './types';
5
+ import { generateTestComposeYaml } from './docker-compose-generator';
6
+ import { ensureSharedInfra } from './shared-infra';
7
+
8
+ /** Package root — where docker/, config/, simulators/ live */
9
+ const PACKAGE_ROOT = join(__dirname, '..');
10
+
11
+ /**
12
+ * Find the Celilo project root by walking up from the package directory.
13
+ * Looks for apps/celilo/ as the marker.
14
+ */
15
+ function findCeliloRoot(): string {
16
+ let dir = PACKAGE_ROOT;
17
+ for (let i = 0; i < 10; i++) {
18
+ if (existsSync(join(dir, 'apps', 'celilo'))) {
19
+ return dir;
20
+ }
21
+ const parent = resolve(dir, '..');
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+ throw new Error(
26
+ 'Could not find Celilo project root (directory containing apps/celilo/).\n' +
27
+ 'Set it explicitly: network().celilo("/path/to/infra").start()',
28
+ );
29
+ }
30
+
31
+ const COMPOSE_TIMEOUT = 120_000;
32
+
33
+ // Track active project names so process-level cleanup can tear them down.
34
+ const activeProjects: Set<string> = new Set();
35
+
36
+ /**
37
+ * Process-level cleanup: tear down any active e2e resources when the
38
+ * process exits — whether from a clean exit, SIGTERM, SIGINT, or an
39
+ * unhandled error. This catches the case where a test times out and
40
+ * afterAll doesn't run.
41
+ *
42
+ * NOTE: only tears down per-test projects. Shared infra is managed
43
+ * separately (by the test runner or explicit stopSharedInfra call).
44
+ */
45
+ function cleanupOnExit() {
46
+ for (const project of activeProjects) {
47
+ try {
48
+ execSync(
49
+ `docker compose -p ${project} down --volumes --remove-orphans`,
50
+ { timeout: 30_000, stdio: 'pipe' },
51
+ );
52
+ } catch {}
53
+ }
54
+ try { execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' }); } catch {}
55
+ }
56
+
57
+ process.on('SIGTERM', () => { cleanupOnExit(); process.exit(1); });
58
+ process.on('SIGINT', () => { cleanupOnExit(); process.exit(1); });
59
+ process.on('beforeExit', () => { cleanupOnExit(); });
60
+
61
+ function run(cmd: string, opts?: ExecSyncOptions): string {
62
+ return execSync(cmd, {
63
+ timeout: COMPOSE_TIMEOUT,
64
+ encoding: 'utf-8',
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ ...opts,
67
+ }).trim();
68
+ }
69
+
70
+ const COMPOSE_FILE = 'docker-compose.test.yml';
71
+
72
+ function dockerExec(projectName: string, composeDir: string, container: string, cmd: string, timeoutMs = 60_000): ExecResult {
73
+ try {
74
+ const stdout = run(
75
+ `docker compose -f ${COMPOSE_FILE} -p ${projectName} exec -T ${container} bash -c ${JSON.stringify(cmd)}`,
76
+ { cwd: composeDir, timeout: timeoutMs },
77
+ );
78
+ return { stdout, stderr: '', exitCode: 0 };
79
+ } catch (err: unknown) {
80
+ const e = err as { stdout?: string; stderr?: string; status?: number };
81
+ return {
82
+ stdout: e.stdout?.toString() ?? '',
83
+ stderr: e.stderr?.toString() ?? '',
84
+ exitCode: e.status ?? 1,
85
+ };
86
+ }
87
+ }
88
+
89
+ function dockerExecAsync(
90
+ projectName: string,
91
+ composeDir: string,
92
+ container: string,
93
+ cmd: string,
94
+ timeoutMs = 60_000,
95
+ ): Promise<ExecResult> {
96
+ return new Promise((resolve) => {
97
+ const args = [
98
+ 'compose', '-f', COMPOSE_FILE, '-p', projectName,
99
+ 'exec', '-T', container, 'bash', '-c', cmd,
100
+ ];
101
+ const child = spawn('docker', args, { cwd: composeDir });
102
+
103
+ let stdout = '';
104
+ let stderr = '';
105
+
106
+ child.stdout.on('data', (data: Buffer) => {
107
+ const text = data.toString();
108
+ stdout += text;
109
+ process.stdout.write(text);
110
+ });
111
+
112
+ child.stderr.on('data', (data: Buffer) => {
113
+ const text = data.toString();
114
+ stderr += text;
115
+ process.stderr.write(text);
116
+ });
117
+
118
+ const timer = setTimeout(() => {
119
+ child.kill('SIGTERM');
120
+ resolve({ stdout, stderr: `${stderr}\ntimeout after ${timeoutMs}ms`, exitCode: 124 });
121
+ }, timeoutMs);
122
+
123
+ child.on('close', (code) => {
124
+ clearTimeout(timer);
125
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
126
+ });
127
+ });
128
+ }
129
+
130
+ async function waitFor(
131
+ check: () => Promise<boolean>,
132
+ timeoutMs: number,
133
+ label: string,
134
+ ): Promise<void> {
135
+ const start = Date.now();
136
+ while (Date.now() - start < timeoutMs) {
137
+ try {
138
+ if (await check()) return;
139
+ } catch {
140
+ // retry
141
+ }
142
+ await new Promise((r) => setTimeout(r, 2000));
143
+ }
144
+ throw new Error(`Timeout waiting for ${label} after ${timeoutMs}ms`);
145
+ }
146
+
147
+ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle> {
148
+ // Pre-flight: fail if live containers are running that will compete for resources.
149
+ try {
150
+ const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
151
+ const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
152
+ const heavy = running.filter(name => heavyPatterns.some(p => name.startsWith(p)));
153
+ if (heavy.length > 0) {
154
+ const names = heavy.map(c => ` - ${c}`).join('\n');
155
+ throw new Error(
156
+ `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
157
+ 'Live and e2e environments are mutually exclusive.\n' +
158
+ 'Stop live containers first: ./bin/e2e-down --all\n',
159
+ );
160
+ }
161
+ } catch (e) {
162
+ if (e instanceof Error && e.message.includes('Cannot start e2e tests')) throw e;
163
+ }
164
+
165
+ // Ensure shared infrastructure (DNS, Pebble, apt-cache) is running.
166
+ // This is idempotent — if already up, it just verifies health.
167
+ await ensureSharedInfra();
168
+
169
+ // Clean up stale per-test containers (self-healing from prior crashes)
170
+ console.log('[e2e] Cleaning up stale test resources...');
171
+ try {
172
+ // Force-kill per-test containers (not shared infra)
173
+ try {
174
+ const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
175
+ if (containers.trim()) {
176
+ run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
177
+ }
178
+ } catch {}
179
+
180
+ // Remove stale per-test networks
181
+ try {
182
+ const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
183
+ .split('\n')
184
+ .filter(n => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
185
+ if (networks.length > 0) {
186
+ const projects = [...new Set(networks.map(n => n.replace(/_[^_]+$/, '')))];
187
+ for (const project of projects) {
188
+ try { run(`docker compose -p ${project} down --volumes --remove-orphans`, { timeout: 30_000 }); } catch {}
189
+ }
190
+ for (const net of networks) {
191
+ try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
192
+ }
193
+ }
194
+ } catch {}
195
+ } catch {}
196
+
197
+ const projectName = `celilo-e2e-${Date.now()}`;
198
+ activeProjects.add(projectName);
199
+
200
+ const composeDir = PACKAGE_ROOT;
201
+ const celiloRoot = config.celiloRoot ?? findCeliloRoot();
202
+
203
+ // Generate per-test compose (references shared infra networks as external)
204
+ const yaml = generateTestComposeYaml(config, celiloRoot);
205
+ writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
206
+
207
+ // Build and start per-test containers only
208
+ console.log('[e2e] Building images...');
209
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} build`, { cwd: composeDir, timeout: 300_000 });
210
+
211
+ console.log('[e2e] Starting containers...');
212
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, { cwd: composeDir, timeout: 120_000 });
213
+
214
+ // DNS convergence is handled by shared infra — skip it here.
215
+ // Just verify routing from per-test containers to shared infra.
216
+
217
+ // Optional routing verification
218
+ if (config.verifyRouting) {
219
+ console.log('[e2e] Verifying routing...');
220
+ await waitFor(
221
+ async () => {
222
+ const result = dockerExec(projectName, composeDir, 'management',
223
+ 'ping -c1 -W2 100.64.0.53');
224
+ return result.exitCode === 0;
225
+ },
226
+ 30_000,
227
+ 'routing verification',
228
+ );
229
+ }
230
+
231
+ // Wait for all target machines to complete setup (systemd boot + routing)
232
+ const allMachines = [...config.dmzMachines, ...config.appMachines, ...config.secureMachines, ...config.internalMachines];
233
+ if (allMachines.length > 0) {
234
+ console.log('[e2e] Waiting for target machines...');
235
+ }
236
+ for (const machine of allMachines) {
237
+ await waitFor(
238
+ async () => {
239
+ const result = dockerExec(projectName, composeDir, machine.name,
240
+ 'systemctl is-active target-setup 2>/dev/null');
241
+ return result.stdout.trim() === 'active';
242
+ },
243
+ 30_000,
244
+ `${machine.name} target-setup`,
245
+ );
246
+ }
247
+
248
+ // Wait for DHCP client lease
249
+ if (config.dhcpClient) {
250
+ console.log('[e2e] Waiting for DHCP client lease...');
251
+ await waitFor(
252
+ async () => {
253
+ const result = dockerExec(projectName, composeDir, 'dhcp-client',
254
+ 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases');
255
+ return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
256
+ },
257
+ 60_000,
258
+ 'DHCP client lease',
259
+ );
260
+ }
261
+
262
+ // Wait for routing to Pebble (through shared infra networks)
263
+ if (allMachines.length > 0) {
264
+ await waitFor(
265
+ async () => {
266
+ const result = dockerExec(projectName, composeDir, 'management',
267
+ 'ping -c1 -W2 100.64.0.100');
268
+ return result.exitCode === 0;
269
+ },
270
+ 30_000,
271
+ 'routing to Pebble',
272
+ );
273
+ }
274
+
275
+ // Initialize celilo
276
+ console.log('[e2e] Initializing celilo...');
277
+ const initResult = dockerExec(projectName, composeDir, 'management',
278
+ `cd /celilo/apps/celilo && bun run src/cli/index.ts system init --accept-defaults \
279
+ network.dmz.subnet=10.0.10.0/24 \
280
+ network.app.subnet=10.0.20.0/24 \
281
+ network.secure.subnet=10.0.30.0/24 \
282
+ network.internal.subnet=192.168.0.0/24 \
283
+ dns.primary=100.100.0.1 \
284
+ dns.fallback=1.0.0.1,8.8.8.8`);
285
+ if (initResult.exitCode !== 0) {
286
+ throw new Error(`Celilo init failed: ${initResult.stderr}`);
287
+ }
288
+
289
+ console.log(`[e2e] Network ready (project: ${projectName})`);
290
+
291
+ const handle: NetworkHandle = {
292
+ projectName,
293
+
294
+ celilo(cmd: string, timeoutMs = 120_000): Promise<ExecResult> {
295
+ return dockerExecAsync(projectName, composeDir, 'management',
296
+ `cd /celilo/apps/celilo && bun run src/cli/index.ts ${cmd}`, timeoutMs);
297
+ },
298
+
299
+ exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
300
+ return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
301
+ },
302
+
303
+ async dig(name: string): Promise<string> {
304
+ const result = dockerExec(projectName, composeDir, 'management',
305
+ `dig +short ${name}`);
306
+ return result.stdout.trim();
307
+ },
308
+
309
+ waitFor,
310
+
311
+ async stop(): Promise<void> {
312
+ console.log(`[e2e] Stopping network (project: ${projectName})...`);
313
+ try {
314
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
315
+ { cwd: composeDir, timeout: 60_000 });
316
+ } catch {
317
+ // Best effort cleanup
318
+ }
319
+ activeProjects.delete(projectName);
320
+ },
321
+ };
322
+
323
+ return handle;
324
+ }