@mark1russell7/harness 0.14.1 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/build/{cell-BccWKiXh.d.ts → cell-B3-BSTit.d.ts} +117 -4
- package/build/{image-Lg8LL6C3.js → image-BMdV7yWD.js} +154 -11
- package/build/image-BMdV7yWD.js.map +1 -0
- package/build/index.d.ts +548 -106
- package/build/index.js +1193 -44
- package/build/index.js.map +1 -1
- package/build/schema.d.ts +1 -1
- package/build/schema.js +1 -1
- package/image/mongo/client-mongo.mjs +5 -3
- package/image/native/src/bin/inject.rs +40 -13
- package/package.json +1 -1
- package/build/image-Lg8LL6C3.js.map +0 -1
package/build/index.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { C as targetImageOf, E as
|
|
1
|
+
import { A as unpinnedBases, C as routerImage, D as targetImageOf, E as subjectProblems, F as locksOf, I as npmVersions, L as versionsOf, M as cargoVersions, N as goVersions, O as targetSourceOf, P as jarVersions, S as programOf, T as subjectClient, _ as mongoClientImage, a as SHIPPED_NAMES, b as pgClientImage, c as buildRecordOf, d as harnessImage, f as harnessVersion, g as inContext, h as imageProblems, i as SHIPPED_CLIENTS, j as LOCK_NAMES, k as unlockedManifests, l as goHttpImage, m as imageOf, n as PINNED, o as amqpClientImage, p as hikariClientImage, r as REPOSITORY, s as basesOf, t as CLIENT_NAMES, u as grpcClientImage, v as nodeClientImage, w as shippedSource, x as planOf, y as nodeHttpImage } from "./image-BMdV7yWD.js";
|
|
2
2
|
import { quantity, valueIn } from "@mark1russell7/quantity";
|
|
3
3
|
import { median, medianInterval as medianInterval$1, quantileSorted } from "@mark1russell7/rigor-statistics";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { release, tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
10
|
//#region src/cell.ts
|
|
6
11
|
/** This function tells if a fault place is a hop of a topology. */
|
|
7
12
|
const isHopPlace = (place) => typeof place === "object" && "hop" in place;
|
|
@@ -401,6 +406,7 @@ const verdictOfBand = (prediction, runs, delta = NOISE) => {
|
|
|
401
406
|
band: null,
|
|
402
407
|
outside: [],
|
|
403
408
|
median: samples.length === 0 ? null : s(median(samples)),
|
|
409
|
+
...samples.length === 0 ? {} : { slowest: s(samples.at(-1)) },
|
|
404
410
|
...reordering === void 0 ? {} : { reordering },
|
|
405
411
|
why
|
|
406
412
|
};
|
|
@@ -867,7 +873,7 @@ const ipOf$1 = (address) => address.slice(0, address.lastIndexOf(":"));
|
|
|
867
873
|
/** This function gives the first line of the client that matches, after an instant. */
|
|
868
874
|
const lineAfter = (events, match, after) => events.find((event) => event.t > after && matches(event, match));
|
|
869
875
|
/** This function gives the name of the error of a line: the field that the match names, or `name`. */
|
|
870
|
-
const nameOf = (line, match) => String(fieldOf(line, match.name ?? "name") ?? line.ev);
|
|
876
|
+
const nameOf$1 = (line, match) => String(fieldOf(line, match.name ?? "name") ?? line.ev);
|
|
871
877
|
/**
|
|
872
878
|
* This function gives the report of the kernel on a socket after an instant: an error, or the end of
|
|
873
879
|
* the file (CLOSE_WAIT). With neither, a close of the socket in SYN_SENT is the end. The program gave
|
|
@@ -1049,12 +1055,12 @@ const measureDriver = (cell, run) => {
|
|
|
1049
1055
|
findings.push(line === void 0 ? `${step.what}: ${wordsOf(step.ends)} never came` : `${step.what}: its reference (${step.from}) never came`);
|
|
1050
1056
|
return {
|
|
1051
1057
|
what: step.what,
|
|
1052
|
-
name: line === void 0 ? null : nameOf(line, step.ends),
|
|
1058
|
+
name: line === void 0 ? null : nameOf$1(line, step.ends),
|
|
1053
1059
|
elapsed: null
|
|
1054
1060
|
};
|
|
1055
1061
|
}
|
|
1056
1062
|
const elapsed = between$2(from, line.t);
|
|
1057
|
-
const name = nameOf(line, step.ends);
|
|
1063
|
+
const name = nameOf$1(line, step.ends);
|
|
1058
1064
|
if (step.name !== "OK" && name !== step.name) findings.push(`${step.what}: it ended with ${name}, not ${step.name}`);
|
|
1059
1065
|
const [low, high] = [round(valueIn(step.low, "s") - noise), round(valueIn(step.high, "s") + noise)];
|
|
1060
1066
|
if (elapsed.value < low || elapsed.value > high) findings.push(`${step.what}: ${elapsed.value} s is outside [${low}, ${high}] s`);
|
|
@@ -1104,7 +1110,7 @@ const measureDriver = (cell, run) => {
|
|
|
1104
1110
|
const ended = line?.t ?? report?.at;
|
|
1105
1111
|
const outcome = line !== void 0 && driver.ends !== void 0 ? {
|
|
1106
1112
|
kind: "error",
|
|
1107
|
-
errno: nameOf(line, driver.ends)
|
|
1113
|
+
errno: nameOf$1(line, driver.ends)
|
|
1108
1114
|
} : report?.outcome ?? { kind: "silent" };
|
|
1109
1115
|
if (ended === void 0) return {
|
|
1110
1116
|
...base,
|
|
@@ -1139,6 +1145,171 @@ const measureDriver = (cell, run) => {
|
|
|
1139
1145
|
};
|
|
1140
1146
|
};
|
|
1141
1147
|
//#endregion
|
|
1148
|
+
//#region src/recovery.ts
|
|
1149
|
+
/**
|
|
1150
|
+
* This module holds the pure parts of a recovery drill. These are the scripts of the shell, the reader
|
|
1151
|
+
* of the output of `bsondump`, and the set check of a recovery. It has no container, thus unit tests
|
|
1152
|
+
* run it. `drill.ts` does the drill on a lab.
|
|
1153
|
+
*
|
|
1154
|
+
* A dump with `--oplog` reads the oplog from the start of the dump to its end. A slice reads the
|
|
1155
|
+
* oplog after the last entry of the backup before it. Thus the backups together hold each write up
|
|
1156
|
+
* to the last entry of the last backup, and no write after it. One writer sends the writes one at a
|
|
1157
|
+
* time, thus the highest sequence number is also the latest write.
|
|
1158
|
+
*/
|
|
1159
|
+
/** This size is one document of generated data. A tenant holds as many documents as its size needs. */
|
|
1160
|
+
const SEED_DOCUMENT = 65536;
|
|
1161
|
+
/** This count is how far the lost writes can be from the writes after the last backup: the writes in flight at the fault. */
|
|
1162
|
+
const LOSS_MARGIN = 2;
|
|
1163
|
+
/** This time is how long a script waits for a server to stop, in tenths of a second. */
|
|
1164
|
+
const STOP_TENTHS = 600;
|
|
1165
|
+
/** This function gives the time of a drill from the start of the backups to the fault, in seconds. */
|
|
1166
|
+
const drillSpan = (drill) => drill.slices * valueIn(drill.sliceEvery, "s") + valueIn(drill.age, "s");
|
|
1167
|
+
/**
|
|
1168
|
+
* This script writes the generated data of each tenant into the collection `tenants`. Each write
|
|
1169
|
+
* waits for `copies` members, thus each member holds the data when the script ends.
|
|
1170
|
+
*/
|
|
1171
|
+
const seedScript = (drill, copies) => {
|
|
1172
|
+
const documents = Math.ceil(valueIn(drill.perTenant, "B") / SEED_DOCUMENT);
|
|
1173
|
+
const blob = `BinData(0, require("crypto").randomBytes(${SEED_DOCUMENT}).toString("base64"))`;
|
|
1174
|
+
return `const c = db.getSiblingDB("cells"); c.tenants.drop({ writeConcern: { w: ${copies} } }); for (let t = 1; t <= ${drill.tenants}; t += 1) { const docs = []; for (let d = 1; d <= ${documents}; d += 1) docs.push({ _id: "t" + t + ".d" + d, tenant: t, blob: ${blob} }); c.tenants.insertMany(docs, { writeConcern: { w: ${copies}, wtimeout: 60000 } }); } print(JSON.stringify({ ok: true }))`;
|
|
1175
|
+
};
|
|
1176
|
+
/** This script prints the hash of the collection `tenants`. It takes locks, thus a run reads it with no writer. */
|
|
1177
|
+
const HASH_SCRIPT = "print(JSON.stringify({ hash: db.getSiblingDB(\"cells\").runCommand({ dbHash: 1, collections: [\"tenants\"] }).collections.tenants }))";
|
|
1178
|
+
/** This function gives the hash from the output of `HASH_SCRIPT`. */
|
|
1179
|
+
const hashOf = (text) => {
|
|
1180
|
+
const line = text.split("\n").filter((each) => each.trim() !== "").at(-1) ?? "";
|
|
1181
|
+
const { hash } = JSON.parse(line);
|
|
1182
|
+
if (typeof hash !== "string" || hash === "") throw new Error(`no hash of the tenants in: ${line}`);
|
|
1183
|
+
return hash;
|
|
1184
|
+
};
|
|
1185
|
+
/** This function gives the command of a server with no set, on its own port. A client of the set cannot reach that server, and a restore writes to it. */
|
|
1186
|
+
const standaloneCommand = (set, port) => `${set.programs.server} --bind_ip_all --port ${port} ${(set.args ?? []).join(" ")}`.trim();
|
|
1187
|
+
/**
|
|
1188
|
+
* This script waits for the end of the server of a box, and it can stop the server first. `kill -0`
|
|
1189
|
+
* also succeeds on a zombie, and the shell of the box can leave its child as a zombie for a long
|
|
1190
|
+
* time. Thus the script reads the state of the process, and a zombie has ended.
|
|
1191
|
+
*/
|
|
1192
|
+
const stopScript = (terminate) => "pid=$(cat /tmp/server.pid); " + (terminate ? "kill -TERM \"$pid\"; " : "") + `for i in $(seq 1 ${STOP_TENTHS}); do s=$(cut -d' ' -f3 /proc/$pid/stat 2>/dev/null); if [ -z "\$s" ] || [ "\$s" = Z ]; then exit 0; fi; sleep 0.1; done; exit 1`;
|
|
1193
|
+
/** This function gives a number from Extended JSON, in its relaxed form or in its canonical form, or null. */
|
|
1194
|
+
const numberOf = (value) => {
|
|
1195
|
+
if (typeof value === "number") return value;
|
|
1196
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1197
|
+
const wrapped = value;
|
|
1198
|
+
const text = wrapped.$numberInt ?? wrapped.$numberLong ?? wrapped.$numberDouble;
|
|
1199
|
+
return typeof text === "string" && text !== "" && Number.isFinite(Number(text)) ? Number(text) : null;
|
|
1200
|
+
};
|
|
1201
|
+
/** This function gives each document of the output of `bsondump`. A line that is not a document does not count. */
|
|
1202
|
+
const documentsOf = (text) => text.split("\n").flatMap((line) => {
|
|
1203
|
+
if (!line.trim().startsWith("{")) return [];
|
|
1204
|
+
try {
|
|
1205
|
+
return [JSON.parse(line)];
|
|
1206
|
+
} catch {
|
|
1207
|
+
return [];
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
/** This function gives the `ts` of an oplog entry, or null. */
|
|
1211
|
+
const timeOf = (entry) => {
|
|
1212
|
+
const stamp = entry["ts"]?.$timestamp;
|
|
1213
|
+
const t = numberOf(stamp?.t);
|
|
1214
|
+
const i = numberOf(stamp?.i);
|
|
1215
|
+
return t === null || i === null ? null : {
|
|
1216
|
+
t,
|
|
1217
|
+
i
|
|
1218
|
+
};
|
|
1219
|
+
};
|
|
1220
|
+
/** This function reads an oplog file from the output of `bsondump`. Only an insert into `cells.w` is a write of a client. */
|
|
1221
|
+
const readOplog = (text) => {
|
|
1222
|
+
let last = null;
|
|
1223
|
+
let highest = null;
|
|
1224
|
+
for (const entry of documentsOf(text)) {
|
|
1225
|
+
const time = timeOf(entry);
|
|
1226
|
+
if (time !== null && (last === null || time.t > last.t || time.t === last.t && time.i > last.i)) last = time;
|
|
1227
|
+
if (entry["op"] !== "i" || entry["ns"] !== "cells.w") continue;
|
|
1228
|
+
const n = numberOf(entry["o"]?.n);
|
|
1229
|
+
if (n !== null && (highest === null || n > highest)) highest = n;
|
|
1230
|
+
}
|
|
1231
|
+
return {
|
|
1232
|
+
last,
|
|
1233
|
+
highest
|
|
1234
|
+
};
|
|
1235
|
+
};
|
|
1236
|
+
/** This function gives the highest sequence number of the file of the writes in a dump, from the output of `bsondump`, or null. */
|
|
1237
|
+
const highestHeld = (text) => {
|
|
1238
|
+
const held = documentsOf(text).flatMap((document) => {
|
|
1239
|
+
const n = numberOf(document["n"]);
|
|
1240
|
+
return n === null ? [] : [n];
|
|
1241
|
+
});
|
|
1242
|
+
return held.length === 0 ? null : Math.max(...held);
|
|
1243
|
+
};
|
|
1244
|
+
/** This function gives the query of a slice of the oplog: each entry after an oplog time, in Extended JSON. */
|
|
1245
|
+
const sliceQuery = (after) => JSON.stringify({ ts: { $gt: { $timestamp: {
|
|
1246
|
+
t: after.t,
|
|
1247
|
+
i: after.i
|
|
1248
|
+
} } } });
|
|
1249
|
+
const rounded = (value) => Math.round(value * 1e6) / 1e6;
|
|
1250
|
+
/** This function gives the acknowledgement time of each accepted write of the first client of a run. */
|
|
1251
|
+
const firstAcks = (events, second) => {
|
|
1252
|
+
const acks = /* @__PURE__ */ new Map();
|
|
1253
|
+
for (const event of events) {
|
|
1254
|
+
const n = Number(event["n"]);
|
|
1255
|
+
if (event.ev === "ack" && (second === null || n < second)) acks.set(n, event.t);
|
|
1256
|
+
}
|
|
1257
|
+
return acks;
|
|
1258
|
+
};
|
|
1259
|
+
/** This function gives the measurement of a drill, from its run. `t0` is the fault. */
|
|
1260
|
+
const recoveryOf = (drill, run, t0) => {
|
|
1261
|
+
const record = run.group?.drill;
|
|
1262
|
+
const sameData = record === void 0 || record.after === null ? null : record.after === record.before;
|
|
1263
|
+
if (drill.recovery === "election" || record === void 0) return {
|
|
1264
|
+
rpo: null,
|
|
1265
|
+
excess: null,
|
|
1266
|
+
holes: [],
|
|
1267
|
+
afterBackup: null,
|
|
1268
|
+
sameData
|
|
1269
|
+
};
|
|
1270
|
+
const acks = firstAcks(run.events, record.second);
|
|
1271
|
+
const accepted = [...acks.keys()].sort((a, b) => a - b);
|
|
1272
|
+
const cutoff = record.backups.at(-1)?.last?.n ?? 0;
|
|
1273
|
+
const afterBackup = accepted.filter((n) => n > cutoff).length;
|
|
1274
|
+
const holds = run.group?.holds ?? null;
|
|
1275
|
+
if (holds === null) return {
|
|
1276
|
+
rpo: null,
|
|
1277
|
+
excess: null,
|
|
1278
|
+
holes: [],
|
|
1279
|
+
afterBackup,
|
|
1280
|
+
sameData
|
|
1281
|
+
};
|
|
1282
|
+
const kept = new Set(holds);
|
|
1283
|
+
const ownKept = holds.filter((n) => record.second === null || n < record.second);
|
|
1284
|
+
const highest = ownKept.length === 0 ? 0 : Math.max(...ownKept);
|
|
1285
|
+
const holes = accepted.filter((n) => n < highest && !kept.has(n));
|
|
1286
|
+
const lastKept = accepted.filter((n) => kept.has(n)).at(-1);
|
|
1287
|
+
const rpo = lastKept === void 0 ? null : rounded((t0 - acks.get(lastKept)) / 1e9);
|
|
1288
|
+
return {
|
|
1289
|
+
rpo: rpo === null ? null : {
|
|
1290
|
+
value: rpo,
|
|
1291
|
+
unit: "s"
|
|
1292
|
+
},
|
|
1293
|
+
excess: rpo === null || drill.quiet === true ? null : {
|
|
1294
|
+
value: rounded(rpo - valueIn(drill.age, "s")),
|
|
1295
|
+
unit: "s"
|
|
1296
|
+
},
|
|
1297
|
+
holes,
|
|
1298
|
+
afterBackup,
|
|
1299
|
+
sameData
|
|
1300
|
+
};
|
|
1301
|
+
};
|
|
1302
|
+
/** This function gives the findings of a drill: each rule of a recovery that its run broke. */
|
|
1303
|
+
const recoveryFindings = (measured, writes) => {
|
|
1304
|
+
const findings = [];
|
|
1305
|
+
if (measured.sameData === null) findings.push("the set did not converge, thus nothing compared the data of the tenants");
|
|
1306
|
+
else if (!measured.sameData) findings.push("the data of the tenants changed in the recovery");
|
|
1307
|
+
if (measured.holes.length > 0) findings.push(`the restore lost ${measured.holes.length} accepted writes below a write that it kept, the first at ${measured.holes[0]}`);
|
|
1308
|
+
const lost = writes.lost?.length ?? null;
|
|
1309
|
+
if (measured.afterBackup !== null && lost !== null && Math.abs(lost - measured.afterBackup) > 2) findings.push(`the restore lost ${lost} accepted writes, and ${measured.afterBackup} came after the last backup`);
|
|
1310
|
+
return findings;
|
|
1311
|
+
};
|
|
1312
|
+
//#endregion
|
|
1142
1313
|
//#region src/timeline.ts
|
|
1143
1314
|
/**
|
|
1144
1315
|
* This module measures a run of a group cell. It gives the first write that a primary accepted
|
|
@@ -1484,7 +1655,8 @@ const measureGroup = (cell, run) => {
|
|
|
1484
1655
|
stages,
|
|
1485
1656
|
writes: writesOf(run, t0, cluster ? "" : primary),
|
|
1486
1657
|
errors: errorsOf(events, t0),
|
|
1487
|
-
...cell.calibrates === "sentinels" ? { lags: lagsOf(run, kernel) } : {}
|
|
1658
|
+
...cell.calibrates === "sentinels" ? { lags: lagsOf(run, kernel) } : {},
|
|
1659
|
+
...cell.drill === void 0 ? {} : { recovery: recoveryOf(cell.drill, run, t0) }
|
|
1488
1660
|
};
|
|
1489
1661
|
let voided = null;
|
|
1490
1662
|
const tilt = timeline.find((line) => line.ev === "sentinel" && line.event === "+tilt" && line.t >= t0);
|
|
@@ -1538,6 +1710,7 @@ const measureGroup = (cell, run) => {
|
|
|
1538
1710
|
if (lost === null) findings.push("the group did not converge, thus the count of its lost writes is not known");
|
|
1539
1711
|
else if (lost.length < low || lost.length > high) findings.push(`the group lost ${lost.length} accepted writes, outside [${low}, ${high}]`);
|
|
1540
1712
|
}
|
|
1713
|
+
if (group.recovery !== void 0) findings.push(...recoveryFindings(group.recovery, group.writes));
|
|
1541
1714
|
if (accepted === void 0) {
|
|
1542
1715
|
if (prediction.detects !== null) findings.push(lands === "new" ? "the client never wrote to a new primary in the window" : "the client never wrote again in the window");
|
|
1543
1716
|
return {
|
|
@@ -2166,6 +2339,547 @@ const measureSocket = (cell, run) => {
|
|
|
2166
2339
|
};
|
|
2167
2340
|
};
|
|
2168
2341
|
//#endregion
|
|
2342
|
+
//#region src/fabric.ts
|
|
2343
|
+
/**
|
|
2344
|
+
* A fabric is a routed network between the client and the target, on layer E3 (docs/S7.md). Four
|
|
2345
|
+
* routers of FRRouting, two leaves and two spines, run real BGP over veth pairs that containerlab
|
|
2346
|
+
* makes. The client hangs off leaf1 and the target off leaf2. The path between them goes through
|
|
2347
|
+
* spine1 by a weight, and spine2 holds the other path. A cell silences spine1, and the routes of
|
|
2348
|
+
* the leaves move to spine2 when a detector of the routers fires.
|
|
2349
|
+
*
|
|
2350
|
+
* This file holds the plan of the fabric as pure functions, and `openFabric` that starts it. A lab
|
|
2351
|
+
* opens a fabric with `Topology.fabric`. Thus the client, the target, the prober and the verdict
|
|
2352
|
+
* stay the same as on layer E1. The fabric needs a Linux host with containerlab and sudo.
|
|
2353
|
+
*/
|
|
2354
|
+
/** This image is FRRouting 10.7.1, pinned by digest. */
|
|
2355
|
+
const FRR_IMAGE = "quay.io/frrouting/frr:10.7.1@sha256:e995beaa50fdc9edb35eadcfefa29b7f062cc06f2b812613789b68fa541554d2";
|
|
2356
|
+
/** This device is the side of the fabric in the client and in the target. Docker keeps eth0. */
|
|
2357
|
+
const FABRIC_DEVICE = "eth1";
|
|
2358
|
+
/** This address is the client on its network behind leaf1. */
|
|
2359
|
+
const FABRIC_CLIENT = "10.1.0.2";
|
|
2360
|
+
/** This address is the target on its network behind leaf2. */
|
|
2361
|
+
const FABRIC_TARGET = "10.2.0.2";
|
|
2362
|
+
/** This device of a leaf goes to spine1, thus a route on it is on the preferred path. */
|
|
2363
|
+
const PREFERRED_DEVICE = "eth1";
|
|
2364
|
+
/** This limit is the time that the sessions and the routes get to come back before a run. */
|
|
2365
|
+
const READY_MS = 9e4;
|
|
2366
|
+
const READY_POLL_MS = 250;
|
|
2367
|
+
const CLIENT_NET = "10.1.0.0/24";
|
|
2368
|
+
const TARGET_NET = "10.2.0.0/24";
|
|
2369
|
+
/** These are the routers. Each leaf prefers spine1 by a weight of 100, thus the path of each run is known. */
|
|
2370
|
+
const ROUTERS = [
|
|
2371
|
+
{
|
|
2372
|
+
name: "leaf1",
|
|
2373
|
+
as: 65001,
|
|
2374
|
+
id: "10.255.0.1",
|
|
2375
|
+
interfaces: {
|
|
2376
|
+
eth1: "10.0.1.0/31",
|
|
2377
|
+
eth2: "10.0.2.0/31",
|
|
2378
|
+
eth3: "10.1.0.1/24"
|
|
2379
|
+
},
|
|
2380
|
+
neighbors: [{
|
|
2381
|
+
address: "10.0.1.1",
|
|
2382
|
+
as: 65101,
|
|
2383
|
+
weight: 100
|
|
2384
|
+
}, {
|
|
2385
|
+
address: "10.0.2.1",
|
|
2386
|
+
as: 65102
|
|
2387
|
+
}],
|
|
2388
|
+
network: CLIENT_NET,
|
|
2389
|
+
remote: TARGET_NET
|
|
2390
|
+
},
|
|
2391
|
+
{
|
|
2392
|
+
name: "leaf2",
|
|
2393
|
+
as: 65002,
|
|
2394
|
+
id: "10.255.0.2",
|
|
2395
|
+
interfaces: {
|
|
2396
|
+
eth1: "10.0.3.0/31",
|
|
2397
|
+
eth2: "10.0.4.0/31",
|
|
2398
|
+
eth3: "10.2.0.1/24"
|
|
2399
|
+
},
|
|
2400
|
+
neighbors: [{
|
|
2401
|
+
address: "10.0.3.1",
|
|
2402
|
+
as: 65101,
|
|
2403
|
+
weight: 100
|
|
2404
|
+
}, {
|
|
2405
|
+
address: "10.0.4.1",
|
|
2406
|
+
as: 65102
|
|
2407
|
+
}],
|
|
2408
|
+
network: TARGET_NET,
|
|
2409
|
+
remote: CLIENT_NET
|
|
2410
|
+
},
|
|
2411
|
+
{
|
|
2412
|
+
name: "spine1",
|
|
2413
|
+
as: 65101,
|
|
2414
|
+
id: "10.255.1.1",
|
|
2415
|
+
interfaces: {
|
|
2416
|
+
eth1: "10.0.1.1/31",
|
|
2417
|
+
eth2: "10.0.3.1/31"
|
|
2418
|
+
},
|
|
2419
|
+
neighbors: [{
|
|
2420
|
+
address: "10.0.1.0",
|
|
2421
|
+
as: 65001
|
|
2422
|
+
}, {
|
|
2423
|
+
address: "10.0.3.0",
|
|
2424
|
+
as: 65002
|
|
2425
|
+
}]
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
name: "spine2",
|
|
2429
|
+
as: 65102,
|
|
2430
|
+
id: "10.255.1.2",
|
|
2431
|
+
interfaces: {
|
|
2432
|
+
eth1: "10.0.2.1/31",
|
|
2433
|
+
eth2: "10.0.4.1/31"
|
|
2434
|
+
},
|
|
2435
|
+
neighbors: [{
|
|
2436
|
+
address: "10.0.2.0",
|
|
2437
|
+
as: 65001
|
|
2438
|
+
}, {
|
|
2439
|
+
address: "10.0.4.0",
|
|
2440
|
+
as: 65002
|
|
2441
|
+
}]
|
|
2442
|
+
}
|
|
2443
|
+
];
|
|
2444
|
+
const LEAVES = ROUTERS.filter((router) => router.remote !== void 0);
|
|
2445
|
+
/** These are the links between the routers. The links to the client and the target come from `topologyOf`. */
|
|
2446
|
+
const LINKS = [
|
|
2447
|
+
["leaf1:eth1", "spine1:eth1"],
|
|
2448
|
+
["leaf1:eth2", "spine2:eth1"],
|
|
2449
|
+
["leaf2:eth1", "spine1:eth2"],
|
|
2450
|
+
["leaf2:eth2", "spine2:eth2"]
|
|
2451
|
+
];
|
|
2452
|
+
/** This text is the `daemons` file of each router: BGP and BFD, on top of what FRRouting always starts. */
|
|
2453
|
+
const DAEMONS = [
|
|
2454
|
+
"bgpd=yes",
|
|
2455
|
+
"bfdd=yes",
|
|
2456
|
+
"vtysh_enable=yes",
|
|
2457
|
+
"zebra_options=\" -A 127.0.0.1 -s 90000000\"",
|
|
2458
|
+
"mgmtd_options=\" -A 127.0.0.1\"",
|
|
2459
|
+
"bgpd_options=\" -A 127.0.0.1\"",
|
|
2460
|
+
"bfdd_options=\" -A 127.0.0.1\"",
|
|
2461
|
+
"staticd_options=\"-A 127.0.0.1\"",
|
|
2462
|
+
""
|
|
2463
|
+
].join("\n");
|
|
2464
|
+
/**
|
|
2465
|
+
* This function gives the configuration of a router. Graceful restart is off: after a hold expiry,
|
|
2466
|
+
* a helper can keep the stale routes, and the move that a cell measures does not come.
|
|
2467
|
+
* `maximum-paths 1` keeps one path, thus a move is one change of one route.
|
|
2468
|
+
*/
|
|
2469
|
+
const frrConf = (router, fabric) => {
|
|
2470
|
+
const keepalive = valueIn(fabric.keepalive, "s");
|
|
2471
|
+
const hold = valueIn(fabric.hold, "s");
|
|
2472
|
+
const out = [
|
|
2473
|
+
"frr defaults traditional",
|
|
2474
|
+
`hostname ${router.name}`,
|
|
2475
|
+
"ip forwarding",
|
|
2476
|
+
"!"
|
|
2477
|
+
];
|
|
2478
|
+
for (const [name, address] of Object.entries(router.interfaces)) out.push(`interface ${name}`, ` ip address ${address}`, "!");
|
|
2479
|
+
if (fabric.bfd !== null) {
|
|
2480
|
+
const interval = valueIn(fabric.bfd.interval, "ms");
|
|
2481
|
+
out.push("bfd", " profile fast", ` receive-interval ${interval}`, ` transmit-interval ${interval}`, ` detect-multiplier ${fabric.bfd.multiplier}`, " exit", "exit", "!");
|
|
2482
|
+
}
|
|
2483
|
+
out.push(`router bgp ${router.as}`, ` bgp router-id ${router.id}`, " no bgp ebgp-requires-policy", " no bgp network import-check", " bgp graceful-restart-disable");
|
|
2484
|
+
for (const neighbor of router.neighbors) {
|
|
2485
|
+
out.push(` neighbor ${neighbor.address} remote-as ${neighbor.as}`, ` neighbor ${neighbor.address} timers ${keepalive} ${hold}`, ` neighbor ${neighbor.address} timers connect 1`, ` neighbor ${neighbor.address} advertisement-interval 0`);
|
|
2486
|
+
if (fabric.bfd !== null) out.push(` neighbor ${neighbor.address} bfd profile fast`);
|
|
2487
|
+
}
|
|
2488
|
+
out.push(" !", " address-family ipv4 unicast");
|
|
2489
|
+
if (router.network !== void 0) out.push(` network ${router.network}`);
|
|
2490
|
+
out.push(" maximum-paths 1");
|
|
2491
|
+
for (const neighbor of router.neighbors) if (neighbor.weight !== void 0) out.push(` neighbor ${neighbor.address} weight ${neighbor.weight}`);
|
|
2492
|
+
out.push(" exit-address-family", "exit", "");
|
|
2493
|
+
return out.join("\n");
|
|
2494
|
+
};
|
|
2495
|
+
/**
|
|
2496
|
+
* This function gives the topology file of containerlab. The client and the target are containers
|
|
2497
|
+
* of the lab, and the fabric adds a device to each. Each link has the MTU of a Docker network, thus
|
|
2498
|
+
* a segment is as long as on layer E1. The default of containerlab is 9500. The management network
|
|
2499
|
+
* belongs to the lab, and Docker chooses its subnet.
|
|
2500
|
+
*/
|
|
2501
|
+
const topologyOf = (lab, dir, client, target) => {
|
|
2502
|
+
const nodes = ROUTERS.flatMap((router) => [
|
|
2503
|
+
` ${router.name}:`,
|
|
2504
|
+
" kind: linux",
|
|
2505
|
+
` image: ${FRR_IMAGE}`,
|
|
2506
|
+
" binds:",
|
|
2507
|
+
` - ${dir}/${router.name}.conf:/etc/frr/frr.conf`,
|
|
2508
|
+
` - ${dir}/daemons:/etc/frr/daemons`,
|
|
2509
|
+
" sysctls:",
|
|
2510
|
+
" net.ipv4.ip_forward: 1",
|
|
2511
|
+
" net.ipv4.conf.all.rp_filter: 0",
|
|
2512
|
+
" net.ipv4.conf.default.rp_filter: 0"
|
|
2513
|
+
]);
|
|
2514
|
+
const links = [
|
|
2515
|
+
...LINKS,
|
|
2516
|
+
["leaf1:eth3", `${client}:${FABRIC_DEVICE}`],
|
|
2517
|
+
["leaf2:eth3", `${target}:${FABRIC_DEVICE}`]
|
|
2518
|
+
];
|
|
2519
|
+
return [
|
|
2520
|
+
`name: ${lab}`,
|
|
2521
|
+
"mgmt:",
|
|
2522
|
+
` network: clab-${lab}`,
|
|
2523
|
+
" ipv4-subnet: auto",
|
|
2524
|
+
"topology:",
|
|
2525
|
+
" nodes:",
|
|
2526
|
+
...nodes,
|
|
2527
|
+
` ${client}:`,
|
|
2528
|
+
" kind: ext-container",
|
|
2529
|
+
` ${target}:`,
|
|
2530
|
+
" kind: ext-container",
|
|
2531
|
+
" links:",
|
|
2532
|
+
...links.flatMap(([a, b]) => [` - endpoints: ["${a}", "${b}"]`, " mtu: 1500"]),
|
|
2533
|
+
""
|
|
2534
|
+
].join("\n");
|
|
2535
|
+
};
|
|
2536
|
+
/** This function reads a line of `ip -ts -o monitor route`, or it gives null for another line. The clock of a container is UTC. */
|
|
2537
|
+
const routeLine = (text) => {
|
|
2538
|
+
const line = text.trim();
|
|
2539
|
+
const found = /^\[(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d{6})\] /.exec(line);
|
|
2540
|
+
if (found === null) return null;
|
|
2541
|
+
const [stamp, year, month, day, hour, minute, second, micro] = found;
|
|
2542
|
+
const words = line.slice(stamp.length).split(" ").filter((word) => word !== "");
|
|
2543
|
+
const change = words[0] === "Deleted" || words[0] === "Replaced" ? words[0] : null;
|
|
2544
|
+
const prefix = words[change === null ? 0 : 1];
|
|
2545
|
+
if (prefix === void 0) return null;
|
|
2546
|
+
const after = (name) => {
|
|
2547
|
+
const at = words.indexOf(name);
|
|
2548
|
+
return at === -1 ? null : words[at + 1] ?? null;
|
|
2549
|
+
};
|
|
2550
|
+
return {
|
|
2551
|
+
wall: Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)) + Number(micro) / 1e3,
|
|
2552
|
+
deleted: change === "Deleted",
|
|
2553
|
+
prefix,
|
|
2554
|
+
via: after("via"),
|
|
2555
|
+
dev: after("dev")
|
|
2556
|
+
};
|
|
2557
|
+
};
|
|
2558
|
+
/**
|
|
2559
|
+
* This function takes a time on the wall clock to the monotonic clock. The harness runs on the host
|
|
2560
|
+
* of the containers, thus `process.hrtime` reads the CLOCK_MONOTONIC of the injector and the
|
|
2561
|
+
* prober. The error of the pair is approximately 1 ms, the step of `Date.now`.
|
|
2562
|
+
*/
|
|
2563
|
+
const monotonicAt = (wall, pair) => pair.mono + (wall - pair.wall) * 1e6;
|
|
2564
|
+
/** This function gives the state of the BGP sessions from `show bgp neighbors json`. */
|
|
2565
|
+
const sessionsOf = (json) => {
|
|
2566
|
+
const views = Object.values(JSON.parse(json));
|
|
2567
|
+
return {
|
|
2568
|
+
up: views.filter((view) => view.bgpState === "Established").length,
|
|
2569
|
+
established: views.reduce((sum, view) => sum + (view.connectionsEstablished ?? 0), 0)
|
|
2570
|
+
};
|
|
2571
|
+
};
|
|
2572
|
+
/**
|
|
2573
|
+
* This function tells what a leaf still lacks before a run, or it gives null. The two sessions must
|
|
2574
|
+
* be up and the route must be on spine1. Spine2 must hold the other path. Without these, the fault
|
|
2575
|
+
* of a run acts on another path, or nothing can take over.
|
|
2576
|
+
*/
|
|
2577
|
+
const leafLacks = (views) => {
|
|
2578
|
+
const { up } = sessionsOf(views.neighbors);
|
|
2579
|
+
if (up < 2) return `${up} of 2 BGP sessions are up`;
|
|
2580
|
+
if (!views.route.includes(` dev ${PREFERRED_DEVICE} `)) return `the route is not on spine1: ${views.route || "no route"}`;
|
|
2581
|
+
const paths = JSON.parse(views.paths).paths?.length ?? 0;
|
|
2582
|
+
if (paths < 2) return `BGP holds ${paths} of 2 paths`;
|
|
2583
|
+
if (views.bfd !== null) {
|
|
2584
|
+
const live = JSON.parse(views.bfd).filter((peer) => peer.status === "up").length;
|
|
2585
|
+
if (live < 2) return `${live} of 2 BFD peers are up`;
|
|
2586
|
+
}
|
|
2587
|
+
return null;
|
|
2588
|
+
};
|
|
2589
|
+
/**
|
|
2590
|
+
* This function gives the later move of the leaves after an instant: for each leaf, the first route
|
|
2591
|
+
* to its far network that leaves spine1. It gives null when a leaf has no move. The later move ends
|
|
2592
|
+
* the outage in the two directions.
|
|
2593
|
+
*/
|
|
2594
|
+
const laterMove = (routes, after) => {
|
|
2595
|
+
const firsts = LEAVES.map((leaf) => routes.find((route) => route.leaf === leaf.name && route.prefix === leaf.remote && route.t > after && !route.deleted && route.dev !== PREFERRED_DEVICE)?.t);
|
|
2596
|
+
return firsts.every((t) => t !== void 0) ? Math.max(...firsts) : null;
|
|
2597
|
+
};
|
|
2598
|
+
/** This function runs a program on the host and gives its output. Its error holds the stderr of the program. */
|
|
2599
|
+
const host = (program, args) => new Promise((resolve, reject) => {
|
|
2600
|
+
execFile(program, [...args], { maxBuffer: 16777216 }, (error, stdout, stderr) => {
|
|
2601
|
+
if (error === null) resolve(stdout);
|
|
2602
|
+
else reject(/* @__PURE__ */ new Error(`${program} ${args.join(" ")}: ${error.message}\n${stderr}`));
|
|
2603
|
+
});
|
|
2604
|
+
});
|
|
2605
|
+
const nameOf = (container) => container.getName().replace(/^\//, "");
|
|
2606
|
+
/** This function gives a clock pair at this instant. */
|
|
2607
|
+
const clockPair = () => ({
|
|
2608
|
+
mono: Number(process.hrtime.bigint()),
|
|
2609
|
+
wall: Date.now()
|
|
2610
|
+
});
|
|
2611
|
+
/**
|
|
2612
|
+
* This function deploys the routers of a lab and joins the client and the target to them. It also
|
|
2613
|
+
* starts the sidecars: the injector of spine1 and a route monitor on each leaf. The caller stops the
|
|
2614
|
+
* sidecars with the other containers of the lab, and it calls `close` first.
|
|
2615
|
+
*/
|
|
2616
|
+
const openFabric = async (parts) => {
|
|
2617
|
+
const { fabric, image, admin, injector, opened, output } = parts;
|
|
2618
|
+
const { GenericContainer } = await import("testcontainers");
|
|
2619
|
+
const lab = `e3-${randomBytes(4).toString("hex")}`;
|
|
2620
|
+
const dir = await mkdtemp(join(tmpdir(), "fabric-"));
|
|
2621
|
+
const file = join(dir, "lab.clab.yml");
|
|
2622
|
+
await writeFile(join(dir, "daemons"), DAEMONS);
|
|
2623
|
+
for (const router of ROUTERS) await writeFile(join(dir, `${router.name}.conf`), frrConf(router, fabric));
|
|
2624
|
+
await writeFile(file, topologyOf(lab, dir, nameOf(parts.client), nameOf(parts.server)));
|
|
2625
|
+
const close = async () => {
|
|
2626
|
+
await host("sudo", [
|
|
2627
|
+
"containerlab",
|
|
2628
|
+
"destroy",
|
|
2629
|
+
"-t",
|
|
2630
|
+
file,
|
|
2631
|
+
"--cleanup"
|
|
2632
|
+
]).catch(() => void 0);
|
|
2633
|
+
await rm(dir, {
|
|
2634
|
+
recursive: true,
|
|
2635
|
+
force: true
|
|
2636
|
+
}).catch(() => void 0);
|
|
2637
|
+
};
|
|
2638
|
+
const node = (name) => `clab-${lab}-${name}`;
|
|
2639
|
+
try {
|
|
2640
|
+
await host("sudo", [
|
|
2641
|
+
"containerlab",
|
|
2642
|
+
"deploy",
|
|
2643
|
+
"-t",
|
|
2644
|
+
file
|
|
2645
|
+
]);
|
|
2646
|
+
const version = /version:\s*(\S+)/.exec(await host("containerlab", ["version"]))?.[1] ?? "unknown";
|
|
2647
|
+
await output(admin, [
|
|
2648
|
+
"ip",
|
|
2649
|
+
"addr",
|
|
2650
|
+
"add",
|
|
2651
|
+
`${FABRIC_CLIENT}/24`,
|
|
2652
|
+
"dev",
|
|
2653
|
+
FABRIC_DEVICE
|
|
2654
|
+
]);
|
|
2655
|
+
await output(admin, [
|
|
2656
|
+
"ip",
|
|
2657
|
+
"route",
|
|
2658
|
+
"replace",
|
|
2659
|
+
TARGET_NET,
|
|
2660
|
+
"via",
|
|
2661
|
+
"10.1.0.1",
|
|
2662
|
+
"dev",
|
|
2663
|
+
FABRIC_DEVICE
|
|
2664
|
+
]);
|
|
2665
|
+
await output(injector, [
|
|
2666
|
+
"ip",
|
|
2667
|
+
"addr",
|
|
2668
|
+
"add",
|
|
2669
|
+
`${FABRIC_TARGET}/24`,
|
|
2670
|
+
"dev",
|
|
2671
|
+
FABRIC_DEVICE
|
|
2672
|
+
]);
|
|
2673
|
+
await output(injector, [
|
|
2674
|
+
"ip",
|
|
2675
|
+
"route",
|
|
2676
|
+
"replace",
|
|
2677
|
+
CLIENT_NET,
|
|
2678
|
+
"via",
|
|
2679
|
+
"10.2.0.1",
|
|
2680
|
+
"dev",
|
|
2681
|
+
FABRIC_DEVICE
|
|
2682
|
+
]);
|
|
2683
|
+
const silencer = await new GenericContainer(image).withNetworkMode(`container:${node("spine1")}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
|
|
2684
|
+
opened.push(silencer);
|
|
2685
|
+
const routes = [];
|
|
2686
|
+
const monitors = /* @__PURE__ */ new Map();
|
|
2687
|
+
for (const leaf of LEAVES) {
|
|
2688
|
+
const monitor = await new GenericContainer(image).withNetworkMode(`container:${node(leaf.name)}`).withCommand([
|
|
2689
|
+
"ip",
|
|
2690
|
+
"-ts",
|
|
2691
|
+
"-o",
|
|
2692
|
+
"monitor",
|
|
2693
|
+
"route"
|
|
2694
|
+
]).withLogConsumer((stream) => {
|
|
2695
|
+
let rest = "";
|
|
2696
|
+
stream.on("data", (chunk) => {
|
|
2697
|
+
const text = (rest + chunk.toString()).split("\n");
|
|
2698
|
+
rest = text.pop() ?? "";
|
|
2699
|
+
for (const each of text) {
|
|
2700
|
+
const line = routeLine(each);
|
|
2701
|
+
if (line === null) continue;
|
|
2702
|
+
const { wall, ...route } = line;
|
|
2703
|
+
routes.push({
|
|
2704
|
+
...route,
|
|
2705
|
+
leaf: leaf.name,
|
|
2706
|
+
t: monotonicAt(wall, clockPair())
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
}).start();
|
|
2711
|
+
opened.push(monitor);
|
|
2712
|
+
monitors.set(leaf.name, monitor);
|
|
2713
|
+
}
|
|
2714
|
+
const stamp = async (action) => {
|
|
2715
|
+
const last = (await output(silencer, ["/opt/harness/inject", action])).split("\n").at(-1);
|
|
2716
|
+
const { t0, t1 } = JSON.parse(last);
|
|
2717
|
+
return {
|
|
2718
|
+
t0,
|
|
2719
|
+
t1
|
|
2720
|
+
};
|
|
2721
|
+
};
|
|
2722
|
+
const vtysh = (leaf, view) => host("docker", [
|
|
2723
|
+
"exec",
|
|
2724
|
+
node(leaf),
|
|
2725
|
+
"vtysh",
|
|
2726
|
+
"-c",
|
|
2727
|
+
view
|
|
2728
|
+
]);
|
|
2729
|
+
return {
|
|
2730
|
+
routes,
|
|
2731
|
+
silence: () => stamp("silence"),
|
|
2732
|
+
clear: () => stamp("clear-silence"),
|
|
2733
|
+
async ready() {
|
|
2734
|
+
const deadline = Date.now() + READY_MS;
|
|
2735
|
+
for (;;) {
|
|
2736
|
+
const lacks = [];
|
|
2737
|
+
for (const leaf of LEAVES) {
|
|
2738
|
+
const prefix = leaf.remote;
|
|
2739
|
+
const lacking = await (async () => leafLacks({
|
|
2740
|
+
neighbors: await vtysh(leaf.name, "show bgp neighbors json"),
|
|
2741
|
+
route: `${await output(monitors.get(leaf.name), [
|
|
2742
|
+
"ip",
|
|
2743
|
+
"-o",
|
|
2744
|
+
"route",
|
|
2745
|
+
"show",
|
|
2746
|
+
prefix
|
|
2747
|
+
])} `,
|
|
2748
|
+
paths: await vtysh(leaf.name, `show bgp ipv4 unicast ${prefix} json`),
|
|
2749
|
+
bfd: fabric.bfd === null ? null : await vtysh(leaf.name, "show bfd peers json")
|
|
2750
|
+
}))().catch((error) => String(error));
|
|
2751
|
+
if (lacking !== null) lacks.push(`${leaf.name}: ${lacking}`);
|
|
2752
|
+
}
|
|
2753
|
+
if (lacks.length === 0) return;
|
|
2754
|
+
if (Date.now() > deadline) throw new Error(`the fabric is not ready: ${lacks.join("; ")}`);
|
|
2755
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
|
|
2756
|
+
}
|
|
2757
|
+
},
|
|
2758
|
+
async sessions() {
|
|
2759
|
+
const out = {};
|
|
2760
|
+
for (const leaf of LEAVES) out[leaf.name] = sessionsOf(await vtysh(leaf.name, "show bgp neighbors json")).established;
|
|
2761
|
+
return out;
|
|
2762
|
+
},
|
|
2763
|
+
manifest: () => ({
|
|
2764
|
+
...fabric,
|
|
2765
|
+
image: FRR_IMAGE,
|
|
2766
|
+
containerlab: version
|
|
2767
|
+
}),
|
|
2768
|
+
close
|
|
2769
|
+
};
|
|
2770
|
+
} catch (error) {
|
|
2771
|
+
await close();
|
|
2772
|
+
throw error;
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
//#endregion
|
|
2776
|
+
//#region src/substrate.ts
|
|
2777
|
+
/** GitHub announced Linux jobs on AWS on this date. Each earlier hosted job ran on Azure. */
|
|
2778
|
+
const AWS_FROM = "2026-10-28";
|
|
2779
|
+
/** This function gives the cloud of a DMI vendor. A kernel name does not tell: one image can start on each cloud. */
|
|
2780
|
+
const cloudOf = (vendor) => {
|
|
2781
|
+
if (vendor === null) return "unknown";
|
|
2782
|
+
if (/microsoft/i.test(vendor)) return "azure";
|
|
2783
|
+
if (/amazon/i.test(vendor)) return "aws";
|
|
2784
|
+
return "unknown";
|
|
2785
|
+
};
|
|
2786
|
+
const textOf = (path) => {
|
|
2787
|
+
try {
|
|
2788
|
+
return readFileSync(path, "utf8").trim();
|
|
2789
|
+
} catch {
|
|
2790
|
+
return null;
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2793
|
+
/** This function reads the host. `root` and `env` are for tests. */
|
|
2794
|
+
const readHost = (root = "/", env = process.env, kernel = release()) => {
|
|
2795
|
+
const config = textOf(join(root, "boot", `config-${kernel}`));
|
|
2796
|
+
const hz = config === null ? void 0 : /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1];
|
|
2797
|
+
const os = env["ImageOS"];
|
|
2798
|
+
const version = env["ImageVersion"];
|
|
2799
|
+
return {
|
|
2800
|
+
kernel,
|
|
2801
|
+
hz: hz === void 0 ? null : Number(hz),
|
|
2802
|
+
vendor: textOf(join(root, "sys", "class", "dmi", "id", "sys_vendor")),
|
|
2803
|
+
kvm: existsSync(join(root, "dev", "kvm")),
|
|
2804
|
+
image: os === void 0 || version === void 0 ? null : `${os} ${version}`
|
|
2805
|
+
};
|
|
2806
|
+
};
|
|
2807
|
+
/** This function gives the substrate of a layer on a host. */
|
|
2808
|
+
const substrateOn = (layer, host = readHost()) => ({
|
|
2809
|
+
layer,
|
|
2810
|
+
cloud: cloudOf(host.vendor),
|
|
2811
|
+
...host
|
|
2812
|
+
});
|
|
2813
|
+
/**
|
|
2814
|
+
* This function gives the substrate of a lab at this place. A guest cannot read its own runner: its
|
|
2815
|
+
* DMI vendor is QEMU, and `ImageOS` is not set. Thus the runner writes its host to `LAB_RUNNER`, and
|
|
2816
|
+
* the guest keeps only its own kernel and HZ. A value of that variable makes the layer E4.
|
|
2817
|
+
*/
|
|
2818
|
+
const substrateHere = (layer, host = readHost(), env = process.env) => {
|
|
2819
|
+
const runner = env["LAB_RUNNER"];
|
|
2820
|
+
if (runner === void 0 || runner === "") return substrateOn(layer, host);
|
|
2821
|
+
return substrateOn("E4", {
|
|
2822
|
+
...JSON.parse(runner),
|
|
2823
|
+
kernel: host.kernel,
|
|
2824
|
+
hz: host.hz
|
|
2825
|
+
});
|
|
2826
|
+
};
|
|
2827
|
+
/** This function reads the CPU time of this machine, or gives null without `/proc/stat`. `root` is for tests. */
|
|
2828
|
+
const readCpu = (root = "/") => {
|
|
2829
|
+
const line = textOf(join(root, "proc", "stat"))?.split("\n")[0];
|
|
2830
|
+
if (line === void 0 || !line.startsWith("cpu ")) return null;
|
|
2831
|
+
const ticks = line.trim().split(/\s+/).slice(1, 9).map(Number);
|
|
2832
|
+
const steal = ticks[7];
|
|
2833
|
+
if (steal === void 0 || ticks.some(Number.isNaN)) return null;
|
|
2834
|
+
return {
|
|
2835
|
+
steal,
|
|
2836
|
+
total: ticks.reduce((sum, tick) => sum + tick, 0)
|
|
2837
|
+
};
|
|
2838
|
+
};
|
|
2839
|
+
/**
|
|
2840
|
+
* This function gives the share of the CPU time between two readings that the host took. The share
|
|
2841
|
+
* goes from zero to one. It is null if no tick passed. A guest sees only the host above it. On a hosted
|
|
2842
|
+
* runner that host is itself a guest, and this number does not show the contention of the layer below it.
|
|
2843
|
+
*/
|
|
2844
|
+
const stealShare = (before, after) => {
|
|
2845
|
+
const total = after.total - before.total;
|
|
2846
|
+
return total <= 0 ? null : (after.steal - before.steal) / total;
|
|
2847
|
+
};
|
|
2848
|
+
/**
|
|
2849
|
+
* This function reads the substrate of a record, the upgrade of a record from before this block.
|
|
2850
|
+
* Nothing rewrites such a record. It reads as the given layer. Its cloud is Azure if the record is
|
|
2851
|
+
* from before `AWS_FROM`, and unknown after that date, because its runner did not record the cloud.
|
|
2852
|
+
*/
|
|
2853
|
+
const substrateOf = (source, at, layer = "E1") => source.substrate ?? {
|
|
2854
|
+
layer,
|
|
2855
|
+
cloud: at.slice(0, 10) < "2026-10-28" ? "azure" : "unknown",
|
|
2856
|
+
vendor: null,
|
|
2857
|
+
kernel: source.kernel ?? null,
|
|
2858
|
+
hz: source.hz === void 0 || source.hz === null ? null : Number(source.hz),
|
|
2859
|
+
kvm: null,
|
|
2860
|
+
image: null
|
|
2861
|
+
};
|
|
2862
|
+
const differ = (a, b, unknown) => a !== null && b !== null && a !== unknown && b !== unknown && a !== b;
|
|
2863
|
+
/**
|
|
2864
|
+
* This function tells if a statistic can pool two substrates. They must have one layer, one kernel
|
|
2865
|
+
* and one cloud. An unknown value does not refuse a pair, because an old record cannot tell. The
|
|
2866
|
+
* reader of such a pair says so with `substrateKey`.
|
|
2867
|
+
*/
|
|
2868
|
+
const comparable = (a, b) => a.layer === b.layer && !differ(a.kernel, b.kernel) && !differ(a.cloud, b.cloud, "unknown");
|
|
2869
|
+
/** This function gives the one-line name of a substrate, for a summary. */
|
|
2870
|
+
const substrateKey = (substrate) => `${substrate.layer} ${substrate.cloud} ${substrate.kernel ?? "unknown"}`;
|
|
2871
|
+
/**
|
|
2872
|
+
* This function gives the first two substrates that a statistic cannot pool, or undefined. It
|
|
2873
|
+
* examines each pair. An unknown value matches Azure and AWS, thus one comparison with the first
|
|
2874
|
+
* substrate can miss a pair.
|
|
2875
|
+
*/
|
|
2876
|
+
const unpoolable = (substrates) => {
|
|
2877
|
+
for (const [index, a] of substrates.entries()) {
|
|
2878
|
+
const b = substrates.slice(index + 1).find((other) => !comparable(a, other));
|
|
2879
|
+
if (b !== void 0) return [a, b];
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
//#endregion
|
|
2169
2883
|
//#region src/lab.ts
|
|
2170
2884
|
/** This function loads Testcontainers when a lab first opens. Code that only measures and judges does not load it. */
|
|
2171
2885
|
const containers = () => import("testcontainers");
|
|
@@ -2367,6 +3081,25 @@ const netnsOf = async (container) => {
|
|
|
2367
3081
|
if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
|
|
2368
3082
|
return Number(inode);
|
|
2369
3083
|
};
|
|
3084
|
+
/** This function gives the build record of a source as a part of a manifest: `{ build }`, or nothing for an image that the harness pulls. */
|
|
3085
|
+
const buildOf = (source) => {
|
|
3086
|
+
const build = buildRecordOf(source);
|
|
3087
|
+
return build === void 0 ? {} : { build };
|
|
3088
|
+
};
|
|
3089
|
+
/** This function gives the version of the harness and the build of its image, as a part of the harness record of a manifest. */
|
|
3090
|
+
const harnessRecord = () => ({
|
|
3091
|
+
version: harnessVersion(),
|
|
3092
|
+
...buildOf(shippedSource("harness"))
|
|
3093
|
+
});
|
|
3094
|
+
/**
|
|
3095
|
+
* This function gives the record of the client of a run that the harness ships, for a manifest:
|
|
3096
|
+
* its kind, its image, and its build.
|
|
3097
|
+
*/
|
|
3098
|
+
const shippedClientRecord = (kind, image) => ({
|
|
3099
|
+
kind,
|
|
3100
|
+
image,
|
|
3101
|
+
...buildOf(SHIPPED_CLIENTS[kind].image)
|
|
3102
|
+
});
|
|
2370
3103
|
/**
|
|
2371
3104
|
* This function gives the image and the command of the client of each run, its kind for the
|
|
2372
3105
|
* manifest, and the source of its image. A client by its name is a client that the harness ships
|
|
@@ -2417,7 +3150,9 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2417
3150
|
const opened = [];
|
|
2418
3151
|
const networks = [];
|
|
2419
3152
|
const serverLines = lines();
|
|
3153
|
+
let fabric;
|
|
2420
3154
|
const close = async () => {
|
|
3155
|
+
await fabric?.close();
|
|
2421
3156
|
for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
|
|
2422
3157
|
for (const network of networks.reverse()) await network.stop().catch(() => void 0);
|
|
2423
3158
|
};
|
|
@@ -2558,7 +3293,18 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2558
3293
|
opened.push(admin);
|
|
2559
3294
|
const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
|
|
2560
3295
|
opened.push(injector);
|
|
2561
|
-
|
|
3296
|
+
if (topology?.fabric !== void 0) fabric = await openFabric({
|
|
3297
|
+
fabric: topology.fabric,
|
|
3298
|
+
image,
|
|
3299
|
+
client,
|
|
3300
|
+
server,
|
|
3301
|
+
admin,
|
|
3302
|
+
injector,
|
|
3303
|
+
opened,
|
|
3304
|
+
output
|
|
3305
|
+
});
|
|
3306
|
+
const device = fabric === void 0 ? "eth0" : FABRIC_DEVICE;
|
|
3307
|
+
const clientIp = fabric === void 0 ? await ipOn(client, networks[0]) : FABRIC_CLIENT;
|
|
2562
3308
|
for (let at = 0; at < inChain.length; at += 1) {
|
|
2563
3309
|
const before = at === 0 ? clientIp : inChain[at - 1].ip.target;
|
|
2564
3310
|
inChain[at] = {
|
|
@@ -2641,7 +3387,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2641
3387
|
const begun = await records.until(["begin", "fail"], 3e4, "prober");
|
|
2642
3388
|
if (begun.ev === "fail") throw new Error(`the prober failed: ${String(begun["why"])}`);
|
|
2643
3389
|
const first = endpointAfter(hops, 0);
|
|
2644
|
-
const targetIp = first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
|
|
3390
|
+
const targetIp = fabric !== void 0 ? FABRIC_TARGET : first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
|
|
2645
3391
|
const targetPort = first === hops.length + 1 ? target.port : inChain[first - 1].hop.port;
|
|
2646
3392
|
const targetPeer = inChain.length === 0 ? clientIp : inChain.at(-1).ip.target;
|
|
2647
3393
|
const placed = inChain.length === 0 ? null : {
|
|
@@ -2694,8 +3440,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2694
3440
|
};
|
|
2695
3441
|
/** This function gives the namespace and the device of netem at a place: an end, or the device of a hop toward a side. */
|
|
2696
3442
|
const deviceAt = (place) => {
|
|
2697
|
-
if (place === "client") return [admin,
|
|
2698
|
-
if (place === "target") return [injector,
|
|
3443
|
+
if (place === "client") return [admin, device];
|
|
3444
|
+
if (place === "target") return [injector, device];
|
|
2699
3445
|
const at = hopPlace(place);
|
|
2700
3446
|
const hop = hopNamed(at.hop);
|
|
2701
3447
|
return [hop.injector, hop.devices[at.toward]];
|
|
@@ -2722,6 +3468,7 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
2722
3468
|
middlebox,
|
|
2723
3469
|
server: serverLines.seen,
|
|
2724
3470
|
kernel: records.seen,
|
|
3471
|
+
...fabric === void 0 ? {} : { fabric },
|
|
2725
3472
|
async sysctl(values) {
|
|
2726
3473
|
const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
|
2727
3474
|
if (settings.length > 0) await output(admin, [
|
|
@@ -3071,6 +3818,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3071
3818
|
})(),
|
|
3072
3819
|
boot_id: await output(client, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
3073
3820
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
3821
|
+
substrate: substrateHere(fabric === void 0 ? "E1" : "E3"),
|
|
3822
|
+
...fabric === void 0 ? {} : { fabric: fabric.manifest() },
|
|
3074
3823
|
target: {
|
|
3075
3824
|
image: targetImage,
|
|
3076
3825
|
...target.subject === void 0 ? {} : {
|
|
@@ -3081,7 +3830,8 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3081
3830
|
settings,
|
|
3082
3831
|
sysctls: targetSysctls,
|
|
3083
3832
|
...target.files === void 0 ? {} : { files: Object.fromEntries(Object.entries(target.files).map(([path, content]) => [path, createHash("sha256").update(content).digest("hex")])) },
|
|
3084
|
-
...serverLines.seen[0] === void 0 ? {} : { first: serverLines.seen[0] }
|
|
3833
|
+
...serverLines.seen[0] === void 0 ? {} : { first: serverLines.seen[0] },
|
|
3834
|
+
...buildOf(targetSourceOf(target))
|
|
3085
3835
|
},
|
|
3086
3836
|
harness: {
|
|
3087
3837
|
image,
|
|
@@ -3090,12 +3840,16 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3090
3840
|
"apk",
|
|
3091
3841
|
"info",
|
|
3092
3842
|
"-v"
|
|
3093
|
-
])).split("\n").sort()
|
|
3843
|
+
])).split("\n").sort(),
|
|
3844
|
+
...harnessRecord()
|
|
3094
3845
|
},
|
|
3095
3846
|
client: {
|
|
3096
3847
|
kind: clientKind,
|
|
3097
3848
|
image: clientImage,
|
|
3098
|
-
...source === void 0 ? {} : {
|
|
3849
|
+
...source === void 0 ? {} : {
|
|
3850
|
+
source,
|
|
3851
|
+
...buildOf(source)
|
|
3852
|
+
}
|
|
3099
3853
|
},
|
|
3100
3854
|
netns,
|
|
3101
3855
|
sysctls,
|
|
@@ -3110,6 +3864,54 @@ const openLab = async (target, kind = "tcp", topology) => {
|
|
|
3110
3864
|
}
|
|
3111
3865
|
};
|
|
3112
3866
|
//#endregion
|
|
3867
|
+
//#region src/pins.ts
|
|
3868
|
+
/**
|
|
3869
|
+
* This module tells if a manifest pins each program that a run operated. A record that does not pin
|
|
3870
|
+
* them is not a record. A later run cannot operate the same programs, thus nobody can reproduce
|
|
3871
|
+
* its numbers (SYS-101). Each image is pulled by its digest, or the harness builds it. A build
|
|
3872
|
+
* records its bases by digest, the sha256 of each lockfile, and the versions of the packages that
|
|
3873
|
+
* its manifests name. The commit of the code that operated is a field of the record of a cell, and
|
|
3874
|
+
* `commitProblems` examines it.
|
|
3875
|
+
*/
|
|
3876
|
+
/** This pattern matches the id of an image: `sha256:` and 64 hex digits. */
|
|
3877
|
+
const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
|
|
3878
|
+
/** This pattern matches the tag of a build of the harness: its name, then 12 hex digits of the sha256 of its context. */
|
|
3879
|
+
const BUILD_TAG = /^[a-z0-9][a-z0-9._\-/]*:[0-9a-f]{12}$/;
|
|
3880
|
+
/** This pattern matches a commit of git: 40 hex digits. */
|
|
3881
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
3882
|
+
/** This function gives each problem of the record of a build. A problem is a base that no digest pins, or a tag of another form. */
|
|
3883
|
+
const buildProblems = (what, build) => [...BUILD_TAG.test(build.tag) ? [] : [`${what}: the build has the tag ${build.tag}, which is not a tag of a build`], ...build.bases.filter((base) => !PINNED.test(base)).map((base) => `${what}: the base ${base} is not pinned by digest`)];
|
|
3884
|
+
/**
|
|
3885
|
+
* This function gives each problem of an image of a manifest. A pulled image needs a digest. An
|
|
3886
|
+
* image that the harness builds needs its build record when the manifest can hold one (`build`).
|
|
3887
|
+
* Otherwise, it needs the tag of a build and the id of its image.
|
|
3888
|
+
*/
|
|
3889
|
+
const pinProblems = (what, image, id, build, needsBuild) => {
|
|
3890
|
+
if (PINNED.test(image)) return [];
|
|
3891
|
+
if (build !== void 0) return buildProblems(what, build);
|
|
3892
|
+
if (BUILD_TAG.test(image) && !needsBuild && id !== void 0 && IMAGE_ID.test(id)) return [];
|
|
3893
|
+
return BUILD_TAG.test(image) && needsBuild ? [`${what}: the image ${image} is a build with no build record`] : [`${what}: the image ${image} has no digest`];
|
|
3894
|
+
};
|
|
3895
|
+
/**
|
|
3896
|
+
* This function gives each problem of a manifest that stops it from pinning the programs of its
|
|
3897
|
+
* run. An empty list means that the manifest pins each program.
|
|
3898
|
+
* - the target: an image by its digest, or the build of a subject with its build record and its base
|
|
3899
|
+
* by digest.
|
|
3900
|
+
* - the harness: the version of its package, and the build record of its image.
|
|
3901
|
+
* - the client: an image by its digest, or a build with its build record.
|
|
3902
|
+
* - each hop: an image by its digest, or the tag of a build and the id of its image.
|
|
3903
|
+
*/
|
|
3904
|
+
const manifestProblems = (manifest) => [
|
|
3905
|
+
...pinProblems("the target", manifest.target.image, manifest.target.id, manifest.target.build, manifest.target.subject !== void 0),
|
|
3906
|
+
...manifest.target.subject !== void 0 && (manifest.target.base === void 0 || !PINNED.test(manifest.target.base)) ? [`the target: the base ${manifest.target.base ?? "(none)"} of its subject is not pinned by digest`] : [],
|
|
3907
|
+
...manifest.harness.version === void 0 ? ["the harness: the manifest has no version of the harness"] : [],
|
|
3908
|
+
...manifest.harness.build === void 0 ? ["the harness: the image has no build record"] : buildProblems("the harness", manifest.harness.build),
|
|
3909
|
+
...pinProblems("the client", manifest.client.image, void 0, manifest.client.build, true),
|
|
3910
|
+
...(manifest.hops ?? []).flatMap((hop) => pinProblems(`the hop ${hop.name}`, hop.image, hop.id, void 0, false))
|
|
3911
|
+
];
|
|
3912
|
+
/** This function gives the problem of the commit of a record: no commit, or a text that is not a commit of git. */
|
|
3913
|
+
const commitProblems = (commit) => commit === void 0 ? ["the record has no commit of the code that operated"] : COMMIT.test(commit) ? [] : [`the record has the commit ${commit}, which is not 40 hex digits`];
|
|
3914
|
+
//#endregion
|
|
3113
3915
|
//#region src/group.ts
|
|
3114
3916
|
/** This function gives the configuration file of a member: its port, its settings, and the primary that a replica follows. */
|
|
3115
3917
|
const memberConfig = (group, primary) => [
|
|
@@ -3599,12 +4401,10 @@ const openGroupLab = async (group, options = {}) => {
|
|
|
3599
4401
|
"apk",
|
|
3600
4402
|
"info",
|
|
3601
4403
|
"-v"
|
|
3602
|
-
])).split("\n").sort()
|
|
3603
|
-
|
|
3604
|
-
client: {
|
|
3605
|
-
kind: "ioredis",
|
|
3606
|
-
image: node
|
|
4404
|
+
])).split("\n").sort(),
|
|
4405
|
+
...harnessRecord()
|
|
3607
4406
|
},
|
|
4407
|
+
client: shippedClientRecord("ioredis", node),
|
|
3608
4408
|
netns: {
|
|
3609
4409
|
client: clientNetns,
|
|
3610
4410
|
target: parts.members[first].netns
|
|
@@ -3913,6 +4713,7 @@ const statusViewOf = (set, ips, status) => {
|
|
|
3913
4713
|
why: "healthy"
|
|
3914
4714
|
};
|
|
3915
4715
|
};
|
|
4716
|
+
/** This function waits for a time, in ms. */
|
|
3916
4717
|
const sleep$2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3917
4718
|
/** These are the arguments of replSetStepDown: the time that the old primary cannot stand again, and the wait for a secondary to catch up. */
|
|
3918
4719
|
const STEP_DOWN = {
|
|
@@ -3928,8 +4729,9 @@ const STEP_DOWN = {
|
|
|
3928
4729
|
* - an injector sidecar for each member, in its network and process namespaces.
|
|
3929
4730
|
* - a client that holds its own network namespace, with a privileged sidecar for its sysctls.
|
|
3930
4731
|
* - the prober, which watches the namespace of the client.
|
|
4732
|
+
* - a backup host for a recovery drill: the stock image, for its tools, with no server.
|
|
3931
4733
|
*/
|
|
3932
|
-
const openReplicaSetLab = async (set) => {
|
|
4734
|
+
const openReplicaSetLab = async (set, backups = false) => {
|
|
3933
4735
|
const image = await harnessImage();
|
|
3934
4736
|
const mongo = await mongoClientImage();
|
|
3935
4737
|
const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
|
|
@@ -3988,6 +4790,12 @@ const openReplicaSetLab = async (set) => {
|
|
|
3988
4790
|
"while :; do sleep 3600; done"
|
|
3989
4791
|
]).start();
|
|
3990
4792
|
opened.push(commander);
|
|
4793
|
+
const backupHost = backups ? await new GenericContainer(set.image).withNetwork(network).withCommand([
|
|
4794
|
+
"sh",
|
|
4795
|
+
"-c",
|
|
4796
|
+
"while :; do sleep 3600; done"
|
|
4797
|
+
]).start() : null;
|
|
4798
|
+
if (backupHost !== null) opened.push(backupHost);
|
|
3991
4799
|
const clientNetns = await netnsOf(admin);
|
|
3992
4800
|
const records = lines();
|
|
3993
4801
|
const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand(["/opt/harness/prober", String(clientNetns)]).withLogConsumer(records.consume).start();
|
|
@@ -4005,11 +4813,11 @@ const openReplicaSetLab = async (set) => {
|
|
|
4005
4813
|
return found;
|
|
4006
4814
|
};
|
|
4007
4815
|
/** This function starts a script of the shell on a member, against its own server only. */
|
|
4008
|
-
const shell = (name, script) => output(box(name), [
|
|
4816
|
+
const shell = (name, script, port = set.port) => output(box(name), [
|
|
4009
4817
|
set.programs.shell,
|
|
4010
4818
|
"--quiet",
|
|
4011
4819
|
"--norc",
|
|
4012
|
-
`mongodb://127.0.0.1:${
|
|
4820
|
+
`mongodb://127.0.0.1:${port}/?directConnection=true&serverSelectionTimeoutMS=3000`,
|
|
4013
4821
|
"--eval",
|
|
4014
4822
|
script
|
|
4015
4823
|
]);
|
|
@@ -4163,6 +4971,14 @@ const openReplicaSetLab = async (set) => {
|
|
|
4163
4971
|
await sleep$2(500);
|
|
4164
4972
|
}
|
|
4165
4973
|
},
|
|
4974
|
+
exec(member, argv) {
|
|
4975
|
+
return output(box(member), argv);
|
|
4976
|
+
},
|
|
4977
|
+
script: shell,
|
|
4978
|
+
vault(argv) {
|
|
4979
|
+
if (backupHost === null) throw new Error("this lab has no backup host");
|
|
4980
|
+
return output(backupHost, argv);
|
|
4981
|
+
},
|
|
4166
4982
|
stepDown(primary) {
|
|
4167
4983
|
return stamped(commander, [
|
|
4168
4984
|
"node",
|
|
@@ -4215,6 +5031,7 @@ const openReplicaSetLab = async (set) => {
|
|
|
4215
5031
|
architecture: await output(holder, ["uname", "-m"]),
|
|
4216
5032
|
hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
|
|
4217
5033
|
boot_id: await output(holder, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
5034
|
+
substrate: substrateHere("E1"),
|
|
4218
5035
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
4219
5036
|
target: {
|
|
4220
5037
|
image: set.image,
|
|
@@ -4229,12 +5046,10 @@ const openReplicaSetLab = async (set) => {
|
|
|
4229
5046
|
"apk",
|
|
4230
5047
|
"info",
|
|
4231
5048
|
"-v"
|
|
4232
|
-
])).split("\n").sort()
|
|
4233
|
-
|
|
4234
|
-
client: {
|
|
4235
|
-
kind: "mongodb",
|
|
4236
|
-
image: mongo
|
|
5049
|
+
])).split("\n").sort(),
|
|
5050
|
+
...harnessRecord()
|
|
4237
5051
|
},
|
|
5052
|
+
client: shippedClientRecord("mongodb", mongo),
|
|
4238
5053
|
netns: {
|
|
4239
5054
|
client: clientNetns,
|
|
4240
5055
|
target: members[set.members[0]].netns
|
|
@@ -4254,8 +5069,6 @@ const openReplicaSetLab = async (set) => {
|
|
|
4254
5069
|
throw error;
|
|
4255
5070
|
}
|
|
4256
5071
|
};
|
|
4257
|
-
/** This count is the number of accepted writes that a run waits for before its fault: the stream is steady then. */
|
|
4258
|
-
const STEADY$1 = 20;
|
|
4259
5072
|
/** This time is how long a run continues after its outcome, so that the record shows what came next. */
|
|
4260
5073
|
const TAIL_MS$2 = 2e3;
|
|
4261
5074
|
/** This function gives the arguments of the client of a replica set cell: each member, and the stream of writes. */
|
|
@@ -4274,7 +5087,7 @@ const replicaSetArgs = (cell, members) => {
|
|
|
4274
5087
|
"--interval-ms",
|
|
4275
5088
|
String(cell.probe.interval === void 0 ? 50 : valueIn(cell.probe.interval, "ms")),
|
|
4276
5089
|
"--max-s",
|
|
4277
|
-
String(Math.ceil(deadlineOf(cell) / 1e3 + (cell.hold === void 0 ? 0 : valueIn(cell.hold, "s")) + 120)),
|
|
5090
|
+
String(Math.ceil(deadlineOf(cell) / 1e3 + (cell.hold === void 0 ? 0 : valueIn(cell.hold, "s")) + (cell.drill === void 0 ? 0 : drillSpan(cell.drill)) + 120)),
|
|
4278
5091
|
"--w",
|
|
4279
5092
|
String(cell.writeConcern ?? "majority"),
|
|
4280
5093
|
...cell.driverOptions === void 0 ? [] : ["--options", JSON.stringify(cell.driverOptions)]
|
|
@@ -4326,7 +5139,7 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4326
5139
|
const undo = [];
|
|
4327
5140
|
try {
|
|
4328
5141
|
await client.until(["ready"], 6e4);
|
|
4329
|
-
if (await waitFor$1(client, (event) => event.ev === "ack" && Number(event["n"]) >=
|
|
5142
|
+
if (await waitFor$1(client, (event) => event.ev === "ack" && Number(event["n"]) >= 20, 3e4) === void 0) throw new Error(`the client did not write 20 times in 30 s`);
|
|
4330
5143
|
let healing = null;
|
|
4331
5144
|
switch (cell.fault) {
|
|
4332
5145
|
case "blackhole":
|
|
@@ -4410,6 +5223,296 @@ const replicaSetOnce = async (lab, cell, environment, index) => {
|
|
|
4410
5223
|
};
|
|
4411
5224
|
};
|
|
4412
5225
|
//#endregion
|
|
5226
|
+
//#region src/drill.ts
|
|
5227
|
+
/**
|
|
5228
|
+
* This module does one run of a recovery drill on the lab of a replica set. A drill stops the store
|
|
5229
|
+
* during writes, removes its disk, and recovers from real backups, or by an election.
|
|
5230
|
+
*
|
|
5231
|
+
* A backup is a real `mongodump`, on a host of its own. The dump has the oplog of its own time, and
|
|
5232
|
+
* each slice has the oplog after the backup before it. The fault comes at the age of the newest
|
|
5233
|
+
* backup, on the clock of the VM. Thus a run measures the worst case of its interval by construction.
|
|
5234
|
+
*
|
|
5235
|
+
* A restore cannot have a client of the set: the client writes to any primary. So the restore writes
|
|
5236
|
+
* to a server with no set, on its own port. That server then stops, each member starts with an empty
|
|
5237
|
+
* set, and the old primary starts the set. Only that member has the data, thus it wins.
|
|
5238
|
+
*
|
|
5239
|
+
* The driver refuses a primary with an election id under the highest one that it saw, and a new
|
|
5240
|
+
* set starts its ids again. So a restore has two clients. The first one writes up to the fault, and
|
|
5241
|
+
* the second one starts after the restore, with the sequence numbers that follow the first.
|
|
5242
|
+
*/
|
|
5243
|
+
/** This folder is the store of the backups, on the backup host. */
|
|
5244
|
+
const VAULT = "/backup";
|
|
5245
|
+
/** This time is the longest wait for a server to answer, in ms. */
|
|
5246
|
+
const STARTS_WITHIN = 6e4;
|
|
5247
|
+
/** This function waits until a check holds, or it stops the run after `within` ms. */
|
|
5248
|
+
const until = async (what, check, within) => {
|
|
5249
|
+
const started = Date.now();
|
|
5250
|
+
while (!await check().catch(() => false)) {
|
|
5251
|
+
if (Date.now() - started > within) throw new Error(`${what} did not come in ${within} ms`);
|
|
5252
|
+
await sleep$2(500);
|
|
5253
|
+
}
|
|
5254
|
+
};
|
|
5255
|
+
/** This function gives the time that the client sent a write. Zero is no write. */
|
|
5256
|
+
const writeOf = (events, n) => {
|
|
5257
|
+
const sent = n === 0 ? void 0 : events.find((event) => event.ev === "sent" && Number(event["n"]) === n);
|
|
5258
|
+
return sent === void 0 ? null : {
|
|
5259
|
+
n,
|
|
5260
|
+
t: sent.t
|
|
5261
|
+
};
|
|
5262
|
+
};
|
|
5263
|
+
/** This function gives the size of a folder of the backup host. */
|
|
5264
|
+
const sizeOf = async (lab, path) => {
|
|
5265
|
+
const [bytes] = (await lab.vault([
|
|
5266
|
+
"du",
|
|
5267
|
+
"-sb",
|
|
5268
|
+
path
|
|
5269
|
+
])).split(/\s+/);
|
|
5270
|
+
return {
|
|
5271
|
+
value: Number(bytes),
|
|
5272
|
+
unit: "B"
|
|
5273
|
+
};
|
|
5274
|
+
};
|
|
5275
|
+
/** This function takes the dump with its oplog, and each slice of the oplog at its interval. It gives each backup in sequence. */
|
|
5276
|
+
const backupsOf = async (lab, drill, primary, writer) => {
|
|
5277
|
+
const host = `${lab.members[primary].ip}:${lab.set.port}`;
|
|
5278
|
+
const started = Date.now();
|
|
5279
|
+
await lab.vault([
|
|
5280
|
+
"mongodump",
|
|
5281
|
+
"--host",
|
|
5282
|
+
host,
|
|
5283
|
+
"--oplog",
|
|
5284
|
+
"--out",
|
|
5285
|
+
`${VAULT}/dump-0`
|
|
5286
|
+
]);
|
|
5287
|
+
const ended = Date.now();
|
|
5288
|
+
const oplog = readOplog(await lab.vault([
|
|
5289
|
+
"bsondump",
|
|
5290
|
+
"--quiet",
|
|
5291
|
+
`${VAULT}/dump-0/oplog.bson`
|
|
5292
|
+
]));
|
|
5293
|
+
const held = highestHeld(await lab.vault([
|
|
5294
|
+
"bsondump",
|
|
5295
|
+
"--quiet",
|
|
5296
|
+
`${VAULT}/dump-0/cells/w.bson`
|
|
5297
|
+
]));
|
|
5298
|
+
if (oplog.last === null) throw new Error("the oplog of the dump has no entry");
|
|
5299
|
+
let highest = Math.max(oplog.highest ?? 0, held ?? 0);
|
|
5300
|
+
let from = oplog.last;
|
|
5301
|
+
const backups = [{
|
|
5302
|
+
kind: "dump",
|
|
5303
|
+
started,
|
|
5304
|
+
ended,
|
|
5305
|
+
last: writeOf(writer.events, highest),
|
|
5306
|
+
size: await sizeOf(lab, `${VAULT}/dump-0`)
|
|
5307
|
+
}];
|
|
5308
|
+
const every = valueIn(drill.sliceEvery, "s") * 1e3;
|
|
5309
|
+
for (let slice = 1; slice <= drill.slices; slice += 1) {
|
|
5310
|
+
await sleep$2(Math.max(0, ended + slice * every - Date.now()));
|
|
5311
|
+
const out = `${VAULT}/slice-${slice}`;
|
|
5312
|
+
const begun = Date.now();
|
|
5313
|
+
await lab.vault([
|
|
5314
|
+
"mongodump",
|
|
5315
|
+
"--host",
|
|
5316
|
+
host,
|
|
5317
|
+
"--db",
|
|
5318
|
+
"local",
|
|
5319
|
+
"--collection",
|
|
5320
|
+
"oplog.rs",
|
|
5321
|
+
"--query",
|
|
5322
|
+
sliceQuery(from),
|
|
5323
|
+
"--out",
|
|
5324
|
+
out
|
|
5325
|
+
]);
|
|
5326
|
+
const finished = Date.now();
|
|
5327
|
+
const read = readOplog(await lab.vault([
|
|
5328
|
+
"bsondump",
|
|
5329
|
+
"--quiet",
|
|
5330
|
+
`${out}/local/oplog.rs.bson`
|
|
5331
|
+
]));
|
|
5332
|
+
highest = Math.max(highest, read.highest ?? 0);
|
|
5333
|
+
from = read.last ?? from;
|
|
5334
|
+
backups.push({
|
|
5335
|
+
kind: "slice",
|
|
5336
|
+
started: begun,
|
|
5337
|
+
ended: finished,
|
|
5338
|
+
last: writeOf(writer.events, highest),
|
|
5339
|
+
size: await sizeOf(lab, out)
|
|
5340
|
+
});
|
|
5341
|
+
}
|
|
5342
|
+
return backups;
|
|
5343
|
+
};
|
|
5344
|
+
/**
|
|
5345
|
+
* This function stops the server of each member that the drill loses, and it removes each disk. A
|
|
5346
|
+
* restore loses each member, and an election loses the primary. The fault comes at the age of the
|
|
5347
|
+
* drill after the last write of the newest backup. A control has no later write, and an election
|
|
5348
|
+
* has no backup: their age counts from this point. The stamp is the one of the primary.
|
|
5349
|
+
*/
|
|
5350
|
+
const lose = async (lab, drill, primary, last) => {
|
|
5351
|
+
const parts = drill.recovery === "restore" ? [primary, ...lab.set.members.filter((name) => name !== primary)] : [primary];
|
|
5352
|
+
const age = valueIn(drill.age, "s");
|
|
5353
|
+
const at = last === null || drill.quiet === true ? void 0 : last.t + age * 1e9;
|
|
5354
|
+
if (at === void 0) await sleep$2(age * 1e3);
|
|
5355
|
+
const stamps = await Promise.all(parts.map((part) => lab.signal(part, "KILL", at)));
|
|
5356
|
+
for (const part of parts) {
|
|
5357
|
+
await lab.exec(part, [
|
|
5358
|
+
"sh",
|
|
5359
|
+
"-c",
|
|
5360
|
+
stopScript(false)
|
|
5361
|
+
]);
|
|
5362
|
+
await lab.exec(part, [
|
|
5363
|
+
"find",
|
|
5364
|
+
"/data/db",
|
|
5365
|
+
"-mindepth",
|
|
5366
|
+
"1",
|
|
5367
|
+
"-delete"
|
|
5368
|
+
]);
|
|
5369
|
+
}
|
|
5370
|
+
return stamps[0];
|
|
5371
|
+
};
|
|
5372
|
+
/**
|
|
5373
|
+
* This function restores the old primary from the backups and starts the set again:
|
|
5374
|
+
* - The slices go after the oplog of the dump, in their sequence, thus one replay does the whole restore.
|
|
5375
|
+
* - The restore writes to a server of the old primary with no set, on its own port.
|
|
5376
|
+
* - Each member that lost its disk starts before the set, because `rs.initiate` asks each member.
|
|
5377
|
+
* - The old primary starts the set. It has the data, and the other members make their first sync from it.
|
|
5378
|
+
*/
|
|
5379
|
+
const restore = async (lab, drill, primary) => {
|
|
5380
|
+
const { set } = lab;
|
|
5381
|
+
const started = Date.now();
|
|
5382
|
+
const port = set.port + 1;
|
|
5383
|
+
const slices = Array.from({ length: drill.slices }, (_, at) => `${VAULT}/slice-${at + 1}/local/oplog.rs.bson`);
|
|
5384
|
+
if (slices.length > 0) await lab.vault([
|
|
5385
|
+
"sh",
|
|
5386
|
+
"-c",
|
|
5387
|
+
`cat ${slices.join(" ")} >> ${VAULT}/dump-0/oplog.bson`
|
|
5388
|
+
]);
|
|
5389
|
+
await lab.exec(primary, [
|
|
5390
|
+
"sh",
|
|
5391
|
+
"-c",
|
|
5392
|
+
`${standaloneCommand(set, port)} > /proc/1/fd/1 2>&1 & echo $! > /tmp/server.pid`
|
|
5393
|
+
]);
|
|
5394
|
+
await until("the server of the restore", async () => (await lab.script(primary, "print(db.hello().ok)", port)).includes("1"), STARTS_WITHIN);
|
|
5395
|
+
await lab.vault([
|
|
5396
|
+
"mongorestore",
|
|
5397
|
+
"--host",
|
|
5398
|
+
`${lab.members[primary].ip}:${port}`,
|
|
5399
|
+
"--oplogReplay",
|
|
5400
|
+
`${VAULT}/dump-0`
|
|
5401
|
+
]);
|
|
5402
|
+
await lab.exec(primary, [
|
|
5403
|
+
"sh",
|
|
5404
|
+
"-c",
|
|
5405
|
+
stopScript(true)
|
|
5406
|
+
]);
|
|
5407
|
+
for (const name of set.members) if (name !== primary) await lab.restart(name);
|
|
5408
|
+
await lab.restart(primary);
|
|
5409
|
+
const ips = Object.fromEntries(Object.entries(lab.members).map(([name, at]) => [name, at.ip]));
|
|
5410
|
+
const initiated = await lab.script(primary, initiateScript(set, ips));
|
|
5411
|
+
if (!initiated.includes("\"ok\":1")) throw new Error(`rs.initiate failed after the restore: ${initiated}`);
|
|
5412
|
+
await until("the primary after the restore", async () => (await lab.script(primary, "print(db.hello().isWritablePrimary)")).includes("true"), STARTS_WITHIN);
|
|
5413
|
+
return {
|
|
5414
|
+
started,
|
|
5415
|
+
ended: Date.now()
|
|
5416
|
+
};
|
|
5417
|
+
};
|
|
5418
|
+
/**
|
|
5419
|
+
* This function does one run of a recovery drill:
|
|
5420
|
+
* - The set converges, and it loses its writes. The tenants get their data, and a hash reads it.
|
|
5421
|
+
* - The client writes until its stream is steady. A control stops it here.
|
|
5422
|
+
* - The backups go, each at its interval. The fault comes at the age of the newest backup.
|
|
5423
|
+
* - An election continues the same client. A restore stops the client, and it starts a second one.
|
|
5424
|
+
* - The set converges again. The writes that it then holds tell which writes it kept, and a second
|
|
5425
|
+
* hash tells if the tenants have the same data.
|
|
5426
|
+
*/
|
|
5427
|
+
const drillOnce = async (lab, cell, environment, index) => {
|
|
5428
|
+
const drill = cell.drill;
|
|
5429
|
+
const { set } = lab;
|
|
5430
|
+
const primary = await lab.converge();
|
|
5431
|
+
await lab.flush(primary);
|
|
5432
|
+
await lab.unfreeze(primary);
|
|
5433
|
+
await lab.forget();
|
|
5434
|
+
await lab.vault([
|
|
5435
|
+
"sh",
|
|
5436
|
+
"-c",
|
|
5437
|
+
`rm -rf ${VAULT} && mkdir -p ${VAULT}`
|
|
5438
|
+
]);
|
|
5439
|
+
const seeded = await lab.script(primary, seedScript(drill, set.members.length));
|
|
5440
|
+
if (!seeded.includes("\"ok\":true")) throw new Error(`the seed failed: ${seeded}`);
|
|
5441
|
+
const before = hashOf(await lab.script(primary, HASH_SCRIPT));
|
|
5442
|
+
const tool = (await lab.vault(["mongodump", "--version"])).split("\n")[0].trim();
|
|
5443
|
+
const from = lab.kernel.length;
|
|
5444
|
+
const logFrom = lab.log.length;
|
|
5445
|
+
const lostBefore = droppedSoFar$2(lab.kernel);
|
|
5446
|
+
const writer = await lab.client(replicaSetArgs(cell, lab.members));
|
|
5447
|
+
const restores = drill.recovery === "restore";
|
|
5448
|
+
let successor = null;
|
|
5449
|
+
let second = null;
|
|
5450
|
+
let fault = null;
|
|
5451
|
+
let restored = null;
|
|
5452
|
+
const backups = [];
|
|
5453
|
+
try {
|
|
5454
|
+
await writer.until(["ready"], 6e4);
|
|
5455
|
+
if (await waitFor$1(writer, (event) => event.ev === "ack" && Number(event["n"]) >= 20, 3e4) === void 0) throw new Error(`the client did not write 20 times in 30 s`);
|
|
5456
|
+
if (drill.quiet === true) await writer.stop();
|
|
5457
|
+
if (restores) backups.push(...await backupsOf(lab, drill, primary, writer));
|
|
5458
|
+
const stamp = await lose(lab, drill, primary, backups.at(-1)?.last ?? null);
|
|
5459
|
+
fault = stamp;
|
|
5460
|
+
if (restores) {
|
|
5461
|
+
await writer.stop();
|
|
5462
|
+
restored = await restore(lab, drill, primary);
|
|
5463
|
+
second = Math.max(0, ...writer.events.filter((event) => event.ev === "sent").map((event) => Number(event["n"]))) + 1;
|
|
5464
|
+
successor = await lab.client([
|
|
5465
|
+
...replicaSetArgs(cell, lab.members),
|
|
5466
|
+
"--from",
|
|
5467
|
+
String(second)
|
|
5468
|
+
]);
|
|
5469
|
+
if (await waitFor$1(successor, (event) => event.ev === "ack", deadlineOf(cell)) !== void 0) await sleep$2(TAIL_MS$2);
|
|
5470
|
+
} else {
|
|
5471
|
+
const old = `${lab.members[primary].ip}:${set.port}`;
|
|
5472
|
+
const sentAt = (n) => writer.events.find((event) => event.ev === "sent" && Number(event["n"]) === n)?.t ?? -Infinity;
|
|
5473
|
+
if (await waitFor$1(writer, (event) => event.ev === "ack" && sentAt(Number(event["n"])) > stamp.t1 && typeof event["at"] === "string" && event["at"] !== old, deadlineOf(cell)) !== void 0) await sleep$2(TAIL_MS$2);
|
|
5474
|
+
await lab.restart(primary);
|
|
5475
|
+
}
|
|
5476
|
+
} finally {
|
|
5477
|
+
await writer.stop();
|
|
5478
|
+
await successor?.stop();
|
|
5479
|
+
}
|
|
5480
|
+
const settled = await lab.settle();
|
|
5481
|
+
const lost = settled === null ? null : settled - lostBefore;
|
|
5482
|
+
const after = await lab.converge().catch(() => null);
|
|
5483
|
+
const holds = after === null ? null : await lab.holds(after);
|
|
5484
|
+
const hash = after === null ? null : hashOf(await lab.script(after, HASH_SCRIPT));
|
|
5485
|
+
const events = [...writer.events, ...successor?.events ?? []].sort((a, b) => a.t - b.t);
|
|
5486
|
+
return {
|
|
5487
|
+
environment,
|
|
5488
|
+
index,
|
|
5489
|
+
events,
|
|
5490
|
+
fault,
|
|
5491
|
+
kernel: lab.kernel.slice(from),
|
|
5492
|
+
netns: lab.netns.client,
|
|
5493
|
+
targetNetns: lab.members[primary].netns,
|
|
5494
|
+
lost,
|
|
5495
|
+
group: {
|
|
5496
|
+
members: lab.members,
|
|
5497
|
+
sentinels: {},
|
|
5498
|
+
primary,
|
|
5499
|
+
at: primary,
|
|
5500
|
+
timeline: onMonotonic(lab.log.slice(logFrom), clockOffsetOf(events)),
|
|
5501
|
+
after,
|
|
5502
|
+
holds,
|
|
5503
|
+
drill: {
|
|
5504
|
+
tool,
|
|
5505
|
+
seed: "generated",
|
|
5506
|
+
backups,
|
|
5507
|
+
before,
|
|
5508
|
+
after: hash,
|
|
5509
|
+
restored,
|
|
5510
|
+
second
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
};
|
|
5514
|
+
};
|
|
5515
|
+
//#endregion
|
|
4413
5516
|
//#region src/cluster.ts
|
|
4414
5517
|
/** This form is a line of the console log of RabbitMQ 4.3: the time with its offset, the level, the process and the message. */
|
|
4415
5518
|
const LINE = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d)\.(\d{3})(\d*)([+-]\d\d:\d\d) \[(\w+)\] <[^>]*> (.*)$/;
|
|
@@ -4902,6 +6005,7 @@ const openClusterLab = async (cluster) => {
|
|
|
4902
6005
|
architecture: await output(holder, ["uname", "-m"]),
|
|
4903
6006
|
hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
|
|
4904
6007
|
boot_id: await output(holder, ["cat", "/proc/sys/kernel/random/boot_id"]),
|
|
6008
|
+
substrate: substrateHere("E1"),
|
|
4905
6009
|
docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
|
|
4906
6010
|
target: {
|
|
4907
6011
|
image: cluster.image,
|
|
@@ -4916,12 +6020,10 @@ const openClusterLab = async (cluster) => {
|
|
|
4916
6020
|
"apk",
|
|
4917
6021
|
"info",
|
|
4918
6022
|
"-v"
|
|
4919
|
-
])).split("\n").sort()
|
|
4920
|
-
|
|
4921
|
-
client: {
|
|
4922
|
-
kind: "amqp",
|
|
4923
|
-
image: amqp
|
|
6023
|
+
])).split("\n").sort(),
|
|
6024
|
+
...harnessRecord()
|
|
4924
6025
|
},
|
|
6026
|
+
client: shippedClientRecord("amqp", amqp),
|
|
4925
6027
|
netns: {
|
|
4926
6028
|
client: clientNetns,
|
|
4927
6029
|
target: members[cluster.members[0]].netns
|
|
@@ -5289,6 +6391,8 @@ const requestSent = (probe, within) => new Promise((resolve, reject) => {
|
|
|
5289
6391
|
});
|
|
5290
6392
|
/** This time is how long a run of a driver cell continues after its outcome, so that the record shows what came next. */
|
|
5291
6393
|
const TAIL_MS = 1e3;
|
|
6394
|
+
/** This time lets the last lines of a route monitor reach the harness after a stall ends. */
|
|
6395
|
+
const MONITOR_MS = 200;
|
|
5292
6396
|
/** This function waits for the outcome of a run of a driver cell, or for `within` ms. The outcome is the end of its band and of each later band. */
|
|
5293
6397
|
const driverOutcome = async (cell, current, within) => {
|
|
5294
6398
|
const started = Date.now();
|
|
@@ -5396,6 +6500,9 @@ const netemSpec = (cell, fault) => {
|
|
|
5396
6500
|
*/
|
|
5397
6501
|
const once = async (lab, cell, environment, index, dry = false) => {
|
|
5398
6502
|
if (cell.before === true) return beforeOnce(lab, cell, environment, index);
|
|
6503
|
+
await lab.fabric?.ready();
|
|
6504
|
+
const sessions = await lab.fabric?.sessions();
|
|
6505
|
+
const routesFrom = lab.fabric?.routes.length ?? 0;
|
|
5399
6506
|
await lab.forget();
|
|
5400
6507
|
const from = lab.kernel.length;
|
|
5401
6508
|
const middleboxFrom = lab.middlebox.length;
|
|
@@ -5415,6 +6522,10 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5415
6522
|
throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
|
|
5416
6523
|
});
|
|
5417
6524
|
const placed = lab.topology === null ? {} : { topology: lab.topology };
|
|
6525
|
+
const fabricOf = () => {
|
|
6526
|
+
if (lab.fabric === void 0) throw new Error(`${cell.name} silences a router of a lab with no fabric`);
|
|
6527
|
+
return lab.fabric;
|
|
6528
|
+
};
|
|
5418
6529
|
/** This function gives the run as the records show it so far: the waits read it. */
|
|
5419
6530
|
const partial = () => ({
|
|
5420
6531
|
environment,
|
|
@@ -5488,6 +6599,7 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5488
6599
|
case "server-close":
|
|
5489
6600
|
await lab.serverClose();
|
|
5490
6601
|
return null;
|
|
6602
|
+
case "silence": return fabricOf().silence();
|
|
5491
6603
|
case "hop-event": {
|
|
5492
6604
|
if (cell.event === void 0) throw new Error(`${cell.name} names no event`);
|
|
5493
6605
|
const stamp = await lab.event(cell.event);
|
|
@@ -5545,6 +6657,9 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5545
6657
|
case "toxic":
|
|
5546
6658
|
await lab.toxic(null);
|
|
5547
6659
|
return;
|
|
6660
|
+
case "silence":
|
|
6661
|
+
await fabricOf().clear();
|
|
6662
|
+
return;
|
|
5548
6663
|
default: return heal === null ? lab.lift("blackhole", faultAt) : void 0;
|
|
5549
6664
|
}
|
|
5550
6665
|
};
|
|
@@ -5576,6 +6691,21 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5576
6691
|
"eof",
|
|
5577
6692
|
"drop"
|
|
5578
6693
|
], window * 1e3).catch(() => void 0);
|
|
6694
|
+
} else if (cell.fault === "silence") {
|
|
6695
|
+
const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
|
|
6696
|
+
await requestSent(probe, 5e3);
|
|
6697
|
+
await probe.until([
|
|
6698
|
+
"data",
|
|
6699
|
+
"error",
|
|
6700
|
+
"eof",
|
|
6701
|
+
"drop"
|
|
6702
|
+
], window * 1e3).catch(() => void 0);
|
|
6703
|
+
await sleep(MONITOR_MS);
|
|
6704
|
+
const moved = laterMove(fabricOf().routes.slice(routesFrom), fault?.t0 ?? 0);
|
|
6705
|
+
heal = moved === null ? null : {
|
|
6706
|
+
t0: moved,
|
|
6707
|
+
t1: moved
|
|
6708
|
+
};
|
|
5579
6709
|
} else if (prediction.detects === null) {
|
|
5580
6710
|
const window = valueIn(prediction.window, "s") * 1e3;
|
|
5581
6711
|
await probe.until([
|
|
@@ -5617,14 +6747,21 @@ const once = async (lab, cell, environment, index, dry = false) => {
|
|
|
5617
6747
|
index,
|
|
5618
6748
|
events: [...probe.events],
|
|
5619
6749
|
fault,
|
|
5620
|
-
...cell.fault === "stall" || thawing !== null ? { heal } : {},
|
|
6750
|
+
...cell.fault === "stall" || cell.fault === "silence" || thawing !== null ? { heal } : {},
|
|
5621
6751
|
kernel: lab.kernel.slice(from),
|
|
5622
6752
|
netns: lab.netns.client,
|
|
5623
6753
|
targetNetns: lab.netns.target,
|
|
5624
6754
|
lost,
|
|
5625
6755
|
...placed,
|
|
5626
6756
|
...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) },
|
|
5627
|
-
...cell.target.lines === true ? { server: lab.server.slice(serverFrom) } : {}
|
|
6757
|
+
...cell.target.lines === true ? { server: lab.server.slice(serverFrom) } : {},
|
|
6758
|
+
...lab.fabric === void 0 || sessions === void 0 ? {} : { fabric: {
|
|
6759
|
+
routes: lab.fabric.routes.slice(routesFrom),
|
|
6760
|
+
established: {
|
|
6761
|
+
before: sessions,
|
|
6762
|
+
after: await lab.fabric.sessions()
|
|
6763
|
+
}
|
|
6764
|
+
} }
|
|
5628
6765
|
};
|
|
5629
6766
|
};
|
|
5630
6767
|
/** This function divides a count of runs across environments, as equally as possible. */
|
|
@@ -5636,6 +6773,17 @@ const spread = (runs, environments) => Array.from({ length: environments }, (_,
|
|
|
5636
6773
|
const endsEnvironment = (cell) => cell.fresh === true || cell.fault === "fin" && (cell.observer === void 0 || cell.observer === "client");
|
|
5637
6774
|
/** This function says one line for a run. */
|
|
5638
6775
|
const line = (cell, run, read) => `${cell.name}: env ${run.environment} ${run.warmup === true ? "warm-up" : `run ${run.index}`} ${read.outcome.kind}${read.elapsed === null ? "" : ` ${read.elapsed.value} s`}${read.void === null ? "" : ` (void: ${read.void})`}`;
|
|
6776
|
+
/** This function adds to a run the share of the CPU that the host took while the run operated. */
|
|
6777
|
+
const withSteal = async (work) => {
|
|
6778
|
+
const before = readCpu();
|
|
6779
|
+
const run = await work();
|
|
6780
|
+
const after = readCpu();
|
|
6781
|
+
const steal = before === null || after === null ? null : stealShare(before, after);
|
|
6782
|
+
return steal === null ? run : {
|
|
6783
|
+
...run,
|
|
6784
|
+
steal
|
|
6785
|
+
};
|
|
6786
|
+
};
|
|
5639
6787
|
/**
|
|
5640
6788
|
* This function does the part of the runs of a cell in one environment, and records its manifest.
|
|
5641
6789
|
* A warm-up goes first, and the verdict does not count it. On a loaded machine, a new environment is
|
|
@@ -5665,13 +6813,13 @@ const inEnvironment = async (cell, environment, count, say) => {
|
|
|
5665
6813
|
jitter: jitterOf(cell, run)
|
|
5666
6814
|
});
|
|
5667
6815
|
const warmup = {
|
|
5668
|
-
...timed(await once(lab, cell, environment, -1, endsEnvironment(cell))),
|
|
6816
|
+
...timed(await withSteal(() => once(lab, cell, environment, -1, endsEnvironment(cell)))),
|
|
5669
6817
|
warmup: true
|
|
5670
6818
|
};
|
|
5671
6819
|
say(line(cell, warmup, measure(cell, warmup)));
|
|
5672
6820
|
const runs = [];
|
|
5673
6821
|
for (let index = 0; index < count; index += 1) {
|
|
5674
|
-
const run = timed(await once(lab, cell, environment, index));
|
|
6822
|
+
const run = timed(await withSteal(() => once(lab, cell, environment, index)));
|
|
5675
6823
|
runs.push(run);
|
|
5676
6824
|
say(line(cell, run, measure(cell, run)));
|
|
5677
6825
|
}
|
|
@@ -5718,17 +6866,18 @@ const inGroupEnvironment = async (cell, environment, count, say) => {
|
|
|
5718
6866
|
* is a whole run with its fault, as for a group under Sentinels. The set converges before each run.
|
|
5719
6867
|
*/
|
|
5720
6868
|
const inReplicaSetEnvironment = async (cell, environment, count, say) => {
|
|
5721
|
-
const lab = await openReplicaSetLab(cell.replicaSet);
|
|
6869
|
+
const lab = await openReplicaSetLab(cell.replicaSet, cell.drill !== void 0);
|
|
6870
|
+
const once = cell.drill === void 0 ? replicaSetOnce : drillOnce;
|
|
5722
6871
|
try {
|
|
5723
6872
|
const manifest = await lab.manifest(await lab.sysctl(cell.sysctls));
|
|
5724
6873
|
const warmup = {
|
|
5725
|
-
...await
|
|
6874
|
+
...await once(lab, cell, environment, -1),
|
|
5726
6875
|
warmup: true
|
|
5727
6876
|
};
|
|
5728
6877
|
say(line(cell, warmup, measure(cell, warmup)));
|
|
5729
6878
|
const runs = [];
|
|
5730
6879
|
for (let index = 0; index < count; index += 1) {
|
|
5731
|
-
const run = await
|
|
6880
|
+
const run = await once(lab, cell, environment, index);
|
|
5732
6881
|
runs.push(run);
|
|
5733
6882
|
say(line(cell, run, measure(cell, run)));
|
|
5734
6883
|
}
|
|
@@ -5823,6 +6972,6 @@ const runCell = async (cell, say = () => {}) => {
|
|
|
5823
6972
|
}
|
|
5824
6973
|
};
|
|
5825
6974
|
//#endregion
|
|
5826
|
-
export { CLIENT_NAMES, CLUSTER_STAGES, CONVERGE_MS, DROP_HANDLING, GIVES_AWAY, INJECT, KEPT, LINGER_MS, LISTING, MEMBER_MARKS, MONITOR_DOWN, NODE_MARKS, NOISE, PINNED, PUMP, PUMP_SLACK, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, REPOSITORY, SHIPPED_CLIENTS, SHIPPED_NAMES, STAGES, STATUS_SCRIPT, STEPS, STEP_DOWN, amqpClientImage, callOf, clockOffsetOf, closeOrExit, clusterArgs, clusterConf, clusterCutOf, clusterHostsOf, clusterOnce, clusterStagesOf, clusterViewOf, crossingsOf, cutOf, deadlineOf, delayVoids, deviceOf, dropFinding, dropOf, endpointAfter, errorsOf, exitOf, fieldOf, fieldsOf, firstUnanswered, goHttpImage, groupOnce, groupVerdictOf, grpcClientImage, harnessImage, hikariClientImage, hzOf, imageOf, imageProblems, inContext, infoOf, initiateScript, isHopPlace, jitterOf, jitterOfLines, judge, lagsOf, latestEndOf, leftAfter, logNameOf, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, monotonicOf, nodeClientImage, nodeCommand, nodeHttpImage, nodeLineOf, nodeNameOf, nodesOnMonotonic, noiseOf, onMonotonic, openClusterLab, openGroupLab, openLab, openReplicaSetLab, pairingOf, pairingsOf, peersOf, pgClientImage, pingsOf, plainOf, planOf, programLines, programOf, quantileOf, reconnectFinding, reconnectOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, restamped, routerImage, routesOf, runCell, sentinelConfig, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, statusViewOf, subjectClient, subjectProblems, subscriptionsOf, targetImageOf, targetSourceOf, templated, toolJsonOf, toxicBody, unlockedManifests, unpinnedBases, verdictOf, viewOf, writesOf };
|
|
6975
|
+
export { AWS_FROM, CLIENT_NAMES, CLUSTER_STAGES, CONVERGE_MS, DAEMONS, DROP_HANDLING, FABRIC_CLIENT, FABRIC_DEVICE, FABRIC_TARGET, FRR_IMAGE, GIVES_AWAY, HASH_SCRIPT, INJECT, KEPT, LINGER_MS, LISTING, LOCK_NAMES, LOSS_MARGIN, MEMBER_MARKS, MONITOR_DOWN, NODE_MARKS, NOISE, PINNED, PUMP, PUMP_SLACK, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, REPOSITORY, ROUTERS, SEED_DOCUMENT, SHIPPED_CLIENTS, SHIPPED_NAMES, STAGES, STATUS_SCRIPT, STEPS, STEP_DOWN, amqpClientImage, basesOf, buildOf, buildRecordOf, callOf, cargoVersions, clockOffsetOf, closeOrExit, cloudOf, clusterArgs, clusterConf, clusterCutOf, clusterHostsOf, clusterOnce, clusterStagesOf, clusterViewOf, commitProblems, comparable, crossingsOf, cutOf, deadlineOf, delayVoids, deviceOf, drillOnce, drillSpan, dropFinding, dropOf, endpointAfter, errorsOf, exitOf, fieldOf, fieldsOf, firstUnanswered, frrConf, goHttpImage, goVersions, groupOnce, groupVerdictOf, grpcClientImage, harnessImage, harnessRecord, harnessVersion, hashOf, highestHeld, hikariClientImage, hzOf, imageOf, imageProblems, inContext, infoOf, initiateScript, isHopPlace, jarVersions, jitterOf, jitterOfLines, judge, lagsOf, laterMove, latestEndOf, leafLacks, leftAfter, locksOf, logNameOf, manifestProblems, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, monotonicAt, monotonicOf, nodeClientImage, nodeCommand, nodeHttpImage, nodeLineOf, nodeNameOf, nodesOnMonotonic, noiseOf, npmVersions, onMonotonic, openClusterLab, openFabric, openGroupLab, openLab, openReplicaSetLab, pairingOf, pairingsOf, peersOf, pgClientImage, pingsOf, plainOf, planOf, programLines, programOf, quantileOf, readCpu, readHost, readOplog, reconnectFinding, reconnectOf, recoveryFindings, recoveryOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, restamped, routeLine, routerImage, routesOf, runCell, seedScript, sentinelConfig, sessionsOf, shippedClientRecord, shippedSource, sliceQuery, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, standaloneCommand, statusViewOf, stealShare, stopScript, subjectClient, subjectProblems, subscriptionsOf, substrateHere, substrateKey, substrateOf, substrateOn, targetImageOf, targetSourceOf, templated, toolJsonOf, topologyOf, toxicBody, unlockedManifests, unpinnedBases, unpoolable, verdictOf, versionsOf, viewOf, writesOf };
|
|
5827
6976
|
|
|
5828
6977
|
//# sourceMappingURL=index.js.map
|