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,207 @@
1
+ """ComposeFileProvider — docker-compose.yaml as first-class blueprint (ADR-0006).
2
+
3
+ Extracts services, images, networks, ports, healthcheck-gated ``depends_on``
4
+ (weight ≥ 2), and heuristically infers external dependencies from service env
5
+ vars. Inferred nodes are flagged and never targetable until allowlisted
6
+ ([ADR-0012]).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from mayhem.domain.topology import (
18
+ Edge,
19
+ EdgeKind,
20
+ ExternalDependencyNode,
21
+ NodeKind,
22
+ PortBinding,
23
+ ServiceNode,
24
+ )
25
+ from mayhem.topology.providers.base import PartialGraph
26
+
27
+ _URL_KEYS = re.compile(r"(DATABASE_URL|REDIS_URL|.*_URL|.*_ENDPOINT)$")
28
+ _URL_RE = re.compile(r"^[a-z][a-z0-9+.-]*://([^/@]+@)?(?P<host>[^/:?#]+)")
29
+
30
+
31
+ def _interpolate(value: str, env: dict[str, str]) -> str:
32
+ """Minimal ${VAR} / ${VAR:-default} interpolation from .env."""
33
+
34
+ def sub(match: re.Match[str]) -> str:
35
+ expr = match.group(1)
36
+ name, sep, default = expr.partition(":-")
37
+ return env.get(name.strip(), default if sep else "")
38
+
39
+ return re.sub(r"\$\{([^}]+)\}", sub, value)
40
+
41
+
42
+ def _load_env_file(path: Path) -> dict[str, str]:
43
+ env: dict[str, str] = {}
44
+ if not path.exists():
45
+ return env
46
+ for raw_line in path.read_text().splitlines():
47
+ line = raw_line.strip()
48
+ if not line or line.startswith("#") or "=" not in line:
49
+ continue
50
+ key, _, value = line.partition("=")
51
+ env[key.strip()] = value.strip().strip("'\"")
52
+ return env
53
+
54
+
55
+ def _parse_ports(raw: Any) -> tuple[PortBinding, ...]:
56
+ """Parse Compose port mappings into PortBinding objects.
57
+
58
+ Compose ports are in the form ``host:container`` or ``host:container/protocol``.
59
+ If only one port is given, it is used as both host and container port.
60
+ """
61
+ bindings: list[PortBinding] = []
62
+ for item in raw or []:
63
+ text = str(item)
64
+ protocol = "tcp"
65
+ if "/" in text:
66
+ text, _, protocol = text.rpartition("/")
67
+ parts = text.split(":")
68
+ if len(parts) == 2:
69
+ host_port, container_port = int(parts[0]), int(parts[1])
70
+ elif len(parts) == 1:
71
+ host_port = container_port = int(parts[0])
72
+ else:
73
+ continue
74
+ bindings.append(
75
+ PortBinding(
76
+ host_port=host_port,
77
+ container_port=container_port,
78
+ protocol=protocol.strip(),
79
+ )
80
+ )
81
+ return tuple(bindings)
82
+
83
+
84
+ def _depends_pairs(depends: Any) -> list[tuple[str, float]]:
85
+ """Normalize depends_on (dict with conditions, or plain list) to weighted pairs."""
86
+ pairs: list[tuple[str, float]] = []
87
+ if isinstance(depends, dict):
88
+ for dep_name, cfg in depends.items():
89
+ condition = (cfg or {}).get("condition", "service_started")
90
+ weight = 2.0 if condition == "service_healthy" else 1.0
91
+ pairs.append((dep_name, weight))
92
+ elif isinstance(depends, list):
93
+ pairs.extend((dep_name, 1.0) for dep_name in depends)
94
+ return pairs
95
+
96
+
97
+ def _env_dict(environment: Any) -> dict[str, str]:
98
+ """Compose allows environment as a mapping or a list of KEY=VALUE strings."""
99
+ if isinstance(environment, list):
100
+ return {
101
+ entry.split("=", 1)[0]: entry.split("=", 1)[1] for entry in environment if "=" in entry
102
+ }
103
+ return {str(k): str(v) for k, v in (environment or {}).items()}
104
+
105
+
106
+ class ComposeFileProvider:
107
+ id = "compose"
108
+
109
+ def __init__(self, compose_path: str | Path) -> None:
110
+ self._path = Path(compose_path)
111
+
112
+ def is_available(self) -> bool:
113
+ return self._path.exists()
114
+
115
+ @property
116
+ def project_name(self) -> str:
117
+ """Compose project name — explicit ``name:`` field or directory name.
118
+
119
+ Normalized to lowercase because Docker and Podman always lowercase
120
+ the ``com.docker.compose.project`` label.
121
+ """
122
+ document: dict[str, Any] = yaml.safe_load(self._path.read_text()) or {}
123
+ return str(document.get("name") or self._path.resolve().parent.name).lower()
124
+
125
+ @property
126
+ def service_names(self) -> tuple[str, ...]:
127
+ """Ordered service names declared in the compose file."""
128
+ document: dict[str, Any] = yaml.safe_load(self._path.read_text()) or {}
129
+ return tuple((document.get("services") or {}).keys())
130
+
131
+ def discover(self) -> PartialGraph:
132
+ document: dict[str, Any] = yaml.safe_load(self._path.read_text()) or {}
133
+ services: dict[str, Any] = document.get("services") or {}
134
+ env = _load_env_file(self._path.parent / ".env")
135
+
136
+ nodes: list[Any] = []
137
+ edges: list[Edge] = []
138
+ notes: list[str] = []
139
+
140
+ for name, svc in services.items():
141
+ image = svc.get("image")
142
+ # Explicit container identity — prefer the standard ``container_name:``
143
+ # field, falling back to the newer per-service ``name:`` alias.
144
+ container_name = svc.get("container_name") or svc.get("name")
145
+ nodes.append(
146
+ ServiceNode(
147
+ id=f"svc-{name}",
148
+ name=name,
149
+ image=image,
150
+ exposed_ports=_parse_ports(svc.get("ports")),
151
+ container_name=container_name,
152
+ )
153
+ )
154
+
155
+ pairs = _depends_pairs(svc.get("depends_on"))
156
+ for dep_name, weight in pairs:
157
+ edges.append(
158
+ Edge(
159
+ src=f"svc-{name}",
160
+ dst=f"svc-{dep_name}",
161
+ kind=EdgeKind.DEPENDS_ON,
162
+ weight=weight,
163
+ )
164
+ )
165
+
166
+ environment = _env_dict(svc.get("environment"))
167
+ for key, raw_value in environment.items():
168
+ value = _interpolate(str(raw_value), env)
169
+ if not _URL_KEYS.match(key):
170
+ continue
171
+ match = _URL_RE.match(value)
172
+ if match is None:
173
+ continue
174
+ host = match.group("host")
175
+ node_id = f"ext-{host}"
176
+ if any(getattr(n, "id", None) == node_id for n in nodes):
177
+ continue
178
+ nodes.append(
179
+ ExternalDependencyNode(
180
+ id=node_id,
181
+ name=host,
182
+ endpoint=value.split("://", 1)[0] + "://" + host,
183
+ inferred=True,
184
+ )
185
+ )
186
+ notes.append(f"inferred external dependency {host!r} from {name}.{key}")
187
+
188
+ known_service_ids = {n.id for n in nodes}
189
+ kept: list[Edge] = []
190
+ dropped_dep: list[str] = []
191
+ for edge in edges:
192
+ if edge.kind is EdgeKind.DEPENDS_ON and edge.dst not in known_service_ids:
193
+ dropped_dep.append(edge.dst)
194
+ continue
195
+ kept.append(edge)
196
+ if dropped_dep:
197
+ notes.append(
198
+ "depends_on references unknown services (dropped): "
199
+ + ", ".join(sorted(set(dropped_dep)))
200
+ )
201
+
202
+ return PartialGraph(
203
+ source=self.id, nodes=tuple(nodes), edges=tuple(kept), notes=tuple(notes)
204
+ )
205
+
206
+
207
+ __all__ = ["ComposeFileProvider", "NodeKind", "PortBinding"]
@@ -0,0 +1,277 @@
1
+ """DockerAdapter — concrete RuntimeAdapter for the Docker engine (ADR-M3-1).
2
+
3
+ Extracted from ``ContainerRuntimeProvider`` in docker_runtime.py. Module-level
4
+ helpers are imported from that file; ``DockerAdapter`` wraps them behind the
5
+ normalised ``RuntimeAdapter`` and ``TopologyProvider`` interfaces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from typing import Any
13
+
14
+ from pydantic.networks import IPvAnyAddress
15
+
16
+ from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
17
+ from mayhem.domain.runtime_adapter import (
18
+ AdapterCapabilities,
19
+ RuntimeAdapter,
20
+ RuntimeCapability,
21
+ )
22
+ from mayhem.topology.providers.base import PartialGraph, TopologyProvider
23
+
24
+ # Module-level helpers from the original provider — unchanged.
25
+ from mayhem.topology.providers.docker_runtime import (
26
+ _container_id,
27
+ _container_name,
28
+ _inspect_meta,
29
+ _inspect_name,
30
+ _inspect_pid,
31
+ _labels,
32
+ _networks,
33
+ _parse_ports,
34
+ _ps,
35
+ )
36
+
37
+
38
+ class DockerAdapter(RuntimeAdapter, TopologyProvider):
39
+ """RuntimeAdapter for the local Docker engine."""
40
+
41
+ ENGINE = "docker"
42
+
43
+ def __init__(
44
+ self,
45
+ engine: str = ENGINE,
46
+ *,
47
+ filter_project: str | None = None,
48
+ filter_services: tuple[str, ...] | None = None,
49
+ filter_names: tuple[str, ...] | None = None,
50
+ ) -> None:
51
+ self._engine = engine
52
+ self._filter_project = filter_project
53
+ self._filter_services = filter_services
54
+ self._filter_names = filter_names
55
+
56
+ @classmethod
57
+ def best_effort(cls, engine: str | None = None) -> DockerAdapter | None:
58
+ """Return a DockerAdapter if the engine is available, else None.
59
+
60
+ Preserved for backward compat with callers that used
61
+ ``ContainerRuntimeProvider.best_effort()``.
62
+ """
63
+ target = engine or cls.ENGINE
64
+ adapter = cls(target)
65
+ return adapter if adapter.is_available() else None
66
+
67
+ # -- identity ------------------------------------------------------------
68
+
69
+ @property
70
+ def id(self) -> str:
71
+ suffix = f"+{self._filter_project}" if self._filter_project else ""
72
+ return f"{self._engine}{suffix}"
73
+
74
+ def is_available(self) -> bool:
75
+ return shutil.which(self._engine) is not None
76
+
77
+ # -- capabilities --------------------------------------------------------
78
+
79
+ def capabilities(self) -> AdapterCapabilities:
80
+ """Rootful Docker: all capabilities SUPPORTED."""
81
+ return AdapterCapabilities(
82
+ engine=self._engine,
83
+ rootless=False,
84
+ supported=frozenset(RuntimeCapability),
85
+ alternatives=frozenset(),
86
+ )
87
+
88
+ # -- container operations ------------------------------------------------
89
+
90
+ def ps(self) -> list[dict[str, Any]]:
91
+ return _ps(self._engine)
92
+
93
+ def inspect(self, container_id: str) -> tuple[RuntimeIdentity, RuntimeMetadata | None]:
94
+ _inspect_name(self._engine, container_id)
95
+ container_name, created_at, started_at = _inspect_meta(self._engine, container_id)
96
+ metadata = RuntimeMetadata.from_inspect(
97
+ {
98
+ "created_at": created_at,
99
+ "started_at": started_at,
100
+ },
101
+ name=container_name,
102
+ )
103
+ identity = RuntimeIdentity(
104
+ runtime=self._engine,
105
+ host_id="host",
106
+ runtime_id=container_id,
107
+ )
108
+ return identity, metadata
109
+
110
+ def exec(self, container_id: str, cmd: list[str], *, timeout_s: float = 30) -> str:
111
+ out = subprocess.run(
112
+ [self._engine, "exec", container_id, *cmd],
113
+ capture_output=True,
114
+ text=True,
115
+ check=False,
116
+ timeout=timeout_s,
117
+ )
118
+ return out.stdout
119
+
120
+ def pid(self, container_id: str) -> int | None:
121
+ return _inspect_pid(self._engine, container_id)
122
+
123
+ def signal(self, container_id: str, signo: int) -> None:
124
+ import signal as _signal
125
+
126
+ try:
127
+ sig = _signal.Signals(signo)
128
+ signame = sig.name
129
+ except (ValueError, AttributeError):
130
+ signame = "SIGKILL"
131
+ subprocess.run(
132
+ [self._engine, "kill", "-s", signame, container_id],
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ timeout=10,
137
+ )
138
+
139
+ def netns(self, container_id: str) -> str | None:
140
+ pid = _inspect_pid(self._engine, container_id)
141
+ if pid is None:
142
+ return None
143
+ return f"/proc/{pid}/ns/net"
144
+
145
+ # -- filtering -----------------------------------------------------------
146
+
147
+ def filter_by_compose(
148
+ self,
149
+ project: str,
150
+ services: tuple[str, ...] | None = None,
151
+ ) -> None:
152
+ self._filter_project = project
153
+ self._filter_services = services
154
+
155
+ def filter_by_names(self, names: list[str]) -> None:
156
+ self._filter_names = tuple(names)
157
+
158
+ # -- discovery (TopologyProvider) ----------------------------------------
159
+
160
+ def _filter_rows(
161
+ self,
162
+ rows: list[dict[str, Any]],
163
+ ) -> list[dict[str, Any]]:
164
+ """Apply compose / name filters to the raw ps rows."""
165
+ allowed_names: set[str] | None = (
166
+ {n.strip().lower() for n in self._filter_names} if self._filter_names else None
167
+ )
168
+ result: list[dict[str, Any]] = []
169
+ for row in rows:
170
+ labels = _labels(row)
171
+ if self._filter_project is not None:
172
+ project = labels.get("com.docker.compose.project", "")
173
+ if project != self._filter_project:
174
+ continue
175
+ if self._filter_services is not None:
176
+ svc = labels.get("com.docker.compose.service", "")
177
+ if svc and svc not in self._filter_services:
178
+ continue
179
+ if allowed_names is not None:
180
+ raw_name = _container_name(row)
181
+ container_names = [n.strip().lower() for n in raw_name.strip("[]").split(",")]
182
+ if not any(n in allowed_names for n in container_names):
183
+ continue
184
+ result.append(row)
185
+ return result
186
+
187
+ def discover(self) -> PartialGraph:
188
+ from mayhem.domain.topology import (
189
+ ContainerNode,
190
+ Edge,
191
+ EdgeKind,
192
+ HostNode,
193
+ )
194
+
195
+ try:
196
+ rows = _ps(self._engine)
197
+ except (OSError, subprocess.TimeoutExpired) as exc:
198
+ return PartialGraph(source=self._engine, notes=(str(exc),))
199
+
200
+ rows = self._filter_rows(rows)
201
+
202
+ nodes: list[Any] = []
203
+ edges: list[Any] = []
204
+ notes: list[str] = []
205
+
206
+ host_id = f"h-{self._engine}-local"
207
+ nodes.append(HostNode(id=host_id, name=self._engine, transport="local"))
208
+
209
+ # Discover network info for IP addresses.
210
+ all_networks: set[str] = set()
211
+ for row in rows:
212
+ nets = row.get("Networks") or []
213
+ if isinstance(nets, list):
214
+ all_networks.update(nets)
215
+ network_info = _networks(self._engine, sorted(all_networks))
216
+
217
+ # Resolve container short_id -> IP from network info.
218
+ container_ips: dict[str, str] = {}
219
+ for _net_name, net_data in network_info.items():
220
+ for cid_key, ip_val in net_data.items():
221
+ if cid_key != "subnet" and isinstance(ip_val, str) and "." in ip_val:
222
+ container_ips[cid_key] = ip_val
223
+
224
+ for row in rows:
225
+ labels = _labels(row)
226
+ service_name = labels.get("com.docker.compose.service")
227
+ ports = _parse_ports(row)
228
+ container_id = _container_id(row)
229
+ short_id = container_id[:12]
230
+ ip_str = container_ips.get(short_id)
231
+ ip_addr = IPvAnyAddress(ip_str) if ip_str else None
232
+
233
+ nets = row.get("Networks") or []
234
+ net_names = tuple(nets) if isinstance(nets, list) else ()
235
+
236
+ container_name, created_at, started_at = _inspect_meta(self._engine, container_id)
237
+ metadata = RuntimeMetadata.from_inspect(
238
+ {
239
+ "labels": labels,
240
+ "created_at": created_at,
241
+ "started_at": started_at,
242
+ "image": str(row.get("Image") or ""),
243
+ },
244
+ name=container_name,
245
+ )
246
+
247
+ node = ContainerNode(
248
+ id=f"ctr-{short_id}",
249
+ name=_container_name(row),
250
+ engine=self._engine,
251
+ runtime_identity=RuntimeIdentity(
252
+ runtime=self._engine,
253
+ host_id=host_id,
254
+ runtime_id=container_id,
255
+ ),
256
+ runtime_metadata=metadata,
257
+ state=str(row.get("State") or row.get("Status") or "unknown"),
258
+ ports=ports,
259
+ container_name=container_name,
260
+ ip_address=ip_addr,
261
+ image=str(row.get("Image") or ""),
262
+ networks=net_names,
263
+ )
264
+ nodes.append(node)
265
+ edges.append(Edge(src=node.id, dst=host_id, kind=EdgeKind.RUNS_ON))
266
+
267
+ if service_name:
268
+ edges.append(
269
+ Edge(src=node.id, dst=f"svc-{service_name}", kind=EdgeKind.CONTAINED_IN)
270
+ )
271
+
272
+ return PartialGraph(
273
+ source=self._engine,
274
+ nodes=tuple(nodes),
275
+ edges=tuple(edges),
276
+ notes=tuple(notes),
277
+ )