@specific.dev/spectest 0.120.1 → 0.121.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";
@@ -34,12 +35,12 @@ import {
34
35
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
35
36
 
36
37
  import type {
37
- ServiceDefinition,
38
+ ServiceConfig,
38
39
  ServiceHelpersContext,
39
40
  ServiceSetupContext,
40
41
  SpectestContext,
41
42
  } from "../index.js";
42
- import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
43
+ import { dnsName, provides, serviceGroup, SELF_SERVICE_TOKEN } from "../index.js";
43
44
  import { stickyDiskPathSync } from "../harness/sticky-paths.js";
44
45
  import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
45
46
  import { prePullK3sImages } from "../harness/k3s-prepull.js";
@@ -66,6 +67,9 @@ export interface K3sRegistryOptions {
66
67
  /** Kubernetes pods receive read-only environment CA mounts during admission.
67
68
  * Image bytes and digests stay unchanged; explicit pod trust settings win. */
68
69
  export interface K3sOptions {
70
+ /** Total schedulable nodes, including the server node. Defaults to 1.
71
+ * Additional nodes run k3s agents in separate containers inside the same VM. */
72
+ nodes?: number;
69
73
  /** Images available to kubelet before project setup. Use registry strings,
70
74
  * or Dockerfile builds with a full `reference` matching your manifests. Builds
71
75
  * support path/content, context, exclude, target and buildArgs, and run in
@@ -184,6 +188,7 @@ export interface K3sOptions {
184
188
 
185
189
  /** Port the in-cluster registry listens on (plain HTTP). */
186
190
  const K3S_REGISTRY_PORT = 5000;
191
+ const JOIN_DIRECTORY = "/etc/spectest-k3s-join";
187
192
 
188
193
  /** The cluster's own containerd root, inside the k3s container. */
189
194
  const CLUSTER_STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
@@ -191,11 +196,10 @@ const CLUSTER_STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
191
196
  const PREPARE_SOCKET = "/run/spectest-prepare.sock";
192
197
 
193
198
  /**
194
- * Written before the server starts and removed by the ready probe.
199
+ * Written before each node starts and removed after cluster setup succeeds.
195
200
  *
196
- * It survives in the store only when a boot never reached a live API
197
- * server, which is the one thing the sweep below cannot work out for
198
- * itself. Blacksmith's rule, from the other end: they refuse to commit a
201
+ * It survives in the store when a node or its image preparation never became
202
+ * 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
199
203
  * cache when the integrity check *did not run*, not only when it failed.
200
204
  */
201
205
  const BOOT_MARKER = ".spectest-boot-incomplete";
@@ -295,6 +299,9 @@ const OVERRIDDEN_EXTRA_ARGS: Record<string, string> = {
295
299
 
296
300
  function assertUsableExtraArgs(extra: readonly string[]): void {
297
301
  for (const arg of extra) {
302
+ if (/^--(?:node-name|with-node-id|token|token-file|agent-token|agent-token-file|server|disable-agent)(?:[=\s]|$)/.test(arg.trim())) {
303
+ throw new Error(`k3s(): extraArgs cannot override cluster identity or joining: ${arg}`);
304
+ }
298
305
  const m = /^--disable[=\s]+(.+)$/.exec(arg.trim());
299
306
  const addon = m?.[1]?.trim();
300
307
  if (!addon) continue;
@@ -475,6 +482,8 @@ export interface LogsOptions {
475
482
 
476
483
  /** Helpers a `k3s(...)` service exposes on `ctx.svc.<name>`. */
477
484
  export interface K3sHelpers {
485
+ /** Kubernetes node names, also their Spectest container/service names. */
486
+ nodes: readonly string[];
478
487
  /**
479
488
  * Fully-loaded `KubeConfig`. The cluster server URL is rewritten to
480
489
  * `https://<service-name>.internal:6443`, the auto-assigned DNS name
@@ -1023,7 +1032,7 @@ const TRAEFIK_DYNAMIC_TLS = `tls:
1023
1032
  * then works for any routed host under the cluster's `ingressDomains`
1024
1033
  * with zero per-Ingress config; the :80 `web` entrypoint is unchanged.
1025
1034
  */
1026
- function buildTraefikManifest(tls: boolean): string {
1035
+ function buildTraefikManifest(tls: boolean, node: string): string {
1027
1036
  const args = [
1028
1037
  " - --entrypoints.web.address=:80",
1029
1038
  ...(tls
@@ -1133,6 +1142,8 @@ spec:
1133
1142
  app: traefik
1134
1143
  spec:
1135
1144
  serviceAccountName: traefik
1145
+ nodeSelector:
1146
+ kubernetes.io/hostname: ${JSON.stringify(node)}
1136
1147
  hostNetwork: true
1137
1148
  dnsPolicy: Default
1138
1149
  tolerations:
@@ -1166,14 +1177,10 @@ const HUB_MIRROR = { registry: "docker.io", mirror: "https://mirror.gcr.io", ups
1166
1177
  * `setup` hook. Two jobs:
1167
1178
  * 1. Mirror the cluster's Docker Hub pulls through `mirror.gcr.io`.
1168
1179
  * 2. Trust the in-cluster registry, addressed as `<key>.internal:5000`
1169
- * (the `{{SPECTEST_SERVICE}}` token is expanded to the cluster's
1170
- * service key when the file is written). Image *references* use
1171
- * that peer-reachable name, but containerd pulls via the loopback
1172
- * endpoint `http://127.0.0.1:5000` — the hostNetwork registry pod
1173
- * shares the node's netns, so this needs no in-container DNS and
1174
- * can't be broken by a clobbered `hostnames`.
1180
+ * using the final group key from service expansion. Every node pulls through
1181
+ * the server's stable .internal alias, including the server itself.
1175
1182
  */
1176
- function buildRegistriesYaml(registryEnabled: boolean, host?: string): string | null {
1183
+ function buildRegistriesYaml(registryEnabled: boolean, cluster: string, host?: string): string | null {
1177
1184
  const lines: string[] = [
1178
1185
  "mirrors:",
1179
1186
  ` "${HUB_MIRROR.registry}":`,
@@ -1182,15 +1189,15 @@ function buildRegistriesYaml(registryEnabled: boolean, host?: string): string |
1182
1189
  ` - "${HUB_MIRROR.upstream}"`,
1183
1190
  ];
1184
1191
  if (registryEnabled) {
1185
- const host = `{{SPECTEST_SERVICE}}.internal:${K3S_REGISTRY_PORT}`;
1192
+ const host = `${cluster}.internal:${K3S_REGISTRY_PORT}`;
1186
1193
  lines.push(
1187
1194
  ` "${host}":`,
1188
1195
  ` endpoint:`,
1189
- ` - "http://127.0.0.1:${K3S_REGISTRY_PORT}"`,
1196
+ ` - "http://${host}"`,
1190
1197
  "configs:",
1191
1198
  // The endpoint is plain HTTP; the config (keyed by endpoint host)
1192
1199
  // makes that explicit and disables any TLS attempt against it.
1193
- ` "127.0.0.1:${K3S_REGISTRY_PORT}":`,
1200
+ ` "${host}":`,
1194
1201
  ` tls:`,
1195
1202
  ` insecure_skip_verify: true`,
1196
1203
  );
@@ -1207,13 +1214,13 @@ function buildRegistriesYaml(registryEnabled: boolean, host?: string): string |
1207
1214
  * In-cluster OCI registry (CNCF `distribution`). `hostNetwork: true`
1208
1215
  * binds the cluster container's `:5000` directly — the same trick
1209
1216
  * Traefik uses — so peer services reach it at `<cluster-key>.internal:5000`
1210
- * (the cluster service's own alias) and the node's own containerd reaches
1211
- * it at `127.0.0.1:5000`. Storage is an `emptyDir` by default. Declared
1217
+ * (the cluster service's own alias). The pod is pinned to the server node
1218
+ * so this address stays valid. Storage is an `emptyDir` by default. Declared
1212
1219
  * publications use a rootfs directory plus a read-only content mount; both
1213
1220
  * variants keep writable registry state outside persistent image caches and
1214
1221
  * capture it with the VM snapshot, isolated per test fork.
1215
1222
  */
1216
- function registryManifest(seeded: boolean): string {
1223
+ function registryManifest(seeded: boolean, node: string): string {
1217
1224
  return `apiVersion: apps/v1
1218
1225
  kind: Deployment
1219
1226
  metadata:
@@ -1231,6 +1238,8 @@ spec:
1231
1238
  labels:
1232
1239
  app: spectest-registry
1233
1240
  spec:
1241
+ nodeSelector:
1242
+ kubernetes.io/hostname: ${JSON.stringify(node)}
1234
1243
  hostNetwork: true
1235
1244
  dnsPolicy: Default
1236
1245
  tolerations:
@@ -1782,11 +1791,11 @@ async function waitForJob(
1782
1791
  * entrypoint serving it as the default cert. Those domains are then
1783
1792
  * reachable over HTTPS with a cert the test framework already trusts.
1784
1793
  */
1785
- async function setupK3sCluster(
1794
+ function setupK3sCluster(
1786
1795
  name: string,
1787
1796
  helpers: K3sHelpers,
1788
1797
  opts: { registry: boolean; ingressDomains: string[]; traefik: boolean; seed?: () => Promise<void> },
1789
- ): Promise<void> {
1798
+ ): { ingress: Promise<void>; registry: Promise<void> } {
1790
1799
  // The registry can start while Traefik's certificates are being prepared.
1791
1800
  const wait = (deployment: string): Promise<void> =>
1792
1801
  helpers.waitForRollout(deployment, {
@@ -1794,46 +1803,45 @@ async function setupK3sCluster(
1794
1803
  timeoutMs: 120_000,
1795
1804
  intervalMs: 250,
1796
1805
  });
1797
- await waitForAll([
1798
- (async () => {
1799
- if (!opts.traefik) return;
1800
- const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1801
- if (tlsEnabled) {
1802
- const { cert, key } = await issueIngressCert(
1803
- opts.ingressDomains.map((d) => `*.${d}`),
1804
- );
1805
- // Apply the cert Secret + dynamic-config ConfigMap before the
1806
- // Deployment that mounts them. `stringData` lets us hand over plain
1807
- // PEM; the API server base64-encodes it.
1808
- await helpers.client.core.createNamespacedSecret({
1809
- namespace: "kube-system",
1810
- body: {
1811
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1812
- type: "kubernetes.io/tls",
1813
- stringData: { "tls.crt": cert, "tls.key": key },
1814
- },
1815
- });
1816
- await helpers.client.core.createNamespacedConfigMap({
1817
- namespace: "kube-system",
1818
- body: {
1819
- metadata: {
1820
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1821
- namespace: "kube-system",
1822
- },
1823
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1806
+ const ingress = (async () => {
1807
+ if (!opts.traefik) return;
1808
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1809
+ if (tlsEnabled) {
1810
+ const { cert, key } = await issueIngressCert(
1811
+ opts.ingressDomains.map((d) => `*.${d}`),
1812
+ );
1813
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1814
+ // Deployment that mounts them. `stringData` lets us hand over plain
1815
+ // PEM; the API server base64-encodes it.
1816
+ await helpers.client.core.createNamespacedSecret({
1817
+ namespace: "kube-system",
1818
+ body: {
1819
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1820
+ type: "kubernetes.io/tls",
1821
+ stringData: { "tls.crt": cert, "tls.key": key },
1822
+ },
1823
+ });
1824
+ await helpers.client.core.createNamespacedConfigMap({
1825
+ namespace: "kube-system",
1826
+ body: {
1827
+ metadata: {
1828
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1829
+ namespace: "kube-system",
1824
1830
  },
1825
- });
1826
- }
1827
- await helpers.apply(buildTraefikManifest(tlsEnabled));
1828
- await wait("traefik");
1829
- })(),
1830
- (async () => {
1831
- if (!opts.registry) return;
1832
- await opts.seed?.();
1833
- await helpers.apply(registryManifest(!!opts.seed));
1834
- await wait("spectest-registry");
1835
- })(),
1836
- ]);
1831
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1832
+ },
1833
+ });
1834
+ }
1835
+ await helpers.apply(buildTraefikManifest(tlsEnabled, name));
1836
+ await wait("traefik");
1837
+ })();
1838
+ const registry = (async () => {
1839
+ if (!opts.registry) return;
1840
+ await opts.seed?.();
1841
+ await helpers.apply(registryManifest(!!opts.seed, name));
1842
+ await wait("spectest-registry");
1843
+ })();
1844
+ return { ingress, registry };
1837
1845
  }
1838
1846
 
1839
1847
  /**
@@ -1901,7 +1909,7 @@ async function collectDiagnostics(
1901
1909
  }
1902
1910
 
1903
1911
  /**
1904
- * A ready-to-use single-node Kubernetes cluster (k3s). Drop into
1912
+ * A ready-to-use Kubernetes cluster (k3s). Drop into
1905
1913
  * `environment.services`:
1906
1914
  *
1907
1915
  * ```ts
@@ -1933,8 +1941,8 @@ async function collectDiagnostics(
1933
1941
  * path regardless, so it stays. It is GA in k8s 1.32, which is what
1934
1942
  * pins `DEFAULT_K3S_VERSION`.
1935
1943
  *
1936
- * Flannel uses the `host-gw` backend: single-node clusters never route
1937
- * pod traffic off the node, so VXLAN encapsulation is pure overhead.
1944
+ * Flannel uses `host-gw`: all node containers share a layer-2 Docker bridge,
1945
+ * so cross-node pod traffic uses direct routes without VXLAN encapsulation.
1938
1946
  */
1939
1947
  /**
1940
1948
  * Default k3s docker image tag.
@@ -1953,6 +1961,12 @@ async function collectDiagnostics(
1953
1961
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
1954
1962
 
1955
1963
  export function k3s(opts: K3sOptions = {}) {
1964
+ const count = opts.nodes === undefined ? 1 : opts.nodes;
1965
+ if (!Number.isSafeInteger(count) || count < 1) throw new Error("k3s(): nodes must be a positive integer");
1966
+ return createK3sCluster(opts, count);
1967
+ }
1968
+
1969
+ function createK3sCluster(opts: K3sOptions, count: number) {
1956
1970
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1957
1971
  const images = kubernetesImages(opts.images ?? []);
1958
1972
  const extra = opts.extraArgs ?? [];
@@ -1977,144 +1991,182 @@ export function k3s(opts: K3sOptions = {}) {
1977
1991
  // `/etc/rancher/k3s/registries.yaml` (host-cache mirrors + trust for
1978
1992
  // the in-cluster registry). Seeded via `files` because k3s reads it
1979
1993
  // only at startup, before any setup hook could run.
1980
- const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
1981
1994
  // Pre-pulls read content from the environment's containerd store.
1982
1995
  const sticky = stickyDiskPathSync();
1983
1996
  const contentDirectory = `${sticky ? `${sticky}/containerd` : "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1984
- const serverArgs = [
1985
- "k3s",
1986
- "server",
1987
- // CoreDNS / pod DNS upstream. Without this, k3s sees only the
1988
- // loopback 127.0.0.11 (Docker's embedded DNS) in the container's
1989
- // /etc/resolv.conf, decides no usable nameserver exists, and writes a
1990
- // fallback `nameserver 8.8.8.8` that CoreDNS then forwards to — so
1991
- // pods reach the public internet but NOT peer services on
1992
- // spectest-net (`<svc>.internal`, fakes, service-TLS hosts all
1993
- // NXDOMAIN). We instead point k3s at the container's default gateway
1994
- // — the spectest-net bridge gateway, where spectest-resolver binds a
1995
- // second listener for exactly this. The file is written by the
1996
- // command wrapper below because the gateway IP is only known at
1997
- // container start.
1998
- "--resolv-conf=/run/spectest-resolv.conf",
1999
- // metrics-server isn't useful in a test cluster.
2000
- "--disable=metrics-server",
2001
- // The bundled traefik is disabled because we install our own with
2002
- // hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
2003
- // for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
2004
- // ours entirely with `k3s({ traefik: false })`; passing
2005
- // `--disable=traefik` via `extraArgs` does NOT do that (it only
2006
- // re-disables the bundled one, which is already off) and is rejected
2007
- // below rather than silently ignored.
2008
- "--disable=traefik",
2009
- // ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
2010
- // Services get an address and work. It was disabled for years
2011
- // because klipper-lb binds its ports with a CNI portmap hostPort,
2012
- // and portmap's iptables-nft rules need the `xt_comment` netfilter
2013
- // match — absent from the stock kernel we used to run on, which made
2014
- // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
2015
- // build the guest kernel ourselves now and it carries
2016
- // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
2017
- // scripts/local-vms-kernel-additions.config), so the constraint is
2018
- // gone. local-storage stays enabled too: a controller pod that binds
2019
- // no host ports.
2020
- ...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
2021
- // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
2022
- // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
2023
- // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
2024
- // 10.42.0.0/16 — with --flannel-backend=host-gw, flannel programs that
2025
- // route into the node's own routing table and gives cni0 the subnet's
2026
- // .1 (10.42.0.1). That shadows the route to the host gateway, so once
2027
- // CNI comes up the node can no longer reach 10.42.0.1 and every
2028
- // subsequent registry pull dies with "connect: connection refused"
2029
- // (e.g. the in-cluster registry's `registry:2`, applied after the
2030
- // cluster is up — the airgap-bundled system images pull *before* CNI
2031
- // and so sneak through). Move pods to 10.44/service to 10.45.
2032
- "--cluster-cidr=10.44.0.0/16",
2033
- "--service-cidr=10.45.0.0/16",
2034
- "--cluster-dns=10.45.0.10",
2035
- "--flannel-backend=host-gw",
2036
- "--write-kubeconfig-mode=644",
2037
- // kube-proxy in nftables mode: native nftables rules, no
2038
- // xt_comment dependency. Pod→ClusterIP routing works, so
2039
- // CoreDNS / helm-install / anything-talking-to-the-API works.
2040
- // GA in k8s 1.32.
2041
- "--kube-proxy-arg=proxy-mode=nftables",
2042
- ...extra,
2043
- ].join(" ");
2044
- // The service `command` runs under `/bin/sh -c` (see runContainer in
2045
- // daemon.ts), so derive the bridge gateway from the container's default
2046
- // route at start time, write it as the k3s resolv-conf, then exec k3s
2047
- // (exec so it stays the container's main process and signals / the
2048
- // readyCheck behave exactly as before). `/run` is a tmpfs on this
2049
- // service, so the file is writable and never persisted into a snapshot.
2050
- const cmd =
2051
- "GW=\"$(ip route 2>/dev/null | awk '/^default/{print $3; exit}')\"; " +
2052
- 'if [ -n "$GW" ]; then ' +
2053
- "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
2054
- "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
2055
- ": > /run/spectest-resolv.conf; fi; " +
2056
- // Make the root mount shared. A CSI node plugin bind-mounts volumes
2057
- // under /var/lib/kubelet and needs that propagation to reach the
2058
- // kubelet and the workload pod; docker gives a container a private
2059
- // root, so without this every CSI node plugin fails to publish and
2060
- // any snapshot/PVC-backed storage test is impossible. `kind` does the
2061
- // same in its entrypoint for the same reason. Best-effort: on a
2062
- // kernel/runtime that refuses it, the cluster still boots — only CSI
2063
- // is affected.
2064
- "mount --make-rshared / 2>/dev/null || " +
2065
- "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
2066
- storePrepareSh() +
2067
- // Sticky and environment-local stores use overlayfs.
2068
- `exec ${serverArgs} --snapshotter=$SNAP`;
2069
- // Plain /readyz probe. On a warm zot cache the cluster's images are
2070
- // already local, so the first boot completes in seconds; the
2071
- // first-ever boot on a cold-cache host pulls through the mirror and
2072
- // can take a couple of minutes (covered by readyTimeoutSecs).
2073
- //
2074
- // Clearing the boot marker is part of the probe on purpose: "the API
2075
- // server answers" is exactly the condition that proves this cluster came
2076
- // up on the store it was given, and it is the only signal the sweep can
2077
- // read on the next boot. See STORE_SWEEP_SH.
2078
- const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
2079
- const def = {
2080
- image: { type: "registry", reference: `rancher/k3s:${version}` },
2081
- imageBuilds: images.builds,
2082
- tls: registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
2083
- command: cmd,
2084
- privileged: true,
2085
- tmpfs: ["/run", "/var/run"],
2086
- cgroupns: "host",
2087
- // 80/443 are advisory — peer services and host code reach them via
2088
- // the k3s container's IP. The component's Traefik binds them directly
2089
- // (hostNetwork, so it shares the container's netns). 5000 is the
2090
- // in-cluster registry (hostNetwork pod bound to the container netns),
2091
- // reached by peers at the cluster's own `<key>.internal:5000` alias.
2092
- // A `type: LoadBalancer` Service's ports are bound by klipper-lb in
2093
- // the same netns and are reachable the same way, without appearing
2094
- // in this list (it's documentation, not a firewall).
2095
- ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
2096
- // Sticky disks retain an independent overlayfs store per service/version.
2097
- volumes: [...(sticky
2098
- ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2099
- : [{ target: CLUSTER_STORE_TARGET }]),
2100
- ...(publishBuilds.length ? [
2101
- { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2102
- { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
1997
+ const nodeNames = (cluster: string) => Array.from({ length: count }, (_, i) => i === 0 ? cluster : `${cluster}-node-${i + 1}`);
1998
+ const createNode = (cluster: string, server: boolean): ServiceConfig => {
1999
+ const registriesYaml = buildRegistriesYaml(registryEnabled, cluster, registryHost);
2000
+ const nodeArgs = [
2001
+ "k3s", server ? "server" : "agent",
2002
+ "--node-name=$(hostname)",
2003
+ // cgroupns=host is required by our nested runtime. Each kubelet must
2004
+ // own a separate tree or its cleanup can remove another node's pods.
2005
+ "--kubelet-arg=cgroup-root=/spectest-$(hostname)",
2006
+ "--kubelet-arg=kubelet-cgroups=/spectest-$(hostname)/k3s",
2007
+ "--kubelet-arg=runtime-cgroups=/spectest-$(hostname)/k3s",
2008
+ `--token-file=${JOIN_DIRECTORY}/token`,
2009
+ ...(server ? [] : [`--server=https://${cluster}.internal:6443`]),
2010
+ // CoreDNS / pod DNS upstream. Without this, k3s sees only the
2011
+ // loopback 127.0.0.11 (Docker's embedded DNS) in the container's
2012
+ // /etc/resolv.conf, decides no usable nameserver exists, and writes a
2013
+ // fallback `nameserver 8.8.8.8` that CoreDNS then forwards to — so
2014
+ // pods reach the public internet but NOT peer services on
2015
+ // spectest-net (`<svc>.internal`, fakes, service-TLS hosts all
2016
+ // NXDOMAIN). We instead point k3s at the container's default gateway
2017
+ // — the spectest-net bridge gateway, where spectest-resolver binds a
2018
+ // second listener for exactly this. The file is written by the
2019
+ // command wrapper below because the gateway IP is only known at
2020
+ // container start.
2021
+ "--resolv-conf=/run/spectest-resolv.conf",
2022
+ // metrics-server isn't useful in a test cluster.
2023
+ ...(server ? [
2024
+ "--disable=metrics-server",
2025
+ // The bundled traefik is disabled because we install our own with
2026
+ // hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
2027
+ // for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
2028
+ // ours entirely with `k3s({ traefik: false })`; passing
2029
+ // `--disable=traefik` via `extraArgs` does NOT do that (it only
2030
+ // re-disables the bundled one, which is already off) and is rejected
2031
+ // below rather than silently ignored.
2032
+ "--disable=traefik",
2033
+ // ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
2034
+ // Services get an address and work. It was disabled for years
2035
+ // because klipper-lb binds its ports with a CNI portmap hostPort,
2036
+ // and portmap's iptables-nft rules need the `xt_comment` netfilter
2037
+ // match — absent from the stock kernel we used to run on, which made
2038
+ // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
2039
+ // build the guest kernel ourselves now and it carries
2040
+ // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
2041
+ // scripts/local-vms-kernel-additions.config), so the constraint is
2042
+ // gone. local-storage stays enabled too: a controller pod that binds
2043
+ // no host ports.
2044
+ ...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
2045
+ // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
2046
+ // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
2047
+ // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
2048
+ // 10.42.0.0/16 — with --flannel-backend=host-gw, flannel programs that
2049
+ // route into the node's own routing table and gives cni0 the subnet's
2050
+ // .1 (10.42.0.1). That shadows the route to the host gateway, so once
2051
+ // CNI comes up the node can no longer reach 10.42.0.1 and every
2052
+ // subsequent registry pull dies with "connect: connection refused"
2053
+ // (e.g. the in-cluster registry's `registry:2`, applied after the
2054
+ // cluster is up — the airgap-bundled system images pull *before* CNI
2055
+ // and so sneak through). Move pods to 10.44/service to 10.45.
2056
+ "--cluster-cidr=10.44.0.0/16",
2057
+ "--service-cidr=10.45.0.0/16",
2058
+ "--cluster-dns=10.45.0.10",
2059
+ "--flannel-backend=host-gw",
2060
+ "--write-kubeconfig-mode=644",
2061
+ `--tls-san=${cluster}.internal`,
2103
2062
  ] : []),
2104
- ],
2105
- files: [
2106
- ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2107
- ],
2108
- readyCheck: {
2109
- type: "exec" as const,
2110
- command: readyCmd,
2111
- timeoutSecs: opts.readyTimeoutSecs ?? 120,
2063
+ // kube-proxy in nftables mode: native nftables rules, no
2064
+ // xt_comment dependency. Pod→ClusterIP routing works, so
2065
+ // CoreDNS / helm-install / anything-talking-to-the-API works.
2066
+ // GA in k8s 1.32.
2067
+ "--kube-proxy-arg=proxy-mode=nftables",
2068
+ ...(server ? extra : []),
2069
+ ].join(" ");
2070
+ // The service `command` runs under `/bin/sh -c` (see runContainer in
2071
+ // daemon.ts), so derive the bridge gateway from the container's default
2072
+ // route at start time, write it as the k3s resolv-conf, then exec k3s
2073
+ // (exec so it stays the container's main process and signals / the
2074
+ // readyCheck behave exactly as before). `/run` is a tmpfs on this
2075
+ // service; its contents are captured with the VM's memory on snapshot.
2076
+ const cmd =
2077
+ "GW=\"$(ip route 2>/dev/null | awk '/^default/{print $3; exit}')\"; " +
2078
+ 'if [ -n "$GW" ]; then ' +
2079
+ "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
2080
+ "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
2081
+ ": > /run/spectest-resolv.conf; fi; " +
2082
+ // Make the root mount shared. A CSI node plugin bind-mounts volumes
2083
+ // under /var/lib/kubelet and needs that propagation to reach the
2084
+ // kubelet and the workload pod; docker gives a container a private
2085
+ // root, so without this every CSI node plugin fails to publish and
2086
+ // any snapshot/PVC-backed storage test is impossible. `kind` does the
2087
+ // same in its entrypoint for the same reason. Best-effort: on a
2088
+ // kernel/runtime that refuses it, the cluster still boots — only CSI
2089
+ // is affected.
2090
+ "mount --make-rshared / 2>/dev/null || " +
2091
+ "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
2092
+ // Kubelet validates its cgroup root before it creates kubepods. Delegate
2093
+ // the controllers on this empty parent, keeping k3s processes in a leaf.
2094
+ 'CG="spectest-$(hostname)"; ' +
2095
+ 'if [ -f /sys/fs/cgroup/cgroup.controllers ]; then ' +
2096
+ 'mkdir -p "/sys/fs/cgroup/$CG/k3s" || exit 1; ' +
2097
+ 'sed "s/[^ ][^ ]*/+&/g" "/sys/fs/cgroup/$CG/cgroup.controllers" > "/sys/fs/cgroup/$CG/cgroup.subtree_control" || exit 1; ' +
2098
+ 'else for C in /sys/fs/cgroup/*; do [ ! -d "$C" ] || mkdir -p "$C/$CG/k3s" || exit 1; done; fi; ' +
2099
+ storePrepareSh() +
2100
+ (server
2101
+ ? `if [ ! -s ${JOIN_DIRECTORY}/token ]; then (umask 077; cat /proc/sys/kernel/random/uuid > ${JOIN_DIRECTORY}/token); fi; `
2102
+ : `while [ ! -s ${JOIN_DIRECTORY}/token ]; do sleep 0.1; done; `) +
2103
+ // Sticky and environment-local stores use overlayfs.
2104
+ `exec ${nodeArgs} --snapshotter=$SNAP`;
2105
+ // Plain /readyz probe. On a warm zot cache the cluster's images are
2106
+ // already local, so the first boot completes in seconds; the
2107
+ // first-ever boot on a cold-cache host pulls through the mirror and
2108
+ // can take a couple of minutes (covered by readyTimeoutSecs).
2109
+ //
2110
+ // Per-node image-store boot markers are cleared after group setup has
2111
+ // checked all Nodes and finished loading images, not at API readiness.
2112
+ const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
2113
+ const def = {
2114
+ image: { type: "registry", reference: `rancher/k3s:${version}` },
2115
+ imageBuilds: server ? images.builds : [],
2116
+ tls: server && registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
2117
+ command: cmd,
2118
+ privileged: true,
2119
+ tmpfs: ["/run", "/var/run"],
2120
+ cgroupns: "host",
2121
+ // 80/443 are advisory — peer services and host code reach them via
2122
+ // the k3s container's IP. The component's Traefik binds them directly
2123
+ // (hostNetwork, so it shares the container's netns). 5000 is the
2124
+ // in-cluster registry (hostNetwork pod bound to the container netns),
2125
+ // reached by peers at the cluster's own `<key>.internal:5000` alias.
2126
+ // A `type: LoadBalancer` Service's ports are bound by klipper-lb in
2127
+ // the same netns and are reachable the same way, without appearing
2128
+ // in this list (it's documentation, not a firewall).
2129
+ ports: server ? (registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443]) : [],
2130
+ // Sticky disks retain an independent overlayfs store per service/version.
2131
+ // Every node has its own writable containerd root.
2132
+ volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
2133
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2134
+ : [{ target: CLUSTER_STORE_TARGET }]),
2135
+ ...(server && publishBuilds.length ? [
2136
+ { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2137
+ { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
2138
+ ] : []),
2139
+ ],
2140
+ files: [
2141
+ ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2142
+ ],
2143
+ // Agents start before the server and wait for its join token. The group
2144
+ // setup below gates readiness on every actual Kubernetes Node, including
2145
+ // the server, rather than treating container startup as cluster readiness.
2146
+ readyCheck: server ? {
2147
+ type: "exec" as const,
2148
+ command: readyCmd,
2149
+ timeoutSecs: opts.readyTimeoutSecs ?? 120,
2150
+ } : undefined,
2151
+ } satisfies ServiceConfig;
2152
+ return server && ingressDomains.length
2153
+ ? provides(def, ingressDomains.map(domain => dnsName(`*.${domain}`, { service: SELF_SERVICE_TOKEN })))
2154
+ : def;
2155
+ };
2156
+
2157
+ return serviceGroup({
2158
+ primary: "server",
2159
+ services: g => {
2160
+ const parts: Record<string, ServiceConfig> = { server: createNode(g.name, true) };
2161
+ for (let i = 2; i <= count; i++) parts[`node-${i}`] = createNode(g.name, false);
2162
+ return parts;
2112
2163
  },
2113
2164
  setup: async (ctx: ServiceSetupContext<K3sHelpers>) => {
2114
2165
  const { name, helpers, exec } = ctx;
2115
2166
  const runtime = componentRuntime(ctx);
2167
+ const nodes = nodeNames(name);
2116
2168
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2117
- const infrastructure = (async () => {
2169
+ const admission = (async () => {
2118
2170
  if (runtime?.caBundle && caPresent()) {
2119
2171
  const { cert, key } = await issueIngressCert([runtime.gateway]);
2120
2172
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
@@ -2149,31 +2201,35 @@ export function k3s(opts: K3sOptions = {}) {
2149
2201
  await new Promise(resolve => setTimeout(resolve, 100));
2150
2202
  }
2151
2203
  }
2152
- await setupK3sCluster(name, helpers, {
2153
- registry: registryEnabled,
2154
- seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory,
2155
- runtime!.images.filter(image => isPublished(image.reference))) : undefined,
2156
- ingressDomains,
2157
- traefik: traefikEnabled,
2158
- });
2159
2204
  })();
2205
+ const infrastructure = admission.then(() => setupK3sCluster(name, helpers, {
2206
+ registry: registryEnabled,
2207
+ seed: publishBuilds.length ? () => seedRegistry(name, contentDirectory,
2208
+ runtime!.images.filter(image => isPublished(image.reference))) : undefined,
2209
+ ingressDomains,
2210
+ traefik: traefikEnabled,
2211
+ }));
2160
2212
  await waitForAll([
2161
- runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
2162
- prePullK3sImages(name, images.pulls.filter(ref => !isPublished(ref)), exec),
2213
+ infrastructure.then(tasks => tasks.ingress),
2214
+ waitForK3sNodes(name, nodes, exec, (opts.readyTimeoutSecs ?? 120) * 1000),
2215
+ ...nodes.map(node => runtime ? loadK3sBuildImages(node, runtime.images, runtime.gateway, exec) : Promise.resolve()),
2216
+ ...nodes.map(node => prePullK3sImages(node, images.pulls.filter(ref => !isPublished(ref) && !ref.startsWith(`${name}.internal:${K3S_REGISTRY_PORT}/`)), exec)),
2163
2217
  (async () => {
2164
2218
  // Registry-local references must not race registry startup/publication.
2165
- await infrastructure;
2166
- await prePullK3sImages(name, images.pulls.filter(isPublished), exec);
2219
+ await infrastructure.then(tasks => tasks.registry);
2220
+ await waitForAll(nodes.map(node => prePullK3sImages(node,
2221
+ images.pulls.filter(ref => isPublished(ref) || ref.startsWith(`${name}.internal:${K3S_REGISTRY_PORT}/`)), exec)));
2167
2222
  })(),
2168
2223
  ]);
2169
- if (sticky) await registerStickyKubernetesStore(name, version);
2224
+ await waitForAll(nodes.map(async node => {
2225
+ const cleared = await exec(node, ["rm", "-f", `${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`]);
2226
+ if (cleared.exitCode !== 0) throw new Error(`k3s(${node}): failed to mark image store ready: ${cleared.stderr}`);
2227
+ if (sticky) await registerStickyKubernetesStore(node, version);
2228
+ }));
2170
2229
  },
2171
2230
  helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
2172
- // Read the cluster's kubeconfig and address the API server by its
2173
- // auto-assigned `<name>.internal` hostname on spectest-net. TLS
2174
- // verification is off (see the K3sHelpers docstring), so the
2175
- // server's cert SAN list doesn't need to include the .internal
2176
- // name.
2231
+ // Read the server's kubeconfig and address its stable .internal alias.
2232
+ // This endpoint is also used by the agents joining the cluster.
2177
2233
  const kcRead = await exec(name, ["cat", "/etc/rancher/k3s/k3s.yaml"]);
2178
2234
  if (kcRead.exitCode !== 0) {
2179
2235
  throw new Error(
@@ -2223,6 +2279,7 @@ export function k3s(opts: K3sOptions = {}) {
2223
2279
  };
2224
2280
 
2225
2281
  return {
2282
+ nodes: nodeNames(name),
2226
2283
  kubeconfig,
2227
2284
  client,
2228
2285
  apply: (manifest, applyOpts) => applyManifest(deps, manifest, applyOpts),
@@ -2251,17 +2308,5 @@ export function k3s(opts: K3sOptions = {}) {
2251
2308
  ),
2252
2309
  };
2253
2310
  },
2254
- } satisfies ServiceDefinition<K3sHelpers>;
2255
-
2256
- // Wildcard ingress domains → a dnsName(`*.<domain>`, { service: self })
2257
- // each, attached via provides(). SELF_SERVICE_TOKEN resolves to this
2258
- // service's key at load time (the component can't know it here). The
2259
- // resolver then points every host under the domain at the cluster.
2260
- if (ingressDomains.length === 0) return def;
2261
- return provides(
2262
- def,
2263
- ingressDomains.map((domain) =>
2264
- dnsName(`*.${domain}`, { service: SELF_SERVICE_TOKEN }),
2265
- ),
2266
- );
2311
+ });
2267
2312
  }