@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/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
+ }