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