@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.
- package/bin/e2e-bake-management +362 -10
- package/config/routing/management-routes.sh +11 -0
- package/config/routing/minio-startup.sh +27 -32
- package/config/routing/resolver-internal-routes.sh +14 -6
- package/config/socks/startup.sh +46 -0
- package/config/ssh/sshd-celilo-api.conf +6 -0
- package/docker/Dockerfile.management +17 -0
- package/docker/Dockerfile.minio +34 -16
- package/docker/Dockerfile.socks-proxy +9 -0
- package/package.json +2 -2
- package/registry-server/src/bootstrap.ts +43 -5
- package/registry-server/src/index.ts +1 -0
- package/registry-server/src/server.ts +18 -1
- package/simulators/greenwave/state.ts +85 -4
- package/src/bake-probe-explanation.test.ts +43 -0
- package/src/bake-probe-fails-closed.test.ts +35 -0
- package/src/bake-probe-no-shell-expansion.test.ts +40 -0
- package/src/cli/build.test.ts +50 -0
- package/src/cli/build.ts +36 -1
- package/src/cli/host.ts +71 -2
- package/src/docker-compose-generator.ts +7 -3
- package/src/doctor.test.ts +87 -6
- package/src/doctor.ts +112 -23
- package/src/greenwave-external-interface.test.ts +63 -0
- package/src/index.ts +1 -0
- package/src/installed-cli-digest.test.ts +113 -0
- package/src/installed-cli-digest.ts +102 -0
- package/src/parse-line.ts +8 -2
- package/src/resolver-internal-config.test.ts +65 -0
- package/src/shared-infra-pebble-root.test.ts +26 -0
- package/src/shared-infra-self-contained.test.ts +53 -0
- package/src/shared-infra.ts +61 -0
- package/src/socks-proxy.ts +18 -5
- package/src/stale-acme-root.ts +66 -0
|
@@ -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
|
-
|
|
170
|
-
|
|
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
|
+
});
|
package/src/shared-infra.ts
CHANGED
|
@@ -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:
|
package/src/socks-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
}
|