@specific.dev/spectest 0.38.0 → 0.41.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/components/k3s.js +1 -24
- 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 +405 -946
- 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 +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -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 +2040 -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 +5489 -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 +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -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";
|
|
@@ -25,15 +24,31 @@ import { pathToFileURL } from "node:url";
|
|
|
25
24
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
26
25
|
import { acquirePersistentBrowser } 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
|
-
import { wrap, wrapResponse } from "./inspect.js";
|
|
46
|
+
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
31
47
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
32
48
|
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);
|
|
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);
|
|
1288
1072
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
return;
|
|
1297
|
-
const delay = ramp[Math.min(attempt, ramp.length - 1)];
|
|
1298
|
-
attempt++;
|
|
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) {
|
|
@@ -2528,8 +2166,9 @@ async function ensureFakeHelpers(name) {
|
|
|
2528
2166
|
fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
|
|
2529
2167
|
return fake.trackedHelpers;
|
|
2530
2168
|
}
|
|
2531
|
-
/** Wrap a fake's helpers so each call becomes a recorded `fake` event
|
|
2532
|
-
* and its return value is `wrap()`ped
|
|
2169
|
+
/** Wrap a fake's helpers so each call becomes a recorded `fake` event,
|
|
2170
|
+
* its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
|
|
2171
|
+
* for assertion provenance. Helpers
|
|
2533
2172
|
* are functions that read/mutate the fake's private state via closure;
|
|
2534
2173
|
* tests only ever see what those functions return. The proxy is built
|
|
2535
2174
|
* once and shared across tests; it consults the recorder at call time,
|
|
@@ -2556,10 +2195,21 @@ function trackFakeHelpers(fakeName, helpers) {
|
|
|
2556
2195
|
}
|
|
2557
2196
|
/** Invoke a fake helper function, recording a `fake` event and wrapping
|
|
2558
2197
|
* the return value. Handles both sync and async helpers, and records an
|
|
2559
|
-
* error event (then rethrows) if the helper throws.
|
|
2560
|
-
|
|
2198
|
+
* error event (then rethrows) if the helper throws.
|
|
2199
|
+
*
|
|
2200
|
+
* Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
|
|
2201
|
+
* user code operating on plain values: a test that feeds one helper's
|
|
2202
|
+
* return (or a `ctx.fetch` field) to another passes a provenance wrapper,
|
|
2203
|
+
* and `msg.html.matchAll(...)` inside the fake would otherwise throw a
|
|
2204
|
+
* `TypeError` from a stack the author can't see. Same treatment the k8s
|
|
2205
|
+
* client's arguments get (`components/k3s.ts::withTagging`). The types
|
|
2206
|
+
* still say the value is a handle — passing a wrapped value where the
|
|
2207
|
+
* parameter type is known is a compile error asking for `.unwrap()`; this
|
|
2208
|
+
* catches the case where it isn't known (an `any` off `req.json()`). */
|
|
2209
|
+
function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
2561
2210
|
const t = Date.now();
|
|
2562
2211
|
const resv = reserveEvent();
|
|
2212
|
+
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2563
2213
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2564
2214
|
const recordResult = (value) => {
|
|
2565
2215
|
const seq = recordFake({
|
|
@@ -2925,11 +2575,6 @@ function stashReplayBundle(caseId, gz) {
|
|
|
2925
2575
|
* so it travels with the snapshot into every fork.
|
|
2926
2576
|
*/
|
|
2927
2577
|
const LOG_MARKERS = new Map();
|
|
2928
|
-
/** Per-(service, stream) delta byte cap. Over this we keep head+tail and
|
|
2929
|
-
* elide the middle — the head preserves the continuation from the parent,
|
|
2930
|
-
* the tail preserves the newest output — while still advancing the marker
|
|
2931
|
-
* to the true line count so the chain stays aligned. */
|
|
2932
|
-
const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
|
|
2933
2578
|
/**
|
|
2934
2579
|
* Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
|
|
2935
2580
|
* running (or when inspect fails — a removed container has no state left to
|
|
@@ -2949,68 +2594,6 @@ async function containerStopState(name) {
|
|
|
2949
2594
|
* `max` (string length, a byte proxy as elsewhere here). Head+tail so an
|
|
2950
2595
|
* over-long delta keeps both the parent-continuation and the newest
|
|
2951
2596
|
* output. */
|
|
2952
|
-
function capMiddle(s, max) {
|
|
2953
|
-
if (s.length <= max)
|
|
2954
|
-
return { value: s, truncated: false };
|
|
2955
|
-
const half = Math.floor(max / 2);
|
|
2956
|
-
const elided = s.length - 2 * half;
|
|
2957
|
-
return {
|
|
2958
|
-
value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
|
|
2959
|
-
truncated: true,
|
|
2960
|
-
};
|
|
2961
|
-
}
|
|
2962
|
-
/**
|
|
2963
|
-
* Compute one stream's delta beyond `marker` complete lines.
|
|
2964
|
-
* - Counts only newline-terminated lines; a trailing partial line (no
|
|
2965
|
-
* `\n` yet) is held back from both the delta and the count, so a line
|
|
2966
|
-
* completed by a later capture isn't split across the fork boundary.
|
|
2967
|
-
* - Reset guard: if the stream shrank below `marker` (container recreated
|
|
2968
|
-
* or rotated) the whole current log is re-emitted and `reset` is set.
|
|
2969
|
-
*/
|
|
2970
|
-
function streamDelta(full, marker) {
|
|
2971
|
-
const lastNl = full.lastIndexOf("\n");
|
|
2972
|
-
const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
|
|
2973
|
-
let total = 0;
|
|
2974
|
-
for (let i = 0; i < complete.length; i++) {
|
|
2975
|
-
if (complete.charCodeAt(i) === 10)
|
|
2976
|
-
total++;
|
|
2977
|
-
}
|
|
2978
|
-
let reset = false;
|
|
2979
|
-
let startLine = marker;
|
|
2980
|
-
if (total < marker) {
|
|
2981
|
-
reset = true;
|
|
2982
|
-
startLine = 0;
|
|
2983
|
-
}
|
|
2984
|
-
let delta;
|
|
2985
|
-
if (startLine <= 0) {
|
|
2986
|
-
delta = complete;
|
|
2987
|
-
}
|
|
2988
|
-
else if (startLine >= total) {
|
|
2989
|
-
delta = "";
|
|
2990
|
-
}
|
|
2991
|
-
else {
|
|
2992
|
-
// Byte offset just past the `startLine`-th newline.
|
|
2993
|
-
let seen = 0;
|
|
2994
|
-
let off = 0;
|
|
2995
|
-
for (let i = 0; i < complete.length; i++) {
|
|
2996
|
-
if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
|
|
2997
|
-
off = i + 1;
|
|
2998
|
-
break;
|
|
2999
|
-
}
|
|
3000
|
-
}
|
|
3001
|
-
delta = complete.slice(off);
|
|
3002
|
-
}
|
|
3003
|
-
const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
|
|
3004
|
-
return { delta: capped.value, total, reset, truncated: capped.truncated };
|
|
3005
|
-
}
|
|
3006
|
-
/**
|
|
3007
|
-
* Capture the per-service log delta for the current case and advance the
|
|
3008
|
-
* markers. Runs on EVERY case (pass or fail). Enumerates boot services
|
|
3009
|
-
* (`namedServices`) plus any runtime services this fork started
|
|
3010
|
-
* (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
|
|
3011
|
-
* as the service's `stderr` WITHOUT advancing the markers — a transient
|
|
3012
|
-
* failure must never desync the chain.
|
|
3013
|
-
*/
|
|
3014
2597
|
async function captureServiceLogDeltas() {
|
|
3015
2598
|
const l = loaded;
|
|
3016
2599
|
if (!l)
|
|
@@ -4722,344 +4305,220 @@ async function runTypecheck() {
|
|
|
4722
4305
|
}
|
|
4723
4306
|
return { status: "errors", errors, totalErrors: total, durationMs };
|
|
4724
4307
|
}
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4308
|
+
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
4309
|
+
async function exclusive(state, slot, busy, job) {
|
|
4310
|
+
if (state[slot])
|
|
4311
|
+
throw conflict(busy);
|
|
4312
|
+
const p = job();
|
|
4313
|
+
state[slot] = p;
|
|
4314
|
+
try {
|
|
4315
|
+
return await p;
|
|
4316
|
+
}
|
|
4317
|
+
finally {
|
|
4318
|
+
state[slot] = null;
|
|
4319
|
+
}
|
|
4735
4320
|
}
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4321
|
+
/** The environment's current catalogue, the shape three methods return. */
|
|
4322
|
+
function loadedSummary(l) {
|
|
4323
|
+
return {
|
|
4324
|
+
environment: l.project.environment,
|
|
4325
|
+
cases: casesMetadata(l.project.tests),
|
|
4326
|
+
fakes: fakesSummary(l.project),
|
|
4327
|
+
};
|
|
4743
4328
|
}
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4329
|
+
/**
|
|
4330
|
+
* Everything the harness can be asked to do.
|
|
4331
|
+
*
|
|
4332
|
+
* Transport-neutral by construction: params in, plain result out, failures
|
|
4333
|
+
* as {@link HarnessError}. The HTTP router below and the supervisor's frame
|
|
4334
|
+
* dispatcher both drive this same table, so a change reaches both — which
|
|
4335
|
+
* is the property that makes it safe to run the two side by side during the
|
|
4336
|
+
* cutover.
|
|
4337
|
+
*/
|
|
4338
|
+
export function harnessMethods(state) {
|
|
4339
|
+
return {
|
|
4340
|
+
health: async () => ({ ok: true }),
|
|
4753
4341
|
// Live bootstrap progress, polled by the control plane during
|
|
4754
|
-
//
|
|
4755
|
-
// first bootstrap
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
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
|
-
jsonResponse(res, 200, { bootLog: bootLogText(), services });
|
|
4794
|
-
return;
|
|
4795
|
-
}
|
|
4796
|
-
if (method === "POST" && url === "/load") {
|
|
4342
|
+
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
4343
|
+
// first bootstrap of this harness process.
|
|
4344
|
+
progress: async () => BOOTSTRAP_PROGRESS ?? {},
|
|
4345
|
+
/**
|
|
4346
|
+
* Live logs for a running environment: the harness's own boot log
|
|
4347
|
+
* (bootstrap plus every `setup` hook's console output) and each
|
|
4348
|
+
* service's container logs.
|
|
4349
|
+
*
|
|
4350
|
+
* Reads without touching LOG_MARKERS, unlike the per-case deltas, so
|
|
4351
|
+
* watching an environment can never perturb a run in progress.
|
|
4352
|
+
*/
|
|
4353
|
+
envLogs: async (params) => {
|
|
4354
|
+
const only = typeof params.service === "string" ? params.service : undefined;
|
|
4355
|
+
const tail = Math.max(1, Math.min(10_000, Number(params.tail ?? 200) || 200));
|
|
4356
|
+
const l = loaded;
|
|
4357
|
+
const byName = new Map();
|
|
4358
|
+
if (l)
|
|
4359
|
+
for (const s of namedServices(l.project.environment))
|
|
4360
|
+
byName.set(s.name, s);
|
|
4361
|
+
for (const [name, s] of RUNTIME_SERVICES)
|
|
4362
|
+
byName.set(name, s);
|
|
4363
|
+
const names = [...byName.keys()].filter((n) => !only || n === only);
|
|
4364
|
+
if (only && names.length === 0) {
|
|
4365
|
+
throw notFound(`unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`);
|
|
4366
|
+
}
|
|
4367
|
+
const services = await Promise.all(names.map(async (name) => {
|
|
4368
|
+
const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
|
|
4369
|
+
const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4370
|
+
const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
|
|
4371
|
+
return {
|
|
4372
|
+
service: name,
|
|
4373
|
+
stdout: out.value,
|
|
4374
|
+
stdoutTruncated: out.truncated,
|
|
4375
|
+
stderr: err.value,
|
|
4376
|
+
stderrTruncated: err.truncated,
|
|
4377
|
+
};
|
|
4378
|
+
}));
|
|
4379
|
+
return { bootLog: bootLogText(), services };
|
|
4380
|
+
},
|
|
4797
4381
|
// Env only — services/fakes/setup. For the legacy single-file layout the
|
|
4798
4382
|
// entry also defines the tests, so `cases` is populated here; for the
|
|
4799
|
-
// split layout
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
if (method === "POST" && url === "/load-tests") {
|
|
4383
|
+
// split layout it stays empty until `loadTests`.
|
|
4384
|
+
load: async () => {
|
|
4385
|
+
const proj = await loadEnv();
|
|
4386
|
+
return {
|
|
4387
|
+
environment: proj.environment,
|
|
4388
|
+
cases: casesMetadata(proj.tests),
|
|
4389
|
+
fakes: fakesSummary(proj),
|
|
4390
|
+
};
|
|
4391
|
+
},
|
|
4809
4392
|
// Import spectest/tests/** into the already-loaded env and return the
|
|
4810
4393
|
// resulting catalogue. Called after the warm snapshot (cold path) or
|
|
4811
4394
|
// against a freshly restored VM (warm path).
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
const l = requireLoaded();
|
|
4819
|
-
jsonResponse(res, 200, {
|
|
4820
|
-
environment: l.project.environment,
|
|
4821
|
-
cases: casesMetadata(l.project.tests),
|
|
4822
|
-
fakes: fakesSummary(l.project),
|
|
4823
|
-
});
|
|
4824
|
-
return;
|
|
4825
|
-
}
|
|
4826
|
-
if (method === "POST" && url === "/typecheck") {
|
|
4827
|
-
// Await the run kicked off at /load-tests (or start one on demand —
|
|
4828
|
-
// e.g. a legacy single-file project loaded before this daemon shipped
|
|
4829
|
-
// the kickoff, or a direct debug call).
|
|
4830
|
-
if (!TYPECHECK)
|
|
4395
|
+
loadTests: async () => {
|
|
4396
|
+
await loadTests();
|
|
4397
|
+
// Background typecheck of the freshly-uploaded app tree — both paths
|
|
4398
|
+
// funnel through here after the upload, so the check always sees the
|
|
4399
|
+
// current code. Never on the critical path: this reply doesn't wait,
|
|
4400
|
+
// `typecheck` collects.
|
|
4831
4401
|
startTypecheck();
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4402
|
+
return loadedSummary(requireLoaded());
|
|
4403
|
+
},
|
|
4404
|
+
// Await the run kicked off at loadTests, or start one on demand — a
|
|
4405
|
+
// legacy single-file project loaded before the kickoff shipped, or a
|
|
4406
|
+
// direct debug call.
|
|
4407
|
+
typecheck: async () => {
|
|
4408
|
+
if (!TYPECHECK)
|
|
4409
|
+
startTypecheck();
|
|
4410
|
+
return await TYPECHECK;
|
|
4411
|
+
},
|
|
4412
|
+
unload: async () => {
|
|
4413
|
+
loaded = null;
|
|
4414
|
+
return { unloaded: true };
|
|
4415
|
+
},
|
|
4841
4416
|
// Full reload (debug aid): re-import the env, then the tests.
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
});
|
|
4850
|
-
return;
|
|
4851
|
-
}
|
|
4852
|
-
if (method === "GET" && url === "/env-config") {
|
|
4853
|
-
const l = requireLoaded();
|
|
4854
|
-
jsonResponse(res, 200, l.project.environment);
|
|
4855
|
-
return;
|
|
4856
|
-
}
|
|
4857
|
-
if (method === "GET" && url === "/cases") {
|
|
4858
|
-
const l = requireLoaded();
|
|
4859
|
-
jsonResponse(res, 200, { cases: casesMetadata(l.project.tests) });
|
|
4860
|
-
return;
|
|
4861
|
-
}
|
|
4862
|
-
if (method === "GET" && url === "/record-secret-refs") {
|
|
4417
|
+
reload: async () => {
|
|
4418
|
+
await loadEnv();
|
|
4419
|
+
await loadTests();
|
|
4420
|
+
return loadedSummary(requireLoaded());
|
|
4421
|
+
},
|
|
4422
|
+
envConfig: async () => requireLoaded().project.environment,
|
|
4423
|
+
cases: async () => ({ cases: casesMetadata(requireLoaded().project.tests) }),
|
|
4863
4424
|
// Union of platform secret refs the loaded fakes declare (replayFake's
|
|
4864
|
-
// `secretRefs`). The control plane resolves these server-side and
|
|
4865
|
-
//
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
for (const
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
jsonResponse(res, 200, { refs: [...refs] });
|
|
4872
|
-
return;
|
|
4873
|
-
}
|
|
4874
|
-
if (method === "POST" && url === "/bootstrap") {
|
|
4875
|
-
if (state.inFlightBootstrap) {
|
|
4876
|
-
jsonResponse(res, 409, { error: "bootstrap already in progress" });
|
|
4877
|
-
return;
|
|
4878
|
-
}
|
|
4879
|
-
const job = bootstrap();
|
|
4880
|
-
state.inFlightBootstrap = job;
|
|
4881
|
-
try {
|
|
4882
|
-
const timings = await job;
|
|
4883
|
-
jsonResponse(res, 200, { ok: true, timings });
|
|
4884
|
-
}
|
|
4885
|
-
finally {
|
|
4886
|
-
state.inFlightBootstrap = null;
|
|
4887
|
-
}
|
|
4888
|
-
return;
|
|
4889
|
-
}
|
|
4890
|
-
if (method === "POST" && url === "/project-setup") {
|
|
4891
|
-
if (state.inFlightProjectSetup) {
|
|
4892
|
-
jsonResponse(res, 409, { error: "project-setup already in progress" });
|
|
4893
|
-
return;
|
|
4894
|
-
}
|
|
4895
|
-
const job = runProjectSetup();
|
|
4896
|
-
state.inFlightProjectSetup = job;
|
|
4897
|
-
try {
|
|
4898
|
-
const result = await job;
|
|
4899
|
-
jsonResponse(res, 200, result);
|
|
4900
|
-
}
|
|
4901
|
-
finally {
|
|
4902
|
-
state.inFlightProjectSetup = null;
|
|
4903
|
-
}
|
|
4904
|
-
return;
|
|
4905
|
-
}
|
|
4906
|
-
if (method === "POST" && url === "/eval") {
|
|
4907
|
-
if (state.inFlightTest) {
|
|
4908
|
-
jsonResponse(res, 409, { error: "a test or eval is already running" });
|
|
4909
|
-
return;
|
|
4910
|
-
}
|
|
4911
|
-
const body = await readBody(req);
|
|
4912
|
-
let parsed;
|
|
4913
|
-
try {
|
|
4914
|
-
parsed = JSON.parse(body || "{}");
|
|
4915
|
-
}
|
|
4916
|
-
catch {
|
|
4917
|
-
jsonResponse(res, 400, { error: "invalid JSON body" });
|
|
4918
|
-
return;
|
|
4919
|
-
}
|
|
4920
|
-
const code = parsed.code;
|
|
4921
|
-
if (typeof code !== "string" || code.length === 0) {
|
|
4922
|
-
jsonResponse(res, 400, { error: "code (string) is required" });
|
|
4923
|
-
return;
|
|
4924
|
-
}
|
|
4925
|
-
// `secrets` are eval-scoped: the control plane resolves the loaded
|
|
4926
|
-
// project's declared `replayFake` refs and pushes the values here on
|
|
4927
|
-
// the eval path only. Never present on the /run (test) path.
|
|
4928
|
-
const exec = evalCode(code, parsed.secrets);
|
|
4929
|
-
state.inFlightTest = exec;
|
|
4930
|
-
try {
|
|
4931
|
-
const result = await exec;
|
|
4932
|
-
jsonResponse(res, 200, result);
|
|
4933
|
-
}
|
|
4934
|
-
finally {
|
|
4935
|
-
state.inFlightTest = null;
|
|
4936
|
-
}
|
|
4937
|
-
return;
|
|
4938
|
-
}
|
|
4939
|
-
if (method === "POST" && url === "/capture-log-baseline") {
|
|
4940
|
-
// Snapshot each service's log output produced during env bring-up
|
|
4941
|
-
// (container startup + project setup), advancing the log markers so the
|
|
4942
|
-
// subsequent per-test deltas start AFTER setup. The control plane calls
|
|
4943
|
-
// this once, on the main env, before the first test runs — see
|
|
4944
|
-
// tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
|
|
4945
|
-
// simply fold into the first test's delta as before.
|
|
4946
|
-
let serviceLogDeltas = [];
|
|
4947
|
-
try {
|
|
4948
|
-
serviceLogDeltas = await captureServiceLogDeltas();
|
|
4949
|
-
}
|
|
4950
|
-
catch (err) {
|
|
4951
|
-
// eslint-disable-next-line no-console
|
|
4952
|
-
console.warn("[service-logs] baseline capture failed:", err);
|
|
4953
|
-
}
|
|
4954
|
-
jsonResponse(res, 200, { serviceLogDeltas });
|
|
4955
|
-
return;
|
|
4956
|
-
}
|
|
4957
|
-
if (method === "POST" && url === "/run") {
|
|
4958
|
-
if (state.inFlightTest) {
|
|
4959
|
-
jsonResponse(res, 409, { error: "another test is already running" });
|
|
4960
|
-
return;
|
|
4961
|
-
}
|
|
4962
|
-
const body = await readBody(req);
|
|
4963
|
-
let parsed;
|
|
4964
|
-
try {
|
|
4965
|
-
parsed = JSON.parse(body || "{}");
|
|
4966
|
-
}
|
|
4967
|
-
catch {
|
|
4968
|
-
jsonResponse(res, 400, { error: "invalid JSON body" });
|
|
4969
|
-
return;
|
|
4970
|
-
}
|
|
4971
|
-
const caseId = parsed.caseId;
|
|
4972
|
-
if (!caseId) {
|
|
4973
|
-
jsonResponse(res, 400, { error: "caseId is required" });
|
|
4974
|
-
return;
|
|
4975
|
-
}
|
|
4976
|
-
const l = requireLoaded();
|
|
4977
|
-
const tc = l.byId.get(caseId);
|
|
4978
|
-
if (!tc) {
|
|
4979
|
-
jsonResponse(res, 404, { error: `unknown caseId: ${caseId}` });
|
|
4980
|
-
return;
|
|
4981
|
-
}
|
|
4982
|
-
const exec = runOne(tc);
|
|
4983
|
-
state.inFlightTest = exec;
|
|
4984
|
-
try {
|
|
4985
|
-
const result = await exec;
|
|
4986
|
-
// Recordings go out-of-band: encode + park the bundle, reply with a
|
|
4987
|
-
// size ref only (see the REPLAY_BUNDLES comment). A bundle-encoding
|
|
4988
|
-
// failure drops the recordings (warn) rather than failing the case —
|
|
4989
|
-
// and never falls back to inlining, which is exactly the >16 MB
|
|
4990
|
-
// response this path exists to avoid.
|
|
4991
|
-
const { browserSessions, ...wire } = result;
|
|
4992
|
-
let replay;
|
|
4993
|
-
if (browserSessions.length > 0) {
|
|
4994
|
-
try {
|
|
4995
|
-
const gz = encodeReplayBundle(caseId, browserSessions);
|
|
4996
|
-
stashReplayBundle(caseId, gz);
|
|
4997
|
-
replay = { bytes: gz.length };
|
|
4998
|
-
}
|
|
4999
|
-
catch (err) {
|
|
5000
|
-
// eslint-disable-next-line no-console
|
|
5001
|
-
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
5002
|
-
}
|
|
4425
|
+
// `secretRefs`). The control plane resolves these server-side and pushes
|
|
4426
|
+
// the values on the eval path only. Empty if nothing's loaded.
|
|
4427
|
+
recordSecretRefs: async () => {
|
|
4428
|
+
const refs = new Set();
|
|
4429
|
+
for (const fake of FAKES.values()) {
|
|
4430
|
+
for (const ref of fake.def.secretRefs ?? [])
|
|
4431
|
+
refs.add(ref);
|
|
5003
4432
|
}
|
|
5004
|
-
|
|
5005
|
-
}
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
jsonResponse(res, 404, { error: `no replay bundle for case: ${parsed.caseId}` });
|
|
5031
|
-
return;
|
|
5032
|
-
}
|
|
5033
|
-
jsonResponse(res, 200, replayChunk(gz, parsed.offset, parsed.limit));
|
|
5034
|
-
return;
|
|
5035
|
-
}
|
|
5036
|
-
jsonResponse(res, 404, { error: "not found" });
|
|
5037
|
-
}
|
|
5038
|
-
async function main() {
|
|
5039
|
-
const state = {
|
|
5040
|
-
inFlightTest: null,
|
|
5041
|
-
inFlightBootstrap: null,
|
|
5042
|
-
inFlightProjectSetup: null,
|
|
5043
|
-
};
|
|
5044
|
-
const server = http.createServer((req, res) => {
|
|
5045
|
-
handle(req, res, state).catch((err) => {
|
|
5046
|
-
const e = err;
|
|
4433
|
+
return { refs: [...refs] };
|
|
4434
|
+
},
|
|
4435
|
+
bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
|
|
4436
|
+
ok: true,
|
|
4437
|
+
timings: await bootstrap(),
|
|
4438
|
+
})),
|
|
4439
|
+
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
4440
|
+
eval: async (params) => {
|
|
4441
|
+
const code = requireString(params, "code");
|
|
4442
|
+
// `secrets` are eval-scoped: the control plane resolves the loaded
|
|
4443
|
+
// project's declared `replayFake` refs and pushes the values here on
|
|
4444
|
+
// the eval path only. Never present on the test path.
|
|
4445
|
+
const secrets = params.secrets;
|
|
4446
|
+
return exclusive(state, "inFlightTest", "a test or eval is already running", () => evalCode(code, secrets));
|
|
4447
|
+
},
|
|
4448
|
+
/**
|
|
4449
|
+
* Snapshot each service's log output produced during env bring-up
|
|
4450
|
+
* (container startup plus project setup), advancing the log markers so
|
|
4451
|
+
* the per-test deltas start *after* setup. The control plane calls this
|
|
4452
|
+
* once, on the main env, before the first test runs.
|
|
4453
|
+
*
|
|
4454
|
+
* Best-effort: on failure the setup lines simply fold into the first
|
|
4455
|
+
* test's delta, as they did before this existed.
|
|
4456
|
+
*/
|
|
4457
|
+
captureLogBaseline: async () => {
|
|
4458
|
+
let serviceLogDeltas = [];
|
|
5047
4459
|
try {
|
|
5048
|
-
|
|
4460
|
+
serviceLogDeltas = await captureServiceLogDeltas();
|
|
5049
4461
|
}
|
|
5050
|
-
catch {
|
|
5051
|
-
//
|
|
4462
|
+
catch (err) {
|
|
4463
|
+
// eslint-disable-next-line no-console
|
|
4464
|
+
console.warn("[service-logs] baseline capture failed:", err);
|
|
5052
4465
|
}
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
4466
|
+
return { serviceLogDeltas };
|
|
4467
|
+
},
|
|
4468
|
+
run: async (params) => {
|
|
4469
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
4470
|
+
const tc = requireLoaded().byId.get(caseId);
|
|
4471
|
+
if (!tc)
|
|
4472
|
+
throw notFound(`unknown caseId: ${caseId}`);
|
|
4473
|
+
return exclusive(state, "inFlightTest", "another test is already running", async () => {
|
|
4474
|
+
const result = await runOne(tc);
|
|
4475
|
+
// Recordings go out-of-band: encode and park the bundle, reply with
|
|
4476
|
+
// a size ref only. A bundle-encoding failure drops the recordings
|
|
4477
|
+
// (with a warning) rather than failing the case — and never falls
|
|
4478
|
+
// back to inlining, which is exactly the oversized reply this path
|
|
4479
|
+
// exists to avoid.
|
|
4480
|
+
const { browserSessions, ...wire } = result;
|
|
4481
|
+
let replay;
|
|
4482
|
+
if (browserSessions.length > 0) {
|
|
4483
|
+
try {
|
|
4484
|
+
const gz = encodeReplayBundle(caseId, browserSessions);
|
|
4485
|
+
stashReplayBundle(caseId, gz);
|
|
4486
|
+
replay = { bytes: gz.length };
|
|
4487
|
+
}
|
|
4488
|
+
catch (err) {
|
|
4489
|
+
// eslint-disable-next-line no-console
|
|
4490
|
+
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
return { ...wire, replay };
|
|
4494
|
+
});
|
|
4495
|
+
},
|
|
4496
|
+
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
4497
|
+
// strings, so they travel in params rather than a URL path.
|
|
4498
|
+
replayChunk: async (params) => {
|
|
4499
|
+
const caseId = requireString(params, "caseId", "caseId");
|
|
4500
|
+
const gz = REPLAY_BUNDLES.get(caseId);
|
|
4501
|
+
if (!gz)
|
|
4502
|
+
throw notFound(`no replay bundle for case: ${caseId}`);
|
|
4503
|
+
return replayChunk(gz, params.offset, params.limit);
|
|
4504
|
+
},
|
|
4505
|
+
};
|
|
5060
4506
|
}
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
4507
|
+
/**
|
|
4508
|
+
* The method table for this process.
|
|
4509
|
+
*
|
|
4510
|
+
* Built once, so the mutual-exclusion slots are process-wide: `run` refuses
|
|
4511
|
+
* a second case while one is in flight, and that guarantee has to hold for
|
|
4512
|
+
* the process, not per caller. Two tables would each enforce the rule and
|
|
4513
|
+
* together break it — which is why this is a value and not a factory the
|
|
4514
|
+
* caller invokes.
|
|
4515
|
+
*
|
|
4516
|
+
* The harness (`sdk/src/harness/main.ts`) serves this over frames. It used
|
|
4517
|
+
* to be served over HTTP as well, by a listener in this file; that was the
|
|
4518
|
+
* in-VM daemon, and it is gone.
|
|
4519
|
+
*/
|
|
4520
|
+
export const METHODS = harnessMethods({
|
|
4521
|
+
inFlightTest: null,
|
|
4522
|
+
inFlightBootstrap: null,
|
|
4523
|
+
inFlightProjectSetup: null,
|
|
5065
4524
|
});
|