@specific.dev/spectest 0.131.18 → 0.131.20
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.js +10 -9
- package/dist/daemon.js +62 -111
- package/dist/harness/dns-registry.d.ts +23 -0
- package/dist/harness/dns-registry.js +129 -0
- package/dist/harness/names-registry.d.ts +3 -18
- package/dist/harness/names-registry.js +3 -18
- package/dist/harness/service-dns.d.ts +35 -0
- package/dist/harness/service-dns.js +97 -0
- package/dist/resolver.js +219 -86
- package/package.json +1 -1
- package/src/browser-executable.test.ts +21 -0
- package/src/browser-finalization.test.ts +1 -1
- package/src/browser-navigation.test.ts +1 -1
- package/src/browser-screenshot.test.ts +66 -0
- package/src/browser-surface.test.ts +7 -4
- package/src/browser-timezone.test.ts +1 -1
- package/src/browser.ts +10 -11
- package/src/daemon.ts +63 -111
- package/src/harness/dns-registry.ts +121 -0
- package/src/harness/names-registry.ts +3 -18
- package/src/harness/service-dns.test.ts +94 -0
- package/src/harness/service-dns.ts +95 -0
- package/src/microphone-fixture.test.ts +1 -1
- package/src/playwright-assertions.test.ts +1 -1
- package/src/resolver.test.ts +267 -0
- package/src/resolver.ts +189 -90
|
@@ -2,10 +2,13 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { chromium } from "playwright-core";
|
|
3
3
|
import { awaitBrowserSurface, captureBrowserScreenshot, reduceBrowserSurfaces } from "./browser-surface.js";
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const engines = {
|
|
6
|
+
chromium: Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"),
|
|
7
|
+
shell: Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"),
|
|
8
|
+
};
|
|
9
|
+
for (const [engine, executablePath] of Object.entries(engines)) for (const mobile of [false, true]) {
|
|
10
|
+
test.skipIf(!executablePath)(`${engine}: reduced surface preserves ${mobile ? "mobile" : "desktop"} behavior and screenshot pixels`, async () => {
|
|
11
|
+
const browser = await chromium.launch({ executablePath: executablePath!, headless: true, args: ["--no-sandbox"] });
|
|
9
12
|
const width = mobile ? 393 : 1280, height = mobile ? 852 : 720, dpr = mobile ? 3 : 1;
|
|
10
13
|
try {
|
|
11
14
|
const context = await browser.newContext({ viewport: { width, height }, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: dpr });
|
|
@@ -21,7 +21,7 @@ async function settings(browser: Browser): Promise<Settings> {
|
|
|
21
21
|
return readRaw(await browser.evaluate<Settings>("read initial regional settings", "window.settings")) as unknown as Settings;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
describe.skipIf(!Bun.which("chromium
|
|
24
|
+
describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser timezone and locale", () => {
|
|
25
25
|
let server: ReturnType<typeof Bun.serve>;
|
|
26
26
|
beforeAll(() => {
|
|
27
27
|
server = Bun.serve({
|
package/src/browser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Headless browser handle for tests. Thin wrapper around playwright-core
|
|
2
|
-
// driving the guest's
|
|
2
|
+
// driving the guest's Headless Shell (pipe transport — `chromium.launch`
|
|
3
3
|
// with an explicit `executablePath`). Electron attaches to the app-owned
|
|
4
4
|
// Chromium over CDP instead; its client also lives inside the VM. The wrapper
|
|
5
5
|
// keeps the public surface stable (we swapped from Bun.WebView to Playwright without
|
|
@@ -463,7 +463,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
463
463
|
// Shared Chromium (playwright-core)
|
|
464
464
|
// ────────────────────────────────────────────────────────────────────────
|
|
465
465
|
|
|
466
|
-
//
|
|
466
|
+
// Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
|
|
467
467
|
// for the daemon's lifetime. Fixture sessions own a process because the audio
|
|
468
468
|
// source is a process-wide flag. Both ride snapshots: the process, playwright's
|
|
469
469
|
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
@@ -481,19 +481,18 @@ const VIEW_CLEANUPS = new WeakMap<BrowserContext, () => Promise<void>>();
|
|
|
481
481
|
// the page only — cookies survived and the next session was still
|
|
482
482
|
// authenticated.
|
|
483
483
|
|
|
484
|
-
function
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
);
|
|
484
|
+
function browserExecutable(): string {
|
|
485
|
+
const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
|
|
486
|
+
if (!executable) {
|
|
487
|
+
throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
|
|
488
|
+
}
|
|
489
|
+
return executable;
|
|
491
490
|
}
|
|
492
491
|
|
|
493
492
|
async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
|
|
494
493
|
if (PW_BROWSER?.isConnected()) return PW_BROWSER;
|
|
495
494
|
PW_BROWSER = await chromium.launch({
|
|
496
|
-
executablePath:
|
|
495
|
+
executablePath: browserExecutable(),
|
|
497
496
|
headless: true,
|
|
498
497
|
args: CHROME_ARGV,
|
|
499
498
|
});
|
|
@@ -531,7 +530,7 @@ async function newViewContext(
|
|
|
531
530
|
let browser: PlaywrightBrowser | undefined;
|
|
532
531
|
try {
|
|
533
532
|
browser = fixture ? await chromium.launch({
|
|
534
|
-
executablePath:
|
|
533
|
+
executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
535
534
|
}) : await ensurePlaywrightBrowser();
|
|
536
535
|
const context = await browser.newContext({
|
|
537
536
|
...(device
|
package/src/daemon.ts
CHANGED
|
@@ -131,7 +131,7 @@ import {
|
|
|
131
131
|
runInstrumented,
|
|
132
132
|
runUninstrumented,
|
|
133
133
|
} from "./harness/instrumentation-scope.js";
|
|
134
|
-
import {
|
|
134
|
+
import { ServiceDns, ServiceOperations, type NameTarget } from "./harness/service-dns.js";
|
|
135
135
|
import {
|
|
136
136
|
InterceptRegistry,
|
|
137
137
|
parseTarget,
|
|
@@ -164,7 +164,6 @@ import {
|
|
|
164
164
|
clearTables,
|
|
165
165
|
emptyTables,
|
|
166
166
|
planBind,
|
|
167
|
-
registryTarget,
|
|
168
167
|
routesFor,
|
|
169
168
|
unbindRoute,
|
|
170
169
|
type Route as IngressRoute,
|
|
@@ -1543,7 +1542,9 @@ async function runContainer(
|
|
|
1543
1542
|
extraAliases: string[] = [],
|
|
1544
1543
|
): Promise<void> {
|
|
1545
1544
|
// Preserve the previous incarnation before the name is reused.
|
|
1546
|
-
if (RUNTIME_SERVICES.has(svc.name)) await
|
|
1545
|
+
if (RUNTIME_SERVICES.has(svc.name)) await stopRuntimeServiceUnlocked(svc.name);
|
|
1546
|
+
PROXY_IP_CACHE.delete(svc.name);
|
|
1547
|
+
await SERVICE_DNS.service(svc.name, null, [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]);
|
|
1547
1548
|
STOPPED_LOG_SERVICES.delete(svc.name);
|
|
1548
1549
|
LOG_MARKERS.delete(`${svc.name}\0stdout`);
|
|
1549
1550
|
LOG_MARKERS.delete(`${svc.name}\0stderr`);
|
|
@@ -1572,6 +1573,10 @@ async function runContainer(
|
|
|
1572
1573
|
`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`,
|
|
1573
1574
|
);
|
|
1574
1575
|
}
|
|
1576
|
+
const ip = await serviceContainerIp(svc.name);
|
|
1577
|
+
if (!ip) throw new Error(`Service ${svc.name} has no IP on ${NETWORK_NAME} after docker run`);
|
|
1578
|
+
await SERVICE_DNS.service(svc.name, ip);
|
|
1579
|
+
PROXY_IP_CACHE.set(svc.name, ip);
|
|
1575
1580
|
}
|
|
1576
1581
|
|
|
1577
1582
|
/** What a readiness probe saw. `detail` is what the plain boolean used to
|
|
@@ -1762,15 +1767,13 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
|
|
|
1762
1767
|
// same root CA, so `ctx.browser()` and peer services trust it.
|
|
1763
1768
|
//
|
|
1764
1769
|
// Both listeners bind on 0.0.0.0 so containers reach them via the
|
|
1765
|
-
// bridge gateway IP (also
|
|
1770
|
+
// bridge gateway IP (also published in the DNS registry for
|
|
1766
1771
|
// spectest-resolver and injected as --add-host on every container).
|
|
1767
1772
|
//
|
|
1768
1773
|
// Per-fake `state` is plain JS memory and lives across snapshot/fork
|
|
1769
1774
|
// along with the rest of the daemon — every fork sees its own copy.
|
|
1770
1775
|
// ────────────────────────────────────────────────────────────────────────
|
|
1771
1776
|
|
|
1772
|
-
const FAKES_REGISTRY_PATH =
|
|
1773
|
-
process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
|
|
1774
1777
|
const DEFAULT_FAKE_PORT = 80;
|
|
1775
1778
|
|
|
1776
1779
|
/** Runtime record for one configured fake. */
|
|
@@ -1971,9 +1974,7 @@ async function startIngress(): Promise<void> {
|
|
|
1971
1974
|
LOWERED.wildcards.length > 0;
|
|
1972
1975
|
if (!hasIngress) {
|
|
1973
1976
|
// Make sure the resolver doesn't see stale entries from a prior project.
|
|
1974
|
-
|
|
1975
|
-
REGISTRY.wildcards = [];
|
|
1976
|
-
await writeRegistry();
|
|
1977
|
+
await seedNamesRegistry();
|
|
1977
1978
|
return;
|
|
1978
1979
|
}
|
|
1979
1980
|
const gw = await bridgeGatewayIp();
|
|
@@ -2061,9 +2062,9 @@ async function startIngress(): Promise<void> {
|
|
|
2061
2062
|
|
|
2062
2063
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
2063
2064
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
2064
|
-
// wildcards. Service-targeted
|
|
2065
|
-
//
|
|
2066
|
-
await seedNamesRegistry(
|
|
2065
|
+
// wildcards. Service-targeted names are reserved until runContainer
|
|
2066
|
+
// publishes their IPs. Dynamic ctx.dnsName calls extend this.
|
|
2067
|
+
await seedNamesRegistry();
|
|
2067
2068
|
}
|
|
2068
2069
|
|
|
2069
2070
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2199,7 +2200,11 @@ function certCovers(hostname: string): boolean {
|
|
|
2199
2200
|
* per-test snapshot exactly like fake state — a `dependsOn` child inherits
|
|
2200
2201
|
* it, siblings forked from an earlier snapshot never see it.
|
|
2201
2202
|
*/
|
|
2202
|
-
|
|
2203
|
+
function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
|
|
2204
|
+
return TLS_OPERATIONS.run(hostname.toLowerCase(), () => bindRuntimeTlsUnlocked(hostname, service, port, http2, cors));
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
async function bindRuntimeTlsUnlocked(hostname: string, service: string, port: number, http2?: boolean, cors?: CorsMode): Promise<void> {
|
|
2203
2208
|
// Reuse the boot primitives for validation + lowercasing; throws on a
|
|
2204
2209
|
// malformed hostname / upstream just like a boot `tls` would at load.
|
|
2205
2210
|
const decl = makeProxyDecl(hostname, { service, port, http2, cors });
|
|
@@ -2238,14 +2243,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
|
|
|
2238
2243
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
2239
2244
|
// A wildcard can only live in the resolver's suffix table.
|
|
2240
2245
|
const gw = await bridgeGatewayIp();
|
|
2241
|
-
|
|
2242
|
-
if (target.wildcard) {
|
|
2243
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
|
|
2244
|
-
REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
|
|
2245
|
-
} else {
|
|
2246
|
-
REGISTRY.hosts[target.host] = gw;
|
|
2247
|
-
}
|
|
2248
|
-
await writeRegistry();
|
|
2246
|
+
await SERVICE_DNS.bind(host, { ip: gw }, `tls:${service}`);
|
|
2249
2247
|
// eslint-disable-next-line no-console
|
|
2250
2248
|
console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
|
|
2251
2249
|
}
|
|
@@ -2256,18 +2254,16 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
|
|
|
2256
2254
|
* left in the SNI table — harmless without a route, and removing it would
|
|
2257
2255
|
* mean an avoidable :443 rebind.
|
|
2258
2256
|
*/
|
|
2259
|
-
|
|
2257
|
+
function unbindRuntimeTls(hostname: string, service: string): Promise<void> {
|
|
2258
|
+
return TLS_OPERATIONS.run(hostname.toLowerCase(), () => unbindRuntimeTlsUnlocked(hostname, service));
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
async function unbindRuntimeTlsUnlocked(hostname: string, service: string): Promise<void> {
|
|
2260
2262
|
const host = hostname.toLowerCase();
|
|
2261
|
-
|
|
2262
|
-
const
|
|
2263
|
-
if (
|
|
2264
|
-
|
|
2265
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
|
|
2266
|
-
if (REGISTRY.wildcards.length !== before) await writeRegistry();
|
|
2267
|
-
} else if (target.host in REGISTRY.hosts) {
|
|
2268
|
-
delete REGISTRY.hosts[target.host];
|
|
2269
|
-
await writeRegistry();
|
|
2270
|
-
}
|
|
2263
|
+
// A newer service may have taken this route/name. Do not remove its route.
|
|
2264
|
+
const route = INGRESS.routesByPort.get(INGRESS_HTTP_PORT)?.get(host);
|
|
2265
|
+
if (route?.kind === "proxy" && route.service === service) unbindRoute(INGRESS, host);
|
|
2266
|
+
await SERVICE_DNS.retire(host, `tls:${service}`);
|
|
2271
2267
|
}
|
|
2272
2268
|
|
|
2273
2269
|
/** Concurrent runtime routes must share the asynchronous first bind. */
|
|
@@ -2559,34 +2555,11 @@ async function proxyToService(
|
|
|
2559
2555
|
);
|
|
2560
2556
|
}
|
|
2561
2557
|
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
* when a test calls `ctx.dnsName`. It lives in daemon memory, so it forks
|
|
2567
|
-
* with the rest of the snapshot — a test's dynamic registration is
|
|
2568
|
-
* isolated to its own fork, exactly like fake state.
|
|
2569
|
-
*/
|
|
2570
|
-
const REGISTRY: {
|
|
2571
|
-
hosts: Record<string, string>;
|
|
2572
|
-
wildcards: Array<{ suffix: string; ip: string }>;
|
|
2573
|
-
} = { hosts: {}, wildcards: [] };
|
|
2574
|
-
|
|
2575
|
-
async function writeRegistry(): Promise<void> {
|
|
2576
|
-
// Format + lookup semantics live in `harness/names-registry.ts`, shared
|
|
2577
|
-
// with the resolver that reads this file.
|
|
2578
|
-
const body = encodeRegistry(REGISTRY, Date.now());
|
|
2579
|
-
try {
|
|
2580
|
-
await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
|
|
2581
|
-
await fs.writeFile(FAKES_REGISTRY_PATH, body);
|
|
2582
|
-
} catch (err) {
|
|
2583
|
-
// Resolver gracefully degrades; just log.
|
|
2584
|
-
// eslint-disable-next-line no-console
|
|
2585
|
-
console.warn(`[names] failed to write registry at ${FAKES_REGISTRY_PATH}:`, err);
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2558
|
+
// The SDK owns desired DNS state; the platform owns serving and persistence.
|
|
2559
|
+
const SERVICE_DNS = new ServiceDns();
|
|
2560
|
+
const SERVICE_OPERATIONS = new ServiceOperations();
|
|
2561
|
+
const TLS_OPERATIONS = new ServiceOperations();
|
|
2588
2562
|
|
|
2589
|
-
/** `*.example.com` → `.example.com` — the suffix the resolver matches. */
|
|
2590
2563
|
/** A service container's IP on spectest-net. `null` if the container isn't
|
|
2591
2564
|
* up or isn't attached to the network yet. */
|
|
2592
2565
|
async function serviceContainerIp(name: string): Promise<string | null> {
|
|
@@ -2631,7 +2604,7 @@ async function proxyUpstreamHost(service: string): Promise<string> {
|
|
|
2631
2604
|
* → that container's IP. Throws if a service target has no IP yet. */
|
|
2632
2605
|
async function resolveDnsTarget(target: DnsTarget): Promise<string> {
|
|
2633
2606
|
if ("ingress" in target) return bridgeGatewayIp();
|
|
2634
|
-
const ip =
|
|
2607
|
+
const ip = SERVICE_DNS.address(target.service);
|
|
2635
2608
|
if (!ip) {
|
|
2636
2609
|
throw new Error(
|
|
2637
2610
|
`dnsName target service ${JSON.stringify(target.service)} has no IP on ${NETWORK_NAME} (is it a running service?)`,
|
|
@@ -2640,37 +2613,19 @@ async function resolveDnsTarget(target: DnsTarget): Promise<string> {
|
|
|
2640
2613
|
return ip;
|
|
2641
2614
|
}
|
|
2642
2615
|
|
|
2643
|
-
/**
|
|
2644
|
-
*
|
|
2645
|
-
|
|
2646
|
-
* hostnames answer during container startup, then once after every
|
|
2647
|
-
* container is up (`servicesUp: true`) so service-targeted wildcards (e.g.
|
|
2648
|
-
* k3s `ingressDomains`) can resolve their container IPs. The post-container
|
|
2649
|
-
* pass is what's captured into the warm template.
|
|
2650
|
-
*/
|
|
2651
|
-
async function seedNamesRegistry(opts: { servicesUp: boolean }): Promise<void> {
|
|
2616
|
+
/** Reserve all declared names before containers start. Each runContainer
|
|
2617
|
+
* publishes its IP before readiness/setup; there is no destructive final reseed. */
|
|
2618
|
+
async function seedNamesRegistry(): Promise<void> {
|
|
2652
2619
|
const gw = await bridgeGatewayIp();
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
if (!opts.servicesUp) continue;
|
|
2663
|
-
const ip = await serviceContainerIp(w.target.service);
|
|
2664
|
-
if (ip) {
|
|
2665
|
-
REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip });
|
|
2666
|
-
} else {
|
|
2667
|
-
// eslint-disable-next-line no-console
|
|
2668
|
-
console.warn(
|
|
2669
|
-
`[names] wildcard ${w.pattern}: service ${JSON.stringify(w.target.service)} has no IP on ${NETWORK_NAME}`,
|
|
2670
|
-
);
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
await writeRegistry();
|
|
2620
|
+
const services = namedServices(requireLoaded().project.environment).map(svc => ({
|
|
2621
|
+
name: svc.name, aliases: LOWERED.aliasesByService[svc.name] ?? [],
|
|
2622
|
+
}));
|
|
2623
|
+
const bindings: Array<{ name: string; target: NameTarget }> = [
|
|
2624
|
+
...LOWERED.ingressHosts.map(name => ({ name, target: { ip: gw } })),
|
|
2625
|
+
...LOWERED.wildcards.map(w => ({ name: w.pattern,
|
|
2626
|
+
target: "ingress" in w.target ? { ip: gw } : { service: w.target.service } })),
|
|
2627
|
+
];
|
|
2628
|
+
await SERVICE_DNS.reset(services, bindings);
|
|
2674
2629
|
}
|
|
2675
2630
|
|
|
2676
2631
|
/**
|
|
@@ -2862,9 +2817,8 @@ function registerInterceptor(
|
|
|
2862
2817
|
* Register a hostname at runtime — the implementation behind `ctx.dnsName`.
|
|
2863
2818
|
* Validates via the same `dnsName` primitive the static path uses, resolves
|
|
2864
2819
|
* the target to an IP, and updates + persists the registry. Exact names go
|
|
2865
|
-
* in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver
|
|
2866
|
-
*
|
|
2867
|
-
* immediately — answered for VM-host/test/browser code and for peer
|
|
2820
|
+
* in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver acknowledges
|
|
2821
|
+
* activation before this returns, so the name is live immediately — answered for VM-host/test/browser code and for peer
|
|
2868
2822
|
* containers (Docker forwards unknown names to the host resolver). It does
|
|
2869
2823
|
* NOT land in any container's /etc/hosts.
|
|
2870
2824
|
*/
|
|
@@ -2873,14 +2827,7 @@ async function registerDnsName(hostname: string, target: DnsTarget): Promise<voi
|
|
|
2873
2827
|
// Reuse the primitive purely for validation + lowercasing.
|
|
2874
2828
|
const decl = makeDnsDecl(hostname, target);
|
|
2875
2829
|
const ip = await resolveDnsTarget(target);
|
|
2876
|
-
|
|
2877
|
-
const suffix = wildcardSuffix(decl.hostname);
|
|
2878
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
2879
|
-
REGISTRY.wildcards.push({ suffix, ip });
|
|
2880
|
-
} else {
|
|
2881
|
-
REGISTRY.hosts[decl.hostname] = ip;
|
|
2882
|
-
}
|
|
2883
|
-
await writeRegistry();
|
|
2830
|
+
await SERVICE_DNS.bind(decl.hostname, "service" in target ? { service: target.service } : { ip });
|
|
2884
2831
|
recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
|
|
2885
2832
|
}
|
|
2886
2833
|
|
|
@@ -2952,7 +2899,11 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
|
2952
2899
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
2953
2900
|
* the container on spectest-net, and waits for its readyCheck. Returns the
|
|
2954
2901
|
* container's name + IP. */
|
|
2955
|
-
|
|
2902
|
+
function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle> {
|
|
2903
|
+
return SERVICE_OPERATIONS.run(spec.name, () => startRuntimeServiceUnlocked(spec));
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
async function startRuntimeServiceUnlocked(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle> {
|
|
2956
2907
|
if (!spec.name || spec.name.length === 0) {
|
|
2957
2908
|
throw new Error("startService: `name` is required");
|
|
2958
2909
|
}
|
|
@@ -2971,14 +2922,14 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
|
|
|
2971
2922
|
...(await ensureCoverage(svc)),
|
|
2972
2923
|
];
|
|
2973
2924
|
await runContainer(svc, tag, flags, aliases);
|
|
2925
|
+
RUNTIME_SERVICES.set(svc.name, svc);
|
|
2974
2926
|
await waitForReady(svc);
|
|
2975
|
-
const ip = (
|
|
2927
|
+
const ip = SERVICE_DNS.address(svc.name)!;
|
|
2976
2928
|
// `tls` is the runtime twin of a boot service's: stand up a
|
|
2977
2929
|
// TLS-terminating reverse proxy at https://<hostname>/ → this container.
|
|
2978
2930
|
for (const entry of svc.tls ?? []) {
|
|
2979
2931
|
await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
|
|
2980
2932
|
}
|
|
2981
|
-
RUNTIME_SERVICES.set(svc.name, svc);
|
|
2982
2933
|
recordEnv({
|
|
2983
2934
|
op: "startService",
|
|
2984
2935
|
service: svc.name,
|
|
@@ -3001,10 +2952,16 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
|
|
|
3001
2952
|
|
|
3002
2953
|
/** Implementation behind `ctx.stopService`. Removes the container and drops
|
|
3003
2954
|
* it from the runtime registry. No-op (rc ignored) if it's already gone. */
|
|
3004
|
-
|
|
2955
|
+
function stopRuntimeService(name: string): Promise<void> {
|
|
2956
|
+
return SERVICE_OPERATIONS.run(name, () => stopRuntimeServiceUnlocked(name));
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
async function stopRuntimeServiceUnlocked(name: string): Promise<void> {
|
|
3005
2960
|
const t0 = Date.now();
|
|
3006
2961
|
const resv = reserveEvent();
|
|
3007
2962
|
const svc = RUNTIME_SERVICES.get(name);
|
|
2963
|
+
await SERVICE_DNS.service(name, null);
|
|
2964
|
+
PROXY_IP_CACHE.delete(name);
|
|
3008
2965
|
// Freeze log production before reading, while Docker still owns the logs.
|
|
3009
2966
|
// Keep force-stop semantics; removing first destroys the only copy.
|
|
3010
2967
|
await docker(["kill", name], 30_000);
|
|
@@ -3016,7 +2973,7 @@ async function stopRuntimeService(name: string): Promise<void> {
|
|
|
3016
2973
|
}
|
|
3017
2974
|
await docker(["rm", "-f", name], 30_000);
|
|
3018
2975
|
RUNTIME_SERVICES.delete(name);
|
|
3019
|
-
for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
|
|
2976
|
+
for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname, name);
|
|
3020
2977
|
recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
|
|
3021
2978
|
}
|
|
3022
2979
|
|
|
@@ -3329,11 +3286,6 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3329
3286
|
|
|
3330
3287
|
await ensureStickyBuildHeadroom();
|
|
3331
3288
|
|
|
3332
|
-
// Containers now have IPs — re-seed so service-targeted wildcards (e.g.
|
|
3333
|
-
// k3s ingressDomains → the cluster container) resolve. Captured into the
|
|
3334
|
-
// warm template, so warm starts inherit the resolved entries.
|
|
3335
|
-
await seedNamesRegistry({ servicesUp: true });
|
|
3336
|
-
|
|
3337
3289
|
// Browser pre-warm DISABLED (2026-06-08). We used to pre-open one view
|
|
3338
3290
|
// into the pool (browser.ts VIEW_POOL) here so every fork inherited a
|
|
3339
3291
|
// live renderer and the first ctx.browser() skipped the ~1.2-1.5s spawn.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Platform/SDK DNS control protocol. The platform persists and activates each
|
|
2
|
+
* complete registry before acknowledging it. No project code runs in DNS. */
|
|
3
|
+
import { isIPv4 } from "node:net";
|
|
4
|
+
import { createServer, request } from "node:http";
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const DNS_SOCKET = process.env.SPECTEST_DNS_SOCKET ?? "/run/spectest-dns.sock";
|
|
9
|
+
export const DNS_REGISTRY_PATH = process.env.SPECTEST_DNS_REGISTRY ?? "/run/spectest-dns.json";
|
|
10
|
+
export const MAX_REGISTRY_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
export interface DnsRegistry {
|
|
12
|
+
version: 1;
|
|
13
|
+
/** Opaque transaction ID, echoed in the acknowledgement. */
|
|
14
|
+
generation: string;
|
|
15
|
+
/** null reserves an unavailable name: it must never escape upstream. */
|
|
16
|
+
hosts: Record<string, string | null>;
|
|
17
|
+
wildcards: Array<{ suffix: string; ip: string | null }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validName(name: string): boolean {
|
|
21
|
+
return name.length > 0 && name.length <= 253 && name === name.toLowerCase() &&
|
|
22
|
+
name.split(".").every(label => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(label));
|
|
23
|
+
}
|
|
24
|
+
export function parseDnsRegistry(text: string): DnsRegistry {
|
|
25
|
+
if (Buffer.byteLength(text) > MAX_REGISTRY_BYTES) throw new Error("DNS registry too large");
|
|
26
|
+
const doc = JSON.parse(text);
|
|
27
|
+
if (!doc || doc.version !== 1 || typeof doc.generation !== "string" || !doc.generation.length || doc.generation.length > 128 ||
|
|
28
|
+
!doc.hosts || typeof doc.hosts !== "object" || Array.isArray(doc.hosts) || !Array.isArray(doc.wildcards))
|
|
29
|
+
throw new Error("Unsupported or malformed DNS registry (expected version 1)");
|
|
30
|
+
const address = (ip: unknown) => ip === null || (typeof ip === "string" && isIPv4(ip));
|
|
31
|
+
for (const [name, ip] of Object.entries(doc.hosts))
|
|
32
|
+
if (!validName(name) || !address(ip)) throw new Error(`Invalid DNS host: ${name}`);
|
|
33
|
+
const suffixes = new Set<string>();
|
|
34
|
+
for (const w of doc.wildcards) {
|
|
35
|
+
if (!w || typeof w.suffix !== "string" || !w.suffix.startsWith(".") || !validName(w.suffix.slice(1)) || !address(w.ip) || suffixes.has(w.suffix))
|
|
36
|
+
throw new Error("Invalid or duplicate DNS wildcard");
|
|
37
|
+
suffixes.add(w.suffix);
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, generation: doc.generation, hosts: doc.hosts, wildcards: doc.wildcards };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compile once per update. undefined = unknown; null = owned but unavailable. */
|
|
43
|
+
export function compileDnsRegistry(doc: DnsRegistry): (name: string) => string | null | undefined {
|
|
44
|
+
const hosts = new Map(Object.entries(doc.hosts));
|
|
45
|
+
const wildcards = new Map(doc.wildcards.map(w => [w.suffix, w.ip]));
|
|
46
|
+
return name => {
|
|
47
|
+
if (hosts.has(name)) return hosts.get(name);
|
|
48
|
+
for (let i = name.indexOf("."); i !== -1; i = name.indexOf(".", i + 1)) {
|
|
49
|
+
const suffix = name.slice(i);
|
|
50
|
+
if (wildcards.has(suffix)) return wildcards.get(suffix);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Restore before binding DNS. A corrupt persisted registry is fatal, because
|
|
57
|
+
* silently forgetting owned domains could send fake traffic to real services. */
|
|
58
|
+
export async function serveDnsRegistry(activate: (doc: DnsRegistry) => void) {
|
|
59
|
+
try { activate(parseDnsRegistry(await readFile(DNS_REGISTRY_PATH, "utf8"))); }
|
|
60
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
61
|
+
await mkdir(dirname(DNS_SOCKET), { recursive: true });
|
|
62
|
+
await rm(DNS_SOCKET, { force: true });
|
|
63
|
+
let pending = Promise.resolve();
|
|
64
|
+
const server = createServer(async (req, res) => {
|
|
65
|
+
if (req.method !== "PUT" || req.url !== "/v1/registry") { res.writeHead(404).end(); return; }
|
|
66
|
+
try {
|
|
67
|
+
const chunks: Buffer[] = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of req) {
|
|
70
|
+
size += chunk.length;
|
|
71
|
+
if (size > MAX_REGISTRY_BYTES) { res.writeHead(413).end(); return; }
|
|
72
|
+
chunks.push(Buffer.from(chunk));
|
|
73
|
+
}
|
|
74
|
+
const doc = parseDnsRegistry(Buffer.concat(chunks).toString("utf8"));
|
|
75
|
+
const update = pending.then(async () => {
|
|
76
|
+
await mkdir(dirname(DNS_REGISTRY_PATH), { recursive: true });
|
|
77
|
+
const tmp = `${DNS_REGISTRY_PATH}.tmp`;
|
|
78
|
+
await writeFile(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
79
|
+
await rename(tmp, DNS_REGISTRY_PATH);
|
|
80
|
+
activate(doc);
|
|
81
|
+
});
|
|
82
|
+
pending = update.catch(() => {});
|
|
83
|
+
await update;
|
|
84
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ version: 1, generation: doc.generation }));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(String(error));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
server.requestTimeout = 10_000;
|
|
90
|
+
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(DNS_SOCKET, resolve); });
|
|
91
|
+
await chmod(DNS_SOCKET, 0o600);
|
|
92
|
+
return server;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
|
|
96
|
+
* fails bootstrap explicitly; never silently switch back to discovery. */
|
|
97
|
+
export async function publishDnsRegistry(doc: DnsRegistry, socketPath = DNS_SOCKET): Promise<void> {
|
|
98
|
+
const body = JSON.stringify(doc);
|
|
99
|
+
parseDnsRegistry(body);
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
const req = request({ socketPath, method: "PUT", path: "/v1/registry", agent: false,
|
|
102
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } }, res => {
|
|
103
|
+
let text = "";
|
|
104
|
+
res.setEncoding("utf8");
|
|
105
|
+
res.on("data", chunk => { text += chunk; });
|
|
106
|
+
res.on("error", reject);
|
|
107
|
+
res.on("end", () => {
|
|
108
|
+
try {
|
|
109
|
+
if (res.statusCode !== 200) throw new Error(`DNS update rejected (${res.statusCode}): ${text}`);
|
|
110
|
+
const ack = JSON.parse(text);
|
|
111
|
+
if (ack.version !== 1 || ack.generation !== doc.generation) throw new Error("DNS acknowledgement mismatch");
|
|
112
|
+
resolve();
|
|
113
|
+
} catch (error) { reject(error); }
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
const timer = setTimeout(() => req.destroy(new Error("DNS registry acknowledgement timed out")), 10_000);
|
|
117
|
+
req.on("close", () => clearTimeout(timer));
|
|
118
|
+
req.on("error", error => reject(new Error(`Cannot publish DNS registry; this SDK requires a platform with DNS registry v1: ${error.message}`)));
|
|
119
|
+
req.end(body);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Ported out of `daemon.ts`. The point of a shared module is that this
|
|
6
|
-
* format had **two independent implementations** — the writer here and
|
|
7
|
-
* the reader in `resolver.ts` — with nothing keeping them in step. That
|
|
8
|
-
* is the same shape as the `config.rs` ↔ SDK drift the harness split
|
|
9
|
-
* exists to remove, just inside one language.
|
|
10
|
-
*
|
|
11
|
-
* The registry answers names that Docker's own DNS cannot:
|
|
12
|
-
* - fake hostnames (`api.stripe.com` → the bridge gateway),
|
|
13
|
-
* - names bound at runtime by `ctx.dnsName`,
|
|
14
|
-
* - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
|
|
15
|
-
* `--add-host` or `--network-alias` equivalent and therefore *only*
|
|
16
|
-
* exist here.
|
|
17
|
-
*
|
|
18
|
-
* Lookup is exact-first, then longest matching suffix — the same
|
|
19
|
-
* precedence the ingress routes and the SNI cert table use.
|
|
2
|
+
* Legacy names-file format used by SDKs before 0.131.20. The platform retains
|
|
3
|
+
* its lookup semantics for pinned SDKs that do not publish a complete registry.
|
|
4
|
+
* New SDKs use dns-registry.ts (control protocol) and service-dns.ts (ownership).
|
|
20
5
|
*/
|
|
21
6
|
|
|
22
7
|
export interface RegistryDoc {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { ServiceDns, ServiceOperations } from "./service-dns";
|
|
3
|
+
import { compileDnsRegistry, parseDnsRegistry, type DnsRegistry } from "./dns-registry";
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
const docs: DnsRegistry[] = [];
|
|
7
|
+
const dns = new ServiceDns(async doc => { docs.push(parseDnsRegistry(JSON.stringify(doc))); });
|
|
8
|
+
return { dns, docs, lookup: (name: string) => compileDnsRegistry(docs.at(-1)!)(name) };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("reserve startup names, publish before setup, retain setup registrations and follow service recreation", async () => {
|
|
12
|
+
const f = fixture();
|
|
13
|
+
await f.dns.reset([{ name: "api", aliases: ["api.example.com"] }, { name: "db", aliases: [] }], [
|
|
14
|
+
{ name: "*.cluster.test", target: { service: "api" } },
|
|
15
|
+
{ name: "fake.example.com", target: { ip: "10.0.0.1" } },
|
|
16
|
+
]);
|
|
17
|
+
expect(f.lookup("api.internal")).toBeNull();
|
|
18
|
+
expect(f.lookup("api.example.com")).toBeNull();
|
|
19
|
+
expect(f.lookup("app.cluster.test")).toBeNull();
|
|
20
|
+
await f.dns.service("api", "10.0.0.2");
|
|
21
|
+
await f.dns.bind("setup.test", { service: "api" });
|
|
22
|
+
await f.dns.service("db", "10.0.0.3");
|
|
23
|
+
expect(f.lookup("setup.test")).toBe("10.0.0.2");
|
|
24
|
+
expect(f.lookup("app.cluster.test")).toBe("10.0.0.2");
|
|
25
|
+
await f.dns.service("api", null);
|
|
26
|
+
expect(f.lookup("setup.test")).toBeNull();
|
|
27
|
+
expect(f.lookup("api.example.com")).toBeNull();
|
|
28
|
+
await f.dns.service("api", "10.0.0.4");
|
|
29
|
+
expect(f.lookup("setup.test")).toBe("10.0.0.4");
|
|
30
|
+
expect(f.lookup("app.cluster.test")).toBe("10.0.0.4");
|
|
31
|
+
expect(f.lookup("fake.example.com")).toBe("10.0.0.1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("explicit names beat aliases; retiring old ownership cannot delete a replacement", async () => {
|
|
35
|
+
const f = fixture();
|
|
36
|
+
await f.dns.service("api", "10.0.0.2", ["shared.test", "old.test"]);
|
|
37
|
+
await f.dns.bind("shared.test", { ip: "10.0.0.1" }, "tls:api");
|
|
38
|
+
await f.dns.bind("shared.test", { ip: "10.0.0.3" }, "tls:other");
|
|
39
|
+
await f.dns.retire("shared.test", "tls:api");
|
|
40
|
+
expect(f.lookup("shared.test")).toBe("10.0.0.3");
|
|
41
|
+
await f.dns.retire("shared.test", "tls:other");
|
|
42
|
+
expect(f.lookup("shared.test")).toBeNull();
|
|
43
|
+
await f.dns.service("api", "10.0.0.4", ["new.test"]);
|
|
44
|
+
expect(f.lookup("old.test")).toBeNull();
|
|
45
|
+
expect(f.lookup("new.test")).toBe("10.0.0.4");
|
|
46
|
+
await f.dns.service("api", "10.0.0.5", ["old.test"]);
|
|
47
|
+
expect(f.lookup("old.test")).toBe("10.0.0.5");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("concurrent publications wait for acknowledgement, reject failures and repair with a full view", async () => {
|
|
51
|
+
const docs: DnsRegistry[] = [];
|
|
52
|
+
let release!: () => void;
|
|
53
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
54
|
+
let first = true;
|
|
55
|
+
const dns = new ServiceDns(async doc => {
|
|
56
|
+
docs.push(doc);
|
|
57
|
+
if (first) { first = false; await gate; throw new Error("lost acknowledgement"); }
|
|
58
|
+
});
|
|
59
|
+
const a = dns.service("a", "10.0.0.2");
|
|
60
|
+
const failed = a.catch(error => error);
|
|
61
|
+
const b = dns.service("b", "10.0.0.3");
|
|
62
|
+
await Bun.sleep(0);
|
|
63
|
+
expect(docs).toHaveLength(1);
|
|
64
|
+
release(); expect((await failed).message).toBe("lost acknowledgement"); await b;
|
|
65
|
+
expect(docs).toHaveLength(2);
|
|
66
|
+
expect(docs[1].hosts).toMatchObject({ a: "10.0.0.2", b: "10.0.0.3" });
|
|
67
|
+
expect(docs[0].hosts.b).toBeUndefined();
|
|
68
|
+
expect(docs[1].generation).not.toBe(docs[0].generation);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("same-service operations serialize through failure without blocking independent services", async () => {
|
|
72
|
+
const ops = new ServiceOperations();
|
|
73
|
+
const events: string[] = [];
|
|
74
|
+
let release!: () => void;
|
|
75
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
76
|
+
const first = ops.run("api", async () => { events.push("start"); await gate; throw new Error("failed start"); });
|
|
77
|
+
const failed = first.catch(error => error);
|
|
78
|
+
const stopped = ops.run("api", async () => { events.push("stop"); });
|
|
79
|
+
await ops.run("db", async () => { events.push("db"); });
|
|
80
|
+
expect(events).toEqual(["start", "db"]);
|
|
81
|
+
release(); expect((await failed).message).toBe("failed start"); await stopped;
|
|
82
|
+
expect(events).toEqual(["start", "db", "stop"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
test("invalid desired state cannot poison later unrelated updates", async () => {
|
|
87
|
+
const f = fixture();
|
|
88
|
+
await f.dns.service("api", "10.0.0.2");
|
|
89
|
+
await expect(f.dns.service("invalid name", "10.0.0.3")).rejects.toThrow("Invalid DNS host");
|
|
90
|
+
await f.dns.service("db", "10.0.0.4");
|
|
91
|
+
expect(f.lookup("api")).toBe("10.0.0.2");
|
|
92
|
+
expect(f.lookup("db")).toBe("10.0.0.4");
|
|
93
|
+
expect(f.docs.at(-1)?.hosts["invalid name"]).toBeUndefined();
|
|
94
|
+
});
|