argus-netconf-exporter/tests/test_h3c_live_netconf.py
2025-12-01 16:11:02 +08:00

287 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
"""
与真实 H3C 设备联调的“活体”测试用例。
说明:
- 连接参数参考 exp/yangcli/run_yangcli.sh
- server=127.0.0.1
- ncport=8830
- user=netconf_user
- password='...'
- 为避免在仓库中硬编码明文密码,本测试通过环境变量注入密码:
- H3C_NETCONF_PASSWORD
- 可选H3C_NETCONF_HOST / H3C_NETCONF_PORT / H3C_NETCONF_USER
默认行为:
- 若未设置 H3C_NETCONF_PASSWORD或无法建立到指定 host:port 的 TCP 连接,
则使用 pytest.skip() 自动跳过,不影响普通单元测试/CI。
- 仅在本地联调时、显式设置上述环境变量后,此测试才会真正访问设备。
"""
import os
import re
import socket
from pathlib import Path
import pytest
from ncclient import manager
from exporter.netconf_client import build_transceiver_filter, parse_netconf_response
H3C_HOST = os.getenv("H3C_NETCONF_HOST", "127.0.0.1")
H3C_PORT = int(os.getenv("H3C_NETCONF_PORT", "8830"))
H3C_USER = os.getenv("H3C_NETCONF_USER", "netconf_user")
def _load_password_from_script() -> str:
"""
尝试从 exp/yangcli 下的脚本中解析 --password='...'.
这样可以重用你已经验证过的 yangcli 参数,而不在测试里硬编码密码。
若脚本不存在或未找到 password则返回空字符串。
"""
root = Path(__file__).resolve().parents[1]
candidates = [
root / "exp" / "yangcli" / "run_yangcli_h3c.sh",
root / "exp" / "yangcli" / "run_yangcli.sh",
]
pattern = re.compile(r"--password='([^']*)'")
for path in candidates:
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
match = pattern.search(text)
if match:
return match.group(1)
return ""
H3C_PASSWORD = os.getenv("H3C_NETCONF_PASSWORD") or _load_password_from_script()
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.h3c_live
def test_h3c_live_transceiver_rpc_and_parse() -> None:
"""
使用真实 H3C 设备验证:
- ncclient 能与设备建立 NETCONF 会话;
- build_transceiver_filter() 构造的 subtree filter 在设备上可用;
- parse_netconf_response() 能正确解析设备返回的 XML并得到非空结果。
"""
if not _can_connect(H3C_HOST, H3C_PORT):
pytest.skip(f"H3C NETCONF {H3C_HOST}:{H3C_PORT} 不可达,跳过 live 测试")
flt = build_transceiver_filter()
with manager.connect(
host=H3C_HOST,
port=H3C_PORT,
username=H3C_USER,
password=H3C_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)
transceivers, channels = parse_netconf_response(xml_str, device_name=f"h3c-{H3C_HOST}")
# 只要返回非空结果,就说明"连接 + filter + 解析"在真实设备上可以工作
assert transceivers or channels, "H3C 设备未返回任何 transceiver/channel 数据"
@pytest.mark.h3c_live
def test_h3c_config_description_consistency() -> None:
"""
验证 H3C 设备上所有 transceiver 的 physical channel 的 config/description 是否正确。
检查规则:
- component 名称格式如: "67.TwoHundredGigE1/0/2:1"
- 从中提取物理端口: "1/0/2"
- channel config/description 应该是: "1/0/2:1", "1/0/2:2", 等
- 即config/description 的端口部分应与 component 名称中的端口部分一致
目的:发现 H3C 设备上 config/description 配置错误的情况
"""
if not _can_connect(H3C_HOST, H3C_PORT):
pytest.skip(f"H3C NETCONF {H3C_HOST}:{H3C_PORT} 不可达,跳过 live 测试")
flt = build_transceiver_filter()
with manager.connect(
host=H3C_HOST,
port=H3C_PORT,
username=H3C_USER,
password=H3C_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)
# 解析 XML 提取详细的 config 和 state 信息
import xml.etree.ElementTree as ET
NS = {
"oc-platform": "http://openconfig.net/yang/platform",
"oc-transceiver": "http://openconfig.net/yang/platform/transceiver",
}
root = ET.fromstring(xml_str)
components_path = ".//oc-platform:components/oc-platform:component"
inconsistencies = []
total_channels_checked = 0
for comp in root.findall(components_path, NS):
# 获取 component 名称
name_elem = comp.find("oc-platform:name", NS)
if name_elem is None or not name_elem.text:
continue
component_name = name_elem.text.strip()
# 只检查 TRANSCEIVER 类型
type_elem = comp.find("oc-platform:state/oc-platform:type", NS)
if type_elem is None or "TRANSCEIVER" not in type_elem.text:
continue
# 从 component 名称中提取端口
# 格式: "67.TwoHundredGigE1/0/2:1" -> 提取 "1/0/2"
expected_port = _extract_port_from_component_name(component_name)
if not expected_port:
# 无法从 component 名称中提取端口,跳过
continue
# 遍历所有 physical channels
channels_path = (
"oc-transceiver:transceiver/oc-transceiver:physical-channels/"
"oc-transceiver:channel"
)
for ch in comp.findall(channels_path, NS):
total_channels_checked += 1
# 获取 channel index
idx_elem = ch.find("oc-transceiver:index", NS)
channel_index = idx_elem.text if idx_elem is not None else "?"
# 获取 config/description
config_desc_elem = ch.find("oc-transceiver:config/oc-transceiver:description", NS)
config_desc = config_desc_elem.text.strip() if config_desc_elem is not None and config_desc_elem.text else None
# 获取 state/description
state_desc_elem = ch.find("oc-transceiver:state/oc-transceiver:description", NS)
state_desc = state_desc_elem.text.strip() if state_desc_elem is not None and state_desc_elem.text else None
# 检查 config/description
if config_desc:
# config/description 应该是 "端口:通道号" 格式,如 "1/0/2:1"
if ":" in config_desc:
config_port = config_desc.split(":", 1)[0]
if config_port != expected_port:
inconsistencies.append({
"component": component_name,
"channel_index": channel_index,
"expected_port": expected_port,
"config_desc": config_desc,
"config_port": config_port,
"issue": "config/description 端口部分与 component 名称不一致"
})
else:
# config/description 缺失
inconsistencies.append({
"component": component_name,
"channel_index": channel_index,
"expected_port": expected_port,
"config_desc": None,
"issue": "config/description 缺失"
})
# 同时检查 state/description仅作信息收集不作为测试失败条件
# 但我们会记录这些差异,以便了解 state 数据质量
pass # state 检查移至单独的报告
# 生成报告
print(f"\n=== H3C Config/Description 一致性检查报告 ===")
print(f"检查的 channel 总数: {total_channels_checked}")
print(f"发现不一致的 channel 数: {len(inconsistencies)}")
if inconsistencies:
print("\n发现的不一致情况:")
for item in inconsistencies:
print(f" Component: {item['component']}")
print(f" Channel Index: {item['channel_index']}")
print(f" 期望端口: {item['expected_port']}")
print(f" 实际 config/description: {item.get('config_desc', 'N/A')}")
if 'config_port' in item:
print(f" config 中的端口: {item['config_port']}")
print(f" 问题: {item['issue']}")
print()
# 测试失败config/description 应该是准确的
pytest.fail(
f"发现 {len(inconsistencies)} 个 channel 的 config/description 与 component 名称不一致,"
"详见上方输出"
)
else:
print("\n✅ 所有 channel 的 config/description 都与 component 名称一致")
def _extract_port_from_component_name(component_name: str) -> str | None:
"""
从 component 名称中提取物理端口。
例如:
- "67.TwoHundredGigE1/0/2:1" -> "1/0/2"
- "63.TwoHundredGigE1/0/1:1" -> "1/0/1"
- "323.FourHundredGigE1/0/66" -> "1/0/66"
返回: 端口字符串,如 "1/0/2",若无法提取则返回 None
"""
import re
# 匹配 "数字/数字/数字" 格式的端口
# 可能在 GigE 或 FourHundredGigE 等后面,可能带或不带 ":通道号"
pattern = r"(\d+/\d+/\d+)"
match = re.search(pattern, component_name)
if match:
return match.group(1)
return None
def test_extract_port_from_component_name():
"""测试从 component 名称中提取端口的辅助函数(不依赖真实设备)"""
# 测试各种可能的命名格式
test_cases = [
("67.TwoHundredGigE1/0/2:1", "1/0/2"),
("63.TwoHundredGigE1/0/1:1", "1/0/1"),
("323.FourHundredGigE1/0/66", "1/0/66"),
("64.TwoHundredGigE10/20/30:2", "10/20/30"),
("SomePrefix99/88/77", "99/88/77"),
("no_port_here", None),
("", None),
]
for component_name, expected_port in test_cases:
result = _extract_port_from_component_name(component_name)
assert result == expected_port, (
f"'{component_name}' 提取端口失败: "
f"期望 '{expected_port}', 实际 '{result}'"
)