@specific.dev/spectest 0.120.1 → 0.122.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.
@@ -1,4 +1,5 @@
1
1
  import { isIP } from "node:net";
2
+ import { waitForK3sNodes } from "./k3s-nodes.js";
2
3
  import { installPodTrust } from "./k3s-trust.js";
3
4
  import { waitForAll } from "../harness/concurrency.js";
4
5
  import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
@@ -10,7 +11,7 @@ import { existsSync } from "node:fs";
10
11
  import { readFile, unlink } from "node:fs/promises";
11
12
  import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, PatchStrategy, ResponseContext, ServerConfiguration, createConfiguration, loadAllYaml, } from "@kubernetes/client-node";
12
13
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
13
- import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
14
+ import { dnsName, provides, serviceGroup, SELF_SERVICE_TOKEN } from "../index.js";
14
15
  import { stickyDiskPathSync } from "../harness/sticky-paths.js";
15
16
  import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
16
17
  import { prePullK3sImages } from "../harness/k3s-prepull.js";
@@ -19,16 +20,16 @@ import { deepUnwrap, readTag, wrap } from "../inspect.js";
19
20
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
20
21
  /** Port the in-cluster registry listens on (plain HTTP). */
21
22
  const K3S_REGISTRY_PORT = 5000;
23
+ const JOIN_DIRECTORY = "/etc/spectest-k3s-join";
22
24
  /** The cluster's own containerd root, inside the k3s container. */
23
25
  const CLUSTER_STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
24
26
  /** The socket of the throwaway daemon that prepares the store. */
25
27
  const PREPARE_SOCKET = "/run/spectest-prepare.sock";
26
28
  /**
27
- * Written before the server starts and removed by the ready probe.
29
+ * Written before each node starts and removed after cluster setup succeeds.
28
30
  *
29
- * It survives in the store only when a boot never reached a live API
30
- * server, which is the one thing the sweep below cannot work out for
31
- * itself. Blacksmith's rule, from the other end: they refuse to commit a
31
+ * It survives in the store when a node or its image preparation never became
32
+ * ready, which is the one thing the sweep below cannot work out for itself. Blacksmith's rule, from the other end: they refuse to commit a
32
33
  * cache when the integrity check *did not run*, not only when it failed.
33
34
  */
34
35
  const BOOT_MARKER = ".spectest-boot-incomplete";
@@ -119,6 +120,9 @@ const OVERRIDDEN_EXTRA_ARGS = {
119
120
  };
120
121
  function assertUsableExtraArgs(extra) {
121
122
  for (const arg of extra) {
123
+ if (/^--(?:node-name|with-node-id|token|token-file|agent-token|agent-token-file|server|disable-agent)(?:[=\s]|$)/.test(arg.trim())) {
124
+ throw new Error(`k3s(): extraArgs cannot override cluster identity or joining: ${arg}`);
125
+ }
122
126
  const m = /^--disable[=\s]+(.+)$/.exec(arg.trim());
123
127
  const addon = m?.[1]?.trim();
124
128
  if (!addon)
@@ -521,7 +525,7 @@ const TRAEFIK_DYNAMIC_TLS = `tls:
521
525
  * then works for any routed host under the cluster's `ingressDomains`
522
526
  * with zero per-Ingress config; the :80 `web` entrypoint is unchanged.
523
527
  */
524
- function buildTraefikManifest(tls) {
528
+ function buildTraefikManifest(tls, node) {
525
529
  const args = [
526
530
  " - --entrypoints.web.address=:80",
527
531
  ...(tls
@@ -631,6 +635,8 @@ spec:
631
635
  app: traefik
632
636
  spec:
633
637
  serviceAccountName: traefik
638
+ nodeSelector:
639
+ kubernetes.io/hostname: ${JSON.stringify(node)}
634
640
  hostNetwork: true
635
641
  dnsPolicy: Default
636
642
  tolerations:
@@ -662,14 +668,10 @@ const HUB_MIRROR = { registry: "docker.io", mirror: "https://mirror.gcr.io", ups
662
668
  * `setup` hook. Two jobs:
663
669
  * 1. Mirror the cluster's Docker Hub pulls through `mirror.gcr.io`.
664
670
  * 2. Trust the in-cluster registry, addressed as `<key>.internal:5000`
665
- * (the `{{SPECTEST_SERVICE}}` token is expanded to the cluster's
666
- * service key when the file is written). Image *references* use
667
- * that peer-reachable name, but containerd pulls via the loopback
668
- * endpoint `http://127.0.0.1:5000` — the hostNetwork registry pod
669
- * shares the node's netns, so this needs no in-container DNS and
670
- * can't be broken by a clobbered `hostnames`.
671
+ * using the final group key from service expansion. Every node pulls through
672
+ * the server's stable .internal alias, including the server itself.
671
673
  */
672
- function buildRegistriesYaml(registryEnabled, host) {
674
+ function buildRegistriesYaml(registryEnabled, cluster, host) {
673
675
  const lines = [
674
676
  "mirrors:",
675
677
  ` "${HUB_MIRROR.registry}":`,
@@ -678,11 +680,11 @@ function buildRegistriesYaml(registryEnabled, host) {
678
680
  ` - "${HUB_MIRROR.upstream}"`,
679
681
  ];
680
682
  if (registryEnabled) {
681
- const host = `{{SPECTEST_SERVICE}}.internal:${K3S_REGISTRY_PORT}`;
682
- lines.push(` "${host}":`, ` endpoint:`, ` - "http://127.0.0.1:${K3S_REGISTRY_PORT}"`, "configs:",
683
+ const host = `${cluster}.internal:${K3S_REGISTRY_PORT}`;
684
+ lines.push(` "${host}":`, ` endpoint:`, ` - "http://${host}"`, "configs:",
683
685
  // The endpoint is plain HTTP; the config (keyed by endpoint host)
684
686
  // makes that explicit and disables any TLS attempt against it.
685
- ` "127.0.0.1:${K3S_REGISTRY_PORT}":`, ` tls:`, ` insecure_skip_verify: true`);
687
+ ` "${host}":`, ` tls:`, ` insecure_skip_verify: true`);
686
688
  }
687
689
  if (host) {
688
690
  // This names the verified HTTPS endpoint itself; do not mirror it to HTTP.
@@ -695,13 +697,13 @@ function buildRegistriesYaml(registryEnabled, host) {
695
697
  * In-cluster OCI registry (CNCF `distribution`). `hostNetwork: true`
696
698
  * binds the cluster container's `:5000` directly — the same trick
697
699
  * Traefik uses — so peer services reach it at `<cluster-key>.internal:5000`
698
- * (the cluster service's own alias) and the node's own containerd reaches
699
- * it at `127.0.0.1:5000`. Storage is an `emptyDir` by default. Declared
700
+ * (the cluster service's own alias). The pod is pinned to the server node
701
+ * so this address stays valid. Storage is an `emptyDir` by default. Declared
700
702
  * publications use a rootfs directory plus a read-only content mount; both
701
703
  * variants keep writable registry state outside persistent image caches and
702
704
  * capture it with the VM snapshot, isolated per test fork.
703
705
  */
704
- function registryManifest(seeded) {
706
+ function registryManifest(seeded, node) {
705
707
  return `apiVersion: apps/v1
706
708
  kind: Deployment
707
709
  metadata:
@@ -719,6 +721,8 @@ spec:
719
721
  labels:
720
722
  app: spectest-registry
721
723
  spec:
724
+ nodeSelector:
725
+ kubernetes.io/hostname: ${JSON.stringify(node)}
722
726
  hostNetwork: true
723
727
  dnsPolicy: Default
724
728
  tolerations:
@@ -1164,53 +1168,52 @@ async function waitForJob(deps, name, opts) {
1164
1168
  * entrypoint serving it as the default cert. Those domains are then
1165
1169
  * reachable over HTTPS with a cert the test framework already trusts.
1166
1170
  */
1167
- async function setupK3sCluster(name, helpers, opts) {
1171
+ function setupK3sCluster(name, helpers, opts) {
1168
1172
  // The registry can start while Traefik's certificates are being prepared.
1169
1173
  const wait = (deployment) => helpers.waitForRollout(deployment, {
1170
1174
  namespace: "kube-system",
1171
1175
  timeoutMs: 120_000,
1172
1176
  intervalMs: 250,
1173
1177
  });
1174
- await waitForAll([
1175
- (async () => {
1176
- if (!opts.traefik)
1177
- return;
1178
- const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1179
- if (tlsEnabled) {
1180
- const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1181
- // Apply the cert Secret + dynamic-config ConfigMap before the
1182
- // Deployment that mounts them. `stringData` lets us hand over plain
1183
- // PEM; the API server base64-encodes it.
1184
- await helpers.client.core.createNamespacedSecret({
1185
- namespace: "kube-system",
1186
- body: {
1187
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1188
- type: "kubernetes.io/tls",
1189
- stringData: { "tls.crt": cert, "tls.key": key },
1190
- },
1191
- });
1192
- await helpers.client.core.createNamespacedConfigMap({
1193
- namespace: "kube-system",
1194
- body: {
1195
- metadata: {
1196
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1197
- namespace: "kube-system",
1198
- },
1199
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1178
+ const ingress = (async () => {
1179
+ if (!opts.traefik)
1180
+ return;
1181
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1182
+ if (tlsEnabled) {
1183
+ const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1184
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1185
+ // Deployment that mounts them. `stringData` lets us hand over plain
1186
+ // PEM; the API server base64-encodes it.
1187
+ await helpers.client.core.createNamespacedSecret({
1188
+ namespace: "kube-system",
1189
+ body: {
1190
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1191
+ type: "kubernetes.io/tls",
1192
+ stringData: { "tls.crt": cert, "tls.key": key },
1193
+ },
1194
+ });
1195
+ await helpers.client.core.createNamespacedConfigMap({
1196
+ namespace: "kube-system",
1197
+ body: {
1198
+ metadata: {
1199
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1200
+ namespace: "kube-system",
1200
1201
  },
1201
- });
1202
- }
1203
- await helpers.apply(buildTraefikManifest(tlsEnabled));
1204
- await wait("traefik");
1205
- })(),
1206
- (async () => {
1207
- if (!opts.registry)
1208
- return;
1209
- await opts.seed?.();
1210
- await helpers.apply(registryManifest(!!opts.seed));
1211
- await wait("spectest-registry");
1212
- })(),
1213
- ]);
1202
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1203
+ },
1204
+ });
1205
+ }
1206
+ await helpers.apply(buildTraefikManifest(tlsEnabled, name));
1207
+ await wait("traefik");
1208
+ })();
1209
+ const registry = (async () => {
1210
+ if (!opts.registry)
1211
+ return;
1212
+ await opts.seed?.();
1213
+ await helpers.apply(registryManifest(!!opts.seed, name));
1214
+ await wait("spectest-registry");
1215
+ })();
1216
+ return { ingress, registry };
1214
1217
  }
1215
1218
  /**
1216
1219
  * Snapshot of a namespace's state, dumped whenever a wait times out: pod
@@ -1273,7 +1276,7 @@ async function collectDiagnostics(client, namespace) {
1273
1276
  return lines.join("\n");
1274
1277
  }
1275
1278
  /**
1276
- * A ready-to-use single-node Kubernetes cluster (k3s). Drop into
1279
+ * A ready-to-use Kubernetes cluster (k3s). Drop into
1277
1280
  * `environment.services`:
1278
1281
  *
1279
1282
  * ```ts
@@ -1305,8 +1308,8 @@ async function collectDiagnostics(client, namespace) {
1305
1308
  * path regardless, so it stays. It is GA in k8s 1.32, which is what
1306
1309
  * pins `DEFAULT_K3S_VERSION`.
1307
1310
  *
1308
- * Flannel uses the `host-gw` backend: single-node clusters never route
1309
- * pod traffic off the node, so VXLAN encapsulation is pure overhead.
1311
+ * Flannel uses `host-gw`: all node containers share a layer-2 Docker bridge,
1312
+ * so cross-node pod traffic uses direct routes without VXLAN encapsulation.
1310
1313
  */
1311
1314
  /**
1312
1315
  * Default k3s docker image tag.
@@ -1324,6 +1327,12 @@ async function collectDiagnostics(client, namespace) {
1324
1327
  */
1325
1328
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
1326
1329
  export function k3s(opts = {}) {
1330
+ const count = opts.nodes === undefined ? 1 : opts.nodes;
1331
+ if (!Number.isSafeInteger(count) || count < 1)
1332
+ throw new Error("k3s(): nodes must be a positive integer");
1333
+ return createK3sCluster(opts, count);
1334
+ }
1335
+ function createK3sCluster(opts, count) {
1327
1336
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1328
1337
  const images = kubernetesImages(opts.images ?? []);
1329
1338
  const extra = opts.extraArgs ?? [];
@@ -1348,144 +1357,182 @@ export function k3s(opts = {}) {
1348
1357
  // `/etc/rancher/k3s/registries.yaml` (host-cache mirrors + trust for
1349
1358
  // the in-cluster registry). Seeded via `files` because k3s reads it
1350
1359
  // only at startup, before any setup hook could run.
1351
- const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
1352
1360
  // Pre-pulls read content from the environment's containerd store.
1353
1361
  const sticky = stickyDiskPathSync();
1354
1362
  const contentDirectory = `${sticky ? `${sticky}/containerd` : "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1355
- const serverArgs = [
1356
- "k3s",
1357
- "server",
1358
- // CoreDNS / pod DNS upstream. Without this, k3s sees only the
1359
- // loopback 127.0.0.11 (Docker's embedded DNS) in the container's
1360
- // /etc/resolv.conf, decides no usable nameserver exists, and writes a
1361
- // fallback `nameserver 8.8.8.8` that CoreDNS then forwards to — so
1362
- // pods reach the public internet but NOT peer services on
1363
- // spectest-net (`<svc>.internal`, fakes, service-TLS hosts all
1364
- // NXDOMAIN). We instead point k3s at the container's default gateway
1365
- // — the spectest-net bridge gateway, where spectest-resolver binds a
1366
- // second listener for exactly this. The file is written by the
1367
- // command wrapper below because the gateway IP is only known at
1368
- // container start.
1369
- "--resolv-conf=/run/spectest-resolv.conf",
1370
- // metrics-server isn't useful in a test cluster.
1371
- "--disable=metrics-server",
1372
- // The bundled traefik is disabled because we install our own with
1373
- // hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
1374
- // for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
1375
- // ours entirely with `k3s({ traefik: false })`; passing
1376
- // `--disable=traefik` via `extraArgs` does NOT do that (it only
1377
- // re-disables the bundled one, which is already off) and is rejected
1378
- // below rather than silently ignored.
1379
- "--disable=traefik",
1380
- // ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
1381
- // Services get an address and work. It was disabled for years
1382
- // because klipper-lb binds its ports with a CNI portmap hostPort,
1383
- // and portmap's iptables-nft rules need the `xt_comment` netfilter
1384
- // match — absent from the stock kernel we used to run on, which made
1385
- // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
1386
- // build the guest kernel ourselves now and it carries
1387
- // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
1388
- // scripts/local-vms-kernel-additions.config), so the constraint is
1389
- // gone. local-storage stays enabled too: a controller pod that binds
1390
- // no host ports.
1391
- ...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
1392
- // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
1393
- // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
1394
- // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
1395
- // 10.42.0.0/16 — with --flannel-backend=host-gw, flannel programs that
1396
- // route into the node's own routing table and gives cni0 the subnet's
1397
- // .1 (10.42.0.1). That shadows the route to the host gateway, so once
1398
- // CNI comes up the node can no longer reach 10.42.0.1 and every
1399
- // subsequent registry pull dies with "connect: connection refused"
1400
- // (e.g. the in-cluster registry's `registry:2`, applied after the
1401
- // cluster is up — the airgap-bundled system images pull *before* CNI
1402
- // and so sneak through). Move pods to 10.44/service to 10.45.
1403
- "--cluster-cidr=10.44.0.0/16",
1404
- "--service-cidr=10.45.0.0/16",
1405
- "--cluster-dns=10.45.0.10",
1406
- "--flannel-backend=host-gw",
1407
- "--write-kubeconfig-mode=644",
1408
- // kube-proxy in nftables mode: native nftables rules, no
1409
- // xt_comment dependency. Pod→ClusterIP routing works, so
1410
- // CoreDNS / helm-install / anything-talking-to-the-API works.
1411
- // GA in k8s 1.32.
1412
- "--kube-proxy-arg=proxy-mode=nftables",
1413
- ...extra,
1414
- ].join(" ");
1415
- // The service `command` runs under `/bin/sh -c` (see runContainer in
1416
- // daemon.ts), so derive the bridge gateway from the container's default
1417
- // route at start time, write it as the k3s resolv-conf, then exec k3s
1418
- // (exec so it stays the container's main process and signals / the
1419
- // readyCheck behave exactly as before). `/run` is a tmpfs on this
1420
- // service, so the file is writable and never persisted into a snapshot.
1421
- const cmd = "GW=\"$(ip route 2>/dev/null | awk '/^default/{print $3; exit}')\"; " +
1422
- 'if [ -n "$GW" ]; then ' +
1423
- "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
1424
- "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
1425
- ": > /run/spectest-resolv.conf; fi; " +
1426
- // Make the root mount shared. A CSI node plugin bind-mounts volumes
1427
- // under /var/lib/kubelet and needs that propagation to reach the
1428
- // kubelet and the workload pod; docker gives a container a private
1429
- // root, so without this every CSI node plugin fails to publish and
1430
- // any snapshot/PVC-backed storage test is impossible. `kind` does the
1431
- // same in its entrypoint for the same reason. Best-effort: on a
1432
- // kernel/runtime that refuses it, the cluster still boots — only CSI
1433
- // is affected.
1434
- "mount --make-rshared / 2>/dev/null || " +
1435
- "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
1436
- storePrepareSh() +
1437
- // Sticky and environment-local stores use overlayfs.
1438
- `exec ${serverArgs} --snapshotter=$SNAP`;
1439
- // Plain /readyz probe. On a warm zot cache the cluster's images are
1440
- // already local, so the first boot completes in seconds; the
1441
- // first-ever boot on a cold-cache host pulls through the mirror and
1442
- // can take a couple of minutes (covered by readyTimeoutSecs).
1443
- //
1444
- // Clearing the boot marker is part of the probe on purpose: "the API
1445
- // server answers" is exactly the condition that proves this cluster came
1446
- // up on the store it was given, and it is the only signal the sweep can
1447
- // read on the next boot. See STORE_SWEEP_SH.
1448
- const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
1449
- const def = {
1450
- image: { type: "registry", reference: `rancher/k3s:${version}` },
1451
- imageBuilds: images.builds,
1452
- tls: registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
1453
- command: cmd,
1454
- privileged: true,
1455
- tmpfs: ["/run", "/var/run"],
1456
- cgroupns: "host",
1457
- // 80/443 are advisory — peer services and host code reach them via
1458
- // the k3s container's IP. The component's Traefik binds them directly
1459
- // (hostNetwork, so it shares the container's netns). 5000 is the
1460
- // in-cluster registry (hostNetwork pod bound to the container netns),
1461
- // reached by peers at the cluster's own `<key>.internal:5000` alias.
1462
- // A `type: LoadBalancer` Service's ports are bound by klipper-lb in
1463
- // the same netns and are reachable the same way, without appearing
1464
- // in this list (it's documentation, not a firewall).
1465
- ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
1466
- // Sticky disks retain an independent overlayfs store per service/version.
1467
- volumes: [...(sticky
1468
- ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1469
- : [{ target: CLUSTER_STORE_TARGET }]),
1470
- ...(publishBuilds.length ? [
1471
- { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1472
- { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
1363
+ const nodeNames = (cluster) => Array.from({ length: count }, (_, i) => i === 0 ? cluster : `${cluster}-node-${i + 1}`);
1364
+ const createNode = (cluster, server) => {
1365
+ const registriesYaml = buildRegistriesYaml(registryEnabled, cluster, registryHost);
1366
+ const nodeArgs = [
1367
+ "k3s", server ? "server" : "agent",
1368
+ "--node-name=$(hostname)",
1369
+ // cgroupns=host is required by our nested runtime. Each kubelet must
1370
+ // own a separate tree or its cleanup can remove another node's pods.
1371
+ "--kubelet-arg=cgroup-root=/spectest-$(hostname)",
1372
+ "--kubelet-arg=kubelet-cgroups=/spectest-$(hostname)/k3s",
1373
+ "--kubelet-arg=runtime-cgroups=/spectest-$(hostname)/k3s",
1374
+ `--token-file=${JOIN_DIRECTORY}/token`,
1375
+ ...(server ? [] : [`--server=https://${cluster}.internal:6443`]),
1376
+ // CoreDNS / pod DNS upstream. Without this, k3s sees only the
1377
+ // loopback 127.0.0.11 (Docker's embedded DNS) in the container's
1378
+ // /etc/resolv.conf, decides no usable nameserver exists, and writes a
1379
+ // fallback `nameserver 8.8.8.8` that CoreDNS then forwards to — so
1380
+ // pods reach the public internet but NOT peer services on
1381
+ // spectest-net (`<svc>.internal`, fakes, service-TLS hosts all
1382
+ // NXDOMAIN). We instead point k3s at the container's default gateway
1383
+ // — the spectest-net bridge gateway, where spectest-resolver binds a
1384
+ // second listener for exactly this. The file is written by the
1385
+ // command wrapper below because the gateway IP is only known at
1386
+ // container start.
1387
+ "--resolv-conf=/run/spectest-resolv.conf",
1388
+ // metrics-server isn't useful in a test cluster.
1389
+ ...(server ? [
1390
+ "--disable=metrics-server",
1391
+ // The bundled traefik is disabled because we install our own with
1392
+ // hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
1393
+ // for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
1394
+ // ours entirely with `k3s({ traefik: false })`; passing
1395
+ // `--disable=traefik` via `extraArgs` does NOT do that (it only
1396
+ // re-disables the bundled one, which is already off) and is rejected
1397
+ // below rather than silently ignored.
1398
+ "--disable=traefik",
1399
+ // ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
1400
+ // Services get an address and work. It was disabled for years
1401
+ // because klipper-lb binds its ports with a CNI portmap hostPort,
1402
+ // and portmap's iptables-nft rules need the `xt_comment` netfilter
1403
+ // match — absent from the stock kernel we used to run on, which made
1404
+ // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
1405
+ // build the guest kernel ourselves now and it carries
1406
+ // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
1407
+ // scripts/local-vms-kernel-additions.config), so the constraint is
1408
+ // gone. local-storage stays enabled too: a controller pod that binds
1409
+ // no host ports.
1410
+ ...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
1411
+ // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
1412
+ // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
1413
+ // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
1414
+ // 10.42.0.0/16 — with --flannel-backend=host-gw, flannel programs that
1415
+ // route into the node's own routing table and gives cni0 the subnet's
1416
+ // .1 (10.42.0.1). That shadows the route to the host gateway, so once
1417
+ // CNI comes up the node can no longer reach 10.42.0.1 and every
1418
+ // subsequent registry pull dies with "connect: connection refused"
1419
+ // (e.g. the in-cluster registry's `registry:2`, applied after the
1420
+ // cluster is up — the airgap-bundled system images pull *before* CNI
1421
+ // and so sneak through). Move pods to 10.44/service to 10.45.
1422
+ "--cluster-cidr=10.44.0.0/16",
1423
+ "--service-cidr=10.45.0.0/16",
1424
+ "--cluster-dns=10.45.0.10",
1425
+ "--flannel-backend=host-gw",
1426
+ "--write-kubeconfig-mode=644",
1427
+ `--tls-san=${cluster}.internal`,
1473
1428
  ] : []),
1474
- ],
1475
- files: [
1476
- ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1477
- ],
1478
- readyCheck: {
1479
- type: "exec",
1480
- command: readyCmd,
1481
- timeoutSecs: opts.readyTimeoutSecs ?? 120,
1429
+ // kube-proxy in nftables mode: native nftables rules, no
1430
+ // xt_comment dependency. Pod→ClusterIP routing works, so
1431
+ // CoreDNS / helm-install / anything-talking-to-the-API works.
1432
+ // GA in k8s 1.32.
1433
+ "--kube-proxy-arg=proxy-mode=nftables",
1434
+ ...(server ? extra : []),
1435
+ ].join(" ");
1436
+ // The service `command` runs under `/bin/sh -c` (see runContainer in
1437
+ // daemon.ts), so derive the bridge gateway from the container's default
1438
+ // route at start time, write it as the k3s resolv-conf, then exec k3s
1439
+ // (exec so it stays the container's main process and signals / the
1440
+ // readyCheck behave exactly as before). `/run` is a tmpfs on this
1441
+ // service; its contents are captured with the VM's memory on snapshot.
1442
+ const cmd = "GW=\"$(ip route 2>/dev/null | awk '/^default/{print $3; exit}')\"; " +
1443
+ 'if [ -n "$GW" ]; then ' +
1444
+ "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
1445
+ "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
1446
+ ": > /run/spectest-resolv.conf; fi; " +
1447
+ // Make the root mount shared. A CSI node plugin bind-mounts volumes
1448
+ // under /var/lib/kubelet and needs that propagation to reach the
1449
+ // kubelet and the workload pod; docker gives a container a private
1450
+ // root, so without this every CSI node plugin fails to publish and
1451
+ // any snapshot/PVC-backed storage test is impossible. `kind` does the
1452
+ // same in its entrypoint for the same reason. Best-effort: on a
1453
+ // kernel/runtime that refuses it, the cluster still boots — only CSI
1454
+ // is affected.
1455
+ "mount --make-rshared / 2>/dev/null || " +
1456
+ "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
1457
+ // Kubelet validates its cgroup root before it creates kubepods. Delegate
1458
+ // the controllers on this empty parent, keeping k3s processes in a leaf.
1459
+ 'CG="spectest-$(hostname)"; ' +
1460
+ 'if [ -f /sys/fs/cgroup/cgroup.controllers ]; then ' +
1461
+ 'mkdir -p "/sys/fs/cgroup/$CG/k3s" || exit 1; ' +
1462
+ 'sed "s/[^ ][^ ]*/+&/g" "/sys/fs/cgroup/$CG/cgroup.controllers" > "/sys/fs/cgroup/$CG/cgroup.subtree_control" || exit 1; ' +
1463
+ 'else for C in /sys/fs/cgroup/*; do [ ! -d "$C" ] || mkdir -p "$C/$CG/k3s" || exit 1; done; fi; ' +
1464
+ storePrepareSh() +
1465
+ (server
1466
+ ? `if [ ! -s ${JOIN_DIRECTORY}/token ]; then (umask 077; cat /proc/sys/kernel/random/uuid > ${JOIN_DIRECTORY}/token); fi; `
1467
+ : `while [ ! -s ${JOIN_DIRECTORY}/token ]; do sleep 0.1; done; `) +
1468
+ // Sticky and environment-local stores use overlayfs.
1469
+ `exec ${nodeArgs} --snapshotter=$SNAP`;
1470
+ // Plain /readyz probe. On a warm zot cache the cluster's images are
1471
+ // already local, so the first boot completes in seconds; the
1472
+ // first-ever boot on a cold-cache host pulls through the mirror and
1473
+ // can take a couple of minutes (covered by readyTimeoutSecs).
1474
+ //
1475
+ // Per-node image-store boot markers are cleared after group setup has
1476
+ // checked all Nodes and finished loading images, not at API readiness.
1477
+ const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
1478
+ const def = {
1479
+ image: { type: "registry", reference: `rancher/k3s:${version}` },
1480
+ imageBuilds: server ? images.builds : [],
1481
+ tls: server && registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
1482
+ command: cmd,
1483
+ privileged: true,
1484
+ tmpfs: ["/run", "/var/run"],
1485
+ cgroupns: "host",
1486
+ // 80/443 are advisory — peer services and host code reach them via
1487
+ // the k3s container's IP. The component's Traefik binds them directly
1488
+ // (hostNetwork, so it shares the container's netns). 5000 is the
1489
+ // in-cluster registry (hostNetwork pod bound to the container netns),
1490
+ // reached by peers at the cluster's own `<key>.internal:5000` alias.
1491
+ // A `type: LoadBalancer` Service's ports are bound by klipper-lb in
1492
+ // the same netns and are reachable the same way, without appearing
1493
+ // in this list (it's documentation, not a firewall).
1494
+ ports: server ? (registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443]) : [],
1495
+ // Sticky disks retain an independent overlayfs store per service/version.
1496
+ // Every node has its own writable containerd root.
1497
+ volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
1498
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1499
+ : [{ target: CLUSTER_STORE_TARGET }]),
1500
+ ...(server && publishBuilds.length ? [
1501
+ { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1502
+ { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
1503
+ ] : []),
1504
+ ],
1505
+ files: [
1506
+ ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1507
+ ],
1508
+ // Agents start before the server and wait for its join token. The group
1509
+ // setup below gates readiness on every actual Kubernetes Node, including
1510
+ // the server, rather than treating container startup as cluster readiness.
1511
+ readyCheck: server ? {
1512
+ type: "exec",
1513
+ command: readyCmd,
1514
+ timeoutSecs: opts.readyTimeoutSecs ?? 120,
1515
+ } : undefined,
1516
+ };
1517
+ return server && ingressDomains.length
1518
+ ? provides(def, ingressDomains.map(domain => dnsName(`*.${domain}`, { service: SELF_SERVICE_TOKEN })))
1519
+ : def;
1520
+ };
1521
+ return serviceGroup({
1522
+ primary: "server",
1523
+ services: g => {
1524
+ const parts = { server: createNode(g.name, true) };
1525
+ for (let i = 2; i <= count; i++)
1526
+ parts[`node-${i}`] = createNode(g.name, false);
1527
+ return parts;
1482
1528
  },
1483
1529
  setup: async (ctx) => {
1484
1530
  const { name, helpers, exec } = ctx;
1485
1531
  const runtime = componentRuntime(ctx);
1532
+ const nodes = nodeNames(name);
1486
1533
  if (images.builds.length && !runtime)
1487
1534
  throw new Error(`k3s(${name}): prepared image inputs are missing`);
1488
- const infrastructure = (async () => {
1535
+ const admission = (async () => {
1489
1536
  if (runtime?.caBundle && caPresent()) {
1490
1537
  const { cert, key } = await issueIngressCert([runtime.gateway]);
1491
1538
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
@@ -1526,31 +1573,35 @@ export function k3s(opts = {}) {
1526
1573
  await new Promise(resolve => setTimeout(resolve, 100));
1527
1574
  }
1528
1575
  }
1529
- await setupK3sCluster(name, helpers, {
1530
- registry: registryEnabled,
1531
- seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory, runtime.images.filter(image => isPublished(image.reference))) : undefined,
1532
- ingressDomains,
1533
- traefik: traefikEnabled,
1534
- });
1535
1576
  })();
1577
+ const infrastructure = admission.then(() => setupK3sCluster(name, helpers, {
1578
+ registry: registryEnabled,
1579
+ seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory, runtime.images.filter(image => isPublished(image.reference))) : undefined,
1580
+ ingressDomains,
1581
+ traefik: traefikEnabled,
1582
+ }));
1536
1583
  await waitForAll([
1537
- runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
1538
- prePullK3sImages(name, images.pulls.filter(ref => !isPublished(ref)), exec),
1584
+ infrastructure.then(tasks => tasks.ingress),
1585
+ waitForK3sNodes(name, nodes, exec, (opts.readyTimeoutSecs ?? 120) * 1000),
1586
+ ...nodes.map(node => runtime ? loadK3sBuildImages(node, runtime.images, runtime.gateway, exec) : Promise.resolve()),
1587
+ ...nodes.map(node => prePullK3sImages(node, images.pulls.filter(ref => !isPublished(ref) && !ref.startsWith(`${name}.internal:${K3S_REGISTRY_PORT}/`)), exec)),
1539
1588
  (async () => {
1540
1589
  // Registry-local references must not race registry startup/publication.
1541
- await infrastructure;
1542
- await prePullK3sImages(name, images.pulls.filter(isPublished), exec);
1590
+ await infrastructure.then(tasks => tasks.registry);
1591
+ await waitForAll(nodes.map(node => prePullK3sImages(node, images.pulls.filter(ref => isPublished(ref) || ref.startsWith(`${name}.internal:${K3S_REGISTRY_PORT}/`)), exec)));
1543
1592
  })(),
1544
1593
  ]);
1545
- if (sticky)
1546
- await registerStickyKubernetesStore(name, version);
1594
+ await waitForAll(nodes.map(async (node) => {
1595
+ const cleared = await exec(node, ["rm", "-f", `${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`]);
1596
+ if (cleared.exitCode !== 0)
1597
+ throw new Error(`k3s(${node}): failed to mark image store ready: ${cleared.stderr}`);
1598
+ if (sticky)
1599
+ await registerStickyKubernetesStore(node, version);
1600
+ }));
1547
1601
  },
1548
1602
  helpers: async ({ name, exec, poll }) => {
1549
- // Read the cluster's kubeconfig and address the API server by its
1550
- // auto-assigned `<name>.internal` hostname on spectest-net. TLS
1551
- // verification is off (see the K3sHelpers docstring), so the
1552
- // server's cert SAN list doesn't need to include the .internal
1553
- // name.
1603
+ // Read the server's kubeconfig and address its stable .internal alias.
1604
+ // This endpoint is also used by the agents joining the cluster.
1554
1605
  const kcRead = await exec(name, ["cat", "/etc/rancher/k3s/k3s.yaml"]);
1555
1606
  if (kcRead.exitCode !== 0) {
1556
1607
  throw new Error(`k3s(${name}): failed to read kubeconfig from container: ${kcRead.stderr.trim()}`);
@@ -1592,6 +1643,7 @@ export function k3s(opts = {}) {
1592
1643
  poll,
1593
1644
  };
1594
1645
  return {
1646
+ nodes: nodeNames(name),
1595
1647
  kubeconfig,
1596
1648
  client,
1597
1649
  apply: (manifest, applyOpts) => applyManifest(deps, manifest, applyOpts),
@@ -1601,12 +1653,5 @@ export function k3s(opts = {}) {
1601
1653
  list: (listOpts) => objects.list(listOpts.apiVersion, listOpts.kind, listOpts.namespace, undefined, undefined, undefined, listOpts.fieldSelector, listOpts.labelSelector, listOpts.limit),
1602
1654
  };
1603
1655
  },
1604
- };
1605
- // Wildcard ingress domains → a dnsName(`*.<domain>`, { service: self })
1606
- // each, attached via provides(). SELF_SERVICE_TOKEN resolves to this
1607
- // service's key at load time (the component can't know it here). The
1608
- // resolver then points every host under the domain at the cluster.
1609
- if (ingressDomains.length === 0)
1610
- return def;
1611
- return provides(def, ingressDomains.map((domain) => dnsName(`*.${domain}`, { service: SELF_SERVICE_TOKEN })));
1656
+ });
1612
1657
  }