@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.
- package/README.md +2 -1
- package/bin/cele2e +14 -0
- package/bin/e2e-cache-images +57 -0
- package/bin/e2e-shell +36 -13
- package/bin/e2e-status +8 -2
- package/bin/e2e-up +5 -5
- package/completions/install-zsh-completion.sh +78 -0
- package/config/dns/celilo.computer.zone +16 -0
- package/config/dns/iamtheinternet.org.zone +7 -6
- 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/docker-image-preload.service +14 -0
- package/config/routing/docker-image-preload.sh +24 -0
- package/config/routing/management-routes.sh +24 -2
- 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.management +2 -0
- package/docker/Dockerfile.registry +34 -0
- package/docker/Dockerfile.target-machine +20 -0
- package/docker/Dockerfile.target-machine-docker +11 -19
- package/docker/Dockerfile.target-technitium +97 -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 +8 -1
- package/simulators/greenwave/state.ts +7 -12
- 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 +214 -0
- package/src/cli/command-registry.ts +100 -0
- package/src/cli/command-tree-parser.ts +52 -0
- package/src/cli/completion.ts +195 -0
- package/src/cli/index.ts +273 -0
- package/src/cli/module-discovery.ts +120 -0
- package/src/cli/scaffold.ts +108 -0
- package/src/container-manager.ts +373 -76
- package/src/docker-compose-generator.ts +127 -23
- package/src/network-builder.ts +21 -0
- package/src/parse-line.ts +147 -0
- package/src/runner.ts +197 -247
- package/src/simulator-ips.ts +42 -0
- package/src/types.ts +28 -0
- package/bin/cache-docker-images +0 -46
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, '..');
|
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
53
|
-
|
|
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 {
|
|
69
|
+
try {
|
|
70
|
+
execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
|
|
71
|
+
} catch {}
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
process.on('SIGTERM', () => {
|
|
61
|
-
|
|
62
|
-
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
|
+
});
|
|
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
|
-
/**
|
|
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',
|
|
108
|
+
'root-dns',
|
|
109
|
+
'tld-dns',
|
|
110
|
+
'namecheap-dns',
|
|
111
|
+
'letsencrypt',
|
|
112
|
+
'apt-cache',
|
|
113
|
+
'registry',
|
|
114
|
+
'isitup',
|
|
79
115
|
]);
|
|
80
116
|
|
|
81
|
-
|
|
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
|
|
112
|
-
// rather than
|
|
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',
|
|
115
|
-
'
|
|
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
|
-
|
|
234
|
-
|
|
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 {
|
|
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 {
|
|
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`, {
|
|
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(
|
|
295
|
-
|
|
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(
|
|
308
|
-
|
|
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 = [
|
|
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(
|
|
325
|
-
|
|
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
|
-
|
|
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(
|
|
339
|
-
|
|
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(
|
|
352
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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 {
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
},
|