@specific.dev/spectest 0.69.0 → 0.71.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The :443 TLS terminator — one listener, for the life of the harness.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Ingress certificates are minted *while tests run*: a fake that
7
+ * provisions a database hands the app a CA-trusted `https://<new-host>/`
8
+ * endpoint, and that hostname did not exist a second earlier. Bun fixes a
9
+ * server's TLS configuration at `Bun.serve` time — `reload({ tls })` is
10
+ * accepted and then ignored (re-measured on Bun 1.4.0: the added
11
+ * `serverName` still gets the first entry's leaf) — so for as long as
12
+ * :443 was a `Bun.serve`, every new certificate meant swapping the
13
+ * listener.
14
+ *
15
+ * **No listener swap can be made safe.** Binding the replacement first
16
+ * under SO_REUSEPORT and draining the original with `stop(false)` saves a
17
+ * request that is already *in flight*, and that is all it saves: measured
18
+ * on Bun 1.4.0, `stop(false)` sends FIN to an **idle kept-alive
19
+ * connection within 1–2 ms**. A client that writes a request into that
20
+ * socket in the same instant gets `SocketError: other side closed`
21
+ * (`UND_ERR_SOCKET` under undici), and Node's `fetch` does not retry it.
22
+ * That is a flake in whatever unrelated test happened to be talking
23
+ * through the ingress — never in the one that provisioned the service —
24
+ * and it is why the app under test would die far from any ingress code.
25
+ *
26
+ * So the listener must stop being swapped, which means the certificate
27
+ * must be chosen **per handshake** rather than baked into the socket.
28
+ * That is what every real server does, and under Bun it is reachable
29
+ * through `node:tls`'s `SNICallback` (verified working on Bun 1.4.0:
30
+ * adding an entry to the table is served on the next handshake, with no
31
+ * restart and no effect on any established connection).
32
+ *
33
+ * ## The shape, and why it is a byte pipe
34
+ *
35
+ * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
+ * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
+ * bound on loopback. Everything downstream — routing, fakes,
38
+ * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
39
+ * bridging — is the same `Bun.serve` code that already serves :80, run
40
+ * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
41
+ * would have meant reimplementing the WebSocket bridge against a
42
+ * different API, which is exactly the kind of trade this change exists to
43
+ * avoid: one flake removed, another introduced somewhere subtler.
44
+ *
45
+ * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
46
+ * 50 MB bodies): ~11 % fewer small requests per second, download
47
+ * throughput unchanged, upload ~500 MB/s against ~800 MB/s direct. Ingress
48
+ * traffic in a test VM is nowhere near either bound.
49
+ *
50
+ * ## Recovering the client's address
51
+ *
52
+ * The plaintext server's peer is the terminator, so `server.requestIP()`
53
+ * reports loopback and `x-forwarded-for` would lose the caller. The
54
+ * upstream socket's *source port* is unique among live connections, so the
55
+ * terminator records `source port → real client address` for the life of
56
+ * each connection and {@link TlsTerminator.clientIpFor} hands it back.
57
+ * Verified end to end: a client connecting from 127.0.0.2 is reported as
58
+ * 127.0.0.2 by the plaintext server behind the pipe.
59
+ */
60
+ import net from "node:net";
61
+ import tls from "node:tls";
62
+
63
+ import type { Leaf } from "./ingress-table";
64
+
65
+ export interface TlsTerminatorOptions {
66
+ /** Public port to accept TLS on (443 in the harness). */
67
+ port: number;
68
+ /** Interface to accept on. */
69
+ hostname: string;
70
+ /** Loopback port of the plaintext ingress server to pipe into. */
71
+ upstreamPort: number;
72
+ /**
73
+ * The leaf to serve for an SNI server name, read live on every
74
+ * handshake — this is what lets a certificate be added with no restart.
75
+ * `undefined` server name means the client sent no SNI.
76
+ */
77
+ certFor(serverName: string | undefined): Leaf | undefined;
78
+ /** Non-fatal diagnostics (a handshake a client aborted, a pipe error). */
79
+ onWarning?(message: string, err: unknown): void;
80
+ }
81
+
82
+ export interface TlsTerminator {
83
+ /** The port actually bound. */
84
+ readonly port: number;
85
+ /**
86
+ * The real client address behind an upstream connection, keyed by the
87
+ * source port the plaintext server sees. `undefined` for a port this
88
+ * terminator did not open.
89
+ */
90
+ clientIpFor(upstreamPort: number | undefined): string | undefined;
91
+ /** Open TLS connections right now (diagnostics and tests). */
92
+ connectionCount(): number;
93
+ /** Stop accepting and force-close every connection. */
94
+ close(): void;
95
+ }
96
+
97
+ /**
98
+ * Start the terminator. Resolves once :443 is accepting, so a caller can
99
+ * register the hostname in DNS immediately afterwards and know the port is
100
+ * live.
101
+ */
102
+ /**
103
+ * Is this the ordinary end of a connection rather than a fault? A peer
104
+ * that goes away mid-stream, or a half-finished handshake, is routine on a
105
+ * listener that fronts browsers.
106
+ */
107
+ function isRoutineDisconnect(err: unknown): boolean {
108
+ const code = (err as { code?: string } | undefined)?.code;
109
+ return (
110
+ code === "ECONNRESET" ||
111
+ code === "EPIPE" ||
112
+ code === "ERR_STREAM_PREMATURE_CLOSE" ||
113
+ code === "ECONNABORTED"
114
+ );
115
+ }
116
+
117
+ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTerminator> {
118
+ // Parsing a PEM into a SecureContext is the expensive half of choosing a
119
+ // certificate, and the table is stable — cache by the certificate's own
120
+ // bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
121
+ // hostname so a re-minted leaf for the same name cannot be served stale.
122
+ const contexts = new Map<string, tls.SecureContext>();
123
+ const contextFor = (leaf: Leaf): tls.SecureContext => {
124
+ let ctx = contexts.get(leaf.cert);
125
+ if (!ctx) {
126
+ ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
127
+ contexts.set(leaf.cert, ctx);
128
+ }
129
+ return ctx;
130
+ };
131
+
132
+ const warn = (message: string, err: unknown): void => {
133
+ // A client hanging up is not a fault, and this listener carries every
134
+ // browser in the VM: Chromium opens speculative connections and drops
135
+ // them, a fetch is abandoned when its test ends, and each of those
136
+ // arrives here as a reset. Logging them would bury the boot log a user
137
+ // reads with `spectest env logs` in noise that means nothing.
138
+ if (isRoutineDisconnect(err)) return;
139
+ if (opts.onWarning) opts.onWarning(message, err);
140
+ // eslint-disable-next-line no-console
141
+ else console.warn(`[ingress] ${message}:`, err);
142
+ };
143
+
144
+ /** upstream source port → the address the TLS client connected from. */
145
+ const clientIpByUpstreamPort = new Map<number, string>();
146
+ const live = new Set<net.Socket>();
147
+
148
+ // A default leaf is only ever used for a client that sends no SNI at
149
+ // all; `SNICallback` decides every other handshake. This mirrors what
150
+ // Bun did with the first entry of its `tls` array.
151
+ const fallback = opts.certFor(undefined);
152
+
153
+ const server = tls.createServer(
154
+ {
155
+ ...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
156
+ SNICallback: (serverName, cb) => {
157
+ const leaf = opts.certFor(serverName) ?? fallback;
158
+ // No certificate at all is a handshake we cannot complete. Answer
159
+ // with the fallback context rather than an error so the client
160
+ // gets a certificate-mismatch alert it can report, which is the
161
+ // same thing it saw when Bun served the first entry.
162
+ cb(null, leaf ? contextFor(leaf) : undefined);
163
+ },
164
+ },
165
+ (client) => {
166
+ // No idle timeout anywhere on the ingress path: it fronts app
167
+ // endpoints that legitimately take minutes (a deploy), and a
168
+ // timeout here would surface as a truncated response with no
169
+ // explanation.
170
+ client.setTimeout(0);
171
+ live.add(client);
172
+
173
+ // Nothing reads from `client` until `pipe` below, so a socket that
174
+ // is written to before the upstream is connected stays paused and
175
+ // its bytes are buffered rather than dropped — which is the normal
176
+ // case, since a client sends its first request the instant the
177
+ // handshake completes.
178
+ const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
179
+ upstream.setTimeout(0);
180
+ upstream.setNoDelay(true);
181
+ client.setNoDelay(true);
182
+
183
+ let recordedPort: number | undefined;
184
+ upstream.on("connect", () => {
185
+ recordedPort = upstream.localPort;
186
+ if (recordedPort !== undefined && client.remoteAddress) {
187
+ clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
188
+ }
189
+ // `pipe` carries backpressure and the half-close in both
190
+ // directions, which is what a WebSocket bridge and a streamed
191
+ // upload each need.
192
+ client.pipe(upstream);
193
+ upstream.pipe(client);
194
+ });
195
+
196
+ const teardown = (): void => {
197
+ if (recordedPort !== undefined) clientIpByUpstreamPort.delete(recordedPort);
198
+ live.delete(client);
199
+ client.destroy();
200
+ upstream.destroy();
201
+ };
202
+ client.on("error", (err) => {
203
+ warn("tls client connection failed", err);
204
+ teardown();
205
+ });
206
+ upstream.on("error", (err) => {
207
+ warn(`ingress upstream :${opts.upstreamPort} failed`, err);
208
+ teardown();
209
+ });
210
+ client.on("close", teardown);
211
+ upstream.on("close", teardown);
212
+ },
213
+ );
214
+
215
+ // A client that aborts mid-handshake, or offers a protocol version we
216
+ // do not speak, emits this. It must be handled: an unhandled 'error' on
217
+ // a net server is thrown, and taking the daemon down over one bad
218
+ // handshake would be a far worse failure than the one being fixed.
219
+ server.on("tlsClientError", (err) => warn("tls handshake failed", err));
220
+ server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
221
+
222
+ return new Promise<TlsTerminator>((resolve, reject) => {
223
+ server.once("error", reject);
224
+ server.listen(opts.port, opts.hostname, () => {
225
+ server.removeListener("error", reject);
226
+ resolve({
227
+ port: opts.port,
228
+ clientIpFor: (upstreamPort) =>
229
+ upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
230
+ connectionCount: () => live.size,
231
+ close: () => {
232
+ try {
233
+ server.close();
234
+ } catch (err) {
235
+ warn("closing the tls listener failed", err);
236
+ }
237
+ for (const sock of [...live]) sock.destroy();
238
+ live.clear();
239
+ clientIpByUpstreamPort.clear();
240
+ },
241
+ });
242
+ });
243
+ });
244
+ }
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
- CACHE_ROOT,
5
4
  resolveHostPath,
6
5
  sanitizeSegment,
7
6
  survivesTeardown,
@@ -68,35 +67,3 @@ describe("resolveHostPath", () => {
68
67
  });
69
68
  });
70
69
 
71
- describe("cache volumes and delta teardown", () => {
72
- /** The whole point of the flag: teardown does `rm -rf /workspace`, so a
73
- * cache volume must be rooted outside it or it does not survive. */
74
- test("a cache volume is rooted outside the workspace", () => {
75
- const host = resolveHostPath("bun", { target: "/root/.bun/install/cache", cache: true }, WS);
76
- expect(host.startsWith(CACHE_ROOT)).toBe(true);
77
- expect(host.startsWith(WS)).toBe(false);
78
- });
79
-
80
- test("a non-cache volume is inside the workspace, so teardown wipes it", () => {
81
- const host = resolveHostPath("db", { target: "/data" }, WS);
82
- expect(host.startsWith(`${WS}/`)).toBe(true);
83
- });
84
-
85
- test("a shared volume honours the cache flag too", () => {
86
- const cached = resolveHostPath("a", { name: "layers", target: "/x", cache: true }, WS);
87
- const plain = resolveHostPath("a", { name: "layers", target: "/x" }, WS);
88
- expect(cached.startsWith(CACHE_ROOT)).toBe(true);
89
- expect(plain.startsWith(`${WS}/`)).toBe(true);
90
- expect(cached).not.toBe(plain);
91
- });
92
-
93
- test("survivesTeardown agrees with where the path landed", () => {
94
- expect(survivesTeardown({ target: "/data" }, "db", WS)).toBe(false);
95
- expect(survivesTeardown({ target: "/data", cache: true }, "db", WS)).toBe(true);
96
- // An absolute source outside /workspace also survives — worth knowing,
97
- // because it is a way to keep state by accident.
98
- expect(survivesTeardown({ source: "/mnt/big", target: "/d" }, "db", WS)).toBe(true);
99
- // …but an absolute source inside /workspace does not.
100
- expect(survivesTeardown({ source: "/workspace/keep", target: "/d" }, "db", WS)).toBe(false);
101
- });
102
- });
@@ -6,27 +6,18 @@
6
6
  * delta-restore teardown**.
7
7
  *
8
8
  * Teardown wipes `/workspace` to give a restored environment fresh-state
9
- * semantics. Anything that must survive it therefore has to live outside
10
- * `/workspace` — and that is exactly what `cache: true` selects, by
11
- * rooting the directory under `/var/cache/spectest/volumes` instead.
12
- *
13
- * The flag is only ever correct for **content-addressed accelerator
14
- * data**: package stores, layer caches — data whose presence can change
15
- * how *fast* something runs but never *what* it does. It is wrong for any
16
- * real state, because a restored environment would then start with a
17
- * previous run's data and stop being reproducible. (Counter-example worth
18
- * remembering: `k3s()` deliberately does not cache its containerd store —
19
- * a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
9
+ * semantics. Every volume lives inside it: the one cache spectest keeps
10
+ * across environments is the container store, which is not a volume at
11
+ * all (CONTAINER_STORE.md).
20
12
  */
21
13
 
22
14
  import path from "node:path";
23
15
 
16
+ import { expandServiceToken } from "./file-mounts";
17
+
24
18
  /** Root of the per-environment state tree. Wiped by delta teardown. */
25
19
  export const DEFAULT_WORKSPACE = "/workspace";
26
20
 
27
- /** Root of the cache tree. Deliberately outside the workspace. */
28
- export const CACHE_ROOT = "/var/cache/spectest/volumes";
29
-
30
21
  /** Directory holding named shared volumes, under whichever root applies. */
31
22
  export const SHARED_DIR = "_shared";
32
23
 
@@ -40,8 +31,6 @@ export interface VolumeSpec {
40
31
  /** Path inside the container. Used to derive a directory when neither
41
32
  * `name` nor `source` is given. */
42
33
  target: string;
43
- /** Survive the delta-restore teardown. Content-addressed data only. */
44
- cache?: boolean;
45
34
  }
46
35
 
47
36
  /**
@@ -70,6 +59,13 @@ export function sanitizeSegment(p: string): string {
70
59
  * 3. A relative `source`, or nothing at all — under the service's own
71
60
  * directory, derived from `target` when `source` is absent.
72
61
  *
62
+ * `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
63
+ * `files` does: a component cannot know the map key the user will give it,
64
+ * and an **absolute** source gets no automatic per-service directory. A
65
+ * component that needs one — a nested runtime keeping its store under
66
+ * {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
67
+ * be two daemons on one metadata store — writes the token into the path.
68
+ *
73
69
  * `workspace` is a parameter rather than a module constant so the rule is
74
70
  * testable without touching the filesystem.
75
71
  */
@@ -79,19 +75,18 @@ export function resolveHostPath(
79
75
  workspace: string = DEFAULT_WORKSPACE,
80
76
  ): string {
81
77
  const stateRoot = [workspace, ".spectest", "volumes"];
78
+ const source = vol.source === undefined ? undefined : expandServiceToken(vol.source, service);
82
79
 
83
80
  if (vol.name) {
84
- const root = vol.cache ? [CACHE_ROOT, SHARED_DIR] : [...stateRoot, SHARED_DIR];
85
- return path.join(...root, sanitizeSegment(vol.name));
81
+ return path.join(...stateRoot, SHARED_DIR, sanitizeSegment(vol.name));
86
82
  }
87
83
 
88
- // An absolute source is the project's own path; `cache` doesn't apply
89
- // because the location was already chosen explicitly.
90
- if (vol.source && vol.source.startsWith("/")) return vol.source;
84
+ // An absolute source is the project's own path, used as is.
85
+ if (source && source.startsWith("/")) return source;
91
86
 
92
- const root = vol.cache ? [CACHE_ROOT, service] : [...stateRoot, service];
93
- if (vol.source) {
94
- return path.join(...root, vol.source.replace(/^\/+/, ""));
87
+ const root = [...stateRoot, service];
88
+ if (source) {
89
+ return path.join(...root, source.replace(/^\/+/, ""));
95
90
  }
96
91
  return path.join(...root, sanitizeSegment(vol.target));
97
92
  }
@@ -99,8 +94,9 @@ export function resolveHostPath(
99
94
  /**
100
95
  * Does this volume survive a delta-restore teardown?
101
96
  *
102
- * True for cache-flagged volumes and for absolute sources outside the
103
- * workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
97
+ * True for a volume on a mounted cache disk, and for an absolute source
98
+ * outside the workspace — the two ways a directory ends up beyond
99
+ * `rm -rf /workspace`.
104
100
  */
105
101
  export function survivesTeardown(
106
102
  vol: VolumeSpec,
package/src/index.ts CHANGED
@@ -1276,15 +1276,6 @@ export interface VolumeMount {
1276
1276
  /** Container path. */
1277
1277
  target: string;
1278
1278
  readOnly?: boolean;
1279
- /**
1280
- * Cache volume: the backing dir lives outside the per-env state tree and
1281
- * survives a delta-restore teardown (which recreates every container,
1282
- * volume, and the daemon for fresh-state semantics). Reserve this for
1283
- * content-addressed data whose presence is purely an accelerator — an
1284
- * image/layer store, a package cache — never for app state: anything in
1285
- * a cache volume is visible to the "fresh" environment.
1286
- */
1287
- cache?: boolean;
1288
1279
  }
1289
1280
 
1290
1281
  export interface FileMount {