-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathtest_health_gate.py
More file actions
269 lines (230 loc) · 11.7 KB
/
Copy pathtest_health_gate.py
File metadata and controls
269 lines (230 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""
Tests for the Day-1 Health Gate orchestrator.
Run from src/ parent dir:
cd 04_Scripts_Tools/DCN_Network_Tool && python -m pytest test_health_gate.py -v
Covers:
- Snapshot capture + diff math
- Regression detection against tolerance
- Happy path: clean window → confirmed
- Sad path: induced regression → abandoned
- Job registry: submit + get + list_recent
- Mode detection: forces simulated when env var set
- Tolerance overrides
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import pytest
# Make src/ importable
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE / "src"))
# Force simulated mode for all tests — no real PyEZ / network access
os.environ["HEALTH_GATE_FORCE_SIMULATE"] = "1"
import health_gate as hg # noqa: E402
# ─── Helpers ────────────────────────────────────────────────────────────────
def fixed_snapshot(state: dict):
"""Return a snapshot_fn that always yields the given state."""
def _fn(hostname: str) -> dict:
return state
return _fn
def scripted_snapshot(states: list[dict]):
"""Return a snapshot_fn that yields successive states across calls."""
box = {"i": 0}
def _fn(hostname: str) -> dict:
i = min(box["i"], len(states) - 1)
box["i"] += 1
return states[i]
return _fn
# ─── Snapshot model ─────────────────────────────────────────────────────────
class TestSnapshot:
def test_capture_uses_default_when_no_fn(self):
snap = hg.capture_snapshot("de-fra-core-01")
assert snap.hostname == "de-fra-core-01"
assert snap.bgp_peers_up == 4
assert snap.interfaces_up == 12
assert snap.alerts_count == 0
assert snap.ts # ISO timestamp set
def test_capture_with_custom_fn(self):
snap = hg.capture_snapshot("anything", snapshot_fn=fixed_snapshot(
{"bgp_peers_up": 7, "interfaces_up": 20, "alerts_count": 3}
))
assert snap.bgp_peers_up == 7
assert snap.interfaces_up == 20
assert snap.alerts_count == 3
def test_diff_negative_when_regression(self):
base = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=12, alerts_count=0)
worse = hg.Snapshot("h", "t", bgp_peers_up=2, interfaces_up=11, alerts_count=2)
d = worse.diff(base)
assert d["bgp_peers_delta"] == -2
assert d["interfaces_delta"] == -1
assert d["alerts_delta"] == 2
def test_regression_detected_when_bgp_lost(self):
base = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=12, alerts_count=0)
worse = hg.Snapshot("h", "t", bgp_peers_up=3, interfaces_up=12, alerts_count=0)
regs = worse.regression_against(base, hg.DEFAULT_TOLERANCE)
assert len(regs) == 1
assert "BGP" in regs[0]
def test_no_regression_when_equal(self):
base = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=12, alerts_count=0)
same = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=12, alerts_count=0)
assert same.regression_against(base, hg.DEFAULT_TOLERANCE) == []
def test_tolerance_allows_some_loss(self):
base = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=12, alerts_count=0)
worse = hg.Snapshot("h", "t", bgp_peers_up=3, interfaces_up=12, alerts_count=0)
# tolerance: allow up to 1 peer loss
regs = worse.regression_against(base, {**hg.DEFAULT_TOLERANCE, "bgp_peers_lost": 1})
assert regs == []
def test_improvement_is_not_regression(self):
base = hg.Snapshot("h", "t", bgp_peers_up=2, interfaces_up=8, alerts_count=5)
better = hg.Snapshot("h", "t", bgp_peers_up=4, interfaces_up=10, alerts_count=2)
assert better.regression_against(base, hg.DEFAULT_TOLERANCE) == []
# ─── Mode detection ─────────────────────────────────────────────────────────
class TestModeDetection:
def test_forced_simulate(self):
assert hg._detect_mode("anything") == "simulated"
def test_unknown_host_is_simulated(self, monkeypatch):
monkeypatch.delenv("HEALTH_GATE_FORCE_SIMULATE", raising=False)
# Even without forced flag, no PyEZ → simulated
# (PyEZ is not installed in the test env)
assert hg._detect_mode("random-host") == "simulated"
# ─── Submit / job registry ──────────────────────────────────────────────────
class TestSubmit:
def test_submit_rejects_empty_hostname(self):
with pytest.raises(ValueError):
hg.submit(hostname="")
def test_submit_returns_job_with_id(self):
job = hg.submit(hostname="de-fra-core-01", block=False, timeout_s=1)
assert job.job_id.startswith("hg-")
assert job.hostname == "de-fra-core-01"
assert job.mode == "simulated"
# The thread is detached; just verify the job is in the registry
assert hg.get_job(job.job_id) is job
def test_get_job_returns_none_for_unknown(self):
assert hg.get_job("hg-nonexistent") is None
# ─── Happy path: clean window → confirmed ───────────────────────────────────
class TestHappyPath:
def test_clean_window_confirms(self, monkeypatch):
# Use very short window for fast test
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
# Force the module-level constant too in the module the worker reads
# (we set DEFAULT_POLL_INTERVAL_S to 0; the loop's `poll` reads it)
stable = fixed_snapshot({"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 0})
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=2,
block=True,
snapshot_fn=stable,
)
assert job.final_verdict == "confirmed"
assert job.phase == "done"
assert job.error == ""
assert job.regressions == []
assert job.pre_snapshot is not None
assert job.last_snapshot is not None
assert len(job.watch_samples) >= 1
# Progress completed
assert job.progress_pct == 100
# ─── Sad path: induced regression → abandoned ───────────────────────────────
class TestSadPath:
def test_induced_regression_abandons(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
stable = fixed_snapshot({"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 0})
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=10,
block=True,
snapshot_fn=stable,
induce_regression_after_s=0, # immediate drop
)
assert job.final_verdict == "abandoned"
assert job.phase == "done"
assert len(job.regressions) >= 1
assert "BGP" in job.regressions[0]
def test_induced_alert_spike_hook_abandons(self, monkeypatch):
# Exercises the induce_alert_spike_after_s hook used by the UI's
# "alert-spike" scenario. Pre is clean; the hook bumps alerts_count by 3
# on each sample after t=0, which exceeds the default tolerance of 0.
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
stable = fixed_snapshot({"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 0})
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=5,
block=True,
snapshot_fn=stable,
induce_alert_spike_after_s=0,
)
assert job.final_verdict == "abandoned"
assert any("Alerts" in r for r in job.regressions)
def test_alerts_spike_abandons(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
# Pre-snapshot clean, then a sample with extra alerts
states = [
{"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 0}, # pre
{"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 3}, # sample
]
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=5,
block=True,
snapshot_fn=scripted_snapshot(states),
)
assert job.final_verdict == "abandoned"
assert any("Alerts" in r for r in job.regressions)
# ─── Tolerance overrides ────────────────────────────────────────────────────
class TestTolerance:
def test_loose_tolerance_allows_known_flap(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
stable = fixed_snapshot({"bgp_peers_up": 3, "interfaces_up": 12, "alerts_count": 0}) # lose 1 peer
# Pre will use the default lab snapshot which has bgp_peers_up=4 for de-fra-core-01.
# First sample uses snapshot_fn → reports 3 peers (regression).
# With loose tolerance allowing 1 peer drop, should still confirm.
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=1,
tolerance={"bgp_peers_lost": 1, "interfaces_lost": 0, "alerts_added": 0},
block=True,
snapshot_fn=None, # use defaults for pre AND samples
)
# Note: snapshot_fn=None means both pre and watch use the deterministic
# default which is identical → no regression. Verify clean confirm.
assert job.final_verdict == "confirmed"
# ─── Error handling ─────────────────────────────────────────────────────────
class TestErrorHandling:
def test_forced_failure_at_snapshot(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=1,
block=True,
fail_at_phase="snapshot_pre",
)
assert job.final_verdict == "error"
assert "forced test failure" in job.error
assert job.phase == "done"
def test_forced_failure_at_apply(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
job = hg.submit(
hostname="de-fra-core-01",
timeout_s=1,
block=True,
fail_at_phase="applying",
)
assert job.final_verdict == "error"
assert job.pre_snapshot is not None # pre captured before failure
# ─── list_recent_jobs ───────────────────────────────────────────────────────
class TestListRecent:
def test_recent_returns_newest_first(self, monkeypatch):
monkeypatch.setattr(hg, "DEFAULT_POLL_INTERVAL_S", 0)
stable = fixed_snapshot({"bgp_peers_up": 4, "interfaces_up": 12, "alerts_count": 0})
j1 = hg.submit("de-fra-core-01", timeout_s=1, block=True, snapshot_fn=stable)
time.sleep(0.01) # ensure strictly later ms timestamp
j2 = hg.submit("uk-lon-core-01", timeout_s=1, block=True, snapshot_fn=stable)
# Use generous limit — the test-suite registry accumulates jobs from prior tests
recents = hg.list_recent_jobs(limit=100)
ids = [r["job_id"] for r in recents]
assert j1.job_id in ids
assert j2.job_id in ids
# Newest first: j2 was submitted after j1
assert ids.index(j2.job_id) < ids.index(j1.job_id)