Skip to content

Commit 17fa72c

Browse files
authored
feat: implement contract event export CLI, chaos testing suite, and automated changelogs (#750)
* feat: implement contract event export CLI and add chaos engineering testing suite * feat: add automated changelog generation workflow using git-cliff * test: add automated forward and rollback validation for ingest migrations * test migration fix * test: add coverage for forward and rollback migration paths in the ingest app * test: add comprehensive forward and rollback migration coverage for the ingest app
1 parent 4e5e0db commit 17fa72c

18 files changed

Lines changed: 1368 additions & 47 deletions

File tree

.github/workflows/changelog.yml

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,10 @@ jobs:
3131
OUTPUT: docs/changelog.md
3232
GITHUB_REPO: ${{ github.repository }}
3333

34-
- name: Ensure GH_PAT is configured
35-
env:
36-
GH_PAT: ${{ secrets.GH_PAT }}
37-
run: |
38-
if [ -z "$GH_PAT" ]; then
39-
echo "GH_PAT secret is required to create changelog pull requests."
40-
exit 1
41-
fi
42-
4334
- name: Create Pull Request
4435
uses: peter-evans/create-pull-request@v6
4536
with:
46-
token: ${{ secrets.GH_PAT }}
37+
token: ${{ secrets.GITHUB_TOKEN }}
4738
commit-message: "chore(changelog): update changelog"
4839
title: "chore(changelog): Update Changelog"
4940
body: |

.github/workflows/chaos-tests.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Chaos Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "chaos-tests/**"
8+
- ".github/workflows/chaos-tests.yml"
9+
pull_request:
10+
branches: [main]
11+
paths:
12+
- "chaos-tests/**"
13+
- ".github/workflows/chaos-tests.yml"
14+
15+
jobs:
16+
validate:
17+
runs-on: ubuntu-latest
18+
defaults:
19+
run:
20+
working-directory: chaos-tests
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Set up Python
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.11"
28+
29+
- name: Install dependencies
30+
run: python -m pip install pytest PyYAML
31+
32+
- name: Validate chaos scenarios
33+
run: python -m pytest -q
9.92 KB
Binary file not shown.

chaos-tests/run_chaos.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Opt-in chaos engineering harness for SoroScan Kubernetes deployments.
2+
3+
The runner validates scenarios by default. It executes disruptive actions only
4+
when SOROSCAN_CHAOS_RUN=1 is set, making it safe for CI validation.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import os
11+
import subprocess
12+
import sys
13+
import time
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
from typing import Any
17+
from urllib.error import URLError
18+
from urllib.request import urlopen
19+
20+
import yaml
21+
22+
23+
ROOT = Path(__file__).resolve().parent
24+
DEFAULT_SCENARIOS = ROOT / "scenarios.yaml"
25+
26+
27+
@dataclass(frozen=True)
28+
class Scenario:
29+
name: str
30+
description: str
31+
namespace: str
32+
selector: str
33+
action: dict[str, Any]
34+
recovery: dict[str, Any]
35+
36+
37+
class ChaosError(RuntimeError):
38+
"""Raised when a chaos scenario cannot be validated or executed."""
39+
40+
41+
def load_scenarios(path: Path = DEFAULT_SCENARIOS) -> list[Scenario]:
42+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
43+
scenarios = []
44+
for raw in data.get("scenarios", []):
45+
scenario = Scenario(
46+
name=raw["name"],
47+
description=raw.get("description", ""),
48+
namespace=raw["namespace"],
49+
selector=raw["selector"],
50+
action=raw["action"],
51+
recovery=raw["recovery"],
52+
)
53+
validate_scenario(scenario)
54+
scenarios.append(scenario)
55+
if not scenarios:
56+
raise ChaosError("No chaos scenarios were defined.")
57+
return scenarios
58+
59+
60+
def validate_scenario(scenario: Scenario) -> None:
61+
action_type = scenario.action.get("type")
62+
supported = {
63+
"pod_termination",
64+
"network_latency",
65+
"memory_exhaustion",
66+
"cpu_throttling",
67+
}
68+
if action_type not in supported:
69+
raise ChaosError(f"Unsupported action type for {scenario.name}: {action_type}")
70+
if not scenario.recovery.get("url"):
71+
raise ChaosError(f"Scenario {scenario.name} requires recovery.url")
72+
if int(scenario.recovery.get("timeout_seconds", 0)) <= 0:
73+
raise ChaosError(f"Scenario {scenario.name} requires positive recovery.timeout_seconds")
74+
75+
76+
def build_commands(scenario: Scenario) -> list[list[str]]:
77+
action = scenario.action
78+
action_type = action["type"]
79+
namespace = scenario.namespace
80+
selector = scenario.selector
81+
82+
if action_type == "pod_termination":
83+
return [
84+
[
85+
"kubectl",
86+
"-n",
87+
namespace,
88+
"delete",
89+
"pod",
90+
"-l",
91+
selector,
92+
"--field-selector=status.phase=Running",
93+
]
94+
]
95+
96+
if action_type == "network_latency":
97+
latency = int(action["latency_ms"])
98+
duration = int(action.get("duration_seconds", 30))
99+
return [
100+
[
101+
"kubectl",
102+
"-n",
103+
namespace,
104+
"exec",
105+
"deploy/" + action.get("deployment", "soroscan-backend"),
106+
"--",
107+
"sh",
108+
"-c",
109+
f"tc qdisc add dev eth0 root netem delay {latency}ms; "
110+
f"sleep {duration}; tc qdisc del dev eth0 root || true",
111+
]
112+
]
113+
114+
if action_type in {"memory_exhaustion", "cpu_throttling"}:
115+
deployment = action["deployment"]
116+
resource = (
117+
f"memory={action['memory_limit']}"
118+
if action_type == "memory_exhaustion"
119+
else f"cpu={action['cpu_limit']}"
120+
)
121+
duration = int(action.get("duration_seconds", 30))
122+
return [
123+
[
124+
"kubectl",
125+
"-n",
126+
namespace,
127+
"set",
128+
"resources",
129+
f"deployment/{deployment}",
130+
"--limits",
131+
resource,
132+
],
133+
["sleep", str(duration)],
134+
[
135+
"kubectl",
136+
"-n",
137+
namespace,
138+
"rollout",
139+
"restart",
140+
f"deployment/{deployment}",
141+
],
142+
[
143+
"kubectl",
144+
"-n",
145+
namespace,
146+
"rollout",
147+
"status",
148+
f"deployment/{deployment}",
149+
"--timeout=120s",
150+
],
151+
]
152+
153+
raise ChaosError(f"Unsupported action type: {action_type}")
154+
155+
156+
def wait_for_recovery(url: str, timeout_seconds: int, interval_seconds: float = 2.0) -> None:
157+
deadline = time.time() + timeout_seconds
158+
last_error = None
159+
while time.time() < deadline:
160+
try:
161+
with urlopen(url, timeout=5) as response:
162+
if 200 <= response.status < 500:
163+
return
164+
except URLError as exc:
165+
last_error = exc
166+
time.sleep(interval_seconds)
167+
raise ChaosError(f"Recovery check failed for {url}: {last_error}")
168+
169+
170+
def run_command(command: list[str]) -> None:
171+
subprocess.run(command, check=True)
172+
173+
174+
def run_scenario(scenario: Scenario, execute: bool) -> None:
175+
commands = build_commands(scenario)
176+
if not execute:
177+
print(f"[dry-run] {scenario.name}: {len(commands)} commands validated")
178+
return
179+
for command in commands:
180+
run_command(command)
181+
wait_for_recovery(
182+
scenario.recovery["url"],
183+
int(scenario.recovery["timeout_seconds"]),
184+
)
185+
print(f"[ok] {scenario.name} recovered")
186+
187+
188+
def main(argv: list[str] | None = None) -> int:
189+
parser = argparse.ArgumentParser(description="Run SoroScan chaos scenarios.")
190+
parser.add_argument("--scenario", help="Run one scenario by name")
191+
parser.add_argument("--scenarios-file", type=Path, default=DEFAULT_SCENARIOS)
192+
parser.add_argument(
193+
"--execute",
194+
action="store_true",
195+
help="Execute disruptive actions. Also requires SOROSCAN_CHAOS_RUN=1.",
196+
)
197+
args = parser.parse_args(argv)
198+
199+
try:
200+
scenarios = load_scenarios(args.scenarios_file)
201+
if args.scenario:
202+
scenarios = [item for item in scenarios if item.name == args.scenario]
203+
if not scenarios:
204+
raise ChaosError(f"Unknown scenario: {args.scenario}")
205+
206+
execute = args.execute and os.getenv("SOROSCAN_CHAOS_RUN") == "1"
207+
if args.execute and not execute:
208+
raise ChaosError("Set SOROSCAN_CHAOS_RUN=1 before executing chaos actions.")
209+
210+
for scenario in scenarios:
211+
run_scenario(scenario, execute=execute)
212+
except ChaosError as exc:
213+
print(f"chaos error: {exc}", file=sys.stderr)
214+
return 1
215+
return 0
216+
217+
218+
if __name__ == "__main__":
219+
raise SystemExit(main())

chaos-tests/scenarios.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
scenarios:
2+
- name: pod_termination
3+
description: Delete one backend pod and verify readiness recovers.
4+
namespace: soroscan
5+
selector: app=soroscan-backend
6+
action:
7+
type: pod_termination
8+
recovery:
9+
url: http://127.0.0.1:8000/ready/
10+
timeout_seconds: 120
11+
12+
- name: network_latency
13+
description: Inject traffic-control latency into backend pods.
14+
namespace: soroscan
15+
selector: app=soroscan-backend
16+
action:
17+
type: network_latency
18+
latency_ms: 250
19+
duration_seconds: 30
20+
recovery:
21+
url: http://127.0.0.1:8000/api/ingest/health/
22+
timeout_seconds: 120
23+
24+
- name: memory_exhaustion
25+
description: Apply a temporary low memory limit and verify graceful recovery.
26+
namespace: soroscan
27+
selector: app=soroscan-backend
28+
action:
29+
type: memory_exhaustion
30+
deployment: soroscan-backend
31+
memory_limit: 128Mi
32+
duration_seconds: 30
33+
recovery:
34+
url: http://127.0.0.1:8000/ready/
35+
timeout_seconds: 120
36+
37+
- name: cpu_throttling
38+
description: Apply a temporary low CPU limit and verify graceful recovery.
39+
namespace: soroscan
40+
selector: app=soroscan-backend
41+
action:
42+
type: cpu_throttling
43+
deployment: soroscan-backend
44+
cpu_limit: 100m
45+
duration_seconds: 30
46+
recovery:
47+
url: http://127.0.0.1:8000/api/ingest/health/
48+
timeout_seconds: 120
Binary file not shown.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from run_chaos import ChaosError, build_commands, load_scenarios, main
6+
7+
8+
def test_loads_all_required_scenarios():
9+
scenarios = load_scenarios(Path(__file__).resolve().parents[1] / "scenarios.yaml")
10+
11+
names = {scenario.name for scenario in scenarios}
12+
13+
assert names == {
14+
"pod_termination",
15+
"network_latency",
16+
"memory_exhaustion",
17+
"cpu_throttling",
18+
}
19+
20+
21+
@pytest.mark.parametrize(
22+
("scenario_name", "expected_fragment"),
23+
[
24+
("pod_termination", "delete"),
25+
("network_latency", "tc qdisc add"),
26+
("memory_exhaustion", "memory=128Mi"),
27+
("cpu_throttling", "cpu=100m"),
28+
],
29+
)
30+
def test_builds_expected_kubectl_commands(scenario_name, expected_fragment):
31+
scenarios = load_scenarios(Path(__file__).resolve().parents[1] / "scenarios.yaml")
32+
scenario = next(item for item in scenarios if item.name == scenario_name)
33+
34+
commands = build_commands(scenario)
35+
flattened = " ".join(" ".join(command) for command in commands)
36+
37+
assert expected_fragment in flattened
38+
39+
40+
def test_execute_requires_explicit_environment_flag(monkeypatch):
41+
monkeypatch.delenv("SOROSCAN_CHAOS_RUN", raising=False)
42+
43+
assert main(["--execute"]) == 1
44+
45+
46+
def test_unknown_scenario_fails_cleanly():
47+
assert main(["--scenario", "missing"]) == 1
48+
49+
50+
def test_invalid_scenario_file_fails(tmp_path):
51+
path = tmp_path / "bad.yaml"
52+
path.write_text(
53+
"""
54+
scenarios:
55+
- name: bad
56+
namespace: soroscan
57+
selector: app=soroscan-backend
58+
action: {type: unknown}
59+
recovery: {url: http://127.0.0.1:8000/ready/, timeout_seconds: 1}
60+
""",
61+
encoding="utf-8",
62+
)
63+
64+
with pytest.raises(ChaosError):
65+
load_scenarios(path)

0 commit comments

Comments
 (0)