@celilo/e2e 0.20.6 → 0.21.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Prove the CLI install.sh actually installed is the CLI this bake packed.
3
+ *
4
+ * `image-freshness` stamps the fingerprint of the STAGED tarballs
5
+ * (pack-manifest.json). That is an attestation of intent: it says what the bake
6
+ * meant to install, never what it installed. The two diverge whenever the
7
+ * tarballs reach the container by a path that can go stale — the npm-registry
8
+ * sim COPYs `.npm-registry-cache/` at docker-BUILD time, so a sim image that
9
+ * failed to rebuild keeps serving last week's tarball while the manifest beside
10
+ * it describes today's tree (celilo#1423, and the staging half in celilo#1299).
11
+ *
12
+ * Measured 2026-09-25: doctor reported `baked from this tree (2496fd8eaf07f66f)`
13
+ * over an image whose installed `@celilo/cli` was the pre-fix revision. A
14
+ * 16-minute run on a P1 was then read as falsifying a correct hypothesis — a fix
15
+ * that did not ship looks exactly like a fix that did not work, and this gate is
16
+ * the one instrument whose job is telling those apart.
17
+ *
18
+ * So: compare bytes. Copy the staged tarball into the container, digest the
19
+ * files it contains from BOTH the extracted tarball and the installed package,
20
+ * and fail the bake when they differ — before `docker commit` can stamp a label
21
+ * that would outlive the mistake. Fail-closed at bake time beats a warning at
22
+ * doctor time: the wrong image never gets committed.
23
+ *
24
+ * Only paths present in the TARBALL are digested. An installer is free to add
25
+ * files (bun does not, today) and a test that forbade it would fail for a reason
26
+ * nobody cares about; a tarball file that is MISSING from the install still
27
+ * fails, because sha256sum errors on it.
28
+ */
29
+
30
+ /** Where install.sh leaves the global @celilo/cli inside the management image. */
31
+ export const INSTALLED_CLI_DIR = '/root/.bun/install/global/node_modules/@celilo/cli';
32
+
33
+ /** Container paths the probe uses for its own scratch. */
34
+ const STAGED_TARBALL = '/tmp/cli-staged.tgz';
35
+ const STAGED_DIR = '/tmp/cli-staged';
36
+
37
+ /**
38
+ * The shell script that prints both digests.
39
+ *
40
+ * Returned as text for the caller to base64 into the container. It must NOT be
41
+ * interpolated into a dockerExec command directly: dockerExec wraps the command
42
+ * as `bash -c "<JSON>"` and hands it to a HOST shell, which expands `$(...)`
43
+ * and `$VAR` itself before the container ever sees them (celilo#1365 lost days
44
+ * to exactly that, via `$?`). base64 has no metacharacters.
45
+ */
46
+ export function digestScript(installedDir: string = INSTALLED_CLI_DIR): string {
47
+ return [
48
+ '#!/bin/bash',
49
+ 'set -euo pipefail',
50
+ `rm -rf ${STAGED_DIR}`,
51
+ `mkdir -p ${STAGED_DIR}`,
52
+ `tar -xzf ${STAGED_TARBALL} -C ${STAGED_DIR}`,
53
+ // npm/bun tarballs root everything under `package/`.
54
+ `S=${STAGED_DIR}/package`,
55
+ `I=${installedDir}`,
56
+ // One list, taken from the tarball, digested against both trees — so the
57
+ // comparison is over the same paths in the same order on both sides.
58
+ // sha256sum on the container, shasum on a macOS dev box. Resolved once so
59
+ // the gate below is runnable everywhere — a test that only runs in CI is a
60
+ // test nobody has watched fail.
61
+ 'if command -v sha256sum >/dev/null; then SUM="sha256sum"; else SUM="shasum -a 256"; fi',
62
+ 'cd "$S" && find . -type f -print | LC_ALL=C sort > /tmp/cli-files',
63
+ // `while read` rather than xargs: portable, and it keeps one file per line
64
+ // so a missing file still errors the pipeline under `set -o pipefail`.
65
+ 'digest() { (cd "$1" && while IFS= read -r f; do $SUM "$f"; done < /tmp/cli-files) | $SUM | cut -d" " -f1; }',
66
+ 'echo "staged=$(digest "$S")"',
67
+ 'echo "installed=$(digest "$I")"',
68
+ ].join('\n');
69
+ }
70
+
71
+ export interface DigestPair {
72
+ staged: string;
73
+ installed: string;
74
+ }
75
+
76
+ /**
77
+ * Pull the two digests out of the probe's output.
78
+ *
79
+ * Throws rather than returning a partial: an unreadable probe is the
80
+ * can't-fail-shaped failure this whole file exists to prevent, so it must not
81
+ * degrade into a pass.
82
+ */
83
+ export function parseDigests(output: string): DigestPair {
84
+ const read = (key: string): string => {
85
+ const match = output.match(new RegExp(`^${key}=([0-9a-f]{64})$`, 'm'));
86
+ if (!match) {
87
+ throw new Error(
88
+ `installed-CLI probe printed no ${key} digest — it did not run to completion.\n` +
89
+ `--- probe output ---\n${output.trim() || '(empty)'}`,
90
+ );
91
+ }
92
+ return match[1] as string;
93
+ };
94
+ return { staged: read('staged'), installed: read('installed') };
95
+ }
96
+
97
+ /** The bake-stopping message for a mismatch, naming the cause worth checking first. */
98
+ export function mismatchMessage({ staged, installed }: DigestPair): string {
99
+ return `The installed @celilo/cli is NOT the tarball this bake staged.\n staged tarball: ${staged}\n installed CLI: ${installed}\ninstall.sh fetched a DIFFERENT build from the npm-registry sim. The sim image COPYs .npm-registry-cache/ at docker-build time, so re-staging tarballs does nothing until that image is rebuilt — and build.ts scans image directories in sorted order and exits on the first failure, so an image failing earlier in the alphabet (minio, say) strands the registry sim while a usable-looking :latest remains. Rebuild the sims: \`cele2e down && cele2e build-infra\`.\nRefusing to commit — a stamped label over the wrong CLI is celilo#1423.`;
100
+ }
101
+
102
+ export { STAGED_TARBALL, STAGED_DIR };
package/src/parse-line.ts CHANGED
@@ -166,7 +166,13 @@ export function parseLine(
166
166
  // for failing tests.
167
167
  const errorMatch = line.match(/\b(?:[A-Z]\w*)?Error: (.*)/);
168
168
  if (errorMatch && !line.includes('bun test') && !line.includes('ipv4: Address already')) {
169
- const err = errorMatch[1].slice(0, 70);
170
- display.subEvent(`${red}✗${reset} ${err}`);
169
+ // Pass the whole message. `subEvent` already fits it to the terminal
170
+ // and appends a visible `…` when it clips, so a second fixed-width
171
+ // slice here only made the clip SILENT and narrower than the screen.
172
+ // Measured 2026-09-25: a 6b stage-1 failure rendered as
173
+ // "No infrastructure " — exactly 70 chars, cutting immediately before
174
+ // the zone name, which is the one fact that localises the defect. It
175
+ // read as a complete sentence and cost a wrong diagnosis.
176
+ display.subEvent(`${red}✗${reset} ${errorMatch[1]}`);
171
177
  }
172
178
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Recurrence gate for celilo#1430: dns-int logged `internal: command not found`
3
+ * on every boot.
4
+ *
5
+ * The unbound config was written through an UNQUOTED heredoc, so a backtick in
6
+ * a comment ran as a command and the split-horizon block was passed as
7
+ * printf's FORMAT STRING. Both are silent: unbound still started, so the only
8
+ * symptom was noise in the middle of the resolver's readiness path.
9
+ *
10
+ * The check runs the real script's config-rendering half in bash and asserts
11
+ * nothing reaches stderr — the one signal a shell-injected comment produces.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { mkdtempSync, readFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const SCRIPT = join(import.meta.dir, '../config/routing/resolver-internal-routes.sh');
21
+
22
+ /** Run the script with `ip`/`unbound` stubbed and the config written to stdout. */
23
+ function render(domains: string): { config: string; stderr: string } {
24
+ const harness = `
25
+ PATH="$PWD/.stub:$PATH"
26
+ mkdir -p .stub
27
+ printf '#!/bin/sh\\nexit 0\\n' > .stub/ip
28
+ printf '#!/bin/sh\\nexit 0\\n' > .stub/unbound
29
+ printf '#!/bin/sh\\nexit 0\\n' > .stub/sleep
30
+ chmod +x .stub/ip .stub/unbound .stub/sleep
31
+ mkdir -p etc/unbound
32
+ sed -e 's#/etc/unbound/unbound.conf#etc/unbound/unbound.conf#' \
33
+ -e 's#/usr/sbin/unbound#unbound#' ${JSON.stringify(SCRIPT)} > run.sh
34
+ bash run.sh > /dev/null
35
+ cat etc/unbound/unbound.conf
36
+ `;
37
+ const r = spawnSync('bash', ['-c', harness], {
38
+ cwd: mkdtempSync(join(tmpdir(), 'celilo-1430-')),
39
+ encoding: 'utf8',
40
+ env: { ...process.env, DOMAINS: domains },
41
+ });
42
+ return { config: r.stdout ?? '', stderr: r.stderr ?? '' };
43
+ }
44
+
45
+ describe('resolver-internal-routes.sh', () => {
46
+ test('renders a multi-domain config with nothing on stderr', () => {
47
+ const { config, stderr } = render('iamtheinternet.org,example.net');
48
+ expect(stderr).toBe('');
49
+ expect(config).toContain('local-zone: "iamtheinternet.org." redirect');
50
+ expect(config).toContain('local-zone: "example.net." redirect');
51
+ expect(config).not.toContain('\\n');
52
+ });
53
+
54
+ test('a single domain renders the same shape', () => {
55
+ const { config, stderr } = render('iamtheinternet.org');
56
+ expect(stderr).toBe('');
57
+ expect(config).toContain('local-zone: "iamtheinternet.org." redirect');
58
+ expect(config).not.toContain('example.net');
59
+ });
60
+
61
+ test('the config heredocs are quoted, so a comment can never run', () => {
62
+ const src = readFileSync(SCRIPT, 'utf8');
63
+ expect(src).not.toMatch(/<<\s*EOF/);
64
+ });
65
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { pebbleMintedANewRoot } from './shared-infra';
3
+
4
+ /**
5
+ * Pebble mints a random root CA at every startup and Caddy caches the cert it
6
+ * was issued, so a Pebble that restarts without its dependants restarting
7
+ * leaves them serving a cert from a root that no longer exists (celilo#1427).
8
+ * Recreating the sims is how that is avoided — but recreating them when Pebble
9
+ * did NOT restart costs a rebuild on every run, so the predicate has to be
10
+ * exact in both directions.
11
+ */
12
+ describe('pebbleMintedANewRoot', () => {
13
+ test('a different container id means a new root', () => {
14
+ expect(pebbleMintedANewRoot('abc123', 'def456')).toBe(true);
15
+ });
16
+
17
+ test('the same container id means the root is unchanged', () => {
18
+ expect(pebbleMintedANewRoot('abc123', 'abc123')).toBe(false);
19
+ });
20
+
21
+ test('an unreadable id is not evidence of a restart', () => {
22
+ expect(pebbleMintedANewRoot('', 'def456')).toBe(false);
23
+ expect(pebbleMintedANewRoot('abc123', '')).toBe(false);
24
+ expect(pebbleMintedANewRoot('', '')).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { generateSharedInfraYaml } from './docker-compose-generator';
3
+
4
+ /**
5
+ * Shared infrastructure must be self-contained.
6
+ *
7
+ * Shared infra starts BEFORE any per-test stack exists, so a shared service
8
+ * that names a per-test address gets no answer at all until a test comes up.
9
+ * That is celilo#1365: the celilo.computer simulator resolved ACME through
10
+ * comcast-resolver (203.0.113.1, per-test) and Pebble validated challenges
11
+ * through the same address, so Caddy could not obtain a cert during
12
+ * shared-infra bringup. The bake then probed https://celilo.computer within
13
+ * 30s, got whatever Caddy serves without an ACME cert, and failed TLS
14
+ * verification — "unable to get local issuer certificate", which reads as a
15
+ * CA-trust bug rather than a startup-order one.
16
+ *
17
+ * The check is derived, not hand-listed: every address shared infra POINTS AT
18
+ * for name resolution must belong to a service shared infra itself defines.
19
+ */
20
+ describe('shared infra is self-contained', () => {
21
+ const yaml = generateSharedInfraYaml();
22
+ const compose = Bun.YAML.parse(yaml) as {
23
+ services: Record<
24
+ string,
25
+ { dns?: string[]; command?: string; networks?: Record<string, { ipv4_address?: string }> }
26
+ >;
27
+ };
28
+
29
+ const ownAddresses = new Set(
30
+ Object.values(compose.services).flatMap((svc) =>
31
+ Object.values(svc.networks ?? {})
32
+ .map((n) => n.ipv4_address)
33
+ .filter((ip): ip is string => Boolean(ip)),
34
+ ),
35
+ );
36
+
37
+ // Every resolver a shared service is pointed at, as {who, address}.
38
+ const resolverRefs = Object.entries(compose.services).flatMap(([name, svc]) => {
39
+ const refs = (svc.dns ?? []).map((ip) => ({ name, field: 'dns', ip }));
40
+ const dnsserver = /-dnsserver\s+(\d+\.\d+\.\d+\.\d+):/.exec(svc.command ?? '');
41
+ if (dnsserver) refs.push({ name, field: '-dnsserver', ip: dnsserver[1] });
42
+ return refs;
43
+ });
44
+
45
+ test('the generator actually emits resolver references to check', () => {
46
+ expect(resolverRefs.length).toBeGreaterThan(0);
47
+ });
48
+
49
+ test('no shared service resolves through an address shared infra does not own', () => {
50
+ const foreign = resolverRefs.filter((ref) => !ownAddresses.has(ref.ip));
51
+ expect(foreign.map((r) => `${r.name}.${r.field} -> ${r.ip}`)).toEqual([]);
52
+ });
53
+ });
@@ -161,6 +161,65 @@ function getE2eDir(): string {
161
161
  * Start the shared infrastructure. Idempotent — if already running,
162
162
  * verifies health and returns immediately.
163
163
  */
164
+ /**
165
+ * Services whose Caddy obtains an ACME cert from Pebble and then CACHES it
166
+ * until expiry.
167
+ */
168
+ const PEBBLE_DEPENDENT_SIMS = ['celilo-website', 'isitup'];
169
+
170
+ function containerIdOf(e2eDir: string, service: string): string {
171
+ try {
172
+ return run(
173
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps -q ${service}`,
174
+ {
175
+ cwd: e2eDir,
176
+ timeout: 15_000,
177
+ },
178
+ ).trim();
179
+ } catch {
180
+ return '';
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Pebble mints a RANDOM root CA at every startup. Caddy caches the cert it was
186
+ * issued and will not re-request until expiry, so a Pebble that restarts
187
+ * WITHOUT its dependants restarting leaves them serving a cert signed by a root
188
+ * that no longer exists. Every probe that waits for HTTPS to VERIFY then waits
189
+ * out its whole budget, which is why celilo#1365 and celilo#1427 both presented
190
+ * as timeouts: `curl -k` returns 200 the entire time.
191
+ *
192
+ * `docker compose up -d` recreates only what changed, so this is the ordinary
193
+ * case, not an edge one — a rebuilt Pebble image against an unchanged website
194
+ * sim produces it every time.
195
+ *
196
+ * Restarting the sim alone does not fix it: it comes back on Docker's embedded
197
+ * resolver and cannot reach the simulated ACME endpoint at all (measured,
198
+ * crew-builder, celilo#1427). Recreating it through compose keeps its
199
+ * configured `dns:`, so it re-runs ACME against the live root.
200
+ */
201
+ export function pebbleMintedANewRoot(idBefore: string, idAfter: string): boolean {
202
+ // An empty id on either side means "we could not tell" — Pebble was absent
203
+ // before (first start: the sims come up fresh anyway) or could not be read
204
+ // after. Neither is evidence of a restart, and recreating on no evidence
205
+ // would rebuild the sims on every single run.
206
+ if (!idBefore || !idAfter) return false;
207
+ return idBefore !== idAfter;
208
+ }
209
+
210
+ function recreateSimsWhosePebbleRestarted(e2eDir: string, pebbleIdBefore: string): void {
211
+ const pebbleIdAfter = containerIdOf(e2eDir, 'letsencrypt');
212
+ if (!pebbleMintedANewRoot(pebbleIdBefore, pebbleIdAfter)) return;
213
+
214
+ console.log(
215
+ '[progress:sub] Pebble was recreated (new root CA) — recreating the sims that cache its certs',
216
+ );
217
+ run(
218
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d --force-recreate ${PEBBLE_DEPENDENT_SIMS.join(' ')}`,
219
+ { cwd: e2eDir, timeout: 120_000 },
220
+ );
221
+ }
222
+
164
223
  export async function ensureSharedInfra(): Promise<void> {
165
224
  if (isSharedInfraRunning()) {
166
225
  // Verify DNS is still healthy
@@ -247,10 +306,12 @@ export async function ensureSharedInfra(): Promise<void> {
247
306
  // baked tag fails right here, naming the image. startNetwork checks the
248
307
  // full referenced set first and names the remedy (cele2e build-infra).
249
308
  console.log('[progress:start] starting shared infrastructure | shared infra started');
309
+ const pebbleIdBefore = containerIdOf(e2eDir, 'letsencrypt');
250
310
  run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`, {
251
311
  cwd: e2eDir,
252
312
  timeout: 120_000,
253
313
  });
314
+ recreateSimsWhosePebbleRestarted(e2eDir, pebbleIdBefore);
254
315
 
255
316
  // Wait for DNS convergence — robustly. The old single 60s window with no
256
317
  // recovery was an intermittent build-infra failure on the builder (#319:
@@ -238,11 +238,24 @@ function makeHandle(containerName: string, port: number): SocksProxyHandle {
238
238
  return { stdout, stderr: '', exitCode: 0 };
239
239
  } catch (err: unknown) {
240
240
  const e = err as { stdout?: Buffer; stderr?: Buffer; status?: number };
241
- return {
242
- stdout: e.stdout?.toString() ?? '',
243
- stderr: e.stderr?.toString() ?? String(err),
244
- exitCode: e.status ?? 1,
245
- };
241
+ const stdout = e.stdout?.toString() ?? '';
242
+ const stderr = e.stderr?.toString() ?? String(err);
243
+ // A MISSING BINARY MUST NOT READ AS AN EMPTY ANSWER. Callers assert on
244
+ // stdout — `expect(probe.stdout.trim()).toBe('302')` — so a command
245
+ // that never ran returns '' and is indistinguishable from a server that
246
+ // answered nothing. celilo#1426's stage 6 failed exactly that way: the
247
+ // proxy image had no curl, `Expected "302", Received ""`, and the
248
+ // investigation went to the tunnel, the firewall and DNS before the
249
+ // container. 127 is the shell's not-found status; surface it in stdout
250
+ // so the assertion's own diff names the cause.
251
+ if (e.status === 127) {
252
+ return {
253
+ stdout: `<command not found in the proxy container: ${stderr.trim() || cmd}>`,
254
+ stderr,
255
+ exitCode: 127,
256
+ };
257
+ }
258
+ return { stdout, stderr, exitCode: e.status ?? 1 };
246
259
  }
247
260
  },
248
261
  async stop(): Promise<void> {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tell a STALE Pebble root apart from an unfinished ACME issuance.
3
+ *
4
+ * Pebble mints a random root CA at every startup and Caddy caches its
5
+ * certificate until expiry, so a Pebble that restarted without the website
6
+ * sim restarting serves a cert whose issuing root no longer exists. The bake
7
+ * probe waits for HTTPS to VERIFY, so that trust failure spends the whole
8
+ * 240s budget and then reports a TIMEOUT — and waiting longer cannot fix it
9
+ * (celilo#1427).
10
+ *
11
+ * The two conditions need opposite responses and curl's exit code cannot
12
+ * separate them: a cold caddy that has obtained nothing yet also fails the
13
+ * handshake. The evidence that CAN is the chain itself — compare the root CN
14
+ * Pebble is serving right now against the issuers in the chain the website
15
+ * hands out.
16
+ *
17
+ * Both inputs are openssl output captured inside the management container:
18
+ * live: `curl -sk https://100.64.0.100:15000/roots/0 | openssl x509 -noout -subject`
19
+ * served: `openssl s_client -showcerts ... | grep '^ i:'`
20
+ */
21
+
22
+ export type StaleRootVerdict =
23
+ /** The served chain is signed by the root Pebble is serving now. */
24
+ | { kind: 'current' }
25
+ /** Not enough evidence — no chain served yet, or no live root readable. */
26
+ | { kind: 'unknown'; reason: string }
27
+ /** The served chain names a root Pebble no longer has. Fatal, instantly. */
28
+ | { kind: 'stale'; liveRoot: string; servedIssuers: string[] };
29
+
30
+ /** `subject=CN = Pebble Root CA 2a43b9` / `subject= CN=Pebble Root CA 2a43b9` -> the CN. */
31
+ function commonName(line: string): string {
32
+ const m = /\bCN\s*=\s*([^,/\n]+)/.exec(line);
33
+ return m ? m[1].trim() : '';
34
+ }
35
+
36
+ export function detectStaleAcmeRoot(
37
+ liveRootSubject: string,
38
+ servedChain: string,
39
+ ): StaleRootVerdict {
40
+ const liveRoot = commonName(liveRootSubject);
41
+ if (!liveRoot) {
42
+ return { kind: 'unknown', reason: 'no live Pebble root CN readable' };
43
+ }
44
+ const servedIssuers = servedChain
45
+ .split('\n')
46
+ .filter((l) => /(^|\s)i[:=]/.test(l))
47
+ .map(commonName)
48
+ .filter(Boolean);
49
+ if (servedIssuers.length === 0) {
50
+ return { kind: 'unknown', reason: 'no certificate chain served yet' };
51
+ }
52
+ if (servedIssuers.includes(liveRoot)) {
53
+ return { kind: 'current' };
54
+ }
55
+ return { kind: 'stale', liveRoot, servedIssuers };
56
+ }
57
+
58
+ export function explainStaleRoot(v: Extract<StaleRootVerdict, { kind: 'stale' }>): string {
59
+ return [
60
+ `celilo.computer is serving a certificate from a DEAD Pebble root. Chain issuers: ${v.servedIssuers.join(' <- ')}. Pebble's live root is "${v.liveRoot}".`,
61
+ 'Pebble mints a random root at every startup and Caddy caches its cert until expiry, so Pebble restarted without the website sim restarting (celilo#1427).',
62
+ 'This is NOT a timeout and waiting cannot fix it.',
63
+ 'Remedy: `cele2e down` then `cele2e build-infra`, so Pebble and Caddy start together.',
64
+ 'Restarting the website sim ALONE makes it worse — it comes back on Docker embedded DNS and cannot reach the simulated ACME endpoint at all.',
65
+ ].join(' ');
66
+ }