Generalize frr_anycast role, add site-cloud environment (4 nodes, prepend, sNAT)

По updated_dia.png: новое окружение "Облачная площадка" (vr01-vr04, AS 65200,
anycast 172.31.255.10/32) с двумя внешними BGP-каналами — основным (ASN 65104,
без as-path prepend) и резервным (ASN 65103, с prepend на всех узлах) — плюс
sNAT (nftables masquerade) на внешнем интерфейсе.

Роль frr_anycast обобщена под это: вместо двух жёстко именованных соседей —
список bgp_neighbors произвольной длины с флагом prepend; sNAT/ip_forward —
опциональный блок (enable_snat). Площадка A мигрирована на новый формат
host_vars/group_vars без изменения site-a.env и без изменения рендера —
render-check.sh (golden-diff) по-прежнему проходит 6/6.

Для site-cloud (окружение новое, ВМ не подняты) добавлен render-check-cloud.sh
с grep-инвариантами: 4 соседа на узел, prepend только на резервном канале,
nftables masquerade на внешнем интерфейсе — 56/56 проверок пройдено.

run.sh обобщён на несколько окружений: инвентарь по имени playbook'а,
env-файл через SITE_ENV_FILE (алиас SITE_A_ENV_FILE сохранён).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bc3HkMiDwooSyLBLoMKNyF
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-09 20:59:40 +03:00
1 parent 98f9f502ce
commit b2b7306a3c
24 files changed
+431 -32

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Site-wide переменные площадки A — тонкий адаптер над ansible/site-a.env.
# Ничего site-специфичного не хардкодится: только имена ключей env-файла.
site_a_asn: "{{ lookup('env', 'SITE_A_ASN') }}"
local_asn: "{{ lookup('env', 'SITE_A_ASN') }}"
anycast_prefix: "{{ lookup('env', 'ANYCAST_PREFIX') }}"
dummy_iface: "{{ lookup('env', 'DUMMY_IFACE') | default('dummy0', true) }}"
priv1_iface: "{{ lookup('env', 'PRIV1_IFACE') }}"
+29
View File
@@ -0,0 +1,29 @@
# Site-wide переменные окружения "Облачная площадка" — тонкий адаптер над
# ansible/site-cloud.env. Ничего site-специфичного не хардкодится: только
# имена ключей env-файла.
local_asn: "{{ lookup('env', 'LOCAL_ASN') }}"
anycast_prefix: "{{ lookup('env', 'ANYCAST_PREFIX') }}"
dummy_iface: "{{ lookup('env', 'DUMMY_IFACE') | default('dummy0', true) }}"
priv1_iface: "{{ lookup('env', 'PRIV_MAIN_IFACE') }}"
priv2_iface: "{{ lookup('env', 'PRIV_BACKUP_IFACE') }}"
ext_iface: "{{ lookup('env', 'EXT_IFACE') }}"
frr_apt_series: "{{ lookup('env', 'FRR_APT_SERIES') | default('frr-stable', true) }}"
enable_snat: true
as_path_prepend_count: "{{ lookup('env', 'AS_PATH_PREPEND_COUNT') }}"
# M1/M2/B1/B2 сидят на общих для всех 4 узлов L2-сегментах, поэтому список
# соседей — один на всё окружение, а не per-node (см. host_vars/vr0N.yml).
bgp_neighbors:
- ip: "{{ lookup('env', 'MAIN1_NEIGHBOR_IP') }}"
asn: "{{ lookup('env', 'MAIN_ASN') }}"
prepend: false
- ip: "{{ lookup('env', 'MAIN2_NEIGHBOR_IP') }}"
asn: "{{ lookup('env', 'MAIN_ASN') }}"
prepend: false
- ip: "{{ lookup('env', 'BACKUP1_NEIGHBOR_IP') }}"
asn: "{{ lookup('env', 'BACKUP_ASN') }}"
prepend: true
- ip: "{{ lookup('env', 'BACKUP2_NEIGHBOR_IP') }}"
asn: "{{ lookup('env', 'BACKUP_ASN') }}"
prepend: true
+7 -4
View File
@@ -1,6 +1,9 @@
# Per-node адаптер над ansible/site-a.env (префикс A1_*).
router_id: "{{ lookup('env', 'A1_ROUTER_ID') }}"
neighbor_b1_ip: "{{ lookup('env', 'A1_NEIGHBOR_B1_IP') }}"
neighbor_b1_asn: "{{ lookup('env', 'A1_NEIGHBOR_B1_ASN') }}"
neighbor_b2_ip: "{{ lookup('env', 'A1_NEIGHBOR_B2_IP') }}"
neighbor_b2_asn: "{{ lookup('env', 'A1_NEIGHBOR_B2_ASN') }}"
bgp_neighbors:
- ip: "{{ lookup('env', 'A1_NEIGHBOR_B1_IP') }}"
asn: "{{ lookup('env', 'A1_NEIGHBOR_B1_ASN') }}"
prepend: false
- ip: "{{ lookup('env', 'A1_NEIGHBOR_B2_IP') }}"
asn: "{{ lookup('env', 'A1_NEIGHBOR_B2_ASN') }}"
prepend: false
+7 -4
View File
@@ -1,6 +1,9 @@
# Per-node адаптер над ansible/site-a.env (префикс A2_*).
router_id: "{{ lookup('env', 'A2_ROUTER_ID') }}"
neighbor_b1_ip: "{{ lookup('env', 'A2_NEIGHBOR_B1_IP') }}"
neighbor_b1_asn: "{{ lookup('env', 'A2_NEIGHBOR_B1_ASN') }}"
neighbor_b2_ip: "{{ lookup('env', 'A2_NEIGHBOR_B2_IP') }}"
neighbor_b2_asn: "{{ lookup('env', 'A2_NEIGHBOR_B2_ASN') }}"
bgp_neighbors:
- ip: "{{ lookup('env', 'A2_NEIGHBOR_B1_IP') }}"
asn: "{{ lookup('env', 'A2_NEIGHBOR_B1_ASN') }}"
prepend: false
- ip: "{{ lookup('env', 'A2_NEIGHBOR_B2_IP') }}"
asn: "{{ lookup('env', 'A2_NEIGHBOR_B2_ASN') }}"
prepend: false
+7 -4
View File
@@ -1,6 +1,9 @@
# Per-node адаптер над ansible/site-a.env (префикс A3_*).
router_id: "{{ lookup('env', 'A3_ROUTER_ID') }}"
neighbor_b1_ip: "{{ lookup('env', 'A3_NEIGHBOR_B1_IP') }}"
neighbor_b1_asn: "{{ lookup('env', 'A3_NEIGHBOR_B1_ASN') }}"
neighbor_b2_ip: "{{ lookup('env', 'A3_NEIGHBOR_B2_IP') }}"
neighbor_b2_asn: "{{ lookup('env', 'A3_NEIGHBOR_B2_ASN') }}"
bgp_neighbors:
- ip: "{{ lookup('env', 'A3_NEIGHBOR_B1_IP') }}"
asn: "{{ lookup('env', 'A3_NEIGHBOR_B1_ASN') }}"
prepend: false
- ip: "{{ lookup('env', 'A3_NEIGHBOR_B2_IP') }}"
asn: "{{ lookup('env', 'A3_NEIGHBOR_B2_ASN') }}"
prepend: false
+2
View File
@@ -0,0 +1,2 @@
# Per-node адаптер над ansible/site-cloud.env (префикс VR01_*).
router_id: "{{ lookup('env', 'VR01_ROUTER_ID') }}"
+2
View File
@@ -0,0 +1,2 @@
# Per-node адаптер над ansible/site-cloud.env (префикс VR02_*).
router_id: "{{ lookup('env', 'VR02_ROUTER_ID') }}"
+2
View File
@@ -0,0 +1,2 @@
# Per-node адаптер над ansible/site-cloud.env (префикс VR03_*).
router_id: "{{ lookup('env', 'VR03_ROUTER_ID') }}"
+2
View File
@@ -0,0 +1,2 @@
# Per-node адаптер над ansible/site-cloud.env (префикс VR04_*).
router_id: "{{ lookup('env', 'VR04_ROUTER_ID') }}"
+15
View File
@@ -0,0 +1,15 @@
all:
children:
site_cloud:
hosts:
vr01:
ansible_host: "{{ lookup('env', 'VR01_MGMT_IP') }}"
vr02:
ansible_host: "{{ lookup('env', 'VR02_MGMT_IP') }}"
vr03:
ansible_host: "{{ lookup('env', 'VR03_MGMT_IP') }}"
vr04:
ansible_host: "{{ lookup('env', 'VR04_MGMT_IP') }}"
vars:
ansible_user: "{{ lookup('env', 'SSH_USER') }}"
ansible_ssh_private_key_file: "{{ lookup('env', 'SSH_PRIVATE_KEY_FILE') }}"
@@ -13,3 +13,9 @@ frr_daemons_enabled:
frr_conf_path: /etc/frr/frr.conf
frr_daemons_path: /etc/frr/daemons
netplan_dummy_config_path: /etc/netplan/90-anycast-dummy0.yaml
# Опциональные возможности роли — выключены по умолчанию, чтобы окружения,
# которые их не используют (площадка A), рендерились без изменений.
as_path_prepend_count: 0 # >0 включает route-map с этим числом prepend'ов
enable_snat: false # true включает nftables-задачи и sysctl ip_forward
nftables_conf_path: /etc/nftables.conf
@@ -11,3 +11,12 @@
ansible.builtin.systemd:
name: frr
state: restarted
- name: Reload sysctl
ansible.builtin.command: sysctl --system
changed_when: true
- name: Reload nftables
ansible.builtin.systemd:
name: nftables
state: reloaded
+43
View File
@@ -12,6 +12,17 @@
этого playbook.
success_msg: "Приватные интерфейсы {{ priv1_iface }} и {{ priv2_iface }} на месте."
- name: Pre-flight | внешний интерфейс должен быть на месте (нужен для sNAT egress)
ansible.builtin.assert:
that:
- ext_iface in ansible_facts.interfaces
- ansible_facts[ext_iface]['active'] | default(false)
fail_msg: >-
Внешний интерфейс {{ ext_iface }} не найден или не активен — он нужен
как egress для sNAT (enable_snat: true).
success_msg: "Внешний интерфейс {{ ext_iface }} на месте."
when: enable_snat | bool
- name: FRR APT-репозиторий | добавить GPG-ключ
ansible.builtin.get_url:
url: https://deb.frrouting.org/frr/keys.gpg
@@ -66,3 +77,35 @@
name: frr
enabled: true
state: started
- name: sNAT | включить net.ipv4.ip_forward персистентно
ansible.builtin.lineinfile:
path: /etc/sysctl.d/99-frr-anycast-forward.conf
line: net.ipv4.ip_forward=1
create: true
mode: "0644"
notify: Reload sysctl
when: enable_snat | bool
- name: sNAT | установить nftables
ansible.builtin.apt:
name: nftables
state: present
when: enable_snat | bool
- name: sNAT | конфиг /etc/nftables.conf (masquerade на внешнем интерфейсе)
ansible.builtin.template:
src: nftables.conf.j2
dest: "{{ nftables_conf_path }}"
owner: root
group: root
mode: "0640"
notify: Reload nftables
when: enable_snat | bool
- name: sNAT | сервис nftables включён и запущен
ansible.builtin.systemd:
name: nftables
enabled: true
state: started
when: enable_snat | bool
@@ -4,16 +4,26 @@ hostname {{ inventory_hostname }}
log stdout informational
no ipv6 forwarding
!
router bgp {{ site_a_asn }}
{% if bgp_neighbors | selectattr('prepend') | list | length > 0 %}
route-map RM-PREPEND-OUT permit 10
set as-path prepend {{ ((local_asn ~ ' ') * (as_path_prepend_count | default(0) | int)) | trim }}
!
{% endif %}
router bgp {{ local_asn }}
bgp router-id {{ router_id }}
no bgp ebgp-requires-policy
neighbor {{ neighbor_b1_ip }} remote-as {{ neighbor_b1_asn }}
neighbor {{ neighbor_b2_ip }} remote-as {{ neighbor_b2_asn }}
{% for n in bgp_neighbors %}
neighbor {{ n.ip }} remote-as {{ n.asn }}
{% endfor %}
!
address-family ipv4 unicast
network {{ anycast_prefix }}
neighbor {{ neighbor_b1_ip }} activate
neighbor {{ neighbor_b2_ip }} activate
{% for n in bgp_neighbors %}
neighbor {{ n.ip }} activate
{% endfor %}
{% for n in bgp_neighbors if n.prepend %}
neighbor {{ n.ip }} route-map RM-PREPEND-OUT out
{% endfor %}
exit-address-family
!
line vty
@@ -0,0 +1,9 @@
#!/usr/sbin/nft -f
flush ruleset
table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "{{ ext_iface }}" masquerade
}
}
+15 -4
View File
@@ -1,11 +1,16 @@
#!/bin/sh
# Загружает site-a.env в environment и вызывает ansible-playbook.
# Использование: ./run.sh site-a.yml [доп. аргументы ansible-playbook]
# Загружает env-файл окружения в environment и вызывает ansible-playbook.
# Использование: ./run.sh <playbook.yml> [доп. аргументы ansible-playbook]
# SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml
# ./run.sh site-a.yml # SITE_ENV_FILE по умолчанию site-a.env
# Инвентарь берётся по имени плейбука: playbook X.yml -> inventory/X.yml
# (соглашение имён между *.yml playbook'ами и inventory/*.yml в этом репозитории).
set -e
cd "$(dirname "$0")"
ENV_FILE="${SITE_A_ENV_FILE:-site-a.env}"
# SITE_A_ENV_FILE — устаревший алиас, сохранён для обратной совместимости.
ENV_FILE="${SITE_ENV_FILE:-${SITE_A_ENV_FILE:-site-a.env}}"
if [ ! -f "$ENV_FILE" ]; then
echo "Не найден файл переменных окружения: $ENV_FILE" >&2
exit 1
@@ -19,9 +24,15 @@ set +a
PLAYBOOK="${1:-site-a.yml}"
shift 2>/dev/null || true
INVENTORY="inventory/$PLAYBOOK"
if [ ! -f "$INVENTORY" ]; then
echo "Не найден инвентарь: $INVENTORY (ожидается рядом с playbook'ом, то же имя)" >&2
exit 1
fi
ANSIBLE_PLAYBOOK_BIN="ansible-playbook"
if [ -x "../venv/bin/ansible-playbook" ]; then
ANSIBLE_PLAYBOOK_BIN="../venv/bin/ansible-playbook"
fi
exec "$ANSIBLE_PLAYBOOK_BIN" -i inventory/site-a.yml "$PLAYBOOK" "$@"
exec "$ANSIBLE_PLAYBOOK_BIN" -i "$INVENTORY" "$PLAYBOOK" "$@"
+56
View File
@@ -0,0 +1,56 @@
# Все специфичные для окружения "Облачная площадка" параметры разворачивания
# (updated_dia.png). Источник истины: значения из этого файла и только из
# него подставляются в шаблоны роли frr_anycast — сама роль site-специфичных
# значений не содержит.
#
# Топология: 4 узла (vR01..vR04), один AS 65200, общий anycast-адрес на всех.
# У каждого узла 2 приватных интерфейса-сегмента:
# - "основной канал" (синий) — общий L2-сегмент с Client Edge Router-M1/M2
# (площадка "Основная", ASN 65104) — анонсы БЕЗ as-path prepend;
# - "резервный канал" (розовый) — общий L2-сегмент с Client Edge Router-B1/B2
# (площадка "Резервная", ASN 65103) — анонсы С as-path prepend.
# Т.к. M1/M2/B1/B2 сидят на общих для всех vR сегментах, bgp_neighbors —
# ОДИН список на всё окружение (см. group_vars/site_cloud.yml), а не per-node.
#
# Использование: SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml [...]
# --- site-wide ---
LOCAL_ASN=65200
ANYCAST_PREFIX=172.31.255.10/32
DUMMY_IFACE=dummy0
PRIV_MAIN_IFACE=ens7 # сегмент к "Основная площадка" (M1/M2), синий/основной канал
PRIV_BACKUP_IFACE=ens8 # сегмент к "Резервная площадка" (B1/B2), розовый/резервный канал
EXT_IFACE=ens9 # внешний интерфейс — интернет-egress для sNAT
FRR_APT_SERIES=frr-8 # ветка репозитория deb.frrouting.org
# SSH/подключение (пользователь и ключ, которые Terraform выдал ВМ при создании)
SSH_USER=ansible
SSH_PRIVATE_KEY_FILE=~/.ssh/site-cloud-deploy
# --- Основная площадка (синий/основной канал, БЕЗ as-path prepend) ---
MAIN_ASN=65104
MAIN1_NEIGHBOR_IP=192.168.200.10 # Client Edge Router-M1 (main)
MAIN2_NEIGHBOR_IP=192.168.200.11 # Client Edge Router-M2 (main)
# --- Резервная площадка (розовый/резервный канал, С as-path prepend) ---
BACKUP_ASN=65103
BACKUP1_NEIGHBOR_IP=192.168.201.10 # Client Edge Router-B1 (backup)
BACKUP2_NEIGHBOR_IP=192.168.201.11 # Client Edge Router-B2 (backup)
AS_PATH_PREPEND_COUNT=3
# --- per-node (только то, что реально уникально на узел) ---
VR01_MGMT_IP=192.168.100.21
VR01_ROUTER_ID=172.31.0.1
VR01_PLATFORM=PA2 # информационно, роль это значение не использует
VR02_MGMT_IP=192.168.100.22
VR02_ROUTER_ID=172.31.0.2
VR02_PLATFORM=ME1
VR03_MGMT_IP=192.168.100.23
VR03_ROUTER_ID=172.31.0.3
VR03_PLATFORM=PA2
VR04_MGMT_IP=192.168.100.24
VR04_ROUTER_ID=172.31.0.4
VR04_PLATFORM=ME1
+6
View File
@@ -0,0 +1,6 @@
---
- name: Configure Cloud site anycast/sNAT routers (vR01-vR04)
hosts: site_cloud
become: true
roles:
- frr_anycast
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Офлайн-проверка шаблонов роли frr_anycast для окружения site_cloud (4 узла,
# 2 канала, as-path prepend только на резервном, sNAT). Golden-файлов для
# этого окружения нет (оно новое, живых ВМ нет) — проверяем grep'ом ожидаемые
# инварианты вместо byte-diff.
set -e
cd "$(dirname "$0")/.." # -> ansible/
ENV_FILE="${SITE_ENV_FILE:-site-cloud.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Не найден файл переменных окружения: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
. "./$ENV_FILE"
set +a
OUT_DIR="$(mktemp -d)"
trap 'rm -rf "$OUT_DIR"' EXIT
ansible-playbook -i inventory/site-cloud.yml tests/render-test-cloud.yml -e "render_out_dir=$OUT_DIR" 2>&1 | cat
STATUS=0
check() {
# check <label> <file> <pattern> <expected_count>
label="$1"; file="$2"; pattern="$3"; expected="$4"
actual="$(grep -Fc -- "$pattern" "$file" || true)"
if [ "$actual" = "$expected" ]; then
echo "OK $label ($file): '$pattern' x$expected"
else
echo "FAIL $label ($file): ожидалось '$pattern' x$expected, найдено x$actual" >&2
STATUS=1
fi
}
for n in vr01 vr02 vr03 vr04; do
f="$OUT_DIR/$n/frr.conf"
check "$n router bgp" "$f" "router bgp 65200" 1
check "$n network anycast" "$f" " network 172.31.255.10/32" 1
check "$n neighbor main1" "$f" " neighbor $MAIN1_NEIGHBOR_IP remote-as 65104" 1
check "$n neighbor main2" "$f" " neighbor $MAIN2_NEIGHBOR_IP remote-as 65104" 1
check "$n neighbor backup1" "$f" " neighbor $BACKUP1_NEIGHBOR_IP remote-as 65103" 1
check "$n neighbor backup2" "$f" " neighbor $BACKUP2_NEIGHBOR_IP remote-as 65103" 1
check "$n route-map defined" "$f" "route-map RM-PREPEND-OUT permit 10" 1
check "$n prepend value" "$f" "set as-path prepend 65200 65200 65200" 1
check "$n route-map on backup1 only" "$f" "neighbor $BACKUP1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1
check "$n route-map on backup2 only" "$f" "neighbor $BACKUP2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1
check "$n NO route-map on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0
check "$n NO route-map on main2" "$f" "neighbor $MAIN2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0
nft="$OUT_DIR/$n/nftables.conf"
check "$n nftables masquerade" "$nft" "masquerade" 1
check "$n nftables oifname" "$nft" "oifname \"$EXT_IFACE\"" 1
done
exit "$STATUS"
+37
View File
@@ -0,0 +1,37 @@
---
# Рендерит шаблоны роли frr_anycast для окружения site_cloud локально (без
# SSH и без реальных ВМ) — используется render-check-cloud.sh.
- name: "Render-only test шаблонов frr_anycast (site_cloud)"
hosts: site_cloud
connection: local
gather_facts: false
become: false
tasks:
# group_vars/host_vars авто-подгружаются относительно каталога playbook'а
# или inventory; render-test-cloud.yml лежит в tests/, поэтому подключаем явно.
- name: Подключить site-wide переменные (group_vars/site_cloud.yml)
ansible.builtin.include_vars: "{{ playbook_dir }}/../group_vars/site_cloud.yml"
- name: Подключить per-node переменные (host_vars/{{ inventory_hostname }}.yml)
ansible.builtin.include_vars: "{{ playbook_dir }}/../host_vars/{{ inventory_hostname }}.yml"
- name: Создать выходную директорию для хоста
ansible.builtin.file:
path: "{{ render_out_dir }}/{{ inventory_hostname }}"
state: directory
mode: "0755"
- name: Рендер frr.conf
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/frr_anycast/templates/frr.conf.j2"
dest: "{{ render_out_dir }}/{{ inventory_hostname }}/frr.conf"
- name: Рендер daemons
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/frr_anycast/templates/daemons.j2"
dest: "{{ render_out_dir }}/{{ inventory_hostname }}/daemons"
- name: Рендер nftables.conf
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/frr_anycast/templates/nftables.conf.j2"
dest: "{{ render_out_dir }}/{{ inventory_hostname }}/nftables.conf"