@mark1russell7/harness 0.4.0 → 0.5.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.
@@ -27,6 +27,17 @@
27
27
  //! toxic of Toxiproxy. The stamps bracket the request and its answer, after the connect, so the
28
28
  //! commit of the toxic is known on the same clock as the kernel's records.
29
29
  //!
30
+ //! - `signal NAME SIGNAL`: SIGNAL (KILL, STOP, CONT or TERM) to each process whose name is NAME,
31
+ //! in the process namespace this sidecar shares with a member of a group. STOP and CONT freeze
32
+ //! a server and thaw it while its kernel stays up; KILL stops it, and its kernel closes its
33
+ //! sockets. The stamps bracket the kill(2) calls.
34
+ //! - `resp ADDR:PORT ARG...`: one command to a Redis-protocol server, for example
35
+ //! `SENTINEL FAILOVER mymaster` to a Sentinel. The stamps bracket the write of the command and
36
+ //! the first line of its reply, so a planned failover starts at a known instant.
37
+ //!
38
+ //! A filter rule's PEER can be a list, `A,B,C`: the rules for each peer go in the same commit, so
39
+ //! a part of a group loses all of its peers at one instant.
40
+ //!
30
41
  //! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
31
42
  //! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
32
43
  //! its 190 ms never reach the stall's length.
@@ -234,12 +245,139 @@ fn http(args: &[String]) -> Result<(), Box<dyn Error>> {
234
245
  Ok(())
235
246
  }
236
247
 
248
+ /// the number of a signal that `signal` sends, by its name
249
+ fn signal_number(name: &str) -> Result<libc::c_int, Box<dyn Error>> {
250
+ Ok(match name {
251
+ "KILL" => libc::SIGKILL,
252
+ "STOP" => libc::SIGSTOP,
253
+ "CONT" => libc::SIGCONT,
254
+ "TERM" => libc::SIGTERM,
255
+ other => return Err(format!("not a signal this injector sends: {other}").into()),
256
+ })
257
+ }
258
+
259
+ /// a process name: letters, digits, dots, dashes and underscores, as /proc/PID/comm holds it
260
+ fn process_name(name: &str) -> Result<&str, Box<dyn Error>> {
261
+ if !name.is_empty()
262
+ && name.len() <= 15
263
+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
264
+ {
265
+ Ok(name)
266
+ } else {
267
+ Err(format!("not a process name: {name}").into())
268
+ }
269
+ }
270
+
271
+ /// tells if a line of /proc/PID/stat is a zombie's: its state, after the name in parentheses, is Z
272
+ fn zombie(stat: &str) -> bool {
273
+ stat.rfind(')').and_then(|end| stat[end + 1..].split_whitespace().next()) == Some("Z")
274
+ }
275
+
276
+ /// the processes of this process namespace whose name is `name`
277
+ fn processes(name: &str) -> Result<Vec<libc::pid_t>, Box<dyn Error>> {
278
+ let mut found = Vec::new();
279
+ for entry in std::fs::read_dir("/proc")? {
280
+ let entry = entry?;
281
+ let Some(pid) = entry.file_name().to_str().and_then(|text| text.parse::<libc::pid_t>().ok()) else {
282
+ continue;
283
+ };
284
+ // a process can end between the listing and the read. A zombie keeps its name until its
285
+ // parent reaps it: a server that a fin killed is one, and no signal reaches it
286
+ let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else { continue };
287
+ let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else { continue };
288
+ if comm.trim_end() == name && !zombie(&stat) {
289
+ found.push(pid);
290
+ }
291
+ }
292
+ found.sort_unstable();
293
+ Ok(found)
294
+ }
295
+
296
+ /// `signal NAME SIGNAL [--at NS]`: the signal to each process of that name, bracketed by stamps
297
+ fn signal(args: &[String]) -> Result<(), Box<dyn Error>> {
298
+ let name = process_name(args.get(1).ok_or("signal NAME SIGNAL [--at NS]")?)?;
299
+ let sig = signal_number(args.get(2).ok_or("and the signal: KILL, STOP, CONT or TERM")?)?;
300
+ let opts = options(&args[3..])?;
301
+ let pids = processes(name)?;
302
+ if pids.is_empty() {
303
+ return Err(format!("no process {name} in this process namespace").into());
304
+ }
305
+ if let Some(at) = opts.at {
306
+ until(at)?;
307
+ }
308
+ let t0 = monotonic_ns();
309
+ for pid in &pids {
310
+ // SAFETY: kill(2) with a pid that this namespace listed, and a signal from the table above
311
+ if unsafe { libc::kill(*pid, sig) } != 0 {
312
+ return Err(format!("kill {pid} failed: {}", std::io::Error::last_os_error()).into());
313
+ }
314
+ }
315
+ let t1 = monotonic_ns();
316
+ println!("{}", json!({ "action": "signal", "name": name, "signal": args[2], "pids": pids, "t0": t0, "t1": t1 }));
317
+ Ok(())
318
+ }
319
+
320
+ /// a command in the Redis protocol: an array of bulk strings
321
+ fn resp_command(args: &[String]) -> Vec<u8> {
322
+ let mut out = format!("*{}\r\n", args.len()).into_bytes();
323
+ for arg in args {
324
+ out.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
325
+ out.extend_from_slice(arg.as_bytes());
326
+ out.extend_from_slice(b"\r\n");
327
+ }
328
+ out
329
+ }
330
+
331
+ /// `resp ADDR:PORT ARG...`: one command, and the first line of its reply, bracketed by stamps
332
+ fn resp(args: &[String]) -> Result<(), Box<dyn Error>> {
333
+ use std::io::Read;
334
+ use std::net::{SocketAddrV4, TcpStream};
335
+ let addr: SocketAddrV4 = args.get(1).ok_or("resp ADDR:PORT ARG...")?.parse()?;
336
+ let command = &args[2..];
337
+ if command.is_empty() {
338
+ return Err("and the command".into());
339
+ }
340
+ let mut stream = TcpStream::connect(addr)?;
341
+ let t0 = monotonic_ns();
342
+ stream.write_all(&resp_command(command))?;
343
+ let mut line = Vec::new();
344
+ let mut byte = [0u8; 1];
345
+ while !line.ends_with(b"\r\n") {
346
+ if stream.read(&mut byte)? == 0 {
347
+ return Err("the server closed before its reply".into());
348
+ }
349
+ line.push(byte[0]);
350
+ }
351
+ let t1 = monotonic_ns();
352
+ let reply = String::from_utf8_lossy(&line[..line.len() - 2]).to_string();
353
+ if reply.starts_with('-') {
354
+ return Err(format!("{} answered {reply}", command.join(" ")).into());
355
+ }
356
+ println!("{}", json!({ "action": "resp", "command": command, "reply": reply, "t0": t0, "t1": t1 }));
357
+ Ok(())
358
+ }
359
+
360
+ /// the peers of a filter rule: one address, or a list `A,B,C`
361
+ fn peers(text: &str) -> Result<Vec<Ipv4Addr>, Box<dyn Error>> {
362
+ let list: Vec<Ipv4Addr> = text.split(',').map(str::parse).collect::<Result<_, _>>()?;
363
+ if list.is_empty() {
364
+ return Err("no peer".into());
365
+ }
366
+ Ok(list)
367
+ }
368
+
237
369
  fn run() -> Result<(), Box<dyn Error>> {
238
370
  let args: Vec<String> = std::env::args().skip(1).collect();
239
371
  let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload, http …")?.as_str();
240
372
  if action == "http" {
241
373
  return http(&args);
242
374
  }
375
+ if action == "signal" {
376
+ return signal(&args);
377
+ }
378
+ if action == "resp" {
379
+ return resp(&args);
380
+ }
243
381
  match action {
244
382
  "netem" | "clear-netem" | "offload" => {
245
383
  let dev = device(args.get(1).ok_or("and a device")?)?;
@@ -279,10 +417,14 @@ fn run() -> Result<(), Box<dyn Error>> {
279
417
  Ok(())
280
418
  }
281
419
  _ => {
282
- // parsed, never pasted: only an address reaches the rules
283
- let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
420
+ // parsed, never pasted: only addresses reach the rules
421
+ let list = peers(args.get(1).ok_or("and the peer's address")?)?;
284
422
  let opts = options(&args[2..])?;
285
- let script = format!("*filter\n{}COMMIT\n", rules(action, peer, &opts)?);
423
+ let mut body = String::new();
424
+ for peer in &list {
425
+ body.push_str(&rules(action, *peer, &opts)?);
426
+ }
427
+ let script = format!("*filter\n{body}COMMIT\n");
286
428
  if let Some(at) = opts.at {
287
429
  until(at)?;
288
430
  }
@@ -295,7 +437,8 @@ fn run() -> Result<(), Box<dyn Error>> {
295
437
  if !status.success() {
296
438
  return Err(format!("iptables-restore exited {status}").into());
297
439
  }
298
- println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
440
+ let named: Vec<String> = list.iter().map(ToString::to_string).collect();
441
+ println!("{}", json!({ "action": action, "peer": named.join(","), "t0": t0, "t1": t1 }));
299
442
  Ok(())
300
443
  }
301
444
  }
@@ -365,6 +508,41 @@ mod tests {
365
508
  assert_eq!(options(&["--via".into(), "eth1".into()]).unwrap().via.as_deref(), Some("eth1"));
366
509
  }
367
510
 
511
+ #[test]
512
+ fn a_list_of_peers_is_addresses_only() {
513
+ assert_eq!(peers("10.0.0.2").unwrap().len(), 1);
514
+ assert_eq!(peers("10.0.0.2,10.0.0.3,10.0.0.4").unwrap()[2].to_string(), "10.0.0.4");
515
+ for text in ["", "10.0.0.2,", "10.0.0.2;x", "a,b", "10.0.0.2 10.0.0.3"] {
516
+ assert!(peers(text).is_err(), "{text}");
517
+ }
518
+ }
519
+
520
+ #[test]
521
+ fn signals_and_process_names_come_from_a_short_list() {
522
+ assert_eq!(signal_number("STOP").unwrap(), libc::SIGSTOP);
523
+ assert_eq!(signal_number("CONT").unwrap(), libc::SIGCONT);
524
+ assert_eq!(signal_number("KILL").unwrap(), libc::SIGKILL);
525
+ assert!(signal_number("HUP").is_err());
526
+ assert!(process_name("valkey-server").is_ok());
527
+ assert!(process_name("valkey-sentinel").is_ok());
528
+ for name in ["", "a b", "x;y", "much-too-long-a-name"] {
529
+ assert!(process_name(name).is_err(), "{name}");
530
+ }
531
+ // the state is the field after the name, whose parentheses can hold spaces
532
+ assert!(zombie("812 (valkey-server) Z 1 812 812 0 -1"));
533
+ assert!(!zombie("813 (valkey-server) S 1 813 813 0 -1"));
534
+ assert!(!zombie("814 (a) Z) S 1"));
535
+ }
536
+
537
+ #[test]
538
+ fn a_resp_command_is_an_array_of_bulk_strings() {
539
+ let args: Vec<String> = ["SENTINEL", "FAILOVER", "mymaster"].iter().map(|a| a.to_string()).collect();
540
+ assert_eq!(
541
+ resp_command(&args),
542
+ b"*3\r\n$8\r\nSENTINEL\r\n$8\r\nFAILOVER\r\n$8\r\nmymaster\r\n".to_vec()
543
+ );
544
+ }
545
+
368
546
  #[test]
369
547
  fn http_requests_take_only_a_method_a_path_and_a_length() {
370
548
  assert!(http_path("/proxies/redis/toxics").is_ok());
@@ -1,6 +1,7 @@
1
1
  # The harness's Node client: ioredis 6.0.0 on Node 24.21.0, the releases the corpus's claims about
2
2
  # ioredis and Node's keepalive name. It runs as a container in the client's
3
- # network namespace, as the harness's own TCP client does, and prints the same JSON lines.
3
+ # network namespace, as the harness's own TCP client does, and prints the same JSON lines. The same
4
+ # image holds a group's client in Sentinel mode and the observer of its Sentinels.
4
5
  # lab.ts stages the build context: this folder, under harness/image/node. The lockfile is
5
6
  # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
6
7
  FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
@@ -8,4 +9,4 @@ WORKDIR /opt/harness/node
8
9
  COPY harness/image/node/package.json ./
9
10
  COPY harness/image/node/npm-lock.json ./package-lock.json
10
11
  RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
11
- COPY harness/image/node/client-ioredis.mjs ./
12
+ COPY harness/image/node/client-ioredis.mjs harness/image/node/client-sentinel.mjs harness/image/node/observer.mjs ./
@@ -0,0 +1,106 @@
1
+ // client-sentinel --sentinels H:P,H:P,... --name NAME [--interval-ms N] [--max-s N] [--options JSON]
2
+ //
3
+ // A group cell's client: ioredis 6.0.0 in Sentinel mode, which asks the Sentinels for the primary
4
+ // of NAME and connects to it. Every event is a JSON line stamped on CLOCK_MONOTONIC
5
+ // (process.hrtime, which libuv reads from it), the clock the prober stamps the kernel's records
6
+ // with, and the injector stamps each fault with.
7
+ //
8
+ // The workload is a stream of writes with a sequence number: SET w:<n> <n>, each sent when the
9
+ // reply to the one before it came and --interval-ms passed. Each write is a sent line, then an
10
+ // ack line with the address of the server that accepted it, or a fail line with its error. A
11
+ // write whose line is neither had no answer when the client stopped. The group's keys after the
12
+ // run tell which writes it kept. --options are ioredis options over its defaults: none at the
13
+ // defaults of a cell. The client never stops on an error: ioredis reconnects, and the stream
14
+ // goes on until --max-s or SIGTERM.
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import Redis from "ioredis";
18
+
19
+ const now = () => process.hrtime.bigint();
20
+ const emit = (ev, fields = {}, at = now()) =>
21
+ process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
22
+
23
+ const argv = process.argv.slice(2);
24
+ const flag = (name) => {
25
+ const at = argv.indexOf(name);
26
+ return at === -1 ? undefined : argv[at + 1];
27
+ };
28
+ const sentinels = (flag("--sentinels") ?? "")
29
+ .split(",")
30
+ .filter((each) => each !== "")
31
+ .map((each) => {
32
+ const [host, port] = each.split(":");
33
+ return { host, port: Number(port) };
34
+ });
35
+ const name = flag("--name");
36
+ if (sentinels.length === 0 || name === undefined) {
37
+ emit("fail", { why: "client-sentinel --sentinels H:P,... --name NAME" });
38
+ process.exit(1);
39
+ }
40
+ const interval = Number(flag("--interval-ms") ?? 50);
41
+ const max = Number(flag("--max-s") ?? 120) * 1000;
42
+ const options = JSON.parse(flag("--options") ?? "{}");
43
+
44
+ emit("start", {
45
+ boot_id: readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
46
+ client: "ioredis",
47
+ mode: "sentinel",
48
+ options,
49
+ });
50
+
51
+ const redis = new Redis({ ...options, sentinels, name, lazyConnect: true });
52
+ /** the server that the connection is on now, or null between connections */
53
+ const remote = () => {
54
+ const stream = redis.stream;
55
+ return stream?.remoteAddress === undefined ? null : `${stream.remoteAddress}:${stream.remotePort}`;
56
+ };
57
+ for (const status of ["connect", "ready", "close", "reconnecting", "end"]) {
58
+ redis.on(status, (delay) => emit("status", { status, at: remote(), ...(status === "reconnecting" ? { delay } : {}) }));
59
+ }
60
+ redis.on("connect", () => {
61
+ const { localAddress, localPort } = redis.stream;
62
+ emit("connected", { local: `${localAddress}:${localPort}`, at: remote() });
63
+ });
64
+ // the errors on the way: a reset, a refused connect, a Sentinel that did not answer
65
+ redis.on("error", (error) =>
66
+ emit("error", { name: error.name, errno: error.code ?? error.errno ?? null, message: error.message }),
67
+ );
68
+ redis.on("sentinelError", (error) => emit("sentinel-error", { message: error.message }));
69
+
70
+ let stopped = false;
71
+ const stop = () => {
72
+ if (stopped) return;
73
+ stopped = true;
74
+ emit("done");
75
+ redis.disconnect(false);
76
+ setTimeout(() => process.exit(0), 50);
77
+ };
78
+ process.on("SIGTERM", stop);
79
+ setTimeout(() => {
80
+ emit("deadline");
81
+ stop();
82
+ }, max);
83
+
84
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
85
+
86
+ try {
87
+ await redis.connect();
88
+ emit("ready", { at: remote() });
89
+ } catch (error) {
90
+ emit("fail", { why: error.message });
91
+ process.exit(1);
92
+ }
93
+
94
+ for (let n = 1; !stopped; n += 1) {
95
+ const at = now();
96
+ // stamped before the write: the kernel can answer before the call returns
97
+ const reply = redis.set(`w:${n}`, String(n));
98
+ emit("sent", { n }, at);
99
+ try {
100
+ const answer = await reply;
101
+ emit("ack", { n, at: remote(), reply: answer });
102
+ } catch (error) {
103
+ emit("fail", { n, name: error.name, message: error.message });
104
+ }
105
+ await sleep(interval);
106
+ }
@@ -0,0 +1,39 @@
1
+ // observer H:P,H:P,...
2
+ //
3
+ // The observer of a group's Sentinels. It subscribes to every channel of each Sentinel
4
+ // (PSUBSCRIBE *): each Sentinel publishes each event of its own on a channel named for the event,
5
+ // +sdown, +odown, +try-failover, +elected-leader, +selected-slave, +promoted-slave, +switch-master
6
+ // and the others. Each message is a JSON line stamped on CLOCK_MONOTONIC when it arrives, the one
7
+ // clock of the harness. The faults of a cell never cut the observer: it is in a namespace of its
8
+ // own, and no rule names its address. A subscribed line gives the address of each connection, so
9
+ // the prober's records of a Sentinel's writes to it can calibrate the stamps.
10
+
11
+ import Redis from "ioredis";
12
+
13
+ const now = () => process.hrtime.bigint();
14
+ const emit = (ev, fields = {}, at = now()) =>
15
+ process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
16
+
17
+ const sentinels = (process.argv[2] ?? "").split(",").filter((each) => each !== "");
18
+ if (sentinels.length === 0) {
19
+ emit("fail", { why: "observer H:P,H:P,..." });
20
+ process.exit(1);
21
+ }
22
+
23
+ for (const address of sentinels) {
24
+ const [host, port] = address.split(":");
25
+ // RESP2: a Sentinel's events are plain pmessage frames, and a lost connection comes back
26
+ const sub = new Redis({ host, port: Number(port), protocol: 2, enableReadyCheck: false, retryStrategy: () => 100 });
27
+ sub.on("ready", () => {
28
+ const { localAddress, localPort } = sub.stream;
29
+ emit("subscribed", { sentinel: address, local: `${localAddress}:${localPort}` });
30
+ });
31
+ sub.on("error", (error) => emit("observer-error", { sentinel: address, message: error.message }));
32
+ sub.on("pmessage", (_pattern, channel, message) => {
33
+ const at = now();
34
+ emit("sentinel", { sentinel: address, event: channel, detail: message }, at);
35
+ });
36
+ sub.psubscribe("*").catch((error) => emit("observer-error", { sentinel: address, message: error.message }));
37
+ }
38
+
39
+ process.on("SIGTERM", () => process.exit(0));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/harness",
4
- "version": "0.4.0",
5
- "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies, injects a fault, measures the kernel's own records and judges a prediction.",
4
+ "version": "0.5.0",
5
+ "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies or a replicated group under Sentinels, injects a fault, measures the kernel's own records and judges a prediction.",
6
6
  "keywords": [
7
7
  "fault-injection",
8
8
  "chaos-engineering",
@@ -43,6 +43,8 @@
43
43
  "image/native/src",
44
44
  "image/node/Dockerfile",
45
45
  "image/node/client-ioredis.mjs",
46
+ "image/node/client-sentinel.mjs",
47
+ "image/node/observer.mjs",
46
48
  "image/node/package.json",
47
49
  "image/node/npm-lock.json",
48
50
  "image/router/Dockerfile",
@@ -56,8 +58,8 @@
56
58
  "node": ">=22.19"
57
59
  },
58
60
  "dependencies": {
59
- "@mark1russell7/prober": "^0.2.0",
60
- "@mark1russell7/quantity": "^0.1.0",
61
+ "@mark1russell7/prober": "^0.3.1",
62
+ "@mark1russell7/quantity": "^0.2.0",
61
63
  "testcontainers": "12.2.0"
62
64
  },
63
65
  "peerDependencies": {