@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.
- package/README.md +19 -0
- package/dist/components/k3s-nodes.d.ts +5 -0
- package/dist/components/k3s-nodes.js +37 -0
- package/dist/components/k3s.d.ts +7 -41
- package/dist/components/k3s.js +260 -215
- package/dist/components/supabase-coverage.d.ts +7 -0
- package/dist/components/supabase-coverage.js +375 -0
- package/dist/components/supabase.js +30 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -3
- package/package.json +1 -1
- package/src/components/internal-fetch.test.ts +68 -0
- package/src/components/k3s-concurrency.test.ts +76 -3
- package/src/components/k3s-docker.test.ts +140 -0
- package/src/components/k3s-nodes.test.ts +73 -0
- package/src/components/k3s-nodes.ts +35 -0
- package/src/components/k3s-registry.test.ts +13 -8
- package/src/components/k3s.ts +268 -223
- package/src/components/supabase-coverage.test.ts +261 -0
- package/src/components/supabase-coverage.ts +373 -0
- package/src/components/supabase.ts +30 -7
- package/src/index.ts +11 -4
package/src/components/k3s.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
199
|
+
* Written before each node starts and removed after cluster setup succeeds.
|
|
195
200
|
*
|
|
196
|
-
* It survives in the store
|
|
197
|
-
*
|
|
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
|
-
*
|
|
1170
|
-
*
|
|
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 =
|
|
1192
|
+
const host = `${cluster}.internal:${K3S_REGISTRY_PORT}`;
|
|
1186
1193
|
lines.push(
|
|
1187
1194
|
` "${host}":`,
|
|
1188
1195
|
` endpoint:`,
|
|
1189
|
-
` - "http
|
|
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
|
-
` "
|
|
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)
|
|
1211
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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
|
|
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
|
|
1937
|
-
* pod traffic
|
|
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
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
|
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
|
-
|
|
2162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2173
|
-
//
|
|
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
|
-
}
|
|
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
|
}
|