@celilo/e2e 0.6.0 → 0.6.2

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 (43) hide show
  1. package/README.md +2 -1
  2. package/config/dns/celilo.computer.zone +16 -0
  3. package/config/dns/iamtheinternet.org.zone +6 -4
  4. package/config/dns/knot-namecheap.conf +8 -0
  5. package/config/dns/knot-tld.conf +4 -0
  6. package/config/dns/lunacycle.net.zone +16 -0
  7. package/config/dns/net.zone +11 -0
  8. package/config/dns/org.zone +5 -0
  9. package/config/dns/root.zone +2 -0
  10. package/config/dns/templates/iamtheinternet.org.zone +16 -0
  11. package/config/dns/templates/lunacycle.net.zone +16 -0
  12. package/config/resolver/unbound.conf +10 -0
  13. package/config/routing/management-routes.sh +19 -0
  14. package/config/routing/resolver-internal-routes.sh +59 -0
  15. package/docker/Dockerfile.internal-resolver +16 -0
  16. package/docker/Dockerfile.isitup +37 -0
  17. package/docker/Dockerfile.registry +34 -0
  18. package/netapps/authentik.netapp +0 -0
  19. package/netapps/caddy.netapp +0 -0
  20. package/netapps/greenwave.netapp +0 -0
  21. package/netapps/homebridge.netapp +0 -0
  22. package/netapps/iptables.netapp +0 -0
  23. package/netapps/knot-unbound-internal.netapp +0 -0
  24. package/netapps/namecheap.netapp +0 -0
  25. package/netapps/technitium.netapp +0 -0
  26. package/netapps/test-ansible-output.netapp +0 -0
  27. package/package.json +2 -1
  28. package/simulators/isitup/Caddyfile +11 -0
  29. package/simulators/isitup/entrypoint.sh +47 -0
  30. package/simulators/isitup/server.ts +112 -0
  31. package/simulators/namecheap-ddns/server.ts +31 -2
  32. package/src/cli/build.ts +17 -3
  33. package/src/cli/command-registry.ts +3 -2
  34. package/src/cli/completion.ts +29 -5
  35. package/src/cli/index.ts +85 -15
  36. package/src/cli/module-discovery.ts +120 -0
  37. package/src/cli/scaffold.ts +4 -5
  38. package/src/container-manager.ts +358 -71
  39. package/src/docker-compose-generator.ts +119 -17
  40. package/src/parse-line.ts +14 -3
  41. package/src/runner.ts +122 -165
  42. package/src/simulator-ips.ts +42 -0
  43. package/src/types.ts +24 -0
@@ -1,10 +1,12 @@
1
- import { execSync, spawn, spawnSync, type ExecSyncOptions } from 'node:child_process';
2
- import { existsSync, writeFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
1
+ import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
2
+ import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { SHARED_PROJECT_NAME, generateTestComposeYaml } from './docker-compose-generator';
6
+ import { ensureSharedInfra } from './shared-infra';
7
+ import { SIMULATOR_IPS } from './simulator-ips';
4
8
  import type { ExecResult, NetworkConfig, NetworkHandle } from './types';
5
9
  import { CeliloCommandError } from './types';
6
- import { generateTestComposeYaml, SHARED_PROJECT_NAME } from './docker-compose-generator';
7
- import { ensureSharedInfra } from './shared-infra';
8
10
 
9
11
  /** Package root — where docker/, config/, simulators/ live */
10
12
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -58,18 +60,28 @@ const activeProjects: Set<string> = new Set();
58
60
  function cleanupOnExit() {
59
61
  for (const project of activeProjects) {
60
62
  try {
61
- execSync(
62
- `docker compose -p ${project} down --volumes --remove-orphans`,
63
- { timeout: 30_000, stdio: 'pipe' },
64
- );
63
+ execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
64
+ timeout: 30_000,
65
+ stdio: 'pipe',
66
+ });
65
67
  } catch {}
66
68
  }
67
- try { execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' }); } catch {}
69
+ try {
70
+ execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
71
+ } catch {}
68
72
  }
69
73
 
70
- process.on('SIGTERM', () => { cleanupOnExit(); process.exit(1); });
71
- process.on('SIGINT', () => { cleanupOnExit(); process.exit(1); });
72
- process.on('beforeExit', () => { cleanupOnExit(); });
74
+ process.on('SIGTERM', () => {
75
+ cleanupOnExit();
76
+ process.exit(1);
77
+ });
78
+ process.on('SIGINT', () => {
79
+ cleanupOnExit();
80
+ process.exit(1);
81
+ });
82
+ process.on('beforeExit', () => {
83
+ cleanupOnExit();
84
+ });
73
85
 
74
86
  function run(cmd: string, opts?: ExecSyncOptions): string {
75
87
  return execSync(cmd, {
@@ -83,12 +95,75 @@ function run(cmd: string, opts?: ExecSyncOptions): string {
83
95
  const COMPOSE_FILE = 'docker-compose.test.yml';
84
96
  const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
85
97
 
86
- /** Containers that live in the shared infra project, not the per-test project */
98
+ /**
99
+ * Containers that live in the shared infra project, not the per-test project.
100
+ *
101
+ * CAVEAT: these names are matched globally — a per-test target-machine with
102
+ * any name in this set will be silently routed to the shared-infra container
103
+ * instead of its own. If you're adding a new e2e test machine, pick a name
104
+ * NOT in this set (e.g., use 'celilo-registry' for an LXC hosting the
105
+ * registry module rather than 'registry', which matches the shared stand-in).
106
+ */
87
107
  const SHARED_CONTAINERS = new Set([
88
- 'root-dns', 'tld-dns', 'namecheap-dns', 'letsencrypt', 'apt-cache',
108
+ 'root-dns',
109
+ 'tld-dns',
110
+ 'namecheap-dns',
111
+ 'letsencrypt',
112
+ 'apt-cache',
113
+ 'registry',
114
+ 'isitup',
89
115
  ]);
90
116
 
91
- function dockerExec(projectName: string, composeDir: string, container: string, cmd: string, timeoutMs = 60_000): ExecResult {
117
+ /**
118
+ * Scrub DNS zone files to baseline templates before a test starts.
119
+ *
120
+ * Copies slim template zones (containing only @, ns1, www) over the
121
+ * host-side zone files (which are bind-mounted into namecheap-dns),
122
+ * then reloads Knot to pick up the changes. This prevents DDNS-written
123
+ * subdomains from one test leaking into the next.
124
+ *
125
+ * Called automatically by startNetwork unless config.skipDnsScrub is
126
+ * set (useful for --keep debugging).
127
+ */
128
+ async function scrubDnsZones(): Promise<void> {
129
+ const zones = ['iamtheinternet.org', 'lunacycle.net'];
130
+ const composeDir = PACKAGE_ROOT;
131
+
132
+ for (const zone of zones) {
133
+ const templatePath = join(PACKAGE_ROOT, 'config', 'dns', 'templates', `${zone}.zone`);
134
+ const liveZonePath = join(PACKAGE_ROOT, 'config', 'dns', `${zone}.zone`);
135
+
136
+ try {
137
+ // Copy template over the host-side zone file. The file is
138
+ // bind-mounted into the container, so overwriting the host file
139
+ // makes the new content available inside.
140
+ copyFileSync(templatePath, liveZonePath);
141
+ } catch (err) {
142
+ // Non-fatal: log and continue. The scrub is a hygiene measure;
143
+ // if it fails (template missing, permissions), tests can still run
144
+ // with stale DNS state.
145
+ console.warn(`[dns-scrub] Failed to copy ${zone} template: ${err}`);
146
+ }
147
+ }
148
+
149
+ // Reload all zones in one shot so Knot re-reads from the bind-mount
150
+ try {
151
+ run(
152
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns knotc zone-reload`,
153
+ { cwd: composeDir },
154
+ );
155
+ } catch (err) {
156
+ console.warn(`[dns-scrub] Failed to reload zones: ${err}`);
157
+ }
158
+ }
159
+
160
+ function dockerExec(
161
+ projectName: string,
162
+ composeDir: string,
163
+ container: string,
164
+ cmd: string,
165
+ timeoutMs = 60_000,
166
+ ): ExecResult {
92
167
  // Use the shared infra project for shared containers
93
168
  const isShared = SHARED_CONTAINERS.has(container);
94
169
  const project = isShared ? SHARED_PROJECT_NAME : projectName;
@@ -121,8 +196,20 @@ function dockerExecAsync(
121
196
  // stdbuf -oL -eL forces line-buffered stdout and stderr so Ansible play/task
122
197
  // markers stream in real time rather than arriving in one burst at the end.
123
198
  const args = [
124
- 'compose', '-f', COMPOSE_FILE, '-p', projectName,
125
- 'exec', '-T', container, 'stdbuf', '-oL', '-eL', 'bash', '-c', cmd,
199
+ 'compose',
200
+ '-f',
201
+ COMPOSE_FILE,
202
+ '-p',
203
+ projectName,
204
+ 'exec',
205
+ '-T',
206
+ container,
207
+ 'stdbuf',
208
+ '-oL',
209
+ '-eL',
210
+ 'bash',
211
+ '-c',
212
+ cmd,
126
213
  ];
127
214
  const child = spawn('docker', args, { cwd: composeDir });
128
215
 
@@ -235,13 +322,13 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
235
322
  try {
236
323
  const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
237
324
  const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
238
- const heavy = running.filter(name => heavyPatterns.some(p => name.startsWith(p)));
325
+ const heavy = running.filter((name) => heavyPatterns.some((p) => name.startsWith(p)));
239
326
  if (heavy.length > 0) {
240
- const names = heavy.map(c => ` - ${c}`).join('\n');
327
+ const names = heavy.map((c) => ` - ${c}`).join('\n');
241
328
  throw new Error(
242
329
  `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
243
- 'Live and e2e environments are mutually exclusive.\n' +
244
- 'Stop live containers first: ./bin/e2e-down --all\n',
330
+ 'Live and e2e environments are mutually exclusive.\n' +
331
+ 'Stop live containers first: cele2e down --all\n',
245
332
  );
246
333
  }
247
334
  } catch (e) {
@@ -252,6 +339,13 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
252
339
  // This is idempotent — if already up, it just verifies health.
253
340
  await ensureSharedInfra();
254
341
 
342
+ // Scrub DNS zone files to baseline before each test (H5 in E2E_TEST_UPDATES.md).
343
+ // Prevents DDNS-written records from one test leaking into the next.
344
+ // Tests can opt out with config.skipDnsScrub (e.g., for --reuse debugging).
345
+ if (!config.skipDnsScrub) {
346
+ await scrubDnsZones();
347
+ }
348
+
255
349
  // Clean up stale per-test containers (self-healing from prior crashes)
256
350
  console.log('[progress:start] cleaning up stale test resources | cleanup complete');
257
351
  try {
@@ -267,14 +361,20 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
267
361
  try {
268
362
  const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
269
363
  .split('\n')
270
- .filter(n => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
364
+ .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
271
365
  if (networks.length > 0) {
272
- const projects = [...new Set(networks.map(n => n.replace(/_[^_]+$/, '')))];
366
+ const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
273
367
  for (const project of projects) {
274
- try { run(`docker compose -p ${project} down --volumes --remove-orphans`, { timeout: 30_000 }); } catch {}
368
+ try {
369
+ run(`docker compose -p ${project} down --volumes --remove-orphans`, {
370
+ timeout: 30_000,
371
+ });
372
+ } catch {}
275
373
  }
276
374
  for (const net of networks) {
277
- try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
375
+ try {
376
+ run(`docker network rm ${net}`, { timeout: 5_000 });
377
+ } catch {}
278
378
  }
279
379
  }
280
380
  } catch {}
@@ -295,27 +395,63 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
295
395
  await streamingBuild(projectName, composeDir, COMPOSE_FILE);
296
396
 
297
397
  console.log('[progress:start] starting containers | containers running');
298
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, { cwd: composeDir, timeout: 120_000 });
398
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
399
+ cwd: composeDir,
400
+ timeout: 120_000,
401
+ });
299
402
 
300
403
  // Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
301
404
  console.log('[progress:start] waiting for DNS convergence | DNS converged');
302
405
  await waitFor(
303
406
  async () => {
304
- const result = dockerExec(projectName, composeDir, 'comcast-resolver',
305
- 'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2');
407
+ const result = dockerExec(
408
+ projectName,
409
+ composeDir,
410
+ 'comcast-resolver',
411
+ 'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2',
412
+ );
306
413
  return result.exitCode === 0 && result.stdout.trim().length > 0;
307
414
  },
308
415
  60_000,
309
416
  'DNS convergence',
310
417
  );
311
418
 
419
+ // Wait for internal resolver (split-horizon DNS for management container).
420
+ // Skipped when the test specifies its own machine named `dns-int` — that
421
+ // means the test is replacing the infra resolver with a module under test
422
+ // (e.g., knot-unbound-internal). The bare target machine has no DNS server
423
+ // until its module deploys, so this wait would always time out. Management
424
+ // can still resolve names via the fallback `nameserver 100.100.0.1` in
425
+ // its resolv.conf during deploy.
426
+ const dnsIntReplaced = config.internalMachines.some((m) => m.name === 'dns-int');
427
+ if (!dnsIntReplaced) {
428
+ console.log('[progress:start] waiting for internal resolver | internal resolver ready');
429
+ await waitFor(
430
+ async () => {
431
+ const result = dockerExec(
432
+ projectName,
433
+ composeDir,
434
+ 'dns-int',
435
+ 'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
436
+ );
437
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
438
+ },
439
+ 30_000,
440
+ 'internal DNS resolver',
441
+ );
442
+ }
443
+
312
444
  // Optional routing verification
313
445
  if (config.verifyRouting) {
314
446
  console.log('[progress:start] verifying routing | routing verified');
315
447
  await waitFor(
316
448
  async () => {
317
- const result = dockerExec(projectName, composeDir, 'management',
318
- 'ping -c1 -W2 100.64.0.53');
449
+ const result = dockerExec(
450
+ projectName,
451
+ composeDir,
452
+ 'management',
453
+ `ping -c1 -W2 ${SIMULATOR_IPS.ROOT_DNS}`,
454
+ );
319
455
  return result.exitCode === 0;
320
456
  },
321
457
  30_000,
@@ -324,18 +460,32 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
324
460
  }
325
461
 
326
462
  // Wait for all target machines to complete setup (systemd boot + routing)
327
- const allMachines = [...config.dmzMachines, ...config.appMachines, ...config.secureMachines, ...config.internalMachines];
463
+ const allMachines = [
464
+ ...config.dmzMachines,
465
+ ...config.appMachines,
466
+ ...config.secureMachines,
467
+ ...config.internalMachines,
468
+ ];
328
469
  if (allMachines.length > 0) {
329
470
  console.log('[progress:start] waiting for target machines | target machines ready');
330
471
  }
331
472
  for (const machine of allMachines) {
332
473
  await waitFor(
333
474
  async () => {
334
- const result = dockerExec(projectName, composeDir, machine.name,
335
- 'systemctl is-active target-setup 2>/dev/null');
475
+ const result = dockerExec(
476
+ projectName,
477
+ composeDir,
478
+ machine.name,
479
+ 'systemctl is-active target-setup 2>/dev/null',
480
+ );
336
481
  return result.stdout.trim() === 'active';
337
482
  },
338
- 30_000,
483
+ // App-zone machines use Dockerfile.target-machine-docker which bakes in
484
+ // dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
485
+ // margin against a 30s timeout. 60s covers the observed worst case
486
+ // without slowing down dmz/internal-only tests (which still hit this
487
+ // in ~2-5s).
488
+ 60_000,
339
489
  `${machine.name} target-setup`,
340
490
  );
341
491
  }
@@ -345,8 +495,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
345
495
  console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
346
496
  await waitFor(
347
497
  async () => {
348
- const result = dockerExec(projectName, composeDir, 'dhcp-client',
349
- 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases');
498
+ const result = dockerExec(
499
+ projectName,
500
+ composeDir,
501
+ 'dhcp-client',
502
+ 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
503
+ );
350
504
  return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
351
505
  },
352
506
  60_000,
@@ -358,8 +512,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
358
512
  if (allMachines.length > 0) {
359
513
  await waitFor(
360
514
  async () => {
361
- const result = dockerExec(projectName, composeDir, 'management',
362
- 'ping -c1 -W2 100.64.0.100');
515
+ const result = dockerExec(
516
+ projectName,
517
+ composeDir,
518
+ 'management',
519
+ `ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
520
+ );
363
521
  return result.exitCode === 0;
364
522
  },
365
523
  30_000,
@@ -369,14 +527,18 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
369
527
 
370
528
  // Initialize celilo
371
529
  console.log('[progress:start] initializing celilo | celilo initialized');
372
- const initResult = dockerExec(projectName, composeDir, 'management',
530
+ const initResult = dockerExec(
531
+ projectName,
532
+ composeDir,
533
+ 'management',
373
534
  `celilo system init --accept-defaults \
374
535
  network.dmz.subnet=10.0.10.0/24 \
375
536
  network.app.subnet=10.0.20.0/24 \
376
537
  network.secure.subnet=10.0.30.0/24 \
377
538
  network.internal.subnet=192.168.0.0/24 \
378
539
  dns.primary=100.100.0.1 \
379
- dns.fallback=1.0.0.1,8.8.8.8`);
540
+ dns.fallback=1.0.0.1,8.8.8.8`,
541
+ );
380
542
  if (initResult.exitCode !== 0) {
381
543
  throw new Error(`Celilo init failed: ${initResult.stderr}`);
382
544
  }
@@ -388,12 +550,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
388
550
  const handle: NetworkHandle = {
389
551
  projectName,
390
552
 
391
- async celilo(cmd: string, optsOrTimeout?: number | { check?: boolean; timeoutMs?: number }): Promise<ExecResult> {
553
+ async celilo(
554
+ cmd: string,
555
+ optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
556
+ ): Promise<ExecResult> {
392
557
  const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
393
- const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
394
-
395
- const result = await dockerExecAsync(projectName, composeDir, 'management',
396
- `celilo ${cmd}`, timeoutMs);
558
+ const timeoutMs =
559
+ typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
560
+
561
+ const result = await dockerExecAsync(
562
+ projectName,
563
+ composeDir,
564
+ 'management',
565
+ `celilo ${cmd}`,
566
+ timeoutMs,
567
+ );
397
568
 
398
569
  if (check && result.exitCode !== 0) {
399
570
  throw new CeliloCommandError(cmd, result);
@@ -406,9 +577,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
406
577
  },
407
578
 
408
579
  async dig(name: string): Promise<string> {
409
- const result = dockerExec(projectName, composeDir, 'management',
410
- `dig +short ${name}`);
411
- return result.stdout.trim();
580
+ const result = dockerExec(projectName, composeDir, 'management', `dig +short ${name}`);
581
+ return result.stdout
582
+ .split('\n')
583
+ .filter((l) => !l.startsWith(';;'))
584
+ .join('\n')
585
+ .trim();
412
586
  },
413
587
 
414
588
  async debug(container = 'management'): Promise<void> {
@@ -424,28 +598,78 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
424
598
  const start = Date.now();
425
599
  const timeout = 86_400_000; // 24 hour max debug session
426
600
  while (!existsSync(signalFile) && Date.now() - start < timeout) {
427
- await new Promise(r => setTimeout(r, 500));
601
+ await new Promise((r) => setTimeout(r, 500));
428
602
  }
429
603
 
430
604
  // Clean up
431
- try { require('node:fs').unlinkSync(signalFile); } catch {}
605
+ try {
606
+ require('node:fs').unlinkSync(signalFile);
607
+ } catch {}
432
608
 
433
609
  console.log('[debug:resumed]');
434
610
  },
435
611
 
436
612
  waitFor,
437
613
 
614
+ async publishModule(localPath: string): Promise<void> {
615
+ const absPath = resolve(localPath);
616
+ const isNetapp = absPath.endsWith('.netapp');
617
+ const moduleId = isNetapp ? basename(absPath).slice(0, -7) : basename(absPath);
618
+
619
+ let netappPath: string;
620
+ let cleanup = false;
621
+
622
+ if (isNetapp) {
623
+ netappPath = absPath;
624
+ } else {
625
+ // Package the module source directory using the celilo CLI
626
+ netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
627
+ cleanup = true;
628
+ const celiloCliPath = join(celiloRoot ?? PACKAGE_ROOT, 'apps/celilo/src/cli/index.ts');
629
+ try {
630
+ run(
631
+ `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
632
+ { timeout: 60_000 },
633
+ );
634
+ } catch (err) {
635
+ throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
636
+ }
637
+ }
638
+
639
+ try {
640
+ // Copy directly into the registry container's /uploads dir.
641
+ // The registry rebuilds its index on every request, so no HTTP handshake needed.
642
+ run(
643
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} cp ${JSON.stringify(netappPath)} registry:/uploads/${moduleId}.netapp`,
644
+ { cwd: composeDir, timeout: 120_000 },
645
+ );
646
+ } finally {
647
+ if (cleanup)
648
+ try {
649
+ execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
650
+ } catch {}
651
+ }
652
+ },
653
+
654
+ async configureAcme(): Promise<void> {
655
+ await handle.celilo(
656
+ 'module config set caddy acme_ca https://acme-v02.api.letsencrypt.org/dir',
657
+ );
658
+ },
659
+
438
660
  async stop(): Promise<void> {
439
661
  // Respect --keep / --reuse: don't tear down the network
440
662
  if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
441
663
  console.log(`[progress:done] network kept alive (project: ${projectName})`);
442
- activeProjects.delete(projectName); // don't re-teardown on exit
664
+ activeProjects.delete(projectName); // don't re-teardown on exit
443
665
  return;
444
666
  }
445
667
  console.log('[progress:start] stopping network | network stopped');
446
668
  try {
447
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
448
- { cwd: composeDir, timeout: 60_000 });
669
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
670
+ cwd: composeDir,
671
+ timeout: 60_000,
672
+ });
449
673
  } catch {
450
674
  // Best effort cleanup
451
675
  }
@@ -469,17 +693,17 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
469
693
 
470
694
  // Verify the project actually exists
471
695
  try {
472
- const result = run(
473
- `docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`,
474
- { cwd: composeDir, timeout: 10_000 },
475
- );
696
+ const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
697
+ cwd: composeDir,
698
+ timeout: 10_000,
699
+ });
476
700
  if (!result.trim()) {
477
701
  throw new Error(`No containers found for project ${projectName}`);
478
702
  }
479
703
  } catch (err) {
480
704
  throw new Error(
481
705
  `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
482
- `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
706
+ `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
483
707
  );
484
708
  }
485
709
 
@@ -490,12 +714,21 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
490
714
  return {
491
715
  projectName,
492
716
 
493
- async celilo(cmd: string, optsOrTimeout?: number | { check?: boolean; timeoutMs?: number }): Promise<ExecResult> {
717
+ async celilo(
718
+ cmd: string,
719
+ optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
720
+ ): Promise<ExecResult> {
494
721
  const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
495
- const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
496
-
497
- const result = await dockerExecAsync(projectName, composeDir, 'management',
498
- `celilo ${cmd}`, timeoutMs);
722
+ const timeoutMs =
723
+ typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
724
+
725
+ const result = await dockerExecAsync(
726
+ projectName,
727
+ composeDir,
728
+ 'management',
729
+ `celilo ${cmd}`,
730
+ timeoutMs,
731
+ );
499
732
 
500
733
  if (check && result.exitCode !== 0) {
501
734
  throw new CeliloCommandError(cmd, result);
@@ -508,9 +741,12 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
508
741
  },
509
742
 
510
743
  async dig(name: string): Promise<string> {
511
- const result = dockerExec(projectName, composeDir, 'management',
512
- `dig +short ${name}`);
513
- return result.stdout.trim();
744
+ const result = dockerExec(projectName, composeDir, 'management', `dig +short ${name}`);
745
+ return result.stdout
746
+ .split('\n')
747
+ .filter((l) => !l.startsWith(';;'))
748
+ .join('\n')
749
+ .trim();
514
750
  },
515
751
 
516
752
  async debug(container = 'management'): Promise<void> {
@@ -519,14 +755,63 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
519
755
  const start = Date.now();
520
756
  const timeout = 86_400_000;
521
757
  while (!existsSync(signalFile) && Date.now() - start < timeout) {
522
- await new Promise(r => setTimeout(r, 500));
758
+ await new Promise((r) => setTimeout(r, 500));
523
759
  }
524
- try { require('node:fs').unlinkSync(signalFile); } catch {}
760
+ try {
761
+ require('node:fs').unlinkSync(signalFile);
762
+ } catch {}
525
763
  console.log('[debug:resumed]');
526
764
  },
527
765
 
528
766
  waitFor,
529
767
 
768
+ async configureAcme(): Promise<void> {
769
+ await handle.celilo(
770
+ 'module config set caddy acme_ca https://acme-v02.api.letsencrypt.org/dir',
771
+ );
772
+ },
773
+
774
+ async publishModule(localPath: string): Promise<void> {
775
+ const absPath = resolve(localPath);
776
+ const isNetapp = absPath.endsWith('.netapp');
777
+ const moduleId = isNetapp ? basename(absPath).slice(0, -7) : basename(absPath);
778
+
779
+ let netappPath: string;
780
+ let cleanup = false;
781
+
782
+ if (isNetapp) {
783
+ netappPath = absPath;
784
+ } else {
785
+ netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
786
+ cleanup = true;
787
+ const resolvedCeliloRoot = findCeliloRoot();
788
+ const celiloCliPath = join(
789
+ resolvedCeliloRoot ?? PACKAGE_ROOT,
790
+ 'apps/celilo/src/cli/index.ts',
791
+ );
792
+ try {
793
+ run(
794
+ `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
795
+ { timeout: 60_000 },
796
+ );
797
+ } catch (err) {
798
+ throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
799
+ }
800
+ }
801
+
802
+ try {
803
+ run(
804
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} cp ${JSON.stringify(netappPath)} registry:/uploads/${moduleId}.netapp`,
805
+ { cwd: composeDir, timeout: 120_000 },
806
+ );
807
+ } finally {
808
+ if (cleanup)
809
+ try {
810
+ execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
811
+ } catch {}
812
+ }
813
+ },
814
+
530
815
  async stop(): Promise<void> {
531
816
  // Respect --keep / --reuse: don't tear down
532
817
  if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
@@ -535,8 +820,10 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
535
820
  return;
536
821
  }
537
822
  try {
538
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
539
- { cwd: composeDir, timeout: 60_000 });
823
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
824
+ cwd: composeDir,
825
+ timeout: 60_000,
826
+ });
540
827
  } catch {}
541
828
  activeProjects.delete(projectName);
542
829
  },