@specific.dev/spectest 0.39.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +464 -987
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2095 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5537 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
package/dist/daemon.js
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
// One sandbox = one daemon. Concurrency between tests is achieved by
|
|
16
16
|
// forking the sandbox; inside a single daemon we never run two tests at
|
|
17
17
|
// once — that keeps stdout capture and timeouts simple.
|
|
18
|
-
import http from "node:http";
|
|
19
18
|
import { execFile, spawn } from "node:child_process";
|
|
20
19
|
import { randomUUID } from "node:crypto";
|
|
21
20
|
import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
@@ -23,8 +22,25 @@ import net from "node:net";
|
|
|
23
22
|
import path from "node:path";
|
|
24
23
|
import { pathToFileURL } from "node:url";
|
|
25
24
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
26
|
-
import { acquirePersistentBrowser } from "./browser.js";
|
|
25
|
+
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
27
26
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
27
|
+
// Pure ingress hostname matching, ported out of this file (see
|
|
28
|
+
// harness/hostmatch.ts). Keeping ONE implementation is the point: the
|
|
29
|
+
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
30
|
+
// each easy to restate subtly differently.
|
|
31
|
+
import { buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
32
|
+
import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
|
|
33
|
+
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
34
|
+
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
35
|
+
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
36
|
+
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
37
|
+
import { encodeRegistry } from "./harness/names-registry.js";
|
|
38
|
+
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
39
|
+
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
40
|
+
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
41
|
+
import { runContainerArgs } from "./harness/container-run.js";
|
|
42
|
+
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
43
|
+
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
28
44
|
import { openTerminal } from "./terminal.js";
|
|
29
45
|
import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
30
46
|
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
@@ -33,7 +49,6 @@ import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
|
33
49
|
function namedServices(cfg) {
|
|
34
50
|
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
|
|
35
51
|
}
|
|
36
|
-
const DEFAULT_PORT = 9876;
|
|
37
52
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
38
53
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
39
54
|
const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
@@ -422,44 +437,6 @@ async function ensureRemoteBuilder() {
|
|
|
422
437
|
}
|
|
423
438
|
return _remoteBuilder;
|
|
424
439
|
}
|
|
425
|
-
// Parse `docker build --progress=plain` (BuildKit) output into per-step
|
|
426
|
-
// timings, sorted slowest-first. Steps are correlated by their `#N` id:
|
|
427
|
-
// the declaration line carries the command, the `DONE`/`CACHED` line the
|
|
428
|
-
// duration. Best-effort — unparseable output yields an empty list.
|
|
429
|
-
function summarizeBuildKit(out) {
|
|
430
|
-
const names = new Map();
|
|
431
|
-
const secs = new Map();
|
|
432
|
-
const cached = new Set();
|
|
433
|
-
for (const line of out.split("\n")) {
|
|
434
|
-
let m = line.match(/^#(\d+)\s+\[[^\]]*\]\s+(.+)$/);
|
|
435
|
-
if (m) {
|
|
436
|
-
const id = `#${m[1]}`;
|
|
437
|
-
if (!names.has(id))
|
|
438
|
-
names.set(id, m[2].trim().slice(0, 80));
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
|
|
442
|
-
if (m) {
|
|
443
|
-
secs.set(`#${m[1]}`, parseFloat(m[2]));
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
m = line.match(/^#(\d+)\s+CACHED/);
|
|
447
|
-
if (m) {
|
|
448
|
-
const id = `#${m[1]}`;
|
|
449
|
-
cached.add(id);
|
|
450
|
-
if (!secs.has(id))
|
|
451
|
-
secs.set(id, 0);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
const steps = [];
|
|
455
|
-
for (const [id, name] of names) {
|
|
456
|
-
steps.push({ name, secs: secs.get(id) ?? 0, cached: cached.has(id) });
|
|
457
|
-
}
|
|
458
|
-
return steps.sort((a, b) => b.secs - a.secs);
|
|
459
|
-
}
|
|
460
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
461
|
-
// Bootstrap stages
|
|
462
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
463
440
|
async function ensureNetwork() {
|
|
464
441
|
const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
|
|
465
442
|
if (inspect.code === 0)
|
|
@@ -469,43 +446,11 @@ async function ensureNetwork() {
|
|
|
469
446
|
throw new Error(`docker network create ${NETWORK_NAME} failed: ${create.stderr.trim() || create.stdout.trim()}`);
|
|
470
447
|
}
|
|
471
448
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
.replace(/^\/+/, "")
|
|
475
|
-
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
476
|
-
.replace(/^-+|-+$/g, "");
|
|
477
|
-
}
|
|
449
|
+
/** Host directory backing one volume mount. Rules (notably which volumes
|
|
450
|
+
* survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
|
|
478
451
|
function resolveHostPath(service, vol) {
|
|
479
|
-
|
|
480
|
-
// Named shared volume: one backing dir per name, shared by every
|
|
481
|
-
// service that mounts the same name (storage-api ↔ imgproxy). Rooted
|
|
482
|
-
// in the per-env state tree (or the cache tree when cache-flagged),
|
|
483
|
-
// so teardown/fork semantics match ordinary volumes.
|
|
484
|
-
const root = vol.cache
|
|
485
|
-
? ["/var/cache/spectest/volumes", "_shared"]
|
|
486
|
-
: [WORKSPACE, ".spectest", "volumes", "_shared"];
|
|
487
|
-
return path.join(...root, sanitizeSegment(vol.name));
|
|
488
|
-
}
|
|
489
|
-
if (vol.source && vol.source.startsWith("/"))
|
|
490
|
-
return vol.source;
|
|
491
|
-
// Cache volumes root OUTSIDE /workspace so the delta-restore teardown
|
|
492
|
-
// (rm -rf /workspace) keeps them — they hold only content-addressed
|
|
493
|
-
// accelerator data (see VolumeMount.cache), never env state.
|
|
494
|
-
const root = vol.cache
|
|
495
|
-
? ["/var/cache/spectest/volumes", service]
|
|
496
|
-
: [WORKSPACE, ".spectest", "volumes", service];
|
|
497
|
-
if (vol.source) {
|
|
498
|
-
return path.join(...root, vol.source.replace(/^\/+/, ""));
|
|
499
|
-
}
|
|
500
|
-
return path.join(...root, sanitizeSegment(vol.target));
|
|
452
|
+
return resolveVolumeHostPath(service, vol, WORKSPACE);
|
|
501
453
|
}
|
|
502
|
-
/// Where the daemon records every ABSOLUTE-source, non-cache volume dir it
|
|
503
|
-
/// has created, one path per line. The delta-restore teardown wipes the
|
|
504
|
-
/// listed dirs: they live outside /workspace (which the teardown removes
|
|
505
|
-
/// wholesale) and outside /var/cache/spectest (deliberately kept), so
|
|
506
|
-
/// without this manifest a `source: "/data/pg"` volume would carry the
|
|
507
|
-
/// previous generation's data into a "fresh" environment. tmpfs-backed
|
|
508
|
-
/// (/run) — survives snapshots like all guest memory, dies with the VM.
|
|
509
454
|
const VOLUME_DIRS_MANIFEST = "/run/spectest-volume-dirs";
|
|
510
455
|
const ABS_VOLUME_DIRS = new Set();
|
|
511
456
|
async function recordAbsoluteVolumeDir(host) {
|
|
@@ -628,12 +573,9 @@ async function imageIdTables(tag) {
|
|
|
628
573
|
async function resolveOwner(svc, tag, what, user, group) {
|
|
629
574
|
if (user === undefined && group === undefined)
|
|
630
575
|
return undefined;
|
|
631
|
-
|
|
632
|
-
let
|
|
633
|
-
|
|
634
|
-
const needsTables = (user !== undefined && uid === undefined) ||
|
|
635
|
-
(group !== undefined && gid === undefined);
|
|
636
|
-
if (needsTables) {
|
|
576
|
+
let uid = numericId(user);
|
|
577
|
+
let gid = numericId(group);
|
|
578
|
+
if (needsIdTables(user, group)) {
|
|
637
579
|
const tables = await imageIdTables(tag);
|
|
638
580
|
if (user !== undefined && uid === undefined) {
|
|
639
581
|
uid = tables.users.get(user);
|
|
@@ -665,16 +607,12 @@ async function resolveOwner(svc, tag, what, user, group) {
|
|
|
665
607
|
async function applyFileOwnership(file, mode, owner) {
|
|
666
608
|
if (mode)
|
|
667
609
|
await fs.chmod(file, parseInt(mode, 8));
|
|
668
|
-
if (!owner)
|
|
610
|
+
if (!owner || isNoopChown(owner))
|
|
669
611
|
return;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
uid = st.uid;
|
|
675
|
-
if (gid < 0)
|
|
676
|
-
gid = st.gid;
|
|
677
|
-
}
|
|
612
|
+
// Bun rejects the `-1` halves, so fill them from the file's current
|
|
613
|
+
// owner — see `harness/file-mounts.ts` for the rule and its test.
|
|
614
|
+
const st = await fs.stat(file);
|
|
615
|
+
const { uid, gid } = resolveChownIds(owner, { uid: st.uid, gid: st.gid });
|
|
678
616
|
await fs.chown(file, uid, gid);
|
|
679
617
|
}
|
|
680
618
|
// Materialize `svc.files` onto the VM host and return `--volume` flags
|
|
@@ -691,14 +629,12 @@ async function ensureFiles(svc, tag) {
|
|
|
691
629
|
const dir = path.join(WORKSPACE, ".spectest", "files", svc.name);
|
|
692
630
|
await fs.mkdir(dir, { recursive: true });
|
|
693
631
|
for (const f of svc.files) {
|
|
694
|
-
|
|
695
|
-
throw new Error(`service "${svc.name}": file path ${JSON.stringify(f.path)} must be absolute`);
|
|
696
|
-
}
|
|
632
|
+
assertAbsolute(svc.name, "file path", f.path);
|
|
697
633
|
// `{{SPECTEST_SERVICE}}` expands to this service's name (its
|
|
698
634
|
// services-map key) so a component can author self-referential
|
|
699
635
|
// config without knowing the key the user will choose — e.g. k3s's
|
|
700
636
|
// registries.yaml keying on `<key>.internal:5000`.
|
|
701
|
-
const content = f.content
|
|
637
|
+
const content = expandServiceToken(f.content, svc.name);
|
|
702
638
|
const host = path.join(dir, sanitizeSegment(f.path));
|
|
703
639
|
await fs.writeFile(host, content);
|
|
704
640
|
// A bind mount carries this inode's mode and ownership into the
|
|
@@ -707,7 +643,7 @@ async function ensureFiles(svc, tag) {
|
|
|
707
643
|
// whoever the container actually runs as.
|
|
708
644
|
const owner = await resolveOwner(svc, tag, `file ${f.path}`, f.user, f.group);
|
|
709
645
|
await applyFileOwnership(host, f.mode, owner);
|
|
710
|
-
flags.push(
|
|
646
|
+
flags.push(mountFlag(host, f.path));
|
|
711
647
|
}
|
|
712
648
|
return flags;
|
|
713
649
|
}
|
|
@@ -742,14 +678,9 @@ async function ensureCertificates(svc, tag) {
|
|
|
742
678
|
["keyPath", c.keyPath],
|
|
743
679
|
...(c.caPath ? [["caPath", c.caPath]] : []),
|
|
744
680
|
]) {
|
|
745
|
-
|
|
746
|
-
throw new Error(`service "${svc.name}": certificate ${label} ${JSON.stringify(p)} must be absolute`);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
const hostnames = c.hostnames.map((h) => h.replaceAll("{{SPECTEST_SERVICE}}", svc.name));
|
|
750
|
-
if (hostnames.length === 0) {
|
|
751
|
-
throw new Error(`service "${svc.name}": certificate entry ${i} lists no hostnames`);
|
|
681
|
+
assertAbsolute(svc.name, `certificate ${label}`, p);
|
|
752
682
|
}
|
|
683
|
+
const hostnames = certificateHostnames(svc.name, i, c.hostnames);
|
|
753
684
|
const { cert, key } = await generateHostCert(`${svc.name}-${i}`, hostnames);
|
|
754
685
|
const certHost = path.join(dir, `${i}.crt`);
|
|
755
686
|
const keyHost = path.join(dir, `${i}.key`);
|
|
@@ -765,43 +696,19 @@ async function ensureCertificates(svc, tag) {
|
|
|
765
696
|
// Declaring who reads the key also says what mode it wants: the
|
|
766
697
|
// strictest one that owner can still open. Only reached when
|
|
767
698
|
// `user`/`group` is set, so no existing environment changes.
|
|
768
|
-
const keyMode = c.mode
|
|
699
|
+
const keyMode = defaultKeyMode(c.mode, owner);
|
|
769
700
|
await applyFileOwnership(keyHost, keyMode, owner);
|
|
770
701
|
// The certificate is public, but it follows the key's owner so a
|
|
771
702
|
// server that insists on owning its whole TLS directory is happy.
|
|
772
703
|
await applyFileOwnership(certHost, undefined, owner);
|
|
773
|
-
flags.push(
|
|
774
|
-
flags.push(
|
|
704
|
+
flags.push(mountFlag(certHost, c.certPath));
|
|
705
|
+
flags.push(mountFlag(keyHost, c.keyPath));
|
|
775
706
|
if (c.caPath)
|
|
776
|
-
flags.push(
|
|
707
|
+
flags.push(mountFlag(CA_PATH, c.caPath));
|
|
777
708
|
console.log(`[bootstrap] ${svc.name}: minted certificate for ${hostnames.join(", ")}`);
|
|
778
709
|
}
|
|
779
710
|
return flags;
|
|
780
711
|
}
|
|
781
|
-
function imageTag(name) {
|
|
782
|
-
return `spectest/${name}:latest`;
|
|
783
|
-
}
|
|
784
|
-
const DEFAULT_DOCKERIGNORE = [
|
|
785
|
-
".git",
|
|
786
|
-
".spectest",
|
|
787
|
-
"spectest",
|
|
788
|
-
"node_modules",
|
|
789
|
-
"target",
|
|
790
|
-
"__pycache__",
|
|
791
|
-
".venv",
|
|
792
|
-
".env",
|
|
793
|
-
".env.local",
|
|
794
|
-
".env.*",
|
|
795
|
-
"dist",
|
|
796
|
-
"build",
|
|
797
|
-
".next",
|
|
798
|
-
".turbo",
|
|
799
|
-
".DS_Store",
|
|
800
|
-
];
|
|
801
|
-
/** First line of the `.dockerignore` we generate ourselves, so a later
|
|
802
|
-
* bootstrap can tell our file apart from one the project ships and never
|
|
803
|
-
* mistakes its own output for user intent. */
|
|
804
|
-
const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
|
|
805
712
|
/**
|
|
806
713
|
* The project's own `/workspace/.dockerignore`, read once per bootstrap
|
|
807
714
|
* before we write anything, or `null` when it ships none.
|
|
@@ -819,7 +726,7 @@ async function readProjectDockerignore() {
|
|
|
819
726
|
try {
|
|
820
727
|
const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
|
|
821
728
|
// Ours, from a previous bootstrap of this workspace — not the project's.
|
|
822
|
-
if (text
|
|
729
|
+
if (isGeneratedDockerignore(text))
|
|
823
730
|
return null;
|
|
824
731
|
return text;
|
|
825
732
|
}
|
|
@@ -827,39 +734,11 @@ async function readProjectDockerignore() {
|
|
|
827
734
|
return null;
|
|
828
735
|
}
|
|
829
736
|
}
|
|
830
|
-
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
* service's `exclude`.
|
|
834
|
-
*
|
|
835
|
-
* Order is load-bearing for the `**` + negations idiom — the project's
|
|
836
|
-
* `**` subsumes our defaults, its `!` lines re-include exactly what the
|
|
837
|
-
* build needs, and the per-service `exclude` still gets the last word.
|
|
838
|
-
*/
|
|
737
|
+
/** Ignore rules for one dockerfile build. Composition (and the reason the
|
|
738
|
+
* order matters) lives in `harness/build-context.ts`; this supplies the
|
|
739
|
+
* project's own file, read once per bootstrap. */
|
|
839
740
|
function serviceDockerignore(exclude) {
|
|
840
|
-
|
|
841
|
-
if (PROJECT_DOCKERIGNORE !== null) {
|
|
842
|
-
parts.push(`# --- from the project's .dockerignore ---\n${PROJECT_DOCKERIGNORE.trimEnd()}`);
|
|
843
|
-
}
|
|
844
|
-
if (exclude && exclude.length > 0) {
|
|
845
|
-
parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
|
|
846
|
-
}
|
|
847
|
-
return parts.join("\n") + "\n";
|
|
848
|
-
}
|
|
849
|
-
function unionDockerignore(services) {
|
|
850
|
-
const seen = new Set(DEFAULT_DOCKERIGNORE);
|
|
851
|
-
const extras = [];
|
|
852
|
-
for (const s of services) {
|
|
853
|
-
if (s.image.type === "dockerfile" && s.image.exclude) {
|
|
854
|
-
for (const e of s.image.exclude) {
|
|
855
|
-
if (!seen.has(e)) {
|
|
856
|
-
seen.add(e);
|
|
857
|
-
extras.push(e);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
return ([GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n");
|
|
741
|
+
return composeServiceDockerignore(PROJECT_DOCKERIGNORE, exclude);
|
|
863
742
|
}
|
|
864
743
|
/// In-flight/finished dockerfile builds of this bootstrap, keyed by
|
|
865
744
|
/// sha256(dockerfile content + exclude list). Services that share an
|
|
@@ -870,11 +749,7 @@ function unionDockerignore(services) {
|
|
|
870
749
|
/// workspace generation (runtime services started mid-test share it).
|
|
871
750
|
const BUILD_DEDUP = new Map();
|
|
872
751
|
function buildContentKey(image) {
|
|
873
|
-
return new Bun.CryptoHasher("sha256")
|
|
874
|
-
.update(image.content)
|
|
875
|
-
.update("\0")
|
|
876
|
-
.update(JSON.stringify(image.exclude ?? []))
|
|
877
|
-
.digest("hex");
|
|
752
|
+
return computeBuildContentKey(() => new Bun.CryptoHasher("sha256"), image);
|
|
878
753
|
}
|
|
879
754
|
async function prepareServiceImage(svc, opts) {
|
|
880
755
|
const tag = imageTag(svc.name);
|
|
@@ -1121,106 +996,21 @@ async function runContainer(svc, tag, volumeFlags,
|
|
|
1121
996
|
extraAliases = []) {
|
|
1122
997
|
// Idempotent: clean up any leftover container with the same name.
|
|
1123
998
|
await docker(["rm", "-f", svc.name], 30_000);
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
// dnsName(h, { service }) into LOWERED.aliasesByService, plus any passed
|
|
1140
|
-
// explicitly by a runtime startService (not present in LOWERED).
|
|
1141
|
-
for (const h of [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]) {
|
|
1142
|
-
args.push(`--network-alias=${h}`);
|
|
1143
|
-
}
|
|
1144
|
-
// Bound TCP give-up time inside THIS container's network namespace.
|
|
1145
|
-
// net.ipv4.tcp_retries2 is per-netns and a fresh netns resets to the kernel
|
|
1146
|
-
// default (15 ≈ ~15 min of RTO backoff), so lowering it on the guest's init
|
|
1147
|
-
// netns (BASE_SETUP_SH) does NOT reach containers — and the connections that
|
|
1148
|
-
// actually wedge run here: buildkit/buildctl pulling base images + exporting
|
|
1149
|
-
// cache, and the k3s container's containerd pulling images, all to the host
|
|
1150
|
-
// zot over the VM↔host path. On a lost-retransmit (transient loss under
|
|
1151
|
-
// concurrent forks) such a flow otherwise stalls a build/pull for minutes.
|
|
1152
|
-
// Setting it per container resets a genuinely-stuck flow in ~tens of seconds
|
|
1153
|
-
// so the client retries on a fresh connection; live connections keep getting
|
|
1154
|
-
// ACKs and are unaffected. Safe because every service runs on the
|
|
1155
|
-
// spectest-net bridge (own netns), never --network=host where net.* is denied.
|
|
1156
|
-
args.push("--sysctl", "net.ipv4.tcp_retries2=6");
|
|
1157
|
-
// Wire every ingress hostname (fakes, TLS-terminated proxies, and any
|
|
1158
|
-
// dnsName(h, { ingress: true })) into the container's /etc/hosts so
|
|
1159
|
-
// `fetch("http://api.stripe.com")` or `fetch("https://app.test")` from
|
|
1160
|
-
// app code reaches the daemon's ingress listener via the bridge gateway.
|
|
1161
|
-
// /etc/hosts beats Docker's embedded DNS (127.0.0.11), so we don't need
|
|
1162
|
-
// to touch the container's resolver settings.
|
|
1163
|
-
if (cachedGatewayIp) {
|
|
1164
|
-
for (const h of LOWERED.ingressHosts) {
|
|
1165
|
-
args.push(`--add-host=${h}:${cachedGatewayIp}`);
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
// Resolve `spectest-host` to the host image-cache gateway so apps can
|
|
1169
|
-
// address the zot mirrors / shared buildkitd by name (see
|
|
1170
|
-
// SPECTEST_HOST_NAME). Skipped where there's no host cache.
|
|
1171
|
-
const hostGw = hostCacheGateway();
|
|
1172
|
-
if (hostGw)
|
|
1173
|
-
args.push(`--add-host=${SPECTEST_HOST_NAME}:${hostGw}`);
|
|
1174
|
-
if (svc.workdir)
|
|
1175
|
-
args.push(`--workdir=${svc.workdir}`);
|
|
1176
|
-
// Trust the spectest root CA from inside the container. Bind-mount
|
|
1177
|
-
// the cert + set the conventional env vars so language runtimes
|
|
1178
|
-
// (Node, Python requests/httpx, AWS SDKs) pick it up without
|
|
1179
|
-
// touching the image's system trust store. The per-image
|
|
1180
|
-
// ensureCaTrustedImage layer also installs it into the system
|
|
1181
|
-
// trust store; this env-var path is the belt-and-braces fallback
|
|
1182
|
-
// for images where the layer step couldn't run (no
|
|
1183
|
-
// update-ca-certificates).
|
|
1184
|
-
args.push(`--volume=${CA_PATH}:${CA_PATH}:ro`);
|
|
1185
|
-
// Appends to the image's roots, so it takes the bare CA.
|
|
1186
|
-
args.push("-e", `NODE_EXTRA_CA_CERTS=${CA_PATH}`);
|
|
1187
|
-
// These three REPLACE the roots — they must get the combined bundle or
|
|
1188
|
-
// the container loses every public CA (see CA_BUNDLE_PATH).
|
|
1189
|
-
const caBundle = await ensureCaBundle();
|
|
1190
|
-
if (caBundle) {
|
|
1191
|
-
if (caBundle !== CA_PATH)
|
|
1192
|
-
args.push(`--volume=${caBundle}:${caBundle}:ro`);
|
|
1193
|
-
args.push("-e", `SSL_CERT_FILE=${caBundle}`);
|
|
1194
|
-
args.push("-e", `REQUESTS_CA_BUNDLE=${caBundle}`);
|
|
1195
|
-
args.push("-e", `AWS_CA_BUNDLE=${caBundle}`);
|
|
1196
|
-
}
|
|
1197
|
-
if (svc.env) {
|
|
1198
|
-
for (const [k, v] of Object.entries(svc.env)) {
|
|
1199
|
-
args.push("-e", `${k}=${v}`);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
for (const flag of volumeFlags)
|
|
1203
|
-
args.push(flag);
|
|
1204
|
-
if (svc.privileged)
|
|
1205
|
-
args.push("--privileged");
|
|
1206
|
-
for (const p of svc.tmpfs ?? [])
|
|
1207
|
-
args.push(`--tmpfs=${p}`);
|
|
1208
|
-
if (svc.cgroupns)
|
|
1209
|
-
args.push(`--cgroupns=${svc.cgroupns}`);
|
|
1210
|
-
// `command` runs via sh -c, replacing the image entrypoint; `args` is a
|
|
1211
|
-
// plain CMD override (`docker run <image> <args…>`) that keeps the
|
|
1212
|
-
// entrypoint — what init-wrapped images (postgres) need for extra flags.
|
|
1213
|
-
if (svc.command && svc.args?.length) {
|
|
1214
|
-
throw new Error(`service ${svc.name}: \`command\` and \`args\` are mutually exclusive ` +
|
|
1215
|
-
`(command replaces the entrypoint with /bin/sh -c; args keeps it)`);
|
|
1216
|
-
}
|
|
1217
|
-
if (svc.command)
|
|
1218
|
-
args.push("--entrypoint=/bin/sh");
|
|
1219
|
-
args.push(tag);
|
|
1220
|
-
if (svc.command)
|
|
1221
|
-
args.push("-c", svc.command);
|
|
1222
|
-
else if (svc.args?.length)
|
|
1223
|
-
args.push(...svc.args);
|
|
999
|
+
// The argv, and every rule shaping it, lives in `harness/container-run.ts`
|
|
1000
|
+
// so it can be asserted without starting a container.
|
|
1001
|
+
const args = runContainerArgs({
|
|
1002
|
+
svc,
|
|
1003
|
+
tag,
|
|
1004
|
+
network: NETWORK_NAME,
|
|
1005
|
+
aliases: [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases],
|
|
1006
|
+
volumeFlags,
|
|
1007
|
+
ingressHosts: LOWERED.ingressHosts,
|
|
1008
|
+
gatewayIp: cachedGatewayIp,
|
|
1009
|
+
hostCacheGateway: hostCacheGateway(),
|
|
1010
|
+
hostCacheName: SPECTEST_HOST_NAME,
|
|
1011
|
+
caPath: CA_PATH,
|
|
1012
|
+
caBundle: await ensureCaBundle(),
|
|
1013
|
+
});
|
|
1224
1014
|
const r = await docker(args, 300_000);
|
|
1225
1015
|
if (r.code !== 0) {
|
|
1226
1016
|
throw new Error(`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
@@ -1274,78 +1064,27 @@ async function waitForReady(svc) {
|
|
|
1274
1064
|
if (!check)
|
|
1275
1065
|
return;
|
|
1276
1066
|
const timeoutSecs = check.timeoutSecs ?? 60;
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
const ramp = check.type === "exec" ? [250, 250, 400, 400, 500] : [50, 100, 150, 250, 400, 500];
|
|
1283
|
-
let attempt = 0;
|
|
1284
|
-
while (Date.now() < deadline) {
|
|
1285
|
-
let ok = false;
|
|
1286
|
-
if (check.type === "tcp") {
|
|
1287
|
-
ok = await probeTcp(svc.name, check.port);
|
|
1288
|
-
}
|
|
1289
|
-
else if (check.type === "http") {
|
|
1290
|
-
ok = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
|
|
1067
|
+
const probe = async () => {
|
|
1068
|
+
if (check.type === "tcp")
|
|
1069
|
+
return probeTcp(svc.name, check.port);
|
|
1070
|
+
if (check.type === "http") {
|
|
1071
|
+
return probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
|
|
1291
1072
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
1300
|
-
}
|
|
1073
|
+
return probeExec(svc.name, check.command);
|
|
1074
|
+
};
|
|
1075
|
+
// Scheduling (the ramp, and not sleeping past the deadline) lives in
|
|
1076
|
+
// `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
|
|
1077
|
+
const { ready } = await pollUntilReady(probe, { kind: check.type, timeoutSecs });
|
|
1078
|
+
if (ready)
|
|
1079
|
+
return;
|
|
1301
1080
|
const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
|
|
1302
1081
|
throw new Error(`service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`);
|
|
1303
1082
|
}
|
|
1304
|
-
/**
|
|
1305
|
-
*
|
|
1306
|
-
* walk it. Rejects unknown dependencies and cycles (the same two errors
|
|
1307
|
-
* the old level scheduler raised) so the DAG runner can assume a clean
|
|
1308
|
-
* graph.
|
|
1309
|
-
*/
|
|
1083
|
+
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1084
|
+
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1310
1085
|
function validateServiceGraph(services) {
|
|
1311
|
-
|
|
1312
|
-
for (const s of services) {
|
|
1313
|
-
for (const d of s.dependsOn ?? []) {
|
|
1314
|
-
if (!byName.has(d)) {
|
|
1315
|
-
throw new Error(`service ${s.name} depends on unknown service ${d}`);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
// Cycle detection via DFS coloring (white=unseen, gray=on stack, black=done).
|
|
1320
|
-
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
1321
|
-
const color = new Map(services.map((s) => [s.name, WHITE]));
|
|
1322
|
-
const visit = (name) => {
|
|
1323
|
-
color.set(name, GRAY);
|
|
1324
|
-
for (const d of byName.get(name).dependsOn ?? []) {
|
|
1325
|
-
const c = color.get(d);
|
|
1326
|
-
if (c === GRAY)
|
|
1327
|
-
throw new Error("service dependency cycle");
|
|
1328
|
-
if (c === WHITE)
|
|
1329
|
-
visit(d);
|
|
1330
|
-
}
|
|
1331
|
-
color.set(name, BLACK);
|
|
1332
|
-
};
|
|
1333
|
-
for (const s of services)
|
|
1334
|
-
if (color.get(s.name) === WHITE)
|
|
1335
|
-
visit(s.name);
|
|
1336
|
-
return byName;
|
|
1086
|
+
return validateGraph(services);
|
|
1337
1087
|
}
|
|
1338
|
-
/**
|
|
1339
|
-
* Bring up every service as early as its own dependencies allow.
|
|
1340
|
-
*
|
|
1341
|
-
* Each service starts the instant all of its `dependsOn` services have
|
|
1342
|
-
* finished `startOne` (run → readyCheck → setup) — not when its whole
|
|
1343
|
-
* topological "level" has. Independent branches run fully concurrently;
|
|
1344
|
-
* a slow probe on one service delays only its own transitive dependents,
|
|
1345
|
-
* never an unrelated branch. `startOne(svc)`'s promise is memoized so each
|
|
1346
|
-
* service runs exactly once even when several dependents share a dep, and
|
|
1347
|
-
* a dependency failure propagates by rejecting every dependent's await.
|
|
1348
|
-
*/
|
|
1349
1088
|
async function startServices(services, startOne) {
|
|
1350
1089
|
const byName = validateServiceGraph(services);
|
|
1351
1090
|
const started = new Map();
|
|
@@ -1390,11 +1129,6 @@ async function startServices(services, startOne) {
|
|
|
1390
1129
|
// ────────────────────────────────────────────────────────────────────────
|
|
1391
1130
|
const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
|
|
1392
1131
|
const DEFAULT_FAKE_PORT = 80;
|
|
1393
|
-
/** Fixed HTTPS port shared by every route (fakes + service-tls). */
|
|
1394
|
-
const INGRESS_HTTPS_PORT = 443;
|
|
1395
|
-
/** Fixed HTTP port always bound for service-tls (alongside any
|
|
1396
|
-
* fakes whose `port` happens to be 80). */
|
|
1397
|
-
const INGRESS_HTTP_PORT = 80;
|
|
1398
1132
|
/** All loaded fakes, keyed by stable name (the `fakes` map key). Holds the
|
|
1399
1133
|
* in-daemon handler, forked state, and helpers — the parts intrinsic to a
|
|
1400
1134
|
* fake. Their *networking* (certs, DNS, routes) comes from `LOWERED`. */
|
|
@@ -1416,31 +1150,36 @@ const INGRESS_HTTP_SERVERS = new Map();
|
|
|
1416
1150
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1417
1151
|
const INGRESS_HTTPS_SERVERS = new Map();
|
|
1418
1152
|
/**
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
|
|
1427
|
-
const INGRESS_ROUTES_BY_PORT = new Map();
|
|
1428
|
-
/**
|
|
1429
|
-
* The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
|
|
1430
|
-
* TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
|
|
1431
|
-
* minting a cert for a *new* hostname requires rebinding the :443 listener
|
|
1432
|
-
* (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
|
|
1433
|
-
* by an existing exact or wildcard cert needs no rebind, just a route entry.
|
|
1153
|
+
* The live ingress tables — per-port routes and the :443 SNI cert table.
|
|
1154
|
+
*
|
|
1155
|
+
* The shape, the lookup rules and the "does this bind cost a rebind?"
|
|
1156
|
+
* decision live in `harness/ingress-table.ts`; this is the one instance the
|
|
1157
|
+
* daemon process owns. It is module state on purpose: it forks with the
|
|
1158
|
+
* snapshot, exactly like fake state and the names REGISTRY, which is what
|
|
1159
|
+
* lets a route bound mid-test be inherited by a `dependsOn` child and stay
|
|
1160
|
+
* invisible to its siblings. Rebuilt on /load (see {@link stopIngressServers}).
|
|
1434
1161
|
*/
|
|
1435
|
-
const
|
|
1162
|
+
const INGRESS = emptyTables();
|
|
1436
1163
|
/**
|
|
1437
1164
|
* Tear down listener servers between /load calls so the new project's
|
|
1438
1165
|
* routes can rebind cleanly.
|
|
1439
1166
|
*/
|
|
1167
|
+
/**
|
|
1168
|
+
* Tear the listeners down between `/load` calls.
|
|
1169
|
+
*
|
|
1170
|
+
* Force-closes rather than draining, deliberately. Every listener now sets
|
|
1171
|
+
* SO_REUSEPORT so a replacement can overlap the original during a cert
|
|
1172
|
+
* rebind ({@link rebindHttpsListener}) — but that also means a listener
|
|
1173
|
+
* left half-alive here would no longer collide with the next project's
|
|
1174
|
+
* bind. It would quietly share the port instead, and the kernel would hand
|
|
1175
|
+
* some connections to the old project's route table. A drain is right for a
|
|
1176
|
+
* rebind, where both listeners serve the same environment; it is wrong
|
|
1177
|
+
* here, where the containers behind those routes are being removed anyway.
|
|
1178
|
+
*/
|
|
1440
1179
|
function stopIngressServers() {
|
|
1441
1180
|
for (const [port, srv] of INGRESS_HTTP_SERVERS) {
|
|
1442
1181
|
try {
|
|
1443
|
-
srv.stop?.();
|
|
1182
|
+
srv.stop?.(true);
|
|
1444
1183
|
}
|
|
1445
1184
|
catch (err) {
|
|
1446
1185
|
// eslint-disable-next-line no-console
|
|
@@ -1450,7 +1189,7 @@ function stopIngressServers() {
|
|
|
1450
1189
|
INGRESS_HTTP_SERVERS.clear();
|
|
1451
1190
|
for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
|
|
1452
1191
|
try {
|
|
1453
|
-
srv.stop?.();
|
|
1192
|
+
srv.stop?.(true);
|
|
1454
1193
|
}
|
|
1455
1194
|
catch (err) {
|
|
1456
1195
|
// eslint-disable-next-line no-console
|
|
@@ -1458,8 +1197,7 @@ function stopIngressServers() {
|
|
|
1458
1197
|
}
|
|
1459
1198
|
}
|
|
1460
1199
|
INGRESS_HTTPS_SERVERS.clear();
|
|
1461
|
-
|
|
1462
|
-
HTTPS_CERT_BY_HOST.clear();
|
|
1200
|
+
clearTables(INGRESS);
|
|
1463
1201
|
}
|
|
1464
1202
|
function buildIngress(project) {
|
|
1465
1203
|
stopIngressServers();
|
|
@@ -1574,98 +1312,6 @@ async function bridgeGatewayIp() {
|
|
|
1574
1312
|
*/
|
|
1575
1313
|
const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
|
|
1576
1314
|
/** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
|
|
1577
|
-
const HOP_BY_HOP_HEADERS = new Set([
|
|
1578
|
-
"connection",
|
|
1579
|
-
"keep-alive",
|
|
1580
|
-
"proxy-authenticate",
|
|
1581
|
-
"proxy-authorization",
|
|
1582
|
-
"te",
|
|
1583
|
-
"trailers",
|
|
1584
|
-
"transfer-encoding",
|
|
1585
|
-
"upgrade",
|
|
1586
|
-
"host",
|
|
1587
|
-
]);
|
|
1588
|
-
/**
|
|
1589
|
-
* Is this a CORS preflight? A preflight is the browser's own probe (never
|
|
1590
|
-
* app business logic): an `OPTIONS` carrying `Origin` +
|
|
1591
|
-
* `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
|
|
1592
|
-
* app requests and pass straight through to the upstream/fake.
|
|
1593
|
-
*/
|
|
1594
|
-
function isCorsPreflight(req) {
|
|
1595
|
-
return (req.method === "OPTIONS" &&
|
|
1596
|
-
req.headers.has("origin") &&
|
|
1597
|
-
req.headers.has("access-control-request-method"));
|
|
1598
|
-
}
|
|
1599
|
-
/**
|
|
1600
|
-
* Answer a CORS preflight at the ingress, permissively, reflecting exactly
|
|
1601
|
-
* what the browser asked for.
|
|
1602
|
-
*
|
|
1603
|
-
* Why this belongs in the platform, not the app: inside the hermetic sandbox
|
|
1604
|
-
* the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
|
|
1605
|
-
* it fetches through this ingress (`https://api.example.com`) are *always*
|
|
1606
|
-
* different origins, so any request with a non-safelisted header — which
|
|
1607
|
-
* includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
|
|
1608
|
-
* preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
|
|
1609
|
-
* request succeeds or fails on whether *that* app happens to enumerate the
|
|
1610
|
-
* header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
|
|
1611
|
-
* but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
|
|
1612
|
-
* HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
|
|
1613
|
-
* with an instant "Failed to fetch" — even though the identical request works
|
|
1614
|
-
* in production behind a permissive edge/gateway. Reflecting
|
|
1615
|
-
* `Access-Control-Request-Headers` verbatim makes the ingress transparent to
|
|
1616
|
-
* whatever header vocabulary the app under test uses.
|
|
1617
|
-
*/
|
|
1618
|
-
function corsPreflightResponse(req) {
|
|
1619
|
-
const origin = req.headers.get("origin") ?? "*";
|
|
1620
|
-
const reqHeaders = req.headers.get("access-control-request-headers");
|
|
1621
|
-
const reqMethod = req.headers.get("access-control-request-method");
|
|
1622
|
-
const headers = new Headers();
|
|
1623
|
-
headers.set("access-control-allow-origin", origin);
|
|
1624
|
-
// Echo the specific origin (not `*`) so credentialed requests are allowed;
|
|
1625
|
-
// `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
|
|
1626
|
-
// reject.
|
|
1627
|
-
headers.set("access-control-allow-credentials", "true");
|
|
1628
|
-
headers.set("access-control-allow-methods", reqMethod && reqMethod.length > 0
|
|
1629
|
-
? reqMethod
|
|
1630
|
-
: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS");
|
|
1631
|
-
headers.set("access-control-allow-headers", reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*");
|
|
1632
|
-
headers.set("access-control-max-age", "600");
|
|
1633
|
-
// The response varies by the reflected origin/headers — keep caches honest.
|
|
1634
|
-
headers.append("vary", "Origin");
|
|
1635
|
-
headers.append("vary", "Access-Control-Request-Headers");
|
|
1636
|
-
return new Response(null, { status: 204, headers });
|
|
1637
|
-
}
|
|
1638
|
-
/**
|
|
1639
|
-
* Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
|
|
1640
|
-
* *actual* cross-origin response. Only fills one in when the upstream/fake
|
|
1641
|
-
* didn't set its own, so an app that manages CORS itself keeps full control;
|
|
1642
|
-
* this just stops a missing header from turning an otherwise-fine 200 into a
|
|
1643
|
-
* "Failed to fetch". No-op for same-origin requests (no `Origin`).
|
|
1644
|
-
*/
|
|
1645
|
-
function augmentCorsResponse(req, res) {
|
|
1646
|
-
const origin = req.headers.get("origin");
|
|
1647
|
-
if (!origin)
|
|
1648
|
-
return res;
|
|
1649
|
-
if (res.headers.has("access-control-allow-origin"))
|
|
1650
|
-
return res;
|
|
1651
|
-
try {
|
|
1652
|
-
res.headers.set("access-control-allow-origin", origin);
|
|
1653
|
-
res.headers.set("access-control-allow-credentials", "true");
|
|
1654
|
-
res.headers.append("vary", "Origin");
|
|
1655
|
-
}
|
|
1656
|
-
catch {
|
|
1657
|
-
// Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
|
|
1658
|
-
// headers — leave those untouched.
|
|
1659
|
-
}
|
|
1660
|
-
return res;
|
|
1661
|
-
}
|
|
1662
|
-
/**
|
|
1663
|
-
* Bring ingress servers up: bind one Bun.serve per unique HTTP port
|
|
1664
|
-
* (fakes' ports plus the always-on :80 for service proxies), plus a
|
|
1665
|
-
* shared HTTPS :443 (SNI per hostname). Build each fake's initial state,
|
|
1666
|
-
* then write the hostname→ip registry that spectest-resolver consults
|
|
1667
|
-
* for DNS. Idempotent — calling twice rebuilds.
|
|
1668
|
-
*/
|
|
1669
1315
|
async function startIngress() {
|
|
1670
1316
|
const hasIngress = FAKES.size > 0 ||
|
|
1671
1317
|
LOWERED.proxies.length > 0 ||
|
|
@@ -1706,7 +1352,7 @@ async function startIngress() {
|
|
|
1706
1352
|
continue;
|
|
1707
1353
|
const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
|
|
1708
1354
|
for (const h of group.hostnames)
|
|
1709
|
-
|
|
1355
|
+
INGRESS.certByHost.set(h, leaf);
|
|
1710
1356
|
}
|
|
1711
1357
|
}
|
|
1712
1358
|
else if (LOWERED.certificates.length > 0) {
|
|
@@ -1718,11 +1364,7 @@ async function startIngress() {
|
|
|
1718
1364
|
// route tables (module scope, so runtime `tls` can extend them later).
|
|
1719
1365
|
// Fakes run an in-daemon handler on their declared port; proxies
|
|
1720
1366
|
// reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
|
|
1721
|
-
const ensurePort = (port) =>
|
|
1722
|
-
const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map();
|
|
1723
|
-
INGRESS_ROUTES_BY_PORT.set(port, m);
|
|
1724
|
-
return m;
|
|
1725
|
-
};
|
|
1367
|
+
const ensurePort = (port) => routesFor(INGRESS, port);
|
|
1726
1368
|
for (const fake of FAKES.values()) {
|
|
1727
1369
|
if (fake.port === INGRESS_HTTPS_PORT)
|
|
1728
1370
|
continue;
|
|
@@ -1740,7 +1382,7 @@ async function startIngress() {
|
|
|
1740
1382
|
// covers — exactly, or through a wildcard SAN, so `*.example.com` in a
|
|
1741
1383
|
// service's `tls` also puts an unrelated exact `api.example.com` route on
|
|
1742
1384
|
// HTTPS. Proxies are applied after fakes so a proxy wins a shared host.
|
|
1743
|
-
if (
|
|
1385
|
+
if (INGRESS.certByHost.size > 0) {
|
|
1744
1386
|
const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
|
|
1745
1387
|
for (const fake of FAKES.values()) {
|
|
1746
1388
|
for (const h of fake.hostnames) {
|
|
@@ -1755,7 +1397,7 @@ async function startIngress() {
|
|
|
1755
1397
|
}
|
|
1756
1398
|
}
|
|
1757
1399
|
// ── HTTP listeners (one per non-443 port).
|
|
1758
|
-
for (const [port, byHost] of
|
|
1400
|
+
for (const [port, byHost] of INGRESS.routesByPort) {
|
|
1759
1401
|
if (port === INGRESS_HTTPS_PORT)
|
|
1760
1402
|
continue;
|
|
1761
1403
|
INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
|
|
@@ -1763,7 +1405,7 @@ async function startIngress() {
|
|
|
1763
1405
|
console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
|
|
1764
1406
|
}
|
|
1765
1407
|
// ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
|
|
1766
|
-
if (
|
|
1408
|
+
if (INGRESS.certByHost.size > 0)
|
|
1767
1409
|
rebindHttpsListener(Bun);
|
|
1768
1410
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
1769
1411
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
@@ -1780,37 +1422,61 @@ function requireBun() {
|
|
|
1780
1422
|
}
|
|
1781
1423
|
return Bun;
|
|
1782
1424
|
}
|
|
1783
|
-
/** Flatten
|
|
1425
|
+
/** Flatten the live SNI cert table into Bun's TLS-entry array. */
|
|
1784
1426
|
function tlsEntriesFromCerts() {
|
|
1785
|
-
return
|
|
1786
|
-
cert: leaf.cert,
|
|
1787
|
-
key: leaf.key,
|
|
1788
|
-
serverName,
|
|
1789
|
-
}));
|
|
1427
|
+
return certEntries(INGRESS);
|
|
1790
1428
|
}
|
|
1791
1429
|
/**
|
|
1792
|
-
* (Re)bind the :443 listener from the current cert table + route map.
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
1430
|
+
* (Re)bind the :443 listener from the current cert table + route map.
|
|
1431
|
+
*
|
|
1432
|
+
* Bun fixes a server's TLS config at `Bun.serve` time — `reload()` accepts a
|
|
1433
|
+
* new `tls` option and silently keeps serving the old certificates (measured
|
|
1434
|
+
* on Bun 1.3.14: after reloading with a second SNI entry, the new hostname
|
|
1435
|
+
* still gets the first one's leaf). So adding a certificate really does mean
|
|
1436
|
+
* a second listener.
|
|
1437
|
+
*
|
|
1438
|
+
* **Bind the new one before stopping the old one.** Doing it the other way
|
|
1439
|
+
* round — which is what this used to do, with `stop(true)` — has two teeth:
|
|
1440
|
+
* the force-close kills every established connection on :443, and the gap
|
|
1441
|
+
* before the new listener binds refuses new ones. Neither is limited to the
|
|
1442
|
+
* hostname being added; they hit all the unrelated traffic the listener is
|
|
1443
|
+
* carrying. On a project that mints a certificate per provisioned database
|
|
1444
|
+
* while deploys stream through the same port, that surfaced as the app under
|
|
1445
|
+
* test dying with `SocketError: other side closed` or `ECONNREFUSED
|
|
1446
|
+
* <gateway>:443` — a different test each run, and nothing pointing at
|
|
1447
|
+
* ingress.
|
|
1448
|
+
*
|
|
1449
|
+
* Overlapping the two needs SO_REUSEPORT on both sockets ({@link
|
|
1450
|
+
* bindIngressServer} sets it unconditionally for that reason), after which
|
|
1451
|
+
* the old listener is drained with `stop(false)` so requests in flight
|
|
1452
|
+
* finish. During the overlap the kernel may hand a new connection to either
|
|
1453
|
+
* socket, which is safe: the only hostname the two disagree about is the one
|
|
1454
|
+
* being added, and it does not resolve until the caller writes the names
|
|
1455
|
+
* registry after this returns.
|
|
1456
|
+
*
|
|
1457
|
+
* The route Map is the persistent module object, so the new listener closes
|
|
1458
|
+
* over the same table and later route additions need no rebind at all.
|
|
1798
1459
|
*/
|
|
1799
1460
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1800
1461
|
function rebindHttpsListener(Bun) {
|
|
1801
|
-
|
|
1802
|
-
|
|
1462
|
+
// The *same* table object the previous listener closed over: the new
|
|
1463
|
+
// listener must serve the routes bound since, and any bound later.
|
|
1464
|
+
const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
|
|
1803
1465
|
const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
|
|
1466
|
+
const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
|
|
1804
1467
|
if (old) {
|
|
1805
1468
|
try {
|
|
1806
|
-
old
|
|
1469
|
+
// Graceful: stop accepting, let in-flight requests finish. The old
|
|
1470
|
+
// server stays alive until they do, which is the point — a long
|
|
1471
|
+
// upload through ingress must not be collateral damage of another
|
|
1472
|
+
// service being provisioned.
|
|
1473
|
+
old.stop(false);
|
|
1807
1474
|
}
|
|
1808
1475
|
catch (err) {
|
|
1809
1476
|
// eslint-disable-next-line no-console
|
|
1810
|
-
console.warn("[ingress] failed to
|
|
1477
|
+
console.warn("[ingress] failed to drain the previous https listener:", err);
|
|
1811
1478
|
}
|
|
1812
1479
|
}
|
|
1813
|
-
const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
|
|
1814
1480
|
INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
|
|
1815
1481
|
// eslint-disable-next-line no-console
|
|
1816
1482
|
console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
|
|
@@ -1822,23 +1488,11 @@ function rebindHttpsListener(Bun) {
|
|
|
1822
1488
|
* client stops at one label. Claiming a deeper name is covered would skip
|
|
1823
1489
|
* minting the leaf it actually needs and hand it a cert it rejects.
|
|
1824
1490
|
*/
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
return false;
|
|
1829
|
-
const label = hostname.slice(0, -suffix.length);
|
|
1830
|
-
return label.length > 0 && !label.includes(".");
|
|
1831
|
-
}
|
|
1832
|
-
/** True if an exact or wildcard cert already covers `hostname` for SNI. */
|
|
1491
|
+
/** True if an exact or wildcard cert already covers `hostname` for SNI.
|
|
1492
|
+
* The matching rules live in `harness/hostmatch.ts`; this just supplies
|
|
1493
|
+
* the live cert table. */
|
|
1833
1494
|
function certCovers(hostname) {
|
|
1834
|
-
|
|
1835
|
-
return true;
|
|
1836
|
-
for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
|
|
1837
|
-
if (isWildcard(serverName) && wildcardCoversHost(serverName, hostname)) {
|
|
1838
|
-
return true;
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
return false;
|
|
1495
|
+
return hostmatchCertCovers(INGRESS.certByHost.keys(), hostname);
|
|
1842
1496
|
}
|
|
1843
1497
|
/**
|
|
1844
1498
|
* Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
|
|
@@ -1863,38 +1517,37 @@ async function bindRuntimeTls(hostname, service, port) {
|
|
|
1863
1517
|
}
|
|
1864
1518
|
const Bun = requireBun();
|
|
1865
1519
|
const route = { kind: "proxy", service, port };
|
|
1866
|
-
//
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1520
|
+
// Decide the cost before mutating anything: an already-covered hostname
|
|
1521
|
+
// (an exact duplicate, or one under a boot wildcard) needs only a route.
|
|
1522
|
+
const plan = planBind(INGRESS, host, {
|
|
1523
|
+
httpsListening: INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT),
|
|
1524
|
+
});
|
|
1525
|
+
// Routes go in before the certificate is minted, not after. Minting
|
|
1526
|
+
// shells out to openssl and takes on the order of a second, and both
|
|
1527
|
+
// schemes are already serving other hosts — so binding first means
|
|
1528
|
+
// `http://<host>/` starts working immediately instead of 404ing for the
|
|
1529
|
+
// length of an RSA keygen. Nothing here can observe a half-bound state:
|
|
1530
|
+
// the route is only reachable once the hostname resolves, which is the
|
|
1531
|
+
// registry write at the end.
|
|
1532
|
+
bindRoute(INGRESS, host, route);
|
|
1873
1533
|
if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
|
|
1874
|
-
INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT,
|
|
1534
|
+
INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT), `port ${INGRESS_HTTP_PORT}`));
|
|
1875
1535
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
const needCert = !certCovers(host);
|
|
1879
|
-
if (needCert) {
|
|
1880
|
-
HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
|
|
1536
|
+
if (plan.needsCert) {
|
|
1537
|
+
INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
|
|
1881
1538
|
}
|
|
1882
|
-
|
|
1883
|
-
INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
|
|
1884
|
-
httpsRoutes.set(host, route);
|
|
1885
|
-
if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
|
|
1539
|
+
if (plan.needsHttpsRebind)
|
|
1886
1540
|
rebindHttpsListener(Bun);
|
|
1887
|
-
}
|
|
1888
1541
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1889
1542
|
// A wildcard can only live in the resolver's suffix table.
|
|
1890
1543
|
const gw = await bridgeGatewayIp();
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1894
|
-
REGISTRY.wildcards.push({ suffix, ip: gw });
|
|
1544
|
+
const target = registryTarget(host);
|
|
1545
|
+
if (target.wildcard) {
|
|
1546
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
|
|
1547
|
+
REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
|
|
1895
1548
|
}
|
|
1896
1549
|
else {
|
|
1897
|
-
REGISTRY.hosts[host] = gw;
|
|
1550
|
+
REGISTRY.hosts[target.host] = gw;
|
|
1898
1551
|
}
|
|
1899
1552
|
await writeRegistry();
|
|
1900
1553
|
// eslint-disable-next-line no-console
|
|
@@ -1908,17 +1561,16 @@ async function bindRuntimeTls(hostname, service, port) {
|
|
|
1908
1561
|
*/
|
|
1909
1562
|
async function unbindRuntimeTls(hostname) {
|
|
1910
1563
|
const host = hostname.toLowerCase();
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
if (
|
|
1914
|
-
const suffix = wildcardSuffix(host);
|
|
1564
|
+
unbindRoute(INGRESS, host);
|
|
1565
|
+
const target = registryTarget(host);
|
|
1566
|
+
if (target.wildcard) {
|
|
1915
1567
|
const before = REGISTRY.wildcards.length;
|
|
1916
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1568
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
|
|
1917
1569
|
if (REGISTRY.wildcards.length !== before)
|
|
1918
1570
|
await writeRegistry();
|
|
1919
1571
|
}
|
|
1920
|
-
else if (host in REGISTRY.hosts) {
|
|
1921
|
-
delete REGISTRY.hosts[host];
|
|
1572
|
+
else if (target.host in REGISTRY.hosts) {
|
|
1573
|
+
delete REGISTRY.hosts[target.host];
|
|
1922
1574
|
await writeRegistry();
|
|
1923
1575
|
}
|
|
1924
1576
|
}
|
|
@@ -1942,6 +1594,12 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
|
|
|
1942
1594
|
const opts = {
|
|
1943
1595
|
port,
|
|
1944
1596
|
hostname: "0.0.0.0",
|
|
1597
|
+
// SO_REUSEPORT on every ingress listener, so a replacement can be bound
|
|
1598
|
+
// while the old one is still serving. That overlap is the only way to
|
|
1599
|
+
// add an SNI certificate without a gap — see {@link rebindHttpsListener}
|
|
1600
|
+
// — and it only works if *both* sockets opt in: a second plain bind
|
|
1601
|
+
// fails with "Is port 443 in use?".
|
|
1602
|
+
reusePort: true,
|
|
1945
1603
|
// Bun.serve defaults to a 10s idleTimeout, which kills any proxied
|
|
1946
1604
|
// request whose upstream takes >10s to produce bytes — under parallel
|
|
1947
1605
|
// test load that surfaced as "fetch failed"/"other side closed" on
|
|
@@ -2046,21 +1704,7 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
|
|
|
2046
1704
|
* present a valid cert over `https://`.
|
|
2047
1705
|
*/
|
|
2048
1706
|
function matchIngressRoute(byHost, host) {
|
|
2049
|
-
|
|
2050
|
-
if (exact)
|
|
2051
|
-
return exact;
|
|
2052
|
-
let best;
|
|
2053
|
-
let bestLen = -1;
|
|
2054
|
-
for (const [pattern, route] of byHost) {
|
|
2055
|
-
if (!isWildcard(pattern))
|
|
2056
|
-
continue;
|
|
2057
|
-
const suffix = wildcardSuffix(pattern);
|
|
2058
|
-
if (host.endsWith(suffix) && suffix.length > bestLen) {
|
|
2059
|
-
best = route;
|
|
2060
|
-
bestLen = suffix.length;
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
return best;
|
|
1707
|
+
return matchRoute(byHost, host);
|
|
2064
1708
|
}
|
|
2065
1709
|
/**
|
|
2066
1710
|
* Per-request dispatch shared by every ingress listener. Looks up the
|
|
@@ -2072,10 +1716,9 @@ function matchIngressRoute(byHost, host) {
|
|
|
2072
1716
|
async function dispatchIngress(req,
|
|
2073
1717
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2074
1718
|
server, byHost, listenerLabel, proto) {
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
.trim();
|
|
1719
|
+
// `hostWithoutPort` rather than split(":")[0]: the naive form turns an
|
|
1720
|
+
// IPv6 literal Host (`[::1]:8080`) into "[".
|
|
1721
|
+
const host = hostWithoutPort((req.headers.get("host") ?? "").toLowerCase());
|
|
2079
1722
|
const route = matchIngressRoute(byHost, host);
|
|
2080
1723
|
if (!route) {
|
|
2081
1724
|
return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
|
|
@@ -2262,11 +1905,9 @@ server, service, port, listenerLabel, proto) {
|
|
|
2262
1905
|
*/
|
|
2263
1906
|
const REGISTRY = { hosts: {}, wildcards: [] };
|
|
2264
1907
|
async function writeRegistry() {
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
updatedAt: Date.now(),
|
|
2269
|
-
});
|
|
1908
|
+
// Format + lookup semantics live in `harness/names-registry.ts`, shared
|
|
1909
|
+
// with the resolver that reads this file.
|
|
1910
|
+
const body = encodeRegistry(REGISTRY, Date.now());
|
|
2270
1911
|
try {
|
|
2271
1912
|
await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
|
|
2272
1913
|
await fs.writeFile(FAKES_REGISTRY_PATH, body);
|
|
@@ -2278,9 +1919,6 @@ async function writeRegistry() {
|
|
|
2278
1919
|
}
|
|
2279
1920
|
}
|
|
2280
1921
|
/** `*.example.com` → `.example.com` — the suffix the resolver matches. */
|
|
2281
|
-
function wildcardSuffix(pattern) {
|
|
2282
|
-
return pattern.slice(1); // drop the leading "*"
|
|
2283
|
-
}
|
|
2284
1922
|
/** A service container's IP on spectest-net. `null` if the container isn't
|
|
2285
1923
|
* up or isn't attached to the network yet. */
|
|
2286
1924
|
async function serviceContainerIp(name) {
|
|
@@ -2937,11 +2575,6 @@ function stashReplayBundle(caseId, gz) {
|
|
|
2937
2575
|
* so it travels with the snapshot into every fork.
|
|
2938
2576
|
*/
|
|
2939
2577
|
const LOG_MARKERS = new Map();
|
|
2940
|
-
/** Per-(service, stream) delta byte cap. Over this we keep head+tail and
|
|
2941
|
-
* elide the middle — the head preserves the continuation from the parent,
|
|
2942
|
-
* the tail preserves the newest output — while still advancing the marker
|
|
2943
|
-
* to the true line count so the chain stays aligned. */
|
|
2944
|
-
const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
|
|
2945
2578
|
/**
|
|
2946
2579
|
* Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
|
|
2947
2580
|
* running (or when inspect fails — a removed container has no state left to
|
|
@@ -2961,68 +2594,6 @@ async function containerStopState(name) {
|
|
|
2961
2594
|
* `max` (string length, a byte proxy as elsewhere here). Head+tail so an
|
|
2962
2595
|
* over-long delta keeps both the parent-continuation and the newest
|
|
2963
2596
|
* output. */
|
|
2964
|
-
function capMiddle(s, max) {
|
|
2965
|
-
if (s.length <= max)
|
|
2966
|
-
return { value: s, truncated: false };
|
|
2967
|
-
const half = Math.floor(max / 2);
|
|
2968
|
-
const elided = s.length - 2 * half;
|
|
2969
|
-
return {
|
|
2970
|
-
value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
|
|
2971
|
-
truncated: true,
|
|
2972
|
-
};
|
|
2973
|
-
}
|
|
2974
|
-
/**
|
|
2975
|
-
* Compute one stream's delta beyond `marker` complete lines.
|
|
2976
|
-
* - Counts only newline-terminated lines; a trailing partial line (no
|
|
2977
|
-
* `\n` yet) is held back from both the delta and the count, so a line
|
|
2978
|
-
* completed by a later capture isn't split across the fork boundary.
|
|
2979
|
-
* - Reset guard: if the stream shrank below `marker` (container recreated
|
|
2980
|
-
* or rotated) the whole current log is re-emitted and `reset` is set.
|
|
2981
|
-
*/
|
|
2982
|
-
function streamDelta(full, marker) {
|
|
2983
|
-
const lastNl = full.lastIndexOf("\n");
|
|
2984
|
-
const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
|
|
2985
|
-
let total = 0;
|
|
2986
|
-
for (let i = 0; i < complete.length; i++) {
|
|
2987
|
-
if (complete.charCodeAt(i) === 10)
|
|
2988
|
-
total++;
|
|
2989
|
-
}
|
|
2990
|
-
let reset = false;
|
|
2991
|
-
let startLine = marker;
|
|
2992
|
-
if (total < marker) {
|
|
2993
|
-
reset = true;
|
|
2994
|
-
startLine = 0;
|
|
2995
|
-
}
|
|
2996
|
-
let delta;
|
|
2997
|
-
if (startLine <= 0) {
|
|
2998
|
-
delta = complete;
|
|
2999
|
-
}
|
|
3000
|
-
else if (startLine >= total) {
|
|
3001
|
-
delta = "";
|
|
3002
|
-
}
|
|
3003
|
-
else {
|
|
3004
|
-
// Byte offset just past the `startLine`-th newline.
|
|
3005
|
-
let seen = 0;
|
|
3006
|
-
let off = 0;
|
|
3007
|
-
for (let i = 0; i < complete.length; i++) {
|
|
3008
|
-
if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
|
|
3009
|
-
off = i + 1;
|
|
3010
|
-
break;
|
|
3011
|
-
}
|
|
3012
|
-
}
|
|
3013
|
-
delta = complete.slice(off);
|
|
3014
|
-
}
|
|
3015
|
-
const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
|
|
3016
|
-
return { delta: capped.value, total, reset, truncated: capped.truncated };
|
|
3017
|
-
}
|
|
3018
|
-
/**
|
|
3019
|
-
* Capture the per-service log delta for the current case and advance the
|
|
3020
|
-
* markers. Runs on EVERY case (pass or fail). Enumerates boot services
|
|
3021
|
-
* (`namedServices`) plus any runtime services this fork started
|
|
3022
|
-
* (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
|
|
3023
|
-
* as the service's `stderr` WITHOUT advancing the markers — a transient
|
|
3024
|
-
* failure must never desync the chain.
|
|
3025
|
-
*/
|
|
3026
2597
|
async function captureServiceLogDeltas() {
|
|
3027
2598
|
const l = loaded;
|
|
3028
2599
|
if (!l)
|
|
@@ -3115,6 +2686,19 @@ function newArtifactCollector() {
|
|
|
3115
2686
|
},
|
|
3116
2687
|
};
|
|
3117
2688
|
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Normalise `ctx.browser(...)`'s two call shapes — `(opts?)` for the
|
|
2691
|
+
* default session and `(name, opts?)` for a named one — into the
|
|
2692
|
+
* `(name, opts)` pair the session registry keys on. The overloads make
|
|
2693
|
+
* these the only two shapes that typecheck, but the implementation still
|
|
2694
|
+
* has to tell them apart at runtime: an untyped caller (`eval`, plain JS)
|
|
2695
|
+
* reaches the same function.
|
|
2696
|
+
*/
|
|
2697
|
+
function browserArgs(nameOrOpts, opts) {
|
|
2698
|
+
return typeof nameOrOpts === "string"
|
|
2699
|
+
? { name: nameOrOpts, opts }
|
|
2700
|
+
: { name: "", opts: nameOrOpts };
|
|
2701
|
+
}
|
|
3118
2702
|
/**
|
|
3119
2703
|
* Build the recorder sink + bookkeeping for a single Browser session.
|
|
3120
2704
|
* The returned `recorder` is what `openBrowser` writes into; the
|
|
@@ -3123,11 +2707,15 @@ function newArtifactCollector() {
|
|
|
3123
2707
|
* test-run sessions don't pass it, which is exactly what makes
|
|
3124
2708
|
* `screenshot()` throw outside eval.
|
|
3125
2709
|
*/
|
|
3126
|
-
function newBrowserSession(testStart, idScope, frame = "browser", artifacts) {
|
|
2710
|
+
function newBrowserSession(testStart, idScope, frame = "browser", artifacts, name = "") {
|
|
3127
2711
|
const record = {
|
|
3128
2712
|
sessionId: newSessionId(idScope),
|
|
3129
2713
|
openedAtMs: Date.now() - testStart,
|
|
3130
2714
|
frame,
|
|
2715
|
+
// Omit the key entirely for the default session rather than storing
|
|
2716
|
+
// `""` — every consumer treats "no name" as "don't render a label",
|
|
2717
|
+
// and an empty string would have to be special-cased in each of them.
|
|
2718
|
+
...(name ? { name } : {}),
|
|
3131
2719
|
steps: [],
|
|
3132
2720
|
};
|
|
3133
2721
|
let closed = false;
|
|
@@ -3135,6 +2723,7 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts) {
|
|
|
3135
2723
|
record,
|
|
3136
2724
|
recorder: {
|
|
3137
2725
|
sessionId: record.sessionId,
|
|
2726
|
+
...(name ? { sessionName: name } : {}),
|
|
3138
2727
|
recordStep(step) {
|
|
3139
2728
|
if (closed)
|
|
3140
2729
|
return;
|
|
@@ -3966,31 +3555,37 @@ async function runOne(testCase) {
|
|
|
3966
3555
|
// an ancestor fork; its TEST_DATA entry travels with the snapshot.
|
|
3967
3556
|
const parentId = testCase.dependsOn?.id;
|
|
3968
3557
|
const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
|
|
3969
|
-
// Browser/mobile sessions are PERSISTENT
|
|
3970
|
-
//
|
|
3971
|
-
// app
|
|
3972
|
-
// snapshot like fake state).
|
|
3973
|
-
//
|
|
3558
|
+
// Browser/mobile sessions are PERSISTENT and keyed by NAME:
|
|
3559
|
+
// `ctx.browser(name?)` acquires the desktop browser called `name` and
|
|
3560
|
+
// `ctx.mobile(app, name?)` that name's session for that app (browser.ts's
|
|
3561
|
+
// module-scoped registry, which forks with the snapshot like fake state).
|
|
3562
|
+
// The default name is `""`, so an unnamed project has exactly the one
|
|
3563
|
+
// session per device it always had. At test end we DETACH — final rrweb
|
|
3564
|
+
// drain, stop writing to this test's recorder — but deliberately keep the
|
|
3974
3565
|
// Chromium alive so the post-test snapshot captures it and dependsOn
|
|
3975
|
-
// children resume the live
|
|
3976
|
-
// state) instead of re-navigating
|
|
3977
|
-
//
|
|
3978
|
-
//
|
|
3979
|
-
//
|
|
3980
|
-
//
|
|
3981
|
-
//
|
|
3982
|
-
//
|
|
3983
|
-
//
|
|
3566
|
+
// children resume the live pages (cookies, localStorage, signed-in SPA
|
|
3567
|
+
// state) instead of re-navigating; that inheritance is per name, so a
|
|
3568
|
+
// parent that signed "alice" and "bob" in hands both down. Each test
|
|
3569
|
+
// still gets its own session record per name (attach re-arms rrweb with a
|
|
3570
|
+
// fresh full snapshot, so replays stay per-case self-contained); records
|
|
3571
|
+
// flow back to the control plane on RunResult.browserSessions and are
|
|
3572
|
+
// archived to S3 as the case's replay bundle. Within one test repeated
|
|
3573
|
+
// calls for the SAME name return the same handle (memoized below) so one
|
|
3574
|
+
// test = one session per (device, name). An explicit `.close()` destroys
|
|
3575
|
+
// that name's instance — its memo entry is cleared so a later call for
|
|
3576
|
+
// the same name starts fresh, and other names are untouched.
|
|
3984
3577
|
const browserDetaches = [];
|
|
3985
3578
|
const sessions = [];
|
|
3986
|
-
|
|
3579
|
+
const sharedBrowsers = new Map();
|
|
3987
3580
|
const sharedMobiles = new Map();
|
|
3988
|
-
const trackedOpenBrowser = async (
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3581
|
+
const trackedOpenBrowser = async (nameOrOpts, maybeOpts) => {
|
|
3582
|
+
const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
|
|
3583
|
+
const memo = sharedBrowsers.get(name);
|
|
3584
|
+
if (memo)
|
|
3585
|
+
return memo;
|
|
3586
|
+
const session = newBrowserSession(start, testCase.id, "browser", undefined, name);
|
|
3992
3587
|
sessions.push(session);
|
|
3993
|
-
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
3588
|
+
const { browser, attached, detach } = await acquirePersistentBrowser(name, {
|
|
3994
3589
|
...(opts ?? {}),
|
|
3995
3590
|
recorder: session.recorder,
|
|
3996
3591
|
});
|
|
@@ -4009,23 +3604,25 @@ async function runOne(testCase) {
|
|
|
4009
3604
|
const innerClose = browser.close.bind(browser);
|
|
4010
3605
|
browser.close = async () => {
|
|
4011
3606
|
await innerClose();
|
|
4012
|
-
if (
|
|
4013
|
-
|
|
3607
|
+
if (sharedBrowsers.get(name) === browser)
|
|
3608
|
+
sharedBrowsers.delete(name);
|
|
4014
3609
|
};
|
|
4015
|
-
|
|
3610
|
+
sharedBrowsers.set(name, browser);
|
|
4016
3611
|
return browser;
|
|
4017
3612
|
};
|
|
4018
|
-
const trackedOpenMobile = async (app) => {
|
|
3613
|
+
const trackedOpenMobile = async (app, name = "") => {
|
|
4019
3614
|
if (!isMobileApp(app)) {
|
|
4020
3615
|
throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
|
|
4021
3616
|
}
|
|
4022
|
-
const
|
|
3617
|
+
const key = mobileKey(name, app.url);
|
|
3618
|
+
const existing = sharedMobiles.get(key);
|
|
4023
3619
|
if (existing)
|
|
4024
3620
|
return existing;
|
|
4025
|
-
const session = newBrowserSession(start, testCase.id, "mobile");
|
|
3621
|
+
const session = newBrowserSession(start, testCase.id, "mobile", undefined, name);
|
|
4026
3622
|
sessions.push(session);
|
|
4027
3623
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4028
3624
|
url: app.url,
|
|
3625
|
+
name,
|
|
4029
3626
|
recorder: session.recorder,
|
|
4030
3627
|
initScript: app.initScript,
|
|
4031
3628
|
});
|
|
@@ -4041,10 +3638,10 @@ async function runOne(testCase) {
|
|
|
4041
3638
|
const innerClose = mobile.close.bind(mobile);
|
|
4042
3639
|
mobile.close = async () => {
|
|
4043
3640
|
await innerClose();
|
|
4044
|
-
if (sharedMobiles.get(
|
|
4045
|
-
sharedMobiles.delete(
|
|
3641
|
+
if (sharedMobiles.get(key) === mobile)
|
|
3642
|
+
sharedMobiles.delete(key);
|
|
4046
3643
|
};
|
|
4047
|
-
sharedMobiles.set(
|
|
3644
|
+
sharedMobiles.set(key, mobile);
|
|
4048
3645
|
return mobile;
|
|
4049
3646
|
};
|
|
4050
3647
|
// The shared context (svc/fakes handles, poll, project files, dnsName,
|
|
@@ -4404,14 +4001,16 @@ async function evalCode(code, secrets) {
|
|
|
4404
4001
|
// Eval-only artifact sink — wiring it here (and nowhere in runOne) is
|
|
4405
4002
|
// what gates screenshot() to eval context.
|
|
4406
4003
|
const artifactCollector = newArtifactCollector();
|
|
4407
|
-
|
|
4004
|
+
const sharedBrowsers = new Map();
|
|
4408
4005
|
const sharedMobiles = new Map();
|
|
4409
|
-
const trackedOpenBrowser = async (
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4006
|
+
const trackedOpenBrowser = async (nameOrOpts, maybeOpts) => {
|
|
4007
|
+
const { name, opts } = browserArgs(nameOrOpts, maybeOpts);
|
|
4008
|
+
const memo = sharedBrowsers.get(name);
|
|
4009
|
+
if (memo)
|
|
4010
|
+
return memo;
|
|
4011
|
+
const session = newBrowserSession(start, "eval", "browser", artifactCollector, name);
|
|
4413
4012
|
sessions.push(session);
|
|
4414
|
-
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4013
|
+
const { browser, attached, detach } = await acquirePersistentBrowser(name, {
|
|
4415
4014
|
...(opts ?? {}),
|
|
4416
4015
|
recorder: session.recorder,
|
|
4417
4016
|
});
|
|
@@ -4428,23 +4027,25 @@ async function evalCode(code, secrets) {
|
|
|
4428
4027
|
const innerClose = browser.close.bind(browser);
|
|
4429
4028
|
browser.close = async () => {
|
|
4430
4029
|
await innerClose();
|
|
4431
|
-
if (
|
|
4432
|
-
|
|
4030
|
+
if (sharedBrowsers.get(name) === browser)
|
|
4031
|
+
sharedBrowsers.delete(name);
|
|
4433
4032
|
};
|
|
4434
|
-
|
|
4033
|
+
sharedBrowsers.set(name, browser);
|
|
4435
4034
|
return browser;
|
|
4436
4035
|
};
|
|
4437
|
-
const trackedOpenMobile = async (app) => {
|
|
4036
|
+
const trackedOpenMobile = async (app, name = "") => {
|
|
4438
4037
|
if (!isMobileApp(app)) {
|
|
4439
4038
|
throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
|
|
4440
4039
|
}
|
|
4441
|
-
const
|
|
4040
|
+
const key = mobileKey(name, app.url);
|
|
4041
|
+
const existing = sharedMobiles.get(key);
|
|
4442
4042
|
if (existing)
|
|
4443
4043
|
return existing;
|
|
4444
|
-
const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
|
|
4044
|
+
const session = newBrowserSession(start, "eval", "mobile", artifactCollector, name);
|
|
4445
4045
|
sessions.push(session);
|
|
4446
4046
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4447
4047
|
url: app.url,
|
|
4048
|
+
name,
|
|
4448
4049
|
recorder: session.recorder,
|
|
4449
4050
|
initScript: app.initScript,
|
|
4450
4051
|
});
|
|
@@ -4460,10 +4061,10 @@ async function evalCode(code, secrets) {
|
|
|
4460
4061
|
const innerClose = mobile.close.bind(mobile);
|
|
4461
4062
|
mobile.close = async () => {
|
|
4462
4063
|
await innerClose();
|
|
4463
|
-
if (sharedMobiles.get(
|
|
4464
|
-
sharedMobiles.delete(
|
|
4064
|
+
if (sharedMobiles.get(key) === mobile)
|
|
4065
|
+
sharedMobiles.delete(key);
|
|
4465
4066
|
};
|
|
4466
|
-
sharedMobiles.set(
|
|
4067
|
+
sharedMobiles.set(key, mobile);
|
|
4467
4068
|
return mobile;
|
|
4468
4069
|
};
|
|
4469
4070
|
// Terminal sessions — same shape as runOne, but eval has no active
|
|
@@ -4734,344 +4335,220 @@ async function runTypecheck() {
|
|
|
4734
4335
|
}
|
|
4735
4336
|
return { status: "errors", errors, totalErrors: total, durationMs };
|
|
4736
4337
|
}
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4338
|
+
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
4339
|
+
async function exclusive(state, slot, busy, job) {
|
|
4340
|
+
if (state[slot])
|
|
4341
|
+
throw conflict(busy);
|
|
4342
|
+
const p = job();
|
|
4343
|
+
state[slot] = p;
|
|
4344
|
+
try {
|
|
4345
|
+
return await p;
|
|
4346
|
+
}
|
|
4347
|
+
finally {
|
|
4348
|
+
state[slot] = null;
|
|
4349
|
+
}
|
|
4747
4350
|
}
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4351
|
+
/** The environment's current catalogue, the shape three methods return. */
|
|
4352
|
+
function loadedSummary(l) {
|
|
4353
|
+
return {
|
|
4354
|
+
environment: l.project.environment,
|
|
4355
|
+
cases: casesMetadata(l.project.tests),
|
|
4356
|
+
fakes: fakesSummary(l.project),
|
|
4357
|
+
};
|
|
4755
4358
|
}
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4359
|
+
/**
|
|
4360
|
+
* Everything the harness can be asked to do.
|
|
4361
|
+
*
|
|
4362
|
+
* Transport-neutral by construction: params in, plain result out, failures
|
|
4363
|
+
* as {@link HarnessError}. The HTTP router below and the supervisor's frame
|
|
4364
|
+
* dispatcher both drive this same table, so a change reaches both — which
|
|
4365
|
+
* is the property that makes it safe to run the two side by side during the
|
|
4366
|
+
* cutover.
|
|
4367
|
+
*/
|
|
4368
|
+
export function harnessMethods(state) {
|
|
4369
|
+
return {
|
|
4370
|
+
health: async () => ({ ok: true }),
|
|
4765
4371
|
// Live bootstrap progress, polled by the control plane during
|
|
4766
|
-
//
|
|
4767
|
-
// first bootstrap
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
}
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
};
|
|
4804
|
-
}
|
|
4805
|
-
jsonResponse(res, 200, { bootLog: bootLogText(), services });
|
|
4806
|
-
return;
|
|
4807
|
-
}
|
|
4808
|
-
if (method === "POST" && url === "/load") {
|
|
4372
|
+
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
4373
|
+
// first bootstrap of this harness process.
|
|
4374
|
+
progress: async () => BOOTSTRAP_PROGRESS ?? {},
|
|
4375
|
+
/**
|
|
4376
|
+
* Live logs for a running environment: the harness's own boot log
|
|
4377
|
+
* (bootstrap plus every `setup` hook's console output) and each
|
|
4378
|
+
* service's container logs.
|
|
4379
|
+
*
|
|
4380
|
+
* Reads without touching LOG_MARKERS, unlike the per-case deltas, so
|
|
4381
|
+
* watching an environment can never perturb a run in progress.
|
|
4382
|
+
*/
|
|
4383
|
+
envLogs: async (params) => {
|
|
4384
|
+
const only = typeof params.service === "string" ? params.service : undefined;
|
|
4385
|
+
const tail = Math.max(1, Math.min(10_000, Number(params.tail ?? 200) || 200));
|
|
4386
|
+
const l = loaded;
|
|
4387
|
+
const byName = new Map();
|
|
4388
|
+
if (l)
|
|
4389
|
+
for (const s of namedServices(l.project.environment))
|
|
4390
|
+
byName.set(s.name, s);
|
|
4391
|
+
for (const [name, s] of RUNTIME_SERVICES)
|
|
4392
|
+
byName.set(name, s);
|
|
4393
|
+
const names = [...byName.keys()].filter((n) => !only || n === only);
|
|
4394
|
+
if (only && names.length === 0) {
|
|
4395
|
+
throw notFound(`unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`);
|
|
4396
|
+
}
|
|
4397
|
+
const services = await Promise.all(names.map(async (name) => {
|
|
4398
|
+
const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
|
|
4399
|
+
const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4400
|
+
const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4401
|
+
return {
|
|
4402
|
+
service: name,
|
|
4403
|
+
stdout: out.value,
|
|
4404
|
+
stdoutTruncated: out.truncated,
|
|
4405
|
+
stderr: err.value,
|
|
4406
|
+
stderrTruncated: err.truncated,
|
|
4407
|
+
};
|
|
4408
|
+
}));
|
|
4409
|
+
return { bootLog: bootLogText(), services };
|
|
4410
|
+
},
|
|
4809
4411
|
// Env only — services/fakes/setup. For the legacy single-file layout the
|
|
4810
4412
|
// entry also defines the tests, so `cases` is populated here; for the
|
|
4811
|
-
// split layout
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
if (method === "POST" && url === "/load-tests") {
|
|
4413
|
+
// split layout it stays empty until `loadTests`.
|
|
4414
|
+
load: async () => {
|
|
4415
|
+
const proj = await loadEnv();
|
|
4416
|
+
return {
|
|
4417
|
+
environment: proj.environment,
|
|
4418
|
+
cases: casesMetadata(proj.tests),
|
|
4419
|
+
fakes: fakesSummary(proj),
|
|
4420
|
+
};
|
|
4421
|
+
},
|
|
4821
4422
|
// Import spectest/tests/** into the already-loaded env and return the
|
|
4822
4423
|
// resulting catalogue. Called after the warm snapshot (cold path) or
|
|
4823
4424
|
// against a freshly restored VM (warm path).
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
const l = requireLoaded();
|
|
4831
|
-
jsonResponse(res, 200, {
|
|
4832
|
-
environment: l.project.environment,
|
|
4833
|
-
cases: casesMetadata(l.project.tests),
|
|
4834
|
-
fakes: fakesSummary(l.project),
|
|
4835
|
-
});
|
|
4836
|
-
return;
|
|
4837
|
-
}
|
|
4838
|
-
if (method === "POST" && url === "/typecheck") {
|
|
4839
|
-
// Await the run kicked off at /load-tests (or start one on demand —
|
|
4840
|
-
// e.g. a legacy single-file project loaded before this daemon shipped
|
|
4841
|
-
// the kickoff, or a direct debug call).
|
|
4842
|
-
if (!TYPECHECK)
|
|
4425
|
+
loadTests: async () => {
|
|
4426
|
+
await loadTests();
|
|
4427
|
+
// Background typecheck of the freshly-uploaded app tree — both paths
|
|
4428
|
+
// funnel through here after the upload, so the check always sees the
|
|
4429
|
+
// current code. Never on the critical path: this reply doesn't wait,
|
|
4430
|
+
// `typecheck` collects.
|
|
4843
4431
|
startTypecheck();
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4432
|
+
return loadedSummary(requireLoaded());
|
|
4433
|
+
},
|
|
4434
|
+
// Await the run kicked off at loadTests, or start one on demand — a
|
|
4435
|
+
// legacy single-file project loaded before the kickoff shipped, or a
|
|
4436
|
+
// direct debug call.
|
|
4437
|
+
typecheck: async () => {
|
|
4438
|
+
if (!TYPECHECK)
|
|
4439
|
+
startTypecheck();
|
|
4440
|
+
return await TYPECHECK;
|
|
4441
|
+
},
|
|
4442
|
+
unload: async () => {
|
|
4443
|
+
loaded = null;
|
|
4444
|
+
return { unloaded: true };
|
|
4445
|
+
},
|
|
4853
4446
|
// Full reload (debug aid): re-import the env, then the tests.
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
});
|
|
4862
|
-
return;
|
|
4863
|
-
}
|
|
4864
|
-
if (method === "GET" && url === "/env-config") {
|
|
4865
|
-
const l = requireLoaded();
|
|
4866
|
-
jsonResponse(res, 200, l.project.environment);
|
|
4867
|
-
return;
|
|
4868
|
-
}
|
|
4869
|
-
if (method === "GET" && url === "/cases") {
|
|
4870
|
-
const l = requireLoaded();
|
|
4871
|
-
jsonResponse(res, 200, { cases: casesMetadata(l.project.tests) });
|
|
4872
|
-
return;
|
|
4873
|
-
}
|
|
4874
|
-
if (method === "GET" && url === "/record-secret-refs") {
|
|
4447
|
+
reload: async () => {
|
|
4448
|
+
await loadEnv();
|
|
4449
|
+
await loadTests();
|
|
4450
|
+
return loadedSummary(requireLoaded());
|
|
4451
|
+
},
|
|
4452
|
+
envConfig: async () => requireLoaded().project.environment,
|
|
4453
|
+
cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
|
|
4875
4454
|
// Union of platform secret refs the loaded fakes declare (replayFake's
|
|
4876
|
-
// `secretRefs`). The control plane resolves these server-side and
|
|
4877
|
-
//
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
for (const
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
jsonResponse(res, 200, { refs: [...refs] });
|
|
4884
|
-
return;
|
|
4885
|
-
}
|
|
4886
|
-
if (method === "POST" && url === "/bootstrap") {
|
|
4887
|
-
if (state.inFlightBootstrap) {
|
|
4888
|
-
jsonResponse(res, 409, { error: "bootstrap already in progress" });
|
|
4889
|
-
return;
|
|
4890
|
-
}
|
|
4891
|
-
const job = bootstrap();
|
|
4892
|
-
state.inFlightBootstrap = job;
|
|
4893
|
-
try {
|
|
4894
|
-
const timings = await job;
|
|
4895
|
-
jsonResponse(res, 200, { ok: true, timings });
|
|
4896
|
-
}
|
|
4897
|
-
finally {
|
|
4898
|
-
state.inFlightBootstrap = null;
|
|
4899
|
-
}
|
|
4900
|
-
return;
|
|
4901
|
-
}
|
|
4902
|
-
if (method === "POST" && url === "/project-setup") {
|
|
4903
|
-
if (state.inFlightProjectSetup) {
|
|
4904
|
-
jsonResponse(res, 409, { error: "project-setup already in progress" });
|
|
4905
|
-
return;
|
|
4906
|
-
}
|
|
4907
|
-
const job = runProjectSetup();
|
|
4908
|
-
state.inFlightProjectSetup = job;
|
|
4909
|
-
try {
|
|
4910
|
-
const result = await job;
|
|
4911
|
-
jsonResponse(res, 200, result);
|
|
4912
|
-
}
|
|
4913
|
-
finally {
|
|
4914
|
-
state.inFlightProjectSetup = null;
|
|
4915
|
-
}
|
|
4916
|
-
return;
|
|
4917
|
-
}
|
|
4918
|
-
if (method === "POST" && url === "/eval") {
|
|
4919
|
-
if (state.inFlightTest) {
|
|
4920
|
-
jsonResponse(res, 409, { error: "a test or eval is already running" });
|
|
4921
|
-
return;
|
|
4922
|
-
}
|
|
4923
|
-
const body = await readBody(req);
|
|
4924
|
-
let parsed;
|
|
4925
|
-
try {
|
|
4926
|
-
parsed = JSON.parse(body || "{}");
|
|
4927
|
-
}
|
|
4928
|
-
catch {
|
|
4929
|
-
jsonResponse(res, 400, { error: "invalid JSON body" });
|
|
4930
|
-
return;
|
|
4931
|
-
}
|
|
4932
|
-
const code = parsed.code;
|
|
4933
|
-
if (typeof code !== "string" || code.length === 0) {
|
|
4934
|
-
jsonResponse(res, 400, { error: "code (string) is required" });
|
|
4935
|
-
return;
|
|
4936
|
-
}
|
|
4937
|
-
// `secrets` are eval-scoped: the control plane resolves the loaded
|
|
4938
|
-
// project's declared `replayFake` refs and pushes the values here on
|
|
4939
|
-
// the eval path only. Never present on the /run (test) path.
|
|
4940
|
-
const exec = evalCode(code, parsed.secrets);
|
|
4941
|
-
state.inFlightTest = exec;
|
|
4942
|
-
try {
|
|
4943
|
-
const result = await exec;
|
|
4944
|
-
jsonResponse(res, 200, result);
|
|
4945
|
-
}
|
|
4946
|
-
finally {
|
|
4947
|
-
state.inFlightTest = null;
|
|
4948
|
-
}
|
|
4949
|
-
return;
|
|
4950
|
-
}
|
|
4951
|
-
if (method === "POST" && url === "/capture-log-baseline") {
|
|
4952
|
-
// Snapshot each service's log output produced during env bring-up
|
|
4953
|
-
// (container startup + project setup), advancing the log markers so the
|
|
4954
|
-
// subsequent per-test deltas start AFTER setup. The control plane calls
|
|
4955
|
-
// this once, on the main env, before the first test runs — see
|
|
4956
|
-
// tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
|
|
4957
|
-
// simply fold into the first test's delta as before.
|
|
4958
|
-
let serviceLogDeltas = [];
|
|
4959
|
-
try {
|
|
4960
|
-
serviceLogDeltas = await captureServiceLogDeltas();
|
|
4961
|
-
}
|
|
4962
|
-
catch (err) {
|
|
4963
|
-
// eslint-disable-next-line no-console
|
|
4964
|
-
console.warn("[service-logs] baseline capture failed:", err);
|
|
4965
|
-
}
|
|
4966
|
-
jsonResponse(res, 200, { serviceLogDeltas });
|
|
4967
|
-
return;
|
|
4968
|
-
}
|
|
4969
|
-
if (method === "POST" && url === "/run") {
|
|
4970
|
-
if (state.inFlightTest) {
|
|
4971
|
-
jsonResponse(res, 409, { error: "another test is already running" });
|
|
4972
|
-
return;
|
|
4973
|
-
}
|
|
4974
|
-
const body = await readBody(req);
|
|
4975
|
-
let parsed;
|
|
4976
|
-
try {
|
|
4977
|
-
parsed = JSON.parse(body || "{}");
|
|
4978
|
-
}
|
|
4979
|
-
catch {
|
|
4980
|
-
jsonResponse(res, 400, { error: "invalid JSON body" });
|
|
4981
|
-
return;
|
|
4982
|
-
}
|
|
4983
|
-
const caseId = parsed.caseId;
|
|
4984
|
-
if (!caseId) {
|
|
4985
|
-
jsonResponse(res, 400, { error: "caseId is required" });
|
|
4986
|
-
return;
|
|
4987
|
-
}
|
|
4988
|
-
const l = requireLoaded();
|
|
4989
|
-
const tc = l.byId.get(caseId);
|
|
4990
|
-
if (!tc) {
|
|
4991
|
-
jsonResponse(res, 404, { error: `unknown caseId: ${caseId}` });
|
|
4992
|
-
return;
|
|
4993
|
-
}
|
|
4994
|
-
const exec = runOne(tc);
|
|
4995
|
-
state.inFlightTest = exec;
|
|
4996
|
-
try {
|
|
4997
|
-
const result = await exec;
|
|
4998
|
-
// Recordings go out-of-band: encode + park the bundle, reply with a
|
|
4999
|
-
// size ref only (see the REPLAY_BUNDLES comment). A bundle-encoding
|
|
5000
|
-
// failure drops the recordings (warn) rather than failing the case —
|
|
5001
|
-
// and never falls back to inlining, which is exactly the >16 MB
|
|
5002
|
-
// response this path exists to avoid.
|
|
5003
|
-
const { browserSessions, ...wire } = result;
|
|
5004
|
-
let replay;
|
|
5005
|
-
if (browserSessions.length > 0) {
|
|
5006
|
-
try {
|
|
5007
|
-
const gz = encodeReplayBundle(caseId, browserSessions);
|
|
5008
|
-
stashReplayBundle(caseId, gz);
|
|
5009
|
-
replay = { bytes: gz.length };
|
|
5010
|
-
}
|
|
5011
|
-
catch (err) {
|
|
5012
|
-
// eslint-disable-next-line no-console
|
|
5013
|
-
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
5014
|
-
}
|
|
4455
|
+
// `secretRefs`). The control plane resolves these server-side and pushes
|
|
4456
|
+
// the values on the eval path only. Empty if nothing's loaded.
|
|
4457
|
+
recordSecretRefs: async () => {
|
|
4458
|
+
const refs = new Set();
|
|
4459
|
+
for (const fake of FAKES.values()) {
|
|
4460
|
+
for (const ref of fake.def.secretRefs ?? [])
|
|
4461
|
+
refs.add(ref);
|
|
5015
4462
|
}
|
|
5016
|
-
|
|
5017
|
-
}
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
jsonResponse(res, 404, { error: `no replay bundle for case: ${parsed.caseId}` });
|
|
5043
|
-
return;
|
|
5044
|
-
}
|
|
5045
|
-
jsonResponse(res, 200, replayChunk(gz, parsed.offset, parsed.limit));
|
|
5046
|
-
return;
|
|
5047
|
-
}
|
|
5048
|
-
jsonResponse(res, 404, { error: "not found" });
|
|
5049
|
-
}
|
|
5050
|
-
async function main() {
|
|
5051
|
-
const state = {
|
|
5052
|
-
inFlightTest: null,
|
|
5053
|
-
inFlightBootstrap: null,
|
|
5054
|
-
inFlightProjectSetup: null,
|
|
5055
|
-
};
|
|
5056
|
-
const server = http.createServer((req, res) => {
|
|
5057
|
-
handle(req, res, state).catch((err) => {
|
|
5058
|
-
const e = err;
|
|
4463
|
+
return { refs: [...refs] };
|
|
4464
|
+
},
|
|
4465
|
+
bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
|
|
4466
|
+
ok: true,
|
|
4467
|
+
timings: await bootstrap(),
|
|
4468
|
+
})),
|
|
4469
|
+
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
4470
|
+
eval: async (params) => {
|
|
4471
|
+
const code = requireString(params, "code");
|
|
4472
|
+
// `secrets` are eval-scoped: the control plane resolves the loaded
|
|
4473
|
+
// project's declared `replayFake` refs and pushes the values here on
|
|
4474
|
+
// the eval path only. Never present on the test path.
|
|
4475
|
+
const secrets = params.secrets;
|
|
4476
|
+
return exclusive(state, "inFlightTest", "a test or eval is already running", () => evalCode(code, secrets));
|
|
4477
|
+
},
|
|
4478
|
+
/**
|
|
4479
|
+
* Snapshot each service's log output produced during env bring-up
|
|
4480
|
+
* (container startup plus project setup), advancing the log markers so
|
|
4481
|
+
* the per-test deltas start *after* setup. The control plane calls this
|
|
4482
|
+
* once, on the main env, before the first test runs.
|
|
4483
|
+
*
|
|
4484
|
+
* Best-effort: on failure the setup lines simply fold into the first
|
|
4485
|
+
* test's delta, as they did before this existed.
|
|
4486
|
+
*/
|
|
4487
|
+
captureLogBaseline: async () => {
|
|
4488
|
+
let serviceLogDeltas = [];
|
|
5059
4489
|
try {
|
|
5060
|
-
|
|
4490
|
+
serviceLogDeltas = await captureServiceLogDeltas();
|
|
5061
4491
|
}
|
|
5062
|
-
catch {
|
|
5063
|
-
//
|
|
4492
|
+
catch (err) {
|
|
4493
|
+
// eslint-disable-next-line no-console
|
|
4494
|
+
console.warn("[service-logs] baseline capture failed:", err);
|
|
5064
4495
|
}
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
4496
|
+
return { serviceLogDeltas };
|
|
4497
|
+
},
|
|
4498
|
+
run: async (params) => {
|
|
4499
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
4500
|
+
const tc = requireLoaded().byId.get(caseId);
|
|
4501
|
+
if (!tc)
|
|
4502
|
+
throw notFound(`unknown caseId: ${caseId}`);
|
|
4503
|
+
return exclusive(state, "inFlightTest", "another test is already running", async () => {
|
|
4504
|
+
const result = await runOne(tc);
|
|
4505
|
+
// Recordings go out-of-band: encode and park the bundle, reply with
|
|
4506
|
+
// a size ref only. A bundle-encoding failure drops the recordings
|
|
4507
|
+
// (with a warning) rather than failing the case — and never falls
|
|
4508
|
+
// back to inlining, which is exactly the oversized reply this path
|
|
4509
|
+
// exists to avoid.
|
|
4510
|
+
const { browserSessions, ...wire } = result;
|
|
4511
|
+
let replay;
|
|
4512
|
+
if (browserSessions.length > 0) {
|
|
4513
|
+
try {
|
|
4514
|
+
const gz = encodeReplayBundle(caseId, browserSessions);
|
|
4515
|
+
stashReplayBundle(caseId, gz);
|
|
4516
|
+
replay = { bytes: gz.length };
|
|
4517
|
+
}
|
|
4518
|
+
catch (err) {
|
|
4519
|
+
// eslint-disable-next-line no-console
|
|
4520
|
+
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
return { ...wire, replay };
|
|
4524
|
+
});
|
|
4525
|
+
},
|
|
4526
|
+
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
4527
|
+
// strings, so they travel in params rather than a URL path.
|
|
4528
|
+
replayChunk: async (params) => {
|
|
4529
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
4530
|
+
const gz = REPLAY_BUNDLES.get(caseId);
|
|
4531
|
+
if (!gz)
|
|
4532
|
+
throw notFound(`no replay bundle for case: ${caseId}`);
|
|
4533
|
+
return replayChunk(gz, params.offset, params.limit);
|
|
4534
|
+
},
|
|
4535
|
+
};
|
|
5072
4536
|
}
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
4537
|
+
/**
|
|
4538
|
+
* The method table for this process.
|
|
4539
|
+
*
|
|
4540
|
+
* Built once, so the mutual-exclusion slots are process-wide: `run` refuses
|
|
4541
|
+
* a second case while one is in flight, and that guarantee has to hold for
|
|
4542
|
+
* the process, not per caller. Two tables would each enforce the rule and
|
|
4543
|
+
* together break it — which is why this is a value and not a factory the
|
|
4544
|
+
* caller invokes.
|
|
4545
|
+
*
|
|
4546
|
+
* The harness (`sdk/src/harness/main.ts`) serves this over frames. It used
|
|
4547
|
+
* to be served over HTTP as well, by a listener in this file; that was the
|
|
4548
|
+
* in-VM daemon, and it is gone.
|
|
4549
|
+
*/
|
|
4550
|
+
export const METHODS = harnessMethods({
|
|
4551
|
+
inFlightTest: null,
|
|
4552
|
+
inFlightBootstrap: null,
|
|
4553
|
+
inFlightProjectSetup: null,
|
|
5077
4554
|
});
|