@mark1russell7/kind 0.13.4 → 0.14.1

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,5 +1,6 @@
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";
1
+ import { $ as rulesOf, A as judgeReconnects, At as overshootBand, B as emptyOf, C as observationEnd, Ct as PORT, D as reusesReset, Dt as backlogEnd, E as referenceOf, Et as VIP_ANNOTATION, F as backendsAt, G as outcomesOf, H as intakeOf, I as byClients, J as podsOf, K as periodOf, L as byNode, M as triesOf, Mt as seconds, N as HBONE_PORT, Nt as secondsOf, O as unitOf, Ot as bandOf, P as TCP_STATE, Pt as showBand, Q as restartsAt, R as deletionOf, S as observationAfter, St as OUTSIDE, T as propagationSource, Tt as SERVER, U as isTunnelState, V as failuresOf, W as kernelCounts, X as requestsOf, Y as recordsOf, Z as restartOf, _ as checkRun, _t as quantileOf, a as calibrationOf, at as sliceReadyAt, b as factsOf, bt as LB_CLASS, c as predictionText, ct as socketsOf, d as FORMAT_V1, dt as tunnelsOf, et as sessionsAt, f as judge, ft as ztunnelPodsOf, g as LAST_SLACK, gt as middle, h as KIND_NOISE, ht as medianInterval, i as readRecord, it as sliceEmptyOf, j as latestReturn, jt as requestBand, k as updatesOf, kt as drainBand, l as timeSpread, lt as stoppingOf, m as upgradeCell, mt as RELATIVE, n as kindCellRecordSchema, nt as signalsOf, o as cellsTable, ot as sliceReadyOf, p as upgrade, pt as ztunnelReplaced, q as podsAt, r as kindCellRecordV1Schema, rt as sliceDropOf, s as medianText, st as slicesOf, t as STAGES, tt as signalOf, u as FORMAT, ut as transitionOf, v as cyclesOf, vt as CLIENT, w as propagationOf, wt as REDIS, x as judgeKind, xt as LOG_LABEL, y as deletionsOf, yt as HEALTH_PORT, z as dropOf } from "./judge-DjtNaAVO.js";
2
2
  import { valueIn } from "@mark1russell7/quantity";
3
+ import { median } from "@mark1russell7/rigor-statistics";
3
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  import { execFile, execFileSync } from "node:child_process";
@@ -643,16 +644,18 @@ const createCluster = async (scenario, dir) => {
643
644
  };
644
645
  //#endregion
645
646
  //#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()) => {
647
+ /**
648
+ * This function gives the path of a chart in the cache, after a download when the cache does not have it.
649
+ * It refuses a download whose sha256 is not the pinned value. A file in the cache with a different
650
+ * sha256 (a torn write) is downloaded again, and each write replaces the file in one step.
651
+ */
652
+ const ensurePinnedChart = async (chart, cache = cacheFolder(), get = download) => {
648
653
  const path = join(cache, "charts", `${chart.name}-${chart.version}.tgz`);
649
- const bytes = existsSync(path) ? readFileSync(path) : await download(chart.url);
654
+ if (existsSync(path) && sha256(readFileSync(path)) === chart.sha256) return path;
655
+ const bytes = await get(chart.url);
650
656
  const found = sha256(bytes);
651
657
  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
- }
658
+ writeWhole(path, bytes, false);
656
659
  return path;
657
660
  };
658
661
  /**
@@ -1069,7 +1072,8 @@ const dnsCommands = (on) => {
1069
1072
  };
1070
1073
  /**
1071
1074
  * This function gives the commands of the injector that put a stateful middlebox in its network
1072
- * namespace, with each peer, on the local port `port`. With `idle` null, they remove it.
1075
+ * namespace, with each peer, on the local port `port`. With `idle` null, they remove the parts of these
1076
+ * peers. A run removes what the table of the namespace holds (`middleboxRemoval`).
1073
1077
  *
1074
1078
  * The middlebox forgets a flow of the port after `idle` with no segment. Then it drops each later
1075
1079
  * segment of it: the silent-drop form of the router of the harness. Conntrack stays as it is. The
@@ -1201,6 +1205,38 @@ const middleboxCommands = (peers, idle, port) => {
1201
1205
  ];
1202
1206
  };
1203
1207
  /**
1208
+ * This function gives the commands that remove a middlebox from a network namespace, from the filter
1209
+ * table that `iptables-save -c` gives for it. An empty list means that the namespace has no part of it.
1210
+ *
1211
+ * The commands remove each part that the table holds, and only those. The lab stops at the first
1212
+ * command that fails. Thus a removal from a list of the peers stops at a jump that is not there, and
1213
+ * its chains stay. For example, a set that failed half way added no jumps, or a client pod has a new
1214
+ * address. Then the next set of the namespace fails at its `-N`.
1215
+ */
1216
+ const middleboxRemoval = (saved) => {
1217
+ const chains = [MIDDLEBOX.input, MIDDLEBOX.output];
1218
+ const lines = saved.split("\n").map((line) => line.trim().replace(/^\[\d+:\d+\]\s+/, ""));
1219
+ const present = chains.filter((chain) => lines.some((line) => line.startsWith(`:${chain} `)));
1220
+ const jumps = lines.map((line) => line.split(/\s+/)).filter((words) => words[0] === "-A" && (words[1] === "INPUT" || words[1] === "OUTPUT") && chains.some((chain) => words.at(-1) === chain && words.at(-2) === "-j")).map((words) => [
1221
+ "iptables",
1222
+ "-D",
1223
+ ...words.slice(1)
1224
+ ]);
1225
+ return [
1226
+ ...present.map((chain) => [
1227
+ "iptables",
1228
+ "-F",
1229
+ chain
1230
+ ]),
1231
+ ...jumps,
1232
+ ...present.map((chain) => [
1233
+ "iptables",
1234
+ "-X",
1235
+ chain
1236
+ ])
1237
+ ];
1238
+ };
1239
+ /**
1204
1240
  * This function gives the target of a reset or a black hole of a cell. In T07 and T18 it is Redis,
1205
1241
  * with the replicas as peers. In T15 the fault is on the mesh leg between the client pods and the
1206
1242
  * replicas. It is a rule in each replica, with the client pods as peers. A partition (`partition`)
@@ -1375,7 +1411,7 @@ const stepsOf = (cell) => {
1375
1411
  case "crash": return {
1376
1412
  inject: [{
1377
1413
  do: "signal",
1378
- target: "server",
1414
+ target: cell.pulsar === void 0 ? "server" : "broker",
1379
1415
  signal: "SIGKILL"
1380
1416
  }],
1381
1417
  hold: null,
@@ -1393,6 +1429,24 @@ const stepsOf = (cell) => {
1393
1429
  restore: []
1394
1430
  };
1395
1431
  case "freeze-process":
1432
+ if (cell.vip !== void 0) return {
1433
+ inject: [{
1434
+ do: "signal",
1435
+ target: "speaker",
1436
+ signal: "SIGSTOP"
1437
+ }],
1438
+ hold: needs(trigger.hold, "a hold, the time that the speaker is frozen"),
1439
+ heal: [{
1440
+ do: "signal",
1441
+ target: "speaker",
1442
+ signal: "SIGCONT"
1443
+ }],
1444
+ restore: [{
1445
+ do: "signal",
1446
+ target: "speaker",
1447
+ signal: "SIGCONT"
1448
+ }]
1449
+ };
1396
1450
  if (cell.template === "T07") return {
1397
1451
  inject: [
1398
1452
  {
@@ -1814,7 +1868,7 @@ const deletes = (cell) => [
1814
1868
  "rolling-update",
1815
1869
  "node-drain",
1816
1870
  "scale-in"
1817
- ].includes(cell.trigger.kind) || cell.template === "T07" && cell.trigger.kind === "freeze-process";
1871
+ ].includes(cell.trigger.kind) || cell.template === "T07" && cell.trigger.kind === "freeze-process" && cell.vip === void 0;
1818
1872
  //#endregion
1819
1873
  //#region src/image.ts
1820
1874
  /** This folder is the root of the package, and its `image/` folder has the files of the image. */
@@ -2511,7 +2565,7 @@ const vipOf = (subnet) => {
2511
2565
  0
2512
2566
  ].map((shift) => Math.floor(address / 2 ** shift) % 256).join(".");
2513
2567
  };
2514
- const sleep$2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2568
+ const sleep$3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2515
2569
  /** This function gives the pods of kube-vip that are not deleted. */
2516
2570
  const kubeVipPods = async (cluster) => {
2517
2571
  return JSON.parse(await cluster.kubectl([
@@ -2540,7 +2594,7 @@ const until$1 = async (within, check) => {
2540
2594
  const end = Date.now() + within;
2541
2595
  let why = await check();
2542
2596
  while (why !== null && Date.now() < end) {
2543
- await sleep$2(500);
2597
+ await sleep$3(500);
2544
2598
  why = await check();
2545
2599
  }
2546
2600
  return why;
@@ -2657,8 +2711,8 @@ const vipServed = (cluster, namespace, service, vip, within = 12e4) => until$1(w
2657
2711
  * a usable speaker (speaker/layer2_controller.go, lines 83–129). A usable speaker is a member of
2658
2712
  * memberlist that is not dead (internal/speakerlist/speakerlist.go, lines 303–320).
2659
2713
  * - 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.
2714
+ * /etc/metallb/memberlist/config.json replaces that config (lines 77–84). Without settings of
2715
+ * memberlist, the install makes no such file. Then each value is the default of memberlist v0.5.1.
2662
2716
  * - Each event of memberlist (a join, a leave or an update) calls `ForceSync` (speakerlist.go, lines
2663
2717
  * 339–349). `ForceSync` reconciles each Service at once, with no delay (internal/k8s/k8s.go, lines
2664
2718
  * 181–184, and internal/k8s/controllers/service_controller_reload.go, lines 62–118).
@@ -2719,6 +2773,60 @@ const MEMBERLIST = {
2719
2773
  unit: "ms"
2720
2774
  }
2721
2775
  };
2776
+ /**
2777
+ * These are the values of the config file of memberlist, as `DefaultLANConfig` of v0.5.1 gives them
2778
+ * (config.go, lines 302–341). The speaker needs each field of the file, and it reads a time as a
2779
+ * duration of Go (speakerlist.go, lines 355–432). The chart mounts the ConfigMap
2780
+ * `metallb-memberlist-config` at /etc/metallb/memberlist, if the ConfigMap exists
2781
+ * (charts/metallb/templates/speaker.yaml, lines 195–198 and 391–392). The key is `config.json`.
2782
+ */
2783
+ const MEMBERLIST_FILE = {
2784
+ TCPTimeout: "10s",
2785
+ IndirectChecks: 3,
2786
+ RetransmitMult: 4,
2787
+ SuspicionMult: 4,
2788
+ SuspicionMaxTimeoutMult: 6,
2789
+ PushPullInterval: "30s",
2790
+ ProbeTimeout: "500ms",
2791
+ ProbeInterval: "1s",
2792
+ DisableTcpPings: false,
2793
+ AwarenessMaxMultiplier: 8,
2794
+ GossipNodes: 3,
2795
+ GossipInterval: "200ms",
2796
+ GossipToTheDeadTime: "30s",
2797
+ GossipVerifyIncoming: true,
2798
+ GossipVerifyOutgoing: true,
2799
+ EnableCompression: true,
2800
+ HandoffQueueDepth: 1024,
2801
+ UDPBufferSize: 1400,
2802
+ QueueCheckInterval: "30s"
2803
+ };
2804
+ /** This name is the ConfigMap of the config file of memberlist. */
2805
+ const MEMBERLIST_CONFIG = "metallb-memberlist-config";
2806
+ /** This function gives a time as a duration of Go in whole milliseconds, for example "500ms". */
2807
+ const goDuration$1 = (time) => {
2808
+ const ms = valueIn(time, "ms");
2809
+ if (!Number.isInteger(ms) || ms <= 0) throw new RangeError(`a time of memberlist is a whole count of milliseconds above zero, not ${ms} ms`);
2810
+ return `${ms}ms`;
2811
+ };
2812
+ /** This function gives the config file of memberlist with `settings`. Each other value is the default of `MEMBERLIST_FILE`. */
2813
+ const memberlistFileOf = (settings) => {
2814
+ if (!Number.isInteger(settings.suspicionMult) || settings.suspicionMult < 1) throw new RangeError(`SuspicionMult is a whole number of 1 or more, not ${settings.suspicionMult}`);
2815
+ if (valueIn(settings.probeTimeout, "ms") >= valueIn(settings.probeInterval, "ms")) throw new RangeError("the probe timeout of memberlist is less than its probe interval");
2816
+ return {
2817
+ ...MEMBERLIST_FILE,
2818
+ ProbeInterval: goDuration$1(settings.probeInterval),
2819
+ ProbeTimeout: goDuration$1(settings.probeTimeout),
2820
+ SuspicionMult: settings.suspicionMult
2821
+ };
2822
+ };
2823
+ /** This function gives the terms of memberlist with `settings`, or the defaults (`MEMBERLIST`) without them. */
2824
+ const memberlistTermsOf = (settings) => settings === void 0 ? MEMBERLIST : {
2825
+ ...MEMBERLIST,
2826
+ probeInterval: settings.probeInterval,
2827
+ probeTimeout: settings.probeTimeout,
2828
+ suspicionMult: settings.suspicionMult
2829
+ };
2722
2830
  /** 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
2831
  const METALLB_SPAM = {
2724
2832
  every: {
@@ -2829,13 +2937,13 @@ const metallbVipOf = (subnet, owner, nodes) => {
2829
2937
  }
2830
2938
  throw new Error(`the subnet ${subnet} has no VIP that ${owner} owns`);
2831
2939
  };
2832
- const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2940
+ const sleep$2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2833
2941
  /** This function waits until a condition gives null, and gives null, or the last reason after `within` ms. */
2834
2942
  const until = async (within, check) => {
2835
2943
  const end = Date.now() + within;
2836
2944
  let why = await check();
2837
2945
  while (why !== null && Date.now() < end) {
2838
- await sleep$1(500);
2946
+ await sleep$2(500);
2839
2947
  why = await check();
2840
2948
  }
2841
2949
  return why;
@@ -2902,8 +3010,12 @@ const holdMetallb = async (cluster, owner, nodes, namespace, service, within = 1
2902
3010
  * and a pool of the VIP. The webhook of the controller checks the pool, thus the function tries the
2903
3011
  * pool again until the webhook answers. A failed install gives the pods of MetalLB and the tail of
2904
3012
  * their logs.
3013
+ *
3014
+ * With `memberlist`, the ConfigMap of the config file comes before the speakers. Then the function
3015
+ * checks that each speaker logged the load of the file (speakerlist.go, line 393). Without it, the
3016
+ * function checks that the ConfigMap does not exist.
2905
3017
  */
2906
- const installMetallb = async (cluster, images, nodes, vip) => {
3018
+ const installMetallb = async (cluster, images, nodes, vip, memberlist) => {
2907
3019
  try {
2908
3020
  for (const node of nodes) await cluster.kubectl([
2909
3021
  "label",
@@ -2912,24 +3024,66 @@ const installMetallb = async (cluster, images, nodes, vip) => {
2912
3024
  `${VIP_LABEL}=1`,
2913
3025
  "--overwrite"
2914
3026
  ]);
3027
+ if (memberlist !== void 0) {
3028
+ await cluster.apply([{
3029
+ apiVersion: "v1",
3030
+ kind: "Namespace",
3031
+ metadata: { name: METALLB_NAMESPACE }
3032
+ }]);
3033
+ await cluster.apply([{
3034
+ apiVersion: "v1",
3035
+ kind: "ConfigMap",
3036
+ metadata: {
3037
+ name: MEMBERLIST_CONFIG,
3038
+ namespace: METALLB_NAMESPACE
3039
+ },
3040
+ data: { "config.json": JSON.stringify(memberlistFileOf(memberlist)) }
3041
+ }]);
3042
+ }
2915
3043
  await helmInstall(cluster, "metallb", METALLB_CHART, METALLB_NAMESPACE, metallbValues(images), { createNamespace: true });
2916
3044
  let failure = null;
2917
3045
  const end = Date.now() + 18e4;
2918
3046
  do {
2919
3047
  failure = await cluster.apply(metallbObjects(vip)).then(() => null, (error) => error);
2920
- if (failure !== null) await sleep$1(2e3);
3048
+ if (failure !== null) await sleep$2(2e3);
2921
3049
  } while (failure !== null && Date.now() < end);
2922
3050
  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");
3051
+ if (memberlist === void 0) {
3052
+ if ((await cluster.kubectl([
3053
+ "-n",
3054
+ "metallb-system",
3055
+ "get",
3056
+ "configmap",
3057
+ MEMBERLIST_CONFIG,
3058
+ "--ignore-not-found",
3059
+ "-o",
3060
+ "name"
3061
+ ])).trim() !== "") throw new Error(`the ConfigMap ${MEMBERLIST_CONFIG} is there: memberlist would not use its defaults`);
3062
+ } else {
3063
+ const loaded = await until(12e4, async () => {
3064
+ const pods = JSON.parse(await cluster.kubectl([
3065
+ "-n",
3066
+ METALLB_NAMESPACE,
3067
+ "get",
3068
+ "pods",
3069
+ "-l",
3070
+ "app.kubernetes.io/component=speaker",
3071
+ "-o",
3072
+ "json"
3073
+ ]));
3074
+ if (pods.items.length !== nodes.length) return `${pods.items.length} speakers, not ${nodes.length}`;
3075
+ for (const pod of pods.items) if (!(await cluster.kubectl([
3076
+ "-n",
3077
+ "metallb-system",
3078
+ "logs",
3079
+ pod.metadata.name,
3080
+ "-c",
3081
+ "speaker"
3082
+ ]).catch((error) => String(error))).includes("loading memberlist config from file")) return `the speaker ${pod.metadata.name} did not log the load of the config file of memberlist`;
3083
+ return null;
3084
+ });
3085
+ if (loaded !== null) throw new Error(loaded);
3086
+ }
2933
3087
  } catch (error) {
2934
3088
  const state = async (args) => (await cluster.kubectl([
2935
3089
  "-n",
@@ -2954,7 +3108,7 @@ const installMetallb = async (cluster, images, nodes, vip) => {
2954
3108
  }
2955
3109
  };
2956
3110
  /** 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/;
3111
+ const KEPT$1 = /node event|Marking|Suspect|Refuting|serviceAnnounced|serviceWithdrawn|memberlist/;
2958
3112
  /**
2959
3113
  * This function gives the record of MetalLB after a run: the nodes that announce the Service, and
2960
3114
  * some lines of each speaker. The lines are about memberlist and the announces, with the times of the kubelet.
@@ -2974,7 +3128,7 @@ const metallbRecord = async (cluster, namespace, service) => {
2974
3128
  "--tail=4000",
2975
3129
  "--prefix",
2976
3130
  "--timestamps"
2977
- ]).catch((error) => String(error))).split("\n").filter((line) => KEPT.test(line)).slice(-120).join("\n").slice(-12e3)
3131
+ ]).catch((error) => String(error))).split("\n").filter((line) => KEPT$1.test(line)).slice(-600).join("\n").slice(-9e4)
2978
3132
  };
2979
3133
  };
2980
3134
  //#endregion
@@ -3025,10 +3179,10 @@ const METALLB_ANNOUNCER = {
3025
3179
  },
3026
3180
  settle: METALLB_SPAM.for,
3027
3181
  vipOf: (subnet, owner, nodes) => metallbVipOf(subnet, owner, nodes),
3028
- install: (cluster, images, _owner, nodes, vip) => installMetallb(cluster, {
3182
+ install: (cluster, images, _owner, nodes, vip, settings) => installMetallb(cluster, {
3029
3183
  speaker: local(images, "metallb-speaker"),
3030
3184
  controller: local(images, "metallb-controller")
3031
- }, nodes, vip),
3185
+ }, nodes, vip, settings?.memberlist),
3032
3186
  hold: (cluster, owner, nodes, namespace, service) => holdMetallb(cluster, owner, nodes, namespace, service),
3033
3187
  record: (cluster, namespace, service) => metallbRecord(cluster, namespace, service)
3034
3188
  };
@@ -3041,6 +3195,455 @@ const macsAt = (found, mac, count) => {
3041
3195
  return wrong.length === 0 ? null : wrong.map((each) => `${each.client} has ${each.mac ?? "no MAC"}`).join(", ");
3042
3196
  };
3043
3197
  //#endregion
3198
+ //#region src/pulsar.ts
3199
+ /**
3200
+ * This module installs Apache Pulsar 5.0.0 in a kind cluster (SYS-56): one ZooKeeper, one bookie and
3201
+ * two brokers, from the Helm chart 4.8.0. It also gives the producers of the cell: a Python script
3202
+ * in the image of Pulsar, with its client 3.13.0 over the C++ client 4.2.0. Each producer sends to
3203
+ * one topic, and a thread of it looks up the owner of that topic at each probe.
3204
+ *
3205
+ * The fault is a SIGKILL of the JVM of the broker that owns the topic. Each term of the return of a
3206
+ * producer comes from a pinned source:
3207
+ * - The owner of a bundle is an ephemeral znode of the session of its broker. A lookup gives the
3208
+ * owner from the cache of the ownership, with no check of its life (pulsar-broker
3209
+ * NamespaceService.java at v5.0.0, lines 489–508). Thus each lookup names the stopped broker
3210
+ * until its session expires.
3211
+ * - The session timeout is `metadataStoreSessionTimeoutMillis`, 30000 (conf/broker.conf at v5.0.0,
3212
+ * line 187, and the chart, values.yaml line 1474). ZKMetadataStore.java gives it to ZooKeeper (line 105).
3213
+ * - The client of ZooKeeper 3.9.6 pings after 9 s with no send (ClientCnxn.java, lines 422, 1184
3214
+ * and 1263–1268). That is half of the read timeout of 2/3 of the session, less 1 s.
3215
+ * The server rounds each expiry up to the next tick of 2000 ms (ExpiryQueue.java, lines 53–54
3216
+ * and 87, and conf/zookeeper.conf of Pulsar, line 21). Thus the session expires 21–32 s after the stop.
3217
+ * - The broker that stays then takes the bundle at the next lookup.
3218
+ * - The chart starts a broker again only after its old znode is gone (templates/broker-statefulset.yaml,
3219
+ * lines 296–302). Thus the stopped broker cannot take the bundle back first.
3220
+ */
3221
+ /** This version is the release of Pulsar of the lab. */
3222
+ const PULSAR_VERSION = "5.0.0";
3223
+ /**
3224
+ * This chart is the Helm chart 4.8.0 of Pulsar. The sha256 is of the file of the release. The URL is
3225
+ * on the archive of Apache, which keeps each release. The mirror downloads.apache.org keeps only the
3226
+ * latest releases.
3227
+ */
3228
+ const PULSAR_CHART = {
3229
+ name: "pulsar",
3230
+ version: "4.8.0",
3231
+ url: "https://archive.apache.org/dist/pulsar/helm-chart/4.8.0/pulsar-4.8.0.tgz",
3232
+ sha256: "464f4a268463d187c9bc83b365b31de0b4a460533d948a04f846ba9b026068f5"
3233
+ };
3234
+ /**
3235
+ * This image is Pulsar 5.0.0 for linux/amd64, pinned by the digest of the manifest of the platform.
3236
+ * It has ZooKeeper 3.9.6, BookKeeper, the broker, Python 3 and the Python client 3.13.0. The lab
3237
+ * pulls it by its digest, tags it (`localPin`), and loads it into each node.
3238
+ */
3239
+ const PULSAR_IMAGE = "apachepulsar/pulsar:5.0.0@sha256:11e9a885121e16be91da77fdd3cfc3d4cbbece4700271d75e949585dbd3a5a5b";
3240
+ /** This namespace holds Pulsar. The release of the chart is `pulsar` too. */
3241
+ const PULSAR_NAMESPACE = "pulsar";
3242
+ /** This name is the app of the producers of a cell. */
3243
+ const PRODUCER = "producer";
3244
+ /** This topic is the topic of the producers. */
3245
+ const PULSAR_TOPIC = "persistent://public/default/lab";
3246
+ /** This URL is the Service of the brokers for the binary protocol. */
3247
+ const PULSAR_SERVICE = `pulsar://pulsar-broker.${PULSAR_NAMESPACE}.svc.cluster.local:6650`;
3248
+ /** This URL is the lookup of the owner of the topic, on the Service of the brokers. */
3249
+ const PULSAR_LOOKUP = `http://pulsar-broker.${PULSAR_NAMESPACE}.svc.cluster.local:8080/lookup/v2/topic/persistent/public/default/lab`;
3250
+ /** This time is the session timeout of each broker in ZooKeeper (`metadataStoreSessionTimeoutMillis`). */
3251
+ const SESSION = {
3252
+ value: 30,
3253
+ unit: "s"
3254
+ };
3255
+ /** This time is the tick of ZooKeeper (`tickTime`). The server rounds each expiry up to a tick. */
3256
+ const TICK = {
3257
+ value: 2,
3258
+ unit: "s"
3259
+ };
3260
+ /** This time is the longest idle time of the client of ZooKeeper before its ping: the half of 2/3 of the session, less 1 s. */
3261
+ const PING = {
3262
+ value: 9,
3263
+ unit: "s"
3264
+ };
3265
+ /**
3266
+ * This backoff is the backoff of a producer of the C++ client 4.2.0, with the defaults of the Python
3267
+ * client 3.13.0:
3268
+ * - The initial delay is 100 ms and the most is 60 s (lib/ClientConfigurationImpl.h, lines 39–40).
3269
+ * - The mandatory stop is the send timeout less 100 ms, and at least 100 ms (lib/ProducerImpl.cc,
3270
+ * lines 55–57). The send timeout of `create_producer` is 30000 ms (pulsar/__init__.py, line 1015).
3271
+ * - The jitter takes 0–9 % of each delay (lib/Backoff.cc, line 54).
3272
+ * - A try that fails takes the lookup and the connect, which a reset ends at once. The lab gives it 100 ms.
3273
+ */
3274
+ const PRODUCER_BACKOFF = {
3275
+ initial: {
3276
+ value: 100,
3277
+ unit: "ms"
3278
+ },
3279
+ max: {
3280
+ value: 60,
3281
+ unit: "s"
3282
+ },
3283
+ mandatoryStop: {
3284
+ value: 29.9,
3285
+ unit: "s"
3286
+ },
3287
+ jitter: .09,
3288
+ attempt: {
3289
+ value: 100,
3290
+ unit: "ms"
3291
+ }
3292
+ };
3293
+ /** This function gives the repository and the tag of a local image, for the values of a chart. */
3294
+ const repositoryOf = (local) => {
3295
+ const at = local.lastIndexOf(":");
3296
+ if (at <= 0) throw new Error(`the image ${local} has no tag`);
3297
+ return {
3298
+ repository: local.slice(0, at),
3299
+ tag: local.slice(at + 1)
3300
+ };
3301
+ };
3302
+ /**
3303
+ * This function gives the values of the chart: ZooKeeper, one bookie and two brokers, with no
3304
+ * proxy, no autorecovery, no toolset and no monitor. The bookie keeps one copy of each entry, and
3305
+ * the brokers do not move a bundle by load. Each image is the local image, with no pull.
3306
+ */
3307
+ const pulsarValues = (image) => {
3308
+ const { repository, tag } = repositoryOf(image);
3309
+ const noMonitor = { podMonitor: { enabled: false } };
3310
+ return {
3311
+ components: {
3312
+ zookeeper: true,
3313
+ bookkeeper: true,
3314
+ broker: true,
3315
+ autorecovery: false,
3316
+ proxy: false,
3317
+ toolset: false,
3318
+ functions: false,
3319
+ function_worker: false
3320
+ },
3321
+ "victoria-metrics-k8s-stack": { enabled: false },
3322
+ defaultPulsarImageRepository: repository,
3323
+ defaultPulsarImageTag: tag,
3324
+ defaultPullPolicy: "Never",
3325
+ affinity: { anti_affinity: false },
3326
+ volumes: { persistence: false },
3327
+ zookeeper: {
3328
+ replicaCount: 1,
3329
+ statefulsetUpgrade: { enabled: false },
3330
+ ...noMonitor
3331
+ },
3332
+ bookkeeper: {
3333
+ replicaCount: 1,
3334
+ ...noMonitor
3335
+ },
3336
+ autorecovery: noMonitor,
3337
+ proxy: noMonitor,
3338
+ broker: {
3339
+ replicaCount: 2,
3340
+ statefulsetUpgrade: { enabled: false },
3341
+ ...noMonitor,
3342
+ configData: {
3343
+ managedLedgerDefaultEnsembleSize: "1",
3344
+ managedLedgerDefaultWriteQuorum: "1",
3345
+ managedLedgerDefaultAckQuorum: "1",
3346
+ loadBalancerSheddingEnabled: "false",
3347
+ loadBalancerAutoBundleSplitEnabled: "false"
3348
+ }
3349
+ }
3350
+ };
3351
+ };
3352
+ /**
3353
+ * This script is a producer of the cell. Each line that it writes is JSON, with the time of
3354
+ * CLOCK_MONOTONIC in milliseconds, the pod and the node:
3355
+ * - `send`: one send, at its start, with its time to the answer and its outcome. A send is `ok` within
3356
+ * the timeout, `late` after it, or the name of the result of the client (for example `timeout`).
3357
+ * - `lookup`: one lookup of the owner of the topic, with the host of the owner.
3358
+ * - `pulsar`: one line of the C++ client about the connection of the producer.
3359
+ */
3360
+ const PRODUCER_SCRIPT = `import json, logging, os, sys, threading, time, urllib.request
3361
+ import pulsar
3362
+
3363
+ POD = os.environ["POD_NAME"]
3364
+ NODE = os.environ["NODE_NAME"]
3365
+ EVERY = int(os.environ["EVERY_MS"]) / 1000
3366
+ TIMEOUT = int(os.environ["TIMEOUT_MS"])
3367
+ PROBE = int(os.environ["PROBE_MS"]) / 1000
3368
+ LOCK = threading.Lock()
3369
+ KEPT = ("Schedule reconnection", "Getting connection from pool", "Created producer", "Connection closed", "Disconnected", "Lookup response", "Failed to", "Finished connecting")
3370
+
3371
+
3372
+ def now():
3373
+ return time.monotonic() * 1000
3374
+
3375
+
3376
+ def say(**line):
3377
+ text = json.dumps({**line, "p": POD, "n": NODE}, separators=(",", ":"))
3378
+ with LOCK:
3379
+ sys.stdout.write(text + "\\n")
3380
+ sys.stdout.flush()
3381
+
3382
+
3383
+ class Relay(logging.Handler):
3384
+ def emit(self, record):
3385
+ message = record.getMessage()
3386
+ if any(word in message for word in KEPT):
3387
+ say(ev="pulsar", t=now(), m=message[:400])
3388
+
3389
+
3390
+ def probe():
3391
+ while True:
3392
+ start = now()
3393
+ owner = None
3394
+ outcome = "ok"
3395
+ try:
3396
+ with urllib.request.urlopen(os.environ["LOOKUP"], timeout=1) as answer:
3397
+ data = json.loads(answer.read())
3398
+ url = data.get("brokerUrl") or ""
3399
+ owner = url.split("://", 1)[-1].rsplit(":", 1)[0] or None
3400
+ except Exception as error:
3401
+ outcome = type(error).__name__.lower()
3402
+ say(ev="lookup", t=start, d=round(now() - start, 3), o=outcome, owner=owner)
3403
+ time.sleep(max(0.0, PROBE - (now() - start) / 1000))
3404
+
3405
+
3406
+ def answered(start):
3407
+ def done(result, message_id):
3408
+ took = now() - start
3409
+ if result == pulsar.Result.Ok:
3410
+ outcome = "ok" if took <= TIMEOUT else "late"
3411
+ else:
3412
+ outcome = getattr(result, "name", str(result).split(".")[-1]).lower()
3413
+ say(ev="send", t=start, d=round(took, 3), o=outcome)
3414
+ return done
3415
+
3416
+
3417
+ logger = logging.getLogger("lab")
3418
+ logger.setLevel(logging.INFO)
3419
+ logger.propagate = False
3420
+ logger.addHandler(Relay())
3421
+ threading.Thread(target=probe, daemon=True).start()
3422
+ client = pulsar.Client(os.environ["SERVICE"], logger=logger)
3423
+ while True:
3424
+ try:
3425
+ producer = client.create_producer(os.environ["TOPIC"])
3426
+ break
3427
+ except Exception as error:
3428
+ say(ev="text", t=now(), text="create_producer: " + str(error)[:200])
3429
+ time.sleep(2)
3430
+ at = time.monotonic()
3431
+ while True:
3432
+ start = now()
3433
+ try:
3434
+ producer.send_async(b"lab", answered(start))
3435
+ except Exception as error:
3436
+ say(ev="send", t=start, d=0, o="refused-" + type(error).__name__.lower())
3437
+ at += EVERY
3438
+ time.sleep(max(0.0, at - time.monotonic()))
3439
+ `;
3440
+ /**
3441
+ * This function gives the producers of a cell in its namespace. They are the script in a ConfigMap,
3442
+ * and a Deployment of `clients` pods in the local image of Pulsar, one on each worker.
3443
+ */
3444
+ const pulsarObjects = (cell, namespace, image) => {
3445
+ if (cell.pulsar === void 0) throw new Error(`${cell.name} has no broker`);
3446
+ const env = {
3447
+ SERVICE: PULSAR_SERVICE,
3448
+ TOPIC: PULSAR_TOPIC,
3449
+ LOOKUP: PULSAR_LOOKUP,
3450
+ EVERY_MS: String(valueIn(cell.workload.every, "ms")),
3451
+ TIMEOUT_MS: String(valueIn(cell.workload.timeout, "ms")),
3452
+ PROBE_MS: String(valueIn(cell.pulsar.probe, "ms")),
3453
+ PYTHONUNBUFFERED: "1"
3454
+ };
3455
+ return [{
3456
+ apiVersion: "v1",
3457
+ kind: "ConfigMap",
3458
+ metadata: {
3459
+ name: PRODUCER,
3460
+ namespace
3461
+ },
3462
+ data: { "producer.py": PRODUCER_SCRIPT }
3463
+ }, {
3464
+ apiVersion: "apps/v1",
3465
+ kind: "Deployment",
3466
+ metadata: {
3467
+ name: PRODUCER,
3468
+ namespace,
3469
+ labels: { app: PRODUCER }
3470
+ },
3471
+ spec: {
3472
+ replicas: cell.pulsar.clients,
3473
+ selector: { matchLabels: { app: PRODUCER } },
3474
+ template: {
3475
+ metadata: { labels: {
3476
+ app: PRODUCER,
3477
+ [LOG_LABEL]: "1"
3478
+ } },
3479
+ spec: {
3480
+ terminationGracePeriodSeconds: 0,
3481
+ topologySpreadConstraints: [{
3482
+ maxSkew: 1,
3483
+ topologyKey: "kubernetes.io/hostname",
3484
+ whenUnsatisfiable: "DoNotSchedule",
3485
+ labelSelector: { matchLabels: { app: PRODUCER } }
3486
+ }],
3487
+ volumes: [{
3488
+ name: "script",
3489
+ configMap: { name: PRODUCER }
3490
+ }],
3491
+ containers: [{
3492
+ name: PRODUCER,
3493
+ image,
3494
+ imagePullPolicy: "Never",
3495
+ command: ["python3", "/lab/producer.py"],
3496
+ volumeMounts: [{
3497
+ name: "script",
3498
+ mountPath: "/lab"
3499
+ }],
3500
+ env: [
3501
+ ...Object.entries(env).map(([name, value]) => ({
3502
+ name,
3503
+ value
3504
+ })),
3505
+ {
3506
+ name: "POD_NAME",
3507
+ valueFrom: { fieldRef: { fieldPath: "metadata.name" } }
3508
+ },
3509
+ {
3510
+ name: "NODE_NAME",
3511
+ valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } }
3512
+ }
3513
+ ]
3514
+ }]
3515
+ }
3516
+ }
3517
+ }
3518
+ }];
3519
+ };
3520
+ /** This function gives the pods of the brokers, in the sequence of their names. */
3521
+ const brokersOf = async (cluster) => {
3522
+ return JSON.parse(await cluster.kubectl([
3523
+ "-n",
3524
+ PULSAR_NAMESPACE,
3525
+ "get",
3526
+ "pods",
3527
+ "-l",
3528
+ "component=broker",
3529
+ "-o",
3530
+ "json"
3531
+ ])).items.filter((pod) => pod.metadata.deletionTimestamp === void 0).map((pod) => ({
3532
+ pod: pod.metadata.name,
3533
+ node: pod.spec.nodeName ?? "",
3534
+ ip: pod.status?.podIP ?? "",
3535
+ ready: (pod.status?.containerStatuses ?? []).length > 0 && pod.status.containerStatuses.every((status) => status.ready)
3536
+ })).sort((a, b) => a.pod.localeCompare(b.pod));
3537
+ };
3538
+ /** This function gives the broker that a host of a lookup names. Its pod is the first label of the host, or its address is the host. */
3539
+ const brokerOfHost = (brokers, host) => brokers.find((broker) => host.split(".")[0] === broker.pod || host === broker.ip);
3540
+ /**
3541
+ * This function tells why the producers are not at rest, or gives the host of the owner of the
3542
+ * topic. At rest, each of `clients` producers had an answer in the last second and no failure in
3543
+ * the last 2 s. The last lookup of each one is in the last 2 s, and each names the same owner.
3544
+ */
3545
+ const ownerAtRest = (state, clients) => {
3546
+ if (state.sends.size !== clients) return `${state.sends.size} producers send, not ${clients}`;
3547
+ for (const [pod, send] of state.sends) {
3548
+ if (state.last - send.ok > 1e3) return `the producer ${pod} has no answer in the last second`;
3549
+ if (state.last - send.failed < 2e3) return `the producer ${pod} failed in the last 2 s`;
3550
+ }
3551
+ const owners = /* @__PURE__ */ new Set();
3552
+ for (const pod of state.sends.keys()) {
3553
+ const seen = state.owners.get(pod);
3554
+ if (seen === void 0 || seen.owner === null || state.last - seen.t > 2e3) return `the producer ${pod} has no lookup with an owner in the last 2 s`;
3555
+ owners.add(seen.owner);
3556
+ }
3557
+ if (owners.size !== 1) return `the lookups name ${[...owners].join(" and ")}`;
3558
+ return { owner: [...owners][0] };
3559
+ };
3560
+ const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3561
+ /**
3562
+ * This function waits until each broker is ready and the producers are at rest (`ownerAtRest`). It
3563
+ * gives the broker that owns the topic and the host that the lookups name, or why it could not in `within` ms.
3564
+ */
3565
+ const holdPulsar = async (cluster, state, clients, within = 3e5) => {
3566
+ const end = Date.now() + within;
3567
+ let why = "the hold did not start";
3568
+ do {
3569
+ const brokers = await brokersOf(cluster);
3570
+ const busy = brokers.find((broker) => !broker.ready);
3571
+ const rest = ownerAtRest(state(), clients);
3572
+ if (brokers.length !== 2) why = `${brokers.length} brokers, not 2`;
3573
+ else if (busy !== void 0) why = `the broker ${busy.pod} is not ready`;
3574
+ else if (typeof rest === "string") why = rest;
3575
+ else {
3576
+ const broker = brokerOfHost(brokers, rest.owner);
3577
+ if (broker !== void 0) return {
3578
+ broker,
3579
+ host: rest.owner
3580
+ };
3581
+ why = `the owner ${rest.owner} is no broker`;
3582
+ }
3583
+ await sleep$1(500);
3584
+ } while (Date.now() < end);
3585
+ return why;
3586
+ };
3587
+ /**
3588
+ * This function installs Pulsar in a cluster, with its image on each node. A failed install gives
3589
+ * the pods of Pulsar and the tail of their logs.
3590
+ */
3591
+ const installPulsar = async (cluster, image) => {
3592
+ try {
3593
+ await helmInstall(cluster, "pulsar", PULSAR_CHART, PULSAR_NAMESPACE, pulsarValues(image), { createNamespace: true });
3594
+ } catch (error) {
3595
+ const state = async (args) => (await cluster.kubectl([
3596
+ "-n",
3597
+ PULSAR_NAMESPACE,
3598
+ ...args
3599
+ ]).catch((why) => String(why))).slice(-4e3);
3600
+ const pods = await state([
3601
+ "get",
3602
+ "pods",
3603
+ "-o",
3604
+ "wide"
3605
+ ]);
3606
+ const logs = await state([
3607
+ "logs",
3608
+ "-l",
3609
+ "app=pulsar",
3610
+ "--tail=30",
3611
+ "--prefix",
3612
+ "--all-containers"
3613
+ ]);
3614
+ throw new Error(`the install of Pulsar failed: ${String(error)}\nthe pods of Pulsar:\n${pods}\nthe logs:\n${logs}`);
3615
+ }
3616
+ };
3617
+ /** These words mark the lines of a broker that the record keeps: the sessions, the leader, the ownership and the topics. */
3618
+ const KEPT = /[Ss]ession|[Ee]xpired|[Ll]eader|[Oo]wnership|[Bb]undle|Loaded|[Ll]oading topic|Created new producer|Disconnect|Closed connection|znode|ManagedLedger.*(open|recover)/;
3619
+ /**
3620
+ * This function gives the record of Pulsar after a run. It has the brokers, and the kept lines of
3621
+ * each broker and of its last container, with the times of the kubelet.
3622
+ */
3623
+ const pulsarRecord = async (cluster) => {
3624
+ const brokers = await brokersOf(cluster);
3625
+ const logs = {};
3626
+ for (const broker of brokers) for (const previous of [false, true]) {
3627
+ const text = await cluster.kubectl([
3628
+ "-n",
3629
+ PULSAR_NAMESPACE,
3630
+ "logs",
3631
+ broker.pod,
3632
+ "-c",
3633
+ "pulsar-broker",
3634
+ "--since=300s",
3635
+ "--tail=6000",
3636
+ "--timestamps",
3637
+ ...previous ? ["--previous"] : []
3638
+ ]).catch((error) => String(error));
3639
+ logs[`${broker.pod}${previous ? ", the last container" : ""}`] = text.split("\n").filter((line) => KEPT.test(line)).slice(-80).join("\n").slice(-8e3);
3640
+ }
3641
+ return {
3642
+ brokers,
3643
+ logs
3644
+ };
3645
+ };
3646
+ //#endregion
3044
3647
  //#region src/lds.ts
3045
3648
  /**
3046
3649
  * This filter of RE2 selects the two counters of the listener manager of Envoy that an update of LDS
@@ -4092,6 +4695,48 @@ const learnWindow = (declaration, readiness, propagation, workload) => {
4092
4695
  };
4093
4696
  };
4094
4697
  //#endregion
4698
+ //#region src/quiet.ts
4699
+ /**
4700
+ * These are the terms of the lab. The flat share is the share of SYS-42. The other terms come from the
4701
+ * calibration of SYS-95, over the 9 records of SYS-57, with no foreign container in them:
4702
+ *
4703
+ * - The margin is the 99th percentile of the fall from the settled median of an environment: 4.88
4704
+ * points of 93 samples, rounded up.
4705
+ * - The floor is the lowest settled median of an environment, 48 %, minus the margin.
4706
+ * - The time to settle holds the slowest environment. Its conntrack table was at its plateau at 105 s.
4707
+ */
4708
+ const QUIET_GATE = {
4709
+ flat: .6,
4710
+ margin: .05,
4711
+ floor: .43,
4712
+ settle: 12e4,
4713
+ samples: 3
4714
+ };
4715
+ /**
4716
+ * This function gives the idle share that a run needs. With no baseline, it is the floor: a warm-up
4717
+ * counts for nothing, and its table is not full yet. A cell that is at rest at the flat share or more
4718
+ * keeps the flat share.
4719
+ */
4720
+ const neededIdle = (gate, baseline) => baseline === null ? gate.floor : Math.min(gate.flat, Math.max(gate.floor, baseline - gate.margin));
4721
+ /** This function gives the reason of a sample that the gate refuses. The site reads it back with `NOT_QUIET`. */
4722
+ const quietWhy = (idle, needed, foreign) => `${Math.round(idle * 100)}% idle, the gate needs ${Math.round(needed * 100)}%, other lab containers: ${foreign.join("; ") || "none"}`;
4723
+ /**
4724
+ * This form reads the void of a run that the gate refuses. Its groups are the wait in seconds, the idle share, the share
4725
+ * of the gate and the other lab containers. A record from before SYS-95 has no share of the gate.
4726
+ */
4727
+ const NOT_QUIET = /^the VM was not quiet in (\d+) s: (\d+)% idle(?:, the gate needs (\d+)%)?, other lab containers: (.*)$/;
4728
+ /**
4729
+ * This function gives the next baseline of a cell from the samples of one environment. Samples with
4730
+ * a foreign container do not count. The highest median holds, because a foreign load only lowers
4731
+ * the idle share. Thus a load in one environment does not move the gate of the other one.
4732
+ */
4733
+ const baselineOf = (previous, samples) => {
4734
+ const clean = samples.filter((sample) => sample.foreign.length === 0).map((sample) => sample.idle);
4735
+ if (clean.length === 0) return previous;
4736
+ const middle = median(clean);
4737
+ return previous === null ? middle : Math.max(previous, middle);
4738
+ };
4739
+ //#endregion
4095
4740
  //#region src/rules.ts
4096
4741
  const seconds$1 = (amount) => valueIn(amount, "s");
4097
4742
  const s = (value) => ({
@@ -4187,9 +4832,11 @@ const hasRedis = (cell) => cell.declaration !== null && cell.declaration.redis !
4187
4832
  * This function gives the key of the cluster of a cell: its kube-proxy mode, its node count, and the
4188
4833
  * flavor of its mesh. A zone loss has clusters of its own, with a zone on each node.
4189
4834
  */
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");
4191
- /** This function tells if a cell needs a cluster with zones: a zone loss. */
4192
- const zoned = (cell) => cell.trigger.kind === "zone-loss";
4835
+ 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" : cell.vip.memberlist === void 0 ? "-vip-mlb" : `-mlb${valueIn(cell.vip.memberlist.probeInterval, "ms")}x${cell.vip.memberlist.suspicionMult}`) + (cell.pulsar === void 0 ? "" : "-pul");
4836
+ /** This function gives the name of the cluster of a cell in an environment. Kind permits 32 characters at most: lowercase letters, digits and dashes. */
4837
+ const clusterNameOf = (cell, env) => `lab-${clusterKey(cell)}-${env}`;
4838
+ /** This function tells if a cell needs a cluster with zones. A zone loss needs one, and so does a cell with settings of memberlist, that shares the cluster of its zone loss. */
4839
+ const zoned = (cell) => cell.trigger.kind === "zone-loss" || cell.vip?.memberlist !== void 0;
4193
4840
  /**
4194
4841
  * This tracker keeps the latest state of the cell from the lines: each pod, each slice and each
4195
4842
  * client. It also has, for each node, the backends of its rules and the hosts of its client sidecar.
@@ -4202,7 +4849,13 @@ var Tracker = class {
4202
4849
  clients = /* @__PURE__ */ new Map();
4203
4850
  /** This map is the backend of the last answer of each client, by client. */
4204
4851
  answers = /* @__PURE__ */ new Map();
4852
+ /** This map is the last send of each producer of a broker that got an answer, and the last that failed. */
4853
+ sends = /* @__PURE__ */ new Map();
4854
+ /** This map is the last lookup of the owner of the topic of each producer, and the host that it names. */
4855
+ owners = /* @__PURE__ */ new Map();
4205
4856
  last = 0;
4857
+ /** This time is the last line of a producer. */
4858
+ lastSend = 0;
4206
4859
  see = (line) => {
4207
4860
  if (typeof line.t === "number" && line.t > this.last && line.ev === "req") this.last = line.t;
4208
4861
  if (line.ev === "pod") {
@@ -4225,6 +4878,23 @@ var Tracker = class {
4225
4878
  else client.failed = Math.max(client.failed, request.t);
4226
4879
  this.clients.set(request.p, client);
4227
4880
  if (request.o === "ok" && request.b !== void 0) this.answers.set(request.p, request.b);
4881
+ } else if (line.ev === "send") {
4882
+ const pod = String(line["p"]);
4883
+ const send = this.sends.get(pod) ?? {
4884
+ ok: 0,
4885
+ failed: 0
4886
+ };
4887
+ if (line["o"] === "ok") send.ok = Math.max(send.ok, line.t);
4888
+ else send.failed = Math.max(send.failed, line.t);
4889
+ this.sends.set(pod, send);
4890
+ this.lastSend = Math.max(this.lastSend, line.t);
4891
+ } else if (line.ev === "lookup") {
4892
+ const pod = String(line["p"]);
4893
+ const owner = line["o"] === "ok" && typeof line["owner"] === "string" ? line["owner"] : null;
4894
+ if (line.t >= (this.owners.get(pod)?.t ?? 0)) this.owners.set(pod, {
4895
+ t: line.t,
4896
+ owner
4897
+ });
4228
4898
  }
4229
4899
  };
4230
4900
  servers() {
@@ -4335,21 +5005,57 @@ const sample = async (ctx, ours) => {
4335
5005
  * This is below the share of 60%. That wait had no bound, and it took approximately 30 min.
4336
5006
  */
4337
5007
  const QUIET_WITHIN = 3e5;
5008
+ /** This function gives the names of this lab at a sample: the prober and the watcher start again with new names. */
5009
+ const oursAt = (ctx, ours) => /* @__PURE__ */ new Set([
5010
+ ...ours,
5011
+ ...ctx.lab.own(),
5012
+ ctx.loader.getName().replace(/^\//, "")
5013
+ ]);
5014
+ /**
5015
+ * This function takes the baseline of the cell in this environment, once. The first run after the
5016
+ * warm-up calls it. It waits until the cell has its age. The conntrack table fills for the life of
5017
+ * its entries, and the read of the table costs more until then.
5018
+ */
5019
+ const takeBaseline = async (ctx, ours) => {
5020
+ if (ctx.settled) return;
5021
+ await sleep(Math.max(0, ctx.deployed + ctx.gate.settle - Date.now()));
5022
+ const samples = [];
5023
+ for (let index = 0; index < ctx.gate.samples; index += 1) samples.push(await sample(ctx, oursAt(ctx, ours)));
5024
+ ctx.loads.push(...samples);
5025
+ const baseline = baselineOf(ctx.baselines.get(ctx.cell.name) ?? null, samples);
5026
+ if (baseline !== null) ctx.baselines.set(ctx.cell.name, baseline);
5027
+ ctx.settled = true;
5028
+ ctx.log(` baseline: ${samples.map((each) => `${Math.round(each.idle * 100)}%`).join(", ")} idle, the gate needs ${Math.round(neededIdle(ctx.gate, baseline) * 100)}%`);
5029
+ };
5030
+ /** This function starts the planned load of a cell for the longest wait of the gate. The load runs in the loader, out of the cluster. */
5031
+ const startPlannedLoad = async (ctx) => {
5032
+ const cores = ctx.cell.plannedLoad;
5033
+ if (cores === void 0) return;
5034
+ const spec = JSON.stringify({
5035
+ cores,
5036
+ ms: 32e4
5037
+ });
5038
+ await ctx.loader.exec([
5039
+ "sh",
5040
+ "-c",
5041
+ `nohup node agent.mjs burn '${spec}' >/dev/null 2>&1 &`
5042
+ ]);
5043
+ };
4338
5044
  /**
4339
5045
  * This function waits until the VM is quiet: no lab container of another agent, and an idle share of
4340
- * the CPUs. It gives null, or the reason when the VM is not quiet in `within` ms.
5046
+ * the CPUs (`neededIdle`). It gives null, or the reason when the VM is not quiet in `within` ms.
4341
5047
  */
4342
5048
  const quiet = async (ctx, ours, within = QUIET_WITHIN) => {
4343
5049
  const end = Date.now() + within;
5050
+ const needed = neededIdle(ctx.gate, ctx.baselines.get(ctx.cell.name) ?? null);
4344
5051
  for (;;) {
4345
- const load = await sample(ctx, /* @__PURE__ */ new Set([
4346
- ...ours,
4347
- ...ctx.lab.own(),
4348
- ctx.loader.getName().replace(/^\//, "")
4349
- ]));
4350
- ctx.loads.push(load);
4351
- if (load.foreign.length === 0 && load.idle >= ctx.idle) return null;
4352
- const why = `${Math.round(load.idle * 100)}% idle, other lab containers: ${load.foreign.join("; ") || "none"}`;
5052
+ const load = await sample(ctx, oursAt(ctx, ours));
5053
+ ctx.loads.push({
5054
+ ...load,
5055
+ needed
5056
+ });
5057
+ if (load.foreign.length === 0 && load.idle >= needed) return null;
5058
+ const why = quietWhy(load.idle, needed, load.foreign);
4353
5059
  if (Date.now() >= end) return `the VM was not quiet in ${within / 1e3} s: ${why}`;
4354
5060
  ctx.log(` waiting: ${why}`);
4355
5061
  await sleep(3e4);
@@ -4405,6 +5111,18 @@ const deploy = async (lab, cluster, cell, namespace, images, vip) => {
4405
5111
  `daemonset/${CLIENT}`,
4406
5112
  "--timeout=300s"
4407
5113
  ]);
5114
+ if (cell.pulsar !== void 0) {
5115
+ if (images.pulsar === void 0) throw new Error(`${cell.name}: the cluster has no image of Pulsar`);
5116
+ await cluster.apply(pulsarObjects(cell, namespace, images.pulsar));
5117
+ await cluster.kubectl([
5118
+ "-n",
5119
+ namespace,
5120
+ "rollout",
5121
+ "status",
5122
+ `deployment/${PRODUCER}`,
5123
+ "--timeout=300s"
5124
+ ]);
5125
+ }
4408
5126
  if (cell.vip !== void 0) {
4409
5127
  if (vip === null) throw new Error(`${cell.name}: the cluster has no VIP`);
4410
5128
  const served = await vipServed(cluster, namespace, "lab", vip);
@@ -4488,7 +5206,7 @@ const probeThePods = async (ctx) => {
4488
5206
  if (key === ctx.probed) return;
4489
5207
  const netns = [];
4490
5208
  for (const pod of pods) {
4491
- const container = pod.labels["app"] === "lab" ? "app" : pod.labels["app"] === "client" ? CLIENT : REDIS;
5209
+ const container = pod.labels["app"] === "lab" ? "app" : pod.labels["app"] === "client" ? CLIENT : pod.labels["app"] === "producer" ? PRODUCER : REDIS;
4492
5210
  const pid = await ctx.lab.pidOf(pod.node, ctx.namespace, pod.name, container);
4493
5211
  netns.push(await ctx.lab.netnsOf(pod.node, pid));
4494
5212
  }
@@ -4645,13 +5363,19 @@ const namespacesOf = async (ctx, target, replicas) => {
4645
5363
  */
4646
5364
  const middleboxServers = async (ctx, idle, port, replicas) => {
4647
5365
  const peers = [...ctx.tracker.pods.values()].filter((pod) => pod.labels["app"] === "client" && !pod.del && pod.ip !== null).map((pod) => pod.ip).sort();
4648
- const commands = middleboxCommands(peers, idle, port);
5366
+ const commands = idle === null ? null : middleboxCommands(peers, idle, port);
4649
5367
  const t0 = await ctx.lab.now();
4650
5368
  const each = await namespacesOf(ctx, "servers", replicas);
4651
5369
  await Promise.all(each.map(async ({ node, pid }) => {
4652
- if (idle === null) await ctx.lab.tables(node, pid, "filter");
4653
- await ctx.lab.netns(node, pid, commands);
4654
- if (idle !== null) await ctx.lab.tables(node, pid, "filter");
5370
+ if (commands !== null) {
5371
+ await ctx.lab.netns(node, pid, commands);
5372
+ await ctx.lab.tables(node, pid, "filter");
5373
+ return;
5374
+ }
5375
+ const removal = middleboxRemoval(await ctx.lab.tables(node, pid, "filter"));
5376
+ if (removal.length > 0) await ctx.lab.netns(node, pid, removal);
5377
+ const left = middleboxRemoval(await ctx.lab.tables(node, pid, "filter"));
5378
+ if (left.length > 0) throw new Error(`the middlebox on ${node} (PID ${pid}) has ${left.length} parts after its removal`);
4655
5379
  }));
4656
5380
  return {
4657
5381
  t0,
@@ -4671,6 +5395,35 @@ const doStep = async (ctx, step, replicas, background) => {
4671
5395
  pid: await lab.pidOf(target.node, namespace, target.pod, "app"),
4672
5396
  pod: null
4673
5397
  };
5398
+ if (which === "speaker") {
5399
+ const owner = lostNodeOf(cluster);
5400
+ const pod = JSON.parse(await cluster.kubectl([
5401
+ "-n",
5402
+ METALLB_NAMESPACE,
5403
+ "get",
5404
+ "pods",
5405
+ "-l",
5406
+ "app.kubernetes.io/component=speaker",
5407
+ "--field-selector",
5408
+ `spec.nodeName=${owner}`,
5409
+ "-o",
5410
+ "json"
5411
+ ])).items[0];
5412
+ if (pod === void 0) throw new Error(`no speaker of MetalLB is on ${owner}`);
5413
+ return {
5414
+ node: owner,
5415
+ pid: await lab.pidOf(owner, METALLB_NAMESPACE, pod.metadata.name, "speaker"),
5416
+ pod: null
5417
+ };
5418
+ }
5419
+ if (which === "broker") {
5420
+ if (ctx.broker === null) throw new Error(`${ctx.cell.name}: the run has no broker that owns the topic`);
5421
+ return {
5422
+ node: ctx.broker.node,
5423
+ pid: await lab.pidOf(ctx.broker.node, PULSAR_NAMESPACE, ctx.broker.pod, "pulsar-broker"),
5424
+ pod: null
5425
+ };
5426
+ }
4674
5427
  if (which === "redis") {
4675
5428
  const redis = [...ctx.tracker.pods.values()].find((pod) => pod.labels["app"] === "redis" && !pod.del);
4676
5429
  return {
@@ -4719,7 +5472,8 @@ const doStep = async (ctx, step, replicas, background) => {
4719
5472
  }
4720
5473
  case "signal": {
4721
5474
  if (step.target === "clients") return signalClients(ctx, step.signal);
4722
- const at = await where(step.target);
5475
+ const at = (step.target === "speaker" && step.signal === "SIGCONT" ? ctx.speaker : void 0) ?? await where(step.target);
5476
+ if (step.target === "speaker") ctx.speaker = step.signal === "SIGSTOP" ? at : void 0;
4723
5477
  return lab.act(at.node, {
4724
5478
  op: "signal",
4725
5479
  pid: at.pid,
@@ -4878,6 +5632,19 @@ const recordVip = async (ctx) => {
4878
5632
  macs
4879
5633
  });
4880
5634
  };
5635
+ /** This function records the brokers after a run, with the kept lines of each broker. It stamps the wall clock and the monotonic clock. */
5636
+ const recordPulsar = async (ctx) => {
5637
+ const wall = Date.now();
5638
+ const t = Number(process.hrtime.bigint()) / 1e6;
5639
+ ctx.lab.record({
5640
+ ev: "pulsar-record",
5641
+ t,
5642
+ wall,
5643
+ version: PULSAR_VERSION,
5644
+ owner: ctx.broker?.pod ?? null,
5645
+ ...await pulsarRecord(ctx.cluster)
5646
+ });
5647
+ };
4881
5648
  /** This function puts the replicas of a node first, and keeps the sequence of the others. */
4882
5649
  const lostFirst = (replicas, node) => [...replicas.filter((replica) => replica.node === node), ...replicas.filter((replica) => replica.node !== node)];
4883
5650
  /** This function tells if a run is over: the trigger is done, and the cell is at rest again. */
@@ -4931,6 +5698,10 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
4931
5698
  if (atRest !== null) return empty(`the cell was not at rest: ${atRest}`, await lab.now());
4932
5699
  await probeThePods(ctx);
4933
5700
  await pollListeners(ctx);
5701
+ if (!warmup) {
5702
+ await takeBaseline(ctx, ours);
5703
+ await startPlannedLoad(ctx);
5704
+ }
4934
5705
  const loud = await quiet(ctx, ours);
4935
5706
  if (loud !== null) {
4936
5707
  ctx.log(` ${loud}`);
@@ -4957,10 +5728,25 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
4957
5728
  await sleep(valueIn(announcer.settle, "ms") + valueIn(cell.workload.timeout, "ms") + 1e3);
4958
5729
  }
4959
5730
  if (await steady(ctx, 3e5) !== null) return empty("the cell was not at rest after the wait for a quiet VM", await lab.now());
5731
+ ctx.broker = null;
5732
+ if (cell.pulsar !== void 0) {
5733
+ const tracker = ctx.tracker;
5734
+ const held = await holdPulsar(cluster, () => ({
5735
+ owners: tracker.owners,
5736
+ sends: tracker.sends,
5737
+ last: tracker.lastSend
5738
+ }), cell.pulsar.clients);
5739
+ if (typeof held === "string") return empty(`the producers were not at rest: ${held}`, await lab.now());
5740
+ ctx.broker = {
5741
+ pod: held.broker.pod,
5742
+ node: held.broker.node,
5743
+ host: held.host
5744
+ };
5745
+ }
4960
5746
  const replicas = zoneLoss ? lostFirst(replicasOf(ctx), lostNodeOf(cluster)) : replicasOf(ctx);
4961
5747
  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());
4962
5748
  const restarts = new Map(ctx.tracker.servers().map((pod) => [pod.name, pod.cs.reduce((sum, status) => sum + status.rc, 0)]));
4963
- const target = cell.vip !== void 0 || hasRedis(cell) && [
5749
+ const target = cell.vip !== void 0 || cell.pulsar !== void 0 || hasRedis(cell) && [
4964
5750
  "rst",
4965
5751
  "blackhole",
4966
5752
  "freeze-process",
@@ -4968,7 +5754,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
4968
5754
  "rollout",
4969
5755
  "latency"
4970
5756
  ].includes(cell.trigger.kind) ? null : replicas[0];
4971
- const paused = cell.template === "T07" && cell.trigger.kind === "freeze-process" ? cluster.nodes[1] ?? cluster.nodes[0] : zoneLoss ? lostNodeOf(cluster) : null;
5757
+ const paused = cell.template === "T07" && cell.trigger.kind === "freeze-process" && cell.vip === void 0 ? cluster.nodes[1] ?? cluster.nodes[0] : zoneLoss ? lostNodeOf(cluster) : null;
4972
5758
  const cleared = await lab.forgetHandshakes(ctx.clusterIp, 80);
4973
5759
  lab.take();
4974
5760
  for (const key of Object.keys(lab.dropped)) delete lab.dropped[key];
@@ -5061,6 +5847,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
5061
5847
  const end = await lab.now();
5062
5848
  const timedOut = Date.now() >= limit;
5063
5849
  if (cell.vip !== void 0) await recordVip(ctx).catch((error) => notes.push(`the record of the VIP failed: ${String(error)}`));
5850
+ if (cell.pulsar !== void 0) await recordPulsar(ctx).catch((error) => notes.push(`the record of Pulsar failed: ${String(error)}`));
5064
5851
  for (const step of plan.restore) try {
5065
5852
  await doStep(ctx, step, replicas, background);
5066
5853
  } catch (error) {
@@ -5103,6 +5890,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
5103
5890
  },
5104
5891
  target: stopped,
5105
5892
  paused,
5893
+ ...ctx.broker === null ? {} : { broker: ctx.broker.host },
5106
5894
  replicas,
5107
5895
  void: null,
5108
5896
  lines,
@@ -5186,19 +5974,22 @@ const runKindCells = async (cells, options) => {
5186
5974
  for (const [name, ref] of Object.entries(announcer.images)) pinned[name] = await localPin(ref);
5187
5975
  announcerImages.set(announcer.name, pinned);
5188
5976
  }
5977
+ const pulsarImage = cells.some((cell) => cell.pulsar !== void 0) ? await localPin(PULSAR_IMAGE) : void 0;
5189
5978
  const images = {
5190
5979
  lab: image,
5191
5980
  redis: redisImage,
5192
5981
  grpc: grpcImage,
5193
- mesh: meshImages
5982
+ mesh: meshImages,
5983
+ pulsar: pulsarImage
5194
5984
  };
5195
5985
  const groups = /* @__PURE__ */ new Map();
5196
5986
  for (const cell of cells) groups.set(clusterKey(cell), [...groups.get(clusterKey(cell)) ?? [], cell]);
5197
5987
  const results = [];
5198
- for (const [key, group] of groups) {
5988
+ const baselines = /* @__PURE__ */ new Map();
5989
+ for (const group of groups.values()) {
5199
5990
  const partial = /* @__PURE__ */ new Map();
5200
5991
  for (let env = 0; env < environments; env += 1) {
5201
- const s = scenario(`lab-${key}-${env}`, group[0].mode, group[0].workers, void 0, zoned(group[0]));
5992
+ const s = scenario(clusterNameOf(group[0], env), group[0].mode, group[0].workers, void 0, zoned(group[0]));
5202
5993
  const dir = join(options.work ?? join(tmpdir(), "systems-kind-work"), s.name);
5203
5994
  const mesh = group[0].mesh;
5204
5995
  options.log(`cluster ${s.name}: ${s.dataplane}, ${s.workers} workers${mesh === void 0 ? "" : `, Istio ${mesh.flavor}`}`);
@@ -5239,9 +6030,16 @@ const runKindCells = async (cells, options) => {
5239
6030
  for (const each of Object.values(pinned)) await cluster.load(each);
5240
6031
  const began = Date.now();
5241
6032
  vip = announcer.vipOf((await vipNetworkOf(cluster.network)).subnet, lostNodeOf(cluster), workersOf(cluster));
5242
- await announcer.install(cluster, pinned, lostNodeOf(cluster), workersOf(cluster), vip);
6033
+ await announcer.install(cluster, pinned, lostNodeOf(cluster), workersOf(cluster), vip, group[0].vip);
5243
6034
  options.log(` ${announcer.name} installed in ${Math.round((Date.now() - began) / 100) / 10} s, the VIP ${vip}`);
5244
6035
  }
6036
+ if (group[0].pulsar !== void 0) {
6037
+ if (pulsarImage === void 0) throw new Error(`${group[0].name}: the run has no image of Pulsar`);
6038
+ await cluster.load(pulsarImage);
6039
+ const began = Date.now();
6040
+ await installPulsar(cluster, pulsarImage);
6041
+ options.log(` Pulsar ${PULSAR_VERSION} installed in ${Math.round((Date.now() - began) / 100) / 10} s`);
6042
+ }
5245
6043
  lab = await openKindLab(cluster, image);
5246
6044
  const { GenericContainer } = await import("testcontainers");
5247
6045
  const Shared = class extends GenericContainer {
@@ -5274,13 +6072,20 @@ const runKindCells = async (cells, options) => {
5274
6072
  vip: cell.vip === void 0 ? null : vip,
5275
6073
  loads,
5276
6074
  loader,
5277
- idle: options.idle ?? .6,
6075
+ gate: {
6076
+ ...QUIET_GATE,
6077
+ flat: options.idle ?? QUIET_GATE.flat
6078
+ },
6079
+ baselines,
6080
+ deployed: Date.now(),
6081
+ settled: false,
5278
6082
  log: options.log,
5279
6083
  version: 1,
5280
6084
  probed: "",
5281
6085
  listened: "",
5282
6086
  expected: cell.pod.replicas,
5283
- pids: /* @__PURE__ */ new Map()
6087
+ pids: /* @__PURE__ */ new Map(),
6088
+ broker: null
5284
6089
  };
5285
6090
  const share = Math.ceil(cell.runs / environments) - (env < cell.runs % environments || cell.runs % environments === 0 ? 0 : 1);
5286
6091
  const runs = [await runOnce(ctx, env, -1, true, ours)];
@@ -5302,7 +6107,8 @@ const runKindCells = async (cells, options) => {
5302
6107
  ...stackOf(cell) === "grpc" ? { grpc: grpcImage } : {},
5303
6108
  ...cell.mesh === void 0 ? {} : Object.fromEntries(MESH_IMAGES[cell.mesh.flavor].map((name) => [`istio-${name}`, ISTIO_IMAGES[name]])),
5304
6109
  ...s.cni === "cilium" ? Object.fromEntries(Object.entries(CILIUM_IMAGES).map(([name, ref]) => [`cilium-${name}`, ref])) : {},
5305
- ...cell.vip === void 0 ? {} : announcerOf(cell).images
6110
+ ...cell.vip === void 0 ? {} : announcerOf(cell).images,
6111
+ ...cell.pulsar === void 0 ? {} : { pulsar: PULSAR_IMAGE }
5306
6112
  }
5307
6113
  });
5308
6114
  entry.loads.push(...loads);
@@ -5370,14 +6176,17 @@ const estimate = (cell, environments = 2) => {
5370
6176
  "ztunnel-restart": 40,
5371
6177
  "certificate-expiry": hold + 20,
5372
6178
  "cannot-serve": hold + 20,
5373
- "zone-loss": hold + 45
6179
+ "zone-loss": hold + 45,
6180
+ ...cell.vip === void 0 ? {} : { "freeze-process": hold + 20 }
5374
6181
  };
5375
6182
  const after = observationAfter(cell);
5376
6183
  const observed = after === null ? 0 : hold + valueIn(after, "s");
5377
6184
  const closes = cell.prediction.kind === "ends" && cell.prediction.band !== null ? valueIn(cell.prediction.band.hi, "s") + 3 : 0;
5378
6185
  const quiet = cell.trigger.quiet === true ? hold + 10 : 0;
5379
- 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")));
5380
- return ((cell.mesh === void 0 ? 60 : 180) + (cell.vip === void 0 ? 0 : 60)) * environments + (cell.runs + environments) * each;
6186
+ const move = cell.vip === void 0 ? 0 : 30;
6187
+ const broker = cell.pulsar === void 0 ? 0 : 150;
6188
+ const each = 25 + move + broker + Math.max(observed, closes, quiet, trigger[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
6189
+ return ((cell.mesh === void 0 ? 60 : 180) + (cell.vip === void 0 ? 0 : 60) + (cell.pulsar === void 0 ? 0 : 240)) * environments + (cell.runs + environments) * each;
5381
6190
  };
5382
6191
  /**
5383
6192
  * This function gives an estimate of the time of a cell: its setup, then each run and each
@@ -5408,6 +6217,6 @@ const shardOf = (cells, index, count) => {
5408
6217
  return out;
5409
6218
  };
5410
6219
  //#endregion
5411
- 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 };
6220
+ 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, MEMBERLIST_FILE, 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, NOT_QUIET, O04, O09, O10, O16, OUTSIDE, PING, POD_LINK, POLICY, PORT, PRODUCER, PRODUCER_BACKOFF, PRODUCER_SCRIPT, PULSAR_CHART, PULSAR_IMAGE, PULSAR_LOOKUP, PULSAR_NAMESPACE, PULSAR_SERVICE, PULSAR_TOPIC, PULSAR_VERSION, QUIET_GATE, QUIET_LEAD, REDIS, REDIS_IMAGE, RELATIVE, ROUTE, RULES_EVERY, SERVER, SESSION, SPREAD_LEAD, STAGES, SYSCTL, TCP_STATE, TICK, VIP_ANNOTATION, VIP_LABEL, WATCHDOG, ZONE_KEPT, ZONE_LABEL, ZONE_LOST, ZTUNNEL_PODS, announcementsOf, announcerOf, arpMacOf, backendsAt, backendsOf, backlogEnd, bandOf, baselineOf, brokerOfHost, brokersOf, byClients, byNode, cacheFolder, calibrationOf, capOf, cellsTable, certificateArgs, checkRun, cidrOverlap, cidrRange, ciliumBackendsOf, ciliumMode, ciliumValues, clientObjects, clusterFilter, clusterNameOf, compose, createCluster, cyclesOf, deletes, deletionOf, deletionWindow, deletionsOf, describeCell, dnsCommands, drainBand, dropOf, emptyOf, ensureChart, ensurePinnedChart, ensureTool, estimatedTime, factsOf, failuresOf, faultSpan, followsZtunnel, goDuration, helmInstall, holdMetallb, holdPulsar, holdVip, hostsOf, installCilium, installKubeVip, installMesh, installMetallb, installPulsar, intakeAfter, intakeOf, isKubeProxyMode, isTunnelState, istioOperator, judge, judgeKind, judgeReconnects, keptKernel, kernelCounts, kindCellRecordSchema, kindCellRecordV1Schema, kindConfig, kubeProxyPatch, kubeVipObjects, kubeVipPods, kubeVipReady, kubeletBand, labImage, labSource, latchBand, latestReturn, leafCertificates, learnWindow, lintCell, listenerCountsOf, localPin, macsAt, medianInterval, medianText, memberlistFileOf, memberlistTermsOf, meshKey, metallbObjects, metallbRecord, metallbValues, metallbVipOf, middle, middleboxCommands, middleboxRemoval, namesService, namespaceObject, namespaceOf, neededIdle, nodeLossBand, nodeNames, nodeReturnBand, observationAfter, observationEnd, openKindLab, outboundCluster, outcomesOf, overshootBand, ownerAtRest, ownerOrder, pausedWindow, periodOf, placementOf, planOf, platformKey, podsAt, podsOf, policyObject, pollsListeners, predictionText, probeOf, propagationOf, propagationSource, pulsarObjects, pulsarRecord, pulsarValues, quantileOf, quietWhy, 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, triesOf, tunnelsOf, unitOf, updatesOf, upgrade, upgradeCell, verdictsOf, vipLeaseOf, vipNetworkOf, vipOf, vipServed, vipService, writeResult, zoneOf, ztunnelCertificates, ztunnelEnvArgs, ztunnelPodsOf, ztunnelReplaced };
5412
6221
 
5413
6222
  //# sourceMappingURL=index.js.map