mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 18:02:04 +03:00
models: ModuleResult, ProbeError, Finding, Signal envelopes
This commit is contained in:
parent
dfaca484d4
commit
f8f5fbc49c
2 changed files with 201 additions and 0 deletions
102
src/netcheck/models.py
Normal file
102
src/netcheck/models.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field, fields, is_dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ERROR_KINDS = (
|
||||||
|
"timeout",
|
||||||
|
"http_error",
|
||||||
|
"rate_limited",
|
||||||
|
"blocked",
|
||||||
|
"parse_error",
|
||||||
|
"unavailable",
|
||||||
|
"no_privilege",
|
||||||
|
"not_applicable",
|
||||||
|
)
|
||||||
|
STATUSES = ("ok", "partial", "failed", "skipped")
|
||||||
|
SEVERITIES = ("ok", "info", "warn", "crit")
|
||||||
|
DIRECTIONS = ("vpn", "clean")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProbeError:
|
||||||
|
source: str
|
||||||
|
kind: str
|
||||||
|
message: str
|
||||||
|
retryable: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in ERROR_KINDS:
|
||||||
|
raise ValueError(f"unknown ProbeError kind: {self.kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleResult:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
data: Any | None = None
|
||||||
|
errors: list[ProbeError] = field(default_factory=list)
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
started_at: str = ""
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.status not in STATUSES:
|
||||||
|
raise ValueError(f"unknown ModuleResult status: {self.status!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Finding:
|
||||||
|
id: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
metric: str | None = None
|
||||||
|
value: float | str | None = None
|
||||||
|
threshold: float | str | None = None
|
||||||
|
advice: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.severity not in SEVERITIES:
|
||||||
|
raise ValueError(f"unknown Finding severity: {self.severity!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Signal:
|
||||||
|
name: str
|
||||||
|
observed: bool
|
||||||
|
weight: float
|
||||||
|
direction: str
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.direction not in DIRECTIONS:
|
||||||
|
raise ValueError(f"unknown Signal direction: {self.direction!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def to_jsonable(obj: Any) -> Any:
|
||||||
|
if obj is None or isinstance(obj, (str, bool, int)):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, float):
|
||||||
|
return obj if math.isfinite(obj) else None
|
||||||
|
if is_dataclass(obj) and not isinstance(obj, type):
|
||||||
|
return {f.name: to_jsonable(getattr(obj, f.name)) for f in fields(obj)}
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {str(k): to_jsonable(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [to_jsonable(v) for v in obj]
|
||||||
|
if isinstance(obj, (set, frozenset)):
|
||||||
|
return sorted(to_jsonable(v) for v in obj)
|
||||||
|
if isinstance(obj, Enum):
|
||||||
|
return to_jsonable(obj.value)
|
||||||
|
if isinstance(obj, (datetime, date)):
|
||||||
|
return obj.isoformat()
|
||||||
|
if isinstance(obj, Path):
|
||||||
|
return str(obj)
|
||||||
|
if isinstance(obj, bytes):
|
||||||
|
return obj.decode("utf-8", "replace")
|
||||||
|
return str(obj)
|
||||||
99
tests/test_models.py
Normal file
99
tests/test_models.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from netcheck.models import Finding, ModuleResult, ProbeError, Signal, to_jsonable
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_error_rejects_unknown_kind():
|
||||||
|
with pytest.raises(ValueError, match="unknown ProbeError kind"):
|
||||||
|
ProbeError(source="ip-api", kind="exploded", message="boom")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
"timeout",
|
||||||
|
"http_error",
|
||||||
|
"rate_limited",
|
||||||
|
"blocked",
|
||||||
|
"parse_error",
|
||||||
|
"unavailable",
|
||||||
|
"no_privilege",
|
||||||
|
"not_applicable",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_probe_error_accepts_documented_kinds(kind):
|
||||||
|
assert ProbeError(source="s", kind=kind, message="m").kind == kind
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_result_rejects_unknown_status():
|
||||||
|
with pytest.raises(ValueError, match="unknown ModuleResult status"):
|
||||||
|
ModuleResult(name="bgp", status="borked")
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_result_serializes_to_strict_json():
|
||||||
|
result = ModuleResult(
|
||||||
|
name="reputation",
|
||||||
|
status="partial",
|
||||||
|
data={"firehol_hits": ["firehol_level1"]},
|
||||||
|
errors=[ProbeError(source="internetdb", kind="timeout", message="8s", retryable=True)],
|
||||||
|
warnings=["abuseipdb key missing"],
|
||||||
|
started_at="2026-08-08T19:12:00Z",
|
||||||
|
duration_ms=1234,
|
||||||
|
)
|
||||||
|
text = json.dumps(to_jsonable(result), allow_nan=False)
|
||||||
|
back = json.loads(text)
|
||||||
|
assert back["name"] == "reputation"
|
||||||
|
assert back["status"] == "partial"
|
||||||
|
assert back["data"]["firehol_hits"] == ["firehol_level1"]
|
||||||
|
assert back["errors"][0] == {
|
||||||
|
"source": "internetdb",
|
||||||
|
"kind": "timeout",
|
||||||
|
"message": "8s",
|
||||||
|
"retryable": True,
|
||||||
|
}
|
||||||
|
assert back["warnings"] == ["abuseipdb key missing"]
|
||||||
|
assert back["duration_ms"] == 1234
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_jsonable_coerces_non_finite_numbers_to_null():
|
||||||
|
payload = {"a": float("inf"), "b": float("-inf"), "c": float("nan"), "d": 1.5}
|
||||||
|
out = to_jsonable(payload)
|
||||||
|
assert out == {"a": None, "b": None, "c": None, "d": 1.5}
|
||||||
|
json.dumps(out, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_jsonable_handles_nested_dataclasses_and_sets():
|
||||||
|
finding = Finding(
|
||||||
|
id="latency.high",
|
||||||
|
severity="warn",
|
||||||
|
title="Latency above target",
|
||||||
|
detail="avg 130 ms to 1.1.1.1",
|
||||||
|
metric="avg_ms",
|
||||||
|
value=130.0,
|
||||||
|
threshold=100.0,
|
||||||
|
advice="Check for a saturated uplink.",
|
||||||
|
)
|
||||||
|
signal = Signal(name="tunnel_iface", observed=True, weight=0.35, direction="vpn", note="wg0")
|
||||||
|
out = to_jsonable({"findings": [finding], "signals": (signal,), "tags": {"a"}})
|
||||||
|
assert out["findings"][0]["severity"] == "warn"
|
||||||
|
assert out["signals"][0]["weight"] == 0.35
|
||||||
|
assert out["tags"] == ["a"]
|
||||||
|
json.dumps(out, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_rejects_unknown_severity():
|
||||||
|
with pytest.raises(ValueError, match="unknown Finding severity"):
|
||||||
|
Finding(
|
||||||
|
id="x",
|
||||||
|
severity="apocalyptic",
|
||||||
|
title="t",
|
||||||
|
detail="d",
|
||||||
|
metric=None,
|
||||||
|
value=None,
|
||||||
|
threshold=None,
|
||||||
|
advice=None,
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue