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