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