From a0236028cdb5b059157c523e898345d29ebdaa07 Mon Sep 17 00:00:00 2001 From: Pipeline Agent Date: Sat, 15 Aug 2026 00:26:33 +0800 Subject: [PATCH] =?UTF-8?q?develop:=20=E5=BC=80=E5=8F=91=20threshold-event?= =?UTF-8?q?-detector=20=E6=A3=80=E6=B5=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 85 +++ detector/__init__.py | 13 + detector/__main__.py | 54 ++ detector/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 840 bytes detector/__pycache__/__main__.cpython-310.pyc | Bin 0 -> 1823 bytes detector/__pycache__/api.cpython-310.pyc | Bin 0 -> 13378 bytes detector/__pycache__/app.cpython-310.pyc | Bin 0 -> 3731 bytes detector/__pycache__/config.cpython-310.pyc | Bin 0 -> 2550 bytes .../__pycache__/converger.cpython-310.pyc | Bin 0 -> 4049 bytes detector/__pycache__/engine.cpython-310.pyc | Bin 0 -> 7608 bytes detector/__pycache__/models.cpython-310.pyc | Bin 0 -> 7255 bytes detector/__pycache__/notifier.cpython-310.pyc | Bin 0 -> 4220 bytes .../__pycache__/rule_loader.cpython-310.pyc | Bin 0 -> 3933 bytes detector/__pycache__/storage.cpython-310.pyc | Bin 0 -> 24886 bytes detector/api.py | 301 +++++++++ detector/app.py | 124 ++++ detector/config.py | 70 ++ detector/converger.py | 101 +++ detector/engine.py | 252 +++++++ detector/models.py | 238 +++++++ detector/notifier.py | 86 +++ detector/rule_loader.py | 101 +++ detector/storage.py | 637 ++++++++++++++++++ pyproject.toml | 18 + requirements.txt | 4 + tests/__init__.py | 0 tests/__pycache__/test_api.cpython-310.pyc | Bin 0 -> 4712 bytes .../test_converger.cpython-310.pyc | Bin 0 -> 2992 bytes tests/__pycache__/test_engine.cpython-310.pyc | Bin 0 -> 4327 bytes tests/__pycache__/test_models.cpython-310.pyc | Bin 0 -> 2677 bytes .../__pycache__/test_notifier.cpython-310.pyc | Bin 0 -> 1339 bytes .../test_rule_loader.cpython-310.pyc | Bin 0 -> 2407 bytes tests/test_api.py | 120 ++++ tests/test_converger.py | 71 ++ tests/test_engine.py | 107 +++ tests/test_models.py | 61 ++ tests/test_notifier.py | 23 + tests/test_rule_loader.py | 54 ++ 38 files changed, 2520 insertions(+) create mode 100644 detector/__init__.py create mode 100644 detector/__main__.py create mode 100644 detector/__pycache__/__init__.cpython-310.pyc create mode 100644 detector/__pycache__/__main__.cpython-310.pyc create mode 100644 detector/__pycache__/api.cpython-310.pyc create mode 100644 detector/__pycache__/app.cpython-310.pyc create mode 100644 detector/__pycache__/config.cpython-310.pyc create mode 100644 detector/__pycache__/converger.cpython-310.pyc create mode 100644 detector/__pycache__/engine.cpython-310.pyc create mode 100644 detector/__pycache__/models.cpython-310.pyc create mode 100644 detector/__pycache__/notifier.cpython-310.pyc create mode 100644 detector/__pycache__/rule_loader.cpython-310.pyc create mode 100644 detector/__pycache__/storage.cpython-310.pyc create mode 100644 detector/api.py create mode 100644 detector/app.py create mode 100644 detector/config.py create mode 100644 detector/converger.py create mode 100644 detector/engine.py create mode 100644 detector/models.py create mode 100644 detector/notifier.py create mode 100644 detector/rule_loader.py create mode 100644 detector/storage.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/test_api.cpython-310.pyc create mode 100644 tests/__pycache__/test_converger.cpython-310.pyc create mode 100644 tests/__pycache__/test_engine.cpython-310.pyc create mode 100644 tests/__pycache__/test_models.cpython-310.pyc create mode 100644 tests/__pycache__/test_notifier.cpython-310.pyc create mode 100644 tests/__pycache__/test_rule_loader.cpython-310.pyc create mode 100644 tests/test_api.py create mode 100644 tests/test_converger.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_models.py create mode 100644 tests/test_notifier.py create mode 100644 tests/test_rule_loader.py diff --git a/README.md b/README.md index faa933b..4582016 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ # threshold-event-detector +主机监控系统(HMS)指标阈值事件检测模块。 + +## 功能 + +1. **规则加载与热更新**:从 MySQL `metric_rule` / `rule_scope` 读取规则,写入 Redis + 缓存(`hms:rule:cache:{rule_id}`、`hms:rule:version`),后台线程按版本号轮询热加载。 +2. **检测引擎**:消费 `metrics.samples`,按 `(host_id, rule_id)` 维护滑动窗口与状态机 + (`OK → FIRING → PENDING → OK`),阈值比对触发 `firing` / `resolved` 事件。 +3. **事件写入与告警收敛**:事件写入 `event` 表;告警按 `(host, rule)` 去重、 + 按 `scope/rule/severity + 时间桶` 聚合,输出 `alert` 并发布到 `alerts.converged`。 +4. **通知渠道封装**:email / webhook / dingtalk / wechat 统一封装。 +5. **REST API**:阈值规则管理、事件告警查询与确认/关闭、样本接入。 + +## 技术栈 + +- Python 3.10+(标准库实现核心逻辑与 HTTP API,零强制第三方依赖)。 +- 可选依赖:`pymysql`(MySQL)、`redis`(Redis)、`kafka-python`(Kafka)。 + 未安装时自动回退到进程内内存实现,便于本地运行与单元测试。 + +## 目录结构 + +``` +threshold-event-detector/ +├── detector/ +│ ├── __init__.py # 包说明 +│ ├── __main__.py # 入口(python -m detector) +│ ├── models.py # 领域模型 +│ ├── config.py # 配置 +│ ├── storage.py # 存储/缓存/总线抽象 + 内存/MySQL/Redis/Kafka 适配器 +│ ├── rule_loader.py # 规则加载与热更新 +│ ├── engine.py # 检测引擎(滑动窗口 + 状态机) +│ ├── converger.py # 告警收敛(去重 + 聚合) +│ ├── notifier.py # 通知渠道封装 +│ ├── api.py # REST API +│ └── app.py # 应用装配 +└── tests/ # 单元测试 +``` + +## 运行 + +```bash +# 零依赖本地运行(进程内存储 + 示例规则) +python3 -m detector --addr 0.0.0.0:8080 + +# 健康检查 +curl http://127.0.0.1:8080/healthz + +# 查看规则 +curl http://127.0.0.1:8080/api/v1/rules + +# 样本接入(端到端联调) +curl -X POST http://127.0.0.1:8080/api/v1/ingest \ + -H 'Content-Type: application/json' \ + -d '{"host_id":"h-001","host_group":"web","samples":[ + {"name":"cpu_usage","value":95,"timestamp":1700000000,"labels":{"service":"web"}}]}' +``` + +## 测试 + +```bash +python3 -m unittest discover -s tests -v +``` + +## 配置(环境变量) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `HMS_HTTP_ADDR` | `0.0.0.0:8080` | HTTP 监听地址 | +| `HMS_EVALUATION_INTERVAL` | `15s` | 评估/恢复观察周期 | +| `HMS_DEDUP_WINDOW` | `5m` | 告警去重窗口 | +| `HMS_AGGREGATE_WINDOW` | `1m` | 告警聚合窗口 | +| `HMS_RULE_STORE_DRIVER` | `memory` | 规则仓库驱动 `memory`/`mysql` | +| `HMS_EVENT_STORE_DRIVER` | `memory` | 事件仓库驱动 | +| `HMS_ALERT_STORE_DRIVER` | `memory` | 告警仓库驱动 | +| `HMS_CACHE_DRIVER` | `memory` | 缓存驱动 `memory`/`redis` | +| `HMS_BUS_DRIVER` | `memory` | 总线驱动 `memory`/`kafka` | +| `HMS_MYSQL_DSN` | 空 | MySQL DSN(`mysql://user:pass@host:port/hms`) | +| `HMS_REDIS_URL` | `redis://127.0.0.1:6379/0` | Redis URL | +| `HMS_KAFKA_BOOTSTRAP` | `127.0.0.1:9092` | Kafka bootstrap | + +## 设计文档 + +- `docs/01-design/architecture.md`(5.2 threshold-event-detector) +- `docs/01-design/database-design.md` +- `docs/01-design/api-design.md` diff --git a/detector/__init__.py b/detector/__init__.py new file mode 100644 index 0000000..3da8a53 --- /dev/null +++ b/detector/__init__.py @@ -0,0 +1,13 @@ +"""threshold-event-detector 指标阈值事件检测模块。 + +该包实现: +1) 阈值规则加载与配置(MySQL metric_rule/rule_scope + Redis 缓存,支持热加载); +2) 检测引擎(对采集指标做阈值比对,状态机 OK/FIRING/PENDING 触发事件); +3) 事件写入(event 表)与告警收敛(Redis 去重/聚合 + 通知渠道封装); +4) REST API(阈值规则管理、事件告警查询)。 + +默认使用进程内存储(Memory),方便本地运行与单元测试; +MySQL / Redis / Kafka 适配器以可选依赖方式提供,接入真实中间件时启用。 +""" + +__version__ = "0.1.0" diff --git a/detector/__main__.py b/detector/__main__.py new file mode 100644 index 0000000..cae6d79 --- /dev/null +++ b/detector/__main__.py @@ -0,0 +1,54 @@ +"""threshold-event-detector 入口(python -m detector)。 + +用法: + python -m detector [--addr 0.0.0.0:8080] [--no-seed] + +默认以进程内存储启动(零依赖),写入示例规则并监听 REST API;可通过环境变量 +HMS_* 切换 MySQL / Redis / Kafka 驱动(需安装对应可选依赖)。 +""" + +from __future__ import annotations + +import argparse +import signal + +from .api import APIServer +from .app import DetectorApp +from .config import Config + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="threshold-event-detector") + parser.add_argument("--addr", default=None, help="HTTP listen address, e.g. 0.0.0.0:8080") + parser.add_argument("--no-seed", action="store_true", help="do not seed demo rules") + args = parser.parse_args(argv) + + config = Config.from_env() + if args.addr: + config.http_addr = args.addr + + app = DetectorApp(config) + if not args.no_seed and config.seed_demo_rules: + app.seed_demo_rules() + app.start() + + api = APIServer(app, config.http_addr) + print(f"[threshold-event-detector] listening on http://{config.http_addr}", flush=True) + + def _stop(signum, frame): # noqa: ARG001 + print("\n[threshold-event-detector] shutting down...", flush=True) + api.shutdown() + app.stop() + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + + try: + api.serve_forever() + finally: + app.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/detector/__pycache__/__init__.cpython-310.pyc b/detector/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1190d6e6066ee7145680dafde69f0b4460204a3a GIT binary patch literal 840 zcmZ`&OHbQC5H^phD24yvYgEAk?Xl`ZDy&DG*0?prSoAf#w0^g-CcL zp%5UDN=?iGqCgz-Ke*yuuTMGk-btKFT)NVZceFb*-+c2~>glm@um+!f|NO0;<8B+y z-PH!pbJ$8Z7?GRe#I~t6v3-)WcPxB>0MCq!l^JPH?8S@N<>?i%2Rl5H%+qj?9t6#u z5bJv;qDLc3(`fK{t{ zIcoYNMzvViLZeHQ@80n9*dsd~g1j(?QNsv|LvmyOAwPjdr@|ZBCMoRHwJ=SnH0(20 z3Ra@Jwp!QHwm~~@0U=tFZbl(1Q7_G4&`bv`$i$bjP*61iSCGh9D^ahHX0G_RBk0xe z#PHZ_G(I%;623e;Jt46TtHD-(|hCm_~CzkCE921?c61xqaRc$##jvRY2E(`}JOb-#}mgn7#exz95K_LlABr z2*M(k6{qA9gkPN80kUkGKOf+1dsrZlxW literal 0 HcmV?d00001 diff --git a/detector/__pycache__/__main__.cpython-310.pyc b/detector/__pycache__/__main__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf8d79c9143808e72df7884aaa05d8c3a4284aab GIT binary patch literal 1823 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 CH-x(Y literal 0 HcmV?d00001 diff --git a/detector/__pycache__/app.cpython-310.pyc b/detector/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e876df5b90c6c0bd66dcdf5fcd039797058526e7 GIT binary patch literal 3731 zcmZ`+-E$Pj5#O)7+uJ+oPNxq*5+El|QkRqv2~Mh#IF1~kTuy=F0-T3+DVt?ybUN5C z&+b8?5)~1IF<@a*kt;TkL^2SYN>b&dY>Wk1|Hr&qcaom`?_kbaiDG`|{uIwqMA|tQ5s;8Nn zmoif@)=0`tdl@q$__V8gSu-p6jB9u~Gbec6&3heYhu3L#dIhr}WLdZ9b(vj)H{6of zZFUPj=k|ELX0O+0_IcaPZQgcsySKyK;q{yS-hesa4Vr`APIITX%iQJdHh0U?OA^VG zj#-IxoL9^t(s@)W7izb``TElQ>imamw=S$-nEUeK^~UAPYah(k-+Zrrd139*qc3L8 zHEzu`KDknV_)g=GS7ErabY#i!Nm`3{O#3p$sI2#1qgz=vS&fEvwu(Ok-Acj?`Sr^9g^{_CgqCQFhb`LJD8% zV?kVTKtIDD3G@{TaFp-s_NQ@g9F!N9>p@0*Ya0bZ%mjVvMM>Er8kQ7J}xD44zwvp|?=|qNb z+QoBi*F474j<|-$#f|%UExDI*o@O+zF`sK=flnQ!%{k<=@a{+d?gKL|hpAXvkQb$C z$x>oV5w+4v>r=gCOGZ3$B7nw?KO%)eO zD5-_k>8ekH)4XU`DvVa}=9`kd_%O2KAgnrEhoDbFPvbdpv#y9e-c3*0ZcXHaRrO=a zV91M%IspW{MQTh;aXs)Y5hUfEEDeXs^$8Yu7WGfDei*XtC^I%ghHXPZt}U)u59;kg z(uZU>l3pZ3NIHP<9>$qlVw$e0oO1zcq)o* z7T=YHQ%`z>o{YjPyfNfXd8(`38}aG?_oNfP-1KDAzS6W0h#In=g3^;_p9k3iG>e-8 zqs6U8gYauo>mt{Func)oKa z#b-jOtJJfrF3+5%$0vi}j)iCV22dL}8z(Db|m0{pdG96jcEzY=3kBK>)|El#X^O@&>@< z*U#w98Lh6Bb-4tOGK1Pv16%TQs}DRQ4i^GLjbbK_^l?Y42LaJI&qSiZiyoKSvN~z{ z#vRP%jUVzUctu_pfl1J18X$ODSyUlsBRDHQ4xcZ*+_&B4shix zmO{)s%i@M*c>%$O)8cu{dc9`5$qaiAKf(gT5Z4)=6vOCX6v^gh1ShJ|4w%5^B0?zF zrMztP8)-u`)Xsej=f=v<@Qh`Vz)1{G)S#0D!wi9&rvWQq&rF2@!@EoNj6IL2i%Hvo zr!6`Rz>I)Eh9~)gfB2H5c@{bBS(2;}-Ax9&aX>GIjxoe~hVj7Q&!NOhND%pwK1no2 rqEEs`O0-wP`LEkC*Pl-g?**)>5q_mf`BYKKE7(}{i00xsxm5TcE099X literal 0 HcmV?d00001 diff --git a/detector/__pycache__/config.cpython-310.pyc b/detector/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a6c2fa48001d802421bfae2fdfffd9adb98bd29 GIT binary patch literal 2550 zcmZ9O>2Djy6~K3ghp6T9&@JCid?-v}QF4(cHjK24$jCxu(j_Se-7nJ(XCyIRa+%qs z3>OBfR1O@YJ_00Y@*x4@G#}i4uo0v!ib{e1kNMCqF)0}+;9lvWp6?AQ+U^qE{mpxC zW@p}eGjH8k%+TOE{+%DaxeW9*HXgr%(D<&V>0TCqX_m%xMjX9HETSU^oPZU8XV3}O zLRJW5G1kwF%Rwv5L$ex-GV`*=%r&y6TYW50(o*r}Dh#!C@4c;C*B-ocee)R_4?+<>cfqz4>zv0 zKmJ9hbNj&zJ06p$%n0t)y+F! zw61>%miWF0Fb6HA%cxziyT0vL-MW{Dc0aRyyW-fMCxdmn#$~|sh3sQ|(QZ1v3=8fz zMZH3>d-p;{H_f1%;sCz(rv53pq+QZ)5m>OEPX#1#J=w?WD^=mvWnhW>DI&0w2*d5? znux;Vn;*a}&C-o(gFDqaPhW7wd9PttxR;(vgU2@4e|i5m>#fhPv_JlB>)k8u>z}0s zZ@6CCUl!b3b{%$%ukgBmjB%e=d{?AL~%c`Vf8DH0-KhhI0hWI zqKXrWO~pyYF~tLl4_isaBfz89fZ{RWacfZVF5unPkm5bSd#z!``+)ab zBkTao^WZ6skFule5IcN12>Ch2jzG93WOzn7t2qKoZ~tty{i~mJa;TTB+ve1y`kj1f z>ZK_W0z28i?E4LBGbUtU`UOu8@fF)?s>Gtzy3YkPWm52pyTS!!O`%khyik)SAY=;ErpvM}vlIyyl9FazJT42s@`tRN*yp zP^ANXQf50`_)p5rid|Xm^)lXQdOIV)!fF@2w;am6`u4cstm>Uir>Bp7`7u5o#0JWbZG8e$f#CZ z^mhQ4G&WU*Z1y!qSYVC34<{IKh=mpR0Y|#r4{WfglAFLW7FV1APO<^TgTO;cz2ig0PpScKH&ZAfa-e?_$l_Z;zPiP*%8G@fhX8Aic`SPvgZ^(4}6SG zu8~jmTN+ERXz%Mcbjac9yhy?-Wem^F)8+D6n#pF1A^|cnh>9^(TNp~*0s6mv6$bGahTE)9lMuVnU@sbsqCxIl zE?=fkSnTU5GxNFP|0(yQQcZm(GjqB(>H!qTalls=U{#%0J%vK$y))l0eQ%y-OL=h+ zWa3ayQOsrMN_3$(FP=ul5mXG}Ft24^eJw*@DHO`3axrsO97gp7ss~VgCRZ-b&6H@l zaCUA+97Sacm1<^c+B>>uQ1=|_)VQTwE(=~iQ=mm~Ur7di(d1BC^l9-j8U-%GCcruZ zr3KG)8FJhUTtHC^RGf}RsRbVXGO{S#8kU0N(DGh|TZ9-o`L999e~bY6w-F@&GD75^ zMwo0HedHfTg#6v;Cx0^x@>e5DzA;Sl7b8agY{bc*j0E|kktBZ@90)%AVWP7ECwIor z!*p=(iRa;#8kZ4Dv9MAq4N7Y+!y}DKlhU`Ew$s)0QOev3rJXEJQ2JPv1}UbnN>Y`M zD(_Tssq9f1A}|vKMqFTg1;$gwPQ^(@LwUX9-l!`0a0)HEOu!k@DaRdWp+l9&1eSK( zRo=63GqfNvi5W2U?jNc1dyEK4>Z)HV+^P0hDpbi=MWf>vl%*nK79Qx5#U^}VfJ+3H vXAwbZwD(a05e}djU+12C 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 q7k{;BM1?kbM}nIO(O&ASMFRixOpGapp$ zomsl^?w1qq#A21t|B`U>nVg&GNt||040+xNYId$L7-U?ko_4ZBnIN%v=UnaGGglWM zTz_|%SHc-9m)@() z{1HPR{q5nSFD_O8{HvwUuh7z$u_-0VL`$Yn@PbT`^9p`nQWx6c!l=-DbJ;-XKg{`o zu#Og~Gvh9^@D1az>+m2;@2~!?cyK3L9VNx!Mb&2T8Z0cWZoqRYEZQnsj|}0njvFqx z^gP~ZGO#p8<+r!0mp-e^+`w}Cgpr}IatlL+8D#jN6Fh0HFYRZ3zz2lMouI@ELOB9Q#$yJ$_pgy6?CrIboIquF7qN zcj5N06DWG@o?@=(xVeJUbK2u0ele4E{GNS1OAp^)eDJ47_a9Vlo~_P)QJFqlz4&Pl zcZ#0h6AW?3AM#v#uk$uOwAXe5CmVRYXQcu!hW~?R4L=udLrqx?Pb$x zzKLF>ovtcq9XyI+11{f0VPQ!fs%%>q2LJpf1~$QPfdXh4D7y3qT)f|DiN)qhl{OCn%9 zDrHty(#oKMk_GCVHcvI(R{9lb*rdUq!zk&AKt#!X847Czg_j)>M%u+Ksq#(?k|Wm` zjLe=ZwUnPX+ZXI`0SbI{p&Hm(KwurX|w)#3(#DpJ;L)z)%QT-&&) zkL$A6D(e)~7uA_f;|S%lF&xzuO&MX{9y>as6jT)eJC49>Ku`kkyG;N@%q3%eV@4@B zxp)6q{D=o421OnlEjnWzfSjAlN`2}X_C2sWSw9JewlhISM1~NT4)=wbcY-0$UN*>3 zOhkfQ-tkJo^55~C+XM1X#)K{>Fziyk=wpPJwn^qpr;zn*N0_1PiU@a3l^iHv2l(_!g zX>MgNel^zF?ATg}NJumztPvud*mBjEB!5fR{uVljhgZje#;NtGI3T+nB6<7)Down- z&E76kD3b3TGCk74+uQ7&_C|XXINWq1czVv>VsD)?CRxgojPks&psZ5SO8YQnsPC0& zq$8K1?2i|&BBiNZKEHJ5ceTuD`Zr7Sx2or_EKmGeDt?6a4%a?;VMXRCEg`MnlW5Yz zfq@2dtRHw}zA`InIb!<%$@~W~QZgR@I@MmFf>^K~pt8TAlG4-^px?tKsXi7`{f4>) zR^qe4b%2rFUZ|oP-mL$qPS+9g2Jt`m92{R}2M|Ved19Ov0n%;=&>=LeLIJHxSlk&z z66bL0l-vOYYAmbX+mIe%)zs8~(zRlC$^&JJHSi?5Qg2eF2&RwXl54(%3TVR$YML~! zOtNuxP#K3Z^(q4|kLyyaw6YGGG0KL`N?`ReKiIN zeRVk`l@qErl8g%7g&CyTg+Ahp`a(kv8fwrkj0yom(I%xa8CgXS6jLHQkfgUR&L%NSj~Hnlu4S9$N<%AJoF&rK)lb2m;D zv%jy*-b3oUG=HII<#>UEgdxvGB62#^Q(@IlMZydZMOn(o>pvA0qM&{PJ?TD0=|BW4 zV}KD>^nrQ`$%`~ql1V8~szUAFCs=l^{bC1**Rx)}coiN zp2z_*C}sRN^-Dpa#3ft+!65AKrT6uussV~_nGcY<$oGjtIh0_El@9~$YAi@-Fp^|pjgp_}W z8qHv|hyxgu;b$0$2Sji<( literal 0 HcmV?d00001 diff --git a/detector/__pycache__/rule_loader.cpython-310.pyc b/detector/__pycache__/rule_loader.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4674d88cafe3fc79e9e1f3b02006120afcea52f8 GIT binary patch literal 3933 zcmZuz?Qayv8K2qr+dJEbxr8>L)D0%#8f-(Qz9CA}g4;wfM`EXeTtru!n+?8W@AjD8 zgW{f5TAKoijgg3=B%taXH7ZC=>4!>Cu*?53U-8-I6QBB_R4RR*S>O5Wva6kaX7-uc z=jAuQd4jGko4^(PnbSM#cTYk5t+ z^}G&m%{R(s-Yi>rtDMLuKv!pmZO8mo(oOLwoVEL>}T{n^9wzqjpyzNPzD z`bKNMHx>old%SSz$hon%M;4jk9w>a zg8I_pjitNyR;Ip=7l$w7<#0cTh4&Xwb^mWrc8YBB;rZXSKb`S-SPUvKe{J!rSDJHw zTetrBGnf0g=Larxij~OYXI#H;b@8vOx4&Gud};Z{Uz&4w9xu$aEW$J}xidfio*hn~Y~TrQPWgf4?D67BR%^t2-v2X!Ld z9#{)y0gdXsH0ZpBNMs;*Bu@d23fskAmRQjNg|DzK);*0l+|7De8YCU0Evy$L1Ej4i z1Cq(I?0L2gB#V8Ay}-7Elt}DI>|n35J!~iIpVsp>+X0vx5H?yo0-5u;Q1|T(g&rch zcoIwyx-^~R{t-Q!5-ij(;=|qBc-lR z(Q1<6m|7&zY7D>Vkcv2THtkdn=?pJW6KR&$Tg8ZJ|m5{4tKg?DUe0=|i~v)NR` zK}3hba@ImG645yKTvn`{6#D2$P&g%Y$Ct0+GhQVUNnE(K43kXz$SKgR3j||Gsqhe)CTkmv7u_ zUb?tE`{fY#szEq}tB2!(&j#fG2ACIl1<1lsTR`){KFrc!^&I#C`>Yr_&NtwfA=+S2 zgLcDXP+Q&;Ti5eC*wPj%?9Kv)u65}kSn$XseF{RIlt_cllSv8;r%XfA4wET538{f` z1;$m7)VXdNCsM0J-_ef|rfNhZQz|7DYaa=Bou!oE{T@8H#)++y8q73GW@Od1Qeqp4 z>_)P#HB$3r9=Me0voQDc9@J@=QCHNB0GS5{t|K=ZwE-~hSv)th`rwo1^*=TjuQe~v zwixCP?D-9TA`lUfV+h6!2ne|o#w4xN64-Z5IjxMvSp>T>(q1T6D5{>E{>3qNY5FsKNf z)ymaSm{H)Mp&~7{-yI$ud*f)%d2{$Zz6F%CI`6|dI=Tzm74NK5cB{f{?GX(>Z1!D4 z&+uO%T4O8S05=O@XVD%1wbXvvOEo&tvndiAa)J0m+)2!R9|Xh}x3NX!ahuUG5-BA_ z8ju20nKp|ki?ONC1ENqPtJhwH$w2nak8ZZll4i%L1R%&w^q><1<3ThKlfpYd>_+5r z2(!>CZrKwCbG>p<$r}7yu%57rq2u~R2*L~4HQtd#AvDMjL^gz}(Jd+?w<$FI$DsdR zctYG!X+@(M*pd_Jj#$Xq{}&7u)MYSU2B8%O06r-xd<(WEEI*W4*iy)WEgnSFAzGti z*$Zk>R^u2Cz6TrWiB<}V6@C!=$WI*r){xwO1Y^=v8`1}9d%7WQsIkFP{9T(Weg&PD zrQyIt8m(-oNE^uVCGC{TGj-CC$vQ?V35`U?xfffMP@!kz*(T^(^Wax&Ev~mbyhU@_ zg!D)lkP!gA+$;Q5^hE9kgpNoI|GMDGb6aR<-D1SI!Dv>Ak!==goCBM0a-San9Slnh zVX8>Rw5WDI1>jYlb}POA-Vh5+{gX5n3Gc6$1Sd8`2SMGA&JUr39?kVo5@9@KY^LZj zI1m}|P)w9#1c>svPNGELIUs>RE^+G%4T(V#fH0t@s(Ilv)0sfdK%9qf!mQAM#-b_O z|8yeL0lv19q>3k*R=KmmC(Lr|+K}HM=i$~@a6b)C$F3Z|^(NZPU~ zKf-F6dK64qbH_$^IuEs$PEHu1tjajXUzlq(qOjLX#3x;CUm=_)gt=BQ7LbNomk9z! zPVU6xAeVdiua@4vu zuZI0FqBpF%fhb2Ah)b!x0?V(RTe4zZz5Ug4;|>(cP_)L?YrAyiaAc8eLKwdLf#-+( z2y{87S(skM{lNE_FybniBkX{ouu>MLtfPU}4YykLDlBWpl!X!HZ(;K`Hn@08m+|l6 z&?c(Tkv@*%7&^eEB>yN^OJW^`M}Xr9 z+i}VPgDnJd%5hHDTtEK8cj8P$zvOrRGn9}(WD52Y~)#_A=Sg_b*>6^D_3zGiyDqF z1{4Gg0fIP|TY<}B*u(YjNU9%CZy-&iM?@ MC~2y}`PQrc4|Q=eOaK4? literal 0 HcmV?d00001 diff --git a/detector/__pycache__/storage.cpython-310.pyc b/detector/__pycache__/storage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3911cedb617ad5d99e6203bd74ddc94f670f8959 GIT binary patch literal 24886 zcmb_^36NXIc_!Y&0|vlg4i1OM@DL$h(n!NY)M4v1Ws0&COCy<7B5Ncr2;yPLAu)qd z1E53%bZC(>DcRH!%d#a=F&X&~+)7R_n|L_0*`~Uy$ex5=q!*GSiR_el46&q&XB??^Ao;&w-I-gw_g-}wBI`Q!Z~{YK0Wyt((@!efOMZdAL;pp z1!!*x(*5=Vq!$zxBE1yph4vz(7ZnyEy$tEa_5jiYg~doOM|z396zQdf0i-Xnm)XmY zL`GKFSKBM>%n@rOV_#!mYOh4>Qu|tamAx9VmG*V^8hb5btL%^2m)Vyiwi+!B+E<{Z zD`X^VkiOC$LV8F>vKHxe_Ijk(%SbLmdV{?Y>5Vdy%aOjy-h}if8Ofj>-5weIc=aDK zSySKr+SFsOo_q403*SFA_5H_wa_F&CYF%bqwOH6*DXBtcLuTv#!bHVQ+*&Lso%re7 zFW!A%`)BUTTsZUHvw!-;Pv1T^b@U5AeH)Q4O~3WE3*Y-j=FW+`3*#kqAT#y7-#h=* zYd?MaCA7RHm)}#syW0x(SlNGfccHSOT&QG*KfB@1;Vs)XZ2j!LcW&GI;mzG$D3^1) z3!ki((dMUfqx*7xo0HXD#j*09*%}dW&!6}@MtJs(?@oX9kI%mK`t+g4rXN2(^}?&? zp8Mg{iC3qd`O>-Pk0SrviSJCk_Ctbq^yvBLUOjjCwR4BQJ$2^osmH%b;GaG-edvj^ zKYaPZ51!XOo;&deQ{Q{y!YhwYpZ@;Tldqop>g(s}GmtNPs zq382&KY!uOvp+fXdBpYm1Zg_6(@1eB;Uyuo!EWV$zov|_s5zKK)AmwT znJY8=mo4W@lc3ZmfJ!H>3YDswc)yp3dC-S=N1ixw ze~k0lf`1nYyB*mcIc*G|M&MZGLUB~hN3I0_Cx_Sud&A_|WT7}VQP}WEN$o37=JJK| zhK(CAiRa(? zH+YdUQecYplLwp>IG`e6l`lbu7fl3~S?l+ZwBE1abO_vIwi5+uyx;Fme-pfH!2L1q z!mH*Xq52pQ#?z)+!1#chp&?(0q>})%%aB&Ga4~{fpFbYA(6Klg)rI2xN9>f4`j}SI zX=h#)a+QMgyVmP>f!}YSe6Ibe7415zPTFoqSG4OWQ0`c}N~PkQyIa<-JG)Q>0SH{{ z+jSQxccNnxl^H2EG=r+YU2mM7{RLHqT-emEw?O$YXrPC6dJ&wqG$|}$hU6~QxiVLr zWSz@cYh4nG+uCZx2RofaHan3UFJ!Y$Dw`cI+2q))lg?%zuI7r3l8$WFF6FaXMP5{k z7%XRyVQ?vfRSecJ@EFr4RMm|F4zoWT~nB#BW zQ-T~Cv)}LXqzYur4j~FBUdHNFEjx)ahO5RZ2b>skROS61ukvWIl&kD;qJ;_Du_hth z6mj4r8O@f*YK8X~$r|+_D10a*Qd-8dV@E!Titdl`86?(VQcJ9BQAN}0#~AxKgXBO-71aayb=JY8U&~@xm zgsH@2XcCk0NlXSQ^C2UZm<*Sg(qopGa34u>utQzRq9F$B7>EF|7`vLmCm8r}N^BwX zHXz^-LX2TXO(Xc%_ny?8nKAaGjSyp8X2QedeJ^VWM=kaTxW1{1laNt>ofPG1fKB9$ z9Upa2pK3FxC?xrP-cY&&LqUP&Y#~t1AVPqo2q%@x@5^d_)@D(Y)GOTNr>+cvV+{d^%9B6vmx6VcT^eA9Y8E z|BTNtz|&DZKtb<4s2862DB3G9U#2^VV-(DF64jk;T8`z5rE+1+Yr8i9nG`umyI@x* zv-=7M95YvOVj^HaDrDQ)flx_^r2C1e#w_kg13|J?)&uxw40=!|cR60Xzx{iSH`&h5wQ= zuJsgePHca!SS>iwN~IXkSfE?%P}D{9TqE5d<1oh{naT)BO)A;)&It#$xdV-tDxd9SCG?unUm zoh6my+hGv2>GHVOa03LL4Qa5GB|Z<5EW`pmod^^O+NF{V-DV}O+%l11>79(XD_@O* z)=oJ6gQ=MrS$KGqZSH2Uhk?jpA2^?fvn<6^=FIy%Om1}_a4c)Ja4e~AGYC)2`Vhya zJ!eYH?u4$>MJJ8xC#zD^JNNprbDuwR?$BH3-+b3&zo7f9iTz@g(&SiPBWWkz7E*q{ zo35e@vR8}B$P2#56fz^rc+4r9ut z8JS3FOJdUCB;S-N=jaCGGfy7pB0z=yL`|UJNjzohBYL7RG0bh5m?$zgb7CmIy@^?3 zM{NtP+$bzNvHai$UuJjOY2?N1 zBwbE98_KB@+jdtezt4$fi}=^pMF7KmY+|gE%|4Ij%5em7BW`pXwIwaXUhh^F7eUL= ziEA19#}TPV_C}uWF(MTVey-{GhMSKC#Q@`6VU1Jc-tJb;IQi9i^% zR1Ix77PUIHpV7ET_;NcC#8dDRD#_>*M%q~YL2X&<=tV`Old2r>ZO~uPBK2HIE>c7Z zB;5x88z;@WajG30{q5n13LFA~))tnb!2rdQW5mbtbwn-_`3WP%;kQ9PT{Yrj^IR}9 zj=N08kZ})T+{Rv+gZ=2Zf=wN!Uy9h~%hAKeV@z3gbP;N0t-91>`AvA+itLTq(T`!y zvaxzQm^X!C9<+wvk8y~ZT2CfdBn=+O6duJKmc>Gb^yai`?8~gCev9k&R;5bH>1dFz zdW3aWFlZJnDucXN@RWNIaGv|{q(Q5uvHJbm^41xj%}40YahXFBTx|^C%4mx#P%^PI z5c#!nc3m5ssY4h+EtP@OKQmgYPT1O=APaOmG+c`6e~2f5hW=U65b$7Xb%;X7{W0$0 zVkZ)Ui&ex0DSp49`jV0R1AJ>6+gS&cbF-!r(>8@l%uqdST7km{+dzD~KzJV%6`==` zf}Ztu?6C@^Kfu!fH-*{m{Z)bsuCe;(?PfjTLDhN_1*Q9A+yj>`9Rasfvv(T-H7`7T zEuc(2av{}zv=S!OHOPAhPeUYGUrk|(6P-pU1%1dl_rCecm*a@tARx2*)N%ItA(ZCww zbY!)EPd$o?N>p~Cw{{aoH2!NmvJWKd-_q7W`3oQ^N8GeC%i!QDNPf|4htbSSv#x>- z_Be{Z;LDhtgfmrbV#gu5cs25Vgr`Al-NwofY6ERXA9V9-XVA55?K%bn3_iwyHLly%T?lMUltNfyk)YhEs10bLwDX zxJgH$q@#xd*fYvxFw)MBo`c4*=t)SsW5zM_Sp1lEEOsn%szX82KWjd0LL(uzu`Qd< zj^`@*J({MlEo-jd=op359oo?>m=q)~K=l9;1*bA$3sK;tZlW5JFO5&$@()OhD{F0} zwyDi@iN2yKc>PV#-*UzBEr__UH^D}+#%DYJi5@|*=4w-hlYp4etOA`OYRMl8{cQC8<=Z@5>H2mZ@+Ia zrgs=Z4Eqemg$)9k+}TkowCE9}qM7tx@rc3EowA@#`A;9>d@Q53q7CkX9HGeeYBPgd z8GI7KV6Pq!#lyd7U&M0&3?{~uZ>ps+UQv5ld#*PS< zKg)~<_d-k~{hrweqmeadUJ4JP)LRO#@|MC)?owEY;q(wsG0C0?&JQ)r#pHbNuRpl} zr-`nUlSMK3;9@vOwAx;ycS7us+t*`fWDrYd3U6OwUx@{_)4oB@3t1y+dp;J>8~p{; zcht^x0o69tClS_R+uO$O$SL!%ft2B=7DY}Os2QzC>!uBh(l#-H>4R1sb|!4mD}i!rAW}E$ z)=iOzSJWZj27%HTH=fHlwbYTXDuweh+M6Kn{_A_WXmJMf?s&mANQB@iTg3DrI zLp**9x$4_2LVY0DR=l9=H{J+U_$QsS(70wu{h zd}mPNI-tZ+g-bw@gJL&}5_eMzo3&Bms8wJhjWWRzC9WGvoGD6NlSotP%|prWnSQJ$Ku;CzB>tWZwO@Ze=Tma zI7oMTZ~H#Hqka>?%#=|K4pIht=27x07fbDX_8C;1WT z$c)2Nd|4ZuwL8|JO`6Z5J&a7cqKYJ{g(yo}#g@DWSU^fr53+itV2x0W~hQgTcQ-Fxb`B__>|sFEMz9foK(!w2HE2E=Lnipx*z*BTkV7 zeSwylP8+FYvq_ZxnYjS|_Wo1~9zwp8$J6wq#M#V+c^4v{S+v|Ui!Sl@(pKn$V%ST| z=(FS4OT*O)*+YxLFUUAAYrr5vd=(BUEI~X8!)U3!3?@*@?t#9z#_4jWatrS%%)#bS zFJowpO(txM;plH}uGY;%AS!L$cp91X>Kit~XsG4=7&q~3AyO!Dpfnq&JjaZw{s?(a za=ZlJe`x}HwiIh2CDpOA6QxIXqA79AB#fHpE^Ty=Vy)dS!P`wrC?|x%PWrzM5wUY3 zP`{Uv@oYM!3&V8C~OM*_NYQ)qPwK_@AIX^`{72(Y=!yoO+!5 zd~`AlMm#%Ng*HyNmFP|>7%JSiuzs1q0r#ePT(7H&XR-+%!r}?;dyx>qwUmPEB#e(! z*i1wYOcGh#Ai~uG8Xp6Z30!4pBd#wE3nf)WqpqNS8*wF2lBZljy%TZAqBkl8p>W<9 z!J~r8jXds6t^Q@r+W^+{CAvG&qz`Apf; z8#06In`Y46t_&+B+<1zr6@Z>Q;d6r?W+kA%re&UAMAxnJEQt7F8)$bUuS#e|bH&uk zHqgFDEKf8FPU$X^Q~3^3slK; zUgTh`60gI@RFC;|P`BDp{0(7D!048f1lkB_Xlu=sFS}^M>2jt7i=mhUJ28Gu!tXG4 znn62C*@J@55qDaFAzLg4=fiF?uo#DgS=&=c9?GZ-3CflMWqN-SrUi0jH?o!RLphol zR<=RGeYe9LtPuj?_Z(v(g6FBAqXa`7%OC))dYabiUxdY%-hmYL0nN&C&ZPnLwv!E0 zO#4ffqG~QORqbP}$Y2~n>-odqW|7dSOpvVfyT|uK`bU0zKwCB}Of<@sNqqqkcTG-=>!VNQ zxh~7k0H~1D(eK?~n@j9NFu#+rT@0wYHA!7cV2{{tK|+YFkNl|(11i3wQLZyhTjE$9 zA!Qba2}{G&aQXZhnp00Oc$h(mx)k!ByS?6E+y=M7mqZymt{i?ezpg=(Z%UX`$uRc!*euM9qTV4?u6nxsXca0Iu}%uWlTM z599&lKH_>SoaEL=^98wyh)uFM?t$0^1t(puSlk6WEHEUF1}a!NH(EHs@o?2N#rp=~ z`hofjB!zfd=!UZd$}}!=V8@`xh!42CVjPW(LQ2}vBZ-Q67|f0kL&LEYCpLbd{BW^_ zz6mBUV_an46}APJrMqorI^A+h$2+zKMUAaw?ec_xfm?#{d}(5Itl^av^gfH0z6o$d zSxZG~s{)5maL^uibc2o{?S_H7a~{vl=<=+s#;|>+5kTuq{d)#7gAx*-twh^tHBpn%(|2U-%mY|Np`FT`+1^@W0;zzPM^;13yrq z34EGbvw|OZ@r(!l-^~SlCx&g|vGUyJKPBhAMTd2g@?N&8iZc?xpLiI!q%lo-+GuU5 zKP^Q~P`^GC)ahVrKk(pn<_7OR-Z`Heya_pAi>vBRkGz$|<khLW`&AN4dC}tMz_Fyww`lw_!HBhgju{ z3vvu+jXusQ$8grlmlb$;^)h{38>aT<`l>ce?LjfWx$ijGFu&8-iR9faYorVJxb!y> zy6shvwL|JJ(Rmfah)jL`&9iSlm6<#sQZsY@#UsJX-MA+(ed3V5agS@=xL2LQ`4sH! z7u`3UCbJ0hPM?{6>ks_9yF{fD4K`N58`IZ zs4Z$9mZ#h_i8;z{t^$IU>kw(+MAT(*X)eS*C$PbtUs%dGd~nM6hVc~2vr^|I>Rg&j9}Cv!OEA?x8;mc+4VhXWqadBs4GR|GZ>K%B|ab^e+_ zQp13qY%T$iwS{5ebVaSCp|Tnu|+)DXTw{& zH#WJ@hUv67KDoq>;eW@;gdIO+OkT>(`V%pLoYbaJEqT?ZkFDQ`zpKXn2ex@|wH=)A zMBXu$l}R8Yd0gz!}y-e@osAL5BJC>bnkHOceii7YwMQ#^mir3>>*CckluS9ayRvHqkaehW8U4i&e(eLM4+V`A zg{Xik!=wF9RFxjVZ7LR*!_2V=mCuqnE=MGRQ%$B}ePG2XWf@;=^cqXVJ3;?MuVMWx z{sFFphnYt9bfq)F==(i);Yi+tVkNNG=Igwi4$g*f(L;zdxCnxYTvRtkjZ--F2u^~C zYH-q$hpdCv!Pvq0!H#Bgh)A z-QA#F!9cchV6rgOxXZ8q?^dPiB*;oJw zQhswzqS0R2Nl8pvbUGSMVoo^O3Pv-G0|rkpiFG)F!&|a0kPg*i7Qu5HaZJd!Ynioz zSs4b+i-y9-q9VV*BW{SsycyQ7EURDUjq4;gc&uMwz7n;iVIdbJv8Cv!Nr@mM@sSB4 zed!6pS(Q#%MKDF!0fT7ZUf1@>!*czmVG`+c*96CP4hxGJDh$AKROtraij9k%75a`o zz;|833;G=Hc#!)!){CU93f4Z};(#-^dKJd903BTq14=MR@EP+^LNah$d(*rK`2c>SUu-K#gzQ*B8I%MC4+ zwA6%Oju%s>$4r4$i9Cio1&FBCoaz;fh35NG5D}o3$7m>!OW&DVivUH!(7vJ$=WP4S3q4^^)p|A}?kWN8B_^rCXvQMjf3#Z-e~EYh z0&mG-R^IzdnTdqi0gmfREl%~N;&6FJyQ5;GRg!VI1Q^oC>QD94w@b*v$i{WbjsH$C_aKD4G~QAYef{CLOI7hXUuEgv?qgAh$$jl3(-hn7UzaPX1P z_?(TtrGUP@A4kRXr|~Z&8BS}H#fk5GB&T+_C=Q>%TkF}O$Oyf_1UeLrKt^94R)gq5 zZN>|(>z{sP7*JQ@U)V`YKxY1I3(0B~dewFkUG^QIUFNpXE{SV1X_v<$Jleg)qumuA z?YjHzF_`WAe4>S&_m$XT=j~PQyk}(Jovr|PuRUom2g$8be~YF+5|t|+Z1QYsJ>9z*doN6S-j6Nuquu5QbP3LU&iP1aS4H2NpA{nC084rlw%r1o<=ZCw5Jl6+2*{1tL^_i$& z;pk5bSa+lTX0H!T^8v5|CmUxte@1ya6Hxb}ER4s`Aa9G;VGB^&$z_%GcNTG_~0w5a_b=m zLBsfbyI%y&GI$W(BR(tSdLXaN^IK0D^$%#cdOd=M4^h0$>n0D-iKst9GK>3ADmzj5 zNdoH1tjlqE7+rnT<=AyK!jpv4admt&*S3zs`;^749sm(*_>u;EuemSrWQh0SQdzHE z$d_z<4zN(3#OFQ>Wh@kbkHPp0Hm>l~MX!&!)8S4RT5$}zIux%3th#~BD=(dAG9;Zy z7WcaI8(h{C=0dYJ-yfYXMt8CqdJbE}D0zVVCI@)9H>ywFcspk5YL4YM_Mxv{W$SmE<49D(P2T-;^^ws{=qFffKF&exaB23m|2tb0i!EAHf5aXn!u~M^N*L3z)6*C10>ZF9YsYy71qoC;*MQSrSYc(QGGPTE(sl82R$2|34P_@c|HKe5MQb9`M z1HluNl)-N)(7A=}36>iA?Cj4?qOjqZ*8 zVHS86tv>3}Aa?uR5qZ+Ib-#1wVG*TtA?N|A8!-r6|F~KHIY#*Ggkt9h(79~0^9+GE zcUd~)a0A!rmCvRyIhHHpvzyB4##O068t%G(L^J9JbP`x}lG(`yl3h`7Ju679nO2z+ z@j#J?6Zzi0RocW)ymM)s4Q#FNFQqXE57zO4E~OR!+<;5;+N6h zl)q(?a#V2C4yCxXuD>IE3uZ7PA91xJ>6E+Ql>iYgGy2Wmc<-{_g}rNf7x#AduIs&~ ax6pe{qAS$_Uy%IuCVR8JJ-ti&SNuQR=#OFm literal 0 HcmV?d00001 diff --git a/detector/api.py b/detector/api.py new file mode 100644 index 0000000..4cf3e8c --- /dev/null +++ b/detector/api.py @@ -0,0 +1,301 @@ +"""REST API(stdlib http.server 实现,零第三方依赖)。 + +接口覆盖 api-design.md 中与本模块相关的部分: +- 健康检查:GET /healthz、GET /readyz +- 规则管理:GET/POST /api/v1/rules、GET/PUT/DELETE /api/v1/rules/{id}、 + POST /api/v1/rules/{id}/enable|disable +- 事件查询:GET /api/v1/events +- 告警查询与管理:GET /api/v1/alerts、GET /api/v1/alerts/{id}、 + POST /api/v1/alerts/{id}/ack、POST /api/v1/alerts/{id}/close、 + POST /api/v1/alerts/batch-ack +- 内部样本接入:POST /api/v1/ingest(供无 Kafka 环境端到端联调) +""" + +from __future__ import annotations + +import json +import re +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +from .app import DetectorApp +from .models import MetricRule, MetricSample + +# 路由条目:(method, path_regex, handler) +Route = Tuple[str, str, Callable] + + +class APIServer: + def __init__(self, app: DetectorApp, addr: str = "0.0.0.0:8080") -> None: + self.app = app + host, port = self._split_addr(addr) + self.server = ThreadingHTTPServer((host, port), self._handler_factory()) + self.routes: List[Route] = [ + ("GET", r"^/healthz$", self.handle_healthz), + ("GET", r"^/readyz$", self.handle_readyz), + ("GET", r"^/api/v1/rules$", self.handle_list_rules), + ("POST", r"^/api/v1/rules$", self.handle_create_rule), + ("GET", r"^/api/v1/rules/([^/]+)$", self.handle_get_rule), + ("PUT", r"^/api/v1/rules/([^/]+)$", self.handle_update_rule), + ("DELETE", r"^/api/v1/rules/([^/]+)$", self.handle_delete_rule), + ("POST", r"^/api/v1/rules/([^/]+)/enable$", self.handle_enable_rule), + ("POST", r"^/api/v1/rules/([^/]+)/disable$", self.handle_disable_rule), + ("GET", r"^/api/v1/events$", self.handle_list_events), + ("GET", r"^/api/v1/alerts$", self.handle_list_alerts), + ("POST", r"^/api/v1/alerts/batch-ack$", self.handle_batch_ack), + ("GET", r"^/api/v1/alerts/([^/]+)$", self.handle_get_alert), + ("POST", r"^/api/v1/alerts/([^/]+)/ack$", self.handle_ack_alert), + ("POST", r"^/api/v1/alerts/([^/]+)/close$", self.handle_close_alert), + ("POST", r"^/api/v1/ingest$", self.handle_ingest), + ] + + @staticmethod + def _split_addr(addr: str) -> Tuple[str, int]: + if ":" in addr: + host, port = addr.rsplit(":", 1) + return host, int(port) + return addr, 8080 + + def _handler_factory(self) -> type: + api = self + + class Handler(BaseHTTPRequestHandler): + server_version = "ThresholdEventDetector/0.1" + + def do_GET(self): # noqa: N802 + self._dispatch("GET") + + def do_POST(self): # noqa: N802 + self._dispatch("POST") + + def do_PUT(self): # noqa: N802 + self._dispatch("PUT") + + def do_DELETE(self): # noqa: N802 + self._dispatch("DELETE") + + def _dispatch(self, method: str) -> None: + parsed = urlparse(self.path) + for route_method, pattern, handler in api.routes: + if route_method != method: + continue + m = re.match(pattern, parsed.path) + if not m: + continue + try: + handler(self, *m.groups()) + except _APIError as exc: + self._write_json(exc.status, {"code": exc.code, "message": exc.message, "data": None}) + except Exception as exc: # noqa: BLE001 + self._write_json(500, {"code": 50000, "message": f"internal error: {exc}", "data": None}) + return + self._write_json(404, {"code": 40400, "message": "not found", "data": None}) + + def _read_json(self) -> Dict[str, Any]: + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + return {} + raw = self.rfile.read(length) + return json.loads(raw.decode("utf-8")) + + def _query(self) -> Dict[str, str]: + parsed = urlparse(self.path) + result: Dict[str, str] = {} + if parsed.query: + for pair in parsed.query.split("&"): + if "=" in pair: + k, v = pair.split("=", 1) + result[k] = v + else: + result[pair] = "" + return result + + def _write_json(self, status: int, body: Dict[str, Any]) -> None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args) -> None: # noqa: A002 + # 静默默认请求日志,便于测试输出整洁;生产可重写为 logging。 + pass + + return Handler + + def serve_forever(self) -> None: + self.server.serve_forever() + + def shutdown(self) -> None: + self.server.shutdown() + + # ------------------------------------------------------------------ # + # 健康检查 + # ------------------------------------------------------------------ # + def handle_healthz(self, handler) -> None: + handler._write_json(200, {"code": 0, "message": "ok", "data": {"status": "ok"}}) + + def handle_readyz(self, handler) -> None: + handler._write_json(200, {"code": 0, "message": "ok", "data": {"status": "ok", "deps": {"rule_store": "up"}}}) + + # ------------------------------------------------------------------ # + # 规则管理 + # ------------------------------------------------------------------ # + def handle_list_rules(self, handler) -> None: + q = handler._query() + rules = self.app.rule_loader.get_rules() + if q.get("metric"): + rules = [r for r in rules if r.metric == q["metric"]] + if q.get("enabled") in ("true", "false"): + enabled = q["enabled"] == "true" + rules = [r for r in rules if r.enabled == enabled] + if q.get("severity"): + rules = [r for r in rules if r.severity == q["severity"]] + page = int(q.get("page", "1")) + page_size = min(int(q.get("page_size", "20")), 200) + total = len(rules) + start = (page - 1) * page_size + items = [r.to_dict() for r in rules[start : start + page_size]] + handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": items}}) + + def handle_get_rule(self, handler, rule_id: str) -> None: + rule = self.app.rule_loader.get_rule(rule_id) + if rule is None: + raise _APIError(404, 40400, f"rule {rule_id} not found") + handler._write_json(200, {"code": 0, "message": "ok", "data": rule.to_dict()}) + + def handle_create_rule(self, handler) -> None: + data = handler._read_json() + rule = MetricRule.from_dict(data) + if not rule.rule_id: + raise _APIError(400, 40001, "rule_id is required") + created = self.app.rule_store.create_rule(rule) + self.app.rule_loader.load() + handler._write_json(200, {"code": 0, "message": "ok", "data": created.to_dict()}) + + def handle_update_rule(self, handler, rule_id: str) -> None: + data = handler._read_json() + data["rule_id"] = rule_id + updated = self.app.rule_store.update_rule(MetricRule.from_dict(data)) + self.app.rule_loader.load() + handler._write_json(200, {"code": 0, "message": "ok", "data": updated.to_dict()}) + + def handle_delete_rule(self, handler, rule_id: str) -> None: + deleted = self.app.rule_store.delete_rule(rule_id) + self.app.rule_loader.load() + handler._write_json(200, {"code": 0, "message": "ok", "data": {"deleted": deleted}}) + + def handle_enable_rule(self, handler, rule_id: str) -> None: + rule = self._set_rule_enabled(rule_id, True) + handler._write_json(200, {"code": 0, "message": "ok", "data": {"enabled": rule.enabled}}) + + def handle_disable_rule(self, handler, rule_id: str) -> None: + rule = self._set_rule_enabled(rule_id, False) + handler._write_json(200, {"code": 0, "message": "ok", "data": {"enabled": rule.enabled}}) + + def _set_rule_enabled(self, rule_id: str, enabled: bool) -> MetricRule: + rule = self.app.rule_loader.get_rule(rule_id) + if rule is None: + raise _APIError(404, 40400, f"rule {rule_id} not found") + rule.enabled = enabled + self.app.rule_store.update_rule(rule) + self.app.rule_loader.load() + return rule + + # ------------------------------------------------------------------ # + # 事件 / 告警查询与管理 + # ------------------------------------------------------------------ # + def handle_list_events(self, handler) -> None: + q = handler._query() + total, items = self.app.event_store.list_events( + host_id=q.get("host_id"), + rule_id=q.get("rule_id"), + status=q.get("status"), + severity=q.get("severity"), + start=_parse_float(q.get("from")), + end=_parse_float(q.get("to")), + page=int(q.get("page", "1")), + page_size=min(int(q.get("page_size", "20")), 200), + ) + handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": [e.to_dict() for e in items]}}) + + def handle_list_alerts(self, handler) -> None: + q = handler._query() + total, items = self.app.alert_store.list_alerts( + severity=q.get("severity"), + status=q.get("status"), + ack_status=q.get("ack_status"), + start=_parse_float(q.get("from")), + end=_parse_float(q.get("to")), + page=int(q.get("page", "1")), + page_size=min(int(q.get("page_size", "20")), 200), + ) + handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": [a.to_dict() for a in items]}}) + + def handle_get_alert(self, handler, alert_id: str) -> None: + alert = self.app.alert_store.get(alert_id) + if alert is None: + raise _APIError(404, 40400, f"alert {alert_id} not found") + handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()}) + + def handle_ack_alert(self, handler, alert_id: str) -> None: + body = handler._read_json() + alert = self.app.alert_store.ack(alert_id, body.get("ack_by") or "system") + if alert is None: + raise _APIError(404, 40400, f"alert {alert_id} not found") + handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()}) + + def handle_close_alert(self, handler, alert_id: str) -> None: + alert = self.app.alert_store.close(alert_id) + if alert is None: + raise _APIError(404, 40400, f"alert {alert_id} not found") + handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()}) + + def handle_batch_ack(self, handler) -> None: + body = handler._read_json() + ids = body.get("alert_ids") or [] + acked = 0 + for alert_id in ids: + if self.app.alert_store.ack(str(alert_id)) is not None: + acked += 1 + handler._write_json(200, {"code": 0, "message": "ok", "data": {"acked": acked}}) + + # ------------------------------------------------------------------ # + # 样本接入(端到端联调) + # ------------------------------------------------------------------ # + def handle_ingest(self, handler) -> None: + body = handler._read_json() + host_id = str(body.get("host_id") or "") + if not host_id: + raise _APIError(400, 40001, "host_id is required") + host_group = body.get("host_group") + samples = [MetricSample.from_dict(s) for s in (body.get("samples") or [])] + events = self.app.ingest(host_id, host_group, samples) + handler._write_json(200, {"code": 0, "message": "ok", "data": {"events": [e.to_dict() for e in events]}}) + + +class _APIError(Exception): + def __init__(self, status: int, code: int, message: str) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +def _parse_float(value: Optional[str]) -> Optional[float]: + if value in (None, ""): + return None + try: + return float(value) + except ValueError: + return None + + +def serve(app: DetectorApp, addr: str = "0.0.0.0:8080") -> APIServer: + api = APIServer(app, addr) + api.serve_forever() + return api diff --git a/detector/app.py b/detector/app.py new file mode 100644 index 0000000..d1d0be5 --- /dev/null +++ b/detector/app.py @@ -0,0 +1,124 @@ +"""应用装配:把规则加载、检测引擎、事件写入、收敛、通知与总线串起来。 + +DetectorApp 同时承载 REST API 所需的各 store 引用。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from .config import Config +from .converger import AlertConverger +from .engine import DetectionEngine +from .models import MetricRule, MetricSample, RuleScope +from .notifier import Notifier +from .rule_loader import RuleLoader +from .storage import ( + AlertStore, + Cache, + EventStore, + InMemoryMessageBus, + MessageBus, + RuleStore, + create_alert_store, + create_bus, + create_cache, + create_event_store, + create_rule_store, +) + + +class DetectorApp: + def __init__(self, config: Optional[Config] = None) -> None: + self.config = config or Config.from_env() + self.rule_store: RuleStore = create_rule_store(self.config) + self.event_store: EventStore = create_event_store(self.config) + self.alert_store: AlertStore = create_alert_store(self.config) + self.cache: Cache = create_cache(self.config) + self.bus: MessageBus = create_bus(self.config) + + self.rule_loader = RuleLoader(self.rule_store, self.cache, self.config.rule_reload_interval) + self.notifier = Notifier.memory() + self.converger = AlertConverger( + alert_store=self.alert_store, + cache=self.cache, + bus=self.bus, + dedup_window=self.config.dedup_window, + aggregate_window=self.config.aggregate_window, + alerts_topic=self.config.alerts_topic, + notifier=self.notifier, + ) + self.engine = DetectionEngine( + rule_loader=self.rule_loader, + evaluation_interval=self.config.evaluation_interval, + recover_duration=self.config.recover_duration, + on_event=self._on_event, + ) + + def _on_event(self, event) -> None: + # 事件先落库,再做收敛(收敛可能因去重被抑制)。 + self.event_store.insert(event) + self.converger.handle_event(event) + + # ------------------------------------------------------------------ # + def start(self) -> None: + self.rule_loader.load() + self.rule_loader.start() + + def stop(self) -> None: + self.rule_loader.stop() + + def seed_demo_rules(self) -> List[MetricRule]: + """写入示例规则,便于本地联调/演示。""" + demos = [ + MetricRule( + rule_id="r-cpu-high", + name="CPU 使用率过高", + metric="cpu_usage", + aggregation="avg", + operator="gt", + threshold=90.0, + for_duration="60s", + severity="critical", + scope=RuleScope(scope_type="all"), + notify_channels=["email", "webhook"], + enabled=True, + ), + MetricRule( + rule_id="r-mem-high", + name="内存使用率过高", + metric="mem_used_percent", + aggregation="avg", + operator="gt", + threshold=85.0, + for_duration="60s", + severity="warning", + scope=RuleScope(scope_type="host_group", host_group="web"), + notify_channels=["email"], + enabled=True, + ), + MetricRule( + rule_id="r-disk-full", + name="磁盘使用率过高", + metric="disk_used_percent", + aggregation="avg", + operator="gt", + threshold=90.0, + for_duration="30s", + severity="critical", + scope=RuleScope(scope_type="all"), + notify_channels=["webhook"], + enabled=True, + ), + ] + created = [] + for rule in demos: + if self.rule_store.get_rule(rule.rule_id) is None: + created.append(self.rule_store.create_rule(rule)) + self.rule_loader.load() + return created + + # ------------------------------------------------------------------ # + def ingest(self, host_id: str, host_group: Optional[str], samples: List[MetricSample]): + """接收一批样本并驱动检测,返回触发的事件。""" + return self.engine.handle_batch(samples, host_id, host_group) diff --git a/detector/config.py b/detector/config.py new file mode 100644 index 0000000..07d7e62 --- /dev/null +++ b/detector/config.py @@ -0,0 +1,70 @@ +"""运行配置。 + +支持从环境变量读取(HMS_ 前缀),未配置时使用安全默认值,保证模块开箱即用。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _env(name: str, default: str) -> str: + return os.environ.get(name, default) + + +@dataclass +class Config: + """检测模块配置。""" + + http_addr: str = "0.0.0.0:8080" + + # 检测引擎 + evaluation_interval: str = "15s" # 评估周期(PENDING 恢复观察默认时长) + recover_duration: str = "15s" # 恢复观察窗口 + + # 告警收敛 + dedup_window: str = "5m" # 去重窗口(同 host+rule 抑制) + aggregate_window: str = "1m" # 聚合窗口 + + # 规则热加载 + rule_reload_interval: str = "10s" + + # 存储驱动:memory / mysql / redis(可选依赖未安装时自动回退 memory) + rule_store_driver: str = "memory" + event_store_driver: str = "memory" + alert_store_driver: str = "memory" + cache_driver: str = "memory" + bus_driver: str = "memory" + + # 中间件连接配置(接入真实中间件时使用) + mysql_dsn: str = "" + redis_url: str = "redis://127.0.0.1:6379/0" + kafka_bootstrap: str = "127.0.0.1:9092" + metrics_topic: str = "metrics.samples" + alerts_topic: str = "alerts.converged" + + # 是否在启动时写入示例规则(便于本地联调/演示) + seed_demo_rules: bool = True + + @classmethod + def from_env(cls) -> "Config": + return cls( + http_addr=_env("HMS_HTTP_ADDR", "0.0.0.0:8080"), + evaluation_interval=_env("HMS_EVALUATION_INTERVAL", "15s"), + recover_duration=_env("HMS_RECOVER_DURATION", "15s"), + dedup_window=_env("HMS_DEDUP_WINDOW", "5m"), + aggregate_window=_env("HMS_AGGREGATE_WINDOW", "1m"), + rule_reload_interval=_env("HMS_RULE_RELOAD_INTERVAL", "10s"), + rule_store_driver=_env("HMS_RULE_STORE_DRIVER", "memory"), + event_store_driver=_env("HMS_EVENT_STORE_DRIVER", "memory"), + alert_store_driver=_env("HMS_ALERT_STORE_DRIVER", "memory"), + cache_driver=_env("HMS_CACHE_DRIVER", "memory"), + bus_driver=_env("HMS_BUS_DRIVER", "memory"), + mysql_dsn=_env("HMS_MYSQL_DSN", ""), + redis_url=_env("HMS_REDIS_URL", "redis://127.0.0.1:6379/0"), + kafka_bootstrap=_env("HMS_KAFKA_BOOTSTRAP", "127.0.0.1:9092"), + metrics_topic=_env("HMS_METRICS_TOPIC", "metrics.samples"), + alerts_topic=_env("HMS_ALERTS_TOPIC", "alerts.converged"), + seed_demo_rules=_env("HMS_SEED_DEMO_RULES", "true").lower() in ("1", "true", "yes"), + ) diff --git a/detector/converger.py b/detector/converger.py new file mode 100644 index 0000000..a6f9f61 --- /dev/null +++ b/detector/converger.py @@ -0,0 +1,101 @@ +"""告警收敛:去重 + 聚合。 + +- 去重:同一 (host_id, rule_id) 在 dedup_window 内不重复产生同类新告警 + (Redis SET key NX EX dedup_window,InMemory 语义等价)。 +- 聚合:同一 aggregate_key(scope/rule/severity + 时间桶)在 aggregate_window + 内多主机同类告警合并为一条,aggregated_count 累加,明细 hosts 列表合并。 +- 收敛后的 alert 写入 AlertStore,并发布到 alerts.converged 总线,由通知分发器消费。 +""" + +from __future__ import annotations + +import uuid +from typing import Callable, Dict, List, Optional + +from .models import Alert, Event, parse_duration +from .storage import AlertStore, Cache, MessageBus + +DEDUP_PREFIX = "hms:dedup:" + + +class AlertConverger: + def __init__( + self, + alert_store: AlertStore, + cache: Cache, + bus: MessageBus, + dedup_window: str = "5m", + aggregate_window: str = "1m", + alerts_topic: str = "alerts.converged", + host_group_resolver: Optional[Callable[[str], str]] = None, + notifier: Optional[object] = None, + ) -> None: + self.alert_store = alert_store + self.cache = cache + self.bus = bus + self.dedup_window = parse_duration(dedup_window) or 300 + self.aggregate_window = parse_duration(aggregate_window) or 60 + self.alerts_topic = alerts_topic + self.host_group_resolver = host_group_resolver or (lambda host_id: "all") + self.notifier = notifier + + def handle_event(self, event: Event) -> Optional[Alert]: + if event.status == "firing": + return self._handle_firing(event) + if event.status == "resolved": + return self._handle_resolved(event) + return None + + def _handle_firing(self, event: Event) -> Optional[Alert]: + dedup_key = f"{event.host_id}:{event.rule_id}" + if not self.cache.set_nx(DEDUP_PREFIX + dedup_key, "1", ttl=self.dedup_window): + # 去重窗口内重复事件,抑制新告警 + return None + + bucket = int(event.fired_at // self.aggregate_window) + group = self.host_group_resolver(event.host_id) + aggregate_key = f"{group}:{event.rule_id}:{event.severity}:{bucket}" + + alert = Alert( + alert_id=f"a-{uuid.uuid4().hex[:16]}", + dedup_key=dedup_key, + aggregate_key=aggregate_key, + severity=event.severity, + status="firing", + title=self._title(event), + detail={ + "hosts": [event.host_id], + "current": event.agg_value, + "threshold": event.threshold, + "metric": event.metric, + "rule_id": event.rule_id, + }, + count=1, + first_at=event.fired_at, + last_at=event.fired_at, + ) + stored = self.alert_store.upsert_firing(alert) + self._publish(stored) + if self.notifier is not None and hasattr(self.notifier, "notify"): + self.notifier.notify(stored, getattr(stored, "notify_channels", None) or self._channels(event)) + return stored + + def _handle_resolved(self, event: Event) -> Optional[Alert]: + dedup_key = f"{event.host_id}:{event.rule_id}" + # 恢复事件允许后续再次触发去重键失效 + self.cache.delete(DEDUP_PREFIX + dedup_key) + alert = self.alert_store.resolve_by_dedup_key(dedup_key, event.fired_at) + if alert is not None: + self._publish(alert) + return alert + + def _publish(self, alert: Alert) -> None: + self.bus.publish(self.alerts_topic, alert.aggregate_key, alert.to_dict()) + + @staticmethod + def _title(event: Event) -> str: + return f"{event.metric} {event.operator} {event.threshold} ({event.status})" + + @staticmethod + def _channels(event: Event) -> List[str]: + return [] diff --git a/detector/engine.py b/detector/engine.py new file mode 100644 index 0000000..75c98f8 --- /dev/null +++ b/detector/engine.py @@ -0,0 +1,252 @@ +"""检测引擎:滑动窗口聚合 + 状态机(OK → FIRING → PENDING → OK)。 + +对每个 (host_id, rule_id) 维护: +- 滑动窗口(保存 for_duration 窗口内的样本,用于计算聚合值); +- 状态机(追踪条件持续满足/不满足时间,抑制瞬时毛刺)。 + +状态跃迁与 architecture.md 5.2.3 对齐: +- OK → FIRING:条件持续满足 ForDuration,触发 firing 事件; +- FIRING → PENDING:最近一次评估不再满足,进入恢复观察; +- PENDING → OK:观察期(recover_duration)内持续不满足,触发 resolved 事件; +- PENDING → FIRING:观察期内再次满足,回到 FIRING(不重复触发事件)。 +""" + +from __future__ import annotations + +import threading +import uuid +from collections import deque +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Tuple + +from .models import Event, MetricRule, MetricSample, parse_duration +from .rule_loader import RuleLoader + + +def aggregate_value(values: List[float], aggregation: str) -> float: + if not values: + return 0.0 + agg = (aggregation or "avg").lower() + if agg == "avg": + return sum(values) / len(values) + if agg == "min": + return min(values) + if agg == "max": + return max(values) + if agg == "sum": + return sum(values) + if agg == "last": + return values[-1] + raise ValueError(f"unknown aggregation: {aggregation}") + + +def compare(value: float, operator: str, threshold: float, threshold2: Optional[float] = None) -> bool: + op = (operator or "gt").lower() + eps = 1e-9 + if op == "gt": + return value > threshold + if op == "gte": + return value >= threshold + if op == "lt": + return value < threshold + if op == "lte": + return value <= threshold + if op == "eq": + return abs(value - threshold) <= eps + if op == "neq": + return abs(value - threshold) > eps + if op == "between": + lo = threshold + hi = threshold2 if threshold2 is not None else threshold + if lo > hi: + lo, hi = hi, lo + return lo <= value <= hi + raise ValueError(f"unknown operator: {operator}") + + +@dataclass +class _State: + status: str = "ok" # ok | firing | pending + satisfied_since: Optional[float] = None + not_satisfied_since: Optional[float] = None + last_value: float = 0.0 + last_eval: float = 0.0 + + +class SlidingWindow: + """按时间衰减的样本窗口。""" + + def __init__(self, max_age: float) -> None: + self.max_age = max_age + self.samples: deque = deque() # (timestamp, value) + + def add(self, timestamp: float, value: float) -> None: + self.samples.append((timestamp, value)) + self._prune(timestamp) + + def _prune(self, now: float) -> None: + while self.samples and (now - self.samples[0][0]) > self.max_age: + self.samples.popleft() + + def values(self) -> List[float]: + return [v for _, v in self.samples] + + def clear(self) -> None: + self.samples.clear() + + +class DetectionEngine: + def __init__( + self, + rule_loader: RuleLoader, + evaluation_interval: str = "15s", + recover_duration: str = "15s", + on_event: Optional[Callable[[Event], None]] = None, + ) -> None: + self.rule_loader = rule_loader + self.evaluation_interval = parse_duration(evaluation_interval) or 15 + self.recover_duration = parse_duration(recover_duration) or self.evaluation_interval + self.on_event = on_event + self._windows: Dict[Tuple[str, str], SlidingWindow] = {} + self._states: Dict[Tuple[str, str], _State] = {} + self._lock = threading.RLock() + + # ------------------------------------------------------------------ # + # 入口 + # ------------------------------------------------------------------ # + def handle( + self, + sample: MetricSample, + host_id: str, + host_group: Optional[str] = None, + now: Optional[float] = None, + ) -> List[Event]: + """处理单条样本,返回本次触发的所有事件。""" + now = sample.timestamp if now is None else now + events: List[Event] = [] + rules = self.rule_loader.match(sample, host_id, host_group) + with self._lock: + for rule in rules: + event = self._evaluate(host_id, rule, sample, now) + if event is not None: + events.append(event) + if self.on_event: + for event in events: + self.on_event(event) + return events + + def handle_batch( + self, + samples: List[MetricSample], + host_id: str, + host_group: Optional[str] = None, + ) -> List[Event]: + events: List[Event] = [] + for sample in samples: + events.extend(self.handle(sample, host_id, host_group)) + return events + + # ------------------------------------------------------------------ # + # 单规则评估 + # ------------------------------------------------------------------ # + def _evaluate( + self, + host_id: str, + rule: MetricRule, + sample: MetricSample, + now: float, + ) -> Optional[Event]: + key = (host_id, rule.rule_id) + for_duration = parse_duration(rule.for_duration) or 60 + + window = self._windows.get(key) + if window is None or window.max_age != for_duration: + window = SlidingWindow(for_duration) + self._windows[key] = window + window.add(sample.timestamp, sample.value) + + state = self._states.get(key) + if state is None: + state = _State() + self._states[key] = state + + value = aggregate_value(window.values(), rule.aggregation) + satisfied = compare(value, rule.operator, rule.threshold, rule.threshold2) + state.last_value = value + state.last_eval = now + + event: Optional[Event] = None + if state.status == "ok": + if satisfied: + if state.satisfied_since is None: + state.satisfied_since = now + if now - state.satisfied_since >= for_duration: + state.status = "firing" + state.not_satisfied_since = None + event = self._make_event(host_id, rule, sample, value, "firing", now) + else: + state.satisfied_since = None + elif state.status == "firing": + if not satisfied: + state.status = "pending" + state.not_satisfied_since = now + elif state.status == "pending": + if satisfied: + state.status = "firing" + state.not_satisfied_since = None + # 回到 FIRING 不重复告警(仅更新计数),与设计一致 + else: + if state.not_satisfied_since is None: + state.not_satisfied_since = now + if now - state.not_satisfied_since >= self.recover_duration: + state.status = "ok" + state.satisfied_since = None + event = self._make_event(host_id, rule, sample, value, "resolved", now) + + self._states[key] = state + return event + + def _make_event( + self, + host_id: str, + rule: MetricRule, + sample: MetricSample, + value: float, + status: str, + now: float, + ) -> Event: + return Event( + event_id=f"e-{uuid.uuid4().hex[:16]}", + host_id=host_id, + rule_id=rule.rule_id, + metric=rule.metric, + agg_value=value, + threshold=rule.threshold, + operator=rule.operator, + status=status, + severity=rule.severity, + fired_at=now, + labels=dict(sample.labels), + ) + + # ------------------------------------------------------------------ # + # 状态查询(测试/可观测) + # ------------------------------------------------------------------ # + def state_of(self, host_id: str, rule_id: str) -> Optional[Dict[str, object]]: + key = (host_id, rule_id) + with self._lock: + state = self._states.get(key) + if state is None: + return None + return { + "status": state.status, + "satisfied_since": state.satisfied_since, + "not_satisfied_since": state.not_satisfied_since, + "last_value": state.last_value, + "last_eval": state.last_eval, + } + + def reset(self) -> None: + with self._lock: + self._windows.clear() + self._states.clear() diff --git a/detector/models.py b/detector/models.py new file mode 100644 index 0000000..fca3db2 --- /dev/null +++ b/detector/models.py @@ -0,0 +1,238 @@ +"""领域模型:指标样本、阈值规则、作用范围、事件与告警。 + +字段命名与 docs/01-design 的 architecture.md / database-design.md / api-design.md 对齐; +对外 JSON 使用 camelCase,内部 Python 使用 snake_case。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +def parse_duration(value: Optional[str]) -> int: + """将 '60s' / '5m' / '1h' / '1h30m' 等时长解析为秒。 + + 纯数字按秒处理;空值返回 0。 + """ + if value is None or value == "": + return 0 + text = str(value).strip().lower() + if text.isdigit(): + return int(text) + + total = 0.0 + num = "" + for ch in text: + if ch.isdigit() or ch == ".": + num += ch + elif ch in "smhd": + if not num: + raise ValueError(f"invalid duration: {value!r}") + n = float(num) + if ch == "s": + total += n + elif ch == "m": + total += n * 60 + elif ch == "h": + total += n * 3600 + elif ch == "d": + total += n * 86400 + num = "" + else: + raise ValueError(f"invalid duration: {value!r}") + if num: + total += float(num) + return int(total) + + +@dataclass +class MetricSample: + """规范化后的单条指标样本(对应 metrics.samples 消息)。""" + + name: str + value: float + timestamp: float + labels: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MetricSample": + return cls( + name=str(data["name"]), + value=float(data["value"]), + timestamp=float(data.get("timestamp", 0)), + labels=dict(data.get("labels") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "value": self.value, + "timestamp": self.timestamp, + "labels": self.labels, + } + + +@dataclass +class RuleScope: + """规则作用范围(对应 rule_scope 表)。""" + + scope_type: str = "all" # all | host_ids | host_group + host_ids: List[str] = field(default_factory=list) + host_group: str = "" + service: str = "" + + def matches(self, host_id: str, host_group: Optional[str], service: Optional[str]) -> bool: + if self.scope_type == "all": + return True + if self.scope_type == "host_ids": + return host_id in self.host_ids + if self.scope_type == "host_group": + if self.host_group and host_group and self.host_group == host_group: + return True + return False + return False + + def to_dict(self) -> Dict[str, Any]: + return { + "scope_type": self.scope_type, + "host_ids": list(self.host_ids), + "host_group": self.host_group, + "service": self.service, + } + + @classmethod + def from_dict(cls, data: Optional[Dict[str, Any]]) -> "RuleScope": + if not data: + return cls() + return cls( + scope_type=str(data.get("scope_type") or "all"), + host_ids=[str(x) for x in (data.get("host_ids") or [])], + host_group=str(data.get("host_group") or ""), + service=str(data.get("service") or ""), + ) + + +@dataclass +class MetricRule: + """阈值规则(对应 metric_rule 表)。""" + + rule_id: str + name: str + metric: str + aggregation: str = "avg" # avg | min | max | sum | last + operator: str = "gt" # gt | gte | lt | lte | eq | neq | between + threshold: float = 0.0 + threshold2: Optional[float] = None + for_duration: str = "60s" + severity: str = "warning" + scope: RuleScope = field(default_factory=RuleScope) + labels: Dict[str, str] = field(default_factory=dict) + notify_channels: List[str] = field(default_factory=list) + enabled: bool = True + version: int = 0 + + def to_dict(self) -> Dict[str, Any]: + return { + "rule_id": self.rule_id, + "name": self.name, + "metric": self.metric, + "aggregation": self.aggregation, + "operator": self.operator, + "threshold": self.threshold, + "threshold2": self.threshold2, + "for_duration": self.for_duration, + "severity": self.severity, + "scope": self.scope.to_dict(), + "labels": dict(self.labels), + "notify_channels": list(self.notify_channels), + "enabled": self.enabled, + "version": self.version, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MetricRule": + return cls( + rule_id=str(data["rule_id"]), + name=str(data.get("name") or ""), + metric=str(data.get("metric") or ""), + aggregation=str(data.get("aggregation") or "avg"), + operator=str(data.get("operator") or "gt"), + threshold=float(data.get("threshold") or 0.0), + threshold2=float(data["threshold2"]) if data.get("threshold2") is not None else None, + for_duration=str(data.get("for_duration") or "60s"), + severity=str(data.get("severity") or "warning"), + scope=RuleScope.from_dict(data.get("scope")), + labels={str(k): str(v) for k, v in (data.get("labels") or {}).items()}, + notify_channels=[str(x) for x in (data.get("notify_channels") or [])], + enabled=bool(data.get("enabled", True)), + version=int(data.get("version") or 0), + ) + + +@dataclass +class Event: + """检测事件(对应 event 表)。""" + + event_id: str + host_id: str + rule_id: str + metric: str + agg_value: float + threshold: float + operator: str + status: str # firing | resolved + severity: str + fired_at: float + labels: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "event_id": self.event_id, + "host_id": self.host_id, + "rule_id": self.rule_id, + "metric": self.metric, + "agg_value": self.agg_value, + "threshold": self.threshold, + "operator": self.operator, + "status": self.status, + "severity": self.severity, + "fired_at": self.fired_at, + "labels": self.labels, + } + + +@dataclass +class Alert: + """收敛后告警(对应 alert 表)。""" + + alert_id: str + dedup_key: str + aggregate_key: str + severity: str + status: str # firing | resolved + title: str = "" + detail: Dict[str, Any] = field(default_factory=dict) + count: int = 1 + first_at: float = 0.0 + last_at: float = 0.0 + ack_status: str = "open" + ack_by: Optional[str] = None + ack_at: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "alert_id": self.alert_id, + "dedup_key": self.dedup_key, + "aggregate_key": self.aggregate_key, + "severity": self.severity, + "status": self.status, + "title": self.title, + "detail": dict(self.detail), + "count": self.count, + "first_at": self.first_at, + "last_at": self.last_at, + "ack_status": self.ack_status, + "ack_by": self.ack_by, + "ack_at": self.ack_at, + } diff --git a/detector/notifier.py b/detector/notifier.py new file mode 100644 index 0000000..f00c094 --- /dev/null +++ b/detector/notifier.py @@ -0,0 +1,86 @@ +"""通知渠道封装。 + +对 email / webhook / dingtalk / wechat 等渠道做统一封装;真实发送通过 +urllib 完成(或按需扩展 SDK),默认 InMemorySender 记录发送历史,便于测试。 +""" + +from __future__ import annotations + +import json +import threading +import urllib.request +from typing import Any, Dict, List, Optional + +from .models import Alert + + +class ChannelSender: + """通知渠道发送器接口。""" + + def send(self, alert: Alert, target: Optional[str] = None) -> bool: + raise NotImplementedError + + +class InMemorySender(ChannelSender): + """记录发送历史,不真正外呼。""" + + def __init__(self, channel: str) -> None: + self.channel = channel + self.sent: List[Dict[str, Any]] = [] + self._lock = threading.Lock() + + def send(self, alert: Alert, target: Optional[str] = None) -> bool: + with self._lock: + self.sent.append({"channel": self.channel, "target": target, "alert_id": alert.alert_id}) + return True + + +class WebhookSender(ChannelSender): + """通用 Webhook(POST JSON)。""" + + def __init__(self, url: str) -> None: + self.url = url + + def send(self, alert: Alert, target: Optional[str] = None) -> bool: + payload = json.dumps(alert.to_dict()).encode("utf-8") + req = urllib.request.Request( + target or self.url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 + return 200 <= resp.status < 300 + + +class Notifier: + """通知分发器:按告警渠道列表投递。""" + + def __init__(self, senders: Optional[Dict[str, ChannelSender]] = None) -> None: + self.senders = senders or {} + self.sent: List[Dict[str, Any]] = [] + self._lock = threading.Lock() + + def register(self, channel: str, sender: ChannelSender) -> None: + self.senders[channel] = sender + + def notify(self, alert: Alert, channels: Optional[List[str]] = None) -> List[str]: + """发送告警,返回成功渠道列表。""" + channels = channels or list(self.senders.keys()) + delivered: List[str] = [] + for channel in channels: + sender = self.senders.get(channel) + if sender is None: + continue + ok = sender.send(alert) + with self._lock: + self.sent.append({"channel": channel, "alert_id": alert.alert_id, "ok": ok}) + if ok: + delivered.append(channel) + return delivered + + @classmethod + def memory(cls, channels: Optional[List[str]] = None) -> "Notifier": + """构造内存版 Notifier,便于本地运行/测试。""" + channels = channels or ["email", "webhook", "dingtalk", "wechat"] + return cls({c: InMemorySender(c) for c in channels}) diff --git a/detector/rule_loader.py b/detector/rule_loader.py new file mode 100644 index 0000000..e945013 --- /dev/null +++ b/detector/rule_loader.py @@ -0,0 +1,101 @@ +"""规则加载与缓存。 + +- 从 RuleStore(MySQL)全量加载规则; +- 写入 Cache(Redis)作为热缓存(hms:rule:cache:{rule_id}、hms:rule:version); +- 后台线程按 reload_interval 轮询版本号,版本变化则热加载; +- 提供 match():按 metric / scope(host_id、host_group、service) 匹配启用的规则。 +""" + +from __future__ import annotations + +import json +import threading +import time +from typing import Dict, List, Optional + +from .models import MetricRule, MetricSample, parse_duration +from .storage import Cache, RuleStore + +RULE_CACHE_PREFIX = "hms:rule:cache:" +RULE_VERSION_KEY = "hms:rule:version" + + +class RuleLoader: + def __init__(self, store: RuleStore, cache: Cache, reload_interval: str = "10s") -> None: + self._store = store + self._cache = cache + self._reload_interval = parse_duration(reload_interval) or 10 + self._rules: Dict[str, MetricRule] = {} + self._version = 0 + self._lock = threading.RLock() + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ # + # 加载与热更新 + # ------------------------------------------------------------------ # + def load(self) -> int: + """全量加载规则,返回当前版本号。""" + rules = self._store.list_rules() + new_map = {r.rule_id: r for r in rules} + version = self._store.get_version() + with self._lock: + self._rules = new_map + self._version = version + for rule in rules: + self._cache.set(RULE_CACHE_PREFIX + rule.rule_id, json.dumps(rule.to_dict()), ttl=600) + self._cache.set(RULE_VERSION_KEY, str(version)) + return version + + def start(self) -> None: + """启动热加载后台线程。""" + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread(target=self._run, name="rule-hot-reload", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + if self._thread: + self._thread.join(timeout=2) + + def _run(self) -> None: + while not self._stop_event.is_set(): + try: + current = self._store.get_version() + if current != self._version: + self.load() + except Exception: # pragma: no cover - 日志由上层处理 + pass + self._stop_event.wait(self._reload_interval) + + # ------------------------------------------------------------------ # + # 查询与匹配 + # ------------------------------------------------------------------ # + def get_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 match( + self, + sample: MetricSample, + host_id: str, + host_group: Optional[str] = None, + service: Optional[str] = None, + ) -> List[MetricRule]: + """返回匹配该样本的启用规则。""" + matched: List[MetricRule] = [] + service = service or sample.labels.get("service") + for rule in self.get_rules(): + if not rule.enabled: + continue + if rule.metric != sample.name: + continue + if not rule.scope.matches(host_id, host_group, service): + continue + matched.append(rule) + return matched diff --git a/detector/storage.py b/detector/storage.py new file mode 100644 index 0000000..f2fe162 --- /dev/null +++ b/detector/storage.py @@ -0,0 +1,637 @@ +"""存储抽象层。 + +- RuleStore / EventStore / AlertStore:MySQL 语义(准),默认 InMemory 实现; +- Cache:Redis 语义(get/set NX/INCR/EXPIRE),默认 InMemory 实现; +- MessageBus:Kafka 语义(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 Truth:MySQL 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9121fa1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "threshold-event-detector" +version = "0.1.0" +description = "HMS metric threshold event detector" +requires-python = ">=3.10" +license = { text = "Proprietary" } + +[project.optional-dependencies] +mysql = ["pymysql>=1.1"] +redis = ["redis>=4.0"] +kafka = ["kafka-python>=2.0"] + +[tool.setuptools.packages.find] +include = ["detector*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3dc8ca4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# 核心模块仅依赖 Python 标准库,以下为接入真实中间件时的可选依赖: +# pymysql>=1.1 # HMS_RULE_STORE_DRIVER=mysql / EVENT/ALERT 同理 +# redis>=4.0 # HMS_CACHE_DRIVER=redis +# kafka-python>=2.0 # HMS_BUS_DRIVER=kafka diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_api.cpython-310.pyc b/tests/__pycache__/test_api.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6c02b516f58f1b1cfa0806758e6e05f94316efb GIT binary patch literal 4712 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_notifier.cpython-310.pyc b/tests/__pycache__/test_notifier.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d42212f486eb373e3a209f0328c64dcfe5db9cc GIT binary patch literal 1339 zcmZuwOOG2x5bmCrXT9Ftuu6DD5DBCp&B1GyBZ|UC5g-ouvJnSgMiW=9cj9@N?l!@e zS2-k7xU9qv$ia@dYC}Usp_xl`?}uPXbT#@efr?u zu`k5$ytp_5TI|u(eKH_G7Gl6}uaKoT@FaixgA=nq;L zLp1i(brj3Bm7oZ@?`G0^ML9)3kv6I1_{rK?~yJ@W@-prN8h-0@j|&m3QPX#4=d;ClS;RUYl?ssdNeLm1_`TSnt~q z%PcQ!>ptE+tn2aI&$_$w)?s(|6V=_da%|(F%1u7Z3L72dDz8S*LK|n?m^25w1_;wh zI>wpZ$VMZDqs*XN#u~px3Z2CQCygffijt_7{B6zUSc+GpQP34>o?Wdn_sNdkTs2R3&95g9gguozR3)7c2mgkm241~NV9P4WqO zD(u~_)mTrmA?n^oy}y3_;m_wk{qghj7e79J`Q(=u-#>nN{IsWVQtO^MRHzT@0=iU8 zWx9X{ho)9NL)4L*Y!OAi1wnV< zTo9kr!jM)UvJ^-Z=VEYD5X%!eK)fQkG~^TYA*{V4piPtZ`GzeP72%^)}>-_HZqwSp-Cde_!`#Q4O@5A6dg~XuCVVdG9-Yv zT^P|vw@6)O2lLaI_tH;mkF zohoF9PQyYsO?l&N_f#xR$DHV6F6DMc8t1drhh%KPsb7c3#y6TWZZZRU(}C)Em*ln| JZ*^qw&%dB?X;1(F literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_rule_loader.cpython-310.pyc b/tests/__pycache__/test_rule_loader.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38f22515aabe8a7ab75ca58d877126cdd715b826 GIT binary patch literal 2407 zcmah~OK%)S5bnp!?Ck7%{YV5Tpa>8L23W5H5(gAHk-|er#0N|+BSoX}bhD4lW79pG z$d)4xiIiMcf)f`GYs4jY4u~B1A1!f$1pEWI=&?Flyr5c3O^QNKJr)&&otd6*9B*X>nENrP{rcqyuSI=R>=rDwRyp{Nl%8a2c9@ z2ZSUM6(nMU3U-e~TyWvQzf-#(xuPL_7<;0*O~Sx*zL2^)K0l>kV*X*@g?0s+eiKAR z?vi~H(~8-DUOD?@M4@p!ZOUQI6$I?^EyFKtXt>+5k{R4A9u}2Uw~{RM6vT)aSL0Qb zXZtDMRN+y^Lv5)ZQsZcueff~66`203527b}TPKl$$G#}Dp5nSS3>a$M?OHBeFy%1m_SEm>4uAuE}}(Y*@p4a*>71@Oo9 zx$EOya7|X%#!Wa;qkBH}=}UC)>`9>3tsPGW_DHF}9 zcLzaZGrms`Ceuv@wsIkw)671~23;;ay#j;qrNA~%LHh_rR4`bM=kVg->dKpi0 zfddoZe>KbjdPs(+e9*lRQhUY;IFZ#H+BP)32BHtwyzT;YbVR;kH`#X04cL1QR>5Vq zzG0}cgsH_U%$wk1Hq=#G=Hty*&=lHTBYW>Z7f;IsOVy+hah_D^VEtlNrb(tZp))sO znAi!M3XLcXxN3ngtyIaRl9owo31=KMrOn>C{hzB zhJ++DegKiENr$ur${vw?>eO_eHIR%m|G@4aHJ@oOSgL}#Eg-RXMAU{Q>br*LGB+zA zfT&W$z^@dN_wX8c1kho)Et?bqpw5FhM9;&P#a_LIvsepFvoGuP);3Nm#1)L4>D1BK zD(irl7HZy-N)OAz_(wgS$wDF4&&TAPjme+{^L2sS*`05C4b(MOt$?mkk9DZ?_X|!y z&(Xc`v~Zki<3v=DMOcY!RJNeASXicxEhZGAGcP85=GbE5LNTd}jx8oL4>0Et8z?~s zY@s{^7WMaxBZm5hSw(pQ_mBUDxoOBE*~x&auYx-^!4+Oxy#XTh>-50Ps1e7EsH1{c zv7u3Kqb9d$pt?A_jsjs@$ldc6WpKzIRu}35Fn^XPxKCrrU7>9Xv`37)r(yh`7Pi6$ zL_5ZM7RM%tzVJ@fkLAkI627KT(d~O$Kd?UH pa3z6~gZ`*XXv)FkPxIUO7wd~}VjfxY`s6HY(-peL{cam5{0}J0Whnpv literal 0 HcmV?d00001 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cc2da54 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,120 @@ +import json +import threading +import unittest +import urllib.request +from urllib.error import HTTPError + +from detector.api import APIServer +from detector.app import DetectorApp +from detector.config import Config + + +class TestAPI(unittest.TestCase): + def setUp(self): + config = Config.from_env() + config.http_addr = "127.0.0.1:0" + self.app = DetectorApp(config) + self.app.seed_demo_rules() + self.app.start() + self.api = APIServer(self.app, "127.0.0.1:0") + self.port = self.api.server.server_address[1] + self.base = f"http://127.0.0.1:{self.port}" + self.thread = threading.Thread(target=self.api.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.api.shutdown() + self.api.server.server_close() + self.app.stop() + + def _request(self, method, path, body=None): + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request( + self.base + path, + data=data, + method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + return exc.code, json.loads(exc.read().decode("utf-8")) + + def test_healthz(self): + status, body = self._request("GET", "/healthz") + self.assertEqual(status, 200) + self.assertEqual(body["data"]["status"], "ok") + + def test_list_rules(self): + status, body = self._request("GET", "/api/v1/rules") + self.assertEqual(status, 200) + self.assertGreaterEqual(body["data"]["total"], 3) + + def test_create_and_get_rule(self): + payload = { + "rule_id": "r-test", + "name": "load high", + "metric": "load_1m", + "aggregation": "avg", + "operator": "gt", + "threshold": 5.0, + "for_duration": "60s", + "severity": "warning", + "scope": {"scope_type": "all"}, + "notify_channels": ["email"], + "enabled": True, + } + status, body = self._request("POST", "/api/v1/rules", payload) + self.assertEqual(status, 200) + self.assertEqual(body["data"]["rule_id"], "r-test") + + status, body = self._request("GET", "/api/v1/rules/r-test") + self.assertEqual(status, 200) + self.assertEqual(body["data"]["threshold"], 5.0) + + def test_ingest_triggers_firing_event(self): + samples = [ + {"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15), "labels": {"service": "web"}} + for i in range(5) + ] + status, body = self._request( + "POST", "/api/v1/ingest", + {"host_id": "h-001", "host_group": "web", "samples": samples}, + ) + self.assertEqual(status, 200) + events = body["data"]["events"] + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["status"], "firing") + + # 事件已落库 + status, body = self._request("GET", "/api/v1/events?host_id=h-001") + self.assertEqual(status, 200) + self.assertEqual(body["data"]["total"], 1) + + # 告警已生成 + status, body = self._request("GET", "/api/v1/alerts") + self.assertEqual(status, 200) + self.assertEqual(body["data"]["total"], 1) + alert_id = body["data"]["items"][0]["alert_id"] + + # 确认告警 + status, body = self._request("POST", f"/api/v1/alerts/{alert_id}/ack", {"ack_by": "tester"}) + self.assertEqual(status, 200) + self.assertEqual(body["data"]["ack_status"], "acked") + + def test_batch_ack(self): + self._request( + "POST", "/api/v1/ingest", + {"host_id": "h-002", "host_group": "web", + "samples": [{"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15)} for i in range(5)]}, + ) + status, body = self._request("GET", "/api/v1/alerts") + alert_ids = [a["alert_id"] for a in body["data"]["items"]] + status, body = self._request("POST", "/api/v1/alerts/batch-ack", {"alert_ids": alert_ids}) + self.assertEqual(status, 200) + self.assertEqual(body["data"]["acked"], len(alert_ids)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_converger.py b/tests/test_converger.py new file mode 100644 index 0000000..0f4b181 --- /dev/null +++ b/tests/test_converger.py @@ -0,0 +1,71 @@ +import unittest + +from detector.converger import AlertConverger +from detector.models import Event +from detector.storage import InMemoryAlertStore, InMemoryCache, InMemoryMessageBus + + +def make_event(status="firing", host_id="h-1", rule_id="r-1", fired_at=100.0): + return Event( + event_id=f"e-{status}-{host_id}", + host_id=host_id, + rule_id=rule_id, + metric="cpu_usage", + agg_value=95.0, + threshold=90.0, + operator="gt", + status=status, + severity="critical", + fired_at=fired_at, + ) + + +class TestAlertConverger(unittest.TestCase): + def setUp(self): + self.store = InMemoryAlertStore() + self.cache = InMemoryCache() + self.bus = InMemoryMessageBus() + self.converger = AlertConverger( + self.store, self.cache, self.bus, dedup_window="5m", aggregate_window="1m", + host_group_resolver=lambda h: "web", + ) + + def test_dedup_suppresses_duplicate(self): + first = self.converger.handle_event(make_event()) + self.assertIsNotNone(first) + second = self.converger.handle_event(make_event()) + self.assertIsNone(second) + self.assertEqual(len(self.store.list_alerts()[1]), 1) + + def test_resolve_clears_dedup(self): + self.converger.handle_event(make_event(fired_at=100.0)) + resolved = self.converger.handle_event(make_event(status="resolved", fired_at=200.0)) + self.assertIsNotNone(resolved) + self.assertEqual(resolved.status, "resolved") + + # 去重键已清除,恢复后再次 firing 可以产生新告警 + again = self.converger.handle_event(make_event(fired_at=300.0)) + self.assertIsNotNone(again) + + def test_aggregate_same_bucket(self): + a1 = self.converger.handle_event(make_event(host_id="h-1", fired_at=100.0)) + a2 = self.converger.handle_event(make_event(host_id="h-2", fired_at=110.0)) + self.assertIsNotNone(a1) + self.assertIsNotNone(a2) + alerts = self.store.list_alerts()[1] + # 同一 aggregate_key(同 bucket/severity/rule/group)聚合为一条 + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].count, 2) + self.assertIn("h-1", alerts[0].detail["hosts"]) + self.assertIn("h-2", alerts[0].detail["hosts"]) + + def test_publish_to_bus(self): + self.converger.handle_event(make_event()) + self.assertEqual(len(self.bus.messages), 1) + topic, key, value = self.bus.messages[0] + self.assertEqual(topic, "alerts.converged") + self.assertIn("alert_id", value) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..a2469e5 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,107 @@ +import unittest + +from detector.engine import DetectionEngine, aggregate_value, compare +from detector.models import MetricRule, MetricSample, RuleScope +from detector.rule_loader import RuleLoader +from detector.storage import InMemoryCache, InMemoryRuleStore + + +def make_rule(rule_id="r-cpu", metric="cpu_usage", operator="gt", threshold=90.0, for_duration="60s"): + return MetricRule( + rule_id=rule_id, + name="cpu high", + metric=metric, + aggregation="avg", + operator=operator, + threshold=threshold, + for_duration=for_duration, + severity="critical", + scope=RuleScope(scope_type="all"), + ) + + +def make_engine(rule): + store = InMemoryRuleStore() + store.create_rule(rule) + loader = RuleLoader(store, InMemoryCache()) + loader.load() + return DetectionEngine(loader, evaluation_interval="15s", recover_duration="15s") + + +class TestAggregateCompare(unittest.TestCase): + def test_aggregate(self): + self.assertEqual(aggregate_value([1, 2, 3], "avg"), 2) + self.assertEqual(aggregate_value([1, 2, 3], "min"), 1) + self.assertEqual(aggregate_value([1, 2, 3], "max"), 3) + self.assertEqual(aggregate_value([1, 2, 3], "sum"), 6) + self.assertEqual(aggregate_value([1, 2, 3], "last"), 3) + + def test_compare(self): + self.assertTrue(compare(91, "gt", 90)) + self.assertFalse(compare(90, "gt", 90)) + self.assertTrue(compare(90, "gte", 90)) + self.assertTrue(compare(89, "lt", 90)) + self.assertTrue(compare(95, "between", 90, 100)) + self.assertFalse(compare(105, "between", 90, 100)) + + +class TestStateMachine(unittest.TestCase): + def test_firing_after_sustained(self): + rule = make_rule(for_duration="60s") + engine = make_engine(rule) + events = [] + for i in range(5): + ts = float(i * 15) + sample = MetricSample(name="cpu_usage", value=95.0, timestamp=ts) + events.extend(engine.handle(sample, "h-1")) + + self.assertEqual(len(events), 1) + self.assertEqual(events[0].status, "firing") + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing") + + def test_no_fire_before_for_duration(self): + rule = make_rule(for_duration="60s") + engine = make_engine(rule) + events = [] + for i in range(3): + ts = float(i * 15) + events.extend(engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=ts), "h-1")) + self.assertEqual(events, []) + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "ok") + + def test_resolved(self): + rule = make_rule(for_duration="60s") + engine = make_engine(rule) + events = [] + for i in range(5): + events.extend(engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=float(i * 15)), "h-1")) + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing") + + # 一次回落进入 pending + events.clear() + events.extend(engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=75.0), "h-1")) + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "pending") + + # 观察期后仍回落 -> resolved + events.clear() + events.extend(engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=90.0), "h-1")) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].status, "resolved") + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "ok") + + def test_pending_back_to_firing_no_duplicate_event(self): + rule = make_rule(for_duration="60s") + engine = make_engine(rule) + for i in range(5): + engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=float(i * 15)), "h-1") + engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=75.0), "h-1") + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "pending") + + # 观察期内再次满足(低值样本滑出窗口后聚合值回升)-> 回到 firing,不重复触发 + events = engine.handle(MetricSample(name="cpu_usage", value=96.0, timestamp=150.0), "h-1") + self.assertEqual(events, []) + self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..1258e6c --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,61 @@ +import unittest + +from detector.models import MetricRule, RuleScope, parse_duration + + +class TestParseDuration(unittest.TestCase): + def test_units(self): + self.assertEqual(parse_duration("60s"), 60) + self.assertEqual(parse_duration("5m"), 300) + self.assertEqual(parse_duration("1h"), 3600) + self.assertEqual(parse_duration("1h30m"), 5400) + self.assertEqual(parse_duration("90"), 90) + self.assertEqual(parse_duration(""), 0) + self.assertEqual(parse_duration(None), 0) + + def test_invalid(self): + with self.assertRaises(ValueError): + parse_duration("abc") + + +class TestRuleScope(unittest.TestCase): + def test_all(self): + scope = RuleScope(scope_type="all") + self.assertTrue(scope.matches("h-1", "web", "svc")) + self.assertTrue(scope.matches("h-2", None, None)) + + def test_host_ids(self): + scope = RuleScope(scope_type="host_ids", host_ids=["h-1", "h-2"]) + self.assertTrue(scope.matches("h-1", None, None)) + self.assertFalse(scope.matches("h-3", None, None)) + + def test_host_group(self): + scope = RuleScope(scope_type="host_group", host_group="web") + self.assertTrue(scope.matches("h-1", "web", None)) + self.assertFalse(scope.matches("h-1", "db", None)) + self.assertFalse(scope.matches("h-1", None, None)) + + +class TestMetricRuleSerialization(unittest.TestCase): + def test_round_trip(self): + rule = MetricRule( + rule_id="r-1", + name="cpu", + metric="cpu_usage", + operator="gt", + threshold=90, + scope=RuleScope(scope_type="host_group", host_group="web"), + labels={"service": "web"}, + notify_channels=["email"], + ) + data = rule.to_dict() + restored = MetricRule.from_dict(data) + self.assertEqual(restored.rule_id, "r-1") + self.assertEqual(restored.threshold, 90) + self.assertEqual(restored.scope.host_group, "web") + self.assertEqual(restored.labels, {"service": "web"}) + self.assertEqual(restored.notify_channels, ["email"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_notifier.py b/tests/test_notifier.py new file mode 100644 index 0000000..d7924e2 --- /dev/null +++ b/tests/test_notifier.py @@ -0,0 +1,23 @@ +import unittest + +from detector.models import Alert +from detector.notifier import InMemorySender, Notifier + + +class TestNotifier(unittest.TestCase): + def test_memory_notifier(self): + notifier = Notifier.memory(["email", "webhook"]) + alert = Alert(alert_id="a-1", dedup_key="h-1:r-1", aggregate_key="k", severity="critical", status="firing") + delivered = notifier.notify(alert, ["email", "webhook", "dingtalk"]) + self.assertEqual(delivered, ["email", "webhook"]) + self.assertEqual(len(notifier.sent), 2) + + def test_in_memory_sender(self): + sender = InMemorySender("email") + alert = Alert(alert_id="a-2", dedup_key="d", aggregate_key="k", severity="warning", status="firing") + self.assertTrue(sender.send(alert, "ops@example.com")) + self.assertEqual(sender.sent[0]["target"], "ops@example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rule_loader.py b/tests/test_rule_loader.py new file mode 100644 index 0000000..b2d9caf --- /dev/null +++ b/tests/test_rule_loader.py @@ -0,0 +1,54 @@ +import unittest + +from detector.models import MetricRule, MetricSample, RuleScope +from detector.rule_loader import RuleLoader +from detector.storage import InMemoryCache, InMemoryRuleStore + + +class TestRuleLoader(unittest.TestCase): + def setUp(self): + self.store = InMemoryRuleStore() + self.cache = InMemoryCache() + self.loader = RuleLoader(self.store, self.cache, reload_interval="10s") + + def test_load_and_match(self): + rule = MetricRule( + rule_id="r-1", + name="cpu", + metric="cpu_usage", + operator="gt", + threshold=90, + scope=RuleScope(scope_type="host_group", host_group="web"), + ) + self.store.create_rule(rule) + self.loader.load() + + sample = MetricSample(name="cpu_usage", value=95, timestamp=0) + matched = self.loader.match(sample, "h-1", host_group="web") + self.assertEqual([r.rule_id for r in matched], ["r-1"]) + + # 分组不匹配 + self.assertEqual(self.loader.match(sample, "h-1", host_group="db"), []) + + def test_hot_reload_by_version(self): + self.loader.load() + self.assertEqual(self.loader.get_rules(), []) + + self.store.create_rule( + MetricRule(rule_id="r-2", name="mem", metric="mem_used_percent", threshold=85) + ) + # 版本变化后手动 load 模拟热加载线程 + self.assertNotEqual(self.store.get_version(), self.loader._version) + self.loader.load() + self.assertEqual(len(self.loader.get_rules()), 1) + + def test_disabled_rule_not_matched(self): + rule = MetricRule(rule_id="r-3", name="disk", metric="disk_used_percent", enabled=False) + self.store.create_rule(rule) + self.loader.load() + sample = MetricSample(name="disk_used_percent", value=95, timestamp=0) + self.assertEqual(self.loader.match(sample, "h-1"), []) + + +if __name__ == "__main__": + unittest.main()