first commit
This commit is contained in:
6
src/exporter/__init__.py
Normal file
6
src/exporter/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
NETCONF Transceiver Exporter package.
|
||||
|
||||
Modules are implemented following the design in specs/transceiver-exporter-design-v9.md.
|
||||
"""
|
||||
|
||||
BIN
src/exporter/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/api.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/config.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/connection.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/connection.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/errors.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/errors.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/logging_utils.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/logging_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/main.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/metrics.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/metrics.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/models.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/netconf_client.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/netconf_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/registry.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/registry.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/scraper.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/scraper.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/exporter/__pycache__/sqlite_store.cpython-312.pyc
Normal file
BIN
src/exporter/__pycache__/sqlite_store.cpython-312.pyc
Normal file
Binary file not shown.
181
src/exporter/api.py
Normal file
181
src/exporter/api.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
from prometheus_client import CollectorRegistry, generate_latest
|
||||
|
||||
from .config import DeviceConfig, GlobalConfig
|
||||
from .metrics import TransceiverCollector
|
||||
from .registry import DeviceRegistry
|
||||
from .sqlite_store import SQLiteDeviceStore
|
||||
|
||||
|
||||
class DeviceIn(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port: int = 830
|
||||
username: str
|
||||
password: str
|
||||
enabled: bool = True
|
||||
supports_xpath: bool = False
|
||||
scrape_interval_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
enabled: bool
|
||||
scrape_interval_seconds: Optional[int]
|
||||
supports_xpath: bool
|
||||
source: str
|
||||
|
||||
|
||||
def _require_token(
|
||||
x_api_token: str | None = Header(default=None, alias="X-API-Token"),
|
||||
expected: str | None = None,
|
||||
) -> None:
|
||||
if expected is None or expected == "":
|
||||
return
|
||||
if x_api_token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unauthorized",
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
registry: DeviceRegistry,
|
||||
store: SQLiteDeviceStore,
|
||||
collector: TransceiverCollector,
|
||||
global_cfg: GlobalConfig,
|
||||
) -> FastAPI:
|
||||
"""
|
||||
创建 FastAPI 应用,挂载 /metrics, /healthz, /api/v1/devices 等路由。
|
||||
"""
|
||||
app = FastAPI()
|
||||
|
||||
# 独立的 Prometheus registry,以便测试隔离
|
||||
prom_registry = CollectorRegistry()
|
||||
prom_registry.register(collector)
|
||||
|
||||
def require_token(x_api_token: str | None = Header(default=None, alias="X-API-Token")):
|
||||
_require_token(x_api_token, global_cfg.api_token)
|
||||
|
||||
@app.get("/metrics")
|
||||
def metrics() -> PlainTextResponse:
|
||||
data = generate_latest(prom_registry)
|
||||
return PlainTextResponse(
|
||||
content=data.decode("utf-8"),
|
||||
media_type="text/plain; version=0.0.4",
|
||||
)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> JSONResponse:
|
||||
devices = registry.list_devices()
|
||||
total = len(devices)
|
||||
enabled = len([d for d in devices if d.enabled])
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "ok",
|
||||
"scraper_running": True,
|
||||
"devices_total": total,
|
||||
"devices_enabled": enabled,
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/v1/devices", response_model=List[DeviceOut], dependencies=[Depends(require_token)])
|
||||
def list_devices_api() -> List[DeviceOut]:
|
||||
devices = registry.list_devices()
|
||||
return [
|
||||
DeviceOut(
|
||||
name=d.name,
|
||||
host=d.host,
|
||||
port=d.port,
|
||||
enabled=d.enabled,
|
||||
scrape_interval_seconds=d.scrape_interval_seconds,
|
||||
supports_xpath=d.supports_xpath,
|
||||
source=d.source,
|
||||
)
|
||||
for d in devices
|
||||
]
|
||||
|
||||
@app.post(
|
||||
"/api/v1/devices",
|
||||
response_model=DeviceOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def create_device_api(device: DeviceIn) -> DeviceOut:
|
||||
# 检查是否已有同名 runtime 设备
|
||||
runtime_devices = {d.name: d for d in store.load_runtime_devices()}
|
||||
if device.name in runtime_devices:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Runtime device already exists",
|
||||
)
|
||||
|
||||
cfg = DeviceConfig(
|
||||
name=device.name,
|
||||
host=device.host,
|
||||
port=device.port,
|
||||
username=device.username,
|
||||
password=device.password,
|
||||
enabled=device.enabled,
|
||||
scrape_interval_seconds=device.scrape_interval_seconds,
|
||||
supports_xpath=device.supports_xpath,
|
||||
source="runtime",
|
||||
)
|
||||
# 持久化并注册到 registry
|
||||
try:
|
||||
store.save_device(cfg)
|
||||
except sqlite3.OperationalError as exc: # type: ignore[name-defined]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
registry.register_runtime_device(cfg)
|
||||
|
||||
return DeviceOut(
|
||||
name=cfg.name,
|
||||
host=cfg.host,
|
||||
port=cfg.port,
|
||||
enabled=cfg.enabled,
|
||||
scrape_interval_seconds=cfg.scrape_interval_seconds,
|
||||
supports_xpath=cfg.supports_xpath,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/devices/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_token)],
|
||||
)
|
||||
def delete_device_api(name: str):
|
||||
# 判断是不是静态设备
|
||||
devices = {d.name: d for d in registry.list_devices()}
|
||||
existing = devices.get(name)
|
||||
if existing is not None and existing.source == "static":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete static device",
|
||||
)
|
||||
|
||||
# 删除 runtime 设备
|
||||
try:
|
||||
store.delete_device(name)
|
||||
except sqlite3.OperationalError as exc: # type: ignore[name-defined]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
registry.delete_runtime_device(name)
|
||||
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
|
||||
|
||||
return app
|
||||
|
||||
131
src/exporter/config.py
Normal file
131
src/exporter/config.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobalConfig:
|
||||
http_listen: str = "0.0.0.0:9100"
|
||||
scrape_interval_seconds: int = 60
|
||||
netconf_port: int = 830
|
||||
connect_timeout_seconds: int = 5
|
||||
rpc_timeout_seconds: int = 10
|
||||
max_workers: int = 10
|
||||
|
||||
api_token: str = "changeme"
|
||||
|
||||
runtime_db_path: str = "./devices.db"
|
||||
password_secret: str = ""
|
||||
|
||||
ssh_keepalive_seconds: int = 30
|
||||
failure_threshold: int = 3
|
||||
max_backoff_factor: int = 8
|
||||
shutdown_timeout_seconds: int = 30
|
||||
|
||||
log_level: str = "INFO"
|
||||
log_to_stdout: bool = True
|
||||
log_file: str = ""
|
||||
log_file_max_bytes: int = 10 * 1024 * 1024
|
||||
log_file_backup_count: int = 5
|
||||
|
||||
hostkey_verify: bool = False
|
||||
known_hosts_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
name: str
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
enabled: bool = True
|
||||
scrape_interval_seconds: Optional[int] = None
|
||||
supports_xpath: bool = False
|
||||
source: str = "static" # "static" | "runtime"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
global_: GlobalConfig = field(default_factory=GlobalConfig)
|
||||
devices: List[DeviceConfig] = field(default_factory=list)
|
||||
|
||||
# ---- 工厂方法 ----
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> "Config":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return cls.from_dict(data)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Config":
|
||||
global_cfg = cls._load_global(data.get("global", {}))
|
||||
devices_cfg = cls._load_devices(data.get("devices", []))
|
||||
|
||||
cfg = cls(global_=global_cfg, devices=devices_cfg)
|
||||
cfg._validate_timing()
|
||||
return cfg
|
||||
|
||||
# ---- 解析 helpers ----
|
||||
|
||||
@staticmethod
|
||||
def _load_global(raw: dict[str, Any]) -> GlobalConfig:
|
||||
gc = GlobalConfig()
|
||||
# 简单字段赋值
|
||||
for field_ in dataclasses.fields(GlobalConfig):
|
||||
if field_.name in raw:
|
||||
setattr(gc, field_.name, raw[field_.name])
|
||||
|
||||
# 校验 Fernet key 格式(32-byte URL-safe base64)
|
||||
if not gc.password_secret:
|
||||
raise ValueError("global.password_secret must be configured")
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(gc.password_secret.encode("utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("Invalid Fernet key in global.password_secret") from exc
|
||||
if len(decoded) != 32:
|
||||
raise ValueError("Invalid Fernet key length in global.password_secret")
|
||||
|
||||
return gc
|
||||
|
||||
@staticmethod
|
||||
def _load_devices(raw_list: list[dict[str, Any]]) -> List[DeviceConfig]:
|
||||
devices: List[DeviceConfig] = []
|
||||
for raw in raw_list:
|
||||
dev = DeviceConfig(
|
||||
name=str(raw["name"]),
|
||||
host=str(raw["host"]),
|
||||
port=int(raw.get("port", 830)),
|
||||
username=str(raw["username"]),
|
||||
password=str(raw["password"]),
|
||||
enabled=bool(raw.get("enabled", True)),
|
||||
scrape_interval_seconds=raw.get("scrape_interval_seconds"),
|
||||
supports_xpath=bool(raw.get("supports_xpath", False)),
|
||||
source="static",
|
||||
)
|
||||
devices.append(dev)
|
||||
return devices
|
||||
|
||||
# ---- 校验逻辑 ----
|
||||
|
||||
def _validate_timing(self) -> None:
|
||||
"""对 shutdown_timeout 与 interval/rpc_timeout 做基本合理性检查。"""
|
||||
g = self.global_
|
||||
min_needed = g.scrape_interval_seconds + g.rpc_timeout_seconds
|
||||
if g.shutdown_timeout_seconds < min_needed:
|
||||
warnings.warn(
|
||||
"global.shutdown_timeout_seconds is smaller than "
|
||||
"scrape_interval_seconds + rpc_timeout_seconds; "
|
||||
"shutdown may terminate scraper before RPCs finish.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
88
src/exporter/connection.py
Normal file
88
src/exporter/connection.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
import ncclient.manager
|
||||
|
||||
from .config import DeviceConfig, GlobalConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetconfSessionEntry:
|
||||
manager: ncclient.manager.Manager
|
||||
last_used_at: float
|
||||
valid: bool = True
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""管理 per-device NETCONF 会话,并启用 SSH keepalive."""
|
||||
|
||||
def __init__(self, global_cfg: GlobalConfig) -> None:
|
||||
self._global_cfg = global_cfg
|
||||
self._sessions: Dict[str, NetconfSessionEntry] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def acquire_session(self, cfg: DeviceConfig) -> ncclient.manager.Manager:
|
||||
"""获取或建立对应设备的 NETCONF 会话."""
|
||||
name = cfg.name
|
||||
with self._lock:
|
||||
entry = self._sessions.get(name)
|
||||
if entry is not None and entry.valid:
|
||||
entry.last_used_at = time.time()
|
||||
return entry.manager
|
||||
|
||||
# 新建会话
|
||||
mgr = ncclient.manager.connect(
|
||||
host=cfg.host,
|
||||
port=cfg.port,
|
||||
username=cfg.username,
|
||||
password=cfg.password,
|
||||
hostkey_verify=self._global_cfg.hostkey_verify,
|
||||
# timeout: SSH 连接超时(秒)
|
||||
timeout=self._global_cfg.connect_timeout_seconds,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
# 设置 RPC 调用的超时时间(ncclient Manager 的 timeout 属性)
|
||||
try:
|
||||
mgr.timeout = self._global_cfg.rpc_timeout_seconds # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
# 某些 ncclient 版本可能不支持此属性,忽略即可
|
||||
pass
|
||||
self._enable_ssh_keepalive(mgr, self._global_cfg.ssh_keepalive_seconds)
|
||||
entry = NetconfSessionEntry(manager=mgr, last_used_at=time.time(), valid=True)
|
||||
self._sessions[name] = entry
|
||||
return mgr
|
||||
|
||||
def mark_session_invalid(self, device_name: str) -> None:
|
||||
"""标记设备会话为无效,下一次获取时会重建."""
|
||||
with self._lock:
|
||||
entry = self._sessions.get(device_name)
|
||||
if entry is not None:
|
||||
entry.valid = False
|
||||
try:
|
||||
entry.manager.close_session()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def close_all(self) -> None:
|
||||
"""关闭所有会话."""
|
||||
with self._lock:
|
||||
for entry in self._sessions.values():
|
||||
try:
|
||||
entry.manager.close_session()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._sessions.clear()
|
||||
|
||||
@staticmethod
|
||||
def _enable_ssh_keepalive(manager: ncclient.manager.Manager, interval: int) -> None:
|
||||
"""在底层 paramiko Transport 上启用 SSH keepalive."""
|
||||
transport = getattr(manager, "transport", None) or getattr(
|
||||
getattr(manager, "_session", None), "_transport", None
|
||||
)
|
||||
if transport is not None and hasattr(transport, "set_keepalive"):
|
||||
transport.set_keepalive(interval)
|
||||
44
src/exporter/errors.py
Normal file
44
src/exporter/errors.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
from ncclient.operations.errors import TimeoutExpiredError
|
||||
|
||||
|
||||
def classify_error(exc: Exception) -> str:
|
||||
"""
|
||||
将异常映射为设计中定义的 error_type 字符串.
|
||||
|
||||
约定的 error_type:
|
||||
- SSHError
|
||||
- SessionCloseError
|
||||
- TimeoutError
|
||||
- AuthenticationError
|
||||
- XMLParseError
|
||||
- FilterError
|
||||
- UnknownError
|
||||
"""
|
||||
if isinstance(exc, paramiko.SSHException):
|
||||
return "SSHError"
|
||||
# ncclient 的 RPC 超时错误
|
||||
if isinstance(exc, TimeoutExpiredError):
|
||||
return "TimeoutError"
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "TimeoutError"
|
||||
if isinstance(exc, ET.ParseError):
|
||||
return "XMLParseError"
|
||||
# 认证相关异常可以按需扩展,这里简单用 PermissionError 代表
|
||||
if isinstance(exc, PermissionError):
|
||||
return "AuthenticationError"
|
||||
|
||||
msg = str(exc)
|
||||
if "filter" in msg.lower():
|
||||
return "FilterError"
|
||||
|
||||
# ncclient 会话关闭错误可能是自定义异常;简单通过名称匹配
|
||||
if exc.__class__.__name__ in {"SessionCloseError", "SessionError"}:
|
||||
return "SessionCloseError"
|
||||
|
||||
return "UnknownError"
|
||||
81
src/exporter/logging_utils.py
Normal file
81
src/exporter/logging_utils.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
from typing import Any, Mapping, MutableMapping, Optional
|
||||
|
||||
from .config import GlobalConfig
|
||||
|
||||
|
||||
class DeviceLoggerAdapter(logging.LoggerAdapter):
|
||||
"""
|
||||
为日志记录注入 device 字段的 LoggerAdapter。
|
||||
|
||||
- 如果 extra 中包含 device,则使用该值;
|
||||
- 否则使用 "-" 作为默认值,确保 formatter 中的 %(device)s 始终有值。
|
||||
"""
|
||||
|
||||
def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]:
|
||||
extra: MutableMapping[str, Any] = kwargs.setdefault("extra", {})
|
||||
# 以 Adapter 自身的 extra 为基础,必要时填充 device
|
||||
device = extra.get("device", self.extra.get("device", "-"))
|
||||
extra["device"] = device or "-"
|
||||
return msg, kwargs
|
||||
|
||||
|
||||
class DeviceFieldFilter(logging.Filter):
|
||||
"""
|
||||
确保每条日志记录都有 device 字段,避免 formatter 中 %(device)s 抛 KeyError。
|
||||
|
||||
- 若 record 上不存在 device 属性,则填充为 "-";
|
||||
- 总是返回 True,不会过滤掉任何记录。
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool: # type: ignore[override]
|
||||
if not hasattr(record, "device"):
|
||||
record.device = "-" # type: ignore[attr-defined]
|
||||
return True
|
||||
|
||||
|
||||
def init_logging(global_cfg: GlobalConfig) -> None:
|
||||
"""
|
||||
根据 GlobalConfig 初始化根 logger。
|
||||
|
||||
- 设置日志级别;
|
||||
- 根据 log_to_stdout / log_file 添加 handler;
|
||||
- 使用统一的格式与时间格式(ISO8601)。
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
|
||||
# 清理现有 handler,避免重复添加
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
level_name = (global_cfg.log_level or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
root.setLevel(level)
|
||||
|
||||
fmt = "%(asctime)s %(levelname)s [%(name)s] device=%(device)s %(message)s"
|
||||
datefmt = "%Y-%m-%dT%H:%M:%S%z"
|
||||
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
|
||||
|
||||
# 为所有 handler 共享一个 DeviceFieldFilter 实例
|
||||
device_filter = DeviceFieldFilter()
|
||||
|
||||
if global_cfg.log_to_stdout:
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setFormatter(formatter)
|
||||
stream_handler.addFilter(device_filter)
|
||||
root.addHandler(stream_handler)
|
||||
|
||||
if global_cfg.log_file:
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
global_cfg.log_file,
|
||||
maxBytes=global_cfg.log_file_max_bytes,
|
||||
backupCount=global_cfg.log_file_backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.addFilter(device_filter)
|
||||
root.addHandler(file_handler)
|
||||
170
src/exporter/main.py
Normal file
170
src/exporter/main.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .api import create_app
|
||||
from .config import Config
|
||||
from .connection import ConnectionManager
|
||||
from .logging_utils import init_logging
|
||||
from .metrics import TransceiverCollector
|
||||
from .models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
from .scraper import scraper_loop
|
||||
from .sqlite_store import PasswordEncryptor, SQLiteDeviceStore
|
||||
from .registry import DeviceRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_http_listen(http_listen: str) -> Tuple[str, int]:
|
||||
"""解析 global.http_listen 字符串为 (host, port)."""
|
||||
if ":" not in http_listen:
|
||||
raise ValueError(f"Invalid http_listen value: {http_listen!r}")
|
||||
host, port_str = http_listen.rsplit(":", 1)
|
||||
if not host:
|
||||
host = "0.0.0.0"
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError as exc: # noqa: BLE001
|
||||
raise ValueError(f"Invalid port in http_listen: {http_listen!r}") from exc
|
||||
return host, port
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="NETCONF Transceiver Exporter")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
dest="config_path",
|
||||
default="config.yaml",
|
||||
help="Path to configuration YAML file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
dest="log_level",
|
||||
default=None,
|
||||
help="Override log level from config (e.g. INFO, DEBUG)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _netconf_get_rpc(manager, filter_xml: str) -> str:
|
||||
"""
|
||||
默认 NETCONF RPC 调用封装。
|
||||
|
||||
- 使用 subtree filter;
|
||||
- 返回 rpc-reply 的 XML 字符串表示。
|
||||
"""
|
||||
reply = manager.get(filter=("subtree", filter_xml))
|
||||
return str(reply)
|
||||
|
||||
|
||||
async def async_main(argv: List[str] | None = None) -> None:
|
||||
"""
|
||||
应用入口(异步部分):
|
||||
|
||||
- 解析 CLI 参数并加载配置;
|
||||
- 初始化日志、SQLite、Registry、ConnectionManager、Collector 与 FastAPI 应用;
|
||||
- 启动 Scraper 线程与 Uvicorn Server;
|
||||
- 在 server 结束后触发 Scraper 优雅退出并关闭资源。
|
||||
"""
|
||||
parser = _build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cfg = Config.from_file(args.config_path)
|
||||
if args.log_level:
|
||||
cfg.global_.log_level = args.log_level
|
||||
|
||||
# 初始化日志
|
||||
init_logging(cfg.global_)
|
||||
logger.info("Starting NETCONF Transceiver Exporter")
|
||||
|
||||
# 初始化加密与 SQLite
|
||||
encryptor = PasswordEncryptor(cfg.global_.password_secret)
|
||||
store = SQLiteDeviceStore(cfg.global_.runtime_db_path, encryptor)
|
||||
store.init_db()
|
||||
|
||||
# 初始化 Registry:先注册静态设备,再加载并注册 runtime 设备
|
||||
registry = DeviceRegistry(global_scrape_interval=cfg.global_.scrape_interval_seconds)
|
||||
for dev in cfg.devices:
|
||||
registry.register_static_device(dev)
|
||||
for dev in store.load_runtime_devices():
|
||||
registry.register_runtime_device(dev)
|
||||
|
||||
# Metrics cache & health state
|
||||
metrics_cache: Dict[str, DeviceMetricsSnapshot] = {}
|
||||
health_state: Dict[str, DeviceHealthState] = {}
|
||||
collector = TransceiverCollector(metrics_cache, health_state)
|
||||
|
||||
# Connection manager
|
||||
connection_manager = ConnectionManager(cfg.global_)
|
||||
|
||||
# FastAPI 应用
|
||||
app = create_app(registry, store, collector, cfg.global_)
|
||||
|
||||
host, port = _parse_http_listen(cfg.global_.http_listen)
|
||||
uv_config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=cfg.global_.log_level.lower(),
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
stop_event = threading.Event()
|
||||
|
||||
scraper_thread = threading.Thread(
|
||||
target=scraper_loop,
|
||||
args=(
|
||||
stop_event,
|
||||
registry,
|
||||
connection_manager,
|
||||
_netconf_get_rpc,
|
||||
metrics_cache,
|
||||
health_state,
|
||||
cfg.global_,
|
||||
),
|
||||
daemon=True,
|
||||
name="netconf-scraper",
|
||||
)
|
||||
scraper_thread.start()
|
||||
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
# 触发 Scraper 退出并等待其在超时时间内结束
|
||||
stop_event.set()
|
||||
scraper_thread.join(timeout=cfg.global_.shutdown_timeout_seconds)
|
||||
if scraper_thread.is_alive():
|
||||
logger.error(
|
||||
"Scraper thread did not stop gracefully within timeout=%s seconds",
|
||||
cfg.global_.shutdown_timeout_seconds,
|
||||
)
|
||||
|
||||
# 关闭所有 NETCONF 连接与 SQLite 存储
|
||||
try:
|
||||
connection_manager.close_all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error closing NETCONF connections: %s", exc)
|
||||
|
||||
try:
|
||||
store.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error closing SQLite store: %s", exc)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""同步入口,供 `python -m exporter.main` 或命令行使用。"""
|
||||
try:
|
||||
asyncio.run(async_main())
|
||||
except KeyboardInterrupt:
|
||||
# 优雅退出:忽略 Ctrl+C 引发的 traceback
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
187
src/exporter/metrics.py
Normal file
187
src/exporter/metrics.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily, InfoMetricFamily
|
||||
|
||||
from .models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
|
||||
|
||||
class TransceiverCollector:
|
||||
"""
|
||||
自定义 Collector,基于 MetricsCache 与 HealthState 输出所有指标。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache: Dict[str, DeviceMetricsSnapshot],
|
||||
health: Dict[str, DeviceHealthState],
|
||||
) -> None:
|
||||
self._cache = cache
|
||||
self._health = health
|
||||
|
||||
def collect(self) -> Iterable:
|
||||
# 复制快照,避免长时间持锁(由调用方负责线程安全)
|
||||
cache_items = list(self._cache.items())
|
||||
health_items = list(self._health.items())
|
||||
|
||||
# 健康指标
|
||||
scrape_duration = GaugeMetricFamily(
|
||||
"netconf_scrape_duration_seconds",
|
||||
"Duration of last NETCONF scrape per device",
|
||||
labels=["device"],
|
||||
)
|
||||
scrape_success = GaugeMetricFamily(
|
||||
"netconf_scrape_success",
|
||||
"Whether last NETCONF scrape succeeded (1) or failed (0)",
|
||||
labels=["device"],
|
||||
)
|
||||
last_scrape_ts = GaugeMetricFamily(
|
||||
"netconf_last_scrape_timestamp_seconds",
|
||||
"Timestamp of last NETCONF scrape per device",
|
||||
labels=["device"],
|
||||
)
|
||||
staleness = GaugeMetricFamily(
|
||||
"transceiver_data_staleness_seconds",
|
||||
"Age of last successful transceiver scrape per device",
|
||||
labels=["device"],
|
||||
)
|
||||
errors_total = CounterMetricFamily(
|
||||
"netconf_scrape_errors_total",
|
||||
"Total number of NETCONF scrape errors by device and type",
|
||||
labels=["device", "error_type"],
|
||||
)
|
||||
|
||||
# health map for quick lookup
|
||||
health_map = dict(health_items)
|
||||
|
||||
# 填充健康指标
|
||||
for device, hs in health_items:
|
||||
if hs.last_scrape_duration is not None:
|
||||
scrape_duration.add_metric([device], hs.last_scrape_duration)
|
||||
if hs.last_scrape_success is not None:
|
||||
scrape_success.add_metric([device], 1.0 if hs.last_scrape_success else 0.0)
|
||||
if hs.last_scrape_timestamp is not None:
|
||||
last_scrape_ts.add_metric([device], hs.last_scrape_timestamp)
|
||||
if hs.last_error_type:
|
||||
errors_total.add_metric([device, hs.last_error_type], 1.0)
|
||||
|
||||
# 业务指标
|
||||
tx_temp = GaugeMetricFamily(
|
||||
"transceiver_temperature_celsius",
|
||||
"Transceiver temperature in degrees Celsius",
|
||||
labels=["device", "port", "component_name"],
|
||||
)
|
||||
tx_supply_v = GaugeMetricFamily(
|
||||
"transceiver_supply_voltage_volts",
|
||||
"Transceiver supply voltage",
|
||||
labels=["device", "port", "component_name"],
|
||||
)
|
||||
tx_present = GaugeMetricFamily(
|
||||
"transceiver_present",
|
||||
"Transceiver present state (1 present, 0 otherwise)",
|
||||
labels=["device", "port", "component_name"],
|
||||
)
|
||||
|
||||
ch_rx_power = GaugeMetricFamily(
|
||||
"transceiver_channel_rx_power_dbm",
|
||||
"Receive optical power per channel (dBm)",
|
||||
labels=["device", "port", "channel", "component_name"],
|
||||
)
|
||||
ch_tx_power = GaugeMetricFamily(
|
||||
"transceiver_channel_tx_power_dbm",
|
||||
"Transmit optical power per channel (dBm)",
|
||||
labels=["device", "port", "channel", "component_name"],
|
||||
)
|
||||
ch_bias = GaugeMetricFamily(
|
||||
"transceiver_channel_bias_current_ma",
|
||||
"Laser bias current per channel (mA)",
|
||||
labels=["device", "port", "channel", "component_name"],
|
||||
)
|
||||
ch_laser_temp = GaugeMetricFamily(
|
||||
"transceiver_channel_laser_temperature_celsius",
|
||||
"Laser temperature per channel (Celsius)",
|
||||
labels=["device", "port", "channel", "component_name"],
|
||||
)
|
||||
|
||||
tx_info = InfoMetricFamily(
|
||||
"transceiver_info",
|
||||
"Transceiver static information",
|
||||
labels=["device", "port", "component_name", "vendor", "serial", "form_factor", "part_number", "hardware_rev"],
|
||||
)
|
||||
ch_info = InfoMetricFamily(
|
||||
"transceiver_channel_info",
|
||||
"Transceiver channel info",
|
||||
labels=["device", "port", "channel", "channel_index", "component_name"],
|
||||
)
|
||||
|
||||
# 为 last_scrape_success==True 的设备导出业务指标
|
||||
for device, snapshot in cache_items:
|
||||
hs = health_map.get(device)
|
||||
if not hs or not hs.last_scrape_success:
|
||||
continue
|
||||
|
||||
# transceiver 指标
|
||||
for t in snapshot.transceivers:
|
||||
labels = [t.device, t.logical_port, t.component_name]
|
||||
if t.temperature_c is not None:
|
||||
tx_temp.add_metric(labels, t.temperature_c)
|
||||
if t.supply_voltage_v is not None:
|
||||
tx_supply_v.add_metric(labels, t.supply_voltage_v)
|
||||
if t.present is not None:
|
||||
present_val = 1.0 if t.present.upper() == "PRESENT" else 0.0
|
||||
tx_present.add_metric(labels, present_val)
|
||||
|
||||
# Info
|
||||
info_labels = [
|
||||
t.device,
|
||||
t.logical_port,
|
||||
t.component_name,
|
||||
t.vendor or "",
|
||||
t.serial or "",
|
||||
"", # form_factor 留空占位
|
||||
t.part_number or "",
|
||||
t.hardware_rev or "",
|
||||
]
|
||||
tx_info.add_metric(info_labels, {})
|
||||
|
||||
# channel 指标
|
||||
for ch in snapshot.channels:
|
||||
labels = [ch.device, ch.logical_port, ch.logical_channel, ch.component_name]
|
||||
if ch.rx_power_dbm is not None:
|
||||
ch_rx_power.add_metric(labels, ch.rx_power_dbm)
|
||||
if ch.tx_power_dbm is not None:
|
||||
ch_tx_power.add_metric(labels, ch.tx_power_dbm)
|
||||
if ch.bias_current_ma is not None:
|
||||
ch_bias.add_metric(labels, ch.bias_current_ma)
|
||||
if ch.laser_temperature_c is not None:
|
||||
ch_laser_temp.add_metric(labels, ch.laser_temperature_c)
|
||||
|
||||
ch_info.add_metric(
|
||||
[
|
||||
ch.device,
|
||||
ch.logical_port,
|
||||
ch.logical_channel,
|
||||
str(ch.channel_index),
|
||||
ch.component_name,
|
||||
],
|
||||
{},
|
||||
)
|
||||
|
||||
# 依次 yield 所有指标
|
||||
yield scrape_duration
|
||||
yield scrape_success
|
||||
yield last_scrape_ts
|
||||
yield staleness
|
||||
yield errors_total
|
||||
|
||||
yield tx_temp
|
||||
yield tx_supply_v
|
||||
yield tx_present
|
||||
yield ch_rx_power
|
||||
yield ch_tx_power
|
||||
yield ch_bias
|
||||
yield ch_laser_temp
|
||||
yield tx_info
|
||||
yield ch_info
|
||||
|
||||
68
src/exporter/models.py
Normal file
68
src/exporter/models.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransceiverRecord:
|
||||
device: str
|
||||
component_name: str
|
||||
logical_port: str
|
||||
present: Optional[str] = None
|
||||
oper_status: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
supply_voltage_v: Optional[float] = None
|
||||
vendor: Optional[str] = None
|
||||
serial: Optional[str] = None
|
||||
part_number: Optional[str] = None
|
||||
hardware_rev: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransceiverChannelRecord:
|
||||
device: str
|
||||
component_name: str
|
||||
logical_port: str
|
||||
channel_index: int
|
||||
logical_channel: str
|
||||
rx_power_dbm: Optional[float] = None
|
||||
tx_power_dbm: Optional[float] = None
|
||||
bias_current_ma: Optional[float] = None
|
||||
laser_temperature_c: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceMetricsSnapshot:
|
||||
device: str
|
||||
collected_at: float
|
||||
transceivers: Tuple[TransceiverRecord, ...]
|
||||
channels: Tuple[TransceiverChannelRecord, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceHealthState:
|
||||
last_scrape_success: Optional[bool] = None
|
||||
last_scrape_duration: Optional[float] = None
|
||||
last_scrape_timestamp: Optional[float] = None
|
||||
last_error_type: Optional[str] = None
|
||||
consecutive_failures: int = 0
|
||||
|
||||
|
||||
INT32_MAX = 2147483647
|
||||
|
||||
|
||||
def is_invalid_value(value: Optional[float]) -> bool:
|
||||
"""
|
||||
Determine whether a parsed numeric value should be treated as invalid.
|
||||
|
||||
Per设计 v9: 精确匹配 INT32_MAX 即视为无效。
|
||||
"""
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return int(value) == INT32_MAX
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return False
|
||||
|
||||
226
src/exporter/netconf_client.py
Normal file
226
src/exporter/netconf_client.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
from .models import (
|
||||
TransceiverChannelRecord,
|
||||
TransceiverRecord,
|
||||
is_invalid_value,
|
||||
)
|
||||
|
||||
NS = {
|
||||
"oc-platform": "http://openconfig.net/yang/platform",
|
||||
"oc-transceiver": "http://openconfig.net/yang/platform/transceiver",
|
||||
}
|
||||
|
||||
|
||||
def build_transceiver_filter() -> str:
|
||||
"""
|
||||
构造 subtree filter 的 XML 片段(不包含外层 <filter> 元素),
|
||||
用于一次性获取所有包含 transceiver 的 component 及其 state。
|
||||
"""
|
||||
return (
|
||||
'<components xmlns="http://openconfig.net/yang/platform">'
|
||||
"<component>"
|
||||
'<transceiver xmlns="http://openconfig.net/yang/platform/transceiver"/>'
|
||||
"<state/>"
|
||||
"</component>"
|
||||
"</components>"
|
||||
)
|
||||
|
||||
|
||||
def parse_port_and_channel(
|
||||
description: str | None,
|
||||
component_name: str,
|
||||
channel_index: int,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
从 description 中解析 (logical_port, logical_channel),并在异常时提供安全 fallback。
|
||||
- 正常格式: "1/0/66:1" -> ("1/0/66", "1/0/66:1")
|
||||
- description 为空/缺失: 使用 (component_name, f"{component_name}:ch{index}")
|
||||
- 其他格式: logical_port = description; logical_channel = f"{description}:ch{index}"
|
||||
"""
|
||||
if not description:
|
||||
logical_port = component_name
|
||||
logical_channel = f"{component_name}:ch{channel_index}"
|
||||
return logical_port, logical_channel
|
||||
try:
|
||||
if ":" in description:
|
||||
port, _ = description.split(":", 1)
|
||||
logical_port = port or component_name
|
||||
logical_channel = description
|
||||
else:
|
||||
logical_port = description
|
||||
logical_channel = f"{description}:ch{channel_index}"
|
||||
return logical_port, logical_channel
|
||||
except Exception:
|
||||
logical_port = component_name
|
||||
logical_channel = f"{component_name}:ch{channel_index}"
|
||||
return logical_port, logical_channel
|
||||
|
||||
|
||||
def _get_text(elem: ET.Element | None) -> str | None:
|
||||
if elem is None:
|
||||
return None
|
||||
text = (elem.text or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _parse_float(elem: ET.Element | None) -> float | None:
|
||||
text = _get_text(elem)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if is_invalid_value(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def parse_netconf_response(
|
||||
xml_str: str,
|
||||
device_name: str,
|
||||
) -> Tuple[List[TransceiverRecord], List[TransceiverChannelRecord]]:
|
||||
"""
|
||||
解析 NETCONF `<get>` RPC 返回的 XML,生成 transceiver 与 channel 记录。
|
||||
"""
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
tx_records: List[TransceiverRecord] = []
|
||||
ch_records: List[TransceiverChannelRecord] = []
|
||||
|
||||
components_path = ".//oc-platform:components/oc-platform:component"
|
||||
for comp in root.findall(components_path, NS):
|
||||
name_elem = comp.find("oc-platform:name", NS)
|
||||
component_name = _get_text(name_elem) or ""
|
||||
|
||||
# 类型检查:只保留 TRANSCEIVER
|
||||
type_elem = comp.find("oc-platform:state/oc-platform:type", NS)
|
||||
comp_type = _get_text(type_elem)
|
||||
if comp_type and "TRANSCEIVER" not in comp_type:
|
||||
continue
|
||||
|
||||
# transceiver state
|
||||
tx_state = comp.find(
|
||||
"oc-transceiver:transceiver/oc-transceiver:state", NS
|
||||
)
|
||||
|
||||
present = _get_text(
|
||||
tx_state.find("oc-transceiver:present", NS) if tx_state is not None else None
|
||||
)
|
||||
vendor = _get_text(
|
||||
tx_state.find("oc-transceiver:vendor", NS) if tx_state is not None else None
|
||||
)
|
||||
serial = _get_text(
|
||||
tx_state.find("oc-transceiver:serial-no", NS) if tx_state is not None else None
|
||||
)
|
||||
part_number = _get_text(
|
||||
tx_state.find("oc-transceiver:vendor-part", NS) if tx_state is not None else None
|
||||
)
|
||||
hardware_rev = _get_text(
|
||||
tx_state.find("oc-transceiver:hardware-version", NS) if tx_state is not None else None
|
||||
)
|
||||
|
||||
oper_status = _get_text(comp.find("oc-platform:state/oc-platform:oper-status", NS))
|
||||
|
||||
# 组件温度
|
||||
temp_elem = comp.find(
|
||||
"oc-platform:state/oc-platform:temperature/oc-platform:instant",
|
||||
NS,
|
||||
)
|
||||
temperature_c = _parse_float(temp_elem)
|
||||
|
||||
# 电压(transceiver state 下)
|
||||
voltage_elem = None
|
||||
if tx_state is not None:
|
||||
voltage_elem = (
|
||||
tx_state.find("oc-transceiver:voltage/oc-transceiver:instant", NS)
|
||||
or tx_state.find("oc-transceiver:supply-voltage/oc-transceiver:instant", NS)
|
||||
)
|
||||
supply_voltage_v = _parse_float(voltage_elem)
|
||||
|
||||
# 遍历 channel
|
||||
channels_path = (
|
||||
"oc-transceiver:transceiver/oc-transceiver:physical-channels/"
|
||||
"oc-transceiver:channel"
|
||||
)
|
||||
channel_elems: Iterable[ET.Element] = comp.findall(channels_path, NS)
|
||||
|
||||
# logical_port 以第一个 channel 的 description 为主,fallback 到 component_name
|
||||
logical_port_for_tx: str | None = None
|
||||
|
||||
for ch in channel_elems:
|
||||
idx_elem = ch.find("oc-transceiver:index", NS)
|
||||
idx_text = _get_text(idx_elem)
|
||||
try:
|
||||
ch_index = int(idx_text) if idx_text is not None else 0
|
||||
except ValueError:
|
||||
ch_index = 0
|
||||
|
||||
desc_elem = ch.find("oc-transceiver:state/oc-transceiver:description", NS)
|
||||
description = _get_text(desc_elem)
|
||||
logical_port, logical_channel = parse_port_and_channel(
|
||||
description, component_name, ch_index
|
||||
)
|
||||
if logical_port_for_tx is None:
|
||||
logical_port_for_tx = logical_port
|
||||
|
||||
state = ch.find("oc-transceiver:state", NS)
|
||||
rx_power_dbm = _parse_float(
|
||||
state.find("oc-transceiver:input-power/oc-transceiver:instant", NS)
|
||||
if state is not None
|
||||
else None
|
||||
)
|
||||
tx_power_dbm = _parse_float(
|
||||
state.find("oc-transceiver:output-power/oc-transceiver:instant", NS)
|
||||
if state is not None
|
||||
else None
|
||||
)
|
||||
bias_current_ma = _parse_float(
|
||||
state.find("oc-transceiver:laser-bias-current/oc-transceiver:instant", NS)
|
||||
if state is not None
|
||||
else None
|
||||
)
|
||||
laser_temperature_c = _parse_float(
|
||||
state.find("oc-transceiver:laser-temperature/oc-transceiver:instant", NS)
|
||||
if state is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ch_records.append(
|
||||
TransceiverChannelRecord(
|
||||
device=device_name,
|
||||
component_name=component_name,
|
||||
logical_port=logical_port,
|
||||
channel_index=ch_index,
|
||||
logical_channel=logical_channel,
|
||||
rx_power_dbm=rx_power_dbm,
|
||||
tx_power_dbm=tx_power_dbm,
|
||||
bias_current_ma=bias_current_ma,
|
||||
laser_temperature_c=laser_temperature_c,
|
||||
)
|
||||
)
|
||||
|
||||
# transceiver record(逻辑端口)
|
||||
logical_port_tx = logical_port_for_tx or component_name
|
||||
|
||||
tx_records.append(
|
||||
TransceiverRecord(
|
||||
device=device_name,
|
||||
component_name=component_name,
|
||||
logical_port=logical_port_tx,
|
||||
present=present,
|
||||
oper_status=oper_status,
|
||||
temperature_c=temperature_c,
|
||||
supply_voltage_v=supply_voltage_v,
|
||||
vendor=vendor,
|
||||
serial=serial,
|
||||
part_number=part_number,
|
||||
hardware_rev=hardware_rev,
|
||||
)
|
||||
)
|
||||
|
||||
return tx_records, ch_records
|
||||
96
src/exporter/registry.py
Normal file
96
src/exporter/registry.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .config import DeviceConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceRuntimeState:
|
||||
cfg: DeviceConfig
|
||||
next_scrape_at: float
|
||||
consecutive_failures: int = 0
|
||||
backoff_factor: int = 1
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
"""维护设备配置与运行时调度状态."""
|
||||
|
||||
def __init__(self, global_scrape_interval: int = 60) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._states: Dict[str, DeviceRuntimeState] = {}
|
||||
self._global_scrape_interval = global_scrape_interval
|
||||
|
||||
# --- 注册与删除 ---
|
||||
|
||||
def register_static_device(self, cfg: DeviceConfig) -> None:
|
||||
self._register_device(cfg)
|
||||
|
||||
def register_runtime_device(self, cfg: DeviceConfig) -> None:
|
||||
cfg.source = "runtime"
|
||||
self._register_device(cfg)
|
||||
|
||||
def _register_device(self, cfg: DeviceConfig) -> None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
interval = cfg.scrape_interval_seconds or self._global_scrape_interval
|
||||
state = DeviceRuntimeState(
|
||||
cfg=cfg,
|
||||
next_scrape_at=now, # 初始立即可采集
|
||||
)
|
||||
self._states[cfg.name] = state
|
||||
|
||||
def delete_runtime_device(self, name: str) -> None:
|
||||
with self._lock:
|
||||
state = self._states.get(name)
|
||||
if state is not None and state.cfg.source == "runtime":
|
||||
del self._states[name]
|
||||
|
||||
# --- 查询 ---
|
||||
|
||||
def list_devices(self) -> List[DeviceConfig]:
|
||||
with self._lock:
|
||||
return [s.cfg for s in self._states.values()]
|
||||
|
||||
def get_enabled_devices(self, now: float) -> List[DeviceRuntimeState]:
|
||||
with self._lock:
|
||||
return [
|
||||
s
|
||||
for s in self._states.values()
|
||||
if s.cfg.enabled and s.next_scrape_at <= now
|
||||
]
|
||||
|
||||
# --- 调度更新 ---
|
||||
|
||||
def update_after_scrape(
|
||||
self,
|
||||
device_name: str,
|
||||
success: bool,
|
||||
now: float,
|
||||
scrape_interval: int,
|
||||
failure_threshold: int,
|
||||
max_backoff_factor: int,
|
||||
) -> None:
|
||||
"""Scraper 在单次采集后调用,更新运行状态."""
|
||||
with self._lock:
|
||||
state = self._states.get(device_name)
|
||||
if state is None:
|
||||
return
|
||||
|
||||
interval = state.cfg.scrape_interval_seconds or scrape_interval
|
||||
|
||||
if success:
|
||||
state.consecutive_failures = 0
|
||||
state.backoff_factor = 1
|
||||
state.next_scrape_at = now + interval
|
||||
else:
|
||||
state.consecutive_failures += 1
|
||||
if state.consecutive_failures >= failure_threshold:
|
||||
state.backoff_factor = min(
|
||||
max_backoff_factor, max(2, state.backoff_factor * 2)
|
||||
)
|
||||
state.next_scrape_at = now + interval * state.backoff_factor
|
||||
|
||||
148
src/exporter/scraper.py
Normal file
148
src/exporter/scraper.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from .config import DeviceConfig, GlobalConfig
|
||||
from .errors import classify_error
|
||||
from .models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
from .netconf_client import parse_netconf_response, build_transceiver_filter
|
||||
from .registry import DeviceRegistry, DeviceRuntimeState
|
||||
from .connection import ConnectionManager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scraper_loop(
|
||||
stop_event: threading.Event,
|
||||
registry: DeviceRegistry,
|
||||
connection_manager: ConnectionManager,
|
||||
netconf_get_rpc,
|
||||
cache: Dict[str, DeviceMetricsSnapshot],
|
||||
health: Dict[str, DeviceHealthState],
|
||||
global_cfg: GlobalConfig,
|
||||
) -> None:
|
||||
"""
|
||||
Scraper 主循环。
|
||||
|
||||
- 使用固定 next_run 调度,避免时间漂移;
|
||||
- 在等待期间通过 stop_event.wait() 支持快速退出。
|
||||
- netconf_get_rpc: 可调用对象,签名为 (manager, filter_xml) -> str(返回 rpc-reply XML 字符串)。
|
||||
"""
|
||||
interval = global_cfg.scrape_interval_seconds
|
||||
failure_threshold = global_cfg.failure_threshold
|
||||
max_backoff = global_cfg.max_backoff_factor
|
||||
|
||||
next_run = time.time()
|
||||
while not stop_event.is_set():
|
||||
now = time.time()
|
||||
if now >= next_run:
|
||||
run_one_scrape_round(
|
||||
now,
|
||||
registry,
|
||||
connection_manager,
|
||||
netconf_get_rpc,
|
||||
cache,
|
||||
health,
|
||||
global_cfg,
|
||||
failure_threshold,
|
||||
max_backoff,
|
||||
)
|
||||
next_run += interval
|
||||
|
||||
remaining = max(0.0, next_run - time.time())
|
||||
if stop_event.wait(timeout=remaining):
|
||||
break
|
||||
|
||||
|
||||
def run_one_scrape_round(
|
||||
now: float,
|
||||
registry: DeviceRegistry,
|
||||
connection_manager: ConnectionManager,
|
||||
netconf_get_rpc,
|
||||
cache: Dict[str, DeviceMetricsSnapshot],
|
||||
health: Dict[str, DeviceHealthState],
|
||||
global_cfg: GlobalConfig,
|
||||
failure_threshold: int,
|
||||
max_backoff_factor: int,
|
||||
) -> None:
|
||||
"""执行一轮对需要采集设备的采集."""
|
||||
devices = registry.get_enabled_devices(now)
|
||||
for state in devices:
|
||||
scrape_device(
|
||||
now,
|
||||
state,
|
||||
registry,
|
||||
connection_manager,
|
||||
netconf_get_rpc,
|
||||
cache,
|
||||
health,
|
||||
global_cfg,
|
||||
failure_threshold,
|
||||
max_backoff_factor,
|
||||
)
|
||||
|
||||
|
||||
def scrape_device(
|
||||
now: float,
|
||||
state: DeviceRuntimeState,
|
||||
registry: DeviceRegistry,
|
||||
connection_manager: ConnectionManager,
|
||||
netconf_get_rpc,
|
||||
cache: Dict[str, DeviceMetricsSnapshot],
|
||||
health: Dict[str, DeviceHealthState],
|
||||
global_cfg: GlobalConfig,
|
||||
failure_threshold: int,
|
||||
max_backoff_factor: int,
|
||||
) -> None:
|
||||
"""采集单台设备的指标,并更新 cache/health/registry."""
|
||||
device = state.cfg.name
|
||||
start = time.time()
|
||||
success = False
|
||||
error_type = None
|
||||
|
||||
if device not in health:
|
||||
health[device] = DeviceHealthState()
|
||||
|
||||
try:
|
||||
mgr = connection_manager.acquire_session(state.cfg)
|
||||
# 构造 filter 并调用外部提供的 RPC 函数
|
||||
flt = build_transceiver_filter()
|
||||
xml_reply = netconf_get_rpc(mgr, flt)
|
||||
tx_records, ch_records = parse_netconf_response(xml_reply, device)
|
||||
|
||||
snapshot = DeviceMetricsSnapshot(
|
||||
device=device,
|
||||
collected_at=now,
|
||||
transceivers=tuple(tx_records),
|
||||
channels=tuple(ch_records),
|
||||
)
|
||||
cache[device] = snapshot
|
||||
success = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error_type = classify_error(exc)
|
||||
# 记录异常以便排查(包括堆栈与 error_type)
|
||||
logger.exception(
|
||||
"NETCONF scrape failed for device %s (error_type=%s)", device, error_type
|
||||
)
|
||||
# 标记会话失效,避免下次继续使用坏连接
|
||||
connection_manager.mark_session_invalid(device)
|
||||
finally:
|
||||
duration = time.time() - start
|
||||
hs = health[device]
|
||||
hs.last_scrape_success = success
|
||||
hs.last_scrape_duration = duration
|
||||
hs.last_scrape_timestamp = now
|
||||
hs.last_error_type = error_type
|
||||
|
||||
registry.update_after_scrape(
|
||||
device_name=device,
|
||||
success=success,
|
||||
now=now,
|
||||
scrape_interval=global_cfg.scrape_interval_seconds,
|
||||
failure_threshold=failure_threshold,
|
||||
max_backoff_factor=max_backoff_factor,
|
||||
)
|
||||
181
src/exporter/sqlite_store.py
Normal file
181
src/exporter/sqlite_store.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Iterable, List
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from .config import DeviceConfig
|
||||
|
||||
|
||||
class PasswordEncryptor:
|
||||
"""对称加密/解密设备密码的封装."""
|
||||
|
||||
def __init__(self, secret: str) -> None:
|
||||
"""
|
||||
:param secret: Fernet key 字符串(32-byte URL-safe base64,长度 44)
|
||||
"""
|
||||
try:
|
||||
key_bytes = secret.encode("utf-8")
|
||||
self._fernet = Fernet(key_bytes)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("Invalid Fernet key in password_secret") from exc
|
||||
|
||||
def encrypt(self, password: str) -> bytes:
|
||||
return self._fernet.encrypt(password.encode("utf-8"))
|
||||
|
||||
def decrypt(self, cipher: bytes) -> str:
|
||||
return self._fernet.decrypt(cipher).decode("utf-8")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SQLiteDeviceStore:
|
||||
"""管理 runtime 设备持久化的 SQLite 存储."""
|
||||
|
||||
db_path: str
|
||||
encryptor: PasswordEncryptor
|
||||
timeout: float = 5.0
|
||||
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def init_db(self) -> None:
|
||||
"""初始化 DB:设置 WAL 模式并创建 devices 表."""
|
||||
conn = sqlite3.connect(self.db_path, timeout=self.timeout)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
name TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_cipher BLOB NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
scrape_interval_seconds INTEGER,
|
||||
supports_xpath INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# internal helper
|
||||
def _with_conn(self, fn: Callable[[sqlite3.Connection], object]) -> object:
|
||||
conn = sqlite3.connect(self.db_path, timeout=self.timeout)
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
return fn(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def save_device(self, cfg: DeviceConfig) -> None:
|
||||
"""保存或更新 runtime 设备."""
|
||||
cipher = self.encryptor.encrypt(cfg.password)
|
||||
now_ts = int(time.time())
|
||||
|
||||
def _do(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO devices (
|
||||
name, host, port, username, password_cipher, enabled,
|
||||
scrape_interval_seconds, supports_xpath,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
COALESCE(
|
||||
(SELECT created_at FROM devices WHERE name=?),
|
||||
?
|
||||
),
|
||||
?
|
||||
);
|
||||
""",
|
||||
(
|
||||
cfg.name,
|
||||
cfg.host,
|
||||
cfg.port,
|
||||
cfg.username,
|
||||
cipher,
|
||||
int(cfg.enabled),
|
||||
cfg.scrape_interval_seconds,
|
||||
int(cfg.supports_xpath),
|
||||
cfg.name,
|
||||
now_ts,
|
||||
now_ts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
# 串行化写入,避免并发竞争
|
||||
with self.lock:
|
||||
self._with_conn(_do)
|
||||
|
||||
def delete_device(self, name: str) -> None:
|
||||
"""删除 runtime 设备;不存在时静默忽略."""
|
||||
|
||||
def _do(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("DELETE FROM devices WHERE name = ?", (name,))
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
with self.lock:
|
||||
self._with_conn(_do)
|
||||
|
||||
def load_runtime_devices(self) -> List[DeviceConfig]:
|
||||
"""加载所有 runtime 设备."""
|
||||
|
||||
def _do(conn: sqlite3.Connection) -> List[DeviceConfig]:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
name, host, port, username, password_cipher,
|
||||
enabled, scrape_interval_seconds, supports_xpath
|
||||
FROM devices;
|
||||
"""
|
||||
)
|
||||
rows: Iterable[sqlite3.Row] = cur.fetchall()
|
||||
devices: List[DeviceConfig] = []
|
||||
for (
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password_cipher,
|
||||
enabled,
|
||||
scrape_interval_seconds,
|
||||
supports_xpath,
|
||||
) in rows:
|
||||
password = self.encryptor.decrypt(password_cipher)
|
||||
dev = DeviceConfig(
|
||||
name=name,
|
||||
host=host,
|
||||
port=int(port),
|
||||
username=username,
|
||||
password=password,
|
||||
enabled=bool(enabled),
|
||||
scrape_interval_seconds=scrape_interval_seconds,
|
||||
supports_xpath=bool(supports_xpath),
|
||||
source="runtime",
|
||||
)
|
||||
devices.append(dev)
|
||||
return devices
|
||||
|
||||
return self._with_conn(_do) # type: ignore[return-value]
|
||||
|
||||
def close(self) -> None:
|
||||
"""兼容接口;当前使用短连接,每次操作后即关闭."""
|
||||
return
|
||||
Reference in New Issue
Block a user