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,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
|
+
)
|