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
mayhem/cli/topology.py ADDED
@@ -0,0 +1,127 @@
1
+ """``topology`` group: discovery and drift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import click
10
+
11
+ from mayhem.cli.resolver import make_group
12
+
13
+ if TYPE_CHECKING:
14
+ from mayhem.cli.context import CliContext
15
+
16
+ topology = make_group("topology", "Discover and inspect target-system topology.")
17
+
18
+ _COMPOSE_CANDIDATES = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
19
+
20
+
21
+ @topology.command("discover")
22
+ @click.option(
23
+ "--compose",
24
+ "compose_path",
25
+ default=None,
26
+ help="Compose file, directory containing one, or omit to auto-detect in cwd.",
27
+ )
28
+ @click.pass_context
29
+ def discover(ctx: click.Context, compose_path: str | None) -> None:
30
+ """Run the topology provider pipeline and print graph + drift JSON."""
31
+ from mayhem.cli.app import _STATE
32
+ from mayhem.topology.providers.adapter_registry import best_effort as runtime_best_effort
33
+ from mayhem.topology.providers.compose import ComposeFileProvider
34
+ from mayhem.topology.service import TopologyService
35
+
36
+ resolved = _resolve_compose(compose_path)
37
+ engine = _resolve_engine(str(_STATE.get("engine", "")))
38
+
39
+ providers: list = []
40
+
41
+ # Compose blueprint — scoped runtime match.
42
+ if resolved is not None:
43
+ compose_provider = ComposeFileProvider(resolved)
44
+ runtime_provider = runtime_best_effort(engine)
45
+ if runtime_provider is not None:
46
+ runtime_provider.filter_by_compose(
47
+ compose_provider.project_name,
48
+ compose_provider.service_names,
49
+ )
50
+ providers = [p for p in (compose_provider, runtime_provider) if p is not None]
51
+ else:
52
+ # No compose file — fall back to mayhem.yaml target.containers,
53
+ # or discover all running containers.
54
+ from mayhem.config import load_config
55
+ from mayhem.domain.errors import SchemaValidationError
56
+
57
+ cli_ctx: CliContext | None = ctx.obj
58
+ try:
59
+ config, _sources = load_config(
60
+ config_path=cli_ctx.config if cli_ctx else None,
61
+ profile=cli_ctx.profile if cli_ctx else None,
62
+ )
63
+ except (OSError, ValueError, SchemaValidationError):
64
+ config = None
65
+
66
+ target_names: list[str] = []
67
+ if config is not None:
68
+ target_names = list(config.target.containers)
69
+
70
+ runtime_provider = runtime_best_effort(engine)
71
+ if runtime_provider is not None and target_names:
72
+ runtime_provider.filter_by_names(target_names)
73
+
74
+ providers = [p for p in (runtime_provider,) if p is not None]
75
+
76
+ if not providers:
77
+ raise click.ClickException(
78
+ "No docker-compose file found and no target containers "
79
+ "configured. Pass --compose <path> or add target.containers "
80
+ "to mayhem.yaml."
81
+ )
82
+
83
+ result = TopologyService().discover(providers)
84
+ click.echo(
85
+ json.dumps(
86
+ {
87
+ "graph": result.graph.model_dump(mode="json"),
88
+ "drift": result.drift_report,
89
+ },
90
+ indent=2,
91
+ )
92
+ )
93
+
94
+
95
+ def _resolve_compose(explicit: str | None) -> str | None:
96
+ """Resolve the compose file from user input.
97
+
98
+ Accepts three forms:
99
+ * ``None`` or empty — auto-detect in the current working directory.
100
+ * A **directory** path — search for compose candidates inside it.
101
+ * A **file** path — use it directly (returns ``None`` if missing).
102
+ """
103
+ if not explicit:
104
+ return _find_compose_in(Path.cwd())
105
+
106
+ target = Path(explicit)
107
+ if target.is_dir():
108
+ return _find_compose_in(target)
109
+ if target.is_file():
110
+ return str(target)
111
+ return None
112
+
113
+
114
+ def _find_compose_in(directory: Path) -> str | None:
115
+ """Return the first matching compose file in *directory*, or ``None``."""
116
+ for name in _COMPOSE_CANDIDATES:
117
+ path = directory / name
118
+ if path.is_file():
119
+ return str(path)
120
+ return None
121
+
122
+
123
+ def _resolve_engine(flag_value: str) -> str | None:
124
+ """Turn the CLI flag value into an explicit engine, or None for auto-detect."""
125
+ if flag_value in ("docker", "podman"):
126
+ return flag_value
127
+ return None
mayhem/config.py ADDED
@@ -0,0 +1,208 @@
1
+ """Layered, versioned, strictly-validated configuration (ADR-0008).
2
+
3
+ Layering order (later wins):
4
+ built-in defaults → ``mayhem.yaml`` → profile overlay (``mayhem.{profile}.yaml``)
5
+ → ``MAYHEM_*`` environment variables (limited allowlist) → CLI flags.
6
+
7
+ Every document carries ``apiVersion: mayhem/v1``; unknown versions and unknown
8
+ keys are rejected loudly. The effective merged config is snapshotted into the
9
+ store so every run records exactly what configuration produced it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ import yaml
21
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
22
+
23
+ from mayhem.domain.common import utc_now
24
+ from mayhem.domain.errors import SchemaValidationError
25
+ from mayhem.domain.experiments import BlastRadiusBudget, ManiacCfg
26
+ from mayhem.domain.risks import RiskLevel
27
+
28
+ API_VERSION: Literal["mayhem/v1"] = "mayhem/v1"
29
+ ENV_PREFIX = "MAYHEM_"
30
+ _ENV_ALLOWED = {
31
+ "STORAGE_PATH": "storage.path",
32
+ "ARTIFACTS_DIR": "storage.artifacts_dir",
33
+ "LOG_LEVEL": "log_level",
34
+ }
35
+
36
+
37
+ class PolicyCfg(BaseModel):
38
+ """G1 config-policy gate (ADR-0012 §4)."""
39
+
40
+ model_config = ConfigDict(frozen=True, extra="forbid")
41
+
42
+ allow_faults: frozenset[str] | None = None # None ⇒ whole catalog
43
+ deny_faults: frozenset[str] = frozenset()
44
+ risk_ceiling: RiskLevel | None = None
45
+ allow_critical: bool = False # config-side half of the critical opt-in
46
+
47
+
48
+ class StorageCfg(BaseModel):
49
+ model_config = ConfigDict(frozen=True, extra="forbid")
50
+
51
+ path: str = "mayhem.db"
52
+ artifacts_dir: str = ".mayhem/artifacts"
53
+
54
+
55
+ class ToolkitOverrides(BaseModel):
56
+ """Pin binaries/versions per tool (architecture/toolkit.md §5)."""
57
+
58
+ model_config = ConfigDict(frozen=True, extra="forbid")
59
+
60
+ binaries: dict[str, str] = Field(default_factory=dict)
61
+
62
+
63
+ class TargetCfg(BaseModel):
64
+ """Explicit container targets when running without a compose file."""
65
+
66
+ model_config = ConfigDict(frozen=True, extra="forbid")
67
+
68
+ containers: list[str] = Field(default_factory=list)
69
+
70
+
71
+ class MayhemConfigBase(BaseModel):
72
+ model_config = ConfigDict(frozen=True, extra="forbid")
73
+
74
+ api_version: Literal["mayhem/v1"] = Field(default=API_VERSION, serialization_alias="apiVersion")
75
+ policy: PolicyCfg = Field(default_factory=PolicyCfg)
76
+ blast_radius: BlastRadiusBudget = Field(default_factory=BlastRadiusBudget)
77
+ storage: StorageCfg = Field(default_factory=StorageCfg)
78
+ toolkit: ToolkitOverrides = Field(default_factory=ToolkitOverrides)
79
+ runtime: Literal["docker", "podman"] = "docker"
80
+ target: TargetCfg = Field(default_factory=TargetCfg)
81
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
82
+ # ADR-M5-1 fallback for `mayhem maniac` when the drill spec's own
83
+ # `config.maniac` block is absent (spec-level settings win).
84
+ maniac: ManiacCfg = Field(default_factory=ManiacCfg)
85
+
86
+
87
+ # Alias kept for readability at call sites.
88
+ MayhemConfig = MayhemConfigBase
89
+
90
+
91
+ def _deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> None:
92
+ for key, value in src.items():
93
+ if isinstance(dst.get(key), dict) and isinstance(value, dict):
94
+ _deep_merge(dst[key], value)
95
+ else:
96
+ dst[key] = value
97
+
98
+
99
+ def _read_document(path: Path) -> dict[str, Any]:
100
+ if not path.exists():
101
+ raise SchemaValidationError("config", f"config file not found: {path}")
102
+ try:
103
+ data = yaml.safe_load(path.read_text()) or {}
104
+ except yaml.YAMLError as exc:
105
+ raise SchemaValidationError("config", f"invalid YAML in {path}: {exc}") from None
106
+ if not isinstance(data, dict):
107
+ raise SchemaValidationError("config", f"{path} must contain a mapping")
108
+ version = data.get("apiVersion")
109
+ if version != API_VERSION:
110
+ raise SchemaValidationError(
111
+ "config", f"apiVersion must be {API_VERSION!r}, got {version!r}"
112
+ )
113
+ return data
114
+
115
+
116
+ def _apply_env(data: dict[str, Any], env: dict[str, str]) -> dict[str, Any]:
117
+ merged: dict[str, Any] = json.loads(json.dumps(data)) # deep-copy defaults away
118
+ for suffix, dotted in _ENV_ALLOWED.items():
119
+ value = env.get(ENV_PREFIX + suffix)
120
+ if value is None:
121
+ continue
122
+ target = merged
123
+ keys = dotted.split(".")
124
+ for key in keys[:-1]:
125
+ target = target.setdefault(key, {})
126
+ target[keys[-1]] = value
127
+ return merged
128
+
129
+
130
+ def load_config(
131
+ *,
132
+ config_path: str | Path | None = None,
133
+ profile: str | None = None,
134
+ cli_overrides: dict[str, Any] | None = None,
135
+ environ: dict[str, str] | None = None,
136
+ skip_default_file_if_spec: str | Path | None = None,
137
+ ) -> tuple[MayhemConfig, dict[str, str]]:
138
+ """Return ``(effective config, source map)``.
139
+
140
+ The source map records which layer last supplied each top-level section —
141
+ provenance is part of the snapshot.
142
+
143
+ ``skip_default_file_if_spec`` guards against the default config file
144
+ (``mayhem.yaml``) doubling as the drill-spec being run: when a spec is
145
+ executing from ``mayhem.yaml`` and no explicit ``--config`` was given, the
146
+ file layer is skipped (pure defaults apply) instead of re-parsing the drill
147
+ spec as a strictly-forbidden config document.
148
+ """
149
+ env = dict(os.environ if environ is None else environ)
150
+ sources: dict[str, str] = dict.fromkeys(
151
+ ("policy", "blast_radius", "storage", "toolkit", "log_level"),
152
+ "defaults",
153
+ )
154
+ merged: dict[str, Any] = {"api_version": API_VERSION}
155
+
156
+ def absorb(layer_data: dict[str, Any], layer_name: str) -> None:
157
+ for key, value in layer_data.items():
158
+ if key == "apiVersion":
159
+ continue
160
+ field_name = "api_version" if key == "apiVersion" else key
161
+ existing = merged.get(field_name)
162
+ if isinstance(existing, dict) and isinstance(value, dict):
163
+ _deep_merge(existing, value)
164
+ else:
165
+ merged[field_name] = value
166
+ sources[field_name] = layer_name
167
+
168
+ base_path = Path(config_path) if config_path else Path("mayhem.yaml")
169
+ skip_file = (
170
+ config_path is None
171
+ and skip_default_file_if_spec is not None
172
+ and base_path.resolve() == Path(skip_default_file_if_spec).resolve()
173
+ )
174
+ if not skip_file and (config_path or base_path.exists()):
175
+ absorb(_read_document(base_path), "file")
176
+ if profile:
177
+ overlay = base_path.parent / f"mayhem.{profile}.yaml"
178
+ absorb(_read_document(overlay), f"profile:{profile}")
179
+ absorb(_apply_env({}, env), "env")
180
+ if cli_overrides:
181
+ absorb({k: v for k, v in cli_overrides.items() if v is not None}, "cli")
182
+
183
+ try:
184
+ return MayhemConfig.model_validate(merged), sources
185
+ except ValidationError as exc:
186
+ raise SchemaValidationError("config", f"invalid configuration: {exc}") from None
187
+
188
+
189
+ def snapshot_id_for(config: MayhemConfig) -> str:
190
+ canonical = json.dumps(config.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
191
+ return "cfg-" + hashlib.sha256(canonical.encode()).hexdigest()[:12]
192
+
193
+
194
+ def save_snapshot(store: Any, config: MayhemConfig, sources: dict[str, str]) -> str:
195
+ """Persist the effective config; idempotent per identical content."""
196
+ snapshot_id = snapshot_id_for(config)
197
+ with store.write() as conn:
198
+ conn.execute(
199
+ "INSERT OR IGNORE INTO config_snapshots (id, resolved_json, source_map, created_at)"
200
+ " VALUES (?, ?, ?, ?)",
201
+ (
202
+ snapshot_id,
203
+ config.model_dump_json(),
204
+ json.dumps(sources, sort_keys=True),
205
+ utc_now().isoformat(),
206
+ ),
207
+ )
208
+ return snapshot_id
@@ -0,0 +1 @@
1
+ """Controller (Phase 3): planner, executor, janitor, watchdog, maniac."""