@celilo/e2e 0.5.1 → 0.6.1

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 (59) hide show
  1. package/README.md +2 -1
  2. package/bin/cele2e +14 -0
  3. package/bin/e2e-cache-images +57 -0
  4. package/bin/e2e-shell +36 -13
  5. package/bin/e2e-status +8 -2
  6. package/bin/e2e-up +5 -5
  7. package/completions/install-zsh-completion.sh +78 -0
  8. package/config/dns/celilo.computer.zone +16 -0
  9. package/config/dns/iamtheinternet.org.zone +7 -6
  10. package/config/dns/knot-namecheap.conf +8 -0
  11. package/config/dns/knot-tld.conf +4 -0
  12. package/config/dns/lunacycle.net.zone +16 -0
  13. package/config/dns/net.zone +11 -0
  14. package/config/dns/org.zone +5 -0
  15. package/config/dns/root.zone +2 -0
  16. package/config/dns/templates/iamtheinternet.org.zone +16 -0
  17. package/config/dns/templates/lunacycle.net.zone +16 -0
  18. package/config/resolver/unbound.conf +10 -0
  19. package/config/routing/docker-image-preload.service +14 -0
  20. package/config/routing/docker-image-preload.sh +24 -0
  21. package/config/routing/management-routes.sh +24 -2
  22. package/config/routing/resolver-internal-routes.sh +59 -0
  23. package/docker/Dockerfile.internal-resolver +16 -0
  24. package/docker/Dockerfile.isitup +37 -0
  25. package/docker/Dockerfile.management +2 -0
  26. package/docker/Dockerfile.registry +34 -0
  27. package/docker/Dockerfile.target-machine +20 -0
  28. package/docker/Dockerfile.target-machine-docker +11 -19
  29. package/docker/Dockerfile.target-technitium +97 -0
  30. package/netapps/authentik.netapp +0 -0
  31. package/netapps/caddy.netapp +0 -0
  32. package/netapps/greenwave.netapp +0 -0
  33. package/netapps/homebridge.netapp +0 -0
  34. package/netapps/iptables.netapp +0 -0
  35. package/netapps/knot-unbound-internal.netapp +0 -0
  36. package/netapps/namecheap.netapp +0 -0
  37. package/netapps/technitium.netapp +0 -0
  38. package/netapps/test-ansible-output.netapp +0 -0
  39. package/package.json +8 -1
  40. package/simulators/greenwave/state.ts +7 -12
  41. package/simulators/isitup/Caddyfile +11 -0
  42. package/simulators/isitup/entrypoint.sh +47 -0
  43. package/simulators/isitup/server.ts +112 -0
  44. package/simulators/namecheap-ddns/server.ts +31 -2
  45. package/src/cli/build.ts +214 -0
  46. package/src/cli/command-registry.ts +100 -0
  47. package/src/cli/command-tree-parser.ts +52 -0
  48. package/src/cli/completion.ts +195 -0
  49. package/src/cli/index.ts +273 -0
  50. package/src/cli/module-discovery.ts +120 -0
  51. package/src/cli/scaffold.ts +108 -0
  52. package/src/container-manager.ts +373 -76
  53. package/src/docker-compose-generator.ts +127 -23
  54. package/src/network-builder.ts +21 -0
  55. package/src/parse-line.ts +147 -0
  56. package/src/runner.ts +197 -247
  57. package/src/simulator-ips.ts +42 -0
  58. package/src/types.ts +28 -0
  59. package/bin/cache-docker-images +0 -46
@@ -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, '..');
@@ -14,11 +16,21 @@ const PACKAGE_ROOT = join(__dirname, '..');
14
16
  * Looks for apps/celilo/ as the marker.
15
17
  */
16
18
  /**
17
- * Try to find the Celilo project root by walking up from the package directory.
18
- * Returns undefined if not found — the CLI is pre-installed in Docker so this
19
- * is only needed for development (mounting local source).
19
+ * Try to find the Celilo project root.
20
+ *
21
+ * Resolution order:
22
+ * 1. CELILO_E2E_INFRA_ROOT env var — explicit path (dev) or empty string
23
+ * (force published mode). Empty string means "do not mount source".
24
+ * 2. Walk up from the package directory looking for apps/celilo/. Works
25
+ * automatically when @celilo/e2e is used directly inside the infra repo.
26
+ * 3. undefined — falls back to the baked-in npm package inside Docker.
20
27
  */
21
28
  function findCeliloRoot(): string | undefined {
29
+ const envRoot = process.env.CELILO_E2E_INFRA_ROOT;
30
+ if (envRoot !== undefined) {
31
+ return envRoot === '' ? undefined : envRoot;
32
+ }
33
+
22
34
  let dir = PACKAGE_ROOT;
23
35
  for (let i = 0; i < 10; i++) {
24
36
  if (existsSync(join(dir, 'apps', 'celilo'))) {
@@ -48,18 +60,28 @@ const activeProjects: Set<string> = new Set();
48
60
  function cleanupOnExit() {
49
61
  for (const project of activeProjects) {
50
62
  try {
51
- execSync(
52
- `docker compose -p ${project} down --volumes --remove-orphans`,
53
- { timeout: 30_000, stdio: 'pipe' },
54
- );
63
+ execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
64
+ timeout: 30_000,
65
+ stdio: 'pipe',
66
+ });
55
67
  } catch {}
56
68
  }
57
- 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 {}
58
72
  }
59
73
 
60
- process.on('SIGTERM', () => { cleanupOnExit(); process.exit(1); });
61
- process.on('SIGINT', () => { cleanupOnExit(); process.exit(1); });
62
- 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
+ });
63
85
 
64
86
  function run(cmd: string, opts?: ExecSyncOptions): string {
65
87
  return execSync(cmd, {
@@ -73,12 +95,75 @@ function run(cmd: string, opts?: ExecSyncOptions): string {
73
95
  const COMPOSE_FILE = 'docker-compose.test.yml';
74
96
  const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
75
97
 
76
- /** 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
+ */
77
107
  const SHARED_CONTAINERS = new Set([
78
- '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',
79
115
  ]);
80
116
 
81
- 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 {
82
167
  // Use the shared infra project for shared containers
83
168
  const isShared = SHARED_CONTAINERS.has(container);
84
169
  const project = isShared ? SHARED_PROJECT_NAME : projectName;
@@ -108,11 +193,23 @@ function dockerExecAsync(
108
193
  timeoutMs = 60_000,
109
194
  ): Promise<ExecResult> {
110
195
  return new Promise((resolve) => {
111
- // stdbuf -oL forces line-buffered stdout so output streams in real time
112
- // rather than appearing in one burst when the process's buffer flushes.
196
+ // stdbuf -oL -eL forces line-buffered stdout and stderr so Ansible play/task
197
+ // markers stream in real time rather than arriving in one burst at the end.
113
198
  const args = [
114
- 'compose', '-f', COMPOSE_FILE, '-p', projectName,
115
- 'exec', '-T', container, 'stdbuf', '-oL', '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,
116
213
  ];
117
214
  const child = spawn('docker', args, { cwd: composeDir });
118
215
 
@@ -225,13 +322,13 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
225
322
  try {
226
323
  const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
227
324
  const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
228
- const heavy = running.filter(name => heavyPatterns.some(p => name.startsWith(p)));
325
+ const heavy = running.filter((name) => heavyPatterns.some((p) => name.startsWith(p)));
229
326
  if (heavy.length > 0) {
230
- const names = heavy.map(c => ` - ${c}`).join('\n');
327
+ const names = heavy.map((c) => ` - ${c}`).join('\n');
231
328
  throw new Error(
232
329
  `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
233
- 'Live and e2e environments are mutually exclusive.\n' +
234
- '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',
235
332
  );
236
333
  }
237
334
  } catch (e) {
@@ -242,6 +339,13 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
242
339
  // This is idempotent — if already up, it just verifies health.
243
340
  await ensureSharedInfra();
244
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
+
245
349
  // Clean up stale per-test containers (self-healing from prior crashes)
246
350
  console.log('[progress:start] cleaning up stale test resources | cleanup complete');
247
351
  try {
@@ -257,14 +361,20 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
257
361
  try {
258
362
  const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
259
363
  .split('\n')
260
- .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
261
365
  if (networks.length > 0) {
262
- const projects = [...new Set(networks.map(n => n.replace(/_[^_]+$/, '')))];
366
+ const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
263
367
  for (const project of projects) {
264
- 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 {}
265
373
  }
266
374
  for (const net of networks) {
267
- try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
375
+ try {
376
+ run(`docker network rm ${net}`, { timeout: 5_000 });
377
+ } catch {}
268
378
  }
269
379
  }
270
380
  } catch {}
@@ -285,27 +395,63 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
285
395
  await streamingBuild(projectName, composeDir, COMPOSE_FILE);
286
396
 
287
397
  console.log('[progress:start] starting containers | containers running');
288
- 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
+ });
289
402
 
290
403
  // Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
291
404
  console.log('[progress:start] waiting for DNS convergence | DNS converged');
292
405
  await waitFor(
293
406
  async () => {
294
- const result = dockerExec(projectName, composeDir, 'comcast-resolver',
295
- '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
+ );
296
413
  return result.exitCode === 0 && result.stdout.trim().length > 0;
297
414
  },
298
415
  60_000,
299
416
  'DNS convergence',
300
417
  );
301
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
+
302
444
  // Optional routing verification
303
445
  if (config.verifyRouting) {
304
446
  console.log('[progress:start] verifying routing | routing verified');
305
447
  await waitFor(
306
448
  async () => {
307
- const result = dockerExec(projectName, composeDir, 'management',
308
- '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
+ );
309
455
  return result.exitCode === 0;
310
456
  },
311
457
  30_000,
@@ -314,18 +460,32 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
314
460
  }
315
461
 
316
462
  // Wait for all target machines to complete setup (systemd boot + routing)
317
- 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
+ ];
318
469
  if (allMachines.length > 0) {
319
470
  console.log('[progress:start] waiting for target machines | target machines ready');
320
471
  }
321
472
  for (const machine of allMachines) {
322
473
  await waitFor(
323
474
  async () => {
324
- const result = dockerExec(projectName, composeDir, machine.name,
325
- '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
+ );
326
481
  return result.stdout.trim() === 'active';
327
482
  },
328
- 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,
329
489
  `${machine.name} target-setup`,
330
490
  );
331
491
  }
@@ -335,8 +495,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
335
495
  console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
336
496
  await waitFor(
337
497
  async () => {
338
- const result = dockerExec(projectName, composeDir, 'dhcp-client',
339
- '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
+ );
340
504
  return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
341
505
  },
342
506
  60_000,
@@ -348,8 +512,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
348
512
  if (allMachines.length > 0) {
349
513
  await waitFor(
350
514
  async () => {
351
- const result = dockerExec(projectName, composeDir, 'management',
352
- '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
+ );
353
521
  return result.exitCode === 0;
354
522
  },
355
523
  30_000,
@@ -359,14 +527,18 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
359
527
 
360
528
  // Initialize celilo
361
529
  console.log('[progress:start] initializing celilo | celilo initialized');
362
- const initResult = dockerExec(projectName, composeDir, 'management',
530
+ const initResult = dockerExec(
531
+ projectName,
532
+ composeDir,
533
+ 'management',
363
534
  `celilo system init --accept-defaults \
364
535
  network.dmz.subnet=10.0.10.0/24 \
365
536
  network.app.subnet=10.0.20.0/24 \
366
537
  network.secure.subnet=10.0.30.0/24 \
367
538
  network.internal.subnet=192.168.0.0/24 \
368
539
  dns.primary=100.100.0.1 \
369
- dns.fallback=1.0.0.1,8.8.8.8`);
540
+ dns.fallback=1.0.0.1,8.8.8.8`,
541
+ );
370
542
  if (initResult.exitCode !== 0) {
371
543
  throw new Error(`Celilo init failed: ${initResult.stderr}`);
372
544
  }
@@ -378,12 +550,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
378
550
  const handle: NetworkHandle = {
379
551
  projectName,
380
552
 
381
- 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> {
382
557
  const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
383
- const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
384
-
385
- const result = await dockerExecAsync(projectName, composeDir, 'management',
386
- `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
+ );
387
568
 
388
569
  if (check && result.exitCode !== 0) {
389
570
  throw new CeliloCommandError(cmd, result);
@@ -396,9 +577,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
396
577
  },
397
578
 
398
579
  async dig(name: string): Promise<string> {
399
- const result = dockerExec(projectName, composeDir, 'management',
400
- `dig +short ${name}`);
401
- 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();
402
586
  },
403
587
 
404
588
  async debug(container = 'management'): Promise<void> {
@@ -414,28 +598,78 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
414
598
  const start = Date.now();
415
599
  const timeout = 86_400_000; // 24 hour max debug session
416
600
  while (!existsSync(signalFile) && Date.now() - start < timeout) {
417
- await new Promise(r => setTimeout(r, 500));
601
+ await new Promise((r) => setTimeout(r, 500));
418
602
  }
419
603
 
420
604
  // Clean up
421
- try { require('node:fs').unlinkSync(signalFile); } catch {}
605
+ try {
606
+ require('node:fs').unlinkSync(signalFile);
607
+ } catch {}
422
608
 
423
609
  console.log('[debug:resumed]');
424
610
  },
425
611
 
426
612
  waitFor,
427
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
+
428
660
  async stop(): Promise<void> {
429
661
  // Respect --keep / --reuse: don't tear down the network
430
662
  if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
431
663
  console.log(`[progress:done] network kept alive (project: ${projectName})`);
432
- activeProjects.delete(projectName); // don't re-teardown on exit
664
+ activeProjects.delete(projectName); // don't re-teardown on exit
433
665
  return;
434
666
  }
435
667
  console.log('[progress:start] stopping network | network stopped');
436
668
  try {
437
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
438
- { 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
+ });
439
673
  } catch {
440
674
  // Best effort cleanup
441
675
  }
@@ -459,17 +693,17 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
459
693
 
460
694
  // Verify the project actually exists
461
695
  try {
462
- const result = run(
463
- `docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`,
464
- { cwd: composeDir, timeout: 10_000 },
465
- );
696
+ const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
697
+ cwd: composeDir,
698
+ timeout: 10_000,
699
+ });
466
700
  if (!result.trim()) {
467
701
  throw new Error(`No containers found for project ${projectName}`);
468
702
  }
469
703
  } catch (err) {
470
704
  throw new Error(
471
705
  `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
472
- `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.`,
473
707
  );
474
708
  }
475
709
 
@@ -480,12 +714,21 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
480
714
  return {
481
715
  projectName,
482
716
 
483
- 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> {
484
721
  const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
485
- const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
486
-
487
- const result = await dockerExecAsync(projectName, composeDir, 'management',
488
- `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
+ );
489
732
 
490
733
  if (check && result.exitCode !== 0) {
491
734
  throw new CeliloCommandError(cmd, result);
@@ -498,9 +741,12 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
498
741
  },
499
742
 
500
743
  async dig(name: string): Promise<string> {
501
- const result = dockerExec(projectName, composeDir, 'management',
502
- `dig +short ${name}`);
503
- 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();
504
750
  },
505
751
 
506
752
  async debug(container = 'management'): Promise<void> {
@@ -509,14 +755,63 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
509
755
  const start = Date.now();
510
756
  const timeout = 86_400_000;
511
757
  while (!existsSync(signalFile) && Date.now() - start < timeout) {
512
- await new Promise(r => setTimeout(r, 500));
758
+ await new Promise((r) => setTimeout(r, 500));
513
759
  }
514
- try { require('node:fs').unlinkSync(signalFile); } catch {}
760
+ try {
761
+ require('node:fs').unlinkSync(signalFile);
762
+ } catch {}
515
763
  console.log('[debug:resumed]');
516
764
  },
517
765
 
518
766
  waitFor,
519
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
+
520
815
  async stop(): Promise<void> {
521
816
  // Respect --keep / --reuse: don't tear down
522
817
  if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
@@ -525,8 +820,10 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
525
820
  return;
526
821
  }
527
822
  try {
528
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
529
- { 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
+ });
530
827
  } catch {}
531
828
  activeProjects.delete(projectName);
532
829
  },