@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/dist/components/k3s.js
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";
|
|
@@ -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
|
|
29
|
+
* Written before each node starts and removed after cluster setup succeeds.
|
|
28
30
|
*
|
|
29
|
-
* It survives in the store
|
|
30
|
-
*
|
|
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
|
-
*
|
|
666
|
-
*
|
|
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 =
|
|
682
|
-
lines.push(` "${host}":`, ` endpoint:`, ` - "http
|
|
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
|
-
` "
|
|
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)
|
|
699
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
|
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
|
|
1309
|
-
* pod traffic
|
|
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
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
|
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
|
-
|
|
1538
|
-
|
|
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(
|
|
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
|
-
|
|
1546
|
-
await
|
|
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
|
|
1550
|
-
//
|
|
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
|
}
|