@celilo/e2e 0.8.0 → 0.9.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 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.
@@ -1,18 +1,25 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Bake celilo into the management image by running install.sh against the
4
- * simulated celilo.computer + npm registry, then `docker commit` the
5
- * result onto the `celilo-e2e/management:latest` tag.
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, provisioned via
10
- * install.sh exactly the way users do
18
+ * - celilo-e2e/management:latest — vanilla + celilo
11
19
  *
12
- * If install.sh regresses, this step fails build-infra loudly with the
13
- * script's own output. Tests cannot mask the failure by accident — the
14
- * default management image they all use literally cannot be produced
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
- async function main(): Promise<void> {
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
- // Step 2.5: wait for the management container's Pebble CA fetch to
59
- // complete. management-routes.sh kicks off the Pebble runtime-root
60
- // download in the background; if install.sh runs before that
61
- // finishes, curl fails on TLS and (without pipefail) the
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 net.exec(
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 net.exec(
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
- // Step 4: verify celilo --version actually works after install. This
111
- // is the difference between "we observed install.sh exit 0" and "we
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,133 @@ 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 t5 = Date.now();
184
+ const tDown = Date.now();
167
185
  await net.stop();
168
- console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
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@latest @celilo/event-bus@latest @celilo/e2e@latest 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
+ // The vanilla container was started with `sleep infinity`; docker commit
279
+ // persists the running Cmd, so override it back to the management image's
280
+ // real entrypoint (Dockerfile.management: CMD ["/startup.sh"], no
281
+ // ENTRYPOINT). Without this the baked image never runs /startup.sh ->
282
+ // ssh-keys volume stays empty -> fw-main crashloops (ce-um6).
283
+ run(
284
+ `docker commit --change 'CMD ["/startup.sh"]' ${name} celilo-e2e/management:latest`,
285
+ );
286
+ console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
287
+ } finally {
288
+ process.stdout.write(' removing container ..... ');
289
+ const t4 = Date.now();
290
+ try {
291
+ run(`docker rm -f ${name}`);
292
+ } catch {
293
+ // best-effort
294
+ }
295
+ console.log(`✔ ${Math.round((Date.now() - t4) / 1000)}s`);
296
+ }
297
+
298
+ console.log('');
299
+ console.log('✓ celilo-e2e/management:latest baked from PUBLISHED @celilo/cli');
300
+ }
301
+
302
+ async function main(): Promise<void> {
303
+ const published = process.argv.includes('--published');
304
+ if (published) {
305
+ await bakeViaPublished();
306
+ } else {
307
+ await bakeViaSim();
308
+ }
309
+ }
310
+
175
311
  main().catch((err) => {
176
312
  console.error('');
177
313
  console.error(err);
@@ -19,6 +19,12 @@ COPY config/apt/99retries.conf /etc/apt/apt.conf.d/99retries.conf
19
19
 
20
20
  RUN apt-get update && apt-get install -y \
21
21
  openssh-client \
22
+ # openssh-server: the celilo-bootstrap deb Depends on it (remote-API server).
23
+ # Pre-caching it here keeps the bootstrap-apt foreground `apt install` fast
24
+ # (no download over the bumped Squid path) and mirrors reality — a real
25
+ # management box already runs sshd. The deb still owns the celilo-api account
26
+ # + forced-command authorized_keys; this only pre-stages the package.
27
+ openssh-server \
22
28
  ansible \
23
29
  iproute2 \
24
30
  iputils-ping \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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",
@@ -37,8 +37,8 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "@celilo/cli-display": "^0.1.9",
41
- "@celilo/event-bus": "^0.1.7",
40
+ "@celilo/cli-display": "workspace:^",
41
+ "@celilo/event-bus": "workspace:^",
42
42
  "yaml": "^2.8.0",
43
43
  "zod": "^3.24.1"
44
44
  },
@@ -32,6 +32,7 @@
32
32
  import { execSync } from 'node:child_process';
33
33
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
+ import { ensureRegistryServerBundle } from '../src/registry-bundle';
35
36
 
36
37
  const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
37
38
 
@@ -187,6 +188,15 @@ function main(): void {
187
188
  rmSync(destination, { recursive: true, force: true });
188
189
  mkdirSync(destination, { recursive: true });
189
190
 
191
+ // @celilo/e2e's `files[]` ships `registry-server/`, but that directory is a
192
+ // .gitignored build artifact — absent in a fresh checkout, so `bun pm pack`
193
+ // would silently pack an e2e tarball WITHOUT it. A monorepo-free consumer's
194
+ // `cele2e build-infra` then dies in buildDockerImages (ensureRegistryServerBundle:
195
+ // "published without its registry-server bundle"). Regenerate it from the
196
+ // sibling packages/registry-server before packing, exactly as buildDockerImages
197
+ // does in-monorepo. (This is why npm-consumer-smoke was red — ce-3rs.)
198
+ ensureRegistryServerBundle(join(REPO_ROOT, 'packages', 'e2e'));
199
+
190
200
  console.log(`Packing @celilo/* packages → ${destination}`);
191
201
  console.log('');
192
202
 
@@ -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
+ });