第326篇:自动化测试:用 pytest 验证网络状态

关键词:pytest、网络测试、合同测试(Contract Testing)、状态验证、接口测试、路由测试、ACL 测试


一、引言

在传统运维模式中,配置变更后是否"正确生效",往往依赖工程师手工登录设备执行 display 命令逐一核对。这种方式不仅效率低下,而且容易因疲劳或疏忽遗漏检查项。

pytest 是 Python 生态中最流行的测试框架。将 pytest 引入网络运维,意味着网络配置的状态验证可以像软件代码测试一样:编写测试用例 → 自动化执行 → 生成报告 → 持续集成中阻断不合格变更。

二、pytest 网络测试框架搭建

2.1 环境配置

# 安装依赖
pip install pytest pytest-html pytest-metadata
pip install pyntc napalm netmiko

# 项目测试目录结构
tests/
- conftest.py              # 全局 Fixtures
- test_interfaces.py       # 接口状态测试
- test_bgp.py              # BGP 协议测试
- test_ospf.py             # OSPF 协议测试
- test_acls.py             # ACL 策略测试
- test_connectivity.py     # 连通性测试
- test_routing.py          # 路由表测试
- fixtures/
  - devices.yaml         # 设备连接信息
  - expected_states.yaml # 期望状态定义

2.2 conftest.py 全局 Fixtures

# tests/conftest.py
import pytest
import yaml
import napalm
from pyntc import ntc_helpers

@pytest.fixture(scope='session')
def device_list():
    """加载设备清单"""
    with open('tests/fixtures/devices.yaml') as f:
        return yaml.safe_load(f)

@pytest.fixture(scope='session')
def expected_states():
    """加载期望状态"""
    with open('tests/fixtures/expected_states.yaml') as f:
        return yaml.safe_load(f)

@pytest.fixture
def device_connection(request, device_list):
    """
    创建设备连接 Fixture
    用法: def test_xxx(device_connection): ...
    参数: request.param 指定设备名
    """
    dev_name = request.param
    dev_info = next(d for d in device_list['devices']
                    if d['name'] == dev_name)

    driver = napalm.get_network_driver(dev_info['os'])
    device = driver(
        dev_info['hostname'],
        dev_info['username'],
        dev_info['password'],
        optional_args={'timeout': 60}
    )
    device.open()
    yield device
    device.close()

# 参数化:所有 PE 设备
@pytest.fixture(params=['PE-BJ-01', 'PE-BJ-02', 'PE-SH-01'])
def pe_device(request, device_connection):
    """PE 设备 Fixture"""
    return device_connection

2.3 期望状态定义

# tests/fixtures/expected_states.yaml
interfaces:
  PE-BJ-01:
    GigabitEthernet1/0/0:
      status: up
      description: "UPLINK_TO_PE-BJ-02"
      ip_address: "10.1.1.1/30"
      mtu: 9216
    GigabitEthernet1/0/1:
      status: up
      description: "UPLINK_TO_P-BJ-01"
      ip_address: "10.1.1.5/30"
      mtu: 9216

  PE-BJ-02:
    GigabitEthernet1/0/0:
      status: up
      description: "UPLINK_TO_PE-BJ-01"
      ip_address: "10.1.1.2/30"
      mtu: 9216

bgp:
  PE-BJ-01:
    as_number: 65001
    router_id: "10.100.1.1"
    peers:
      - ip: "10.1.1.2"
        remote_as: 65002
        state: "Established"
        af: "ipv4 unicast"
        keepalive: 10
        holdtime: 30

acl:
  PE-BJ-01:
    advanced_acl_3001:
      rules:
        - "permit tcp any 192.168.1.0 0.0.0.255 eq 443"
        - "deny ip any any"

三、核心测试用例编写

3.1 接口状态测试

# tests/test_interfaces.py
import pytest

def test_interface_status(pe_device, expected_states):
    """验证接口 Up/Down 状态"""
    dev_name = pe_device._name  # 从 fixture 获取设备名
    interfaces = pe_device.get_interfaces()
    expected = expected_states['interfaces'].get(dev_name, {})

    for intf_name, expected_attrs in expected.items():
        if intf_name in interfaces:
            actual = interfaces[intf_name]
            assert actual['is_up'] == (expected_attrs.get('status') == 'up'), \
                f"接口 {intf_name} 状态异常: 期望 {expected_attrs.get('status')}, " \
                f"实际 {'up' if actual['is_up'] else 'down'}"
        else:
            pytest.fail(f"接口 {intf_name} 不存在于设备 {dev_name}")

def test_interface_description(pe_device, expected_states):
    """验证接口描述(用于核对连接关系)"""
    dev_name = pe_device._name
    interfaces = pe_device.get_interfaces()
    expected = expected_states['interfaces'].get(dev_name, {})

    for intf_name, expected_attrs in expected.items():
        if 'description' in expected_attrs:
            actual_desc = interfaces[intf_name].get('description', '')
            assert expected_attrs['description'].lower() in actual_desc.lower(), \
                f"接口 {intf_name} 描述不匹配: " \
                f"期望包含 '{expected_attrs['description']}', " \
                f"实际 '{actual_desc}'"

def test_interface_mtu(pe_device, expected_states):
    """验证接口 MTU"""
    dev_name = pe_device._name
    interfaces = pe_device.get_interfaces()
    expected = expected_states['interfaces'].get(dev_name, {})

    for intf_name, expected_attrs in expected.items():
        if 'mtu' in expected_attrs:
            actual_mtu = interfaces[intf_name].get('mtu', 1500)
            assert actual_mtu == expected_attrs['mtu'], \
                f"接口 {intf_name} MTU 不匹配: " \
                f"期望 {expected_attrs['mtu']}, 实际 {actual_mtu}"

def test_interface_ip_address(pe_device, expected_states):
    """验证接口 IP 地址"""
    dev_name = pe_device._name
    facts = pe_device.get_interfaces_ip()
    expected = expected_states['interfaces'].get(dev_name, {})

    for intf_name, expected_attrs in expected.items():
        if 'ip_address' in expected_attrs:
            ip_network = expected_attrs['ip_address']
            # 解析期望 IP 和掩码
            if '/' in ip_network:
                ip, mask = ip_network.split('/')
            else:
                ip = ip_network
                mask = '24'

            assert ip in str(facts.get(intf_name, {}).get('ipv4', {})), \
                f"接口 {intf_name} IP 地址不匹配"

3.2 BGP 协议测试

# tests/test_bgp.py
import pytest

def test_bgp_as_number(pe_device, expected_states):
    """验证 BGP AS 号"""
    dev_name = pe_device._name
    bgp_config = pe_device.get_bgp_config()
    expected = expected_states['bgp'].get(dev_name, {})

    actual_as = bgp_config.get('global', {}).get('as', 0)
    expected_as = expected.get('as_number', 0)
    assert actual_as == expected_as, \
        f"BGP AS 不匹配: 期望 {expected_as}, 实际 {actual_as}"

def test_bgp_router_id(pe_device, expected_states):
    """验证 BGP Router ID"""
    dev_name = pe_device._name
    bgp_config = pe_device.get_bgp_config()
    expected = expected_states['bgp'].get(dev_name, {})

    actual_rid = bgp_config.get('global', {}).get('router_id', '')
    expected_rid = expected.get('router_id', '')
    assert actual_rid == expected_rid, \
        f"BGP Router ID 不匹配: 期望 {expected_rid}, 实际 {actual_rid}"

def test_bgp_peer_state(pe_device, expected_states):
    """验证 BGP Peer 会话状态"""
    dev_name = pe_device._name
    bgp_fact = pe_device.get_bgp_facts()
    expected = expected_states['bgp'].get(dev_name, {})

    for peer_cfg in expected.get('peers', []):
        peer_ip = peer_cfg['ip']
        expected_state = peer_cfg['state']

        # 在 BGP facts 中查找对应 peer
        peer_found = False
        for peer_key, peer_info in bgp_fact.get('peers', {}).items():
            if peer_ip in str(peer_key):
                peer_found = True
                actual_state = peer_info.get('state', 'Idle')
                assert actual_state == expected_state, \
                    f"BGP Peer {peer_ip} 状态异常: " \
                    f"期望 {expected_state}, 实际 {actual_state}"
                break

        assert peer_found, f"BGP Peer {peer_ip} 未在运行状态中找到"

def test_bgp_peer_count(pe_device, expected_states):
    """验证 BGP Peer 数量"""
    dev_name = pe_device._name
    bgp_fact = pe_device.get_bgp_facts()
    expected = expected_states['bgp'].get(dev_name, {})

    expected_count = len(expected.get('peers', []))
    established_count = sum(
        1 for p in bgp_fact.get('peers', {}).values()
        if p.get('state') == 'Established'
    )
    assert established_count == expected_count, \
        f"BGP Established Peer 数量不匹配: " \
        f"期望 {expected_count}, 实际 {established_count}"

def test_bgp_route_count(pe_device):
    """验证 BGP 路由表条目数不为 0"""
    bgp_fact = pe_device.get_bgp_facts()
    total_routes = bgp_fact.get('routes', {}).get('total', 0)
    assert total_routes > 0, \
        "BGP 路由表为空,可能存在路由泄露或配置错误"

3.3 ACL 策略测试

# tests/test_acls.py
import pytest

def test_acl_rules_exist(device_connection, expected_states):
    """验证 ACL 规则已应用"""
    dev_name = device_connection._name
    acls = device_connection.get_acls()
    expected_acls = expected_states['acl'].get(dev_name, {})

    for acl_name in expected_acls:
        assert acl_name in acls, \
            f"ACL {acl_name} 未找到"

        actual_rules = acls[acl_name].get('rules', [])
        expected_rules = expected_acls[acl_name].get('rules', [])

        for exp_rule in expected_rules:
            found = any(exp_rule.lower() in str(rule).lower()
                       for rule in actual_rules)
            assert found, \
                f"ACL {acl_name} 中未找到规则: {exp_rule}"

def test_default_deny_applied(device_connection, expected_states):
    """验证默认拒绝策略已生效"""
    dev_name = device_connection._name
    acls = device_connection.get_acls()
    expected_acls = expected_states['acl'].get(dev_name, {})

    for acl_name, acl_cfg in expected_acls.items():
        rules = acl_cfg.get('rules', [])
        # 检查最后一条规则是否为 deny
        if rules and 'deny' in rules[-1].lower():
            actual_rules = acls[acl_name].get('rules', [])
            assert actual_rules, \
                f"ACL {acl_name} 规则列表为空"

def test_acl_applied_to_interface(pe_device, expected_states):
    """验证 ACL 已应用到接口"""
    dev_name = pe_device._name
    interfaces = pe_device.get_interfaces()

    # 检查入方向 ACL 应用
    for intf_name, intf_info in interfaces.items():
        applied_acls = intf_info.get('ipv4', {}).get('acl', '')
        # 根据业务规则验证特定接口是否绑定了 ACL
        if 'UPLINK' in intf_info.get('description', '').upper():
            assert applied_acls, \
                f"上联接口 {intf_name} 未绑定 ACL"

3.4 连通性测试

# tests/test_connectivity.py
import pytest
import subprocess

def test_ping_reachability(pe_device):
    """从设备执行 Ping 测试"""
    # 测试到核心路由器的连通性
    targets = ['10.1.1.3', '10.1.1.4', '10.1.1.5']

    for target in targets:
        result = subprocess.run(
            ['ping', '-c', '3', '-W', '2', target],
            capture_output=True, text=True
        )
        assert result.returncode == 0, \
            f"Ping {target} 失败: {result.stderr}"

def test_traceroute_path(pe_device):
    """验证 Traceroute 路径符合预期"""
    # 通过 SSH 执行 traceroute
    result = pe_device.cli(['traceroute 10.100.0.1 max-hops 10'])
    hops = result.strip().split('\n')

    # 验证路径不经过非预期的跳
    assert len(hops) <= 5, \
        "Traceroute 路径过长,可能存在次优路由"

四、测试执行与报告

4.1 执行命令

# 执行全部测试
pytest tests/ -v --html=reports/test_report.html

# 执行特定测试文件
pytest tests/test_bgp.py -v

# 执行特定设备测试
pytest tests/test_interfaces.py -v -k "PE-BJ-01"

# 仅执行失败的测试(调试用)
pytest tests/ --lf

# 带详细输出
pytest tests/ -v -s --tb=long

4.2 CI/CD 集成

# .gitlab-ci.yml 中的测试阶段
network_test:
  stage: integration-test
  image: python:3.11-slim
  script:
    - pip install -r requirements-test.txt
    - pytest tests/ -v
      --html=reports/test_report.html
      --junitxml=reports/junit.xml
  artifacts:
    when: always
    reports:
      junit: reports/junit.xml
    paths:
      - reports/
    expire_in: 30 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

4.3 测试输出示例

=================================== test session starts ===================================
platform linux -- Python 3.11.6, pytest-7.4.3
plugins: html-3.2.0, metadata-3.0.0
collected 24 items

tests/test_interfaces.py::test_interface_status[PE-BJ-01] PASSED            [  4%]
tests/test_interfaces.py::test_interface_status[PE-BJ-02] PASSED            [  8%]
tests/test_interfaces.py::test_interface_status[PE-SH-01] PASSED            [ 12%]
tests/test_interfaces.py::test_interface_description[PE-BJ-01] PASSED       [ 16%]
tests/test_interfaces.py::test_interface_description[PE-BJ-02] PASSED       [ 20%]
tests/test_bgp.py::test_bgp_as_number[PE-BJ-01] PASSED                     [ 25%]
tests/test_bgp.py::test_bgp_peer_state[PE-BJ-01] FAILED                    [ 29%]
tests/test_bgp.py::test_bgp_peer_count[PE-BJ-01] FAILED                    [ 33%]
tests/test_acls.py::test_acl_rules_exist[PE-BJ-01] PASSED                  [ 37%]

==================================== FAILURES =============================================
____________________ test_bgp_peer_state[PE-BJ-01] ____________________

E   AssertionError: BGP Peer 10.1.1.2 状态异常: 期望 Established, 实际 Idle

tests/test_bgp.py:45: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_bgp.py::test_bgp_peer_state[PE-BJ-01] - AssertionError...
FAILED tests/test_bgp.py::test_bgp_peer_count[PE-BJ-01] - AssertionError...
========================= 2 failed, 22 passed in 45.32s ==========================

五、Network Tools 工具集:SNMP采集器/配置备份/批量执行

5.1 工具集架构设计

工具集架构设计 - 图示

5.2 SNMP 设备指标采集器

# tools/snmp_collector.py
"""
SNMP 批量采集器
支持:CPU/内存利用率、接口流量/错误计数、温度/风扇状态
协议:SNMPv2c / SNMPv3(AES-128 认证 + 加密)
"""
import pysnmp.hlapi as snmp
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional
from datetime import datetime

logger = logging.getLogger(__name__)

# 常用 OID 定义
SNMP_OIDS = {
    'system_uptime': '1.3.6.1.2.1.1.3.0',
    'hostname': '1.3.6.1.2.1.1.5.0',
    # 华为 VRP CPU/内存
    'cpu_usage': '1.3.6.1.4.1.2011.5.25.31.1.1.1.1.1',
    'memory_usage': '1.3.6.1.4.1.2011.5.25.31.1.1.1.1.2',
    # 标准接口表
    'if_table_entries': '1.3.6.1.2.1.2.2.1.2',
    'if_in_octets': '1.3.6.1.2.1.2.2.1.10',
    'if_out_octets': '1.3.6.1.2.1.2.2.1.16',
    'if_in_errors': '1.3.6.1.2.1.2.2.1.14',
    'if_out_errors': '1.3.6.1.2.1.2.2.1.20',
    'if_admin_status': '1.3.6.1.2.1.2.2.1.7',
    'if_oper_status': '1.3.6.1.2.1.2.2.1.8',
}


@dataclass
class DeviceMetrics:
    """设备指标数据模型"""
    device_name: str
    ip_address: str
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
    cpu_usage: Optional[float] = None
    memory_usage: Optional[float] = None
    interfaces: List[Dict] = field(default_factory=list)
    errors: List[str] = field(default_factory=list)


class SNMPCollector:
    """SNMP 批量采集器"""

    def __init__(self, community='public', version=2, timeout=5, retries=2):
        self.community = community
        self.version = version
        self.timeout = timeout
        self.retries = retries

    def _build_snmp_version(self):
        if self.version == 3:
            from pysnmp.hlapi import UsmUserData
            return UsmUserData('admin', 'authkey', 'privkey',
                               authProtocol='usmHMACSHAAuthProtocol',
                               privProtocol='usmAesCBCCipher')
        return snmp.CommunityData(self.community)

    def collect_single(self, host: str, device_name: str) -> DeviceMetrics:
        """采集单台设备的指标"""
        metrics = DeviceMetrics(device_name=device_name, ip_address=host)
        creds = self._build_snmp_version()

        try:
            # 采集标量 OID
            for metric_name, oid in SNMP_OIDS.items():
                if metric_name in ('if_table_entries', 'if_in_octets',
                                   'if_out_octets', 'if_in_errors',
                                   'if_out_errors', 'if_admin_status',
                                   'if_oper_status'):
                    continue  # 表格OID单独处理

                iterator = snmp.getCmd(
                    snmp.SnmpEngine(),
                    creds,
                    snmp.UdpTransportTarget((host, 161),
                                            timeout=self.timeout,
                                            retries=self.retries),
                    snmp.ObjectType(snmp.ObjectIdentifier(oid))
                )
                error_indication, error_status, error_index, var_binds = next(iterator)

                if error_indication:
                    metrics.errors.append(f"SNMP Error: {error_indication}")
                elif var_binds:
                    value = var_binds[0][1]
                    if metric_name == 'cpu_usage':
                        metrics.cpu_usage = float(value) / 10.0  # 转换为百分比
                    elif metric_name == 'memory_usage':
                        metrics.memory_usage = float(value) / 10.0

            # 采集接口表格数据
            interfaces = self._collect_interfaces(host, creds)
            metrics.interfaces = interfaces

        except Exception as e:
            metrics.errors.append(f"采集异常: {str(e)}")
            logger.error(f"设备 {host} 采集失败: {e}")

        return metrics

    def _collect_interfaces(self, host: str, creds) -> List[Dict]:
        """采集接口表格数据"""
        interfaces = []
        # 遍历接口索引(假设最多128个接口)
        for idx in range(1, 129):
            name_oid = f"1.3.6.1.2.1.2.2.1.2.{idx}"
            iterator = snmp.getCmd(
                snmp.SnmpEngine(),
                creds,
                snmp.UdpTransportTarget((host, 161),
                                        timeout=self.timeout,
                                        retries=self.retries),
                snmp.ObjectType(snmp.ObjectIdentifier(name_oid))
            )
            _, _, _, var_binds = next(iterator)
            if not var_binds:
                break  # 到达接口表末尾

            iface_name = str(var_binds[0][1])
            in_octets = self._get_oid(host, creds, f"1.3.6.1.2.1.2.2.1.10.{idx}")
            out_octets = self._get_oid(host, creds, f"1.3.6.1.2.1.2.2.1.16.{idx}")
            in_errors = self._get_oid(host, creds, f"1.3.6.1.2.1.2.2.1.14.{idx}")
            oper_status = self._get_oid(host, creds, f"1.3.6.1.2.1.2.2.1.8.{idx}")

            interfaces.append({
                'name': iface_name,
                'index': idx,
                'in_octets': int(in_octets) if in_octets else 0,
                'out_octets': int(out_octets) if out_octets else 0,
                'in_errors': int(in_errors) if in_errors else 0,
                'status': 'up' if oper_status == '1' else 'down'
            })
        return interfaces

    def _get_oid(self, host: str, creds, oid: str) -> Optional[str]:
        """获取单个 OID 值"""
        try:
            iterator = snmp.getCmd(
                snmp.SnmpEngine(),
                creds,
                snmp.UdpTransportTarget((host, 161),
                                        timeout=self.timeout,
                                        retries=self.retries),
                snmp.ObjectType(snmp.ObjectIdentifier(oid))
            )
            _, _, _, var_binds = next(iterator)
            return str(var_binds[0][1]) if var_binds else None
        except:
            return None

    def collect_batch(self, devices: List[Dict], max_workers: int = 20) -> List[DeviceMetrics]:
        """批量采集多台设备"""
        results = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(self.collect_single, d['ip'], d['name']): d
                for d in devices
            }
            for future in as_completed(futures):
                try:
                    result = future.result()
                    results.append(result)
                except Exception as e:
                    dev = futures[future]
                    logger.error(f"设备 {dev['name']} 采集异常: {e}")
        return results


# 使用示例
if __name__ == '__main__':
    devices = [
        {'name': 'PE-BJ-01', 'ip': '10.1.1.1'},
        {'name': 'PE-BJ-02', 'ip': '10.1.1.2'},
        {'name': 'PE-SH-01', 'ip': '10.1.2.1'},
    ]
    collector = SNMPCollector(community='public')
    results = collector.collect_batch(devices)
    for m in results:
        print(f"{m.device_name}: CPU={m.cpu_usage}%, MEM={m.memory_usage}%")

5.3 配置备份工具

# tools/config_backup.py
"""
网络配置备份工具
功能:批量采集设备运行配置、生成SHA256校验、增量检测、自动归档
"""
import os
import hashlib
import logging
from netmiko import ConnectHandler
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)


class ConfigBackupManager:
    """配置备份管理器"""

    def __init__(self, backup_root: str = './backups',
                 device_credentials: Optional[Dict] = None):
        self.backup_root = Path(backup_root)
        self.backup_root.mkdir(parents=True, exist_ok=True)
        self.creds = device_credentials or {}
        # 存储上次备份的哈希值,用于增量检测
        self.last_hashes: Dict[str, str] = {}
        self._load_last_hashes()

    def _load_last_hashes(self):
        """加载上次备份的哈希映射"""
        hash_file = self.backup_root / '.last_hashes.json'
        if hash_file.exists():
            import json
            with open(hash_file) as f:
                self.last_hashes = json.load(f)

    def _save_hashes(self):
        """保存当前哈希映射"""
        import json
        hash_file = self.backup_root / '.last_hashes.json'
        with open(hash_file, 'w') as f:
            json.dump(self.last_hashes, f, indent=2, ensure_ascii=False)

    def _get_device_params(self, device: Dict) -> Dict:
        """构建 Netmiko 连接参数"""
        base = {
            'device_type': device.get('platform', 'huawei_vrpv8'),
            'host': device['ip'],
            'username': device.get('username', self.creds.get('username', 'admin')),
            'password': device.get('password', self.creds.get('password', '')),
            'timeout': 30,
            'session_timeout': 60,
        }
        if device.get('port'):
            base['port'] = device['port']
        return base

    def backup_single(self, device: Dict) -> Dict:
        """备份单台设备配置"""
        dev_name = device['name']
        today = datetime.now().strftime('%Y/%m/%d')
        backup_dir = self.backup_root / dev_name / today
        backup_dir.mkdir(parents=True, exist_ok=True)

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        backup_file = backup_dir / f"{dev_name}_{timestamp}.cfg"
        change_file = backup_dir / f"{dev_name}_{timestamp}.changes"

        result = {
            'device': dev_name,
            'status': 'success',
            'backup_file': str(backup_file),
            'has_changes': False,
            'errors': []
        }

        try:
            with ConnectHandler(**self._get_device_params(device)) as net:
                # 获取运行配置
                running_config = net.send_command(
                    'display current-configuration',
                    use_textfsession=True
                )

                # 写入备份文件
                with open(backup_file, 'w', encoding='utf-8') as f:
                    f.write(running_config)

                # 计算哈希
                current_hash = hashlib.sha256(
                    running_config.encode()
                ).hexdigest()

                # 增量检测
                if dev_name in self.last_hashes:
                    if self.last_hashes[dev_name] != current_hash:
                        result['has_changes'] = True
                        # 生成 diff 文件
                        self._generate_diff(
                            dev_name,
                            self.last_hashes[dev_name],
                            current_hash,
                            change_file
                        )
                        logger.info(f"设备 {dev_name} 配置已变更")
                else:
                    result['has_changes'] = True
                    logger.info(f"设备 {dev_name} 首次备份")

                self.last_hashes[dev_name] = current_hash

        except Exception as e:
            result['status'] = 'failed'
            result['errors'].append(str(e))
            logger.error(f"设备 {dev_name} 备份失败: {e}")

        return result

    def _generate_diff(self, device_name: str, old_hash: str,
                       new_hash: str, diff_file: Path):
        """生成配置差异报告"""
        import difflib
        # 读取上次备份
        last_backup = self._find_last_backup(device_name)
        if last_backup:
            with open(last_backup, 'r', encoding='utf-8') as f:
                old_config = f.read()

            with open(diff_file.parent / f"{device_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.cfg",
                      'r', encoding='utf-8') as f:
                new_config = f.read()

            diff = difflib.unified_diff(
                old_config.splitlines(),
                new_config.splitlines(),
                fromfile='previous',
                tofile='current',
                lineterm=''
            )
            with open(diff_file, 'w', encoding='utf-8') as f:
                f.write('\n'.join(diff))

    def _find_last_backup(self, device_name: str) -> Optional[Path]:
        """查找设备上次备份文件"""
        dev_dir = self.backup_root / device_name
        if not dev_dir.exists():
            return None
        cfg_files = sorted(dev_dir.rglob('*.cfg'), reverse=True)
        for cfg in cfg_files:
            if '.changes' not in cfg.name:
                return cfg
        return None

    def backup_all(self, devices: List[Dict], max_workers: int = 10) -> List[Dict]:
        """批量备份所有设备"""
        results = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(self.backup_single, d): d
                for d in devices
            }
            for future in as_completed(futures):
                results.append(future.result())

        # 保存哈希映射
        self._save_hashes()

        # 汇总报告
        changed = sum(1 for r in results if r['has_changes'])
        failed = sum(1 for r in results if r['status'] == 'failed')
        logger.info(f"备份完成: 总计{len(results)}台, "
                    f"变更{changed}台, 失败{failed}台")
        return results


# 配合 crontab 定时执行
# 0 2 * * * /usr/bin/python3 /opt/tools/config_backup.py --all

5.4 批量命令执行器

# tools/batch_executor.py
"""
批量命令执行器
支持:单命令批量执行、多命令顺序/并行执行、结果聚合、超时控制
"""
import logging
from netmiko import ConnectHandler
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from datetime import datetime

logger = logging.getLogger(__name__)


@dataclass
class ExecResult:
    """单台设备执行结果"""
    device_name: str
    ip_address: str
    command: str
    output: str = ""
    success: bool = True
    error: str = ""
    duration_ms: float = 0.0
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())


class BatchCommandExecutor:
    """批量命令执行器"""

    def __init__(self, default_credentials: Optional[Dict] = None,
                 connect_timeout: int = 30, session_timeout: int = 120):
        self.default_creds = default_credentials or {}
        self.connect_timeout = connect_timeout
        self.session_timeout = session_timeout

    def execute_single(self, device: Dict, commands: List[str],
                       delay_factor: int = 1) -> List[ExecResult]:
        """在单台设备上顺序执行多条命令"""
        results = []
        params = {
            'device_type': device.get('platform', 'huawei_vrpv8'),
            'host': device['ip'],
            'username': device.get('username', self.default_creds.get('username')),
            'password': device.get('password', self.default_creds.get('password')),
            'timeout': self.connect_timeout,
            'session_timeout': self.session_timeout,
            'delay_factor': delay_factor,
        }

        try:
            with ConnectHandler(**params) as net:
                for cmd in commands:
                    import time
                    start = time.time()
                    try:
                        output = net.send_command(cmd, read_timeout=60)
                        duration = (time.time() - start) * 1000
                        results.append(ExecResult(
                            device_name=device['name'],
                            ip_address=device['ip'],
                            command=cmd,
                            output=output,
                            success=True,
                            duration_ms=duration
                        ))
                    except Exception as e:
                        duration = (time.time() - start) * 1000
                        results.append(ExecResult(
                            device_name=device['name'],
                            ip_address=device['ip'],
                            command=cmd,
                            success=False,
                            error=str(e),
                            duration_ms=duration
                        ))
        except Exception as e:
            # 连接失败,所有命令标记失败
            for cmd in commands:
                results.append(ExecResult(
                    device_name=device['name'],
                    ip_address=device['ip'],
                    command=cmd,
                    success=False,
                    error=f"连接失败: {str(e)}"
                ))

        return results

    def execute_parallel(self, devices: List[Dict], commands: List[str],
                         max_workers: int = 20) -> List[ExecResult]:
        """并行执行:所有设备同时执行指定命令"""
        all_results = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {
                executor.submit(self.execute_single, d, commands): d['name']
                for d in devices
            }
            for future in as_completed(futures):
                dev_name = futures[future]
                try:
                    results = future.result()
                    all_results.extend(results)
                except Exception as e:
                    logger.error(f"设备 {dev_name} 执行异常: {e}")

        return all_results

    def execute_sequential_with_check(self, devices: List[Dict],
                                       commands: List[Dict],
                                       check_interval: int = 60) -> List[ExecResult]:
        """
        顺序执行带健康检查
        commands: [{'cmd': '...', 'check': 'keyword', 'critical': True}]
        如果关键命令检查结果不匹配,停止后续设备执行
        """
        all_results = []
        for device in devices:
            results = self.execute_single(device,
                                          [c['cmd'] for c in commands])
            all_results.extend(results)

            # 检查结果
            for i, cmd_info in enumerate(commands):
                if cmd_info.get('critical') and i < len(results):
                    result = results[i]
                    if result.success:
                        check_keyword = cmd_info.get('check', '')
                        if check_keyword and check_keyword not in result.output:
                            logger.warning(
                                f"设备 {device['name']} 检查结果异常: "
                                f"期望 '{check_keyword}' 不在输出中"
                            )
                            # 关键检查失败,是否中断?
                            if cmd_info.get('stop_on_fail'):
                                logger.error("关键检查失败,停止后续设备执行")
                                break

            # 等待检查间隔
            if check_interval > 0:
                import time
                time.sleep(check_interval)

        return all_results

    def generate_report(self, results: List[ExecResult]) -> str:
        """生成执行报告"""
        lines = ["## 批量命令执行报告", ""]
        lines.append(f"| 设备 | 命令 | 状态 | 耗时(ms) | 错误 |")
        lines.append(f"|------|------|------|----------|------|")

        for r in results:
            status = '✅' if r.success else '❌'
            lines.append(
                f"| {r.device_name} | `{r.command[:40]}...` "
                f"| {status} | {r.duration_ms:.0f} | {r.error[:30]} |"
            )

        total = len(results)
        failed = sum(1 for r in results if not r.success)
        lines.extend([
            "",
            f"**统计**: 总计 {total} 条, 成功 {total - failed} 条, "
            f"失败 {failed} 条, 成功率 {(total-failed)/total*100:.1f}%"
        ])
        return '\n'.join(lines)

5.5 工具集单元测试示例

# tests/test_network_tools.py
"""Network Tools 工具集单元测试"""
import pytest
from unittest.mock import MagicMock, patch
from tools.snmp_collector import SNMPCollector, DeviceMetrics
from tools.config_backup import ConfigBackupManager
from tools.batch_executor import BatchCommandExecutor, ExecResult


class TestSNMPCollector:
    """SNMP 采集器测试"""

    @pytest.fixture
    def collector(self):
        return SNMPCollector(community='public', version=2)

    def test_collect_single_success(self, collector):
        """测试单设备采集成功"""
        with patch('tools.snmp_collector.snmp') as mock_snmp:
            # Mock SNMP 返回值
            mock_iterator = iter([(None, 0, 0,
                                  [(snmp.ObjectIdentifier('1.3.6.1.4.1.2011.5.25.31.1.1.1.1.1'),
                                    MagicMock().__str__=MagicMock(return_value='45'))])])
            mock_snmp.getCmd.return_value = mock_iterator

            result = collector.collect_single('10.1.1.1', 'PE-BJ-01')
            assert result.device_name == 'PE-BJ-01'
            assert result.ip_address == '10.1.1.1'
            assert isinstance(result.timestamp, str)

    def test_collect_single_timeout(self, collector):
        """测试采集超时处理"""
        with patch('tools.snmp_collector.snmp') as mock_snmp:
            mock_snmp.getCmd.side_effect = TimeoutError()
            result = collector.collect_single('10.1.1.1', 'PE-BJ-01')
            assert len(result.errors) > 0

    def test_device_metrics_defaults(self):
        """测试 DeviceMetrics 默认值"""
        m = DeviceMetrics(device_name='test', ip_address='1.2.3.4')
        assert m.cpu_usage is None
        assert m.memory_usage is None
        assert m.interfaces == []
        assert m.errors == []


class TestConfigBackupManager:
    """配置备份工具测试"""

    @pytest.fixture
    def backup_mgr(self, tmp_path):
        mgr = ConfigBackupManager(backup_root=str(tmp_path / 'backups'))
        return mgr

    def test_backup_single_success(self, backup_mgr):
        """测试单设备备份成功"""
        with patch('tools.config_backup.ConnectHandler') as mock_connect:
            mock_net = MagicMock()
            mock_net.send_command.return_value = 'sysname test-device\n'
            mock_connect.return_value.__enter__ = MagicMock(return_value=mock_net)
            mock_connect.return_value.__exit__ = MagicMock(return_value=False)

            result = backup_mgr.backup_single({
                'name': 'PE-BJ-01', 'ip': '10.1.1.1'
            })
            assert result['status'] == 'success'
            assert result['has_changes'] is True  # 首次备份

    def test_backup_detects_no_change(self, backup_mgr):
        """测试无变更时不标记变更"""
        device = {'name': 'PE-BJ-01', 'ip': '10.1.1.1'}
        config = 'sysname test-device\n'

        with patch('tools.config_backup.ConnectHandler') as mock_connect:
            mock_net = MagicMock()
            mock_net.send_command.return_value = config
            mock_connect.return_value.__enter__ = MagicMock(return_value=mock_net)
            mock_connect.return_value.__exit__ = MagicMock(return_value=False)

            # 首次备份
            backup_mgr.backup_single(device)
            # 再次备份(配置未变)
            result = backup_mgr.backup_single(device)
            assert result['has_changes'] is False


class TestBatchExecutor:
    """批量命令执行器测试"""

    @pytest.fixture
    def executor(self):
        return BatchCommandExecutor(
            default_credentials={'username': 'admin', 'password': 'test'}
        )

    def test_generate_report(self, executor):
        """测试报告生成"""
        results = [
            ExecResult('PE-BJ-01', '10.1.1.1', 'display version',
                       'VRP version 8.x', True, '', 120.5),
            ExecResult('PE-BJ-02', '10.1.1.2', 'display version',
                       '', False, 'Connection timeout', 30000),
        ]
        report = executor.generate_report(results)
        assert '## 批量命令执行报告' in report
        assert '✅' in report
        assert '❌' in report
        assert '成功率 50.0%' in report

5.6 CI/CD 集成方案

# .gitlab-ci.yml - Network Tools CI/CD 集成
stages:
  - unit-test
  - lint
  - integration-test
  - deploy-tools

variables:
  PYTHONPATH: "${PYTHONPATH}:."

# 单元测试
unit_test_tools:
  stage: unit-test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - pip install -r requirements-test.txt
    - pytest tests/test_network_tools.py -v --junitxml=report.xml
      --cov=tools --cov-report=xml --cov-report=html
  coverage: '/TOTAL.*\s+(\d+%)/'
  artifacts:
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
    paths:
      - htmlcov/
    expire_in: 30 days

# 代码规范检查
lint_tools:
  stage: lint
  image: python:3.11-slim
  script:
    - pip install ruff mypy black
    - ruff check tools/
    - black --check tools/
    - mypy tools/ --ignore-missing-imports

# 集成测试(连接仿真环境)
integration_test:
  stage: integration-test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - pytest tests/integration/ -v
      --devices-config=tests/fixtures/simulator_devices.yaml
  needs:
    - unit_test_tools
    - lint_tools
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# 打包部署工具
deploy_tools:
  stage: deploy-tools
  image: python:3.11-slim
  script:
    - pip install build
    - python -m build
    - echo "构建完成,产物: dist/*"
  artifacts:
    paths:
      - dist/
    expire_in: 7 days
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

六、总结

A["单元测试<br/>配置语法检查<br/>YAML 格式校验<br/>模板渲染验证"] --> B
B["集成测试<br/>单设备状态验证<br/>BGP/OSPF/ACL<br/>接口/路由表"] --> C
C["端到端测试<br/>多设备连通性<br/>业务流验证<br/>故障切换演练"] --> D
D["混沌测试<br/>随机端口 Down<br/>链路中断模拟<br/>设备重启演练"]

style A fill:#E8F5E9
style B fill:#E3F2FD
style C fill:#FFF3E0
style D fill:#FFEBEE

```

故障案例:pytest 拦截 BGP Timer 变更回归

在 CI/CD Pipeline(第322篇)中,工程师提交了 BGP Timer 调整的配置变更。pytest 集成测试阶段发现了问题:

  1. test_bgp_peer_state 检测到 PE-BJ-01 的 BGP 会话从 Established 降至 Idle
  2. test_bgp_peer_count 检测到 Established Peer 数从 4 降至 2
  3. Pipeline 自动标记为 Failed,阻止了后续的生产部署
  4. 工程师分析发现:对端设备的 holdtime 未同步调整,导致 BGP Timer 不匹配
  5. 补充对端设备的 Timer 配置后,测试全部通过,变更成功部署

思考题

  1. pytest 网络测试中的"期望状态"文件(expected_states.yaml)由谁维护?当网络变更频繁时,如何确保期望状态的时效性?
  2. 对于涉及多台设备协同工作的测试(如 BGP 对等会话、OSPF 邻接关系),如何设计测试用例以准确验证端到端状态?

下篇预告:第327篇《ChatOps:用飞书/钉钉机器人操作网络》——通过即时通讯工具机器人实现网络操作的对话式交互。