@mark1russell7/kind 0.13.1 → 0.13.3

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/build/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as signalsOf, A as HBONE_PORT, B as isTunnelState, C as observationEnd, Ct as drainBand, D as reusesReset, Dt as secondsOf, E as referenceOf, Et as seconds, F as deletionOf, G as podsOf, H as outcomesOf, I as dropOf, J as restartOf, K as recordsOf, L as emptyOf, M as backendsAt, N as byClients, O as unitOf, Ot as showBand, P as byNode, Q as signalOf, R as failuresOf, S as observationAfter, St as bandOf, T as propagationSource, Tt as requestBand, U as periodOf, V as kernelCounts, W as podsAt, X as rulesOf, Y as restartsAt, Z as sessionsAt, _ as checkRun, _t as LOG_LABEL, a as calibrationOf, at as socketsOf, b as factsOf, bt as SERVER, c as predictionText, ct as tunnelsOf, d as FORMAT_V1, dt as RELATIVE, et as sliceDropOf, f as judge, ft as medianInterval, g as LAST_SLACK, gt as HEALTH_PORT, h as KIND_NOISE, ht as CLIENT, i as readRecord, it as slicesOf, j as TCP_STATE, k as updatesOf, l as timeSpread, lt as ztunnelPodsOf, m as upgradeCell, mt as quantileOf, n as kindCellRecordSchema, nt as sliceReadyAt, o as cellsTable, ot as stoppingOf, p as upgrade, pt as middle, q as requestsOf, r as kindCellRecordV1Schema, rt as sliceReadyOf, s as medianText, st as transitionOf, t as STAGES, tt as sliceEmptyOf, u as FORMAT, ut as ztunnelReplaced, v as cyclesOf, vt as PORT, w as propagationOf, wt as overshootBand, x as judgeKind, xt as backlogEnd, y as deletionsOf, yt as REDIS, z as intakeOf } from "./judge-DA754h1o.js";
1
+ import { $ as signalsOf, A as HBONE_PORT, At as secondsOf, B as isTunnelState, C as observationEnd, Ct as VIP_ANNOTATION, D as reusesReset, Dt as overshootBand, E as referenceOf, Et as drainBand, F as deletionOf, G as podsOf, H as outcomesOf, I as dropOf, J as restartOf, K as recordsOf, L as emptyOf, M as backendsAt, N as byClients, O as unitOf, Ot as requestBand, P as byNode, Q as signalOf, R as failuresOf, S as observationAfter, St as SERVER, T as propagationSource, Tt as bandOf, U as periodOf, V as kernelCounts, W as podsAt, X as rulesOf, Y as restartsAt, Z as sessionsAt, _ as checkRun, _t as LB_CLASS, a as calibrationOf, at as socketsOf, b as factsOf, bt as PORT, c as predictionText, ct as tunnelsOf, d as FORMAT_V1, dt as RELATIVE, et as sliceDropOf, f as judge, ft as medianInterval, g as LAST_SLACK, gt as HEALTH_PORT, h as KIND_NOISE, ht as CLIENT, i as readRecord, it as slicesOf, j as TCP_STATE, jt as showBand, k as updatesOf, kt as seconds, l as timeSpread, lt as ztunnelPodsOf, m as upgradeCell, mt as quantileOf, n as kindCellRecordSchema, nt as sliceReadyAt, o as cellsTable, ot as stoppingOf, p as upgrade, pt as middle, q as requestsOf, r as kindCellRecordV1Schema, rt as sliceReadyOf, s as medianText, st as transitionOf, t as STAGES, tt as sliceEmptyOf, u as FORMAT, ut as ztunnelReplaced, v as cyclesOf, vt as LOG_LABEL, w as propagationOf, wt as backlogEnd, x as judgeKind, xt as REDIS, y as deletionsOf, yt as OUTSIDE, z as intakeOf } from "./judge-DKrgfhAt.js";
2
2
  import { valueIn } from "@mark1russell7/quantity";
3
3
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
@@ -642,6 +642,55 @@ const createCluster = async (scenario, dir) => {
642
642
  return cluster;
643
643
  };
644
644
  //#endregion
645
+ //#region src/helm.ts
646
+ /** This function gives the path of a chart in the cache, after a download when the cache does not have it. It refuses a file whose sha256 is not the pinned value. */
647
+ const ensurePinnedChart = async (chart, cache = cacheFolder()) => {
648
+ const path = join(cache, "charts", `${chart.name}-${chart.version}.tgz`);
649
+ const bytes = existsSync(path) ? readFileSync(path) : await download(chart.url);
650
+ const found = sha256(bytes);
651
+ if (found !== chart.sha256) throw new Error(`the chart ${chart.name} ${chart.version} has the sha256 ${found}, not the pinned ${chart.sha256}`);
652
+ if (!existsSync(path)) {
653
+ mkdirSync(join(cache, "charts"), { recursive: true });
654
+ writeFileSync(path, bytes);
655
+ }
656
+ return path;
657
+ };
658
+ /**
659
+ * This function installs a pinned chart in a cluster with the values `values`. It writes the values
660
+ * to a file in the folder of the cluster. Helm waits for each workload of the chart, for up to 10 min.
661
+ */
662
+ const helmInstall = async (cluster, release, chart, namespace, values, options = {}) => {
663
+ const helm = await ensureTool(HELM);
664
+ const path = await ensurePinnedChart(chart);
665
+ const file = join(cluster.dir, `${release}-values.json`);
666
+ writeFileSync(file, `${JSON.stringify(values, null, 2)}\n`);
667
+ const home = join(cluster.dir, "helm");
668
+ const env = {
669
+ HELM_CACHE_HOME: join(home, "cache"),
670
+ HELM_CONFIG_HOME: join(home, "config"),
671
+ HELM_DATA_HOME: join(home, "data")
672
+ };
673
+ const create = options.createNamespace === true ? ["--create-namespace"] : [];
674
+ await command(helm, [
675
+ "install",
676
+ release,
677
+ path,
678
+ "-n",
679
+ namespace,
680
+ ...create,
681
+ "--kubeconfig",
682
+ cluster.kubeconfig,
683
+ "-f",
684
+ file,
685
+ "--wait",
686
+ "--timeout",
687
+ "10m"
688
+ ], {
689
+ env,
690
+ timeout: 9e5
691
+ });
692
+ };
693
+ //#endregion
645
694
  //#region src/cilium.ts
646
695
  /**
647
696
  * This module installs Cilium 1.20.4 in a kind cluster with Helm, from images pinned by digest.
@@ -652,6 +701,8 @@ const createCluster = async (scenario, dir) => {
652
701
  const CILIUM_VERSION = "1.20.4";
653
702
  /** This chart is the Helm chart of Cilium 1.20.4. The sha256 is of the file on `helm.cilium.io` (read on 2026-10-27). */
654
703
  const CILIUM_CHART = {
704
+ name: "cilium",
705
+ version: CILIUM_VERSION,
655
706
  url: "https://helm.cilium.io/cilium-1.20.4.tgz",
656
707
  sha256: "505abff8385811df1a949776fc42e9f10697989a332acc86d6e8933efcb855ed"
657
708
  };
@@ -712,17 +763,7 @@ const CILIUM_WORKLOADS = [
712
763
  "daemonset/cilium-envoy"
713
764
  ];
714
765
  /** This function gives the path of the chart in the cache, after a download when the cache does not have it. It refuses a file whose sha256 is not the pinned value. */
715
- const ensureChart = async (cache = cacheFolder()) => {
716
- const path = join(cache, "charts", `cilium-${CILIUM_VERSION}.tgz`);
717
- const bytes = existsSync(path) ? readFileSync(path) : await download(CILIUM_CHART.url);
718
- const found = sha256(bytes);
719
- if (found !== CILIUM_CHART.sha256) throw new Error(`the chart of Cilium has the sha256 ${found}, not the pinned ${CILIUM_CHART.sha256}`);
720
- if (!existsSync(path)) {
721
- mkdirSync(join(cache, "charts"), { recursive: true });
722
- writeFileSync(path, bytes);
723
- }
724
- return path;
725
- };
766
+ const ensureChart = (cache = cacheFolder()) => ensurePinnedChart(CILIUM_CHART, cache);
726
767
  /**
727
768
  * This function installs Cilium in a cluster with no CNI and no kube-proxy. Helm keeps its own
728
769
  * files in the folder of the cluster. The function waits for each workload, for each node to be
@@ -730,34 +771,8 @@ const ensureChart = async (cache = cacheFolder()) => {
730
771
  * lines of the log of the agent.
731
772
  */
732
773
  const installCilium = async (cluster, images) => {
733
- const helm = await ensureTool(HELM);
734
- const chart = await ensureChart();
735
- const file = join(cluster.dir, "cilium-values.json");
736
- writeFileSync(file, `${JSON.stringify(ciliumValues(cluster.scenario.name, images), null, 2)}\n`);
737
- const home = join(cluster.dir, "helm");
738
- const env = {
739
- HELM_CACHE_HOME: join(home, "cache"),
740
- HELM_CONFIG_HOME: join(home, "config"),
741
- HELM_DATA_HOME: join(home, "data")
742
- };
743
774
  try {
744
- await command(helm, [
745
- "install",
746
- "cilium",
747
- chart,
748
- "-n",
749
- "kube-system",
750
- "--kubeconfig",
751
- cluster.kubeconfig,
752
- "-f",
753
- file,
754
- "--wait",
755
- "--timeout",
756
- "10m"
757
- ], {
758
- env,
759
- timeout: 9e5
760
- });
775
+ await helmInstall(cluster, "cilium", CILIUM_CHART, "kube-system", ciliumValues(cluster.scenario.name, images));
761
776
  for (const workload of CILIUM_WORKLOADS) await cluster.kubectl([
762
777
  "-n",
763
778
  "kube-system",
@@ -2223,6 +2238,809 @@ const ztunnelCertificates = async (cluster) => {
2223
2238
  return out;
2224
2239
  };
2225
2240
  //#endregion
2241
+ //#region src/kubevip.ts
2242
+ /**
2243
+ * This module installs kube-vip 1.3.1 in a kind cluster (SYS-51). The install gives a Service of type
2244
+ * LoadBalancer an address on the Docker network of the nodes: the VIP. In ARP mode, one node holds
2245
+ * the VIP on its link, and it sends a gratuitous ARP for it each `vip_arpRate`. A Lease elects that
2246
+ * node. Each value of the install is in `kubeVipObjects`, with its reason.
2247
+ *
2248
+ * The lab uses a VIP of a Service, not a VIP of the control plane. A VIP of the control plane needs
2249
+ * three control-plane nodes and TLS on port 6443. A VIP of a Service needs one DaemonSet on the
2250
+ * workers, and it serves the server of the cell.
2251
+ *
2252
+ * Each term of the election comes from the source of v1.3.1:
2253
+ * - With `svc_election` off, one election of the lease `plndr-svcs-lock` gives each VIP to one
2254
+ * node (pkg/manager/worker/common.go, `GlobalLeader`). The lease is in the namespace of the pod.
2255
+ * - The election is `leaderelection.RunOrDie` of client-go v0.37.1, with a Lease lock and
2256
+ * `ReleaseOnCancel` (pkg/election/election.go). The identity is the name of the node.
2257
+ * - A leader that loses its lease stops the process (`OnStoppedLeading`), and the kubelet starts it again.
2258
+ * - The ARP manager sends a gratuitous ARP for each VIP at each tick of one ticker of
2259
+ * `vip_arpRate`. A new VIP gets its first one at the next tick, not at once (pkg/arp/arp.go).
2260
+ */
2261
+ /** This version is the release of kube-vip of the lab, from 2026-10-19: the tag of its image. */
2262
+ const KUBE_VIP_VERSION = "1.3.1";
2263
+ /**
2264
+ * This image is kube-vip 1.3.1 for linux/amd64, pinned by the digest of its manifest. The digest of
2265
+ * the index of the tag is sha256:1962f9daf226503f7e6b107f595cb28b7917fd2d0113957075e19a3e5a6c1e33.
2266
+ * The manifest of one platform loads into kind with no index.
2267
+ */
2268
+ const KUBE_VIP_IMAGE = "ghcr.io/kube-vip/kube-vip:v1.3.1@sha256:47353c7bde7fec21642cf621bc3fda2b999c051342912efda468999a399be667";
2269
+ /** This is the name of kube-vip: of its ServiceAccount, its DaemonSet and its container. */
2270
+ const KUBE_VIP = "kube-vip";
2271
+ /** This is the Lease of the election of the VIPs of the Services (`svc_leasename`, cmd/kube-vip.go of v1.3.1). */
2272
+ const KUBE_VIP_LEASE = {
2273
+ namespace: "kube-system",
2274
+ name: "plndr-svcs-lock"
2275
+ };
2276
+ /**
2277
+ * These are the times of the election, as the install sets them. Each one is the default of the
2278
+ * flags of v1.3.1 (cmd/kube-vip.go, lines 91–94). They are a lease of 15 s, a renew deadline of 10 s
2279
+ * and a retry period of 2 s. The install sets each one in the environment, thus a new default cannot
2280
+ * change a cell.
2281
+ */
2282
+ const KUBE_VIP_ELECTION = {
2283
+ lease: {
2284
+ value: 15,
2285
+ unit: "s"
2286
+ },
2287
+ renew: {
2288
+ value: 10,
2289
+ unit: "s"
2290
+ },
2291
+ retry: {
2292
+ value: 2,
2293
+ unit: "s"
2294
+ }
2295
+ };
2296
+ /** This time is the interval of the gratuitous ARPs, the default of the flag `arpRate` of v1.3.1 (cmd/kube-vip.go, line 61). */
2297
+ const KUBE_VIP_ARP_RATE = {
2298
+ value: 3e3,
2299
+ unit: "ms"
2300
+ };
2301
+ /** This label of a node selects the nodes of kube-vip: the DaemonSet has a pod only on a node with it. */
2302
+ const VIP_LABEL = "lab.systems/vip";
2303
+ /** This label of a pod of kube-vip selects them. */
2304
+ const APP = { "app.kubernetes.io/name": KUBE_VIP };
2305
+ const whole$1 = (amount) => String(Math.round(valueIn(amount, "s")));
2306
+ /**
2307
+ * This function gives the objects of kube-vip in kube-system. They are its ServiceAccount, its
2308
+ * ClusterRole and binding, and its DaemonSet. The rules of the role are those of the manifest that
2309
+ * v1.3.1 generates (pkg/kubevip/config_generator.go, lines 95–134). The DaemonSet:
2310
+ * - is on the host network, with NET_ADMIN and NET_RAW. It adds the VIP to `eth0` and sends ARP.
2311
+ * - has a pod only on a node with `VIP_LABEL`. Thus the lab chooses the holder of the VIP.
2312
+ * - sets ARP mode, the Services, one election for all VIPs, and each time of the election.
2313
+ * - takes the image from the node (`imagePullPolicy: Never`).
2314
+ */
2315
+ const kubeVipObjects = (image) => {
2316
+ const env = {
2317
+ vip_arp: "true",
2318
+ vip_interface: "eth0",
2319
+ port: "6443",
2320
+ cp_enable: "false",
2321
+ svc_enable: "true",
2322
+ svc_election: "false",
2323
+ svc_leasename: KUBE_VIP_LEASE.name,
2324
+ vip_leaderelection: "true",
2325
+ vip_leaseduration: whole$1(KUBE_VIP_ELECTION.lease),
2326
+ vip_renewdeadline: whole$1(KUBE_VIP_ELECTION.renew),
2327
+ vip_retryperiod: whole$1(KUBE_VIP_ELECTION.retry),
2328
+ vip_arpRate: String(Math.round(valueIn(KUBE_VIP_ARP_RATE, "ms")))
2329
+ };
2330
+ const namespace = KUBE_VIP_LEASE.namespace;
2331
+ return [
2332
+ {
2333
+ apiVersion: "v1",
2334
+ kind: "ServiceAccount",
2335
+ metadata: {
2336
+ name: KUBE_VIP,
2337
+ namespace
2338
+ }
2339
+ },
2340
+ {
2341
+ apiVersion: "rbac.authorization.k8s.io/v1",
2342
+ kind: "ClusterRole",
2343
+ metadata: { name: "system:kube-vip-role" },
2344
+ rules: [
2345
+ {
2346
+ apiGroups: [""],
2347
+ resources: ["services/status"],
2348
+ verbs: ["update"]
2349
+ },
2350
+ {
2351
+ apiGroups: [""],
2352
+ resources: ["services", "endpoints"],
2353
+ verbs: [
2354
+ "list",
2355
+ "get",
2356
+ "watch",
2357
+ "update"
2358
+ ]
2359
+ },
2360
+ {
2361
+ apiGroups: [""],
2362
+ resources: ["nodes"],
2363
+ verbs: [
2364
+ "list",
2365
+ "get",
2366
+ "watch",
2367
+ "update",
2368
+ "patch"
2369
+ ]
2370
+ },
2371
+ {
2372
+ apiGroups: ["coordination.k8s.io"],
2373
+ resources: ["leases"],
2374
+ verbs: [
2375
+ "list",
2376
+ "get",
2377
+ "watch",
2378
+ "update",
2379
+ "create"
2380
+ ]
2381
+ },
2382
+ {
2383
+ apiGroups: ["discovery.k8s.io"],
2384
+ resources: ["endpointslices"],
2385
+ verbs: [
2386
+ "list",
2387
+ "get",
2388
+ "watch",
2389
+ "update"
2390
+ ]
2391
+ },
2392
+ {
2393
+ apiGroups: [""],
2394
+ resources: ["pods"],
2395
+ verbs: ["list"]
2396
+ },
2397
+ {
2398
+ apiGroups: ["networking.k8s.io"],
2399
+ resources: ["servicecidrs"],
2400
+ verbs: [
2401
+ "list",
2402
+ "get",
2403
+ "watch"
2404
+ ]
2405
+ }
2406
+ ]
2407
+ },
2408
+ {
2409
+ apiVersion: "rbac.authorization.k8s.io/v1",
2410
+ kind: "ClusterRoleBinding",
2411
+ metadata: { name: "system:kube-vip-binding" },
2412
+ roleRef: {
2413
+ apiGroup: "rbac.authorization.k8s.io",
2414
+ kind: "ClusterRole",
2415
+ name: "system:kube-vip-role"
2416
+ },
2417
+ subjects: [{
2418
+ kind: "ServiceAccount",
2419
+ name: KUBE_VIP,
2420
+ namespace
2421
+ }]
2422
+ },
2423
+ {
2424
+ apiVersion: "apps/v1",
2425
+ kind: "DaemonSet",
2426
+ metadata: {
2427
+ name: KUBE_VIP,
2428
+ namespace,
2429
+ labels: APP
2430
+ },
2431
+ spec: {
2432
+ selector: { matchLabels: APP },
2433
+ template: {
2434
+ metadata: { labels: APP },
2435
+ spec: {
2436
+ serviceAccountName: KUBE_VIP,
2437
+ hostNetwork: true,
2438
+ nodeSelector: { [VIP_LABEL]: "1" },
2439
+ containers: [{
2440
+ name: KUBE_VIP,
2441
+ image,
2442
+ imagePullPolicy: "Never",
2443
+ args: ["manager"],
2444
+ env: [...Object.entries(env).map(([name, value]) => ({
2445
+ name,
2446
+ value
2447
+ })), {
2448
+ name: "vip_nodename",
2449
+ valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } }
2450
+ }],
2451
+ securityContext: { capabilities: { add: ["NET_ADMIN", "NET_RAW"] } }
2452
+ }]
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+ ];
2458
+ };
2459
+ /** This function gives the Lease of the election, or null when it is not there yet. */
2460
+ const vipLeaseOf = async (cluster) => {
2461
+ const text = await cluster.kubectl([
2462
+ "-n",
2463
+ KUBE_VIP_LEASE.namespace,
2464
+ "get",
2465
+ "lease",
2466
+ KUBE_VIP_LEASE.name,
2467
+ "-o",
2468
+ "json",
2469
+ "--ignore-not-found"
2470
+ ]);
2471
+ if (text.trim() === "") return null;
2472
+ const spec = JSON.parse(text).spec ?? {};
2473
+ const text0 = (key) => typeof spec[key] === "string" && spec[key] !== "" ? spec[key] : null;
2474
+ const number0 = (key) => typeof spec[key] === "number" ? spec[key] : null;
2475
+ return {
2476
+ holder: text0("holderIdentity"),
2477
+ acquireTime: text0("acquireTime"),
2478
+ renewTime: text0("renewTime"),
2479
+ leaseDurationSeconds: number0("leaseDurationSeconds"),
2480
+ leaseTransitions: number0("leaseTransitions")
2481
+ };
2482
+ };
2483
+ /** This function reads the Docker network of a cluster: its IPv4 subnet, and the MAC address of each container. */
2484
+ const vipNetworkOf = async (network) => {
2485
+ const [info] = JSON.parse(await command("docker", [
2486
+ "network",
2487
+ "inspect",
2488
+ network
2489
+ ]));
2490
+ const subnet = (info?.IPAM?.Config ?? []).map((config) => config.Subnet ?? "").find((each) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(each));
2491
+ if (subnet === void 0) throw new Error(`the Docker network ${network} has no IPv4 subnet`);
2492
+ const macs = {};
2493
+ for (const container of Object.values(info?.Containers ?? {})) if (container.Name !== void 0 && container.MacAddress !== void 0) macs[container.Name] = container.MacAddress.toLowerCase();
2494
+ return {
2495
+ subnet,
2496
+ macs
2497
+ };
2498
+ };
2499
+ /**
2500
+ * This function gives the VIP of a subnet: the sixth address from its end. Docker gives the address
2501
+ * of each container from the start of the subnet, thus no container takes it.
2502
+ */
2503
+ const vipOf = (subnet) => {
2504
+ const [first, last] = cidrRange(subnet);
2505
+ const address = last - 5;
2506
+ if (address <= first + 1) throw new Error(`the subnet ${subnet} is too small for a VIP`);
2507
+ return [
2508
+ 24,
2509
+ 16,
2510
+ 8,
2511
+ 0
2512
+ ].map((shift) => Math.floor(address / 2 ** shift) % 256).join(".");
2513
+ };
2514
+ const sleep$2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2515
+ /** This function gives the pods of kube-vip that are not deleted. */
2516
+ const kubeVipPods = async (cluster) => {
2517
+ return JSON.parse(await cluster.kubectl([
2518
+ "-n",
2519
+ KUBE_VIP_LEASE.namespace,
2520
+ "get",
2521
+ "pods",
2522
+ "-l",
2523
+ `app.kubernetes.io/name=${KUBE_VIP}`,
2524
+ "-o",
2525
+ "json"
2526
+ ])).items.filter((pod) => pod.metadata.deletionTimestamp === void 0).map((pod) => {
2527
+ const statuses = pod.status?.containerStatuses ?? [];
2528
+ return {
2529
+ name: pod.metadata.name,
2530
+ node: pod.spec.nodeName ?? "",
2531
+ ready: statuses.length > 0 && statuses.every((status) => status.ready),
2532
+ restarts: statuses.reduce((sum, status) => sum + (status.restartCount ?? 0), 0)
2533
+ };
2534
+ });
2535
+ };
2536
+ /** This function gives the nodes with a ready pod of kube-vip that is not deleted. */
2537
+ const kubeVipReady = async (cluster) => (await kubeVipPods(cluster)).filter((pod) => pod.ready && pod.node !== "").map((pod) => pod.node).sort();
2538
+ /** This function waits until a condition gives null, and gives null, or the last reason after `within` ms. */
2539
+ const until$1 = async (within, check) => {
2540
+ const end = Date.now() + within;
2541
+ let why = await check();
2542
+ while (why !== null && Date.now() < end) {
2543
+ await sleep$2(500);
2544
+ why = await check();
2545
+ }
2546
+ return why;
2547
+ };
2548
+ const label = (cluster, node, on) => cluster.kubectl([
2549
+ "label",
2550
+ "node",
2551
+ node,
2552
+ on ? `${VIP_LABEL}=1` : `${VIP_LABEL}-`,
2553
+ ...on ? ["--overwrite"] : []
2554
+ ]);
2555
+ /**
2556
+ * This function makes `holder` the holder of the VIP, with a ready pod of kube-vip on each node of
2557
+ * `nodes`. It gives null, or why it could not in `within` ms. If another node holds the lease, the
2558
+ * function takes the label from each other node. The DaemonSet then deletes their pods. The leader
2559
+ * gives back its lease at its stop (`ReleaseOnCancel`). The function gives each label back after
2560
+ * `holder` takes the lease.
2561
+ *
2562
+ * A leader that lost its lease restarted its container. The kubelet gives a container a longer
2563
+ * back-off at each restart, thus the function first deletes each pod of kube-vip with a restart.
2564
+ * The DaemonSet then makes a new pod with no back-off.
2565
+ */
2566
+ const holdVip = async (cluster, holder, nodes, within = 18e4) => {
2567
+ const others = nodes.filter((node) => node !== holder);
2568
+ for (const pod of await kubeVipPods(cluster)) if (pod.restarts > 0) await cluster.kubectl([
2569
+ "-n",
2570
+ KUBE_VIP_LEASE.namespace,
2571
+ "delete",
2572
+ "pod",
2573
+ pod.name,
2574
+ "--wait=true",
2575
+ "--timeout=120s"
2576
+ ]);
2577
+ await label(cluster, holder, true);
2578
+ const own = await until$1(within, async () => (await kubeVipReady(cluster)).includes(holder) ? null : `kube-vip on ${holder} is not ready`);
2579
+ if (own !== null) return own;
2580
+ const held = async () => {
2581
+ const lease = await vipLeaseOf(cluster);
2582
+ return lease?.holder === holder ? null : `the lease ${KUBE_VIP_LEASE.name} is held by ${lease?.holder ?? "no node"}, not ${holder}`;
2583
+ };
2584
+ if (await held() !== null) {
2585
+ for (const node of others) await label(cluster, node, false);
2586
+ const moved = await until$1(within, held);
2587
+ if (moved !== null) return moved;
2588
+ }
2589
+ for (const node of others) await label(cluster, node, true);
2590
+ return until$1(within, async () => {
2591
+ const ready = await kubeVipReady(cluster);
2592
+ const missing = nodes.filter((node) => !ready.includes(node));
2593
+ return missing.length === 0 ? null : `kube-vip is not ready on ${missing.join(", ")}`;
2594
+ });
2595
+ };
2596
+ /**
2597
+ * This function installs kube-vip in a cluster, with its image on each node (`cluster.load`), and
2598
+ * makes `holder` the holder of the VIP. A failed install gives the pods of kube-vip and the tail of
2599
+ * their logs.
2600
+ */
2601
+ const installKubeVip = async (cluster, image, holder, nodes) => {
2602
+ try {
2603
+ await label(cluster, holder, true);
2604
+ await cluster.apply(kubeVipObjects(image));
2605
+ const why = await holdVip(cluster, holder, nodes);
2606
+ if (why !== null) throw new Error(why);
2607
+ } catch (error) {
2608
+ const state = async (args) => (await cluster.kubectl([
2609
+ "-n",
2610
+ KUBE_VIP_LEASE.namespace,
2611
+ ...args
2612
+ ]).catch((failure) => String(failure))).slice(-4e3);
2613
+ const pods = await state([
2614
+ "get",
2615
+ "pods",
2616
+ "-l",
2617
+ `app.kubernetes.io/name=${KUBE_VIP}`,
2618
+ "-o",
2619
+ "wide"
2620
+ ]);
2621
+ const logs = await state([
2622
+ "logs",
2623
+ "-l",
2624
+ `app.kubernetes.io/name=${KUBE_VIP}`,
2625
+ "--tail=40",
2626
+ "--prefix"
2627
+ ]);
2628
+ throw new Error(`the install of kube-vip failed: ${String(error)}\nthe pods of kube-vip:\n${pods}\nthe logs:\n${logs}`);
2629
+ }
2630
+ };
2631
+ /**
2632
+ * This function waits until the Service of a VIP has the VIP in its status: kube-vip took it. It
2633
+ * gives null, or why not in `within` ms.
2634
+ */
2635
+ const vipServed = (cluster, namespace, service, vip, within = 12e4) => until$1(within, async () => {
2636
+ const ingress = (await cluster.kubectl([
2637
+ "-n",
2638
+ namespace,
2639
+ "get",
2640
+ "service",
2641
+ service,
2642
+ "-o",
2643
+ "jsonpath={.status.loadBalancer.ingress[*].ip}"
2644
+ ])).trim();
2645
+ return ingress.split(/\s+/).includes(vip) ? null : `the Service ${service} has the ingress ${ingress || "none"}, not ${vip}`;
2646
+ });
2647
+ //#endregion
2648
+ //#region src/metallb.ts
2649
+ /**
2650
+ * This module installs MetalLB 0.16.1 in L2 mode in a kind cluster (SYS-53). The controller of
2651
+ * MetalLB gives a Service of type LoadBalancer an address from a pool: the VIP. A speaker on each
2652
+ * worker answers ARP for the VIP if its node owns the VIP. The VIP is on no interface of a node.
2653
+ * Thus a frozen speaker answers nothing.
2654
+ *
2655
+ * Each term of the owner comes from the source of the tag v0.16.1 (commit 791dfcc6):
2656
+ * - The owner of a VIP is the first node in the order of sha256(node + "#" + VIP), of the nodes with
2657
+ * a usable speaker (speaker/layer2_controller.go, lines 83–129). A usable speaker is a member of
2658
+ * memberlist that is not dead (internal/speakerlist/speakerlist.go, lines 303–320).
2659
+ * - The speakers start memberlist from `DefaultLANConfig` (speakerlist.go, line 72). A file
2660
+ * /etc/metallb/memberlist/config.json replaces that config (lines 77–84). The install makes no
2661
+ * such file, thus each value is the default of hashicorp/memberlist v0.5.1.
2662
+ * - Each event of memberlist (a join, a leave or an update) calls `ForceSync` (speakerlist.go, lines
2663
+ * 339–349). `ForceSync` reconciles each Service at once, with no delay (internal/k8s/k8s.go, lines
2664
+ * 181–184, and internal/k8s/controllers/service_controller_reload.go, lines 62–118).
2665
+ * - The owner sends a gratuitous ARP at each reconcile. It sends one at once, then one each 1100 ms
2666
+ * for 5 s (internal/layer2/announcer.go, lines 157–197 and 258–272).
2667
+ */
2668
+ /** This version is the release of MetalLB of the lab: the tag v0.16.1, the commit 791dfcc63ced7a6b3e869131eae75e645150fff0. */
2669
+ const METALLB_VERSION = "0.16.1";
2670
+ /** This chart is the Helm chart of MetalLB 0.16.1. The sha256 is of the file of the release, and it is the digest in the index of the charts. */
2671
+ const METALLB_CHART = {
2672
+ name: "metallb",
2673
+ version: METALLB_VERSION,
2674
+ url: "https://github.com/metallb/metallb/releases/download/metallb-chart-0.16.1/metallb-0.16.1.tgz",
2675
+ sha256: "fb06bb584fcb7856f15733b2a6a2aff5b61b5c350687e341c163ae24a5938adc"
2676
+ };
2677
+ /**
2678
+ * These are the images of MetalLB 0.16.1 for linux/amd64, pinned by the digest of the manifest of the
2679
+ * platform. The lab pulls each one by its digest, tags it (`localPin`), and loads it into each node.
2680
+ */
2681
+ const METALLB_IMAGES = {
2682
+ speaker: "quay.io/metallb/speaker:v0.16.1@sha256:37a98a9d1cd970051c5dededb6f922c1e6c3b90fdd0fc1350d1686e67675af0e",
2683
+ controller: "quay.io/metallb/controller:v0.16.1@sha256:5a3e101335f5ea2cfb0ddf51acdfa9538251e0623ffcd6cbfc1b274b7898790c"
2684
+ };
2685
+ /** This namespace holds MetalLB: its controller, its speakers and its objects. */
2686
+ const METALLB_NAMESPACE = "metallb-system";
2687
+ /** This annotation of a Service gives the controller of MetalLB the address of its VIP. */
2688
+ const METALLB_ANNOTATION = "metallb.io/loadBalancerIPs";
2689
+ /** This name is the name of the pool of the VIP and of its L2Advertisement. */
2690
+ const METALLB_POOL = "lab-vip";
2691
+ /** This annotation of the Service makes each speaker reconcile it. The owner then sends its gratuitous ARPs. */
2692
+ const ANNOUNCE_ANNOTATION = "lab.systems/announce";
2693
+ /**
2694
+ * These are the terms of memberlist v0.5.1 that the speakers use: the defaults of `DefaultLANConfig`
2695
+ * (config.go, lines 302–341).
2696
+ * - `probeInterval` is the interval of the probes (line 318). The awareness of a healthy member does not scale it.
2697
+ * - `probeTimeout` is the wait for the ack of a direct ping (line 317).
2698
+ * - `suspicionMult` and `suspicionMaxTimeoutMult` give the time from suspect to dead (lines 314–315).
2699
+ * - `pushPullInterval` is the interval of the full exchange of state (line 316).
2700
+ * - `gossipInterval` is the interval of the gossip (line 323).
2701
+ */
2702
+ const MEMBERLIST = {
2703
+ probeInterval: {
2704
+ value: 1,
2705
+ unit: "s"
2706
+ },
2707
+ probeTimeout: {
2708
+ value: 500,
2709
+ unit: "ms"
2710
+ },
2711
+ suspicionMult: 4,
2712
+ suspicionMaxTimeoutMult: 6,
2713
+ pushPullInterval: {
2714
+ value: 30,
2715
+ unit: "s"
2716
+ },
2717
+ gossipInterval: {
2718
+ value: 200,
2719
+ unit: "ms"
2720
+ }
2721
+ };
2722
+ /** These are the gratuitous ARPs of the owner at each reconcile: one at once, then one each `every` until `for` (announcer.go, lines 172–182). */
2723
+ const METALLB_SPAM = {
2724
+ every: {
2725
+ value: 1100,
2726
+ unit: "ms"
2727
+ },
2728
+ for: {
2729
+ value: 5,
2730
+ unit: "s"
2731
+ }
2732
+ };
2733
+ /**
2734
+ * This function gives the time from suspect to dead in memberlist v0.5.1, for a cluster of `members`
2735
+ * members with no peer that can confirm. The terms:
2736
+ * - `suspicionTimeout` is SuspicionMult × max(1, log10(n)) × ProbeInterval (util.go, lines 71–76).
2737
+ * - `suspectNode` sets k = SuspicionMult − 2 confirmations, and k = 0 if n − 2 < k (state.go, lines 1216–1224).
2738
+ * - With k = 0, the timer fires at the minimum, not at the maximum (suspicion.go, lines 70–76).
2739
+ * With confirmations to wait for, the function gives the maximum: SuspicionMaxTimeoutMult times the minimum.
2740
+ */
2741
+ const suspicionOf = (members, terms = MEMBERLIST) => {
2742
+ const min = Math.floor(Math.max(1, Math.log10(Math.max(1, members))) * 1e3) * valueIn(terms.probeInterval, "ms") * terms.suspicionMult / 1e3;
2743
+ return {
2744
+ value: (members - 2 < terms.suspicionMult - 2 ? 0 : terms.suspicionMult - 2) < 1 ? min : min * terms.suspicionMaxTimeoutMult,
2745
+ unit: "ms"
2746
+ };
2747
+ };
2748
+ /**
2749
+ * This function gives the Helm values of the install. The values that are not the defaults of the chart:
2750
+ * - No FRR and no frr-k8s: the lab uses L2 mode only, thus it needs no BGP.
2751
+ * - A speaker only on a node with `VIP_LABEL`, the workers. Thus memberlist has the workers as its
2752
+ * members, and the control plane does not answer ARP.
2753
+ * - The controller on the control plane. A zone loss of a worker then does not take it away.
2754
+ * - Each image from the node (`pullPolicy: Never`).
2755
+ */
2756
+ const metallbValues = (images) => {
2757
+ const image = (local) => {
2758
+ const at = local.lastIndexOf(":");
2759
+ if (at <= 0) throw new Error(`the image ${local} has no tag`);
2760
+ return {
2761
+ repository: local.slice(0, at),
2762
+ tag: local.slice(at + 1),
2763
+ pullPolicy: "Never"
2764
+ };
2765
+ };
2766
+ return {
2767
+ frrk8s: { enabled: false },
2768
+ controller: {
2769
+ image: image(images.controller),
2770
+ nodeSelector: { "node-role.kubernetes.io/control-plane": "" },
2771
+ tolerations: [{
2772
+ key: "node-role.kubernetes.io/control-plane",
2773
+ operator: "Exists",
2774
+ effect: "NoSchedule"
2775
+ }]
2776
+ },
2777
+ speaker: {
2778
+ image: image(images.speaker),
2779
+ frr: { enabled: false },
2780
+ tolerateMaster: false,
2781
+ nodeSelector: { [VIP_LABEL]: "1" },
2782
+ memberlist: {
2783
+ enabled: true,
2784
+ mlBindPort: 7946
2785
+ }
2786
+ }
2787
+ };
2788
+ };
2789
+ /** This function gives the pool of the VIP and its L2Advertisement. The pool has only the VIP. */
2790
+ const metallbObjects = (vip) => [{
2791
+ apiVersion: "metallb.io/v1beta1",
2792
+ kind: "IPAddressPool",
2793
+ metadata: {
2794
+ name: METALLB_POOL,
2795
+ namespace: METALLB_NAMESPACE
2796
+ },
2797
+ spec: { addresses: [`${vip}/32`] }
2798
+ }, {
2799
+ apiVersion: "metallb.io/v1beta1",
2800
+ kind: "L2Advertisement",
2801
+ metadata: {
2802
+ name: METALLB_POOL,
2803
+ namespace: METALLB_NAMESPACE
2804
+ },
2805
+ spec: { ipAddressPools: [METALLB_POOL] }
2806
+ }];
2807
+ /** This function gives the nodes in the order of the election of the owner of `vip`: by sha256(node + "#" + vip), as bytes (layer2_controller.go, lines 115–120). */
2808
+ const ownerOrder = (nodes, vip) => {
2809
+ const hash = (node) => createHash("sha256").update(`${node}#${vip}`).digest();
2810
+ return [...nodes].sort((a, b) => Buffer.compare(hash(a), hash(b)));
2811
+ };
2812
+ const dotted = (address) => [
2813
+ 24,
2814
+ 16,
2815
+ 8,
2816
+ 0
2817
+ ].map((shift) => Math.floor(address / 2 ** shift) % 256).join(".");
2818
+ /**
2819
+ * This function gives the VIP of a subnet for MetalLB. It looks down from the sixth address from the
2820
+ * end, and it gives the first address that `owner` owns, of the nodes `nodes`. Docker gives the address of each container
2821
+ * from the start of the subnet, thus no container takes it.
2822
+ */
2823
+ const metallbVipOf = (subnet, owner, nodes) => {
2824
+ if (!nodes.includes(owner)) throw new Error(`the owner ${owner} is not one of the nodes ${nodes.join(", ")}`);
2825
+ const [first, last] = cidrRange(subnet);
2826
+ for (let address = last - 5; address > first + 1 && address > last - 260; address--) {
2827
+ const vip = dotted(address);
2828
+ if (ownerOrder(nodes, vip)[0] === owner) return vip;
2829
+ }
2830
+ throw new Error(`the subnet ${subnet} has no VIP that ${owner} owns`);
2831
+ };
2832
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2833
+ /** This function waits until a condition gives null, and gives null, or the last reason after `within` ms. */
2834
+ const until = async (within, check) => {
2835
+ const end = Date.now() + within;
2836
+ let why = await check();
2837
+ while (why !== null && Date.now() < end) {
2838
+ await sleep$1(500);
2839
+ why = await check();
2840
+ }
2841
+ return why;
2842
+ };
2843
+ /** This function gives the nodes that announce a Service, from the ServiceL2Status objects of the speakers. */
2844
+ const announcementsOf = async (cluster, namespace, service) => {
2845
+ return JSON.parse(await cluster.kubectl([
2846
+ "-n",
2847
+ METALLB_NAMESPACE,
2848
+ "get",
2849
+ "servicel2statuses.metallb.io",
2850
+ "-o",
2851
+ "json"
2852
+ ])).items.filter((item) => item.status?.serviceName === service && item.status.serviceNamespace === namespace && typeof item.status.node === "string").map((item) => ({
2853
+ node: item.status.node,
2854
+ created: item.metadata?.creationTimestamp ?? null
2855
+ })).sort((a, b) => a.node.localeCompare(b.node));
2856
+ };
2857
+ /** This function gives the nodes with a ready speaker that is not deleted. */
2858
+ const speakersReady = async (cluster) => {
2859
+ return JSON.parse(await cluster.kubectl([
2860
+ "-n",
2861
+ METALLB_NAMESPACE,
2862
+ "get",
2863
+ "pods",
2864
+ "-l",
2865
+ "app.kubernetes.io/component=speaker",
2866
+ "-o",
2867
+ "json"
2868
+ ])).items.filter((pod) => pod.metadata.deletionTimestamp === void 0 && pod.spec.nodeName !== void 0).filter((pod) => (pod.status?.containerStatuses ?? []).length > 0 && pod.status.containerStatuses.every((status) => status.ready)).map((pod) => pod.spec.nodeName).sort();
2869
+ };
2870
+ /**
2871
+ * This function makes `owner` the only node that announces the Service, with a ready speaker on each
2872
+ * node of `nodes`. It gives null, or why it could not in `within` ms. After a heal, the other node
2873
+ * stops when it sees that the owner is alive again. The function then annotates the Service. Each
2874
+ * speaker reconciles it, and the owner sends its gratuitous ARPs for 5 s. Thus each client learns
2875
+ * the MAC address of the owner.
2876
+ */
2877
+ const holdMetallb = async (cluster, owner, nodes, namespace, service, within = 18e4) => {
2878
+ const ready = await until(within, async () => {
2879
+ const found = await speakersReady(cluster);
2880
+ const missing = nodes.filter((node) => !found.includes(node));
2881
+ return missing.length === 0 ? null : `the speaker of MetalLB is not ready on ${missing.join(", ")}`;
2882
+ });
2883
+ if (ready !== null) return ready;
2884
+ const sole = await until(within, async () => {
2885
+ const found = (await announcementsOf(cluster, namespace, service)).map((each) => each.node);
2886
+ return found.length === 1 && found[0] === owner ? null : `the Service ${service} is announced by ${found.join(", ") || "no node"}, not only by ${owner}`;
2887
+ });
2888
+ if (sole !== null) return sole;
2889
+ await cluster.kubectl([
2890
+ "-n",
2891
+ namespace,
2892
+ "annotate",
2893
+ "service",
2894
+ service,
2895
+ `${ANNOUNCE_ANNOTATION}=${Date.now()}`,
2896
+ "--overwrite"
2897
+ ]);
2898
+ return null;
2899
+ };
2900
+ /**
2901
+ * This function installs MetalLB in a cluster, with its images on each node, the speakers on `nodes`,
2902
+ * and a pool of the VIP. The webhook of the controller checks the pool, thus the function tries the
2903
+ * pool again until the webhook answers. A failed install gives the pods of MetalLB and the tail of
2904
+ * their logs.
2905
+ */
2906
+ const installMetallb = async (cluster, images, nodes, vip) => {
2907
+ try {
2908
+ for (const node of nodes) await cluster.kubectl([
2909
+ "label",
2910
+ "node",
2911
+ node,
2912
+ `${VIP_LABEL}=1`,
2913
+ "--overwrite"
2914
+ ]);
2915
+ await helmInstall(cluster, "metallb", METALLB_CHART, METALLB_NAMESPACE, metallbValues(images), { createNamespace: true });
2916
+ let failure = null;
2917
+ const end = Date.now() + 18e4;
2918
+ do {
2919
+ failure = await cluster.apply(metallbObjects(vip)).then(() => null, (error) => error);
2920
+ if (failure !== null) await sleep$1(2e3);
2921
+ } while (failure !== null && Date.now() < end);
2922
+ if (failure !== null) throw failure;
2923
+ if ((await cluster.kubectl([
2924
+ "-n",
2925
+ "metallb-system",
2926
+ "get",
2927
+ "configmap",
2928
+ "metallb-memberlist-config",
2929
+ "--ignore-not-found",
2930
+ "-o",
2931
+ "name"
2932
+ ])).trim() !== "") throw new Error("the ConfigMap metallb-memberlist-config is there: memberlist would not use its defaults");
2933
+ } catch (error) {
2934
+ const state = async (args) => (await cluster.kubectl([
2935
+ "-n",
2936
+ METALLB_NAMESPACE,
2937
+ ...args
2938
+ ]).catch((why) => String(why))).slice(-4e3);
2939
+ const pods = await state([
2940
+ "get",
2941
+ "pods",
2942
+ "-o",
2943
+ "wide"
2944
+ ]);
2945
+ const logs = await state([
2946
+ "logs",
2947
+ "-l",
2948
+ "app.kubernetes.io/name=metallb",
2949
+ "--tail=40",
2950
+ "--prefix",
2951
+ "--all-containers"
2952
+ ]);
2953
+ throw new Error(`the install of MetalLB failed: ${String(error)}\nthe pods of MetalLB:\n${pods}\nthe logs:\n${logs}`);
2954
+ }
2955
+ };
2956
+ /** These words mark the lines of a speaker that the record keeps: the events of memberlist, the announces and the withdraws. */
2957
+ const KEPT = /node event|Marking|Suspect|Refuting|serviceAnnounced|serviceWithdrawn|memberlist/;
2958
+ /**
2959
+ * This function gives the record of MetalLB after a run: the nodes that announce the Service, and
2960
+ * some lines of each speaker. The lines are about memberlist and the announces, with the times of the kubelet.
2961
+ */
2962
+ const metallbRecord = async (cluster, namespace, service) => {
2963
+ return {
2964
+ announcements: await announcementsOf(cluster, namespace, service),
2965
+ log: (await cluster.kubectl([
2966
+ "-n",
2967
+ METALLB_NAMESPACE,
2968
+ "logs",
2969
+ "-l",
2970
+ "app.kubernetes.io/component=speaker",
2971
+ "-c",
2972
+ "speaker",
2973
+ "--since=300s",
2974
+ "--tail=4000",
2975
+ "--prefix",
2976
+ "--timestamps"
2977
+ ]).catch((error) => String(error))).split("\n").filter((line) => KEPT.test(line)).slice(-120).join("\n").slice(-12e3)
2978
+ };
2979
+ };
2980
+ //#endregion
2981
+ //#region src/announcer.ts
2982
+ const local = (images, name) => {
2983
+ const image = images[name];
2984
+ if (image === void 0) throw new Error(`the run has no local image of ${name}`);
2985
+ return image;
2986
+ };
2987
+ /** This announcer is kube-vip 1.3.1 in ARP mode, with one Lease for each VIP of a Service (`kubevip.ts`). */
2988
+ const KUBE_VIP_ANNOUNCER = {
2989
+ name: "kube-vip",
2990
+ images: { [KUBE_VIP]: KUBE_VIP_IMAGE },
2991
+ service: {
2992
+ loadBalancerClass: LB_CLASS,
2993
+ annotation: VIP_ANNOTATION
2994
+ },
2995
+ settle: KUBE_VIP_ARP_RATE,
2996
+ vipOf: (subnet) => vipOf(subnet),
2997
+ install: (cluster, images, owner, nodes) => installKubeVip(cluster, local(images, KUBE_VIP), owner, nodes),
2998
+ hold: (cluster, owner, nodes) => holdVip(cluster, owner, nodes),
2999
+ record: async (cluster) => {
3000
+ return {
3001
+ lease: await vipLeaseOf(cluster),
3002
+ log: (await cluster.kubectl([
3003
+ "-n",
3004
+ KUBE_VIP_LEASE.namespace,
3005
+ "logs",
3006
+ "-l",
3007
+ `app.kubernetes.io/name=${KUBE_VIP}`,
3008
+ "--tail=30",
3009
+ "--prefix",
3010
+ "--timestamps"
3011
+ ]).catch((error) => String(error))).slice(-6e3)
3012
+ };
3013
+ }
3014
+ };
3015
+ /** This announcer is MetalLB 0.16.1 in L2 mode, with its speakers in memberlist (`metallb.ts`). */
3016
+ const METALLB_ANNOUNCER = {
3017
+ name: "metallb",
3018
+ images: {
3019
+ "metallb-speaker": METALLB_IMAGES.speaker,
3020
+ "metallb-controller": METALLB_IMAGES.controller
3021
+ },
3022
+ service: {
3023
+ loadBalancerClass: null,
3024
+ annotation: METALLB_ANNOTATION
3025
+ },
3026
+ settle: METALLB_SPAM.for,
3027
+ vipOf: (subnet, owner, nodes) => metallbVipOf(subnet, owner, nodes),
3028
+ install: (cluster, images, _owner, nodes, vip) => installMetallb(cluster, {
3029
+ speaker: local(images, "metallb-speaker"),
3030
+ controller: local(images, "metallb-controller")
3031
+ }, nodes, vip),
3032
+ hold: (cluster, owner, nodes, namespace, service) => holdMetallb(cluster, owner, nodes, namespace, service),
3033
+ record: (cluster, namespace, service) => metallbRecord(cluster, namespace, service)
3034
+ };
3035
+ /** This function gives the announcer of the VIP of a cell: kube-vip when the cell names none. */
3036
+ const announcerOf = (cell) => cell.vip?.announcer === "metallb" ? METALLB_ANNOUNCER : KUBE_VIP_ANNOUNCER;
3037
+ /** This function gives null if each client sends to `mac`, or the clients that do not. */
3038
+ const macsAt = (found, mac, count) => {
3039
+ const wrong = found.filter((each) => each.mac !== mac);
3040
+ if (found.length !== count) return `${found.length} clients outside answer, not ${count}`;
3041
+ return wrong.length === 0 ? null : wrong.map((each) => `${each.client} has ${each.mac ?? "no MAC"}`).join(", ");
3042
+ };
3043
+ //#endregion
2226
3044
  //#region src/lds.ts
2227
3045
  /**
2228
3046
  * This filter of RE2 selects the two counters of the listener manager of Envoy that an update of LDS
@@ -2275,6 +3093,18 @@ const KEPT_KERNEL = /* @__PURE__ */ new Set([
2275
3093
  ]);
2276
3094
  /** This function tells if the lab keeps a kernel record of the prober. */
2277
3095
  const keptKernel = (record) => KEPT_KERNEL.has(String(record["ev"])) || isTunnelState(record);
3096
+ /**
3097
+ * This function gives the MAC address of `ip` in a table of /proc/net/arp, in lower case. It gives
3098
+ * null if the table has no row for `ip`, or if the row has the flags 0x0: no MAC yet. The ARP watch
3099
+ * of `client.mjs` reads the table the same way.
3100
+ */
3101
+ const arpMacOf = (table, ip) => {
3102
+ for (const row of table.split("\n").slice(1)) {
3103
+ const [address, , flags, mac] = row.trim().split(/\s+/);
3104
+ if (address === ip) return flags === "0x0" || mac === void 0 ? null : mac.toLowerCase();
3105
+ }
3106
+ return null;
3107
+ };
2278
3108
  /** This container class can share the process namespace of another container. */
2279
3109
  const shared = async () => {
2280
3110
  const { GenericContainer } = await import("testcontainers");
@@ -2335,6 +3165,11 @@ const openKindLab = async (cluster, image) => {
2335
3165
  const opened = [];
2336
3166
  let watcher = null;
2337
3167
  let prober = null;
3168
+ let outsiders = [];
3169
+ const stopOutside = async () => {
3170
+ for (const container of outsiders) await container.stop({ timeout: 0 }).catch(() => void 0);
3171
+ outsiders = [];
3172
+ };
2338
3173
  try {
2339
3174
  for (const node of cluster.nodes) {
2340
3175
  const agent = await new Shared(image).withPidMode(`container:${node}`).withPrivilegedMode().withNetworkMode("none").withEnvironment({ NODE_NAME: node }).withCommand([
@@ -2401,7 +3236,8 @@ const openKindLab = async (cluster, image) => {
2401
3236
  return [
2402
3237
  ...opened,
2403
3238
  ...watcher === null ? [] : [watcher],
2404
- ...prober === null ? [] : [prober]
3239
+ ...prober === null ? [] : [prober],
3240
+ ...outsiders
2405
3241
  ].map((container) => container.getName().replace(/^\//, ""));
2406
3242
  },
2407
3243
  take() {
@@ -2448,6 +3284,32 @@ const openKindLab = async (cluster, image) => {
2448
3284
  })).start();
2449
3285
  await Promise.race([ready, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("no begin line from the prober in 60 s")), 6e4))]);
2450
3286
  },
3287
+ async outside(spec) {
3288
+ await stopOutside();
3289
+ if (spec === null) return;
3290
+ for (let k = 0; k < spec.count; k += 1) {
3291
+ const client = await new GenericContainer(image).withNetworkMode(cluster.network).withEnvironment({
3292
+ TARGET: spec.target,
3293
+ EVERY_MS: String(valueIn(spec.every, "ms")),
3294
+ TIMEOUT_MS: String(valueIn(spec.timeout, "ms")),
3295
+ POD_NAME: `${OUTSIDE}-${k}`,
3296
+ NODE_NAME: OUTSIDE,
3297
+ ARP_WATCH: spec.arp
3298
+ }).withCommand(["node", "client.mjs"]).withLogConsumer(consume((line) => push(line))).start();
3299
+ outsiders.push(client);
3300
+ }
3301
+ },
3302
+ async outsideMacs(ip) {
3303
+ const found = [];
3304
+ for (const [k, client] of outsiders.entries()) {
3305
+ const { output } = await client.exec(["cat", "/proc/net/arp"]);
3306
+ found.push({
3307
+ client: `${OUTSIDE}-${k}`,
3308
+ mac: arpMacOf(output, ip)
3309
+ });
3310
+ }
3311
+ return found;
3312
+ },
2451
3313
  async stopProbe() {
2452
3314
  if (prober !== null) await prober.stop({ timeout: 0 }).catch(() => void 0);
2453
3315
  prober = null;
@@ -2634,6 +3496,7 @@ const openKindLab = async (cluster, image) => {
2634
3496
  },
2635
3497
  async close() {
2636
3498
  await lab.stopProbe();
3499
+ await stopOutside();
2637
3500
  if (watcher !== null) await watcher.stop({ timeout: 0 }).catch(() => void 0);
2638
3501
  watcher = null;
2639
3502
  for (const container of opened.reverse()) await container.stop({ timeout: 0 }).catch(() => void 0);
@@ -2853,14 +3716,57 @@ const preStopOf = (preStop, grace) => {
2853
3716
  `sleep ${preStop}`
2854
3717
  ] } } } };
2855
3718
  };
3719
+ /** This affinity keeps a pod in the zone that a zone loss keeps. */
3720
+ const keptZone = { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [{
3721
+ key: ZONE_LABEL,
3722
+ operator: "NotIn",
3723
+ values: [ZONE_LOST]
3724
+ }] }] } } };
3725
+ /**
3726
+ * This function gives the Service of a VIP (`announcer.ts`). It is of type LoadBalancer, with the
3727
+ * class of its announcer, if the announcer has one. The annotation of the announcer has the address
3728
+ * of the VIP. Without `service`, the announcer is kube-vip. The policy `Cluster` lets each node
3729
+ * send a request to a replica on another node. The holder writes the address to the status of the
3730
+ * Service. Then kube-proxy has the rules of the address on each node.
3731
+ */
3732
+ const vipService = (namespace, vip, ports, service = {
3733
+ loadBalancerClass: LB_CLASS,
3734
+ annotation: VIP_ANNOTATION
3735
+ }) => ({
3736
+ apiVersion: "v1",
3737
+ kind: "Service",
3738
+ metadata: {
3739
+ name: "lab",
3740
+ namespace,
3741
+ annotations: { [service.annotation]: vip }
3742
+ },
3743
+ spec: {
3744
+ type: "LoadBalancer",
3745
+ ...service.loadBalancerClass === null ? {} : { loadBalancerClass: service.loadBalancerClass },
3746
+ externalTrafficPolicy: "Cluster",
3747
+ selector: { app: "lab" },
3748
+ ports
3749
+ }
3750
+ });
2856
3751
  /**
2857
3752
  * These are the objects of the server: a Service of type ClusterIP, and the Deployment with the
2858
3753
  * settings of the cell. The image is the lab image for the service, or the image of grpc-go.
3754
+ *
3755
+ * With a VIP (`KindCell.vip`), the Service is of type LoadBalancer, with the address `vip` and the
3756
+ * class of its announcer (`vipService`). No replica is in the lost zone. Thus a zone loss takes away
3757
+ * only the holder of the VIP.
2859
3758
  */
2860
- const serverObjects = (cell, namespace, image, version = 1) => {
3759
+ const serverObjects = (cell, namespace, image, version = 1, vip = null) => {
2861
3760
  const preStop = whole(cell.pod.preStop, "the preStop sleep");
2862
3761
  const grace = whole(cell.pod.grace, "the grace period");
2863
- return [{
3762
+ if (cell.vip !== void 0 && vip === null) throw new Error(`${cell.name}: a cell with a VIP needs the address of the VIP`);
3763
+ const ports = [{
3764
+ name: stackOf(cell) === "grpc" ? "grpc" : "http",
3765
+ port: 80,
3766
+ targetPort: PORT
3767
+ }];
3768
+ const zoneLoss = cell.trigger.kind === "zone-loss";
3769
+ return [vip === null ? {
2864
3770
  apiVersion: "v1",
2865
3771
  kind: "Service",
2866
3772
  metadata: {
@@ -2869,13 +3775,9 @@ const serverObjects = (cell, namespace, image, version = 1) => {
2869
3775
  },
2870
3776
  spec: {
2871
3777
  selector: { app: "lab" },
2872
- ports: [{
2873
- name: stackOf(cell) === "grpc" ? "grpc" : "http",
2874
- port: 80,
2875
- targetPort: PORT
2876
- }]
3778
+ ports
2877
3779
  }
2878
- }, {
3780
+ } : vipService(namespace, vip, ports, announcerOf(cell).service), {
2879
3781
  apiVersion: "apps/v1",
2880
3782
  kind: "Deployment",
2881
3783
  metadata: {
@@ -2908,12 +3810,13 @@ const serverObjects = (cell, namespace, image, version = 1) => {
2908
3810
  topologyKey: "kubernetes.io/hostname",
2909
3811
  whenUnsatisfiable: "ScheduleAnyway",
2910
3812
  labelSelector: { matchLabels: { app: "lab" } }
2911
- }, ...cell.trigger.kind === "zone-loss" ? [{
3813
+ }, ...zoneLoss && cell.vip === void 0 ? [{
2912
3814
  maxSkew: 1,
2913
3815
  topologyKey: ZONE_LABEL,
2914
3816
  whenUnsatisfiable: "DoNotSchedule",
2915
3817
  labelSelector: { matchLabels: { app: "lab" } }
2916
3818
  }] : []],
3819
+ ...zoneLoss && cell.vip !== void 0 ? { affinity: keptZone } : {},
2917
3820
  containers: [{
2918
3821
  name: "app",
2919
3822
  image,
@@ -2955,11 +3858,7 @@ const clientObjects = (cell, namespace, image, target) => [{
2955
3858
  operator: "Exists",
2956
3859
  effect: "NoSchedule"
2957
3860
  }],
2958
- ...cell.trigger.kind === "zone-loss" ? { affinity: { nodeAffinity: { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [{
2959
- key: ZONE_LABEL,
2960
- operator: "NotIn",
2961
- values: [ZONE_LOST]
2962
- }] }] } } } } : {},
3861
+ ...cell.trigger.kind === "zone-loss" ? { affinity: keptZone } : {},
2963
3862
  containers: [{
2964
3863
  name: CLIENT,
2965
3864
  image,
@@ -3288,7 +4187,7 @@ const hasRedis = (cell) => cell.declaration !== null && cell.declaration.redis !
3288
4187
  * This function gives the key of the cluster of a cell: its kube-proxy mode, its node count, and the
3289
4188
  * flavor of its mesh. A zone loss has clusters of its own, with a zone on each node.
3290
4189
  */
3291
- const clusterKey = (cell) => cell.mesh !== void 0 ? `${cell.mode}-${cell.workers}-${cell.mesh.flavor}` : zoned(cell) ? `${cell.mode}-${cell.workers}-zones` : `${cell.mode}-${cell.workers}`;
4190
+ const clusterKey = (cell) => (cell.mesh !== void 0 ? `${cell.mode}-${cell.workers}-${cell.mesh.flavor}` : zoned(cell) ? `${cell.mode}-${cell.workers}-zones` : `${cell.mode}-${cell.workers}`) + (cell.vip === void 0 ? "" : cell.vip.announcer === "metallb" ? "-vip-mlb" : "-vip");
3292
4191
  /** This function tells if a cell needs a cluster with zones: a zone loss. */
3293
4192
  const zoned = (cell) => cell.trigger.kind === "zone-loss";
3294
4193
  /**
@@ -3353,8 +4252,12 @@ const unsteady = (ctx) => {
3353
4252
  for (const node of ctx.cluster.nodes) if ((tracker.hosts.get(node) ?? []).join() !== expected) return `the client sidecar of ${node} has ${(tracker.hosts.get(node) ?? []).join(" ") || "no host"}`;
3354
4253
  }
3355
4254
  const nodes = new Set([...tracker.clients.values()].map((client) => client.node));
3356
- const clientNodes = ctx.cluster.nodes.length - (cell.trigger.kind === "zone-loss" ? 1 : 0);
4255
+ const clientNodes = ctx.cluster.nodes.length - (cell.trigger.kind === "zone-loss" ? 1 : 0) + (cell.vip === void 0 ? 0 : 1);
3357
4256
  if (nodes.size !== clientNodes) return `clients answer on ${nodes.size} nodes, not ${clientNodes}`;
4257
+ if (cell.vip !== void 0) {
4258
+ const outside = [...tracker.clients.values()].filter((client) => client.node === OUTSIDE).length;
4259
+ if (outside !== cell.vip.clients) return `${outside} clients outside answer, not ${cell.vip.clients}`;
4260
+ }
3358
4261
  for (const [pod, client] of tracker.clients) {
3359
4262
  if (tracker.last - client.ok > 1e3) return `client ${pod} has no answer in the last second`;
3360
4263
  if (tracker.last - client.failed < 2e3) return `client ${pod} failed in the last 2 s`;
@@ -3457,7 +4360,7 @@ const quiet = async (ctx, ours, within = QUIET_WITHIN) => {
3457
4360
  * clients. A client of gRPC starts after the server is ready: each failed connect of a channel makes
3458
4361
  * the backoff of grpc-js longer, up to 120 s.
3459
4362
  */
3460
- const deploy = async (lab, cluster, cell, namespace, images) => {
4363
+ const deploy = async (lab, cluster, cell, namespace, images, vip) => {
3461
4364
  await cluster.apply([namespaceObject(cell, namespace)]);
3462
4365
  await lab.watch([namespace], { ztunnel: followsZtunnel(cell) });
3463
4366
  if (hasRedis(cell)) {
@@ -3473,7 +4376,7 @@ const deploy = async (lab, cluster, cell, namespace, images) => {
3473
4376
  }
3474
4377
  const grpc = stackOf(cell) === "grpc";
3475
4378
  if (grpc && images.grpc === void 0) throw new Error(`${cell.name}: the cluster has no image of grpc-go`);
3476
- await cluster.apply(serverObjects(cell, namespace, grpc ? images.grpc : lab.image));
4379
+ await cluster.apply(serverObjects(cell, namespace, grpc ? images.grpc : lab.image, 1, cell.vip === void 0 ? null : vip));
3477
4380
  const clusterIp = (await cluster.kubectl([
3478
4381
  "-n",
3479
4382
  namespace,
@@ -3502,6 +4405,18 @@ const deploy = async (lab, cluster, cell, namespace, images) => {
3502
4405
  `daemonset/${CLIENT}`,
3503
4406
  "--timeout=300s"
3504
4407
  ]);
4408
+ if (cell.vip !== void 0) {
4409
+ if (vip === null) throw new Error(`${cell.name}: the cluster has no VIP`);
4410
+ const served = await vipServed(cluster, namespace, "lab", vip);
4411
+ if (served !== null) throw new Error(`${cell.name}: ${served}`);
4412
+ await lab.outside({
4413
+ target: `${vip}:80`,
4414
+ every: cell.workload.every,
4415
+ timeout: cell.workload.timeout,
4416
+ count: cell.vip.clients,
4417
+ arp: vip
4418
+ });
4419
+ }
3505
4420
  await lab.rules({
3506
4421
  mode: cluster.scenario.dataplane,
3507
4422
  ns: namespace,
@@ -3938,6 +4853,31 @@ const doStep = async (ctx, step, replicas, background) => {
3938
4853
  };
3939
4854
  /** This function gives the node of the lost zone: the last worker (`zoneOf`). */
3940
4855
  const lostNodeOf = (cluster) => cluster.nodes.at(-1);
4856
+ /** This function gives the workers of a cluster: each node but the control plane, the first (`nodeNames`). */
4857
+ const workersOf = (cluster) => cluster.nodes.slice(1);
4858
+ /**
4859
+ * This function adds a line `vip` to the lines. The line has the state of the announcer, the MAC
4860
+ * address of each node on the Docker network, and the name of the announcer. The state of kube-vip
4861
+ * is its Lease. The state of MetalLB is the nodes that announce the Service. It also has the wall clock of the runner with its CLOCK_MONOTONIC. The times of the
4862
+ * Lease and of the logs are on the wall clock, and each stamp of the lab is on CLOCK_MONOTONIC. The containers of
4863
+ * the runner share one kernel, thus the pair converts one into the other.
4864
+ */
4865
+ const recordVip = async (ctx) => {
4866
+ const wall = Date.now();
4867
+ const t = Number(process.hrtime.bigint()) / 1e6;
4868
+ const announcer = announcerOf(ctx.cell);
4869
+ const state = await announcer.record(ctx.cluster, ctx.namespace, "lab");
4870
+ const { macs } = await vipNetworkOf(ctx.cluster.network);
4871
+ ctx.lab.record({
4872
+ ev: "vip",
4873
+ t,
4874
+ wall,
4875
+ vip: ctx.vip,
4876
+ announcer: announcer.name,
4877
+ ...state,
4878
+ macs
4879
+ });
4880
+ };
3941
4881
  /** This function puts the replicas of a node first, and keeps the sequence of the others. */
3942
4882
  const lostFirst = (replicas, node) => [...replicas.filter((replica) => replica.node === node), ...replicas.filter((replica) => replica.node !== node)];
3943
4883
  /** This function tells if a run is over: the trigger is done, and the cell is at rest again. */
@@ -3969,7 +4909,6 @@ const stoppedOf = (cell, replicas, target, lines) => deletes(cell) ? replicas.fi
3969
4909
  const runOnce = async (ctx, env, index, warmup, ours) => {
3970
4910
  const { lab, cell, cluster, namespace } = ctx;
3971
4911
  const plan = planOf(cell);
3972
- const atRest = await steady(ctx, 3e5);
3973
4912
  const empty = (why, start) => ({
3974
4913
  env,
3975
4914
  index,
@@ -3988,6 +4927,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
3988
4927
  lines: lab.take(),
3989
4928
  dropped: {}
3990
4929
  });
4930
+ const atRest = await steady(ctx, 3e5);
3991
4931
  if (atRest !== null) return empty(`the cell was not at rest: ${atRest}`, await lab.now());
3992
4932
  await probeThePods(ctx);
3993
4933
  await pollListeners(ctx);
@@ -3999,11 +4939,28 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
3999
4939
  const zoneLoss = cell.trigger.kind === "zone-loss";
4000
4940
  const lost = zoneLoss ? await nodeSteady(cluster, lostNodeOf(cluster), 3e5) : null;
4001
4941
  if (lost !== null) return empty(`the node of the lost zone was not at rest: ${lost}`, await lab.now());
4942
+ if (cell.vip !== void 0) {
4943
+ const announcer = announcerOf(cell);
4944
+ const holder = lostNodeOf(cluster);
4945
+ const held = await announcer.hold(cluster, holder, workersOf(cluster), ctx.namespace, "lab");
4946
+ if (held !== null) return empty(`the VIP is not on ${holder}: ${held}`, await lab.now());
4947
+ if (ctx.vip === null) return empty(`${cell.name}: the cluster has no VIP`, await lab.now());
4948
+ const mac = (await vipNetworkOf(cluster.network)).macs[holder];
4949
+ if (mac === void 0) return empty(`the node ${holder} has no MAC address on the Docker network`, await lab.now());
4950
+ const end = Date.now() + valueIn(announcer.settle, "ms") + 1e4;
4951
+ let moved = macsAt(await lab.outsideMacs(ctx.vip), mac, cell.vip.clients);
4952
+ while (moved !== null && Date.now() < end) {
4953
+ await sleep(250);
4954
+ moved = macsAt(await lab.outsideMacs(ctx.vip), mac, cell.vip.clients);
4955
+ }
4956
+ if (moved !== null) return empty(`the clients outside do not send to ${holder} (${mac}): ${moved}`, await lab.now());
4957
+ await sleep(valueIn(announcer.settle, "ms") + valueIn(cell.workload.timeout, "ms") + 1e3);
4958
+ }
4002
4959
  if (await steady(ctx, 3e5) !== null) return empty("the cell was not at rest after the wait for a quiet VM", await lab.now());
4003
4960
  const replicas = zoneLoss ? lostFirst(replicasOf(ctx), lostNodeOf(cluster)) : replicasOf(ctx);
4004
- if (zoneLoss && replicas[0]?.node !== lostNodeOf(cluster)) return empty(`no replica is on the node of the lost zone, ${lostNodeOf(cluster)}`, await lab.now());
4961
+ if (zoneLoss && cell.vip === void 0 && replicas[0]?.node !== lostNodeOf(cluster)) return empty(`no replica is on the node of the lost zone, ${lostNodeOf(cluster)}`, await lab.now());
4005
4962
  const restarts = new Map(ctx.tracker.servers().map((pod) => [pod.name, pod.cs.reduce((sum, status) => sum + status.rc, 0)]));
4006
- const target = hasRedis(cell) && [
4963
+ const target = cell.vip !== void 0 || hasRedis(cell) && [
4007
4964
  "rst",
4008
4965
  "blackhole",
4009
4966
  "freeze-process",
@@ -4103,6 +5060,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
4103
5060
  }
4104
5061
  const end = await lab.now();
4105
5062
  const timedOut = Date.now() >= limit;
5063
+ if (cell.vip !== void 0) await recordVip(ctx).catch((error) => notes.push(`the record of the VIP failed: ${String(error)}`));
4106
5064
  for (const step of plan.restore) try {
4107
5065
  await doStep(ctx, step, replicas, background);
4108
5066
  } catch (error) {
@@ -4220,6 +5178,13 @@ const runKindCells = async (cells, options) => {
4220
5178
  operator: await localPin(CILIUM_IMAGES.operator),
4221
5179
  envoy: await localPin(CILIUM_IMAGES.envoy)
4222
5180
  } : void 0;
5181
+ const announcerImages = /* @__PURE__ */ new Map();
5182
+ for (const announcer of [KUBE_VIP_ANNOUNCER, METALLB_ANNOUNCER]) {
5183
+ if (!cells.some((cell) => cell.vip !== void 0 && announcerOf(cell).name === announcer.name)) continue;
5184
+ const pinned = {};
5185
+ for (const [name, ref] of Object.entries(announcer.images)) pinned[name] = await localPin(ref);
5186
+ announcerImages.set(announcer.name, pinned);
5187
+ }
4223
5188
  const images = {
4224
5189
  lab: image,
4225
5190
  redis: redisImage,
@@ -4265,6 +5230,17 @@ const runKindCells = async (cells, options) => {
4265
5230
  await installMesh(cluster, mesh.flavor, images.mesh);
4266
5231
  options.log(` Istio ${mesh.flavor} installed in ${Math.round((Date.now() - began) / 100) / 10} s`);
4267
5232
  }
5233
+ let vip = null;
5234
+ if (group[0].vip !== void 0) {
5235
+ const announcer = announcerOf(group[0]);
5236
+ const pinned = announcerImages.get(announcer.name);
5237
+ if (pinned === void 0) throw new Error(`${group[0].name}: the run has no images of ${announcer.name}`);
5238
+ for (const each of Object.values(pinned)) await cluster.load(each);
5239
+ const began = Date.now();
5240
+ vip = announcer.vipOf((await vipNetworkOf(cluster.network)).subnet, lostNodeOf(cluster), workersOf(cluster));
5241
+ await announcer.install(cluster, pinned, lostNodeOf(cluster), workersOf(cluster), vip);
5242
+ options.log(` ${announcer.name} installed in ${Math.round((Date.now() - began) / 100) / 10} s, the VIP ${vip}`);
5243
+ }
4268
5244
  lab = await openKindLab(cluster, image);
4269
5245
  const { GenericContainer } = await import("testcontainers");
4270
5246
  const Shared = class extends GenericContainer {
@@ -4285,7 +5261,7 @@ const runKindCells = async (cells, options) => {
4285
5261
  const ztunnelEnv = cell.mesh?.ztunnel;
4286
5262
  try {
4287
5263
  if (ztunnelEnv !== void 0) await setZtunnelEnv(cluster, ztunnelEnv, false);
4288
- const clusterIp = await deploy(lab, cluster, cell, namespace, images);
5264
+ const clusterIp = await deploy(lab, cluster, cell, namespace, images, vip);
4289
5265
  for (const name of [...lab.own(), loader.getName().replace(/^\//, "")]) ours.add(name);
4290
5266
  const ctx = {
4291
5267
  lab,
@@ -4294,6 +5270,7 @@ const runKindCells = async (cells, options) => {
4294
5270
  namespace,
4295
5271
  tracker,
4296
5272
  clusterIp,
5273
+ vip: cell.vip === void 0 ? null : vip,
4297
5274
  loads,
4298
5275
  loader,
4299
5276
  idle: options.idle ?? .6,
@@ -4322,7 +5299,8 @@ const runKindCells = async (cells, options) => {
4322
5299
  redis: redisImage,
4323
5300
  ...stackOf(cell) === "grpc" ? { grpc: grpcImage } : {},
4324
5301
  ...cell.mesh === void 0 ? {} : Object.fromEntries(MESH_IMAGES[cell.mesh.flavor].map((name) => [`istio-${name}`, ISTIO_IMAGES[name]])),
4325
- ...s.cni === "cilium" ? Object.fromEntries(Object.entries(CILIUM_IMAGES).map(([name, ref]) => [`cilium-${name}`, ref])) : {}
5302
+ ...s.cni === "cilium" ? Object.fromEntries(Object.entries(CILIUM_IMAGES).map(([name, ref]) => [`cilium-${name}`, ref])) : {},
5303
+ ...cell.vip === void 0 ? {} : announcerOf(cell).images
4326
5304
  }
4327
5305
  });
4328
5306
  entry.loads.push(...loads);
@@ -4336,6 +5314,7 @@ const runKindCells = async (cells, options) => {
4336
5314
  }));
4337
5315
  } finally {
4338
5316
  off();
5317
+ await lab.outside(null).catch(() => void 0);
4339
5318
  await lab.rules(null).catch(() => void 0);
4340
5319
  await lab.eds(null).catch(() => void 0);
4341
5320
  await lab.lds(null).catch(() => void 0);
@@ -4395,8 +5374,8 @@ const estimate = (cell, environments = 2) => {
4395
5374
  const observed = after === null ? 0 : hold + valueIn(after, "s");
4396
5375
  const closes = cell.prediction.kind === "ends" && cell.prediction.band !== null ? valueIn(cell.prediction.band.hi, "s") + 3 : 0;
4397
5376
  const quiet = cell.trigger.quiet === true ? hold + 10 : 0;
4398
- const each = 25 + Math.max(observed, closes, quiet, trigger[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
4399
- return (cell.mesh === void 0 ? 60 : 180) * environments + (cell.runs + environments) * each;
5377
+ const each = 25 + (cell.vip === void 0 ? 0 : 30) + Math.max(observed, closes, quiet, trigger[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
5378
+ return ((cell.mesh === void 0 ? 60 : 180) + (cell.vip === void 0 ? 0 : 60)) * environments + (cell.runs + environments) * each;
4400
5379
  };
4401
5380
  /**
4402
5381
  * This function gives an estimate of the time of a cell: its setup, then each run and each
@@ -4427,6 +5406,6 @@ const shardOf = (cells, index, count) => {
4427
5406
  return out;
4428
5407
  };
4429
5408
  //#endregion
4430
- export { ACCESS_LOG_FORMAT, CHANNEL_USE, CILIUM_CHART, CILIUM_CONFIG_KEYS, CILIUM_IMAGES, CILIUM_VERSION, CILIUM_WORKLOADS, CLIENT, CLIENT_ACCOUNT, CLOSE_LEAD, CONTROLLER, DATAPLANES, DNS_PORT, DOCUMENTED, DRAIN_RULES, EDS_EVERY, FLAG_CHECK, FORMAT, FORMAT_V1, HBONE_PORT, HEALTH_PORT, HELM, ISTIOCTL, ISTIO_IMAGES, ISTIO_VERSION, KEPT_KERNEL, KIND, KIND_NOISE, KUBECTL, KUBE_PROXY_MODES, LAST_SLACK, LDS_EVERY, LDS_FILTER, LOG_LABEL, MESH_IMAGES, MESH_WORKLOADS, MIDDLEBOX, NETEM_DRAIN, NETNS_DEFAULTS, NODE_IMAGES, NODE_LIFECYCLE, O04, O09, O10, O16, POD_LINK, POLICY, PORT, QUIET_LEAD, REDIS, REDIS_IMAGE, RELATIVE, ROUTE, RULES_EVERY, SERVER, SPREAD_LEAD, STAGES, SYSCTL, TCP_STATE, WATCHDOG, ZONE_KEPT, ZONE_LABEL, ZONE_LOST, ZTUNNEL_PODS, backendsAt, backendsOf, backlogEnd, bandOf, byClients, byNode, cacheFolder, calibrationOf, capOf, cellsTable, certificateArgs, checkRun, cidrOverlap, cidrRange, ciliumBackendsOf, ciliumMode, ciliumValues, clientObjects, clusterFilter, compose, createCluster, cyclesOf, deletes, deletionOf, deletionWindow, deletionsOf, describeCell, dnsCommands, drainBand, dropOf, emptyOf, ensureChart, ensureTool, estimatedTime, factsOf, failuresOf, faultSpan, followsZtunnel, goDuration, hostsOf, installCilium, installMesh, intakeAfter, intakeOf, isKubeProxyMode, isTunnelState, istioOperator, judge, judgeKind, keptKernel, kernelCounts, kindCellRecordSchema, kindCellRecordV1Schema, kindConfig, kubeProxyPatch, kubeletBand, labImage, labSource, latchBand, leafCertificates, learnWindow, lintCell, listenerCountsOf, localPin, medianInterval, medianText, meshKey, middle, middleboxCommands, namesService, namespaceObject, namespaceOf, nodeLossBand, nodeNames, nodeReturnBand, observationAfter, observationEnd, openKindLab, outboundCluster, outcomesOf, overshootBand, pausedWindow, periodOf, placementOf, planOf, platformKey, podsAt, podsOf, policyObject, pollsListeners, predictionText, probeOf, propagationOf, propagationSource, quantileOf, readRecord, recordsOf, redisObjects, referenceOf, removalBand, requestBand, requestsOf, restartBand, restartOf, restartsAt, reusesReset, rulesOf, runKindCells, scenario, scenarioOf, scenarioProblems, seconds, secondsOf, serverCluster, serverObjects, sessionsAt, setZtunnelEnv, sha256, shardOf, sharedGate, showBand, signalOf, signalsOf, sliceDropOf, sliceEmptyOf, sliceReadyAt, sliceReadyOf, slicesOf, socketsOf, stackOf, stageLabImage, stallRemovalBand, stopOf, stoppingOf, sysctlCommand, tarEntry, timeSpread, toolPath, transitionOf, tunnelsOf, unitOf, updatesOf, upgrade, upgradeCell, verdictsOf, writeResult, zoneOf, ztunnelCertificates, ztunnelEnvArgs, ztunnelPodsOf, ztunnelReplaced };
5409
+ export { ACCESS_LOG_FORMAT, ANNOUNCE_ANNOTATION, CHANNEL_USE, CILIUM_CHART, CILIUM_CONFIG_KEYS, CILIUM_IMAGES, CILIUM_VERSION, CILIUM_WORKLOADS, CLIENT, CLIENT_ACCOUNT, CLOSE_LEAD, CONTROLLER, DATAPLANES, DNS_PORT, DOCUMENTED, DRAIN_RULES, EDS_EVERY, FLAG_CHECK, FORMAT, FORMAT_V1, HBONE_PORT, HEALTH_PORT, HELM, ISTIOCTL, ISTIO_IMAGES, ISTIO_VERSION, KEPT_KERNEL, KIND, KIND_NOISE, KUBECTL, KUBE_PROXY_MODES, KUBE_VIP, KUBE_VIP_ANNOUNCER, KUBE_VIP_ARP_RATE, KUBE_VIP_ELECTION, KUBE_VIP_IMAGE, KUBE_VIP_LEASE, KUBE_VIP_VERSION, LAST_SLACK, LB_CLASS, LDS_EVERY, LDS_FILTER, LOG_LABEL, MEMBERLIST, MESH_IMAGES, MESH_WORKLOADS, METALLB_ANNOTATION, METALLB_ANNOUNCER, METALLB_CHART, METALLB_IMAGES, METALLB_NAMESPACE, METALLB_POOL, METALLB_SPAM, METALLB_VERSION, MIDDLEBOX, NETEM_DRAIN, NETNS_DEFAULTS, NODE_IMAGES, NODE_LIFECYCLE, O04, O09, O10, O16, OUTSIDE, POD_LINK, POLICY, PORT, QUIET_LEAD, REDIS, REDIS_IMAGE, RELATIVE, ROUTE, RULES_EVERY, SERVER, SPREAD_LEAD, STAGES, SYSCTL, TCP_STATE, VIP_ANNOTATION, VIP_LABEL, WATCHDOG, ZONE_KEPT, ZONE_LABEL, ZONE_LOST, ZTUNNEL_PODS, announcementsOf, announcerOf, arpMacOf, backendsAt, backendsOf, backlogEnd, bandOf, byClients, byNode, cacheFolder, calibrationOf, capOf, cellsTable, certificateArgs, checkRun, cidrOverlap, cidrRange, ciliumBackendsOf, ciliumMode, ciliumValues, clientObjects, clusterFilter, compose, createCluster, cyclesOf, deletes, deletionOf, deletionWindow, deletionsOf, describeCell, dnsCommands, drainBand, dropOf, emptyOf, ensureChart, ensurePinnedChart, ensureTool, estimatedTime, factsOf, failuresOf, faultSpan, followsZtunnel, goDuration, helmInstall, holdMetallb, holdVip, hostsOf, installCilium, installKubeVip, installMesh, installMetallb, intakeAfter, intakeOf, isKubeProxyMode, isTunnelState, istioOperator, judge, judgeKind, keptKernel, kernelCounts, kindCellRecordSchema, kindCellRecordV1Schema, kindConfig, kubeProxyPatch, kubeVipObjects, kubeVipPods, kubeVipReady, kubeletBand, labImage, labSource, latchBand, leafCertificates, learnWindow, lintCell, listenerCountsOf, localPin, macsAt, medianInterval, medianText, meshKey, metallbObjects, metallbRecord, metallbValues, metallbVipOf, middle, middleboxCommands, namesService, namespaceObject, namespaceOf, nodeLossBand, nodeNames, nodeReturnBand, observationAfter, observationEnd, openKindLab, outboundCluster, outcomesOf, overshootBand, ownerOrder, pausedWindow, periodOf, placementOf, planOf, platformKey, podsAt, podsOf, policyObject, pollsListeners, predictionText, probeOf, propagationOf, propagationSource, quantileOf, readRecord, recordsOf, redisObjects, referenceOf, removalBand, requestBand, requestsOf, restartBand, restartOf, restartsAt, reusesReset, rulesOf, runKindCells, scenario, scenarioOf, scenarioProblems, seconds, secondsOf, serverCluster, serverObjects, sessionsAt, setZtunnelEnv, sha256, shardOf, sharedGate, showBand, signalOf, signalsOf, sliceDropOf, sliceEmptyOf, sliceReadyAt, sliceReadyOf, slicesOf, socketsOf, speakersReady, stackOf, stageLabImage, stallRemovalBand, stopOf, stoppingOf, suspicionOf, sysctlCommand, tarEntry, timeSpread, toolPath, transitionOf, tunnelsOf, unitOf, updatesOf, upgrade, upgradeCell, verdictsOf, vipLeaseOf, vipNetworkOf, vipOf, vipServed, vipService, writeResult, zoneOf, ztunnelCertificates, ztunnelEnvArgs, ztunnelPodsOf, ztunnelReplaced };
4431
5410
 
4432
5411
  //# sourceMappingURL=index.js.map