Add SNMP profile mapping and fix asset cleanup
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_role
|
||||
from app.db.session import get_db
|
||||
from app.models import Asset, User
|
||||
from app.models import Asset, Incident, Monitor, User
|
||||
from app.schemas.core import AssetCreate, AssetRead, AssetUpdate
|
||||
|
||||
router = APIRouter(prefix="/assets", tags=["assets"])
|
||||
@@ -86,5 +88,26 @@ def delete_asset(
|
||||
asset = db.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
attached_monitors = db.scalars(select(Monitor).where(Monitor.asset_id == asset.id)).all()
|
||||
attached_monitor_ids = [monitor.id for monitor in attached_monitors]
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if attached_monitor_ids:
|
||||
monitor_incidents = db.scalars(select(Incident).where(Incident.monitor_id.in_(attached_monitor_ids))).all()
|
||||
for incident in monitor_incidents:
|
||||
if incident.status == "open":
|
||||
incident.status = "resolved"
|
||||
incident.resolved_at = now
|
||||
incident.details = {**(incident.details or {}), "recovery_message": "Asset was deleted"}
|
||||
incident.monitor_id = None
|
||||
|
||||
asset_incidents = db.scalars(select(Incident).where(Incident.asset_id == asset.id)).all()
|
||||
for incident in asset_incidents:
|
||||
incident.asset_id = None
|
||||
|
||||
for monitor in attached_monitors:
|
||||
db.delete(monitor)
|
||||
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
|
||||
@@ -65,6 +65,9 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev
|
||||
return SnmpDiscoveryRead(
|
||||
host=discovered.host,
|
||||
credential_profile_id=credential_profile_id,
|
||||
profile_key=discovered.profile_key,
|
||||
profile_name=discovered.profile_name,
|
||||
capabilities=discovered.capabilities,
|
||||
device_name=discovered.device_name,
|
||||
description=discovered.description,
|
||||
uptime_seconds=discovered.uptime_seconds,
|
||||
@@ -74,15 +77,27 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev
|
||||
|
||||
|
||||
def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryItemRead]:
|
||||
items = [
|
||||
SnmpDiscoveryItemRead(
|
||||
item_id="device.uptime",
|
||||
item_type="device_uptime",
|
||||
group="Device Health",
|
||||
label="Device uptime",
|
||||
unit="seconds",
|
||||
items = []
|
||||
if discovered.uptime_seconds is not None:
|
||||
items.append(
|
||||
SnmpDiscoveryItemRead(
|
||||
item_id="device.uptime",
|
||||
item_type="device_uptime",
|
||||
group="Device Health",
|
||||
label="Device uptime",
|
||||
unit="seconds",
|
||||
)
|
||||
)
|
||||
]
|
||||
items.extend(
|
||||
SnmpDiscoveryItemRead(
|
||||
item_id=item.item_id,
|
||||
item_type=item.item_type,
|
||||
group=item.group,
|
||||
label=item.label,
|
||||
unit=item.unit,
|
||||
)
|
||||
for item in discovered.health_items
|
||||
)
|
||||
for interface in discovered.interfaces:
|
||||
group = f"Interface {interface.name}"
|
||||
item_prefix = f"interface.{interface.index}"
|
||||
|
||||
@@ -248,6 +248,9 @@ class SnmpMonitorsCreate(BaseModel):
|
||||
class SnmpDiscoveryRead(BaseModel):
|
||||
host: str
|
||||
credential_profile_id: int
|
||||
profile_key: str
|
||||
profile_name: str
|
||||
capabilities: dict[str, bool]
|
||||
device_name: str | None
|
||||
description: str | None
|
||||
uptime_seconds: int | None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import random
|
||||
import socket
|
||||
from typing import Any
|
||||
@@ -26,6 +26,15 @@ class DiscoveredSnmpInterface:
|
||||
speed_bps: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredSnmpHealthItem:
|
||||
item_id: str
|
||||
item_type: str
|
||||
group: str
|
||||
label: str
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredSnmpDevice:
|
||||
host: str
|
||||
@@ -33,6 +42,10 @@ class DiscoveredSnmpDevice:
|
||||
description: str | None
|
||||
uptime_seconds: int | None
|
||||
interfaces: list[DiscoveredSnmpInterface]
|
||||
profile_key: str = "generic_snmp"
|
||||
profile_name: str = "Generic SNMP"
|
||||
capabilities: dict[str, bool] = field(default_factory=dict)
|
||||
health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list)
|
||||
|
||||
|
||||
SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0)
|
||||
@@ -43,6 +56,24 @@ IF_SPEED = (1, 3, 6, 1, 2, 1, 2, 2, 1, 5)
|
||||
IF_ADMIN_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 7)
|
||||
IF_OPER_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 8)
|
||||
IF_NAME = (1, 3, 6, 1, 2, 1, 31, 1, 1, 1, 1)
|
||||
HR_PROCESSOR_LOAD = (1, 3, 6, 1, 2, 1, 25, 3, 3, 1, 2)
|
||||
HR_STORAGE_TYPE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 2)
|
||||
HR_STORAGE_DESCR = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 3)
|
||||
HR_STORAGE_ALLOCATION_UNITS = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 4)
|
||||
HR_STORAGE_SIZE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 5)
|
||||
HR_STORAGE_USED = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 6)
|
||||
ENT_PHYSICAL_DESCR = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 2)
|
||||
ENT_PHYSICAL_NAME = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 7)
|
||||
ENT_PHY_SENSOR_TYPE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 1)
|
||||
ENT_PHY_SENSOR_SCALE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 2)
|
||||
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
|
||||
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
|
||||
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
|
||||
|
||||
HR_STORAGE_RAM = "1.3.6.1.2.1.25.2.1.2"
|
||||
HR_STORAGE_VIRTUAL_MEMORY = "1.3.6.1.2.1.25.2.1.3"
|
||||
HR_STORAGE_FIXED_DISK = "1.3.6.1.2.1.25.2.1.4"
|
||||
HR_STORAGE_REMOVABLE_DISK = "1.3.6.1.2.1.25.2.1.5"
|
||||
|
||||
STATUS_LABELS = {
|
||||
1: "up",
|
||||
@@ -54,20 +85,92 @@ STATUS_LABELS = {
|
||||
7: "lower layer down",
|
||||
}
|
||||
|
||||
SENSOR_TYPE_LABELS = {
|
||||
3: ("AC voltage", "V"),
|
||||
4: ("DC voltage", "V"),
|
||||
5: ("Current", "A"),
|
||||
6: ("Power", "W"),
|
||||
7: ("Frequency", "Hz"),
|
||||
8: ("Temperature", "C"),
|
||||
9: ("Humidity", "%"),
|
||||
10: ("Fan speed", "rpm"),
|
||||
11: ("Airflow", "m3/min"),
|
||||
12: ("Sensor state", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SnmpProfile:
|
||||
key: str
|
||||
name: str
|
||||
match_terms: tuple[str, ...] = ()
|
||||
|
||||
def matches(self, system_text: str) -> bool:
|
||||
return any(term in system_text for term in self.match_terms)
|
||||
|
||||
def discover_health_items(self, client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||
return [*_discover_host_resource_items(client), *_discover_sensor_items(client)]
|
||||
|
||||
|
||||
SNMP_PROFILES = (
|
||||
SnmpProfile("cisco_ios", "Cisco IOS SNMP", ("cisco", "ios")),
|
||||
SnmpProfile("mikrotik_routeros", "MikroTik RouterOS SNMP", ("mikrotik", "routeros")),
|
||||
SnmpProfile("net_snmp", "Net-SNMP Host Resources", ("net-snmp", "linux")),
|
||||
SnmpProfile("generic_snmp", "Generic SNMP"),
|
||||
)
|
||||
|
||||
|
||||
def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
|
||||
client = SnmpV2Client(host, credential)
|
||||
system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME])
|
||||
profile = _select_profile(system)
|
||||
interfaces = _discover_interfaces(client)
|
||||
health_items = profile.discover_health_items(client)
|
||||
return DiscoveredSnmpDevice(
|
||||
host=host,
|
||||
device_name=_string_value(system.get(SYS_NAME)),
|
||||
description=_string_value(system.get(SYS_DESCR)),
|
||||
uptime_seconds=_timeticks_to_seconds(system.get(SYS_UPTIME)),
|
||||
interfaces=interfaces,
|
||||
profile_key=profile.key,
|
||||
profile_name=profile.name,
|
||||
capabilities=_capabilities(system, interfaces, health_items),
|
||||
health_items=health_items,
|
||||
)
|
||||
|
||||
|
||||
def _select_profile(system: dict[tuple[int, ...], Any]) -> SnmpProfile:
|
||||
system_text = " ".join(
|
||||
value.lower()
|
||||
for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))]
|
||||
if value
|
||||
)
|
||||
for profile in SNMP_PROFILES:
|
||||
if profile.match_terms and profile.matches(system_text):
|
||||
return profile
|
||||
return SNMP_PROFILES[-1]
|
||||
|
||||
|
||||
def _capabilities(
|
||||
system: dict[tuple[int, ...], Any],
|
||||
interfaces: list[DiscoveredSnmpInterface],
|
||||
health_items: list[DiscoveredSnmpHealthItem],
|
||||
) -> dict[str, bool]:
|
||||
item_types = {item.item_type for item in health_items}
|
||||
return {
|
||||
"system": any(system.get(oid) is not None for oid in [SYS_NAME, SYS_DESCR, SYS_UPTIME]),
|
||||
"interfaces": bool(interfaces),
|
||||
"interface_status": bool(interfaces),
|
||||
"interface_traffic": bool(interfaces),
|
||||
"interface_errors": bool(interfaces),
|
||||
"cpu": "cpu_load" in item_types,
|
||||
"memory": "memory_usage" in item_types,
|
||||
"storage": "storage_usage" in item_types,
|
||||
"sensors": "sensor_value" in item_types,
|
||||
"environmental": "sensor_value" in item_types,
|
||||
}
|
||||
|
||||
|
||||
def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface]:
|
||||
names = client.walk(IF_NAME)
|
||||
descriptions = client.walk(IF_DESCR)
|
||||
@@ -106,6 +209,108 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
|
||||
return interfaces
|
||||
|
||||
|
||||
def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||
items: list[DiscoveredSnmpHealthItem] = []
|
||||
|
||||
processor_loads = _indexed_values(client.walk(HR_PROCESSOR_LOAD, max_items=256))
|
||||
processor_indexes = sorted(index for index, value in processor_loads.items() if _int_value(value) is not None)
|
||||
for position, index in enumerate(processor_indexes, start=1):
|
||||
label = "CPU load" if len(processor_indexes) == 1 else f"CPU {position} load"
|
||||
items.append(
|
||||
DiscoveredSnmpHealthItem(
|
||||
item_id=f"cpu.{index}.load",
|
||||
item_type="cpu_load",
|
||||
group="Device Health",
|
||||
label=label,
|
||||
unit="%",
|
||||
)
|
||||
)
|
||||
|
||||
storage_types = _indexed_values(client.walk(HR_STORAGE_TYPE, max_items=256))
|
||||
descriptions = _indexed_values(client.walk(HR_STORAGE_DESCR, max_items=256))
|
||||
allocation_units = _indexed_values(client.walk(HR_STORAGE_ALLOCATION_UNITS, max_items=256))
|
||||
sizes = _indexed_values(client.walk(HR_STORAGE_SIZE, max_items=256))
|
||||
used = _indexed_values(client.walk(HR_STORAGE_USED, max_items=256))
|
||||
|
||||
for index in sorted(storage_types):
|
||||
storage_type = _string_value(storage_types.get(index))
|
||||
allocation_unit = _int_value(allocation_units.get(index))
|
||||
size = _int_value(sizes.get(index))
|
||||
used_blocks = _int_value(used.get(index))
|
||||
if not storage_type or not allocation_unit or not size or used_blocks is None:
|
||||
continue
|
||||
description = _string_value(descriptions.get(index)) or f"Storage {index}"
|
||||
if storage_type in {HR_STORAGE_RAM, HR_STORAGE_VIRTUAL_MEMORY}:
|
||||
items.append(
|
||||
DiscoveredSnmpHealthItem(
|
||||
item_id=f"storage.{index}.memory",
|
||||
item_type="memory_usage",
|
||||
group="Device Health",
|
||||
label="Memory used",
|
||||
unit="%",
|
||||
)
|
||||
)
|
||||
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
|
||||
items.append(
|
||||
DiscoveredSnmpHealthItem(
|
||||
item_id=f"storage.{index}.usage",
|
||||
item_type="storage_usage",
|
||||
group="Storage",
|
||||
label=_storage_usage_label(description),
|
||||
unit="%",
|
||||
)
|
||||
)
|
||||
|
||||
return _deduplicate_items(items)
|
||||
|
||||
|
||||
def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||
sensor_types = _indexed_values(client.walk(ENT_PHY_SENSOR_TYPE, max_items=256))
|
||||
sensor_values = _indexed_values(client.walk(ENT_PHY_SENSOR_VALUE, max_items=256))
|
||||
sensor_names = _indexed_values(client.walk(ENT_PHYSICAL_NAME, max_items=256))
|
||||
sensor_descriptions = _indexed_values(client.walk(ENT_PHYSICAL_DESCR, max_items=256))
|
||||
|
||||
items: list[DiscoveredSnmpHealthItem] = []
|
||||
for index in sorted(sensor_types):
|
||||
sensor_type = _int_value(sensor_types.get(index))
|
||||
if sensor_type not in SENSOR_TYPE_LABELS or _int_value(sensor_values.get(index)) is None:
|
||||
continue
|
||||
kind, unit = SENSOR_TYPE_LABELS[sensor_type]
|
||||
name = _string_value(sensor_names.get(index)) or _string_value(sensor_descriptions.get(index))
|
||||
label = kind if not name else f"{kind} {name}"
|
||||
items.append(
|
||||
DiscoveredSnmpHealthItem(
|
||||
item_id=f"sensor.{index}.value",
|
||||
item_type="sensor_value",
|
||||
group="Environmental",
|
||||
label=label,
|
||||
unit=unit,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _storage_usage_label(description: str) -> str:
|
||||
normalized = description.strip()
|
||||
if normalized in {"/", "/boot", "/home", "/var"}:
|
||||
return f"Disk {normalized} usage"
|
||||
if "disk" not in normalized.lower() and normalized.startswith("/"):
|
||||
return f"Disk {normalized} usage"
|
||||
return f"{normalized} usage"
|
||||
|
||||
|
||||
def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]:
|
||||
seen: set[tuple[str, str]] = set()
|
||||
deduplicated: list[DiscoveredSnmpHealthItem] = []
|
||||
for item in items:
|
||||
key = (item.item_type, item.label)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduplicated.append(item)
|
||||
return deduplicated
|
||||
|
||||
|
||||
def _indexed_values(values: dict[tuple[int, ...], Any]) -> dict[int, Any]:
|
||||
indexed: dict[int, Any] = {}
|
||||
for oid, value in values.items():
|
||||
@@ -317,7 +522,9 @@ def _decode_oid(value: bytes) -> tuple[int, ...]:
|
||||
|
||||
|
||||
def _decode_value(tag: int, value: bytes) -> Any:
|
||||
if tag in {0x02, 0x41, 0x42, 0x43, 0x46}:
|
||||
if tag == 0x02:
|
||||
return _decode_integer(value)
|
||||
if tag in {0x41, 0x42, 0x43, 0x46}:
|
||||
return int.from_bytes(value, "big")
|
||||
if tag == 0x04:
|
||||
return value.decode("utf-8", errors="replace")
|
||||
|
||||
@@ -2,7 +2,27 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.secrets import encrypt_secret
|
||||
from app.models import Credential
|
||||
from app.services.snmp import DiscoveredSnmpDevice, DiscoveredSnmpInterface, SnmpCredential, SnmpDiscoveryError
|
||||
from app.services.snmp import (
|
||||
ENT_PHYSICAL_NAME,
|
||||
ENT_PHY_SENSOR_TYPE,
|
||||
ENT_PHY_SENSOR_VALUE,
|
||||
HR_PROCESSOR_LOAD,
|
||||
HR_STORAGE_ALLOCATION_UNITS,
|
||||
HR_STORAGE_DESCR,
|
||||
HR_STORAGE_FIXED_DISK,
|
||||
HR_STORAGE_RAM,
|
||||
HR_STORAGE_SIZE,
|
||||
HR_STORAGE_TYPE,
|
||||
HR_STORAGE_USED,
|
||||
SYS_DESCR,
|
||||
SYS_NAME,
|
||||
SYS_UPTIME,
|
||||
DiscoveredSnmpDevice,
|
||||
DiscoveredSnmpInterface,
|
||||
SnmpCredential,
|
||||
SnmpDiscoveryError,
|
||||
discover_snmp_device,
|
||||
)
|
||||
|
||||
|
||||
def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestClient, db_session, monkeypatch) -> None:
|
||||
@@ -49,6 +69,9 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
||||
body = response.json()
|
||||
assert body["host"] == "192.0.2.10"
|
||||
assert body["credential_profile_id"] == profile.id
|
||||
assert body["profile_key"] == "generic_snmp"
|
||||
assert body["profile_name"] == "Generic SNMP"
|
||||
assert body["capabilities"] == {}
|
||||
assert body["device_name"] == "core-sw-1"
|
||||
assert body["description"] == "Core switch"
|
||||
assert body["uptime_seconds"] == 12345
|
||||
@@ -96,6 +119,66 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
||||
assert "1.3.6" not in response.text
|
||||
|
||||
|
||||
def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||
self.host = host
|
||||
self.credential = credential
|
||||
|
||||
def get_many(self, _oids):
|
||||
return {
|
||||
SYS_NAME: "edge-router",
|
||||
SYS_DESCR: "Linux edge-router net-snmp",
|
||||
SYS_UPTIME: 10_000,
|
||||
}
|
||||
|
||||
def walk(self, base_oid, max_items=128):
|
||||
values = {
|
||||
HR_PROCESSOR_LOAD: {(*HR_PROCESSOR_LOAD, 196608): 17},
|
||||
HR_STORAGE_TYPE: {
|
||||
(*HR_STORAGE_TYPE, 1): HR_STORAGE_RAM,
|
||||
(*HR_STORAGE_TYPE, 31): HR_STORAGE_FIXED_DISK,
|
||||
},
|
||||
HR_STORAGE_DESCR: {
|
||||
(*HR_STORAGE_DESCR, 1): "Physical memory",
|
||||
(*HR_STORAGE_DESCR, 31): "/",
|
||||
},
|
||||
HR_STORAGE_ALLOCATION_UNITS: {
|
||||
(*HR_STORAGE_ALLOCATION_UNITS, 1): 1024,
|
||||
(*HR_STORAGE_ALLOCATION_UNITS, 31): 4096,
|
||||
},
|
||||
HR_STORAGE_SIZE: {
|
||||
(*HR_STORAGE_SIZE, 1): 2048,
|
||||
(*HR_STORAGE_SIZE, 31): 4096,
|
||||
},
|
||||
HR_STORAGE_USED: {
|
||||
(*HR_STORAGE_USED, 1): 1024,
|
||||
(*HR_STORAGE_USED, 31): 1024,
|
||||
},
|
||||
ENT_PHY_SENSOR_TYPE: {(*ENT_PHY_SENSOR_TYPE, 10): 8},
|
||||
ENT_PHY_SENSOR_VALUE: {(*ENT_PHY_SENSOR_VALUE, 10): 310},
|
||||
ENT_PHYSICAL_NAME: {(*ENT_PHYSICAL_NAME, 10): "Inlet"},
|
||||
}
|
||||
return values.get(base_oid, {})
|
||||
|
||||
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
|
||||
|
||||
discovered = discover_snmp_device("192.0.2.20", SnmpCredential(community="private-community"))
|
||||
|
||||
assert discovered.profile_key == "net_snmp"
|
||||
assert discovered.profile_name == "Net-SNMP Host Resources"
|
||||
assert discovered.capabilities["cpu"] is True
|
||||
assert discovered.capabilities["memory"] is True
|
||||
assert discovered.capabilities["storage"] is True
|
||||
assert discovered.capabilities["sensors"] is True
|
||||
assert [(item.item_id, item.item_type, item.group, item.label, item.unit) for item in discovered.health_items] == [
|
||||
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%"),
|
||||
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%"),
|
||||
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%"),
|
||||
("sensor.10.value", "sensor_value", "Environmental", "Temperature Inlet", "C"),
|
||||
]
|
||||
|
||||
|
||||
def test_snmp_discovery_rejects_missing_profile(client: TestClient) -> None:
|
||||
response = client.post("/discovery/snmp", json={"host": "192.0.2.10", "credential_profile_id": 999})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.secrets import encrypt_secret
|
||||
from app.models import AlertRule, Asset, Credential, Monitor
|
||||
from app.models import AlertRule, Asset, Credential, Incident, Monitor
|
||||
|
||||
|
||||
def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None:
|
||||
@@ -211,3 +211,41 @@ def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_ses
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_asset_deletes_attached_monitors_and_resolves_incidents(client: TestClient, db_session: Session) -> None:
|
||||
asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="down", extra={})
|
||||
monitor = Monitor(
|
||||
asset=asset,
|
||||
name="Router ping",
|
||||
monitor_type="ping",
|
||||
target="192.0.2.1",
|
||||
config={},
|
||||
interval_seconds=60,
|
||||
status="down",
|
||||
)
|
||||
db_session.add_all([asset, monitor])
|
||||
db_session.flush()
|
||||
incident = Incident(
|
||||
asset_id=asset.id,
|
||||
monitor_id=monitor.id,
|
||||
alert_rule_id=None,
|
||||
title="Router ping is failing",
|
||||
severity="warning",
|
||||
status="open",
|
||||
details={"last_message": "Ping failed"},
|
||||
)
|
||||
db_session.add(incident)
|
||||
db_session.commit()
|
||||
|
||||
response = client.delete(f"/assets/{asset.id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert db_session.get(Asset, asset.id) is None
|
||||
assert db_session.get(Monitor, monitor.id) is None
|
||||
db_session.refresh(incident)
|
||||
assert incident.status == "resolved"
|
||||
assert incident.resolved_at is not None
|
||||
assert incident.asset_id is None
|
||||
assert incident.monitor_id is None
|
||||
assert incident.details["recovery_message"] == "Asset was deleted"
|
||||
|
||||
Reference in New Issue
Block a user