@mark1russell7/kind 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +142 -2
- package/build/index.d.ts +393 -0
- package/build/index.js +2325 -0
- package/build/index.js.map +1 -0
- package/build/judge-CDDCr1Z8.d.ts +695 -0
- package/build/judge-CW1IX2zP.js +1018 -0
- package/build/judge-CW1IX2zP.js.map +1 -0
- package/build/judge.d.ts +2 -0
- package/build/judge.js +1 -0
- package/image/Dockerfile +18 -0
- package/image/agent.mjs +122 -0
- package/image/app.mjs +140 -0
- package/image/client.mjs +68 -0
- package/image/npm-lock.json +106 -0
- package/image/package.json +9 -0
- package/image/watch.mjs +175 -0
- package/package.json +83 -3
- package/src/backends.ts +70 -0
- package/src/compose.ts +68 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,2325 @@
|
|
|
1
|
+
import { $ as SERVER, A as dropOf, B as restartsAt, C as RELATIVE, D as backendsAt, E as quantileOf, F as outcomesOf, G as sliceEmptyOf, H as signalOf, I as podsAt, J as CLIENT, K as slicesOf, L as podsOf, M as failuresOf, N as intakeOf, O as byNode, P as kernelCounts, Q as REDIS, R as recordsOf, S as unitOf, T as middle, U as signalsOf, V as rulesOf, W as sliceDropOf, X as LOG_LABEL, Y as HEALTH_PORT, Z as PORT, _ as checkRun, a as calibrationOf, b as propagationOf, c as predictionText, d as FORMAT_V1, et as bandOf, f as judge, g as LAST_SLACK, h as KIND_NOISE, i as readRecord, j as emptyOf, k as deletionOf, l as timeSpread, m as upgradeCell, n as kindCellRecordSchema, nt as secondsOf, o as cellsTable, p as upgrade, q as transitionOf, r as kindCellRecordV1Schema, rt as showBand, s as medianText, t as STAGES, tt as seconds, u as FORMAT, v as factsOf, w as medianInterval, x as referenceOf, y as judgeKind, z as requestsOf } from "./judge-CW1IX2zP.js";
|
|
2
|
+
import { valueIn } from "@mark1russell7/quantity";
|
|
3
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { failSpan, lint, service } from "@mark1russell7/service";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { harnessImage } from "@mark1russell7/harness";
|
|
13
|
+
import { redisClient } from "@mark1russell7/service/redis";
|
|
14
|
+
//#region src/backends.ts
|
|
15
|
+
const hexIp = (hex) => [
|
|
16
|
+
0,
|
|
17
|
+
2,
|
|
18
|
+
4,
|
|
19
|
+
6
|
|
20
|
+
].map((at) => parseInt(hex.slice(at, at + 2), 16)).join(".");
|
|
21
|
+
/**
|
|
22
|
+
* This function gives the backends of a Service in a dump of the rules of kube-proxy, as
|
|
23
|
+
* `ip:port`, sorted. A backend of IPVS with the weight zero takes no new connection, thus it is
|
|
24
|
+
* not a backend.
|
|
25
|
+
* - iptables: the `KUBE-SVC` chain of the Service jumps to a `KUBE-SEP` chain for each backend, and that chain does the DNAT.
|
|
26
|
+
* - nftables: the `service-` chain of the Service does the DNAT to each backend, or it names an `endpoint-` chain for each one.
|
|
27
|
+
* - IPVS: `/proc/net/ip_vs` has the line of the virtual service, then a line for each real server.
|
|
28
|
+
*/
|
|
29
|
+
const backendsOf = (mode, text, rule) => {
|
|
30
|
+
const out = /* @__PURE__ */ new Set();
|
|
31
|
+
const lines = text.split("\n");
|
|
32
|
+
if (mode === "iptables") {
|
|
33
|
+
const seps = /* @__PURE__ */ new Set();
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (!line.startsWith("-A KUBE-SVC-") || !line.includes(`${rule.ns}/${rule.svc}:`)) continue;
|
|
36
|
+
const jump = / -j (KUBE-SEP-\S+)/.exec(line);
|
|
37
|
+
if (jump !== null) seps.add(jump[1]);
|
|
38
|
+
}
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const chain = /^-A (KUBE-SEP-\S+) /.exec(line);
|
|
41
|
+
if (chain === null || !seps.has(chain[1])) continue;
|
|
42
|
+
const to = / --to-destination (\S+)/.exec(line);
|
|
43
|
+
if (to !== null) out.add(to[1]);
|
|
44
|
+
}
|
|
45
|
+
} else if (mode === "nftables") {
|
|
46
|
+
let inside = false;
|
|
47
|
+
const ours = `-${rule.ns}/${rule.svc}/`;
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const head = /^\s*chain\s+"?([^"\s{]+)"?\s*\{/.exec(line);
|
|
50
|
+
if (head !== null) inside = head[1].startsWith("service-") && head[1].includes(ours);
|
|
51
|
+
else if (/^\s*(set|map)\s/.test(line)) inside = false;
|
|
52
|
+
if (!inside) continue;
|
|
53
|
+
for (const match of line.matchAll(/(?:goto|jump)\s+"?endpoint-[0-9A-Za-z]+(-[^\s,}"]+)__([0-9.]+)\/(\d+)"?/g)) if (match[1].startsWith(ours)) out.add(`${match[2]}:${match[3]}`);
|
|
54
|
+
for (const match of line.matchAll(/(?:^|[\s{,:])(\d+\.\d+\.\d+\.\d+) \. (\d+)(?=\s*[,}]|\s*$)/g)) out.add(`${match[1]}:${match[2]}`);
|
|
55
|
+
for (const match of line.matchAll(/\bdnat (?:ip )?to (\d+\.\d+\.\d+\.\d+):(\d+)/g)) out.add(`${match[1]}:${match[2]}`);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
let inside = false;
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const service = /^(TCP|UDP|SCTP)\s+([0-9A-F]{8}):([0-9A-F]{4})\s/.exec(line);
|
|
61
|
+
if (service !== null) {
|
|
62
|
+
inside = service[1] === "TCP" && hexIp(service[2]) === rule.ip && parseInt(service[3], 16) === rule.port;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const real = /^\s+->\s+([0-9A-F]{8}):([0-9A-F]{4})\s+\S+\s+(\d+)/.exec(line);
|
|
66
|
+
if (inside && real !== null && Number(real[3]) > 0) out.add(`${hexIp(real[1])}:${parseInt(real[2], 16)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...out].sort();
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/scenario.ts
|
|
73
|
+
const KUBE_PROXY_MODES = [
|
|
74
|
+
"iptables",
|
|
75
|
+
"ipvs",
|
|
76
|
+
"nftables"
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* These are the node images of kind v0.33.0, from its release notes. The image of Kubernetes 1.37
|
|
80
|
+
* is v1.37.0. The research names v1.37.1 for some defaults, thus a default that changed in the
|
|
81
|
+
* patch release is a mismatch to record.
|
|
82
|
+
*/
|
|
83
|
+
const NODE_IMAGES = {
|
|
84
|
+
"1.37": {
|
|
85
|
+
kubernetes: "v1.37.0",
|
|
86
|
+
image: "kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5"
|
|
87
|
+
},
|
|
88
|
+
"1.36": {
|
|
89
|
+
kubernetes: "v1.36.4",
|
|
90
|
+
image: "kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed"
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** This function gives a scenario with the defaults of Kubernetes for kube-proxy, as explicit values. */
|
|
94
|
+
const scenario = (name, mode, workers, kubernetes = "1.37") => ({
|
|
95
|
+
name,
|
|
96
|
+
kubernetes,
|
|
97
|
+
workers,
|
|
98
|
+
kubeProxy: {
|
|
99
|
+
mode,
|
|
100
|
+
minSyncPeriod: {
|
|
101
|
+
value: 1,
|
|
102
|
+
unit: "s"
|
|
103
|
+
},
|
|
104
|
+
syncPeriod: {
|
|
105
|
+
value: 30,
|
|
106
|
+
unit: "s"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
cni: "kindnet",
|
|
110
|
+
podSubnet: "10.244.0.0/16",
|
|
111
|
+
serviceSubnet: "10.96.0.0/16"
|
|
112
|
+
});
|
|
113
|
+
const NAME = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
114
|
+
/** This function gives the problems of a scenario: a name that kind refuses, no image for the version, or a count out of range. */
|
|
115
|
+
const scenarioProblems = (s) => {
|
|
116
|
+
const problems = [];
|
|
117
|
+
if (!NAME.test(s.name)) problems.push(`the name ${s.name} is not lowercase letters, digits and dashes of 1 to 32 characters`);
|
|
118
|
+
if (NODE_IMAGES[s.kubernetes] === void 0) problems.push(`no node image is pinned for Kubernetes ${s.kubernetes}`);
|
|
119
|
+
if (!Number.isInteger(s.workers) || s.workers < 0 || s.workers > 8) problems.push(`${s.workers} workers is not a whole number from 0 to 8`);
|
|
120
|
+
if (cidrOverlap(s.podSubnet, s.serviceSubnet)) problems.push(`the pod subnet ${s.podSubnet} overlaps the service subnet ${s.serviceSubnet}`);
|
|
121
|
+
return problems;
|
|
122
|
+
};
|
|
123
|
+
/** This function gives the names of the node containers, as kind names them: the control plane, then each worker. */
|
|
124
|
+
const nodeNames = (s) => [`${s.name}-control-plane`, ...Array.from({ length: s.workers }, (_, at) => `${s.name}-worker${at === 0 ? "" : at + 1}`)];
|
|
125
|
+
/** This function gives a duration as kube-proxy reads it, in whole milliseconds: `1s`, `500ms`. */
|
|
126
|
+
const goDuration = (amount) => {
|
|
127
|
+
const ms = valueIn(amount, "ms");
|
|
128
|
+
if (!Number.isInteger(ms) || ms < 0) throw new RangeError(`${ms} ms is not a whole number of milliseconds`);
|
|
129
|
+
return ms % 1e3 === 0 ? `${ms / 1e3}s` : `${ms}ms`;
|
|
130
|
+
};
|
|
131
|
+
/** This function gives the KubeProxyConfiguration patch of a scenario, as kubeadm reads it. */
|
|
132
|
+
const kubeProxyPatch = (s) => [
|
|
133
|
+
"apiVersion: kubeproxy.config.k8s.io/v1alpha1",
|
|
134
|
+
"kind: KubeProxyConfiguration",
|
|
135
|
+
`mode: ${s.kubeProxy.mode}`,
|
|
136
|
+
`${s.kubeProxy.mode}:`,
|
|
137
|
+
` minSyncPeriod: ${goDuration(s.kubeProxy.minSyncPeriod)}`,
|
|
138
|
+
` syncPeriod: ${goDuration(s.kubeProxy.syncPeriod)}`,
|
|
139
|
+
""
|
|
140
|
+
].join("\n");
|
|
141
|
+
/** This function generates the configuration of kind from a scenario. It refuses a scenario with a problem. */
|
|
142
|
+
const kindConfig = (s) => {
|
|
143
|
+
const problems = scenarioProblems(s);
|
|
144
|
+
if (problems.length > 0) throw new Error(`the scenario ${s.name}: ${problems.join("; ")}`);
|
|
145
|
+
const image = NODE_IMAGES[s.kubernetes].image;
|
|
146
|
+
return {
|
|
147
|
+
kind: "Cluster",
|
|
148
|
+
apiVersion: "kind.x-k8s.io/v1alpha4",
|
|
149
|
+
networking: {
|
|
150
|
+
ipFamily: "ipv4",
|
|
151
|
+
kubeProxyMode: s.kubeProxy.mode,
|
|
152
|
+
disableDefaultCNI: false,
|
|
153
|
+
podSubnet: s.podSubnet,
|
|
154
|
+
serviceSubnet: s.serviceSubnet
|
|
155
|
+
},
|
|
156
|
+
nodes: nodeNames(s).map((node, at) => ({
|
|
157
|
+
role: at === 0 ? "control-plane" : "worker",
|
|
158
|
+
image,
|
|
159
|
+
labels: { "lab.systems/node": node.slice(s.name.length + 1) }
|
|
160
|
+
})),
|
|
161
|
+
kubeadmConfigPatches: [kubeProxyPatch(s)]
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
/** This function gives the first and the last address of an IPv4 CIDR, as numbers. */
|
|
165
|
+
const cidrRange = (cidr) => {
|
|
166
|
+
const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/.exec(cidr);
|
|
167
|
+
if (match === null) throw new Error(`not an IPv4 CIDR: ${cidr}`);
|
|
168
|
+
const octets = match.slice(1, 5).map(Number);
|
|
169
|
+
const bits = Number(match[5]);
|
|
170
|
+
if (octets.some((octet) => octet > 255) || bits > 32) throw new Error(`not an IPv4 CIDR: ${cidr}`);
|
|
171
|
+
const base = octets.reduce((sum, octet) => sum * 256 + octet, 0);
|
|
172
|
+
const size = 2 ** (32 - bits);
|
|
173
|
+
const first = Math.floor(base / size) * size;
|
|
174
|
+
return [first, first + size - 1];
|
|
175
|
+
};
|
|
176
|
+
/** This function tells if two IPv4 CIDRs share an address. */
|
|
177
|
+
const cidrOverlap = (a, b) => {
|
|
178
|
+
const [a0, a1] = cidrRange(a);
|
|
179
|
+
const [b0, b1] = cidrRange(b);
|
|
180
|
+
return a0 <= b1 && b0 <= a1;
|
|
181
|
+
};
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/tools.ts
|
|
184
|
+
/** This release of kind is v0.33.0, from 2026-08-26. Its default node image is Kubernetes 1.37.0. The sums are from its `.sha256sum` assets. */
|
|
185
|
+
const KIND = {
|
|
186
|
+
name: "kind",
|
|
187
|
+
version: "v0.33.0",
|
|
188
|
+
assets: {
|
|
189
|
+
"win32-x64": {
|
|
190
|
+
url: "https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-windows-amd64",
|
|
191
|
+
sha256: "4b22adaa135368c5a465d56bbd8e520cbea87272a06ca00b6078e7b81515c9fc"
|
|
192
|
+
},
|
|
193
|
+
"linux-x64": {
|
|
194
|
+
url: "https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-linux-amd64",
|
|
195
|
+
sha256: "aee6151561422756b764a4ae28e7f44cda5af5a9eead3cc9985112b1de8d8e0d"
|
|
196
|
+
},
|
|
197
|
+
"linux-arm64": {
|
|
198
|
+
url: "https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-linux-arm64",
|
|
199
|
+
sha256: "20022bee6cfcd5086cb7234d218e3454e6090022f2a8f55d1fa7fcf42c3867a2"
|
|
200
|
+
},
|
|
201
|
+
"darwin-arm64": {
|
|
202
|
+
url: "https://github.com/kubernetes-sigs/kind/releases/download/v0.33.0/kind-darwin-arm64",
|
|
203
|
+
sha256: "0c8c7dbe5e23594a198b786c4bc13dacc101fa6196b0cb0b23a1ca44e61f4b4f"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
/** This release of kubectl is v1.37.0, the version of the node image. The sums are from the `.sha256` files of `dl.k8s.io`. */
|
|
208
|
+
const KUBECTL = {
|
|
209
|
+
name: "kubectl",
|
|
210
|
+
version: "v1.37.0",
|
|
211
|
+
assets: {
|
|
212
|
+
"win32-x64": {
|
|
213
|
+
url: "https://dl.k8s.io/release/v1.37.0/bin/windows/amd64/kubectl.exe",
|
|
214
|
+
sha256: "4721b614a67bb4932a0369e61f4a323d8c6ca00943d3a2ff14837c124da06f0e"
|
|
215
|
+
},
|
|
216
|
+
"linux-x64": {
|
|
217
|
+
url: "https://dl.k8s.io/release/v1.37.0/bin/linux/amd64/kubectl",
|
|
218
|
+
sha256: "6129359f4e1f3848a5572ccb0b26cf28b8ca08cef38c95a765b2f64a2c961a2f"
|
|
219
|
+
},
|
|
220
|
+
"linux-arm64": {
|
|
221
|
+
url: "https://dl.k8s.io/release/v1.37.0/bin/linux/arm64/kubectl",
|
|
222
|
+
sha256: "922df28df248cc00a9e025f947704f1d1482de64ece54cfe57e61f19eaf1eef3"
|
|
223
|
+
},
|
|
224
|
+
"darwin-arm64": {
|
|
225
|
+
url: "https://dl.k8s.io/release/v1.37.0/bin/darwin/arm64/kubectl",
|
|
226
|
+
sha256: "583beedaebe422e71d3f1a96acef8b1fef86ea2f09a45ad01aa6c9ce287c1380"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/** This folder is the cache of the tools: `.cache/` in this package, which git ignores. `KIND_CACHE` names another folder. */
|
|
231
|
+
const cacheFolder = () => process.env["KIND_CACHE"] ?? fileURLToPath(new URL("../.cache/", import.meta.url));
|
|
232
|
+
/** This function gives the platform key of this process, as the assets name it. */
|
|
233
|
+
const platformKey = (platform = process.platform, arch = process.arch) => `${platform}-${arch}`;
|
|
234
|
+
/** This function gives the sha256 of some bytes, in hexadecimal. */
|
|
235
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
236
|
+
/** This function gives the path of a tool in the cache, for a platform. */
|
|
237
|
+
const toolPath = (tool, key = platformKey(), cache = cacheFolder()) => join(cache, "tools", `${tool.name}-${tool.version}-${key}`, key.startsWith("win32") ? `${tool.name}.exe` : tool.name);
|
|
238
|
+
/**
|
|
239
|
+
* 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.
|
|
241
|
+
*/
|
|
242
|
+
const ensureTool = async (tool, key = platformKey(), cache = cacheFolder()) => {
|
|
243
|
+
const asset = tool.assets[key];
|
|
244
|
+
if (asset === void 0) throw new Error(`${tool.name} ${tool.version} has no pinned asset for ${key}`);
|
|
245
|
+
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;
|
|
250
|
+
}
|
|
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);
|
|
261
|
+
return path;
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/cluster.ts
|
|
265
|
+
const run = promisify(execFile);
|
|
266
|
+
/** This function starts a program and gives its standard output. A failure gives its standard error in the message. */
|
|
267
|
+
const command = async (program, args, options = {}) => {
|
|
268
|
+
try {
|
|
269
|
+
const child = run(program, [...args], {
|
|
270
|
+
env: {
|
|
271
|
+
...process.env,
|
|
272
|
+
...options.env
|
|
273
|
+
},
|
|
274
|
+
maxBuffer: 256 << 20,
|
|
275
|
+
timeout: options.timeout ?? 6e5,
|
|
276
|
+
windowsHide: true
|
|
277
|
+
});
|
|
278
|
+
if (options.input !== void 0) child.child.stdin?.end(options.input);
|
|
279
|
+
const { stdout } = await child;
|
|
280
|
+
return stdout.toString();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const failed = error;
|
|
283
|
+
throw new Error(`${program} ${args.join(" ")} failed: ${(failed.stderr ?? failed.message ?? "").toString().trim().slice(0, 2e3)}`);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
/** This function gives the subnets of the Docker networks, by their names. */
|
|
287
|
+
const dockerSubnets = async () => {
|
|
288
|
+
const ids = (await command("docker", [
|
|
289
|
+
"network",
|
|
290
|
+
"ls",
|
|
291
|
+
"-q"
|
|
292
|
+
])).split(/\s+/).filter((id) => id !== "");
|
|
293
|
+
if (ids.length === 0) return [];
|
|
294
|
+
return JSON.parse(await command("docker", [
|
|
295
|
+
"network",
|
|
296
|
+
"inspect",
|
|
297
|
+
...ids
|
|
298
|
+
])).flatMap((network) => (network.IPAM?.Config ?? []).map((config) => config.Subnet ?? "").filter((subnet) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(subnet)).map((subnet) => [network.Name, subnet]));
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* This function makes a kind cluster from a scenario, in a folder that holds its config, its
|
|
302
|
+
* kubeconfig and the cache of kubectl. The cluster gets a Docker network of its own. The function
|
|
303
|
+
* refuses a pod or service subnet that overlaps a Docker network, because that breaks the traffic
|
|
304
|
+
* between the nodes.
|
|
305
|
+
*/
|
|
306
|
+
const createCluster = async (scenario, dir) => {
|
|
307
|
+
const kind = await ensureTool(KIND);
|
|
308
|
+
const kubectlPath = await ensureTool(KUBECTL);
|
|
309
|
+
mkdirSync(dir, { recursive: true });
|
|
310
|
+
const kubeconfig = join(dir, "kubeconfig");
|
|
311
|
+
const network = `systems-${scenario.name}`;
|
|
312
|
+
const config = kindConfig(scenario);
|
|
313
|
+
for (const [name, subnet] of await dockerSubnets()) {
|
|
314
|
+
if (name === network) continue;
|
|
315
|
+
for (const ours of [scenario.podSubnet, scenario.serviceSubnet]) if (cidrOverlap(ours, subnet)) throw new Error(`the subnet ${ours} overlaps the Docker network ${name} (${subnet})`);
|
|
316
|
+
}
|
|
317
|
+
const file = join(dir, "kind.json");
|
|
318
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
319
|
+
const env = { KIND_EXPERIMENTAL_DOCKER_NETWORK: network };
|
|
320
|
+
const nodes = nodeNames(scenario);
|
|
321
|
+
const kubectl = (args, input) => command(kubectlPath, [
|
|
322
|
+
"--kubeconfig",
|
|
323
|
+
kubeconfig,
|
|
324
|
+
"--cache-dir",
|
|
325
|
+
join(dir, "kubectl-cache"),
|
|
326
|
+
...args
|
|
327
|
+
], input === void 0 ? {} : { input });
|
|
328
|
+
const cluster = {
|
|
329
|
+
scenario,
|
|
330
|
+
dir,
|
|
331
|
+
kubeconfig,
|
|
332
|
+
network,
|
|
333
|
+
nodes,
|
|
334
|
+
kubectl,
|
|
335
|
+
async apply(objects) {
|
|
336
|
+
await kubectl([
|
|
337
|
+
"apply",
|
|
338
|
+
"-f",
|
|
339
|
+
"-"
|
|
340
|
+
], JSON.stringify({
|
|
341
|
+
apiVersion: "v1",
|
|
342
|
+
kind: "List",
|
|
343
|
+
items: objects
|
|
344
|
+
}));
|
|
345
|
+
},
|
|
346
|
+
exec: (node, argv) => command("docker", [
|
|
347
|
+
"exec",
|
|
348
|
+
node,
|
|
349
|
+
...argv
|
|
350
|
+
]),
|
|
351
|
+
async load(image) {
|
|
352
|
+
await command(kind, [
|
|
353
|
+
"load",
|
|
354
|
+
"docker-image",
|
|
355
|
+
"--name",
|
|
356
|
+
scenario.name,
|
|
357
|
+
image
|
|
358
|
+
], {
|
|
359
|
+
env,
|
|
360
|
+
timeout: 9e5
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
async credentials() {
|
|
364
|
+
const internal = join(dir, "kubeconfig.internal");
|
|
365
|
+
writeFileSync(internal, await command(kind, [
|
|
366
|
+
"get",
|
|
367
|
+
"kubeconfig",
|
|
368
|
+
"--internal",
|
|
369
|
+
"--name",
|
|
370
|
+
scenario.name
|
|
371
|
+
], { env }));
|
|
372
|
+
const view = JSON.parse(await command(kubectlPath, [
|
|
373
|
+
"--kubeconfig",
|
|
374
|
+
internal,
|
|
375
|
+
"config",
|
|
376
|
+
"view",
|
|
377
|
+
"--raw",
|
|
378
|
+
"-o",
|
|
379
|
+
"json"
|
|
380
|
+
]));
|
|
381
|
+
return {
|
|
382
|
+
server: view.clusters[0].cluster.server,
|
|
383
|
+
ca: view.clusters[0].cluster["certificate-authority-data"],
|
|
384
|
+
cert: view.users[0].user["client-certificate-data"],
|
|
385
|
+
key: view.users[0].user["client-key-data"]
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
async manifest() {
|
|
389
|
+
const nodesJson = JSON.parse(await kubectl([
|
|
390
|
+
"get",
|
|
391
|
+
"nodes",
|
|
392
|
+
"-o",
|
|
393
|
+
"json"
|
|
394
|
+
]));
|
|
395
|
+
const proxy = JSON.parse(await kubectl([
|
|
396
|
+
"-n",
|
|
397
|
+
"kube-system",
|
|
398
|
+
"get",
|
|
399
|
+
"configmap",
|
|
400
|
+
"kube-proxy",
|
|
401
|
+
"-o",
|
|
402
|
+
"json"
|
|
403
|
+
]));
|
|
404
|
+
const mode = /^mode:\s*(\S*)/m.exec(proxy.data["config.conf"] ?? "")?.[1] ?? "";
|
|
405
|
+
const version = JSON.parse(await kubectl([
|
|
406
|
+
"version",
|
|
407
|
+
"-o",
|
|
408
|
+
"json"
|
|
409
|
+
]));
|
|
410
|
+
return {
|
|
411
|
+
kind: (await command(kind, ["version"])).trim(),
|
|
412
|
+
nodeImage: NODE_IMAGES[scenario.kubernetes].image,
|
|
413
|
+
server: version.serverVersion?.gitVersion ?? "",
|
|
414
|
+
mode: mode.replaceAll("\"", ""),
|
|
415
|
+
docker: (await command("docker", [
|
|
416
|
+
"version",
|
|
417
|
+
"--format",
|
|
418
|
+
"{{.Server.Version}}"
|
|
419
|
+
])).trim(),
|
|
420
|
+
nodes: nodesJson.items.map((item) => ({
|
|
421
|
+
name: item.metadata.name,
|
|
422
|
+
kubelet: item.status.nodeInfo["kubeletVersion"] ?? "",
|
|
423
|
+
kernel: item.status.nodeInfo["kernelVersion"] ?? "",
|
|
424
|
+
runtime: item.status.nodeInfo["containerRuntimeVersion"] ?? "",
|
|
425
|
+
os: item.status.nodeInfo["osImage"] ?? ""
|
|
426
|
+
}))
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
async delete() {
|
|
430
|
+
await command(kind, [
|
|
431
|
+
"delete",
|
|
432
|
+
"cluster",
|
|
433
|
+
"--name",
|
|
434
|
+
scenario.name,
|
|
435
|
+
"--kubeconfig",
|
|
436
|
+
kubeconfig
|
|
437
|
+
], { env });
|
|
438
|
+
await command("docker", [
|
|
439
|
+
"network",
|
|
440
|
+
"rm",
|
|
441
|
+
network
|
|
442
|
+
]).catch(() => void 0);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
await command(kind, [
|
|
446
|
+
"create",
|
|
447
|
+
"cluster",
|
|
448
|
+
"--name",
|
|
449
|
+
scenario.name,
|
|
450
|
+
"--config",
|
|
451
|
+
file,
|
|
452
|
+
"--kubeconfig",
|
|
453
|
+
kubeconfig,
|
|
454
|
+
"--wait",
|
|
455
|
+
"180s"
|
|
456
|
+
], {
|
|
457
|
+
env,
|
|
458
|
+
timeout: 9e5
|
|
459
|
+
});
|
|
460
|
+
return cluster;
|
|
461
|
+
};
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/compose.ts
|
|
464
|
+
/** This name is the controller of the service, and its one route reads Redis when the service has Redis. */
|
|
465
|
+
const CONTROLLER = "work";
|
|
466
|
+
const ROUTE = "GET /work";
|
|
467
|
+
/** This name is the check that a flag file fails: the seam of the liveness restart. */
|
|
468
|
+
const FLAG_CHECK = "self.flag";
|
|
469
|
+
/** This name is the watchdog of the event loop. */
|
|
470
|
+
const WATCHDOG = "self.event-loop";
|
|
471
|
+
/**
|
|
472
|
+
* This function composes the service of a declaration: its policies, its couplings, its checks,
|
|
473
|
+
* one server, one controller and each stage of the shutdown. A declaration with Redis needs the
|
|
474
|
+
* client, because the client declares the dependency and its checks.
|
|
475
|
+
*/
|
|
476
|
+
const compose = (name, declaration, parts = {}) => {
|
|
477
|
+
const svc = service(name).readiness(declaration.readiness).liveness(declaration.liveness).startup(declaration.startup).cooperativeWindow(declaration.cooperativeWindow);
|
|
478
|
+
for (const coupling of declaration.couplings) svc.couple(coupling);
|
|
479
|
+
if (declaration.redis !== null) {
|
|
480
|
+
if (parts.redis === void 0) throw new Error(`${name} declares Redis, thus its composition needs the client`);
|
|
481
|
+
parts.redis.attach(svc, {
|
|
482
|
+
poll: declaration.poll,
|
|
483
|
+
steps: { "round-trip": declaration.redis.gates }
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (declaration.flag !== null) svc.check({
|
|
487
|
+
name: FLAG_CHECK,
|
|
488
|
+
dependency: null,
|
|
489
|
+
step: "local-state",
|
|
490
|
+
ownConnection: true,
|
|
491
|
+
gates: declaration.flag,
|
|
492
|
+
poll: declaration.poll
|
|
493
|
+
}, parts.flag);
|
|
494
|
+
if (declaration.watchdog !== null) svc.watchdog({
|
|
495
|
+
name: WATCHDOG,
|
|
496
|
+
tick: declaration.watchdog.tick,
|
|
497
|
+
threshold: declaration.watchdog.threshold,
|
|
498
|
+
gates: "liveness",
|
|
499
|
+
poll: declaration.poll
|
|
500
|
+
});
|
|
501
|
+
svc.server("http", {}, parts.http);
|
|
502
|
+
const controller = svc.controller(CONTROLLER);
|
|
503
|
+
if (declaration.redis !== null) controller.requires(parts.redis.name);
|
|
504
|
+
controller.route(ROUTE, { idempotent: true });
|
|
505
|
+
if (parts.health !== void 0) svc.healthServer(parts.health);
|
|
506
|
+
for (const [stage, cap] of declaration.phases) svc.phase(stage, cap);
|
|
507
|
+
return svc;
|
|
508
|
+
};
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/faults.ts
|
|
511
|
+
const needs = (time, what) => {
|
|
512
|
+
if (time === void 0) throw new Error(`the trigger needs ${what}`);
|
|
513
|
+
return time;
|
|
514
|
+
};
|
|
515
|
+
/** This function gives an amount in microseconds as a whole number, for `cpu.max`. */
|
|
516
|
+
const micros = (amount) => Math.round(valueIn(amount, "us"));
|
|
517
|
+
/** This value marks the name of the target pod in the arguments of kubectl. The runner replaces it. */
|
|
518
|
+
const POD = "{pod}";
|
|
519
|
+
/** This value marks the node of the target pod in the arguments of kubectl. */
|
|
520
|
+
const NODE = "{node}";
|
|
521
|
+
/** This value marks the next version of the Deployment, for a rollout. */
|
|
522
|
+
const VERSION = "{version}";
|
|
523
|
+
/** This function gives the fault plan of a cell. */
|
|
524
|
+
const planOf = (cell) => {
|
|
525
|
+
const ns = ["-n", "{namespace}"];
|
|
526
|
+
const trigger = cell.trigger;
|
|
527
|
+
const replicas = cell.pod.replicas;
|
|
528
|
+
switch (trigger.kind) {
|
|
529
|
+
case "pod-deletion": return {
|
|
530
|
+
inject: [{
|
|
531
|
+
do: "kubectl",
|
|
532
|
+
args: [
|
|
533
|
+
...ns,
|
|
534
|
+
"delete",
|
|
535
|
+
"pod",
|
|
536
|
+
POD,
|
|
537
|
+
"--wait=false"
|
|
538
|
+
]
|
|
539
|
+
}],
|
|
540
|
+
hold: null,
|
|
541
|
+
heal: [],
|
|
542
|
+
restore: []
|
|
543
|
+
};
|
|
544
|
+
case "rolling-update": return {
|
|
545
|
+
inject: [{
|
|
546
|
+
do: "kubectl",
|
|
547
|
+
args: [
|
|
548
|
+
...ns,
|
|
549
|
+
"set",
|
|
550
|
+
"env",
|
|
551
|
+
`deployment/lab`,
|
|
552
|
+
`LAB_VERSION=${VERSION}`
|
|
553
|
+
]
|
|
554
|
+
}],
|
|
555
|
+
hold: null,
|
|
556
|
+
heal: [],
|
|
557
|
+
restore: []
|
|
558
|
+
};
|
|
559
|
+
case "node-drain": return {
|
|
560
|
+
inject: [{
|
|
561
|
+
do: "kubectl",
|
|
562
|
+
args: [
|
|
563
|
+
"drain",
|
|
564
|
+
NODE,
|
|
565
|
+
"--ignore-daemonsets",
|
|
566
|
+
"--delete-emptydir-data",
|
|
567
|
+
"--timeout=180s",
|
|
568
|
+
`--pod-selector=app=lab`
|
|
569
|
+
],
|
|
570
|
+
background: true
|
|
571
|
+
}],
|
|
572
|
+
hold: null,
|
|
573
|
+
heal: [],
|
|
574
|
+
restore: [{
|
|
575
|
+
do: "kubectl",
|
|
576
|
+
args: ["uncordon", NODE]
|
|
577
|
+
}]
|
|
578
|
+
};
|
|
579
|
+
case "scale-in": return {
|
|
580
|
+
inject: [{
|
|
581
|
+
do: "kubectl",
|
|
582
|
+
args: [
|
|
583
|
+
...ns,
|
|
584
|
+
"scale",
|
|
585
|
+
`deployment/lab`,
|
|
586
|
+
`--replicas=${replicas - 1}`
|
|
587
|
+
]
|
|
588
|
+
}],
|
|
589
|
+
hold: null,
|
|
590
|
+
heal: [],
|
|
591
|
+
restore: [{
|
|
592
|
+
do: "kubectl",
|
|
593
|
+
args: [
|
|
594
|
+
...ns,
|
|
595
|
+
"scale",
|
|
596
|
+
`deployment/lab`,
|
|
597
|
+
`--replicas=${replicas}`
|
|
598
|
+
]
|
|
599
|
+
}]
|
|
600
|
+
};
|
|
601
|
+
case "crash": return {
|
|
602
|
+
inject: [{
|
|
603
|
+
do: "signal",
|
|
604
|
+
target: "server",
|
|
605
|
+
signal: "SIGKILL"
|
|
606
|
+
}],
|
|
607
|
+
hold: null,
|
|
608
|
+
heal: [],
|
|
609
|
+
restore: []
|
|
610
|
+
};
|
|
611
|
+
case "liveness-restart": return {
|
|
612
|
+
inject: [{
|
|
613
|
+
do: "flag",
|
|
614
|
+
target: "server",
|
|
615
|
+
on: true
|
|
616
|
+
}],
|
|
617
|
+
hold: null,
|
|
618
|
+
heal: [],
|
|
619
|
+
restore: []
|
|
620
|
+
};
|
|
621
|
+
case "freeze-process":
|
|
622
|
+
if (cell.template === "T07") return {
|
|
623
|
+
inject: [
|
|
624
|
+
{
|
|
625
|
+
do: "pause",
|
|
626
|
+
target: "kube-proxy",
|
|
627
|
+
time: needs(trigger.hold, "a hold")
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
do: "wait",
|
|
631
|
+
time: needs(trigger.lead, "a lead")
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
do: "kubectl",
|
|
635
|
+
args: [
|
|
636
|
+
...ns,
|
|
637
|
+
"delete",
|
|
638
|
+
"pod",
|
|
639
|
+
POD,
|
|
640
|
+
"--wait=false"
|
|
641
|
+
]
|
|
642
|
+
}
|
|
643
|
+
],
|
|
644
|
+
hold: null,
|
|
645
|
+
heal: [],
|
|
646
|
+
restore: []
|
|
647
|
+
};
|
|
648
|
+
return {
|
|
649
|
+
inject: [{
|
|
650
|
+
do: "signal",
|
|
651
|
+
target: "redis",
|
|
652
|
+
signal: "SIGSTOP"
|
|
653
|
+
}],
|
|
654
|
+
hold: needs(trigger.hold, "a hold"),
|
|
655
|
+
heal: [{
|
|
656
|
+
do: "signal",
|
|
657
|
+
target: "redis",
|
|
658
|
+
signal: "SIGCONT"
|
|
659
|
+
}],
|
|
660
|
+
restore: []
|
|
661
|
+
};
|
|
662
|
+
case "rst": return {
|
|
663
|
+
inject: [{
|
|
664
|
+
do: "inject",
|
|
665
|
+
target: "redis",
|
|
666
|
+
action: "rst"
|
|
667
|
+
}],
|
|
668
|
+
hold: needs(trigger.hold, "a hold"),
|
|
669
|
+
heal: [{
|
|
670
|
+
do: "inject",
|
|
671
|
+
target: "redis",
|
|
672
|
+
action: "clear-rst"
|
|
673
|
+
}],
|
|
674
|
+
restore: []
|
|
675
|
+
};
|
|
676
|
+
case "blackhole": return {
|
|
677
|
+
inject: [{
|
|
678
|
+
do: "inject",
|
|
679
|
+
target: "redis",
|
|
680
|
+
action: "blackhole"
|
|
681
|
+
}],
|
|
682
|
+
hold: needs(trigger.hold, "a hold"),
|
|
683
|
+
heal: [{
|
|
684
|
+
do: "inject",
|
|
685
|
+
target: "redis",
|
|
686
|
+
action: "clear"
|
|
687
|
+
}],
|
|
688
|
+
restore: []
|
|
689
|
+
};
|
|
690
|
+
case "peer-gone-without-close": return {
|
|
691
|
+
inject: [{
|
|
692
|
+
do: "inject",
|
|
693
|
+
target: "redis",
|
|
694
|
+
action: "blackhole"
|
|
695
|
+
}, {
|
|
696
|
+
do: "kubectl",
|
|
697
|
+
args: [
|
|
698
|
+
...ns,
|
|
699
|
+
"scale",
|
|
700
|
+
`deployment/${REDIS}`,
|
|
701
|
+
"--replicas=0"
|
|
702
|
+
]
|
|
703
|
+
}],
|
|
704
|
+
hold: needs(trigger.hold, "a hold"),
|
|
705
|
+
heal: [{
|
|
706
|
+
do: "kubectl",
|
|
707
|
+
args: [
|
|
708
|
+
...ns,
|
|
709
|
+
"scale",
|
|
710
|
+
`deployment/${REDIS}`,
|
|
711
|
+
"--replicas=1"
|
|
712
|
+
]
|
|
713
|
+
}],
|
|
714
|
+
restore: []
|
|
715
|
+
};
|
|
716
|
+
case "app-stall": return {
|
|
717
|
+
inject: [{
|
|
718
|
+
do: "stall",
|
|
719
|
+
target: "server",
|
|
720
|
+
time: needs(trigger.hold, "a hold")
|
|
721
|
+
}],
|
|
722
|
+
hold: null,
|
|
723
|
+
heal: [],
|
|
724
|
+
restore: []
|
|
725
|
+
};
|
|
726
|
+
case "rollout": return {
|
|
727
|
+
inject: [{
|
|
728
|
+
do: "kubectl",
|
|
729
|
+
args: [
|
|
730
|
+
...ns,
|
|
731
|
+
"scale",
|
|
732
|
+
`deployment/${REDIS}`,
|
|
733
|
+
"--replicas=0"
|
|
734
|
+
]
|
|
735
|
+
}, {
|
|
736
|
+
do: "kubectl",
|
|
737
|
+
args: [
|
|
738
|
+
...ns,
|
|
739
|
+
"set",
|
|
740
|
+
"env",
|
|
741
|
+
`deployment/lab`,
|
|
742
|
+
`LAB_VERSION=${VERSION}`
|
|
743
|
+
]
|
|
744
|
+
}],
|
|
745
|
+
hold: needs(trigger.hold, "a hold"),
|
|
746
|
+
heal: [{
|
|
747
|
+
do: "kubectl",
|
|
748
|
+
args: [
|
|
749
|
+
...ns,
|
|
750
|
+
"scale",
|
|
751
|
+
`deployment/${REDIS}`,
|
|
752
|
+
"--replicas=1"
|
|
753
|
+
]
|
|
754
|
+
}],
|
|
755
|
+
restore: []
|
|
756
|
+
};
|
|
757
|
+
case "cpu-throttle": {
|
|
758
|
+
const max = trigger.cpuMax;
|
|
759
|
+
if (max === void 0) throw new Error("the trigger needs its cpu.max");
|
|
760
|
+
return {
|
|
761
|
+
inject: [{
|
|
762
|
+
do: "cpu",
|
|
763
|
+
target: "server",
|
|
764
|
+
max: `${micros(max.quota)} ${micros(max.period)}`
|
|
765
|
+
}],
|
|
766
|
+
hold: needs(trigger.hold, "a hold"),
|
|
767
|
+
heal: [{
|
|
768
|
+
do: "cpu",
|
|
769
|
+
target: "server",
|
|
770
|
+
max: `max ${micros(max.period)}`
|
|
771
|
+
}],
|
|
772
|
+
restore: []
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
/** This function tells if a trigger stops a pod of the service with its grace: a deletion, an eviction, a scale-in or a rollout. */
|
|
778
|
+
const deletes = (cell) => [
|
|
779
|
+
"pod-deletion",
|
|
780
|
+
"rolling-update",
|
|
781
|
+
"node-drain",
|
|
782
|
+
"scale-in"
|
|
783
|
+
].includes(cell.trigger.kind) || cell.template === "T07" && cell.trigger.kind === "freeze-process";
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/image.ts
|
|
786
|
+
/** This folder is the root of the package, and its `image/` folder has the files of the image. */
|
|
787
|
+
const PACKAGE = fileURLToPath(new URL("../", import.meta.url));
|
|
788
|
+
/** This folder is the root of the repository, when the package is in the workspace of the repository. */
|
|
789
|
+
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
|
790
|
+
/** These are the packages of this repository that the image holds, in the sequence of their dependencies. */
|
|
791
|
+
const WORKSPACE_PACKAGES = [
|
|
792
|
+
"quantity",
|
|
793
|
+
"algebra",
|
|
794
|
+
"service"
|
|
795
|
+
];
|
|
796
|
+
/** These are the files of the image, by their name in the context and their source in the package. */
|
|
797
|
+
const FILES = [
|
|
798
|
+
["Dockerfile", "image/Dockerfile"],
|
|
799
|
+
["package.json", "image/package.json"],
|
|
800
|
+
["npm-lock.json", "image/npm-lock.json"],
|
|
801
|
+
["app.mjs", "image/app.mjs"],
|
|
802
|
+
["client.mjs", "image/client.mjs"],
|
|
803
|
+
["agent.mjs", "image/agent.mjs"],
|
|
804
|
+
["watch.mjs", "image/watch.mjs"],
|
|
805
|
+
["compose.ts", "src/compose.ts"],
|
|
806
|
+
["backends.ts", "src/backends.ts"]
|
|
807
|
+
];
|
|
808
|
+
const shell = process.platform === "win32";
|
|
809
|
+
/** This function gives each file of a staged context, with its path in the context, in a stable sequence. */
|
|
810
|
+
const filesOf = (root, at = "") => readdirSync(join(root, at), { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? filesOf(root, join(at, entry.name)) : [join(at, entry.name)]).sort();
|
|
811
|
+
/** This function gives the tag of a staged context from its content: the name, then 12 digits of a sha256. */
|
|
812
|
+
const contentTag = (name, root) => {
|
|
813
|
+
const hash = createHash("sha256");
|
|
814
|
+
for (const file of filesOf(root)) {
|
|
815
|
+
hash.update(file.replaceAll("\\", "/"));
|
|
816
|
+
hash.update(readFileSync(join(root, file)));
|
|
817
|
+
}
|
|
818
|
+
return `${name}:${hash.digest("hex").slice(0, 12)}`;
|
|
819
|
+
};
|
|
820
|
+
/** This function tells if the package is in the workspace of its repository. From npm, it is not. */
|
|
821
|
+
const inWorkspace = () => existsSync(join(REPO, "pnpm-workspace.yaml")) && WORKSPACE_PACKAGES.every((name) => existsSync(join(REPO, "packages", name, "package.json")));
|
|
822
|
+
/**
|
|
823
|
+
* This function gives the folder of each installed package that the image holds. Node finds a
|
|
824
|
+
* package from the folder of a package that depends on it. The service depends on the algebra,
|
|
825
|
+
* thus the search starts with the service.
|
|
826
|
+
*/
|
|
827
|
+
const installedFolders = () => {
|
|
828
|
+
const folders = /* @__PURE__ */ new Map();
|
|
829
|
+
for (const name of [...WORKSPACE_PACKAGES].reverse()) {
|
|
830
|
+
const found = [PACKAGE, ...folders.values()].flatMap((from) => {
|
|
831
|
+
try {
|
|
832
|
+
return [dirname(realpathSync(createRequire(join(from, "package.json")).resolve(`@mark1russell7/${name}/package.json`)))];
|
|
833
|
+
} catch {
|
|
834
|
+
return [];
|
|
835
|
+
}
|
|
836
|
+
})[0];
|
|
837
|
+
if (found === void 0) throw new Error(`@mark1russell7/${name} is not installed with @mark1russell7/kind`);
|
|
838
|
+
folders.set(name, found);
|
|
839
|
+
}
|
|
840
|
+
return folders;
|
|
841
|
+
};
|
|
842
|
+
/**
|
|
843
|
+
* This function stages the build context of the lab image. In the workspace, it builds the packages
|
|
844
|
+
* of the repository and packs each one as npm gets it. From npm, it packs the installed packages.
|
|
845
|
+
* Then it copies the files of the image.
|
|
846
|
+
*/
|
|
847
|
+
const stageLabImage = () => {
|
|
848
|
+
const context = mkdtempSync(join(tmpdir(), "systems-kind-lab-"));
|
|
849
|
+
const packed = join(context, "workspace");
|
|
850
|
+
mkdirSync(packed);
|
|
851
|
+
if (inWorkspace()) {
|
|
852
|
+
const filters = WORKSPACE_PACKAGES.flatMap((name) => ["--filter", `@mark1russell7/${name}`]);
|
|
853
|
+
execFileSync("pnpm", [...filters, "build"], {
|
|
854
|
+
cwd: REPO,
|
|
855
|
+
shell,
|
|
856
|
+
stdio: "ignore"
|
|
857
|
+
});
|
|
858
|
+
for (const name of WORKSPACE_PACKAGES) execFileSync("pnpm", [
|
|
859
|
+
"pack",
|
|
860
|
+
"--pack-destination",
|
|
861
|
+
packed
|
|
862
|
+
], {
|
|
863
|
+
cwd: join(REPO, "packages", name),
|
|
864
|
+
shell,
|
|
865
|
+
stdio: "ignore"
|
|
866
|
+
});
|
|
867
|
+
} else for (const folder of installedFolders().values()) execFileSync("npm", [
|
|
868
|
+
"pack",
|
|
869
|
+
"--pack-destination",
|
|
870
|
+
packed,
|
|
871
|
+
"--ignore-scripts"
|
|
872
|
+
], {
|
|
873
|
+
cwd: folder,
|
|
874
|
+
shell,
|
|
875
|
+
stdio: "ignore"
|
|
876
|
+
});
|
|
877
|
+
for (const [at, from] of FILES) copyFileSync(join(PACKAGE, from), join(context, at));
|
|
878
|
+
return context;
|
|
879
|
+
};
|
|
880
|
+
let built = null;
|
|
881
|
+
/** This function builds the lab image one time for each process, and gives its tag. Docker keeps its layers between builds. */
|
|
882
|
+
const labImage = () => {
|
|
883
|
+
built ??= (async () => {
|
|
884
|
+
const context = stageLabImage();
|
|
885
|
+
try {
|
|
886
|
+
const tag = contentTag("systems-kind-lab", context);
|
|
887
|
+
const { GenericContainer } = await import("testcontainers");
|
|
888
|
+
await GenericContainer.fromDockerfile(context).build(tag, { deleteOnExit: false });
|
|
889
|
+
return tag;
|
|
890
|
+
} finally {
|
|
891
|
+
rmSync(context, {
|
|
892
|
+
recursive: true,
|
|
893
|
+
force: true
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
})();
|
|
897
|
+
return built;
|
|
898
|
+
};
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/lab.ts
|
|
901
|
+
/** These kinds of kernel record stay in the raw record of a run. The lab counts the other kinds and drops them. */
|
|
902
|
+
const KEPT_KERNEL = /* @__PURE__ */ new Set([
|
|
903
|
+
"rst_tx",
|
|
904
|
+
"rst_rx",
|
|
905
|
+
"rst_v4",
|
|
906
|
+
"error",
|
|
907
|
+
"retrans",
|
|
908
|
+
"begin",
|
|
909
|
+
"fail"
|
|
910
|
+
]);
|
|
911
|
+
/** This container class can share the process namespace of another container. */
|
|
912
|
+
const shared = async () => {
|
|
913
|
+
const { GenericContainer } = await import("testcontainers");
|
|
914
|
+
return class extends GenericContainer {
|
|
915
|
+
withPidMode(mode) {
|
|
916
|
+
this.hostConfig.PidMode = mode;
|
|
917
|
+
return this;
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
};
|
|
921
|
+
/** This function collects the JSON lines of a stream, and gives each one to `take`. */
|
|
922
|
+
const consume = (take) => (stream) => {
|
|
923
|
+
let partial = "";
|
|
924
|
+
stream.on("data", (chunk) => {
|
|
925
|
+
partial += chunk.toString();
|
|
926
|
+
const split = partial.split("\n");
|
|
927
|
+
partial = split.pop() ?? "";
|
|
928
|
+
for (const text of split) {
|
|
929
|
+
const trimmed = text.trim();
|
|
930
|
+
if (!trimmed.startsWith("{")) continue;
|
|
931
|
+
try {
|
|
932
|
+
take(JSON.parse(trimmed));
|
|
933
|
+
} catch {}
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
/** This function gives a stamp of the injector of the harness, in nanoseconds, as milliseconds. */
|
|
938
|
+
const injectorStamp = (stdout) => {
|
|
939
|
+
const stamp = JSON.parse(stdout.trim().split("\n").at(-1));
|
|
940
|
+
return {
|
|
941
|
+
t0: Number(BigInt(stamp.t0)) / 1e6,
|
|
942
|
+
t1: Number(BigInt(stamp.t1)) / 1e6
|
|
943
|
+
};
|
|
944
|
+
};
|
|
945
|
+
const output = async (container, argv) => {
|
|
946
|
+
const result = await container.exec([...argv]);
|
|
947
|
+
if (result.exitCode !== 0) throw new Error(`${argv.join(" ")} exited ${result.exitCode}: ${result.output.trim().slice(0, 500)}`);
|
|
948
|
+
return result.stdout;
|
|
949
|
+
};
|
|
950
|
+
/**
|
|
951
|
+
* This function opens the instruments of a cluster. Each node gets the agent of the lab and the
|
|
952
|
+
* injector of the harness, privileged and in the PID namespace of the node. Thus the agent signals
|
|
953
|
+
* a process of a pod by its PID on the node. The injector enters the network namespace of a pod
|
|
954
|
+
* with `nsenter`.
|
|
955
|
+
*/
|
|
956
|
+
const openKindLab = async (cluster, image) => {
|
|
957
|
+
const harness = await harnessImage();
|
|
958
|
+
const Shared = await shared();
|
|
959
|
+
const { GenericContainer } = await import("testcontainers");
|
|
960
|
+
let lines = [];
|
|
961
|
+
const dropped = {};
|
|
962
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
963
|
+
const push = (line) => {
|
|
964
|
+
lines.push(line);
|
|
965
|
+
for (const listener of listeners) listener(line);
|
|
966
|
+
};
|
|
967
|
+
const sidecars = /* @__PURE__ */ new Map();
|
|
968
|
+
const opened = [];
|
|
969
|
+
let watcher = null;
|
|
970
|
+
let prober = null;
|
|
971
|
+
try {
|
|
972
|
+
for (const node of cluster.nodes) {
|
|
973
|
+
const agent = await new Shared(image).withPidMode(`container:${node}`).withPrivilegedMode().withNetworkMode("none").withEnvironment({ NODE_NAME: node }).withCommand([
|
|
974
|
+
"node",
|
|
975
|
+
"agent.mjs",
|
|
976
|
+
"serve"
|
|
977
|
+
]).withLogConsumer(consume((line) => push({
|
|
978
|
+
...line,
|
|
979
|
+
node
|
|
980
|
+
}))).start();
|
|
981
|
+
opened.push(agent);
|
|
982
|
+
const injector = await new Shared(harness).withPidMode(`container:${node}`).withPrivilegedMode().withNetworkMode("none").start();
|
|
983
|
+
opened.push(injector);
|
|
984
|
+
sidecars.set(node, {
|
|
985
|
+
agent,
|
|
986
|
+
injector
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
} catch (error) {
|
|
990
|
+
for (const container of opened.reverse()) await container.stop({ timeout: 0 }).catch(() => void 0);
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
const sidecarOf = (node) => {
|
|
994
|
+
const found = sidecars.get(node);
|
|
995
|
+
if (found === void 0) throw new Error(`the cluster has no node ${node}`);
|
|
996
|
+
return found;
|
|
997
|
+
};
|
|
998
|
+
const firstNode = cluster.nodes[0];
|
|
999
|
+
const lab = {
|
|
1000
|
+
cluster,
|
|
1001
|
+
image,
|
|
1002
|
+
get lines() {
|
|
1003
|
+
return lines;
|
|
1004
|
+
},
|
|
1005
|
+
dropped,
|
|
1006
|
+
own() {
|
|
1007
|
+
return [
|
|
1008
|
+
...opened,
|
|
1009
|
+
...watcher === null ? [] : [watcher],
|
|
1010
|
+
...prober === null ? [] : [prober]
|
|
1011
|
+
].map((container) => container.getName().replace(/^\//, ""));
|
|
1012
|
+
},
|
|
1013
|
+
take() {
|
|
1014
|
+
const out = lines;
|
|
1015
|
+
lines = [];
|
|
1016
|
+
return out;
|
|
1017
|
+
},
|
|
1018
|
+
listen(listener) {
|
|
1019
|
+
listeners.add(listener);
|
|
1020
|
+
return () => listeners.delete(listener);
|
|
1021
|
+
},
|
|
1022
|
+
async watch(namespaces) {
|
|
1023
|
+
if (watcher !== null) await watcher.stop({ timeout: 0 }).catch(() => void 0);
|
|
1024
|
+
const kube = await cluster.credentials();
|
|
1025
|
+
watcher = await new GenericContainer(image).withNetworkMode(cluster.network).withEnvironment({
|
|
1026
|
+
KUBE: JSON.stringify(kube),
|
|
1027
|
+
NAMESPACES: namespaces.join(",")
|
|
1028
|
+
}).withCommand(["node", "watch.mjs"]).withLogConsumer(consume((line) => push(line))).start();
|
|
1029
|
+
},
|
|
1030
|
+
async probe(netns) {
|
|
1031
|
+
await lab.stopProbe();
|
|
1032
|
+
let begun = () => {};
|
|
1033
|
+
const ready = new Promise((resolve, reject) => {
|
|
1034
|
+
begun = (ok, why) => ok ? resolve() : reject(/* @__PURE__ */ new Error(`the prober failed: ${why}`));
|
|
1035
|
+
});
|
|
1036
|
+
prober = await new GenericContainer(harness).withPrivilegedMode().withNetworkMode("none").withCommand(["/opt/harness/prober", ...netns.map(String)]).withLogConsumer(consume((record) => {
|
|
1037
|
+
const ev = String(record["ev"]);
|
|
1038
|
+
if (ev === "begin") begun(true, "");
|
|
1039
|
+
if (ev === "fail") begun(false, String(record["why"]));
|
|
1040
|
+
if (!KEPT_KERNEL.has(ev) && ev !== "clock") {
|
|
1041
|
+
dropped[ev] = (dropped[ev] ?? 0) + 1;
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const mono = Number(record["mono"] ?? 0);
|
|
1045
|
+
push({
|
|
1046
|
+
...record,
|
|
1047
|
+
ev: `kernel.${ev}`,
|
|
1048
|
+
t: mono / 1e6
|
|
1049
|
+
});
|
|
1050
|
+
})).start();
|
|
1051
|
+
await Promise.race([ready, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("no begin line from the prober in 60 s")), 6e4))]);
|
|
1052
|
+
},
|
|
1053
|
+
async stopProbe() {
|
|
1054
|
+
if (prober !== null) await prober.stop({ timeout: 0 }).catch(() => void 0);
|
|
1055
|
+
prober = null;
|
|
1056
|
+
},
|
|
1057
|
+
async rules(rule) {
|
|
1058
|
+
const file = rule === null ? null : JSON.stringify({
|
|
1059
|
+
...rule,
|
|
1060
|
+
every: valueIn(rule.every, "ms")
|
|
1061
|
+
});
|
|
1062
|
+
for (const { agent } of sidecars.values()) if (file === null) await output(agent, [
|
|
1063
|
+
"rm",
|
|
1064
|
+
"-f",
|
|
1065
|
+
"/tmp/rules.json"
|
|
1066
|
+
]);
|
|
1067
|
+
else await output(agent, [
|
|
1068
|
+
"sh",
|
|
1069
|
+
"-c",
|
|
1070
|
+
`printf '%s' '${file}' > /tmp/rules.json`
|
|
1071
|
+
]);
|
|
1072
|
+
},
|
|
1073
|
+
async act(node, spec) {
|
|
1074
|
+
const stdout = await output(sidecarOf(node).agent, [
|
|
1075
|
+
"node",
|
|
1076
|
+
"agent.mjs",
|
|
1077
|
+
"act",
|
|
1078
|
+
JSON.stringify(spec)
|
|
1079
|
+
]);
|
|
1080
|
+
const result = JSON.parse(stdout.trim().split("\n").at(-1));
|
|
1081
|
+
if (!result.ok) throw new Error(`the act ${JSON.stringify(spec)} on ${node} failed: ${result.why}`);
|
|
1082
|
+
push({
|
|
1083
|
+
ev: "act",
|
|
1084
|
+
t: result.t0,
|
|
1085
|
+
t1: result.t1,
|
|
1086
|
+
node,
|
|
1087
|
+
spec
|
|
1088
|
+
});
|
|
1089
|
+
return {
|
|
1090
|
+
t0: result.t0,
|
|
1091
|
+
t1: result.t1
|
|
1092
|
+
};
|
|
1093
|
+
},
|
|
1094
|
+
async inject(node, pid, args) {
|
|
1095
|
+
const stdout = await output(sidecarOf(node).injector, [
|
|
1096
|
+
"nsenter",
|
|
1097
|
+
"-t",
|
|
1098
|
+
String(pid),
|
|
1099
|
+
"-n",
|
|
1100
|
+
"/opt/harness/inject",
|
|
1101
|
+
...args
|
|
1102
|
+
]);
|
|
1103
|
+
const stamp = injectorStamp(stdout);
|
|
1104
|
+
push({
|
|
1105
|
+
ev: "act",
|
|
1106
|
+
t: stamp.t0,
|
|
1107
|
+
t1: stamp.t1,
|
|
1108
|
+
node,
|
|
1109
|
+
spec: {
|
|
1110
|
+
op: "inject",
|
|
1111
|
+
pid,
|
|
1112
|
+
args
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
return stamp;
|
|
1116
|
+
},
|
|
1117
|
+
async now() {
|
|
1118
|
+
return (await lab.act(firstNode, { op: "now" })).t0;
|
|
1119
|
+
},
|
|
1120
|
+
async pidOf(node, namespace, pod, container) {
|
|
1121
|
+
const ids = (await cluster.exec(node, [
|
|
1122
|
+
"crictl",
|
|
1123
|
+
"ps",
|
|
1124
|
+
"-q",
|
|
1125
|
+
"--state",
|
|
1126
|
+
"running",
|
|
1127
|
+
"--label",
|
|
1128
|
+
`io.kubernetes.pod.namespace=${namespace}`,
|
|
1129
|
+
"--label",
|
|
1130
|
+
`io.kubernetes.pod.name=${pod}`,
|
|
1131
|
+
"--name",
|
|
1132
|
+
`^${container}$`
|
|
1133
|
+
])).split(/\s+/).filter((id) => id !== "");
|
|
1134
|
+
if (ids.length !== 1) throw new Error(`${namespace}/${pod}/${container} on ${node} has ${ids.length} running containers, not 1`);
|
|
1135
|
+
const pid = JSON.parse(await cluster.exec(node, [
|
|
1136
|
+
"crictl",
|
|
1137
|
+
"inspect",
|
|
1138
|
+
ids[0]
|
|
1139
|
+
])).info?.pid;
|
|
1140
|
+
if (pid === void 0 || !(pid > 1)) throw new Error(`crictl gives no PID for ${namespace}/${pod}/${container}`);
|
|
1141
|
+
return pid;
|
|
1142
|
+
},
|
|
1143
|
+
async netnsOf(node, pid) {
|
|
1144
|
+
const link = (await cluster.exec(node, ["readlink", `/proc/${pid}/ns/net`])).trim();
|
|
1145
|
+
const inode = /^net:\[(\d+)\]$/.exec(link)?.[1];
|
|
1146
|
+
if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
|
|
1147
|
+
return Number(inode);
|
|
1148
|
+
},
|
|
1149
|
+
async cgroupOf(node, pid) {
|
|
1150
|
+
const text = (await cluster.exec(node, ["cat", `/proc/${pid}/cgroup`])).trim();
|
|
1151
|
+
const path = /^0::(\S+)$/m.exec(text)?.[1];
|
|
1152
|
+
if (path === void 0 || !/\/cri-containerd-[0-9a-f]{64}\.scope$/.test(path)) throw new Error(`the cgroup of ${pid} on ${node} is not the scope of one container: ${text}`);
|
|
1153
|
+
return `/proc/1/root/sys/fs/cgroup${path}`;
|
|
1154
|
+
},
|
|
1155
|
+
async close() {
|
|
1156
|
+
await lab.stopProbe();
|
|
1157
|
+
if (watcher !== null) await watcher.stop({ timeout: 0 }).catch(() => void 0);
|
|
1158
|
+
watcher = null;
|
|
1159
|
+
for (const container of opened.reverse()) await container.stop({ timeout: 0 }).catch(() => void 0);
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
return lab;
|
|
1163
|
+
};
|
|
1164
|
+
/** This function pins a Docker image by digest under a local tag that holds the digest, for `kind load` and `imagePullPolicy: Never`. */
|
|
1165
|
+
const localPin = async (image) => {
|
|
1166
|
+
const match = /^([^@]+?)(?::([^@:/]+))?@sha256:([0-9a-f]{64})$/.exec(image);
|
|
1167
|
+
if (match === null) throw new Error(`${image} is not pinned by a digest`);
|
|
1168
|
+
const [, repository, tag, digest] = match;
|
|
1169
|
+
const local = `systems-lab/${repository.split("/").at(-1)}:${tag ?? "pinned"}-${digest.slice(0, 12)}`;
|
|
1170
|
+
if (!(await command("docker", [
|
|
1171
|
+
"image",
|
|
1172
|
+
"inspect",
|
|
1173
|
+
"--format",
|
|
1174
|
+
"{{json .RepoDigests}}",
|
|
1175
|
+
`${repository}@sha256:${digest}`
|
|
1176
|
+
]).catch(() => "")).includes(digest)) await command("docker", ["pull", `${repository}@sha256:${digest}`], { timeout: 9e5 });
|
|
1177
|
+
await command("docker", [
|
|
1178
|
+
"tag",
|
|
1179
|
+
`${repository}@sha256:${digest}`,
|
|
1180
|
+
local
|
|
1181
|
+
]);
|
|
1182
|
+
return local;
|
|
1183
|
+
};
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/manifests.ts
|
|
1186
|
+
/** This image is Redis 7.4, the stock target of the harness, pinned by digest. */
|
|
1187
|
+
const REDIS_IMAGE = "redis:7.4@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f";
|
|
1188
|
+
const whole = (amount, what) => {
|
|
1189
|
+
const seconds = valueIn(amount, "s");
|
|
1190
|
+
if (!Number.isInteger(seconds) || seconds < 0) throw new RangeError(`${what} is ${seconds} s, not a whole number of seconds`);
|
|
1191
|
+
return seconds;
|
|
1192
|
+
};
|
|
1193
|
+
/** This function gives the namespace of a cell: `lab-` and 10 digits of the sha256 of its name. */
|
|
1194
|
+
const namespaceOf = (cell) => `lab-${createHash("sha256").update(cell.name).digest("hex").slice(0, 10)}`;
|
|
1195
|
+
/** This function gives a probe of the pod: an HTTP GET of the health server, with each field of the cell. */
|
|
1196
|
+
const probeOf = (path, probe) => ({
|
|
1197
|
+
httpGet: {
|
|
1198
|
+
path,
|
|
1199
|
+
port: HEALTH_PORT
|
|
1200
|
+
},
|
|
1201
|
+
initialDelaySeconds: 0,
|
|
1202
|
+
periodSeconds: whole(probe.period, `the period of ${path}`),
|
|
1203
|
+
timeoutSeconds: whole(probe.timeout, `the timeout of ${path}`),
|
|
1204
|
+
failureThreshold: probe.threshold,
|
|
1205
|
+
successThreshold: 1
|
|
1206
|
+
});
|
|
1207
|
+
/** This function gives the environment of the app: its config as JSON, and the name of its pod. */
|
|
1208
|
+
const appEnv = (cell) => [{
|
|
1209
|
+
name: "CONFIG",
|
|
1210
|
+
value: JSON.stringify({
|
|
1211
|
+
name: "lab",
|
|
1212
|
+
port: PORT,
|
|
1213
|
+
healthPort: HEALTH_PORT,
|
|
1214
|
+
declaration: cell.declaration
|
|
1215
|
+
})
|
|
1216
|
+
}, {
|
|
1217
|
+
name: "POD_NAME",
|
|
1218
|
+
valueFrom: { fieldRef: { fieldPath: "metadata.name" } }
|
|
1219
|
+
}];
|
|
1220
|
+
/** These are the objects of Redis: one replica behind a Service, with no persistence. */
|
|
1221
|
+
const redisObjects = (namespace, image) => [{
|
|
1222
|
+
apiVersion: "apps/v1",
|
|
1223
|
+
kind: "Deployment",
|
|
1224
|
+
metadata: {
|
|
1225
|
+
name: REDIS,
|
|
1226
|
+
namespace,
|
|
1227
|
+
labels: { app: REDIS }
|
|
1228
|
+
},
|
|
1229
|
+
spec: {
|
|
1230
|
+
replicas: 1,
|
|
1231
|
+
selector: { matchLabels: { app: REDIS } },
|
|
1232
|
+
template: {
|
|
1233
|
+
metadata: { labels: { app: REDIS } },
|
|
1234
|
+
spec: {
|
|
1235
|
+
terminationGracePeriodSeconds: 1,
|
|
1236
|
+
containers: [{
|
|
1237
|
+
name: REDIS,
|
|
1238
|
+
image,
|
|
1239
|
+
imagePullPolicy: "Never",
|
|
1240
|
+
command: [
|
|
1241
|
+
"redis-server",
|
|
1242
|
+
"--save",
|
|
1243
|
+
"",
|
|
1244
|
+
"--appendonly",
|
|
1245
|
+
"no"
|
|
1246
|
+
],
|
|
1247
|
+
ports: [{ containerPort: 6379 }],
|
|
1248
|
+
readinessProbe: {
|
|
1249
|
+
tcpSocket: { port: 6379 },
|
|
1250
|
+
periodSeconds: 1,
|
|
1251
|
+
failureThreshold: 1,
|
|
1252
|
+
successThreshold: 1,
|
|
1253
|
+
timeoutSeconds: 1
|
|
1254
|
+
}
|
|
1255
|
+
}]
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}, {
|
|
1260
|
+
apiVersion: "v1",
|
|
1261
|
+
kind: "Service",
|
|
1262
|
+
metadata: {
|
|
1263
|
+
name: REDIS,
|
|
1264
|
+
namespace
|
|
1265
|
+
},
|
|
1266
|
+
spec: {
|
|
1267
|
+
selector: { app: REDIS },
|
|
1268
|
+
ports: [{
|
|
1269
|
+
port: 6379,
|
|
1270
|
+
targetPort: 6379
|
|
1271
|
+
}]
|
|
1272
|
+
}
|
|
1273
|
+
}];
|
|
1274
|
+
/** These are the objects of the service: a Service of type ClusterIP, and the Deployment with the settings of the cell. */
|
|
1275
|
+
const serverObjects = (cell, namespace, image, version = 1) => {
|
|
1276
|
+
const preStop = whole(cell.pod.preStop, "the preStop sleep");
|
|
1277
|
+
return [{
|
|
1278
|
+
apiVersion: "v1",
|
|
1279
|
+
kind: "Service",
|
|
1280
|
+
metadata: {
|
|
1281
|
+
name: "lab",
|
|
1282
|
+
namespace
|
|
1283
|
+
},
|
|
1284
|
+
spec: {
|
|
1285
|
+
selector: { app: "lab" },
|
|
1286
|
+
ports: [{
|
|
1287
|
+
name: "http",
|
|
1288
|
+
port: 80,
|
|
1289
|
+
targetPort: PORT
|
|
1290
|
+
}]
|
|
1291
|
+
}
|
|
1292
|
+
}, {
|
|
1293
|
+
apiVersion: "apps/v1",
|
|
1294
|
+
kind: "Deployment",
|
|
1295
|
+
metadata: {
|
|
1296
|
+
name: "lab",
|
|
1297
|
+
namespace,
|
|
1298
|
+
labels: { app: "lab" }
|
|
1299
|
+
},
|
|
1300
|
+
spec: {
|
|
1301
|
+
replicas: cell.pod.replicas,
|
|
1302
|
+
selector: { matchLabels: { app: "lab" } },
|
|
1303
|
+
strategy: {
|
|
1304
|
+
type: "RollingUpdate",
|
|
1305
|
+
rollingUpdate: {
|
|
1306
|
+
maxSurge: "25%",
|
|
1307
|
+
maxUnavailable: "25%"
|
|
1308
|
+
}
|
|
1309
|
+
},
|
|
1310
|
+
minReadySeconds: 0,
|
|
1311
|
+
template: {
|
|
1312
|
+
metadata: { labels: {
|
|
1313
|
+
app: "lab",
|
|
1314
|
+
[LOG_LABEL]: "1",
|
|
1315
|
+
"lab.systems/version": String(version)
|
|
1316
|
+
} },
|
|
1317
|
+
spec: {
|
|
1318
|
+
terminationGracePeriodSeconds: whole(cell.pod.grace, "the grace period"),
|
|
1319
|
+
enableServiceLinks: true,
|
|
1320
|
+
topologySpreadConstraints: [{
|
|
1321
|
+
maxSkew: 1,
|
|
1322
|
+
topologyKey: "kubernetes.io/hostname",
|
|
1323
|
+
whenUnsatisfiable: "ScheduleAnyway",
|
|
1324
|
+
labelSelector: { matchLabels: { app: "lab" } }
|
|
1325
|
+
}],
|
|
1326
|
+
containers: [{
|
|
1327
|
+
name: "app",
|
|
1328
|
+
image,
|
|
1329
|
+
imagePullPolicy: "Never",
|
|
1330
|
+
command: ["node", "app.mjs"],
|
|
1331
|
+
env: [...appEnv(cell), {
|
|
1332
|
+
name: "LAB_VERSION",
|
|
1333
|
+
value: String(version)
|
|
1334
|
+
}],
|
|
1335
|
+
ports: [{ containerPort: PORT }, { containerPort: HEALTH_PORT }],
|
|
1336
|
+
...cell.pod.startup === null ? {} : { startupProbe: probeOf("/startup", cell.pod.startup) },
|
|
1337
|
+
readinessProbe: probeOf("/readiness", cell.pod.readiness),
|
|
1338
|
+
livenessProbe: probeOf("/liveness", cell.pod.liveness),
|
|
1339
|
+
...preStop === 0 ? {} : { lifecycle: { preStop: { sleep: { seconds: preStop } } } }
|
|
1340
|
+
}]
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}];
|
|
1345
|
+
};
|
|
1346
|
+
/** These are the objects of the clients: a DaemonSet with one client on each node, the control plane too. */
|
|
1347
|
+
const clientObjects = (cell, namespace, image, target) => [{
|
|
1348
|
+
apiVersion: "apps/v1",
|
|
1349
|
+
kind: "DaemonSet",
|
|
1350
|
+
metadata: {
|
|
1351
|
+
name: CLIENT,
|
|
1352
|
+
namespace,
|
|
1353
|
+
labels: { app: CLIENT }
|
|
1354
|
+
},
|
|
1355
|
+
spec: {
|
|
1356
|
+
selector: { matchLabels: { app: CLIENT } },
|
|
1357
|
+
template: {
|
|
1358
|
+
metadata: { labels: {
|
|
1359
|
+
app: CLIENT,
|
|
1360
|
+
[LOG_LABEL]: "1"
|
|
1361
|
+
} },
|
|
1362
|
+
spec: {
|
|
1363
|
+
terminationGracePeriodSeconds: 0,
|
|
1364
|
+
tolerations: [{
|
|
1365
|
+
key: "node-role.kubernetes.io/control-plane",
|
|
1366
|
+
operator: "Exists",
|
|
1367
|
+
effect: "NoSchedule"
|
|
1368
|
+
}],
|
|
1369
|
+
containers: [{
|
|
1370
|
+
name: CLIENT,
|
|
1371
|
+
image,
|
|
1372
|
+
imagePullPolicy: "Never",
|
|
1373
|
+
command: ["node", "client.mjs"],
|
|
1374
|
+
env: [
|
|
1375
|
+
{
|
|
1376
|
+
name: "TARGET",
|
|
1377
|
+
value: target
|
|
1378
|
+
},
|
|
1379
|
+
{
|
|
1380
|
+
name: "EVERY_MS",
|
|
1381
|
+
value: String(valueIn(cell.workload.every, "ms"))
|
|
1382
|
+
},
|
|
1383
|
+
{
|
|
1384
|
+
name: "TIMEOUT_MS",
|
|
1385
|
+
value: String(valueIn(cell.workload.timeout, "ms"))
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
name: "POD_NAME",
|
|
1389
|
+
valueFrom: { fieldRef: { fieldPath: "metadata.name" } }
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
name: "NODE_NAME",
|
|
1393
|
+
valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } }
|
|
1394
|
+
}
|
|
1395
|
+
]
|
|
1396
|
+
}]
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}];
|
|
1401
|
+
//#endregion
|
|
1402
|
+
//#region src/predict.ts
|
|
1403
|
+
/**
|
|
1404
|
+
* This module is the arithmetic of the predictions. Each band is a range of times after a
|
|
1405
|
+
* reference event of the run. Each term comes from a declaration, from the pod settings, or from
|
|
1406
|
+
* the calibration of the propagation. No term comes from a default. The arithmetic operates in
|
|
1407
|
+
* seconds, and each result is a quantity.
|
|
1408
|
+
*/
|
|
1409
|
+
const s$1 = (amount) => valueIn(amount, "s");
|
|
1410
|
+
/** This function rounds a time to the microsecond, thus a band has no float residue. */
|
|
1411
|
+
const us = (value) => Math.round(value * 1e6) / 1e6;
|
|
1412
|
+
const band = (lo, hi) => bandOf(us(lo), us(hi));
|
|
1413
|
+
/** This propagation is the documented bound, before a calibration: from zero to one `minSyncPeriod` of 1 s. */
|
|
1414
|
+
const DOCUMENTED = {
|
|
1415
|
+
lo: seconds(0),
|
|
1416
|
+
hi: seconds(1),
|
|
1417
|
+
from: "minSyncPeriod (1 s), not calibrated"
|
|
1418
|
+
};
|
|
1419
|
+
/**
|
|
1420
|
+
* This function gives the time from a flip of the probed state to the decision of the kubelet. The
|
|
1421
|
+
* next probe comes within one period, then `threshold − 1` more periods follow. A probe of an
|
|
1422
|
+
* endpoint that does not answer fails at its timeout.
|
|
1423
|
+
*/
|
|
1424
|
+
const kubeletBand = (probe, hangs) => {
|
|
1425
|
+
const late = hangs ? s$1(probe.timeout) : 0;
|
|
1426
|
+
return band((probe.threshold - 1) * s$1(probe.period) + late, probe.threshold * s$1(probe.period) + late);
|
|
1427
|
+
};
|
|
1428
|
+
/**
|
|
1429
|
+
* This function gives the time from a fault of a dependency to the trip of a readiness or liveness
|
|
1430
|
+
* latch. A dependency that refuses fails each poll at once. A silent dependency fails each poll at
|
|
1431
|
+
* the request timeout of its client, or at the timeout of the poll when that is shorter. The
|
|
1432
|
+
* watchdog of the client can close the connection sooner, and then each poll fails at once.
|
|
1433
|
+
*/
|
|
1434
|
+
const latchBand = (timing, poll, failure, requestTimeout) => {
|
|
1435
|
+
const error = failSpan(timing, poll, "error");
|
|
1436
|
+
if (failure === "refuses") return band(s$1(error.min), s$1(error.max));
|
|
1437
|
+
const wait = requestTimeout === void 0 ? s$1(poll.timeout) : Math.min(s$1(requestTimeout), s$1(poll.timeout));
|
|
1438
|
+
const late = failSpan(timing, {
|
|
1439
|
+
...poll,
|
|
1440
|
+
timeout: seconds(wait)
|
|
1441
|
+
}, "timeout");
|
|
1442
|
+
return band(s$1(error.min), s$1(late.max));
|
|
1443
|
+
};
|
|
1444
|
+
/** This function gives the time from a fault to the removal of the endpoints from the rules: the latch, then the kubelet, then the propagation. */
|
|
1445
|
+
const removalBand = (latch, probe, propagation) => {
|
|
1446
|
+
const kubelet = secondsOf(kubeletBand(probe, false));
|
|
1447
|
+
const trip = secondsOf(latch);
|
|
1448
|
+
const spread = secondsOf(propagation);
|
|
1449
|
+
return band(trip.lo + kubelet.lo + spread.lo, trip.hi + kubelet.hi + spread.hi);
|
|
1450
|
+
};
|
|
1451
|
+
/**
|
|
1452
|
+
* This function gives the time from a fault to the SIGTERM of a replica whose liveness fails with
|
|
1453
|
+
* its readiness. The time is the latch, then the liveness probes of the kubelet, then the preStop sleep.
|
|
1454
|
+
*/
|
|
1455
|
+
const restartBand = (latch, liveness, preStop) => {
|
|
1456
|
+
const kubelet = secondsOf(kubeletBand(liveness, false));
|
|
1457
|
+
const trip = secondsOf(latch);
|
|
1458
|
+
return band(trip.lo + kubelet.lo + s$1(preStop), trip.hi + kubelet.hi + s$1(preStop));
|
|
1459
|
+
};
|
|
1460
|
+
/**
|
|
1461
|
+
* This function gives the time from the start of a stall of the event loop to the removal of the
|
|
1462
|
+
* replica from the rules. Each probe in the stall fails at its timeout, because the loop does not answer it.
|
|
1463
|
+
*/
|
|
1464
|
+
const stallRemovalBand = (readiness, propagation) => {
|
|
1465
|
+
const kubelet = secondsOf(kubeletBand(readiness, true));
|
|
1466
|
+
const spread = secondsOf(propagation);
|
|
1467
|
+
return band(kubelet.lo + spread.lo, kubelet.hi + spread.hi);
|
|
1468
|
+
};
|
|
1469
|
+
/** This function gives the cap of a stage in a declaration, or zero when the declaration does not name the stage. */
|
|
1470
|
+
const capOf = (declaration, stage) => {
|
|
1471
|
+
const found = declaration.phases.find(([name]) => name === stage);
|
|
1472
|
+
return seconds(found === void 0 ? 0 : s$1(found[1]));
|
|
1473
|
+
};
|
|
1474
|
+
/**
|
|
1475
|
+
* This function gives the shortest time from the deletion of a pod to the stop of its intake. It
|
|
1476
|
+
* is the preStop sleep, then the hold of `fail readiness`. The kubelet adds its own delay before
|
|
1477
|
+
* the preStop, thus the real time is longer.
|
|
1478
|
+
*/
|
|
1479
|
+
const intakeAfter = (declaration, preStop) => seconds(us(s$1(preStop) + s$1(capOf(declaration, "fail readiness"))));
|
|
1480
|
+
/** This function gives the deletion window of a declaration and a preStop sleep on a node with a propagation. */
|
|
1481
|
+
const deletionWindow = (declaration, preStop, propagation) => {
|
|
1482
|
+
const intake = intakeAfter(declaration, preStop);
|
|
1483
|
+
const spread = secondsOf(propagation);
|
|
1484
|
+
return {
|
|
1485
|
+
empty: s$1(intake) >= spread.hi,
|
|
1486
|
+
end: band(spread.lo, spread.hi),
|
|
1487
|
+
intake
|
|
1488
|
+
};
|
|
1489
|
+
};
|
|
1490
|
+
/**
|
|
1491
|
+
* This function gives the window on a node whose kube-proxy pauses across a deletion. The pause
|
|
1492
|
+
* starts `lead` before the deletion and holds for `hold`. The node then syncs in one propagation
|
|
1493
|
+
* after the end of the pause, at `resume` after the deletion. The window is empty when the intake
|
|
1494
|
+
* stops after that.
|
|
1495
|
+
*/
|
|
1496
|
+
const pausedWindow = (declaration, preStop, lead, hold, propagation) => {
|
|
1497
|
+
const intake = intakeAfter(declaration, preStop);
|
|
1498
|
+
const resume = us(s$1(hold) - s$1(lead));
|
|
1499
|
+
const spread = secondsOf(propagation);
|
|
1500
|
+
return {
|
|
1501
|
+
empty: s$1(intake) >= resume + spread.hi,
|
|
1502
|
+
end: band(spread.lo, spread.hi),
|
|
1503
|
+
intake,
|
|
1504
|
+
resume: seconds(resume)
|
|
1505
|
+
};
|
|
1506
|
+
};
|
|
1507
|
+
//#endregion
|
|
1508
|
+
//#region src/rules.ts
|
|
1509
|
+
/**
|
|
1510
|
+
* These are the rules of the ordering law that a drain in Kubernetes meets, as data. The caller of
|
|
1511
|
+
* `lint()` gives the rules, thus they are here and not in the service.
|
|
1512
|
+
*/
|
|
1513
|
+
/**
|
|
1514
|
+
* O04 with the hold of the service. The listener of a pod stays open through the preStop sleep and
|
|
1515
|
+
* then through the hold of `fail readiness`. Thus the sum of the two must cover the propagation of
|
|
1516
|
+
* the endpoints. The rule of the research is the case without a hold.
|
|
1517
|
+
*/
|
|
1518
|
+
const O04 = {
|
|
1519
|
+
id: "O04",
|
|
1520
|
+
rule: "the preStop sleep plus the hold of fail readiness is at least the endpoint propagation",
|
|
1521
|
+
window: "new connections reach a pod whose listener is closed",
|
|
1522
|
+
expr: {
|
|
1523
|
+
lhs: { terms: [{
|
|
1524
|
+
knob: "prestop_sleep",
|
|
1525
|
+
coef: 1,
|
|
1526
|
+
at: "pod"
|
|
1527
|
+
}, {
|
|
1528
|
+
knob: "readiness_hold",
|
|
1529
|
+
coef: 1,
|
|
1530
|
+
at: "process"
|
|
1531
|
+
}] },
|
|
1532
|
+
op: ">=",
|
|
1533
|
+
rhs: { terms: [{
|
|
1534
|
+
knob: "propagation_bound",
|
|
1535
|
+
coef: 1,
|
|
1536
|
+
at: "cluster"
|
|
1537
|
+
}] }
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
/** O09 is W8: readiness detection plus propagation is no longer than liveness detection plus the preStop sleep. */
|
|
1541
|
+
const O09 = {
|
|
1542
|
+
id: "O09",
|
|
1543
|
+
rule: "readiness detection plus endpoint propagation is no longer than liveness detection plus the preStop sleep",
|
|
1544
|
+
window: "traffic reaches a container that its liveness probe kills",
|
|
1545
|
+
expr: {
|
|
1546
|
+
lhs: { terms: [{
|
|
1547
|
+
knob: "readiness_detection_window",
|
|
1548
|
+
coef: 1,
|
|
1549
|
+
at: "pod"
|
|
1550
|
+
}, {
|
|
1551
|
+
knob: "propagation_bound",
|
|
1552
|
+
coef: 1,
|
|
1553
|
+
at: "cluster"
|
|
1554
|
+
}] },
|
|
1555
|
+
op: "<=",
|
|
1556
|
+
rhs: { terms: [{
|
|
1557
|
+
knob: "liveness_detection_window",
|
|
1558
|
+
coef: 1,
|
|
1559
|
+
at: "pod"
|
|
1560
|
+
}, {
|
|
1561
|
+
knob: "prestop_sleep",
|
|
1562
|
+
coef: 1,
|
|
1563
|
+
at: "pod"
|
|
1564
|
+
}] }
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
/** O10: the preStop sleep plus the drain of the process fits in the grace period. */
|
|
1568
|
+
const O10 = {
|
|
1569
|
+
id: "O10",
|
|
1570
|
+
rule: "the preStop sleep plus the shutdown of the process fits in the grace period",
|
|
1571
|
+
window: "work in flight at SIGKILL is cut, and each connection closes at once",
|
|
1572
|
+
expr: {
|
|
1573
|
+
lhs: { terms: [{
|
|
1574
|
+
knob: "prestop_sleep",
|
|
1575
|
+
coef: 1,
|
|
1576
|
+
at: "pod"
|
|
1577
|
+
}, {
|
|
1578
|
+
knob: "process_drain_time",
|
|
1579
|
+
coef: 1,
|
|
1580
|
+
at: "process"
|
|
1581
|
+
}] },
|
|
1582
|
+
op: "<=",
|
|
1583
|
+
rhs: { terms: [{
|
|
1584
|
+
knob: "termination_grace",
|
|
1585
|
+
coef: 1,
|
|
1586
|
+
at: "pod"
|
|
1587
|
+
}] }
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
/** O16: the cooperative window is shorter than the drain, which is shorter than the grace less the preStop sleep. */
|
|
1591
|
+
const O16 = {
|
|
1592
|
+
id: "O16",
|
|
1593
|
+
rule: "the cooperative window is shorter than the drain, which is shorter than the grace less the preStop sleep",
|
|
1594
|
+
window: "handlers are cut before they answer, or the drain itself gets SIGKILL",
|
|
1595
|
+
expr: {
|
|
1596
|
+
lhs: { terms: [{
|
|
1597
|
+
knob: "cooperative_window",
|
|
1598
|
+
coef: 1,
|
|
1599
|
+
at: "process"
|
|
1600
|
+
}] },
|
|
1601
|
+
op: "<",
|
|
1602
|
+
rhs: { terms: [{
|
|
1603
|
+
knob: "process_drain_time",
|
|
1604
|
+
coef: 1,
|
|
1605
|
+
at: "process"
|
|
1606
|
+
}] }
|
|
1607
|
+
},
|
|
1608
|
+
expr_more: [{
|
|
1609
|
+
lhs: { terms: [{
|
|
1610
|
+
knob: "process_drain_time",
|
|
1611
|
+
coef: 1,
|
|
1612
|
+
at: "process"
|
|
1613
|
+
}] },
|
|
1614
|
+
op: "<",
|
|
1615
|
+
rhs: { terms: [{
|
|
1616
|
+
knob: "termination_grace",
|
|
1617
|
+
coef: 1,
|
|
1618
|
+
at: "pod"
|
|
1619
|
+
}, {
|
|
1620
|
+
knob: "prestop_sleep",
|
|
1621
|
+
coef: -1,
|
|
1622
|
+
at: "pod"
|
|
1623
|
+
}] }
|
|
1624
|
+
}]
|
|
1625
|
+
};
|
|
1626
|
+
/** These are the rules of the drain that each kind cell gets. */
|
|
1627
|
+
const DRAIN_RULES = [
|
|
1628
|
+
O04,
|
|
1629
|
+
O09,
|
|
1630
|
+
O10,
|
|
1631
|
+
O16
|
|
1632
|
+
];
|
|
1633
|
+
const seconds$1 = (amount) => valueIn(amount, "s");
|
|
1634
|
+
const s = (value) => ({
|
|
1635
|
+
value,
|
|
1636
|
+
unit: "s"
|
|
1637
|
+
});
|
|
1638
|
+
/**
|
|
1639
|
+
* This function gives the scenario of a cell for `lint()`: the preStop sleep, the grace, the probe
|
|
1640
|
+
* period and the propagation. The probe windows are the period times the threshold of each probe.
|
|
1641
|
+
*/
|
|
1642
|
+
const scenarioOf = (cell, propagation) => ({
|
|
1643
|
+
platform: {
|
|
1644
|
+
"prestop_sleep@pod": cell.pod.preStop,
|
|
1645
|
+
"termination_grace@pod": cell.pod.grace,
|
|
1646
|
+
"probe_period@pod": cell.pod.readiness.period,
|
|
1647
|
+
"propagation_bound@cluster": propagation.hi
|
|
1648
|
+
},
|
|
1649
|
+
probes: {
|
|
1650
|
+
readiness: s(seconds$1(cell.pod.readiness.period) * cell.pod.readiness.threshold),
|
|
1651
|
+
liveness: s(seconds$1(cell.pod.liveness.period) * cell.pod.liveness.threshold)
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
/** This function applies the drain rules to the description of a cell, and gives each finding. */
|
|
1655
|
+
const lintCell = (description, cell, propagation) => lint(description, scenarioOf(cell, propagation), DRAIN_RULES);
|
|
1656
|
+
/** This function gives the verdict of each drain rule, by its identifier. */
|
|
1657
|
+
const verdictsOf = (findings) => Object.fromEntries(findings.map((finding) => [finding.rule, finding.verdict]));
|
|
1658
|
+
/** This function gives the description of the service of a cell, as the app of the image composes it. Nothing connects. */
|
|
1659
|
+
const describeCell = (cell) => {
|
|
1660
|
+
const redis = cell.declaration.redis;
|
|
1661
|
+
const client = redis === null ? void 0 : redisClient("redis", {
|
|
1662
|
+
host: "redis",
|
|
1663
|
+
port: 6379,
|
|
1664
|
+
system: "systems.component.redis",
|
|
1665
|
+
knobs: redis.knobs,
|
|
1666
|
+
heartbeatMisses: redis.misses
|
|
1667
|
+
});
|
|
1668
|
+
return compose("lab", cell.declaration, { redis: client }).describe();
|
|
1669
|
+
};
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/run.ts
|
|
1672
|
+
/** This time is the interval of the poll of the rules on each node. */
|
|
1673
|
+
const RULES_EVERY = {
|
|
1674
|
+
value: 50,
|
|
1675
|
+
unit: "ms"
|
|
1676
|
+
};
|
|
1677
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1678
|
+
const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1679
|
+
/** This tracker keeps the latest state of the cell from the lines: each pod, each slice, the backends of each node and each client. */
|
|
1680
|
+
var Tracker = class {
|
|
1681
|
+
pods = /* @__PURE__ */ new Map();
|
|
1682
|
+
slices = /* @__PURE__ */ new Map();
|
|
1683
|
+
backends = /* @__PURE__ */ new Map();
|
|
1684
|
+
clients = /* @__PURE__ */ new Map();
|
|
1685
|
+
last = 0;
|
|
1686
|
+
see = (line) => {
|
|
1687
|
+
if (typeof line.t === "number" && line.t > this.last && line.ev === "req") this.last = line.t;
|
|
1688
|
+
if (line.ev === "pod") {
|
|
1689
|
+
const pod = line;
|
|
1690
|
+
if (pod.type === "DELETED") this.pods.delete(pod.name);
|
|
1691
|
+
else this.pods.set(pod.name, pod);
|
|
1692
|
+
} else if (line.ev === "slice") {
|
|
1693
|
+
if (line["type"] === "DELETED") this.slices.delete(String(line["name"]));
|
|
1694
|
+
else this.slices.set(String(line["name"]), line);
|
|
1695
|
+
} else if (line.ev === "rules") this.backends.set(String(line["node"]), line["b"]);
|
|
1696
|
+
else if (line.ev === "req") {
|
|
1697
|
+
const request = line;
|
|
1698
|
+
const client = this.clients.get(request.p) ?? {
|
|
1699
|
+
node: request.n,
|
|
1700
|
+
ok: 0,
|
|
1701
|
+
failed: 0
|
|
1702
|
+
};
|
|
1703
|
+
if (request.o === "ok") client.ok = Math.max(client.ok, request.t);
|
|
1704
|
+
else client.failed = Math.max(client.failed, request.t);
|
|
1705
|
+
this.clients.set(request.p, client);
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
servers() {
|
|
1709
|
+
return [...this.pods.values()].filter((pod) => pod.labels["app"] === "lab").sort((a, b) => a.name.localeCompare(b.name));
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
/** This function tells why the cell is not at rest, or gives null when it is. */
|
|
1713
|
+
const unsteady = (ctx) => {
|
|
1714
|
+
const { tracker, cell } = ctx;
|
|
1715
|
+
const servers = tracker.servers();
|
|
1716
|
+
if (servers.length !== ctx.expected) return `${servers.length} replicas, not ${ctx.expected}`;
|
|
1717
|
+
const busy = servers.find((pod) => pod.del || !pod.ready);
|
|
1718
|
+
if (busy !== void 0) return `${busy.name} is not ready`;
|
|
1719
|
+
if (new Set(servers.map((pod) => pod.labels["pod-template-hash"])).size > 1) return "two versions of the replicas";
|
|
1720
|
+
if (cell.declaration.redis !== null) {
|
|
1721
|
+
const redis = [...tracker.pods.values()].filter((pod) => pod.labels["app"] === REDIS);
|
|
1722
|
+
if (redis.length !== 1 || !redis[0].ready || redis[0].del) return "Redis is not ready";
|
|
1723
|
+
}
|
|
1724
|
+
const ips = servers.map((pod) => pod.ip ?? "").sort();
|
|
1725
|
+
const ready = [...tracker.slices.values()].filter((slice) => slice["svc"] === "lab").flatMap((slice) => slice["eps"].filter((endpoint) => endpoint.r).map((endpoint) => endpoint.ip)).sort();
|
|
1726
|
+
if (ready.join() !== ips.join()) return `the EndpointSlice has ${ready.join(" ")}, not ${ips.join(" ")}`;
|
|
1727
|
+
const expected = ips.map((ip) => `${ip}:8080`).sort().join();
|
|
1728
|
+
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(" ")}`;
|
|
1729
|
+
const nodes = new Set([...tracker.clients.values()].map((client) => client.node));
|
|
1730
|
+
if (nodes.size !== ctx.cluster.nodes.length) return `clients answer on ${nodes.size} nodes`;
|
|
1731
|
+
for (const [pod, client] of tracker.clients) {
|
|
1732
|
+
if (tracker.last - client.ok > 1e3) return `client ${pod} has no answer in the last second`;
|
|
1733
|
+
if (tracker.last - client.failed < 2e3) return `client ${pod} failed in the last 2 s`;
|
|
1734
|
+
}
|
|
1735
|
+
return null;
|
|
1736
|
+
};
|
|
1737
|
+
/** This function waits until the cell is at rest, and gives null, or the reason when it is not at rest in time. */
|
|
1738
|
+
const steady = async (ctx, within) => {
|
|
1739
|
+
const end = Date.now() + within;
|
|
1740
|
+
let why = unsteady(ctx);
|
|
1741
|
+
while (why !== null && Date.now() < end) {
|
|
1742
|
+
await sleep(200);
|
|
1743
|
+
why = unsteady(ctx);
|
|
1744
|
+
}
|
|
1745
|
+
return why;
|
|
1746
|
+
};
|
|
1747
|
+
/** This function samples the load of the VM for 4 s, and names the lab containers that are not of this run. */
|
|
1748
|
+
const sample = async (ctx, ours) => {
|
|
1749
|
+
const second = (await ctx.loader.exec([
|
|
1750
|
+
"top",
|
|
1751
|
+
"-b",
|
|
1752
|
+
"-n",
|
|
1753
|
+
"2",
|
|
1754
|
+
"-d",
|
|
1755
|
+
"4"
|
|
1756
|
+
])).stdout.split(/^Mem:/m).at(-1) ?? "";
|
|
1757
|
+
const idle = Number(/(\d+)% idle/.exec(second)?.[1] ?? "0") / 100;
|
|
1758
|
+
const top = second.split("\n").filter((line) => /^\s*\d+\s/.test(line)).slice(0, 6).map((line) => line.trim().replace(/\s+/g, " "));
|
|
1759
|
+
const foreign = (await command("docker", [
|
|
1760
|
+
"ps",
|
|
1761
|
+
"--format",
|
|
1762
|
+
"{{.ID}} {{.Names}} {{.Image}}"
|
|
1763
|
+
])).trim().split("\n").filter((line) => line !== "").filter((line) => !ours.has(line.split(" ")[1]) && !ours.has(line.split(" ")[0])).filter((line) => /loom-|kindest\/node|toxiproxy|haproxy|redis|systems-kind-lab|f6-/.test(line.split(" ")[2] ?? ""));
|
|
1764
|
+
return {
|
|
1765
|
+
t: Date.now(),
|
|
1766
|
+
idle,
|
|
1767
|
+
top,
|
|
1768
|
+
foreign
|
|
1769
|
+
};
|
|
1770
|
+
};
|
|
1771
|
+
/** This function waits until the VM is quiet: no lab container of another agent, and an idle share of the CPUs. */
|
|
1772
|
+
const quiet = async (ctx, ours) => {
|
|
1773
|
+
for (;;) {
|
|
1774
|
+
const load = await sample(ctx, /* @__PURE__ */ new Set([
|
|
1775
|
+
...ours,
|
|
1776
|
+
...ctx.lab.own(),
|
|
1777
|
+
ctx.loader.getName().replace(/^\//, "")
|
|
1778
|
+
]));
|
|
1779
|
+
ctx.loads.push(load);
|
|
1780
|
+
if (load.foreign.length === 0 && load.idle >= ctx.idle) return load;
|
|
1781
|
+
ctx.log(` waiting: ${Math.round(load.idle * 100)}% idle, other lab containers: ${load.foreign.join("; ") || "none"}`);
|
|
1782
|
+
await sleep(3e4);
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
/** This function deploys a cell in its namespace, with the watcher, the poll of the rules and the clients. */
|
|
1786
|
+
const deploy = async (lab, cluster, cell, namespace, redisImage) => {
|
|
1787
|
+
await cluster.apply([{
|
|
1788
|
+
apiVersion: "v1",
|
|
1789
|
+
kind: "Namespace",
|
|
1790
|
+
metadata: { name: namespace }
|
|
1791
|
+
}]);
|
|
1792
|
+
await lab.watch([namespace]);
|
|
1793
|
+
if (cell.declaration.redis !== null) {
|
|
1794
|
+
await cluster.apply(redisObjects(namespace, redisImage));
|
|
1795
|
+
await cluster.kubectl([
|
|
1796
|
+
"-n",
|
|
1797
|
+
namespace,
|
|
1798
|
+
"rollout",
|
|
1799
|
+
"status",
|
|
1800
|
+
`deployment/${REDIS}`,
|
|
1801
|
+
"--timeout=180s"
|
|
1802
|
+
]);
|
|
1803
|
+
}
|
|
1804
|
+
await cluster.apply(serverObjects(cell, namespace, lab.image));
|
|
1805
|
+
const clusterIp = (await cluster.kubectl([
|
|
1806
|
+
"-n",
|
|
1807
|
+
namespace,
|
|
1808
|
+
"get",
|
|
1809
|
+
"service",
|
|
1810
|
+
"lab",
|
|
1811
|
+
"-o",
|
|
1812
|
+
"jsonpath={.spec.clusterIP}"
|
|
1813
|
+
])).trim();
|
|
1814
|
+
await cluster.apply(clientObjects(cell, namespace, lab.image, `${clusterIp}:80`));
|
|
1815
|
+
await cluster.kubectl([
|
|
1816
|
+
"-n",
|
|
1817
|
+
namespace,
|
|
1818
|
+
"rollout",
|
|
1819
|
+
"status",
|
|
1820
|
+
`deployment/lab`,
|
|
1821
|
+
"--timeout=300s"
|
|
1822
|
+
]);
|
|
1823
|
+
await cluster.kubectl([
|
|
1824
|
+
"-n",
|
|
1825
|
+
namespace,
|
|
1826
|
+
"rollout",
|
|
1827
|
+
"status",
|
|
1828
|
+
`daemonset/${CLIENT}`,
|
|
1829
|
+
"--timeout=300s"
|
|
1830
|
+
]);
|
|
1831
|
+
await lab.rules({
|
|
1832
|
+
mode: cluster.scenario.kubeProxy.mode,
|
|
1833
|
+
ns: namespace,
|
|
1834
|
+
svc: "lab",
|
|
1835
|
+
ip: clusterIp,
|
|
1836
|
+
port: 80,
|
|
1837
|
+
every: RULES_EVERY
|
|
1838
|
+
});
|
|
1839
|
+
return clusterIp;
|
|
1840
|
+
};
|
|
1841
|
+
/** This function starts the prober again when the pods of the cell changed: it watches the namespace of each pod. */
|
|
1842
|
+
const probeThePods = async (ctx) => {
|
|
1843
|
+
const pods = [...ctx.tracker.pods.values()].filter((pod) => !pod.del && pod.node !== null && pod.cs.every((status) => status.st === "running"));
|
|
1844
|
+
const key = pods.map((pod) => `${pod.name}/${pod.cs.map((status) => status.id).join("+")}`).sort().join();
|
|
1845
|
+
if (key === ctx.probed) return;
|
|
1846
|
+
const netns = [];
|
|
1847
|
+
for (const pod of pods) {
|
|
1848
|
+
const container = pod.labels["app"] === "lab" ? "app" : pod.labels["app"] === "client" ? CLIENT : REDIS;
|
|
1849
|
+
const pid = await ctx.lab.pidOf(pod.node, ctx.namespace, pod.name, container);
|
|
1850
|
+
netns.push(await ctx.lab.netnsOf(pod.node, pid));
|
|
1851
|
+
}
|
|
1852
|
+
await ctx.lab.probe([...new Set(netns)]);
|
|
1853
|
+
ctx.probed = key;
|
|
1854
|
+
};
|
|
1855
|
+
/** This function gives the replicas of the service at this time, in the sequence of their names. */
|
|
1856
|
+
const replicasOf = (ctx) => ctx.tracker.servers().map((pod) => ({
|
|
1857
|
+
pod: pod.name,
|
|
1858
|
+
node: pod.node ?? "",
|
|
1859
|
+
ip: pod.ip ?? "",
|
|
1860
|
+
version: pod.labels["pod-template-hash"] ?? ""
|
|
1861
|
+
}));
|
|
1862
|
+
/** This function does one step of a fault plan. It gives the stamp of its act, or null for an act without a stamp. */
|
|
1863
|
+
const doStep = async (ctx, step, replicas, background) => {
|
|
1864
|
+
const { lab, cluster, namespace } = ctx;
|
|
1865
|
+
const target = replicas[0];
|
|
1866
|
+
const fill = (args) => args.map((arg) => arg.replaceAll("{namespace}", namespace).replaceAll(POD, target.pod).replaceAll(NODE, target.node).replaceAll(VERSION, String(ctx.version)));
|
|
1867
|
+
const where = async (which) => {
|
|
1868
|
+
if (which === "server") return {
|
|
1869
|
+
node: target.node,
|
|
1870
|
+
pid: await lab.pidOf(target.node, namespace, target.pod, "app"),
|
|
1871
|
+
pod: null
|
|
1872
|
+
};
|
|
1873
|
+
if (which === "redis") {
|
|
1874
|
+
const redis = [...ctx.tracker.pods.values()].find((pod) => pod.labels["app"] === "redis" && !pod.del);
|
|
1875
|
+
return {
|
|
1876
|
+
node: redis.node,
|
|
1877
|
+
pid: await lab.pidOf(redis.node, namespace, redis.name, REDIS),
|
|
1878
|
+
pod: redis
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
const worker = cluster.nodes[1] ?? cluster.nodes[0];
|
|
1882
|
+
const proxy = JSON.parse(await cluster.kubectl([
|
|
1883
|
+
"-n",
|
|
1884
|
+
"kube-system",
|
|
1885
|
+
"get",
|
|
1886
|
+
"pods",
|
|
1887
|
+
"-l",
|
|
1888
|
+
"k8s-app=kube-proxy",
|
|
1889
|
+
"--field-selector",
|
|
1890
|
+
`spec.nodeName=${worker}`,
|
|
1891
|
+
"-o",
|
|
1892
|
+
"json"
|
|
1893
|
+
]));
|
|
1894
|
+
return {
|
|
1895
|
+
node: worker,
|
|
1896
|
+
pid: await lab.pidOf(worker, "kube-system", proxy.items[0].metadata.name, "kube-proxy"),
|
|
1897
|
+
pod: null
|
|
1898
|
+
};
|
|
1899
|
+
};
|
|
1900
|
+
switch (step.do) {
|
|
1901
|
+
case "kubectl": {
|
|
1902
|
+
const t0 = await lab.now();
|
|
1903
|
+
if (step.args.includes(`LAB_VERSION={version}`)) ctx.version += 1;
|
|
1904
|
+
const call = cluster.kubectl(fill(step.args));
|
|
1905
|
+
if (step.background === true) {
|
|
1906
|
+
background.push(call.catch((error) => ctx.log(` kubectl ${step.args.join(" ")}: ${String(error)}`)));
|
|
1907
|
+
return {
|
|
1908
|
+
t0,
|
|
1909
|
+
t1: t0
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
await call;
|
|
1913
|
+
return {
|
|
1914
|
+
t0,
|
|
1915
|
+
t1: await lab.now()
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
case "signal": {
|
|
1919
|
+
const at = await where(step.target);
|
|
1920
|
+
return lab.act(at.node, {
|
|
1921
|
+
op: "signal",
|
|
1922
|
+
pid: at.pid,
|
|
1923
|
+
signal: step.signal
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
case "pause": {
|
|
1927
|
+
const at = await where(step.target);
|
|
1928
|
+
const stamp = lab.act(at.node, {
|
|
1929
|
+
op: "pause",
|
|
1930
|
+
pid: at.pid,
|
|
1931
|
+
ms: valueIn(step.time, "ms")
|
|
1932
|
+
});
|
|
1933
|
+
background.push(stamp);
|
|
1934
|
+
const now = await lab.now();
|
|
1935
|
+
return {
|
|
1936
|
+
t0: now,
|
|
1937
|
+
t1: now
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
case "inject": {
|
|
1941
|
+
const at = await where(step.target);
|
|
1942
|
+
let first = null;
|
|
1943
|
+
for (const replica of replicas) {
|
|
1944
|
+
const stamp = await lab.inject(at.node, at.pid, [step.action, replica.ip]);
|
|
1945
|
+
first ??= stamp;
|
|
1946
|
+
}
|
|
1947
|
+
return first;
|
|
1948
|
+
}
|
|
1949
|
+
case "flag": {
|
|
1950
|
+
const at = await where(step.target);
|
|
1951
|
+
return step.on ? lab.act(at.node, {
|
|
1952
|
+
op: "write",
|
|
1953
|
+
path: `/proc/${at.pid}/root/tmp/lab-fail`,
|
|
1954
|
+
data: "1"
|
|
1955
|
+
}) : lab.act(at.node, {
|
|
1956
|
+
op: "remove",
|
|
1957
|
+
path: `/proc/${at.pid}/root/tmp/lab-fail`
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
case "cpu": {
|
|
1961
|
+
const at = await where(step.target);
|
|
1962
|
+
const cgroup = await lab.cgroupOf(at.node, at.pid);
|
|
1963
|
+
return lab.act(at.node, {
|
|
1964
|
+
op: "write",
|
|
1965
|
+
path: `${cgroup}/cpu.max`,
|
|
1966
|
+
data: step.max
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
case "stall": {
|
|
1970
|
+
const at = await where(step.target);
|
|
1971
|
+
return lab.act(at.node, {
|
|
1972
|
+
op: "stall",
|
|
1973
|
+
pid: at.pid,
|
|
1974
|
+
ms: valueIn(step.time, "ms")
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
case "wait":
|
|
1978
|
+
await sleep(valueIn(step.time, "ms"));
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
/** This function tells if a run is over: the trigger is done, and the cell is at rest again. */
|
|
1983
|
+
const over = (ctx, run, lines) => {
|
|
1984
|
+
if (unsteady(ctx) !== null) return false;
|
|
1985
|
+
const kind = ctx.cell.trigger.kind;
|
|
1986
|
+
if ((kind === "crash" || kind === "liveness-restart") && run.target !== null) {
|
|
1987
|
+
const pod = ctx.tracker.pods.get(run.target.pod);
|
|
1988
|
+
return (pod === void 0 ? 0 : pod.cs.reduce((sum, status) => sum + status.rc, 0)) > (run.restarts.get(run.target.pod) ?? 0);
|
|
1989
|
+
}
|
|
1990
|
+
if (run.target !== null && [
|
|
1991
|
+
"pod-deletion",
|
|
1992
|
+
"node-drain",
|
|
1993
|
+
"scale-in",
|
|
1994
|
+
"freeze-process"
|
|
1995
|
+
].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);
|
|
1996
|
+
if (kind === "rolling-update" || kind === "rollout") return run.replicas.every((replica) => !ctx.tracker.pods.has(replica.pod));
|
|
1997
|
+
if (kind === "app-stall") return lines.some((line) => line.ev === "stall" && line["phase"] === "end");
|
|
1998
|
+
return true;
|
|
1999
|
+
};
|
|
2000
|
+
/** This function does one run of a cell: at rest, the trigger, the heal, then the observation until the cell is at rest again. */
|
|
2001
|
+
const runOnce = async (ctx, env, index, warmup, ours) => {
|
|
2002
|
+
const { lab, cell, cluster, namespace } = ctx;
|
|
2003
|
+
const plan = planOf(cell);
|
|
2004
|
+
const atRest = await steady(ctx, 3e5);
|
|
2005
|
+
const empty = (why, start) => ({
|
|
2006
|
+
env,
|
|
2007
|
+
index,
|
|
2008
|
+
warmup,
|
|
2009
|
+
start,
|
|
2010
|
+
end: start,
|
|
2011
|
+
acts: {
|
|
2012
|
+
fault: null,
|
|
2013
|
+
heal: null,
|
|
2014
|
+
resume: null
|
|
2015
|
+
},
|
|
2016
|
+
target: null,
|
|
2017
|
+
paused: null,
|
|
2018
|
+
replicas: [],
|
|
2019
|
+
void: why,
|
|
2020
|
+
lines: lab.take(),
|
|
2021
|
+
dropped: {}
|
|
2022
|
+
});
|
|
2023
|
+
if (atRest !== null) return empty(`the cell was not at rest: ${atRest}`, await lab.now());
|
|
2024
|
+
await probeThePods(ctx);
|
|
2025
|
+
await quiet(ctx, ours);
|
|
2026
|
+
if (await steady(ctx, 3e5) !== null) return empty("the cell was not at rest after the wait for a quiet VM", await lab.now());
|
|
2027
|
+
const replicas = replicasOf(ctx);
|
|
2028
|
+
const restarts = new Map(ctx.tracker.servers().map((pod) => [pod.name, pod.cs.reduce((sum, status) => sum + status.rc, 0)]));
|
|
2029
|
+
const target = cell.declaration.redis !== null && [
|
|
2030
|
+
"rst",
|
|
2031
|
+
"blackhole",
|
|
2032
|
+
"freeze-process",
|
|
2033
|
+
"peer-gone-without-close",
|
|
2034
|
+
"rollout"
|
|
2035
|
+
].includes(cell.trigger.kind) ? null : replicas[0];
|
|
2036
|
+
const paused = cell.template === "T07" && cell.trigger.kind === "freeze-process" ? cluster.nodes[1] ?? cluster.nodes[0] : null;
|
|
2037
|
+
lab.take();
|
|
2038
|
+
for (const key of Object.keys(lab.dropped)) delete lab.dropped[key];
|
|
2039
|
+
const start = await lab.now();
|
|
2040
|
+
const background = [];
|
|
2041
|
+
let fault = null;
|
|
2042
|
+
let resume = null;
|
|
2043
|
+
if (cell.trigger.kind === "scale-in") ctx.expected = cell.pod.replicas - 1;
|
|
2044
|
+
const notes = [];
|
|
2045
|
+
for (const step of plan.inject) {
|
|
2046
|
+
const stamp = await doStep(ctx, step, replicas, background);
|
|
2047
|
+
if (stamp !== null && fault === null) fault = stamp.t0;
|
|
2048
|
+
}
|
|
2049
|
+
let heal = null;
|
|
2050
|
+
if (plan.hold !== null) {
|
|
2051
|
+
const now = await lab.now();
|
|
2052
|
+
await sleep(Math.max(0, valueIn(plan.hold, "ms") - (now - (fault ?? now))));
|
|
2053
|
+
for (const step of plan.heal) try {
|
|
2054
|
+
const stamp = await doStep(ctx, step, replicas, background);
|
|
2055
|
+
if (stamp !== null && heal === null) heal = stamp.t0;
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
notes.push(`the heal ${step.do} failed: ${String(error)}`);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const settled = await Promise.allSettled(background);
|
|
2061
|
+
for (const each of settled) if (each.status === "fulfilled" && typeof each.value === "object" && each.value !== null && "t1" in each.value) resume = each.value.t1;
|
|
2062
|
+
const limit = Date.now() + 24e4 + valueIn(cell.pod.grace, "ms") + valueIn(cell.pod.preStop, "ms");
|
|
2063
|
+
await sleep(3e3);
|
|
2064
|
+
while (Date.now() < limit && !over(ctx, {
|
|
2065
|
+
target,
|
|
2066
|
+
replicas,
|
|
2067
|
+
restarts
|
|
2068
|
+
}, lab.lines)) await sleep(250);
|
|
2069
|
+
const end = await lab.now();
|
|
2070
|
+
const timedOut = Date.now() >= limit;
|
|
2071
|
+
for (const step of plan.restore) try {
|
|
2072
|
+
await doStep(ctx, step, replicas, background);
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
notes.push(`the restore ${step.do} failed: ${String(error)}`);
|
|
2075
|
+
}
|
|
2076
|
+
ctx.expected = cell.pod.replicas;
|
|
2077
|
+
for (const note of notes) ctx.log(` ${note}`);
|
|
2078
|
+
const lines = lab.take();
|
|
2079
|
+
const stopped = deletes(cell) ? replicas.find((replica) => lines.some((line) => line.ev === "pod" && line["name"] === replica.pod && line["del"] === true)) ?? target : target;
|
|
2080
|
+
if (lines.some((line) => line.ev === "pod" && line["cs"].some((status) => status.rc > 0))) {
|
|
2081
|
+
await cluster.kubectl([
|
|
2082
|
+
"-n",
|
|
2083
|
+
namespace,
|
|
2084
|
+
"rollout",
|
|
2085
|
+
"restart",
|
|
2086
|
+
`deployment/lab`
|
|
2087
|
+
]);
|
|
2088
|
+
await cluster.kubectl([
|
|
2089
|
+
"-n",
|
|
2090
|
+
namespace,
|
|
2091
|
+
"rollout",
|
|
2092
|
+
"status",
|
|
2093
|
+
`deployment/lab`,
|
|
2094
|
+
"--timeout=300s"
|
|
2095
|
+
]);
|
|
2096
|
+
}
|
|
2097
|
+
if (cell.trigger.kind === "node-drain") await rebalance(ctx);
|
|
2098
|
+
ctx.log(` ${warmup ? "warm-up" : `run ${index}`}: ${sec(end - start)} s${timedOut ? ", not at rest in time" : ""}`);
|
|
2099
|
+
return {
|
|
2100
|
+
env,
|
|
2101
|
+
index,
|
|
2102
|
+
warmup,
|
|
2103
|
+
start,
|
|
2104
|
+
end,
|
|
2105
|
+
acts: {
|
|
2106
|
+
fault,
|
|
2107
|
+
heal,
|
|
2108
|
+
resume
|
|
2109
|
+
},
|
|
2110
|
+
target: stopped,
|
|
2111
|
+
paused,
|
|
2112
|
+
replicas,
|
|
2113
|
+
void: null,
|
|
2114
|
+
lines,
|
|
2115
|
+
dropped: { ...lab.dropped },
|
|
2116
|
+
notes
|
|
2117
|
+
};
|
|
2118
|
+
};
|
|
2119
|
+
const sec = (ms) => Math.round(ms) / 1e3;
|
|
2120
|
+
/** This function moves a replica back to a node without one after a drain: it deletes one replica of the node with two. */
|
|
2121
|
+
const rebalance = async (ctx) => {
|
|
2122
|
+
await steady(ctx, 3e5);
|
|
2123
|
+
const servers = ctx.tracker.servers();
|
|
2124
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
2125
|
+
for (const pod of servers) byNode.set(pod.node ?? "", [...byNode.get(pod.node ?? "") ?? [], pod.name]);
|
|
2126
|
+
const crowded = [...byNode.values()].find((pods) => pods.length > 1);
|
|
2127
|
+
if (crowded !== void 0 && byNode.size < ctx.cluster.nodes.length - 1) await ctx.cluster.kubectl([
|
|
2128
|
+
"-n",
|
|
2129
|
+
ctx.namespace,
|
|
2130
|
+
"delete",
|
|
2131
|
+
"pod",
|
|
2132
|
+
crowded[0],
|
|
2133
|
+
"--wait=true"
|
|
2134
|
+
]);
|
|
2135
|
+
};
|
|
2136
|
+
/** This function writes the result of a cell: its raw record, and one line of its verdict in `summary.ndjson`. */
|
|
2137
|
+
const writeResult = (out, result) => {
|
|
2138
|
+
mkdirSync(out, { recursive: true });
|
|
2139
|
+
const record = {
|
|
2140
|
+
format: FORMAT,
|
|
2141
|
+
...result
|
|
2142
|
+
};
|
|
2143
|
+
writeFileSync(join(out, `${slug(result.cell.name)}.json`), JSON.stringify(record));
|
|
2144
|
+
const { verdict, cell } = result;
|
|
2145
|
+
writeFileSync(join(out, "summary.ndjson"), `${JSON.stringify({
|
|
2146
|
+
id: cell.id,
|
|
2147
|
+
name: cell.name,
|
|
2148
|
+
mode: cell.mode,
|
|
2149
|
+
trigger: cell.trigger.kind,
|
|
2150
|
+
prediction: cell.prediction.kind,
|
|
2151
|
+
decision: verdict.decision,
|
|
2152
|
+
counted: verdict.counted,
|
|
2153
|
+
voided: verdict.voided,
|
|
2154
|
+
failed: verdict.failed,
|
|
2155
|
+
median: verdict.median,
|
|
2156
|
+
why: verdict.why
|
|
2157
|
+
})}\n`, { flag: "a" });
|
|
2158
|
+
};
|
|
2159
|
+
/**
|
|
2160
|
+
* This function does the runs of a catalog of kind cells. The cells of one kube-proxy mode and node
|
|
2161
|
+
* count share a cluster. Each group gets new clusters in sequence, one for each environment. Each
|
|
2162
|
+
* environment does its share of the runs of each cell, after one warm-up. The function deletes each
|
|
2163
|
+
* cluster after its cells.
|
|
2164
|
+
*/
|
|
2165
|
+
const runKindCells = async (cells, options) => {
|
|
2166
|
+
const environments = options.environments ?? 2;
|
|
2167
|
+
const image = await labImage();
|
|
2168
|
+
const redisImage = await localPin(REDIS_IMAGE);
|
|
2169
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2170
|
+
for (const cell of cells) groups.set(`${cell.mode}-${cell.workers}`, [...groups.get(`${cell.mode}-${cell.workers}`) ?? [], cell]);
|
|
2171
|
+
const results = [];
|
|
2172
|
+
for (const [key, group] of groups) {
|
|
2173
|
+
const partial = /* @__PURE__ */ new Map();
|
|
2174
|
+
for (let env = 0; env < environments; env += 1) {
|
|
2175
|
+
const s = scenario(`lab-${key}-${env}`, group[0].mode, group[0].workers);
|
|
2176
|
+
const dir = join(options.work ?? join(tmpdir(), "systems-kind-work"), s.name);
|
|
2177
|
+
options.log(`cluster ${s.name}: ${s.kubeProxy.mode}, ${s.workers} workers`);
|
|
2178
|
+
const cluster = await createCluster(s, dir);
|
|
2179
|
+
let lab = null;
|
|
2180
|
+
let loader = null;
|
|
2181
|
+
try {
|
|
2182
|
+
await cluster.load(image);
|
|
2183
|
+
await cluster.load(redisImage);
|
|
2184
|
+
lab = await openKindLab(cluster, image);
|
|
2185
|
+
const { GenericContainer } = await import("testcontainers");
|
|
2186
|
+
const Shared = class extends GenericContainer {
|
|
2187
|
+
withPidMode(mode) {
|
|
2188
|
+
this.hostConfig.PidMode = mode;
|
|
2189
|
+
return this;
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
loader = await new Shared(image).withPidMode("host").withNetworkMode("none").withCommand(["sleep", "infinity"]).start();
|
|
2193
|
+
const manifest = await cluster.manifest();
|
|
2194
|
+
const ours = /* @__PURE__ */ new Set([...cluster.nodes]);
|
|
2195
|
+
for (const cell of group) {
|
|
2196
|
+
const namespace = namespaceOf(cell);
|
|
2197
|
+
options.log(`${cell.name} (${cell.id}), environment ${env}`);
|
|
2198
|
+
const tracker = new Tracker();
|
|
2199
|
+
const off = lab.listen(tracker.see);
|
|
2200
|
+
const loads = [];
|
|
2201
|
+
try {
|
|
2202
|
+
const clusterIp = await deploy(lab, cluster, cell, namespace, redisImage);
|
|
2203
|
+
for (const name of [...lab.own(), loader.getName().replace(/^\//, "")]) ours.add(name);
|
|
2204
|
+
const ctx = {
|
|
2205
|
+
lab,
|
|
2206
|
+
cluster,
|
|
2207
|
+
cell,
|
|
2208
|
+
namespace,
|
|
2209
|
+
tracker,
|
|
2210
|
+
clusterIp,
|
|
2211
|
+
loads,
|
|
2212
|
+
loader,
|
|
2213
|
+
idle: options.idle ?? .6,
|
|
2214
|
+
log: options.log,
|
|
2215
|
+
version: 1,
|
|
2216
|
+
probed: "",
|
|
2217
|
+
expected: cell.pod.replicas
|
|
2218
|
+
};
|
|
2219
|
+
const share = Math.ceil(cell.runs / environments) - (env < cell.runs % environments || cell.runs % environments === 0 ? 0 : 1);
|
|
2220
|
+
const runs = [await runOnce(ctx, env, -1, true, ours)];
|
|
2221
|
+
for (let index = 0; index < share; index += 1) runs.push(await runOnce(ctx, env, index, false, ours));
|
|
2222
|
+
const entry = partial.get(cell.name) ?? {
|
|
2223
|
+
runs: [],
|
|
2224
|
+
manifests: [],
|
|
2225
|
+
loads: []
|
|
2226
|
+
};
|
|
2227
|
+
entry.runs.push(...runs);
|
|
2228
|
+
entry.manifests.push({
|
|
2229
|
+
env,
|
|
2230
|
+
scenario: s,
|
|
2231
|
+
cluster: manifest,
|
|
2232
|
+
images: {
|
|
2233
|
+
lab: image,
|
|
2234
|
+
redis: redisImage
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
entry.loads.push(...loads);
|
|
2238
|
+
partial.set(cell.name, entry);
|
|
2239
|
+
mkdirSync(join(options.out, "partial"), { recursive: true });
|
|
2240
|
+
writeFileSync(join(options.out, "partial", `${slug(cell.name)}.env${env}.json`), JSON.stringify({
|
|
2241
|
+
cell,
|
|
2242
|
+
runs,
|
|
2243
|
+
manifest,
|
|
2244
|
+
loads
|
|
2245
|
+
}));
|
|
2246
|
+
} finally {
|
|
2247
|
+
off();
|
|
2248
|
+
await lab.rules(null).catch(() => void 0);
|
|
2249
|
+
await lab.stopProbe();
|
|
2250
|
+
await cluster.kubectl([
|
|
2251
|
+
"delete",
|
|
2252
|
+
"namespace",
|
|
2253
|
+
namespace,
|
|
2254
|
+
"--wait=true",
|
|
2255
|
+
"--timeout=300s"
|
|
2256
|
+
]).catch((error) => options.log(` ${String(error)}`));
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
} finally {
|
|
2260
|
+
if (loader !== null) await loader.stop({ timeout: 0 }).catch(() => void 0);
|
|
2261
|
+
if (lab !== null) await lab.close();
|
|
2262
|
+
await cluster.delete();
|
|
2263
|
+
options.log(`cluster ${s.name} deleted`);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
for (const cell of group) {
|
|
2267
|
+
const entry = partial.get(cell.name);
|
|
2268
|
+
if (entry === void 0) continue;
|
|
2269
|
+
const result = {
|
|
2270
|
+
cell,
|
|
2271
|
+
...entry,
|
|
2272
|
+
verdict: judgeKind(cell, entry.runs),
|
|
2273
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2274
|
+
};
|
|
2275
|
+
writeResult(options.out, result);
|
|
2276
|
+
rmSync(join(options.out, "partial", `${slug(cell.name)}.env0.json`), { force: true });
|
|
2277
|
+
for (let env = 1; env < environments; env += 1) rmSync(join(options.out, "partial", `${slug(cell.name)}.env${env}.json`), { force: true });
|
|
2278
|
+
options.log(`${cell.name}: ${result.verdict.decision}, ${result.verdict.why}`);
|
|
2279
|
+
results.push(result);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return results;
|
|
2283
|
+
};
|
|
2284
|
+
/** This function gives the estimate of a cell in seconds, for the arithmetic of the shards. */
|
|
2285
|
+
const estimate = (cell, environments = 2) => {
|
|
2286
|
+
const hold = cell.trigger.hold === void 0 ? 0 : valueIn(cell.trigger.hold, "s");
|
|
2287
|
+
const each = 25 + ({
|
|
2288
|
+
"liveness-restart": 100,
|
|
2289
|
+
crash: 30,
|
|
2290
|
+
"app-stall": hold + 40,
|
|
2291
|
+
"cpu-throttle": hold + 40,
|
|
2292
|
+
rollout: hold + 50
|
|
2293
|
+
}[cell.trigger.kind] ?? (cell.template === "T18" ? hold + 35 : 15 + valueIn(cell.pod.preStop, "s")));
|
|
2294
|
+
return 60 * environments + (cell.runs + environments) * each;
|
|
2295
|
+
};
|
|
2296
|
+
/**
|
|
2297
|
+
* This function gives an estimate of the time of a cell: its setup, then each run and each
|
|
2298
|
+
* warm-up. A run waits for the cell at rest, samples the load, does the trigger and waits until the
|
|
2299
|
+
* cell is at rest again.
|
|
2300
|
+
*/
|
|
2301
|
+
const estimatedTime = (cell, environments = 2) => ({
|
|
2302
|
+
value: estimate(cell, environments),
|
|
2303
|
+
unit: "s"
|
|
2304
|
+
});
|
|
2305
|
+
/**
|
|
2306
|
+
* This function gives the share of a shard of a catalog: cells in sequence. Thus the cells of one
|
|
2307
|
+
* mode stay together and share clusters. Each shard gets an equal share of the estimated time.
|
|
2308
|
+
*/
|
|
2309
|
+
const shardOf = (cells, index, count) => {
|
|
2310
|
+
if (!(Number.isInteger(index) && Number.isInteger(count) && count >= 1 && index >= 0 && index < count)) throw new RangeError(`shard ${index} of ${count} is not a shard`);
|
|
2311
|
+
const ordered = [...cells].sort((a, b) => a.mode === b.mode ? 0 : a.mode < b.mode ? -1 : 1);
|
|
2312
|
+
const total = ordered.reduce((sum, cell) => sum + estimate(cell), 0);
|
|
2313
|
+
const out = [];
|
|
2314
|
+
let before = 0;
|
|
2315
|
+
for (const cell of ordered) {
|
|
2316
|
+
const middle = before + estimate(cell) / 2;
|
|
2317
|
+
if (Math.min(count - 1, Math.floor(middle / total * count)) === index) out.push(cell);
|
|
2318
|
+
before += estimate(cell);
|
|
2319
|
+
}
|
|
2320
|
+
return out;
|
|
2321
|
+
};
|
|
2322
|
+
//#endregion
|
|
2323
|
+
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, byNode, cacheFolder, calibrationOf, capOf, cellsTable, checkRun, cidrOverlap, cidrRange, clientObjects, compose, createCluster, deletes, deletionOf, deletionWindow, 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, rulesOf, runKindCells, scenario, scenarioOf, scenarioProblems, seconds, secondsOf, serverObjects, sha256, shardOf, showBand, signalOf, signalsOf, sliceDropOf, sliceEmptyOf, slicesOf, stageLabImage, stallRemovalBand, timeSpread, toolPath, transitionOf, unitOf, upgrade, upgradeCell, verdictsOf, writeResult };
|
|
2324
|
+
|
|
2325
|
+
//# sourceMappingURL=index.js.map
|