638 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""存储抽象层。
- RuleStore / EventStore / AlertStoreMySQL 语义(准),默认 InMemory 实现;
- CacheRedis 语义get/set NX/INCR/EXPIRE默认 InMemory 实现;
- MessageBusKafka 语义publish默认 InMemory 实现。
真实中间件适配器放在同文件末尾,采用惰性导入(可选依赖),未安装驱动时给出
清晰错误;测试与本地运行均使用 InMemory 实现,保证零依赖可编译、可运行。
"""
from __future__ import annotations
import itertools
import json
import threading
import time
import uuid
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
from .models import Alert, Event, MetricRule
# --------------------------------------------------------------------------- #
# 接口定义
# --------------------------------------------------------------------------- #
class RuleStore(ABC):
"""规则仓库Source of TruthMySQL metric_rule + rule_scope"""
@abstractmethod
def list_rules(self) -> List[MetricRule]:
...
@abstractmethod
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
...
@abstractmethod
def create_rule(self, rule: MetricRule) -> MetricRule:
...
@abstractmethod
def update_rule(self, rule: MetricRule) -> MetricRule:
...
@abstractmethod
def delete_rule(self, rule_id: str) -> bool:
...
@abstractmethod
def get_version(self) -> int:
...
class EventStore(ABC):
"""事件仓库MySQL event 表)。"""
@abstractmethod
def insert(self, event: Event) -> None:
...
@abstractmethod
def list_events(
self,
host_id: Optional[str] = None,
rule_id: Optional[str] = None,
status: Optional[str] = None,
severity: Optional[str] = None,
start: Optional[float] = None,
end: Optional[float] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[int, List[Event]]:
...
class AlertStore(ABC):
"""告警仓库MySQL alert 表)。"""
@abstractmethod
def upsert_firing(self, alert: Alert) -> Alert:
...
@abstractmethod
def get(self, alert_id: str) -> Optional[Alert]:
...
@abstractmethod
def list_alerts(
self,
severity: Optional[str] = None,
status: Optional[str] = None,
ack_status: Optional[str] = None,
start: Optional[float] = None,
end: Optional[float] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[int, List[Alert]]:
...
@abstractmethod
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
...
@abstractmethod
def close(self, alert_id: str) -> Optional[Alert]:
...
@abstractmethod
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
...
class Cache(ABC):
"""缓存抽象Redis 语义子集)。"""
@abstractmethod
def get(self, key: str) -> Optional[str]:
...
@abstractmethod
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
...
@abstractmethod
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
...
@abstractmethod
def incr(self, key: str, ttl: Optional[int] = None) -> int:
...
@abstractmethod
def delete(self, key: str) -> bool:
...
class MessageBus(ABC):
"""消息总线抽象Kafka 语义子集)。"""
@abstractmethod
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
...
# --------------------------------------------------------------------------- #
# InMemory 实现(线程安全)
# --------------------------------------------------------------------------- #
class InMemoryRuleStore(RuleStore):
def __init__(self) -> None:
self._rules: Dict[str, MetricRule] = {}
self._version = 0
self._lock = threading.RLock()
def list_rules(self) -> List[MetricRule]:
with self._lock:
return list(self._rules.values())
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
with self._lock:
return self._rules.get(rule_id)
def create_rule(self, rule: MetricRule) -> MetricRule:
with self._lock:
if rule.rule_id in self._rules:
raise ValueError(f"rule {rule.rule_id} already exists")
rule.version = rule.version + 1
self._rules[rule.rule_id] = rule
self._version += 1
return rule
def update_rule(self, rule: MetricRule) -> MetricRule:
with self._lock:
if rule.rule_id not in self._rules:
raise ValueError(f"rule {rule.rule_id} not found")
rule.version = self._rules[rule.rule_id].version + 1
self._rules[rule.rule_id] = rule
self._version += 1
return rule
def delete_rule(self, rule_id: str) -> bool:
with self._lock:
if rule_id not in self._rules:
return False
del self._rules[rule_id]
self._version += 1
return True
def get_version(self) -> int:
with self._lock:
return self._version
class InMemoryEventStore(EventStore):
def __init__(self) -> None:
self._events: List[Event] = []
self._lock = threading.RLock()
def insert(self, event: Event) -> None:
with self._lock:
self._events.append(event)
def list_events(
self,
host_id: Optional[str] = None,
rule_id: Optional[str] = None,
status: Optional[str] = None,
severity: Optional[str] = None,
start: Optional[float] = None,
end: Optional[float] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[int, List[Event]]:
with self._lock:
items = [e for e in self._events if self._match_event(e, host_id, rule_id, status, severity, start, end)]
items.sort(key=lambda e: e.fired_at, reverse=True)
total = len(items)
start_idx = (page - 1) * page_size
return total, items[start_idx : start_idx + page_size]
@staticmethod
def _match_event(
e: Event,
host_id: Optional[str],
rule_id: Optional[str],
status: Optional[str],
severity: Optional[str],
start: Optional[float],
end: Optional[float],
) -> bool:
if host_id and e.host_id != host_id:
return False
if rule_id and e.rule_id != rule_id:
return False
if status and e.status != status:
return False
if severity and e.severity != severity:
return False
if start is not None and e.fired_at < start:
return False
if end is not None and e.fired_at > end:
return False
return True
class InMemoryAlertStore(AlertStore):
def __init__(self) -> None:
self._alerts: Dict[str, Alert] = {}
self._lock = threading.RLock()
def upsert_firing(self, alert: Alert) -> Alert:
with self._lock:
# 同 aggregate_key 且仍在 firing 的告警做聚合:计数 + 1更新 last_at 与明细。
for existing in self._alerts.values():
if existing.aggregate_key == alert.aggregate_key and existing.status == "firing":
existing.count += 1
existing.last_at = alert.last_at
existing.detail = self._merge_detail(existing.detail, alert.detail)
return existing
self._alerts[alert.alert_id] = alert
return alert
@staticmethod
def _merge_detail(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
merged = dict(a)
hosts = list(dict.fromkeys(list(merged.get("hosts", [])) + list(b.get("hosts", []))))
merged["hosts"] = hosts
for key in ("current", "threshold", "metric"):
if key in b:
merged[key] = b[key]
return merged
def get(self, alert_id: str) -> Optional[Alert]:
with self._lock:
return self._alerts.get(alert_id)
def list_alerts(
self,
severity: Optional[str] = None,
status: Optional[str] = None,
ack_status: Optional[str] = None,
start: Optional[float] = None,
end: Optional[float] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[int, List[Alert]]:
with self._lock:
items = [
a
for a in self._alerts.values()
if self._match_alert(a, severity, status, ack_status, start, end)
]
items.sort(key=lambda a: a.last_at, reverse=True)
total = len(items)
start_idx = (page - 1) * page_size
return total, items[start_idx : start_idx + page_size]
@staticmethod
def _match_alert(
a: Alert,
severity: Optional[str],
status: Optional[str],
ack_status: Optional[str],
start: Optional[float],
end: Optional[float],
) -> bool:
if severity and a.severity != severity:
return False
if status and a.status != status:
return False
if ack_status and a.ack_status != ack_status:
return False
if start is not None and a.last_at < start:
return False
if end is not None and a.last_at > end:
return False
return True
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
with self._lock:
a = self._alerts.get(alert_id)
if a is None:
return None
a.ack_status = "acked"
a.ack_by = ack_by
a.ack_at = time.time()
return a
def close(self, alert_id: str) -> Optional[Alert]:
with self._lock:
a = self._alerts.get(alert_id)
if a is None:
return None
a.ack_status = "closed"
return a
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
with self._lock:
for a in self._alerts.values():
if a.dedup_key == dedup_key and a.status == "firing":
a.status = "resolved"
a.last_at = at
return a
return None
class InMemoryCache(Cache):
def __init__(self) -> None:
self._data: Dict[str, Tuple[str, Optional[float]]] = {}
self._lock = threading.RLock()
def _now(self) -> float:
return time.monotonic()
def _purge(self, key: str) -> None:
if key in self._data:
value, exp = self._data[key]
if exp is not None and exp <= self._now():
del self._data[key]
def get(self, key: str) -> Optional[str]:
with self._lock:
self._purge(key)
item = self._data.get(key)
return item[0] if item else None
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
with self._lock:
exp = self._now() + ttl if ttl else None
self._data[key] = (value, exp)
return True
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
with self._lock:
self._purge(key)
if key in self._data:
return False
exp = self._now() + ttl if ttl else None
self._data[key] = (value, exp)
return True
def incr(self, key: str, ttl: Optional[int] = None) -> int:
with self._lock:
self._purge(key)
item = self._data.get(key)
current = int(item[0]) + 1 if item else 1
exp = self._now() + ttl if ttl else None
self._data[key] = (str(current), exp)
return current
def delete(self, key: str) -> bool:
with self._lock:
if key in self._data:
del self._data[key]
return True
return False
class InMemoryMessageBus(MessageBus):
def __init__(self) -> None:
self.messages: List[Tuple[str, str, Dict[str, Any]]] = []
self._lock = threading.RLock()
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
with self._lock:
self.messages.append((topic, key, value))
# --------------------------------------------------------------------------- #
# 工厂函数:根据配置选择驱动(未安装可选依赖时安全回退 memory
# --------------------------------------------------------------------------- #
def _safe(loader, fallback):
try:
return loader()
except Exception:
return fallback()
def create_rule_store(config) -> RuleStore:
if config.rule_store_driver == "mysql":
return _safe(lambda: MySQLRuleStore(config.mysql_dsn), InMemoryRuleStore)
return InMemoryRuleStore()
def create_event_store(config) -> EventStore:
if config.event_store_driver == "mysql":
return _safe(lambda: MySQLEventStore(config.mysql_dsn), InMemoryEventStore)
return InMemoryEventStore()
def create_alert_store(config) -> AlertStore:
if config.alert_store_driver == "mysql":
return _safe(lambda: MySQLAlertStore(config.mysql_dsn), InMemoryAlertStore)
return InMemoryAlertStore()
def create_cache(config) -> Cache:
if config.cache_driver == "redis":
return _safe(lambda: RedisCache(config.redis_url), InMemoryCache)
return InMemoryCache()
def create_bus(config) -> MessageBus:
if config.bus_driver == "kafka":
return _safe(lambda: KafkaBus(config.kafka_bootstrap), InMemoryMessageBus)
return InMemoryMessageBus()
# --------------------------------------------------------------------------- #
# 可选中间件适配器(惰性导入;未安装驱动时抛出 RuntimeError
# --------------------------------------------------------------------------- #
class MySQLRuleStore(RuleStore):
"""基于 pymysql 的规则仓库。需安装pip install pymysql"""
def __init__(self, dsn: str) -> None:
try:
import pymysql # noqa: F401
except ImportError as exc: # pragma: no cover
raise RuntimeError("MySQLRuleStore requires pymysql") from exc
self._dsn = dsn
self._conn = None
def _cursor(self):
import pymysql
if self._conn is None:
self._conn = pymysql.connect(**self._parse_dsn(self._dsn))
return self._conn.cursor()
@staticmethod
def _parse_dsn(dsn: str) -> Dict[str, Any]:
# 简化mysql://user:pass@host:port/db
import urllib.parse
parsed = urllib.parse.urlparse(dsn)
return {
"host": parsed.hostname or "127.0.0.1",
"port": parsed.port or 3306,
"user": parsed.username or "root",
"password": parsed.password or "",
"database": parsed.path.lstrip("/") or "hms",
}
def list_rules(self) -> List[MetricRule]:
cur = self._cursor()
cur.execute(
"SELECT rule_id,name,metric,aggregation,operator,threshold,threshold2,"
"for_duration,severity,labels,notify_channels,enabled,version "
"FROM metric_rule"
)
rules = []
for row in cur.fetchall():
scopes = self._load_scopes(row[0])
rules.append(
MetricRule(
rule_id=row[0], name=row[1], metric=row[2], aggregation=row[3],
operator=row[4], threshold=float(row[5]),
threshold2=float(row[6]) if row[6] is not None else None,
for_duration=row[7], severity=row[8],
labels=json.loads(row[9]) if row[9] else {},
notify_channels=json.loads(row[10]) if row[10] else [],
enabled=bool(row[11]), version=int(row[12]),
scope=scopes[0] if scopes else RuleScope(),
)
)
return rules
def _load_scopes(self, rule_id: str) -> List[RuleScope]:
cur = self._cursor()
cur.execute(
"SELECT scope_type,host_id,host_group,service FROM rule_scope WHERE rule_id=%s",
(rule_id,),
)
scopes = []
for st, host_id, host_group, service in cur.fetchall():
scopes.append(
RuleScope(
scope_type=st,
host_ids=[host_id] if host_id else [],
host_group=host_group or "",
service=service or "",
)
)
return scopes
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
for r in self.list_rules():
if r.rule_id == rule_id:
return r
return None
def create_rule(self, rule: MetricRule) -> MetricRule:
raise NotImplementedError("MySQL write path 由上层 API 通过事务实现")
def update_rule(self, rule: MetricRule) -> MetricRule:
raise NotImplementedError
def delete_rule(self, rule_id: str) -> bool:
raise NotImplementedError
def get_version(self) -> int:
cur = self._cursor()
cur.execute("SELECT COALESCE(MAX(version),0) FROM metric_rule")
return int(cur.fetchone()[0])
class MySQLEventStore(EventStore):
def __init__(self, dsn: str) -> None:
try:
import pymysql # noqa: F401
except ImportError as exc: # pragma: no cover
raise RuntimeError("MySQLEventStore requires pymysql") from exc
self._dsn = dsn
def insert(self, event: Event) -> None:
raise NotImplementedError("接入真实 MySQL 时实现 event 表写入")
def list_events(self, **kwargs) -> Tuple[int, List[Event]]:
raise NotImplementedError
class MySQLAlertStore(AlertStore):
def __init__(self, dsn: str) -> None:
try:
import pymysql # noqa: F401
except ImportError as exc: # pragma: no cover
raise RuntimeError("MySQLAlertStore requires pymysql") from exc
self._dsn = dsn
def upsert_firing(self, alert: Alert) -> Alert:
raise NotImplementedError("接入真实 MySQL 时实现 alert 表 upsert")
def get(self, alert_id: str) -> Optional[Alert]:
raise NotImplementedError
def list_alerts(self, **kwargs) -> Tuple[int, List[Alert]]:
raise NotImplementedError
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
raise NotImplementedError
def close(self, alert_id: str) -> Optional[Alert]:
raise NotImplementedError
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
raise NotImplementedError
class RedisCache(Cache):
"""基于 redis-py 的缓存。需安装pip install redis"""
def __init__(self, url: str) -> None:
try:
import redis # noqa: F401
except ImportError as exc: # pragma: no cover
raise RuntimeError("RedisCache requires redis") from exc
import redis
self._client = redis.Redis.from_url(url, decode_responses=True)
def get(self, key: str) -> Optional[str]:
return self._client.get(key)
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
return bool(self._client.set(key, value, ex=ttl))
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
return bool(self._client.set(key, value, ex=ttl, nx=True))
def incr(self, key: str, ttl: Optional[int] = None) -> int:
value = self._client.incr(key)
if ttl and value == 1:
self._client.expire(key, ttl)
return int(value)
def delete(self, key: str) -> bool:
return bool(self._client.delete(key))
class KafkaBus(MessageBus):
"""基于 kafka-python 的消息总线。需安装pip install kafka-python"""
def __init__(self, bootstrap: str) -> None:
try:
from kafka import KafkaProducer # noqa: F401
except ImportError as exc: # pragma: no cover
raise RuntimeError("KafkaBus requires kafka-python") from exc
from kafka import KafkaProducer
self._producer = KafkaProducer(
bootstrap_servers=bootstrap,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8"),
)
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
self._producer.send(topic, key=key, value=value)