mayhem-cli 0.5.1__py3-none-any.whl

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.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,2156 @@
1
+ """Compensation synthesis — undo ops and verify probes decided at plan time.
2
+
3
+ The planner refuses any fault it cannot compensate for: a plan without a
4
+ write-ahead undo contract never leaves the planner. Templates are keyed by
5
+ fault prefix and may inspect resolved nodes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ from mayhem.domain.common import parse_duration
16
+ from mayhem.domain.errors import InvariantViolationError
17
+ from mayhem.domain.leases import UndoOp, VerifyProbe
18
+ from mayhem.domain.topology import NodeKind, ProcessNode
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+ from mayhem.domain.experiments import PlannedFault
24
+ from mayhem.domain.topology import TopologyNode
25
+
26
+ NO_UNDO = InvariantViolationError("undo_template_missing", "no compensation template")
27
+
28
+
29
+ def _first_process(nodes: tuple[TopologyNode, ...]) -> ProcessNode | None:
30
+ for node in nodes:
31
+ if node.kind is NodeKind.PROCESS and isinstance(node, ProcessNode):
32
+ return node
33
+ return None
34
+
35
+
36
+ # PID placeholder resolved to the live value at execution time (ADR-0020). The
37
+ # value encodes the node_id so substitution can target the right container.
38
+ _LIVE_PID = "@live-pid"
39
+
40
+
41
+ def _pid_arg(node: TopologyNode) -> str:
42
+ """Return the PID arg for an undo/verify op, using the live placeholder."""
43
+ if getattr(node, "container_name", None):
44
+ return f"{node.id}:{_LIVE_PID}"
45
+ return str(getattr(node, "pid", 0))
46
+
47
+
48
+ def _proc_pause_undo(nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
49
+ proc = _first_process(nodes)
50
+ if proc is None:
51
+ raise NO_UNDO
52
+ return (UndoOp(op="signal.cont", args={"pid": _pid_arg(proc)}),)
53
+
54
+
55
+ def _proc_pause_verify(nodes: tuple[TopologyNode, ...]) -> tuple[VerifyProbe, ...]:
56
+ proc = _first_process(nodes)
57
+ if proc is None:
58
+ raise NO_UNDO
59
+ pid = _pid_arg(proc)
60
+ return (
61
+ VerifyProbe(
62
+ probe="exec",
63
+ args={"cmd": ["ps", "-p", pid], "timeout_s": "5"},
64
+ expect_present=True,
65
+ ),
66
+ )
67
+
68
+
69
+ def _process_term_undo(nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
70
+ """process.stop / process.kill terminate the pid — nothing to undo.
71
+
72
+ The single no-op op still carries the ``pid`` so the inject executor can
73
+ resolve the target; undo itself performs no action (the pid is gone).
74
+ """
75
+ proc = _first_process(nodes)
76
+ if proc is None:
77
+ raise NO_UNDO
78
+ return (UndoOp(op="noop", args={"pid": _pid_arg(proc)}),)
79
+
80
+
81
+ def _process_term_verify(nodes: tuple[TopologyNode, ...]) -> tuple[VerifyProbe, ...]:
82
+ proc = _first_process(nodes)
83
+ if proc is None:
84
+ raise NO_UNDO
85
+ return (
86
+ VerifyProbe(
87
+ probe="process",
88
+ args={"pid": _pid_arg(proc), "timeout_s": "5"},
89
+ # Target must be gone after termination. The process probe treats
90
+ # a zombie (terminated but un-reaped) as gone, so this stays true
91
+ # even when the parent has not yet called wait().
92
+ expect_present=False,
93
+ ),
94
+ )
95
+
96
+
97
+ _SENTINEL = object()
98
+
99
+
100
+ def _param(fault: PlannedFault, name: str, default: object) -> object:
101
+ """Resolve a fault parameter (validated at plan time) or a fallback."""
102
+ value = fault.params.get(name, _SENTINEL)
103
+ if value is _SENTINEL or value is None:
104
+ return default
105
+ return value
106
+
107
+
108
+ def _fparam(fault: PlannedFault, name: str, default: float) -> float:
109
+ """Coerce a validated param to float (params deserialize from YAML scalars)."""
110
+ raw = _param(fault, name, default)
111
+ return default if not isinstance(raw, (int, float)) else float(raw)
112
+
113
+
114
+ def _iparam(fault: PlannedFault, name: str, default: int) -> int:
115
+ """Coerce a validated param to int (params deserialize from YAML scalars)."""
116
+ raw = _param(fault, name, default)
117
+ return default if not isinstance(raw, (int, float)) else int(raw)
118
+
119
+
120
+ def _fault_duration_s(fault: PlannedFault, default: float = 30.0) -> float:
121
+ """Fault window in seconds (planned faults may carry a ``10s`` string)."""
122
+ raw = fault.duration
123
+ if isinstance(raw, str):
124
+ try:
125
+ return parse_duration(raw)
126
+ except (TypeError, ValueError):
127
+ return default
128
+ try:
129
+ return float(raw)
130
+ except (TypeError, ValueError):
131
+ return default
132
+
133
+
134
+ def _payload_marker(fault: PlannedFault, node: TopologyNode) -> str:
135
+ tag = re.sub(r"[^A-Za-z0-9_-]", "-", f"{fault.fault_id}.{node.id}")
136
+ return f"/tmp/mayhem.{tag}.pid"
137
+
138
+
139
+ def _payload_source(fault: PlannedFault, marker: str) -> str: # noqa: PLR0911
140
+ """Python payload that produces a *real* effect inside the target container.
141
+
142
+ The payload is executed with ``python -c <source>`` from a detached engine
143
+ exec (``podman/docker exec -d``), so it lives inside the container's pid and
144
+ network namespaces and holds the fault in force until the undo op SIGKILLs
145
+ it. Markers let undo and the effect probe detect the payload deterministically.
146
+ """
147
+ fid = fault.fault_id
148
+ header = f"import os, time\nopen({marker!r}, 'w').write(str(os.getpid()))\n"
149
+ # Payloads holding a fault in force until the undo op SIGKILLs the marker
150
+ # pid must keep the interpreter alive once their threads are spawned —
151
+ # otherwise ``python -c`` hits EOF, the daemon threads are killed at
152
+ # interpreter shutdown, and the effect evaporates within milliseconds.
153
+ _hold = "while True:\n time.sleep(3600)\n"
154
+ if fid == "mem.exhaust":
155
+ amount = _fparam(fault, "amount", 0.0)
156
+ percent = min(_fparam(fault, "percent", 60.0), 99.0)
157
+ mode = str(_param(fault, "mode", "allocate")).strip().lower()
158
+ if mode != "allocate":
159
+ raise InvariantViolationError(
160
+ "fault_mode", f"unsupported memory-exhaust mode {mode!r}; only 'allocate' exists"
161
+ )
162
+ return header + (
163
+ # Balloon anonymous memory to the explicit ``amount`` bytes (e.g.
164
+ # ``256M``), or to ``percent`` of the container's cgroup memory
165
+ # limit (falling back to the node's available RAM). An explicit
166
+ # amount is capped at 95% of a real cgroup limit so the run can
167
+ # never OOM-kill the whole container. Pages stay resident and the
168
+ # payload stays alive until the undo op SIGKILLs its marker pid.
169
+ f"amount = {amount:.0f}\n"
170
+ f"percent = {percent:g}\n"
171
+ "try:\n"
172
+ " f = open('/sys/fs/cgroup/memory.max'); lim = int(f.read().strip()); f.close()\n"
173
+ "except Exception:\n"
174
+ " lim = 0\n"
175
+ "goal = amount if amount > 0 else (\n"
176
+ " lim * percent / 100 if 0 < lim < 10 ** 14 else (\n"
177
+ " os.sysconf('SC_AVPHYS_PAGES') * os.sysconf('SC_PAGE_SIZE') * percent / 100))\n"
178
+ "if 0 < lim < 10 ** 14:\n"
179
+ " goal = min(goal, lim * 95 // 100)\n"
180
+ "chunks = []\n"
181
+ "while sum(map(len, chunks)) < goal:\n"
182
+ " try:\n"
183
+ " chunks.append(bytearray(4 * 2 ** 20))\n"
184
+ " except MemoryError:\n"
185
+ " break\n"
186
+ " time.sleep(0.02)\n"
187
+ "while True:\n"
188
+ " time.sleep(3600)\n"
189
+ )
190
+ if fid == "mem.leak":
191
+ rate_mb = max(_iparam(fault, "rate_mb", 8), 1)
192
+ return header + (
193
+ # Leak a steady ``rate_mb`` MiB/s (one bytearray per second). The
194
+ # list holds each allocation forever, so the payload's RSS climbs
195
+ # lineally until the undo op SIGKILLs it; the loop is the hold, and
196
+ # the marker pid detaches undo from the allocator thread.
197
+ f"_chunk = bytearray({rate_mb} * 1024 * 1024)\n"
198
+ "_grow = []\n"
199
+ "try:\n"
200
+ " while True:\n"
201
+ " _grow.append(_chunk)\n"
202
+ " time.sleep(1)\n"
203
+ "except Exception:\n"
204
+ " pass\n"
205
+ )
206
+ if fid == "cpu.saturate":
207
+ percent = min(_fparam(fault, "percent", 80.0), 100.0)
208
+ return header + (
209
+ # Pin ``percent`` of the container's visible cores. A pure-Python
210
+ # arithmetic loop is GIL-bound to a single core no matter how many
211
+ # threads run, so the burner spins the OpenSSL-backed ``hashlib`` in
212
+ # each thread instead — C work that releases the GIL, letting N
213
+ # threads genuinely saturate N cores. Undo stays intact: SIGKILLing
214
+ # the marker pid kills every burner thread with the process.
215
+ "import threading, hashlib\n"
216
+ "def burn():\n"
217
+ " buf = bytes(64 * 1024)\n"
218
+ " while True:\n"
219
+ " hashlib.sha256(buf).digest()\n"
220
+ f"n = max(1, (os.cpu_count() or 1) * {percent:g} // 100)\n"
221
+ "for _ in range(n):\n"
222
+ " threading.Thread(target=burn, daemon=True).start()\n" + _hold
223
+ )
224
+ if fid == "fs.fill":
225
+ percent = min(_fparam(fault, "percent", 45.0), 99.0)
226
+ capped = 1024**3
227
+ return header + (
228
+ # Fill the container's filesystem to ``percent`` of total capacity,
229
+ # but never beyond a 1 GiB absolute cap so an e2e run cannot fill the
230
+ # podman VM's root disk. The damage target is real: the service's own
231
+ # writable layer (and any other process writing to this filesystem)
232
+ # loses exactly the filled space until undo reclaims it.
233
+ "st = os.statvfs('/tmp')\n"
234
+ "total = st.f_blocks * st.f_frsize\n"
235
+ "free0 = st.f_bavail * st.f_frsize\n"
236
+ "def usage():\n"
237
+ " s = os.statvfs('/tmp')\n"
238
+ " return 1 - s.f_bavail * s.f_frsize / (s.f_blocks * s.f_frsize)\n"
239
+ f"goal = min({percent} / 100, (free0 + {capped}) / total)\n"
240
+ "i = 0\n"
241
+ "while usage() < goal:\n"
242
+ " try:\n"
243
+ " f = open(marker + '.' + str(i), 'ab')\n"
244
+ " f.write(b'\\0' * (1 << 20))\n"
245
+ " f.close()\n"
246
+ " except OSError:\n"
247
+ " time.sleep(0.3)\n"
248
+ " i += 1\n" + _hold
249
+ )
250
+ if fid == "fs.inode_exhaust":
251
+ percent = min(_fparam(fault, "percent", 50.0), 99.0)
252
+ return header + (
253
+ # Exhaust free inodes, not capacity: create zero-byte marker files
254
+ # until ``percent`` of the filesystem's free inodes are consumed,
255
+ # never beyond a hard count cap so an e2e run cannot brick the
256
+ # podman VM's root filesystem. The filesystem is restored when undo
257
+ # SIGKILLs the payload and removes the ``marker.*`` siblings, each
258
+ # of which frees one inode.
259
+ "st = os.statvfs('/tmp')\n"
260
+ "free0 = st.f_ffree\n"
261
+ "def used():\n"
262
+ " s = os.statvfs('/tmp')\n"
263
+ " return s.f_files - s.f_ffree\n"
264
+ f"goal = min(free0 * {percent} / 100, 200000)\n"
265
+ "i = 0\n"
266
+ "while used() - free0 < goal:\n"
267
+ " try:\n"
268
+ " open(marker + '.' + str(i), 'w').close()\n"
269
+ " except OSError:\n"
270
+ " time.sleep(0.3)\n"
271
+ " i += 1\n" + _hold
272
+ )
273
+ if fid == "fs.io_stress":
274
+ workers = max(_iparam(fault, "workers", 1), 1)
275
+ io_bytes = _fparam(fault, "io_bytes", 64.0 * 1024 * 1024)
276
+ read_mb_s = _iparam(fault, "read_mb_s", 0)
277
+ write_mb_s = _iparam(fault, "write_mb_s", 0)
278
+ if read_mb_s or write_mb_s:
279
+ # Spec twin (fs.io_stress): sustained read()/write() throughput on
280
+ # twin marker working files (``marker.rN``/``marker.wN``) paced to
281
+ # ``read_mb_s``/``write_mb_s`` per worker. Files are marker siblings,
282
+ # so undo removes them with the payload pid via the ``marker.*`` glob.
283
+ rd = max(read_mb_s, 0) * 1024 * 1024
284
+ wr = max(write_mb_s, 0) * 1024 * 1024
285
+ return header + (
286
+ "import threading\n"
287
+ f"rd = {rd}\n"
288
+ f"wr = {wr}\n"
289
+ "blk = 65536\n"
290
+ "def pace(r):\n"
291
+ " return (blk / r) if r > 0 else 0.0\n"
292
+ "def reader(w):\n"
293
+ " p = marker + '.r' + str(w)\n"
294
+ " try:\n"
295
+ " f = open(p, 'wb'); f.truncate(64 * 1024 * 1024); f.close()\n"
296
+ " with open(p, 'rb') as f:\n"
297
+ " while True:\n"
298
+ " if not f.read(blk):\n"
299
+ " f.seek(0)\n"
300
+ " continue\n"
301
+ " time.sleep(pace(rd))\n"
302
+ " except OSError:\n"
303
+ " pass\n"
304
+ "def writer(w):\n"
305
+ " p = marker + '.w' + str(w)\n"
306
+ " try:\n"
307
+ " with open(p, 'wb') as f:\n"
308
+ " while True:\n"
309
+ " f.write(os.urandom(blk))\n"
310
+ " f.flush()\n"
311
+ " time.sleep(pace(wr))\n"
312
+ " except OSError:\n"
313
+ " pass\n"
314
+ f"if rd > 0:\n"
315
+ f" for w in range({workers}):\n"
316
+ " threading.Thread(target=reader, args=(w,), daemon=True).start()\n"
317
+ f"if wr > 0:\n"
318
+ f" for w in range({workers}):\n"
319
+ " threading.Thread(target=writer, args=(w,), daemon=True).start()\n" + _hold
320
+ )
321
+ return header + (
322
+ # Bound I/O churn: ``workers`` writers hammer their marker working
323
+ # file (``marker.wN``) with buffered 64 KiB writes, looping once the
324
+ # per-worker ``io_bytes`` budget is spent (truncate + rewind so the
325
+ # write stays bounded). Working files are marker siblings, so undo
326
+ # removes them with the payload pid via the ``marker.*`` glob.
327
+ "import threading\n"
328
+ f"total = min({io_bytes:.0f}, 1024 ** 3)\n"
329
+ "def writer(w):\n"
330
+ " written = 0\n"
331
+ " f = open(marker + '.w' + str(w), 'wb')\n"
332
+ " try:\n"
333
+ " while True:\n"
334
+ " f.write(os.urandom(65536))\n"
335
+ " f.flush()\n"
336
+ " written += 65536\n"
337
+ " if written >= total:\n"
338
+ " f.truncate(0)\n"
339
+ " f.seek(0)\n"
340
+ " written = 0\n"
341
+ " except OSError:\n"
342
+ " pass\n"
343
+ " finally:\n"
344
+ " f.close()\n"
345
+ f"for w in range({workers}):\n"
346
+ " threading.Thread(target=writer, args=(w,), daemon=True).start()\n" + _hold
347
+ )
348
+ if fid == "fd.exhaust":
349
+ limit = max(_iparam(fault, "limit", 64), 1)
350
+ return header + (
351
+ f"opened = 0\n"
352
+ "fds = []\n"
353
+ f"while opened < {limit}:\n"
354
+ " try:\n"
355
+ " fds.append(open('/dev/null'))\n"
356
+ " except OSError:\n"
357
+ " break\n"
358
+ " opened += 1\n"
359
+ "try:\n"
360
+ f" open({marker!r} + '.count', 'w').write(str(opened))\n"
361
+ "except Exception:\n"
362
+ " pass\n" + _hold
363
+ )
364
+ if fid == "load.spike":
365
+ seconds = min(_fparam(fault, "seconds", 8.0), 60.0)
366
+ concurrency = max(_iparam(fault, "rps", 60) // 50, 1)
367
+ return header + (
368
+ # Push real request traffic at the service's listening port so the
369
+ # server's thread pool / event loop actually contends.
370
+ "import socket\n"
371
+ "port = 8000\n"
372
+ "try:\n"
373
+ " for line in open('/proc/net/tcp').read().splitlines()[1:]:\n"
374
+ " p = line.split()\n"
375
+ " if len(p) > 1 and p[3] == '0A' and p[1].split(':')[1] != '0000':\n"
376
+ " port = int(p[1].split(':')[1], 16)\n"
377
+ " break\n"
378
+ "except Exception:\n"
379
+ " pass\n"
380
+ f"end = time.time() + {seconds}\n"
381
+ "def blast():\n"
382
+ " while time.time() < end:\n"
383
+ " try:\n"
384
+ " s = socket.create_connection(('127.0.0.1', port), timeout=1.5)\n"
385
+ " s.sendall(b'GET / HTTP/1.1\\r\\n\\r\\n')\n"
386
+ " s.close()\n"
387
+ " except OSError:\n"
388
+ " pass\n"
389
+ "import threading\n"
390
+ f"for _ in range({concurrency}):\n"
391
+ " threading.Thread(target=blast, daemon=True).start()\n" + f"time.sleep({seconds})\n"
392
+ )
393
+ if fid == "fuzz.protocol_abuse":
394
+ seconds = min(_fparam(fault, "seconds", 8.0), 60.0)
395
+ return header + (
396
+ # Send malformed / hostile HTTP candidates at the service port. The
397
+ # server must parse them; the giant header and binary garbage force
398
+ # real parser work and rejected requests.
399
+ "import socket\n"
400
+ "port = 8000\n"
401
+ "try:\n"
402
+ " for line in open('/proc/net/tcp').read().splitlines()[1:]:\n"
403
+ " p = line.split()\n"
404
+ " if len(p) > 1 and p[3] == '0A' and p[1].split(':')[1] != '0000':\n"
405
+ " port = int(p[1].split(':')[1], 16)\n"
406
+ " break\n"
407
+ "except Exception:\n"
408
+ " pass\n"
409
+ "payloads = (\n"
410
+ " b'\\x00\\xff\\x80\\r\\n',\n"
411
+ " b'GET / HTTP/1.1\\r\\nX-A: ' + b'a' * 100000 + b'\\r\\n\\r\\n',\n"
412
+ " b'\\xff' * 65536,\n"
413
+ " b'POST / HTTP/1.1\\r\\nContent-Length: -1\\r\\n\\r\\n',\n"
414
+ " b'GET ../../etc/passwd HTTP/1.1\\r\\n\\r\\n',\n"
415
+ ")\n"
416
+ f"end = time.time() + {seconds}\n"
417
+ "def abuse():\n"
418
+ " i = 0\n"
419
+ " while time.time() < end:\n"
420
+ " try:\n"
421
+ " s = socket.create_connection(('127.0.0.1', port), timeout=1.5)\n"
422
+ " s.sendall(payloads[i % len(payloads)])\n"
423
+ " try:\n"
424
+ " s.recv(1)\n"
425
+ " except OSError:\n"
426
+ " pass\n"
427
+ " s.close()\n"
428
+ " except OSError:\n"
429
+ " pass\n"
430
+ " i += 1\n"
431
+ "import threading\n"
432
+ "for _ in range(4):\n"
433
+ " threading.Thread(target=abuse, daemon=True).start()\n" + f"time.sleep({seconds})\n"
434
+ )
435
+ raise NO_UNDO # pragma: no cover - only reachable for unregistered payloads
436
+
437
+
438
+ # ``` needs `import time` for the loops that reference ``time``.
439
+ _PAYLOAD_IMPORTS = "import os, time\n"
440
+
441
+
442
+ def _payload_node(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> TopologyNode | None:
443
+ """Pick the engine address for a payload fault: the first node that carries a
444
+ container_name — a live container, its process, or the compose service
445
+ (blueprint-only topology) — since payload inject/undo/verify all run through
446
+ the container engine (ADR-0020)."""
447
+ from mayhem.domain.topology import ( # noqa: PLC0415
448
+ ContainerNode,
449
+ ProcessNode,
450
+ ServiceNode,
451
+ )
452
+
453
+ node: TopologyNode | None = next(
454
+ (
455
+ n
456
+ for n in nodes
457
+ if isinstance(n, (ContainerNode, ProcessNode)) and getattr(n, "container_name", None)
458
+ ),
459
+ None,
460
+ )
461
+ if node is None:
462
+ node = next(
463
+ (n for n in nodes if isinstance(n, ServiceNode) and getattr(n, "container_name", None)),
464
+ None,
465
+ )
466
+ return node
467
+
468
+
469
+ def _payload_undo_ops(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
470
+ """Compensation for payload-family faults: SIGKILL the injected process and
471
+ remove its marker files. Reversible by construction (marker-addressed undo)."""
472
+ node = _payload_node(fault, nodes)
473
+ if node is None:
474
+ raise NO_UNDO
475
+ marker = _payload_marker(fault, node)
476
+ src = _PAYLOAD_IMPORTS + _payload_source(fault, marker)
477
+ return (
478
+ UndoOp(
479
+ op="payload.undo",
480
+ args={
481
+ "fault": fault.fault_id,
482
+ "payload": src,
483
+ "marker": marker,
484
+ "pid": _pid_arg(node), # node_id:@live-pid -> cont/engine at exec
485
+ },
486
+ ),
487
+ )
488
+
489
+
490
+ def _payload_verify(
491
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
492
+ ) -> tuple[VerifyProbe, ...]:
493
+ """Recovery evidence for payload-family faults: the marker file must be gone
494
+ after undo (undo SIGKILLs the marker pid and deletes the marker). Addressed
495
+ through the same container engine path as the undo op (ADR-0020), so the
496
+ probe only passes once the payload's in-container effects were removed."""
497
+ node = _payload_node(fault, nodes)
498
+ if node is None:
499
+ raise NO_UNDO
500
+ marker = _payload_marker(fault, node)
501
+ return (
502
+ VerifyProbe(
503
+ probe="exec",
504
+ args={
505
+ "cmd": ["sh", "-c", f"test ! -e {marker}"],
506
+ "incontainer": True,
507
+ "pid": _pid_arg(node),
508
+ "timeout_s": "5",
509
+ },
510
+ expect_present=True,
511
+ ),
512
+ )
513
+
514
+
515
+ _PAYLOAD_FAULTS = frozenset(
516
+ {
517
+ "mem.exhaust",
518
+ "mem.leak",
519
+ "cpu.saturate",
520
+ "fs.fill",
521
+ "fs.inode_exhaust",
522
+ "fs.io_stress",
523
+ "fd.exhaust",
524
+ "load.spike",
525
+ "fuzz.protocol_abuse",
526
+ }
527
+ )
528
+
529
+
530
+ class CompensationTemplate:
531
+ """Undo/verify factory pair for one fault family.
532
+
533
+ ``build(fault, nodes)`` receives the planned fault (params, duration) and
534
+ the compensation node pool (matched targets + connected processes), so
535
+ templates can parameterize the undo contract by fault params.
536
+ """
537
+
538
+ def __init__(
539
+ self,
540
+ undo: Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]],
541
+ verify: Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]],
542
+ ) -> None:
543
+ self._undo = undo
544
+ self._verify = verify
545
+
546
+ def build(
547
+ self, fault: PlannedFault, nodes: tuple[TopologyNode, ...]
548
+ ) -> tuple[tuple[UndoOp, ...], tuple[VerifyProbe, ...]]:
549
+ return self._undo(fault, nodes), self._verify(fault, nodes)
550
+
551
+
552
+ def _ignores_fault(
553
+ undo: Callable[[tuple[TopologyNode, ...]], tuple[UndoOp, ...]],
554
+ verify: Callable[[tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]],
555
+ ) -> CompensationTemplate:
556
+ return CompensationTemplate(
557
+ lambda _fault, nodes: undo(nodes), lambda _fault, nodes: verify(nodes)
558
+ )
559
+
560
+
561
+ def _payload_compensation_templates() -> dict[str, CompensationTemplate]:
562
+ return dict.fromkeys(
563
+ sorted(_PAYLOAD_FAULTS),
564
+ CompensationTemplate(_payload_undo_ops, _payload_verify),
565
+ )
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Tool compensation — argv-pair faults driven by :class:`ToolExecutor`.
570
+ #
571
+ # Each family emits exactly one undo op whose args carry ``inject_argv`` /
572
+ # ``undo_argv`` (JSON list[str]) plus a ``node_id:@live-pid`` placeholder so the
573
+ # live substitute appends the container address (``cont``/``engine``). The argv
574
+ # pairs embed ``@engine`` / ``@cont`` tokens that :class:`ToolExecutor` rewrites
575
+ # to the live engine + container at execution time (ADR-0020). Verify probes are
576
+ # the payload style: an in-container command that must exit 0 after undo, or an
577
+ # engine-inspect presence check when the fault stops the container.
578
+ # ---------------------------------------------------------------------------
579
+
580
+ _ENGINE_TOKEN = "@engine"
581
+ _CONTAINER_TOKEN = "@cont"
582
+
583
+
584
+ def _tool_node(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> TopologyNode | None:
585
+ """Engine address for an argv-pair fault: same as the payload selection."""
586
+ return _payload_node(fault, nodes)
587
+
588
+
589
+ def _tool_marker(fault: PlannedFault, node: TopologyNode, suffix: str) -> str:
590
+ tag = re.sub(r"[^A-Za-z0-9_-]", "-", f"{fault.fault_id}.{node.id}")
591
+ return f"/tmp/mayhem.{tag}.{suffix}"
592
+
593
+
594
+ def _tool_op(
595
+ fault: PlannedFault,
596
+ node: TopologyNode,
597
+ name: str,
598
+ inject_argv: list[str],
599
+ undo_argv: list[str],
600
+ ) -> UndoOp:
601
+ return UndoOp(
602
+ op=name,
603
+ args={
604
+ "fault": fault.fault_id,
605
+ "inject_argv": json.dumps(inject_argv),
606
+ "undo_argv": json.dumps(undo_argv),
607
+ "pid": _pid_arg(node), # node_id:@live-pid -> cont/engine at execution
608
+ },
609
+ )
610
+
611
+
612
+ def _exec_verify(
613
+ node: TopologyNode, cmd: list[str], *, incontainer: bool, timeout_s: str = "5"
614
+ ) -> VerifyProbe:
615
+ args: dict[str, object] = {"cmd": cmd, "pid": _pid_arg(node), "timeout_s": timeout_s}
616
+ if incontainer:
617
+ args["incontainer"] = True
618
+ return VerifyProbe(probe="exec", args=args, expect_present=True)
619
+
620
+
621
+ def _incontainer_argv(cmd: list[str]) -> list[str]:
622
+ return [_ENGINE_TOKEN, "exec", _CONTAINER_TOKEN, *cmd]
623
+
624
+
625
+ def _engine_argv(action: str) -> list[str]:
626
+ return [_ENGINE_TOKEN, action, _CONTAINER_TOKEN]
627
+
628
+
629
+ def _tool_template(
630
+ undo: Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]],
631
+ verify: Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]],
632
+ ) -> CompensationTemplate:
633
+ return CompensationTemplate(undo, verify)
634
+
635
+
636
+ def _net_latency_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
637
+ node = _tool_node(fault, nodes)
638
+ if node is None:
639
+ raise NO_UNDO
640
+ delay_ms = _iparam(fault, "seconds", 5) * 1000
641
+ jitter_ms = _iparam(fault, "jitter_ms", 0)
642
+ tail = ["netem", "delay", f"{delay_ms}ms"]
643
+ if jitter_ms > 0:
644
+ tail.append(f"{jitter_ms}ms")
645
+ return _tc_qdisc_undo(fault, node, tail)
646
+
647
+
648
+ def _net_latency_verify(
649
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
650
+ ) -> tuple[VerifyProbe, ...]:
651
+ node = _tool_node(fault, nodes)
652
+ if node is None:
653
+ raise NO_UNDO
654
+ return _tc_qdisc_verify(fault, node, "netem")
655
+
656
+
657
+ # ── Direction-aware tc qdisc infra (egress / ingress / both) ──────────────
658
+ #
659
+ # tc cannot shape inbound traffic directly; standard practice is to mirror the
660
+ # container's egress into an ``ifb`` device and attach the qdisc there. The
661
+ # ``both`` direction composes both paths in one inject. Undo removes exactly
662
+ # the devices/qdiscs this fault added (net_admin scope), so a drill never
663
+ # leaves a queue discipline behind: egress delete, ifb + ingress teardown.
664
+
665
+ _EGRESS_DEV = "eth0"
666
+ _IFB_DEV = "ifb0"
667
+ _DIRECTIONS = frozenset({"egress", "ingress", "both"})
668
+
669
+ _INGRESS_SETUP = (
670
+ f"ip link add {_IFB_DEV} type ifb 2>/dev/null; "
671
+ f"ip link set {_IFB_DEV} up; "
672
+ f"tc qdisc del dev {_EGRESS_DEV} ingress 2>/dev/null; "
673
+ f"tc qdisc add dev {_EGRESS_DEV} handle ffff: ingress; "
674
+ f"tc filter add dev {_EGRESS_DEV} parent ffff: protocol ip u32 match u32 0 0 "
675
+ f"action mirred egress redirect dev {_IFB_DEV}"
676
+ )
677
+
678
+ _INGRESS_UNDO = (
679
+ f"tc qdisc del dev {_IFB_DEV} root 2>/dev/null; "
680
+ f"tc qdisc del dev {_EGRESS_DEV} ingress 2>/dev/null; "
681
+ f"ip link del {_IFB_DEV} 2>/dev/null"
682
+ )
683
+
684
+
685
+ def _direction(fault: PlannedFault, default: str = "egress") -> str:
686
+ value = str(_param(fault, "direction", default)).strip().lower()
687
+ if value not in _DIRECTIONS:
688
+ raise InvariantViolationError(
689
+ "fault_direction",
690
+ f"unsupported net direction {value!r}; expected one of {sorted(_DIRECTIONS)}",
691
+ )
692
+ return value
693
+
694
+
695
+ def _tc_qdisc_undo(
696
+ fault: PlannedFault, node: TopologyNode, qdisc_tail: list[str]
697
+ ) -> tuple[UndoOp, ...]:
698
+ """Build inject/undo for a root tc qdisc, honoring ``direction``.
699
+
700
+ ``qdisc_tail`` is the ``tc qdisc add dev <dev> root <tail>`` argument list
701
+ that follows the root keyword (e.g. ``["netem", "loss", "10%"]``).
702
+ """
703
+ direction = _direction(fault)
704
+ egress_add = ["tc", "qdisc", "add", "dev", _EGRESS_DEV, "root", *qdisc_tail]
705
+ ifb_add = ["tc", "qdisc", "add", "dev", _IFB_DEV, "root", *qdisc_tail]
706
+ if direction == "egress":
707
+ inject = egress_add
708
+ undo = ["tc", "qdisc", "del", "dev", _EGRESS_DEV, "root"]
709
+ elif direction == "ingress":
710
+ inject = ["sh", "-c", f"{_INGRESS_SETUP}; {' '.join(ifb_add)}"]
711
+ undo = ["sh", "-c", _INGRESS_UNDO]
712
+ else: # both
713
+ inject = [
714
+ "sh",
715
+ "-c",
716
+ f"{' '.join(egress_add)}; {_INGRESS_SETUP}; {' '.join(ifb_add)}",
717
+ ]
718
+ undo = ["sh", "-c", f"tc qdisc del dev {_EGRESS_DEV} root; {_INGRESS_UNDO}"]
719
+ return (
720
+ _tool_op(fault, node, "tc.del_qdisc", _incontainer_argv(inject), _incontainer_argv(undo)),
721
+ )
722
+
723
+
724
+ def _tc_verify_cmd(fault: PlannedFault, qdisc_grep: str) -> str:
725
+ direction = _direction(fault)
726
+ if direction == "egress":
727
+ return f"! tc qdisc show dev {_EGRESS_DEV} | grep -q {qdisc_grep}"
728
+ if direction == "ingress":
729
+ return f"! ip link show {_IFB_DEV} 2>/dev/null | grep -q {_IFB_DEV}"
730
+ return (
731
+ f"! tc qdisc show dev {_EGRESS_DEV} | grep -q {qdisc_grep} "
732
+ f"&& ! ip link show {_IFB_DEV} 2>/dev/null | grep -q {_IFB_DEV}"
733
+ )
734
+
735
+
736
+ def _tc_qdisc_verify(
737
+ fault: PlannedFault, node: TopologyNode, qdisc_grep: str
738
+ ) -> tuple[VerifyProbe, ...]:
739
+ return (
740
+ _exec_verify(
741
+ node,
742
+ ["sh", "-c", _tc_verify_cmd(fault, qdisc_grep)],
743
+ incontainer=True,
744
+ ),
745
+ )
746
+
747
+
748
+ def _net_packet_loss_undo(
749
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
750
+ ) -> tuple[UndoOp, ...]:
751
+ node = _tool_node(fault, nodes)
752
+ if node is None:
753
+ raise NO_UNDO
754
+ percent = min(_iparam(fault, "percent", 10), 100)
755
+ return _tc_qdisc_undo(fault, node, ["netem", "loss", f"{percent}%"])
756
+
757
+
758
+ def _net_packet_loss_verify(
759
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
760
+ ) -> tuple[VerifyProbe, ...]:
761
+ node = _tool_node(fault, nodes)
762
+ if node is None:
763
+ raise NO_UNDO
764
+ return _tc_qdisc_verify(fault, node, "netem")
765
+
766
+
767
+ _RATE_RE = re.compile(r"^[1-9][0-9]*(kbit|mbit|gbit|kbps|mbps|gbps)?$")
768
+
769
+
770
+ def _bandwidth_tokens(fault: PlannedFault) -> tuple[str, str]:
771
+ rate = str(_param(fault, "rate", "")).strip().lower()
772
+ if not _RATE_RE.match(rate):
773
+ raise InvariantViolationError(
774
+ "fault_rate",
775
+ f"invalid net.bandwidth rate {rate!r}; expected e.g. '10mbit' / '2kbps'",
776
+ )
777
+ burst = str(_param(fault, "burst", "10k")).strip().lower()
778
+ if not re.match(r"^[1-9][0-9]*k?$", burst):
779
+ raise InvariantViolationError(
780
+ "fault_burst", f"invalid net.bandwidth burst {burst!r}; expected bytes like '10k'"
781
+ )
782
+ return rate, burst
783
+
784
+
785
+ def _net_bandwidth_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
786
+ node = _tool_node(fault, nodes)
787
+ if node is None:
788
+ raise NO_UNDO
789
+ rate, burst = _bandwidth_tokens(fault)
790
+ return _tc_qdisc_undo(fault, node, ["tbf", "rate", rate, "burst", burst, "latency", "50ms"])
791
+
792
+
793
+ def _net_bandwidth_verify(
794
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
795
+ ) -> tuple[VerifyProbe, ...]:
796
+ node = _tool_node(fault, nodes)
797
+ if node is None:
798
+ raise NO_UNDO
799
+ return _tc_qdisc_verify(fault, node, "tbf")
800
+
801
+
802
+ def _net_partition_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
803
+ node = _tool_node(fault, nodes)
804
+ if node is None:
805
+ raise NO_UNDO
806
+ inject = ["tc", "qdisc", "add", "dev", "eth0", "root", "netem", "loss", "100%"]
807
+ undo = ["tc", "qdisc", "del", "dev", "eth0", "root"]
808
+ return (
809
+ _tool_op(fault, node, "tc.del_qdisc", _incontainer_argv(inject), _incontainer_argv(undo)),
810
+ )
811
+
812
+
813
+ def _net_partition_verify(
814
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
815
+ ) -> tuple[VerifyProbe, ...]:
816
+ node = _tool_node(fault, nodes)
817
+ if node is None:
818
+ raise NO_UNDO
819
+ return (
820
+ _exec_verify(
821
+ node, ["sh", "-c", "! tc qdisc show dev eth0 | grep -q netem"], incontainer=True
822
+ ),
823
+ )
824
+
825
+
826
+ def _host_op(
827
+ fault: PlannedFault, name: str, inject_argv: list[str], undo_argv: list[str]
828
+ ) -> UndoOp:
829
+ """A tool op whose argv runs on the drill host (no ``@engine`` tokens).
830
+
831
+ Host-addressed faults (``net.load``) get no container address and no
832
+ ``@live-pid``, so pid substitution leaves the command a plain host process.
833
+ """
834
+ return UndoOp(
835
+ op=name,
836
+ args={
837
+ "fault": fault.fault_id,
838
+ "inject_argv": json.dumps(inject_argv),
839
+ "undo_argv": json.dumps(undo_argv),
840
+ },
841
+ )
842
+
843
+
844
+ def _host_exec_verify(cmd: list[str], *, timeout_s: str = "5") -> VerifyProbe:
845
+ """A verify probe that runs on the drill host (no container address)."""
846
+ return VerifyProbe(
847
+ probe="exec",
848
+ args={"cmd": list(cmd), "timeout_s": timeout_s},
849
+ expect_present=True,
850
+ )
851
+
852
+
853
+ def _net_load_target_url(fault: PlannedFault, node: TopologyNode) -> str:
854
+ """Resolve the URL k6 drives from the host.
855
+
856
+ An explicit ``params.url`` wins. Otherwise the first TCP port binding
857
+ selects the address: a binding on a loopback host address
858
+ (``127.0.0.1``/``::1``) is reached through the host's ``localhost:<host_port>``
859
+ (host_port can differ from container_port), while any other binding is
860
+ reached directly at the container's own live IP and container-side port —
861
+ so the host load generator can always reach the container. Blueprint-only
862
+ topology (no live ``ip_address``) or portless nodes fall back to the
863
+ documented ``http://localhost/`` default.
864
+ """
865
+ explicit = _param(fault, "url", None)
866
+ if isinstance(explicit, str) and explicit.strip():
867
+ return explicit
868
+ ip = getattr(node, "ip_address", None)
869
+ ports: tuple[object, ...] = tuple(
870
+ getattr(node, "ports", ()) or getattr(node, "exposed_ports", ())
871
+ )
872
+ for binding in ports:
873
+ if getattr(binding, "protocol", "tcp") != "tcp":
874
+ continue
875
+ host_addr = str(getattr(binding, "host_address", "") or "0.0.0.0")
876
+ host_port = int(getattr(binding, "host_port", 0) or 0)
877
+ if host_port and host_addr.strip("[]") in {"127.0.0.1", "::1", "localhost"}:
878
+ return f"http://localhost:{host_port}/"
879
+ container_port = int(getattr(binding, "container_port", 0) or 0)
880
+ if container_port and ip is not None:
881
+ return f"http://{ip}:{container_port}/"
882
+ return "http://localhost/"
883
+
884
+
885
+ def _net_load_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
886
+ """Saturate the target container with a host-driven k6 HTTP load generator.
887
+
888
+ When ``params.script_content`` is set (content of a k6 ``script.js``
889
+ embedded by the planner), the file is materialized on the drill host and
890
+ run as ``k6 run -u <users> -d <duration>s``; otherwise a minimal inline
891
+ script against the container's ``ip:serving-port`` is written. Either way
892
+ the script lives under the marker path (so the verify probe can see the
893
+ fault while it is live) and ``k6 run`` is detached on the host, driving
894
+ load at the container over the network — k6 never runs inside the target.
895
+ Undo SIGKILLs the recorded k6 pid and removes both marker files, so
896
+ recovery, undo and the verify probe agree.
897
+ """
898
+ node = _tool_node(fault, nodes)
899
+ if node is None:
900
+ raise NO_UNDO
901
+ users = max(1, _iparam(fault, "users", 1))
902
+ duration_s = max(1, int(_fault_duration_s(fault)))
903
+ script = _tool_marker(fault, node, "load.js")
904
+ pidfile = _tool_marker(fault, node, "k6.pid")
905
+ inline = _param(fault, "script_content", None)
906
+ if inline:
907
+ # User-supplied k6 script.js: materialize the embedded content on the
908
+ # host, then run it. The heredoc delimiter is unique per content so a
909
+ # user script containing a literal ``K6EOF`` line still copies intact.
910
+ delim = f"K6EOF_{abs(hash(inline or '') or 1):x}"
911
+ import_sh = f"cat > {script} <<'{delim}'\n{inline}\n{delim}\n"
912
+ else:
913
+ url = _net_load_target_url(fault, node)
914
+ url = url.replace("\\", "\\\\").replace('"', '\\"')
915
+ import_sh = (
916
+ f"cat > {script} <<'K6EOF'\n"
917
+ "import http from 'k6/http';\n"
918
+ "export default function () {\n"
919
+ f' http.get("{url}");\n'
920
+ "}\n"
921
+ "K6EOF\n"
922
+ )
923
+ source = (
924
+ import_sh
925
+ + f"k6 run -u {users} -d {duration_s}s {script} >/dev/null 2>&1 &\n"
926
+ + f"echo $! > {pidfile}\n"
927
+ + f'[ -s {pidfile} ] && kill -0 "$(cat {pidfile})" 2>/dev/null && exit 0\n'
928
+ + "exit 1\n"
929
+ )
930
+ undo = [
931
+ "sh",
932
+ "-c",
933
+ f'p={pidfile}; [ ! -f "$p" ] || kill "$(cat "$p")" 2>/dev/null; rm -f "$p" {script}',
934
+ ]
935
+ return (_host_op(fault, "k6.host", ["sh", "-c", source], undo),)
936
+
937
+
938
+ def _net_load_verify(
939
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
940
+ ) -> tuple[VerifyProbe, ...]:
941
+ node = _tool_node(fault, nodes)
942
+ if node is None:
943
+ raise NO_UNDO
944
+ script = _tool_marker(fault, node, "load.js")
945
+ pidfile = _tool_marker(fault, node, "k6.pid")
946
+ return (_host_exec_verify(["sh", "-c", f"test ! -e {script} && test ! -e {pidfile}"]),)
947
+
948
+
949
+ # ── net.connection_reset / net.connection_refuse ──────────────────────────
950
+
951
+
952
+ def _net_conn_reset_undo(
953
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
954
+ ) -> tuple[UndoOp, ...]:
955
+ """iptables REJECT with tcp-reset on the target port."""
956
+ return _param_netfilter("port", "tcp", "REJECT", ["--reject-with", "tcp-reset"], 80)(
957
+ fault, nodes
958
+ )
959
+
960
+
961
+ def _net_conn_reset_verify(
962
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
963
+ ) -> tuple[VerifyProbe, ...]:
964
+ return _param_netfilter_verify("port", 80)(fault, nodes)
965
+
966
+
967
+ def _net_conn_refuse_undo(
968
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
969
+ ) -> tuple[UndoOp, ...]:
970
+ """iptables REJECT with icmp-port-unreachable on the target port."""
971
+ return _param_netfilter(
972
+ "port", "tcp", "REJECT", ["--reject-with", "icmp-port-unreachable"], 80
973
+ )(fault, nodes)
974
+
975
+
976
+ def _net_conn_refuse_verify(
977
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
978
+ ) -> tuple[VerifyProbe, ...]:
979
+ return _param_netfilter_verify("port", 80)(fault, nodes)
980
+
981
+
982
+ # ── net.reorder / net.duplicate ───────────────────────────────────────────
983
+
984
+
985
+ def _net_reorder_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
986
+ """tc netem reorder: reorder <percent>% with delay_ms base delay."""
987
+ node = _tool_node(fault, nodes)
988
+ if node is None:
989
+ raise NO_UNDO
990
+ percent = max(1, min(_iparam(fault, "percent", 30), 100))
991
+ delay_ms = max(1, _iparam(fault, "delay_ms", 50))
992
+ tail = ["netem", "delay", f"{delay_ms}ms", "reorder", f"{percent}%"]
993
+ return _tc_qdisc_undo(fault, node, tail)
994
+
995
+
996
+ def _net_reorder_verify(
997
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
998
+ ) -> tuple[VerifyProbe, ...]:
999
+ node = _tool_node(fault, nodes)
1000
+ if node is None:
1001
+ raise NO_UNDO
1002
+ return _tc_qdisc_verify(fault, node, "netem")
1003
+
1004
+
1005
+ def _net_duplicate_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1006
+ """tc netem duplicate: duplicate <percent>% of packets."""
1007
+ node = _tool_node(fault, nodes)
1008
+ if node is None:
1009
+ raise NO_UNDO
1010
+ percent = max(1, min(_iparam(fault, "percent", 10), 100))
1011
+ tail = ["netem", "duplicate", f"{percent}%"]
1012
+ return _tc_qdisc_undo(fault, node, tail)
1013
+
1014
+
1015
+ def _net_duplicate_verify(
1016
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1017
+ ) -> tuple[VerifyProbe, ...]:
1018
+ node = _tool_node(fault, nodes)
1019
+ if node is None:
1020
+ raise NO_UNDO
1021
+ return _tc_qdisc_verify(fault, node, "netem")
1022
+
1023
+
1024
+ # ── dependency.connection_refuse ──────────────────────────────────────────
1025
+
1026
+
1027
+ def _dep_conn_refuse_undo(
1028
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1029
+ ) -> tuple[UndoOp, ...]:
1030
+ """iptables REJECT on the dependency's port (fast-fail vs DROP in block)."""
1031
+ return _param_netfilter(
1032
+ "port", "tcp", "REJECT", ["--reject-with", "icmp-port-unreachable"], 80
1033
+ )(fault, nodes)
1034
+
1035
+
1036
+ def _dep_conn_refuse_verify(
1037
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1038
+ ) -> tuple[VerifyProbe, ...]:
1039
+ return _param_netfilter_verify("port", 80)(fault, nodes)
1040
+
1041
+
1042
+ def _engine_restart_undo(
1043
+ inject_action: str, undo_action: str
1044
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1045
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1046
+ node = _tool_node(fault, nodes)
1047
+ if node is None:
1048
+ raise NO_UNDO
1049
+ return (
1050
+ _tool_op(
1051
+ fault,
1052
+ node,
1053
+ "engine.restart",
1054
+ _engine_argv(inject_action),
1055
+ _engine_argv(undo_action),
1056
+ ),
1057
+ )
1058
+
1059
+ return build
1060
+
1061
+
1062
+ def _engine_signal_undo(
1063
+ action: str, undo_action: str
1064
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1065
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1066
+ node = _tool_node(fault, nodes)
1067
+ if node is None:
1068
+ raise NO_UNDO
1069
+ sig = str(_param(fault, "signal", "SIGKILL"))
1070
+ if sig.startswith("SIG"):
1071
+ sig = sig[3:]
1072
+ inject = [_ENGINE_TOKEN, action, "--signal", sig, _CONTAINER_TOKEN]
1073
+ undo = [_ENGINE_TOKEN, undo_action, _CONTAINER_TOKEN]
1074
+ return (_tool_op(fault, node, "engine.restart", inject, undo),)
1075
+
1076
+ return build
1077
+
1078
+
1079
+ def _engine_restart_verify(
1080
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1081
+ ) -> tuple[VerifyProbe, ...]:
1082
+ node = _tool_node(fault, nodes)
1083
+ if node is None:
1084
+ raise NO_UNDO
1085
+ return (_exec_verify(node, ["true"], incontainer=False),)
1086
+
1087
+
1088
+ def _netfilter_undo(
1089
+ dport: str,
1090
+ *,
1091
+ proto: str = "tcp",
1092
+ jump: str = "DROP",
1093
+ extra: list[str] | None = None,
1094
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1095
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1096
+ node = _tool_node(fault, nodes)
1097
+ if node is None:
1098
+ raise NO_UNDO
1099
+ body = ["-p", proto, "--dport", dport]
1100
+ inject = ["iptables", "-I", "OUTPUT", *body, "-j", jump, *(extra or [])]
1101
+ undo = ["iptables", "-D", "OUTPUT", *body, "-j", jump, *(extra or [])]
1102
+ return (
1103
+ _tool_op(
1104
+ fault,
1105
+ node,
1106
+ "iptables.sync",
1107
+ _incontainer_argv(inject),
1108
+ _incontainer_argv(undo),
1109
+ ),
1110
+ )
1111
+
1112
+ return build
1113
+
1114
+
1115
+ def _netfilter_verify(
1116
+ dport: str,
1117
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]]:
1118
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[VerifyProbe, ...]:
1119
+ node = _tool_node(fault, nodes)
1120
+ if node is None:
1121
+ raise NO_UNDO
1122
+ return (
1123
+ _exec_verify(
1124
+ node,
1125
+ ["sh", "-c", f"! iptables -S OUTPUT | grep -q -- '--dport {dport}'"],
1126
+ incontainer=True,
1127
+ ),
1128
+ )
1129
+
1130
+ return build
1131
+
1132
+
1133
+ def _port_rule(
1134
+ fault: PlannedFault,
1135
+ *,
1136
+ port_param: str,
1137
+ default_port: int,
1138
+ proto: str,
1139
+ jump: str,
1140
+ extra: list[str],
1141
+ ) -> tuple[list[str], list[str]]:
1142
+ port = _iparam(fault, port_param, default_port)
1143
+ body = ["-p", proto, "--dport", str(port)]
1144
+ return (
1145
+ ["iptables", "-I", "OUTPUT", *body, "-j", jump, *extra],
1146
+ ["iptables", "-D", "OUTPUT", *body, "-j", jump, *extra],
1147
+ )
1148
+
1149
+
1150
+ def _param_netfilter(
1151
+ port_param: str,
1152
+ proto: str,
1153
+ jump: str,
1154
+ extra: list[str],
1155
+ default_port: int,
1156
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1157
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1158
+ node = _tool_node(fault, nodes)
1159
+ if node is None:
1160
+ raise NO_UNDO
1161
+ inject, undo = _port_rule(
1162
+ fault,
1163
+ port_param=port_param,
1164
+ default_port=default_port,
1165
+ proto=proto,
1166
+ jump=jump,
1167
+ extra=extra,
1168
+ )
1169
+ return (
1170
+ _tool_op(
1171
+ fault,
1172
+ node,
1173
+ "iptables.sync",
1174
+ _incontainer_argv(inject),
1175
+ _incontainer_argv(undo),
1176
+ ),
1177
+ )
1178
+
1179
+ return build
1180
+
1181
+
1182
+ def _param_netfilter_verify(
1183
+ port_param: str,
1184
+ default_port: int,
1185
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]]:
1186
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[VerifyProbe, ...]:
1187
+ node = _tool_node(fault, nodes)
1188
+ if node is None:
1189
+ raise NO_UNDO
1190
+ port = _iparam(fault, port_param, default_port)
1191
+ return (
1192
+ _exec_verify(
1193
+ node,
1194
+ ["sh", "-c", f"! iptables -S OUTPUT | grep -q -- '--dport {port}'"],
1195
+ incontainer=True,
1196
+ ),
1197
+ )
1198
+
1199
+ return build
1200
+
1201
+
1202
+ def _pulse_rule(
1203
+ fault: PlannedFault,
1204
+ node: TopologyNode,
1205
+ *,
1206
+ proto: str,
1207
+ dport: int,
1208
+ jump: str,
1209
+ extra: list[str],
1210
+ probability: float,
1211
+ marker_suffix: str,
1212
+ tick_s: float = 5.0,
1213
+ ) -> tuple[str, str, str]:
1214
+ """Deterministic ``probability``-% rule, re-asserted by a background loop.
1215
+
1216
+ The loop keeps a single OUTPUT rule present ``probability``% of the time by
1217
+ keying the tick on ``date +%s % 100`` — no random source, so two agents on
1218
+ the same second agree. Returns ``(inject, undo, verify_cmd)`` shell snippets;
1219
+ the undo kills the loop pid and deletes the rule deterministically.
1220
+ """
1221
+ marker = _tool_marker(fault, node, marker_suffix)
1222
+ pidf = f"{marker}.pid"
1223
+ prob = max(0, min(int(probability), 99))
1224
+ body = " ".join(["-p", proto, "--dport", str(dport), "-j", jump, *extra])
1225
+ inject = f"""P={prob}; M={marker}
1226
+ loop() {{
1227
+ if [ $(( $(date +%s) % 100 )) -lt $P ]; then
1228
+ iptables -C OUTPUT {body} 2>/dev/null || iptables -A OUTPUT {body}
1229
+ else
1230
+ iptables -D OUTPUT {body} 2>/dev/null
1231
+ fi
1232
+ sleep {tick_s}
1233
+ }}
1234
+ loop & echo $! > {pidf}
1235
+ kill -0 "$(cat {pidf})" 2>/dev/null && exit 0
1236
+ exit 1"""
1237
+ undo = f"""p={pidf}
1238
+ [ ! -f "$p" ] || kill "$(cat "$p")" 2>/dev/null
1239
+ iptables -D OUTPUT {body} 2>/dev/null
1240
+ rm -f {pidf}; true"""
1241
+ verify = f"test ! -e {pidf} && ! iptables -S OUTPUT | grep -q -- '--dport {dport}'"
1242
+ return inject, undo, verify
1243
+
1244
+
1245
+ def _pulse_undo_op(
1246
+ fault: PlannedFault,
1247
+ nodes: tuple[TopologyNode, ...],
1248
+ *,
1249
+ proto: str,
1250
+ dport: int,
1251
+ jump: str,
1252
+ extra: list[str],
1253
+ probability: float,
1254
+ marker_suffix: str,
1255
+ tick_s: float = 5.0,
1256
+ op_name: str = "iptables.sync",
1257
+ ) -> tuple[UndoOp, ...]:
1258
+ node = _tool_node(fault, nodes)
1259
+ if node is None:
1260
+ raise NO_UNDO
1261
+ inject, undo, _ = _pulse_rule(
1262
+ fault,
1263
+ node,
1264
+ proto=proto,
1265
+ dport=dport,
1266
+ jump=jump,
1267
+ extra=extra,
1268
+ probability=probability,
1269
+ marker_suffix=marker_suffix,
1270
+ tick_s=tick_s,
1271
+ )
1272
+ return (
1273
+ _tool_op(
1274
+ fault,
1275
+ node,
1276
+ op_name,
1277
+ _incontainer_argv(["sh", "-c", inject]),
1278
+ _incontainer_argv(["sh", "-c", undo]),
1279
+ ),
1280
+ )
1281
+
1282
+
1283
+ def _pulse_verify(
1284
+ fault: PlannedFault,
1285
+ nodes: tuple[TopologyNode, ...],
1286
+ *,
1287
+ dport: int,
1288
+ marker_suffix: str,
1289
+ ) -> tuple[VerifyProbe, ...]:
1290
+ node = _tool_node(fault, nodes)
1291
+ if node is None:
1292
+ raise NO_UNDO
1293
+ pidf = _tool_marker(fault, node, marker_suffix) + ".pid"
1294
+ check = f"test ! -e {pidf} && ! iptables -S OUTPUT | grep -q -- '--dport {dport}'"
1295
+ return (
1296
+ _exec_verify(
1297
+ node,
1298
+ ["sh", "-c", check],
1299
+ incontainer=True,
1300
+ ),
1301
+ )
1302
+
1303
+
1304
+ def _pulse_netfilter(
1305
+ proto: str,
1306
+ dport: int,
1307
+ *,
1308
+ jump: str,
1309
+ extra: list[str],
1310
+ probability: float,
1311
+ marker_suffix: str,
1312
+ tick_s: float = 5.0,
1313
+ ) -> tuple[
1314
+ Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]],
1315
+ Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[VerifyProbe, ...]],
1316
+ ]:
1317
+ def undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1318
+ return _pulse_undo_op(
1319
+ fault,
1320
+ nodes,
1321
+ proto=proto,
1322
+ dport=dport,
1323
+ jump=jump,
1324
+ extra=extra,
1325
+ probability=probability,
1326
+ marker_suffix=marker_suffix,
1327
+ tick_s=tick_s,
1328
+ )
1329
+
1330
+ def verify(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[VerifyProbe, ...]:
1331
+ return _pulse_verify(fault, nodes, dport=dport, marker_suffix=marker_suffix)
1332
+
1333
+ return undo, verify
1334
+
1335
+
1336
+ def _http_proxy_source(
1337
+ *,
1338
+ target: int,
1339
+ prob: float,
1340
+ marker_port: str,
1341
+ delay_ms: int | None = None,
1342
+ status: int | None = None,
1343
+ rate: int | None = None,
1344
+ burst: int = 200,
1345
+ code: int = 429,
1346
+ ) -> str:
1347
+ """In-container python passthrough proxy serving a fault mode.
1348
+
1349
+ Binds an ephemeral port on loopback and reports it under ``marker_port``; the
1350
+ wrapping shell inserts an OUTPUT REDIRECT rule to it. Modes:
1351
+ * status — responded calls get canned HTTP ``status``
1352
+ * delay — responded calls are held ``delay_ms`` before relaying
1353
+ * rate — responded calls consume a token bucket (``rate``/s, ``burst``
1354
+ tokens, ``code`` when dry)
1355
+ Unresponded calls (outside ``prob``) relay through untouched. The accept
1356
+ loop blocks, keeping the interpreter alive for the whole lease.
1357
+ """
1358
+ lines = [
1359
+ "import socket, threading, time, random",
1360
+ f"TARGET = {target}",
1361
+ f"PROB = {min(max(prob, 0.0), 100.0) / 100.0:.3f}",
1362
+ "status = 0",
1363
+ "rate = 0",
1364
+ "delay_s = 0.0",
1365
+ "ls = socket.socket(socket.AF_INET, socket.SOCK_STREAM)",
1366
+ "ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)",
1367
+ "ls.bind(('127.0.0.1', 0))",
1368
+ "ls.listen(128)",
1369
+ f"open({marker_port!r}, 'w').write(str(ls.getsockname()[1]))",
1370
+ ]
1371
+ if status is not None:
1372
+ lines.append(f"status = {max(int(status), 100)}")
1373
+ lines.append(f"code = {max(int(status), 100)}")
1374
+ if rate is not None:
1375
+ lines += [
1376
+ f"rate = {max(int(rate), 1)}",
1377
+ f"burst = {max(int(burst), 1)}",
1378
+ f"code = {max(int(code), 100)}",
1379
+ "bucket = float(burst)",
1380
+ "last = time.monotonic()",
1381
+ "def acquire():",
1382
+ " global bucket, last",
1383
+ " now = time.monotonic()",
1384
+ " bucket = min(bucket + (now - last) * rate, float(burst))",
1385
+ " last = now",
1386
+ " if bucket < 1.0:",
1387
+ " return False",
1388
+ " bucket -= 1.0",
1389
+ " return True",
1390
+ ]
1391
+ if delay_ms is not None and int(delay_ms) > 0:
1392
+ lines.append(f"delay_s = {max(int(delay_ms), 1) / 1000.0:.3f}")
1393
+ lines += [
1394
+ "def relay(a, b):",
1395
+ " try:",
1396
+ " while True:",
1397
+ " d = a.recv(65536)",
1398
+ " if not d:",
1399
+ " break",
1400
+ " b.sendall(d)",
1401
+ " except OSError:",
1402
+ " pass",
1403
+ " finally:",
1404
+ " try:",
1405
+ " b.shutdown(socket.SHUT_WR)",
1406
+ " except OSError:",
1407
+ " pass",
1408
+ "def forward(c):",
1409
+ " s = socket.create_connection(('127.0.0.1', TARGET), timeout=30)",
1410
+ " t1 = threading.Thread(target=relay, args=(c, s), daemon=True)",
1411
+ " t2 = threading.Thread(target=relay, args=(s, c), daemon=True)",
1412
+ " t1.start(); t2.start(); t1.join(); t2.join()",
1413
+ " s.close()",
1414
+ "def canned(c, n):",
1415
+ " line = f'HTTP/1.1 {n} X\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n'",
1416
+ " c.sendall(line.encode('ascii'))",
1417
+ "def handle(c):",
1418
+ " try:",
1419
+ " if random.random() >= PROB:",
1420
+ " forward(c)",
1421
+ " return",
1422
+ " if status:",
1423
+ " canned(c, status)",
1424
+ " return",
1425
+ " if rate and not acquire():",
1426
+ " canned(c, code)",
1427
+ " return",
1428
+ " if delay_s:",
1429
+ " time.sleep(delay_s)",
1430
+ " forward(c)",
1431
+ " except OSError:",
1432
+ " pass",
1433
+ "while True:",
1434
+ " try:",
1435
+ " conn, _ = ls.accept()",
1436
+ " except OSError:",
1437
+ " break",
1438
+ " threading.Thread(target=handle, args=(conn,), daemon=True).start()",
1439
+ ]
1440
+ return "\n".join(lines) + "\n"
1441
+
1442
+
1443
+ @dataclass(frozen=True)
1444
+ class _HttpEffect:
1445
+ delay_ms: int | None = None
1446
+ status: int | None = None
1447
+ rate: int | None = None
1448
+ burst: int = 200
1449
+ code: int = 429
1450
+
1451
+
1452
+ def _http_proxy_ops(
1453
+ fault: PlannedFault,
1454
+ nodes: tuple[TopologyNode, ...],
1455
+ *,
1456
+ effect: _HttpEffect | None = None,
1457
+ op_name: str = "http.proxy",
1458
+ ) -> tuple[UndoOp, ...]:
1459
+ """In-container python proxy + OUTPUT REDIRECT, addressed by a marker pid.
1460
+
1461
+ The proxy owns no state of its own: undo kills the marker pid, deletes the
1462
+ nat OUTPUT REDIRECT rule, and removes the marker files, so recovery, undo,
1463
+ and the verify probe all agree on the same files (ADR-0021 pattern).
1464
+ """
1465
+ node = _tool_node(fault, nodes)
1466
+ if node is None:
1467
+ raise NO_UNDO
1468
+ if effect is None:
1469
+ effect = _HttpEffect()
1470
+ marker = _tool_marker(fault, node, "http")
1471
+ pidf = f"{marker}.pid"
1472
+ portf = f"{marker}.port"
1473
+ srcf = f"{marker}.src"
1474
+ target = _iparam(fault, "port", 80)
1475
+ prob = _fparam(fault, "probability", 100.0)
1476
+ source = _http_proxy_source(
1477
+ target=target,
1478
+ prob=prob,
1479
+ marker_port=portf,
1480
+ delay_ms=effect.delay_ms,
1481
+ status=effect.status,
1482
+ rate=effect.rate,
1483
+ burst=effect.burst,
1484
+ code=effect.code,
1485
+ )
1486
+ inject = (
1487
+ f"cat > {srcf} <<'MAYHEM_PY_EOF'\n{source}MAYHEM_PY_EOF\n"
1488
+ f"python3 {srcf} >/dev/null 2>&1 &\n"
1489
+ f"echo $! > {pidf}\n"
1490
+ f"i=0\n"
1491
+ f"while [ ! -s {portf} ] && [ $i -lt 60 ]; do sleep 0.1; i=$((i + 1)); done\n"
1492
+ f"[ -s {portf} ] || exit 1\n"
1493
+ f"PORT=$(cat {portf})\n"
1494
+ f"iptables -t nat -A OUTPUT -p tcp --dport {target} -j REDIRECT --to-ports $PORT\n"
1495
+ f"exit 0\n"
1496
+ )
1497
+ undo = (
1498
+ f"p={pidf}\n"
1499
+ f'[ ! -f "$p" ] || kill "$(cat "$p")" 2>/dev/null\n'
1500
+ f"PORT=$(cat {portf} 2>/dev/null)\n"
1501
+ f'[ -z "$PORT" ] || iptables -t nat -D OUTPUT -p tcp --dport {target} -j REDIRECT --to-ports "$PORT"\n'
1502
+ f"rm -f {pidf} {portf} {srcf}\n"
1503
+ f"true\n"
1504
+ )
1505
+ return (
1506
+ _tool_op(
1507
+ fault,
1508
+ node,
1509
+ op_name,
1510
+ _incontainer_argv(["sh", "-c", inject]),
1511
+ _incontainer_argv(["sh", "-c", undo]),
1512
+ ),
1513
+ )
1514
+
1515
+
1516
+ def _http_proxy_verify(
1517
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1518
+ ) -> tuple[VerifyProbe, ...]:
1519
+ node = _tool_node(fault, nodes)
1520
+ if node is None:
1521
+ raise NO_UNDO
1522
+ marker = _tool_marker(fault, node, "http")
1523
+ target = _iparam(fault, "port", 80)
1524
+ return (
1525
+ _exec_verify(
1526
+ node,
1527
+ [
1528
+ "sh",
1529
+ "-c",
1530
+ f"test ! -e {marker}.pid && test ! -e {marker}.port "
1531
+ f"&& ! iptables -t nat -S OUTPUT | grep -q -- '--dport {target}'",
1532
+ ],
1533
+ incontainer=True,
1534
+ ),
1535
+ )
1536
+
1537
+
1538
+ def _http_error_inject() -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1539
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1540
+ node = _tool_node(fault, nodes)
1541
+ if node is None:
1542
+ raise NO_UNDO
1543
+ prob = _fparam(fault, "probability", 0.0)
1544
+ if prob < 1.0:
1545
+ # Legacy deterministic fault: hard TCP reset on outgoing 80.
1546
+ inject = [
1547
+ "iptables",
1548
+ "-I",
1549
+ "OUTPUT",
1550
+ "-p",
1551
+ "tcp",
1552
+ "--dport",
1553
+ str(_iparam(fault, "port", 80)),
1554
+ "-j",
1555
+ "REJECT",
1556
+ "--reject-with",
1557
+ "tcp-reset",
1558
+ ]
1559
+ undo = [
1560
+ "iptables",
1561
+ "-D",
1562
+ "OUTPUT",
1563
+ "-p",
1564
+ "tcp",
1565
+ "--dport",
1566
+ str(_iparam(fault, "port", 80)),
1567
+ "-j",
1568
+ "REJECT",
1569
+ "--reject-with",
1570
+ "tcp-reset",
1571
+ ]
1572
+ return (
1573
+ _tool_op(
1574
+ fault,
1575
+ node,
1576
+ "iptables.sync",
1577
+ _incontainer_argv(inject),
1578
+ _incontainer_argv(undo),
1579
+ ),
1580
+ )
1581
+ # Probabilistic / canned-status mode: proxy serves ``status`` to a
1582
+ # ``probability``% slice of the traffic (see http.latency).
1583
+ return _http_proxy_ops(
1584
+ fault,
1585
+ nodes,
1586
+ effect=_HttpEffect(status=_iparam(fault, "status", 500)),
1587
+ )
1588
+
1589
+ return build
1590
+
1591
+
1592
+ def _http_error_verify(
1593
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1594
+ ) -> tuple[VerifyProbe, ...]:
1595
+ if _fparam(fault, "probability", 0.0) < 1.0:
1596
+ node = _tool_node(fault, nodes)
1597
+ if node is None:
1598
+ raise NO_UNDO
1599
+ return (
1600
+ _exec_verify(
1601
+ node,
1602
+ [
1603
+ "sh",
1604
+ "-c",
1605
+ f"! iptables -S OUTPUT | grep -q -- '--dport {_iparam(fault, 'port', 80)}'",
1606
+ ],
1607
+ incontainer=True,
1608
+ ),
1609
+ )
1610
+ return _http_proxy_verify(fault, nodes)
1611
+
1612
+
1613
+ def _http_latency_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1614
+ return _http_proxy_ops(
1615
+ fault, nodes, effect=_HttpEffect(delay_ms=_iparam(fault, "delay_ms", 100))
1616
+ )
1617
+
1618
+
1619
+ def _http_latency_verify(
1620
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1621
+ ) -> tuple[VerifyProbe, ...]:
1622
+ return _http_proxy_verify(fault, nodes)
1623
+
1624
+
1625
+ def _dep_rate_limit_undo(
1626
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1627
+ ) -> tuple[UndoOp, ...]:
1628
+ rate = _iparam(fault, "rate", 0)
1629
+ if rate < 1:
1630
+ raise InvariantViolationError(
1631
+ "fault_rate", "dependency.rate_limit requires a positive rate"
1632
+ )
1633
+ return _http_proxy_ops(
1634
+ fault,
1635
+ nodes,
1636
+ effect=_HttpEffect(
1637
+ rate=rate,
1638
+ burst=_iparam(fault, "burst", 200),
1639
+ code=_iparam(fault, "code", 429),
1640
+ ),
1641
+ )
1642
+
1643
+
1644
+ def _dep_rate_limit_verify(
1645
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1646
+ ) -> tuple[VerifyProbe, ...]:
1647
+ return _http_proxy_verify(fault, nodes)
1648
+
1649
+
1650
+ def _dep_flap_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1651
+ node = _tool_node(fault, nodes)
1652
+ if node is None:
1653
+ raise NO_UNDO
1654
+ port = _iparam(fault, "port", 0)
1655
+ interval = max(_fparam(fault, "interval", 10.0), 1.0)
1656
+ prob = _fparam(fault, "failure_probability", 50.0)
1657
+ proto = str(_param(fault, "protocol", "tcp"))
1658
+ return _pulse_undo_op(
1659
+ fault,
1660
+ nodes,
1661
+ proto=proto,
1662
+ dport=port,
1663
+ jump="DROP",
1664
+ extra=[],
1665
+ probability=prob,
1666
+ marker_suffix="flap",
1667
+ tick_s=interval,
1668
+ )
1669
+
1670
+
1671
+ def _dep_flap_verify(
1672
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1673
+ ) -> tuple[VerifyProbe, ...]:
1674
+ return _pulse_verify(fault, nodes, dport=_iparam(fault, "port", 0), marker_suffix="flap")
1675
+
1676
+
1677
+ def _db_query_error_undo(
1678
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1679
+ ) -> tuple[UndoOp, ...]:
1680
+ prob = _fparam(fault, "probability", 100.0)
1681
+ if prob >= 100.0:
1682
+ return _param_netfilter("port", "tcp", "REJECT", ["--reject-with", "tcp-reset"], 3306)(
1683
+ fault, nodes
1684
+ )
1685
+ return _pulse_undo_op(
1686
+ fault,
1687
+ nodes,
1688
+ proto="tcp",
1689
+ dport=_iparam(fault, "port", 3306),
1690
+ jump="REJECT",
1691
+ extra=["--reject-with", "tcp-reset"],
1692
+ probability=prob,
1693
+ marker_suffix="db",
1694
+ )
1695
+
1696
+
1697
+ def _db_query_error_verify(
1698
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1699
+ ) -> tuple[VerifyProbe, ...]:
1700
+ if _fparam(fault, "probability", 100.0) >= 100.0:
1701
+ return _param_netfilter_verify("port", 3306)(fault, nodes)
1702
+ return _pulse_verify(fault, nodes, dport=_iparam(fault, "port", 3306), marker_suffix="db")
1703
+
1704
+
1705
+ def _tls_failure_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1706
+ return _param_netfilter("port", "tcp", "REJECT", ["--reject-with", "tcp-reset"], 443)(
1707
+ fault, nodes
1708
+ )
1709
+
1710
+
1711
+ def _tls_failure_verify(
1712
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1713
+ ) -> tuple[VerifyProbe, ...]:
1714
+ return _param_netfilter_verify("port", 443)(fault, nodes)
1715
+
1716
+
1717
+ def _conn_exhaust_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1718
+ """Exhaust the target's connection pool by holding real sockets open from
1719
+ inside the container. Marker-addressed, same lifecycle as the http proxy."""
1720
+ node = _tool_node(fault, nodes)
1721
+ if node is None:
1722
+ raise NO_UNDO
1723
+ connections = max(_iparam(fault, "connections", 32), 1)
1724
+ host = str(_param(fault, "host", "localhost"))
1725
+ port = _iparam(fault, "port", 3306)
1726
+ marker = _tool_marker(fault, node, "conn")
1727
+ pidf = f"{marker}.pid"
1728
+ srcf = f"{marker}.src"
1729
+ source = (
1730
+ "import socket, time\n"
1731
+ f"host = {host!r}\n"
1732
+ f"port = {port}\n"
1733
+ f"total = {connections}\n"
1734
+ "left = []\n"
1735
+ "while len(left) < total:\n"
1736
+ " try:\n"
1737
+ " s = socket.create_connection((host, port), timeout=10)\n"
1738
+ " left.append(s)\n"
1739
+ " except OSError:\n"
1740
+ " pass\n"
1741
+ "while True:\n"
1742
+ " time.sleep(3600)\n"
1743
+ )
1744
+ inject = (
1745
+ f"cat > {srcf} <<'MAYHEM_PY_EOF'\n{source}MAYHEM_PY_EOF\n"
1746
+ f"python3 {srcf} >/dev/null 2>&1 &\n"
1747
+ f"echo $! > {pidf}\n"
1748
+ f'[ -s {pidf} ] && kill -0 "$(cat {pidf})" 2>/dev/null && exit 0\n'
1749
+ "exit 1\n"
1750
+ )
1751
+ undo = f'p={pidf}\n[ ! -f "$p" ] || kill "$(cat "$p")" 2>/dev/null\nrm -f {pidf} {srcf}\ntrue\n'
1752
+ return (
1753
+ _tool_op(
1754
+ fault,
1755
+ node,
1756
+ "http.proxy",
1757
+ _incontainer_argv(["sh", "-c", inject]),
1758
+ _incontainer_argv(["sh", "-c", undo]),
1759
+ ),
1760
+ )
1761
+
1762
+
1763
+ def _conn_exhaust_verify(
1764
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1765
+ ) -> tuple[VerifyProbe, ...]:
1766
+ node = _tool_node(fault, nodes)
1767
+ if node is None:
1768
+ raise NO_UNDO
1769
+ marker = _tool_marker(fault, node, "conn")
1770
+ return (
1771
+ _exec_verify(
1772
+ node,
1773
+ ["sh", "-c", f"test ! -e {marker}.pid && test ! -e {marker}.src"],
1774
+ incontainer=True,
1775
+ ),
1776
+ )
1777
+
1778
+
1779
+ def _cpu_throttle_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1780
+ """Cap the container's CPU share at ``percent``% of one core.
1781
+
1782
+ Routed to the argv-pair ToolExecutor (executor override), so this must be a
1783
+ pure argv-pair op — same shape as container.restart. Undo lifts the cap back
1784
+ to unlimited (documented deviation: the pre-fault share is not re-read).
1785
+ """
1786
+ node = _tool_node(fault, nodes)
1787
+ if node is None:
1788
+ raise NO_UNDO
1789
+ percent = max(min(_fparam(fault, "percent", 50.0), 100.0), 1.0)
1790
+ return (
1791
+ _tool_op(
1792
+ fault,
1793
+ node,
1794
+ "engine.update",
1795
+ [_ENGINE_TOKEN, "update", "--cpus", f"{percent / 100:.2f}", _CONTAINER_TOKEN],
1796
+ [_ENGINE_TOKEN, "update", "--cpus", "0", _CONTAINER_TOKEN],
1797
+ ),
1798
+ )
1799
+
1800
+
1801
+ def _cpu_throttle_verify(
1802
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1803
+ ) -> tuple[VerifyProbe, ...]:
1804
+ node = _tool_node(fault, nodes)
1805
+ if node is None:
1806
+ raise NO_UNDO
1807
+ return (_exec_verify(node, ["true"], incontainer=False),)
1808
+
1809
+
1810
+ def _file_revert_undo(
1811
+ target: str, *ops: str
1812
+ ) -> Callable[[PlannedFault, tuple[TopologyNode, ...]], tuple[UndoOp, ...]]:
1813
+ """Marker-addressed file swap: back up ``target``, then apply ``ops`` (lines
1814
+ of shell run in order). Undo restores the backup and removes the marker."""
1815
+
1816
+ def build(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1817
+ node = _tool_node(fault, nodes)
1818
+ if node is None:
1819
+ raise NO_UNDO
1820
+ marker = _tool_marker(fault, node, "orig")
1821
+ inject = ["sh", "-c", f"cp {target} {marker}; " + "; ".join(ops)]
1822
+ undo = ["sh", "-c", f"mv -f {marker} {target}; rm -f {marker}"]
1823
+ return (
1824
+ _tool_op(
1825
+ fault,
1826
+ node,
1827
+ "file.revert",
1828
+ _incontainer_argv(inject),
1829
+ _incontainer_argv(undo),
1830
+ ),
1831
+ )
1832
+
1833
+ return build
1834
+
1835
+
1836
+ def _file_revert_verify(
1837
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1838
+ ) -> tuple[VerifyProbe, ...]:
1839
+ node = _tool_node(fault, nodes)
1840
+ if node is None:
1841
+ raise NO_UNDO
1842
+ marker = _tool_marker(fault, node, "orig")
1843
+ return (
1844
+ _exec_verify(
1845
+ node,
1846
+ ["sh", "-c", f"test ! -e {marker}"],
1847
+ incontainer=True,
1848
+ ),
1849
+ )
1850
+
1851
+
1852
+ def _fs_read_only_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1853
+ """Remount the target filesystem read-only; undo restores read-write.
1854
+
1855
+ A write-probe marker confirms the fs accepts writes again, guarding against
1856
+ a remount that silently wedged the container.
1857
+ """
1858
+ node = _tool_node(fault, nodes)
1859
+ if node is None:
1860
+ raise NO_UNDO
1861
+ path = str(_param(fault, "path", "/"))
1862
+ marker = _tool_marker(fault, node, "rwprobe")
1863
+ inject = ["sh", "-c", f"mount -o remount,ro {path}"]
1864
+ undo = [
1865
+ "sh",
1866
+ "-c",
1867
+ f"mount -o remount,rw {path} && touch {marker} && rm -f {marker}",
1868
+ ]
1869
+ return (
1870
+ _tool_op(
1871
+ fault,
1872
+ node,
1873
+ "fs.remount",
1874
+ _incontainer_argv(inject),
1875
+ _incontainer_argv(undo),
1876
+ ),
1877
+ )
1878
+
1879
+
1880
+ def _fs_read_only_verify(
1881
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1882
+ ) -> tuple[VerifyProbe, ...]:
1883
+ node = _tool_node(fault, nodes)
1884
+ if node is None:
1885
+ raise NO_UNDO
1886
+ marker = _tool_marker(fault, node, "rwprobe")
1887
+ return (
1888
+ _exec_verify(
1889
+ node,
1890
+ ["sh", "-c", f"touch {marker} && rm -f {marker}"],
1891
+ incontainer=True,
1892
+ ),
1893
+ )
1894
+
1895
+
1896
+ # ── process.crash_loop ─────────────────────────────────────────────────────
1897
+
1898
+
1899
+ def _process_crash_loop_undo(
1900
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1901
+ ) -> tuple[UndoOp, ...]:
1902
+ """Drive a restart cadence via engine stop/start argv-pairs.
1903
+
1904
+ Inject runs ``<engine> stop <cont>; <engine> start <cont>`` once; undo
1905
+ ``start``s the container so it recovers to a running state.
1906
+ """
1907
+ node = _tool_node(fault, nodes)
1908
+ if node is None:
1909
+ raise NO_UNDO
1910
+ restart_n = max(1, _iparam(fault, "restarts", 10))
1911
+ interval_s = str(_param(fault, "interval", "2s"))
1912
+ # Shell loop of stop→start ``restart_n`` times at the engine level; undo
1913
+ # just starts the container so the service returns to a running baseline.
1914
+ step = (
1915
+ f"{_ENGINE_TOKEN} stop {_CONTAINER_TOKEN}; "
1916
+ f"{_ENGINE_TOKEN} start {_CONTAINER_TOKEN}; sleep {interval_s}"
1917
+ )
1918
+ loop = " && ".join([step] * restart_n)
1919
+ inject = ["sh", "-c", loop]
1920
+ undo = _engine_argv("start")
1921
+ return (_tool_op(fault, node, "engine.restart", inject, undo),)
1922
+
1923
+
1924
+ def _process_crash_loop_verify(
1925
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1926
+ ) -> tuple[VerifyProbe, ...]:
1927
+ node = _tool_node(fault, nodes)
1928
+ if node is None:
1929
+ raise NO_UNDO
1930
+ # Undo started the container; the engine start action is the recovery.
1931
+ return (_exec_verify(node, ["true"], incontainer=False),)
1932
+
1933
+
1934
+ def _clock_skew_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1935
+ node = _tool_node(fault, nodes)
1936
+ if node is None:
1937
+ raise NO_UNDO
1938
+ marker = _tool_marker(fault, node, "clock.orig")
1939
+ offset_ms = _iparam(fault, "offset_ms", 0)
1940
+ inject = [
1941
+ "sh",
1942
+ "-c",
1943
+ f"date -u '+%s' > {marker}; target=$(( $(cat {marker}) + {offset_ms} )); "
1944
+ f'date -u -s "@$target"',
1945
+ ]
1946
+ undo = [
1947
+ "sh",
1948
+ "-c",
1949
+ f'if [ -f {marker} ]; then date -u -s "@$(cat {marker})"; fi; rm -f {marker}',
1950
+ ]
1951
+ return (
1952
+ _tool_op(
1953
+ fault,
1954
+ node,
1955
+ "clock.restore",
1956
+ _incontainer_argv(inject),
1957
+ _incontainer_argv(undo),
1958
+ ),
1959
+ )
1960
+
1961
+
1962
+ def _clock_skew_verify(
1963
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
1964
+ ) -> tuple[VerifyProbe, ...]:
1965
+ node = _tool_node(fault, nodes)
1966
+ if node is None:
1967
+ raise NO_UNDO
1968
+ marker = _tool_marker(fault, node, "clock.orig")
1969
+ return (
1970
+ _exec_verify(
1971
+ node,
1972
+ ["sh", "-c", f"test ! -e {marker}"],
1973
+ incontainer=True,
1974
+ ),
1975
+ )
1976
+
1977
+
1978
+ def _dns_nxdomain_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1979
+ node = _tool_node(fault, nodes)
1980
+ if node is None:
1981
+ raise NO_UNDO
1982
+ target = "/etc/hosts"
1983
+ marker = _tool_marker(fault, node, "orig")
1984
+ domain = str(_param(fault, "domain", "example.com"))
1985
+ inject = ["sh", "-c", f"cp {target} {marker}; echo '127.0.0.1 {domain}' >> {target}"]
1986
+ undo = ["sh", "-c", f"mv -f {marker} {target}; rm -f {marker}"]
1987
+ return (
1988
+ _tool_op(
1989
+ fault,
1990
+ node,
1991
+ "file.revert",
1992
+ _incontainer_argv(inject),
1993
+ _incontainer_argv(undo),
1994
+ ),
1995
+ )
1996
+
1997
+
1998
+ def _dep_block_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
1999
+ node = _tool_node(fault, nodes)
2000
+ if node is None:
2001
+ raise NO_UNDO
2002
+ port = _iparam(fault, "port", 0)
2003
+ proto = str(_param(fault, "protocol", "tcp"))
2004
+ inject = ["iptables", "-A", "OUTPUT", "-p", proto, "--dport", str(port), "-j", "DROP"]
2005
+ undo = ["iptables", "-D", "OUTPUT", "-p", proto, "--dport", str(port), "-j", "DROP"]
2006
+ return (
2007
+ _tool_op(fault, node, "iptables.sync", _incontainer_argv(inject), _incontainer_argv(undo)),
2008
+ )
2009
+
2010
+
2011
+ def _dep_block_verify(
2012
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
2013
+ ) -> tuple[VerifyProbe, ...]:
2014
+ node = _tool_node(fault, nodes)
2015
+ if node is None:
2016
+ raise NO_UNDO
2017
+ port = _iparam(fault, "port", 0)
2018
+ return (
2019
+ _exec_verify(
2020
+ node,
2021
+ ["sh", "-c", f"! iptables -S OUTPUT | grep -q -- '--dport {port}'"],
2022
+ incontainer=True,
2023
+ ),
2024
+ )
2025
+
2026
+
2027
+ def _dep_timeout_undo(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> tuple[UndoOp, ...]:
2028
+ node = _tool_node(fault, nodes)
2029
+ if node is None:
2030
+ raise NO_UNDO
2031
+ delay_ms = _iparam(fault, "delay_ms", 500)
2032
+ inject = ["tc", "qdisc", "add", "dev", "eth0", "root", "netem", "delay", f"{delay_ms}ms"]
2033
+ undo = ["tc", "qdisc", "del", "dev", "eth0", "root"]
2034
+ return (
2035
+ _tool_op(fault, node, "tc.del_qdisc", _incontainer_argv(inject), _incontainer_argv(undo)),
2036
+ )
2037
+
2038
+
2039
+ def _dep_timeout_verify(
2040
+ fault: PlannedFault, nodes: tuple[TopologyNode, ...]
2041
+ ) -> tuple[VerifyProbe, ...]:
2042
+ node = _tool_node(fault, nodes)
2043
+ if node is None:
2044
+ raise NO_UNDO
2045
+ return (
2046
+ _exec_verify(
2047
+ node,
2048
+ ["sh", "-c", "! tc qdisc show dev eth0 | grep -q netem"],
2049
+ incontainer=True,
2050
+ ),
2051
+ )
2052
+
2053
+
2054
+ def _tool_compensation_templates() -> dict[str, CompensationTemplate]:
2055
+ return {
2056
+ "net.latency": _tool_template(_net_latency_undo, _net_latency_verify),
2057
+ "net.partition": _tool_template(_net_partition_undo, _net_partition_verify),
2058
+ "net.load": _tool_template(_net_load_undo, _net_load_verify),
2059
+ "container.kill": _tool_template(
2060
+ _engine_signal_undo("kill", "start"), _engine_restart_verify
2061
+ ),
2062
+ "container.restart": _tool_template(
2063
+ _engine_restart_undo("restart", "start"), _engine_restart_verify
2064
+ ),
2065
+ "container.pause": _tool_template(
2066
+ _engine_restart_undo("pause", "unpause"), _engine_restart_verify
2067
+ ),
2068
+ "node.service_stop": _tool_template(
2069
+ _engine_restart_undo("stop", "start"), _engine_restart_verify
2070
+ ),
2071
+ "http.error_injection": _tool_template(_http_error_inject(), _http_error_verify),
2072
+ "http.latency": _tool_template(_http_latency_undo, _http_latency_verify),
2073
+ "net.packet_loss": _tool_template(_net_packet_loss_undo, _net_packet_loss_verify),
2074
+ "net.bandwidth": _tool_template(_net_bandwidth_undo, _net_bandwidth_verify),
2075
+ "db.connection_exhaust": _tool_template(_conn_exhaust_undo, _conn_exhaust_verify),
2076
+ "db.query_error": _tool_template(_db_query_error_undo, _db_query_error_verify),
2077
+ "db.slow_query": _tool_template(
2078
+ _netfilter_undo("3306"),
2079
+ _netfilter_verify("3306"),
2080
+ ),
2081
+ "dns.resolve_delay": _tool_template(
2082
+ _file_revert_undo(
2083
+ "/etc/resolv.conf",
2084
+ "printf 'nameserver 10.255.255.1\\n' > /etc/resolv.conf",
2085
+ ),
2086
+ _file_revert_verify,
2087
+ ),
2088
+ "dns.nxdomain": _tool_template(_dns_nxdomain_undo, _file_revert_verify),
2089
+ "dns.timeout": _tool_template(
2090
+ *_pulse_netfilter(
2091
+ "udp", 53, jump="DROP", extra=[], probability=80, marker_suffix="dns", tick_s=2.0
2092
+ )
2093
+ ),
2094
+ "dns.servfail": _tool_template(
2095
+ *_pulse_netfilter(
2096
+ "udp",
2097
+ 53,
2098
+ jump="REJECT",
2099
+ extra=["--reject-with", "icmp-port-unreachable"],
2100
+ probability=80,
2101
+ marker_suffix="dns",
2102
+ tick_s=2.0,
2103
+ )
2104
+ ),
2105
+ "tls.certificate_expired": _tool_template(
2106
+ _file_revert_undo(
2107
+ "/etc/ssl/certs/ca-certificates.crt",
2108
+ ": > /etc/ssl/certs/ca-certificates.crt",
2109
+ ),
2110
+ _file_revert_verify,
2111
+ ),
2112
+ "tls.handshake_failure": _tool_template(_tls_failure_undo, _tls_failure_verify),
2113
+ "clock.skew": _tool_template(_clock_skew_undo, _clock_skew_verify),
2114
+ "dependency.block": _tool_template(_dep_block_undo, _dep_block_verify),
2115
+ "dependency.timeout": _tool_template(_dep_timeout_undo, _dep_timeout_verify),
2116
+ "dependency.flap": _tool_template(_dep_flap_undo, _dep_flap_verify),
2117
+ "dependency.rate_limit": _tool_template(_dep_rate_limit_undo, _dep_rate_limit_verify),
2118
+ "dependency.connection_refuse": _tool_template(
2119
+ _dep_conn_refuse_undo, _dep_conn_refuse_verify
2120
+ ),
2121
+ "net.connection_reset": _tool_template(_net_conn_reset_undo, _net_conn_reset_verify),
2122
+ "net.connection_refuse": _tool_template(_net_conn_refuse_undo, _net_conn_refuse_verify),
2123
+ "net.reorder": _tool_template(_net_reorder_undo, _net_reorder_verify),
2124
+ "net.duplicate": _tool_template(_net_duplicate_undo, _net_duplicate_verify),
2125
+ "fs.read_only": _tool_template(_fs_read_only_undo, _fs_read_only_verify),
2126
+ "process.crash_loop": _tool_template(_process_crash_loop_undo, _process_crash_loop_verify),
2127
+ "cpu.throttle": _tool_template(_cpu_throttle_undo, _cpu_throttle_verify),
2128
+ }
2129
+
2130
+
2131
+ _TEMPLATES: dict[str, CompensationTemplate] = {
2132
+ "proc.pause": _ignores_fault(_proc_pause_undo, _proc_pause_verify),
2133
+ "process.stop": _ignores_fault(_process_term_undo, _process_term_verify),
2134
+ "process.kill": _ignores_fault(_process_term_undo, _process_term_verify),
2135
+ **_payload_compensation_templates(),
2136
+ **_tool_compensation_templates(),
2137
+ }
2138
+
2139
+
2140
+ def template_for(fault_id: str) -> CompensationTemplate | None:
2141
+ return _TEMPLATES.get(fault_id)
2142
+
2143
+
2144
+ def compensated(fault: PlannedFault, nodes: tuple[TopologyNode, ...]) -> PlannedFault:
2145
+ """Return ``fault`` with undo/verify filled in; refuse uncompensatable faults."""
2146
+ if fault.undo_ops and fault.verify_probes:
2147
+ return fault
2148
+ tpl = template_for(fault.fault_id)
2149
+ if tpl is None:
2150
+ msg = (
2151
+ f"fault {fault.fault_id!r} has no compensation template; "
2152
+ f"planner refuses to emit an uncompensatable injection"
2153
+ )
2154
+ raise InvariantViolationError("plan_uncompensated_fault", msg)
2155
+ undo_ops, verify_probes = tpl.build(fault, nodes)
2156
+ return fault.model_copy(update={"undo_ops": undo_ops, "verify_probes": verify_probes})