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,461 @@
1
+ """ContainerRuntimeProvider — live truth via CLI, one code path (ADR-0006).
2
+
3
+ Docker and Podman share identical ``ps --format json`` semantics; the engine
4
+ binary is the only difference. CLI wrappers, not SDKs, so both engines and a
5
+ remote context behave identically. Import of this module is confined to
6
+ ``topology/providers`` per the import rule ([ADR-0013]).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ from typing import Any
15
+
16
+ from pydantic.networks import IPvAnyAddress
17
+
18
+ from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
19
+ from mayhem.domain.topology import (
20
+ ContainerNode,
21
+ Edge,
22
+ EdgeKind,
23
+ HostNode,
24
+ PortBinding,
25
+ ProcessNode,
26
+ )
27
+ from mayhem.topology.providers.base import PartialGraph
28
+
29
+
30
+ def _inspect_pid(engine: str, container_id: str) -> int | None:
31
+ """Query the main PID of a running container via ``engine inspect``."""
32
+ try:
33
+ out = subprocess.run(
34
+ [engine, "inspect", "--format", "{{.State.Pid}}", container_id],
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=5,
38
+ )
39
+ pid = int(out.stdout.strip())
40
+ return pid if pid > 0 else None
41
+ except (subprocess.TimeoutExpired, OSError, ValueError):
42
+ return None
43
+
44
+
45
+ def _inspect_name(engine: str, container_id: str) -> str:
46
+ """Query the canonical container name via ``engine inspect``.
47
+
48
+ Docker returns ``/container-name``; we strip the leading ``/``.
49
+ """
50
+ try:
51
+ out = subprocess.run(
52
+ [engine, "inspect", "--format", "{{.Name}}", container_id],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=5,
56
+ )
57
+ raw = out.stdout.strip()
58
+ return raw.lstrip("/") if raw else ""
59
+ except (subprocess.TimeoutExpired, OSError, ValueError):
60
+ return ""
61
+
62
+
63
+ def _inspect_meta(engine: str, container_id: str) -> tuple[str, str | None, str | None]:
64
+ """Query name + lifecycle timestamps in a single inspect pass (ADR-M1-2).
65
+
66
+ Returns ``(name, created_at, started_at)`` with the leading ``/`` stripped.
67
+ """
68
+ try:
69
+ out = subprocess.run( # noqa: PLW1510 — expected fire-and-forget inspect
70
+ [
71
+ engine,
72
+ "inspect",
73
+ "--format",
74
+ "{{.Name}}|{{.Created}}|{{.State.StartedAt}}",
75
+ container_id,
76
+ ],
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=5,
80
+ )
81
+ raw = out.stdout.strip()
82
+ name, _, rest = raw.partition("|")
83
+ created, _, started = rest.partition("|")
84
+ return (
85
+ name.lstrip("/"),
86
+ created or None,
87
+ started or None,
88
+ )
89
+ except (subprocess.TimeoutExpired, OSError, ValueError):
90
+ return ("", None, None)
91
+
92
+
93
+ def _container_id(row: dict[str, Any]) -> str:
94
+ """Extract container ID handling both Docker ('ID') and Podman ('Id') keys."""
95
+ return str(row.get("ID") or row.get("Id") or "")
96
+
97
+
98
+ def _container_name(row: dict[str, Any]) -> str:
99
+ """Extract a single container name handling Docker (str) and Podman (list)."""
100
+ names = row.get("Names") or row.get("Name") or ""
101
+ if isinstance(names, list):
102
+ return names[0] if names else ""
103
+ return str(names)
104
+
105
+
106
+ def _ps(engine: str) -> list[dict[str, Any]]:
107
+ out = subprocess.run(
108
+ [engine, "ps", "--format", "json"],
109
+ capture_output=True,
110
+ text=True,
111
+ check=False,
112
+ timeout=15,
113
+ )
114
+ text = out.stdout.strip()
115
+ if not text:
116
+ return []
117
+
118
+ # Podman emits a single JSON array; Docker emits one JSON object per line.
119
+ # Try the array form first — if the whole output parses as a list, extract
120
+ # the dicts from it. Otherwise fall back to line-by-line (NDJSON) parsing.
121
+ try:
122
+ parsed = json.loads(text)
123
+ if isinstance(parsed, list):
124
+ return [item for item in parsed if isinstance(item, dict)]
125
+ except (json.JSONDecodeError, ValueError):
126
+ pass
127
+
128
+ rows: list[dict[str, Any]] = []
129
+ for raw_line in text.splitlines():
130
+ line = raw_line.strip()
131
+ if not line:
132
+ continue
133
+ try:
134
+ row = json.loads(line)
135
+ except json.JSONDecodeError:
136
+ continue # tolerate partial/fragment lines from pretty-printed output
137
+ if isinstance(row, dict):
138
+ rows.append(row)
139
+ return rows
140
+
141
+
142
+ class ContainerRuntimeProvider:
143
+ def __init__(
144
+ self,
145
+ engine: str,
146
+ *,
147
+ filter_project: str | None = None,
148
+ filter_services: tuple[str, ...] | None = None,
149
+ filter_names: tuple[str, ...] | None = None,
150
+ ) -> None:
151
+ self._engine = engine
152
+ self._filter_project = filter_project
153
+ self._filter_services = filter_services
154
+ self._filter_names = filter_names
155
+
156
+ @property
157
+ def id(self) -> str:
158
+ suffix = f"+{self._filter_project}" if self._filter_project else ""
159
+ return f"{self._engine}{suffix}"
160
+
161
+ @classmethod
162
+ def best_effort(cls, engine: str | None = None) -> ContainerRuntimeProvider | None:
163
+ """Return a provider for the requested engine, or whichever is available.
164
+
165
+ *engine* may be ``"docker"`` or ``"podman"``. When *None*, the first
166
+ installed engine wins (docker preferred).
167
+ """
168
+ if engine is not None:
169
+ if shutil.which(engine):
170
+ return cls(engine)
171
+ return None
172
+ for candidate in ("docker", "podman"):
173
+ if shutil.which(candidate):
174
+ return cls(candidate)
175
+ return None
176
+
177
+ def is_available(self) -> bool:
178
+ return shutil.which(self._engine) is not None
179
+
180
+ def filter_by_compose(
181
+ self,
182
+ project: str,
183
+ services: tuple[str, ...] | None = None,
184
+ ) -> None:
185
+ """Scope this provider to containers belonging to a compose project."""
186
+ self._filter_project = project
187
+ self._filter_services = services
188
+
189
+ def filter_by_names(self, names: list[str]) -> None:
190
+ """Scope this provider to containers matching explicit names."""
191
+ self._filter_names = tuple(names)
192
+
193
+ def _filter_rows(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
194
+ """Narrow *rows* to containers matching the active filters.
195
+
196
+ Filters are combined with AND semantics. When no filters are set
197
+ (standalone runtime mode), all rows pass through.
198
+ """
199
+ has_any_filter = (
200
+ self._filter_project is not None
201
+ or self._filter_services is not None
202
+ or self._filter_names is not None
203
+ )
204
+ if not has_any_filter:
205
+ return rows
206
+
207
+ # Name filter is a simple substring match on the container name.
208
+ allowed_names: set[str] | None = None
209
+ if self._filter_names is not None:
210
+ allowed_names = {n.lower() for n in self._filter_names}
211
+
212
+ result: list[dict[str, Any]] = []
213
+ for row in rows:
214
+ labels = _labels(row)
215
+ if self._filter_project is not None:
216
+ project = labels.get("com.docker.compose.project", "")
217
+ if project != self._filter_project:
218
+ continue
219
+ if self._filter_services is not None:
220
+ svc = labels.get("com.docker.compose.service", "")
221
+ if svc and svc not in self._filter_services:
222
+ continue
223
+ if allowed_names is not None:
224
+ raw_name = _container_name(row)
225
+ container_names = [n.strip().lower() for n in raw_name.strip("[]").split(",")]
226
+ if not any(n in allowed_names for n in container_names):
227
+ continue
228
+ result.append(row)
229
+ return result
230
+
231
+ def discover(self) -> PartialGraph:
232
+ try:
233
+ rows = _ps(self._engine)
234
+ except (subprocess.TimeoutExpired, OSError) as exc:
235
+ return PartialGraph(source=self._engine, notes=(f"engine probe failed: {exc}",))
236
+
237
+ rows = self._filter_rows(rows)
238
+
239
+ nodes: list[HostNode | ContainerNode | ProcessNode] = []
240
+ edges: list[Edge] = []
241
+ notes: list[str] = []
242
+ host_id = f"h-{self._engine}-local"
243
+ nodes.append(HostNode(id=host_id, name=self._engine, transport="local"))
244
+
245
+ # Discover network info for IP addresses.
246
+ all_networks: set[str] = set()
247
+ for row in rows:
248
+ nets = row.get("Networks") or []
249
+ if isinstance(nets, list):
250
+ all_networks.update(nets)
251
+ network_info = _networks(self._engine, sorted(all_networks))
252
+
253
+ # Resolve container short_id → IP from network info.
254
+ container_ips: dict[str, str] = {}
255
+ for _net_name, net_data in network_info.items():
256
+ for cid_key, ip_val in net_data.items():
257
+ if cid_key != "subnet" and isinstance(ip_val, str) and "." in ip_val:
258
+ container_ips[cid_key] = ip_val
259
+
260
+ for row in rows:
261
+ labels = _labels(row)
262
+ service_name = labels.get("com.docker.compose.service")
263
+ ports = _parse_ports(row)
264
+ container_id = _container_id(row)
265
+ short_id = container_id[:12]
266
+ ip_str = container_ips.get(short_id)
267
+
268
+ ip_addr = IPvAnyAddress(ip_str) if ip_str else None
269
+
270
+ nets = row.get("Networks") or []
271
+ net_names = tuple(nets) if isinstance(nets, list) else ()
272
+
273
+ # Identity (ADR-M1-1) + descriptive metadata (ADR-M1-2) in one
274
+ # inspect pass; `container_name` stays as the authored resolver key.
275
+ container_name, created_at, started_at = _inspect_meta(self._engine, container_id)
276
+ metadata = RuntimeMetadata.from_inspect(
277
+ {
278
+ "labels": labels,
279
+ "created_at": created_at,
280
+ "started_at": started_at,
281
+ "image": str(row.get("Image") or ""),
282
+ },
283
+ name=container_name,
284
+ )
285
+
286
+ node = ContainerNode(
287
+ id=f"ctr-{short_id}",
288
+ name=_container_name(row),
289
+ engine=self._engine,
290
+ runtime_identity=RuntimeIdentity(
291
+ runtime=self._engine,
292
+ host_id=host_id,
293
+ runtime_id=container_id,
294
+ ),
295
+ runtime_metadata=metadata,
296
+ state=str(row.get("State") or row.get("Status") or "unknown"),
297
+ ports=ports,
298
+ container_name=container_name,
299
+ ip_address=ip_addr,
300
+ image=str(row.get("Image") or ""),
301
+ networks=net_names,
302
+ )
303
+ nodes.append(node)
304
+ edges.append(Edge(src=node.id, dst=host_id, kind=EdgeKind.RUNS_ON))
305
+
306
+ # Container → service edge (contained_in).
307
+ if service_name:
308
+ edges.append(
309
+ Edge(src=node.id, dst=f"svc-{service_name}", kind=EdgeKind.CONTAINED_IN)
310
+ )
311
+
312
+ # Emit a ProcessNode for the container's main PID.
313
+ process_name = service_name or node.name
314
+ pid = _inspect_pid(self._engine, container_id)
315
+ if pid is not None and process_name:
316
+ proc_id = f"proc-{process_name}-{short_id}"
317
+ proc_node = ProcessNode(
318
+ id=proc_id,
319
+ name=process_name,
320
+ pid=pid,
321
+ host_id=host_id,
322
+ cmdline=f"{self._engine} container {short_id}",
323
+ container_id=short_id,
324
+ container_name=container_name,
325
+ )
326
+ nodes.append(proc_node)
327
+ edges.append(Edge(src=proc_id, dst=node.id, kind=EdgeKind.RUNS_ON))
328
+
329
+ return PartialGraph(
330
+ source=self._engine,
331
+ nodes=tuple(nodes),
332
+ edges=tuple(edges),
333
+ notes=tuple(notes),
334
+ )
335
+
336
+
337
+ def _parse_ports(row: dict[str, Any]) -> tuple[PortBinding, ...]:
338
+ """Extract port bindings from a container ps row.
339
+
340
+ Docker ``ps --format json`` emits *Ports* as a comma-separated string
341
+ like ``"0.0.0.0:5432->5432/tcp"``. Podman emits it as either a dict
342
+ keyed by port number or a list of dicts with *host_port* keys.
343
+ """
344
+ raw = row.get("Ports")
345
+ bindings: list[PortBinding] = []
346
+
347
+ # Podman list-of-dicts: [{"host_port": 8080, "container_port": 80, ...}]
348
+ if isinstance(raw, list):
349
+ for item in raw:
350
+ if isinstance(item, dict) and "host_port" in item:
351
+ bindings.append(
352
+ PortBinding(
353
+ host_port=int(item["host_port"]),
354
+ container_port=int(item.get("container_port", item["host_port"])),
355
+ host_address=str(item.get("host_ip", "") or "0.0.0.0"),
356
+ protocol=str(item.get("protocol", "tcp")),
357
+ )
358
+ )
359
+ return tuple(bindings)
360
+
361
+ # Podman dict: {"5432/tcp": []} or {"80/tcp": [{"HostPort": "8080"}]}
362
+ if isinstance(raw, dict):
363
+ for port_key, port_entries in raw.items():
364
+ parts = str(port_key).split("/")
365
+ try:
366
+ cport = int(parts[0])
367
+ except (ValueError, IndexError):
368
+ continue
369
+ proto = parts[1] if len(parts) > 1 else "tcp"
370
+ if isinstance(port_entries, list) and port_entries:
371
+ for entry in port_entries:
372
+ if isinstance(entry, dict):
373
+ bindings.append(
374
+ PortBinding(
375
+ host_port=int(entry.get("HostPort", cport)),
376
+ container_port=cport,
377
+ host_address=str(entry.get("HostIp", "") or "0.0.0.0"),
378
+ protocol=proto,
379
+ )
380
+ )
381
+ else:
382
+ bindings.append(PortBinding(host_port=cport, container_port=cport, protocol=proto))
383
+ return tuple(bindings)
384
+
385
+ # Docker string form: "0.0.0.0:5432->5432/tcp,192.168.1.5:8080->8080/tcp"
386
+ for part in str(raw or "").split(","):
387
+ if "->" in part:
388
+ try:
389
+ host_part, rest = part.split("->")
390
+ host_addr, _, host_port_s = host_part.rpartition(":")
391
+ cport_s, _, proto = rest.partition("/")
392
+ bindings.append(
393
+ PortBinding(
394
+ host_port=int(host_port_s),
395
+ container_port=int(cport_s),
396
+ host_address=host_addr or "0.0.0.0",
397
+ protocol=proto.strip() or "tcp",
398
+ )
399
+ )
400
+ except (IndexError, ValueError):
401
+ continue
402
+ return tuple(bindings)
403
+
404
+
405
+ def _labels(row: dict[str, Any]) -> dict[str, str]:
406
+ raw = row.get("Labels")
407
+ # Podman returns Labels as a dict; Docker returns a comma-separated string.
408
+ if isinstance(raw, dict):
409
+ return {k: str(v) for k, v in raw.items()}
410
+ labels: dict[str, str] = {}
411
+ for pair in str(raw or "").split(","):
412
+ if "=" in pair:
413
+ key, _, value = pair.partition("=")
414
+ labels[key] = value
415
+ return labels
416
+
417
+
418
+ def _networks(engine: str, network_names: list[str]) -> dict[str, dict[str, str]]:
419
+ """Discover network info: {network_name: {subnet, container_id: ip}}.
420
+
421
+ Returns a mapping of network name → {subnet, container_id → ip_address}.
422
+ """
423
+ result: dict[str, dict[str, str]] = {}
424
+ for net_name in network_names:
425
+ try:
426
+ out = subprocess.run(
427
+ [engine, "network", "inspect", net_name, "--format", "{{json .}}"],
428
+ capture_output=True,
429
+ text=True,
430
+ check=False,
431
+ timeout=10,
432
+ )
433
+ if out.returncode != 0:
434
+ continue
435
+ data = json.loads(out.stdout.strip())
436
+ net = (
437
+ data[0]
438
+ if isinstance(data, list) and data
439
+ else data
440
+ if isinstance(data, dict)
441
+ else {}
442
+ )
443
+ entry: dict[str, str] = {}
444
+ subnets = net.get("subnets") or []
445
+ if subnets and isinstance(subnets[0], dict):
446
+ entry["subnet"] = subnets[0].get("subnet", "")
447
+ containers = net.get("containers") or {}
448
+ for cid, cdata in containers.items():
449
+ if isinstance(cdata, dict):
450
+ interfaces = cdata.get("interfaces") or {}
451
+ for iface in interfaces.values():
452
+ subnets = iface.get("subnets") or []
453
+ if subnets and isinstance(subnets[0], dict):
454
+ ipnet = subnets[0].get("ipnet", "")
455
+ if "/" in ipnet:
456
+ entry[cid[:12]] = ipnet.split("/")[0]
457
+ break
458
+ result[net_name] = entry
459
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError):
460
+ continue
461
+ return result