@celilo/e2e 0.7.17 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/bin/e2e-bake-management +214 -85
- package/config/routing/fw-main-routes.sh +26 -9
- package/package.json +3 -5
- package/simulators/namecheap-ddns/zone-updater.test.ts +31 -0
- package/simulators/namecheap-ddns/zone-updater.ts +26 -0
- package/src/cli/build.test.ts +47 -0
- package/src/cli/build.ts +283 -11
- package/src/cli/command-registry.ts +11 -3
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +59 -3
- package/src/cli-version-contract.test.ts +22 -0
- package/src/cli-version-contract.ts +37 -0
- package/src/container-manager.ts +522 -606
- package/src/docker-compose-generator.ts +5 -3
- package/src/network-builder.ts +20 -28
- package/src/run-lock.test.ts +105 -0
- package/src/run-lock.ts +252 -0
- package/src/runner.ts +121 -13
- package/src/shared-infra.ts +36 -58
- package/src/types.ts +16 -1
- package/AGENTS.md +0 -117
- package/COVERAGE.md +0 -60
- package/config/dns/iamtheinternet.org.zone +0 -16
package/README.md
CHANGED
|
@@ -399,3 +399,29 @@ e2e/
|
|
|
399
399
|
smoke.test.ts # Basic connectivity verification
|
|
400
400
|
...
|
|
401
401
|
```
|
|
402
|
+
|
|
403
|
+
## Suite health policy (e2e-confidence)
|
|
404
|
+
|
|
405
|
+
The suite must never be tolerated as ambient-red — a regression hiding in chronic
|
|
406
|
+
red is how the install-sh DNS-pollution bug survived for weeks. Two rules:
|
|
407
|
+
|
|
408
|
+
- **No ambient red.** A flaky or failing test is either *fixed* or *quarantined
|
|
409
|
+
with a tracking issue*, never left standing-red. Quarantine by adding a
|
|
410
|
+
`cele2e-ci-unsafe` marker comment to the test file (with the issue id); the
|
|
411
|
+
nightly runs `--ci-safe`, which skips quarantined tests, so it stays green on
|
|
412
|
+
the supported set. `--complete` / `--all` run everything including quarantined.
|
|
413
|
+
|
|
414
|
+
- **Randomized-order lane.** Order-dependent bugs (a test polluting shared infra
|
|
415
|
+
for a *later* test) pass in fixed order and solo. The weekly `cele2e-shuffle`
|
|
416
|
+
workflow runs `--ci-safe --shuffle` to flush them out. The shuffle is seeded
|
|
417
|
+
and the seed is logged; replay a failing order exactly with:
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
cele2e run --ci-safe --shuffle --seed=<n>
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Supporting guards (so a future bug is caught at the cause, not 15 tests later):
|
|
424
|
+
the shared-DNS reset verifies its own postcondition, the namecheap-ddns sim
|
|
425
|
+
refuses overwrites of protected seeded records, the runner names any test that
|
|
426
|
+
leaves shared DNS polluted, and readiness waits attach live-state diagnostics on
|
|
427
|
+
timeout instead of speculating.
|
package/bin/e2e-bake-management
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Bake celilo into the management image
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Bake celilo into the management image, then `docker commit` the result
|
|
4
|
+
* onto the `celilo-e2e/management:latest` tag. Two modes:
|
|
5
|
+
*
|
|
6
|
+
* (default) Run install.sh against the simulated celilo.computer +
|
|
7
|
+
* npm registry, installing the monorepo DEV cli exactly the
|
|
8
|
+
* way users do. Exercises install.sh end-to-end.
|
|
9
|
+
*
|
|
10
|
+
* --published Install the PUBLISHED @celilo/cli from REAL npm in a
|
|
11
|
+
* standalone container on the default docker network. The
|
|
12
|
+
* simulated topology has no route to npmjs.org, so this
|
|
13
|
+
* path deliberately runs outside it. This is what a
|
|
14
|
+
* monorepo-free npm consumer actually gets (ce-fo6).
|
|
6
15
|
*
|
|
7
16
|
* Run as the last step of `cele2e build-infra`. After this:
|
|
8
17
|
* - celilo-e2e/management:vanilla — bun + unzip + bunfig (no celilo)
|
|
9
|
-
* - celilo-e2e/management:latest — vanilla + celilo
|
|
10
|
-
* install.sh exactly the way users do
|
|
18
|
+
* - celilo-e2e/management:latest — vanilla + celilo
|
|
11
19
|
*
|
|
12
|
-
* If install
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* without install.sh succeeding.
|
|
20
|
+
* If the install regresses, this step fails build-infra loudly. The CLI is
|
|
21
|
+
* verified AFTER the source-mount shim is written and with no /celilo mount,
|
|
22
|
+
* so a broken shim fallback can't ship green (ISS-0124).
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { execSync } from 'node:child_process';
|
|
@@ -20,12 +27,6 @@ import { join } from 'node:path';
|
|
|
20
27
|
import { ensureSharedInfra } from '../src/shared-infra';
|
|
21
28
|
import { network } from '../src/network-builder';
|
|
22
29
|
|
|
23
|
-
// Absolute paths relative to this script — important because cwd at
|
|
24
|
-
// invocation time can be either the monorepo root (./cele2e wrapper)
|
|
25
|
-
// or the package root (./cele2e build-infra after the wrapper cd's
|
|
26
|
-
// into packages/e2e). Hardcoding a relative path was a real bug
|
|
27
|
-
// (`packages/e2e/packages/e2e/...` doubling when cwd was already the
|
|
28
|
-
// package dir) and showed up as a silent bake failure.
|
|
29
30
|
const PACKAGE_ROOT = join(import.meta.dir, '..');
|
|
30
31
|
const COMPOSE_FILE = join(PACKAGE_ROOT, 'docker-compose.test.yml');
|
|
31
32
|
|
|
@@ -33,42 +34,106 @@ function run(cmd: string): string {
|
|
|
33
34
|
return execSync(cmd, { stdio: 'pipe', encoding: 'utf-8' });
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
interface ExecResult {
|
|
38
|
+
exitCode: number;
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
}
|
|
42
|
+
type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the source-mount dev-loop shim to /usr/local/bin/celilo and verify
|
|
46
|
+
* the CLI works through it. Shared by both bake modes.
|
|
47
|
+
*
|
|
48
|
+
* The shim (at /usr/local/bin/celilo so plain `docker exec bash -c celilo`
|
|
49
|
+
* finds it) prefers a mounted /celilo workspace, else falls through to the
|
|
50
|
+
* real installed CLI at /root/.bun/bin/celilo. Two things must hold, both
|
|
51
|
+
* bitten in ISS-0124:
|
|
52
|
+
*
|
|
53
|
+
* 1. `bun add -g` links the celilo binary at /usr/local/bin/celilo as a
|
|
54
|
+
* SYMLINK into the package (bun's global bin dir in this image is
|
|
55
|
+
* /usr/local/bin, where bun itself lives). Writing the shim with
|
|
56
|
+
* `> /usr/local/bin/celilo` would follow that symlink and clobber the
|
|
57
|
+
* package's OWN launcher — turning the fallback into an infinite loop.
|
|
58
|
+
* So we `rm -f` the symlink first and write a fresh regular file.
|
|
59
|
+
* 2. The fallback target /root/.bun/bin/celilo doesn't exist after a
|
|
60
|
+
* /usr/local/bin install, so we point a guaranteed symlink at the
|
|
61
|
+
* package launcher first. Guarded so an image where bun genuinely
|
|
62
|
+
* lives under /root/.bun is left untouched.
|
|
63
|
+
*
|
|
64
|
+
* Verify runs AFTER the shim and with NO /celilo mount — i.e. exactly the
|
|
65
|
+
* shipped path — so a dangling/looping fallback fails the bake instead of
|
|
66
|
+
* shipping green.
|
|
67
|
+
*/
|
|
68
|
+
async function installShimAndVerify(exec: Exec, label: string): Promise<void> {
|
|
69
|
+
process.stdout.write(' source-mount shim ...... ');
|
|
70
|
+
const t0 = Date.now();
|
|
71
|
+
const shim = [
|
|
72
|
+
'#!/bin/bash',
|
|
73
|
+
'if [ -d /celilo/apps/celilo ]; then',
|
|
74
|
+
' exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"',
|
|
75
|
+
'fi',
|
|
76
|
+
'exec /root/.bun/bin/celilo "$@"',
|
|
77
|
+
].join('\n');
|
|
78
|
+
const shimBase64 = Buffer.from(shim).toString('base64');
|
|
79
|
+
const shimResult = await exec(
|
|
80
|
+
'mkdir -p /root/.bun/bin && ' +
|
|
81
|
+
'{ [ -e /root/.bun/bin/celilo ] || ' +
|
|
82
|
+
'ln -s /root/.bun/install/global/node_modules/@celilo/cli/bin/celilo /root/.bun/bin/celilo; } && ' +
|
|
83
|
+
'rm -f /usr/local/bin/celilo && ' +
|
|
84
|
+
`echo ${shimBase64} | base64 -d > /usr/local/bin/celilo && chmod +x /usr/local/bin/celilo`,
|
|
85
|
+
);
|
|
86
|
+
if (shimResult.exitCode !== 0) {
|
|
87
|
+
throw new Error(`Shim install failed: ${shimResult.stderr}`);
|
|
88
|
+
}
|
|
89
|
+
console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
90
|
+
|
|
91
|
+
process.stdout.write(' verify celilo --version ');
|
|
92
|
+
const t1 = Date.now();
|
|
93
|
+
const verify = await exec('celilo --version 2>&1');
|
|
94
|
+
if (verify.exitCode !== 0) {
|
|
95
|
+
console.log(`✗ exit ${verify.exitCode}`);
|
|
96
|
+
console.error('--- celilo --version output ---');
|
|
97
|
+
console.error(verify.stdout);
|
|
98
|
+
console.error(verify.stderr);
|
|
99
|
+
throw new Error(
|
|
100
|
+
`celilo --version failed after shim (${label}) — image is broken (dangling shim fallback?)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s (${verify.stdout.trim()})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Default bake: install.sh against the simulated celilo.computer / npm
|
|
108
|
+
* registry, inside the standard topology.
|
|
109
|
+
*/
|
|
110
|
+
async function bakeViaSim(): Promise<void> {
|
|
37
111
|
console.log('▸ Baking celilo into management:latest via install.sh');
|
|
38
112
|
console.log('');
|
|
39
113
|
|
|
40
|
-
// Step 1: shared infra (DNS, Pebble, simulators).
|
|
41
114
|
process.stdout.write(' shared-infra ........... ');
|
|
42
115
|
const t0 = Date.now();
|
|
43
116
|
await ensureSharedInfra();
|
|
44
117
|
console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
45
118
|
|
|
46
|
-
// Step 2: per-test stack with vanilla management. Default topology so
|
|
47
|
-
// celilo.computer resolution exercises fw-isp + fw-ext.
|
|
48
119
|
process.stdout.write(' per-test stack ......... ');
|
|
49
120
|
const t1 = Date.now();
|
|
50
|
-
const net = await network()
|
|
51
|
-
.topology('default')
|
|
52
|
-
.managementVariant('vanilla')
|
|
53
|
-
.start();
|
|
121
|
+
const net = await network().topology('default').managementVariant('vanilla').start();
|
|
54
122
|
console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s (${net.projectName})`);
|
|
55
123
|
|
|
124
|
+
const exec: Exec = (cmd, timeoutMs) => net.exec('management', cmd, timeoutMs);
|
|
125
|
+
|
|
56
126
|
let containerId = '';
|
|
57
127
|
try {
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// curl-pipe-bash exits 0 silently — install.sh "ran" in 0s and
|
|
63
|
-
// no celilo gets installed. We poll an actual HTTPS GET to
|
|
64
|
-
// celilo.computer to confirm the cert chain is verifiable from
|
|
65
|
-
// inside the container.
|
|
128
|
+
// Wait for the management container's Pebble CA fetch to complete so
|
|
129
|
+
// the curl-pipe-bash below can verify TLS to celilo.computer. Without
|
|
130
|
+
// pipefail an early curl failure returns 0 silently and install.sh
|
|
131
|
+
// "runs" in 0s installing nothing.
|
|
66
132
|
process.stdout.write(' pebble CA ready .......... ');
|
|
67
133
|
const tCa = Date.now();
|
|
68
134
|
let caReady = false;
|
|
69
135
|
for (let i = 0; i < 30; i++) {
|
|
70
|
-
const probe = await
|
|
71
|
-
'management',
|
|
136
|
+
const probe = await exec(
|
|
72
137
|
'curl -fsSL --max-time 3 https://celilo.computer/install.sh -o /dev/null 2>&1',
|
|
73
138
|
5_000,
|
|
74
139
|
);
|
|
@@ -86,15 +151,9 @@ async function main(): Promise<void> {
|
|
|
86
151
|
}
|
|
87
152
|
console.log(`✔ ${Math.round((Date.now() - tCa) / 1000)}s`);
|
|
88
153
|
|
|
89
|
-
// Step 3: run install.sh inside the vanilla management container.
|
|
90
|
-
// install.sh is non-interactive by default (post commit 1d5f2c1) —
|
|
91
|
-
// no flags needed. `set -o pipefail` is critical: without it, a
|
|
92
|
-
// curl failure (TLS, network, etc.) returns bash's empty-stdin
|
|
93
|
-
// exit (0) and we silently "succeed" without installing anything.
|
|
94
154
|
process.stdout.write(' install.sh ............. ');
|
|
95
155
|
const t2 = Date.now();
|
|
96
|
-
const result = await
|
|
97
|
-
'management',
|
|
156
|
+
const result = await exec(
|
|
98
157
|
'set -o pipefail; curl -fsSL https://celilo.computer/install.sh | bash 2>&1',
|
|
99
158
|
180_000,
|
|
100
159
|
);
|
|
@@ -107,50 +166,9 @@ async function main(): Promise<void> {
|
|
|
107
166
|
}
|
|
108
167
|
console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
|
|
109
168
|
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
// confirmed install.sh produced a working celilo." Using bash -lc so
|
|
113
|
-
// /root/.bashrc's PATH munging takes effect, mirroring the real-user
|
|
114
|
-
// experience after install.
|
|
115
|
-
process.stdout.write(' verify celilo --version ');
|
|
116
|
-
const t3 = Date.now();
|
|
117
|
-
const verify = await net.exec('management', 'bash -lc "celilo --version" 2>&1');
|
|
118
|
-
if (verify.exitCode !== 0) {
|
|
119
|
-
console.log(`✗ exit ${verify.exitCode}`);
|
|
120
|
-
console.error('--- celilo --version output ---');
|
|
121
|
-
console.error(verify.stdout);
|
|
122
|
-
console.error(verify.stderr);
|
|
123
|
-
throw new Error('celilo --version failed after install.sh — install is broken');
|
|
124
|
-
}
|
|
125
|
-
console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
|
|
126
|
-
|
|
127
|
-
// Step 5: install the source-mount shim so the dev loop (mount
|
|
128
|
-
// /celilo into a running test) keeps working — same shim the old
|
|
129
|
-
// Dockerfile.management baked in. Done at runtime here so install.sh
|
|
130
|
-
// owns the canonical celilo path; the shim is purely a dev
|
|
131
|
-
// convenience layered on top. The shim sits at /usr/local/bin/celilo
|
|
132
|
-
// so non-login shells (i.e. plain `docker exec bash -c "celilo …"`,
|
|
133
|
-
// which is what `net.celilo()` does) find it on default PATH.
|
|
134
|
-
process.stdout.write(' source-mount shim ...... ');
|
|
135
|
-
const t4 = Date.now();
|
|
136
|
-
const shim = [
|
|
137
|
-
'#!/bin/bash',
|
|
138
|
-
'if [ -d /celilo/apps/celilo ]; then',
|
|
139
|
-
' exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"',
|
|
140
|
-
'fi',
|
|
141
|
-
'exec /root/.bun/bin/celilo "$@"',
|
|
142
|
-
].join('\n');
|
|
143
|
-
const shimBase64 = Buffer.from(shim).toString('base64');
|
|
144
|
-
const shimResult = await net.exec(
|
|
145
|
-
'management',
|
|
146
|
-
`echo ${shimBase64} | base64 -d > /usr/local/bin/celilo && chmod +x /usr/local/bin/celilo`,
|
|
147
|
-
);
|
|
148
|
-
if (shimResult.exitCode !== 0) {
|
|
149
|
-
throw new Error(`Shim install failed: ${shimResult.stderr}`);
|
|
150
|
-
}
|
|
151
|
-
console.log(`✔ ${Math.round((Date.now() - t4) / 1000)}s`);
|
|
169
|
+
// Shim + verify (verify is post-shim, no mount — the shipped path).
|
|
170
|
+
await installShimAndVerify(exec, 'sim');
|
|
152
171
|
|
|
153
|
-
// Step 6: docker commit the baked container onto the :latest tag.
|
|
154
172
|
process.stdout.write(' docker commit .......... ');
|
|
155
173
|
const t5 = Date.now();
|
|
156
174
|
containerId = run(
|
|
@@ -163,15 +181,126 @@ async function main(): Promise<void> {
|
|
|
163
181
|
console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
|
|
164
182
|
} finally {
|
|
165
183
|
process.stdout.write(' tearing down ........... ');
|
|
166
|
-
const
|
|
184
|
+
const tDown = Date.now();
|
|
167
185
|
await net.stop();
|
|
168
|
-
console.log(`✔ ${Math.round((Date.now() -
|
|
186
|
+
console.log(`✔ ${Math.round((Date.now() - tDown) / 1000)}s`);
|
|
169
187
|
}
|
|
170
188
|
|
|
171
189
|
console.log('');
|
|
172
190
|
console.log('✓ celilo-e2e/management:latest baked from install.sh');
|
|
173
191
|
}
|
|
174
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Published bake: install the PUBLISHED @celilo/cli from REAL npm in a
|
|
195
|
+
* standalone vanilla container on the default docker network (real
|
|
196
|
+
* internet). The simulated topology has no route to npmjs.org, so we run
|
|
197
|
+
* outside it.
|
|
198
|
+
*/
|
|
199
|
+
async function bakeViaPublished(): Promise<void> {
|
|
200
|
+
console.log('▸ Baking PUBLISHED @celilo/cli into management:latest from real npm');
|
|
201
|
+
console.log('');
|
|
202
|
+
|
|
203
|
+
const name = 'celilo-bake-published';
|
|
204
|
+
const dexec: Exec = (cmd) => {
|
|
205
|
+
try {
|
|
206
|
+
const stdout = execSync(`docker exec ${name} bash -c ${JSON.stringify(cmd)}`, {
|
|
207
|
+
stdio: 'pipe',
|
|
208
|
+
encoding: 'utf-8',
|
|
209
|
+
});
|
|
210
|
+
return Promise.resolve({ stdout, stderr: '', exitCode: 0 });
|
|
211
|
+
} catch (e: unknown) {
|
|
212
|
+
const err = e as { stdout?: Buffer; stderr?: Buffer; status?: number };
|
|
213
|
+
return Promise.resolve({
|
|
214
|
+
stdout: err.stdout?.toString() ?? '',
|
|
215
|
+
stderr: err.stderr?.toString() ?? '',
|
|
216
|
+
exitCode: err.status ?? 1,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Fresh container every bake — a stale one may have half-applied global state.
|
|
222
|
+
try {
|
|
223
|
+
run(`docker rm -f ${name}`);
|
|
224
|
+
} catch {
|
|
225
|
+
// no stale container — fine
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
process.stdout.write(' start vanilla container ');
|
|
229
|
+
const t0 = Date.now();
|
|
230
|
+
// Default bridge network → real internet (real npm, real DNS).
|
|
231
|
+
run(`docker run -d --name ${name} celilo-e2e/management:vanilla sleep infinity`);
|
|
232
|
+
console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Reset bun global state before installing from real npm:
|
|
236
|
+
// - the @celilo scope override (bunfig) pins @celilo to the sim
|
|
237
|
+
// registry (npm-registry.lab), unresolvable on the default bridge —
|
|
238
|
+
// move it aside so `bun add -g` reaches real npm.
|
|
239
|
+
// - a prior monorepo bake leaves bun.lock pinning @celilo tarball URLs
|
|
240
|
+
// to the sim registry; every later `bun add -g` then dies with
|
|
241
|
+
// FailedToOpenSocket regardless of cache clearing (ce-fo6). Nuke it.
|
|
242
|
+
process.stdout.write(' reset bun state ........ ');
|
|
243
|
+
const t1 = Date.now();
|
|
244
|
+
const reset = await dexec(
|
|
245
|
+
'mv /root/.bunfig.toml /root/.bunfig.toml.sim 2>/dev/null; ' +
|
|
246
|
+
'rm -f /root/.bun/install/global/bun.lock; true',
|
|
247
|
+
);
|
|
248
|
+
if (reset.exitCode !== 0) {
|
|
249
|
+
throw new Error(`Failed to reset bun state: ${reset.stderr}`);
|
|
250
|
+
}
|
|
251
|
+
console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s`);
|
|
252
|
+
|
|
253
|
+
process.stdout.write(' bun add -g (real npm) .. ');
|
|
254
|
+
const t2 = Date.now();
|
|
255
|
+
const add = await dexec(
|
|
256
|
+
'bun add -g @celilo/cli@alpha @celilo/event-bus@alpha @celilo/e2e@alpha 2>&1',
|
|
257
|
+
);
|
|
258
|
+
if (add.exitCode !== 0) {
|
|
259
|
+
console.log(`✗ exit ${add.exitCode}`);
|
|
260
|
+
console.error('--- bun add -g output ---');
|
|
261
|
+
console.error(add.stdout.slice(-3000));
|
|
262
|
+
console.error(add.stderr.slice(-2000));
|
|
263
|
+
throw new Error('bun add -g of published @celilo/* from real npm failed');
|
|
264
|
+
}
|
|
265
|
+
console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
|
|
266
|
+
|
|
267
|
+
// Shim + verify (verify is post-shim, no mount — the shipped path).
|
|
268
|
+
await installShimAndVerify(dexec, 'published');
|
|
269
|
+
|
|
270
|
+
// Restore the sim @celilo scope override so the committed image is
|
|
271
|
+
// drop-in compatible with the simulated topology it runs tests in.
|
|
272
|
+
// ponytail: inert at test runtime (nothing re-fetches @celilo), kept
|
|
273
|
+
// only for parity with the sim-baked image.
|
|
274
|
+
await dexec('mv /root/.bunfig.toml.sim /root/.bunfig.toml 2>/dev/null; true');
|
|
275
|
+
|
|
276
|
+
process.stdout.write(' docker commit .......... ');
|
|
277
|
+
const t3 = Date.now();
|
|
278
|
+
run(`docker commit ${name} celilo-e2e/management:latest`);
|
|
279
|
+
console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
|
|
280
|
+
} finally {
|
|
281
|
+
process.stdout.write(' removing container ..... ');
|
|
282
|
+
const t4 = Date.now();
|
|
283
|
+
try {
|
|
284
|
+
run(`docker rm -f ${name}`);
|
|
285
|
+
} catch {
|
|
286
|
+
// best-effort
|
|
287
|
+
}
|
|
288
|
+
console.log(`✔ ${Math.round((Date.now() - t4) / 1000)}s`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.log('');
|
|
292
|
+
console.log('✓ celilo-e2e/management:latest baked from PUBLISHED @celilo/cli');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function main(): Promise<void> {
|
|
296
|
+
const published = process.argv.includes('--published');
|
|
297
|
+
if (published) {
|
|
298
|
+
await bakeViaPublished();
|
|
299
|
+
} else {
|
|
300
|
+
await bakeViaSim();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
175
304
|
main().catch((err) => {
|
|
176
305
|
console.error('');
|
|
177
306
|
console.error(err);
|
|
@@ -8,22 +8,39 @@ chmod 700 /root/.ssh
|
|
|
8
8
|
chmod 600 /root/.ssh/authorized_keys
|
|
9
9
|
/usr/sbin/sshd
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# IP forwarding is set via sysctls in docker-compose
|
|
12
|
+
iptables -P FORWARD ACCEPT
|
|
13
|
+
|
|
14
|
+
# Routing + egress NAT — depends on topology (detected by the presence of an
|
|
15
|
+
# isp-external interface).
|
|
12
16
|
ip route del default 2>/dev/null || true
|
|
13
17
|
if ip -o addr show | grep -q '100.100.0'; then
|
|
14
|
-
# Direct
|
|
18
|
+
# Direct-internet: fw-main has its own external (WAN) interface.
|
|
15
19
|
ip route add default via 100.100.0.101
|
|
20
|
+
# Egress NAT SCOPED to the external/WAN interface (the one with the public
|
|
21
|
+
# 100.100.0.x address) — NOT a blanket MASQUERADE (ISS-0156). Scoping to the WAN
|
|
22
|
+
# means inter-zone traffic (protected↔protected, protected↔internal) keeps its
|
|
23
|
+
# source, so the dmz-resident resolver sees each client's real zone for
|
|
24
|
+
# source-based DNS views. This boot-time rule is what target machines need for
|
|
25
|
+
# egress/DNS BEFORE any module deploys; celilo's iptables module applies the
|
|
26
|
+
# same WAN-scoped MASQUERADE on deploy (idempotent — the production code path).
|
|
27
|
+
EXT_IFACE=$(ip -o addr show | awk '/100\.100\.0\./{print $2; exit}')
|
|
28
|
+
iptables -t nat -A POSTROUTING -o "$EXT_IFACE" -j MASQUERADE
|
|
16
29
|
else
|
|
17
|
-
#
|
|
30
|
+
# Two-layer: no external interface on fw-main; it routes outbound to the
|
|
31
|
+
# upstream firewall (fw-isp) and MASQUERADEs toward it (egress leaves via the
|
|
32
|
+
# internal-facing interface, so there is no single WAN interface to scope to).
|
|
18
33
|
ip route add default via 192.168.0.1
|
|
34
|
+
# Protected↔protected (dmz/app/secure ↔ each other, all in 10/8) is NOT NAT'd —
|
|
35
|
+
# a real firewall routes between its segmented zones without NAT, preserving the
|
|
36
|
+
# client source so the dmz-resident resolver can serve source-based split-horizon
|
|
37
|
+
# (ISS-0156). Only protected↔internal and egress get MASQUERADE'd below. Without
|
|
38
|
+
# this RETURN, app→dmz and internal→dmz would both be SNAT'd to the dmz gateway
|
|
39
|
+
# and become indistinguishable (confirmed via live probe).
|
|
40
|
+
iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -d 10.0.0.0/8 -j RETURN
|
|
41
|
+
iptables -t nat -A POSTROUTING -j MASQUERADE
|
|
19
42
|
fi
|
|
20
43
|
|
|
21
|
-
# IP forwarding is set via sysctls in docker-compose
|
|
22
|
-
|
|
23
|
-
# NAT all outbound traffic from dmz/app/secure
|
|
24
|
-
iptables -t nat -A POSTROUTING -j MASQUERADE
|
|
25
|
-
iptables -P FORWARD ACCEPT
|
|
26
|
-
|
|
27
44
|
# DNS
|
|
28
45
|
echo "nameserver 100.100.0.1" > /etc/resolv.conf
|
|
29
46
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,13 +34,11 @@
|
|
|
34
34
|
"npm-registry-server/tsconfig.json",
|
|
35
35
|
"npm-registry-server/src/",
|
|
36
36
|
"scripts/",
|
|
37
|
-
"README.md"
|
|
38
|
-
"AGENTS.md",
|
|
39
|
-
"COVERAGE.md"
|
|
37
|
+
"README.md"
|
|
40
38
|
],
|
|
41
39
|
"dependencies": {
|
|
42
40
|
"@celilo/cli-display": "^0.1.9",
|
|
43
|
-
"@celilo/event-bus": "^0.1.
|
|
41
|
+
"@celilo/event-bus": "^0.1.8",
|
|
44
42
|
"yaml": "^2.8.0",
|
|
45
43
|
"zod": "^3.24.1"
|
|
46
44
|
},
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isProtectedRecord, updateZone } from './zone-updater';
|
|
3
|
+
|
|
4
|
+
describe('namecheap-ddns protected records (e2e-confidence #257)', () => {
|
|
5
|
+
test('celilo.computer apex + www are protected', () => {
|
|
6
|
+
expect(isProtectedRecord('celilo.computer', '@')).toBe(true);
|
|
7
|
+
expect(isProtectedRecord('celilo.computer', '')).toBe(true);
|
|
8
|
+
expect(isProtectedRecord('celilo.computer', 'celilo.computer')).toBe(true);
|
|
9
|
+
expect(isProtectedRecord('celilo.computer', 'www')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('customer DDNS hosts are NOT protected', () => {
|
|
13
|
+
// subdomains of the reserved domain are fine — only the seeded sim records
|
|
14
|
+
// are protected
|
|
15
|
+
expect(isProtectedRecord('celilo.computer', 'app')).toBe(false);
|
|
16
|
+
expect(isProtectedRecord('celilo.computer', 'dns-int.infra')).toBe(false);
|
|
17
|
+
// other domains are entirely customer-managed
|
|
18
|
+
expect(isProtectedRecord('iamtheinternet.org', '@')).toBe(false);
|
|
19
|
+
expect(isProtectedRecord('example.net', 'www')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('updateZone refuses a protected record before touching the filesystem', () => {
|
|
23
|
+
// The guard runs before any readFileSync, so this throws the protection
|
|
24
|
+
// error (not an ENOENT) even with no /config present — proving a DDNS apex
|
|
25
|
+
// clobber of the website-sim can never reach the zone file.
|
|
26
|
+
expect(() => updateZone('celilo.computer', '@', '100.64.0.1')).toThrow(/reserved e2e infrastructure/);
|
|
27
|
+
expect(() => updateZone('celilo.computer', 'www', '100.64.0.1')).toThrow(
|
|
28
|
+
/reserved e2e infrastructure/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -3,14 +3,40 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
|
|
4
4
|
const ZONE_DIR = '/config';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Records that are reserved e2e infrastructure and must NEVER be mutated by a
|
|
8
|
+
* customer DDNS update — the website-sim's seeded names. A DDNS update with a
|
|
9
|
+
* NAT'd source IP once clobbered celilo.computer's apex off the website-sim
|
|
10
|
+
* (100.64.0.58 -> the fw-ext gateway 100.64.0.1), silently breaking install-sh
|
|
11
|
+
* for the rest of the suite. Also a real-operator footgun: a misbehaving DDNS
|
|
12
|
+
* client must not be able to overwrite a domain's apex/website record.
|
|
13
|
+
* (e2e-confidence #257.)
|
|
14
|
+
*/
|
|
15
|
+
const PROTECTED_RECORDS: Record<string, ReadonlySet<string>> = {
|
|
16
|
+
'celilo.computer': new Set(['@', '', 'celilo.computer', 'www']),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** True if (domain, host) names a protected (seeded sim) record. */
|
|
20
|
+
export function isProtectedRecord(domain: string, host: string): boolean {
|
|
21
|
+
return PROTECTED_RECORDS[domain]?.has(host) ?? false;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
/**
|
|
7
25
|
* Update an A record in a Knot zone file and reload.
|
|
8
26
|
*
|
|
9
27
|
* If the host already has an A record, it's replaced.
|
|
10
28
|
* If not, a new one is appended before the end of the file.
|
|
11
29
|
* The SOA serial is incremented on every update.
|
|
30
|
+
*
|
|
31
|
+
* Refuses to touch protected reserved-infrastructure records (PROTECTED_RECORDS).
|
|
12
32
|
*/
|
|
13
33
|
export function updateZone(domain: string, host: string, ip: string): void {
|
|
34
|
+
if (isProtectedRecord(domain, host)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Refusing DDNS update to "${host}.${domain}": it is reserved e2e infrastructure ` +
|
|
37
|
+
`(the website-sim), not a DDNS-managed host (e2e-confidence #257).`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
14
40
|
const zoneFile = `${ZONE_DIR}/${domain}.zone`;
|
|
15
41
|
let content = readFileSync(zoneFile, 'utf-8');
|
|
16
42
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { stageNetappsFromRegistry } from './build';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
const realFetch = globalThis.fetch;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), 'netapps-'));
|
|
12
|
+
process.env.CELILO_REGISTRY_URL = 'https://example.test/registry/';
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
globalThis.fetch = realFetch;
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
process.env.CELILO_REGISTRY_URL = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('fetches each module latest version to <name>.netapp via the download endpoint', async () => {
|
|
22
|
+
const seen: string[] = [];
|
|
23
|
+
// @ts-expect-error — minimal fetch stub for the two shapes we call.
|
|
24
|
+
globalThis.fetch = async (url: string) => {
|
|
25
|
+
seen.push(url);
|
|
26
|
+
if (url.includes('/api/v1/modules?')) {
|
|
27
|
+
return new Response(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
modules: [
|
|
30
|
+
{ name: 'caddy', max_version: '1.2.3' },
|
|
31
|
+
{ name: 'namecheap', max_version: '0.4.0' },
|
|
32
|
+
],
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return new Response(new Uint8Array([1, 2, 3]));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
await stageNetappsFromRegistry(dir);
|
|
40
|
+
|
|
41
|
+
expect(readdirSync(dir).sort()).toEqual(['caddy.netapp', 'namecheap.netapp']);
|
|
42
|
+
expect(existsSync(join(dir, 'caddy.netapp'))).toBe(true);
|
|
43
|
+
expect([...readFileSync(join(dir, 'caddy.netapp'))]).toEqual([1, 2, 3]);
|
|
44
|
+
// Trailing slash stripped; download hits the module's max_version.
|
|
45
|
+
expect(seen).toContain('https://example.test/registry/api/v1/modules/caddy/1.2.3/download');
|
|
46
|
+
expect(seen).toContain('https://example.test/registry/api/v1/modules/namecheap/0.4.0/download');
|
|
47
|
+
});
|