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,328 @@
1
+ """PodmanAdapter — concrete RuntimeAdapter for the Podman engine (ADR-M3-1, ADR-M3-4).
2
+
3
+ Handles rootful and rootless Podman. Rootless mode downgrades NETNS to
4
+ ALTERNATIVE and RESOURCE_LIMITS to UNSUPPORTED.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ from typing import Any
13
+
14
+ from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
15
+ from mayhem.domain.runtime_adapter import (
16
+ AdapterCapabilities,
17
+ RuntimeAdapter,
18
+ RuntimeCapability,
19
+ )
20
+ from mayhem.topology.providers.base import PartialGraph, TopologyProvider
21
+
22
+ # Module-level helpers from the original provider — unchanged.
23
+ from mayhem.topology.providers.docker_runtime import (
24
+ _container_id,
25
+ _container_name,
26
+ _inspect_meta,
27
+ _inspect_pid,
28
+ _labels,
29
+ _networks,
30
+ _parse_ports,
31
+ _ps,
32
+ )
33
+
34
+
35
+ def _detect_rootless(engine: str = "podman") -> bool:
36
+ """Detect whether Podman is running rootless via ``podman info``."""
37
+ try:
38
+ out = subprocess.run(
39
+ [engine, "info", "--format", "json"],
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=5,
43
+ )
44
+ data = json.loads(out.stdout.strip())
45
+ host = data.get("host") or {}
46
+ security = host.get("security") or {}
47
+ return bool(security.get("rootless", False))
48
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
49
+ return False
50
+
51
+
52
+ class PodmanAdapter(RuntimeAdapter, TopologyProvider):
53
+ """RuntimeAdapter for the local Podman engine.
54
+
55
+ Handles both rootful and rootless modes. Rootless detection is performed
56
+ once at construction time and cached in the ``AdapterCapabilities``.
57
+ """
58
+
59
+ ENGINE = "podman"
60
+
61
+ def __init__(
62
+ self,
63
+ engine: str = ENGINE,
64
+ *,
65
+ filter_project: str | None = None,
66
+ filter_services: tuple[str, ...] | None = None,
67
+ filter_names: tuple[str, ...] | None = None,
68
+ ) -> None:
69
+ self._engine = engine
70
+ self._filter_project = filter_project
71
+ self._filter_services = filter_services
72
+ self._filter_names = filter_names
73
+ self._rootless = _detect_rootless(engine)
74
+
75
+ # -- identity ------------------------------------------------------------
76
+
77
+ @property
78
+ def id(self) -> str:
79
+ suffix = f"+{self._filter_project}" if self._filter_project else ""
80
+ return f"{self._engine}{suffix}"
81
+
82
+ def is_available(self) -> bool:
83
+ return shutil.which(self._engine) is not None
84
+
85
+ # -- capabilities --------------------------------------------------------
86
+
87
+ def capabilities(self) -> AdapterCapabilities:
88
+ if self._rootless:
89
+ # Rootless: NETNS=ALTERNATIVE, RESOURCE_LIMITS=UNSUPPORTED (ADR-M3-4)
90
+ return AdapterCapabilities(
91
+ engine=self._engine,
92
+ rootless=True,
93
+ supported=frozenset(
94
+ {
95
+ RuntimeCapability.EXEC,
96
+ RuntimeCapability.PID,
97
+ RuntimeCapability.SIGNAL,
98
+ RuntimeCapability.INSPECT,
99
+ RuntimeCapability.COMPOSE_FILTER,
100
+ }
101
+ ),
102
+ alternatives=frozenset({RuntimeCapability.NETNS}),
103
+ )
104
+ # Rootful: all SUPPORTED (same as docker)
105
+ return AdapterCapabilities(
106
+ engine=self._engine,
107
+ rootless=False,
108
+ supported=frozenset(RuntimeCapability),
109
+ alternatives=frozenset(),
110
+ )
111
+
112
+ # -- container operations ------------------------------------------------
113
+
114
+ def ps(self) -> list[dict[str, Any]]:
115
+ return _ps(self._engine)
116
+
117
+ def inspect(self, container_id: str) -> tuple[RuntimeIdentity, RuntimeMetadata | None]:
118
+ container_name, created_at, started_at = _inspect_meta(self._engine, container_id)
119
+ metadata = RuntimeMetadata.from_inspect(
120
+ {
121
+ "created_at": created_at,
122
+ "started_at": started_at,
123
+ },
124
+ name=container_name,
125
+ )
126
+ identity = RuntimeIdentity(
127
+ runtime=self._engine,
128
+ host_id="host",
129
+ runtime_id=container_id,
130
+ )
131
+ return identity, metadata
132
+
133
+ def exec(self, container_id: str, cmd: list[str], *, timeout_s: float = 30) -> str:
134
+ out = subprocess.run(
135
+ [self._engine, "exec", container_id, *cmd],
136
+ capture_output=True,
137
+ text=True,
138
+ check=False,
139
+ timeout=timeout_s,
140
+ )
141
+ return out.stdout
142
+
143
+ def pid(self, container_id: str) -> int | None:
144
+ return _inspect_pid(self._engine, container_id)
145
+
146
+ def signal(self, container_id: str, signo: int) -> None:
147
+ import signal as _signal
148
+
149
+ try:
150
+ sig = _signal.Signals(signo)
151
+ signame = sig.name
152
+ except (ValueError, AttributeError):
153
+ signame = "SIGKILL"
154
+ subprocess.run(
155
+ [self._engine, "kill", "-s", signame, container_id],
156
+ capture_output=True,
157
+ text=True,
158
+ check=False,
159
+ timeout=10,
160
+ )
161
+
162
+ def netns(self, container_id: str) -> str | None:
163
+ """Return network-namespace path.
164
+
165
+ In rootless mode this may require ``podman unshare nsenter`` — the
166
+ caller receives ALTERNATIVE verdict and must degrade gracefully.
167
+ """
168
+ pid = _inspect_pid(self._engine, container_id)
169
+ if pid is None:
170
+ return None
171
+ return f"/proc/{pid}/ns/net"
172
+
173
+ # -- filtering -----------------------------------------------------------
174
+
175
+ def filter_by_compose(
176
+ self,
177
+ project: str,
178
+ services: tuple[str, ...] | None = None,
179
+ ) -> None:
180
+ self._filter_project = project
181
+ self._filter_services = services
182
+
183
+ def filter_by_names(self, names: list[str]) -> None:
184
+ self._filter_names = tuple(names)
185
+
186
+ # -- discovery (TopologyProvider) ----------------------------------------
187
+
188
+ def _filter_rows(
189
+ self,
190
+ rows: list[dict[str, Any]],
191
+ ) -> list[dict[str, Any]]:
192
+ allowed_names: set[str] | None = (
193
+ {n.strip().lower() for n in self._filter_names} if self._filter_names else None
194
+ )
195
+ result: list[dict[str, Any]] = []
196
+ for row in rows:
197
+ labels = _labels(row)
198
+ if self._filter_project is not None:
199
+ project = labels.get("com.docker.compose.project", "")
200
+ if project != self._filter_project:
201
+ continue
202
+ if self._filter_services is not None:
203
+ svc = labels.get("com.docker.compose.service", "")
204
+ if svc and svc not in self._filter_services:
205
+ continue
206
+ if allowed_names is not None:
207
+ raw_name = _container_name(row)
208
+ container_names = [n.strip().lower() for n in raw_name.strip("[]").split(",")]
209
+ if not any(n in allowed_names for n in container_names):
210
+ continue
211
+ result.append(row)
212
+ return result
213
+
214
+ def discover(self) -> PartialGraph:
215
+ from mayhem.domain.topology import (
216
+ ContainerNode,
217
+ Edge,
218
+ EdgeKind,
219
+ HostNode,
220
+ )
221
+
222
+ try:
223
+ rows = _ps(self._engine)
224
+ except (OSError, subprocess.TimeoutExpired) as exc:
225
+ return PartialGraph(source=self._engine, notes=(str(exc),))
226
+
227
+ rows = self._filter_rows(rows)
228
+
229
+ nodes: list[Any] = []
230
+ edges: list[Any] = []
231
+ notes: list[str] = []
232
+
233
+ host_id = f"h-{self._engine}-local"
234
+ nodes.append(HostNode(id=host_id, name=self._engine, transport="local"))
235
+
236
+ all_networks: set[str] = set()
237
+ for row in rows:
238
+ nets = row.get("Networks") or []
239
+ if isinstance(nets, list):
240
+ all_networks.update(nets)
241
+ network_info = _networks(self._engine, sorted(all_networks))
242
+
243
+ container_ips: dict[str, str] = {}
244
+ for _net_name, net_data in network_info.items():
245
+ for cid_key, ip_val in net_data.items():
246
+ if cid_key != "subnet" and isinstance(ip_val, str) and "." in ip_val:
247
+ container_ips[cid_key] = ip_val
248
+
249
+ for row in rows:
250
+ labels = _labels(row)
251
+ service_name = labels.get("com.docker.compose.service")
252
+ ports = _parse_ports(row)
253
+ container_id = _container_id(row)
254
+ short_id = container_id[:12]
255
+ ip_str = container_ips.get(short_id)
256
+ ip_addr = None
257
+ if ip_str:
258
+ from pydantic.networks import IPvAnyAddress
259
+
260
+ ip_addr = IPvAnyAddress(ip_str)
261
+
262
+ nets = row.get("Networks") or []
263
+ net_names = tuple(nets) if isinstance(nets, list) else ()
264
+
265
+ container_name, created_at, started_at = _inspect_meta(self._engine, container_id)
266
+ metadata = RuntimeMetadata.from_inspect(
267
+ {
268
+ "labels": labels,
269
+ "created_at": created_at,
270
+ "started_at": started_at,
271
+ "image": str(row.get("Image") or ""),
272
+ },
273
+ name=container_name,
274
+ )
275
+
276
+ node = ContainerNode(
277
+ id=f"ctr-{short_id}",
278
+ name=_container_name(row),
279
+ engine=self._engine,
280
+ runtime_identity=RuntimeIdentity(
281
+ runtime=self._engine,
282
+ host_id=host_id,
283
+ runtime_id=container_id,
284
+ ),
285
+ runtime_metadata=metadata,
286
+ state=str(row.get("State") or row.get("Status") or "unknown"),
287
+ ports=ports,
288
+ container_name=container_name,
289
+ ip_address=ip_addr,
290
+ image=str(row.get("Image") or ""),
291
+ networks=net_names,
292
+ )
293
+ nodes.append(node)
294
+ edges.append(Edge(src=node.id, dst=host_id, kind=EdgeKind.RUNS_ON))
295
+
296
+ if service_name:
297
+ edges.append(
298
+ Edge(src=node.id, dst=f"svc-{service_name}", kind=EdgeKind.CONTAINED_IN)
299
+ )
300
+
301
+ # Emit a ProcessNode for the container's main PID so process-addressed
302
+ # faults (proc.pause / process.stop / process.kill) resolve a real PID
303
+ # carried with the container's runtime address — the same contract as
304
+ # the docker provider (ADR-0020 / ADR-M1-1).
305
+ from mayhem.domain.topology import ProcessNode # noqa: PLC0415
306
+
307
+ process_name = service_name or _container_name(row)
308
+ pid = _inspect_pid(self._engine, container_id)
309
+ if pid is not None and process_name:
310
+ proc_id = f"proc-{process_name}-{short_id}"
311
+ proc_node = ProcessNode(
312
+ id=proc_id,
313
+ name=process_name,
314
+ pid=pid,
315
+ host_id=host_id,
316
+ cmdline=f"{self._engine} container {short_id}",
317
+ container_id=short_id,
318
+ container_name=container_name,
319
+ )
320
+ nodes.append(proc_node)
321
+ edges.append(Edge(src=proc_id, dst=node.id, kind=EdgeKind.RUNS_ON))
322
+
323
+ return PartialGraph(
324
+ source=self._engine,
325
+ nodes=tuple(nodes),
326
+ edges=tuple(edges),
327
+ notes=tuple(notes),
328
+ )
@@ -0,0 +1,196 @@
1
+ """Container resolution — get current PID and IP from container names.
2
+
3
+ Resolution is invoked at execution time, not discovery time, so the PID is
4
+ never older than the injection syscall. A single ``inspect`` per container
5
+ returns the current main PID, IP address, and state (ADR-0020).
6
+
7
+ Import of this module is confined to ``topology`` per the import rule
8
+ ([ADR-0013]) — it shells out to the container engine binary.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from mayhem.domain.identity import ProcessRuntimeIdentity, RuntimeIdentity, RuntimeMetadata
21
+
22
+ __all__ = [
23
+ "ContainerInfo",
24
+ "resolve_all",
25
+ "resolve_container",
26
+ "resolve_identity",
27
+ "resolve_ip",
28
+ "resolve_metadata",
29
+ "resolve_pid",
30
+ "resolve_process_identity",
31
+ ]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ContainerInfo:
36
+ pid: int
37
+ ip_address: str
38
+ state: str # "running", "exited", etc.
39
+
40
+
41
+ def _detect_engine() -> str:
42
+ """Return 'podman' or 'docker', preferring podman."""
43
+ for engine in ("podman", "docker"):
44
+ if shutil.which(engine):
45
+ return engine
46
+ raise RuntimeError("neither podman nor docker found in PATH")
47
+
48
+
49
+ def _inspect(engine: str, container_name: str, fmt: str) -> str:
50
+ """Run ``engine inspect`` with a Go template format string."""
51
+ cmd = [engine, "inspect", "--format", fmt, container_name]
52
+ try:
53
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
54
+ except subprocess.TimeoutExpired as exc:
55
+ raise RuntimeError(f"{engine} inspect timed out for {container_name}") from exc
56
+ if result.returncode != 0:
57
+ raise RuntimeError(f"{engine} inspect failed for {container_name}: {result.stderr.strip()}")
58
+ return result.stdout.strip()
59
+
60
+
61
+ def _host_for_engine(engine: str) -> str:
62
+ """Local host identity for the active engine (ADR-M1-1)."""
63
+ return f"h-{engine}-local"
64
+
65
+
66
+ def resolve_identity(container_name: str, engine: str | None = None) -> RuntimeIdentity:
67
+ """Resolve the live ``RuntimeIdentity`` for a named container.
68
+
69
+ The engine reports the container's full id via ``inspect .Id``; combined with
70
+ the engine label and the local host identity this IS the equality key
71
+ (ADR-M1-1). Raises :class:`RuntimeError` if the container is gone.
72
+ """
73
+ engine = engine or _detect_engine()
74
+ runtime_id = _inspect(engine, container_name, "{{.Id}}")
75
+ if not runtime_id:
76
+ raise RuntimeError(f"container {container_name} has no runtime id")
77
+ return RuntimeIdentity(runtime=engine, host_id=_host_for_engine(engine), runtime_id=runtime_id)
78
+
79
+
80
+ def resolve_metadata(container_name: str, engine: str | None = None) -> RuntimeMetadata:
81
+ """Resolve descriptive metadata for a named container (ADR-M1-2)."""
82
+ engine = engine or _detect_engine()
83
+ fmt = "{{json .Config.Labels}}|{{.Created}}|{{.State.StartedAt}}|{{.Image}}"
84
+ raw = _inspect(engine, container_name, fmt)
85
+ labels_part, _, rest = raw.partition("|")
86
+ created, _, rest2 = rest.partition("|")
87
+ started, _, image = rest2.partition("|")
88
+ try:
89
+ labels = json.loads(labels_part) if labels_part else {}
90
+ except json.JSONDecodeError:
91
+ labels = {}
92
+ return RuntimeMetadata.from_inspect(
93
+ {
94
+ "labels": labels if isinstance(labels, dict) else {},
95
+ "created_at": created or None,
96
+ "started_at": started or None,
97
+ "image": image or None,
98
+ },
99
+ name=container_name,
100
+ )
101
+
102
+
103
+ def resolve_pid(container_name: str, engine: str | None = None) -> int:
104
+ """Get the current host PID for a named container."""
105
+ engine = engine or _detect_engine()
106
+ out = _inspect(engine, container_name, "{{.State.Pid}}")
107
+ try:
108
+ pid = int(out)
109
+ except ValueError as exc:
110
+ raise RuntimeError(
111
+ f"{engine} inspect returned non-numeric pid {out!r} for {container_name}"
112
+ ) from exc
113
+ if pid <= 0:
114
+ raise RuntimeError(f"container {container_name} has no running process (pid={pid})")
115
+ return pid
116
+
117
+
118
+ def resolve_process_identity(
119
+ pid: int,
120
+ host_id: str,
121
+ container_name: str | None = None,
122
+ ) -> ProcessRuntimeIdentity:
123
+ """Resolve the live process identity for a PID (ADR-M2 Phase 2.4).
124
+
125
+ The boot time (``/proc/<pid>/stat`` field 22 — start time in clock ticks
126
+ since boot) is read on Linux hosts to disambiguate PID recycling. On
127
+ platforms without a procfs the boot time is ``None`` and the identity
128
+ degrades to a pid-only key (the PID-reuse guard then cannot distinguish a
129
+ recycled PID, so container-addressed faults still carry their runtime id).
130
+ """
131
+ boot_time: int | None = None
132
+ if sys.platform.startswith("linux"):
133
+ stat_path = f"/proc/{pid}/stat"
134
+ try:
135
+ raw = Path(stat_path).read_text()
136
+ # field 22 is starttime; fields 3.. are comm (may contain spaces in
137
+ # parentheses), so split AFTER the closing ')' of the comm field.
138
+ rest = raw.split(")", maxsplit=1)[1]
139
+ fields = rest.split()
140
+ boot_time = int(fields[19]) if len(fields) > 19 else None
141
+ except (OSError, ValueError, IndexError):
142
+ boot_time = None
143
+ return ProcessRuntimeIdentity(
144
+ host_id=host_id,
145
+ pid=pid,
146
+ boot_time=boot_time,
147
+ container_name=container_name,
148
+ )
149
+
150
+
151
+ def resolve_ip(container_name: str, engine: str | None = None) -> str:
152
+ """Get the current IP address for a named container."""
153
+ engine = engine or _detect_engine()
154
+ fmt_ip = "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
155
+ return _inspect(engine, container_name, fmt_ip)
156
+
157
+
158
+ def resolve_container(container_name: str, engine: str | None = None) -> ContainerInfo:
159
+ """Resolve PID, IP, and state for a named container in one inspect pass.
160
+
161
+ May raise :class:`RuntimeError` if the container is missing or stopped.
162
+ """
163
+ engine = engine or _detect_engine()
164
+ pid = resolve_pid(container_name, engine)
165
+ ip = resolve_ip(container_name, engine)
166
+ state = resolve_status(container_name, engine)
167
+ return ContainerInfo(pid=pid, ip_address=ip, state=state)
168
+
169
+
170
+ def resolve_status(container_name: str, engine: str | None = None) -> str:
171
+ """Resolve the current ``State.Status`` for a named container.
172
+
173
+ Unlike :func:`resolve_container` this never inspects the host PID, so it
174
+ also succeeds on VM-contained engines (podman-machine on macOS, Docker
175
+ Desktop) where ``.State.Pid`` is ``0`` while the container is running.
176
+ Raises :class:`RuntimeError` only when the container itself is gone.
177
+ """
178
+ engine = engine or _detect_engine()
179
+ return _inspect(engine, container_name, "{{.State.Status}}")
180
+
181
+
182
+ def resolve_all(
183
+ container_names: tuple[str, ...], engine: str | None = None
184
+ ) -> dict[str, ContainerInfo]:
185
+ """Resolve several containers by name, returning a name → info mapping.
186
+
187
+ Containers that fail to resolve are omitted; no exception is raised.
188
+ """
189
+ engine = engine or _detect_engine()
190
+ result: dict[str, ContainerInfo] = {}
191
+ for name in container_names:
192
+ try:
193
+ result[name] = resolve_container(name, engine)
194
+ except RuntimeError:
195
+ continue
196
+ return result
@@ -0,0 +1,158 @@
1
+ """TopologyService — merge provider fragments into one graph + drift report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ from mayhem.domain.topology import ContainerNode, Edge, NodeKind, TopologyGraph
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ from mayhem.domain.topology import TopologyNode
14
+ from mayhem.topology.providers.base import PartialGraph, TopologyProvider
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DiscoveryResult:
19
+ graph: TopologyGraph
20
+ drift_report: dict[str, object] = field(default_factory=dict)
21
+ partial: bool = False
22
+ errors: tuple[str, ...] = ()
23
+
24
+
25
+ def _find_extra_containers(
26
+ all_live: list[ContainerNode],
27
+ services: Mapping[str, TopologyNode],
28
+ ) -> list[dict[str, str]]:
29
+ """Return live containers whose service isn't in the blueprint."""
30
+ extras: list[dict[str, str]] = []
31
+ for c in all_live:
32
+ svc = c.runtime_metadata.service if c.runtime_metadata else None
33
+ if svc and svc not in services:
34
+ extras.append({"name": c.name, "engine": c.engine, "image": str(c.image or "")})
35
+ return extras
36
+
37
+
38
+ def _find_image_drift(
39
+ services: Mapping[str, TopologyNode],
40
+ live_by_service: Mapping[str, list[ContainerNode]],
41
+ ) -> list[dict[str, str]]:
42
+ """Return services whose running image differs from the blueprint."""
43
+ changed: list[dict[str, str]] = []
44
+ for name, svc in services.items():
45
+ expected = getattr(svc, "image", None)
46
+ if expected is None:
47
+ continue
48
+ for container in live_by_service.get(name, []):
49
+ actual = getattr(container, "image", None)
50
+ if actual and str(expected) != str(actual):
51
+ changed.append({"service": name, "expected": str(expected), "actual": str(actual)})
52
+ return changed
53
+
54
+
55
+ class TopologyService:
56
+ """Blueprint (compose) ↔ live (runtime) merge; drift is recorded, never hidden."""
57
+
58
+ def discover(self, providers: list[TopologyProvider]) -> DiscoveryResult:
59
+ fragments = []
60
+ errors: list[str] = []
61
+ for provider in providers:
62
+ if not provider.is_available():
63
+ errors.append(f"{provider.id}: not available")
64
+ continue
65
+ try:
66
+ fragment = provider.discover()
67
+ fragments.append(fragment)
68
+ except Exception as exc:
69
+ errors.append(f"{provider.id}: discovery failed: {exc}")
70
+
71
+ blueprint = next((f for f in fragments if f.source == "compose"), None)
72
+ live = [f for f in fragments if f.source != "compose"]
73
+
74
+ nodes: dict[str, TopologyNode] = {}
75
+ edges: list[Edge] = []
76
+ for fragment in fragments:
77
+ for node in fragment.nodes:
78
+ nodes.setdefault(node.id, node)
79
+ edges.extend(fragment.edges)
80
+
81
+ drift: dict[str, object] = {}
82
+ if blueprint is not None:
83
+ drift = self._diff(blueprint, live)
84
+ notes = [n for f in fragments for n in f.notes]
85
+ if notes:
86
+ drift["notes"] = list(notes)
87
+
88
+ graph_nodes = tuple(nodes.values())
89
+ known_ids = set(nodes)
90
+ safe_edges = tuple(e for e in edges if e.src in known_ids and e.dst in known_ids)
91
+ graph = _graph(graph_nodes, safe_edges)
92
+
93
+ # Validate every ContainerNode has a container_name (ADR-0020).
94
+ for node in graph_nodes:
95
+ if isinstance(node, ContainerNode) and not node.container_name:
96
+ node_service = node.runtime_metadata.service if node.runtime_metadata else None
97
+ errors.append(
98
+ f"container {node.id} (service={node_service or 'unknown'}) has no "
99
+ "container_name — add 'name:' to docker-compose.yml"
100
+ )
101
+
102
+ return DiscoveryResult(
103
+ graph=graph,
104
+ drift_report=drift,
105
+ partial=bool(errors),
106
+ errors=tuple(errors),
107
+ )
108
+
109
+ @staticmethod
110
+ def _diff(
111
+ blueprint: PartialGraph,
112
+ live_fragments: list[PartialGraph],
113
+ ) -> dict[str, object]:
114
+ services = {n.name: n for n in blueprint.nodes if n.kind is NodeKind.SERVICE}
115
+ live_by_service: dict[str, list[ContainerNode]] = {}
116
+ all_live_containers: list[ContainerNode] = []
117
+ for fragment in live_fragments:
118
+ for node in fragment.nodes:
119
+ if isinstance(node, ContainerNode):
120
+ all_live_containers.append(node)
121
+ svc_name = node.runtime_metadata.service if node.runtime_metadata else None
122
+ if svc_name:
123
+ live_by_service.setdefault(svc_name, []).append(node)
124
+
125
+ # --- matched services ---
126
+ matched_services: list[str] = []
127
+ for name in sorted(set(services) & set(live_by_service)):
128
+ matched_services.append(name)
129
+
130
+ # --- missing services ---
131
+ missing_services = sorted(set(services) - set(live_by_service))
132
+
133
+ # --- extra containers (runtime containers with no matching service) ---
134
+ extra_containers = _find_extra_containers(all_live_containers, services)
135
+
136
+ # --- image drift ---
137
+ changed_images = _find_image_drift(services, live_by_service)
138
+
139
+ # --- state anomalies (stopped, paused, restarting) ---
140
+ unhealthy: list[dict[str, str]] = []
141
+ for name, containers in live_by_service.items():
142
+ for c in containers:
143
+ state = c.state.lower() if c.state else ""
144
+ if state not in ("running", "up", ""):
145
+ unhealthy.append({"service": name, "name": c.name, "state": c.state})
146
+
147
+ report: dict[str, object] = {
148
+ "matched_services": matched_services,
149
+ "missing_services": missing_services,
150
+ "extra_containers": extra_containers,
151
+ "changed_images": changed_images,
152
+ "unhealthy": unhealthy,
153
+ }
154
+ return {k: v for k, v in report.items() if v}
155
+
156
+
157
+ def _graph(nodes: tuple[TopologyNode, ...], edges: tuple[Edge, ...]) -> TopologyGraph:
158
+ return TopologyGraph(nodes=nodes, edges=edges)