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
mayhem/agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""``mayhem-agent`` — the agent-side entrypoint (ADR-0003)."""
|
mayhem/agent/cli.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""``mayhem-agent serve`` — ndjson JSON-RPC session over stdio (ADR-0003).
|
|
2
|
+
|
|
3
|
+
The agent never binds sockets. The controller spawns this process, locally or
|
|
4
|
+
over ``ssh <host> -- mayhem-agent serve``, and speaks the protocol on stdin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from mayhem.agents.executors import NoopExecutor, ProcPauseExecutor
|
|
12
|
+
from mayhem.agents.server import serve_sync
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="mayhem fault-injection agent.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _builtin_executors() -> tuple[ProcPauseExecutor, NoopExecutor]:
|
|
18
|
+
return (ProcPauseExecutor(), NoopExecutor())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def _root() -> None:
|
|
23
|
+
"""mayhem-agent: controller-spawned fault agent."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def serve(
|
|
28
|
+
roles: str = typer.Option("proc,fs", "--roles", help="Comma-separated role names."),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Serve one JSON-RPC session on stdin/stdout until EOF."""
|
|
31
|
+
role_tuple = tuple(r.strip() for r in roles.split(",") if r.strip())
|
|
32
|
+
serve_sync(roles=role_tuple, executors=_builtin_executors())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent-side SDK (Phase 2): fault executors, lease client, probes."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Agent capability constraints and security model (ADR-0017).
|
|
2
|
+
|
|
3
|
+
Every agent declares what it can do via ``AgentCapabilities``. The controller
|
|
4
|
+
enforces these at task dispatch time — an agent cannot inject a fault it
|
|
5
|
+
hasn't declared capability for, and cannot touch resources outside its scope.
|
|
6
|
+
|
|
7
|
+
This is the *compile-time* agent security boundary. Runtime isolation
|
|
8
|
+
(sandboxing, process-level containment) is a separate concern.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CapabilityKind(StrEnum):
|
|
19
|
+
"""What an agent is allowed to do."""
|
|
20
|
+
|
|
21
|
+
FAULT_INJECT = "fault_inject" # inject a specific fault type
|
|
22
|
+
FAULT_UNDO = "fault_undo" # undo/clean up a fault type
|
|
23
|
+
PROBE_RUN = "probe_run" # execute observation probes
|
|
24
|
+
RESOURCE_ACCESS = "resource_access" # touch tracked resources
|
|
25
|
+
NETWORK_ACCESS = "network_access" # make outbound network calls
|
|
26
|
+
SHELL_EXEC = "shell_exec" # execute shell commands on hosts
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentCapabilities(BaseModel):
|
|
30
|
+
"""The set of capabilities an agent declares at registration time.
|
|
31
|
+
|
|
32
|
+
The controller validates task dispatch against these capabilities.
|
|
33
|
+
Unknown capabilities are ignored (fail-closed for unrecognized kinds).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True)
|
|
37
|
+
|
|
38
|
+
allowed_faults: tuple[str, ...] = ()
|
|
39
|
+
allowed_resources: tuple[str, ...] = ()
|
|
40
|
+
capabilities: tuple[CapabilityKind, ...] = ()
|
|
41
|
+
|
|
42
|
+
def can_inject(self, fault_id: str) -> bool:
|
|
43
|
+
"""Can this agent inject the given fault type?"""
|
|
44
|
+
if CapabilityKind.FAULT_INJECT not in self.capabilities:
|
|
45
|
+
return False
|
|
46
|
+
if not self.allowed_faults:
|
|
47
|
+
return True # empty allowlist = all faults allowed
|
|
48
|
+
return fault_id in self.allowed_faults or any(
|
|
49
|
+
fault_id.startswith(prefix) for prefix in self.allowed_faults
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def can_undo(self, fault_id: str) -> bool:
|
|
53
|
+
"""Can this agent undo the given fault type?"""
|
|
54
|
+
if CapabilityKind.FAULT_UNDO not in self.capabilities:
|
|
55
|
+
return False
|
|
56
|
+
if not self.allowed_faults:
|
|
57
|
+
return True
|
|
58
|
+
return fault_id in self.allowed_faults or any(
|
|
59
|
+
fault_id.startswith(prefix) for prefix in self.allowed_faults
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def can_access_resource(self, resource_type: str) -> bool:
|
|
63
|
+
"""Can this agent touch the given resource type?"""
|
|
64
|
+
if CapabilityKind.RESOURCE_ACCESS not in self.capabilities:
|
|
65
|
+
return False
|
|
66
|
+
if not self.allowed_resources:
|
|
67
|
+
return True
|
|
68
|
+
return resource_type in self.allowed_resources
|
|
69
|
+
|
|
70
|
+
def can_use_network(self) -> bool:
|
|
71
|
+
return CapabilityKind.NETWORK_ACCESS in self.capabilities
|
|
72
|
+
|
|
73
|
+
def can_exec_shell(self) -> bool:
|
|
74
|
+
return CapabilityKind.SHELL_EXEC in self.capabilities
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AgentIdentity(BaseModel):
|
|
78
|
+
"""Immutable identity for an agent, set at registration."""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(frozen=True)
|
|
81
|
+
|
|
82
|
+
agent_id: str
|
|
83
|
+
hostname: str = ""
|
|
84
|
+
pid: int | None = None
|
|
85
|
+
started_at: str = ""
|
|
86
|
+
capabilities: AgentCapabilities = Field(default_factory=AgentCapabilities)
|
|
87
|
+
|
|
88
|
+
def validate_fault_dispatch(self, fault_id: str, *, is_undo: bool = False) -> None:
|
|
89
|
+
"""Raise if this agent cannot handle the given fault.
|
|
90
|
+
|
|
91
|
+
Uses a domain-level error rather than a generic exception so callers
|
|
92
|
+
can catch it uniformly.
|
|
93
|
+
"""
|
|
94
|
+
from mayhem.domain.errors import InvariantViolationError # noqa: PLC0415
|
|
95
|
+
|
|
96
|
+
action = "undo" if is_undo else "inject"
|
|
97
|
+
if is_undo and not self.capabilities.can_undo(fault_id):
|
|
98
|
+
raise InvariantViolationError(
|
|
99
|
+
"agent.capability.denied",
|
|
100
|
+
f"agent '{self.agent_id}' not authorized to {action} '{fault_id}'",
|
|
101
|
+
)
|
|
102
|
+
if not is_undo and not self.capabilities.can_inject(fault_id):
|
|
103
|
+
raise InvariantViolationError(
|
|
104
|
+
"agent.capability.denied",
|
|
105
|
+
f"agent '{self.agent_id}' not authorized to {action} '{fault_id}'",
|
|
106
|
+
)
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Fault executors — the only code allowed to perturb the system.
|
|
2
|
+
|
|
3
|
+
An Executor owns inject + undo for a family of fault ids. Undo must be
|
|
4
|
+
idempotent and must never raise: if undo fails, the executor reports DIRTY and
|
|
5
|
+
the controller escalates; it does not retry blindly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from mayhem.toolkit.tool_runner import ToolError, ToolResult, run_tool
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from mayhem.domain.leases import FaultLease
|
|
21
|
+
|
|
22
|
+
os_kill = os.kill
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_boot_time(pid: int) -> int | None:
|
|
26
|
+
"""Read a process's start time (``/proc/<pid>/stat`` field 22).
|
|
27
|
+
|
|
28
|
+
Used by the PID-reuse guard (ADR-M2 Phase 2.4 / ADR-M6-2): a PID alone is
|
|
29
|
+
not an identity because the kernel recycles PIDs after exit. The boot time
|
|
30
|
+
in clock ticks since boot disambiguates a recycled PID from the original
|
|
31
|
+
target. Returns ``None`` on platforms without procfs or when the process
|
|
32
|
+
is gone — the guard then degrades to pid-only signalling.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
raw = Path(f"/proc/{pid}/stat").read_text()
|
|
36
|
+
fields = raw.split(")", maxsplit=1)[1].split()
|
|
37
|
+
return int(fields[19]) if len(fields) > 19 else None
|
|
38
|
+
except (OSError, ValueError, IndexError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class StepOutcome:
|
|
44
|
+
step: str # "inject" | "undo"
|
|
45
|
+
ok: bool
|
|
46
|
+
detail: str
|
|
47
|
+
tool_result: ToolResult | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FaultExecutor:
|
|
51
|
+
"""Base class: subclasses declare which prefixes they own."""
|
|
52
|
+
|
|
53
|
+
prefixes: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
def supports(self, fault_id: str) -> bool:
|
|
56
|
+
return fault_id.split(".", 1)[0] in self.prefixes
|
|
57
|
+
|
|
58
|
+
def capable_faults(self) -> tuple[str, ...]:
|
|
59
|
+
"""Fault families handled here; advertised via capabilities.query."""
|
|
60
|
+
return self.prefixes
|
|
61
|
+
|
|
62
|
+
def can_apply(self, lease: FaultLease) -> str | None:
|
|
63
|
+
"""Execution-time capability revalidation (ADR-M2 Phase 2.3).
|
|
64
|
+
|
|
65
|
+
Runs immediately before the mutation boundary inside ``inject``. Return
|
|
66
|
+
``None`` when the capability still holds; return a human-readable reason
|
|
67
|
+
when it does not — the executor then records ``failed_to_apply`` and
|
|
68
|
+
never mutates. The default assumes the capability holds; subclasses
|
|
69
|
+
revalidate what they actually need.
|
|
70
|
+
"""
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def inject(self, lease: FaultLease) -> StepOutcome: # pragma: no cover
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def undo(self, lease: FaultLease) -> StepOutcome: # pragma: no cover
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ProcPauseExecutor(FaultExecutor):
|
|
81
|
+
"""Process signal faults against a live PID.
|
|
82
|
+
|
|
83
|
+
``proc.pause`` SIGSTOP, fully reversible with SIGCONT.
|
|
84
|
+
``process.stop`` SIGTERM (graceful shutdown); pid is gone — undo no-ops.
|
|
85
|
+
``process.kill`` SIGKILL; pid is gone — undo no-ops.
|
|
86
|
+
|
|
87
|
+
All three are guarded by the PID-reuse boot_time check (ADR-M2 Phase 2.4).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
prefixes = ("proc", "process")
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._paused_pids: set[int] = set()
|
|
94
|
+
|
|
95
|
+
def _signal_for(self, fault_id: str) -> int | None:
|
|
96
|
+
"""Map a fault id to its signal, or ``None`` for a non-signal fault."""
|
|
97
|
+
family = fault_id.split(".", 1)[0]
|
|
98
|
+
if family == "proc" and fault_id == "proc.pause":
|
|
99
|
+
return signal.SIGSTOP
|
|
100
|
+
if family == "process":
|
|
101
|
+
if fault_id.endswith("kill"):
|
|
102
|
+
return signal.SIGKILL
|
|
103
|
+
if fault_id.endswith("stop"):
|
|
104
|
+
return signal.SIGTERM
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def can_apply(self, lease: FaultLease) -> str | None:
|
|
108
|
+
"""Revalidate the container-mode signal capability (ADR-M2 Phase 2.3).
|
|
109
|
+
|
|
110
|
+
A container-addressed ``proc.pause`` needs the engine binary on PATH to
|
|
111
|
+
deliver ``SIGSTOP`` inside the container's pid namespace. If the binary
|
|
112
|
+
vanished between plan time and the mutation boundary, this fault cannot
|
|
113
|
+
be applied — report ``failed_to_apply`` instead of mutating.
|
|
114
|
+
"""
|
|
115
|
+
import shutil # noqa: PLC0415
|
|
116
|
+
|
|
117
|
+
_pid, cont, engine, _boot = self._signal_spec(lease)
|
|
118
|
+
if cont is None or engine is None:
|
|
119
|
+
return None # host-mode signal needs no engine binary
|
|
120
|
+
if shutil.which(engine) is None:
|
|
121
|
+
return f"capability lost: container engine {engine!r} no longer on PATH"
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _signal_spec(
|
|
125
|
+
self, lease: FaultLease
|
|
126
|
+
) -> tuple[int | None, str | None, str | None, int | None]:
|
|
127
|
+
"""Return (pid, container, engine, boot_time) carried by the first usable undo op.
|
|
128
|
+
|
|
129
|
+
``pid`` is the process to signal (``.State.Pid`` resolved at execution
|
|
130
|
+
time, valid both on the host and inside the owning container's pid
|
|
131
|
+
namespace). ``boot_time`` is the process start time (``/proc/<pid>/stat``
|
|
132
|
+
field 22) recorded when the target's identity was resolved; it feeds the
|
|
133
|
+
PID-reuse guard so a recycled PID is never signalled. When the op was
|
|
134
|
+
annotated for container mode, ``container`` and ``engine`` (podman/docker)
|
|
135
|
+
are present so the signal can be delivered *inside* the container via
|
|
136
|
+
``engine exec <cont> kill`` — required when the container runtime lives in
|
|
137
|
+
a detached VM (podman-machine on macOS) where a host ``os.kill`` cannot
|
|
138
|
+
reach the container pid namespace.
|
|
139
|
+
"""
|
|
140
|
+
for op in lease.undo_ops:
|
|
141
|
+
raw = op.args.get("pid")
|
|
142
|
+
try:
|
|
143
|
+
pid = int(raw) if raw is not None else None
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
continue
|
|
146
|
+
if pid is not None:
|
|
147
|
+
boot_raw = op.args.get("boot_time")
|
|
148
|
+
try:
|
|
149
|
+
boot = int(boot_raw) if boot_raw is not None else None
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
boot = None
|
|
152
|
+
return pid, op.args.get("cont"), op.args.get("engine"), boot
|
|
153
|
+
return None, None, None, None
|
|
154
|
+
|
|
155
|
+
def inject(self, lease: FaultLease) -> StepOutcome:
|
|
156
|
+
sig = self._signal_for(lease.fault_id)
|
|
157
|
+
if sig is None:
|
|
158
|
+
return StepOutcome("inject", False, f"unplannable signal fault {lease.fault_id}")
|
|
159
|
+
pid, cont, engine, _boot = self._signal_spec(lease)
|
|
160
|
+
# Host mode needs a numeric pid; container/exec mode (ADR-0020) signals
|
|
161
|
+
# via ``<engine> kill --signal <cont>`` and works even when the host PID
|
|
162
|
+
# is a sentinel 0 (VM-contained engines like podman-machine on macOS).
|
|
163
|
+
if cont and engine:
|
|
164
|
+
pid = pid if pid is not None else 0
|
|
165
|
+
elif pid is None or pid <= 1:
|
|
166
|
+
return StepOutcome("inject", False, f"lease {lease.id} carries no usable pid")
|
|
167
|
+
outcome = self._signal(pid, sig, cont, engine, _boot, "inject")
|
|
168
|
+
if outcome.ok:
|
|
169
|
+
self._paused_pids.add(pid)
|
|
170
|
+
return outcome
|
|
171
|
+
|
|
172
|
+
def undo(self, lease: FaultLease) -> StepOutcome:
|
|
173
|
+
pid, cont, engine, boot = self._signal_spec(lease)
|
|
174
|
+
if self._signal_for(lease.fault_id) not in (signal.SIGSTOP, signal.SIGCONT):
|
|
175
|
+
# process.stop / process.kill terminate the pid; there is nothing to
|
|
176
|
+
# resume. Report idempotent success so the lease releases cleanly.
|
|
177
|
+
self._paused_pids.discard(pid or 0)
|
|
178
|
+
return StepOutcome("undo", True, "process already terminated; no resume to perform")
|
|
179
|
+
if pid is None:
|
|
180
|
+
return StepOutcome("undo", True, "nothing to resume")
|
|
181
|
+
outcome = self._signal(pid, signal.SIGCONT, cont, engine, boot, "undo")
|
|
182
|
+
self._paused_pids.discard(pid)
|
|
183
|
+
if outcome.ok:
|
|
184
|
+
return StepOutcome("undo", True, f"SIGCONT delivered to {pid}")
|
|
185
|
+
if "already gone" in outcome.detail:
|
|
186
|
+
return StepOutcome("undo", True, f"pid {pid} already gone; treated as resumed")
|
|
187
|
+
return outcome
|
|
188
|
+
|
|
189
|
+
def _signal(
|
|
190
|
+
self,
|
|
191
|
+
pid: int,
|
|
192
|
+
sig: int,
|
|
193
|
+
cont: str | None,
|
|
194
|
+
engine: str | None,
|
|
195
|
+
boot: int | None,
|
|
196
|
+
op: str,
|
|
197
|
+
) -> StepOutcome:
|
|
198
|
+
"""Deliver ``sig`` to the target, inside the container when annotated.
|
|
199
|
+
|
|
200
|
+
Container mode uses ``engine kill --signal <SIG> <cont>`` so the signal is
|
|
201
|
+
delivered to the container's main process from the runtime side. This is
|
|
202
|
+
namespace-agnostic: unlike ``engine exec ... kill <pid>`` it does not
|
|
203
|
+
require the resolved pid (``.State.Pid``, a VM/host pid) to be addressable
|
|
204
|
+
inside the container's pid namespace. Host mode falls back to ``os.kill``,
|
|
205
|
+
guarded by the PID-reuse check (ADR-M2 Phase 2.4): when a boot time was
|
|
206
|
+
recorded at identity-resolution time, we re-read the current boot time
|
|
207
|
+
and refuse to signal on mismatch (the PID was recycled by an unrelated
|
|
208
|
+
process) — reported as ``target_drift`` rather than mutating the wrong
|
|
209
|
+
process.
|
|
210
|
+
"""
|
|
211
|
+
if cont and engine:
|
|
212
|
+
signame = signal.Signals(sig).name # e.g. SIGSTOP / SIGCONT
|
|
213
|
+
argv = [engine, "kill", "--signal", signame, cont]
|
|
214
|
+
try:
|
|
215
|
+
result = run_tool(argv, timeout_s=30)
|
|
216
|
+
if result.succeeded:
|
|
217
|
+
return StepOutcome(op, True, f"{signame} sent to {cont}")
|
|
218
|
+
return StepOutcome(
|
|
219
|
+
op,
|
|
220
|
+
False,
|
|
221
|
+
f"{signame} failed for {cont}: {result.stderr.strip()[:120]}",
|
|
222
|
+
)
|
|
223
|
+
except ToolError as exc:
|
|
224
|
+
return StepOutcome(op, False, f"engine kill failed for {cont}: {exc}")
|
|
225
|
+
drift = read_boot_time(pid)
|
|
226
|
+
if boot is not None and drift is not None and drift != boot:
|
|
227
|
+
return StepOutcome(
|
|
228
|
+
op,
|
|
229
|
+
False,
|
|
230
|
+
f"pid-reuse guard: pid {pid} boot_time {drift} != expected {boot}; "
|
|
231
|
+
"refusing to signal recycled PID",
|
|
232
|
+
tool_result=None,
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
os_kill(pid, sig)
|
|
236
|
+
except ProcessLookupError:
|
|
237
|
+
return StepOutcome(op, False, f"pid {pid} already gone")
|
|
238
|
+
except PermissionError as exc:
|
|
239
|
+
return StepOutcome(op, False, f"cannot signal pid {pid}: {exc}")
|
|
240
|
+
return StepOutcome(op, True, f"signal delivered to {pid}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PayloadExecutor(FaultExecutor):
|
|
244
|
+
"""mem/cpu/fs/fd/load/fuzz: real in-container effects via ``engine exec -d``.
|
|
245
|
+
|
|
246
|
+
Inject launches ``<engine> exec -d <cont> python -c <payload>``: a detached
|
|
247
|
+
process inside the target container's pid + network namespaces that produces
|
|
248
|
+
the fault's observable effect (memory balloon, CPU spin, fs fill, fd
|
|
249
|
+
exhaustion, request flood, malformed HTTP abuse). Undo runs a small kill
|
|
250
|
+
payload against the same container that SIGKILLs the recorded pid and
|
|
251
|
+
removes the payload's marker files, so the effect is fully reversible and
|
|
252
|
+
idempotent. Runs are driven entirely by the lease's ``payload.undo`` op,
|
|
253
|
+
whose ``pid`` placeholder resolves to ``cont``/``engine`` at execution time.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
prefixes = ("mem", "cpu", "fs", "fd", "load", "fuzz")
|
|
257
|
+
|
|
258
|
+
_KILL = (
|
|
259
|
+
"import os, signal, glob, sys\n"
|
|
260
|
+
"mp = sys.argv[1]\n"
|
|
261
|
+
"try:\n"
|
|
262
|
+
" p = int(open(mp).read().strip())\n"
|
|
263
|
+
" os.kill(p, signal.SIGKILL)\n"
|
|
264
|
+
"except Exception:\n"
|
|
265
|
+
" pass\n"
|
|
266
|
+
"for f in glob.glob(mp + '.*') + [mp]:\n"
|
|
267
|
+
" try:\n"
|
|
268
|
+
" os.unlink(f)\n"
|
|
269
|
+
" except OSError:\n"
|
|
270
|
+
" pass\n"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _spec(self, lease: FaultLease) -> tuple[str, str, str, str] | None:
|
|
274
|
+
"""Return ``(engine, cont, payload, marker)`` from the undo op."""
|
|
275
|
+
for op in lease.undo_ops:
|
|
276
|
+
engine, cont = op.args.get("engine"), op.args.get("cont")
|
|
277
|
+
payload, marker = op.args.get("payload"), op.args.get("marker")
|
|
278
|
+
if engine and cont and payload and marker:
|
|
279
|
+
return str(engine), str(cont), str(payload), str(marker)
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def inject(self, lease: FaultLease) -> StepOutcome:
|
|
283
|
+
spec = self._spec(lease)
|
|
284
|
+
if spec is None:
|
|
285
|
+
return StepOutcome(
|
|
286
|
+
"inject", False, f"lease {lease.id} lacks payload spec (no cont/engine)"
|
|
287
|
+
)
|
|
288
|
+
engine, cont, payload, _marker = spec
|
|
289
|
+
argv = [engine, "exec", "-d", cont, "python", "-c", payload]
|
|
290
|
+
try:
|
|
291
|
+
result = run_tool(argv, timeout_s=30)
|
|
292
|
+
except ToolError as exc:
|
|
293
|
+
return StepOutcome("inject", False, f"payload launch failed: {exc}")
|
|
294
|
+
detail = f"{lease.fault_id} payload launched in {cont}"
|
|
295
|
+
if result.stderr:
|
|
296
|
+
detail += f": {result.stderr.strip()[:120]}"
|
|
297
|
+
return StepOutcome("inject", result.succeeded, detail, result)
|
|
298
|
+
|
|
299
|
+
def undo(self, lease: FaultLease) -> StepOutcome:
|
|
300
|
+
spec = self._spec(lease)
|
|
301
|
+
if spec is None:
|
|
302
|
+
return StepOutcome(
|
|
303
|
+
"undo", False, f"lease {lease.id} lacks payload spec (no cont/engine)"
|
|
304
|
+
)
|
|
305
|
+
engine, cont, _payload, marker = spec
|
|
306
|
+
argv = [engine, "exec", cont, "python", "-c", self._KILL, marker]
|
|
307
|
+
try:
|
|
308
|
+
result = run_tool(argv, timeout_s=30)
|
|
309
|
+
except ToolError as exc:
|
|
310
|
+
return StepOutcome("undo", False, f"payload cleanup failed: {exc}")
|
|
311
|
+
return StepOutcome(
|
|
312
|
+
"undo", result.succeeded, f"payload for {lease.fault_id} terminated", result
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class NoopExecutor(FaultExecutor):
|
|
317
|
+
"""Step-outcome-only executor for placeholder steps with no effect.
|
|
318
|
+
|
|
319
|
+
Kept for emitters/callers that produce steps without real faults; real
|
|
320
|
+
payload families are handled by :class:`PayloadExecutor`.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
prefixes: tuple[str, ...] = ()
|
|
324
|
+
|
|
325
|
+
def inject(self, lease: FaultLease) -> StepOutcome:
|
|
326
|
+
return StepOutcome("inject", True, f"no-op injection recorded for {lease.fault_id}")
|
|
327
|
+
|
|
328
|
+
def undo(self, lease: FaultLease) -> StepOutcome:
|
|
329
|
+
return StepOutcome("undo", True, "noop has nothing to undo")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class ToolExecutor(FaultExecutor):
|
|
333
|
+
"""Generic executor driven by argv pairs declared on the lease.
|
|
334
|
+
|
|
335
|
+
Expects one undo_op whose args carry ``inject_argv`` / ``undo_argv`` as
|
|
336
|
+
JSON-encoded ``list[str]`` (UndoOp.args is str→str by domain contract).
|
|
337
|
+
Keeps exotic faults declarative without new executor classes.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
prefixes = (
|
|
341
|
+
"net",
|
|
342
|
+
"disk",
|
|
343
|
+
"container",
|
|
344
|
+
"node",
|
|
345
|
+
"http",
|
|
346
|
+
"db",
|
|
347
|
+
"dns",
|
|
348
|
+
"tls",
|
|
349
|
+
"clock",
|
|
350
|
+
"dependency",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _argv_for(self, lease: FaultLease, key: str) -> list[str]:
|
|
354
|
+
for op in lease.undo_ops:
|
|
355
|
+
raw = op.args.get(key)
|
|
356
|
+
if isinstance(raw, str):
|
|
357
|
+
try:
|
|
358
|
+
decoded = json.loads(raw)
|
|
359
|
+
except json.JSONDecodeError:
|
|
360
|
+
continue
|
|
361
|
+
if (
|
|
362
|
+
isinstance(decoded, list)
|
|
363
|
+
and decoded
|
|
364
|
+
and all(isinstance(item, str) for item in decoded)
|
|
365
|
+
):
|
|
366
|
+
# Container-addressed faults embed @engine/@cont tokens that the
|
|
367
|
+
# live substitute resolves (ADR-0020); leave them untouched when
|
|
368
|
+
# the op carries no live address so a stale plan fails loudly.
|
|
369
|
+
engine = op.args.get("engine")
|
|
370
|
+
cont = op.args.get("cont")
|
|
371
|
+
if engine and cont:
|
|
372
|
+
return [
|
|
373
|
+
item.replace("@engine", engine).replace("@cont", cont)
|
|
374
|
+
for item in decoded
|
|
375
|
+
]
|
|
376
|
+
return decoded
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
def inject(self, lease: FaultLease) -> StepOutcome:
|
|
380
|
+
argv = self._argv_for(lease, "inject_argv")
|
|
381
|
+
if not argv:
|
|
382
|
+
return StepOutcome("inject", False, f"lease {lease.id} lacks inject_argv")
|
|
383
|
+
result = run_tool(argv)
|
|
384
|
+
return StepOutcome("inject", result.succeeded, result.stderr[:200], result)
|
|
385
|
+
|
|
386
|
+
def undo(self, lease: FaultLease) -> StepOutcome:
|
|
387
|
+
argv = self._argv_for(lease, "undo_argv")
|
|
388
|
+
if not argv:
|
|
389
|
+
return StepOutcome("undo", False, f"lease {lease.id} lacks undo_argv")
|
|
390
|
+
result = run_tool(argv)
|
|
391
|
+
return StepOutcome("undo", result.succeeded, result.stderr[:200], result)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# cpu.throttle applies a container-engine CPU share (``update --cpus``), which
|
|
395
|
+
# is argv-pair work; it is not a burner payload, so it must not be claimed by
|
|
396
|
+
# the prefix-based PayloadExecutor. Registered faults bypass prefix matching.
|
|
397
|
+
_FAULT_EXECUTOR_OVERRIDES: dict[str, FaultExecutor] = {}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _register_fault_executor(fault_id: str, executor: FaultExecutor) -> None:
|
|
401
|
+
_FAULT_EXECUTOR_OVERRIDES[fault_id] = executor
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
EXECUTORS: tuple[FaultExecutor, ...] = (
|
|
405
|
+
ProcPauseExecutor(),
|
|
406
|
+
PayloadExecutor(),
|
|
407
|
+
NoopExecutor(),
|
|
408
|
+
ToolExecutor(),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
_register_fault_executor("cpu.throttle", EXECUTORS[-1])
|
|
412
|
+
# fs.read_only remounts the filesystem in-place; it is argv-pair work, not a
|
|
413
|
+
# burner payload, so it must bypass the prefix-based PayloadExecutor (prefix
|
|
414
|
+
# ``fs``). process.crash_loop drives a container-engine restart cadence, which
|
|
415
|
+
# is also argv-pair work; the process-prefix ProcPauseExecutor only handles
|
|
416
|
+
# SIGSTOP/SIGTERM/SIGKILL, so it is bypassed the same way.
|
|
417
|
+
_register_fault_executor("fs.read_only", EXECUTORS[-1])
|
|
418
|
+
_register_fault_executor("process.crash_loop", EXECUTORS[-1])
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def executor_for(fault_id: str) -> FaultExecutor | None:
|
|
422
|
+
"""Explicit fault-level override wins; otherwise first registered executor
|
|
423
|
+
claiming this fault's prefix."""
|
|
424
|
+
override = _FAULT_EXECUTOR_OVERRIDES.get(fault_id)
|
|
425
|
+
if override is not None:
|
|
426
|
+
return override
|
|
427
|
+
for executor in EXECUTORS:
|
|
428
|
+
if executor.supports(fault_id):
|
|
429
|
+
return executor
|
|
430
|
+
return None
|