[#2] 增加ruijie支持,注册接口增加vendor类型
This commit is contained in:
parent
79afe4fbfc
commit
475bce46f6
58
README.md
58
README.md
@ -160,6 +160,17 @@ global:
|
||||
log_file: "" # 若非空,则写入指定文件
|
||||
|
||||
devices: [] # 静态设备先留空,通过 API 动态注册
|
||||
|
||||
# 如需在配置文件中声明静态设备,可使用如下结构:
|
||||
# devices:
|
||||
# - name: h3c-static-1
|
||||
# host: 192.168.1.10
|
||||
# port: 830
|
||||
# username: netconf_user
|
||||
# password: "******"
|
||||
# enabled: true
|
||||
# supports_xpath: false
|
||||
# vendor: "h3c" # 可选,多厂商解析时用于选择 H3C 解析策略
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -204,7 +215,7 @@ curl -s http://127.0.0.1:19100/healthz
|
||||
|
||||
---
|
||||
|
||||
## 7. 通过 curl 注册 H3C 设备(runtime device)
|
||||
## 7. 通过 curl 注册设备(runtime device)
|
||||
|
||||
假设已经准备好 H3C 的 NETCONF 代理:
|
||||
|
||||
@ -226,7 +237,9 @@ curl -s -X POST \
|
||||
"username": "netconf_user",
|
||||
"password": "NASPLab123!",
|
||||
"enabled": true,
|
||||
"supports_xpath": false
|
||||
"supports_xpath": false,
|
||||
"scrape_interval_seconds": null,
|
||||
"vendor": "h3c"
|
||||
}' \
|
||||
http://127.0.0.1:19100/api/v1/devices
|
||||
```
|
||||
@ -241,7 +254,8 @@ curl -s -X POST \
|
||||
"enabled": true,
|
||||
"scrape_interval_seconds": null,
|
||||
"supports_xpath": false,
|
||||
"source": "runtime"
|
||||
"source": "runtime",
|
||||
"vendor": "h3c"
|
||||
}
|
||||
```
|
||||
|
||||
@ -251,7 +265,43 @@ curl -s -X POST \
|
||||
curl -s -H "X-API-Token: changeme" http://127.0.0.1:19100/api/v1/devices
|
||||
```
|
||||
|
||||
确认设备已注册(包含 `source: "runtime"`)。
|
||||
确认设备已注册(包含 `source: "runtime"` 和 `vendor: "h3c"`)。
|
||||
|
||||
### 7.1 注册 Ruijie 设备(示例)
|
||||
|
||||
如果已经在 `.env` 中配置了 Ruijie 的 NETCONF 代理(例如:`RUIJIE_NETCONF_HOST=127.0.0.1`、`RUIJIE_NETCONF_PORT=9830` 等),可以类似地注册一个 Ruijie 设备:
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Token: changeme" \
|
||||
-d '{
|
||||
"name": "ruijie-live-1",
|
||||
"host": "127.0.0.1",
|
||||
"port": 9830,
|
||||
"username": "ruijie1-admin",
|
||||
"password": "******",
|
||||
"enabled": true,
|
||||
"supports_xpath": false,
|
||||
"vendor": " Ruijie "
|
||||
}' \
|
||||
http://127.0.0.1:19100/api/v1/devices
|
||||
```
|
||||
|
||||
API 会自动将 `vendor` 规范化为小写、去掉首尾空格,返回类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ruijie-live-1",
|
||||
"host": "127.0.0.1",
|
||||
"port": 9830,
|
||||
"enabled": true,
|
||||
"scrape_interval_seconds": null,
|
||||
"supports_xpath": false,
|
||||
"source": "runtime",
|
||||
"vendor": "ruijie"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
300
docs/ruijie_netconf_components_with_transceiver.md
Normal file
300
docs/ruijie_netconf_components_with_transceiver.md
Normal file
@ -0,0 +1,300 @@
|
||||
./run_yangcli.sh "sget /oc-platform:components/oc-platform:component/oc-transceiver:transceiver"
|
||||
|
||||
rpc-reply {
|
||||
data {
|
||||
components {
|
||||
component TRANSCEIVER-1/0/1-Te0/1 {
|
||||
name TRANSCEIVER-1/0/1-Te0/1
|
||||
transceiver {
|
||||
config {
|
||||
enabled true
|
||||
}
|
||||
state {
|
||||
enabled true
|
||||
present NOT_PRESENT
|
||||
}
|
||||
}
|
||||
}
|
||||
component TRANSCEIVER-1/0/2-Te0/2 {
|
||||
name TRANSCEIVER-1/0/2-Te0/2
|
||||
transceiver {
|
||||
config {
|
||||
enabled true
|
||||
}
|
||||
state {
|
||||
enabled true
|
||||
present NOT_PRESENT
|
||||
}
|
||||
}
|
||||
}
|
||||
component {
|
||||
name TRANSCEIVER-1/0/129-FH0/1:1
|
||||
transceiver {
|
||||
config {
|
||||
enabled true
|
||||
}
|
||||
state {
|
||||
enabled true
|
||||
present PRESENT
|
||||
form-factor openconfig-transport-types:QSFP112
|
||||
connector-type oc-opt-types:MPO_CONNECTOR
|
||||
vendor H3C
|
||||
vendor-part EQ854HG01M3-H3C
|
||||
vendor-rev 03
|
||||
ethernet-pmd oc-opt-types:ETH_UNDEFINED
|
||||
serial-no G80231AM995701FK
|
||||
date-code 2025-07-09T00:00:00Z-08:00
|
||||
supply-voltage {
|
||||
instant 3.31
|
||||
}
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
voltage {
|
||||
instant 3.31
|
||||
}
|
||||
}
|
||||
physical-channels {
|
||||
channel 1 {
|
||||
index 1
|
||||
state {
|
||||
index 1
|
||||
description TRANSCEIVER-1/0/129/1-FH0/1:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 2 {
|
||||
index 2
|
||||
state {
|
||||
index 2
|
||||
description TRANSCEIVER-1/0/129/2-FH0/1:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 3 {
|
||||
index 3
|
||||
state {
|
||||
index 3
|
||||
description TRANSCEIVER-1/0/129/3-FH0/1:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 4 {
|
||||
index 4
|
||||
state {
|
||||
index 4
|
||||
description TRANSCEIVER-1/0/129/4-FH0/1:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component {
|
||||
name TRANSCEIVER-1/0/130-FH0/1:2
|
||||
transceiver {
|
||||
config {
|
||||
enabled true
|
||||
}
|
||||
state {
|
||||
enabled true
|
||||
present PRESENT
|
||||
form-factor openconfig-transport-types:QSFP112
|
||||
connector-type oc-opt-types:MPO_CONNECTOR
|
||||
vendor H3C
|
||||
vendor-part EQ854HG01M3-H3C
|
||||
vendor-rev 03
|
||||
ethernet-pmd oc-opt-types:ETH_UNDEFINED
|
||||
serial-no G80231AM995701FK
|
||||
date-code 2025-07-09T00:00:00Z-08:00
|
||||
supply-voltage {
|
||||
instant 3.31
|
||||
}
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
voltage {
|
||||
instant 3.31
|
||||
}
|
||||
}
|
||||
physical-channels {
|
||||
channel 1 {
|
||||
index 1
|
||||
state {
|
||||
index 1
|
||||
description TRANSCEIVER-1/0/130/1-FH0/1:2
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 2 {
|
||||
index 2
|
||||
state {
|
||||
index 2
|
||||
description TRANSCEIVER-1/0/130/2-FH0/1:2
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 3 {
|
||||
index 3
|
||||
state {
|
||||
index 3
|
||||
description TRANSCEIVER-1/0/130/3-FH0/1:2
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 4 {
|
||||
index 4
|
||||
state {
|
||||
index 4
|
||||
description TRANSCEIVER-1/0/130/4-FH0/1:2
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.50
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component {
|
||||
name TRANSCEIVER-1/0/131-FH0/2:1
|
||||
transceiver {
|
||||
config {
|
||||
enabled true
|
||||
}
|
||||
state {
|
||||
enabled true
|
||||
present PRESENT
|
||||
form-factor openconfig-transport-types:QSFP112
|
||||
connector-type oc-opt-types:MPO_CONNECTOR
|
||||
vendor H3C
|
||||
vendor-part EQ854HG01M3-H3C
|
||||
vendor-rev 03
|
||||
ethernet-pmd oc-opt-types:ETH_UNDEFINED
|
||||
serial-no G80231AM995701J8
|
||||
date-code 2025-07-09T00:00:00Z-08:00
|
||||
supply-voltage {
|
||||
instant 3.32
|
||||
}
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.47
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
voltage {
|
||||
instant 3.32
|
||||
}
|
||||
}
|
||||
physical-channels {
|
||||
channel 1 {
|
||||
index 1
|
||||
state {
|
||||
index 1
|
||||
description TRANSCEIVER-1/0/131/1-FH0/2:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.47
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 2 {
|
||||
index 2
|
||||
state {
|
||||
index 2
|
||||
description TRANSCEIVER-1/0/131/2-FH0/2:1
|
||||
output-power {
|
||||
instant -40.00
|
||||
}
|
||||
input-power {
|
||||
instant 1.47
|
||||
}
|
||||
laser-bias-current {
|
||||
instant 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
channel 3 {
|
||||
index 3
|
||||
state {
|
||||
@ -2,3 +2,4 @@
|
||||
markers =
|
||||
h3c_live: tests that talk to a live H3C NETCONF device
|
||||
http_e2e: end-to-end tests that start the full HTTP server in a subprocess
|
||||
ruijie_live: tests that talk to a live Ruijie NETCONF device
|
||||
|
||||
26
scripts/register_device.sh
Normal file
26
scripts/register_device.sh
Normal file
@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" -H "X-API-Token: changeme" -d '{
|
||||
"name": "h3c-live-1",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8830,
|
||||
"username": "netconf_user",
|
||||
"password": "NASPLab123!",
|
||||
"enabled": true,
|
||||
"supports_xpath": false,
|
||||
"scrape_interval_seconds": null,
|
||||
"vendor": "h3c"
|
||||
}' http://127.0.0.1:19100/api/v1/devices
|
||||
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" -H "X-API-Token: changeme" -d '{
|
||||
"name": "ruijie-live-1",
|
||||
"host": "127.0.0.1",
|
||||
"port": 9830,
|
||||
"username": "ruijie1-admin",
|
||||
"password": "1qw2#ER$_ruijie",
|
||||
"enabled": true,
|
||||
"supports_xpath": false,
|
||||
"vendor": " Ruijie "
|
||||
}' http://127.0.0.1:19100/api/v1/devices
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
|
||||
# 本地8830 转发到h3c交换机830端口,经过c1服务器
|
||||
ssh -L 8830:192.168.19.11:830 yuyr@c1
|
||||
# 本地9830 转发到锐捷交换机830端口,经过c1服务器
|
||||
ssh -L 8830:192.168.19.11:830 \
|
||||
-L 9830:192.168.19.152:830 \
|
||||
yuyr@c1
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import sqlite3
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
from prometheus_client import CollectorRegistry, generate_latest
|
||||
|
||||
from .config import DeviceConfig, GlobalConfig
|
||||
@ -22,6 +23,15 @@ class DeviceIn(BaseModel):
|
||||
enabled: bool = True
|
||||
supports_xpath: bool = False
|
||||
scrape_interval_seconds: Optional[int] = None
|
||||
vendor: Optional[str] = None
|
||||
|
||||
@field_validator("vendor")
|
||||
@classmethod
|
||||
def normalize_vendor(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
v_str = str(v).strip().lower()
|
||||
return v_str or None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
@ -32,6 +42,7 @@ class DeviceOut(BaseModel):
|
||||
scrape_interval_seconds: Optional[int]
|
||||
supports_xpath: bool
|
||||
source: str
|
||||
vendor: Optional[str]
|
||||
|
||||
|
||||
def _require_token(
|
||||
@ -99,6 +110,7 @@ def create_app(
|
||||
scrape_interval_seconds=d.scrape_interval_seconds,
|
||||
supports_xpath=d.supports_xpath,
|
||||
source=d.source,
|
||||
vendor=d.vendor,
|
||||
)
|
||||
for d in devices
|
||||
]
|
||||
@ -127,6 +139,7 @@ def create_app(
|
||||
enabled=device.enabled,
|
||||
scrape_interval_seconds=device.scrape_interval_seconds,
|
||||
supports_xpath=device.supports_xpath,
|
||||
vendor=device.vendor,
|
||||
source="runtime",
|
||||
)
|
||||
# 持久化并注册到 registry
|
||||
@ -148,6 +161,7 @@ def create_app(
|
||||
scrape_interval_seconds=cfg.scrape_interval_seconds,
|
||||
supports_xpath=cfg.supports_xpath,
|
||||
source=cfg.source,
|
||||
vendor=cfg.vendor,
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
@ -178,4 +192,3 @@ def create_app(
|
||||
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@ -49,6 +49,8 @@ class DeviceConfig:
|
||||
enabled: bool = True
|
||||
scrape_interval_seconds: Optional[int] = None
|
||||
supports_xpath: bool = False
|
||||
# 设备厂商标识,例如 "h3c"、"ruijie"、"huawei" 等;若未设置则为 None
|
||||
vendor: Optional[str] = None
|
||||
source: str = "static" # "static" | "runtime"
|
||||
|
||||
|
||||
@ -100,6 +102,12 @@ class Config:
|
||||
def _load_devices(raw_list: list[dict[str, Any]]) -> List[DeviceConfig]:
|
||||
devices: List[DeviceConfig] = []
|
||||
for raw in raw_list:
|
||||
raw_vendor = raw.get("vendor")
|
||||
vendor: Optional[str]
|
||||
if raw_vendor is None:
|
||||
vendor = None
|
||||
else:
|
||||
vendor = str(raw_vendor).strip().lower() or None
|
||||
dev = DeviceConfig(
|
||||
name=str(raw["name"]),
|
||||
host=str(raw["host"]),
|
||||
@ -109,6 +117,7 @@ class Config:
|
||||
enabled=bool(raw.get("enabled", True)),
|
||||
scrape_interval_seconds=raw.get("scrape_interval_seconds"),
|
||||
supports_xpath=bool(raw.get("supports_xpath", False)),
|
||||
vendor=vendor,
|
||||
source="static",
|
||||
)
|
||||
devices.append(dev)
|
||||
@ -128,4 +137,3 @@ class Config:
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
@ -15,6 +18,20 @@ NS = {
|
||||
}
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RE_RUIJIE_CHANNEL = re.compile(
|
||||
r"^TRANSCEIVER-(?P<prefix>.+)/(?P<ch>\d+)-(?P<ifname>.+):(?P<subport>\d+)$"
|
||||
)
|
||||
|
||||
_RE_RUIJIE_COMPONENT = re.compile(
|
||||
r"^TRANSCEIVER-(?P<prefix>.+?)-(?P<ifname>.+):(?P<subport>\d+)$"
|
||||
)
|
||||
|
||||
_ruijie_warning_issued: set[str] = set()
|
||||
_ruijie_warning_lock = threading.Lock()
|
||||
|
||||
|
||||
def build_transceiver_filter() -> str:
|
||||
"""
|
||||
构造 subtree filter 的 XML 片段(不包含外层 <filter> 元素),
|
||||
@ -30,17 +47,12 @@ def build_transceiver_filter() -> str:
|
||||
)
|
||||
|
||||
|
||||
def parse_port_and_channel(
|
||||
def _parse_port_channel_h3c_or_default(
|
||||
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}"
|
||||
"""
|
||||
"""H3C 及默认设备的端口/通道解析策略."""
|
||||
if not description:
|
||||
logical_port = component_name
|
||||
logical_channel = f"{component_name}:ch{channel_index}"
|
||||
@ -60,6 +72,91 @@ def parse_port_and_channel(
|
||||
return logical_port, logical_channel
|
||||
|
||||
|
||||
def _parse_port_channel_ruijie(
|
||||
description: str | None,
|
||||
component_name: str,
|
||||
channel_index: int,
|
||||
device_name: str | None = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Ruijie 设备的端口/通道解析策略."""
|
||||
if not description:
|
||||
return _parse_port_channel_h3c_or_default(description, component_name, channel_index)
|
||||
|
||||
m = _RE_RUIJIE_CHANNEL.match(description)
|
||||
if not m:
|
||||
# 不符合 Ruijie 预期模式,退回默认策略
|
||||
return _parse_port_channel_h3c_or_default(description, component_name, channel_index)
|
||||
|
||||
ch_from_desc = int(m.group("ch"))
|
||||
ifname = m.group("ifname")
|
||||
subport = m.group("subport")
|
||||
|
||||
# 若 description 中的 ch 与 XML index 不一致,则记录 warning,便于定位数据异常
|
||||
if ch_from_desc != channel_index:
|
||||
logger.warning(
|
||||
"Ruijie channel index mismatch: description='%s' (ch=%d, subport=%s), xml_index=%d",
|
||||
description,
|
||||
ch_from_desc,
|
||||
subport,
|
||||
channel_index,
|
||||
extra={"device": device_name or "-"},
|
||||
)
|
||||
|
||||
logical_port = ifname or component_name
|
||||
logical_channel = f"{logical_port}:{channel_index}"
|
||||
return logical_port, logical_channel
|
||||
|
||||
|
||||
def parse_transceiver_port_from_component_name(
|
||||
component_name: str,
|
||||
vendor: str | None,
|
||||
) -> str:
|
||||
"""根据 component_name 与厂商信息解析 transceiver 的 logical_port。"""
|
||||
vendor_norm = (vendor or "").strip().lower()
|
||||
|
||||
if vendor_norm == "ruijie":
|
||||
m = _RE_RUIJIE_COMPONENT.match(component_name)
|
||||
if m:
|
||||
ifname = m.group("ifname")
|
||||
return ifname or component_name
|
||||
|
||||
# 默认/H3C:尝试提取形如 "1/0/1" 的端口模式
|
||||
m = re.search(r"\d+/\d+/\d+", component_name)
|
||||
if m:
|
||||
return m.group(0)
|
||||
|
||||
return component_name
|
||||
|
||||
|
||||
def parse_port_and_channel(
|
||||
description: str | None,
|
||||
component_name: str,
|
||||
channel_index: int,
|
||||
vendor: str | None = None,
|
||||
device_name: str | None = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
从 description 中解析 (logical_port, logical_channel),并在异常时提供安全 fallback。
|
||||
|
||||
- H3C/默认: 与现有逻辑保持一致;
|
||||
- Ruijie: 使用专用正则解析 TRANSCEIVER- 前缀结构。
|
||||
"""
|
||||
vendor_norm = (vendor or "").strip().lower()
|
||||
|
||||
if vendor_norm in ("", "h3c"):
|
||||
return _parse_port_channel_h3c_or_default(description, component_name, channel_index)
|
||||
if vendor_norm == "ruijie":
|
||||
return _parse_port_channel_ruijie(description, component_name, channel_index, device_name)
|
||||
|
||||
# 未知厂商:给出 warning,回退到 H3C 默认策略
|
||||
logger.warning(
|
||||
"Unknown vendor '%s' for device, using default H3C strategy",
|
||||
vendor,
|
||||
extra={"device": device_name or "-"},
|
||||
)
|
||||
return _parse_port_channel_h3c_or_default(description, component_name, channel_index)
|
||||
|
||||
|
||||
def _get_text(elem: ET.Element | None) -> str | None:
|
||||
if elem is None:
|
||||
return None
|
||||
@ -83,12 +180,32 @@ def _parse_float(elem: ET.Element | None) -> float | None:
|
||||
def parse_netconf_response(
|
||||
xml_str: str,
|
||||
device_name: str,
|
||||
vendor: str | None = None,
|
||||
) -> Tuple[List[TransceiverRecord], List[TransceiverChannelRecord]]:
|
||||
"""
|
||||
解析 NETCONF `<get>` RPC 返回的 XML,生成 transceiver 与 channel 记录。
|
||||
"""
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
# vendor 参数表示“设备厂商”,用于端口/通道解析策略,
|
||||
# 不应与 transceiver 模块自身的 vendor 字段混用。
|
||||
device_vendor = vendor
|
||||
device_vendor_norm = (device_vendor or "").strip().lower()
|
||||
|
||||
# 对疑似 Ruijie 但 vendor 未显式设置的情况给出一次性 warning(线程安全)
|
||||
if device_vendor_norm in ("", "h3c"):
|
||||
with _ruijie_warning_lock:
|
||||
if device_name not in _ruijie_warning_issued:
|
||||
if "TRANSCEIVER-" in xml_str and re.search(r"TRANSCEIVER-\d+/\d+/\d+", xml_str):
|
||||
logger.warning(
|
||||
"Device '%s' response looks like Ruijie (TRANSCEIVER-*), "
|
||||
"but vendor is not set to 'ruijie'. Using default H3C parsing strategy; "
|
||||
"labels may be suboptimal.",
|
||||
device_name,
|
||||
extra={"device": device_name},
|
||||
)
|
||||
_ruijie_warning_issued.add(device_name)
|
||||
|
||||
tx_records: List[TransceiverRecord] = []
|
||||
ch_records: List[TransceiverChannelRecord] = []
|
||||
|
||||
@ -111,7 +228,7 @@ def parse_netconf_response(
|
||||
present = _get_text(
|
||||
tx_state.find("oc-transceiver:present", NS) if tx_state is not None else None
|
||||
)
|
||||
vendor = _get_text(
|
||||
module_vendor = _get_text(
|
||||
tx_state.find("oc-transceiver:vendor", NS) if tx_state is not None else None
|
||||
)
|
||||
serial = _get_text(
|
||||
@ -149,7 +266,7 @@ def parse_netconf_response(
|
||||
)
|
||||
channel_elems: Iterable[ET.Element] = comp.findall(channels_path, NS)
|
||||
|
||||
# logical_port 以第一个 channel 的 description 为主,fallback 到 component_name
|
||||
# logical_port 以第一个成功解析的 channel 为主,fallback 到 component_name
|
||||
logical_port_for_tx: str | None = None
|
||||
|
||||
for ch in channel_elems:
|
||||
@ -166,7 +283,11 @@ def parse_netconf_response(
|
||||
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
|
||||
description,
|
||||
component_name,
|
||||
ch_index,
|
||||
vendor=device_vendor,
|
||||
device_name=device_name,
|
||||
)
|
||||
if logical_port_for_tx is None:
|
||||
logical_port_for_tx = logical_port
|
||||
@ -208,7 +329,12 @@ def parse_netconf_response(
|
||||
)
|
||||
|
||||
# transceiver record(逻辑端口)
|
||||
logical_port_tx = logical_port_for_tx or component_name
|
||||
if logical_port_for_tx is None:
|
||||
logical_port_tx = parse_transceiver_port_from_component_name(
|
||||
component_name, device_vendor
|
||||
)
|
||||
else:
|
||||
logical_port_tx = logical_port_for_tx
|
||||
|
||||
tx_records.append(
|
||||
TransceiverRecord(
|
||||
@ -219,7 +345,7 @@ def parse_netconf_response(
|
||||
oper_status=oper_status,
|
||||
temperature_c=temperature_c,
|
||||
supply_voltage_v=supply_voltage_v,
|
||||
vendor=vendor,
|
||||
vendor=module_vendor,
|
||||
serial=serial,
|
||||
part_number=part_number,
|
||||
hardware_rev=hardware_rev,
|
||||
|
||||
@ -112,7 +112,11 @@ def scrape_device(
|
||||
# 构造 filter 并调用外部提供的 RPC 函数
|
||||
flt = build_transceiver_filter()
|
||||
xml_reply = netconf_get_rpc(mgr, flt)
|
||||
tx_records, ch_records = parse_netconf_response(xml_reply, device)
|
||||
tx_records, ch_records = parse_netconf_response(
|
||||
xml_reply,
|
||||
device,
|
||||
vendor=state.cfg.vendor,
|
||||
)
|
||||
|
||||
snapshot = DeviceMetricsSnapshot(
|
||||
device=device,
|
||||
|
||||
@ -41,7 +41,7 @@ class SQLiteDeviceStore:
|
||||
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def init_db(self) -> None:
|
||||
"""初始化 DB:设置 WAL 模式并创建 devices 表."""
|
||||
"""初始化 DB:设置 WAL 模式并创建/更新 devices 表."""
|
||||
conn = sqlite3.connect(self.db_path, timeout=self.timeout)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
@ -57,11 +57,22 @@ class SQLiteDeviceStore:
|
||||
enabled INTEGER NOT NULL,
|
||||
scrape_interval_seconds INTEGER,
|
||||
supports_xpath INTEGER NOT NULL DEFAULT 0,
|
||||
vendor TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
# 防御性补列:针对旧版本 DB 中尚未包含 vendor 列的情况
|
||||
try:
|
||||
conn.execute("ALTER TABLE devices ADD COLUMN vendor TEXT;")
|
||||
except sqlite3.OperationalError as exc: # noqa: PERF203
|
||||
msg = str(exc).lower()
|
||||
# 对于“重复列”等情况忽略,其它错误抛出
|
||||
if "duplicate column" in msg or "already exists" in msg:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@ -86,11 +97,11 @@ class SQLiteDeviceStore:
|
||||
"""
|
||||
INSERT OR REPLACE INTO devices (
|
||||
name, host, port, username, password_cipher, enabled,
|
||||
scrape_interval_seconds, supports_xpath,
|
||||
scrape_interval_seconds, supports_xpath, vendor,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
COALESCE(
|
||||
(SELECT created_at FROM devices WHERE name=?),
|
||||
?
|
||||
@ -107,6 +118,7 @@ class SQLiteDeviceStore:
|
||||
int(cfg.enabled),
|
||||
cfg.scrape_interval_seconds,
|
||||
int(cfg.supports_xpath),
|
||||
cfg.vendor,
|
||||
cfg.name,
|
||||
now_ts,
|
||||
now_ts,
|
||||
@ -143,7 +155,7 @@ class SQLiteDeviceStore:
|
||||
"""
|
||||
SELECT
|
||||
name, host, port, username, password_cipher,
|
||||
enabled, scrape_interval_seconds, supports_xpath
|
||||
enabled, scrape_interval_seconds, supports_xpath, vendor
|
||||
FROM devices;
|
||||
"""
|
||||
)
|
||||
@ -158,8 +170,12 @@ class SQLiteDeviceStore:
|
||||
enabled,
|
||||
scrape_interval_seconds,
|
||||
supports_xpath,
|
||||
vendor,
|
||||
) in rows:
|
||||
password = self.encryptor.decrypt(password_cipher)
|
||||
vendor_norm = None
|
||||
if vendor is not None:
|
||||
vendor_norm = str(vendor).strip().lower() or None
|
||||
dev = DeviceConfig(
|
||||
name=name,
|
||||
host=host,
|
||||
@ -169,6 +185,7 @@ class SQLiteDeviceStore:
|
||||
enabled=bool(enabled),
|
||||
scrape_interval_seconds=scrape_interval_seconds,
|
||||
supports_xpath=bool(supports_xpath),
|
||||
vendor=vendor_norm,
|
||||
source="runtime",
|
||||
)
|
||||
devices.append(dev)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,7 +5,7 @@ import sqlite3
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exporter.api import create_app
|
||||
from exporter.api import create_app, DeviceIn
|
||||
from exporter.config import DeviceConfig, GlobalConfig
|
||||
from exporter.metrics import TransceiverCollector
|
||||
from exporter.models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
@ -46,6 +46,17 @@ def app_with_registry(global_cfg) -> Tuple[TestClient, DeviceRegistry]:
|
||||
return _make_app_and_registry(global_cfg)
|
||||
|
||||
|
||||
def test_devicein_vendor_validator_accepts_none():
|
||||
# vendor 省略时应保持为 None,走 validator 的 None 分支
|
||||
d = DeviceIn(
|
||||
name="dev1",
|
||||
host="192.0.2.1",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
assert d.vendor is None
|
||||
|
||||
|
||||
def test_get_devices_requires_auth(app_with_registry):
|
||||
client, _ = app_with_registry
|
||||
resp = client.get("/api/v1/devices")
|
||||
@ -86,6 +97,35 @@ def test_post_device_creates_runtime_device(app_with_registry):
|
||||
assert any(d.name == "new-device" and d.source == "runtime" for d in devices)
|
||||
|
||||
|
||||
def test_post_device_accepts_vendor_and_normalizes(app_with_registry):
|
||||
client, registry = app_with_registry
|
||||
|
||||
device_data = {
|
||||
"name": "rj-dev",
|
||||
"host": "192.168.1.200",
|
||||
"port": 830,
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"enabled": True,
|
||||
"vendor": " Ruijie ",
|
||||
}
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/devices",
|
||||
headers={"X-API-Token": "changeme"},
|
||||
json=device_data,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
# API 返回的 vendor 应已被 strip + lower
|
||||
assert body["vendor"] == "ruijie"
|
||||
|
||||
# registry 中也应保存规范化后的 vendor
|
||||
devices = registry.list_devices()
|
||||
dev = next(d for d in devices if d.name == "rj-dev")
|
||||
assert dev.vendor == "ruijie"
|
||||
|
||||
|
||||
def test_post_duplicate_device_returns_409(app_with_registry):
|
||||
client, _ = app_with_registry
|
||||
|
||||
@ -152,6 +192,7 @@ def test_delete_static_device_fails(app_with_registry):
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
vendor="h3c",
|
||||
source="static",
|
||||
)
|
||||
registry.register_static_device(static_dev)
|
||||
@ -181,3 +222,24 @@ def test_metrics_endpoint_returns_prometheus_format(app_with_registry):
|
||||
assert "# HELP" in resp.text
|
||||
assert "netconf_scrape_success" in resp.text
|
||||
|
||||
|
||||
def test_get_devices_when_api_token_disabled(tmp_path):
|
||||
# 当 global.api_token 为空时,/api/v1/devices 不应要求鉴权
|
||||
gc = GlobalConfig()
|
||||
gc.api_token = ""
|
||||
gc.runtime_db_path = str(tmp_path / "devices.db")
|
||||
gc.password_secret = VALID_FERNET_KEY
|
||||
|
||||
encryptor = PasswordEncryptor(gc.password_secret)
|
||||
store = SQLiteDeviceStore(gc.runtime_db_path, encryptor)
|
||||
store.init_db()
|
||||
|
||||
registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds)
|
||||
cache: dict[str, DeviceMetricsSnapshot] = {}
|
||||
health: dict[str, DeviceHealthState] = {}
|
||||
collector = TransceiverCollector(cache, health)
|
||||
app = create_app(registry, store, collector, gc)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/v1/devices")
|
||||
assert resp.status_code == 200
|
||||
|
||||
86
tests/test_api_sqlite_errors.py
Normal file
86
tests/test_api_sqlite_errors.py
Normal file
@ -0,0 +1,86 @@
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exporter.api import create_app
|
||||
from exporter.config import DeviceConfig, GlobalConfig
|
||||
from exporter.metrics import TransceiverCollector
|
||||
from exporter.models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
from exporter.registry import DeviceRegistry
|
||||
|
||||
|
||||
VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
class DummyStore:
|
||||
"""简单的测试用存储,实现 API 需要的接口,并在指定操作上抛 OperationalError。"""
|
||||
|
||||
def __init__(self, fail_on: str | None = None) -> None:
|
||||
self.fail_on = fail_on
|
||||
|
||||
# 与 SQLiteDeviceStore 接口对齐
|
||||
def init_db(self) -> None: # pragma: no cover - 在这些测试中不会调用
|
||||
return
|
||||
|
||||
def load_runtime_devices(self) -> List[DeviceConfig]:
|
||||
return []
|
||||
|
||||
def save_device(self, cfg: DeviceConfig) -> None:
|
||||
if self.fail_on == "save":
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
def delete_device(self, name: str) -> None:
|
||||
if self.fail_on == "delete":
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
def close(self) -> None: # pragma: no cover - 这里无需验证
|
||||
return
|
||||
|
||||
|
||||
def _build_app_with_dummy_store(fail_on: str | None) -> TestClient:
|
||||
gc = GlobalConfig()
|
||||
gc.api_token = "token"
|
||||
gc.runtime_db_path = ":memory:"
|
||||
gc.password_secret = VALID_FERNET_KEY
|
||||
|
||||
registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds)
|
||||
cache: Dict[str, DeviceMetricsSnapshot] = {}
|
||||
health: Dict[str, DeviceHealthState] = {}
|
||||
collector = TransceiverCollector(cache, health)
|
||||
|
||||
store = DummyStore(fail_on=fail_on)
|
||||
app = create_app(registry, store, collector, gc)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_post_device_sqlite_operational_error_returns_503():
|
||||
client = _build_app_with_dummy_store(fail_on="save")
|
||||
|
||||
payload = {
|
||||
"name": "dev-save-error",
|
||||
"host": "192.0.2.10",
|
||||
"port": 830,
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"enabled": True,
|
||||
}
|
||||
resp = client.post(
|
||||
"/api/v1/devices",
|
||||
headers={"X-API-Token": "token"},
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "database is locked" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_delete_device_sqlite_operational_error_returns_503():
|
||||
client = _build_app_with_dummy_store(fail_on="delete")
|
||||
|
||||
resp = client.delete(
|
||||
"/api/v1/devices/nonexistent",
|
||||
headers={"X-API-Token": "token"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "database is locked" in resp.json()["detail"]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
@ -58,6 +59,31 @@ def test_config_invalid_fernet_key_raises():
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_config_missing_password_secret_raises():
|
||||
data = {
|
||||
"global": {
|
||||
"runtime_db_path": "./devices.db",
|
||||
# password_secret 缺失
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="global.password_secret must be configured"):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_config_invalid_fernet_key_length_raises():
|
||||
# 构造一个合法 base64 但长度不是 32 字节的 key
|
||||
bad_key_bytes = b"too-short"
|
||||
bad_key = base64.urlsafe_b64encode(bad_key_bytes).decode()
|
||||
data = {
|
||||
"global": {
|
||||
"runtime_db_path": "./devices.db",
|
||||
"password_secret": bad_key,
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="Invalid Fernet key length"):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_shutdown_timeout_too_small_warns():
|
||||
data = {
|
||||
"global": {
|
||||
@ -84,3 +110,39 @@ def test_shutdown_timeout_too_small_warns():
|
||||
with pytest.warns(UserWarning):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_deviceconfig_vendor_parsed_and_normalized_from_yaml(tmp_path: Path):
|
||||
yaml_content = f"""
|
||||
global:
|
||||
runtime_db_path: "./devices.db"
|
||||
password_secret: "{VALID_FERNET_KEY}"
|
||||
devices:
|
||||
- name: rj-1
|
||||
host: 192.0.2.10
|
||||
port: 830
|
||||
username: u
|
||||
password: p
|
||||
enabled: true
|
||||
supports_xpath: false
|
||||
vendor: " Ruijie "
|
||||
- name: h3c-1
|
||||
host: 198.51.100.10
|
||||
port: 830
|
||||
username: u2
|
||||
password: p2
|
||||
enabled: true
|
||||
supports_xpath: false
|
||||
"""
|
||||
cfg_file = tmp_path / "config_vendor.yaml"
|
||||
cfg_file.write_text(yaml_content)
|
||||
|
||||
cfg = Config.from_file(cfg_file)
|
||||
assert len(cfg.devices) == 2
|
||||
|
||||
rj = next(d for d in cfg.devices if d.name == "rj-1")
|
||||
h3c = next(d for d in cfg.devices if d.name == "h3c-1")
|
||||
|
||||
# vendor 显式配置应被 strip + lower
|
||||
assert rj.vendor == "ruijie"
|
||||
# 未配置 vendor 时应为 None
|
||||
assert h3c.vendor is None
|
||||
|
||||
@ -123,3 +123,21 @@ def test_close_all_closes_all_sessions(monkeypatch, global_cfg):
|
||||
assert mgr_instances[0].closed is True
|
||||
assert mgr_instances[1].closed is True
|
||||
|
||||
|
||||
def test_acquire_session_reuses_existing_manager(monkeypatch, global_cfg, device_cfg):
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_connect(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return DummyManager()
|
||||
|
||||
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
|
||||
|
||||
cm = ConnectionManager(global_cfg)
|
||||
# 第一次会触发 connect
|
||||
sess1 = cm.acquire_session(device_cfg)
|
||||
# 第二次在会话仍有效时应复用,不再调用 connect
|
||||
sess2 = cm.acquire_session(device_cfg)
|
||||
|
||||
assert sess1 is sess2
|
||||
assert len(calls) == 1
|
||||
|
||||
@ -13,4 +13,9 @@ def test_classify_error_from_exception():
|
||||
assert classify_error(ET.ParseError()) == "XMLParseError"
|
||||
assert classify_error(PermissionError()) == "AuthenticationError"
|
||||
assert classify_error(RuntimeError("filter failed")) == "FilterError"
|
||||
|
||||
# SessionCloseError / SessionError 通过类名匹配
|
||||
SessionCloseErrorType = type("SessionCloseError", (Exception,), {})
|
||||
assert classify_error(SessionCloseErrorType("closed")) == "SessionCloseError"
|
||||
|
||||
assert classify_error(RuntimeError("something else")) == "UnknownError"
|
||||
|
||||
@ -29,7 +29,8 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
|
||||
config: Dict[str, Any] = {
|
||||
"global": {
|
||||
"http_listen": "127.0.0.1:19100",
|
||||
# 使用 29200 端口,避免与独立部署冲突
|
||||
"http_listen": "127.0.0.1:29200",
|
||||
"scrape_interval_seconds": 2,
|
||||
"rpc_timeout_seconds": 2,
|
||||
"shutdown_timeout_seconds": 10,
|
||||
@ -57,12 +58,12 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
text=True,
|
||||
)
|
||||
|
||||
base_url = "http://127.0.0.1:19100"
|
||||
base_url = "http://127.0.0.1:29200"
|
||||
|
||||
def _http_request(path: str, method: str = "GET", body: bytes | None = None, headers: Dict[str, str] | None = None):
|
||||
import http.client
|
||||
|
||||
conn = http.client.HTTPConnection("127.0.0.1", 19100, timeout=5)
|
||||
conn = http.client.HTTPConnection("127.0.0.1", 29200, timeout=5)
|
||||
try:
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
resp = conn.getresponse()
|
||||
@ -93,29 +94,72 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
# server 可能尚未 ready,稍后重试
|
||||
time.sleep(0.5)
|
||||
|
||||
# 4. 通过 API 注册一个 runtime 设备
|
||||
device_payload = {
|
||||
"name": "e2e-device-1",
|
||||
# 4. 通过 API 注册两个 runtime 设备:一个 H3C、一个 Ruijie
|
||||
# 使用带时间戳的名称,避免受残留 runtime DB 状态影响
|
||||
base_name = f"e2e-{int(time.time() * 1000)}"
|
||||
h3c_name = f"{base_name}-h3c"
|
||||
ruijie_name = f"{base_name}-ruijie"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Token": "changeme",
|
||||
}
|
||||
|
||||
# H3C 设备(不显式设置 vendor 或设置为 h3c)
|
||||
h3c_payload = {
|
||||
"name": h3c_name,
|
||||
"host": "192.0.2.10",
|
||||
"port": 830,
|
||||
"username": "netconf_user",
|
||||
"password": "secret",
|
||||
"enabled": True,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Token": "changeme",
|
||||
"vendor": "h3c",
|
||||
}
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="POST",
|
||||
body=json.dumps(device_payload).encode("utf-8"),
|
||||
body=json.dumps(h3c_payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices: {status}, body={data!r}"
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices (h3c): {status}, body={data!r}"
|
||||
body_json = json.loads(data.decode("utf-8"))
|
||||
assert body_json["name"] == "e2e-device-1"
|
||||
assert body_json["name"] == h3c_name
|
||||
assert body_json["source"] == "runtime"
|
||||
assert body_json.get("vendor") == "h3c"
|
||||
|
||||
# Ruijie 设备(vendor 应被规范化为 ruijie)
|
||||
ruijie_payload = {
|
||||
"name": ruijie_name,
|
||||
"host": "192.0.2.11",
|
||||
"port": 830,
|
||||
"username": "ruijie",
|
||||
"password": "secret",
|
||||
"enabled": True,
|
||||
"vendor": " Ruijie ",
|
||||
}
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="POST",
|
||||
body=json.dumps(ruijie_payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices (ruijie): {status}, body={data!r}"
|
||||
body_json = json.loads(data.decode("utf-8"))
|
||||
assert body_json["name"] == ruijie_name
|
||||
assert body_json["source"] == "runtime"
|
||||
assert body_json.get("vendor") == "ruijie"
|
||||
|
||||
# 验证 GET /api/v1/devices 能同时看到 H3C 和 Ruijie 两个设备
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="GET",
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 200
|
||||
devices = json.loads(data.decode("utf-8"))
|
||||
names = {d["name"] for d in devices}
|
||||
assert h3c_name in names
|
||||
assert ruijie_name in names
|
||||
|
||||
# 5. 访问 /metrics,验证 Prometheus 输出可用
|
||||
status, headers_list, data = _http_request("/metrics")
|
||||
|
||||
@ -75,3 +75,30 @@ def test_init_logging_configures_root_logger_handlers() -> None:
|
||||
for handler in root.handlers
|
||||
for flt in handler.filters
|
||||
)
|
||||
|
||||
|
||||
def test_init_logging_with_file_handler(tmp_path) -> None:
|
||||
"""当配置 log_file 时,应创建文件 handler 并挂载 DeviceFieldFilter。"""
|
||||
log_file = tmp_path / "exporter.log"
|
||||
gc = GlobalConfig(
|
||||
log_level="INFO",
|
||||
log_to_stdout=False,
|
||||
log_file=str(log_file),
|
||||
log_file_max_bytes=1024,
|
||||
log_file_backup_count=1,
|
||||
)
|
||||
|
||||
init_logging(gc)
|
||||
|
||||
root = logging.getLogger()
|
||||
# 应至少存在一个 RotatingFileHandler
|
||||
file_handlers = [
|
||||
h for h in root.handlers if isinstance(h, logging.handlers.RotatingFileHandler)
|
||||
]
|
||||
assert file_handlers
|
||||
# 并且这些 handler 上也应安装 DeviceFieldFilter
|
||||
assert any(
|
||||
isinstance(flt, DeviceFieldFilter)
|
||||
for h in file_handlers
|
||||
for flt in h.filters
|
||||
)
|
||||
|
||||
58
tests/test_netconf_parser_vendor_none_ruijie.py
Normal file
58
tests/test_netconf_parser_vendor_none_ruijie.py
Normal file
@ -0,0 +1,58 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from exporter.netconf_client import parse_netconf_response
|
||||
|
||||
|
||||
RUJIE_SAMPLE_XML = """\
|
||||
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
|
||||
<data>
|
||||
<components xmlns="http://openconfig.net/yang/platform">
|
||||
<component>
|
||||
<name>TRANSCEIVER-1/0/129-FH0/1:1</name>
|
||||
<state>
|
||||
<type>TRANSCEIVER</type>
|
||||
</state>
|
||||
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
|
||||
<state>
|
||||
<serial-no>ABC123</serial-no>
|
||||
</state>
|
||||
<physical-channels>
|
||||
<channel>
|
||||
<index>1</index>
|
||||
<state>
|
||||
<description>TRANSCEIVER-1/0/129/1-FH0/1:1</description>
|
||||
</state>
|
||||
</channel>
|
||||
</physical-channels>
|
||||
</transceiver>
|
||||
</component>
|
||||
</components>
|
||||
</data>
|
||||
</rpc-reply>
|
||||
"""
|
||||
|
||||
|
||||
def test_vendor_none_with_ruijie_sample_uses_h3c_strategy():
|
||||
txs, chs = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None)
|
||||
assert len(chs) == 1
|
||||
ch = chs[0]
|
||||
# H3C 默认策略:冒号前为端口
|
||||
assert ch.logical_port == "TRANSCEIVER-1/0/129/1-FH0/1"
|
||||
assert ch.logical_channel == "TRANSCEIVER-1/0/129/1-FH0/1:1"
|
||||
|
||||
|
||||
def test_vendor_none_to_ruijie_changes_labels():
|
||||
_, chs_none = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None)
|
||||
_, chs_ruijie = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor="ruijie")
|
||||
|
||||
assert len(chs_none) == len(chs_ruijie) == 1
|
||||
ch_none = chs_none[0]
|
||||
ch_rj = chs_ruijie[0]
|
||||
|
||||
assert ch_none.logical_port.startswith("TRANSCEIVER-")
|
||||
# Ruijie 策略应清洗出短端口 FH0/1
|
||||
assert ch_rj.logical_port == "FH0/1"
|
||||
assert ch_rj.logical_channel == "FH0/1:1"
|
||||
|
||||
117
tests/test_ruijie_live_netconf.py
Normal file
117
tests/test_ruijie_live_netconf.py
Normal file
@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
与真实 Ruijie 设备联调的“活体”测试用例。
|
||||
|
||||
说明:
|
||||
- 连接参数通过环境变量注入(你已经在 .env 中配置):
|
||||
- RUIJIE_NETCONF_HOST
|
||||
- RUIJIE_NETCONF_PORT
|
||||
- RUIJIE_NETCONF_USER
|
||||
- RUIJIE_NETCONF_PASSWORD
|
||||
|
||||
默认行为:
|
||||
- 若未设置 RUIJIE_NETCONF_PASSWORD,或无法建立到指定 host:port 的 TCP 连接,
|
||||
则使用 pytest.skip() 自动跳过,不影响普通单元测试/CI。
|
||||
- 仅在本地联调时、显式设置上述环境变量后,此测试才会真正访问设备。
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from ncclient import manager
|
||||
|
||||
from exporter.netconf_client import build_transceiver_filter, parse_netconf_response
|
||||
|
||||
|
||||
RUIJIE_HOST = os.getenv("RUIJIE_NETCONF_HOST", "127.0.0.1")
|
||||
RUIJIE_PORT = int(os.getenv("RUIJIE_NETCONF_PORT", "9830"))
|
||||
RUIJIE_USER = os.getenv("RUIJIE_NETCONF_USER", "ruijie1-admin")
|
||||
RUIJIE_PASSWORD = os.getenv("RUIJIE_NETCONF_PASSWORD", "")
|
||||
|
||||
|
||||
def _can_connect(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
"""快速探测 host:port 是否可连,用于决定是否跳过 live 测试。"""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout)
|
||||
sock.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
@pytest.mark.ruijie_live
|
||||
def test_ruijie_live_transceiver_rpc_and_parse() -> None:
|
||||
"""
|
||||
使用真实 Ruijie 设备验证:
|
||||
- ncclient 能与设备建立 NETCONF 会话;
|
||||
- build_transceiver_filter() 构造的 subtree filter 在设备上可用;
|
||||
- parse_netconf_response(..., vendor='ruijie') 能正确解析设备返回的 XML。
|
||||
"""
|
||||
if not RUIJIE_PASSWORD:
|
||||
pytest.skip("RUIJIE_NETCONF_PASSWORD 未设置,跳过 Ruijie live 测试")
|
||||
|
||||
if not _can_connect(RUIJIE_HOST, RUIJIE_PORT):
|
||||
pytest.skip(f"Ruijie NETCONF {RUIJIE_HOST}:{RUIJIE_PORT} 不可达,跳过 live 测试")
|
||||
|
||||
flt = build_transceiver_filter()
|
||||
|
||||
with manager.connect(
|
||||
host=RUIJIE_HOST,
|
||||
port=RUIJIE_PORT,
|
||||
username=RUIJIE_USER,
|
||||
password=RUIJIE_PASSWORD,
|
||||
hostkey_verify=False,
|
||||
timeout=30,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
) as m:
|
||||
reply = m.get(filter=("subtree", flt))
|
||||
xml_str = str(reply)
|
||||
|
||||
# vendor="ruijie" 走厂商感知解析路径
|
||||
transceivers, channels = parse_netconf_response(
|
||||
xml_str,
|
||||
device_name=f"ruijie-{RUIJIE_HOST}",
|
||||
vendor="ruijie",
|
||||
)
|
||||
|
||||
# 只要返回非空结果,就说明 "连接 + filter + 解析" 在真实设备上可以工作
|
||||
assert transceivers or channels, "Ruijie 设备未返回任何 transceiver/channel 数据"
|
||||
|
||||
# 至少有一个 transceiver 拥有对应的 channel
|
||||
tx_by_component = {t.component_name: t for t in transceivers}
|
||||
ch_by_component = {}
|
||||
for ch in channels:
|
||||
ch_by_component.setdefault(ch.component_name, []).append(ch)
|
||||
|
||||
has_tx_with_channel = any(
|
||||
comp in tx_by_component and len(ch_list) > 0
|
||||
for comp, ch_list in ch_by_component.items()
|
||||
)
|
||||
assert has_tx_with_channel, (
|
||||
"Ruijie live 数据中未发现“同时存在 transceiver 与 channel”的组件,"
|
||||
"请检查设备返回的 transceiver/physical-channels 数据是否完整"
|
||||
)
|
||||
|
||||
# 至少有一个 channel 具有 rx 或 tx power 数值
|
||||
channels_with_power = [
|
||||
ch
|
||||
for ch in channels
|
||||
if ch.rx_power_dbm is not None or ch.tx_power_dbm is not None
|
||||
]
|
||||
assert channels_with_power, (
|
||||
"Ruijie live 数据中未发现带 rx/tx power 的通道,"
|
||||
"请检查设备是否开启了相关光功率采集"
|
||||
)
|
||||
|
||||
# 额外 sanity 检查:至少有一个端口 label 不以 TRANSCEIVER- 开头,验证清洗逻辑生效
|
||||
ports = {t.logical_port for t in transceivers} | {c.logical_port for c in channels}
|
||||
assert any(not p.startswith("TRANSCEIVER-") for p in ports), (
|
||||
"Ruijie live 数据中未发现清洗后的端口名,"
|
||||
"请检查 vendor='ruijie' 解析逻辑是否生效"
|
||||
)
|
||||
160
tests/test_sqlite_vendor_column.py
Normal file
160
tests/test_sqlite_vendor_column.py
Normal file
@ -0,0 +1,160 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from exporter.config import DeviceConfig
|
||||
from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryptor() -> PasswordEncryptor:
|
||||
# 生成一个有效 Fernet key
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key().decode()
|
||||
return PasswordEncryptor(key)
|
||||
|
||||
|
||||
def test_init_db_creates_vendor_column_on_fresh_db(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "test_vendor.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(devices)").fetchall()]
|
||||
conn.close()
|
||||
|
||||
assert "vendor" in cols
|
||||
|
||||
|
||||
def test_init_db_alter_table_vendor_preserves_existing_rows(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
# 创建旧版本 devices 表(无 vendor 列)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE 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.execute(
|
||||
"INSERT INTO devices (name, host, port, username, password_cipher, enabled, "
|
||||
"scrape_interval_seconds, supports_xpath, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("old-dev", "h", 830, "u", b"cipher", 1, None, 0, 1, 1),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
row = conn2.execute("SELECT name, vendor FROM devices WHERE name = 'old-dev'").fetchone()
|
||||
conn2.close()
|
||||
|
||||
assert row is not None
|
||||
assert row[0] == "old-dev"
|
||||
# 旧数据 vendor 应为空
|
||||
assert row[1] is None
|
||||
|
||||
|
||||
def test_save_and_load_device_persists_vendor(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "vendor_persist.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
dev = DeviceConfig(
|
||||
name="dev-vendor",
|
||||
host="h",
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
enabled=True,
|
||||
vendor="ruijie",
|
||||
source="runtime",
|
||||
)
|
||||
store.save_device(dev)
|
||||
loaded = store.load_runtime_devices()
|
||||
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].name == "dev-vendor"
|
||||
assert loaded[0].vendor == "ruijie"
|
||||
|
||||
|
||||
def test_save_and_load_device_vendor_none_roundtrip(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "vendor_none.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
dev = DeviceConfig(
|
||||
name="dev-no-vendor",
|
||||
host="h",
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
enabled=True,
|
||||
vendor=None,
|
||||
source="runtime",
|
||||
)
|
||||
store.save_device(dev)
|
||||
loaded = store.load_runtime_devices()
|
||||
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].name == "dev-no-vendor"
|
||||
assert loaded[0].vendor is None
|
||||
|
||||
|
||||
def test_init_db_alter_table_silently_ignores_duplicate_column_error(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "dup_col.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
# 第一次初始化,创建带 vendor 列的表
|
||||
store.init_db()
|
||||
# 第二次调用,不应抛异常
|
||||
store.init_db()
|
||||
|
||||
|
||||
def test_init_db_alter_table_raises_on_non_duplicate_errors(tmp_path: Path, encryptor: PasswordEncryptor, monkeypatch):
|
||||
db_path = tmp_path / "locked.db"
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
class ConnWrapper:
|
||||
def __init__(self, inner: sqlite3.Connection) -> None:
|
||||
self._inner = inner
|
||||
self._alter_attempted = False
|
||||
|
||||
def execute(self, sql: str, *args, **kwargs):
|
||||
# 在第一次尝试 ALTER TABLE 时注入错误
|
||||
if "ALTER TABLE devices ADD COLUMN vendor" in sql and not self._alter_attempted:
|
||||
self._alter_attempted = True
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
return self._inner.execute(sql, *args, **kwargs)
|
||||
|
||||
def commit(self) -> None:
|
||||
return self._inner.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
return self._inner.close()
|
||||
|
||||
def wrapped_connect(*args, **kwargs):
|
||||
conn = real_connect(*args, **kwargs)
|
||||
return ConnWrapper(conn)
|
||||
|
||||
monkeypatch.setattr(sqlite3, "connect", wrapped_connect)
|
||||
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
store.init_db()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user