@mark1russell7/kind 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as HEALTH_PORT, A as byClients, B as podsOf, C as reusesReset, D as middle, E as medianInterval, F as failuresOf, G as signalOf, H as requestsOf, I as intakeOf, J as sliceEmptyOf, K as signalsOf, L as kernelCounts, M as deletionOf, N as dropOf, O as quantileOf, P as emptyOf, Q as CLIENT, R as outcomesOf, S as referenceOf, T as RELATIVE, U as restartsAt, V as recordsOf, W as rulesOf, X as stoppingOf, Y as slicesOf, Z as transitionOf, _ as checkRun, a as calibrationOf, at as seconds, b as judgeKind, c as predictionText, d as FORMAT_V1, et as LOG_LABEL, f as judge, g as LAST_SLACK, h as KIND_NOISE, i as readRecord, it as bandOf, j as byNode, k as backendsAt, l as timeSpread, m as upgradeCell, n as kindCellRecordSchema, nt as REDIS, o as cellsTable, ot as secondsOf, p as upgrade, q as sliceDropOf, r as kindCellRecordV1Schema, rt as SERVER, s as medianText, st as showBand, t as STAGES, tt as PORT, u as FORMAT, v as deletionsOf, w as unitOf, x as propagationOf, y as factsOf, z as podsAt } from "./judge-reWPMVr4.js";
1
+ import { $ as stoppingOf, A as middle, B as kernelCounts, C as propagationOf, D as unitOf, E as reusesReset, F as deletionOf, G as requestsOf, H as podsAt, I as dropOf, J as signalOf, K as restartsAt, L as emptyOf, M as backendsAt, N as byClients, O as RELATIVE, P as byNode, Q as slicesOf, R as failuresOf, S as observationEnd, T as referenceOf, U as podsOf, V as outcomesOf, W as recordsOf, X as sliceDropOf, Y as signalsOf, Z as sliceEmptyOf, _ as checkRun, a as calibrationOf, at as REDIS, b as judgeKind, c as predictionText, ct as seconds, d as FORMAT_V1, et as transitionOf, f as judge, g as LAST_SLACK, h as KIND_NOISE, i as readRecord, it as PORT, j as quantileOf, k as medianInterval, l as timeSpread, lt as secondsOf, m as upgradeCell, n as kindCellRecordSchema, nt as HEALTH_PORT, o as cellsTable, ot as SERVER, p as upgrade, q as rulesOf, r as kindCellRecordV1Schema, rt as LOG_LABEL, s as medianText, st as bandOf, t as STAGES, tt as CLIENT, u as FORMAT, ut as showBand, v as deletionsOf, w as propagationSource, x as observationAfter, y as factsOf, z as intakeOf } from "./judge-CRMYoEOw.js";
2
2
  import { valueIn } from "@mark1russell7/quantity";
3
3
  import { execFile, execFileSync } from "node:child_process";
4
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { createHash } from "node:crypto";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { gunzipSync } from "node:zlib";
9
10
  import { failSpan, lint, service } from "@mark1russell7/service";
10
11
  import { createRequire } from "node:module";
11
12
  import { tmpdir } from "node:os";
@@ -227,6 +228,57 @@ const KUBECTL = {
227
228
  }
228
229
  }
229
230
  };
231
+ /**
232
+ * This release of istioctl is 1.31.3, from 2026-10-22. It holds the charts of its own version, thus the
233
+ * lab needs no other tool to install Istio. The sums are from the `.sha256` assets of the release. The
234
+ * release has a zip file for Windows, and the lab reads only a gzipped tar file. Thus Windows has no asset.
235
+ */
236
+ const ISTIOCTL = {
237
+ name: "istioctl",
238
+ version: "1.31.3",
239
+ entry: "istioctl",
240
+ assets: {
241
+ "linux-x64": {
242
+ url: "https://github.com/istio/istio/releases/download/1.31.3/istioctl-1.31.3-linux-amd64.tar.gz",
243
+ sha256: "382c3004ce2e9c5ed3064b092ae0da7f6bbe30c766a1be282f45725add004860"
244
+ },
245
+ "linux-arm64": {
246
+ url: "https://github.com/istio/istio/releases/download/1.31.3/istioctl-1.31.3-linux-arm64.tar.gz",
247
+ sha256: "7476801623dbb900073cb1dba6a23b6a15c564b7e92b208aed36829315fefff1"
248
+ },
249
+ "darwin-arm64": {
250
+ url: "https://github.com/istio/istio/releases/download/1.31.3/istioctl-1.31.3-osx-arm64.tar.gz",
251
+ sha256: "881c3b443d9e458e8f19feed6ffa187dcfcb03dc032cb56068f7d1d0cd976c82"
252
+ }
253
+ }
254
+ };
255
+ /**
256
+ * This function gives the bytes of one file of a gzipped tar file. A header of the ustar format has
257
+ * 512 bytes. It has the name at 0, the size in octal at 124, the type at 156 and the prefix of the
258
+ * name at 345. The data follows the header, in blocks of 512 bytes. The match is on the last part of the
259
+ * name. A link, a folder and an extended header are not a file, thus the search skips them.
260
+ */
261
+ const tarEntry = (gzipped, entry) => {
262
+ const tar = gunzipSync(gzipped);
263
+ const text = (from, length) => {
264
+ const field = tar.subarray(from, from + length);
265
+ const end = field.indexOf(0);
266
+ return field.subarray(0, end === -1 ? field.length : end).toString("latin1");
267
+ };
268
+ let at = 0;
269
+ while (at + 512 <= tar.length) {
270
+ const name = text(at, 100);
271
+ if (name === "") break;
272
+ const size = Number.parseInt(text(at + 124, 12).trim() || "0", 8);
273
+ if (!Number.isFinite(size)) throw new Error(`the tar entry ${name} has no size in octal`);
274
+ const type = String.fromCharCode(tar[at + 156] ?? 0);
275
+ const prefix = text(at + 345, 155);
276
+ const full = prefix === "" ? name : `${prefix}/${name}`;
277
+ if ((type === "0" || type === "\0") && full.split("/").at(-1) === entry) return new Uint8Array(tar.subarray(at + 512, at + 512 + size));
278
+ at += 512 + Math.ceil(size / 512) * 512;
279
+ }
280
+ throw new Error(`the tar file has no file ${entry}`);
281
+ };
230
282
  /** This folder is the cache of the tools: `.cache/` in this package, which git ignores. `KIND_CACHE` names another folder. */
231
283
  const cacheFolder = () => process.env["KIND_CACHE"] ?? fileURLToPath(new URL("../.cache/", import.meta.url));
232
284
  /** This function gives the platform key of this process, as the assets name it. */
@@ -235,29 +287,43 @@ const platformKey = (platform = process.platform, arch = process.arch) => `${pla
235
287
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
236
288
  /** This function gives the path of a tool in the cache, for a platform. */
237
289
  const toolPath = (tool, key = platformKey(), cache = cacheFolder()) => join(cache, "tools", `${tool.name}-${tool.version}-${key}`, key.startsWith("win32") ? `${tool.name}.exe` : tool.name);
290
+ /** This function writes a file through a partial file, thus a stop in the middle leaves no file at the path. */
291
+ const writeWhole = (path, bytes, executable) => {
292
+ mkdirSync(dirname(path), { recursive: true });
293
+ const partial = `${path}.partial`;
294
+ writeFileSync(partial, bytes);
295
+ if (executable) chmodSync(partial, 493);
296
+ renameSync(partial, path);
297
+ };
238
298
  /**
239
299
  * This function gives the path of a tool, after a download when the cache does not have it. It
240
- * refuses bytes whose sha256 is not the pinned value, from the cache or from the download.
300
+ * refuses bytes whose sha256 is not the pinned value, from the cache or from the download. For a
301
+ * tool in a tar file, the cache keeps the tar file beside the tool. Each use examines the tar file,
302
+ * and writes the tool again when its bytes are not those of the entry.
241
303
  */
242
304
  const ensureTool = async (tool, key = platformKey(), cache = cacheFolder()) => {
243
305
  const asset = tool.assets[key];
244
306
  if (asset === void 0) throw new Error(`${tool.name} ${tool.version} has no pinned asset for ${key}`);
245
307
  const path = toolPath(tool, key, cache);
246
- if (existsSync(path)) {
247
- const found = sha256(readFileSync(path));
248
- if (found !== asset.sha256) throw new Error(`${path} has the sha256 ${found}, not the pinned ${asset.sha256}`);
249
- return path;
308
+ const executable = !key.startsWith("win32");
309
+ const kept = tool.entry === void 0 ? path : `${path}.tar.gz`;
310
+ let bytes;
311
+ if (existsSync(kept)) {
312
+ bytes = readFileSync(kept);
313
+ const found = sha256(bytes);
314
+ if (found !== asset.sha256) throw new Error(`${kept} has the sha256 ${found}, not the pinned ${asset.sha256}`);
315
+ if (tool.entry === void 0) return path;
316
+ } else {
317
+ const response = await fetch(asset.url, { redirect: "follow" });
318
+ if (!response.ok) throw new Error(`the download of ${asset.url} answered ${response.status}`);
319
+ bytes = new Uint8Array(await response.arrayBuffer());
320
+ const found = sha256(bytes);
321
+ if (found !== asset.sha256) throw new Error(`${asset.url} has the sha256 ${found}, not the pinned ${asset.sha256}`);
322
+ writeWhole(kept, bytes, executable && tool.entry === void 0);
323
+ if (tool.entry === void 0) return path;
250
324
  }
251
- const response = await fetch(asset.url, { redirect: "follow" });
252
- if (!response.ok) throw new Error(`the download of ${asset.url} answered ${response.status}`);
253
- const bytes = new Uint8Array(await response.arrayBuffer());
254
- const found = sha256(bytes);
255
- if (found !== asset.sha256) throw new Error(`${asset.url} has the sha256 ${found}, not the pinned ${asset.sha256}`);
256
- mkdirSync(dirname(path), { recursive: true });
257
- const partial = `${path}.partial`;
258
- writeFileSync(partial, bytes);
259
- if (!key.startsWith("win32")) chmodSync(partial, 493);
260
- renameSync(partial, path);
325
+ const binary = tarEntry(bytes, tool.entry);
326
+ if (!existsSync(path) || sha256(readFileSync(path)) !== sha256(binary)) writeWhole(path, binary, executable);
261
327
  return path;
262
328
  };
263
329
  //#endregion
@@ -507,6 +573,37 @@ const compose = (name, declaration, parts = {}) => {
507
573
  return svc;
508
574
  };
509
575
  //#endregion
576
+ //#region src/eds.ts
577
+ /**
578
+ * This function gives the name of the outbound cluster of a Service port in a sidecar of Istio:
579
+ * `outbound|<port>||<service>.<namespace>.svc.cluster.local`.
580
+ */
581
+ const outboundCluster = (service, namespace, port) => `outbound|${port}||${service}.${namespace}.svc.cluster.local`;
582
+ /** This function gives the filter of `/clusters` that selects one cluster: a regular expression of RE2 with anchors. */
583
+ const clusterFilter = (cluster) => `^${cluster.replace(/[.|\\^$*+?()[\]{}]/g, (c) => `\\${c}`)}$`;
584
+ /**
585
+ * This function gives the hosts of a cluster from the text of `/clusters`. Each host of the text has
586
+ * a line `<cluster>::<ip:port>::health_flags::<flags>`. The flags are `healthy`, or the names of the
587
+ * flags that the host has (`clusters_handler.cc` and `host_utility.cc` of Envoy v1.39.4).
588
+ */
589
+ const hostsOf = (text, cluster) => {
590
+ const healthy = [];
591
+ const other = {};
592
+ const prefix = `${cluster}::`;
593
+ for (const line of text.split("\n")) {
594
+ if (!line.startsWith(prefix)) continue;
595
+ const parts = line.slice(prefix.length).split("::");
596
+ if (parts.length !== 3 || parts[1] !== "health_flags") continue;
597
+ const [host, , flags] = parts;
598
+ if (flags.trim() === "healthy") healthy.push(host);
599
+ else other[host] = flags.trim();
600
+ }
601
+ return {
602
+ b: healthy.sort(),
603
+ u: other
604
+ };
605
+ };
606
+ //#endregion
510
607
  //#region src/faults.ts
511
608
  const needs = (time, what) => {
512
609
  if (time === void 0) throw new Error(`the trigger needs ${what}`);
@@ -818,7 +915,8 @@ const FILES = [
818
915
  ["compose.ts", "src/compose.ts"],
819
916
  ["backends.ts", "src/backends.ts"],
820
917
  ["conntrack.ts", "src/conntrack.ts"],
821
- ["drops.ts", "src/drops.ts"]
918
+ ["drops.ts", "src/drops.ts"],
919
+ ["eds.ts", "src/eds.ts"]
822
920
  ];
823
921
  const shell = process.platform === "win32";
824
922
  /** This function gives each file of a staged context, with its path in the context, in a stable sequence. */
@@ -912,6 +1010,132 @@ const labImage = () => {
912
1010
  return built;
913
1011
  };
914
1012
  //#endregion
1013
+ //#region src/istio.ts
1014
+ /**
1015
+ * This module installs Istio 1.31.3 in a kind cluster with istioctl, from images pinned by digest.
1016
+ * The release notes of 1.31.2 and 1.31.3 change no default that a cell of T15 reads. Each value
1017
+ * of the install that is not a default of Istio is in `istioOperator`, with its reason.
1018
+ */
1019
+ /** This version is the release of Istio of the lab: the tag of istioctl and of each image. */
1020
+ const ISTIO_VERSION = "1.31.3";
1021
+ /**
1022
+ * These are the images of the flavor `sidecar`, pinned by the digest of their index on Docker Hub
1023
+ * (read on 2026-10-24). `gcr.io/istio-release` has no final tag of 1.31. The lab pulls each one by
1024
+ * its digest, tags it with that digest (`localPin`), and loads it into each node. Thus no node pulls
1025
+ * from a registry.
1026
+ */
1027
+ const ISTIO_IMAGES = {
1028
+ pilot: "istio/pilot:1.31.3@sha256:6ad1e816c4d4767ba3f30a6286a96d121851ddb23d33e20736758acb5f3f4ea9",
1029
+ proxy: "istio/proxyv2:1.31.3@sha256:eb74eacea98d3fb1e239ea84016085155478586a554158676b9b67d960151084"
1030
+ };
1031
+ /**
1032
+ * This format is the JSON access log of each sidecar. Each field is a command of the formatter of
1033
+ * Envoy v1.39.4. The fields give the status, the response flags and their details, and the gRPC
1034
+ * status as a number. They also give the tries of the router, the upstream host and cluster, and
1035
+ * the reasons of a failed connection. The watcher reads `code`, `flags`, `grpc` and `attempts`
1036
+ * (`image/watch.mjs`).
1037
+ */
1038
+ const ACCESS_LOG_FORMAT = {
1039
+ start: "%START_TIME%",
1040
+ code: "%RESPONSE_CODE%",
1041
+ flags: "%RESPONSE_FLAGS%",
1042
+ details: "%RESPONSE_CODE_DETAILS%",
1043
+ grpc: "%GRPC_STATUS_NUMBER%",
1044
+ attempts: "%UPSTREAM_REQUEST_ATTEMPT_COUNT%",
1045
+ host: "%UPSTREAM_HOST%",
1046
+ cluster: "%UPSTREAM_CLUSTER%",
1047
+ local: "%DOWNSTREAM_LOCAL_ADDRESS%",
1048
+ remote: "%DOWNSTREAM_REMOTE_ADDRESS%",
1049
+ duration: "%DURATION%",
1050
+ failure: "%UPSTREAM_TRANSPORT_FAILURE_REASON%",
1051
+ termination: "%CONNECTION_TERMINATION_DETAILS%"
1052
+ };
1053
+ /**
1054
+ * This function gives the IstioOperator of a flavor. The values that are not the defaults of Istio:
1055
+ * - The profile `minimal` is istiod only. The profile `default` also has an ingress gateway, which no cell uses.
1056
+ * - Each image is a full reference, thus the charts use it as it is (`contains "/"` in the
1057
+ * templates of istiod and of the injector). The init container of the injector has the image of the proxy too (`proxy_init`).
1058
+ * - `imagePullPolicy: Never`: each image is on each node. An image that the lab did not load fails the install at once.
1059
+ * - istiod requests 256 MiB, not 2 GiB, and has one replica with no autoscaler. Each node of kind
1060
+ * reports the whole runner, and no cell scales istiod.
1061
+ * - The access log of each sidecar goes to its output as JSON (`ACCESS_LOG_FORMAT`).
1062
+ *
1063
+ * The flavor `ambient` comes in a later step, thus it is refused.
1064
+ */
1065
+ const istioOperator = (flavor, images) => {
1066
+ if (flavor !== "sidecar") throw new Error(`the lab installs the mesh flavor sidecar only, not ${flavor}`);
1067
+ return {
1068
+ apiVersion: "install.istio.io/v1alpha1",
1069
+ kind: "IstioOperator",
1070
+ metadata: {
1071
+ name: "lab",
1072
+ namespace: "istio-system"
1073
+ },
1074
+ spec: {
1075
+ profile: "minimal",
1076
+ hub: "docker.io/istio",
1077
+ tag: ISTIO_VERSION,
1078
+ meshConfig: {
1079
+ accessLogFile: "/dev/stdout",
1080
+ accessLogEncoding: "JSON",
1081
+ accessLogFormat: JSON.stringify(ACCESS_LOG_FORMAT)
1082
+ },
1083
+ values: {
1084
+ global: {
1085
+ imagePullPolicy: "Never",
1086
+ proxy: { image: images.proxy },
1087
+ proxy_init: { image: images.proxy }
1088
+ },
1089
+ pilot: {
1090
+ image: images.pilot,
1091
+ autoscaleEnabled: false,
1092
+ replicaCount: 1,
1093
+ resources: { requests: {
1094
+ cpu: "500m",
1095
+ memory: "256Mi"
1096
+ } }
1097
+ }
1098
+ }
1099
+ }
1100
+ };
1101
+ };
1102
+ /**
1103
+ * This function installs the mesh of a flavor in a cluster with `istioctl install` and the operator
1104
+ * of the flavor. Then it waits for istiod. The kubeconfig of the cluster goes to istioctl.
1105
+ */
1106
+ const installMesh = async (cluster, flavor, images) => {
1107
+ const istioctl = await ensureTool(ISTIOCTL);
1108
+ const file = join(cluster.dir, `istio-${flavor}.json`);
1109
+ writeFileSync(file, `${JSON.stringify(istioOperator(flavor, images), null, 2)}\n`);
1110
+ await command(istioctl, [
1111
+ "install",
1112
+ "--kubeconfig",
1113
+ cluster.kubeconfig,
1114
+ "-y",
1115
+ "-f",
1116
+ file,
1117
+ "--readiness-timeout",
1118
+ "10m0s"
1119
+ ], { timeout: 9e5 });
1120
+ await cluster.kubectl([
1121
+ "-n",
1122
+ "istio-system",
1123
+ "rollout",
1124
+ "status",
1125
+ "deployment/istiod",
1126
+ "--timeout=300s"
1127
+ ]);
1128
+ };
1129
+ /** This function gives the key of the mesh of a cell for its cluster: its flavor, or `none`. */
1130
+ const meshKey = (cell) => cell.mesh?.flavor ?? "none";
1131
+ /** This function gives the outbound cluster of the server of a cell in each client sidecar: the port 80 of its Service. */
1132
+ const serverCluster = (namespace) => outboundCluster("lab", namespace, 80);
1133
+ /** This time is the interval of the EDS poll of each client sidecar, the same as the poll of the rules. */
1134
+ const EDS_EVERY = {
1135
+ value: 50,
1136
+ unit: "ms"
1137
+ };
1138
+ //#endregion
915
1139
  //#region src/conntrack.ts
916
1140
  /** This function gives the count of entries that `conntrack -D` deleted, from its last line: "N flow entries have been deleted". */
917
1141
  const deletedOf = (text) => {
@@ -1093,6 +1317,29 @@ const openKindLab = async (cluster, image) => {
1093
1317
  `printf '%s' '${file}' > /tmp/rules.json`
1094
1318
  ]);
1095
1319
  },
1320
+ async eds(spec) {
1321
+ for (const [node, { agent }] of sidecars) {
1322
+ const pods = spec === null ? [] : spec.pods.filter((pod) => pod.node === node).map(({ pod, pid }) => ({
1323
+ pod,
1324
+ pid
1325
+ }));
1326
+ const file = spec === null || pods.length === 0 ? null : JSON.stringify({
1327
+ cluster: spec.cluster,
1328
+ every: valueIn(spec.every, "ms"),
1329
+ pods
1330
+ });
1331
+ if (file === null) await output(agent, [
1332
+ "rm",
1333
+ "-f",
1334
+ "/tmp/eds.json"
1335
+ ]);
1336
+ else await output(agent, [
1337
+ "sh",
1338
+ "-c",
1339
+ `printf '%s' '${file}' > /tmp/eds.json.partial && mv /tmp/eds.json.partial /tmp/eds.json`
1340
+ ]);
1341
+ }
1342
+ },
1096
1343
  async forgetHandshakes(ip, port) {
1097
1344
  const out = {};
1098
1345
  for (const [node, { agent }] of sidecars) {
@@ -1251,6 +1498,18 @@ const whole = (amount, what) => {
1251
1498
  /** This function gives the namespace of a cell: `lab-` and 10 digits of the sha256 of its name. */
1252
1499
  const namespaceOf = (cell) => `lab-${createHash("sha256").update(cell.name).digest("hex").slice(0, 10)}`;
1253
1500
  /**
1501
+ * This function gives the Namespace object of a cell. In the sidecar mode of a mesh, the label
1502
+ * `istio-injection=enabled` makes the injector of Istio put a sidecar in each pod of the namespace.
1503
+ */
1504
+ const namespaceObject = (cell, namespace) => ({
1505
+ apiVersion: "v1",
1506
+ kind: "Namespace",
1507
+ metadata: {
1508
+ name: namespace,
1509
+ ...cell.mesh?.mode === "sidecar" ? { labels: { "istio-injection": "enabled" } } : {}
1510
+ }
1511
+ });
1512
+ /**
1254
1513
  * This function gives a probe of the pod, with each field of the cell. The probe of the service is an
1255
1514
  * HTTP GET of its health server (`path`). The probe of grpc-go (`path` null) is the gRPC probe of the
1256
1515
  * kubelet. It is a Check of the service "" on the port of the server, and the answer is SERVING until
@@ -1808,11 +2067,17 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1808
2067
  const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1809
2068
  /** This function tells if a cell has Redis: only a declaration of the service can have it. */
1810
2069
  const hasRedis = (cell) => cell.declaration !== null && cell.declaration.redis !== null;
1811
- /** This tracker keeps the latest state of the cell from the lines: each pod, each slice, the backends of each node and each client. */
2070
+ /** This function gives the key of the cluster of a cell: its kube-proxy mode, its node count, and the flavor of its mesh. */
2071
+ const clusterKey = (cell) => cell.mesh === void 0 ? `${cell.mode}-${cell.workers}` : `${cell.mode}-${cell.workers}-${cell.mesh.flavor}`;
2072
+ /**
2073
+ * This tracker keeps the latest state of the cell from the lines: each pod, each slice and each
2074
+ * client. It also has, for each node, the backends of its rules and the hosts of its client sidecar.
2075
+ */
1812
2076
  var Tracker = class {
1813
2077
  pods = /* @__PURE__ */ new Map();
1814
2078
  slices = /* @__PURE__ */ new Map();
1815
2079
  backends = /* @__PURE__ */ new Map();
2080
+ hosts = /* @__PURE__ */ new Map();
1816
2081
  clients = /* @__PURE__ */ new Map();
1817
2082
  /** This map is the backend of the last answer of each client, by client. */
1818
2083
  answers = /* @__PURE__ */ new Map();
@@ -1827,6 +2092,7 @@ var Tracker = class {
1827
2092
  if (line["type"] === "DELETED") this.slices.delete(String(line["name"]));
1828
2093
  else this.slices.set(String(line["name"]), line);
1829
2094
  } else if (line.ev === "rules") this.backends.set(String(line["node"]), line["b"]);
2095
+ else if (line.ev === "eds") this.hosts.set(String(line["node"]), line["b"]);
1830
2096
  else if (line.ev === "req") {
1831
2097
  const request = line;
1832
2098
  const client = this.clients.get(request.p) ?? {
@@ -1861,6 +2127,9 @@ const unsteady = (ctx) => {
1861
2127
  if (ready.join() !== ips.join()) return `the EndpointSlice has ${ready.join(" ")}, not ${ips.join(" ")}`;
1862
2128
  const expected = ips.map((ip) => `${ip}:8080`).sort().join();
1863
2129
  for (const node of ctx.cluster.nodes) if ((tracker.backends.get(node) ?? []).join() !== expected) return `the rules of ${node} have ${(tracker.backends.get(node) ?? []).join(" ")}`;
2130
+ if (cell.mesh?.mode === "sidecar") {
2131
+ 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"}`;
2132
+ }
1864
2133
  const nodes = new Set([...tracker.clients.values()].map((client) => client.node));
1865
2134
  if (nodes.size !== ctx.cluster.nodes.length) return `clients answer on ${nodes.size} nodes`;
1866
2135
  for (const [pod, client] of tracker.clients) {
@@ -1923,11 +2192,7 @@ const quiet = async (ctx, ours) => {
1923
2192
  * the backoff of grpc-js longer, up to 120 s.
1924
2193
  */
1925
2194
  const deploy = async (lab, cluster, cell, namespace, images) => {
1926
- await cluster.apply([{
1927
- apiVersion: "v1",
1928
- kind: "Namespace",
1929
- metadata: { name: namespace }
1930
- }]);
2195
+ await cluster.apply([namespaceObject(cell, namespace)]);
1931
2196
  await lab.watch([namespace]);
1932
2197
  if (hasRedis(cell)) {
1933
2198
  await cluster.apply(redisObjects(namespace, images.redis));
@@ -1979,8 +2244,40 @@ const deploy = async (lab, cluster, cell, namespace, images) => {
1979
2244
  port: 80,
1980
2245
  every: RULES_EVERY
1981
2246
  });
2247
+ if (cell.mesh?.mode === "sidecar") await pollEndpoints(lab, cluster, namespace);
1982
2248
  return clusterIp;
1983
2249
  };
2250
+ /**
2251
+ * This function starts the EDS poll of the client sidecar on each node: the hosts of the server
2252
+ * cluster, each 50 ms. The agent enters the network namespace of the client pod through the PID of
2253
+ * its client process. The sidecar shares that namespace.
2254
+ */
2255
+ const pollEndpoints = async (lab, cluster, namespace) => {
2256
+ const listed = JSON.parse(await cluster.kubectl([
2257
+ "-n",
2258
+ namespace,
2259
+ "get",
2260
+ "pods",
2261
+ "-l",
2262
+ `app=${CLIENT}`,
2263
+ "-o",
2264
+ "json"
2265
+ ]));
2266
+ const pods = [];
2267
+ for (const item of listed.items.filter((each) => each.metadata.deletionTimestamp === void 0)) {
2268
+ const node = item.spec.nodeName ?? "";
2269
+ pods.push({
2270
+ node,
2271
+ pod: item.metadata.name,
2272
+ pid: await lab.pidOf(node, namespace, item.metadata.name, CLIENT)
2273
+ });
2274
+ }
2275
+ await lab.eds({
2276
+ cluster: serverCluster(namespace),
2277
+ every: EDS_EVERY,
2278
+ pods
2279
+ });
2280
+ };
1984
2281
  /** This function starts the prober again when the pods of the cell changed: it watches the namespace of each pod. */
1985
2282
  const probeThePods = async (ctx) => {
1986
2283
  const pods = [...ctx.tracker.pods.values()].filter((pod) => !pod.del && pod.node !== null && pod.cs.every((status) => status.st === "running"));
@@ -2143,12 +2440,18 @@ const over = (ctx, run, lines) => {
2143
2440
  "node-drain",
2144
2441
  "scale-in",
2145
2442
  "freeze-process"
2146
- ].includes(kind) && ctx.cell.template === "T07") return kind === "scale-in" || kind === "node-drain" ? run.replicas.some((replica) => !ctx.tracker.pods.has(replica.pod)) : !ctx.tracker.pods.has(run.target.pod);
2443
+ ].includes(kind) && (ctx.cell.template === "T07" || ctx.cell.template === "T15")) return kind === "scale-in" || kind === "node-drain" ? run.replicas.some((replica) => !ctx.tracker.pods.has(replica.pod)) : !ctx.tracker.pods.has(run.target.pod);
2147
2444
  if (kind === "rolling-update" || kind === "rollout") return run.replicas.every((replica) => !ctx.tracker.pods.has(replica.pod));
2148
2445
  if (kind === "app-stall") return lines.some((line) => line.ev === "stall" && line["phase"] === "end");
2149
2446
  return true;
2150
2447
  };
2151
- /** This function does one run of a cell: at rest, the trigger, the heal, then the observation until the cell is at rest again. */
2448
+ /** This function gives the replica that the trigger stopped. For a scale-in or a drain, the controller chooses it. */
2449
+ const stoppedOf = (cell, replicas, target, lines) => deletes(cell) ? replicas.find((replica) => lines.some((line) => line.ev === "pod" && line["name"] === replica.pod && line["del"] === true)) ?? target : target;
2450
+ /**
2451
+ * This function does one run of a cell: at rest, the trigger, the heal, then the observation. The
2452
+ * observation lasts until the cell is at rest again, and until each predicted window can close
2453
+ * (`observationEnd`).
2454
+ */
2152
2455
  const runOnce = async (ctx, env, index, warmup, ours) => {
2153
2456
  const { lab, cell, cluster, namespace } = ctx;
2154
2457
  const plan = planOf(cell);
@@ -2220,6 +2523,21 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
2220
2523
  replicas,
2221
2524
  restarts
2222
2525
  }, lab.lines)) await sleep(250);
2526
+ const until = observationEnd(cell, {
2527
+ start,
2528
+ acts: {
2529
+ fault,
2530
+ heal,
2531
+ resume
2532
+ },
2533
+ target: stoppedOf(cell, replicas, target, lab.lines),
2534
+ replicas,
2535
+ lines: lab.lines
2536
+ });
2537
+ if (until !== null) {
2538
+ const wait = Math.min(until - await lab.now(), limit - Date.now());
2539
+ if (wait > 0) await sleep(wait);
2540
+ }
2223
2541
  const end = await lab.now();
2224
2542
  const timedOut = Date.now() >= limit;
2225
2543
  for (const step of plan.restore) try {
@@ -2230,7 +2548,7 @@ const runOnce = async (ctx, env, index, warmup, ours) => {
2230
2548
  ctx.expected = cell.pod.replicas;
2231
2549
  for (const note of notes) ctx.log(` ${note}`);
2232
2550
  const lines = lab.take();
2233
- const stopped = deletes(cell) ? replicas.find((replica) => lines.some((line) => line.ev === "pod" && line["name"] === replica.pod && line["del"] === true)) ?? target : target;
2551
+ const stopped = stoppedOf(cell, replicas, target, lines);
2234
2552
  if (lines.some((line) => line.ev === "pod" && line["cs"].some((status) => status.rc > 0))) {
2235
2553
  await cluster.kubectl([
2236
2554
  "-n",
@@ -2327,17 +2645,22 @@ const runKindCells = async (cells, options) => {
2327
2645
  const images = {
2328
2646
  lab: image,
2329
2647
  redis: redisImage,
2330
- grpc: grpcImage
2648
+ grpc: grpcImage,
2649
+ mesh: cells.some((cell) => cell.mesh !== void 0) ? {
2650
+ pilot: await localPin(ISTIO_IMAGES.pilot),
2651
+ proxy: await localPin(ISTIO_IMAGES.proxy)
2652
+ } : void 0
2331
2653
  };
2332
2654
  const groups = /* @__PURE__ */ new Map();
2333
- for (const cell of cells) groups.set(`${cell.mode}-${cell.workers}`, [...groups.get(`${cell.mode}-${cell.workers}`) ?? [], cell]);
2655
+ for (const cell of cells) groups.set(clusterKey(cell), [...groups.get(clusterKey(cell)) ?? [], cell]);
2334
2656
  const results = [];
2335
2657
  for (const [key, group] of groups) {
2336
2658
  const partial = /* @__PURE__ */ new Map();
2337
2659
  for (let env = 0; env < environments; env += 1) {
2338
2660
  const s = scenario(`lab-${key}-${env}`, group[0].mode, group[0].workers);
2339
2661
  const dir = join(options.work ?? join(tmpdir(), "systems-kind-work"), s.name);
2340
- options.log(`cluster ${s.name}: ${s.kubeProxy.mode}, ${s.workers} workers`);
2662
+ const mesh = group[0].mesh;
2663
+ options.log(`cluster ${s.name}: ${s.kubeProxy.mode}, ${s.workers} workers${mesh === void 0 ? "" : `, Istio ${mesh.flavor}`}`);
2341
2664
  const cluster = await createCluster(s, dir);
2342
2665
  let lab = null;
2343
2666
  let loader = null;
@@ -2345,6 +2668,13 @@ const runKindCells = async (cells, options) => {
2345
2668
  await cluster.load(image);
2346
2669
  await cluster.load(redisImage);
2347
2670
  if (grpcImage !== void 0 && group.some((cell) => stackOf(cell) === "grpc")) await cluster.load(grpcImage);
2671
+ if (mesh !== void 0) {
2672
+ if (images.mesh === void 0) throw new Error(`${group[0].name}: the run has no images of Istio`);
2673
+ for (const each of [images.mesh.pilot, images.mesh.proxy]) await cluster.load(each);
2674
+ const began = Date.now();
2675
+ await installMesh(cluster, mesh.flavor, images.mesh);
2676
+ options.log(` Istio ${mesh.flavor} installed in ${Math.round((Date.now() - began) / 100) / 10} s`);
2677
+ }
2348
2678
  lab = await openKindLab(cluster, image);
2349
2679
  const { GenericContainer } = await import("testcontainers");
2350
2680
  const Shared = class extends GenericContainer {
@@ -2396,7 +2726,11 @@ const runKindCells = async (cells, options) => {
2396
2726
  images: {
2397
2727
  lab: image,
2398
2728
  redis: redisImage,
2399
- ...stackOf(cell) === "grpc" ? { grpc: grpcImage } : {}
2729
+ ...stackOf(cell) === "grpc" ? { grpc: grpcImage } : {},
2730
+ ...cell.mesh === void 0 ? {} : {
2731
+ "istio-pilot": ISTIO_IMAGES.pilot,
2732
+ "istio-proxy": ISTIO_IMAGES.proxy
2733
+ }
2400
2734
  }
2401
2735
  });
2402
2736
  entry.loads.push(...loads);
@@ -2411,6 +2745,7 @@ const runKindCells = async (cells, options) => {
2411
2745
  } finally {
2412
2746
  off();
2413
2747
  await lab.rules(null).catch(() => void 0);
2748
+ await lab.eds(null).catch(() => void 0);
2414
2749
  await lab.stopProbe();
2415
2750
  await cluster.kubectl([
2416
2751
  "delete",
@@ -2449,14 +2784,17 @@ const runKindCells = async (cells, options) => {
2449
2784
  /** This function gives the estimate of a cell in seconds, for the arithmetic of the shards. */
2450
2785
  const estimate = (cell, environments = 2) => {
2451
2786
  const hold = cell.trigger.hold === void 0 ? 0 : valueIn(cell.trigger.hold, "s");
2452
- const each = 25 + ({
2787
+ const trigger = {
2453
2788
  "liveness-restart": 100,
2454
2789
  crash: 30,
2455
2790
  "app-stall": hold + 40,
2456
2791
  "cpu-throttle": hold + 40,
2457
2792
  rollout: hold + 50
2458
- }[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
2459
- return 60 * environments + (cell.runs + environments) * each;
2793
+ };
2794
+ const after = observationAfter(cell);
2795
+ const observed = after === null ? 0 : hold + valueIn(after, "s");
2796
+ const each = 25 + Math.max(observed, trigger[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
2797
+ return (cell.mesh === void 0 ? 60 : 180) * environments + (cell.runs + environments) * each;
2460
2798
  };
2461
2799
  /**
2462
2800
  * This function gives an estimate of the time of a cell: its setup, then each run and each
@@ -2469,11 +2807,13 @@ const estimatedTime = (cell, environments = 2) => ({
2469
2807
  });
2470
2808
  /**
2471
2809
  * This function gives the share of a shard of a catalog: cells in sequence. Thus the cells of one
2472
- * mode stay together and share clusters. Each shard gets an equal share of the estimated time.
2810
+ * mode and one mesh flavor stay together and share clusters. Each shard gets an equal share of the
2811
+ * estimated time. In one mode, the cells with no mesh come first, in the sequence of the catalog.
2473
2812
  */
2474
2813
  const shardOf = (cells, index, count) => {
2475
2814
  if (!(Number.isInteger(index) && Number.isInteger(count) && count >= 1 && index >= 0 && index < count)) throw new RangeError(`shard ${index} of ${count} is not a shard`);
2476
- const ordered = [...cells].sort((a, b) => a.mode === b.mode ? 0 : a.mode < b.mode ? -1 : 1);
2815
+ const rank = (cell) => `${cell.mode}/${meshKey(cell) === "none" ? "" : meshKey(cell)}`;
2816
+ const ordered = [...cells].sort((a, b) => rank(a) === rank(b) ? 0 : rank(a) < rank(b) ? -1 : 1);
2477
2817
  const total = ordered.reduce((sum, cell) => sum + estimate(cell), 0);
2478
2818
  const out = [];
2479
2819
  let before = 0;
@@ -2485,6 +2825,6 @@ const shardOf = (cells, index, count) => {
2485
2825
  return out;
2486
2826
  };
2487
2827
  //#endregion
2488
- export { CLIENT, CONTROLLER, DOCUMENTED, DRAIN_RULES, FLAG_CHECK, FORMAT, FORMAT_V1, HEALTH_PORT, KEPT_KERNEL, KIND, KIND_NOISE, KUBECTL, KUBE_PROXY_MODES, LAST_SLACK, LOG_LABEL, NODE_IMAGES, O04, O09, O10, O16, PORT, REDIS, REDIS_IMAGE, RELATIVE, ROUTE, RULES_EVERY, SERVER, STAGES, WATCHDOG, backendsAt, backendsOf, bandOf, byClients, byNode, cacheFolder, calibrationOf, capOf, cellsTable, checkRun, cidrOverlap, cidrRange, clientObjects, compose, createCluster, deletes, deletionOf, deletionWindow, deletionsOf, describeCell, dropOf, emptyOf, ensureTool, estimatedTime, factsOf, failuresOf, goDuration, intakeAfter, intakeOf, judge, judgeKind, kernelCounts, kindCellRecordSchema, kindCellRecordV1Schema, kindConfig, kubeProxyPatch, kubeletBand, labImage, latchBand, lintCell, localPin, medianInterval, medianText, middle, namespaceOf, nodeNames, openKindLab, outcomesOf, pausedWindow, planOf, platformKey, podsAt, podsOf, predictionText, probeOf, propagationOf, quantileOf, readRecord, recordsOf, redisObjects, referenceOf, removalBand, requestsOf, restartBand, restartsAt, reusesReset, rulesOf, runKindCells, scenario, scenarioOf, scenarioProblems, seconds, secondsOf, serverObjects, sha256, shardOf, showBand, signalOf, signalsOf, sliceDropOf, sliceEmptyOf, slicesOf, stackOf, stageLabImage, stallRemovalBand, stoppingOf, timeSpread, toolPath, transitionOf, unitOf, upgrade, upgradeCell, verdictsOf, writeResult };
2828
+ export { ACCESS_LOG_FORMAT, CLIENT, CONTROLLER, DOCUMENTED, DRAIN_RULES, EDS_EVERY, FLAG_CHECK, FORMAT, FORMAT_V1, HEALTH_PORT, ISTIOCTL, ISTIO_IMAGES, ISTIO_VERSION, KEPT_KERNEL, KIND, KIND_NOISE, KUBECTL, KUBE_PROXY_MODES, LAST_SLACK, LOG_LABEL, NODE_IMAGES, O04, O09, O10, O16, PORT, REDIS, REDIS_IMAGE, RELATIVE, ROUTE, RULES_EVERY, SERVER, STAGES, WATCHDOG, backendsAt, backendsOf, bandOf, byClients, byNode, cacheFolder, calibrationOf, capOf, cellsTable, checkRun, cidrOverlap, cidrRange, clientObjects, clusterFilter, compose, createCluster, deletes, deletionOf, deletionWindow, deletionsOf, describeCell, dropOf, emptyOf, ensureTool, estimatedTime, factsOf, failuresOf, goDuration, hostsOf, installMesh, intakeAfter, intakeOf, istioOperator, judge, judgeKind, kernelCounts, kindCellRecordSchema, kindCellRecordV1Schema, kindConfig, kubeProxyPatch, kubeletBand, labImage, latchBand, lintCell, localPin, medianInterval, medianText, meshKey, middle, namespaceObject, namespaceOf, nodeNames, observationAfter, observationEnd, openKindLab, outboundCluster, outcomesOf, pausedWindow, planOf, platformKey, podsAt, podsOf, predictionText, probeOf, propagationOf, propagationSource, quantileOf, readRecord, recordsOf, redisObjects, referenceOf, removalBand, requestsOf, restartBand, restartsAt, reusesReset, rulesOf, runKindCells, scenario, scenarioOf, scenarioProblems, seconds, secondsOf, serverCluster, serverObjects, sha256, shardOf, showBand, signalOf, signalsOf, sliceDropOf, sliceEmptyOf, slicesOf, stackOf, stageLabImage, stallRemovalBand, stoppingOf, tarEntry, timeSpread, toolPath, transitionOf, unitOf, upgrade, upgradeCell, verdictsOf, writeResult };
2489
2829
 
2490
2830
  //# sourceMappingURL=index.js.map