@specific.dev/spectest 0.4.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/package.json +38 -0
- package/src/browser.ts +824 -0
- package/src/components/index.ts +32 -0
- package/src/components/k3s.ts +1324 -0
- package/src/components/postgres.ts +281 -0
- package/src/components/replayFake.ts +515 -0
- package/src/daemon.ts +3910 -0
- package/src/index.ts +1601 -0
- package/src/ingress.ts +288 -0
- package/src/inspect.ts +604 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +659 -0
- package/src/resolver.ts +351 -0
- package/src/terminal.ts +740 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +520 -0
- package/src/vendor/rrweb-record.min.js +5 -0
package/src/ingress.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Low-level ingress primitives — the first-class building blocks the
|
|
2
|
+
// daemon's networking is expressed in terms of. The friendly
|
|
3
|
+
// `services.<name>.tls` / `services.<name>.hostnames` fields and
|
|
4
|
+
// `defineFake(...)` are all *sugar* that lower onto these three:
|
|
5
|
+
//
|
|
6
|
+
// certificate(hostnames) — mint a leaf cert from the in-VM root CA
|
|
7
|
+
// dnsName(hostname, target) — register a name → a container or the daemon
|
|
8
|
+
// proxy(hostname, upstream) — reverse-proxy a hostname to a service:port
|
|
9
|
+
//
|
|
10
|
+
// The daemon consumes only the lowered result (see `lowerIngress`), so it
|
|
11
|
+
// never special-cases tls/hostnames/fakes — it just executes a generic set
|
|
12
|
+
// of certs, proxy routes, DNS registrations, and aliases. A user-authored
|
|
13
|
+
// component emits the same primitives by attaching them with `provides(...)`,
|
|
14
|
+
// so it can do anything the built-ins do without any daemon change.
|
|
15
|
+
//
|
|
16
|
+
// None of this is part of the wire `EnvironmentConfig` the control plane
|
|
17
|
+
// deserialises: the decls live on the `Project` (daemon-side only) and,
|
|
18
|
+
// when attached to a service, ride on a Symbol key that `JSON.stringify`
|
|
19
|
+
// drops — so Rust's `deny_unknown_fields` never sees them.
|
|
20
|
+
|
|
21
|
+
import type { FakeDefinition, Project, ServiceConfig } from "./index.js";
|
|
22
|
+
|
|
23
|
+
/** Where a DNS name points. `ingress` = the spectest-daemon listener on the
|
|
24
|
+
* bridge gateway (fakes, TLS-terminated proxies); `service` = a container's
|
|
25
|
+
* live IP on spectest-net (a plain peer alias, no daemon hop). */
|
|
26
|
+
export type DnsTarget = { ingress: true } | { service: string };
|
|
27
|
+
|
|
28
|
+
export interface CertificateDecl {
|
|
29
|
+
kind: "certificate";
|
|
30
|
+
/** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
|
|
31
|
+
hostnames: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DnsDecl {
|
|
35
|
+
kind: "dns";
|
|
36
|
+
hostname: string;
|
|
37
|
+
target: DnsTarget;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProxyDecl {
|
|
41
|
+
kind: "proxy";
|
|
42
|
+
hostname: string;
|
|
43
|
+
upstream: { service: string; port: number };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
47
|
+
|
|
48
|
+
/** Token a component can use in a decl where it can't know its own
|
|
49
|
+
* services-map key yet — resolved to that key during `lowerIngress`.
|
|
50
|
+
* Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
|
|
51
|
+
export const SELF_SERVICE_TOKEN = "{{SPECTEST_SERVICE}}";
|
|
52
|
+
|
|
53
|
+
// Multi-label hostname: at least one dot, each label 1–63 chars of
|
|
54
|
+
// [a-z0-9-], no leading/trailing hyphen. Kept in lockstep with index.ts.
|
|
55
|
+
const HOSTNAME_RE =
|
|
56
|
+
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
57
|
+
|
|
58
|
+
function assertHostname(h: string, ctx: string): void {
|
|
59
|
+
const lower = h.toLowerCase();
|
|
60
|
+
if (!HOSTNAME_RE.test(lower)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${ctx}: invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (lower === "internal" || lower.endsWith(".internal")) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${ctx}: hostname ${JSON.stringify(h)} ends in the reserved ".internal" TLD`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A wildcard is `*.` + a normal multi-label hostname (e.g.
|
|
73
|
+
* `*.example.com`). It matches any name ending in that suffix. */
|
|
74
|
+
export function isWildcard(hostname: string): boolean {
|
|
75
|
+
return hostname.startsWith("*.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Validate an exact hostname OR a `*.suffix` wildcard. */
|
|
79
|
+
function assertNameOrWildcard(h: string, ctx: string): void {
|
|
80
|
+
if (isWildcard(h)) {
|
|
81
|
+
// The bit after `*.` must itself be a valid multi-label hostname, so
|
|
82
|
+
// `*.example.com` is fine but `*.com` (too broad) and `*` are not.
|
|
83
|
+
assertHostname(h.slice(2), `${ctx} wildcard`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
assertHostname(h, ctx);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Request a leaf certificate (signed by the in-VM root CA) covering
|
|
91
|
+
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
92
|
+
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
93
|
+
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
94
|
+
*/
|
|
95
|
+
export function certificate(hostnames: string[]): CertificateDecl {
|
|
96
|
+
if (!Array.isArray(hostnames) || hostnames.length === 0) {
|
|
97
|
+
throw new Error("certificate(): at least one hostname is required");
|
|
98
|
+
}
|
|
99
|
+
for (const h of hostnames) assertHostname(h, "certificate()");
|
|
100
|
+
return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register `hostname` in spectest's DNS. `{ ingress: true }` points it at
|
|
105
|
+
* the daemon (for fakes / TLS proxies — resolved to the bridge gateway and
|
|
106
|
+
* injected as `--add-host` into every container); `{ service }` makes it an
|
|
107
|
+
* extra peer alias for that container (resolved live to its IP). The
|
|
108
|
+
* `SELF_SERVICE_TOKEN` may be used for `service` when a component can't yet
|
|
109
|
+
* know its own key.
|
|
110
|
+
*/
|
|
111
|
+
export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
|
|
112
|
+
assertNameOrWildcard(hostname, "dnsName()");
|
|
113
|
+
if (!("ingress" in target) && !("service" in target)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`dnsName(${JSON.stringify(hostname)}): target must be { ingress: true } or { service }`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { kind: "dns", hostname: hostname.toLowerCase(), target };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reverse-proxy `hostname` to `http://<service>:<port>` on spectest-net.
|
|
123
|
+
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
124
|
+
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
125
|
+
* serve it over HTTPS as well as HTTP.
|
|
126
|
+
*/
|
|
127
|
+
export function proxy(
|
|
128
|
+
hostname: string,
|
|
129
|
+
upstream: { service: string; port: number },
|
|
130
|
+
): ProxyDecl {
|
|
131
|
+
assertHostname(hostname, "proxy()");
|
|
132
|
+
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Symbol key under which `provides(...)` stashes a component's ingress
|
|
141
|
+
* decls on its service object. A Symbol so `JSON.stringify(environment)`
|
|
142
|
+
* (the wire config shipped to the Rust control plane) drops it — symbol
|
|
143
|
+
* keys are never serialised, regardless of enumerability. Kept
|
|
144
|
+
* *enumerable* so it survives the common `{ ...component() }` spread (object
|
|
145
|
+
* spread copies enumerable symbol props but skips non-enumerable ones). */
|
|
146
|
+
const INGRESS_DECLS: unique symbol = Symbol.for("spectest.ingress.decls");
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Attach low-level ingress decls to a service definition a component
|
|
150
|
+
* returns. The decls are read by `lowerIngress` at load time and never
|
|
151
|
+
* serialised into the wire config.
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* return provides({ image, command }, [
|
|
155
|
+
* certificate(["app.test"]),
|
|
156
|
+
* dnsName("app.test", { ingress: true }),
|
|
157
|
+
* proxy("app.test", { service: SELF_SERVICE_TOKEN, port: 3000 }),
|
|
158
|
+
* ]);
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export function provides<T extends ServiceConfig>(
|
|
162
|
+
service: T,
|
|
163
|
+
decls: IngressDecl[],
|
|
164
|
+
): T {
|
|
165
|
+
// Merge with any decls already attached (e.g. a component built via
|
|
166
|
+
// provides() that the caller then wraps again) so neither layer is lost.
|
|
167
|
+
const prior = readProvided(service);
|
|
168
|
+
Object.defineProperty(service, INGRESS_DECLS, {
|
|
169
|
+
value: [...prior, ...decls],
|
|
170
|
+
enumerable: true,
|
|
171
|
+
configurable: true,
|
|
172
|
+
writable: true,
|
|
173
|
+
});
|
|
174
|
+
return service;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readProvided(service: ServiceConfig): IngressDecl[] {
|
|
178
|
+
const decls = (service as unknown as Record<symbol, unknown>)[INGRESS_DECLS];
|
|
179
|
+
return Array.isArray(decls) ? (decls as IngressDecl[]) : [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Everything the daemon needs to stand up ingress, derived once per
|
|
183
|
+
* /load. The daemon executes this generically — it reads neither
|
|
184
|
+
* `svc.tls` nor `svc.hostnames` directly. */
|
|
185
|
+
export interface LoweredIngress {
|
|
186
|
+
/** One leaf cert per group; SANs = the group's hostnames. */
|
|
187
|
+
certificates: { hostnames: string[] }[];
|
|
188
|
+
/** Reverse-proxy routes: hostname → upstream service:port. */
|
|
189
|
+
proxies: { hostname: string; service: string; port: number }[];
|
|
190
|
+
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
191
|
+
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
|
192
|
+
* any `dnsName(h, { ingress: true })`. */
|
|
193
|
+
ingressHosts: string[];
|
|
194
|
+
/** service key → extra `--network-alias`es (from `hostnames` /
|
|
195
|
+
* `dnsName(h, { service })`). */
|
|
196
|
+
aliasesByService: Record<string, string[]>;
|
|
197
|
+
/** Wildcard suffixes (`*.example.com` → `.example.com`) and where they
|
|
198
|
+
* point. Resolved to a concrete IP in the daemon (service → that
|
|
199
|
+
* container's IP, ingress → the bridge gateway) and written to the
|
|
200
|
+
* resolver's wildcard table. Only the resolver answers these — they
|
|
201
|
+
* can't be expressed as `--network-alias`/`--add-host`. */
|
|
202
|
+
wildcards: { pattern: string; target: DnsTarget }[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolveSelf(service: string, selfKey: string): string {
|
|
206
|
+
return service === SELF_SERVICE_TOKEN ? selfKey : service;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Lower a project's friendly surface (`services.*.tls`,
|
|
211
|
+
* `services.*.hostnames`, component `provides(...)`, and `fakes`) into the
|
|
212
|
+
* generic `LoweredIngress` the daemon executes. This is the framework
|
|
213
|
+
* abstraction the user asked for: the special-casing lives here, in one
|
|
214
|
+
* pure function, instead of being scattered through the daemon.
|
|
215
|
+
*/
|
|
216
|
+
export function lowerIngress(project: Project): LoweredIngress {
|
|
217
|
+
const certificates: { hostnames: string[] }[] = [];
|
|
218
|
+
const proxies: { hostname: string; service: string; port: number }[] = [];
|
|
219
|
+
const ingressSet = new Set<string>();
|
|
220
|
+
const aliasesByService: Record<string, string[]> = {};
|
|
221
|
+
const wildcards: { pattern: string; target: DnsTarget }[] = [];
|
|
222
|
+
|
|
223
|
+
const addAlias = (service: string, host: string): void => {
|
|
224
|
+
(aliasesByService[service] ??= []).push(host.toLowerCase());
|
|
225
|
+
};
|
|
226
|
+
const applyDecl = (decl: IngressDecl, selfKey: string): void => {
|
|
227
|
+
switch (decl.kind) {
|
|
228
|
+
case "certificate":
|
|
229
|
+
certificates.push({ hostnames: decl.hostnames });
|
|
230
|
+
break;
|
|
231
|
+
case "proxy": {
|
|
232
|
+
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
233
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
234
|
+
// A proxied hostname must route to the daemon.
|
|
235
|
+
ingressSet.add(decl.hostname);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "dns": {
|
|
239
|
+
// A wildcard can only be answered by the resolver (no
|
|
240
|
+
// --network-alias / --add-host equivalent), so route either kind
|
|
241
|
+
// of target through the wildcard table; the daemon resolves the IP.
|
|
242
|
+
if (isWildcard(decl.hostname)) {
|
|
243
|
+
const target =
|
|
244
|
+
"service" in decl.target
|
|
245
|
+
? { service: resolveSelf(decl.target.service, selfKey) }
|
|
246
|
+
: decl.target;
|
|
247
|
+
wildcards.push({ pattern: decl.hostname, target });
|
|
248
|
+
} else if ("ingress" in decl.target) {
|
|
249
|
+
ingressSet.add(decl.hostname);
|
|
250
|
+
} else {
|
|
251
|
+
addAlias(resolveSelf(decl.target.service, selfKey), decl.hostname);
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
for (const [name, svc] of Object.entries(project.environment.services)) {
|
|
259
|
+
// Component-provided explicit decls.
|
|
260
|
+
for (const decl of readProvided(svc)) applyDecl(decl, name);
|
|
261
|
+
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
262
|
+
for (const entry of (svc as ServiceConfig).tls ?? []) {
|
|
263
|
+
applyDecl(certificate([entry.hostname]), name);
|
|
264
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
|
|
265
|
+
}
|
|
266
|
+
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
267
|
+
for (const h of (svc as ServiceConfig).hostnames ?? []) {
|
|
268
|
+
applyDecl(dnsName(h, { service: name }), name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Fakes: in-daemon handlers. The handler routing stays keyed by hostname
|
|
273
|
+
// in the daemon's FAKES map; here we only contribute their networking
|
|
274
|
+
// (a leaf cert for HTTPS + ingress DNS).
|
|
275
|
+
for (const fake of Object.values(project.fakes ?? {}) as FakeDefinition[]) {
|
|
276
|
+
const hostnames = fake.hostnames.map((h) => h.toLowerCase());
|
|
277
|
+
certificates.push({ hostnames });
|
|
278
|
+
for (const h of hostnames) ingressSet.add(h);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
certificates,
|
|
283
|
+
proxies,
|
|
284
|
+
ingressHosts: [...ingressSet],
|
|
285
|
+
aliasesByService,
|
|
286
|
+
wildcards,
|
|
287
|
+
};
|
|
288
|
+
}
|