Add Ansible playbook for Site A FRR configuration on VMs

Разворачивает FRR на a1/a2/a3 (площадка A) через Ansible вместо
Docker-лабы, для реальных ВМ. Provisioning ВМ (3 NIC: внешний +
2 приватных) — Terraform, вне рамок этой задачи; playbook создаёт
только dummy0 (netplan) и конфигурирует FRR.

- ansible/site-a.env — единственный источник site-A-специфичных
  значений (ASN, anycast-префикс, router-id/BGP-соседи по узлам,
  имена интерфейсов); роль frr_anycast полностью параметризована
- ansible/roles/frr_anycast — задачи, handlers (netplan apply перед
  restart frr), шаблоны frr.conf.j2/daemons.j2/dummy0.netplan.yml.j2
- ansible/tests/render-check.sh — офлайн golden-diff проверка
  рендера конфигов против уже провалидированных в Docker-лабе
  frr/a{1,2,3}/ (ВМ ещё не подняты, функциональный тест недоступен)
- README.md — раздел «Развёртывание площадки A на ВМ (Ansible)»
- docs/ — план и summary изменения

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-03 14:14:04 +03:00
1 parent eaa577e6b7
commit 98f9f502ce
21 files changed
+456

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
[defaults]
inventory = inventory/site-a.yml
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
[privilege_escalation]
become = True
become_method = sudo
+8
View File
@@ -0,0 +1,8 @@
# Site-wide переменные площадки A — тонкий адаптер над ansible/site-a.env.
# Ничего site-специфичного не хардкодится: только имена ключей env-файла.
site_a_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') }}"
priv2_iface: "{{ lookup('env', 'PRIV2_IFACE') }}"
frr_apt_series: "{{ lookup('env', 'FRR_APT_SERIES') | default('frr-stable', true) }}"
+6
View File
@@ -0,0 +1,6 @@
# 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') }}"
+6
View File
@@ -0,0 +1,6 @@
# 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') }}"
+6
View File
@@ -0,0 +1,6 @@
# 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') }}"
+13
View File
@@ -0,0 +1,13 @@
all:
children:
site_a:
hosts:
a1:
ansible_host: "{{ lookup('env', 'A1_MGMT_IP') }}"
a2:
ansible_host: "{{ lookup('env', 'A2_MGMT_IP') }}"
a3:
ansible_host: "{{ lookup('env', 'A3_MGMT_IP') }}"
vars:
ansible_user: "{{ lookup('env', 'SSH_USER') }}"
ansible_ssh_private_key_file: "{{ lookup('env', 'SSH_PRIVATE_KEY_FILE') }}"
@@ -0,0 +1,15 @@
---
# Не site-специфичные дефолты роли (пакеты, пути). Все site-A-специфичные
# значения приходят из group_vars/host_vars (см. ansible/site-a.env).
frr_packages:
- frr
- frr-pythontools
frr_daemons_enabled:
- zebra
- bgpd
- staticd
frr_conf_path: /etc/frr/frr.conf
frr_daemons_path: /etc/frr/daemons
netplan_dummy_config_path: /etc/netplan/90-anycast-dummy0.yaml
@@ -0,0 +1,13 @@
---
# Порядок важен: Ansible исполняет handlers в порядке ИХ ОБЪЯВЛЕНИЯ здесь,
# а не в порядке notify. "Apply netplan" должен отработать раньше "Restart
# frr", иначе dummy0 (и anycast-адрес на нём) ещё не будет существовать к
# моменту старта zebra/bgpd.
- name: Apply netplan
ansible.builtin.command: netplan apply
changed_when: true
- name: Restart frr
ansible.builtin.systemd:
name: frr
state: restarted
+68
View File
@@ -0,0 +1,68 @@
---
- name: Pre-flight | приватные интерфейсы должны быть уже сконфигурированы Terraform-слоем
ansible.builtin.assert:
that:
- priv1_iface in ansible_facts.interfaces
- priv2_iface in ansible_facts.interfaces
- ansible_facts[priv1_iface]['active'] | default(false)
- ansible_facts[priv2_iface]['active'] | default(false)
fail_msg: >-
Интерфейсы {{ priv1_iface }}/{{ priv2_iface }} не найдены или не активны.
Ожидается, что Terraform-провижининг уже создал и поднял их до запуска
этого playbook.
success_msg: "Приватные интерфейсы {{ priv1_iface }} и {{ priv2_iface }} на месте."
- name: FRR APT-репозиторий | добавить GPG-ключ
ansible.builtin.get_url:
url: https://deb.frrouting.org/frr/keys.gpg
dest: /usr/share/keyrings/frrouting.gpg
mode: "0644"
- name: FRR APT-репозиторий | добавить источник пакетов
ansible.builtin.apt_repository:
repo: >-
deb [signed-by=/usr/share/keyrings/frrouting.gpg]
https://deb.frrouting.org/frr {{ ansible_distribution_release }} {{ frr_apt_series }}
filename: frr
state: present
update_cache: true
- name: Установить пакеты FRR
ansible.builtin.apt:
name: "{{ frr_packages }}"
state: present
- name: Конфиг | /etc/frr/daemons
ansible.builtin.template:
src: daemons.j2
dest: "{{ frr_daemons_path }}"
owner: frr
group: frr
mode: "0640"
notify: Restart frr
- name: Конфиг | /etc/frr/frr.conf
ansible.builtin.template:
src: frr.conf.j2
dest: "{{ frr_conf_path }}"
owner: frr
group: frr
mode: "0640"
notify: Restart frr
- name: Сеть | netplan-конфиг для dummy0 (anycast)
ansible.builtin.template:
src: dummy0.netplan.yml.j2
dest: "{{ netplan_dummy_config_path }}"
owner: root
group: root
mode: "0600"
notify:
- Apply netplan
- Restart frr
- name: FRR | сервис включён и запущен
ansible.builtin.systemd:
name: frr
enabled: true
state: started
@@ -0,0 +1,25 @@
zebra=yes
bgpd=yes
ospfd=no
ospf6d=no
ripd=no
ripngd=no
isisd=no
pimd=no
pim6d=no
ldpd=no
nhrpd=no
eigrpd=no
babeld=no
sharpd=no
pbrd=no
bfdd=no
fabricd=no
vrrpd=no
pathd=no
staticd=yes
vtysh_enable=yes
zebra_options=" -A 127.0.0.1 -s 90000000"
bgpd_options=" -A 127.0.0.1"
staticd_options="-A 127.0.0.1"
@@ -0,0 +1,6 @@
network:
version: 2
dummy-devices:
{{ dummy_iface }}:
addresses:
- {{ anycast_prefix }}
@@ -0,0 +1,20 @@
frr version 8.5
frr defaults traditional
hostname {{ inventory_hostname }}
log stdout informational
no ipv6 forwarding
!
router bgp {{ site_a_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 }}
!
address-family ipv4 unicast
network {{ anycast_prefix }}
neighbor {{ neighbor_b1_ip }} activate
neighbor {{ neighbor_b2_ip }} activate
exit-address-family
!
line vty
!
Executable
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Загружает site-a.env в environment и вызывает ansible-playbook.
# Использование: ./run.sh site-a.yml [доп. аргументы ansible-playbook]
set -e
cd "$(dirname "$0")"
ENV_FILE="${SITE_A_ENV_FILE:-site-a.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Не найден файл переменных окружения: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
. "./$ENV_FILE"
set +a
PLAYBOOK="${1:-site-a.yml}"
shift 2>/dev/null || true
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" "$@"
+42
View File
@@ -0,0 +1,42 @@
# Все специфичные для площадки A параметры разворачивания (hld.md).
# Источник истины: значения из этого файла и только из него подставляются
# в шаблоны роли frr_anycast — сама роль site-специфичных значений не содержит.
#
# Использование: ./run.sh site-a.yml [доп. аргументы ansible-playbook]
# (run.sh делает `set -a; source site-a.env; set +a` перед вызовом ansible-playbook)
# --- site-wide ---
SITE_A_ASN=65001
ANYCAST_PREFIX=10.200.200.1/32
DUMMY_IFACE=dummy0
PRIV1_IFACE=ens7 # интерфейс к B1 на всех A-нодах (имя задаёт Terraform-провижининг)
PRIV2_IFACE=ens8 # интерфейс к B2
FRR_APT_SERIES=frr-8 # ветка репозитория deb.frrouting.org
# SSH/подключение (пользователь и ключ, которые Terraform выдал ВМ при создании)
SSH_USER=ansible
SSH_PRIVATE_KEY_FILE=~/.ssh/site-a-deploy
# --- per-node: A1 (bgp router-id 10.0.0.1) ---
A1_MGMT_IP=192.168.100.11
A1_ROUTER_ID=10.0.0.1
A1_NEIGHBOR_B1_IP=10.0.1.2
A1_NEIGHBOR_B1_ASN=65002
A1_NEIGHBOR_B2_IP=10.0.2.2
A1_NEIGHBOR_B2_ASN=65003
# --- per-node: A2 (bgp router-id 10.0.0.2) ---
A2_MGMT_IP=192.168.100.12
A2_ROUTER_ID=10.0.0.2
A2_NEIGHBOR_B1_IP=10.0.3.2
A2_NEIGHBOR_B1_ASN=65002
A2_NEIGHBOR_B2_IP=10.0.4.2
A2_NEIGHBOR_B2_ASN=65003
# --- per-node: A3 (bgp router-id 10.0.0.3) ---
A3_MGMT_IP=192.168.100.13
A3_ROUTER_ID=10.0.0.3
A3_NEIGHBOR_B1_IP=10.0.5.2
A3_NEIGHBOR_B1_ASN=65002
A3_NEIGHBOR_B2_IP=10.0.6.2
A3_NEIGHBOR_B2_ASN=65003
+6
View File
@@ -0,0 +1,6 @@
---
- name: Configure Site A FRR anycast routers
hosts: site_a
become: true
roles:
- frr_anycast
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# Офлайн-проверка шаблонов роли frr_anycast: рендерит frr.conf/daemons для
# a1/a2/a3 (только control node, реальные ВМ не нужны) и сравнивает с уже
# проверенными в Docker-лабе golden-файлами frr/a{1,2,3}/{frr.conf,daemons}.
set -e
cd "$(dirname "$0")/.." # -> ansible/
ENV_FILE="${SITE_A_ENV_FILE:-site-a.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-a.yml tests/render-test.yml -e "render_out_dir=$OUT_DIR" 2>&1 | cat
STATUS=0
for n in a1 a2 a3; do
for f in frr.conf daemons; do
golden="../frr/$n/$f"
rendered="$OUT_DIR/$n/$f"
# frr.conf в Docker-лабе включает "interface dummy0 / ip address ...":
# там его назначает zebra. В VM-варианте тот же адрес назначается через
# netplan (dummy0.netplan.yml.j2), поэтому в frr.conf.j2 блока нет —
# это осознанное расхождение (см. Context плана), исключаем его перед diff.
if [ "$f" = "frr.conf" ]; then
golden_cmp="$OUT_DIR/.golden-$n-$f"
sed '/^interface dummy0$/,/^!$/d' "$golden" > "$golden_cmp"
else
golden_cmp="$golden"
fi
if diff -u "$golden_cmp" "$rendered" >/tmp/render-check.diff 2>&1; then
echo "OK $n/$f совпадает с $golden (за вычетом дизайн-отличия dummy0, см. выше)"
else
echo "FAIL $n/$f отличается от $golden:"
cat /tmp/render-check.diff
STATUS=1
fi
done
done
exit "$STATUS"
+32
View File
@@ -0,0 +1,32 @@
---
# Рендерит шаблоны роли frr_anycast локально (без SSH и без реальных ВМ) —
# используется render-check.sh для сверки с golden-файлами frr/a{1,2,3}/.
- name: "Render-only test шаблонов frr_anycast"
hosts: site_a
connection: local
gather_facts: false
become: false
tasks:
# group_vars/host_vars авто-подгружаются относительно каталога playbook'а
# или inventory; render-test.yml лежит в tests/, поэтому подключаем явно.
- name: Подключить site-wide переменные (group_vars/site_a.yml)
ansible.builtin.include_vars: "{{ playbook_dir }}/../group_vars/site_a.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"