@specific.dev/spectest 0.24.0 → 0.27.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1807
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -1,1312 +0,0 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import { spawn as nodeSpawn } from "node:child_process";
3
- import { randomUUID } from "node:crypto";
4
- import { existsSync, readFileSync } from "node:fs";
5
- import { readFile, unlink } from "node:fs/promises";
6
-
7
- import {
8
- AppsV1Api,
9
- CoreV1Api,
10
- KubeConfig,
11
- KubernetesObjectApi,
12
- ResponseContext,
13
- ServerConfiguration,
14
- createConfiguration,
15
- loadAllYaml,
16
- type KubernetesObject,
17
- type RequestContext,
18
- } from "@kubernetes/client-node";
19
- import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
20
-
21
- import type { ServiceDefinition, ServiceHelpersContext } from "../index.js";
22
- import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
23
- import { readRaw, readTag, wrap } from "../inspect.js";
24
- import type { Wrapped } from "../inspect.js";
25
- import { recorderAnnotate, recorderRemove } from "../recorder.js";
26
-
27
- export interface K3sOptions {
28
- /** Image tag for the official `rancher/k3s` image. Default `"v1.30.6-k3s1"`. */
29
- version?: string;
30
- /**
31
- * Extra arguments appended to `k3s server`. Useful for `--tls-san=...`,
32
- * additional `--disable=<addon>`, custom CIDRs, etc.
33
- */
34
- extraArgs?: string[];
35
- /**
36
- * Readiness probe timeout in seconds. k3s on a warm image is ready in
37
- * a few seconds; the first cold start of an env (image pull + cluster
38
- * bootstrap) can take 30–60s. Default `120`.
39
- */
40
- readyTimeoutSecs?: number;
41
- /**
42
- * Run an in-cluster OCI registry (CNCF `distribution` / `registry:2`)
43
- * that the cluster's own containerd trusts. This is the hermetic
44
- * stand-in for a cloud registry (ECR/GCR/GHCR): a peer service builds
45
- * an image, pushes it here over plain HTTP, references
46
- * `<cluster-key>.internal:5000/...` from a Deployment, and the kubelet
47
- * pulls it straight back. Lets you test a real
48
- * `build → push → deploy → pull` pipeline with no external registry
49
- * and no image pre-baking.
50
- *
51
- * **The registry's push/pull address is `<cluster-key>.internal:5000`**,
52
- * where `<cluster-key>` is the key you give this service in the
53
- * `services` map (e.g. a cluster at `services.cluster` is reachable at
54
- * `cluster.internal:5000`). That's the cluster service's own
55
- * unconditional `.internal` alias — peer-reachable and never clobbered
56
- * by any `hostnames` you set — so it resolves identically from peer
57
- * containers (push) and the cluster's own containerd (pull). Wire it
58
- * into your platform, e.g. `env: { REGISTRY_URL: "cluster.internal:5000" }`.
59
- * Plain HTTP, so configure your push client for an insecure registry.
60
- *
61
- * On by default. Set `false` for clusters that only ever run public
62
- * images — that skips the extra pod.
63
- */
64
- registry?: boolean;
65
- /**
66
- * Domains to route into this cluster's ingress via **wildcard DNS**. For
67
- * each `"example.com"`, spectest-resolver answers any `*.example.com`
68
- * query with the cluster container's IP, where Traefik dispatches by Host
69
- * to the matching Ingress. This lets a test `kubectl apply` an Ingress for
70
- * any host under the domain and reach it immediately — no need to
71
- * pre-declare each hostname in `hostnames`.
72
- *
73
- * ```ts
74
- * services: { k8s: k3s({ ingressDomains: ["example.com"] }) }
75
- * // a test then applies an Ingress for foo.example.com and fetches it.
76
- * ```
77
- *
78
- * For one-off hosts not under a declared domain, a test can also register
79
- * dynamically with `ctx.dnsName(host, { service: "k8s" })`.
80
- *
81
- * **TLS.** Setting `ingressDomains` also makes those domains reachable
82
- * over **HTTPS**: Traefik gains a `:443` entrypoint and serves a default
83
- * certificate minted from the in-VM root CA with SANs `*.<domain>` for
84
- * each declared domain. The CA is already trusted by the test framework
85
- * (Node `fetch`, `ctx.browser()`, Python, the system store), so
86
- * `ctx.fetch("https://foo.example.com")` gets a clean handshake — no
87
- * per-Ingress `spec.tls` and no `--insecure` needed. Only hosts **under**
88
- * a declared domain are covered by the cert; static `hostnames` not under
89
- * one (and one-off `ctx.dnsName` hosts) remain HTTP-only.
90
- */
91
- ingressDomains?: string[];
92
- }
93
-
94
- /** Port the in-cluster registry listens on (plain HTTP). */
95
- const K3S_REGISTRY_PORT = 5000;
96
-
97
- /**
98
- * Rewrites a `@kubernetes/client-node` API class so each method's resolved
99
- * value comes back **inspect-wrapped** ({@link Wrapped}) — exactly what
100
- * `withTagging` does at runtime. The method *signatures* (argument types) are
101
- * untouched; only the `Promise<R>` result becomes `Promise<Wrapped<R>>`, so
102
- * `expect(pod.status.phase)` links to the API call with no cast and you
103
- * `.unwrap()` before using a value as raw data. Non-method
104
- * members pass through unchanged. Mirrors {@link RecordingSqlClient} on the
105
- * postgres side.
106
- */
107
- type Tagged<T> = {
108
- [K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R>
109
- ? (...args: A) => Promise<Wrapped<R>>
110
- : T[K];
111
- };
112
-
113
- /**
114
- * Pre-instantiated `@kubernetes/client-node` API clients sharing the
115
- * same recording HTTP transport. Every method call lands on the test
116
- * event log as an HTTP event alongside `fetch` calls, and its result is
117
- * inspect-wrapped (see {@link Tagged}) so assertions on it stay linked.
118
- */
119
- export interface K3sClient {
120
- core: Tagged<CoreV1Api>;
121
- apps: Tagged<AppsV1Api>;
122
- /** Generic object API — `create()`, `read()`, `patch()`, `delete()`
123
- * against any Kubernetes resource (custom resources included). */
124
- objects: Tagged<KubernetesObjectApi>;
125
- }
126
-
127
- /** Helpers a `k3s(...)` service exposes on `ctx.svc.<name>`. */
128
- export interface K3sHelpers {
129
- /**
130
- * Fully-loaded `KubeConfig`. The cluster server URL is rewritten to
131
- * `https://<service-name>.internal:6443`, the auto-assigned DNS name
132
- * for this service on `spectest-net`. TLS verification is off because
133
- * Bun's fetch doesn't honor an https.Agent's CA option (the client
134
- * cert from the kubeconfig still flows through for auth), so the
135
- * server's cert SAN list doesn't need to include the .internal name.
136
- */
137
- kubeconfig: KubeConfig;
138
- /** Pre-built API clients. */
139
- client: K3sClient;
140
- /**
141
- * Apply a (multi-document) YAML manifest. Each parsed document is
142
- * created via `KubernetesObjectApi.create`. Returns the API server's
143
- * response objects in input order — each element inspect-wrapped (the
144
- * array container itself is plain), so `expect(created[0]!.metadata.uid)`
145
- * links to its create call.
146
- */
147
- apply: (manifest: string) => Promise<Wrapped<KubernetesObject>[]>;
148
- }
149
-
150
- interface DockerExecResult {
151
- stdout: string;
152
- stderr: string;
153
- code: number;
154
- }
155
-
156
- function runProcess(
157
- cmd: string,
158
- args: string[],
159
- timeoutMs = 30_000,
160
- ): Promise<DockerExecResult> {
161
- return new Promise((resolve, reject) => {
162
- const cp = nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
163
- const out: Buffer[] = [];
164
- const err: Buffer[] = [];
165
- cp.stdout!.on("data", (c) => out.push(c));
166
- cp.stderr!.on("data", (c) => err.push(c));
167
- const t = setTimeout(() => cp.kill("SIGKILL"), timeoutMs);
168
- cp.on("error", (e) => {
169
- clearTimeout(t);
170
- reject(e);
171
- });
172
- cp.on("close", (code) => {
173
- clearTimeout(t);
174
- resolve({
175
- stdout: Buffer.concat(out).toString("utf8"),
176
- stderr: Buffer.concat(err).toString("utf8"),
177
- code: code ?? -1,
178
- });
179
- });
180
- });
181
- }
182
-
183
- // In-VM root CA, generated once into the base snapshot (see
184
- // control-plane `base.rs`). Trusted everywhere the test framework runs —
185
- // Node (`NODE_EXTRA_CA_CERTS`), Chromium (NSS DB), Python, the system
186
- // store — so a leaf signed by it gives `ctx.fetch`/`ctx.browser()` a
187
- // clean HTTPS handshake. The k3s `setup` hook runs inside the daemon's
188
- // Bun process (root in the VM), so it can read the CA key and mint
189
- // directly. These constants are intentionally redeclared here rather than
190
- // imported from the daemon: the SDK ships to end users and must not
191
- // depend on daemon internals.
192
- const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
193
- const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
194
-
195
- function caPresent(): boolean {
196
- return existsSync(CA_PATH) && existsSync(CA_KEY_PATH);
197
- }
198
-
199
- /**
200
- * Mint a leaf certificate from the in-VM root CA covering `hostnames`
201
- * (used here as the SANs of the cluster's wildcard ingress domains).
202
- * Returns the cert + key as PEM strings. Self-contained openssl shell-out
203
- * — deliberately not shared with the daemon's own cert minting to keep
204
- * the distributed SDK decoupled from daemon code.
205
- */
206
- async function issueIngressCert(
207
- hostnames: string[],
208
- ): Promise<{ cert: string; key: string }> {
209
- const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
210
- const keyPath = `/tmp/${id}.key`;
211
- const crtPath = `/tmp/${id}.crt`;
212
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
213
- const r = await runProcess(
214
- "openssl",
215
- [
216
- "req",
217
- "-newkey",
218
- "rsa:2048",
219
- "-nodes",
220
- "-keyout",
221
- keyPath,
222
- "-out",
223
- crtPath,
224
- "-x509",
225
- "-CA",
226
- CA_PATH,
227
- "-CAkey",
228
- CA_KEY_PATH,
229
- "-days",
230
- "3650",
231
- "-subj",
232
- "/CN=spectest-k3s-ingress",
233
- "-addext",
234
- `subjectAltName=${sans}`,
235
- "-addext",
236
- "basicConstraints=CA:FALSE",
237
- "-addext",
238
- "extendedKeyUsage=serverAuth",
239
- "-addext",
240
- "keyUsage=digitalSignature,keyEncipherment",
241
- ],
242
- 30_000,
243
- );
244
- if (r.code !== 0) {
245
- throw new Error(
246
- `k3s ingress cert minting failed (openssl rc=${r.code}): ${
247
- r.stderr.trim() || r.stdout.trim()
248
- }`,
249
- );
250
- }
251
- try {
252
- const [cert, key] = await Promise.all([
253
- readFile(crtPath, "utf8"),
254
- readFile(keyPath, "utf8"),
255
- ]);
256
- return { cert, key };
257
- } finally {
258
- await Promise.all([
259
- unlink(keyPath).catch(() => {}),
260
- unlink(crtPath).catch(() => {}),
261
- ]);
262
- }
263
- }
264
-
265
- // Holds the inspector `sourceSeq` for the most recent HTTP call inside
266
- // a single API-method invocation. Filled in by `doFetch` after each
267
- // request, read by the `withTagging` proxy when the method's promise
268
- // resolves so the returned parsed object carries the back-reference.
269
- // AsyncLocalStorage is the right scope here — every call to an Api
270
- // method runs in its own holder and concurrent calls don't race.
271
- interface CallSlot {
272
- seq?: number;
273
- }
274
- const callContext = new AsyncLocalStorage<CallSlot>();
275
-
276
- // HTTP transport for `@kubernetes/client-node` that routes through
277
- // `globalThis.fetch`. Two reasons:
278
- // 1. The daemon's per-test `installFetchWrapper` already records every
279
- // `globalThis.fetch` call as an HTTP event — using fetch here gets
280
- // k8s API calls recorded for free, no library-specific wiring.
281
- // 2. Bun's fetch needs Bun-shaped TLS options (`tls: { ... }`) for
282
- // mTLS; node-fetch's `agent` parameter — which the library's
283
- // default transport relies on — is silently ignored under Bun.
284
- // We honor the lib's auth flow (`KubeConfig.applySecurityAuthentication`
285
- // sets an Agent on the request) by extracting cert/key off that
286
- // agent and passing them via the Bun-shaped option.
287
- class FetchHttpLibrary {
288
- send(request: RequestContext): Observable<ResponseContext> {
289
- const promise = doFetch(request);
290
- return new Observable(promise);
291
- }
292
- }
293
-
294
- /**
295
- * Parsed Kubernetes semantics of a single API request, derived purely
296
- * from the HTTP method + request path. Fed to `recorderAnnotate` to
297
- * reclassify the generic `http` event the fetch wrapper recorded into a
298
- * Kubernetes-specific `kube` event.
299
- */
300
- interface KubeRequestMeta {
301
- verb: string;
302
- group?: string;
303
- apiVersion?: string;
304
- resource?: string;
305
- subresource?: string;
306
- name?: string;
307
- namespace?: string;
308
- }
309
-
310
- /**
311
- * Map a Kubernetes API request to its `(verb, group/version, resource,
312
- * namespace, name, subresource)` from the URL + HTTP method alone.
313
- *
314
- * Path grammar (the two API roots):
315
- * - core group: `/api/<version>/...`
316
- * - named group: `/apis/<group>/<version>/...`
317
- * after which the remainder is either a cluster-scoped resource
318
- * (`nodes`, `namespaces`, …) or `namespaces/<ns>/<resource>...`. The
319
- * trailing `<resource>[/<name>[/<subresource>]]` shape plus the method
320
- * (and `?watch=`) yields the verb.
321
- *
322
- * Returns `null` for non-resource paths — discovery (`/api`, `/apis`,
323
- * `/apis/<group>/<version>`), `/version`, `/healthz`, `/openapi/...` —
324
- * so those stay rendered as plain `http`.
325
- */
326
- function describeKubeRequest(
327
- method: string,
328
- rawUrl: string,
329
- ): KubeRequestMeta | null {
330
- let path: string;
331
- let query: URLSearchParams;
332
- try {
333
- const u = new URL(rawUrl);
334
- path = u.pathname;
335
- query = u.searchParams;
336
- } catch {
337
- const q = rawUrl.indexOf("?");
338
- path = q === -1 ? rawUrl : rawUrl.slice(0, q);
339
- query = new URLSearchParams(q === -1 ? "" : rawUrl.slice(q + 1));
340
- }
341
-
342
- const segs = path.split("/").filter((s) => s.length > 0);
343
- if (segs.length === 0) return null;
344
-
345
- let group: string | undefined;
346
- let apiVersion: string | undefined;
347
- let rest: string[];
348
- if (segs[0] === "api") {
349
- group = "";
350
- apiVersion = segs[1];
351
- rest = segs.slice(2);
352
- } else if (segs[0] === "apis") {
353
- group = segs[1];
354
- apiVersion = segs[2];
355
- rest = segs.slice(3);
356
- } else {
357
- return null; // /version, /healthz, /openapi, …
358
- }
359
- if (!apiVersion) return null; // discovery root (/api, /apis/<group>)
360
-
361
- // `namespaces/<ns>/<resource>...` is namespaced; everything else
362
- // (including `namespaces` and `namespaces/<name>` themselves, and
363
- // cluster-scoped resources like `nodes`) is taken as-is.
364
- let namespace: string | undefined;
365
- let resourcePath = rest;
366
- if (rest[0] === "namespaces" && rest.length >= 3) {
367
- namespace = rest[1];
368
- resourcePath = rest.slice(2);
369
- }
370
- if (resourcePath.length === 0) return null; // APIResourceList discovery
371
-
372
- const resource = resourcePath[0];
373
- const name = resourcePath.length >= 2 ? resourcePath[1] : undefined;
374
- const subresource = resourcePath.length >= 3 ? resourcePath[2] : undefined;
375
-
376
- const watchParam = query.get("watch");
377
- const watch = watchParam === "true" || watchParam === "1";
378
- const hasName = name !== undefined;
379
- let verb: string;
380
- switch (method.toUpperCase()) {
381
- case "GET":
382
- case "HEAD":
383
- verb = hasName ? "get" : watch ? "watch" : "list";
384
- break;
385
- case "POST":
386
- verb = "create";
387
- break;
388
- case "PUT":
389
- verb = "update";
390
- break;
391
- case "PATCH":
392
- verb = "patch";
393
- break;
394
- case "DELETE":
395
- verb = hasName ? "delete" : "deletecollection";
396
- break;
397
- default:
398
- verb = method.toLowerCase();
399
- }
400
-
401
- return { verb, group, apiVersion, resource, subresource, name, namespace };
402
- }
403
-
404
- /**
405
- * True for Kubernetes API *discovery* paths — the version/group/resource
406
- * enumeration endpoints (`/api`, `/api/<version>`, `/apis`, `/apis/<group>`,
407
- * `/apis/<group>/<version>`) the dynamic client hits to resolve a kind to
408
- * its resource path. They carry no resource operation (so
409
- * `describeKubeRequest` returns null), and `doFetch` retracts their events
410
- * from the timeline. Non-resource paths that are NOT discovery (`/healthz`,
411
- * `/version`, `/openapi`, …) are deliberately not matched — they stay as
412
- * `http`.
413
- */
414
- function isKubeDiscoveryPath(rawUrl: string): boolean {
415
- let path: string;
416
- try {
417
- path = new URL(rawUrl).pathname;
418
- } catch {
419
- const q = rawUrl.indexOf("?");
420
- path = q === -1 ? rawUrl : rawUrl.slice(0, q);
421
- }
422
- const segs = path.split("/").filter((s) => s.length > 0);
423
- if (segs.length === 0) return false;
424
- // `/api` + `/api/<version>`; `/apis` + `/apis/<group>` + `/apis/<group>/<version>`.
425
- // Anything longer carries a resource segment and is handled as `kube`.
426
- if (segs[0] === "api") return segs.length <= 2;
427
- if (segs[0] === "apis") return segs.length <= 3;
428
- return false;
429
- }
430
-
431
- async function doFetch(request: RequestContext): Promise<ResponseContext> {
432
- const url = request.getUrl();
433
- const method = String(request.getHttpMethod());
434
- const body = request.getBody();
435
- const reqHeaders: Record<string, string> = {};
436
- for (const [k, v] of Object.entries(request.getHeaders())) {
437
- reqHeaders[k] = String(v);
438
- }
439
-
440
- // The library's auth flow puts client cert/key on an https.Agent
441
- // attached to the request. Pull them out so we can hand them to Bun's
442
- // fetch via its `tls` option.
443
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
444
- const agent = request.getAgent() as any;
445
- const agentOpts = agent?.options ?? {};
446
- const tlsOpts: Record<string, unknown> = { rejectUnauthorized: false };
447
- if (agentOpts.cert) tlsOpts.cert = agentOpts.cert;
448
- if (agentOpts.key) tlsOpts.key = agentOpts.key;
449
-
450
- const wrapped = await fetch(url, {
451
- method,
452
- headers: reqHeaders,
453
- body: body as BodyInit | undefined,
454
- signal: request.getSignal(),
455
- // Bun-specific TLS shape; under Node this option is ignored.
456
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
457
- tls: tlsOpts,
458
- } as RequestInit);
459
-
460
- // Before unwrapping, capture the inspector tag the daemon's fetch
461
- // wrapper installed on the Response. We feed the seq back through
462
- // AsyncLocalStorage so the API method's eventual return value can
463
- // re-acquire it — otherwise the chain `core.listNode() →
464
- // expect(result)` would record assertions with no back-reference.
465
- const tag = readTag(wrapped);
466
- const slot = callContext.getStore();
467
- if (slot && tag) slot.seq = tag.sourceSeq;
468
-
469
- // Reclassify the `http` event the fetch wrapper just recorded into a
470
- // Kubernetes-specific `kube` event (verb/resource/namespace/name), so
471
- // the timeline reads `list pods · default` rather than the raw API URL.
472
- // A `tag` is only present when recording was active for this call, so
473
- // this is a no-op outside instrumented test runs.
474
- if (tag && tag.sourceSeq !== undefined) {
475
- const meta = describeKubeRequest(method, url);
476
- if (meta) {
477
- recorderAnnotate(tag.sourceSeq, { kind: "kube", ...meta });
478
- } else if (isKubeDiscoveryPath(url)) {
479
- // The dynamic client (`objects`, KubernetesObjectApi) can't know a
480
- // kind's resource path ahead of time, so before the real request it
481
- // GETs the group's resource list (`/apis/<group>/<version>` →
482
- // APIResourceList) to map e.g. Ingress → `ingresses`/namespaced, then
483
- // caches it (apiVersionResourceCache). That discovery GET is library
484
- // plumbing the test author never wrote, and because the cache is
485
- // per-daemon-process it surfaces non-deterministically across forks
486
- // (first access pays it; `dependsOn` children inheriting the warm
487
- // cache don't). Retract it rather than leave a bare `http` row — the
488
- // real list/read that follows is recorded and reclassified as usual.
489
- recorderRemove(tag.sourceSeq);
490
- }
491
- // Other non-resource paths (/healthz, /version, /openapi, …) fall
492
- // through and stay rendered as plain `http`.
493
- }
494
-
495
- // The daemon's fetch wrapper proxies `Response.status` and similar
496
- // primitives as carrier objects (so test assertions can fold under
497
- // the originating HTTP event). The kubernetes/client-node lib calls
498
- // `httpStatusCode.toString()` which would then return
499
- // "[object Object]" and the status-code dispatch falls through to
500
- // "Unknown API Status Code!". Pull out the raw Response.
501
- const response =
502
- (wrapped as { unwrap?: () => Response }).unwrap?.() ?? wrapped;
503
-
504
- const resHeaders: Record<string, string> = {};
505
- response.headers.forEach((v, k) => {
506
- resHeaders[k] = v;
507
- });
508
- const buf = Buffer.from(await response.arrayBuffer());
509
- return new ResponseContext(response.status, resHeaders, {
510
- text: async () => buf.toString("utf8"),
511
- binary: async () => buf,
512
- });
513
- }
514
-
515
- /**
516
- * Recursively strip the inspector's carrier/proxy wrappers from a
517
- * value. Needed for arguments flowing into the kubernetes/client-node
518
- * API methods — if a wrapped pod's `metadata.name` (a primitive-carrier
519
- * object) reaches a URL template, the lib stringifies it to
520
- * `"[object Object]"` and the request 404s.
521
- */
522
- function deepUnwrap(value: unknown): unknown {
523
- if (value === null || value === undefined) return value;
524
- const raw = readRaw(value);
525
- if (raw !== value) return deepUnwrap(raw);
526
- if (typeof value !== "object") return value;
527
- if (Array.isArray(value)) return value.map(deepUnwrap);
528
- const out: Record<string, unknown> = {};
529
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
530
- out[k] = deepUnwrap(v);
531
- }
532
- return out;
533
- }
534
-
535
- /**
536
- * Wrap a `@kubernetes/client-node` Api instance so each method call
537
- * runs in its own AsyncLocalStorage slot — `doFetch` writes the HTTP
538
- * event's `sourceSeq` into the slot, and after the lib parses the
539
- * response we re-attach the seq to the returned object. Downstream
540
- * `expect(result.items[0].status…)` assertions then fold under that
541
- * HTTP event in the test event log, the same way `expect(res.status)`
542
- * does for plain `fetch` calls.
543
- *
544
- * Method arguments are deep-unwrapped on the way in so values pulled
545
- * from a previous API response (still carrying the inspector wrappers)
546
- * can be passed straight back into another call.
547
- *
548
- * Non-function properties pass through untagged.
549
- */
550
- function withTagging<T extends object>(api: T): Tagged<T> {
551
- return new Proxy(api, {
552
- get(target, prop, receiver) {
553
- const value = Reflect.get(target, prop, receiver);
554
- if (typeof value !== "function") return value;
555
- // Bind the original method to `target` so the lib's internal
556
- // `this.configuration` accesses keep working.
557
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
558
- const method = (value as any).bind(target);
559
- return (...args: unknown[]): unknown => {
560
- const unwrappedArgs = args.map(deepUnwrap);
561
- const slot: CallSlot = {};
562
- const result = callContext.run(slot, () =>
563
- method(...unwrappedArgs),
564
- );
565
- // Wrap unconditionally — `slot.seq` is undefined when no event was
566
- // recorded (setup/eval, no active recorder), but the result's type is
567
- // wrapped, so the value must be wrapped at runtime too (just without a
568
- // provenance link). Keeps `.unwrap()` available in every context.
569
- if (result && typeof (result as Promise<unknown>).then === "function") {
570
- return (result as Promise<unknown>).then((v) => wrap(v, slot.seq));
571
- }
572
- return wrap(result, slot.seq);
573
- };
574
- },
575
- }) as unknown as Tagged<T>;
576
- }
577
-
578
- /**
579
- * Image tag for the Traefik we install. Pulled on first cluster boot
580
- * through the host `zot` mirror (`docker.io` → cache) configured in
581
- * `registries.yaml`, then captured into the warm-template snapshot so
582
- * warm starts never pull.
583
- */
584
- const TRAEFIK_IMAGE = "rancher/mirrored-library-traefik:3.3.2";
585
-
586
- /**
587
- * Names of the in-cluster resources that carry the CA-signed default
588
- * ingress certificate (created in `setupK3sCluster` when TLS is enabled).
589
- * The Secret holds the leaf cert+key; the ConfigMap holds the Traefik
590
- * file-provider snippet that points the `default` TLS store at it.
591
- */
592
- const TRAEFIK_TLS_SECRET = "traefik-default-tls";
593
- const TRAEFIK_DYNAMIC_CONFIGMAP = "traefik-dynamic";
594
-
595
- /**
596
- * Traefik file-provider dynamic config: make the in-VM-CA leaf the
597
- * `default` store certificate, so every router on the `websecure`
598
- * entrypoint (which we force to TLS) serves it with no per-Ingress
599
- * `spec.tls` needed.
600
- */
601
- const TRAEFIK_DYNAMIC_TLS = `tls:
602
- stores:
603
- default:
604
- defaultCertificate:
605
- certFile: /certs/tls.crt
606
- keyFile: /certs/tls.key
607
- `;
608
-
609
- /**
610
- * Traefik manifest applied during setup(). `hostNetwork: true` puts
611
- * Traefik in the k3s container's netns, so it binds the container's :80
612
- * (and, with `tls`, :443) directly — no CNI portmap involved (that path
613
- * still trips on the kernel's missing xt_comment match).
614
- *
615
- * When `tls` is set we add a `websecure` :443 entrypoint with TLS forced
616
- * on (served from the `default` store, i.e. the in-VM-CA leaf mounted
617
- * from the `traefik-default-tls` Secret via the file provider). HTTPS
618
- * then works for any routed host under the cluster's `ingressDomains`
619
- * with zero per-Ingress config; the :80 `web` entrypoint is unchanged.
620
- */
621
- function buildTraefikManifest(tls: boolean): string {
622
- const args = [
623
- " - --entrypoints.web.address=:80",
624
- ...(tls
625
- ? [
626
- " - --entrypoints.websecure.address=:443",
627
- " - --entrypoints.websecure.http.tls=true",
628
- ]
629
- : []),
630
- " - --providers.kubernetesingress=true",
631
- " - --providers.kubernetesingress.ingressclass=traefik",
632
- ...(tls
633
- ? [
634
- " - --providers.file.directory=/dynamic",
635
- " - --providers.file.watch=true",
636
- ]
637
- : []),
638
- " - --log.level=INFO",
639
- ].join("\n");
640
- const ports = [
641
- " - name: web",
642
- " containerPort: 80",
643
- ...(tls
644
- ? [" - name: websecure", " containerPort: 443"]
645
- : []),
646
- ].join("\n");
647
- const volumeMounts = tls
648
- ? `
649
- volumeMounts:
650
- - name: default-cert
651
- mountPath: /certs
652
- readOnly: true
653
- - name: dynamic
654
- mountPath: /dynamic
655
- readOnly: true`
656
- : "";
657
- const volumes = tls
658
- ? `
659
- volumes:
660
- - name: default-cert
661
- secret:
662
- secretName: ${TRAEFIK_TLS_SECRET}
663
- - name: dynamic
664
- configMap:
665
- name: ${TRAEFIK_DYNAMIC_CONFIGMAP}`
666
- : "";
667
- return `apiVersion: v1
668
- kind: ServiceAccount
669
- metadata:
670
- name: traefik
671
- namespace: kube-system
672
- ---
673
- apiVersion: rbac.authorization.k8s.io/v1
674
- kind: ClusterRole
675
- metadata:
676
- name: traefik
677
- rules:
678
- - apiGroups: [""]
679
- resources: ["services", "endpoints", "secrets", "nodes"]
680
- verbs: ["get", "list", "watch"]
681
- - apiGroups: ["discovery.k8s.io"]
682
- resources: ["endpointslices"]
683
- verbs: ["get", "list", "watch"]
684
- - apiGroups: ["networking.k8s.io"]
685
- resources: ["ingresses", "ingressclasses"]
686
- verbs: ["get", "list", "watch"]
687
- - apiGroups: ["networking.k8s.io"]
688
- resources: ["ingresses/status"]
689
- verbs: ["update"]
690
- ---
691
- apiVersion: rbac.authorization.k8s.io/v1
692
- kind: ClusterRoleBinding
693
- metadata:
694
- name: traefik
695
- roleRef:
696
- apiGroup: rbac.authorization.k8s.io
697
- kind: ClusterRole
698
- name: traefik
699
- subjects:
700
- - kind: ServiceAccount
701
- name: traefik
702
- namespace: kube-system
703
- ---
704
- apiVersion: networking.k8s.io/v1
705
- kind: IngressClass
706
- metadata:
707
- name: traefik
708
- annotations:
709
- ingressclass.kubernetes.io/is-default-class: "true"
710
- spec:
711
- controller: traefik.io/ingress-controller
712
- ---
713
- apiVersion: apps/v1
714
- kind: Deployment
715
- metadata:
716
- name: traefik
717
- namespace: kube-system
718
- labels:
719
- app: traefik
720
- spec:
721
- replicas: 1
722
- selector:
723
- matchLabels:
724
- app: traefik
725
- template:
726
- metadata:
727
- labels:
728
- app: traefik
729
- spec:
730
- serviceAccountName: traefik
731
- hostNetwork: true
732
- dnsPolicy: Default
733
- tolerations:
734
- - operator: Exists
735
- containers:
736
- - name: traefik
737
- image: ${TRAEFIK_IMAGE}
738
- imagePullPolicy: IfNotPresent
739
- args:
740
- ${args}
741
- ports:
742
- ${ports}${volumeMounts}${volumes}
743
- `;
744
- }
745
-
746
- /**
747
- * Host-side `zot` pull-through cache layout (local Firecracker provider
748
- * only). One zot instance per upstream registry, all bound to the
749
- * `spectest-br0` gateway `10.42.0.1` on the ports below — **kept in sync
750
- * with `scripts/install-zot.sh`**. We mirror the cluster's containerd
751
- * through these so every image pull reuses the shared host cache instead
752
- * of hitting the public registry, and we list the canonical upstream as
753
- * a fallback endpoint so a missing/cold mirror only ever slows a pull,
754
- * never breaks it.
755
- */
756
- const ZOT_MIRRORS: Array<{ registry: string; port: number; upstream: string }> = [
757
- { registry: "docker.io", port: 5000, upstream: "https://registry-1.docker.io" },
758
- { registry: "ghcr.io", port: 5001, upstream: "https://ghcr.io" },
759
- { registry: "quay.io", port: 5002, upstream: "https://quay.io" },
760
- { registry: "registry.k8s.io", port: 5003, upstream: "https://registry.k8s.io" },
761
- { registry: "public.ecr.aws", port: 5004, upstream: "https://public.ecr.aws" },
762
- { registry: "gcr.io", port: 5005, upstream: "https://gcr.io" },
763
- { registry: "mcr.microsoft.com", port: 5006, upstream: "https://mcr.microsoft.com" },
764
- ];
765
-
766
- /**
767
- * Discover the host-side image cache gateway by reading the same
768
- * `registry-mirrors` entry the in-VM dockerd already uses (baked into
769
- * the local provider's golden `/etc/docker/daemon.json`). Returns the
770
- * gateway host (`"10.42.0.1"`) when present, or `null` when there's no
771
- * host cache — e.g. on Freestyle, where the cluster then pulls every
772
- * image direct. Runs inside the daemon (VM) at `index.ts` load time, so
773
- * the result is stable per host and never poisons the warm-template
774
- * cache.
775
- */
776
- function detectHostMirrorGateway(): string | null {
777
- try {
778
- const cfg = JSON.parse(
779
- readFileSync("/etc/docker/daemon.json", "utf8"),
780
- ) as { "registry-mirrors"?: string[] };
781
- const first = cfg["registry-mirrors"]?.[0];
782
- return first ? new URL(first).hostname || null : null;
783
- } catch {
784
- return null;
785
- }
786
- }
787
-
788
- /**
789
- * Build `/etc/rancher/k3s/registries.yaml`. k3s reads this **once, at
790
- * startup**, to configure its embedded containerd — which is why it has
791
- * to be seeded via `files` (a pre-start bind mount) rather than a
792
- * `setup` hook. Two jobs:
793
- * 1. Mirror the cluster's image pulls through the host `zot` cache
794
- * (local provider only; omitted when there's no host cache).
795
- * 2. Trust the in-cluster registry, addressed as `<key>.internal:5000`
796
- * (the `{{SPECTEST_SERVICE}}` token is expanded to the cluster's
797
- * service key when the file is written). Image *references* use
798
- * that peer-reachable name, but containerd pulls via the loopback
799
- * endpoint `http://127.0.0.1:5000` — the hostNetwork registry pod
800
- * shares the node's netns, so this needs no in-container DNS and
801
- * can't be broken by a clobbered `hostnames`.
802
- * Returns `null` when there's nothing to configure (no host cache and
803
- * `registry` disabled), in which case no file is injected.
804
- */
805
- function buildRegistriesYaml(registryEnabled: boolean): string | null {
806
- const gateway = detectHostMirrorGateway();
807
- if (!gateway && !registryEnabled) return null;
808
-
809
- const lines: string[] = ["mirrors:"];
810
- if (gateway) {
811
- for (const { registry, port, upstream } of ZOT_MIRRORS) {
812
- lines.push(
813
- ` "${registry}":`,
814
- ` endpoint:`,
815
- ` - "http://${gateway}:${port}"`,
816
- ` - "${upstream}"`,
817
- );
818
- }
819
- }
820
- if (registryEnabled) {
821
- const host = `{{SPECTEST_SERVICE}}.internal:${K3S_REGISTRY_PORT}`;
822
- lines.push(
823
- ` "${host}":`,
824
- ` endpoint:`,
825
- ` - "http://127.0.0.1:${K3S_REGISTRY_PORT}"`,
826
- "configs:",
827
- // The endpoint is plain HTTP; the config (keyed by endpoint host)
828
- // makes that explicit and disables any TLS attempt against it.
829
- ` "127.0.0.1:${K3S_REGISTRY_PORT}":`,
830
- ` tls:`,
831
- ` insecure_skip_verify: true`,
832
- );
833
- }
834
- return lines.join("\n") + "\n";
835
- }
836
-
837
- /**
838
- * In-cluster OCI registry (CNCF `distribution`). `hostNetwork: true`
839
- * binds the cluster container's `:5000` directly — the same trick
840
- * Traefik uses — so peer services reach it at `<cluster-key>.internal:5000`
841
- * (the cluster service's own alias) and the node's own containerd reaches
842
- * it at `127.0.0.1:5000`. Storage is an `emptyDir`, so pushed images live
843
- * in the cluster and are captured by snapshot / isolated per test fork
844
- * like all other in-VM state.
845
- */
846
- const REGISTRY_MANIFEST = `apiVersion: apps/v1
847
- kind: Deployment
848
- metadata:
849
- name: spectest-registry
850
- namespace: kube-system
851
- labels:
852
- app: spectest-registry
853
- spec:
854
- replicas: 1
855
- selector:
856
- matchLabels:
857
- app: spectest-registry
858
- template:
859
- metadata:
860
- labels:
861
- app: spectest-registry
862
- spec:
863
- hostNetwork: true
864
- dnsPolicy: Default
865
- tolerations:
866
- - operator: Exists
867
- containers:
868
- - name: registry
869
- image: registry:2
870
- imagePullPolicy: IfNotPresent
871
- env:
872
- - name: REGISTRY_HTTP_ADDR
873
- value: ":${K3S_REGISTRY_PORT}"
874
- - name: REGISTRY_STORAGE_DELETE_ENABLED
875
- value: "true"
876
- ports:
877
- - name: registry
878
- containerPort: ${K3S_REGISTRY_PORT}
879
- volumeMounts:
880
- - name: data
881
- mountPath: /var/lib/registry
882
- volumes:
883
- - name: data
884
- emptyDir: {}
885
- `;
886
-
887
- /**
888
- * Wait for a Deployment to reach its desired ready-replica count,
889
- * polling once a second up to `timeoutMs`. Throws with the last-seen
890
- * status (plus kube-system pod diagnostics) on timeout.
891
- */
892
- async function waitForDeployment(
893
- clusterName: string,
894
- helpers: K3sHelpers,
895
- deployment: string,
896
- timeoutMs: number,
897
- ): Promise<void> {
898
- const deadline = Date.now() + timeoutMs;
899
- let lastErr: string | undefined;
900
- while (Date.now() < deadline) {
901
- try {
902
- // `.unwrap()` recovers the plain object — the client wraps its result in
903
- // every context now (provenance-free here, since this internal poll runs
904
- // during setup with no active recorder). We're reading for control flow,
905
- // not asserting, so go straight to raw.
906
- const dep = (
907
- await helpers.client.apps.readNamespacedDeployment({
908
- name: deployment,
909
- namespace: "kube-system",
910
- })
911
- ).unwrap();
912
- const ready = dep.status?.readyReplicas ?? 0;
913
- const want = dep.spec?.replicas ?? 1;
914
- if (ready >= want && want > 0) return;
915
- lastErr = `${deployment} Deployment exists but only ${ready}/${want} replicas Ready`;
916
- } catch (err) {
917
- const msg = (err as Error)?.message ?? String(err);
918
- lastErr = /not found|404/i.test(msg)
919
- ? `${deployment} Deployment does not exist yet`
920
- : msg;
921
- }
922
- // 250ms: the two sequential rollout waits in setup sit on the cold
923
- // start's critical path, and a 1s poll wasted up to ~2s of it.
924
- await new Promise((r) => setTimeout(r, 250));
925
- }
926
- const diag = await collectTraefikDiagnostics(helpers);
927
- throw new Error(
928
- `k3s(${clusterName}): ${deployment} did not reach Ready within ${
929
- timeoutMs / 1000
930
- }s. ${lastErr ?? ""}\n${diag}`,
931
- );
932
- }
933
-
934
- /**
935
- * Post-Ready setup. Apply the Traefik manifest (hostNetwork) and, when
936
- * enabled, the in-cluster registry; wait for each Deployment to come
937
- * Ready. Captured by the warm-template snapshot, so warm starts pay none
938
- * of this cost.
939
- *
940
- * When the cluster declares `ingressDomains` (and the in-VM CA is
941
- * present), TLS is enabled: we mint a CA-signed leaf covering `*.<domain>`
942
- * for each domain, stash it in the `traefik-default-tls` Secret + a
943
- * file-provider ConfigMap, and bring Traefik up with a `websecure` :443
944
- * entrypoint serving it as the default cert. Those domains are then
945
- * reachable over HTTPS with a cert the test framework already trusts.
946
- */
947
- async function setupK3sCluster(
948
- name: string,
949
- helpers: K3sHelpers,
950
- opts: { registry: boolean; ingressDomains: string[] },
951
- ): Promise<void> {
952
- const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
953
- if (tlsEnabled) {
954
- const { cert, key } = await issueIngressCert(
955
- opts.ingressDomains.map((d) => `*.${d}`),
956
- );
957
- // Apply the cert Secret + dynamic-config ConfigMap before the
958
- // Deployment that mounts them. `stringData` lets us hand over plain
959
- // PEM; the API server base64-encodes it.
960
- await helpers.client.core.createNamespacedSecret({
961
- namespace: "kube-system",
962
- body: {
963
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
964
- type: "kubernetes.io/tls",
965
- stringData: { "tls.crt": cert, "tls.key": key },
966
- },
967
- });
968
- await helpers.client.core.createNamespacedConfigMap({
969
- namespace: "kube-system",
970
- body: {
971
- metadata: {
972
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
973
- namespace: "kube-system",
974
- },
975
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
976
- },
977
- });
978
- }
979
- await helpers.apply(buildTraefikManifest(tlsEnabled));
980
- if (opts.registry) await helpers.apply(REGISTRY_MANIFEST);
981
- // Both rollouts proceed independently inside the cluster — wait on them
982
- // concurrently (they used to serialize, wasting up to a rollout's tail).
983
- const waits = [waitForDeployment(name, helpers, "traefik", 120_000)];
984
- if (opts.registry) {
985
- waits.push(waitForDeployment(name, helpers, "spectest-registry", 120_000));
986
- }
987
- await Promise.all(waits);
988
- }
989
-
990
- /**
991
- * Snapshot of kube-system state, dumped on traefik-wait timeout. With
992
- * the static install, the failure surface is just "did our Deployment
993
- * schedule and become Ready?" — pod listing covers that.
994
- */
995
- async function collectTraefikDiagnostics(helpers: K3sHelpers): Promise<string> {
996
- const lines: string[] = [];
997
- try {
998
- const pods = await helpers.client.core.listNamespacedPod({
999
- namespace: "kube-system",
1000
- });
1001
- lines.push(`kube-system pods (${pods.items.length}):`);
1002
- for (const p of pods.items) {
1003
- const phase = p.status?.phase ?? "?";
1004
- const cs = p.status?.containerStatuses ?? [];
1005
- const reasons = cs
1006
- .map((c) => c.state?.waiting?.reason ?? c.state?.terminated?.reason ?? "")
1007
- .filter((s) => s)
1008
- .join(",");
1009
- lines.push(
1010
- ` ${p.metadata?.name ?? "?"}: phase=${phase}${reasons ? ` reasons=${reasons}` : ""}`,
1011
- );
1012
- // The waiting `message` carries containerd's actual error — e.g. the
1013
- // failing endpoint, an upstream `429 Too Many Requests`, or a
1014
- // `connection refused`. The `reason` alone (`ErrImagePull`) hides all
1015
- // of that, which is exactly what we need when a pull won't settle.
1016
- for (const c of cs) {
1017
- const msg =
1018
- c.state?.waiting?.message ?? c.state?.terminated?.message ?? "";
1019
- if (msg) lines.push(` ${c.name}: ${msg.replace(/\s+/g, " ").trim()}`);
1020
- }
1021
- }
1022
- } catch (err) {
1023
- lines.push(`(listing pods failed: ${(err as Error)?.message ?? String(err)})`);
1024
- }
1025
- // Recent Warning events surface pull failures the kubelet emits before a
1026
- // container status even settles (FailedPull / Failed / BackOff), with the
1027
- // raw containerd message attached. Best-effort: never let diagnostics throw.
1028
- try {
1029
- const events = await helpers.client.core.listNamespacedEvent({
1030
- namespace: "kube-system",
1031
- });
1032
- const warnings = (events.items ?? [])
1033
- .filter((e) => e.type === "Warning")
1034
- .map((e) => ({
1035
- obj: e.involvedObject?.name ?? "?",
1036
- reason: e.reason ?? "?",
1037
- message: (e.message ?? "").replace(/\s+/g, " ").trim(),
1038
- }))
1039
- .filter((e) => e.message);
1040
- if (warnings.length) {
1041
- lines.push(`kube-system Warning events (${warnings.length}):`);
1042
- // Keep the tail — newest events are appended last by the API.
1043
- for (const w of warnings.slice(-12)) {
1044
- lines.push(` ${w.obj} [${w.reason}] ${w.message}`);
1045
- }
1046
- }
1047
- } catch (err) {
1048
- lines.push(`(listing events failed: ${(err as Error)?.message ?? String(err)})`);
1049
- }
1050
- return lines.join("\n");
1051
- }
1052
-
1053
- /**
1054
- * A ready-to-use single-node Kubernetes cluster (k3s). Drop into
1055
- * `environment.services`:
1056
- *
1057
- * ```ts
1058
- * services: { k8s: k3s() }
1059
- * ```
1060
- *
1061
- * Tests get `@kubernetes/client-node` API objects pre-wired to this
1062
- * cluster at `ctx.svc.<key>.client` — `core`, `apps`, and a generic
1063
- * `objects` (`KubernetesObjectApi`). Every API call is recorded on the
1064
- * test event log alongside `fetch` calls. There's also `apply(yaml)`
1065
- * sugar for piping a multi-document manifest in.
1066
- *
1067
- * **Ingress.** We deploy Traefik ourselves in `hostNetwork` mode
1068
- * during `setup()`. Traefik binds the cluster container's :80 directly
1069
- * (no ServiceLB / klipper-lb needed), watches Ingress objects via the
1070
- * API, and routes incoming requests to pod Endpoints. Any `hostnames`
1071
- * declared on this service in env.ts therefore route through Traefik:
1072
- * a peer doing `fetch("http://app.example.com")` resolves the host to
1073
- * the k3s container's IP (via systest-resolver), lands on Traefik,
1074
- * and gets dispatched to the matching Ingress rule's backend pods.
1075
- *
1076
- * **Workarounds for Freestyle's kernel** (Linux 6.1.0-x-freestyle).
1077
- * The stock kernel is missing the `xt_comment` netfilter match
1078
- * extension. Two consequences, each handled below:
1079
- *
1080
- * 1. *kube-proxy* in default iptables mode generates rules with
1081
- * `-m comment --comment "..."`, which the kernel rejects —
1082
- * breaking pod→ClusterIP routing and every pod that talks to the
1083
- * in-cluster API (helm-install Jobs, CoreDNS, …). Fixed by
1084
- * `--kube-proxy-arg=proxy-mode=nftables`: kube-proxy emits
1085
- * native nftables rules where comments are a first-class
1086
- * construct, no xt_comment dependency. nftables proxy mode is
1087
- * GA in k8s 1.32, which is why we pin that.
1088
- *
1089
- * 2. *CNI portmap plugin* (used by klipper-lb's hostPort to expose
1090
- * LoadBalancer ports on the host) still uses iptables-nft and
1091
- * hits the same xt_comment failure — there's no equivalent
1092
- * flag to switch it to native nftables. Workaround: disable the
1093
- * bundled traefik + ServiceLB and run Traefik with
1094
- * `hostNetwork: true` ourselves. hostNetwork pods don't go
1095
- * through portmap at all (they share the node's netns directly),
1096
- * so the broken plugin is never invoked.
1097
- *
1098
- * Flannel uses the `host-gw` backend because Freestyle's stock kernel
1099
- * lacks the VXLAN module — fine for single-node clusters.
1100
- */
1101
- /**
1102
- * Default k3s docker image tag.
1103
- *
1104
- * The cluster's system images (the `rancher/k3s` image itself, plus
1105
- * coredns / local-path-provisioner / pause and the Traefik we deploy)
1106
- * are pulled on first boot through the host `zot` pull-through cache —
1107
- * `registries.yaml` (seeded via `files`) mirrors `docker.io` and
1108
- * `registry.k8s.io` at it. The first-ever cluster boot on a cold-cache
1109
- * host pays the upstream pull once; thereafter zot serves the blobs
1110
- * host-wide and the warm-template snapshot captures the booted cluster,
1111
- * so neither cold-cache nor warm starts re-pull. Any `opts.version`
1112
- * works — there's no base-snapshot release to keep in sync with.
1113
- *
1114
- * **Why v1.32.x:** kube-proxy's `nftables` proxy mode is GA in k8s 1.32
1115
- * (beta in 1.31, alpha-gated in 1.30). The component runs kube-proxy in
1116
- * this mode to sidestep Freestyle's missing `xt_comment` netfilter
1117
- * extension; dropping below 1.31 reintroduces the broken iptables path.
1118
- */
1119
- const DEFAULT_K3S_VERSION = "v1.32.1-k3s1";
1120
-
1121
- export function k3s(opts: K3sOptions = {}) {
1122
- const version = opts.version ?? DEFAULT_K3S_VERSION;
1123
- const extra = opts.extraArgs ?? [];
1124
- const registryEnabled = opts.registry !== false;
1125
- // Wildcard ingress domains. Drives both the `provides(... dnsName)`
1126
- // wiring below and (when non-empty) the CA-signed TLS default cert that
1127
- // setupK3sCluster mints so these domains are reachable over HTTPS.
1128
- const ingressDomains = opts.ingressDomains ?? [];
1129
- // `/etc/rancher/k3s/registries.yaml` (host-cache mirrors + trust for
1130
- // the in-cluster registry). Seeded via `files` because k3s reads it
1131
- // only at startup, before any setup hook could run.
1132
- const registriesYaml = buildRegistriesYaml(registryEnabled);
1133
- const serverArgs = [
1134
- "k3s",
1135
- "server",
1136
- // CoreDNS / pod DNS upstream. Without this, k3s sees only the
1137
- // loopback 127.0.0.11 (Docker's embedded DNS) in the container's
1138
- // /etc/resolv.conf, decides no usable nameserver exists, and writes a
1139
- // fallback `nameserver 8.8.8.8` that CoreDNS then forwards to — so
1140
- // pods reach the public internet but NOT peer services on
1141
- // spectest-net (`<svc>.internal`, fakes, service-TLS hosts all
1142
- // NXDOMAIN). We instead point k3s at the container's default gateway
1143
- // — the spectest-net bridge gateway, where spectest-resolver binds a
1144
- // second listener for exactly this. The file is written by the
1145
- // command wrapper below because the gateway IP is only known at
1146
- // container start.
1147
- "--resolv-conf=/run/spectest-resolv.conf",
1148
- // metrics-server isn't useful in a test cluster.
1149
- "--disable=metrics-server",
1150
- // traefik + servicelb disabled: their klipper-lb DaemonSet uses
1151
- // CNI portmap to bind host port 80, which still needs xt_comment
1152
- // (the iptables compat path that the kernel can't satisfy).
1153
- // We install Traefik with hostNetwork in setup() — same effect,
1154
- // no portmap involved. local-storage stays enabled: it's a
1155
- // controller pod that doesn't bind host ports.
1156
- "--disable=traefik",
1157
- "--disable=servicelb",
1158
- // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
1159
- // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
1160
- // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
1161
- // 10.42.0.0/16 — with --flannel-backend=host-gw, flannel programs that
1162
- // route into the node's own routing table and gives cni0 the subnet's
1163
- // .1 (10.42.0.1). That shadows the route to the host gateway, so once
1164
- // CNI comes up the node can no longer reach 10.42.0.1 and every
1165
- // subsequent registry pull dies with "connect: connection refused"
1166
- // (e.g. the in-cluster registry's `registry:2`, applied after the
1167
- // cluster is up — the airgap-bundled system images pull *before* CNI
1168
- // and so sneak through). Move pods to 10.44/service to 10.45.
1169
- "--cluster-cidr=10.44.0.0/16",
1170
- "--service-cidr=10.45.0.0/16",
1171
- "--cluster-dns=10.45.0.10",
1172
- "--flannel-backend=host-gw",
1173
- "--write-kubeconfig-mode=644",
1174
- // kube-proxy in nftables mode: native nftables rules, no
1175
- // xt_comment dependency. Pod→ClusterIP routing works, so
1176
- // CoreDNS / helm-install / anything-talking-to-the-API works.
1177
- // GA in k8s 1.32.
1178
- "--kube-proxy-arg=proxy-mode=nftables",
1179
- ...extra,
1180
- ].join(" ");
1181
- // The service `command` runs under `/bin/sh -c` (see runContainer in
1182
- // daemon.ts), so derive the bridge gateway from the container's default
1183
- // route at start time, write it as the k3s resolv-conf, then exec k3s
1184
- // (exec so it stays the container's main process and signals / the
1185
- // readyCheck behave exactly as before). `/run` is a tmpfs on this
1186
- // service, so the file is writable and never persisted into a snapshot.
1187
- const cmd =
1188
- "GW=\"$(ip route 2>/dev/null | awk '/^default/{print $3; exit}')\"; " +
1189
- 'if [ -n "$GW" ]; then ' +
1190
- "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
1191
- "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
1192
- ": > /run/spectest-resolv.conf; fi; " +
1193
- `exec ${serverArgs}`;
1194
- // Plain /readyz probe. On a warm zot cache the cluster's images are
1195
- // already local, so the first boot completes in seconds; the
1196
- // first-ever boot on a cold-cache host pulls through the mirror and
1197
- // can take a couple of minutes (covered by readyTimeoutSecs).
1198
- const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
1199
- const def = {
1200
- image: { type: "registry", reference: `rancher/k3s:${version}` },
1201
- command: cmd,
1202
- privileged: true,
1203
- tmpfs: ["/run", "/var/run"],
1204
- cgroupns: "host",
1205
- // 80/443 are advisory — peer services and host code reach them via
1206
- // the k3s container's IP. ServiceLB (klipper-lb) binds them inside
1207
- // the container's netns and forwards to the traefik pod. 5000 is the
1208
- // in-cluster registry (hostNetwork pod bound to the container netns),
1209
- // reached by peers at the cluster's own `<key>.internal:5000` alias.
1210
- ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
1211
- // NOTE: do NOT mount /var/lib/rancher/k3s/agent/containerd as a cache
1212
- // volume. It was tried (to spare a recreated cluster re-pulling its
1213
- // system images on delta restores) and a fresh k3s server against the
1214
- // previous container's containerd store — killed un-cleanly by the
1215
- // teardown's `docker rm -f` — wedged the apiserver minutes in
1216
- // (rollouts never settled, pod listing started failing). The zot
1217
- // mirror already makes those re-pulls cheap; the residual win wasn't
1218
- // worth the recovery semantics of a crash-state store under a fresh
1219
- // cluster db.
1220
- ...(registriesYaml
1221
- ? {
1222
- files: [
1223
- { path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml },
1224
- ],
1225
- }
1226
- : {}),
1227
- readyCheck: {
1228
- type: "exec" as const,
1229
- command: readyCmd,
1230
- timeoutSecs: opts.readyTimeoutSecs ?? 120,
1231
- },
1232
- setup: async ({ name, helpers }: { name: string; helpers: K3sHelpers }) => {
1233
- await setupK3sCluster(name, helpers, {
1234
- registry: registryEnabled,
1235
- ingressDomains,
1236
- });
1237
- },
1238
- helpers: async ({ name, exec }: ServiceHelpersContext): Promise<K3sHelpers> => {
1239
- // Read the cluster's kubeconfig and address the API server by its
1240
- // auto-assigned `<name>.internal` hostname on spectest-net. TLS
1241
- // verification is off (see the K3sHelpers docstring), so the
1242
- // server's cert SAN list doesn't need to include the .internal
1243
- // name.
1244
- const kcRead = await exec(name, ["cat", "/etc/rancher/k3s/k3s.yaml"]);
1245
- if (kcRead.exitCode !== 0) {
1246
- throw new Error(
1247
- `k3s(${name}): failed to read kubeconfig from container: ${kcRead.stderr.trim()}`,
1248
- );
1249
- }
1250
-
1251
- const kubeconfig = new KubeConfig();
1252
- kubeconfig.loadFromString(kcRead.stdout);
1253
-
1254
- const server = `https://${name}.internal:6443`;
1255
- // Update kc.clusters so any code that reads kubeconfig sees the
1256
- // right server URL, but the actual request server comes from the
1257
- // Configuration we build below. `Cluster.server` is typed `readonly`
1258
- // by @kubernetes/client-node, but the loaded object is a plain mutable
1259
- // record — write through a mutable view rather than rebuild the config.
1260
- for (const cluster of kubeconfig.clusters) {
1261
- (cluster as { -readonly [K in keyof typeof cluster]: typeof cluster[K] }).server =
1262
- server;
1263
- }
1264
-
1265
- const httpApi = new FetchHttpLibrary();
1266
- const baseServer = new ServerConfiguration(server, {});
1267
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1268
- const config = createConfiguration({
1269
- baseServer,
1270
- authMethods: { default: kubeconfig as any },
1271
- httpApi: httpApi as any,
1272
- });
1273
-
1274
- const core = withTagging(new CoreV1Api(config));
1275
- const apps = withTagging(new AppsV1Api(config));
1276
- const objects = withTagging(new KubernetesObjectApi(config));
1277
-
1278
- const apply = async (
1279
- manifest: string,
1280
- ): Promise<Wrapped<KubernetesObject>[]> => {
1281
- const docs = loadAllYaml(manifest) as KubernetesObject[];
1282
- const out: Wrapped<KubernetesObject>[] = [];
1283
- for (const doc of docs) {
1284
- if (!doc || typeof doc !== "object" || !("kind" in doc)) continue;
1285
- // `objects.create` is wrapped by `withTagging`, so each returned
1286
- // object already carries the back-reference to its create call.
1287
- const created = await objects.create(doc);
1288
- out.push(created as unknown as Wrapped<KubernetesObject>);
1289
- }
1290
- return out;
1291
- };
1292
-
1293
- return {
1294
- kubeconfig,
1295
- client: { core, apps, objects },
1296
- apply,
1297
- };
1298
- },
1299
- } satisfies ServiceDefinition<K3sHelpers>;
1300
-
1301
- // Wildcard ingress domains → a dnsName(`*.<domain>`, { service: self })
1302
- // each, attached via provides(). SELF_SERVICE_TOKEN resolves to this
1303
- // service's key at load time (the component can't know it here). The
1304
- // resolver then points every host under the domain at the cluster.
1305
- if (ingressDomains.length === 0) return def;
1306
- return provides(
1307
- def,
1308
- ingressDomains.map((domain) =>
1309
- dnsName(`*.${domain}`, { service: SELF_SERVICE_TOKEN }),
1310
- ),
1311
- );
1312
- }