@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.
@@ -0,0 +1,267 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { createServer } from "node:http";
3
+ import { createConnection, createServer as createTcpServer } from "node:net";
4
+ import { request } from "node:http";
5
+ import { createSocket } from "node:dgram";
6
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { publishDnsRegistry } from "./harness/dns-registry";
10
+ import { ServiceDns } from "./harness/service-dns";
11
+ import * as packet from "dns-packet";
12
+
13
+ const cleanup: Array<() => Promise<void>> = [];
14
+ afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn(); });
15
+
16
+ async function fixture(initialRegistry?: unknown) {
17
+ const dir = await mkdtemp(join(tmpdir(), "spectest-dns-"));
18
+ if (initialRegistry) await writeFile(join(dir, "dns.json"), JSON.stringify(initialRegistry));
19
+ const paths: string[] = [];
20
+ let ip = "172.20.0.9";
21
+ let fail = false;
22
+ let failContainer = false;
23
+ const server = createServer((req, res) => {
24
+ paths.push(req.url!);
25
+ res.setHeader("Content-Type", "application/json");
26
+ if (fail) { res.writeHead(503).end("{}"); return; }
27
+ if (req.url === "/networks/spectest-net") {
28
+ res.end(JSON.stringify({ Containers: Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`c${i}`, {}])) }));
29
+ } else if (req.url?.startsWith("/containers/") && req.url !== "/containers/json") {
30
+ const id = req.url.split("/")[2];
31
+ if (id === "c2" && failContainer) { res.writeHead(503).end("{}"); return; }
32
+ res.end(JSON.stringify({ State: { Running: id !== "c0" }, NetworkSettings: { Networks: {
33
+ "spectest-net": { IPAddress: ip, Aliases: [id === "c0" ? "stopped.test" : `${id}.test`] },
34
+ } } }));
35
+ } else { res.writeHead(404).end("{}"); }
36
+ });
37
+ const socket = join(dir, "docker.sock");
38
+ await new Promise<void>(resolve => server.listen(socket, resolve));
39
+ await writeFile(join(dir, "registry.json"), JSON.stringify({ hosts: { "explicit.test": "172.20.0.7" }, wildcards: [{ suffix: ".wild.test", ip: "172.20.0.8" }] }));
40
+ let upstreamQueries = 0;
41
+ let upstreamDelay = 0;
42
+ const upstream = createSocket("udp4");
43
+ upstream.on("message", (msg, remote) => {
44
+ upstreamQueries++;
45
+ const q = packet.decode(msg);
46
+ setTimeout(() => upstream.send(packet.encode({ type: "response", id: q.id, questions: q.questions, answers: [{ name: q.questions![0].name, type: "A", ttl: 1, data: "192.0.2.1" }] }), remote.port, remote.address), upstreamDelay);
47
+ });
48
+ await new Promise<void>(resolve => upstream.bind(0, "127.0.0.1", resolve));
49
+ const tcpUpstream = createTcpServer(socket => {
50
+ let data = Buffer.alloc(0);
51
+ socket.on("error", () => socket.destroy());
52
+ socket.on("data", chunk => {
53
+ data = Buffer.concat([data, Buffer.from(chunk)]);
54
+ if (data.length < 2 || data.length < 2 + data.readUInt16BE(0)) return;
55
+ const q = packet.decode(data.subarray(2));
56
+ const answer = packet.encode({ type: "response", id: q.id, questions: q.questions,
57
+ answers: [{ name: q.questions![0].name, type: "A", ttl: 1, data: "192.0.2.2" }] });
58
+ const prefix = Buffer.alloc(2); prefix.writeUInt16BE(answer.length);
59
+ socket.end(Buffer.concat([prefix, answer]));
60
+ });
61
+ });
62
+ await new Promise<void>(resolve => tcpUpstream.listen(upstream.address().port, "127.0.0.1", resolve));
63
+ const reservation = createSocket("udp4");
64
+ await new Promise<void>(resolve => reservation.bind(0, "127.0.0.1", resolve));
65
+ const port = reservation.address().port;
66
+ await new Promise<void>(resolve => reservation.close(resolve));
67
+ const spawn = () => Bun.spawn([process.execPath, join(import.meta.dir, "resolver.ts")], {
68
+ env: { ...process.env, SPECTEST_DNS_SOCKET: join(dir, "dns.sock"), SPECTEST_DNS_REGISTRY: join(dir, "dns.json"), DOCKER_SOCKET: socket, SPECTEST_RESOLVER_ADDR: "127.0.0.1", SPECTEST_RESOLVER_PORT: `${port}`, SPECTEST_RESOLVER_NO_BRIDGE: "1", SPECTEST_FAKES_REGISTRY: join(dir, "registry.json"), SPECTEST_UPSTREAM_DNS: "127.0.0.1", SPECTEST_UPSTREAM_PORT: `${upstream.address().port}` },
69
+ stdout: "pipe", stderr: "pipe",
70
+ });
71
+ let child = spawn();
72
+ cleanup.push(async () => {
73
+ child.kill(); await child.exited;
74
+ upstream.close(); await new Promise<void>(resolve => tcpUpstream.close(() => resolve())); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
75
+ await rm(dir, { recursive: true, force: true });
76
+ });
77
+ const reader = child.stdout.getReader();
78
+ await reader.read(); reader.releaseLock();
79
+ async function query(name: string, type: packet.RecordType = "A") {
80
+ const sock = createSocket("udp4");
81
+ try {
82
+ return await new Promise<packet.Packet>((resolve, reject) => {
83
+ const timeout = setTimeout(() => reject(new Error(`DNS timeout: ${name}`)), 3000);
84
+ sock.once("message", msg => { clearTimeout(timeout); resolve(packet.decode(msg)); });
85
+ sock.once("error", err => { clearTimeout(timeout); reject(err); });
86
+ sock.send(packet.encode({ type: "query", id: 42, questions: [{ name, type }] }), port, "127.0.0.1");
87
+ });
88
+ } finally { sock.close(); }
89
+ }
90
+ async function publish(doc: unknown) {
91
+ return new Promise<{ status: number; body: string }>((resolve, reject) => {
92
+ const req = request({ socketPath: join(dir, "dns.sock"), method: "PUT", path: "/v1/registry", agent: false }, res => {
93
+ let body = ""; res.setEncoding("utf8"); res.on("data", chunk => { body += chunk; });
94
+ res.on("end", () => resolve({ status: res.statusCode!, body }));
95
+ });
96
+ req.on("error", reject); req.end(JSON.stringify(doc));
97
+ });
98
+ }
99
+ async function restart() {
100
+ child.kill(); await child.exited; child = spawn();
101
+ const reader = child.stdout.getReader(); await reader.read(); reader.releaseLock();
102
+ }
103
+ async function tcpQuery(name: string) {
104
+ const q = packet.encode({ type: "query", id: 81, questions: [{ name, type: "A" }] });
105
+ const size = Buffer.alloc(2); size.writeUInt16BE(q.length);
106
+ return new Promise<packet.Packet>((resolve, reject) => {
107
+ const socket = createConnection({ host: "127.0.0.1", port });
108
+ let data = Buffer.alloc(0);
109
+ socket.setTimeout(3000, () => socket.destroy(new Error("TCP DNS timeout")));
110
+ socket.on("error", reject);
111
+ socket.on("connect", () => { socket.write(size); socket.write(q.subarray(0, 4)); socket.write(q.subarray(4)); });
112
+ socket.on("data", chunk => {
113
+ data = Buffer.concat([data, Buffer.from(chunk)]);
114
+ if (data.length >= 2 && data.length >= data.readUInt16BE(0) + 2) {
115
+ socket.destroy(); resolve(packet.decode(data.subarray(2)));
116
+ }
117
+ });
118
+ });
119
+ }
120
+ return { query, tcpQuery, publish, restart, dir, upstreamDelay: (ms: number) => { upstreamDelay = ms; }, upstreamQueries: () => upstreamQueries, paths, change: (next: string) => { ip = next; }, fail: (next: boolean) => { fail = next; }, failContainer: (next: boolean) => { failContainer = next; } };
121
+ }
122
+
123
+ test("DNS bursts share network discovery, preserve alias/AAAA/registry/upstream behavior", async () => {
124
+ const f = await fixture();
125
+ const answers = await Promise.all(Array.from({ length: 80 }, (_, i) => f.query(`c${i % 19 + 1}.test`, i % 2 ? "AAAA" : "A")));
126
+ for (let i = 0; i < answers.length; i++) expect(answers[i].answers).toHaveLength(i % 2 ? 0 : 1);
127
+ expect(f.paths.filter(p => p === "/networks/spectest-net")).toHaveLength(1);
128
+ expect(f.paths.filter(p => p.startsWith("/containers/"))).toHaveLength(20);
129
+ expect(f.paths.some(p => p.startsWith("/containers/json"))).toBe(false);
130
+ expect((await f.query("explicit.test")).answers?.[0]).toMatchObject({ data: "172.20.0.7" });
131
+ expect((await f.query("child.wild.test")).answers?.[0]).toMatchObject({ data: "172.20.0.8" });
132
+ expect((await f.query("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
133
+ expect((await f.query("stopped.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
134
+ });
135
+
136
+ test("runtime alias changes expire and transient Docker failures are not cached", async () => {
137
+ const f = await fixture();
138
+ f.fail(true);
139
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
140
+ f.fail(false);
141
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
142
+ f.change("172.20.0.10");
143
+ const deadline = performance.now() + 2500;
144
+ for (;;) {
145
+ const answer = await f.query("c1.test");
146
+ if ((answer.answers?.[0] as packet.AAnswer)?.data === "172.20.0.10") break;
147
+ if (performance.now() > deadline) throw new Error("Changed container IP remained stale");
148
+ await Bun.sleep(25);
149
+ }
150
+ expect(f.paths.filter(p => p === "/networks/spectest-net").length).toBe(3);
151
+ });
152
+
153
+
154
+ test("one unavailable container does not hide healthy aliases or poison the next refresh", async () => {
155
+ const f = await fixture();
156
+ f.failContainer(true);
157
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
158
+ f.failContainer(false);
159
+ expect((await f.query("c2.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
160
+ expect(f.paths.filter(p => p === "/networks/spectest-net")).toHaveLength(2);
161
+ });
162
+
163
+ const fullRegistry = (generation = "one") => ({ version: 1, generation, hosts: {
164
+ api: "172.20.0.2", "api.internal": "172.20.0.2", "api.example.com": "172.20.0.3", "stopped.example.com": null,
165
+ }, wildcards: [{ suffix: ".example.com", ip: "172.20.0.4" }, { suffix: ".deep.example.com", ip: "172.20.0.5" }] });
166
+
167
+ test("acknowledged complete registry is memory-only, authoritative for every type, and serves TCP", async () => {
168
+ const f = await fixture();
169
+ const doc = fullRegistry();
170
+ expect(await f.publish(doc)).toEqual({ status: 200, body: JSON.stringify({ version: 1, generation: "one" }) });
171
+ expect(JSON.parse(await readFile(join(f.dir, "dns.json"), "utf8"))).toEqual(doc);
172
+ // Neither a changed legacy file nor a corrupt persisted file affects live DNS.
173
+ await writeFile(join(f.dir, "registry.json"), "invalid");
174
+ await writeFile(join(f.dir, "dns.json"), "invalid");
175
+ f.fail(true);
176
+ const replies = await Promise.all(Array.from({ length: 100 }, (_, i) => f.query(i % 2 ? "api" : "api.internal")));
177
+ for (const reply of replies) expect(reply.answers?.[0]).toMatchObject({ data: "172.20.0.2" });
178
+ expect((await f.query("API.EXAMPLE.COM.")).answers?.[0]).toMatchObject({ data: "172.20.0.3" });
179
+ expect((await f.query("x.deep.example.com")).answers?.[0]).toMatchObject({ data: "172.20.0.5" });
180
+ expect((await f.tcpQuery("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
181
+ for (const type of ["AAAA", "TXT", "MX", "HTTPS"] as packet.RecordType[])
182
+ expect((await f.query("api.example.com", type)).answers).toHaveLength(0);
183
+ for (const name of ["stopped.example.com", "unknown.internal", "unknown"])
184
+ expect((await f.query(name)).answers).toHaveLength(0);
185
+ expect(f.upstreamQueries()).toBe(0);
186
+ expect(f.paths).toHaveLength(0);
187
+ expect((await f.query("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
188
+ expect(f.paths).toHaveLength(0);
189
+ expect((await f.tcpQuery("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.2" });
190
+ });
191
+
192
+ test("bad updates preserve last state; replacement and restart never need Docker", async () => {
193
+ const f = await fixture();
194
+ await f.publish(fullRegistry());
195
+ for (const bad of [null, { ...fullRegistry(), version: 2 }, { ...fullRegistry(), hosts: { api: "garbage" } }, { ...fullRegistry(), wildcards: [{ suffix: "example.com", ip: "1.1.1.1" }] }])
196
+ expect((await f.publish(bad)).status).toBe(400);
197
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
198
+ const next = { ...fullRegistry("two"), hosts: { api: "172.20.0.9", "api.example.com": null } };
199
+ expect((await f.publish(next)).status).toBe(200);
200
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
201
+ expect((await f.query("api.example.com")).answers).toHaveLength(0);
202
+ await f.restart();
203
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
204
+ expect((await f.query("api.example.com")).answers).toHaveLength(0);
205
+ expect(f.paths).toHaveLength(0);
206
+ });
207
+
208
+ test("simultaneous external clients sharing transaction IDs receive their own questions", async () => {
209
+ const f = await fixture();
210
+ await f.publish(fullRegistry());
211
+ const names = Array.from({ length: 60 }, (_, i) => `external${i}.test`);
212
+ const replies = await Promise.all(names.map(name => f.query(name)));
213
+ for (let i = 0; i < names.length; i++) {
214
+ expect(replies[i].id).toBe(42);
215
+ expect(replies[i].questions?.[0].name).toBe(names[i]);
216
+ }
217
+ expect(f.paths).toHaveLength(0);
218
+ });
219
+
220
+
221
+ test("SDK writer waits for platform activation and persists concurrent lifecycle changes", async () => {
222
+ const f = await fixture();
223
+ const dns = new ServiceDns(doc => publishDnsRegistry(doc, join(f.dir, "dns.sock")));
224
+ await dns.reset([{ name: "api", aliases: ["api.test"] }], []);
225
+ expect((await f.query("api.test")).answers).toHaveLength(0);
226
+ await Promise.all([dns.service("api", "172.20.0.2"), dns.service("db", "172.20.0.3"), dns.bind("setup.test", { service: "api" })]);
227
+ expect((await f.query("setup.test")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
228
+ expect((await f.query("db")).answers?.[0]).toMatchObject({ data: "172.20.0.3" });
229
+ await dns.service("api", null);
230
+ expect((await f.query("setup.test")).answers).toHaveLength(0);
231
+ await dns.service("api", "172.20.0.4");
232
+ await f.restart();
233
+ expect((await f.query("setup.test")).answers?.[0]).toMatchObject({ data: "172.20.0.4" });
234
+ expect(f.paths).toHaveLength(0);
235
+ });
236
+
237
+
238
+ test("identical external queries coalesce while outstanding", async () => {
239
+ const f = await fixture(fullRegistry());
240
+ f.upstreamDelay(50);
241
+ const replies = await Promise.all(Array.from({ length: 40 }, () => f.query("same.external.test")));
242
+ for (const reply of replies) expect(reply.answers?.[0]).toMatchObject({ data: "192.0.2.1" });
243
+ expect(f.upstreamQueries()).toBe(1);
244
+ });
245
+
246
+ test("persistence failure rejects the update without replacing the active or saved view", async () => {
247
+ const f = await fixture(fullRegistry());
248
+ await mkdir(join(f.dir, "dns.json.tmp"));
249
+ const dns = new ServiceDns(doc => publishDnsRegistry(doc, join(f.dir, "dns.sock")));
250
+ await expect(dns.service("api", "172.20.0.9")).rejects.toThrow("DNS update rejected");
251
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
252
+ expect(JSON.parse(await readFile(join(f.dir, "dns.json"), "utf8"))).toEqual(fullRegistry());
253
+ await rm(join(f.dir, "dns.json.tmp"), { recursive: true });
254
+ await dns.service("api", "172.20.0.9");
255
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
256
+ });
257
+
258
+ test("copies of persisted registry evolve independently", async () => {
259
+ const a = await fixture(fullRegistry());
260
+ const b = await fixture(fullRegistry());
261
+ await a.publish({ ...fullRegistry("child"), hosts: { api: "172.20.0.9" } });
262
+ expect((await a.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
263
+ expect((await b.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
264
+ await a.restart(); await b.restart();
265
+ expect((await a.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
266
+ expect((await b.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
267
+ });