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,69 @@
1
+ """Environment fingerprinting — the 'same environment' predicate (run contract).
2
+
3
+ A run's ``environment_fingerprint`` must change whenever anything that could
4
+ alter fault outcomes changes: OS, arch, python, controller version, or the
5
+ version/output of any chaos backend present on the host.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ import sys
12
+ from typing import Any
13
+
14
+ from mayhem.toolkit.hashing import digest
15
+ from mayhem.toolkit.tool_runner import ToolResult, run_tool
16
+
17
+ CONTROLLER_VERSION = "0.1.0"
18
+
19
+ _PROBED_TOOLS: tuple[tuple[str, tuple[str, ...]], ...] = (
20
+ ("docker", ("docker", "--version")),
21
+ ("kubectl", ("kubectl", "version", "--client=true", "-o", "json")),
22
+ ("tc", ("tc", "-Version")),
23
+ ("iptables", ("iptables", "--version")),
24
+ ("stress-ng", ("stress-ng", "--version")),
25
+ )
26
+
27
+
28
+ def build_fingerprint(tool_versions: dict[str, str | None]) -> str:
29
+ """Pure hash over the environment descriptor."""
30
+ descriptor: dict[str, Any] = {
31
+ "controller_version": CONTROLLER_VERSION,
32
+ "os": platform.system(),
33
+ "os_release": platform.release(),
34
+ "arch": platform.machine(),
35
+ "python": platform.python_version(),
36
+ "tools": dict(sorted(tool_versions.items())),
37
+ }
38
+ return digest(descriptor)
39
+
40
+
41
+ def collect_tool_versions(
42
+ probed: tuple[tuple[str, tuple[str, ...]], ...] = _PROBED_TOOLS,
43
+ ) -> tuple[dict[str, str | None], list[ToolResult]]:
44
+ """Probe known backends; missing tools are recorded as None, never fatal."""
45
+ versions: dict[str, str | None] = {}
46
+ evidence: list[ToolResult] = []
47
+ for name, argv in probed:
48
+ try:
49
+ result = run_tool(argv, timeout_s=10)
50
+ except Exception: # a missing tool is an expected outcome
51
+ versions[name] = None
52
+ continue
53
+ if result.succeeded or result.exit_code is not None:
54
+ first_line = result.stdout.strip().splitlines()
55
+ versions[name] = first_line[0][:200] if first_line else result.stderr[:200]
56
+ else:
57
+ versions[name] = None
58
+ evidence.append(result)
59
+ return versions, evidence
60
+
61
+
62
+ def current_fingerprint() -> str:
63
+ versions, _ = collect_tool_versions()
64
+ return build_fingerprint(versions)
65
+
66
+
67
+ def interpreter_marker() -> str:
68
+ """Cheap identity of this python process; used by agent heartbeats."""
69
+ return f"python-{sys.version_info.major}.{sys.version_info.minor}"
@@ -0,0 +1,32 @@
1
+ """Canonical hashing helpers — one definition of 'same input' for the whole system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def canonical_json(value: Any) -> str:
11
+ """Deterministic JSON encoding: sorted keys, no whitespace, stable unicode."""
12
+ return json.dumps(
13
+ value,
14
+ sort_keys=True,
15
+ separators=(",", ":"),
16
+ ensure_ascii=False,
17
+ default=str,
18
+ )
19
+
20
+
21
+ def sha256_hex(text: str) -> str:
22
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
23
+
24
+
25
+ def digest(value: Any) -> str:
26
+ """Hash any JSON-compatible value canonically."""
27
+ return sha256_hex(canonical_json(value))
28
+
29
+
30
+ def digest_mapping(mapping: dict[str, Any]) -> str:
31
+ """Hash a mapping by its canonical form."""
32
+ return digest(dict(mapping))
@@ -0,0 +1,11 @@
1
+ tool: docker
2
+ provides: [container.kill, container.pause, container.exec]
3
+ probe:
4
+ cmd: [docker, --version]
5
+ version_regex: 'Docker version (?P<v>[\d.]+)'
6
+ privilege: sudo_patterns
7
+ risk: high
8
+ fallback_groups:
9
+ container.kill: fallback_1
10
+ container.pause: fallback_1
11
+ container.exec: primary
@@ -0,0 +1,11 @@
1
+ tool: podman
2
+ provides: [container.kill, container.pause, container.exec]
3
+ probe:
4
+ cmd: [podman, --version]
5
+ version_regex: 'podman version (?P<v>[\d.]+)'
6
+ privilege: none
7
+ risk: high
8
+ fallback_groups:
9
+ container.kill: primary
10
+ container.pause: primary
11
+ container.exec: fallback_1
@@ -0,0 +1,11 @@
1
+ tool: stress-ng
2
+ provides: [cpu.pressure, mem.pressure, io.stress]
3
+ probe:
4
+ cmd: [stress-ng, --version]
5
+ version_regex: 'version\s+(?P<v>[\d.]+)'
6
+ privilege: none
7
+ risk: medium
8
+ fallback_groups:
9
+ cpu.pressure: primary
10
+ mem.pressure: primary
11
+ io.stress: primary
@@ -0,0 +1,11 @@
1
+ tool: tc
2
+ provides: [net.latency, net.loss, net.partition]
3
+ probe:
4
+ cmd: [tc, -Version]
5
+ version_regex: 'tc utility, iproute2-(?P<v>[\w.\-]+)'
6
+ privilege: root
7
+ risk: high
8
+ fallback_groups:
9
+ net.latency: primary
10
+ net.loss: primary
11
+ net.partition: fallback_1
@@ -0,0 +1,10 @@
1
+ tool: toxiproxy
2
+ provides: [net.latency, net.partition]
3
+ probe:
4
+ cmd: [toxiproxy-cli, --version]
5
+ version_regex: '(?P<v>\d+\.\d+\.\d+)'
6
+ privilege: none
7
+ risk: medium
8
+ fallback_groups:
9
+ net.latency: fallback_1
10
+ net.partition: primary
@@ -0,0 +1,185 @@
1
+ """Capability registry — declarative manifests + probing + fallback chains.
2
+
3
+ ADR-0004: every external tool is declared as data (a manifest), probed once at
4
+ handshake, and resolved per capability through an ordered fallback chain.
5
+ ADR-0011: this registry *is* the extension point — new tool = new manifest +
6
+ adapter; agents never hardcode binaries.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from enum import StrEnum
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
17
+
18
+ from mayhem.domain.errors import SchemaValidationError
19
+ from mayhem.domain.risks import RiskLevel
20
+ from mayhem.toolkit.tool_runner import run_tool
21
+
22
+ BUILTIN_MANIFESTS_DIR = Path(__file__).parent / "manifests"
23
+
24
+
25
+ class Privilege(StrEnum):
26
+ NONE = "none"
27
+ SUDO_PATTERNS = "sudo_patterns"
28
+ ROOT = "root"
29
+
30
+
31
+ _FALLBACK_NAME = re.compile(r"^(primary|fallback_(?P<n>[1-9]\d*))$")
32
+
33
+
34
+ def _fallback_rank(name: str) -> int:
35
+ if name == "primary":
36
+ return 0
37
+ match = _FALLBACK_NAME.match(name)
38
+ if match is None:
39
+ raise SchemaValidationError(
40
+ "fallback_rank", f"invalid slot name {name!r} (primary|fallback_N)"
41
+ )
42
+ return int(match.group("n"))
43
+
44
+
45
+ class CapabilityManifest(BaseModel):
46
+ """One external tool, declared as data."""
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ tool: str
51
+ provides: tuple[str, ...]
52
+ probe_cmd: tuple[str, ...]
53
+ version_regex: str
54
+ privilege: Privilege = Privilege.NONE
55
+ risk: RiskLevel = RiskLevel.MEDIUM
56
+ fallback_slots: dict[str, str] = Field(default_factory=dict) # capability → primary|fallback_N
57
+
58
+ @field_validator("provides")
59
+ @classmethod
60
+ def _nonempty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
61
+ if not value:
62
+ raise SchemaValidationError("manifest", "tool must provide ≥1 capability")
63
+ return value
64
+
65
+ @field_validator("probe_cmd")
66
+ @classmethod
67
+ def _argv_only(cls, value: tuple[str, ...]) -> tuple[str, ...]:
68
+ if not value or any(not part for part in value):
69
+ raise SchemaValidationError("manifest", "probe_cmd must be a non-empty argv list")
70
+ return value
71
+
72
+ @field_validator("fallback_slots")
73
+ @classmethod
74
+ def _slots_reference_provided(
75
+ cls, value: dict[str, str], info: ValidationInfo
76
+ ) -> dict[str, str]:
77
+ provides = info.data.get("provides", ())
78
+ for capability, slot in value.items():
79
+ if capability not in provides:
80
+ raise SchemaValidationError(
81
+ "fallback_rank",
82
+ f"slot for {capability!r} but tool does not provide it",
83
+ )
84
+ _fallback_rank(slot) # validates name shape
85
+ return value
86
+
87
+ def rank_for(self, capability: str) -> int | None:
88
+ slot = self.fallback_slots.get(capability)
89
+ return None if slot is None else _fallback_rank(slot)
90
+
91
+
92
+ class ProbedTool(BaseModel):
93
+ model_config = ConfigDict(frozen=True)
94
+
95
+ manifest: CapabilityManifest
96
+ version: str
97
+
98
+
99
+ class CapabilityReport(BaseModel):
100
+ """Cached probe outcome per host. Refreshed explicitly, never mid-run."""
101
+
102
+ model_config = ConfigDict(frozen=True)
103
+
104
+ host: str
105
+ tools: tuple[ProbedTool, ...] = ()
106
+
107
+ def has(self, capability_id: str) -> bool:
108
+ return any(capability_id in t.manifest.provides for t in self.tools)
109
+
110
+
111
+ class ToolRegistry:
112
+ """Registry of manifests + cached probes + ranked fallback resolution."""
113
+
114
+ def __init__(self, manifests: tuple[CapabilityManifest, ...] = ()) -> None:
115
+ self._manifests: dict[str, CapabilityManifest] = {}
116
+ self._reports: dict[str, CapabilityReport] = {}
117
+ for manifest in manifests:
118
+ self.register(manifest)
119
+
120
+ def register(self, manifest: CapabilityManifest) -> None:
121
+ if manifest.tool in self._manifests:
122
+ raise SchemaValidationError("registry", f"duplicate tool manifest: {manifest.tool}")
123
+ self._manifests[manifest.tool] = manifest
124
+ self._reports.clear() # new tool may satisfy capabilities on known hosts
125
+
126
+ @property
127
+ def manifests(self) -> tuple[CapabilityManifest, ...]:
128
+ return tuple(self._manifests.values())
129
+
130
+ def probe(self, host: str = "local") -> CapabilityReport:
131
+ """Run each manifest's probe_cmd; keep only tools that answer with a version."""
132
+ probed: list[ProbedTool] = []
133
+ for manifest in sorted(self._manifests.values(), key=lambda m: m.tool):
134
+ try:
135
+ result = run_tool(list(manifest.probe_cmd), timeout_s=10.0)
136
+ except Exception:
137
+ continue # binary missing / not executable → simply not capable here
138
+ match = re.search(manifest.version_regex, result.stdout + result.stderr)
139
+ if result.succeeded and match:
140
+ probed.append(ProbedTool(manifest=manifest, version=match.group("v")))
141
+ report = CapabilityReport(host=host, tools=tuple(probed))
142
+ self._reports[host] = report
143
+ return report
144
+
145
+ def report_for(self, host: str = "local") -> CapabilityReport:
146
+ return self._reports.get(host) or self.probe(host)
147
+
148
+ def refresh(self, host: str = "local") -> CapabilityReport:
149
+ """Explicit re-probe; never called implicitly mid-run."""
150
+ return self.probe(host)
151
+
152
+ def resolve(self, capability_id: str, host: str = "local") -> tuple[CapabilityManifest, ...]:
153
+ """Ranked candidates for a capability, filtered by the host's cached probe."""
154
+ candidates: list[tuple[int, CapabilityManifest]] = []
155
+ for probed in self.report_for(host).tools:
156
+ manifest = probed.manifest
157
+ if capability_id not in manifest.provides:
158
+ continue
159
+ rank = manifest.rank_for(capability_id)
160
+ candidates.append((rank if rank is not None else 10**6, manifest))
161
+ candidates.sort(key=lambda pair: pair[0])
162
+ return tuple(m for _, m in candidates)
163
+
164
+
165
+ def manifest_from_yaml(path: Path) -> CapabilityManifest:
166
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
167
+ slots_raw = raw.pop("fallback_groups", {}) or {}
168
+ slots = {capability: slot for capability, slot in slots_raw.items() if slot is not None}
169
+ return CapabilityManifest(
170
+ tool=raw["tool"],
171
+ provides=tuple(raw["provides"]),
172
+ probe_cmd=tuple(raw["probe"]["cmd"]),
173
+ version_regex=raw["probe"]["version_regex"],
174
+ privilege=Privilege(raw.get("privilege", "none")),
175
+ risk=RiskLevel(raw.get("risk", "medium")),
176
+ fallback_slots=slots,
177
+ )
178
+
179
+
180
+ def default_registry() -> ToolRegistry:
181
+ registry = ToolRegistry()
182
+ if BUILTIN_MANIFESTS_DIR.is_dir():
183
+ for path in sorted(BUILTIN_MANIFESTS_DIR.glob("*.yaml")):
184
+ registry.register(manifest_from_yaml(path))
185
+ return registry
@@ -0,0 +1,129 @@
1
+ """Tool execution with full evidence capture (tool-run contract).
2
+
3
+ Every external invocation goes through ``run_tool`` so the run record can
4
+ reconstruct: exact argv, environment digest, host, exit code, duration, and
5
+ (truncation-bounded) output. Nothing else in the codebase may spawn processes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+ import subprocess
13
+ import time
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from mayhem.domain.errors import DomainError
18
+ from mayhem.toolkit.hashing import digest_mapping
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+ DEFAULT_MAX_OUTPUT_BYTES = 1_048_576 # 1 MiB per stream
24
+
25
+
26
+ class ToolError(DomainError):
27
+ """A tool invocation could not be executed at all."""
28
+
29
+
30
+ class ToolTimeoutError(ToolError):
31
+ """The tool exceeded its time budget and was killed."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ToolResult:
36
+ argv: tuple[str, ...]
37
+ argv_digest: str
38
+ env_digest: str
39
+ host: str
40
+ cwd: str | None
41
+ exit_code: int | None
42
+ duration_ms: int
43
+ stdout: str
44
+ stderr: str
45
+ truncated: bool
46
+
47
+ @property
48
+ def succeeded(self) -> bool:
49
+ return self.exit_code == 0
50
+
51
+ def to_row(self, *, invocation_ref: str | None = None) -> dict[str, Any]:
52
+ """Shape matching the tool_runs table (stdout/stderr refs added by caller)."""
53
+ return {
54
+ "id": self.argv_digest,
55
+ "invocation_ref": invocation_ref,
56
+ "argv_digest": self.argv_digest,
57
+ "argv_json": list(self.argv),
58
+ "env_digest": self.env_digest,
59
+ "host": self.host,
60
+ "exit_code": self.exit_code,
61
+ "duration_ms": self.duration_ms,
62
+ "truncated": int(self.truncated),
63
+ "stdout_ref": None,
64
+ "stderr_ref": None,
65
+ }
66
+
67
+
68
+ def _truncate(data: bytes, limit: int) -> tuple[str, bool]:
69
+ truncated = len(data) > limit
70
+ text = data[:limit].decode("utf-8", errors="replace")
71
+ if truncated:
72
+ text += "\n...[TRUNCATED]"
73
+ return text, truncated
74
+
75
+
76
+ def run_tool(
77
+ argv: list[str] | tuple[str, ...],
78
+ *,
79
+ env: dict[str, str] | None = None,
80
+ cwd: Path | str | None = None,
81
+ timeout_s: float | None = None,
82
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
83
+ stdin_data: str | None = None,
84
+ ) -> ToolResult:
85
+ """Run a tool synchronously; never raises for non-zero exit codes.
86
+
87
+ Raises ToolError only when the process could not be started or timed out —
88
+ those are harness failures, not tool failures, and callers must record them.
89
+ """
90
+ argv_tuple = tuple(argv)
91
+ if not argv_tuple:
92
+ raise ToolError("tool_runner", "refusing to execute empty argv")
93
+ effective_env = dict(env) if env is not None else dict(os.environ)
94
+ started = time.monotonic()
95
+ try:
96
+ completed = subprocess.run(
97
+ argv_tuple,
98
+ capture_output=True,
99
+ text=False,
100
+ env=effective_env,
101
+ cwd=str(cwd) if cwd else None,
102
+ timeout=timeout_s,
103
+ input=stdin_data.encode("utf-8") if stdin_data is not None else b"",
104
+ check=False,
105
+ )
106
+ except subprocess.TimeoutExpired as exc:
107
+ raise ToolTimeoutError(
108
+ "tool_runner",
109
+ f"{argv_tuple[0]} exceeded timeout of {timeout_s}s",
110
+ ) from exc
111
+ except OSError as exc:
112
+ raise ToolError("tool_runner", f"failed to start {argv_tuple[0]}: {exc}") from exc
113
+ duration_ms = int((time.monotonic() - started) * 1000)
114
+
115
+ stdout_text, stdout_truncated = _truncate(completed.stdout or b"", max_output_bytes)
116
+ stderr_text, stderr_truncated = _truncate(completed.stderr or b"", max_output_bytes)
117
+
118
+ return ToolResult(
119
+ argv=argv_tuple,
120
+ argv_digest=digest_mapping({"argv": list(argv_tuple)}),
121
+ env_digest=digest_mapping(effective_env),
122
+ host=platform.node(),
123
+ cwd=str(cwd) if cwd else None,
124
+ exit_code=completed.returncode,
125
+ duration_ms=duration_ms,
126
+ stdout=stdout_text,
127
+ stderr=stderr_text,
128
+ truncated=stdout_truncated or stderr_truncated,
129
+ )
@@ -0,0 +1,10 @@
1
+ """Topology discovery pipeline (ADR-0006, ADR-0013).
2
+
3
+ Providers produce :class:`PartialGraph` fragments; the TopologyService merges
4
+ blueprint (compose) with live truth (container engines / host processes) and
5
+ emits a drift report.
6
+ """
7
+
8
+ from mayhem.topology.service import DiscoveryResult, TopologyService
9
+
10
+ __all__ = ["DiscoveryResult", "TopologyService"]
File without changes
@@ -0,0 +1,60 @@
1
+ """Adapter registry — lookup and instantiate RuntimeAdapter by engine name.
2
+
3
+ ``best_effort(engine)`` returns the first available adapter, preferring
4
+ Docker when *engine* is None (preserving existing semantics from
5
+ ``ContainerRuntimeProvider.best_effort``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from mayhem.domain.runtime_adapter import RuntimeAdapter
14
+
15
+ _REGISTRY: dict[str, type[RuntimeAdapter]] = {}
16
+
17
+
18
+ def register(name: str, cls: type[RuntimeAdapter]) -> None:
19
+ """Register an adapter class under *name*."""
20
+ _REGISTRY[name] = cls
21
+
22
+
23
+ def best_effort(engine: str | None = None) -> RuntimeAdapter | None:
24
+ """Return an adapter for *engine*, or the first available one.
25
+
26
+ When *engine* is None the lookup order is ``["docker", "podman"]``
27
+ so Docker is preferred, matching the prior
28
+ ``ContainerRuntimeProvider.best_effort`` behaviour.
29
+ """
30
+ if engine is not None:
31
+ cls = _REGISTRY.get(engine)
32
+ if cls is not None:
33
+ adapter = cls(engine) # type: ignore[call-arg]
34
+ if adapter.is_available():
35
+ return adapter
36
+ return None
37
+
38
+ for candidate in ("docker", "podman"):
39
+ cls = _REGISTRY.get(candidate)
40
+ if cls is not None:
41
+ adapter = cls()
42
+ if adapter.is_available():
43
+ return adapter
44
+ return None
45
+
46
+
47
+ # Auto-register on import.
48
+ from mayhem.topology.providers.docker_adapter import DockerAdapter # noqa: E402
49
+ from mayhem.topology.providers.podman_adapter import PodmanAdapter # noqa: E402
50
+
51
+ register("docker", DockerAdapter)
52
+ register("podman", PodmanAdapter)
53
+
54
+ # ── Kubernetes adapter (ADR-M7-1) ───────────────────────────────────────────
55
+ # Registered so ``best_effort("kubernetes")`` can locate the contract.
56
+ # ``KubernetesAdapter.is_available()`` always returns ``False``; the adapter
57
+ # is purely interface-level until a live-cluster driver ships (M8).
58
+ from mayhem.domain.k8s_adapter import KubernetesAdapter # noqa: E402
59
+
60
+ register("kubernetes", KubernetesAdapter)
@@ -0,0 +1,31 @@
1
+ """Provider seam: every discovery source implements :class:`TopologyProvider`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ from mayhem.domain.topology import Edge, TopologyNode
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PartialGraph:
14
+ """One provider's fragment of the world; merged by the TopologyService."""
15
+
16
+ source: str # provider id, e.g. "compose", "docker", "podman", "host"
17
+ nodes: tuple[TopologyNode, ...] = ()
18
+ edges: tuple[Edge, ...] = ()
19
+ notes: tuple[str, ...] = field(default_factory=tuple) # provider-level caveats
20
+
21
+
22
+ @runtime_checkable
23
+ class TopologyProvider(Protocol):
24
+ """``async discover()`` is the contract (ADR-0006); sync MVP uses discover_sync."""
25
+
26
+ @property
27
+ def id(self) -> str: ...
28
+
29
+ def is_available(self) -> bool: ...
30
+
31
+ def discover(self) -> PartialGraph: ...