Теоретические основы и архитектура

Генерация конфигураций операторского уровня строится на связке Python, шаблонизатора Jinja2 и библиотек доставки (Scrapli, Netmiko, NAPALM, Nornir). Принцип Infrastructure as Code (IaC) разделяет данные, шаблоны и транспорт: 1. Данные сервиса (Service Data) хранятся в YAML или JSON (имя клиента, RD, RT, префиксы CE, параметры QoS) и до рендера проверяются схемой (Pydantic, JSON Schema) и бизнес-правилами. 2. Шаблон Jinja2 (.j2) содержит синтаксис конкретного вендора (Cisco, Huawei, Juniper, MikroTik) с подстановкой переменных ({{ vrf_name }}, {{ rd }}) и управляющими конструкциями ({% for peer in peers %}, {% if vlan %}); режим StrictUndefined не даёт отрисовать пустое значение. 3. Скрипт Python рендерит конфигурацию и доставляет её: Netmiko и Scrapli отправляют CLI-строки без транзакций (результат нужно проверять), NAPALM даёт load_merge_candidate, compare_config, commit_config и rollback только для поддерживаемых драйверов (например, ios и junos; для Huawei VRP и RouterOS драйверов в основной поставке нет), Nornir запускает задачи параллельно. Такой подход устраняет опечатки в сотнях однотипных блоков, но не заменяет проверку данных, diff перед применением и пост-проверки.

Источник правды отдельно от шаблона

Подход Infrastructure as Code для PE строится на разделении трёх вещей: данные (что должно быть настроено), шаблон (как это записывается на конкретной платформе) и транспорт (как это доставляется). Данные описывают намерение: клиент A, RT 65000:100, интерфейс PE-CE. Их хранят в YAML/JSON в Git или в NetBox. Шаблон - Jinja2-файл на вендора, без бизнес-логики. Транспорт - Netmiko, NAPALM, Scrapli или Nornir. Одно и то же намерение рендерится в четыре синтаксиса, и ручных правок на устройствах нет.

yaml
# data/customers.yml (source of truth, no secrets)
asn: 65000
vrfs:
  - {name: A, rt: "65000:100", rd_offset: 0, iface: ether2}
  - {name: B, rt: "65000:200", rd_offset: 10, iface: ether3}
  - {name: SS, rt: "65000:900", rt_import: ["65000:900", "65000:100"],
     rd_offset: 20, iface: ether4}

RD собирается формулой asn:(pe_id + rd_offset), и у каждого PE он уникален, что нужно для раздельного анонса префиксов с одного VRF на двух PE (multipath, быстрая сходимость).

Валидация данных до рендера

Данные проверяют схемой: Pydantic (типы, регулярное выражение ^\d+:\d+$ для RT и RD), JSON Schema или yamllint. Бизнес-правила пишут кодом: RT клиента входит в разрешённый список, имена VRF уникальны, RD уникален в пределах PE, интерфейс принадлежит PE. Если данные неверны, шаблон не должен вызываться: ошибка на этом этапе стоит ноль, на устройстве она стоит инцидент.

Jinja2: что действительно нужно для конфигураций

  • StrictUndefined: по умолчанию неизвестная переменная рендерится пустой строкой, и вы получите route-target export без значения. Со StrictUndefined рендер падает с UndefinedError.
  • trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True: теги {% %} не оставляют пустых строк и отступов. Для конфигураций отступ важен (в Cisco и Huawei вложенность определяется пробелами).
  • Циклы с loop.index, фильтры sort(attribute="name") (стабильный порядок вывода, а значит чистый diff), default([...]) для необязательных полей, join(","), selectattr, map, unique.
  • Арифметика в выражениях (pe_id + v.rd_offset), macro и include для повторяющихся блоков, namespace для счётчиков внутри циклов.
  • autoescape для конфигураций выключен: это не HTML. Но и шаблоны из недоверенного источника рендерить нельзя; для них есть SandboxedEnvironment.
  • Пользовательские фильтры (например, проверка RD) регистрируются в env.filters.

Рендер должен быть детерминированным: одни и те же данные дают побайтово одинаковый результат, иначе diff в CI шумит.

python
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined

env = Environment(
    loader=FileSystemLoader("templates"),
    undefined=StrictUndefined,
    trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True,
)
data = yaml.safe_load(open("data/customers.yml"))
cfg = env.get_template("pe_ios.j2").render(**data, pe_id=1)

Транспорт: Netmiko, NAPALM, Scrapli, Nornir

  • Netmiko (ConnectHandler, типы cisco_xe, juniper_junos, huawei_vrpv8, mikrotik_routeros) отправляет CLI-строки. Он не знает про транзакции: send_config_set по умолчанию не прерывается на % Invalid input, если не задан error_pattern. Проверяйте вывод.
  • NAPALM даёт единый API load_merge_candidate, compare_config, commit_config, discard_config, rollback для драйверов ios, junos и других; для Huawei VRP и RouterOS в основной поставке драйверов нет. На Junos commit_config(revert_in=...) реализует commit confirmed, а на IOS-XE откат основан на configure replace.
  • Scrapli быстрее Netmiko на больших парках и имеет типизированные драйверы; вендорские платформы нужно выбирать правильно (драйвер IOS-XE не подходит для Junos).
  • Nornir запускает задачи параллельно (num_workers), хранит inventory на Python и применяется для сложных последовательностей: canary, затем остальные.

Идемпотентность и diff

Пуш всего конфига при каждом запуске плох: он зашумляет логи и повышает риск. Нужно вычислять разницу. На Junos compare_config показывает diff candidate. На IOS-XE сравнивают отрисованные строки с show running-config | section vrf definition. Для RouterOS сравнивают с /export. Если diff пуст, на устройство ничего не отправляется: это и есть идемпотентность на уровне скрипта.