@specific.dev/spectest 0.131.19 → 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/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/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/resolver.test.ts +267 -0
- package/src/resolver.ts +189 -90
package/dist/daemon.js
CHANGED
|
@@ -65,12 +65,12 @@ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, tak
|
|
|
65
65
|
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
66
66
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
67
67
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
68
|
-
import {
|
|
68
|
+
import { ServiceDns, ServiceOperations } from "./harness/service-dns.js";
|
|
69
69
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
70
70
|
import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
|
|
71
71
|
import { openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
72
72
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
73
|
-
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind,
|
|
73
|
+
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
74
74
|
import { startIngressServer } from "./harness/ingress-server.js";
|
|
75
75
|
import { startHttp2Proxy } from "./harness/http2-proxy.js";
|
|
76
76
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
@@ -1218,7 +1218,9 @@ async function runContainer(svc, tag, volumeFlags,
|
|
|
1218
1218
|
extraAliases = []) {
|
|
1219
1219
|
// Preserve the previous incarnation before the name is reused.
|
|
1220
1220
|
if (RUNTIME_SERVICES.has(svc.name))
|
|
1221
|
-
await
|
|
1221
|
+
await stopRuntimeServiceUnlocked(svc.name);
|
|
1222
|
+
PROXY_IP_CACHE.delete(svc.name);
|
|
1223
|
+
await SERVICE_DNS.service(svc.name, null, [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]);
|
|
1222
1224
|
STOPPED_LOG_SERVICES.delete(svc.name);
|
|
1223
1225
|
LOG_MARKERS.delete(`${svc.name}\0stdout`);
|
|
1224
1226
|
LOG_MARKERS.delete(`${svc.name}\0stderr`);
|
|
@@ -1243,6 +1245,11 @@ extraAliases = []) {
|
|
|
1243
1245
|
if (r.code !== 0) {
|
|
1244
1246
|
throw new Error(`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
1245
1247
|
}
|
|
1248
|
+
const ip = await serviceContainerIp(svc.name);
|
|
1249
|
+
if (!ip)
|
|
1250
|
+
throw new Error(`Service ${svc.name} has no IP on ${NETWORK_NAME} after docker run`);
|
|
1251
|
+
await SERVICE_DNS.service(svc.name, ip);
|
|
1252
|
+
PROXY_IP_CACHE.set(svc.name, ip);
|
|
1246
1253
|
}
|
|
1247
1254
|
async function probeTcp(host, port) {
|
|
1248
1255
|
return new Promise((resolve) => {
|
|
@@ -1403,13 +1410,12 @@ async function withContainerPostMortem(name, err) {
|
|
|
1403
1410
|
// same root CA, so `ctx.browser()` and peer services trust it.
|
|
1404
1411
|
//
|
|
1405
1412
|
// Both listeners bind on 0.0.0.0 so containers reach them via the
|
|
1406
|
-
// bridge gateway IP (also
|
|
1413
|
+
// bridge gateway IP (also published in the DNS registry for
|
|
1407
1414
|
// spectest-resolver and injected as --add-host on every container).
|
|
1408
1415
|
//
|
|
1409
1416
|
// Per-fake `state` is plain JS memory and lives across snapshot/fork
|
|
1410
1417
|
// along with the rest of the daemon — every fork sees its own copy.
|
|
1411
1418
|
// ────────────────────────────────────────────────────────────────────────
|
|
1412
|
-
const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
|
|
1413
1419
|
const DEFAULT_FAKE_PORT = 80;
|
|
1414
1420
|
/** All loaded fakes, keyed by stable name (the `fakes` map key). Holds the
|
|
1415
1421
|
* in-daemon handler, forked state, and helpers — the parts intrinsic to a
|
|
@@ -1581,9 +1587,7 @@ async function startIngress() {
|
|
|
1581
1587
|
LOWERED.wildcards.length > 0;
|
|
1582
1588
|
if (!hasIngress) {
|
|
1583
1589
|
// Make sure the resolver doesn't see stale entries from a prior project.
|
|
1584
|
-
|
|
1585
|
-
REGISTRY.wildcards = [];
|
|
1586
|
-
await writeRegistry();
|
|
1590
|
+
await seedNamesRegistry();
|
|
1587
1591
|
return;
|
|
1588
1592
|
}
|
|
1589
1593
|
const gw = await bridgeGatewayIp();
|
|
@@ -1671,9 +1675,9 @@ async function startIngress() {
|
|
|
1671
1675
|
await ensureHttpsIngress();
|
|
1672
1676
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
1673
1677
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
1674
|
-
// wildcards. Service-targeted
|
|
1675
|
-
//
|
|
1676
|
-
await seedNamesRegistry(
|
|
1678
|
+
// wildcards. Service-targeted names are reserved until runContainer
|
|
1679
|
+
// publishes their IPs. Dynamic ctx.dnsName calls extend this.
|
|
1680
|
+
await seedNamesRegistry();
|
|
1677
1681
|
}
|
|
1678
1682
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1679
1683
|
function requireBun() {
|
|
@@ -1804,7 +1808,10 @@ function certCovers(hostname) {
|
|
|
1804
1808
|
* per-test snapshot exactly like fake state — a `dependsOn` child inherits
|
|
1805
1809
|
* it, siblings forked from an earlier snapshot never see it.
|
|
1806
1810
|
*/
|
|
1807
|
-
|
|
1811
|
+
function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
1812
|
+
return TLS_OPERATIONS.run(hostname.toLowerCase(), () => bindRuntimeTlsUnlocked(hostname, service, port, http2, cors));
|
|
1813
|
+
}
|
|
1814
|
+
async function bindRuntimeTlsUnlocked(hostname, service, port, http2, cors) {
|
|
1808
1815
|
// Reuse the boot primitives for validation + lowercasing; throws on a
|
|
1809
1816
|
// malformed hostname / upstream just like a boot `tls` would at load.
|
|
1810
1817
|
const decl = makeProxyDecl(hostname, { service, port, http2, cors });
|
|
@@ -1839,15 +1846,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1839
1846
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1840
1847
|
// A wildcard can only live in the resolver's suffix table.
|
|
1841
1848
|
const gw = await bridgeGatewayIp();
|
|
1842
|
-
|
|
1843
|
-
if (target.wildcard) {
|
|
1844
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
|
|
1845
|
-
REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
|
|
1846
|
-
}
|
|
1847
|
-
else {
|
|
1848
|
-
REGISTRY.hosts[target.host] = gw;
|
|
1849
|
-
}
|
|
1850
|
-
await writeRegistry();
|
|
1849
|
+
await SERVICE_DNS.bind(host, { ip: gw }, `tls:${service}`);
|
|
1851
1850
|
// eslint-disable-next-line no-console
|
|
1852
1851
|
console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
|
|
1853
1852
|
}
|
|
@@ -1857,20 +1856,16 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1857
1856
|
* left in the SNI table — harmless without a route, and removing it would
|
|
1858
1857
|
* mean an avoidable :443 rebind.
|
|
1859
1858
|
*/
|
|
1860
|
-
|
|
1859
|
+
function unbindRuntimeTls(hostname, service) {
|
|
1860
|
+
return TLS_OPERATIONS.run(hostname.toLowerCase(), () => unbindRuntimeTlsUnlocked(hostname, service));
|
|
1861
|
+
}
|
|
1862
|
+
async function unbindRuntimeTlsUnlocked(hostname, service) {
|
|
1861
1863
|
const host = hostname.toLowerCase();
|
|
1862
|
-
|
|
1863
|
-
const
|
|
1864
|
-
if (
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
if (REGISTRY.wildcards.length !== before)
|
|
1868
|
-
await writeRegistry();
|
|
1869
|
-
}
|
|
1870
|
-
else if (target.host in REGISTRY.hosts) {
|
|
1871
|
-
delete REGISTRY.hosts[target.host];
|
|
1872
|
-
await writeRegistry();
|
|
1873
|
-
}
|
|
1864
|
+
// A newer service may have taken this route/name. Do not remove its route.
|
|
1865
|
+
const route = INGRESS.routesByPort.get(INGRESS_HTTP_PORT)?.get(host);
|
|
1866
|
+
if (route?.kind === "proxy" && route.service === service)
|
|
1867
|
+
unbindRoute(INGRESS, host);
|
|
1868
|
+
await SERVICE_DNS.retire(host, `tls:${service}`);
|
|
1874
1869
|
}
|
|
1875
1870
|
/** Concurrent runtime routes must share the asynchronous first bind. */
|
|
1876
1871
|
async function ensureHttpIngress(port, byHost) {
|
|
@@ -2125,30 +2120,10 @@ server, service, port, listenerLabel, proto) {
|
|
|
2125
2120
|
const e = lastErr;
|
|
2126
2121
|
return new Response(`spectest-daemon: upstream ${service}:${port} unreachable: ${e?.message ?? String(lastErr)}\n`, { status: 502, headers: { "content-type": "text/plain" } });
|
|
2127
2122
|
}
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
* when a test calls `ctx.dnsName`. It lives in daemon memory, so it forks
|
|
2133
|
-
* with the rest of the snapshot — a test's dynamic registration is
|
|
2134
|
-
* isolated to its own fork, exactly like fake state.
|
|
2135
|
-
*/
|
|
2136
|
-
const REGISTRY = { hosts: {}, wildcards: [] };
|
|
2137
|
-
async function writeRegistry() {
|
|
2138
|
-
// Format + lookup semantics live in `harness/names-registry.ts`, shared
|
|
2139
|
-
// with the resolver that reads this file.
|
|
2140
|
-
const body = encodeRegistry(REGISTRY, Date.now());
|
|
2141
|
-
try {
|
|
2142
|
-
await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
|
|
2143
|
-
await fs.writeFile(FAKES_REGISTRY_PATH, body);
|
|
2144
|
-
}
|
|
2145
|
-
catch (err) {
|
|
2146
|
-
// Resolver gracefully degrades; just log.
|
|
2147
|
-
// eslint-disable-next-line no-console
|
|
2148
|
-
console.warn(`[names] failed to write registry at ${FAKES_REGISTRY_PATH}:`, err);
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
/** `*.example.com` → `.example.com` — the suffix the resolver matches. */
|
|
2123
|
+
// The SDK owns desired DNS state; the platform owns serving and persistence.
|
|
2124
|
+
const SERVICE_DNS = new ServiceDns();
|
|
2125
|
+
const SERVICE_OPERATIONS = new ServiceOperations();
|
|
2126
|
+
const TLS_OPERATIONS = new ServiceOperations();
|
|
2152
2127
|
/** A service container's IP on spectest-net. `null` if the container isn't
|
|
2153
2128
|
* up or isn't attached to the network yet. */
|
|
2154
2129
|
async function serviceContainerIp(name) {
|
|
@@ -2190,44 +2165,25 @@ async function proxyUpstreamHost(service) {
|
|
|
2190
2165
|
async function resolveDnsTarget(target) {
|
|
2191
2166
|
if ("ingress" in target)
|
|
2192
2167
|
return bridgeGatewayIp();
|
|
2193
|
-
const ip =
|
|
2168
|
+
const ip = SERVICE_DNS.address(target.service);
|
|
2194
2169
|
if (!ip) {
|
|
2195
2170
|
throw new Error(`dnsName target service ${JSON.stringify(target.service)} has no IP on ${NETWORK_NAME} (is it a running service?)`);
|
|
2196
2171
|
}
|
|
2197
2172
|
return ip;
|
|
2198
2173
|
}
|
|
2199
|
-
/**
|
|
2200
|
-
*
|
|
2201
|
-
|
|
2202
|
-
* hostnames answer during container startup, then once after every
|
|
2203
|
-
* container is up (`servicesUp: true`) so service-targeted wildcards (e.g.
|
|
2204
|
-
* k3s `ingressDomains`) can resolve their container IPs. The post-container
|
|
2205
|
-
* pass is what's captured into the warm template.
|
|
2206
|
-
*/
|
|
2207
|
-
async function seedNamesRegistry(opts) {
|
|
2174
|
+
/** Reserve all declared names before containers start. Each runContainer
|
|
2175
|
+
* publishes its IP before readiness/setup; there is no destructive final reseed. */
|
|
2176
|
+
async function seedNamesRegistry() {
|
|
2208
2177
|
const gw = await bridgeGatewayIp();
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
// Service target — only resolvable once the container has an IP.
|
|
2219
|
-
if (!opts.servicesUp)
|
|
2220
|
-
continue;
|
|
2221
|
-
const ip = await serviceContainerIp(w.target.service);
|
|
2222
|
-
if (ip) {
|
|
2223
|
-
REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip });
|
|
2224
|
-
}
|
|
2225
|
-
else {
|
|
2226
|
-
// eslint-disable-next-line no-console
|
|
2227
|
-
console.warn(`[names] wildcard ${w.pattern}: service ${JSON.stringify(w.target.service)} has no IP on ${NETWORK_NAME}`);
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
await writeRegistry();
|
|
2178
|
+
const services = namedServices(requireLoaded().project.environment).map(svc => ({
|
|
2179
|
+
name: svc.name, aliases: LOWERED.aliasesByService[svc.name] ?? [],
|
|
2180
|
+
}));
|
|
2181
|
+
const bindings = [
|
|
2182
|
+
...LOWERED.ingressHosts.map(name => ({ name, target: { ip: gw } })),
|
|
2183
|
+
...LOWERED.wildcards.map(w => ({ name: w.pattern,
|
|
2184
|
+
target: "ingress" in w.target ? { ip: gw } : { service: w.target.service } })),
|
|
2185
|
+
];
|
|
2186
|
+
await SERVICE_DNS.reset(services, bindings);
|
|
2231
2187
|
}
|
|
2232
2188
|
/**
|
|
2233
2189
|
* The live interceptors (`ctx.intercept`). Module memory, so they fork with
|
|
@@ -2399,9 +2355,8 @@ function registerInterceptor(target, description, handler) {
|
|
|
2399
2355
|
* Register a hostname at runtime — the implementation behind `ctx.dnsName`.
|
|
2400
2356
|
* Validates via the same `dnsName` primitive the static path uses, resolves
|
|
2401
2357
|
* the target to an IP, and updates + persists the registry. Exact names go
|
|
2402
|
-
* in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver
|
|
2403
|
-
*
|
|
2404
|
-
* immediately — answered for VM-host/test/browser code and for peer
|
|
2358
|
+
* in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver acknowledges
|
|
2359
|
+
* activation before this returns, so the name is live immediately — answered for VM-host/test/browser code and for peer
|
|
2405
2360
|
* containers (Docker forwards unknown names to the host resolver). It does
|
|
2406
2361
|
* NOT land in any container's /etc/hosts.
|
|
2407
2362
|
*/
|
|
@@ -2410,15 +2365,7 @@ async function registerDnsName(hostname, target) {
|
|
|
2410
2365
|
// Reuse the primitive purely for validation + lowercasing.
|
|
2411
2366
|
const decl = makeDnsDecl(hostname, target);
|
|
2412
2367
|
const ip = await resolveDnsTarget(target);
|
|
2413
|
-
|
|
2414
|
-
const suffix = wildcardSuffix(decl.hostname);
|
|
2415
|
-
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
2416
|
-
REGISTRY.wildcards.push({ suffix, ip });
|
|
2417
|
-
}
|
|
2418
|
-
else {
|
|
2419
|
-
REGISTRY.hosts[decl.hostname] = ip;
|
|
2420
|
-
}
|
|
2421
|
-
await writeRegistry();
|
|
2368
|
+
await SERVICE_DNS.bind(decl.hostname, "service" in target ? { service: target.service } : { ip });
|
|
2422
2369
|
recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
|
|
2423
2370
|
}
|
|
2424
2371
|
/**
|
|
@@ -2480,7 +2427,10 @@ function specToNamedService(spec) {
|
|
|
2480
2427
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
2481
2428
|
* the container on spectest-net, and waits for its readyCheck. Returns the
|
|
2482
2429
|
* container's name + IP. */
|
|
2483
|
-
|
|
2430
|
+
function startRuntimeService(spec) {
|
|
2431
|
+
return SERVICE_OPERATIONS.run(spec.name, () => startRuntimeServiceUnlocked(spec));
|
|
2432
|
+
}
|
|
2433
|
+
async function startRuntimeServiceUnlocked(spec) {
|
|
2484
2434
|
if (!spec.name || spec.name.length === 0) {
|
|
2485
2435
|
throw new Error("startService: `name` is required");
|
|
2486
2436
|
}
|
|
@@ -2499,14 +2449,14 @@ async function startRuntimeService(spec) {
|
|
|
2499
2449
|
...(await ensureCoverage(svc)),
|
|
2500
2450
|
];
|
|
2501
2451
|
await runContainer(svc, tag, flags, aliases);
|
|
2452
|
+
RUNTIME_SERVICES.set(svc.name, svc);
|
|
2502
2453
|
await waitForReady(svc);
|
|
2503
|
-
const ip = (
|
|
2454
|
+
const ip = SERVICE_DNS.address(svc.name);
|
|
2504
2455
|
// `tls` is the runtime twin of a boot service's: stand up a
|
|
2505
2456
|
// TLS-terminating reverse proxy at https://<hostname>/ → this container.
|
|
2506
2457
|
for (const entry of svc.tls ?? []) {
|
|
2507
2458
|
await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
|
|
2508
2459
|
}
|
|
2509
|
-
RUNTIME_SERVICES.set(svc.name, svc);
|
|
2510
2460
|
recordEnv({
|
|
2511
2461
|
op: "startService",
|
|
2512
2462
|
service: svc.name,
|
|
@@ -2529,10 +2479,15 @@ async function startRuntimeService(spec) {
|
|
|
2529
2479
|
}
|
|
2530
2480
|
/** Implementation behind `ctx.stopService`. Removes the container and drops
|
|
2531
2481
|
* it from the runtime registry. No-op (rc ignored) if it's already gone. */
|
|
2532
|
-
|
|
2482
|
+
function stopRuntimeService(name) {
|
|
2483
|
+
return SERVICE_OPERATIONS.run(name, () => stopRuntimeServiceUnlocked(name));
|
|
2484
|
+
}
|
|
2485
|
+
async function stopRuntimeServiceUnlocked(name) {
|
|
2533
2486
|
const t0 = Date.now();
|
|
2534
2487
|
const resv = reserveEvent();
|
|
2535
2488
|
const svc = RUNTIME_SERVICES.get(name);
|
|
2489
|
+
await SERVICE_DNS.service(name, null);
|
|
2490
|
+
PROXY_IP_CACHE.delete(name);
|
|
2536
2491
|
// Freeze log production before reading, while Docker still owns the logs.
|
|
2537
2492
|
// Keep force-stop semantics; removing first destroys the only copy.
|
|
2538
2493
|
await docker(["kill", name], 30_000);
|
|
@@ -2545,7 +2500,7 @@ async function stopRuntimeService(name) {
|
|
|
2545
2500
|
await docker(["rm", "-f", name], 30_000);
|
|
2546
2501
|
RUNTIME_SERVICES.delete(name);
|
|
2547
2502
|
for (const entry of svc?.tls ?? [])
|
|
2548
|
-
await unbindRuntimeTls(entry.hostname);
|
|
2503
|
+
await unbindRuntimeTls(entry.hostname, name);
|
|
2549
2504
|
recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
|
|
2550
2505
|
}
|
|
2551
2506
|
/** The runtime environment-control handle handed to fakes (3rd handler arg /
|
|
@@ -2835,10 +2790,6 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2835
2790
|
}
|
|
2836
2791
|
});
|
|
2837
2792
|
await ensureStickyBuildHeadroom();
|
|
2838
|
-
// Containers now have IPs — re-seed so service-targeted wildcards (e.g.
|
|
2839
|
-
// k3s ingressDomains → the cluster container) resolve. Captured into the
|
|
2840
|
-
// warm template, so warm starts inherit the resolved entries.
|
|
2841
|
-
await seedNamesRegistry({ servicesUp: true });
|
|
2842
2793
|
// Browser pre-warm DISABLED (2026-06-08). We used to pre-open one view
|
|
2843
2794
|
// into the pool (browser.ts VIEW_POOL) here so every fork inherited a
|
|
2844
2795
|
// live renderer and the first ctx.browser() skipped the ~1.2-1.5s spawn.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const DNS_SOCKET: string;
|
|
2
|
+
export declare const DNS_REGISTRY_PATH: string;
|
|
3
|
+
export declare const MAX_REGISTRY_BYTES: number;
|
|
4
|
+
export interface DnsRegistry {
|
|
5
|
+
version: 1;
|
|
6
|
+
/** Opaque transaction ID, echoed in the acknowledgement. */
|
|
7
|
+
generation: string;
|
|
8
|
+
/** null reserves an unavailable name: it must never escape upstream. */
|
|
9
|
+
hosts: Record<string, string | null>;
|
|
10
|
+
wildcards: Array<{
|
|
11
|
+
suffix: string;
|
|
12
|
+
ip: string | null;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseDnsRegistry(text: string): DnsRegistry;
|
|
16
|
+
/** Compile once per update. undefined = unknown; null = owned but unavailable. */
|
|
17
|
+
export declare function compileDnsRegistry(doc: DnsRegistry): (name: string) => string | null | undefined;
|
|
18
|
+
/** Restore before binding DNS. A corrupt persisted registry is fatal, because
|
|
19
|
+
* silently forgetting owned domains could send fake traffic to real services. */
|
|
20
|
+
export declare function serveDnsRegistry(activate: (doc: DnsRegistry) => void): Promise<import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>>;
|
|
21
|
+
/** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
|
|
22
|
+
* fails bootstrap explicitly; never silently switch back to discovery. */
|
|
23
|
+
export declare function publishDnsRegistry(doc: DnsRegistry, socketPath?: string): Promise<void>;
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
export const DNS_SOCKET = process.env.SPECTEST_DNS_SOCKET ?? "/run/spectest-dns.sock";
|
|
8
|
+
export const DNS_REGISTRY_PATH = process.env.SPECTEST_DNS_REGISTRY ?? "/run/spectest-dns.json";
|
|
9
|
+
export const MAX_REGISTRY_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
function validName(name) {
|
|
11
|
+
return name.length > 0 && name.length <= 253 && name === name.toLowerCase() &&
|
|
12
|
+
name.split(".").every(label => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(label));
|
|
13
|
+
}
|
|
14
|
+
export function parseDnsRegistry(text) {
|
|
15
|
+
if (Buffer.byteLength(text) > MAX_REGISTRY_BYTES)
|
|
16
|
+
throw new Error("DNS registry too large");
|
|
17
|
+
const doc = JSON.parse(text);
|
|
18
|
+
if (!doc || doc.version !== 1 || typeof doc.generation !== "string" || !doc.generation.length || doc.generation.length > 128 ||
|
|
19
|
+
!doc.hosts || typeof doc.hosts !== "object" || Array.isArray(doc.hosts) || !Array.isArray(doc.wildcards))
|
|
20
|
+
throw new Error("Unsupported or malformed DNS registry (expected version 1)");
|
|
21
|
+
const address = (ip) => ip === null || (typeof ip === "string" && isIPv4(ip));
|
|
22
|
+
for (const [name, ip] of Object.entries(doc.hosts))
|
|
23
|
+
if (!validName(name) || !address(ip))
|
|
24
|
+
throw new Error(`Invalid DNS host: ${name}`);
|
|
25
|
+
const suffixes = new Set();
|
|
26
|
+
for (const w of doc.wildcards) {
|
|
27
|
+
if (!w || typeof w.suffix !== "string" || !w.suffix.startsWith(".") || !validName(w.suffix.slice(1)) || !address(w.ip) || suffixes.has(w.suffix))
|
|
28
|
+
throw new Error("Invalid or duplicate DNS wildcard");
|
|
29
|
+
suffixes.add(w.suffix);
|
|
30
|
+
}
|
|
31
|
+
return { version: 1, generation: doc.generation, hosts: doc.hosts, wildcards: doc.wildcards };
|
|
32
|
+
}
|
|
33
|
+
/** Compile once per update. undefined = unknown; null = owned but unavailable. */
|
|
34
|
+
export function compileDnsRegistry(doc) {
|
|
35
|
+
const hosts = new Map(Object.entries(doc.hosts));
|
|
36
|
+
const wildcards = new Map(doc.wildcards.map(w => [w.suffix, w.ip]));
|
|
37
|
+
return name => {
|
|
38
|
+
if (hosts.has(name))
|
|
39
|
+
return hosts.get(name);
|
|
40
|
+
for (let i = name.indexOf("."); i !== -1; i = name.indexOf(".", i + 1)) {
|
|
41
|
+
const suffix = name.slice(i);
|
|
42
|
+
if (wildcards.has(suffix))
|
|
43
|
+
return wildcards.get(suffix);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Restore before binding DNS. A corrupt persisted registry is fatal, because
|
|
49
|
+
* silently forgetting owned domains could send fake traffic to real services. */
|
|
50
|
+
export async function serveDnsRegistry(activate) {
|
|
51
|
+
try {
|
|
52
|
+
activate(parseDnsRegistry(await readFile(DNS_REGISTRY_PATH, "utf8")));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== "ENOENT")
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
await mkdir(dirname(DNS_SOCKET), { recursive: true });
|
|
59
|
+
await rm(DNS_SOCKET, { force: true });
|
|
60
|
+
let pending = Promise.resolve();
|
|
61
|
+
const server = createServer(async (req, res) => {
|
|
62
|
+
if (req.method !== "PUT" || req.url !== "/v1/registry") {
|
|
63
|
+
res.writeHead(404).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of req) {
|
|
70
|
+
size += chunk.length;
|
|
71
|
+
if (size > MAX_REGISTRY_BYTES) {
|
|
72
|
+
res.writeHead(413).end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
chunks.push(Buffer.from(chunk));
|
|
76
|
+
}
|
|
77
|
+
const doc = parseDnsRegistry(Buffer.concat(chunks).toString("utf8"));
|
|
78
|
+
const update = pending.then(async () => {
|
|
79
|
+
await mkdir(dirname(DNS_REGISTRY_PATH), { recursive: true });
|
|
80
|
+
const tmp = `${DNS_REGISTRY_PATH}.tmp`;
|
|
81
|
+
await writeFile(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
82
|
+
await rename(tmp, DNS_REGISTRY_PATH);
|
|
83
|
+
activate(doc);
|
|
84
|
+
});
|
|
85
|
+
pending = update.catch(() => { });
|
|
86
|
+
await update;
|
|
87
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ version: 1, generation: doc.generation }));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(String(error));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
server.requestTimeout = 10_000;
|
|
94
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(DNS_SOCKET, resolve); });
|
|
95
|
+
await chmod(DNS_SOCKET, 0o600);
|
|
96
|
+
return server;
|
|
97
|
+
}
|
|
98
|
+
/** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
|
|
99
|
+
* fails bootstrap explicitly; never silently switch back to discovery. */
|
|
100
|
+
export async function publishDnsRegistry(doc, socketPath = DNS_SOCKET) {
|
|
101
|
+
const body = JSON.stringify(doc);
|
|
102
|
+
parseDnsRegistry(body);
|
|
103
|
+
await new Promise((resolve, reject) => {
|
|
104
|
+
const req = request({ socketPath, method: "PUT", path: "/v1/registry", agent: false,
|
|
105
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } }, res => {
|
|
106
|
+
let text = "";
|
|
107
|
+
res.setEncoding("utf8");
|
|
108
|
+
res.on("data", chunk => { text += chunk; });
|
|
109
|
+
res.on("error", reject);
|
|
110
|
+
res.on("end", () => {
|
|
111
|
+
try {
|
|
112
|
+
if (res.statusCode !== 200)
|
|
113
|
+
throw new Error(`DNS update rejected (${res.statusCode}): ${text}`);
|
|
114
|
+
const ack = JSON.parse(text);
|
|
115
|
+
if (ack.version !== 1 || ack.generation !== doc.generation)
|
|
116
|
+
throw new Error("DNS acknowledgement mismatch");
|
|
117
|
+
resolve();
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
reject(error);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
const timer = setTimeout(() => req.destroy(new Error("DNS registry acknowledgement timed out")), 10_000);
|
|
125
|
+
req.on("close", () => clearTimeout(timer));
|
|
126
|
+
req.on("error", error => reject(new Error(`Cannot publish DNS registry; this SDK requires a platform with DNS registry v1: ${error.message}`)));
|
|
127
|
+
req.end(body);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -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
|
export interface RegistryDoc {
|
|
22
7
|
/** Exact name → IP. */
|
|
@@ -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
|
export const EMPTY_REGISTRY = { hosts: {}, wildcards: [] };
|
|
22
7
|
/** Serialise for the resolver. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DnsRegistry } from "./dns-registry.js";
|
|
2
|
+
export type NameTarget = {
|
|
3
|
+
service: string;
|
|
4
|
+
} | {
|
|
5
|
+
ip: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** SDK-owned desired state. Updates are serialized and publish the whole view.
|
|
8
|
+
* Service bindings retain their target, so replacement IPs and stops also
|
|
9
|
+
* update ctx.dnsName aliases. State and the publication queue fork with the VM. */
|
|
10
|
+
export declare class ServiceDns {
|
|
11
|
+
private publish;
|
|
12
|
+
private services;
|
|
13
|
+
private bindings;
|
|
14
|
+
private pending;
|
|
15
|
+
constructor(publish?: (doc: DnsRegistry) => Promise<void>);
|
|
16
|
+
private update;
|
|
17
|
+
private document;
|
|
18
|
+
address(name: string): string | null;
|
|
19
|
+
reset(services: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
aliases: string[];
|
|
22
|
+
}>, bindings: Array<{
|
|
23
|
+
name: string;
|
|
24
|
+
target: NameTarget;
|
|
25
|
+
}>): Promise<void>;
|
|
26
|
+
service(name: string, ip: string | null, aliases?: string[]): Promise<void>;
|
|
27
|
+
bind(name: string, target: NameTarget, owner?: string): Promise<void>;
|
|
28
|
+
retire(name: string, owner: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Serialize same-name start/stop operations, while different services can
|
|
31
|
+
* start concurrently. Prevents stale teardown deleting a replacement. */
|
|
32
|
+
export declare class ServiceOperations {
|
|
33
|
+
private pending;
|
|
34
|
+
run<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { publishDnsRegistry, parseDnsRegistry } from "./dns-registry.js";
|
|
3
|
+
/** SDK-owned desired state. Updates are serialized and publish the whole view.
|
|
4
|
+
* Service bindings retain their target, so replacement IPs and stops also
|
|
5
|
+
* update ctx.dnsName aliases. State and the publication queue fork with the VM. */
|
|
6
|
+
export class ServiceDns {
|
|
7
|
+
publish;
|
|
8
|
+
services = new Map();
|
|
9
|
+
bindings = new Map();
|
|
10
|
+
pending = Promise.resolve();
|
|
11
|
+
constructor(publish = publishDnsRegistry) {
|
|
12
|
+
this.publish = publish;
|
|
13
|
+
}
|
|
14
|
+
update(change) {
|
|
15
|
+
const next = this.pending.then(async () => {
|
|
16
|
+
const services = this.services;
|
|
17
|
+
const bindings = this.bindings;
|
|
18
|
+
this.services = new Map(services);
|
|
19
|
+
this.bindings = new Map(bindings);
|
|
20
|
+
let doc;
|
|
21
|
+
try {
|
|
22
|
+
change();
|
|
23
|
+
doc = parseDnsRegistry(JSON.stringify(this.document()));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
this.services = services;
|
|
27
|
+
this.bindings = bindings;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
await this.publish(doc);
|
|
31
|
+
});
|
|
32
|
+
// Preserve desired state after failure; the caller gets an error and the
|
|
33
|
+
// next full publication repairs an uncertain/lost acknowledgement.
|
|
34
|
+
this.pending = next.catch(() => { });
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
document() {
|
|
38
|
+
const hosts = Object.create(null);
|
|
39
|
+
const suffixes = new Map();
|
|
40
|
+
const put = (name, ip) => {
|
|
41
|
+
if (name.startsWith("*."))
|
|
42
|
+
suffixes.set(name.slice(1), ip);
|
|
43
|
+
else
|
|
44
|
+
hosts[name] = ip;
|
|
45
|
+
};
|
|
46
|
+
for (const [name, svc] of this.services)
|
|
47
|
+
for (const alias of [name, `${name}.internal`, ...svc.aliases])
|
|
48
|
+
put(alias.toLowerCase(), svc.ip);
|
|
49
|
+
for (const [name, { target }] of this.bindings)
|
|
50
|
+
put(name, "service" in target ? this.services.get(target.service)?.ip ?? null : target.ip);
|
|
51
|
+
return { version: 1, generation: randomUUID(), hosts, wildcards: [...suffixes].map(([suffix, ip]) => ({ suffix, ip })) };
|
|
52
|
+
}
|
|
53
|
+
address(name) { return this.services.get(name)?.ip ?? null; }
|
|
54
|
+
reset(services, bindings) {
|
|
55
|
+
return this.update(() => {
|
|
56
|
+
this.services = new Map(services.map(svc => [svc.name, { aliases: svc.aliases, ip: null }]));
|
|
57
|
+
this.bindings = new Map(bindings.map(b => [b.name.toLowerCase(), { target: b.target, owner: "static" }]));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
service(name, ip, aliases) {
|
|
61
|
+
return this.update(() => {
|
|
62
|
+
const previous = this.services.get(name);
|
|
63
|
+
// Retire removed aliases as owned/unavailable as well.
|
|
64
|
+
for (const old of previous?.aliases ?? []) {
|
|
65
|
+
if (aliases && !aliases.includes(old) && !this.bindings.has(old))
|
|
66
|
+
this.bindings.set(old, { target: { ip: null }, owner: `retired:${name}` });
|
|
67
|
+
}
|
|
68
|
+
for (const alias of aliases ?? []) {
|
|
69
|
+
if (this.bindings.get(alias)?.owner === `retired:${name}`)
|
|
70
|
+
this.bindings.delete(alias);
|
|
71
|
+
}
|
|
72
|
+
this.services.set(name, { ip, aliases: aliases ?? previous?.aliases ?? [] });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
bind(name, target, owner = "explicit") {
|
|
76
|
+
return this.update(() => { this.bindings.set(name.toLowerCase(), { target, owner }); });
|
|
77
|
+
}
|
|
78
|
+
retire(name, owner) {
|
|
79
|
+
return this.update(() => {
|
|
80
|
+
const binding = this.bindings.get(name.toLowerCase());
|
|
81
|
+
if (binding?.owner === owner)
|
|
82
|
+
this.bindings.set(name.toLowerCase(), { target: { ip: null }, owner });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Serialize same-name start/stop operations, while different services can
|
|
87
|
+
* start concurrently. Prevents stale teardown deleting a replacement. */
|
|
88
|
+
export class ServiceOperations {
|
|
89
|
+
pending = new Map();
|
|
90
|
+
run(name, fn) {
|
|
91
|
+
const next = (this.pending.get(name) ?? Promise.resolve()).catch(() => { }).then(fn);
|
|
92
|
+
this.pending.set(name, next);
|
|
93
|
+
void next.finally(() => { if (this.pending.get(name) === next)
|
|
94
|
+
this.pending.delete(name); }).catch(() => { });
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
}
|