first commit

This commit is contained in:
2025-11-28 14:35:21 +08:00
commit c9634f672a
195 changed files with 73003 additions and 0 deletions

6
src/exporter/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
NETCONF Transceiver Exporter package.
Modules are implemented following the design in specs/transceiver-exporter-design-v9.md.
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

181
src/exporter/api.py Normal file
View 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
View 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,
)

View 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
View 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"

View 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
View 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
View 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
View 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

View 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
View 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
View 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,
)

View 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