@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.
- package/README.md +2 -1
- package/config/dns/celilo.computer.zone +16 -0
- package/config/dns/iamtheinternet.org.zone +6 -4
- package/config/dns/knot-namecheap.conf +8 -0
- package/config/dns/knot-tld.conf +4 -0
- package/config/dns/lunacycle.net.zone +16 -0
- package/config/dns/net.zone +11 -0
- package/config/dns/org.zone +5 -0
- package/config/dns/root.zone +2 -0
- package/config/dns/templates/iamtheinternet.org.zone +16 -0
- package/config/dns/templates/lunacycle.net.zone +16 -0
- package/config/resolver/unbound.conf +10 -0
- package/config/routing/management-routes.sh +19 -0
- package/config/routing/resolver-internal-routes.sh +59 -0
- package/docker/Dockerfile.internal-resolver +16 -0
- package/docker/Dockerfile.isitup +37 -0
- package/docker/Dockerfile.registry +34 -0
- package/netapps/authentik.netapp +0 -0
- package/netapps/caddy.netapp +0 -0
- package/netapps/greenwave.netapp +0 -0
- package/netapps/homebridge.netapp +0 -0
- package/netapps/iptables.netapp +0 -0
- package/netapps/knot-unbound-internal.netapp +0 -0
- package/netapps/namecheap.netapp +0 -0
- package/netapps/technitium.netapp +0 -0
- package/netapps/test-ansible-output.netapp +0 -0
- package/package.json +2 -1
- package/simulators/isitup/Caddyfile +11 -0
- package/simulators/isitup/entrypoint.sh +47 -0
- package/simulators/isitup/server.ts +112 -0
- package/simulators/namecheap-ddns/server.ts +31 -2
- package/src/cli/build.ts +17 -3
- package/src/cli/command-registry.ts +3 -2
- package/src/cli/completion.ts +29 -5
- package/src/cli/index.ts +85 -15
- package/src/cli/module-discovery.ts +120 -0
- package/src/cli/scaffold.ts +4 -5
- package/src/container-manager.ts +358 -71
- package/src/docker-compose-generator.ts +119 -17
- package/src/parse-line.ts +14 -3
- package/src/runner.ts +122 -165
- package/src/simulator-ips.ts +42 -0
- package/src/types.ts +24 -0
package/src/container-manager.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
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
|
-
|
|
63
|
-
|
|
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 {
|
|
69
|
+
try {
|
|
70
|
+
execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
|
|
71
|
+
} catch {}
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
process.on('SIGTERM', () => {
|
|
71
|
-
|
|
72
|
-
process.
|
|
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
|
-
/**
|
|
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',
|
|
108
|
+
'root-dns',
|
|
109
|
+
'tld-dns',
|
|
110
|
+
'namecheap-dns',
|
|
111
|
+
'letsencrypt',
|
|
112
|
+
'apt-cache',
|
|
113
|
+
'registry',
|
|
114
|
+
'isitup',
|
|
89
115
|
]);
|
|
90
116
|
|
|
91
|
-
|
|
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',
|
|
125
|
-
'
|
|
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
|
-
|
|
244
|
-
|
|
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 {
|
|
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 {
|
|
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`, {
|
|
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(
|
|
305
|
-
|
|
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(
|
|
318
|
-
|
|
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 = [
|
|
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(
|
|
335
|
-
|
|
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
|
-
|
|
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(
|
|
349
|
-
|
|
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(
|
|
362
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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 {
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
},
|