mayhem-cli 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,729 @@
1
+ """Fault impact gate — can this fault actually perturb this container?
2
+
3
+ Faults are functional only when the runtime the flavour injects into is real:
4
+ `net.latency` needs ``tc`` *and* the ``NET_ADMIN`` capability to add a netem
5
+ qdisc inside the container, `http.error_injection` needs ``iptables``, payload
6
+ faults need a Python interpreter, `net.load` needs a ``k6`` binary **on the
7
+ drill host** (the load generator drives the container from outside; it is not
8
+ a container package). A fault whose tooling is absent still *completes* (inject
9
+ exits 0) but produces zero perturbation — the run degrades into a survey
10
+ instead of a drill.
11
+
12
+ This module probes the live container once (read-only), decides per family
13
+ whether the injection can physically take effect, and lets the planner gate
14
+ refuse definitively inert faults before they ever execute. Requirements marked
15
+ ``host=True`` are resolved against the drill host instead of the container —
16
+ the container-tooling probe and ``mayhem dependency install`` never see them.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ import json
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ from dataclasses import dataclass
27
+ from typing import TYPE_CHECKING
28
+
29
+ from mayhem.toolkit.tool_runner import ToolError, run_tool
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Sequence
33
+
34
+ from mayhem.domain.experiments import ExecutionPlan, PlannedFault
35
+ from mayhem.domain.topology import TopologyGraph
36
+
37
+ _CAP_BITS: dict[str, int] = {
38
+ "NET_ADMIN": 12,
39
+ "SYS_TIME": 25,
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FaultRequirement:
45
+ """Runtime capability a fault family needs inside the target container.
46
+
47
+ ``host=True`` moves the ``bins`` check to the drill host (e.g. ``k6`` for
48
+ ``net.load`` — host-side load generator, not container tooling). The
49
+ container probe and ``mayhem dependency install`` ignore host requirements.
50
+ """
51
+
52
+ bins: frozenset[str] = frozenset()
53
+ caps: frozenset[str] = frozenset()
54
+ need_root: bool = False
55
+ host: bool = False
56
+
57
+ def describe(self) -> str:
58
+ parts = [f"bin({b}{'@host' if self.host else ''})" for b in sorted(self.bins)]
59
+ parts += [f"cap({c})" for c in sorted(self.caps)]
60
+ if self.need_root:
61
+ parts.append("uid(0)")
62
+ return ", ".join(parts) if parts else "none"
63
+
64
+
65
+ REQUIREMENTS: dict[str, FaultRequirement] = {
66
+ "proc.pause": FaultRequirement(bins=frozenset({"kill"})),
67
+ "mem.exhaust": FaultRequirement(bins=frozenset({"python"})),
68
+ "mem.leak": FaultRequirement(bins=frozenset({"python"})),
69
+ "cpu.saturate": FaultRequirement(bins=frozenset({"python"})),
70
+ "fs.fill": FaultRequirement(bins=frozenset({"python"})),
71
+ "fs.inode_exhaust": FaultRequirement(bins=frozenset({"python"})),
72
+ "fs.io_stress": FaultRequirement(bins=frozenset({"python"})),
73
+ "fs.read_only": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
74
+ "fd.exhaust": FaultRequirement(bins=frozenset({"python"})),
75
+ "load.spike": FaultRequirement(bins=frozenset({"python"})),
76
+ "fuzz.protocol_abuse": FaultRequirement(bins=frozenset({"python"})),
77
+ "http.latency": FaultRequirement(bins=frozenset({"python"})),
78
+ "db.connection_exhaust": FaultRequirement(bins=frozenset({"python"})),
79
+ "dependency.rate_limit": FaultRequirement(bins=frozenset({"python"})),
80
+ "net.latency": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
81
+ "net.packet_loss": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
82
+ "net.bandwidth": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
83
+ "net.partition": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
84
+ "net.reorder": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
85
+ "net.duplicate": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
86
+ "dependency.timeout": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
87
+ "net.load": FaultRequirement(bins=frozenset({"k6"}), host=True),
88
+ "http.error_injection": FaultRequirement(
89
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
90
+ ),
91
+ "net.connection_reset": FaultRequirement(
92
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
93
+ ),
94
+ "net.connection_refuse": FaultRequirement(
95
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
96
+ ),
97
+ "db.slow_query": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
98
+ "db.query_error": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
99
+ "dns.timeout": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
100
+ "dns.servfail": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
101
+ "tls.handshake_failure": FaultRequirement(
102
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
103
+ ),
104
+ "dependency.block": FaultRequirement(
105
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
106
+ ),
107
+ "dependency.flap": FaultRequirement(
108
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
109
+ ),
110
+ "dependency.connection_refuse": FaultRequirement(
111
+ bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
112
+ ),
113
+ "dns.resolve_delay": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
114
+ "dns.nxdomain": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
115
+ "tls.certificate_expired": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
116
+ "clock.skew": FaultRequirement(bins=frozenset({"date"}), caps=frozenset({"SYS_TIME"})),
117
+ }
118
+
119
+ # Engine-addressed faults (kill/stop/start the runtime itself, or drive the
120
+ # host engine: ``update --cpus``, restart cadence): no in-image tooling is
121
+ # needed, so they are never gated on container binaries — the engine being
122
+ # reachable and the container being resolvable is sufficient.
123
+ _ENGINE_FAULTS = frozenset(
124
+ {
125
+ "container.kill",
126
+ "container.restart",
127
+ "container.pause",
128
+ "process.crash_loop",
129
+ "cpu.throttle",
130
+ "node.service_stop",
131
+ }
132
+ )
133
+
134
+ #: Faults whose recovery probe cannot see the live perturbation. The generic
135
+ #: "recovery probe inverted during the window" observation is therefore
136
+ #: inconclusive for these families, and must not be read as "no impact".
137
+ #: Terminate faults (process.stop/kill) blind the probe too: the post-kill
138
+ #: world (process gone) is indistinguishable from the post-recovery world, so
139
+ #: the observation is inconclusive rather than "no impact".
140
+ OBSERVATION_BLIND: frozenset[str] = frozenset({"proc.pause", "process.stop", "process.kill"})
141
+
142
+ #: Package-manager binaries probed so the CLI can tell the user — and offer to
143
+ #: run — the right ``<pm> install`` command when a fault's tooling is missing.
144
+ #: ``package_manager()`` resolves the first present manager (priority order).
145
+ _PACKAGE_MANAGERS = ("apt-get", "apk", "dnf", "yum", "microdnf", "zypper")
146
+ #: In-container binaries a fault family may need. ``k6`` is deliberately absent:
147
+ #: ``net.load`` drives the container from the drill host, so it is gated there
148
+ #: (``host=True``) and never shows up in container dependency management.
149
+ _PROBE_BINS: tuple[str, ...] = (
150
+ "kill",
151
+ "tc",
152
+ "iptables",
153
+ "python",
154
+ "python3",
155
+ "date",
156
+ "sh",
157
+ *_PACKAGE_MANAGERS,
158
+ )
159
+
160
+ #: Host-side tooling required by some fault family, checked once via
161
+ #: ``shutil.which`` (cached). Container ``mayhem dependency`` only reports these,
162
+ #: never installs them.
163
+ _HOST_TOOL_BINS: tuple[str, ...] = ("k6",)
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class ContainerRuntime:
168
+ """Probed, read-only view of one live container."""
169
+
170
+ container: str
171
+ engine: str
172
+ bins: dict[str, bool]
173
+ uid: int | None
174
+ cap_eff: int = 0
175
+
176
+ def has_bin(self, name: str) -> bool:
177
+ if name == "python":
178
+ return bool(self.bins.get("python") or self.bins.get("python3"))
179
+ return bool(self.bins.get(name))
180
+
181
+ def has_cap(self, name: str) -> bool:
182
+ bit = _CAP_BITS.get(name)
183
+ return bit is not None and bool(self.cap_eff & (1 << bit))
184
+
185
+ def package_manager(self) -> str | None:
186
+ """First package-manager binary present, in priority order.
187
+
188
+ ``apt-get`` (Debian/Ubuntu), ``apk`` (Alpine), ``dnf`` (Fedora/RHEL9),
189
+ ``yum`` (RHEL7/8), ``microdnf`` (minimal RHEL/UBI), ``zypper`` (SUSE).
190
+ """
191
+ for pm in _PACKAGE_MANAGERS:
192
+ if self.has_bin(pm):
193
+ return pm
194
+ return None
195
+
196
+
197
+ _PROBE_SH = (
198
+ "printf 'BINS'"
199
+ + "".join(
200
+ f"; printf ' {b}:%s' \"$(command -v {b} >/dev/null 2>&1 && echo 1 || echo 0)\""
201
+ for b in _PROBE_BINS
202
+ )
203
+ + "; echo; printf 'UID %s\\n' \"$(id -u 2>/dev/null || echo -1)\";"
204
+ + " printf 'CAPEFF %s\\n' \"$(awk '/CapEff/{print $2}' /proc/1/status 2>/dev/null || echo 0)\""
205
+ )
206
+
207
+ _BINS_RE = re.compile(r"BINS((?:\s+[a-z0-9-]+:[01])+)")
208
+ _UID_RE = re.compile(r"UID (\d+)")
209
+ _CAPEFF_RE = re.compile(r"CAPEFF ([0-9a-fA-F]+)")
210
+
211
+
212
+ def probe_container_runtime(
213
+ engine: str, container: str, timeout_s: int = 10
214
+ ) -> ContainerRuntime | None:
215
+ """One read-only ``engine exec`` returning the container's runtime surface.
216
+
217
+ Returns ``None`` when the engine or container is unreachable — the caller
218
+ treats an unreachable runtime as "cannot prove inert", never as a pass.
219
+ """
220
+ try:
221
+ result = run_tool(
222
+ [engine, "exec", container, "sh", "-c", _PROBE_SH],
223
+ timeout_s=timeout_s,
224
+ )
225
+ except ToolError:
226
+ return None
227
+ if not result.succeeded:
228
+ return None
229
+ return parse_runtime_output(engine, container, result.stdout)
230
+
231
+
232
+ def parse_runtime_output(engine: str, container: str, text: str) -> ContainerRuntime | None:
233
+ m = _BINS_RE.search(text)
234
+ if m is None:
235
+ return None
236
+ bins = {pair.split(":")[0]: pair.split(":")[1] == "1" for pair in m.group(1).split()}
237
+ uid_m = _UID_RE.search(text)
238
+ cap_m = _CAPEFF_RE.search(text)
239
+ cap_eff = int(cap_m.group(1), 16) if cap_m else 0
240
+ uid = int(uid_m.group(1)) if uid_m else None
241
+ return ContainerRuntime(container=container, engine=engine, bins=bins, uid=uid, cap_eff=cap_eff)
242
+
243
+
244
+ @functools.lru_cache(maxsize=32)
245
+ def _host_bin_present(name: str) -> bool:
246
+ """Cached host ``which`` check (host tooling, e.g. k6 for net.load)."""
247
+ return shutil.which(name) is not None
248
+
249
+
250
+ def host_tooling_gaps(plan: ExecutionPlan) -> list[str]:
251
+ """Host-side binaries the plan needs but the drill host lacks (e.g. k6).
252
+
253
+ Container ``mayhem dependency`` reports these but never installs them —
254
+ the load generator lives on the host, not in a distro package.
255
+ """
256
+ needed: set[str] = set()
257
+ for step in plan.steps:
258
+ fault = step.fault
259
+ if fault is None:
260
+ continue
261
+ requirement = REQUIREMENTS.get(fault.fault_id)
262
+ if requirement is not None and requirement.host:
263
+ needed.update(requirement.bins)
264
+ return sorted(b for b in needed if not _host_bin_present(b))
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class GateVerdict:
269
+ """Per fault/container: can the injection physically take effect?"""
270
+
271
+ fault_id: str
272
+ container: str
273
+ impact_possible: bool
274
+ missing: tuple[str, ...] = ()
275
+ probed: bool = True
276
+ host: bool = False
277
+ note: str = ""
278
+
279
+
280
+ @functools.lru_cache(maxsize=8)
281
+ def _engine_is_rootless(engine: str) -> bool:
282
+ """True when the container engine runs rootless (userns).
283
+
284
+ A rootless engine maps container roots onto unprivileged host uids inside a
285
+ user namespace. That makes two fault families *physically* impossible no
286
+ matter what the container reports:
287
+
288
+ * ``CAP_SYS_TIME`` in the container's ``CapEff`` is only meaningful inside
289
+ its userns. Setting the host-global ``CLOCK_REALTIME`` (``clock.skew`` →
290
+ ``date -u -s``) needs ``CAP_SYS_TIME`` in the *initial* user namespace,
291
+ which a rootless engine never grants — time namespaces do not virtualize
292
+ the realtime clock. The kernel returns ``EPERM`` (``login: cannot set
293
+ date: Operation not permitted``) even when ``CAP_SYS_TIME`` is set.
294
+
295
+ The container has no way to fake a $(date) read; the fault is inert by
296
+ construction. Probing ``CapEff`` is not enough: the bit reads as present.
297
+
298
+ Detection reuses the engine's own ``info`` output. Best-effort — any
299
+ failure (engine missing, odd output) returns False (assume rootful), so a
300
+ detection hiccup never *blocks* a fault that could work.
301
+ """
302
+ try:
303
+ result = subprocess.run(
304
+ [engine, "info", "--format", "json"],
305
+ capture_output=True,
306
+ text=True,
307
+ timeout=5,
308
+ check=False,
309
+ )
310
+ except (OSError, subprocess.TimeoutExpired):
311
+ return False
312
+ try:
313
+ info = json.loads(result.stdout)
314
+ except (json.JSONDecodeError, ValueError):
315
+ return False
316
+ host = info.get("host") or {}
317
+ security = host.get("security") or {}
318
+ if isinstance(security, dict) and "rootless" in security:
319
+ return bool(security["rootless"])
320
+ # docker: rootless mode surfaces as a userns/rootless security option
321
+ # (podman nests it under host.security; docker keeps access at top level).
322
+ options = host.get("securityOptions") or info.get("SecurityOptions") or []
323
+ return "userns" in " ".join(options) or "rootless" in " ".join(options)
324
+
325
+
326
+ def _gate_sys_time_for_rootless(
327
+ fault_id: str, container: str, engine: str, requirement: FaultRequirement
328
+ ) -> GateVerdict | None:
329
+ """Rootless engines cannot set CLOCK_REALTIME: reject SYS_TIME faults.
330
+
331
+ The container reports ``CAP_SYS_TIME`` (its userns grants the bit) but the
332
+ realtime clock is host-global — setting it demands host-root privilege a
333
+ rootless engine never provides. Treat such a fault as inert (probed) so the
334
+ execution layer bypasses it fail-safe instead of running a doomed inject.
335
+ Returns ``None`` when the fault is unaffected by rootlessness.
336
+ """
337
+ if "SYS_TIME" not in requirement.caps or not _engine_is_rootless(engine):
338
+ return None
339
+ return GateVerdict(
340
+ fault_id,
341
+ container,
342
+ False,
343
+ probed=True,
344
+ note=(
345
+ "rootless engine: container CAP_SYS_TIME is namespaced; "
346
+ "setting the host CLOCK_REALTIME is denied (EPERM)"
347
+ ),
348
+ )
349
+
350
+
351
+ def gate_fault(
352
+ fault_id: str,
353
+ container: str,
354
+ engine: str,
355
+ runtime: ContainerRuntime | None = None,
356
+ ) -> GateVerdict:
357
+ """Verdict for one fault against one (optionally pre-probed) container."""
358
+ if fault_id in _ENGINE_FAULTS:
359
+ return GateVerdict(fault_id, container, True, note="engine-addressed fault")
360
+ requirement = REQUIREMENTS.get(fault_id)
361
+ if requirement is None:
362
+ return GateVerdict(fault_id, container, True, note="no in-image tooling required")
363
+ if requirement.host:
364
+ return _gate_host_requirement(fault_id, container, requirement)
365
+ rootless_gate = _gate_sys_time_for_rootless(fault_id, container, engine, requirement)
366
+ if rootless_gate is not None:
367
+ return rootless_gate
368
+ run = runtime if runtime is not None else probe_container_runtime(engine, container)
369
+ if run is None:
370
+ return GateVerdict(
371
+ fault_id,
372
+ container,
373
+ False,
374
+ probed=False,
375
+ note="runtime unreachable — cannot prove impact possible",
376
+ )
377
+ missing: list[str] = []
378
+ for req_bin in sorted(requirement.bins):
379
+ if not run.has_bin(req_bin):
380
+ missing.append(f"bin:{req_bin}")
381
+ for req_cap in sorted(requirement.caps):
382
+ if not run.has_cap(req_cap):
383
+ missing.append(f"cap:{req_cap}")
384
+ if requirement.need_root and run.uid not in (0, None):
385
+ missing.append("uid(0)")
386
+ possible = not missing
387
+ note = "" if possible else f"missing {', '.join(missing)}"
388
+ return GateVerdict(fault_id, container, possible, tuple(missing), note=note)
389
+
390
+
391
+ def _gate_host_requirement(
392
+ fault_id: str, container: str, requirement: FaultRequirement
393
+ ) -> GateVerdict:
394
+ """Host-addressed requirements (e.g. net.load → k6 on the drill host).
395
+
396
+ The container is irrelevant here: whether the fault perturbs depends on
397
+ host-side attack tooling. No container probe runs, so nothing about this
398
+ verdict can leak into container dependency planning.
399
+ """
400
+ missing = [f"bin:{b}" for b in sorted(requirement.bins) if not _host_bin_present(b)]
401
+ possible = not missing
402
+ note = "" if possible else f"missing host tooling: {', '.join(missing)}"
403
+ return GateVerdict(fault_id, container, possible, tuple(missing), host=True, note=note)
404
+
405
+
406
+ def _container_for(graph: TopologyGraph, fault: PlannedFault) -> str | None:
407
+ """First resolvable container the fault would inject into (for gating)."""
408
+ for target in fault.targets:
409
+ for node_id in target.node_ids:
410
+ node = graph.by_id(node_id)
411
+ if node is None:
412
+ continue
413
+ name = getattr(node, "container_name", None)
414
+ if isinstance(name, str) and name:
415
+ return name
416
+ return None
417
+
418
+
419
+ def scan_plan_faults(
420
+ plan: ExecutionPlan, graph: TopologyGraph, engine: str
421
+ ) -> tuple[list[GateVerdict], bool]:
422
+ """Gate every fault in ``plan`` against its live container.
423
+
424
+ Returns ``(verdicts, engine_probed)``. ``engine_probed`` is False when the
425
+ engine could not be reached at all; callers must not fail the run on that.
426
+ ``verdicts`` holds one entry per (fault, container) pair, with
427
+ ``impact_possible=False`` only when the live container proved the fault's
428
+ tooling is absent.
429
+ """
430
+ seen: dict[tuple[str, str], ContainerRuntime | None] = {}
431
+ verdicts: list[GateVerdict] = []
432
+ engine_probed = False
433
+ for step in plan.steps:
434
+ fault = step.fault
435
+ if fault is None:
436
+ continue
437
+ container = _container_for(graph, fault)
438
+ if container is None:
439
+ verdicts.append(
440
+ GateVerdict(
441
+ fault.fault_id,
442
+ "?",
443
+ False,
444
+ probed=False,
445
+ note="no container target resolved — nothing to gate on",
446
+ )
447
+ )
448
+ continue
449
+ requirement = REQUIREMENTS.get(fault.fault_id)
450
+ if requirement is not None and requirement.host:
451
+ # Host-addressed fault (net.load → k6): the container runtime is
452
+ # irrelevant, so we never probe it and never count it as engine
453
+ # reachability.
454
+ verdicts.append(gate_fault(fault.fault_id, container, engine))
455
+ continue
456
+ key = (engine, container)
457
+ if key in seen:
458
+ runtime = seen[key]
459
+ else:
460
+ runtime = probe_container_runtime(engine, container)
461
+ seen[key] = runtime
462
+ if runtime is not None:
463
+ engine_probed = True
464
+ verdicts.append(gate_fault(fault.fault_id, container, engine, runtime))
465
+ return verdicts, engine_probed
466
+
467
+
468
+ def bypass_from_verdicts(
469
+ verdicts: Sequence[GateVerdict],
470
+ ) -> dict[tuple[str, str], str]:
471
+ """Verified-inert injections → ``{(fault_id, container): reason}``.
472
+
473
+ Fail-safe contract: a fault whose tooling is **proven absent** in its
474
+ target container is bypassed at execution time (logged as ``bypass due to
475
+ <reason>``) instead of aborting the whole run. Only ``probed`` verdicts
476
+ count — an unreachable runtime cannot be proven inert, so those faults are
477
+ still attempted.
478
+ """
479
+ return {
480
+ (v.fault_id, v.container): v.note or v.fault_id
481
+ for v in verdicts
482
+ if v.probed and not v.impact_possible
483
+ }
484
+
485
+
486
+ # ── Missing-tooling remediation ─────────────────────────────────────────────
487
+ # The gate marks a fault inert when its in-image tooling is absent. The bins a
488
+ # fault family needs map onto distro packages; the right <pm> is detected from
489
+ # the live container (package_manager()). Bare-metal knowledge:
490
+ # python → python3 (the probe treats python|python3 as one requirement)
491
+ # tc → iproute2 (Debian/Alpine/SUSE) / iproute (RHEL-family)
492
+ # kill → procps(-ng) (kill(1) lives in the process-utils package)
493
+ # date → coreutils
494
+ # sh → dash / busybox / bash depending on the family
495
+ # ``k6`` ships in no distro repo (net.load needs the Grafana k6 binary), so it
496
+ # is reported as a manual step, never auto-installed.
497
+ _PM_PACKAGES: dict[str, dict[str, str]] = {
498
+ "python": dict.fromkeys(_PACKAGE_MANAGERS, "python3"),
499
+ "tc": {
500
+ "apt-get": "iproute2",
501
+ "apk": "iproute2",
502
+ "dnf": "iproute",
503
+ "yum": "iproute",
504
+ "microdnf": "iproute",
505
+ "zypper": "iproute2",
506
+ },
507
+ "iptables": dict.fromkeys(_PACKAGE_MANAGERS, "iptables"),
508
+ "kill": {
509
+ "apt-get": "procps",
510
+ "apk": "procps",
511
+ "dnf": "procps-ng",
512
+ "yum": "procps-ng",
513
+ "microdnf": "procps-ng",
514
+ "zypper": "procps",
515
+ },
516
+ "date": dict.fromkeys(_PACKAGE_MANAGERS, "coreutils"),
517
+ "sh": {
518
+ "apt-get": "dash",
519
+ "apk": "busybox",
520
+ "dnf": "bash",
521
+ "yum": "bash",
522
+ "microdnf": "bash",
523
+ "zypper": "bash",
524
+ },
525
+ }
526
+ #: Bins whose package cannot be installed from a distro repo. Reported as a
527
+ #: manual step with guidance instead of being auto-installed. (Host-side tools
528
+ #: like k6 are not listed here — they are covered by ``host=True`` gating and
529
+ #: reported via ``host_tooling_gaps()``, never as container packages.)
530
+ _MANUAL_BINS: dict[str, str] = {}
531
+
532
+ #: Engine-manager → ``<pm> install`` sub-command shape. ``apt-get`` also needs
533
+ #: an ``update`` pass first (best-effort; a missing index fails loudly).
534
+ #: ``microdnf`` only exists in minimal RHEL-family images and is its own binary
535
+ #: (not a dnf flag).
536
+
537
+
538
+ def _install_argv(
539
+ engine: str, container: str, pm: str, packages: Sequence[str], *, as_root: bool
540
+ ) -> list[list[str]]:
541
+ """Exec argv list that installs ``packages`` inside ``container``.
542
+
543
+ Each inner list is one standalone ``engine exec`` invocation so the CLI can
544
+ report per-command results. ``as_root`` prefixes ``--user 0`` when the probe
545
+ showed the container's default user is non-root (package managers need
546
+ write access to system dirs).
547
+ """
548
+ prefix = [engine, "exec", container]
549
+ if as_root:
550
+ prefix += ["--user", "0"]
551
+ if pm == "apt-get":
552
+ return [
553
+ [*prefix, "apt-get", "update"],
554
+ [*prefix, "apt-get", "install", "-y", *packages],
555
+ ]
556
+ if pm == "apk":
557
+ return [[*prefix, "apk", "add", "--no-cache", *packages]]
558
+ if pm in ("dnf", "yum", "microdnf"):
559
+ return [[*prefix, pm, "install", "-y", *packages]]
560
+ if pm == "zypper":
561
+ return [[*prefix, "zypper", "-n", "install", *packages]]
562
+ return []
563
+
564
+
565
+ @dataclass(frozen=True)
566
+ class ContainerDependencyPlan:
567
+ """Everything the CLI needs to restore a container's fault tooling."""
568
+
569
+ container: str
570
+ engine: str
571
+ pm: str | None
572
+ #: Installable package names, deduplicated and sorted ('' when pm is None).
573
+ packages: tuple[str, ...] = ()
574
+ #: The missing bin names those packages provide (verification probe targets).
575
+ bins: tuple[str, ...] = ()
576
+ #: Bins with no auto-installable package — printed as guidance
577
+ #: (e.g. a container with no package manager at all).
578
+ manual: tuple[str, ...] = ()
579
+ #: cap:* requirements the gate flagged — must be granted at runtime, e.g.
580
+ #: ``podman run --cap-add=NET_ADMIN``; never installable in-image.
581
+ caps_missing: tuple[str, ...] = ()
582
+ #: A gated fault also needs uid(0); package installs are attempted as root.
583
+ need_root: bool = False
584
+
585
+ @property
586
+ def installable(self) -> bool:
587
+ return bool(self.packages)
588
+
589
+ @property
590
+ def gaps_remain(self) -> bool:
591
+ return not (self.installable or self.manual or self.caps_missing or self.need_root)
592
+
593
+ def install_argv(self) -> list[list[str]]:
594
+ if self.pm is None or not self.packages:
595
+ return []
596
+ return _install_argv(
597
+ self.engine, self.container, self.pm, self.packages, as_root=self.need_root
598
+ )
599
+
600
+
601
+ def dependency_plan(
602
+ plan: ExecutionPlan, graph: TopologyGraph, engine: str
603
+ ) -> list[ContainerDependencyPlan]:
604
+ """Union the missing tooling over every planned fault, per container.
605
+
606
+ One plan per container that hosts at least one gated-out fault. The plan
607
+ carries the detected package manager, the installable package list, and the
608
+ non-installable gaps (caps need a runtime flag, a container without a
609
+ package manager can only be re-provisioned at image build time, uid(0)
610
+ faults need a root exec). Host-addressed faults (``net.load`` → k6 on the
611
+ drill host) are outside container dependency management — see
612
+ ``host_tooling_gaps()``. Containers that are healthy for every planned
613
+ fault — or unreachable — produce no entry.
614
+ """
615
+ runtimes: dict[str, ContainerRuntime | None] = {}
616
+ missing_by: dict[str, set[str]] = {}
617
+ for step in plan.steps:
618
+ fault = step.fault
619
+ if fault is None:
620
+ continue
621
+ requirement = REQUIREMENTS.get(fault.fault_id)
622
+ if requirement is not None and requirement.host:
623
+ continue # host-addressed tooling (k6) — not a container dependency
624
+ container = _container_for(graph, fault)
625
+ if container is None:
626
+ continue
627
+ if container not in runtimes:
628
+ runtimes[container] = probe_container_runtime(engine, container)
629
+ run = runtimes[container]
630
+ if run is None:
631
+ continue # unreachable — cannot plan tooling for it
632
+ verdict = gate_fault(fault.fault_id, container, engine, run)
633
+ if verdict.impact_possible:
634
+ continue
635
+ missing_by.setdefault(container, set()).update(verdict.missing)
636
+ plans: list[ContainerDependencyPlan] = []
637
+ for container in sorted(missing_by):
638
+ run = runtimes[container]
639
+ missing = sorted(missing_by[container])
640
+ pm = run.package_manager() if run else None
641
+ packages: set[str] = set()
642
+ bin_map: dict[str, str] = {}
643
+ manual: list[str] = []
644
+ caps_missing = [m for m in missing if m.startswith("cap:")]
645
+ need_root = "uid(0)" in missing
646
+ for item in missing:
647
+ if not item.startswith("bin:"):
648
+ continue
649
+ bin_name = item.split(":", 1)[1]
650
+ mapping = _PM_PACKAGES.get(bin_name, {})
651
+ pkg = mapping.get(pm) if pm else None
652
+ if pkg is not None:
653
+ packages.add(pkg)
654
+ bin_map[bin_name] = pkg
655
+ continue
656
+ manual.append(bin_name)
657
+ plans.append(
658
+ ContainerDependencyPlan(
659
+ container=container,
660
+ engine=engine,
661
+ pm=pm,
662
+ packages=tuple(sorted(packages)),
663
+ bins=tuple(sorted(bin_map)),
664
+ manual=tuple(sorted(set(manual))),
665
+ caps_missing=tuple(caps_missing),
666
+ need_root=need_root,
667
+ )
668
+ )
669
+ return plans
670
+
671
+
672
+ @dataclass(frozen=True)
673
+ class ContainerCompilePlan:
674
+ """Offline (no runtime probe) tooling a compose service must carry.
675
+
676
+ Produced by ``compile_requirements``: the union of every fault family's
677
+ requirements for the container across the whole drill plan. Unlike
678
+ ``ContainerDependencyPlan`` this is state-independent — it is the join of
679
+ the plan, not a diff against a live container. Host-addressed tooling
680
+ (``net.load`` → k6) never lands here.
681
+ """
682
+
683
+ container: str
684
+ #: Bins the planned faults require *and* that map to distro packages.
685
+ bins: tuple[str, ...] = ()
686
+ #: Capability names the service must be started with (bare, e.g.
687
+ #: ``"NET_ADMIN"`` — maps straight onto compose ``cap_add:``).
688
+ caps: tuple[str, ...] = ()
689
+ #: Bins with no mapped distro package — reported, never compile-able.
690
+ manual: tuple[str, ...] = ()
691
+
692
+
693
+ def compile_requirements(plan: ExecutionPlan, graph: TopologyGraph) -> list[ContainerCompilePlan]:
694
+ """Union the tooling requirements per container over the whole plan.
695
+
696
+ The compose compiler needs the *requirement* set, not the diff against a
697
+ possibly-unstarted stack: the generated ``docker-compose.mayhem.yml`` must
698
+ carry the tooling before any container runs. Host-addressed faults are
699
+ skipped — they can never be compiled into a service definition.
700
+ """
701
+ bins_by: dict[str, set[str]] = {}
702
+ caps_by: dict[str, set[str]] = {}
703
+ manual_by: dict[str, set[str]] = {}
704
+ for step in plan.steps:
705
+ fault = step.fault
706
+ if fault is None:
707
+ continue
708
+ requirement = REQUIREMENTS.get(fault.fault_id)
709
+ if requirement is None or requirement.host:
710
+ continue
711
+ container = _container_for(graph, fault)
712
+ if container is None:
713
+ continue
714
+ manual = {b for b in requirement.bins if b not in _PM_PACKAGES}
715
+ bins = {b for b in requirement.bins if b in _PM_PACKAGES}
716
+ bins_by.setdefault(container, set()).update(bins)
717
+ manual_by.setdefault(container, set()).update(manual)
718
+ caps_by.setdefault(container, set()).update(requirement.caps)
719
+ plans: list[ContainerCompilePlan] = []
720
+ for container in sorted(set(bins_by) | set(caps_by) | set(manual_by)):
721
+ plans.append(
722
+ ContainerCompilePlan(
723
+ container=container,
724
+ bins=tuple(sorted(bins_by.get(container, ()))),
725
+ caps=tuple(sorted(caps_by.get(container, ()))),
726
+ manual=tuple(sorted(manual_by.get(container, ()))),
727
+ )
728
+ )
729
+ return plans