@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/src/redis.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// `RedisClient` — a drop-in for `Bun.RedisClient` (`Bun.redis`) that records
|
|
2
|
+
// every command on the test event log and returns its reply inspect-wrapped,
|
|
3
|
+
// so `expect(await client.get("k"))` links the assertion back to the command in
|
|
4
|
+
// the timeline (same provenance mechanism as the wrapped `fetch` / `SQL`). Swap
|
|
5
|
+
// `new Bun.RedisClient(url)` → `new RedisClient(url)` and nothing else changes
|
|
6
|
+
// at the call site; replies come back `Wrapped<T>` (that wrapper is what carries
|
|
7
|
+
// provenance), so `.unwrap()` before a `===` or before feeding a value onward.
|
|
8
|
+
//
|
|
9
|
+
// Reach for it in service `helpers` when you stand up a redis/valkey yourself,
|
|
10
|
+
// or directly in a test against a connection string a fake handed back.
|
|
11
|
+
|
|
12
|
+
import { recordRedis, reserveEvent, safeSerialize } from "./recorder.js";
|
|
13
|
+
import { wrap } from "./inspect.js";
|
|
14
|
+
import type { Wrapped } from "./inspect.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Structural type for the parts of `Bun.RedisClient` we use, declared locally
|
|
18
|
+
* so user projects don't need `@types/bun`. The real implementation comes from
|
|
19
|
+
* Bun at runtime via `globalThis.Bun.RedisClient`. Commands resolve to their
|
|
20
|
+
* reply; lifecycle methods (`connect`/`close`/`subscribe`/…) are passed through
|
|
21
|
+
* un-instrumented.
|
|
22
|
+
*/
|
|
23
|
+
interface RawRedisClient {
|
|
24
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
25
|
+
close?(): void;
|
|
26
|
+
[method: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The instrumented Redis surface — same shape as `Bun.RedisClient`, but every
|
|
30
|
+
* command resolves to a {@link Wrapped} reply recorded as a `redis` event.
|
|
31
|
+
* Indexable so every Bun command method is reachable without re-declaring the
|
|
32
|
+
* whole (large) surface; the common ones are typed for ergonomics. */
|
|
33
|
+
export interface RedisClientLike {
|
|
34
|
+
get(key: string): Promise<Wrapped<string | null>>;
|
|
35
|
+
set(key: string, value: string): Promise<Wrapped<string>>;
|
|
36
|
+
del(...keys: string[]): Promise<Wrapped<number>>;
|
|
37
|
+
exists(...keys: string[]): Promise<Wrapped<number>>;
|
|
38
|
+
incr(key: string): Promise<Wrapped<number>>;
|
|
39
|
+
hgetall(key: string): Promise<Wrapped<Record<string, string>>>;
|
|
40
|
+
/** Run an arbitrary command — `client.send("SET", ["k", "v"])`. */
|
|
41
|
+
send(command: string, args: string[]): Promise<Wrapped<unknown>>;
|
|
42
|
+
close(): void;
|
|
43
|
+
[method: string]: (...args: any[]) => any;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options accepted by the {@link RedisClient} constructor beyond Bun's own. */
|
|
47
|
+
export interface RedisOptions {
|
|
48
|
+
/** Label shown on each recorded `redis` event (the timeline step's tag).
|
|
49
|
+
* Defaults to the connection URL's host (usually the service name). */
|
|
50
|
+
label?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BunGlobal {
|
|
54
|
+
RedisClient: new (url?: string, options?: unknown) => RawRedisClient;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RedisConstructor {
|
|
58
|
+
new (url?: string, opts?: RedisOptions): RedisClientLike;
|
|
59
|
+
(url?: string, opts?: RedisOptions): RedisClientLike;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Long-lived / connection-management methods that don't map to a single
|
|
63
|
+
// request→reply step — passed straight through, never recorded.
|
|
64
|
+
const REDIS_PASSTHROUGH = new Set([
|
|
65
|
+
"connect",
|
|
66
|
+
"close",
|
|
67
|
+
"disconnect",
|
|
68
|
+
"duplicate",
|
|
69
|
+
"subscribe",
|
|
70
|
+
"unsubscribe",
|
|
71
|
+
"psubscribe",
|
|
72
|
+
"punsubscribe",
|
|
73
|
+
"ssubscribe",
|
|
74
|
+
"sunsubscribe",
|
|
75
|
+
"onclose",
|
|
76
|
+
"ref",
|
|
77
|
+
"unref",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const MAX_RESULT_BYTES = 2048;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Open an instrumented Redis client against `url` (default: Bun's own
|
|
84
|
+
* `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
|
|
85
|
+
* runtime — it runs inside the spectest daemon.
|
|
86
|
+
*/
|
|
87
|
+
export const RedisClient = function RedisClient(
|
|
88
|
+
url?: string,
|
|
89
|
+
opts?: RedisOptions,
|
|
90
|
+
): RedisClientLike {
|
|
91
|
+
const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
|
|
92
|
+
if (!bun?.RedisClient) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"RedisClient requires the Bun runtime (Bun >= 1.2.9) — it runs inside the spectest daemon.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const raw = url !== undefined ? new bun.RedisClient(url, opts) : new bun.RedisClient();
|
|
98
|
+
return instrumentRedis(raw, opts?.label ?? hostLabel(url));
|
|
99
|
+
} as unknown as RedisConstructor;
|
|
100
|
+
|
|
101
|
+
function hostLabel(url?: string): string {
|
|
102
|
+
if (!url) return "redis";
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).hostname || "redis";
|
|
105
|
+
} catch {
|
|
106
|
+
return "redis";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
|
|
112
|
+
* `redis` event when its promise settles and resolves to a {@link Wrapped}
|
|
113
|
+
* reply. Lifecycle methods and non-function properties pass through.
|
|
114
|
+
*
|
|
115
|
+
* Exported so a client built another way can opt into the same instrumentation.
|
|
116
|
+
*/
|
|
117
|
+
export function instrumentRedis(raw: RawRedisClient, label: string): RedisClientLike {
|
|
118
|
+
const run = (
|
|
119
|
+
command: string,
|
|
120
|
+
key: string | undefined,
|
|
121
|
+
extra: unknown[],
|
|
122
|
+
invoke: () => unknown,
|
|
123
|
+
): Promise<unknown> => {
|
|
124
|
+
const started = Date.now();
|
|
125
|
+
const resv = reserveEvent();
|
|
126
|
+
return Promise.resolve(invoke()).then(
|
|
127
|
+
(value) => {
|
|
128
|
+
const { preview, truncated } = capValue(value);
|
|
129
|
+
const seq = recordRedis(
|
|
130
|
+
{
|
|
131
|
+
service: label,
|
|
132
|
+
command,
|
|
133
|
+
key,
|
|
134
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
135
|
+
result: preview,
|
|
136
|
+
resultTruncated: truncated,
|
|
137
|
+
durationMs: Date.now() - started,
|
|
138
|
+
},
|
|
139
|
+
resv,
|
|
140
|
+
);
|
|
141
|
+
return wrap(value, seq);
|
|
142
|
+
},
|
|
143
|
+
(err) => {
|
|
144
|
+
const e = err as Error;
|
|
145
|
+
recordRedis(
|
|
146
|
+
{
|
|
147
|
+
service: label,
|
|
148
|
+
command,
|
|
149
|
+
key,
|
|
150
|
+
args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
|
|
151
|
+
durationMs: Date.now() - started,
|
|
152
|
+
error: e?.message ?? String(err),
|
|
153
|
+
},
|
|
154
|
+
resv,
|
|
155
|
+
);
|
|
156
|
+
throw err;
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handler: ProxyHandler<RawRedisClient> = {
|
|
162
|
+
get(target, prop, receiver) {
|
|
163
|
+
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
164
|
+
const v = Reflect.get(target, prop, receiver);
|
|
165
|
+
if (typeof v !== "function") return v;
|
|
166
|
+
const fn = v as (...a: unknown[]) => unknown;
|
|
167
|
+
if (REDIS_PASSTHROUGH.has(prop)) return fn.bind(target);
|
|
168
|
+
// `send(command, args)` carries the real command in its first arg.
|
|
169
|
+
if (prop === "send") {
|
|
170
|
+
return (command: string, args: string[] = []) =>
|
|
171
|
+
run(String(command).toUpperCase(), args[0], args.slice(1), () =>
|
|
172
|
+
fn.call(target, command, args),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return (...args: unknown[]) =>
|
|
176
|
+
run(
|
|
177
|
+
prop.toUpperCase(),
|
|
178
|
+
typeof args[0] === "string" ? args[0] : undefined,
|
|
179
|
+
args.slice(1),
|
|
180
|
+
() => fn.apply(target, args),
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
return new Proxy(raw, handler) as unknown as RedisClientLike;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Best-effort JSON-safe preview of a reply, capped at {@link MAX_RESULT_BYTES}. */
|
|
188
|
+
function capValue(value: unknown): { preview?: unknown; truncated?: boolean } {
|
|
189
|
+
if (value === null || value === undefined) return {};
|
|
190
|
+
const safe = safeSerialize(value);
|
|
191
|
+
const json = (() => {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.stringify(safe);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
if (json !== undefined && json.length > MAX_RESULT_BYTES) {
|
|
199
|
+
return { preview: `${json.slice(0, MAX_RESULT_BYTES)}…`, truncated: true };
|
|
200
|
+
}
|
|
201
|
+
return { preview: safe };
|
|
202
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Replay-bundle encoding: a case's rrweb sessions → one gzipped JSON blob
|
|
2
|
+
// with large inlined assets deduplicated.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: recordings must NOT ride the `/run` response. The
|
|
5
|
+
// vm-agent proxies daemon HTTP through a 16 MB response cap, and a
|
|
6
|
+
// browser-heavy case blows straight past it — rrweb's `inlineImages`
|
|
7
|
+
// re-inlines every image into every full snapshot, and animation-heavy
|
|
8
|
+
// apps (e.g. Reanimated's per-frame inline-style writes) emit mutation
|
|
9
|
+
// streams in the tens of MB. The daemon parks the encoded bundle in
|
|
10
|
+
// memory instead and the control plane pulls it in chunks via
|
|
11
|
+
// `POST /replay-chunk` (see daemon.ts), each chunk sized under the cap.
|
|
12
|
+
//
|
|
13
|
+
// Inside the bundle, every large base64 `data:` URI is extracted into an
|
|
14
|
+
// `assets` table keyed by content hash and replaced with a
|
|
15
|
+
// `data:x-spectest-asset/<hash>` token — the N re-inlined copies of one
|
|
16
|
+
// image collapse to one asset plus N short tokens. The dashboard's case
|
|
17
|
+
// viewer substitutes the real URI back into the raw JSON text before
|
|
18
|
+
// parsing (mirror of this file's token format lives in
|
|
19
|
+
// `web/case_viewer.rs::rehydrateAssets`). Only base64 URIs are extracted:
|
|
20
|
+
// their charset needs no JSON escaping, so plain text substitution in
|
|
21
|
+
// either direction cannot corrupt the surrounding JSON document.
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { gzipSync } from "node:zlib";
|
|
25
|
+
|
|
26
|
+
/** Minimum data: URI payload length (chars) worth extracting. Below this
|
|
27
|
+
* the token + asset-table overhead rivals the URI itself. */
|
|
28
|
+
const ASSET_MIN_BASE64_CHARS = 1024;
|
|
29
|
+
|
|
30
|
+
/** Base64 `data:` URIs, ≥1 KiB of payload. Mediatype + parameters are
|
|
31
|
+
* restricted to charsets that never need JSON escaping — a match taken
|
|
32
|
+
* from serialized JSON text is therefore byte-identical to the logical
|
|
33
|
+
* string value it sits inside. */
|
|
34
|
+
const ASSET_RX = new RegExp(
|
|
35
|
+
"data:[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+(?:;[A-Za-z0-9.=+-]+)*;base64," +
|
|
36
|
+
`[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`,
|
|
37
|
+
"g",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Token an extracted asset is replaced with. Still a syntactically valid
|
|
41
|
+
* data: URI so anything that merely carries it along stays well-formed;
|
|
42
|
+
* it never renders (the viewer rehydrates before the events reach rrweb). */
|
|
43
|
+
export function assetToken(hash: string): string {
|
|
44
|
+
return `data:x-spectest-asset/${hash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialize `sessions` and pull every large base64 data: URI out into a
|
|
49
|
+
* content-addressed asset table. Returns the deduplicated sessions as a
|
|
50
|
+
* JSON *string* (already serialized — splice it into the bundle document
|
|
51
|
+
* verbatim) plus the asset table.
|
|
52
|
+
*/
|
|
53
|
+
export function extractReplayAssets(sessions: unknown): {
|
|
54
|
+
sessionsJson: string;
|
|
55
|
+
assets: Record<string, string>;
|
|
56
|
+
} {
|
|
57
|
+
const assets: Record<string, string> = {};
|
|
58
|
+
const raw = JSON.stringify(sessions);
|
|
59
|
+
const sessionsJson = raw.replace(ASSET_RX, (uri) => {
|
|
60
|
+
// 16 hex chars (64 bits) of SHA-256: collision-safe at replay-asset
|
|
61
|
+
// scale (dozens of assets per case), and short enough that a token is
|
|
62
|
+
// negligible next to the URI it replaces.
|
|
63
|
+
const hash = createHash("sha256").update(uri).digest("hex").slice(0, 16);
|
|
64
|
+
assets[hash] = uri;
|
|
65
|
+
return assetToken(hash);
|
|
66
|
+
});
|
|
67
|
+
return { sessionsJson, assets };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Encode a case's sessions as the gzipped replay-bundle document the
|
|
72
|
+
* control plane archives to S3 verbatim:
|
|
73
|
+
* `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
|
|
74
|
+
* shape of `storage.rs::CaseReplayBundle`.
|
|
75
|
+
*/
|
|
76
|
+
export function encodeReplayBundle(caseId: string, sessions: unknown): Buffer {
|
|
77
|
+
const { sessionsJson, assets } = extractReplayAssets(sessions);
|
|
78
|
+
const doc = `{"caseId":${JSON.stringify(caseId)},"sessions":${sessionsJson},"assets":${JSON.stringify(assets)}}`;
|
|
79
|
+
return gzipSync(Buffer.from(doc));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
|
|
83
|
+
* 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
|
|
84
|
+
* with headroom. The control plane's `fetch_replay_bundle` requests
|
|
85
|
+
* exactly this much per round trip. */
|
|
86
|
+
export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
|
|
90
|
+
* offset/limit clamped to sane values (never more than
|
|
91
|
+
* `REPLAY_CHUNK_MAX_BYTES` raw bytes). An out-of-range offset yields an
|
|
92
|
+
* empty `b64`, which the control plane treats as an error — it only ever
|
|
93
|
+
* asks for offsets below the advertised total.
|
|
94
|
+
*/
|
|
95
|
+
export function replayChunk(
|
|
96
|
+
gz: Buffer,
|
|
97
|
+
offsetIn: unknown,
|
|
98
|
+
limitIn: unknown,
|
|
99
|
+
): { total: number; offset: number; b64: string } {
|
|
100
|
+
const offset = Math.max(0, Math.floor(Number(offsetIn) || 0));
|
|
101
|
+
const wanted = Math.floor(Number(limitIn) || REPLAY_CHUNK_MAX_BYTES);
|
|
102
|
+
const limit = Math.min(Math.max(1, wanted), REPLAY_CHUNK_MAX_BYTES);
|
|
103
|
+
return {
|
|
104
|
+
total: gz.length,
|
|
105
|
+
offset,
|
|
106
|
+
b64: gz.subarray(offset, offset + limit).toString("base64"),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// spectest-resolver: a tiny DNS server that mirrors Docker's embedded
|
|
2
|
+
// DNS, but from the VM host instead of from inside a container.
|
|
3
|
+
//
|
|
4
|
+
// Single-label lookups (no dots, e.g. `api`) hit the Docker socket by
|
|
5
|
+
// container name. Multi-label lookups (e.g. `api.stripe.com`) scan
|
|
6
|
+
// containers on `spectest-net` for one whose network aliases include the
|
|
7
|
+
// queried name — set via service `hostnames` in env.ts. Anything we
|
|
8
|
+
// can't resolve from Docker is forwarded to an upstream DNS server
|
|
9
|
+
// (default 1.1.1.1). The daemon and any test code running on the VM
|
|
10
|
+
// host can do `fetch("http://api:8000")` or `fetch("http://api.stripe.com")`
|
|
11
|
+
// exactly like a container on the same bridge would.
|
|
12
|
+
//
|
|
13
|
+
// Designed to be tiny: one process, no caching beyond DNS TTL (5s), no
|
|
14
|
+
// docker event subscription — every query asks Docker fresh. The latency
|
|
15
|
+
// is ~1ms per query and the failure mode (Docker socket down) is rare
|
|
16
|
+
// enough that simplicity wins.
|
|
17
|
+
//
|
|
18
|
+
// Listens on two *specific* addresses (never 0.0.0.0 — see the bind logic
|
|
19
|
+
// at the bottom): 127.0.0.53, the nameserver the VM's /etc/resolv.conf
|
|
20
|
+
// points at (daemon, host-side test code, and dockerd's embedded DNS as
|
|
21
|
+
// its ExtServer all use it), and the spectest-net bridge gateway IP,
|
|
22
|
+
// which is how an in-cluster k3s pod reaches us (k3s CoreDNS is pointed
|
|
23
|
+
// there; see components/k3s.ts). Binding the specific gateway IP rather
|
|
24
|
+
// than 0.0.0.0 matters: a 0.0.0.0 socket replies to a 127.0.0.53 query
|
|
25
|
+
// from a kernel-chosen source (127.0.0.1), and glibc's resolver drops the
|
|
26
|
+
// answer because its source doesn't match the queried server.
|
|
27
|
+
|
|
28
|
+
import { createSocket, type RemoteInfo } from "node:dgram";
|
|
29
|
+
import { request as httpRequest } from "node:http";
|
|
30
|
+
import { readFile, stat } from "node:fs/promises";
|
|
31
|
+
import { lookup } from "./harness/names-registry.js";
|
|
32
|
+
import * as dnsPacket from "dns-packet";
|
|
33
|
+
|
|
34
|
+
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
35
|
+
const DOCKER_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
|
|
36
|
+
const UPSTREAM_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
37
|
+
const UPSTREAM_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
38
|
+
// Primary listen address: the loopback nameserver in the VM's
|
|
39
|
+
// /etc/resolv.conf. Always bound at startup.
|
|
40
|
+
const LISTEN_ADDR = process.env.SPECTEST_RESOLVER_ADDR ?? "127.0.0.53";
|
|
41
|
+
const LISTEN_PORT = Number(process.env.SPECTEST_RESOLVER_PORT ?? "53");
|
|
42
|
+
const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
|
|
43
|
+
/** Path the harness writes when it brings fakes up. */
|
|
44
|
+
const FAKES_REGISTRY_PATH =
|
|
45
|
+
process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
|
|
46
|
+
|
|
47
|
+
function dockerGet(path: string): Promise<unknown | null> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const req = httpRequest(
|
|
50
|
+
{ socketPath: DOCKER_SOCKET, path, method: "GET" },
|
|
51
|
+
(res) => {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
res.resume();
|
|
54
|
+
resolve(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const chunks: Buffer[] = [];
|
|
58
|
+
res.on("data", (c) => chunks.push(c));
|
|
59
|
+
res.on("end", () => {
|
|
60
|
+
try {
|
|
61
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
62
|
+
} catch {
|
|
63
|
+
resolve(null);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
res.on("error", () => resolve(null));
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
req.on("error", () => resolve(null));
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Single-label lookup: ask Docker for the container with this exact name
|
|
76
|
+
* and read its IP on spectest-net. Cheap — one container fetch.
|
|
77
|
+
*/
|
|
78
|
+
async function dockerLookupByName(name: string): Promise<string | null> {
|
|
79
|
+
const body = (await dockerGet(`/containers/${encodeURIComponent(name)}/json`)) as
|
|
80
|
+
| { NetworkSettings?: { Networks?: Record<string, { IPAddress?: string }> } }
|
|
81
|
+
| null;
|
|
82
|
+
const ip = body?.NetworkSettings?.Networks?.[NETWORK_NAME]?.IPAddress;
|
|
83
|
+
return ip && ip.length > 0 ? ip : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Multi-label lookup: scan containers on spectest-net for one whose
|
|
88
|
+
* Aliases include `name`. Aliases are set with `--network-alias` at
|
|
89
|
+
* `docker run` time (see runContainer in daemon.ts).
|
|
90
|
+
*
|
|
91
|
+
* `/containers/json` returns `Aliases: null` in many Docker versions
|
|
92
|
+
* even when --network-alias was set, so we use it only to enumerate
|
|
93
|
+
* container IDs and then inspect each one — inspect reliably surfaces
|
|
94
|
+
* the alias list. The fan-out is bounded by service count (~handful),
|
|
95
|
+
* so the extra roundtrips are cheap.
|
|
96
|
+
*/
|
|
97
|
+
async function dockerLookupByAlias(name: string): Promise<string | null> {
|
|
98
|
+
const filters = encodeURIComponent(JSON.stringify({ network: [NETWORK_NAME] }));
|
|
99
|
+
const list = (await dockerGet(`/containers/json?filters=${filters}`)) as
|
|
100
|
+
| Array<{ Id?: string }>
|
|
101
|
+
| null;
|
|
102
|
+
if (!Array.isArray(list)) return null;
|
|
103
|
+
for (const c of list) {
|
|
104
|
+
if (!c.Id) continue;
|
|
105
|
+
const info = (await dockerGet(`/containers/${c.Id}/json`)) as
|
|
106
|
+
| { NetworkSettings?: { Networks?: Record<string, { Aliases?: string[]; IPAddress?: string }> } }
|
|
107
|
+
| null;
|
|
108
|
+
const net = info?.NetworkSettings?.Networks?.[NETWORK_NAME];
|
|
109
|
+
if (!net) continue;
|
|
110
|
+
if ((net.Aliases ?? []).includes(name) && net.IPAddress) {
|
|
111
|
+
return net.IPAddress;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Cache the names registry file by mtime so we re-read only when the
|
|
118
|
+
// daemon has rewritten it (at /bootstrap, and live whenever a test calls
|
|
119
|
+
// ctx.dnsName). A miss returns empty tables — the registry is optional,
|
|
120
|
+
// and the daemon may not have written the file yet on a fresh boot.
|
|
121
|
+
//
|
|
122
|
+
// `hosts` are exact hostname → IP (fakes, TLS proxies, dnsName(→ingress),
|
|
123
|
+
// dynamic exact registrations). `wildcards` are suffix → IP, e.g.
|
|
124
|
+
// `*.example.com` stored as `{ suffix: ".example.com", ip }`; matched only
|
|
125
|
+
// after exact lookups (registry + docker) miss, so an exact name always
|
|
126
|
+
// wins over a wildcard.
|
|
127
|
+
interface RegistryCache {
|
|
128
|
+
mtimeMs: number;
|
|
129
|
+
hosts: Record<string, string>;
|
|
130
|
+
wildcards: Array<{ suffix: string; ip: string }>;
|
|
131
|
+
}
|
|
132
|
+
let registryCache: RegistryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
|
|
133
|
+
|
|
134
|
+
async function refreshRegistry(): Promise<void> {
|
|
135
|
+
try {
|
|
136
|
+
const st = await stat(FAKES_REGISTRY_PATH);
|
|
137
|
+
if (st.mtimeMs !== registryCache.mtimeMs) {
|
|
138
|
+
const raw = await readFile(FAKES_REGISTRY_PATH, "utf8");
|
|
139
|
+
const parsed = JSON.parse(raw) as {
|
|
140
|
+
hosts?: Record<string, string>;
|
|
141
|
+
wildcards?: Array<{ suffix: string; ip: string }>;
|
|
142
|
+
};
|
|
143
|
+
registryCache = {
|
|
144
|
+
mtimeMs: st.mtimeMs,
|
|
145
|
+
hosts: parsed.hosts ?? {},
|
|
146
|
+
wildcards: parsed.wildcards ?? [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// File missing or unreadable — treat as empty.
|
|
151
|
+
registryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Exact names-registry lookup. */
|
|
156
|
+
async function lookupFake(name: string): Promise<string | null> {
|
|
157
|
+
await refreshRegistry();
|
|
158
|
+
return registryCache.hosts[name] ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Wildcard suffix lookup — consulted only after an exact miss. The
|
|
162
|
+
* longest (most specific) matching suffix wins. */
|
|
163
|
+
async function lookupWildcard(name: string): Promise<string | null> {
|
|
164
|
+
await refreshRegistry();
|
|
165
|
+
// Same module the harness writes with, so the two halves of this file
|
|
166
|
+
// format cannot drift apart.
|
|
167
|
+
return lookup({ hosts: {}, wildcards: registryCache.wildcards }, name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function forwardUpstream(query: Buffer): Promise<Buffer | null> {
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
const sock = createSocket("udp4");
|
|
173
|
+
let done = false;
|
|
174
|
+
const finish = (b: Buffer | null) => {
|
|
175
|
+
if (done) return;
|
|
176
|
+
done = true;
|
|
177
|
+
try {
|
|
178
|
+
sock.close();
|
|
179
|
+
} catch {
|
|
180
|
+
// ignore
|
|
181
|
+
}
|
|
182
|
+
resolve(b);
|
|
183
|
+
};
|
|
184
|
+
sock.on("message", (msg) => finish(msg));
|
|
185
|
+
sock.on("error", () => finish(null));
|
|
186
|
+
sock.send(query, UPSTREAM_PORT, UPSTREAM_DNS, (err) => {
|
|
187
|
+
if (err) finish(null);
|
|
188
|
+
});
|
|
189
|
+
setTimeout(() => finish(null), 3_000);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function emptyAnswer(query: dnsPacket.Packet): Buffer {
|
|
194
|
+
return dnsPacket.encode({
|
|
195
|
+
type: "response",
|
|
196
|
+
id: query.id,
|
|
197
|
+
flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
|
|
198
|
+
questions: query.questions,
|
|
199
|
+
answers: [],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function aAnswer(query: dnsPacket.Packet, name: string, ip: string): Buffer {
|
|
204
|
+
return dnsPacket.encode({
|
|
205
|
+
type: "response",
|
|
206
|
+
id: query.id,
|
|
207
|
+
flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
|
|
208
|
+
questions: query.questions,
|
|
209
|
+
answers: [{ type: "A", name, ttl: TTL_SECONDS, data: ip }],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Shared query handler. Replies are sent back through the *same* socket
|
|
214
|
+
// the query arrived on so the reply's source address matches the address
|
|
215
|
+
// the client sent to — which is why each listener is bound to a specific
|
|
216
|
+
// IP rather than 0.0.0.0 (glibc drops answers whose source differs from
|
|
217
|
+
// the queried server).
|
|
218
|
+
async function handleQuery(
|
|
219
|
+
sock: ReturnType<typeof createSocket>,
|
|
220
|
+
msg: Buffer,
|
|
221
|
+
rinfo: RemoteInfo,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
let query: dnsPacket.Packet;
|
|
224
|
+
try {
|
|
225
|
+
query = dnsPacket.decode(msg);
|
|
226
|
+
} catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const q = query.questions?.[0];
|
|
230
|
+
if (q && (q.type === "A" || q.type === "AAAA")) {
|
|
231
|
+
const name = q.name.toLowerCase();
|
|
232
|
+
const isSingleLabel = !name.includes(".");
|
|
233
|
+
|
|
234
|
+
// Fakes win over docker — they're explicitly registered by the
|
|
235
|
+
// daemon and a fake's hostname (e.g. api.stripe.com) might collide
|
|
236
|
+
// with a real upstream we don't want to call.
|
|
237
|
+
const fakeIp = isSingleLabel ? null : await lookupFake(name);
|
|
238
|
+
const ip = fakeIp
|
|
239
|
+
?? (isSingleLabel
|
|
240
|
+
? await dockerLookupByName(name)
|
|
241
|
+
: await dockerLookupByAlias(name));
|
|
242
|
+
|
|
243
|
+
if (ip) {
|
|
244
|
+
// For AAAA we still answer empty — Docker bridges are IPv4 only, but
|
|
245
|
+
// we own this name so libc should fall back to A instead of chasing
|
|
246
|
+
// a stray upstream AAAA for the real public hostname.
|
|
247
|
+
const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
|
|
248
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isSingleLabel) {
|
|
253
|
+
// Bare hostname we don't own — reply NOERROR with no answers so the
|
|
254
|
+
// resolver moves on quickly. Forwarding bare hostnames upstream just
|
|
255
|
+
// causes timeouts.
|
|
256
|
+
sock.send(emptyAnswer(query), rinfo.port, rinfo.address);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Multi-label exact miss: try wildcard suffixes (e.g. *.example.com
|
|
260
|
+
// → the k3s cluster) before forwarding upstream. Exact lookups above
|
|
261
|
+
// always win, so a specific alias beats a covering wildcard.
|
|
262
|
+
const wildIp = await lookupWildcard(name);
|
|
263
|
+
if (wildIp) {
|
|
264
|
+
const resp = q.type === "A" ? aAnswer(query, q.name, wildIp) : emptyAnswer(query);
|
|
265
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Still nothing — fall through and forward to upstream below.
|
|
269
|
+
}
|
|
270
|
+
// Anything we don't own — forward.
|
|
271
|
+
const upstream = await forwardUpstream(msg);
|
|
272
|
+
if (upstream) {
|
|
273
|
+
sock.send(upstream, rinfo.port, rinfo.address);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Upstream unreachable; return SERVFAIL (rcode 2 in low 4 bits of flags).
|
|
277
|
+
const resp = dnsPacket.encode({
|
|
278
|
+
type: "response",
|
|
279
|
+
id: query.id,
|
|
280
|
+
flags: dnsPacket.RECURSION_DESIRED | 0x2,
|
|
281
|
+
questions: query.questions ?? [],
|
|
282
|
+
});
|
|
283
|
+
sock.send(resp, rinfo.port, rinfo.address);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Bind one UDP listener on `addr`, wired to the shared handler. A bind
|
|
287
|
+
* error on the primary loopback address is fatal (DNS is fully down);
|
|
288
|
+
* on the secondary bridge address it's logged and retried by the caller. */
|
|
289
|
+
function bindListener(addr: string, fatalOnError: boolean): Promise<boolean> {
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
const sock = createSocket("udp4");
|
|
292
|
+
sock.on("message", (msg: Buffer, rinfo: RemoteInfo) => {
|
|
293
|
+
void handleQuery(sock, msg, rinfo);
|
|
294
|
+
});
|
|
295
|
+
sock.on("error", (err) => {
|
|
296
|
+
// eslint-disable-next-line no-console
|
|
297
|
+
console.error(`[spectest-resolver] socket error on ${addr}:`, err);
|
|
298
|
+
if (fatalOnError) process.exit(1);
|
|
299
|
+
try {
|
|
300
|
+
sock.close();
|
|
301
|
+
} catch {
|
|
302
|
+
// ignore
|
|
303
|
+
}
|
|
304
|
+
resolve(false);
|
|
305
|
+
});
|
|
306
|
+
sock.bind(LISTEN_PORT, addr, () => {
|
|
307
|
+
// eslint-disable-next-line no-console
|
|
308
|
+
console.log(
|
|
309
|
+
`[spectest-resolver] listening on ${addr}:${LISTEN_PORT} (network=${NETWORK_NAME}, upstream=${UPSTREAM_DNS}:${UPSTREAM_PORT})`,
|
|
310
|
+
);
|
|
311
|
+
resolve(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Read the spectest-net bridge gateway IP from Docker — the address an
|
|
317
|
+
* in-cluster k3s pod (or any off-bridge client routed through the node)
|
|
318
|
+
* uses to reach the VM host. Null until the network exists. */
|
|
319
|
+
async function bridgeGatewayIp(): Promise<string | null> {
|
|
320
|
+
const net = (await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`)) as
|
|
321
|
+
| { IPAM?: { Config?: Array<{ Gateway?: string }> } }
|
|
322
|
+
| null;
|
|
323
|
+
const gw = net?.IPAM?.Config?.find((c) => c.Gateway)?.Gateway;
|
|
324
|
+
return gw && gw.length > 0 ? gw : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Primary listener: the loopback nameserver. Fatal if it can't bind.
|
|
328
|
+
void bindListener(LISTEN_ADDR, true);
|
|
329
|
+
|
|
330
|
+
// Secondary listener: the spectest-net bridge gateway, for k3s pods.
|
|
331
|
+
// The network is created by the daemon at /load — after this process
|
|
332
|
+
// starts and after it's captured into the base snapshot — so poll until
|
|
333
|
+
// the gateway appears, bind once, then stop. The gateway is stable for a
|
|
334
|
+
// VM's life (the daemon only creates the network when it's missing), so a
|
|
335
|
+
// single successful bind survives snapshot/restore/fork. Opt out with
|
|
336
|
+
// SPECTEST_RESOLVER_NO_BRIDGE=1.
|
|
337
|
+
if (process.env.SPECTEST_RESOLVER_NO_BRIDGE !== "1") {
|
|
338
|
+
let bound = false;
|
|
339
|
+
const poll = setInterval(async () => {
|
|
340
|
+
if (bound) return;
|
|
341
|
+
const gw = await bridgeGatewayIp().catch(() => null);
|
|
342
|
+
if (!gw) return;
|
|
343
|
+
bound = true;
|
|
344
|
+
if (!(await bindListener(gw, false))) bound = false; // retry on failure
|
|
345
|
+
}, 2_000);
|
|
346
|
+
// Don't keep the event loop alive solely for the poll timer.
|
|
347
|
+
poll.unref?.();
|
|
348
|
+
}
|