feat: classify offline and degraded connectivity
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""Behavior tests for multi-packet fping execution and persistence."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.device import Base, Device, DeviceTypeEnum
|
||||
from app.models.ping_record import PingRecord
|
||||
from app.services.fping_runner import (
|
||||
ProbeResult,
|
||||
parse_fping_count_output,
|
||||
run_fping_count,
|
||||
)
|
||||
from app.services.pinger import Pinger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session() -> AsyncSession:
|
||||
"""Provide an isolated database for pinger transaction tests."""
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_parses_loss_and_average_rtt():
|
||||
"""Numeric replies count as received packets and dashes count as loss."""
|
||||
output = "10.0.0.8 : 12.4 - 12.6\n10.0.0.9 : - - -"
|
||||
|
||||
result = parse_fping_count_output(
|
||||
output,
|
||||
{"10.0.0.8", "10.0.0.9"},
|
||||
3,
|
||||
)
|
||||
|
||||
assert result["10.0.0.8"].received_count == 2
|
||||
assert result["10.0.0.8"].average_rtt_ms == 12.5
|
||||
assert result["10.0.0.8"].packet_loss_percent == pytest.approx(
|
||||
33.3333333333
|
||||
)
|
||||
assert result["10.0.0.9"].packet_loss_percent == 100.0
|
||||
|
||||
|
||||
def test_marks_missing_and_malformed_expected_output_invalid():
|
||||
"""Incomplete fping output cannot be interpreted as device packet loss."""
|
||||
output = "10.0.0.8 : 10.2 bad-token -"
|
||||
|
||||
result = parse_fping_count_output(
|
||||
output,
|
||||
{"10.0.0.8", "10.0.0.9"},
|
||||
3,
|
||||
)
|
||||
|
||||
assert result["10.0.0.8"].is_valid is False
|
||||
assert result["10.0.0.8"].failure_reason == "malformed fping replies"
|
||||
assert result["10.0.0.9"].is_valid is False
|
||||
assert result["10.0.0.9"].failure_reason == "missing fping output"
|
||||
|
||||
|
||||
def test_parses_ipv6_without_confusing_address_colons_for_delimiter():
|
||||
"""An IPv6 address remains the result key when parsing the output delimiter."""
|
||||
result = parse_fping_count_output(
|
||||
"2001:db8::8 : 1.25 - 1.75",
|
||||
{"2001:db8::8"},
|
||||
3,
|
||||
)
|
||||
|
||||
assert result["2001:db8::8"].is_valid is True
|
||||
assert result["2001:db8::8"].received_count == 2
|
||||
assert result["2001:db8::8"].average_rtt_ms == 1.5
|
||||
|
||||
|
||||
async def test_runner_uses_argument_vector_and_parses_quiet_stderr(monkeypatch):
|
||||
"""The runner passes IPs as argv entries and reads fping -C quiet output."""
|
||||
observed = {}
|
||||
|
||||
class CompletedProcess:
|
||||
returncode = 1
|
||||
|
||||
async def communicate(self):
|
||||
return b"", b"10.0.0.8 : 4.2 - 4.4\n"
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
observed["args"] = args
|
||||
observed["kwargs"] = kwargs
|
||||
return CompletedProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
fake_create_subprocess_exec,
|
||||
)
|
||||
|
||||
result = await run_fping_count(
|
||||
["10.0.0.8"],
|
||||
packets_per_round=3,
|
||||
timeout_ms=800,
|
||||
executable="/test/fping",
|
||||
)
|
||||
|
||||
assert observed["args"] == (
|
||||
"/test/fping",
|
||||
"-C",
|
||||
"3",
|
||||
"-q",
|
||||
"-t",
|
||||
"800",
|
||||
"10.0.0.8",
|
||||
)
|
||||
assert observed["kwargs"] == {
|
||||
"stdout": asyncio.subprocess.PIPE,
|
||||
"stderr": asyncio.subprocess.PIPE,
|
||||
}
|
||||
assert result["10.0.0.8"].received_count == 2
|
||||
|
||||
|
||||
async def test_runner_failure_returns_invalid_result_instead_of_packet_loss(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A process communication failure remains an invalid probe aggregate."""
|
||||
|
||||
class BrokenProcess:
|
||||
async def communicate(self):
|
||||
raise RuntimeError("simulated process pipe failure")
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
return BrokenProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
fake_create_subprocess_exec,
|
||||
)
|
||||
|
||||
result = await run_fping_count(
|
||||
["10.0.0.8"],
|
||||
packets_per_round=3,
|
||||
timeout_ms=800,
|
||||
executable="/test/fping",
|
||||
)
|
||||
|
||||
assert result["10.0.0.8"].is_valid is False
|
||||
assert result["10.0.0.8"].failure_reason == (
|
||||
"fping execution failed: RuntimeError"
|
||||
)
|
||||
|
||||
|
||||
async def test_pinger_persists_one_summary_for_each_enabled_device(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
):
|
||||
"""One monitoring round writes exactly one aggregate per enabled device."""
|
||||
first = Device(
|
||||
name="edge-a",
|
||||
ip="10.0.0.8",
|
||||
device_type=DeviceTypeEnum.switch,
|
||||
current_status="online",
|
||||
is_enabled=True,
|
||||
)
|
||||
second = Device(
|
||||
name="edge-b",
|
||||
ip="10.0.0.9",
|
||||
device_type=DeviceTypeEnum.switch,
|
||||
current_status="online",
|
||||
is_enabled=True,
|
||||
)
|
||||
disabled = Device(
|
||||
name="edge-disabled",
|
||||
ip="10.0.0.10",
|
||||
device_type=DeviceTypeEnum.switch,
|
||||
current_status="online",
|
||||
is_enabled=False,
|
||||
)
|
||||
db_session.add_all([first, second, disabled])
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_run_fping_count(
|
||||
ips,
|
||||
packets_per_round,
|
||||
timeout_ms,
|
||||
executable,
|
||||
):
|
||||
assert set(ips) == {"10.0.0.8", "10.0.0.9"}
|
||||
return {
|
||||
"10.0.0.8": ProbeResult("10.0.0.8", 3, 2, 8.5, True),
|
||||
"10.0.0.9": ProbeResult("10.0.0.9", 3, 3, 9.5, True),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.pinger.run_fping_count",
|
||||
fake_run_fping_count,
|
||||
)
|
||||
|
||||
changes = await Pinger(Settings()).run_one_round(db_session)
|
||||
records = list(
|
||||
(
|
||||
await db_session.execute(
|
||||
select(PingRecord).order_by(PingRecord.device_id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
assert changes == []
|
||||
assert len(records) == 2
|
||||
assert [record.device_id for record in records] == [first.id, second.id]
|
||||
assert records[0].sent_count == 3
|
||||
assert records[0].received_count == 2
|
||||
assert records[0].packet_loss_percent == pytest.approx(33.3333333333)
|
||||
assert records[0].average_rtt_ms == 8.5
|
||||
assert records[0].is_valid is True
|
||||
|
||||
|
||||
async def test_pinger_persists_invalid_probe_without_changing_device_state(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A runner failure remains observable but cannot mark a device offline."""
|
||||
device = Device(
|
||||
name="edge-a",
|
||||
ip="10.0.0.8",
|
||||
device_type=DeviceTypeEnum.switch,
|
||||
current_status="online",
|
||||
is_enabled=True,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_run_fping_count(*args, **kwargs):
|
||||
return {
|
||||
device.ip: ProbeResult(
|
||||
device.ip,
|
||||
3,
|
||||
0,
|
||||
None,
|
||||
False,
|
||||
"fping executable not found",
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.pinger.run_fping_count",
|
||||
fake_run_fping_count,
|
||||
)
|
||||
|
||||
changes = await Pinger(Settings()).run_one_round(db_session)
|
||||
record = (
|
||||
await db_session.execute(select(PingRecord).where(PingRecord.device_id == device.id))
|
||||
).scalar_one()
|
||||
|
||||
assert changes == []
|
||||
assert device.current_status == "online"
|
||||
assert record.is_valid is False
|
||||
assert record.packet_loss_percent is None
|
||||
assert record.failure_reason == "fping executable not found"
|
||||
|
||||
|
||||
async def test_pinger_evaluates_recent_valid_summaries_and_returns_change(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
):
|
||||
"""A newly persisted full-loss summary completes the offline streak."""
|
||||
device = Device(
|
||||
name="edge-a",
|
||||
ip="10.0.0.8",
|
||||
device_type=DeviceTypeEnum.switch,
|
||||
current_status="online",
|
||||
is_enabled=True,
|
||||
offline_consecutive_rounds=2,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
PingRecord(
|
||||
device_id=device.id,
|
||||
is_alive=False,
|
||||
response_time_ms=None,
|
||||
round_num=1,
|
||||
sent_count=3,
|
||||
received_count=0,
|
||||
packet_loss_percent=100.0,
|
||||
average_rtt_ms=None,
|
||||
is_valid=True,
|
||||
created_at=datetime.now() - timedelta(seconds=30),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_run_fping_count(*args, **kwargs):
|
||||
return {
|
||||
device.ip: ProbeResult(
|
||||
device.ip,
|
||||
3,
|
||||
0,
|
||||
None,
|
||||
True,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.pinger.run_fping_count",
|
||||
fake_run_fping_count,
|
||||
)
|
||||
|
||||
changes = await Pinger(Settings()).run_one_round(db_session)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].device is device
|
||||
assert changes[0].old_status == "online"
|
||||
assert changes[0].new_status == "offline"
|
||||
assert changes[0].event_type == "offline"
|
||||
assert device.current_status == "offline"
|
||||
assert device.last_offline_time is not None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for the connectivity health state machine."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.device import DeviceMonitoringPolicy
|
||||
from app.services.state_machine import evaluate_health
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecentProbe:
|
||||
"""Minimal real-data-shaped record consumed by the pure state machine."""
|
||||
|
||||
sent_count: int
|
||||
received_count: int
|
||||
is_valid: bool = True
|
||||
|
||||
|
||||
def loss(sent_count: int) -> RecentProbe:
|
||||
return RecentProbe(sent_count=sent_count, received_count=0)
|
||||
|
||||
|
||||
def partial(sent_count: int, received_count: int) -> RecentProbe:
|
||||
return RecentProbe(
|
||||
sent_count=sent_count,
|
||||
received_count=received_count,
|
||||
)
|
||||
|
||||
|
||||
def clean(sent_count: int) -> RecentProbe:
|
||||
return RecentProbe(sent_count=sent_count, received_count=sent_count)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy() -> DeviceMonitoringPolicy:
|
||||
return DeviceMonitoringPolicy(
|
||||
probe_packets_per_round=3,
|
||||
offline_consecutive_rounds=2,
|
||||
degraded_window_rounds=5,
|
||||
degraded_loss_percent=20.0,
|
||||
recovery_consecutive_clean_rounds=3,
|
||||
)
|
||||
|
||||
|
||||
def test_two_full_loss_rounds_take_device_offline(policy):
|
||||
"""Configured consecutive full-loss rounds transition to offline once."""
|
||||
decision = evaluate_health("online", [loss(3), loss(3)], policy)
|
||||
|
||||
assert (decision.next_status, decision.event_type) == (
|
||||
"offline",
|
||||
"offline",
|
||||
)
|
||||
assert decision.should_notify is True
|
||||
|
||||
|
||||
def test_offline_priority_wins_over_aggregate_degraded_loss(policy):
|
||||
"""Full-loss streaks are classified offline even when the window also degrades."""
|
||||
recent = [partial(3, 2)] * 3 + [loss(3), loss(3)]
|
||||
|
||||
decision = evaluate_health("online", recent, policy)
|
||||
|
||||
assert decision.next_status == "offline"
|
||||
assert decision.event_type == "offline"
|
||||
|
||||
|
||||
def test_five_round_window_with_loss_is_degraded(policy):
|
||||
"""Aggregate loss at the configured window threshold is degraded."""
|
||||
decision = evaluate_health(
|
||||
"online",
|
||||
[partial(3, 2)] * 5,
|
||||
policy,
|
||||
)
|
||||
|
||||
assert decision.next_status == "degraded"
|
||||
assert decision.event_type == "degraded"
|
||||
|
||||
|
||||
def test_degraded_requires_a_complete_window(policy):
|
||||
"""A partial history cannot satisfy an aggregate-window decision."""
|
||||
decision = evaluate_health(
|
||||
"online",
|
||||
[partial(3, 2)] * 4,
|
||||
policy,
|
||||
)
|
||||
|
||||
assert decision.next_status == "online"
|
||||
assert decision.event_type is None
|
||||
|
||||
|
||||
def test_three_clean_rounds_recovers(policy):
|
||||
"""An alerted device recovers only after the configured clean streak."""
|
||||
decision = evaluate_health(
|
||||
"offline",
|
||||
[clean(3)] * 3,
|
||||
policy,
|
||||
)
|
||||
|
||||
assert (decision.next_status, decision.event_type) == (
|
||||
"online",
|
||||
"recovered",
|
||||
)
|
||||
assert decision.should_notify is True
|
||||
|
||||
|
||||
def test_clean_online_device_does_not_emit_duplicate_event(policy):
|
||||
"""Clean probes retain an already-online state without notifying."""
|
||||
decision = evaluate_health(
|
||||
"online",
|
||||
[clean(3)] * 5,
|
||||
policy,
|
||||
)
|
||||
|
||||
assert decision.next_status == "online"
|
||||
assert decision.event_type is None
|
||||
assert decision.should_notify is False
|
||||
|
||||
|
||||
def test_latest_invalid_probe_retains_state(policy):
|
||||
"""A malformed current result cannot reuse old loss to trigger a fault."""
|
||||
recent = [loss(3), RecentProbe(3, 0, is_valid=False)]
|
||||
|
||||
decision = evaluate_health("online", recent, policy)
|
||||
|
||||
assert decision.next_status == "online"
|
||||
assert decision.event_type is None
|
||||
assert decision.should_notify is False
|
||||
|
||||
|
||||
def test_offline_does_not_recover_before_clean_streak_is_complete(policy):
|
||||
"""Recovery debounce retains offline until enough clean rounds exist."""
|
||||
decision = evaluate_health(
|
||||
"offline",
|
||||
[clean(3), clean(3)],
|
||||
policy,
|
||||
)
|
||||
|
||||
assert decision.next_status == "offline"
|
||||
assert decision.event_type is None
|
||||
Reference in New Issue
Block a user