第306篇:Jinja2 模板引擎——配置模板化

关键词:Jinja2、模板引擎、配置模板、宏、继承、条件渲染、循环、网络配置生成


一、引言

在前几篇中,我们掌握了连接设备、批量执行命令的方法。但当配置内容变得复杂——比如为50台交换机分别生成包含VLAN、接口、BGP、OSPF等数十行配置的完整配置时,逐条拼接字符串的方式既难以维护又容易出错。

Jinja2是Python生态中最强大的模板引擎,Flask、Ansible、OpenStack等知名项目均以其为核心。它将配置内容从"硬编码命令"提升为"参数化模板",使一份模板即可适配数百台设备的差异化配置需求。


二、Jinja2 基础语法

2.1 安装与Hello World

pip install jinja2
from jinja2 import Template

# 最基本的模板渲染
template_str = "你好, {{ name }}!欢迎学习 {{ topic }}。"
template = Template(template_str)

output = template.render(name="网络工程师", topic="Jinja2")
print(output)
# 输出: 你好, 网络工程师!欢迎学习 Jinja2。

2.2 核心语法速查

Jinja2使用三种分隔符:

语法 分隔符 说明 示例
变量输出 {{ }} 渲染变量或表达式 {{ hostname }}
控制语句 {% %} if/for/extends等 {% for item in list %}
注释 {# #} 模板内注释,不输出 {# 这是注释 #}

三、条件渲染与循环

3.1 条件判断(if/elif/else)

from jinja2 import Template

template_str = """
hostname {{ hostname }}

{% if role == "core" %}
! === 核心交换机配置 ===
vlan batch 10 20 30 100 200
ospf 1 router-id {{ router_id }}
 area 0.0.0.0
  network 10.0.0.0 0.0.0.255
{% elif role == "aggregation" %}
! === 汇聚交换机配置 ===
vlan batch 10 20 30
ospf 1 router-id {{ router_id }}
 area 0.0.0.0
  network {{ mgmt_network }} 0.0.0.255
{% elif role == "access" %}
! === 接入交换机配置 ===
vlan batch 10 20
{% endif %}
"""

template = Template(template_str)

# 渲染核心交换机配置
core_config = template.render(
    hostname="Core-SW-01",
    role="core",
    router_id="10.255.0.1",
    mgmt_network="10.255.1.0"
)
print(core_config)

# 渲染接入交换机配置
access_config = template.render(
    hostname="Access-SW-01",
    role="access",
    router_id="10.255.2.1"
)
print(access_config)

3.2 for 循环

from jinja2 import Template

template_str = """
! === VLAN 配置 ===
{% for vlan_id, vlan_info in vlans.items() %}
vlan {{ vlan_id }}
 description {{ vlan_info.name }}
{% if vlan_info.ipv4_subnet %}
 ip interface {{ vlan_info.ipv4_subnet }}
{% endif %}
{% endfor %}

! === 接口配置 ===
{% for iface in interfaces %}
interface {{ iface.name }}
 description {{ iface.description }}
 port link-type {{ iface.link_type }}
{% if iface.pvid %}
 port default vlan {{ iface.pvid }}
{% endif %}
{% if iface.trunk_vlans %}
 port trunk allow-pass vlan {{ iface.trunk_vlans }}
{% endif %}
{% endfor %}
"""

template = Template(template_str)

# 渲染数据
config = template.render(
    vlans={
        10: {"name": "USER_VLAN", "ipv4_subnet": "10.10.10.1/24"},
        20: {"name": "SERVER_VLAN", "ipv4_subnet": "10.20.20.1/24"},
        100: {"name": "MGMT_VLAN", "ipv4_subnet": None},
    },
    interfaces=[
        {"name": "GigabitEthernet0/0/1", "description": "To-Core-01",
         "link_type": "trunk", "pvid": None, "trunk_vlans": "10 20 100"},
        {"name": "GigabitEthernet0/0/2", "description": "To-Core-02",
         "link_type": "trunk", "pvid": None, "trunk_vlans": "10 20 100"},
        {"name": "GigabitEthernet0/0/10", "description": "To-Server-A",
         "link_type": "access", "pvid": 20, "trunk_vlans": None},
    ]
)
print(config)

3.3 循环中的过滤器与特殊变量

template_str = """
{% for iface in interfaces %}
! 接口 {{ loop.index }}/{{ loop.length }}: {{ iface.name }}
{% if loop.first %}
! ---- 第一个接口(通常是上行) ----
{% endif %}
{% if loop.last %}
! ---- 最后一个接口 ----
{% endif %}
{% endfor %}
"""

# loop.index     - 当前索引(从1开始)
# loop.index0    - 当前索引(从0开始)
# loop.first     - 是否为第一个元素
# loop.last      - 是否为最后一个元素
# loop.length    - 总元素数

四、宏(Macro):配置块复用

宏类似于Python中的函数,定义一次、多次调用:

from jinja2 import Template

template_str = """
{# 定义宏:生成接口配置块 #}
{% macro config_interface(iface) %}
interface {{ iface.name }}
 description {{ iface.description }}
{% if iface.link_type == "access" %}
 port link-type access
 port default vlan {{ iface.pvid }}
{% elif iface.link_type == "trunk" %}
 port link-type trunk
 port trunk allow-pass vlan {{ iface.trunk_vlans }}
{% endif %}
{% if iface.ip_address %}
 ip address {{ iface.ip_address }}
{% endif %}
{% endmacro %}

{# 定义宏:生成VLAN配置块 #}
{% macro config_vlan(vlan_id, name, ipv4=None) %}
vlan {{ vlan_id }}
 description {{ name }}
{% if ipv4 %}
 ip interface {{ ipv4 }}
{% endif %}
{% endmacro %}

! ========== VLAN配置 ==========
{{ config_vlan(10, "USER", "10.10.10.1/24") }}
{{ config_vlan(20, "SERVER", "10.20.20.1/24") }}
{{ config_vlan(100, "MGMT") }}

! ========== 接口配置 ==========
{{ config_interface(interfaces[0]) }}
{{ config_interface(interfaces[1]) }}
{{ config_interface(interfaces[2]) }}
"""

template = Template(template_str)
config = template.render(interfaces=[
    {"name": "GE0/0/1", "description": "Uplink-Core",
     "link_type": "trunk", "trunk_vlans": "10-20 100", "ip_address": None},
    {"name": "GE0/0/10", "description": "To-Server-Rack",
     "link_type": "access", "pvid": 20, "ip_address": None},
    {"name": "Vlanif100", "description": "Management",
     "link_type": "access", "pvid": 100, "ip_address": "10.255.0.1/24"},
])
print(config)

五、模板继承:避免重复配置

模板继承允许定义一个基础模板,子类模板只需覆盖特定区块:

from jinja2 import Environment, FileSystemLoader
import os

# 使用Environment管理多个模板
env = Environment(loader=FileSystemLoader("templates"))

# ============================================================
# 基础模板: base_config.j2
# ============================================================
base_template = """
! ============================================================
! 设备配置 - {{ hostname }}
! 生成时间: {{ generated_at }}
! ============================================================

hostname {{ hostname }}

{% block sys_opt %}
sysname {{ hostname }}
clock timezone Beijing add 8
{% endblock %}

! --- SSH 配置 ---
{% block ssh_config %}
ssh server enable
aaa
 authentication-scheme default
  scheme password
 authorization-scheme default
  scheme authenticated
 domain default
  authentication-scheme default
  authorization-scheme default
user-interface vty 0 4
 authentication-mode aaa
 protocol inbound ssh
{% endblock %}

! --- 业务配置 ---
{% block business_config %}
{# 子类覆盖此区块 #}
{% endblock %}

! --- 保存配置 ---
return
save force
"""

# ============================================================
# 接入交换机模板: access_switch.j2 (继承base_config)
# ============================================================
access_template = """
{% extends "base_config.j2" %}

{% block business_config %}
! === 接入层VLAN ===
{% for vlan_id, vlan_name in vlans.items() %}
vlan {{ vlan_id }}
 description {{ vlan_name }}
{% endfor %}

! === 接入层接口 ===
{% for iface in interfaces %}
interface {{ iface.name }}
 description {{ iface.description }}
 port link-type {{ iface.link_type }}
{% if iface.link_type == "access" %}
 port default vlan {{ iface.pvid }}
{% elif iface.link_type == "trunk" %}
 port trunk allow-pass vlan {{ iface.trunk_vlans }}
{% endif %}
{% endfor %}

! === STP 配置 ===
stp mode mstp
stp region-configuration
 region-name {{ stp_region }}
{% for vlan_id in stp_instances %}
 instance {{ vlan_id }}
{% endfor %}
 active region-configuration
stp edged-port default
{% endblock %}
"""

# ============================================================
# 渲染接入交换机配置
# ============================================================
from datetime import datetime

# 实际使用时将模板保存到templates/目录,此处用字符串演示
access_config = access_template
# 简化演示,实际应使用FileSystemLoader

六、BGP配置模板实战

6.1 完整BGP模板

from jinja2 import Template

bgp_template = Template("""
! ===== BGP 配置 =====
bgp {{ local_as }}
 router-id {{ router_id }}
 !
 {% if graceful_restart %}
 graceful-restart
 {% endif %}
 !
 {% for afi_safi, config in address_families.items() %}
 address-family {{ afi_safi }}
 {% endfor %}
 !
{% for peer_group in peer_groups %}
{% if peer_group.name %}
 peer {{ peer_group.name }} group
  peer as-number {{ peer_group.remote_as }}
{% if peer_group.password %}
  peer password simple {{ peer_group.password }}
{% endif %}
{% if peer_group.description %}
  peer description {{ peer_group.description }}
{% endif %}
{% if peer_group.send_community %}
  peer enable-ipv6 send-community
{% endif %}
{% for afi_safi in peer_group.address_families %}
  peer {{ peer_group.name }} allow-import-route address-family {{ afi_safi }}
  peer {{ peer_group.name }} enable address-family {{ afi_safi }}
{% endfor %}
{% endif %}
{% for peer in peer_group.get('peers', []) %}
 peer {{ peer.ip }}
  peer as-number {{ peer.asn | default(peer_group.remote_as) }}
{% if peer.description %}
  peer description {{ peer.description }}
{% endif %}
{% if peer.local_ip %}
  peer bind-peers {{ peer.local_ip }}
{% endif %}
{% if peer.next_hop_self %}
  peer enable-ipv6 next-update-origin
{% endif %}
{% for afi_safi in peer_group.address_families %}
  peer {{ peer.ip }} allow-import-route address-family {{ afi_safi }}
  peer {{ peer.ip }} enable address-family {{ afi_safi }}
{% endfor %}
{% endfor %}
{% endfor %}
 !
{% for import_policy in import_policies %}
 import-route policy {{ import_policy.name }} address-family {{ import_policy.afi_safi }}
{% endfor %}
{% for export_policy in export_policies %}
 export-route policy {{ export_policy.name }} address-family {{ export_policy.afi_safi }}
{% endfor %}
""")

# 渲染BGP配置
bgp_config = bgp_template.render(
    local_as=65001,
    router_id="10.255.0.1",
    graceful_restart=True,
    address_families={
        "ipv4-unicast": {},
        "ipv6-unicast": {},
        "labeled-ipv4-unicast": {},
    },
    peer_groups=[
        {
            "name": "INTERNAL_PEERS",
            "remote_as": 65001,
            "description": "iBGP Peers",
            "send_community": True,
            "address_families": ["ipv4-unicast", "labeled-ipv4-unicast"],
            "peers": [
                {"ip": "10.255.0.2", "description": "To-PE-02"},
                {"ip": "10.255.0.3", "description": "To-PE-03"},
            ]
        },
        {
            "name": "EBGP_PEERS",
            "remote_as": 65100,
            "password": "BgpP@ss2025",
            "description": "eBGP Peers",
            "address_families": ["ipv4-unicast"],
            "peers": [
                {"ip": "203.0.113.1", "description": "To-ISP-A"},
            ]
        }
    ],
    import_policies=[
        {"name": "IMPORT_DEFAULT", "afi_safi": "ipv4-unicast"}
    ],
    export_policies=[
        {"name": "EXPORT_TO_EBGP", "afi_safi": "ipv4-unicast"},
    ]
)
print(bgp_config)

七、模板变量管理与多设备适配

7.1 从YAML读取设备变量

import yaml
from jinja2 import Environment, FileSystemLoader

# 设备变量文件: devices.yaml
devices_yaml = """
devices:
  - hostname: Core-SW-01
    role: core
    mgmt_ip: 10.255.0.1
    router_id: 10.255.0.1
    vlans:
      10: {name: USER, subnet: 10.10.10.1/24}
      20: {name: SERVER, subnet: 10.20.20.1/24}
      100: {name: MGMT, subnet: null}
    uplinks:
      - name: GigabitEthernet0/0/1
        description: To-Core-SW-02
        type: trunk
        vlans: "10-20 100"
      - name: GigabitEthernet0/0/2
        description: To-Core-SW-03
        type: trunk
        vlans: "10-20 100"
    local_vlans:
      - {name: GigabitEthernet0/0/10, pvid: 10, desc: To-Server-A}
      - {name: GigabitEthernet0/0/11, pvid: 20, desc: To-Server-B}
  - hostname: Core-SW-02
    role: core
    mgmt_ip: 10.255.0.2
    router_id: 10.255.0.2
    vlans:
      10: {name: USER, subnet: 10.10.10.2/24}
      20: {name: SERVER, subnet: 10.20.20.2/24}
      100: {name: MGMT, subnet: null}
    uplinks:
      - name: GigabitEthernet0/0/1
        description: To-Core-SW-01
        type: trunk
        vlans: "10-20 100"
    local_vlans:
      - {name: GigabitEthernet0/0/10, pvid: 10, desc: To-Server-C}
"""

# 解析YAML
data = yaml.safe_load(devices_yaml)

# 加载模板
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("switch_config.j2")

# 为每台设备生成配置
for device in data["devices"]:
    config = template.render(**device)
    filename = f"configs/{device['hostname']}.cfg"
    with open(filename, "w", encoding="utf-8") as f:
        f.write(config)
    print(f"[+] 已生成 {filename}")

7.2 自定义过滤器

from jinja2 import Environment

env = Environment()

# 自定义过滤器:将VLAN ID列表转为范围字符串
def vlan_range(vlan_list):
    """将 [10, 11, 12, 20, 21, 100] 转为 '10-12 20-21 100'"""
    if not vlan_list:
        return ""
    vlan_list = sorted(set(vlan_list))
    ranges = []
    start = vlan_list[0]
    end = vlan_list[0]

    for vlan in vlan_list[1:]:
        if vlan == end + 1:
            end = vlan
        else:
            if start == end:
                ranges.append(str(start))
            else:
                ranges.append(f"{start}-{end}")
            start = vlan
            end = vlan

    if start == end:
        ranges.append(str(start))
    else:
        ranges.append(f"{start}-{end}")

    return " ".join(ranges)

# 注册过滤器
env.filters["vlan_range"] = vlan_range

# 使用
template = env.from_string("port trunk allow-pass vlan {{ vlans | vlan_range }}")
output = template.render(vlans=[10, 11, 12, 20, 21, 100])
print(output)  # port trunk allow-pass vlan 10-12 20-21 100

八、故障案例:模板变量未定义导致静默错误

8.1 故障现象

批量生成的50台设备配置中,10台缺失BGP配置段。

8.2 排查过程

模板片段:

{% if bgp_enabled %}
bgp {{ bgp_local_as }}
 router-id {{ bgp_router_id }}
 peer {{ bgp_peer_ip }}
  peer as-number {{ bgp_peer_as }}
{% endif %}

设备变量中bgp_enabled字段缺失时,Jinja2默认将其视为undefined,在某些环境下undefined等价于False,导致{% if %}块被静默跳过,不报错也不告警。

8.3 解决方案

from jinja2 import Environment, UndefinedError, StrictUndefined

# 方案1:严格模式——未定义变量直接报错
env = Environment(
    loader=FileSystemLoader("templates"),
    undefined=StrictUndefined   # 未定义变量抛出异常
)

# 方案2:默认值
template = Template("""
{% if bgp_enabled is defined and bgp_enabled %}
bgp {{ bgp_local_as }}
{% endif %}
""")

# 方案3:使用default过滤器
template = Template("""
{% if bgp_enabled | default(false) %}
bgp {{ bgp_local_as | default(65000) }}
{% endif %}
""")

八(续)、IOS设备自动化与批量验证

8.1 IOS 设备自动化脚本

#!/usr/bin/env python3
# scripts/ios_automation.py
"""
Cisco IOS 设备自动化脚本
支持:IOS/IOS-XE 设备配置生成、批量下发、验证
"""
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from netmiko import ConnectHandler
import yaml
import logging
from pathlib import Path
from typing import Dict, List

logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)


class IOSConfigGenerator:
    """IOS 配置生成器"""

    def __init__(self, template_dir: str = 'templates/ios'):
        self.env = Environment(
            loader=FileSystemLoader(template_dir),
            undefined=StrictUndefined
        )
        # 注册自定义过滤器
        self.env.filters['ios_vlan_range'] = self.ios_vlan_range
        self.env.filters['ios_acl_action'] = self.ios_acl_action
        self.env.filters['ios_wildcard'] = self.ios_wildcard_mask

    def ios_vlan_range(self, vlan_ids: List[int]) -> str:
        """VLAN ID 列表转为 Cisco 范围格式: 10,11,12,20-25"""
        if not vlan_ids:
            return ''
        sorted_ids = sorted(set(vlan_ids))
        ranges = []
        start = end = sorted_ids[0]
        for vid in sorted_ids[1:]:
            if vid == end + 1:
                end = vid
            else:
                ranges.append(str(start) if start == end else f'{start}-{end}')
                start = end = vid
        ranges.append(str(start) if start == end else f'{start}-{end}')
        return ','.join(ranges)

    def ios_acl_action(self, action: str) -> str:
        """转换 ACL 动作"""
        return 'permit' if action.lower() == 'permit' else 'deny'

    def ios_wildcard_mask(self, cidr: str) -> str:
        """CIDR 掩码转通配符掩码"""
        if '/' in cidr:
            prefix_len = int(cidr.split('/')[1])
        else:
            prefix_len = 24
        mask = (0xFFFFFFFF >> prefix_len) & 0xFFFFFFFF
        return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in range(3, -1, -1))

    def generate(self, template_name: str, **kwargs) -> str:
        """生成配置"""
        template = self.env.get_template(template_name)
        return template.render(**kwargs)


# IOS 模板示例
IOS_BASE_TEMPLATE = """
! ============================================
! 设备: {{ hostname }}
! 角色: {{ role }}
! 生成时间: {{ generated_at }}
! ============================================

hostname {{ hostname }}

! --- 系统优化 ---
ip domain-name {{ domain_name | default('localdomain') }}
ip name-server {{ dns_primary }} {{ dns_secondary | default('8.8.8.8') }}
no ip domain-lookup

! --- AAA 配置 ---
aaa new-model
aaa authentication login default local
aaa authorization exec default local
aaa session-id common

! --- 用户管理 ---
username {{ admin_user }} privilege 15 secret {{ admin_password }}

! --- SSH 配置 ---
ip ssh version 2
ip ssh maxstartups 10
ip ssh time-out 60
ip ssh authentication-retries 3
line vty 0 15
 login authentication default
 transport input ssh
 no exec-timo

! --- NTP 配置 ---
{% for server in ntp_servers %}
ntp server {{ server }}
{% endfor %}

! --- SNMP 配置 ---
snmp-server community {{ snmp_ro_community }} RO
snmp-server host {{ snmp_trap_host }} traps version 2c {{ snmp_community }}

! --- 日志配置 ---
logging host {{ syslog_host }}
logging trap informational

! --- 业务配置 ---
{% block business_config %}{% endblock %}

! --- 保存 ---
end
write memory
"""

IOS_INTERFACE_TEMPLATE = """
{% for iface in interfaces %}
! === {{ iface.name }}: {{ iface.description }} ===
interface {{ iface.name }}
 description {{ iface.description }}
{% if iface.ip_address %}
 ip address {{ iface.ip_address }}
{% endif %}
{% if iface.secondary_ips %}
{% for sec_ip in iface.secondary_ips %}
 ip address {{ sec_ip }} secondary
{% endfor %}
{% endif %}
{% if iface.duplicate %}
 ip address {% for ip in iface.duplicate %}{{ ip }}{% if not loop.last %} {{ ' ' }}{% endif %}{% endfor %}
{% endif %}
{% if iface.native_vlan %}
 switchport mode access
 switchport access vlan {{ iface.native_vlan }}
{% elif iface.trunk_vlans %}
 switchport mode trunk
 switchport trunk allowed vlan {{ iface.trunk_vlans | ios_vlan_range }}
{% endif %}
{% if iface.speed %}
 speed {{ iface.speed }}
{% endif %}
{% if iface.duplex %}
 duplex {{ iface.duplex }}
{% endif %}
{% if iface.mtu %}
 mtu {{ iface.mtu }}
{% endif %}
{% if iface.shutdown is sameas false %}
 no shutdown
{% endif %}
!
{% endfor %}
"""

IOS_BGP_TEMPLATE = """
! === BGP 配置 ===
router bgp {{ local_as }}
 bgp router-id {{ router_id }}
 bgp log-neighbors-changes
{% if graceful_restart %}
 bgp graceful-restart
 bgp graceful-restart restart-time {{ gr_restart_time | default(120) }}
 bgp graceful-restart helper
{% endif %}
!
{% for peer_group in peer_groups %}
! --- Peer Group: {{ peer_group.name }} ---
 neighbor {{ peer_group.name }} peer group
 neighbor {{ peer_group.name }} remote-as {{ peer_group.remote_as }}
{% if peer_group.password %}
 neighbor {{ peer_group.name }} password {{ peer_group.password }}
{% endif %}
{% if peer_group.description %}
 neighbor {{ peer_group.name }} description {{ peer_group.description }}
{% endif %}
 neighbor {{ peer_group.name }} timers {{ peer_group.timers_keepalive | default(10) }} {{ peer_group.timers_holdtime | default(30) }}
{% if peer_group.update_source %}
 neighbor {{ peer_group.name }} update-source {{ peer_group.update_source }}
{% endif %}
{% for af in peer_group.address_families %}
 address-family {{ af }}
  neighbor {{ peer_group.name }} activate
{% if peer_group.route_maps %}
  neighbor {{ peer_group.name }} route-map {{ peer_group.route_maps.get('import', 'IMPORT_' ~ peer_group.name) }} in
  neighbor {{ peer_group.name }} route-map {{ peer_group.route_maps.get('export', 'EXPORT_' ~ peer_group.name) }} out
{% endif %}
{% if peer_group.send_community %}
  neighbor {{ peer_group.name }} send-community
{% endif %}
 exit-address-family
{% endfor %}
!
{% for peer in peer_group.get('members', []) %}
 neighbor {{ peer.ip }} peer group {{ peer_group.name }}
{% if peer.description %}
 neighbor {{ peer.ip }} description {{ peer.description }}
{% endif %}
{% if peer.local_ip %}
 neighbor {{ peer.ip }} update-source {{ peer.local_ip }}
{% endif %}
!
{% endfor %}
{% endfor %}
!
{% for network in networks %}
 network {{ network }}
{% endfor %}
!
{% for redistribute in redistribute_routes %}
 redistribute {{ redistribute.protocol }} metric {{ redistribute.metric | default(100) }}
{% endfor %}
"""

IOS_ACL_TEMPLATE = """
! === ACL 配置 ===
{% for acl in acls %}
! --- ACL: {{ acl.name }} ({{ acl.type }}) ---
ip access-list {{ acl.type }} {{ acl.name }}
{% for rule in acl.rules %}
 {{ rule.action | lower }} {{ rule.protocol | default('ip') }} {{ rule.source }} {{ rule.source_mask }} {{ rule.destination }} {{ rule.dest_mask }}{% if rule.port %} eq {{ rule.port }}{% endif %}
{% endfor %}
!
{% endfor %}
"""

# 使用示例
if __name__ == '__main__':
    gen = IOSConfigGenerator()

    # 加载设备参数
    with open('devices/ios_pe_router.yaml') as f:
        params = yaml.safe_load(f)

    # 生成配置
    config = gen.generate('ios_full_config.j2', **params)
    print(config)

8.2 配置模板引擎(统一多厂商)

#!/usr/bin/env python3
# scripts/multi_vendor_engine.py
"""
多厂商配置模板引擎
根据设备平台自动选择对应模板
"""
from jinja2 import Environment, FileSystemLoader, StrictUndefined, ChoiceLoader, PrefixLoader
import yaml
import logging
from pathlib import Path
from typing import Dict, List

logger = logging.getLogger(__name__)

# 平台到模板前缀的映射
PLATFORM_TEMPLATE_MAP = {
    'huawei_vrpv8': 'huawei',
    'cisco_ios': 'cisco_ios',
    'cisco_xr': 'cisco_xr',
    'juniper_junos': 'juniper',
    'arista_eos': 'arista',
}

# 统一参数模型
UNIFIED_CONFIG_MODEL = {
    'hostname': str,
    'interfaces': List[Dict],
    'vlans': Dict,
    'bgp': Dict,
    'ospf': Dict,
    'isis': Dict,
    'acls': List[Dict],
    'routing_policies': List[Dict],
    'snmp': Dict,
    'ntp': Dict,
    'syslog': Dict,
}


class MultiVendorConfigEngine:
    """多厂商配置模板引擎"""

    def __init__(self, template_root: str = 'templates'):
        self.template_root = Path(template_root)

        # 构建前缀加载器
        loaders = {}
        for prefix in PLATFORM_TEMPLATE_MAP.values():
            prefix_dir = self.template_root / prefix
            if prefix_dir.exists():
                loaders[prefix] = FileSystemLoader(str(prefix_dir))

        # 公共模板(跨厂商共用)
        loaders['common'] = FileSystemLoader(str(self.template_root / 'common'))

        self.env = Environment(
            loader=ChoiceLoader(list(loaders.values())),
            undefined=StrictUndefined
        )

        # 注册通用过滤器
        self._register_filters()

    def _register_filters(self):
        """注册通用过滤器"""
        self.env.filters['vlan_range'] = self._vlan_range_filter
        self.env.filters['sort_by_name'] = self._sort_by_name_filter
        self.env.filters['first_ip'] = self._first_ip_filter

    @staticmethod
    def _vlan_range_filter(vlans: List[int]) -> str:
        """VLAN 范围格式"""
        if not vlans:
            return ''
        sorted_v = sorted(set(vlans))
        ranges = []
        s = e = sorted_v[0]
        for v in sorted_v[1:]:
            if v == e + 1:
                e = v
            else:
                ranges.append(str(s) if s == e else f'{s}-{e}')
                s = e = v
        ranges.append(str(s) if s == e else f'{s}-{e}')
        return ' '.join(ranges)

    def get_template_prefix(self, platform: str) -> str:
        """获取平台对应的模板前缀"""
        return PLATFORM_TEMPLATE_MAP.get(platform, 'huawei')

    def generate_config(self, device: Dict,
                       template_name: str = 'full_config') -> str:
        """
        为单台设备生成配置
        :param device: 设备参数(统一模型)
        :param template_name: 模板名称(不含前缀)
        """
        prefix = self.get_template_prefix(device.get('platform', 'huawei_vrpv8'))
        tpl_name = f"{prefix}/{template_name}.j2"

        try:
            template = self.env.get_template(tpl_name)
            config = template.render(**device)
            logger.info(f"为设备 {device.get('hostname')} 生成配置 "
                       f"(平台: {prefix}, 模板: {template_name})")
            return config
        except Exception as e:
            logger.error(f"模板渲染失败: {e}")
            raise

    def generate_all(self, devices_file: str,
                    output_dir: str = 'generated_configs') -> List[str]:
        """批量生成所有设备配置"""
        with open(devices_file) as f:
            data = yaml.safe_load(f)

        devices = data.get('devices', [])
        output_path = Path(output_dir)
        output_path.mkdir(parents=True, exist_ok=True)

        generated = []
        for device in devices:
            try:
                config = self.generate_config(device)
                filename = output_path / f"{device['hostname']}.cfg"
                filename.write_text(config, encoding='utf-8')
                generated.append(str(filename))
            except Exception as e:
                logger.error(f"设备 {device.get('hostname')} 配置生成失败: {e}")

        logger.info(f"批量生成完成: {len(generated)}/{len(devices)} 台成功")
        return generated

8.3 IOS 批量验证脚本

#!/usr/bin/env python3
# scripts/ios_validator.py
"""
IOS 配置批量验证脚本
在配置下发前进行语法和逻辑校验
"""
import re
import logging
from typing import Dict, List, Tuple
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass
class ValidationResult:
    """验证结果"""
    config_name: str
    passed: bool
    errors: List[str] = None
    warnings: List[str] = None

    def __post_init__(self):
        self.errors = self.errors or []
        self.warnings = self.warnings or []


class IOSConfigValidator:
    """IOS 配置验证器"""

    # 验证规则
    RULES = {
        'hostname_format': (
            r'^hostname [a-zA-Z0-9][a-zA-Z0-9\-\.]*$',
            '主机名格式不符合规范'
        ),
        'no_telnet': (
            r'line vty.*transport input (telnet|all)',
            '不建议使用 Telnet,应仅使用 SSH'
        ),
        'snmp_community': (
            r'snmp-server community\s+\S+\s+?(?:RO|RW)?$',
            'SNMP Community 应使用 SNMPv3'
        ),
        'no_cdp': (
            r'cdp\s+run',
            '生产环境建议关闭 CDP'
        ),
        'bgp_as_format': (
            r'router bgp \d+',
            None  # 仅做格式检查,不报错
        ),
    }

    def __init__(self):
        self.compiled_rules = {}
        for name, (pattern, message) in self.RULES.items():
            self.compiled_rules[name] = (re.compile(pattern), message)

    def validate(self, config: str, config_name: str = '') -> ValidationResult:
        """验证配置"""
        result = ValidationResult(
            config_name=config_name or 'unknown',
            passed=True
        )

        lines = config.splitlines()

        for name, (pattern, message) in self.compiled_rules.items():
            for line_num, line in enumerate(lines, 1):
                if pattern.search(line):
                    if message:
                        result.warnings.append(
                            f"第 {line_num} 行: {message} "
                            f"(匹配: {line.strip()[:50]})"
                        )

        # 检查必要的配置段
        required_sections = {
            'hostname': '缺少 hostname 配置',
            'aaa new-model': '缺少 AAA 配置',
            'ip ssh version': '缺少 SSH 版本配置',
        }

        config_text = '\n'.join(lines)
        for section, error_msg in required_sections.items():
            if section not in config_text:
                result.errors.append(f"[缺少] {error_msg}")
                result.passed = False

        # 检查 IP 地址格式
        ip_pattern = re.compile(r'ip address\s+(\S+)\s+(\S+)')
        for line_num, line in enumerate(lines, 1):
            match = ip_pattern.search(line)
            if match:
                ip = match.group(1)
                mask = match.group(2)
                if not self._valid_ip(ip):
                    result.errors.append(
                        f"第 {line_num} 行: 无效 IP 地址 '{ip}'"
                    )
                    result.passed = False
                if not self._valid_mask(mask):
                    result.errors.append(
                        f"第 {line_num} 行: 无效掩码 '{mask}'"
                    )
                    result.passed = False

        # 检查 ACL 规则完整性
        acl_errors = self._validate_acls(lines)
        result.errors.extend(acl_errors)
        if acl_errors:
            result.passed = False

        return result

    @staticmethod
    def _valid_ip(ip: str) -> bool:
        """验证 IP 地址格式"""
        parts = ip.split('.')
        if len(parts) != 4:
            return False
        try:
            return all(0 <= int(p) <= 255 for p in parts)
        except ValueError:
            return False

    @staticmethod
    def _valid_mask(mask: str) -> bool:
        """验证子网掩码格式"""
        if '/' in mask:
            try:
                return 0 <= int(mask.split('/')[1]) <= 32
            except ValueError:
                return False
        parts = mask.split('.')
        if len(parts) != 4:
            return False
        try:
            return all(0 <= int(p) <= 255 for p in parts)
        except ValueError:
            return False

    def _validate_acls(self, lines: List[str]) -> List[str]:
        """验证 ACL 规则"""
        errors = []
        in_acl = False
        acl_name = ''

        for line_num, line in enumerate(lines, 1):
            stripped = line.strip()
            if stripped.startswith('ip access-list'):
                in_acl = True
                parts = stripped.split()
                if len(parts) >= 4:
                    acl_name = parts[-1]
                continue

            if in_acl and stripped.startswith('!'):
                in_acl = False
                continue

        return errors

    def validate_batch(self, configs: Dict[str, str]) -> List[ValidationResult]:
        """批量验证配置"""
        results = []
        for name, config in configs.items():
            result = self.validate(config, name)
            results.append(result)
            status = '✅ 通过' if result.passed else '❌ 失败'
            logger.info(f"验证 {name}: {status} "
                       f"(错误: {len(result.errors)}, "
                       f"警告: {len(result.warnings)})")
        return results

8.4 IOS 特定命令处理

#!/usr/bin/env python3
# scripts/ios_command_handler.py
"""
IOS 特定命令处理
处理 Cisco IOS/IOS-XE 特有的命令语法和行为
"""
import re
from typing import Dict, List, Tuple, Optional


class IOSCommandHandler:
    """IOS 命令处理器"""

    # IOS 特有命令映射
    IOS_COMMANDS = {
        # 接口相关
        'enable_port': 'no shutdown',
        'disable_port': 'shutdown',
        'set_description': 'description {desc}',
        'set_ip': 'ip address {ip} {mask}',
        'set_vlan_access': 'switchport access vlan {vlan}',
        'set_vlan_trunk': 'switchport trunk allowed vlan {vlans}',
        'set_native_vlan': 'switchport access vlan {vlan}',
        'set_speed': 'speed {speed}',
        'set_duplex': 'duplex {duplex}',
        'set_mtu': 'mtu {mtu}',

        # VLAN 相关
        'create_vlan': 'vlan {vlan_id}\n name {vlan_name}',
        'delete_vlan': 'no vlan {vlan_id}',

        # 路由相关
        'static_route': 'ip route {dest} {mask} {next_hop}',
        'ospf_network': 'network {network} {wildcard} area {area}',

        # ACL 相关
        'permit_acl': 'permit {protocol} {src} {src_mask} {dst} {dst_mask}',
        'deny_acl': 'deny {protocol} {src} {src_mask} {dst} {dst_mask}',
    }

    # IOS 命令输出解析模式
    OUTPUT_PATTERNS = {
        'interface_status': re.compile(
            r'(\S+)\s+[\w\-]+\s+(\S+)\s+(\S+)\s+(\S+)'
        ),
        'bgp_peer': re.compile(
            r'\s+(\d+\.\d+\.\d+\.\d+)\s+\S+\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)'
        ),
        'ospf_neighbor': re.compile(
            r'\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)'
        ),
        'arp_entry': re.compile(
            r'Internet\s+(\d+\.\d+\.\d+\.\d+)\s+\d+\s+([\da-f:]+)\s+\S+\s+(\S+)'
        ),
    }

    def translate_command(self, command: str, **kwargs) -> str:
        """
        将通用命令翻译为 IOS 命令
        :param command: 命令模板名
        :param kwargs: 命令参数
        """
        template = self.IOS_COMMANDS.get(command)
        if not template:
            raise ValueError(f"未知命令: {command}")
        return template.format(**kwargs)

    def parse_show_interfaces(self, output: str) -> List[Dict]:
        """解析 show interfaces 输出"""
        interfaces = []
        for line in output.splitlines():
            match = self.OUTPUT_PATTERNS['interface_status'].search(line)
            if match:
                interfaces.append({
                    'name': match.group(1),
                    'protocol': match.group(2),
                    'status': match.group(3),
                    'duplex': match.group(4),
                })
        return interfaces

    def parse_show_bgp(self, output: str) -> List[Dict]:
        """解析 show ip bgp summary 输出"""
        peers = []
        for line in output.splitlines():
            match = self.OUTPUT_PATTERNS['bgp_peer'].search(line)
            if match:
                peers.append({
                    'ip': match.group(1),
                    'as': match.group(2),
                    'msg_rcvd': match.group(3),
                    'msg_sent': match.group(4),
                    'state': match.group(5),
                })
        return peers

    def parse_show_arp(self, output: str) -> List[Dict]:
        """解析 show ip arp 输出"""
        entries = []
        for line in output.splitlines():
            match = self.OUTPUT_PATTERNS['arp_entry'].search(line)
            if match:
                entries.append({
                    'ip': match.group(1),
                    'mac': match.group(2),
                    'interface': match.group(3),
                })
        return entries

    def generate_ios_commands(self, actions: List[Dict]) -> List[str]:
        """
        根据操作列表生成 IOS 命令序列
        :param actions: 操作列表,每个操作为字典
        """
        commands = []
        for action in actions:
            cmd_type = action.get('type')
            if cmd_type == 'interface':
                commands.append(f"interface {action['name']}")
                if 'description' in action:
                    commands.append(
                        f" description {action['description']}")
                if 'ip_address' in action:
                    ip, mask = action['ip_address'].split('/')
                    # 转换 CIDR 为点分十进制掩码
                    mask = self._cidr_to_mask(int(mask))
                    commands.append(f" ip address {ip} {mask}")
                if 'shutdown' not in action or not action['shutdown']:
                    commands.append(" no shutdown")
                commands.append("exit")

            elif cmd_type == 'vlan':
                commands.append(f"vlan {action['id']}")
                if 'name' in action:
                    commands.append(f" name {action['name']}")
                commands.append("exit")

            elif cmd_type == 'static_route':
                commands.append(
                    f"ip route {action['dest']} "
                    f"{self._cidr_to_mask(action.get('prefix', 24))} "
                    f"{action['next_hop']}"
                )

            elif cmd_type == 'raw':
                commands.append(action['command'])

        return commands

    @staticmethod
    def _cidr_to_mask(prefix: int) -> str:
        """CIDR 前缀转点分十进制掩码"""
        mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
        return '.'.join(
            str((mask >> (8 * i)) & 0xFF) for i in range(3, -1, -1)
        )

九、总结

知识点 要点
基本语法 {{ }}变量输出、{% %}控制语句、{# #}注释
条件渲染 {% if/elif/else %}按设备角色生成不同配置
循环 {% for %}遍历接口列表、VLAN列表、邻居列表
宏 {% macro %}定义可复用配置块
模板继承 {% extends %} + {% block %}避免重复基础配置
自定义过滤器 扩展模板处理能力(VLAN范围、IP转换等)
严格模式 StrictUndefined防止变量未定义导致静默错误
模板+变量 YAML存储变量、模板存储结构,解耦配置与数据

思考题

  1. 宏(Macro)和模板继承(Block)在配置模板中各自适用于什么场景?请举例说明何时用宏、何时用继承。
  2. 如果一个模板中有嵌套的三层for循环(设备→VLAN→接口),性能会如何?如何优化?
  3. 设计一个BGP多跳邻居模板:支持配置GR、Add-Path、Route-Reflector等不同BGP特性的组合,且每个特性的开关由变量控制。

下篇预告

第307篇:YAML与JSON——结构化数据格式。学习两种网络自动化中最常用的数据交换格式,掌握设备清单、配置变量、采集结果的存储规范。