@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.
- package/dist/components/k3s.d.ts +3 -0
- package/dist/components/k3s.js +156 -60
- package/dist/daemon.js +366 -185
- package/dist/harness/hostmatch.d.ts +17 -0
- package/dist/harness/hostmatch.js +33 -0
- package/dist/harness/ingress-table.d.ts +29 -18
- package/dist/harness/ingress-table.js +28 -17
- package/dist/harness/tls-terminator.d.ts +32 -0
- package/dist/harness/tls-terminator.js +199 -0
- package/dist/harness/volume-paths.d.ts +13 -17
- package/dist/harness/volume-paths.js +22 -24
- package/dist/index.d.ts +0 -9
- package/package.json +1 -1
- package/src/components/k3s.ts +160 -66
- package/src/daemon.ts +360 -191
- package/src/harness/hostmatch.ts +35 -0
- package/src/harness/ingress-rebind.test.ts +18 -10
- package/src/harness/ingress-table.test.ts +23 -8
- package/src/harness/ingress-table.ts +30 -19
- package/src/harness/tls-terminator.test.ts +240 -0
- package/src/harness/tls-terminator.ts +244 -0
- package/src/harness/volume-paths.test.ts +0 -33
- package/src/harness/volume-paths.ts +22 -26
- package/src/index.ts +0 -9
|
@@ -48,6 +48,23 @@ export declare function wildcardCoversHost(pattern: string, hostname: string): b
|
|
|
48
48
|
* `HTTPS_CERT_BY_HOST.keys()`.
|
|
49
49
|
*/
|
|
50
50
|
export declare function certCovers(serverNames: Iterable<string>, hostname: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Pick the certificate server name that should serve `hostname`'s TLS
|
|
53
|
+
* handshake: the exact entry first, then the longest matching wildcard.
|
|
54
|
+
*
|
|
55
|
+
* This is the same precedence {@link matchRoute} applies to routes and the
|
|
56
|
+
* resolver applies to DNS — but over the *certificate* rule, so a wildcard
|
|
57
|
+
* covers exactly one label ({@link wildcardCoversHost}) rather than any
|
|
58
|
+
* depth.
|
|
59
|
+
*
|
|
60
|
+
* It exists because the SNI lookup is now ours to make. Until the :443
|
|
61
|
+
* listener stopped being rebuilt for every new certificate, Bun performed
|
|
62
|
+
* this match internally from the `tls` array it was served with; the
|
|
63
|
+
* terminator picks a certificate per handshake instead, so the rule has to
|
|
64
|
+
* be written down — and, being written down, it is testable, which Bun's
|
|
65
|
+
* internal version never was.
|
|
66
|
+
*/
|
|
67
|
+
export declare function selectCertName(serverNames: Iterable<string>, hostname: string): string | undefined;
|
|
51
68
|
/**
|
|
52
69
|
* Pick the route for `host`: exact match first, then the longest matching
|
|
53
70
|
* wildcard suffix.
|
|
@@ -65,6 +65,39 @@ export function certCovers(serverNames, hostname) {
|
|
|
65
65
|
return true;
|
|
66
66
|
return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Pick the certificate server name that should serve `hostname`'s TLS
|
|
70
|
+
* handshake: the exact entry first, then the longest matching wildcard.
|
|
71
|
+
*
|
|
72
|
+
* This is the same precedence {@link matchRoute} applies to routes and the
|
|
73
|
+
* resolver applies to DNS — but over the *certificate* rule, so a wildcard
|
|
74
|
+
* covers exactly one label ({@link wildcardCoversHost}) rather than any
|
|
75
|
+
* depth.
|
|
76
|
+
*
|
|
77
|
+
* It exists because the SNI lookup is now ours to make. Until the :443
|
|
78
|
+
* listener stopped being rebuilt for every new certificate, Bun performed
|
|
79
|
+
* this match internally from the `tls` array it was served with; the
|
|
80
|
+
* terminator picks a certificate per handshake instead, so the rule has to
|
|
81
|
+
* be written down — and, being written down, it is testable, which Bun's
|
|
82
|
+
* internal version never was.
|
|
83
|
+
*/
|
|
84
|
+
export function selectCertName(serverNames, hostname) {
|
|
85
|
+
const names = [...serverNames];
|
|
86
|
+
if (names.includes(hostname))
|
|
87
|
+
return hostname;
|
|
88
|
+
let best;
|
|
89
|
+
let bestLen = -1;
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
if (!isWildcard(name) || !wildcardCoversHost(name, hostname))
|
|
92
|
+
continue;
|
|
93
|
+
const len = wildcardSuffix(name).length;
|
|
94
|
+
if (len > bestLen) {
|
|
95
|
+
best = name;
|
|
96
|
+
bestLen = len;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
68
101
|
/**
|
|
69
102
|
* Pick the route for `host`: exact match first, then the longest matching
|
|
70
103
|
* wildcard suffix.
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
* routes added afterwards go nowhere. {@link routesFor} is the only way to
|
|
33
33
|
* reach a table so that this can't be done by accident.
|
|
34
34
|
*
|
|
35
|
-
* **
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* **Adding a certificate no longer touches the listener.** :443 is
|
|
36
|
+
* terminated by `harness/tls-terminator.ts`, which chooses a leaf per TLS
|
|
37
|
+
* handshake, so a new certificate is a `Map` entry and nothing more. It
|
|
38
|
+
* used to mean stopping and re-serving :443, and that swap severed every
|
|
39
|
+
* idle kept-alive connection the listener was carrying — an unrelated
|
|
40
|
+
* test's request, failing with `SocketError: other side closed`. The only
|
|
41
|
+
* thing {@link planBind} still has to say is whether the listener exists
|
|
42
|
+
* yet.
|
|
42
43
|
*/
|
|
43
44
|
/**
|
|
44
45
|
* One hostname's upstream: a fake handled in-process, or a container.
|
|
@@ -89,18 +90,21 @@ export declare function routesFor<F>(tables: IngressTables<F>, port: number): Ma
|
|
|
89
90
|
export interface BindPlan {
|
|
90
91
|
/** No existing cert covers the hostname, so a leaf must be minted. */
|
|
91
92
|
needsCert: boolean;
|
|
92
|
-
/** The :443 listener
|
|
93
|
-
|
|
93
|
+
/** The :443 listener does not exist yet and must be started. */
|
|
94
|
+
needsHttpsListener: boolean;
|
|
94
95
|
}
|
|
95
96
|
/**
|
|
96
97
|
* Decide what binding `hostname` for TLS costs.
|
|
97
98
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
99
|
+
* Minting is still earned — a hostname an existing exact or wildcard cert
|
|
100
|
+
* already covers needs nothing but a route, which is why a component can
|
|
101
|
+
* claim a whole domain up front and have services appear under it for
|
|
102
|
+
* free.
|
|
103
|
+
*
|
|
104
|
+
* Starting the listener, on the other hand, is now a once-per-process
|
|
105
|
+
* event: it is needed when :443 is not up yet, and never again. A new
|
|
106
|
+
* certificate does **not** imply it. That is the whole point of the
|
|
107
|
+
* terminator — see the module header.
|
|
104
108
|
*/
|
|
105
109
|
export declare function planBind<F>(tables: IngressTables<F>, hostname: string, opts: {
|
|
106
110
|
httpsListening: boolean;
|
|
@@ -117,11 +121,18 @@ export declare function bindRoute<F>(tables: IngressTables<F>, hostname: string,
|
|
|
117
121
|
* Drop `hostname`'s routes, so it 404s.
|
|
118
122
|
*
|
|
119
123
|
* The certificate is deliberately left in the SNI table: it is harmless
|
|
120
|
-
* without a route, and
|
|
121
|
-
*
|
|
124
|
+
* without a route, and a leaf nobody asks for costs one unused `Map`
|
|
125
|
+
* entry. (Before the terminator, removing it would also have forced a
|
|
126
|
+
* :443 rebind at exactly the moment a service was going away.)
|
|
122
127
|
*/
|
|
123
128
|
export declare function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void;
|
|
124
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The SNI table flattened to entries.
|
|
131
|
+
*
|
|
132
|
+
* This was `Bun.serve`'s `tls` array. The terminator selects per handshake
|
|
133
|
+
* instead ({@link import("./hostmatch").selectCertName}), so this survives
|
|
134
|
+
* as the readable projection of the table — for diagnostics and tests.
|
|
135
|
+
*/
|
|
125
136
|
export declare function certEntries<F>(tables: IngressTables<F>): Array<{
|
|
126
137
|
cert: string;
|
|
127
138
|
key: string;
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
* routes added afterwards go nowhere. {@link routesFor} is the only way to
|
|
33
33
|
* reach a table so that this can't be done by accident.
|
|
34
34
|
*
|
|
35
|
-
* **
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* **Adding a certificate no longer touches the listener.** :443 is
|
|
36
|
+
* terminated by `harness/tls-terminator.ts`, which chooses a leaf per TLS
|
|
37
|
+
* handshake, so a new certificate is a `Map` entry and nothing more. It
|
|
38
|
+
* used to mean stopping and re-serving :443, and that swap severed every
|
|
39
|
+
* idle kept-alive connection the listener was carrying — an unrelated
|
|
40
|
+
* test's request, failing with `SocketError: other side closed`. The only
|
|
41
|
+
* thing {@link planBind} still has to say is whether the listener exists
|
|
42
|
+
* yet.
|
|
42
43
|
*/
|
|
43
44
|
import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
44
45
|
/** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */
|
|
@@ -66,16 +67,19 @@ export function routesFor(tables, port) {
|
|
|
66
67
|
/**
|
|
67
68
|
* Decide what binding `hostname` for TLS costs.
|
|
68
69
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
70
|
+
* Minting is still earned — a hostname an existing exact or wildcard cert
|
|
71
|
+
* already covers needs nothing but a route, which is why a component can
|
|
72
|
+
* claim a whole domain up front and have services appear under it for
|
|
73
|
+
* free.
|
|
74
|
+
*
|
|
75
|
+
* Starting the listener, on the other hand, is now a once-per-process
|
|
76
|
+
* event: it is needed when :443 is not up yet, and never again. A new
|
|
77
|
+
* certificate does **not** imply it. That is the whole point of the
|
|
78
|
+
* terminator — see the module header.
|
|
75
79
|
*/
|
|
76
80
|
export function planBind(tables, hostname, opts) {
|
|
77
81
|
const needsCert = !certCovers(tables.certByHost.keys(), hostname);
|
|
78
|
-
return { needsCert,
|
|
82
|
+
return { needsCert, needsHttpsListener: !opts.httpsListening };
|
|
79
83
|
}
|
|
80
84
|
/**
|
|
81
85
|
* Point `hostname` at `route` on both :80 and :443.
|
|
@@ -92,14 +96,21 @@ export function bindRoute(tables, hostname, route) {
|
|
|
92
96
|
* Drop `hostname`'s routes, so it 404s.
|
|
93
97
|
*
|
|
94
98
|
* The certificate is deliberately left in the SNI table: it is harmless
|
|
95
|
-
* without a route, and
|
|
96
|
-
*
|
|
99
|
+
* without a route, and a leaf nobody asks for costs one unused `Map`
|
|
100
|
+
* entry. (Before the terminator, removing it would also have forced a
|
|
101
|
+
* :443 rebind at exactly the moment a service was going away.)
|
|
97
102
|
*/
|
|
98
103
|
export function unbindRoute(tables, hostname) {
|
|
99
104
|
tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
|
|
100
105
|
tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
|
|
101
106
|
}
|
|
102
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The SNI table flattened to entries.
|
|
109
|
+
*
|
|
110
|
+
* This was `Bun.serve`'s `tls` array. The terminator selects per handshake
|
|
111
|
+
* instead ({@link import("./hostmatch").selectCertName}), so this survives
|
|
112
|
+
* as the readable projection of the table — for diagnostics and tests.
|
|
113
|
+
*/
|
|
103
114
|
export function certEntries(tables) {
|
|
104
115
|
return [...tables.certByHost].map(([serverName, leaf]) => ({
|
|
105
116
|
cert: leaf.cert,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Leaf } from "./ingress-table";
|
|
2
|
+
export interface TlsTerminatorOptions {
|
|
3
|
+
/** Public port to accept TLS on (443 in the harness). */
|
|
4
|
+
port: number;
|
|
5
|
+
/** Interface to accept on. */
|
|
6
|
+
hostname: string;
|
|
7
|
+
/** Loopback port of the plaintext ingress server to pipe into. */
|
|
8
|
+
upstreamPort: number;
|
|
9
|
+
/**
|
|
10
|
+
* The leaf to serve for an SNI server name, read live on every
|
|
11
|
+
* handshake — this is what lets a certificate be added with no restart.
|
|
12
|
+
* `undefined` server name means the client sent no SNI.
|
|
13
|
+
*/
|
|
14
|
+
certFor(serverName: string | undefined): Leaf | undefined;
|
|
15
|
+
/** Non-fatal diagnostics (a handshake a client aborted, a pipe error). */
|
|
16
|
+
onWarning?(message: string, err: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
export interface TlsTerminator {
|
|
19
|
+
/** The port actually bound. */
|
|
20
|
+
readonly port: number;
|
|
21
|
+
/**
|
|
22
|
+
* The real client address behind an upstream connection, keyed by the
|
|
23
|
+
* source port the plaintext server sees. `undefined` for a port this
|
|
24
|
+
* terminator did not open.
|
|
25
|
+
*/
|
|
26
|
+
clientIpFor(upstreamPort: number | undefined): string | undefined;
|
|
27
|
+
/** Open TLS connections right now (diagnostics and tests). */
|
|
28
|
+
connectionCount(): number;
|
|
29
|
+
/** Stop accepting and force-close every connection. */
|
|
30
|
+
close(): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTerminator>;
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
* Start the terminator. Resolves once :443 is accepting, so a caller can
|
|
64
|
+
* register the hostname in DNS immediately afterwards and know the port is
|
|
65
|
+
* live.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* Is this the ordinary end of a connection rather than a fault? A peer
|
|
69
|
+
* that goes away mid-stream, or a half-finished handshake, is routine on a
|
|
70
|
+
* listener that fronts browsers.
|
|
71
|
+
*/
|
|
72
|
+
function isRoutineDisconnect(err) {
|
|
73
|
+
const code = err?.code;
|
|
74
|
+
return (code === "ECONNRESET" ||
|
|
75
|
+
code === "EPIPE" ||
|
|
76
|
+
code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
77
|
+
code === "ECONNABORTED");
|
|
78
|
+
}
|
|
79
|
+
export function startTlsTerminator(opts) {
|
|
80
|
+
// Parsing a PEM into a SecureContext is the expensive half of choosing a
|
|
81
|
+
// certificate, and the table is stable — cache by the certificate's own
|
|
82
|
+
// bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
|
|
83
|
+
// hostname so a re-minted leaf for the same name cannot be served stale.
|
|
84
|
+
const contexts = new Map();
|
|
85
|
+
const contextFor = (leaf) => {
|
|
86
|
+
let ctx = contexts.get(leaf.cert);
|
|
87
|
+
if (!ctx) {
|
|
88
|
+
ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
|
|
89
|
+
contexts.set(leaf.cert, ctx);
|
|
90
|
+
}
|
|
91
|
+
return ctx;
|
|
92
|
+
};
|
|
93
|
+
const warn = (message, err) => {
|
|
94
|
+
// A client hanging up is not a fault, and this listener carries every
|
|
95
|
+
// browser in the VM: Chromium opens speculative connections and drops
|
|
96
|
+
// them, a fetch is abandoned when its test ends, and each of those
|
|
97
|
+
// arrives here as a reset. Logging them would bury the boot log a user
|
|
98
|
+
// reads with `spectest env logs` in noise that means nothing.
|
|
99
|
+
if (isRoutineDisconnect(err))
|
|
100
|
+
return;
|
|
101
|
+
if (opts.onWarning)
|
|
102
|
+
opts.onWarning(message, err);
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
else
|
|
105
|
+
console.warn(`[ingress] ${message}:`, err);
|
|
106
|
+
};
|
|
107
|
+
/** upstream source port → the address the TLS client connected from. */
|
|
108
|
+
const clientIpByUpstreamPort = new Map();
|
|
109
|
+
const live = new Set();
|
|
110
|
+
// A default leaf is only ever used for a client that sends no SNI at
|
|
111
|
+
// all; `SNICallback` decides every other handshake. This mirrors what
|
|
112
|
+
// Bun did with the first entry of its `tls` array.
|
|
113
|
+
const fallback = opts.certFor(undefined);
|
|
114
|
+
const server = tls.createServer({
|
|
115
|
+
...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
|
|
116
|
+
SNICallback: (serverName, cb) => {
|
|
117
|
+
const leaf = opts.certFor(serverName) ?? fallback;
|
|
118
|
+
// No certificate at all is a handshake we cannot complete. Answer
|
|
119
|
+
// with the fallback context rather than an error so the client
|
|
120
|
+
// gets a certificate-mismatch alert it can report, which is the
|
|
121
|
+
// same thing it saw when Bun served the first entry.
|
|
122
|
+
cb(null, leaf ? contextFor(leaf) : undefined);
|
|
123
|
+
},
|
|
124
|
+
}, (client) => {
|
|
125
|
+
// No idle timeout anywhere on the ingress path: it fronts app
|
|
126
|
+
// endpoints that legitimately take minutes (a deploy), and a
|
|
127
|
+
// timeout here would surface as a truncated response with no
|
|
128
|
+
// explanation.
|
|
129
|
+
client.setTimeout(0);
|
|
130
|
+
live.add(client);
|
|
131
|
+
// Nothing reads from `client` until `pipe` below, so a socket that
|
|
132
|
+
// is written to before the upstream is connected stays paused and
|
|
133
|
+
// its bytes are buffered rather than dropped — which is the normal
|
|
134
|
+
// case, since a client sends its first request the instant the
|
|
135
|
+
// handshake completes.
|
|
136
|
+
const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
|
|
137
|
+
upstream.setTimeout(0);
|
|
138
|
+
upstream.setNoDelay(true);
|
|
139
|
+
client.setNoDelay(true);
|
|
140
|
+
let recordedPort;
|
|
141
|
+
upstream.on("connect", () => {
|
|
142
|
+
recordedPort = upstream.localPort;
|
|
143
|
+
if (recordedPort !== undefined && client.remoteAddress) {
|
|
144
|
+
clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
|
|
145
|
+
}
|
|
146
|
+
// `pipe` carries backpressure and the half-close in both
|
|
147
|
+
// directions, which is what a WebSocket bridge and a streamed
|
|
148
|
+
// upload each need.
|
|
149
|
+
client.pipe(upstream);
|
|
150
|
+
upstream.pipe(client);
|
|
151
|
+
});
|
|
152
|
+
const teardown = () => {
|
|
153
|
+
if (recordedPort !== undefined)
|
|
154
|
+
clientIpByUpstreamPort.delete(recordedPort);
|
|
155
|
+
live.delete(client);
|
|
156
|
+
client.destroy();
|
|
157
|
+
upstream.destroy();
|
|
158
|
+
};
|
|
159
|
+
client.on("error", (err) => {
|
|
160
|
+
warn("tls client connection failed", err);
|
|
161
|
+
teardown();
|
|
162
|
+
});
|
|
163
|
+
upstream.on("error", (err) => {
|
|
164
|
+
warn(`ingress upstream :${opts.upstreamPort} failed`, err);
|
|
165
|
+
teardown();
|
|
166
|
+
});
|
|
167
|
+
client.on("close", teardown);
|
|
168
|
+
upstream.on("close", teardown);
|
|
169
|
+
});
|
|
170
|
+
// A client that aborts mid-handshake, or offers a protocol version we
|
|
171
|
+
// do not speak, emits this. It must be handled: an unhandled 'error' on
|
|
172
|
+
// a net server is thrown, and taking the daemon down over one bad
|
|
173
|
+
// handshake would be a far worse failure than the one being fixed.
|
|
174
|
+
server.on("tlsClientError", (err) => warn("tls handshake failed", err));
|
|
175
|
+
server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
server.once("error", reject);
|
|
178
|
+
server.listen(opts.port, opts.hostname, () => {
|
|
179
|
+
server.removeListener("error", reject);
|
|
180
|
+
resolve({
|
|
181
|
+
port: opts.port,
|
|
182
|
+
clientIpFor: (upstreamPort) => upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
|
|
183
|
+
connectionCount: () => live.size,
|
|
184
|
+
close: () => {
|
|
185
|
+
try {
|
|
186
|
+
server.close();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
warn("closing the tls listener failed", err);
|
|
190
|
+
}
|
|
191
|
+
for (const sock of [...live])
|
|
192
|
+
sock.destroy();
|
|
193
|
+
live.clear();
|
|
194
|
+
clientIpByUpstreamPort.clear();
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -6,22 +6,12 @@
|
|
|
6
6
|
* delta-restore teardown**.
|
|
7
7
|
*
|
|
8
8
|
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
-
* semantics.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
22
14
|
export declare const DEFAULT_WORKSPACE = "/workspace";
|
|
23
|
-
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
24
|
-
export declare const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
25
15
|
/** Directory holding named shared volumes, under whichever root applies. */
|
|
26
16
|
export declare const SHARED_DIR = "_shared";
|
|
27
17
|
export interface VolumeSpec {
|
|
@@ -34,8 +24,6 @@ export interface VolumeSpec {
|
|
|
34
24
|
/** Path inside the container. Used to derive a directory when neither
|
|
35
25
|
* `name` nor `source` is given. */
|
|
36
26
|
target: string;
|
|
37
|
-
/** Survive the delta-restore teardown. Content-addressed data only. */
|
|
38
|
-
cache?: boolean;
|
|
39
27
|
}
|
|
40
28
|
/**
|
|
41
29
|
* Make an arbitrary string safe as a single path segment.
|
|
@@ -57,6 +45,13 @@ export declare function sanitizeSegment(p: string): string;
|
|
|
57
45
|
* 3. A relative `source`, or nothing at all — under the service's own
|
|
58
46
|
* directory, derived from `target` when `source` is absent.
|
|
59
47
|
*
|
|
48
|
+
* `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
|
|
49
|
+
* `files` does: a component cannot know the map key the user will give it,
|
|
50
|
+
* and an **absolute** source gets no automatic per-service directory. A
|
|
51
|
+
* component that needs one — a nested runtime keeping its store under
|
|
52
|
+
* {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
|
|
53
|
+
* be two daemons on one metadata store — writes the token into the path.
|
|
54
|
+
*
|
|
60
55
|
* `workspace` is a parameter rather than a module constant so the rule is
|
|
61
56
|
* testable without touching the filesystem.
|
|
62
57
|
*/
|
|
@@ -64,7 +59,8 @@ export declare function resolveHostPath(service: string, vol: VolumeSpec, worksp
|
|
|
64
59
|
/**
|
|
65
60
|
* Does this volume survive a delta-restore teardown?
|
|
66
61
|
*
|
|
67
|
-
* True for cache
|
|
68
|
-
* workspace — the two ways a directory ends up beyond
|
|
62
|
+
* True for a volume on a mounted cache disk, and for an absolute source
|
|
63
|
+
* outside the workspace — the two ways a directory ends up beyond
|
|
64
|
+
* `rm -rf /workspace`.
|
|
69
65
|
*/
|
|
70
66
|
export declare function survivesTeardown(vol: VolumeSpec, service: string, workspace?: string): boolean;
|
|
@@ -6,23 +6,14 @@
|
|
|
6
6
|
* delta-restore teardown**.
|
|
7
7
|
*
|
|
8
8
|
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
-
* semantics.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
import path from "node:path";
|
|
14
|
+
import { expandServiceToken } from "./file-mounts";
|
|
22
15
|
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
23
16
|
export const DEFAULT_WORKSPACE = "/workspace";
|
|
24
|
-
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
25
|
-
export const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
26
17
|
/** Directory holding named shared volumes, under whichever root applies. */
|
|
27
18
|
export const SHARED_DIR = "_shared";
|
|
28
19
|
/**
|
|
@@ -50,30 +41,37 @@ export function sanitizeSegment(p) {
|
|
|
50
41
|
* 3. A relative `source`, or nothing at all — under the service's own
|
|
51
42
|
* directory, derived from `target` when `source` is absent.
|
|
52
43
|
*
|
|
44
|
+
* `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
|
|
45
|
+
* `files` does: a component cannot know the map key the user will give it,
|
|
46
|
+
* and an **absolute** source gets no automatic per-service directory. A
|
|
47
|
+
* component that needs one — a nested runtime keeping its store under
|
|
48
|
+
* {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
|
|
49
|
+
* be two daemons on one metadata store — writes the token into the path.
|
|
50
|
+
*
|
|
53
51
|
* `workspace` is a parameter rather than a module constant so the rule is
|
|
54
52
|
* testable without touching the filesystem.
|
|
55
53
|
*/
|
|
56
54
|
export function resolveHostPath(service, vol, workspace = DEFAULT_WORKSPACE) {
|
|
57
55
|
const stateRoot = [workspace, ".spectest", "volumes"];
|
|
56
|
+
const source = vol.source === undefined ? undefined : expandServiceToken(vol.source, service);
|
|
58
57
|
if (vol.name) {
|
|
59
|
-
|
|
60
|
-
return path.join(...root, sanitizeSegment(vol.name));
|
|
58
|
+
return path.join(...stateRoot, SHARED_DIR, sanitizeSegment(vol.name));
|
|
61
59
|
}
|
|
62
|
-
// An absolute source is the project's own path
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return path.join(...root, vol.source.replace(/^\/+/, ""));
|
|
60
|
+
// An absolute source is the project's own path, used as is.
|
|
61
|
+
if (source && source.startsWith("/"))
|
|
62
|
+
return source;
|
|
63
|
+
const root = [...stateRoot, service];
|
|
64
|
+
if (source) {
|
|
65
|
+
return path.join(...root, source.replace(/^\/+/, ""));
|
|
69
66
|
}
|
|
70
67
|
return path.join(...root, sanitizeSegment(vol.target));
|
|
71
68
|
}
|
|
72
69
|
/**
|
|
73
70
|
* Does this volume survive a delta-restore teardown?
|
|
74
71
|
*
|
|
75
|
-
* True for cache
|
|
76
|
-
* workspace — the two ways a directory ends up beyond
|
|
72
|
+
* True for a volume on a mounted cache disk, and for an absolute source
|
|
73
|
+
* outside the workspace — the two ways a directory ends up beyond
|
|
74
|
+
* `rm -rf /workspace`.
|
|
77
75
|
*/
|
|
78
76
|
export function survivesTeardown(vol, service, workspace = DEFAULT_WORKSPACE) {
|
|
79
77
|
const host = resolveHostPath(service, vol, workspace);
|
package/dist/index.d.ts
CHANGED
|
@@ -815,15 +815,6 @@ export interface VolumeMount {
|
|
|
815
815
|
/** Container path. */
|
|
816
816
|
target: string;
|
|
817
817
|
readOnly?: boolean;
|
|
818
|
-
/**
|
|
819
|
-
* Cache volume: the backing dir lives outside the per-env state tree and
|
|
820
|
-
* survives a delta-restore teardown (which recreates every container,
|
|
821
|
-
* volume, and the daemon for fresh-state semantics). Reserve this for
|
|
822
|
-
* content-addressed data whose presence is purely an accelerator — an
|
|
823
|
-
* image/layer store, a package cache — never for app state: anything in
|
|
824
|
-
* a cache volume is visible to the "fresh" environment.
|
|
825
|
-
*/
|
|
826
|
-
cache?: boolean;
|
|
827
818
|
}
|
|
828
819
|
export interface FileMount {
|
|
829
820
|
/** Absolute path inside the container where the file is mounted. */
|