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,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,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: ...
|