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,120 @@
|
|
|
1
|
+
"""Execution context model (ADR-0014).
|
|
2
|
+
|
|
3
|
+
A fault must declare *where* it runs — not just *what* it targets. The execution
|
|
4
|
+
context distinguishes host-level, container-level, process-level, network-
|
|
5
|
+
namespace, and remote execution so the planner can validate feasibility before
|
|
6
|
+
any mutation occurs.
|
|
7
|
+
|
|
8
|
+
Backward compatibility: the ``execution`` field on ``InjectFault`` is optional.
|
|
9
|
+
When absent the planner infers the context from the target node kind, which
|
|
10
|
+
matches the pre-0.2.0 behaviour.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict
|
|
19
|
+
|
|
20
|
+
from mayhem.domain.errors import InvariantViolationError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from mayhem.domain.execution_loci import ThreeLocusContext
|
|
24
|
+
from mayhem.domain.topology import NodeKind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExecutionContext(StrEnum):
|
|
28
|
+
"""Where a fault is injected relative to the target."""
|
|
29
|
+
|
|
30
|
+
HOST = "host"
|
|
31
|
+
CONTAINER = "container"
|
|
32
|
+
PROCESS = "process"
|
|
33
|
+
NETWORK_NAMESPACE = "network_namespace"
|
|
34
|
+
REMOTE_HOST = "remote_host"
|
|
35
|
+
REMOTE_CONTAINER = "remote_container"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Which topology node-kinds are compatible with each context.
|
|
39
|
+
# The planner uses this to reject impossible combinations *before* execution.
|
|
40
|
+
_CONTEXT_COMPATIBILITY: dict[ExecutionContext, frozenset[NodeKind]] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_compatibility() -> dict[ExecutionContext, frozenset[NodeKind]]:
|
|
44
|
+
from mayhem.domain.topology import NodeKind # local import to break cycles
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
ExecutionContext.HOST: frozenset({NodeKind.HOST}),
|
|
48
|
+
ExecutionContext.CONTAINER: frozenset({NodeKind.CONTAINER, NodeKind.SERVICE}),
|
|
49
|
+
ExecutionContext.PROCESS: frozenset({NodeKind.PROCESS, NodeKind.CONTAINER, NodeKind.HOST}),
|
|
50
|
+
ExecutionContext.NETWORK_NAMESPACE: frozenset({NodeKind.CONTAINER, NodeKind.HOST}),
|
|
51
|
+
ExecutionContext.REMOTE_HOST: frozenset({NodeKind.HOST, NodeKind.EXTERNAL_DEPENDENCY}),
|
|
52
|
+
ExecutionContext.REMOTE_CONTAINER: frozenset({NodeKind.CONTAINER, NodeKind.SERVICE}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _compatibility() -> dict[ExecutionContext, frozenset[NodeKind]]:
|
|
57
|
+
"""Lazy-init the compatibility map on first access."""
|
|
58
|
+
if not _CONTEXT_COMPATIBILITY:
|
|
59
|
+
_CONTEXT_COMPATIBILITY.update(_build_compatibility())
|
|
60
|
+
return _CONTEXT_COMPATIBILITY
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ExecutionContextSpec(BaseModel):
|
|
64
|
+
"""Declared execution context for a fault step.
|
|
65
|
+
|
|
66
|
+
Optional on ``InjectFault`` — when absent the planner infers from the
|
|
67
|
+
resolved target node kinds.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(frozen=True)
|
|
71
|
+
|
|
72
|
+
context: ExecutionContext
|
|
73
|
+
|
|
74
|
+
def assert_compatible(self, node_kinds: frozenset[NodeKind]) -> None:
|
|
75
|
+
"""Raise if *none* of the resolved target node-kinds are compatible.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
InvariantViolationError: When no target kind matches this context.
|
|
79
|
+
"""
|
|
80
|
+
compat = _compatibility()
|
|
81
|
+
allowed = compat.get(self.context, frozenset())
|
|
82
|
+
if not allowed.intersection(node_kinds):
|
|
83
|
+
kinds_str = ", ".join(sorted(k.value for k in node_kinds))
|
|
84
|
+
raise InvariantViolationError(
|
|
85
|
+
"execution_context_incompatible",
|
|
86
|
+
f"context '{self.context.value}' cannot execute against "
|
|
87
|
+
f"node kinds {{{kinds_str}}}; "
|
|
88
|
+
f"allowed: {{{', '.join(sorted(k.value for k in allowed))}}}",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def to_three_locus(self) -> ThreeLocusContext:
|
|
92
|
+
"""Bridge to the three-locus model (ADR-M3-3).
|
|
93
|
+
|
|
94
|
+
Returns a ``ThreeLocusContext`` derived from this legacy single-locus
|
|
95
|
+
spec. Imported lazily to keep this module dependency-light.
|
|
96
|
+
"""
|
|
97
|
+
from mayhem.domain.execution_loci import ThreeLocusContext
|
|
98
|
+
|
|
99
|
+
return ThreeLocusContext.from_single(self.context)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def infer_context_for_node(node_kind: NodeKind) -> ExecutionContext:
|
|
103
|
+
"""Best-default execution context for a given node kind.
|
|
104
|
+
|
|
105
|
+
This is the heuristic the planner uses when the author omits an explicit
|
|
106
|
+
``execution`` block — it must never be more permissive than the explicit
|
|
107
|
+
path.
|
|
108
|
+
"""
|
|
109
|
+
from mayhem.domain.topology import NodeKind as NK # local import
|
|
110
|
+
|
|
111
|
+
mapping: dict[NodeKind, ExecutionContext] = {
|
|
112
|
+
NK.HOST: ExecutionContext.HOST,
|
|
113
|
+
NK.CONTAINER: ExecutionContext.CONTAINER,
|
|
114
|
+
NK.SERVICE: ExecutionContext.CONTAINER,
|
|
115
|
+
NK.PROCESS: ExecutionContext.PROCESS,
|
|
116
|
+
NK.EXTERNAL_DEPENDENCY: ExecutionContext.REMOTE_HOST,
|
|
117
|
+
NK.POD: ExecutionContext.REMOTE_HOST,
|
|
118
|
+
NK.K8S_NODE: ExecutionContext.REMOTE_HOST,
|
|
119
|
+
}
|
|
120
|
+
return mapping.get(node_kind, ExecutionContext.HOST)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Three-locus execution context (ADR-M3-3).
|
|
2
|
+
|
|
3
|
+
Replaces the single-locus ``ExecutionContext`` with three loci that let the
|
|
4
|
+
engine reason about *where the mutation lands* (target locus) vs *where the
|
|
5
|
+
agent/tool actually runs* (agent/tool locus) — local vs remote vs container.
|
|
6
|
+
|
|
7
|
+
Backward compatibility: ``from_single()`` derives all three loci from the
|
|
8
|
+
legacy single-locus value so pre-0.3.0 specs keep working unchanged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
|
|
16
|
+
from mayhem.domain.execution_context import ExecutionContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Locus(StrEnum):
|
|
20
|
+
"""Where a given locus physically runs."""
|
|
21
|
+
|
|
22
|
+
LOCAL = "local"
|
|
23
|
+
REMOTE = "remote"
|
|
24
|
+
CONTAINER = "container"
|
|
25
|
+
NETWORK_NAMESPACE = "network_namespace"
|
|
26
|
+
KUBERNETES = "kubernetes"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class LocusSpec:
|
|
31
|
+
"""A single locus: the kind (where) plus the resolved identifier (what).
|
|
32
|
+
|
|
33
|
+
For example a container locus carries the container id; a network-namespace
|
|
34
|
+
locus carries the ns path. The identifier is optional because the planner
|
|
35
|
+
may not yet have resolved it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
kind: Locus
|
|
39
|
+
identifier: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ThreeLocusContext:
|
|
44
|
+
"""Target / agent / tool loci for one planned fault.
|
|
45
|
+
|
|
46
|
+
``plan_context`` is the required legacy ``ExecutionContext`` retained for
|
|
47
|
+
M2 drift detection and planner compatibility. The three loci trade it for
|
|
48
|
+
higher precision where available.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
target_locus: LocusSpec
|
|
52
|
+
agent_locus: LocusSpec
|
|
53
|
+
tool_locus: LocusSpec
|
|
54
|
+
plan_context: ExecutionContext
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_single(cls, ctx: ExecutionContext) -> ThreeLocusContext:
|
|
58
|
+
"""Derive a three-locus context from a legacy single locus.
|
|
59
|
+
|
|
60
|
+
For host/remote contexts the agent and tool run on the same node as the
|
|
61
|
+
target. For container/process contexts the agent and tool run via the
|
|
62
|
+
engine (inside the container), while the target is the container or its
|
|
63
|
+
network namespace.
|
|
64
|
+
"""
|
|
65
|
+
if ctx in (ExecutionContext.HOST, ExecutionContext.REMOTE_HOST):
|
|
66
|
+
kind = Locus.LOCAL if ctx == ExecutionContext.HOST else Locus.REMOTE
|
|
67
|
+
return cls(
|
|
68
|
+
target_locus=LocusSpec(kind),
|
|
69
|
+
agent_locus=LocusSpec(kind),
|
|
70
|
+
tool_locus=LocusSpec(kind),
|
|
71
|
+
plan_context=ctx,
|
|
72
|
+
)
|
|
73
|
+
if ctx in (ExecutionContext.CONTAINER, ExecutionContext.PROCESS):
|
|
74
|
+
return cls(
|
|
75
|
+
target_locus=LocusSpec(Locus.CONTAINER),
|
|
76
|
+
agent_locus=LocusSpec(Locus.CONTAINER),
|
|
77
|
+
tool_locus=LocusSpec(Locus.CONTAINER),
|
|
78
|
+
plan_context=ctx,
|
|
79
|
+
)
|
|
80
|
+
if ctx == ExecutionContext.NETWORK_NAMESPACE:
|
|
81
|
+
return cls(
|
|
82
|
+
target_locus=LocusSpec(Locus.NETWORK_NAMESPACE),
|
|
83
|
+
agent_locus=LocusSpec(Locus.LOCAL),
|
|
84
|
+
tool_locus=LocusSpec(Locus.LOCAL),
|
|
85
|
+
plan_context=ctx,
|
|
86
|
+
)
|
|
87
|
+
if ctx == ExecutionContext.REMOTE_CONTAINER:
|
|
88
|
+
return cls(
|
|
89
|
+
target_locus=LocusSpec(Locus.CONTAINER),
|
|
90
|
+
agent_locus=LocusSpec(Locus.REMOTE),
|
|
91
|
+
tool_locus=LocusSpec(Locus.REMOTE),
|
|
92
|
+
plan_context=ctx,
|
|
93
|
+
)
|
|
94
|
+
raise ValueError(f"unsupported execution context: {ctx!r}")
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Experiment specs and execution plans.
|
|
2
|
+
|
|
3
|
+
``DrillSpec`` is the authored input format (ADR-0019); ``ExecutionPlan`` is
|
|
4
|
+
the frozen, validated output of compilation. The plan pins config/topology
|
|
5
|
+
snapshot ids so any later analysis knows what the controller knew when it
|
|
6
|
+
committed. Since the clean break (ADR-0021) the only supported kind is
|
|
7
|
+
``drill`` — the deterministic/random authoring surfaces were removed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Annotated, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
16
|
+
|
|
17
|
+
from mayhem.domain.capabilities import Identifier
|
|
18
|
+
from mayhem.domain.checks import CheckLocus, CheckSpec, Probe
|
|
19
|
+
from mayhem.domain.common import Duration
|
|
20
|
+
from mayhem.domain.decisions import DecisionRef
|
|
21
|
+
from mayhem.domain.errors import InvariantViolationError
|
|
22
|
+
from mayhem.domain.execution_context import ExecutionContextSpec
|
|
23
|
+
from mayhem.domain.faults import FaultCategory
|
|
24
|
+
from mayhem.domain.identity import RuntimeIdentity
|
|
25
|
+
from mayhem.domain.leases import UndoOp, VerifyProbe
|
|
26
|
+
from mayhem.domain.observability import ObservabilityConfig
|
|
27
|
+
from mayhem.domain.risks import RiskLevel
|
|
28
|
+
from mayhem.domain.success import SuccessCriteria
|
|
29
|
+
from mayhem.domain.topology import TargetSelector
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExperimentKind(StrEnum):
|
|
33
|
+
DETERMINISTIC = "deterministic"
|
|
34
|
+
RANDOM = "random"
|
|
35
|
+
DRILL = "drill"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OnFailure(StrEnum):
|
|
39
|
+
ABORT_AND_RECOVER = "abort_and_recover"
|
|
40
|
+
CONTINUE = "continue"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BlastRadiusBudget(BaseModel):
|
|
44
|
+
"""Topology-derived limits enforced by the scheduler (ADR-0012 §5)."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(frozen=True)
|
|
47
|
+
|
|
48
|
+
max_services_pct: float = Field(default=50.0, gt=0, le=100)
|
|
49
|
+
max_hosts: int = Field(default=2, ge=1)
|
|
50
|
+
max_concurrent_faults: int = Field(default=3, ge=1)
|
|
51
|
+
max_duration_per_fault_s: float = 300.0
|
|
52
|
+
forbidden_fault_pairs: frozenset[frozenset[str]] = Field(default_factory=frozenset)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Constraints(BaseModel):
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
57
|
+
|
|
58
|
+
duration_cap: Duration | None = None
|
|
59
|
+
abort_on_violation: bool = True
|
|
60
|
+
require_dry_run_first: bool = True
|
|
61
|
+
risk_ceiling: RiskLevel | None = None # may only tighten policy ceiling
|
|
62
|
+
blast_radius: BlastRadiusBudget | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -- step actions ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InjectFault(BaseModel):
|
|
69
|
+
type: Literal["inject_fault"] = "inject_fault"
|
|
70
|
+
fault: str
|
|
71
|
+
selectors: tuple[TargetSelector, ...]
|
|
72
|
+
params: dict[str, object] = Field(default_factory=dict)
|
|
73
|
+
duration: Duration
|
|
74
|
+
backend: Identifier | None = None
|
|
75
|
+
execution: ExecutionContextSpec | None = None # ADR-0014; None → infer from target
|
|
76
|
+
|
|
77
|
+
@field_validator("fault")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _known_category(cls, value: str) -> str:
|
|
80
|
+
FaultCategory.from_fault_id(value)
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
@field_validator("selectors")
|
|
84
|
+
@classmethod
|
|
85
|
+
def _non_empty_selectors(cls, value: tuple[TargetSelector, ...]) -> tuple[TargetSelector, ...]:
|
|
86
|
+
if not value:
|
|
87
|
+
raise InvariantViolationError(
|
|
88
|
+
"step_requires_targets", "inject_fault needs >= 1 selector"
|
|
89
|
+
)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Wait(BaseModel):
|
|
94
|
+
type: Literal["wait"] = "wait"
|
|
95
|
+
duration: Duration | None = None
|
|
96
|
+
until_check_passes: str | None = None # check id
|
|
97
|
+
timeout: Duration = 120.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CheckHttp(BaseModel):
|
|
101
|
+
"""Inline HTTP health check for drill plans (ADR-0019)."""
|
|
102
|
+
|
|
103
|
+
type: Literal["check_http"] = "check_http"
|
|
104
|
+
url: str
|
|
105
|
+
expected_status: int | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CheckSpecStep(BaseModel):
|
|
109
|
+
"""A drill check step compiled from a :class:`CheckSpec` (ADR-M4-2).
|
|
110
|
+
|
|
111
|
+
Carries the fully-resolved probe and its execution locus so the executor
|
|
112
|
+
can evaluate the check where it is declared to run.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
type: Literal["check_spec"] = "check_spec"
|
|
116
|
+
check_id: str
|
|
117
|
+
probe: Probe
|
|
118
|
+
execution: CheckLocus | None = None # None → infer from fault target
|
|
119
|
+
target: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# The raw action of a planned step. Drill plans only ever emit inject_fault,
|
|
123
|
+
# wait and check_http — the start_load/stop_load/check/notify/parallel action
|
|
124
|
+
# types were authoring-only and removed with the deterministic/random surfaces.
|
|
125
|
+
StepAction = Annotated[InjectFault | Wait | CheckHttp | CheckSpecStep, Field(discriminator="type")]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# -- drill spec (ADR-0019) ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ManiacCfg(BaseModel):
|
|
132
|
+
"""Maniac-mode tuning — ``config.maniac`` in a drill spec or ``maniac:``
|
|
133
|
+
in the layered ``mayhem.yaml`` config (spec-level wins, ADR-M5-1).
|
|
134
|
+
|
|
135
|
+
``level`` — the "level of randomness" dial:
|
|
136
|
+
|
|
137
|
+
===== ==================================================================
|
|
138
|
+
level behaviour
|
|
139
|
+
===== ==================================================================
|
|
140
|
+
1 random container, first authored fault on it; no duration jitter
|
|
141
|
+
2 random container, random one of its authored faults; no jitter
|
|
142
|
+
3 random container, any fault from the whole spec (cross-locus); no jitter
|
|
143
|
+
4 cross-locus pool + duration jitter of ±10 %
|
|
144
|
+
5 cross-locus pool + duration jitter of ±20 % (full chaos)
|
|
145
|
+
===== ==================================================================
|
|
146
|
+
|
|
147
|
+
Jitter is clamped to the fault's catalog maximum duration and never drops
|
|
148
|
+
below 1 second; the spec's own safety gates (risk ceiling, blast radius,
|
|
149
|
+
``max_faults``, timeout) still apply to every round.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
153
|
+
|
|
154
|
+
level: int = Field(default=2, ge=1, le=5)
|
|
155
|
+
run_level: int = Field(default=10, ge=1, le=500) # injection rounds
|
|
156
|
+
seed: int | None = Field(default=None, ge=0) # reproducible draws
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DrillConfig(BaseModel):
|
|
160
|
+
"""Configuration for a drill spec — replaces the separate mayhem.yml."""
|
|
161
|
+
|
|
162
|
+
model_config = ConfigDict(frozen=True)
|
|
163
|
+
|
|
164
|
+
risk_ceiling: RiskLevel = RiskLevel.HIGH
|
|
165
|
+
max_faults: int = Field(default=1, ge=0)
|
|
166
|
+
timeout: Duration = "30m"
|
|
167
|
+
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
168
|
+
# True: auto-recover after each fault (undo contract runs, container restored).
|
|
169
|
+
# False: keep the perturbation in place after injection — the container stays
|
|
170
|
+
# faulted so downstream checks observe whether the stack self-heals.
|
|
171
|
+
recovery: bool = True
|
|
172
|
+
# Drill-wide failure policy: when a fault round fails, `abort_and_recover`
|
|
173
|
+
# cancels the remaining steps and recovers, `continue` records the failure
|
|
174
|
+
# and keeps testing the remaining faults (the run still ends `failed`).
|
|
175
|
+
# A fault can override this per-fault via its own `on_failure`.
|
|
176
|
+
on_failure: OnFailure = OnFailure.ABORT_AND_RECOVER
|
|
177
|
+
# ADR-M5-1: when set, `mayhem maniac` replaces the authored execution with
|
|
178
|
+
# `maniac.run_level` random (container, fault) rounds dialed by `maniac.level`.
|
|
179
|
+
# Leave unset to keep `mayhem run` fully deterministic.
|
|
180
|
+
maniac: ManiacCfg | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DrillFault(BaseModel):
|
|
184
|
+
"""A single fault to inject on a container."""
|
|
185
|
+
|
|
186
|
+
model_config = ConfigDict(frozen=True, extra="allow")
|
|
187
|
+
|
|
188
|
+
fault: str
|
|
189
|
+
duration: Duration = "10s"
|
|
190
|
+
# Per-fault override of ``config.on_failure``; None ⇒ inherit config.
|
|
191
|
+
on_failure: OnFailure | None = None
|
|
192
|
+
targets: tuple[str, ...] = () # for network faults: container names to partition
|
|
193
|
+
network_path: str | None = None # ADR-M3-7 optional path target for network faults
|
|
194
|
+
# Optional per-fault override of ``config.recovery``; None ⇒ inherit config.
|
|
195
|
+
recovery: bool | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class DrillContainer(BaseModel):
|
|
199
|
+
"""Faults to run on a specific container (identified by container_name)."""
|
|
200
|
+
|
|
201
|
+
model_config = ConfigDict(frozen=True)
|
|
202
|
+
|
|
203
|
+
faults: tuple[DrillFault, ...] = ()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CheckExpectation(BaseModel):
|
|
207
|
+
"""What to verify in a check probe."""
|
|
208
|
+
|
|
209
|
+
model_config = ConfigDict(frozen=True)
|
|
210
|
+
|
|
211
|
+
status: int | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class CheckProbe(BaseModel):
|
|
215
|
+
"""A health check to run between execution rounds."""
|
|
216
|
+
|
|
217
|
+
model_config = ConfigDict(frozen=True)
|
|
218
|
+
|
|
219
|
+
http: str | None = None
|
|
220
|
+
expect: CheckExpectation = CheckExpectation()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ExecutionStep(BaseModel):
|
|
224
|
+
"""A single step in the execution plan — parallel, sequential, wait, or check."""
|
|
225
|
+
|
|
226
|
+
model_config = ConfigDict(frozen=True)
|
|
227
|
+
|
|
228
|
+
parallel: tuple[str, ...] | None = None # container names to run concurrently
|
|
229
|
+
sequential: tuple[str, ...] | None = None # container names to run in order
|
|
230
|
+
wait: Duration | None = None # seconds to wait after this step
|
|
231
|
+
check: tuple[CheckProbe, ...] | None = None # health checks to run
|
|
232
|
+
check_spec: tuple[CheckSpec, ...] | None = None # locus-aware checks (ADR-M4-2)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class DrillSpec(BaseModel):
|
|
236
|
+
"""Unified drill spec — single YAML file replacing config + fault spec (ADR-0019)."""
|
|
237
|
+
|
|
238
|
+
model_config = ConfigDict(frozen=True)
|
|
239
|
+
|
|
240
|
+
kind: Literal["drill"]
|
|
241
|
+
name: str
|
|
242
|
+
hypothesis: str = ""
|
|
243
|
+
config: DrillConfig = Field(default_factory=DrillConfig)
|
|
244
|
+
containers: dict[str, DrillContainer] # key = container_name from docker-compose
|
|
245
|
+
execution: tuple[ExecutionStep, ...]
|
|
246
|
+
success: SuccessCriteria | None = None # optional machine verdict (ADR-M4-3)
|
|
247
|
+
observability: ObservabilityConfig | None = None # optional evidence sources (ADR-M4-4)
|
|
248
|
+
|
|
249
|
+
@field_validator("containers")
|
|
250
|
+
@classmethod
|
|
251
|
+
def _at_least_one_container(cls, value: dict[str, DrillContainer]) -> dict[str, DrillContainer]:
|
|
252
|
+
if not value:
|
|
253
|
+
raise InvariantViolationError("drill_requires_containers", "no containers defined")
|
|
254
|
+
return value
|
|
255
|
+
|
|
256
|
+
@field_validator("execution")
|
|
257
|
+
@classmethod
|
|
258
|
+
def _at_least_one_step(cls, value: tuple[ExecutionStep, ...]) -> tuple[ExecutionStep, ...]:
|
|
259
|
+
if not value:
|
|
260
|
+
raise InvariantViolationError("drill_requires_execution", "no execution steps defined")
|
|
261
|
+
return value
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# -- compiled plan ------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ResolvedTarget(BaseModel):
|
|
268
|
+
model_config = ConfigDict(frozen=True)
|
|
269
|
+
|
|
270
|
+
selector: TargetSelector
|
|
271
|
+
node_ids: frozenset[str]
|
|
272
|
+
|
|
273
|
+
@field_validator("node_ids")
|
|
274
|
+
@classmethod
|
|
275
|
+
def _resolved(cls, value: frozenset[str]) -> frozenset[str]:
|
|
276
|
+
if not value:
|
|
277
|
+
raise InvariantViolationError("plan_targets_resolved", "selector matched no nodes")
|
|
278
|
+
return value
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class PlannedFault(BaseModel):
|
|
282
|
+
"""A fault invocation with compile-time resolution done.
|
|
283
|
+
|
|
284
|
+
Carries its compensation contract (write-ahead undo ops + verify probes)
|
|
285
|
+
decided at planning time — never discovered mid-execution.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
model_config = ConfigDict(frozen=True)
|
|
289
|
+
|
|
290
|
+
fault_id: str
|
|
291
|
+
targets: tuple[ResolvedTarget, ...]
|
|
292
|
+
undo_ops: tuple[UndoOp, ...] = ()
|
|
293
|
+
verify_probes: tuple[VerifyProbe, ...] = ()
|
|
294
|
+
params: dict[str, object] = Field(default_factory=dict)
|
|
295
|
+
duration: Duration
|
|
296
|
+
backend: Identifier | None = None
|
|
297
|
+
execution_context: ExecutionContextSpec | None = None # ADR-0014
|
|
298
|
+
runtime_identity: RuntimeIdentity | None = None # planned identity (ADR-M1-1/1-3)
|
|
299
|
+
execution_loci: dict[str, object] | None = None # ADR-M3-3 target/agent/tool loci
|
|
300
|
+
# False ⇒ executor keeps the perturbation in place instead of undoing it
|
|
301
|
+
# after injection (self-healing observation mode).
|
|
302
|
+
recovery: bool = True
|
|
303
|
+
# Resolved failure policy (config default overridden per-fault at planning
|
|
304
|
+
# time): abort_and_recover cancels the remaining steps on the first failing
|
|
305
|
+
# round; continue records the failure and keeps testing the rest.
|
|
306
|
+
on_failure: OnFailure = OnFailure.ABORT_AND_RECOVER
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class ExecutionPlan(BaseModel):
|
|
310
|
+
"""Frozen contract handed from planning to execution."""
|
|
311
|
+
|
|
312
|
+
model_config = ConfigDict(frozen=True)
|
|
313
|
+
|
|
314
|
+
run_id: str
|
|
315
|
+
kind: ExperimentKind
|
|
316
|
+
steps: tuple[PlannedStep, ...]
|
|
317
|
+
config_snapshot_id: str
|
|
318
|
+
topology_snapshot_id: str
|
|
319
|
+
environment_fingerprint: str
|
|
320
|
+
seed: int | None = None
|
|
321
|
+
success: SuccessCriteria | None = None # copied from the spec (ADR-M4-3)
|
|
322
|
+
observability: ObservabilityConfig | None = None # copied from the spec (ADR-M4-4)
|
|
323
|
+
decision_refs: tuple[DecisionRef, ...] = () # governing ADR ids + timestamps
|
|
324
|
+
|
|
325
|
+
@model_validator(mode="after")
|
|
326
|
+
def _check_plan(self) -> ExecutionPlan:
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class GroupMode(StrEnum):
|
|
331
|
+
"""Execution semantics of a fault group (ADR-M2-1)."""
|
|
332
|
+
|
|
333
|
+
PARALLEL = "parallel" # members run concurrently
|
|
334
|
+
SEQUENTIAL = "sequential" # members run one-at-a-time in order
|
|
335
|
+
BEST_EFFORT = "best_effort" # continue past member failures
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class FaultGroup(BaseModel):
|
|
339
|
+
"""A set of fault members executed under one persistent identity.
|
|
340
|
+
|
|
341
|
+
v1 semantics (ADR-M2-1): ``parallel`` runs members concurrently;
|
|
342
|
+
``sequential`` runs them in order; ``best_effort`` continues past member
|
|
343
|
+
failures. ``atomic`` (strong all-or-nothing) is demoted to
|
|
344
|
+
compensate-on-failure in v1. Every executed group carries a persistent
|
|
345
|
+
``execution_group_id`` and a ``group_path``; member faults share the id.
|
|
346
|
+
Partial failure is a first-class result (which members succeeded/failed).
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
model_config = ConfigDict(frozen=True)
|
|
350
|
+
|
|
351
|
+
execution_group_id: str
|
|
352
|
+
parent_group_id: str | None = None
|
|
353
|
+
mode: GroupMode = GroupMode.SEQUENTIAL
|
|
354
|
+
path: str = "/"
|
|
355
|
+
fault_ids: tuple[str, ...] = ()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class PlannedStep(BaseModel):
|
|
359
|
+
"""A step whose fault actions have been fully resolved against topology."""
|
|
360
|
+
|
|
361
|
+
model_config = ConfigDict(frozen=True)
|
|
362
|
+
|
|
363
|
+
id: str
|
|
364
|
+
seq: int
|
|
365
|
+
fault: PlannedFault | None = None
|
|
366
|
+
raw_action: StepAction # for non-fault steps (wait/check)
|
|
367
|
+
runtime_identity: RuntimeIdentity | None = None # planned identity (ADR-M1-1/1-3)
|
|
368
|
+
execution_group_id: str | None = None # group attribution (ADR-M2-1/2-2)
|
|
369
|
+
group_mode: GroupMode | None = None # parallel|sequential|best_effort
|
|
370
|
+
group_path: str | None = None # hierarchical group location
|