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

+46 -10
View File
@@ -89,32 +89,38 @@ docker compose exec -T c1 curl -s http://10.200.200.1/
Все специфичные для площадки A значения (ASN, anycast-префикс, router-id и BGP-соседи каждой ноды, имена интерфейсов, SSH-доступ) вынесены в единый файл переменных окружения `ansible/site-a.env` — сама роль `frr_anycast` полностью параметризована и не содержит захардкоженных адресов/ASN. `frr.conf.j2` при рендере воспроизводит уже проверенную в Docker-лабе BGP-конфигурацию (`frr/a{1,2,3}/frr.conf`), за вычетом блока `interface dummy0` — в VM-варианте этот адрес назначается через netplan (`dummy0.netplan.yml.j2`), а не через frr.conf.
Детали: [docs/2026-09-03-ansible-site-a-plan.md](docs/2026-09-03-ansible-site-a-plan.md) / [docs/2026-09-03-ansible-site-a-summary.md](docs/2026-09-03-ansible-site-a-summary.md).
Роль `frr_anycast` обобщена: список BGP-соседей узла — не 2 жёстко именованных (`neighbor_b1`/`neighbor_b2`), а `bgp_neighbors` произвольной длины, каждый с флагом `prepend` (as-path prepend на исходящих анонсах). Опционально (`enable_snat: true`) роль также поднимает sNAT через `nftables` (masquerade на внешнем интерфейсе) — оба узла ниже используют эту одну и ту же роль с разными `group_vars`.
Детали: [docs/2026-09-03-ansible-site-a-plan.md](docs/2026-09-03-ansible-site-a-plan.md) / [docs/2026-09-03-ansible-site-a-summary.md](docs/2026-09-03-ansible-site-a-summary.md) — исходная реализация; [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md) — доработка под второе окружение (см. ниже).
### Структура
```
ansible/
├── site-a.env источник всех site-A-специфичных значений
├── run.sh source site-a.env + ansible-playbook
├── inventory/site-a.yml a1/a2/a3, ansible_host из lookup('env', 'A{N}_MGMT_IP')
├── group_vars/site_a.yml site-wide переменные (ASN, anycast-префикс, интерфейсы)
├── host_vars/{a1,a2,a3}.yml per-node переменные (router-id, BGP-соседи)
├── site-a.yml playbook (role: frr_anycast)
├── roles/frr_anycast/ задачи, handlers, шаблоны (daemons.j2, frr.conf.j2, dummy0.netplan.yml.j2)
└── tests/render-check.sh офлайн-проверка шаблонов без реальных ВМ (см. ниже)
├── site-a.env / site-cloud.env источники site-специфичных значений (2 окружения)
├── run.sh общий для всех окружений (SITE_ENV_FILE + имя playbook'а)
├── inventory/{site-a,site-cloud}.yml ansible_host из lookup('env', ...)
├── group_vars/{site_a,site_cloud}.yml site-wide переменные (ASN, anycast, интерфейсы, bgp_neighbors)
├── host_vars/{a1,a2,a3,vr01..vr04}.yml per-node переменные (router-id, [bgp_neighbors])
├── site-a.yml / site-cloud.yml playbook'и (role: frr_anycast — общая для обоих)
├── roles/frr_anycast/ задачи, handlers, шаблоны (daemons.j2, frr.conf.j2,
│ dummy0.netplan.yml.j2, nftables.conf.j2)
└── tests/ render-check.sh (площадка A, golden-diff),
render-check-cloud.sh (cloud, grep-инварианты)
```
### Запуск
```bash
cd ansible
./run.sh site-a.yml # применить конфигурацию к a1/a2/a3
./run.sh site-a.yml # применить конфигурацию к a1/a2/a3 (env по умолчанию: site-a.env)
./run.sh site-a.yml --check --diff # сухой прогон
```
Если `ansible-playbook` не установлен в системе — поставить локально в venv, не трогая системный Python: `python3 -m venv venv && venv/bin/pip install ansible` (каталог `venv/` в `.gitignore`, не коммитится); `run.sh` сам подхватит `venv/bin/ansible-playbook`, если он есть.
`run.sh` универсален для всех окружений в этом репозитории: инвентарь берётся по имени playbook'а (`X.yml` → `inventory/X.yml`), env-файл — из `SITE_ENV_FILE` (по умолчанию `site-a.env`).
### Проверка без живых ВМ
На момент подготовки playbook ВМ площадки A ещё не подняты (Terraform-провижининг — отдельный шаг), поэтому функциональная проверка (`vtysh`, реальный apply) не выполнялась и не входит в текущую поставку. Доступна и выполнена офлайн-проверка:
@@ -126,3 +132,33 @@ ansible/tests/render-check.sh # golden-diff: рендер frr.conf
```
После того как Terraform поднимет ВМ — реальный прогон `./run.sh site-a.yml`, затем те же `vtysh`-команды, что и в разделе «[Проверка окружения](#проверка-окружения)» выше (заменить `docker compose exec -T <node> vtysh ...` на `ssh <node> vtysh ...` или `ansible site_a -m command -a "vtysh -c 'show bgp summary'"`).
## Второе окружение: «Облачная площадка» (anycast + sNAT, 2 канала)
Второе, независимое от площадки A окружение (та же роль `frr_anycast`, другие `group_vars`/`host_vars`): 4 узла `vr01`…`vr04`, один AS `65200`, общий anycast-адрес `172.31.255.10/32`. У каждого узла — 2 приватных интерфейса-сегмента (не point-to-point, а общий L2 с обоими пирами стороны):
- **Основной канал** — сегмент к «Основная площадка» (внешняя сторона, ASN `65104`, пиры M1/M2) — анонсы **без** as-path prepend.
- **Резервный канал** — сегмент к «Резервная площадка» (внешняя сторона, ASN `65103`, пиры B1/B2) — анонсы **с** as-path prepend (на всех 4 узлах, `AS_PATH_PREPEND_COUNT` из env, по умолчанию 3), чтобы при исправном основном канале трафик шёл через него.
- **sNAT** (nftables, masquerade) на внешнем интерфейсе — на всех 4 узлах: трафик на anycast-адрес транслируется для выхода в интернет.
Все значения — в `ansible/site-cloud.env` (ASN, anycast-префикс, IP всех 4 пиров, имена интерфейсов, per-node router-id). Т.к. пиры общие для всех 4 узлов, список `bgp_neighbors` задан один раз в `group_vars/site_cloud.yml`, а не per-node.
```bash
cd ansible
SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml # применить
SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml --check --diff # сухой прогон
```
### Проверка без живых ВМ
ВМ этого окружения тоже не подняты — golden-файла для сравнения тоже нет (окружение новое), поэтому проверка через grep-инварианты вместо byte-diff:
```bash
SITE_ENV_FILE=site-cloud.env ansible/run.sh site-cloud.yml --syntax-check
ansible/tests/render-check.sh # регресс: площадка A рендерится как прежде
ansible/tests/render-check-cloud.sh # 4 соседа, prepend только на резервном канале, sNAT
```
`render-check-cloud.sh` проверяет: у каждого из 4 узлов ровно 4 `neighbor ... remote-as` (2×65104 без route-map, 2×65103 с `route-map RM-PREPEND-OUT out`), `route-map` содержит `set as-path prepend 65200 65200 65200`, `nftables.conf` содержит `masquerade` на заданном внешнем интерфейсе.
Детали и разбор диаграммы: [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md).
+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"
@@ -0,0 +1,16 @@
# План внедрения: доработка Ansible под топологию updated_dia.png
**Дата:** 2026-09-09
## Цель
Новое окружение «Облачная площадка» (4 узла vR01–vR04, AS 65200, anycast `172.31.255.10/32`), с двумя внешними BGP-каналами (Основной → ASN 65104 без prepend, Резервный → ASN 65103 с AS-path prepend на всех 4 узлах) и sNAT (nftables, masquerade на внешнем интерфейсе).
Полный анализ диаграммы, архитектурные решения и обоснование — согласованы с пользователем в чате, зафиксированы в плане `/root/.claude/plans/hdl-composed-octopus.md` (эта же сессия). Ключевое: роль `frr_anycast` обобщается со «2 жёстко именованных соседа» на список `bgp_neighbors` произвольной длины с флагом `prepend`, что требует рефакторинга площадки A (`group_vars/site_a.yml`, `host_vars/a{1,2,3}.yml`) — без изменения `site-a.env` и без изменения рендера (проверяется регрессом `render-check.sh`).
## Definition of done
- Роль `frr_anycast`: `bgp_neighbors`-based `frr.conf.j2`, опциональный sNAT (`enable_snat`) с `nftables.conf.j2`.
- `ansible/tests/render-check.sh` (площадка A) проходит без изменений в результате.
- Новое окружение `site-cloud` (env/inventory/group_vars/host_vars/playbook) + `render-check-cloud.sh` с grep-проверками 4 соседей / prepend только на резервном канале / sNAT.
- README и summary обновлены.
@@ -0,0 +1,41 @@
# Summary: доработка Ansible под топологию updated_dia.png
**Дата:** 2026-09-09
**План:** [2026-09-09-ansible-site-cloud-plan.md](2026-09-09-ansible-site-cloud-plan.md)
## Что сделано
1. **Обобщена роль `frr_anycast`** (используется обоими окружениями):
- `templates/frr.conf.j2` переписан с двух жёстко именованных соседей (`neighbor_b1`/`neighbor_b2`) на список `bgp_neighbors` произвольной длины, каждый элемент — `{ip, asn, prepend}`. Route-map с as-path prepend рендерится условно (только если хотя бы у одного соседа `prepend: true`), количество prepend'ов — `as_path_prepend_count`.
- Добавлена опциональная (`enable_snat: true`) поддержка sNAT: `templates/nftables.conf.j2` (masquerade на внешнем интерфейсе), задачи в `tasks/main.yml` (persist `net.ipv4.ip_forward=1`, установка/конфиг/запуск `nftables`), pre-flight проверка `ext_iface`, handlers `Reload sysctl`/`Reload nftables`.
- Все новые возможности выключены по умолчанию (`defaults/main.yml`), поэтому не активны, пока окружение явно их не включит.
2. **Рефакторинг площадки A под новый формат роли** (без изменения `site-a.env` и, что важно, без изменения рендера): `group_vars/site_a.yml` (`site_a_asn` → `local_asn`), `host_vars/a{1,2,3}.yml` (переписаны на `bgp_neighbors: [...]`, оба `prepend: false`).
3. **Новое окружение `site-cloud`** (по диаграмме `updated_dia.png`, вторая/уточнённая версия): 4 узла `vr01`–`vr04`, AS 65200, anycast `172.31.255.10/32`, единый `bgp_neighbors` (2 пира ASN 65104 без prepend, 2 пира ASN 65103 с prepend) в `group_vars/site_cloud.yml` — т.к. все 4 узла сидят на общих с обоими пирами L2-сегментах, список соседей не различается по узлам (только `router_id` в `host_vars/vr0N.yml`).
4. `run.sh` обобщён: env-файл через `SITE_ENV_FILE` (алиас `SITE_A_ENV_FILE` сохранён), инвентарь определяется по имени playbook'а (`X.yml` → `inventory/X.yml`) — без этой правки `run.sh` подставлял бы `inventory/site-a.yml` даже для `site-cloud.yml`.
5. Новый `ansible/tests/render-check-cloud.sh` (+`render-test-cloud.yml`) — golden-файла для нового окружения нет, поэтому вместо byte-diff используются grep-инварианты (см. ниже).
## Как менялось понимание топологии в процессе (важно для истории)
Первая версия `updated_dia.png` читалась как «2 наши площадки по 2 узла, prepend только у узлов резервной площадки» — после того как пользователь обновил диаграмму и дал точные уточнения, выяснилось: это **одна** наша площадка («Облачная», 4 узла), у **каждого** узла по 2 канала (не 2 площадки на 2 узла), prepend — по каналу (резервному), а не по узлу, и применяется **на всех 4** узлах одновременно. План был полностью пересобран под это уточнённое прочтение до начала реализации.
## Результаты проверки (ВМ не подняты — офлайн)
- `--syntax-check` — OK для `site-a.yml` и `site-cloud.yml`.
- **Регресс площадки A**: `ansible/tests/render-check.sh` — все 6 проверок (`frr.conf`/`daemons` × a1/a2/a3) по-прежнему проходят побайтово после рефакторинга роли и host_vars.
- **`render-check-cloud.sh`**: 56/56 grep-проверок пройдено — на каждом из 4 узлов ровно 4 BGP-соседа (2×65104, 2×65103), `route-map RM-PREPEND-OUT` с `set as-path prepend 65200 65200 65200` применён **только** к двум резервным соседям, `nftables.conf` содержит `masquerade` на заданном внешнем интерфейсе.
- Вручную проверен полный рендер `frr.conf` для vr01/vr03 — идентичен по структуре, отличается только `hostname`/`router-id`.
- `nftables.conf` дополнительно провалидирован через `nft -c -f` (синтаксис корректен).
## Известное ограничение
Реальный apply и функциональная проверка на живых ВМ не выполнялись — ВМ окружения `site-cloud` не подняты (как и ранее для площадки A).
## Изменённые/новые файлы
- Роль: `ansible/roles/frr_anycast/{defaults,tasks,handlers}/main.yml`, `templates/frr.conf.j2` (переписан), `templates/nftables.conf.j2` (новый).
- Площадка A (рефакторинг, без изменения рендера): `ansible/group_vars/site_a.yml`, `ansible/host_vars/a{1,2,3}.yml`.
- Новое окружение: `ansible/site-cloud.env`, `ansible/inventory/site-cloud.yml`, `ansible/group_vars/site_cloud.yml`, `ansible/host_vars/vr0{1,2,3,4}.yml`, `ansible/site-cloud.yml`.
- `ansible/run.sh` — обобщён (SITE_ENV_FILE + auto-inventory).
- `ansible/tests/render-test-cloud.yml`, `ansible/tests/render-check-cloud.sh` — новые.
- `README.md` — новый раздел «Второе окружение: Облачная площадка», обновлена структура/раздел площадки A.
- `docs/2026-09-09-ansible-site-cloud-plan.md`, `docs/2026-09-09-ansible-site-cloud-summary.md`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB