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