develop: 开发 fault-log-analyzer 分析模块
This commit is contained in:
parent
dfa317f183
commit
ca47bd1391
76
src/fault_log_analyzer/__main__.py
Normal file
76
src/fault_log_analyzer/__main__.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""CLI 入口:启动 REST API(默认内存存储,便于本地验证)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .api import FaultLogApiServer
|
||||
from .config import Config
|
||||
from .storage import (
|
||||
InMemoryEventRepository,
|
||||
InMemoryFaultLogRepository,
|
||||
InMemoryFaultTypeRepository,
|
||||
InMemoryFilterRuleRepository,
|
||||
InMemoryRootCauseRepository,
|
||||
)
|
||||
|
||||
|
||||
def build_server(cfg: Config) -> FaultLogApiServer:
|
||||
if cfg.storage == "memory":
|
||||
fault_log_repo = InMemoryFaultLogRepository()
|
||||
root_cause_repo = InMemoryRootCauseRepository()
|
||||
fault_type_repo = InMemoryFaultTypeRepository()
|
||||
filter_rule_repo = InMemoryFilterRuleRepository()
|
||||
else: # pragma: no cover - 需要真实后端依赖
|
||||
from .integrations import (
|
||||
MySQLFaultLogRepository,
|
||||
MySQLFaultTypeRepository,
|
||||
MySQLFilterRuleRepository,
|
||||
MySQLRootCauseRepository,
|
||||
)
|
||||
|
||||
fault_log_repo = MySQLFaultLogRepository(
|
||||
cfg.mysql_host, cfg.mysql_port, cfg.mysql_user, cfg.mysql_password, cfg.mysql_db
|
||||
)
|
||||
root_cause_repo = MySQLRootCauseRepository(
|
||||
cfg.mysql_host, cfg.mysql_port, cfg.mysql_user, cfg.mysql_password, cfg.mysql_db
|
||||
)
|
||||
fault_type_repo = MySQLFaultTypeRepository(
|
||||
cfg.mysql_host, cfg.mysql_port, cfg.mysql_user, cfg.mysql_password, cfg.mysql_db
|
||||
)
|
||||
filter_rule_repo = MySQLFilterRuleRepository(
|
||||
cfg.mysql_host, cfg.mysql_port, cfg.mysql_user, cfg.mysql_password, cfg.mysql_db
|
||||
)
|
||||
|
||||
return FaultLogApiServer(
|
||||
fault_log_repo=fault_log_repo,
|
||||
root_cause_repo=root_cause_repo,
|
||||
fault_type_repo=fault_type_repo,
|
||||
filter_rule_repo=filter_rule_repo,
|
||||
host=cfg.api_host,
|
||||
port=cfg.api_port,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="fault-log-analyzer service")
|
||||
parser.add_argument("--storage", default=None, help="memory 或 mysql(默认从环境变量读取)")
|
||||
parser.add_argument("--api-host", default=None)
|
||||
parser.add_argument("--api-port", type=int, default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cfg = Config.from_env()
|
||||
if args.storage:
|
||||
cfg.storage = args.storage
|
||||
if args.api_host:
|
||||
cfg.api_host = args.api_host
|
||||
if args.api_port:
|
||||
cfg.api_port = args.api_port
|
||||
|
||||
server = build_server(cfg)
|
||||
print(f"fault-log-analyzer listening on http://{cfg.api_host}:{cfg.api_port}")
|
||||
server.start(block=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
205
src/fault_log_analyzer/api.py
Normal file
205
src/fault_log_analyzer/api.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""REST API:故障日志查询、根因分析、故障类型/过滤规则管理。
|
||||
|
||||
使用标准库 ``http.server`` 实现,零第三方依赖,可直接运行;生产环境可由
|
||||
host-monitor 网关统一转发到本服务(或替换为 FastAPI 等实现)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from .models import FaultFilterRule, FaultType, from_iso
|
||||
from .storage import (
|
||||
FaultLogRepository,
|
||||
FaultTypeRepository,
|
||||
FilterRuleRepository,
|
||||
RootCauseRepository,
|
||||
)
|
||||
|
||||
BASE_PATH = "/api/v1"
|
||||
|
||||
|
||||
class _JsonHandler(BaseHTTPRequestHandler):
|
||||
server_version = "FaultLogAnalyzer/0.1"
|
||||
|
||||
# 由 create_handler 注入
|
||||
fault_log_repo: FaultLogRepository
|
||||
root_cause_repo: RootCauseRepository
|
||||
fault_type_repo: FaultTypeRepository
|
||||
filter_rule_repo: FilterRuleRepository
|
||||
|
||||
# -- 基础工具 ---------------------------------------------------------
|
||||
def _send_json(self, code: int, payload: dict, status: int = 200) -> None:
|
||||
body = json.dumps(payload, 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(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _ok(self, data) -> None:
|
||||
self._send_json(200, {"code": 0, "message": "ok", "data": data})
|
||||
|
||||
def _error(self, code: int, message: str, status: int = 400) -> None:
|
||||
self._send_json(status, {"code": code, "message": message, "data": None})
|
||||
|
||||
def _read_body(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
def _query(self) -> dict:
|
||||
parsed = urlparse(self.path)
|
||||
return {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||
|
||||
# -- 路由 -------------------------------------------------------------
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == "/healthz":
|
||||
return self._ok({"status": "ok"})
|
||||
if path == "/readyz":
|
||||
return self._ok({"status": "ok", "deps": ["memory"]})
|
||||
if path == f"{BASE_PATH}/fault-logs":
|
||||
return self._list_fault_logs()
|
||||
if path.startswith(f"{BASE_PATH}/fault-logs/"):
|
||||
rest = path[len(f"{BASE_PATH}/fault-logs/") :]
|
||||
if rest.endswith("/root-cause"):
|
||||
return self._get_root_cause(rest[: -len("/root-cause")])
|
||||
return self._get_fault_log(rest)
|
||||
if path == f"{BASE_PATH}/fault-types":
|
||||
return self._list_fault_types()
|
||||
if path == f"{BASE_PATH}/fault-filters":
|
||||
return self._list_fault_filters()
|
||||
return self._error(40401, "not found", 404)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urlparse(self.path).path
|
||||
if path == f"{BASE_PATH}/fault-types":
|
||||
return self._create_fault_type()
|
||||
if path == f"{BASE_PATH}/fault-filters":
|
||||
return self._create_fault_filter()
|
||||
return self._error(40401, "not found", 404)
|
||||
|
||||
# -- 故障日志 ---------------------------------------------------------
|
||||
def _list_fault_logs(self) -> None:
|
||||
q = self._query()
|
||||
try:
|
||||
page = max(1, int(q.get("page", "1")))
|
||||
page_size = min(200, max(1, int(q.get("page_size", "20"))))
|
||||
except ValueError:
|
||||
return self._error(40001, "invalid param: page/page_size")
|
||||
start = from_iso(q.get("from"))
|
||||
end = from_iso(q.get("to"))
|
||||
total, items = self.fault_log_repo.list(
|
||||
host_id=q.get("host_id", ""),
|
||||
fault_type=q.get("fault_type", ""),
|
||||
level=q.get("level", ""),
|
||||
keyword=q.get("keyword", ""),
|
||||
start=start,
|
||||
end=end,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
self._ok({"total": total, "items": [i.to_dict() for i in items]})
|
||||
|
||||
def _get_fault_log(self, fault_log_id: str) -> None:
|
||||
item = self.fault_log_repo.get(fault_log_id)
|
||||
if item is None:
|
||||
return self._error(40402, "fault log not found", 404)
|
||||
self._ok(item.to_dict())
|
||||
|
||||
def _get_root_cause(self, fault_log_id: str) -> None:
|
||||
item = self.root_cause_repo.get(fault_log_id)
|
||||
if item is None:
|
||||
return self._error(40403, "root cause not found", 404)
|
||||
self._ok(item.to_dict())
|
||||
|
||||
# -- 故障类型 ---------------------------------------------------------
|
||||
def _list_fault_types(self) -> None:
|
||||
self._ok({"items": [ft.to_dict() for ft in self.fault_type_repo.list()]})
|
||||
|
||||
def _create_fault_type(self) -> None:
|
||||
body = self._read_body()
|
||||
try:
|
||||
ft = FaultType.from_dict(body)
|
||||
if not ft.fault_type or not ft.name:
|
||||
return self._error(40002, "fault_type and name are required")
|
||||
self.fault_type_repo.add(ft)
|
||||
except (ValueError, KeyError):
|
||||
return self._error(40001, "invalid param")
|
||||
self._ok(ft.to_dict())
|
||||
|
||||
# -- 过滤规则 ---------------------------------------------------------
|
||||
def _list_fault_filters(self) -> None:
|
||||
self._ok({"items": [r.to_dict() for r in self.filter_rule_repo.list()]})
|
||||
|
||||
def _create_fault_filter(self) -> None:
|
||||
body = self._read_body()
|
||||
try:
|
||||
rule = FaultFilterRule.from_dict(body)
|
||||
if not rule.name or not rule.pattern:
|
||||
return self._error(40002, "name and pattern are required")
|
||||
self.filter_rule_repo.add(rule)
|
||||
except (ValueError, KeyError):
|
||||
return self._error(40001, "invalid param")
|
||||
self._ok(rule.to_dict())
|
||||
|
||||
def log_message(self, format: str, *args) -> None: # noqa: A002
|
||||
# 静默访问日志,避免污染测试输出
|
||||
pass
|
||||
|
||||
|
||||
def create_handler(
|
||||
fault_log_repo: FaultLogRepository,
|
||||
root_cause_repo: RootCauseRepository,
|
||||
fault_type_repo: FaultTypeRepository,
|
||||
filter_rule_repo: FilterRuleRepository,
|
||||
):
|
||||
"""创建绑定存储的请求处理器类。"""
|
||||
|
||||
class Handler(_JsonHandler):
|
||||
pass
|
||||
|
||||
Handler.fault_log_repo = fault_log_repo
|
||||
Handler.root_cause_repo = root_cause_repo
|
||||
Handler.fault_type_repo = fault_type_repo
|
||||
Handler.filter_rule_repo = filter_rule_repo
|
||||
return Handler
|
||||
|
||||
|
||||
class FaultLogApiServer:
|
||||
"""可编程启停的 HTTP 服务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fault_log_repo: FaultLogRepository,
|
||||
root_cause_repo: RootCauseRepository,
|
||||
fault_type_repo: FaultTypeRepository,
|
||||
filter_rule_repo: FilterRuleRepository,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
):
|
||||
handler = create_handler(fault_log_repo, root_cause_repo, fault_type_repo, filter_rule_repo)
|
||||
self._httpd = ThreadingHTTPServer((host, port), handler)
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def start(self, block: bool = False) -> None:
|
||||
if block:
|
||||
self._httpd.serve_forever()
|
||||
else:
|
||||
import threading
|
||||
|
||||
t = threading.Thread(target=self._httpd.serve_forever, daemon=True)
|
||||
t.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
75
tests/test_parser.py
Normal file
75
tests/test_parser.py
Normal file
@ -0,0 +1,75 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fault_log_analyzer.parser import parse_log, parse_timestamp, templatize, tokenize
|
||||
|
||||
|
||||
class TestParseTimestamp(unittest.TestCase):
|
||||
def test_iso_with_z(self):
|
||||
dt = parse_timestamp("2025-01-01T10:18:00Z")
|
||||
self.assertEqual(dt.year, 2025)
|
||||
self.assertEqual(dt.minute, 18)
|
||||
|
||||
def test_epoch_seconds(self):
|
||||
dt = parse_timestamp(1735726680) # 2025-01-01T10:18:00Z
|
||||
self.assertEqual(dt.year, 2025)
|
||||
|
||||
def test_epoch_millis(self):
|
||||
dt = parse_timestamp(1735726680000)
|
||||
self.assertEqual(dt.year, 2025)
|
||||
|
||||
def test_common_format(self):
|
||||
dt = parse_timestamp("2025-01-01 10:18:00")
|
||||
self.assertEqual(dt.year, 2025)
|
||||
self.assertEqual(dt.hour, 10)
|
||||
|
||||
def test_invalid_returns_none(self):
|
||||
self.assertIsNone(parse_timestamp("not-a-date"))
|
||||
|
||||
|
||||
class TestParseLog(unittest.TestCase):
|
||||
def test_structured_json(self):
|
||||
raw = {
|
||||
"timestamp": "2025-01-01T10:18:00Z",
|
||||
"level": "ERROR",
|
||||
"message": "No space left on device",
|
||||
"host_id": "h-001",
|
||||
"service": "app",
|
||||
"trace_id": "tr-1",
|
||||
}
|
||||
entry = parse_log(raw)
|
||||
self.assertEqual(entry.level, "ERROR")
|
||||
self.assertEqual(entry.host_id, "h-001")
|
||||
self.assertEqual(entry.trace_id, "tr-1")
|
||||
|
||||
def test_level_extracted_from_message(self):
|
||||
entry = parse_log({"message": "ERROR: something bad"})
|
||||
self.assertEqual(entry.level, "ERROR")
|
||||
|
||||
def test_json_message_expands_fields(self):
|
||||
raw = {"message": '{"message": "disk full", "level": "FATAL", "host_id": "h-2"}'}
|
||||
entry = parse_log(raw)
|
||||
self.assertEqual(entry.message, "disk full")
|
||||
self.assertEqual(entry.level, "FATAL")
|
||||
self.assertEqual(entry.host_id, "h-2")
|
||||
|
||||
|
||||
class TestTemplatize(unittest.TestCase):
|
||||
def test_replace_variables(self):
|
||||
msg = "Connection from 10.0.0.11 port 8080 failed after 3 retries"
|
||||
tpl = templatize(msg)
|
||||
self.assertIn("<IP>", tpl)
|
||||
self.assertIn("<NUM>", tpl)
|
||||
|
||||
def test_replace_uuid(self):
|
||||
tpl = templatize("request 123e4567-e89b-12d3-a456-426614174000 failed")
|
||||
self.assertIn("<UUID>", tpl)
|
||||
|
||||
def test_tokenize(self):
|
||||
tokens = tokenize("No space left on device")
|
||||
self.assertIn("space", tokens)
|
||||
self.assertIn("device", tokens)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user