@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.
@@ -24,14 +24,22 @@
24
24
 
25
25
  import { execSync } from 'node:child_process';
26
26
  import { join } from 'node:path';
27
- import { ensureSharedInfra } from '../src/shared-infra';
27
+ import { ensureSharedInfra, stopSharedInfra } from '../src/shared-infra';
28
28
  import { network } from '../src/network-builder';
29
29
  import { imageTag } from '../src/docker-compose-generator';
30
30
  import { findMonorepoRoot } from '../src/repo-root';
31
+ import { detectStaleAcmeRoot, explainStaleRoot } from '../src/stale-acme-root';
31
32
  import {
32
33
  packNpmRegistryTarballs,
33
34
  stageWebsiteDist,
34
35
  } from '../src/stage-simulator-inputs';
36
+ import {
37
+ INSTALLED_CLI_DIR,
38
+ STAGED_TARBALL,
39
+ digestScript,
40
+ mismatchMessage,
41
+ parseDigests,
42
+ } from '../src/installed-cli-digest';
35
43
  import {
36
44
  CONSUMER_FINGERPRINT_PREFIX,
37
45
  PUBLISHED_FINGERPRINT_PREFIX,
@@ -221,6 +229,43 @@ function labelSafeVersion(version: string): string {
221
229
  return version.trim().split(/\s+/).pop() || 'unknown';
222
230
  }
223
231
 
232
+ /**
233
+ * The staged tarball for a package, as pack-celilo-packages recorded it.
234
+ */
235
+ function stagedTarballPath(packageName: string): string {
236
+ const manifestPath = join(PACKAGE_ROOT, '.npm-registry-cache', 'pack-manifest.json');
237
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as {
238
+ packages?: Array<{ name: string; tarball: string }>;
239
+ };
240
+ const entry = manifest.packages?.find((p) => p.name === packageName);
241
+ if (!entry) {
242
+ throw new Error(`pack-manifest.json lists no ${packageName} tarball to verify the install against.`);
243
+ }
244
+ // pack() records the basename; the tarballs sit beside the manifest.
245
+ return join(PACKAGE_ROOT, '.npm-registry-cache', entry.tarball);
246
+ }
247
+
248
+ /**
249
+ * Refuse to stamp a freshness label over a CLI that is not the one we packed.
250
+ * See src/installed-cli-digest.ts for why the label alone cannot say this.
251
+ */
252
+ async function verifyInstalledCliIsStaged(exec: Exec, containerId: string): Promise<void> {
253
+ process.stdout.write(' installed CLI = staged ');
254
+ const t = Date.now();
255
+ run(`docker cp ${JSON.stringify(stagedTarballPath('@celilo/cli'))} ${containerId}:${STAGED_TARBALL}`);
256
+ const b64 = Buffer.from(digestScript(INSTALLED_CLI_DIR)).toString('base64');
257
+ const probe = await exec(
258
+ `echo ${b64} | base64 -d > /tmp/verify-cli.sh && bash /tmp/verify-cli.sh 2>&1`,
259
+ 120_000,
260
+ );
261
+ const digests = parseDigests(`${probe.stdout}${probe.stderr}`);
262
+ if (digests.staged !== digests.installed) {
263
+ console.log('\u2717');
264
+ throw new Error(mismatchMessage(digests));
265
+ }
266
+ console.log(`\u2714 ${Math.round((Date.now() - t) / 1000)}s (${digests.staged.slice(0, 16)})`);
267
+ }
268
+
224
269
  function sourceStamp(published: boolean, version: string): string {
225
270
  if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
226
271
  // Consumer mode: restageSimulatorInputs is the only writer of
@@ -280,6 +325,148 @@ function restageSimulatorInputs(): void {
280
325
  * Default bake: install.sh against the simulated celilo.computer / npm
281
326
  * registry, inside the standard topology.
282
327
  */
328
+ /**
329
+ * The probe waits for HTTPS to VERIFY, so a trust failure and an unfinished
330
+ * issuance both spend the whole budget and both used to print "timed out".
331
+ * They need opposite responses, so branch the headline on curl's own exit code
332
+ * rather than making the reader infer it from the layers (crew-builder,
333
+ * celilo#1427).
334
+ */
335
+ /**
336
+ * The TLS/DNS layers, read from inside the management container.
337
+ *
338
+ * Hoisted out of the probe's failure path because the probe is not the only
339
+ * step that can fail on TLS: install.sh's own curl-pipe-bash talks to the same
340
+ * host a moment later, and when THAT failed the bake printed the curl error
341
+ * alone with no layers under it (measured, run 7255: `curl: (35) TLS connect
342
+ * error ... tlsv1 alert internal error`, one second after the probe had
343
+ * verified the same URL successfully). A failure with no layers is the exact
344
+ * shape celilo#1365 spent two weeks in.
345
+ */
346
+ async function tlsDiagnosticLayers(exec: Exec, projectName?: string): Promise<string> {
347
+ const diag = async (label: string, cmd: string): Promise<string> => {
348
+ const out = await exec(cmd, 10_000);
349
+ return `--- ${label} ---\n${out.stdout.trim() || '(no output)'}`;
350
+ };
351
+ return [
352
+ await diag('resolv.conf', 'cat /etc/resolv.conf'),
353
+ await diag('dig @10.226.1.10 (internal resolver)', 'dig +time=2 +tries=1 @10.226.1.10 celilo.computer'),
354
+ await diag('dig @203.0.113.1 (public fallback)', 'dig +time=2 +tries=1 @203.0.113.1 celilo.computer'),
355
+ await diag('trusted CA files', 'ls -la /usr/local/share/ca-certificates/'),
356
+ await diag('Pebble runtime root reachable from management', 'curl -sk --max-time 5 https://100.64.0.100:15000/roots/0 | head -c 300; echo'),
357
+ await diag('fetched runtime root (if any)', 'openssl x509 -in /usr/local/share/ca-certificates/pebble-acme-root.crt -noout -subject -enddate 2>/dev/null'),
358
+ await diag('cert chain served by website-sim', 'timeout 8 openssl s_client -connect celilo.computer:443 -servername celilo.computer -showcerts </dev/null 2>/dev/null | grep -E "^ [0-9] s:|^ i:"'),
359
+ await diag('curl -v (DNS/connect/TLS/HTTP stages)', 'curl -v --max-time 5 https://celilo.computer/install.sh -o /dev/null 2>&1 | tail -20'),
360
+ // The probe's EXACT command, re-run here. Runs 7255-7260 all showed the
361
+ // probe returning 0 and install.sh's curl failing 130ms later on the same
362
+ // host, which is either two different answers from one server or a probe
363
+ // reading something other than what it thinks. Printing both side by side
364
+ // decides it, instead of reasoning about which flag matters.
365
+ await diag(
366
+ 're-run of the probe command',
367
+ "curl -sS --max-time 3 -o /dev/null -w 'curl-exit=%{exitcode}\n' https://celilo.computer/install.sh 2>&1",
368
+ ),
369
+ await diag(
370
+ 'the same URL with -f, as install.sh fetches it',
371
+ "curl -fsS --max-time 5 -o /dev/null -w 'curl-exit=%{exitcode}\n' https://celilo.computer/install.sh 2>&1",
372
+ ),
373
+ // Did management's own startup script reach its Pebble-root fetch at all?
374
+ // management-routes.sh runs under `set -e` with six `ip route add` calls
375
+ // AHEAD of that fetch (line 61), and a route add exiting 2 has killed this
376
+ // script before — celilo#1351, recorded in docker-compose-generator.ts. If
377
+ // the trust store has no runtime root, this log says whether the fetch
378
+ // failed, is still looping, or was never reached.
379
+ await diag('is /startup.sh still running?', 'ps -ef | grep -c "[s]tartup.sh"'),
380
+ managementStartupLog(projectName),
381
+ // Every layer above is read from INSIDE management, so all of them describe
382
+ // the client. When the server aborts the handshake before sending a
383
+ // certificate (run 7258), the client has nothing left to report and the
384
+ // reason is only in Caddy's own log — whether it got an ACME cert at all.
385
+ websiteSimLog(),
386
+ ].join('\n');
387
+ }
388
+
389
+ /** The per-test management container's own startup output, read from the host. */
390
+ function managementStartupLog(projectName?: string): string {
391
+ if (!projectName) return '--- management startup log ---\n(project name unavailable)';
392
+ try {
393
+ const id = execSync(`docker ps -aq --filter name=${projectName} --filter name=management`, {
394
+ encoding: 'utf-8',
395
+ timeout: 15_000,
396
+ })
397
+ .trim()
398
+ .split('\n')[0];
399
+ if (!id) return '--- management startup log ---\n(container not found)';
400
+ const out = execSync(`docker logs --tail 40 ${id} 2>&1`, {
401
+ encoding: 'utf-8',
402
+ timeout: 20_000,
403
+ });
404
+ return `--- management startup log (server side) ---\n${out.trim() || '(no output)'}`;
405
+ } catch (err) {
406
+ return `--- management startup log ---\n(unreadable: ${(err as Error).message})`;
407
+ }
408
+ }
409
+
410
+ /** celilo-website-sim's own Caddy log, read from the host. */
411
+ function websiteSimLog(): string {
412
+ try {
413
+ const out = execSync(
414
+ 'docker compose -f docker-compose.shared.yml -p celilo-e2e-shared logs --tail 40 celilo-website 2>&1',
415
+ // The package's OWN root, not the monorepo's: npm-consumer-smoke runs this
416
+ // from node_modules outside any checkout, where findMonorepoRoot() is
417
+ // undefined and join() throws (measured, run 7260). Same resolution
418
+ // shared-infra.ts:getE2eDir uses, for the same reason.
419
+ { cwd: join(import.meta.dir, '..'), encoding: 'utf-8', timeout: 20_000 },
420
+ );
421
+ return `--- celilo-website-sim log (server side) ---\n${out.trim() || '(no output)'}`;
422
+ } catch (err) {
423
+ return `--- celilo-website-sim log (server side) ---\n(unreadable: ${(err as Error).message})`;
424
+ }
425
+ }
426
+
427
+ /** Sentinel: the probe ran but its `curl-exit=` marker was not in the output. */
428
+ const CA_WAIT_SECONDS = 240;
429
+ const PROBE_UNREADABLE = -2;
430
+
431
+ function explainProbeFailure(curlExit: number): string {
432
+ // 60 unknown issuer, 35 TLS connect, 51 peer cert not ok.
433
+ if ([60, 35, 51].includes(curlExit)) {
434
+ return (
435
+ `celilo.computer served a certificate this container cannot verify (curl ${curlExit}). ` +
436
+ "The step above already waited for management's runtime root, so this is the STALE-ROOT " +
437
+ 'case, not the not-yet-fetched one — those need opposite responses and exit 60 alone ' +
438
+ 'cannot tell them apart (celilo#1365 spent runs on the wrong one). ' +
439
+ 'This is NOT a timeout and waiting longer cannot fix it. Pebble mints a random root CA ' +
440
+ 'at every startup and Caddy caches its cert until expiry, so a Pebble that restarted ' +
441
+ 'without the website sim restarting is serving a cert from a root that no longer exists ' +
442
+ '(celilo#1427). Check the chain above against the live root. Remedy: `cele2e down` then ' +
443
+ '`cele2e build-infra`, so Pebble and Caddy start together.'
444
+ );
445
+ }
446
+ // 6 could not resolve, 7 could not connect, 28 operation timeout.
447
+ if ([6, 7, 28].includes(curlExit)) {
448
+ return (
449
+ `celilo.computer could not be reached at all (curl ${curlExit}) within 30s — a resolution ` +
450
+ 'or connection failure, not a trust one. Either the website sim is down or its ACME ' +
451
+ 'issuance genuinely has not finished. Read the DNS layers above.'
452
+ );
453
+ }
454
+ if (curlExit === PROBE_UNREADABLE) {
455
+ return (
456
+ 'The HTTPS probe never returned a readable curl exit code in 30s — its ' +
457
+ '`curl-exit=` marker was missing from both streams. Treated as NOT ready: ' +
458
+ 'the probe cannot prove the site is serving, and a probe that cannot fail ' +
459
+ 'is how celilo#1365 stayed invisible. See the layers above.'
460
+ );
461
+ }
462
+ return (
463
+ `Timed out (${Math.round(CA_WAIT_SECONDS)}s of wall clock) waiting for celilo.computer to serve install.sh over HTTPS (curl ${curlExit}). ` +
464
+ 'See the DNS / curl -v layers above for which one failed. A cold shared-infra ' +
465
+ 'caddy needs ~90s to obtain its FIRST certificate, so this is a real failure ' +
466
+ 'rather than an impatient probe.'
467
+ );
468
+ }
469
+
283
470
  async function bakeViaSim(): Promise<void> {
284
471
  console.log('▸ Baking celilo into management:latest via install.sh');
285
472
  console.log('');
@@ -302,30 +489,146 @@ async function bakeViaSim(): Promise<void> {
302
489
  const exec: Exec = (cmd, timeoutMs) => net.exec('management', cmd, timeoutMs);
303
490
 
304
491
  let containerId = '';
492
+ let bakeFailed = false;
305
493
  try {
306
494
  // Wait for the management container's Pebble CA fetch to complete so
307
495
  // the curl-pipe-bash below can verify TLS to celilo.computer. Without
308
496
  // pipefail an early curl failure returns 0 silently and install.sh
309
497
  // "runs" in 0s installing nothing.
498
+ // WAIT FOR THE DEPENDENCY THIS STEP IS NAMED AFTER, not for a symptom of it.
499
+ // management-routes.sh fetches Pebble's runtime root in a FOREGROUND retry
500
+ // loop of 60 tries x 2s (config/routing/management-routes.sh:61-71) — worst
501
+ // case 120s, and it only starts once the per-test routing is up. The HTTPS
502
+ // probe below budgeted 30s, so it gave up while that fetch was still
503
+ // running and blamed the certificate. Measured on run 7266: /startup.sh
504
+ // still running, its log stopped at "Waiting for Pebble ACME root CA..."
505
+ // with no "installed" line, and the trust store held only build-time certs
506
+ // — while the website sim had already obtained a valid cert. The two waits
507
+ // were simply sized wrong relative to each other.
508
+ process.stdout.write(' pebble root installed .... ');
509
+ const tRoot = Date.now();
510
+ let rootReady = false;
511
+ // 240s, not the 150 this was first written with. MEASURED on run 7268:
512
+ // the fetch took 136s — past the loop's nominal 120s ceiling, because each
513
+ // failing curl spends its own connect timeout before the 2s sleep. 150
514
+ // left 10% headroom on an idle builder, which is how a green gate starts
515
+ // flaking under load. The budget costs nothing when the root arrives fast:
516
+ // the wait ends the moment the file appears.
517
+ for (let i = 0; i < 240; i++) {
518
+ const probe = await exec(
519
+ 'test -s /usr/local/share/ca-certificates/pebble-acme-root.crt && echo root-present || echo root-absent',
520
+ 5_000,
521
+ );
522
+ if (`${probe.stdout}${probe.stderr}`.includes('root-present')) {
523
+ rootReady = true;
524
+ break;
525
+ }
526
+ await new Promise((resolve) => setTimeout(resolve, 1000));
527
+ }
528
+ if (!rootReady) {
529
+ console.log('✗');
530
+ console.error(await tlsDiagnosticLayers(exec, net.projectName));
531
+ throw new Error(
532
+ "management never installed Pebble's runtime ACME root in 240s. Its startup script " +
533
+ 'fetches it in a foreground loop before celilo.computer can be verified at all; the ' +
534
+ 'layers above say whether that loop is still running, died early, or is failing its ' +
535
+ 'fetch (config/routing/management-routes.sh:61).',
536
+ );
537
+ }
538
+ console.log(`✔ ${Math.round((Date.now() - tRoot) / 1000)}s`);
539
+
310
540
  process.stdout.write(' pebble CA ready .......... ');
311
541
  const tCa = Date.now();
312
542
  let caReady = false;
313
- for (let i = 0; i < 30; i++) {
543
+ let lastProbeExit = -1;
544
+ let lastProbeOutput = '';
545
+ // Re-checked while INCONCLUSIVE, at most every 30s. A single check at t=0
546
+ // sits at the flakiest moment in the run (shared infra has just come up), and
547
+ // an exec that answers nothing there scores `unknown` — so a check that fired
548
+ // only once would silently fall back to burning the whole budget, which is the
549
+ // exact defect this is here to remove.
550
+ let staleCheckAt = 0;
551
+ // WALL CLOCK, not an iteration count. Each pass costs up to 3s of curl plus a
552
+ // 1s sleep, so `i < 30` was never 30 seconds — and the error text said "30s"
553
+ // regardless, which is how this budget got read as generous when it was not.
554
+ // Measured 2026-09-26 on a torn-down host: shared-infra caddy needs ~90s to
555
+ // obtain its FIRST certificate (caddy's own log: "certificate obtained
556
+ // successfully" at 90.9s elapsed, after one tls-alpn-01 retry). A 30-iteration
557
+ // cap could not reach that, so a cold rig failed the bake three runs running
558
+ // while the website was merely still issuing. 240s covers the measurement with
559
+ // margin and still fails in well under the suite's own budget.
560
+ const CA_WAIT_MS = 240_000;
561
+ const caDeadline = Date.now() + CA_WAIT_MS;
562
+ while (Date.now() < caDeadline) {
563
+ // NO `$?` HERE, EVER. dockerExec wraps the command as
564
+ // `bash -c ${JSON.stringify(cmd)}` (container-manager.ts:636) and hands
565
+ // that to a HOST shell, which expands `$?` itself — to its own last
566
+ // status, which is 0. So `echo "curl-exit=$?"` reported curl-exit=0 on
567
+ // every run this bead has ever had, including the ones where curl printed
568
+ // `(60) SSL certificate problem: unable to get local issuer certificate`
569
+ // two lines above it (run 7262 prints exactly that pair). The probe was
570
+ // structurally incapable of failing, which is why celilo#1365 read as a
571
+ // 30s timeout and then as a green step: the step never had an opinion.
572
+ // curl's own %{exitcode} needs no shell variable and cannot be expanded
573
+ // away.
314
574
  const probe = await exec(
315
- 'curl -fsSL --max-time 3 https://celilo.computer/install.sh -o /dev/null 2>&1',
575
+ "curl -sS --max-time 3 -o /dev/null -w 'curl-exit=%{exitcode}\n' https://celilo.computer/install.sh 2>&1",
316
576
  5_000,
317
577
  );
318
- if (probe.exitCode === 0) {
578
+ const text = `${probe.stdout}\n${probe.stderr}`.trim();
579
+ const code = /curl-exit=(\d+)/.exec(text);
580
+ // FAIL CLOSED. The probe command ends in `echo`, so the shell's exit is 0
581
+ // whatever curl did — falling back to probe.exitCode scored "I could not
582
+ // read the answer" as "ready". Measured on run 7258: the probe reported
583
+ // `✔ 0s` while celilo.computer was serving NO certificate at all
584
+ // (openssl s_client returned an empty chain and the server aborted the
585
+ // handshake with `internal error (592)`), and the real failure surfaced
586
+ // one step later as install.sh's curl (35). A probe that cannot fail is
587
+ // worse than no probe: it converted celilo#1365's defect into a green
588
+ // step plus a mystery. Unreadable now counts as not-ready, the same bias
589
+ // live-stack.ts already applies to its own evidence test.
590
+ lastProbeExit = code ? Number(code[1]) : PROBE_UNREADABLE;
591
+ lastProbeOutput = text;
592
+ if (lastProbeExit === 0) {
319
593
  caReady = true;
320
594
  break;
321
595
  }
596
+ // REFUSE INSTANTLY on a dead root rather than spending the 240s budget
597
+ // and then reporting a timeout (celilo#1427). The chain either names
598
+ // Pebble's live root or it does not, and that answer does not change by
599
+ // waiting. A cold caddy serving NOTHING yet scores `unknown` and keeps
600
+ // the wait, which is the case the budget exists for.
601
+ if (Date.now() >= staleCheckAt) {
602
+ staleCheckAt = Date.now() + 30_000;
603
+ const [live, served] = await Promise.all([
604
+ exec('curl -sk --max-time 5 https://100.64.0.100:15000/roots/0 | openssl x509 -noout -subject 2>/dev/null', 10_000),
605
+ // Deliberately a LOOSE grep: openssl's indentation for the issuer line
606
+ // differs across versions, and detectStaleAcmeRoot does the real
607
+ // parsing anyway. An anchored pattern that misses scores `unknown`,
608
+ // which reads exactly like a cold caddy and disables the check.
609
+ exec('timeout 8 openssl s_client -connect celilo.computer:443 -servername celilo.computer -showcerts </dev/null 2>/dev/null | grep i:', 12_000),
610
+ ]);
611
+ const verdict = detectStaleAcmeRoot(live.stdout, served.stdout);
612
+ if (verdict.kind === 'stale') {
613
+ console.log('✗');
614
+ console.error(await tlsDiagnosticLayers(exec, net.projectName));
615
+ throw new Error(explainStaleRoot(verdict));
616
+ }
617
+ // Settled: the served chain names the live root, so it cannot go stale
618
+ // by waiting. Stop paying ~20s of exec per cycle for a known answer.
619
+ if (verdict.kind === 'current') staleCheckAt = Number.POSITIVE_INFINITY;
620
+ }
322
621
  await new Promise((resolve) => setTimeout(resolve, 1000));
323
622
  }
324
623
  if (!caReady) {
325
- throw new Error(
326
- 'Timed out (30s) waiting for celilo.computer to serve install.sh ' +
327
- 'over HTTPS — Pebble CA fetch likely stalled or website-sim is down.',
328
- );
624
+ // Self-diagnosing (e2e-confidence #255, mirroring install-sh.test.ts):
625
+ // a bake that fails here used to name two GUESSES and discard the probe
626
+ // output, so celilo#1365 produced two CI failures with no cause visible.
627
+ // Dump the DNS / reachability / TLS layers from inside the container.
628
+ const layers = await tlsDiagnosticLayers(exec, net.projectName);
629
+ console.error(layers);
630
+ console.error(`--- last probe (curl exit ${lastProbeExit}) ---\n${lastProbeOutput || '(no output)'}`);
631
+ throw new Error(explainProbeFailure(lastProbeExit));
329
632
  }
330
633
  console.log(`✔ ${Math.round((Date.now() - tCa) / 1000)}s`);
331
634
 
@@ -340,6 +643,10 @@ async function bakeViaSim(): Promise<void> {
340
643
  console.error('--- install.sh output (tail) ---');
341
644
  console.error(result.stdout.slice(-3000));
342
645
  console.error(result.stderr.slice(-2000));
646
+ // The probe verified this very URL seconds ago, so a TLS failure HERE is
647
+ // a change between the two calls, not a static misconfiguration — and it
648
+ // is unreadable without the layers (run 7255).
649
+ console.error(await tlsDiagnosticLayers(exec, net.projectName));
343
650
  throw new Error('install.sh failed inside the bake container');
344
651
  }
345
652
  console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
@@ -348,14 +655,40 @@ async function bakeViaSim(): Promise<void> {
348
655
  // CLI, genuinely against the installed binary rather than the mount.
349
656
  const version = await installShimAndVerify(exec, 'sim');
350
657
 
351
- process.stdout.write(' docker commit .......... ');
352
- const t5 = Date.now();
353
658
  containerId = run(
354
659
  `docker compose -f ${COMPOSE_FILE} -p ${net.projectName} ps -q management`,
355
660
  ).trim();
356
661
  if (!containerId) {
357
662
  throw new Error('Could not resolve management container id');
358
663
  }
664
+ // CONSUMER MODE HAS NOTHING TO VERIFY AGAINST, and the check reads a file
665
+ // that is deliberately not shipped. verifyInstalledCliIsStaged compares the
666
+ // installed CLI against the tarball restageSimulatorInputs packed, recorded
667
+ // in PACKAGE_ROOT/.npm-registry-cache/pack-manifest.json. Without a checkout
668
+ // that restage is skipped (see restageSimulatorInputs), nothing writes the
669
+ // manifest, and `.npm-registry-cache` is excluded from the e2e tarball on
670
+ // purpose (celilo#116) — so PACKAGE_ROOT resolves into node_modules and the
671
+ // read is a guaranteed ENOENT. sourceStamp already reasons exactly this way
672
+ // a few lines up.
673
+ //
674
+ // It is not merely impossible here, it is meaningless: in consumer mode the
675
+ // CLI came from the registry this run, so there is no staged copy for it to
676
+ // have drifted from. The freshness label records CONSUMER_FINGERPRINT for
677
+ // the same reason.
678
+ //
679
+ // Measured 2026-09-26: npm-consumer-smoke run 7335 died at 174s with
680
+ // `ENOENT ... node_modules/@celilo/e2e/.npm-registry-cache/pack-manifest.json`,
681
+ // which failed PR #1437 and blocked the release the fleet was waiting on.
682
+ // The check landed in 00e2367d for celilo#1423 (image-freshness certifying a
683
+ // stale CLI) — a real fix whose guard was written for monorepo mode only.
684
+ if (findMonorepoRoot(PACKAGE_ROOT)) {
685
+ await verifyInstalledCliIsStaged(exec, containerId);
686
+ } else {
687
+ console.log(' installed CLI = staged skipped (consumer mode: nothing was staged to compare)');
688
+ }
689
+
690
+ process.stdout.write(' docker commit .......... ');
691
+ const t5 = Date.now();
359
692
  const superseded = imageIdOnTag('celilo-e2e/management:latest');
360
693
  // The committed container runs under the sim topology's compose env, and
361
694
  // commit captures its Env. Since the default managementZone became
@@ -377,11 +710,30 @@ async function bakeViaSim(): Promise<void> {
377
710
  );
378
711
  console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
379
712
  removeSupersededImage(superseded, 'celilo-e2e/management:latest');
713
+ } catch (err) {
714
+ bakeFailed = true;
715
+ throw err;
380
716
  } finally {
381
717
  process.stdout.write(' tearing down ........... ');
382
718
  const tDown = Date.now();
383
719
  await net.stop();
384
720
  console.log(`✔ ${Math.round((Date.now() - tDown) / 1000)}s`);
721
+ if (bakeFailed) {
722
+ // A failed bake must not leave the shared stack live. On the builder the
723
+ // job dies at this step and the workflow's `if: always()` teardown steps
724
+ // never run (observed on runs 6645/6682/6693/6698: the log ends at
725
+ // "Cleaning up container for job"), so the 13-container shared stack
726
+ // then refuses every full run for the next 60 minutes under the
727
+ // orphan-age gate in live-stack.ts. Success keeps the stack: the Mac dev
728
+ // flow reuses it and the workflow's teardown step owns the builder's
729
+ // success path. (celilo#1365, celilo#1320)
730
+ try {
731
+ await stopSharedInfra();
732
+ } catch {
733
+ // best-effort: the workflow teardown or the next run's sweep is the
734
+ // backstop, and hiding the reason here beats failing twice.
735
+ }
736
+ }
385
737
  }
386
738
 
387
739
  console.log('');
@@ -6,6 +6,17 @@ set -e
6
6
  # Generate SSH keys if needed
7
7
  /config/ssh/generate-keys.sh /ssh-keys
8
8
 
9
+ # Serve the remote API. `celilo --remote` and celilo-web-console both arrive
10
+ # as an ssh forced command; without sshd the console is granted in the
11
+ # database and cannot open a connection (celilo#1442). Host keys are generated
12
+ # per container -- clients in this rig do not pin them.
13
+ ssh-keygen -A >/dev/null 2>&1 || true
14
+ mkdir -p /run/sshd
15
+ # Not fatal: this script runs under `set -e`, and letting a failed sshd abort
16
+ # it would take the whole management box down -- turning one suite's remote-API
17
+ # problem into every suite failing with no container.
18
+ /usr/sbin/sshd || echo "WARNING: sshd did not start; the remote API is unreachable" >&2
19
+
9
20
  # Set up SSH
10
21
  mkdir -p /root/.ssh
11
22
  cp /ssh-keys/id_ed25519 /root/.ssh/id_ed25519
@@ -1,10 +1,14 @@
1
1
  #!/bin/sh
2
- # Start MinIO and pre-create the backup bucket.
2
+ # Start the S3 simulator and pre-create the backup bucket.
3
+ #
4
+ # ⚠️ The server is s3proxy, not MinIO — see Dockerfile.minio's header for why
5
+ # (MinIO's images stopped being anonymously pullable from both Docker Hub and
6
+ # quay.io; celilo#1389). The hostname `minio.lab` and this file's name are
7
+ # deliberately unchanged.
3
8
  #
4
9
  # celilo's S3 provider never creates buckets (its initialize() only probes via
5
10
  # ListObjectsV2), so the bucket must exist before `storage add s3` verifies or
6
- # `backup --storage` uploads. We start the server, wait for it to answer, then
7
- # `mc mb --ignore-existing`.
11
+ # `backup --storage` uploads.
8
12
  set -e
9
13
 
10
14
  # Route the customer's public prefix via the ISP edge (fw-ext), like every
@@ -12,38 +16,29 @@ set -e
12
16
  # across networks, so without this a reply to the customer's WAN address is
13
17
  # dropped and `backup --storage` hangs. fw-ext is per-test, so this is
14
18
  # expected to fail while shared infra runs alone — hence the tolerance.
15
- # (`busybox ip` because the MinIO image ships no iproute2.)
19
+ # (`busybox ip` because the s3proxy image ships no iproute2.)
16
20
  busybox ip route del default 2>/dev/null || true
17
21
  busybox ip route add default via 100.64.0.1 2>/dev/null || true
18
22
 
23
+ # Pre-create the bucket. Under the jclouds filesystem backend a bucket IS a
24
+ # directory under JCLOUDS_FILESYSTEM_BASEDIR, so this needs no running server,
25
+ # no credentials and no client binary — which is the entire reason the
26
+ # `minio/mc` image is gone. Measured 2026-09-24: with only this mkdir,
27
+ # ListObjectsV2 on the bucket answers 200 and an object PUT/GET round-trips.
28
+ #
29
+ # It also means the bucket is ready BEFORE the server accepts a connection,
30
+ # so the old "wait up to 30s for the admin alias, then mc mb" loop is gone.
31
+ # There is no readiness race left to lose.
32
+ mkdir -p "${JCLOUDS_FILESYSTEM_BASEDIR}/${MINIO_BUCKET}"
33
+ echo "minio-startup: bucket '${MINIO_BUCKET}' ready at ${JCLOUDS_FILESYSTEM_BASEDIR}/${MINIO_BUCKET}"
34
+
19
35
  # Serve the S3 API on :80 so the management box reaches it over the SAME
20
36
  # proven HTTP egress path as the other public sims (apt-repo, npm-registry):
21
- # fw-ext's Squid-bypass exception is scoped to dport 80/443 → 100.64.0.0/24, so
37
+ # fw-ext's Squid-bypass exception is scoped to dport 80/443 -> 100.64.0.0/24, so
22
38
  # plain HTTP to minio.lab:80 forwards straight through (no TLS bump, no cert,
23
- # no nonstandard-port filtering). Console stays on :9001.
24
- minio server /data --address ":80" --console-address ":9001" &
25
- MINIO_PID=$!
26
-
27
- # Wait for the server to accept the admin alias (up to ~30s).
28
- ready=0
29
- i=0
30
- while [ "$i" -lt 30 ]; do
31
- if mc alias set local "http://127.0.0.1:80" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; then
32
- ready=1
33
- break
34
- fi
35
- i=$((i + 1))
36
- sleep 1
37
- done
38
-
39
- if [ "$ready" -ne 1 ]; then
40
- echo "minio-startup: MinIO did not become ready in 30s" >&2
41
- exit 1
42
- fi
43
-
44
- mc mb --ignore-existing "local/${MINIO_BUCKET}"
45
- echo "minio-startup: bucket '${MINIO_BUCKET}' ready; MinIO serving on :80"
46
-
47
- # Hand the foreground back to the server so the container stays up and signals
48
- # propagate to it.
49
- wait "$MINIO_PID"
39
+ # no nonstandard-port filtering). S3PROXY_ENDPOINT pins the listen address.
40
+ #
41
+ # exec so the server becomes PID 1's child directly and signals propagate; the
42
+ # old script backgrounded the server and `wait`ed on it, which was only needed
43
+ # because the bucket step ran after startup.
44
+ exec /opt/s3proxy/run-docker-container.sh
@@ -26,12 +26,19 @@ ip route add default via "$GATEWAY"
26
26
  SPLIT_HORIZON_BLOCK=""
27
27
  IFS=',' read -ra DOMAIN_LIST <<< "${DOMAINS}"
28
28
  for d in "${DOMAIN_LIST[@]}"; do
29
- SPLIT_HORIZON_BLOCK="${SPLIT_HORIZON_BLOCK} local-zone: \"${d}.\" redirect\n local-data: \"${d}. IN A ${CADDY_IP}\"\n"
29
+ SPLIT_HORIZON_BLOCK="${SPLIT_HORIZON_BLOCK} local-zone: \"${d}.\" redirect
30
+ local-data: \"${d}. IN A ${CADDY_IP}\"
31
+ "
30
32
  done
31
33
 
32
34
  # Generate unbound config with split-horizon for each test domain plus
33
35
  # the standard simulator overrides (Pebble, registry).
34
- cat > /etc/unbound/unbound.conf << EOF
36
+ # Quoted heredocs: nothing inside is expanded, so a backtick or a $ in a
37
+ # comment is text, not code. The split-horizon block is printed BETWEEN them
38
+ # as data. (A `internal` in a comment used to run as a command on every boot —
39
+ # celilo#1430.)
40
+ {
41
+ cat << 'EOF'
35
42
  server:
36
43
  interface: 0.0.0.0
37
44
  access-control: 0.0.0.0/0 allow
@@ -41,7 +48,9 @@ server:
41
48
  do-daemonize: no
42
49
  do-not-query-localhost: no
43
50
 
44
- $(printf "${SPLIT_HORIZON_BLOCK}")
51
+ EOF
52
+ printf '%s' "${SPLIT_HORIZON_BLOCK}"
53
+ cat << 'EOF'
45
54
  # Mirror the public resolver's simulated endpoint overrides so that
46
55
  # management uses Pebble for ACME and the e2e registry for modules.
47
56
  local-data: "acme-v02.api.letsencrypt.org. IN A 100.64.0.100"
@@ -52,7 +61,6 @@ $(printf "${SPLIT_HORIZON_BLOCK}")
52
61
  local-data: "npm-registry.lab. IN A 100.64.0.59"
53
62
  # apt-repo simulator — serves the celilo + celilo-bootstrap .debs to the
54
63
  # apt install step in the bootstrap-apt test. See SIMULATOR_IPS.APT_REPO.
55
- # (No backticks in this heredoc — it is unquoted, so backticks execute.)
56
64
  local-data: "apt.celilo.lab. IN A 100.64.0.60"
57
65
  # MinIO S3 simulator — backup/restore target for the migration e2e.
58
66
  # See SIMULATOR_IPS.MINIO.
@@ -60,10 +68,9 @@ $(printf "${SPLIT_HORIZON_BLOCK}")
60
68
  # IP echo simulator — the rig's api.ipify.org. celilo's public_dns check
61
69
  # asks it the one thing the fleet cannot ask itself: what address it is
62
70
  # currently reachable at. See SIMULATOR_IPS.IP_ECHO.
63
- # (Backtick-free on purpose — see the note above; this heredoc is unquoted.)
64
71
  local-data: "ip-echo.lab. IN A 100.64.0.65"
65
72
  # signal-cli daemon (real, unlinked) and its simulator counterpart. Both
66
- # sit on the `internal` network — celilo-mgr reaches a notification
73
+ # sit on the internal network — celilo-mgr reaches a notification
67
74
  # transport locally; a transport needing public ingress could not tell
68
75
  # you the ingress was broken.
69
76
  local-data: "signal-cli.lab. IN A 10.226.1.90"
@@ -77,6 +84,7 @@ forward-zone:
77
84
  name: "."
78
85
  forward-addr: 203.0.113.1
79
86
  EOF
87
+ } > /etc/unbound/unbound.conf
80
88
 
81
89
  /usr/sbin/unbound -d &
82
90
 
@@ -47,4 +47,50 @@ echo "nameserver $NAMESERVER" > /etc/resolv.conf
47
47
 
48
48
  echo "socks-proxy ready: vantage=$VANTAGE gateway=$GATEWAY dns=$NAMESERVER" >&2
49
49
 
50
+ # TEACH DANTE ABOUT THE TUNNEL ONCE IT EXISTS.
51
+ #
52
+ # danted.conf names `external: eth0`, the interface dante binds OUTBOUND
53
+ # connections to. That is correct at startup and wrong the moment a test brings
54
+ # up a WireGuard tunnel: a destination routed over wg0 is then dialled from
55
+ # eth0's address, and the far side drops it. The symptom is that a client INSIDE
56
+ # this container reaches the target fine (ordinary routing picks wg0) while the
57
+ # same target through the SOCKS port times out — so the proxy looks up, the
58
+ # tunnel looks up, and only the relay fails.
59
+ #
60
+ # Measured 2026-09-26 (celilo#1438): celilo-web-console-oidc stage 6 passed
61
+ # (its curl runs inside the container) and stage 7 failed with
62
+ # `net::ERR_TIMED_OUT` on the identical URL through the proxy. From the host:
63
+ # inside=302, via-socks=000.
64
+ #
65
+ # wg0 does not exist when danted starts, so it cannot simply be listed. This
66
+ # watches for it, adds it, and reloads — and VERIFIES the config first, because
67
+ # a danted that dies on a bad reload takes the proxy out for every suite.
68
+ (
69
+ while :; do
70
+ if ip link show wg0 >/dev/null 2>&1; then
71
+ if ! grep -q '^external: wg0' /etc/danted.conf; then
72
+ cp /etc/danted.conf /tmp/danted.conf.pre-wg0
73
+ sed -i 's|^external: eth0$|external: eth0\nexternal: wg0\nexternal.rotation: route|' /etc/danted.conf
74
+ # READ THE OUTPUT, NOT THE EXIT CODE. `danted -V` exits 0 even on a
75
+ # config it refuses outright — measured 2026-09-26: a config naming a
76
+ # nonexistent interface logged `error: ... could not resolve hostname`
77
+ # and `alert: mother[1/1]: shutting down`, and still returned 0. A guard
78
+ # keyed on the status would have HUPped a config danted cannot load and
79
+ # killed the proxy for every suite, which is the thing it exists to stop.
80
+ /usr/sbin/danted -V -f /etc/danted.conf >/tmp/danted-verify.log 2>&1 || true
81
+ if ! grep -qiE '(^|[^a-z])(error|alert):' /tmp/danted-verify.log; then
82
+ kill -HUP 1 2>/dev/null || true
83
+ echo "socks-proxy: wg0 up, danted now routes outbound by route" >&2
84
+ else
85
+ cp /tmp/danted.conf.pre-wg0 /etc/danted.conf
86
+ echo "socks-proxy: REFUSED to reload danted, config invalid — proxy left on eth0 only:" >&2
87
+ tail -3 /tmp/danted-verify.log >&2
88
+ fi
89
+ fi
90
+ break
91
+ fi
92
+ sleep 1
93
+ done
94
+ ) &
95
+
50
96
  exec /usr/sbin/danted -f /etc/danted.conf