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.
- mayhem/agent/__init__.py +1 -0
- mayhem/agent/cli.py +36 -0
- mayhem/agents/__init__.py +1 -0
- mayhem/agents/capabilities.py +106 -0
- mayhem/agents/executors.py +430 -0
- mayhem/agents/impact.py +729 -0
- mayhem/agents/lease_client.py +141 -0
- mayhem/agents/probes.py +284 -0
- mayhem/agents/protocol.py +134 -0
- mayhem/agents/server.py +281 -0
- mayhem/agents/sinks.py +60 -0
- mayhem/agents/transports.py +134 -0
- mayhem/agents/watchdog.py +140 -0
- mayhem/cli/__init__.py +11 -0
- mayhem/cli/app.py +154 -0
- mayhem/cli/campaign.py +496 -0
- mayhem/cli/config_cmd.py +47 -0
- mayhem/cli/context.py +23 -0
- mayhem/cli/dependency.py +429 -0
- mayhem/cli/exit_codes.py +24 -0
- mayhem/cli/experiment.py +24 -0
- mayhem/cli/lifecycle.py +805 -0
- mayhem/cli/resolver.py +72 -0
- mayhem/cli/services.py +459 -0
- mayhem/cli/style.py +101 -0
- mayhem/cli/toolkit.py +41 -0
- mayhem/cli/topology.py +127 -0
- mayhem/config.py +208 -0
- mayhem/controller/__init__.py +1 -0
- mayhem/controller/compensation.py +2156 -0
- mayhem/controller/executor.py +1719 -0
- mayhem/controller/janitor.py +196 -0
- mayhem/controller/observability_collector.py +382 -0
- mayhem/controller/observations.py +102 -0
- mayhem/controller/planner.py +715 -0
- mayhem/controller/recovery.py +245 -0
- mayhem/controller/resilience_report.py +585 -0
- mayhem/controller/resource_manager.py +457 -0
- mayhem/controller/safety.py +392 -0
- mayhem/domain/__init__.py +6 -0
- mayhem/domain/campaigns.py +118 -0
- mayhem/domain/cancellation.py +110 -0
- mayhem/domain/candidates.py +101 -0
- mayhem/domain/capabilities.py +86 -0
- mayhem/domain/catalog.py +727 -0
- mayhem/domain/checks.py +173 -0
- mayhem/domain/common.py +104 -0
- mayhem/domain/coverage.py +106 -0
- mayhem/domain/decisions.py +57 -0
- mayhem/domain/errors.py +87 -0
- mayhem/domain/events.py +61 -0
- mayhem/domain/execution_context.py +120 -0
- mayhem/domain/execution_loci.py +94 -0
- mayhem/domain/experiments.py +370 -0
- mayhem/domain/faults.py +239 -0
- mayhem/domain/identity.py +200 -0
- mayhem/domain/k8s_adapter.py +132 -0
- mayhem/domain/leases.py +186 -0
- mayhem/domain/load_strategy.py +98 -0
- mayhem/domain/m5_campaign.py +120 -0
- mayhem/domain/maniac.py +93 -0
- mayhem/domain/observability.py +146 -0
- mayhem/domain/outcomes.py +92 -0
- mayhem/domain/remote_agent_interface.py +70 -0
- mayhem/domain/resources.py +245 -0
- mayhem/domain/risks.py +61 -0
- mayhem/domain/run_outcome.py +146 -0
- mayhem/domain/runtime_adapter.py +256 -0
- mayhem/domain/success.py +329 -0
- mayhem/domain/topology.py +452 -0
- mayhem/infra/__init__.py +1 -0
- mayhem/infra/campaign_engine.py +205 -0
- mayhem/infra/candidate_gates.py +124 -0
- mayhem/infra/candidate_generator.py +110 -0
- mayhem/infra/coverage_repository.py +101 -0
- mayhem/infra/lease_repository.py +129 -0
- mayhem/infra/maniac.py +103 -0
- mayhem/infra/migrations.py +596 -0
- mayhem/infra/migrator.py +149 -0
- mayhem/infra/report.py +227 -0
- mayhem/infra/store.py +200 -0
- mayhem/py.typed +0 -0
- mayhem/spec.py +52 -0
- mayhem/toolkit/__init__.py +1 -0
- mayhem/toolkit/fingerprint.py +69 -0
- mayhem/toolkit/hashing.py +32 -0
- mayhem/toolkit/manifests/docker.yaml +11 -0
- mayhem/toolkit/manifests/podman.yaml +11 -0
- mayhem/toolkit/manifests/stress-ng.yaml +11 -0
- mayhem/toolkit/manifests/tc-netem.yaml +11 -0
- mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
- mayhem/toolkit/registry.py +185 -0
- mayhem/toolkit/tool_runner.py +129 -0
- mayhem/topology/__init__.py +10 -0
- mayhem/topology/providers/__init__.py +0 -0
- mayhem/topology/providers/adapter_registry.py +60 -0
- mayhem/topology/providers/base.py +31 -0
- mayhem/topology/providers/compose.py +207 -0
- mayhem/topology/providers/docker_adapter.py +277 -0
- mayhem/topology/providers/docker_runtime.py +461 -0
- mayhem/topology/providers/podman_adapter.py +328 -0
- mayhem/topology/resolve.py +196 -0
- mayhem/topology/service.py +158 -0
- mayhem_cli-0.5.1.dist-info/METADATA +555 -0
- mayhem_cli-0.5.1.dist-info/RECORD +107 -0
- mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
- 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})
|