# Внешние сети с фиксированными IP для окружения `mvm-s3` ## Контекст Текущий Terraform (`terraform/`) разворачивает 4 маршрутизатора (`router1..router4`) в PROD-проекте VK Cloud: у каждого 1 WAN-интерфейс (`data.vkcs_networking_network.extnet`) и 2 приватных интерфейса в сетях, которые Terraform сам создаёт (`vkcs_networking_network`/`vkcs_networking_subnet` по `var.private_network_cidrs`), с IP, которые назначает Neutron IPAM автоматически (адрес заранее не известен и не фиксируется — см. комментарий про коллизию с служебным портом `network:dns` в `main.tf`). Появилось новое окружение — `mvm-s3`, другой VK Cloud проект, недоступный отсюда напрямую. Администратор этого проекта уже создал там (проверено через OpenStack CLI) две приватные сети/подсети и заранее расписал, какой IP получит каждый из 4 маршрутизаторов в каждой из них (диаграмма пользователя): | Роль | UUID сети | UUID подсети | CIDR | router1 | router2 | router3 | router4 | |---|---|---|---|---|---|---|---| | **primary** (основной канал связи) | `25532efe-2931-4666-a090-0da3a6f18224` | `1fa5357c-1f11-4d20-89f4-b27a4fb56e6e` | `172.16.252.8/29` | `.11` | `.12` | `.13` | `.14` | | **backup** (резервный канал связи) | `2b4cc25f-55a1-4d36-a3a2-5b16414af31a` | `5eacbc86-e15d-4c49-8704-547a719acc23` | `172.16.252.0/29` | `.3` | `.4` | `.5` | `.6` | Третий (External/WAN) интерфейс у роутеров тоже есть, но он вне рамок этой диаграммы и этой задачи — переиспользуется существующий механизм (`data.vkcs_networking_network.extnet`), без изменений. Согласовано с пользователем: - Для `mvm-s3` эти 2 внешние сети **заменяют** `private_network_cidrs`-механизм (никаких project-managed приватных сетей в этом окружении не создаётся) — но сам механизм из кода не удаляется, поскольку PROD (`10.90.x`, уже развёрнут и работает) продолжает от него зависеть в своём отдельном состоянии. - `mvm-s3` — **отдельный Terraform workspace** (`terraform workspace new mvm-s3`), отдельный state. PROD (`default`/текущий workspace) не трогается. - Сейчас `terraform.tfvars` и `prod.auto.tfvars` (creds) авто-загружаются Terraform'ом всегда, без явного `-var-file`. С двумя окружениями это небезопасно — переходим на **явные `-var-file`** для всех переменных, отличающихся между окружениями, для обоих окружений симметрично (в т.ч. переименовываем `prod.auto.tfvars` → `prod.secrets.tfvars`, чтобы он больше не подхватывался неявно и не «протекал» в apply для `mvm-s3`). - У меня нет доступа к `mvm-s3` проекту — `terraform plan`/`apply` там должен будет выполнить пользователь самостоятельно. Проверка с моей стороны — только офлайн (`terraform validate`, pytest-сьют, `terraform plan` при наличии тестовых кредов/моков). **Открытый вопрос к пользователю (нужно закрыть до/во время реализации):** реальные `auth_url`/`username`/`password`/`project_id`/`region`/`user_domain_name` и `ssh_key_name`/`ssh_public_key` для `mvm-s3` — я их не знаю и не должен придумывать. Файл с ними создаётся по аналогии с `prod.auto.tfvars.example` — как шаблон с плейсхолдерами, который пользователь заполнит сам. ## Реализация ### 1. `terraform/variables.tf` - `private_network_cidrs`: сделать **опциональной** (`default = []`), убрать валидацию «минимум 1 элемент» (для `mvm-s3` список должен быть пустым — 0 project-managed приватных сетей). Остальные валидации (валидный CIDR, уникальность) остаются, тривиально проходят на пустом списке. - Добавить новую переменную `router_networks` — карта заранее существующих внешних сетей с фиксированным IP на роутер: ```hcl variable "router_networks" { description = "Заранее существующие сети (в другом VK Cloud проекте, только по UUID, не управляются этим Terraform), в которые каждый роутер получает интерфейс с фиксированным IP. Ключ карты — имя роли/интерфейса (например \"primary\"/\"backup\"); ip_addresses[i] соответствует router(i+1)." type = map(object({ network_id = string subnet_id = string cidr = string ip_addresses = list(string) })) default = {} validation { condition = alltrue([for r in var.router_networks : can(cidrhost(r.cidr, 0))]) error_message = "router_networks[*].cidr must be a valid IPv4 CIDR." } validation { condition = alltrue([ for r in var.router_networks : alltrue([ for ip in r.ip_addresses : can(cidrhost("${ip}/32", 0)) ]) ]) error_message = "router_networks[*].ip_addresses entries must be valid IPv4 addresses." } validation { condition = alltrue([ for r in var.router_networks : alltrue([ for ip in r.ip_addresses : cidrhost("${ip}/${split("/", r.cidr)[1]}", 0) == cidrhost(r.cidr, 0) ]) ]) error_message = "Every router_networks[*].ip_addresses entry must fall inside that role's own cidr." } } ``` Проверку `length(ip_addresses) >= var.router_count` вынести в `lifecycle.precondition` порта (см. ниже) — она версия-независима и даёт понятную ошибку на конкретном роутере/роли, а не общую. ### 2. `terraform/main.tf` — объединить оба механизма в один набор ресурсов Ключевая идея переиспользования: и project-managed сети (CIDR), и внешние сети (UUID) в итоге дают роутеру «роль → {network_id, subnet_id, cidr, опциональный список фиксированных IP}» — собрать это в один `local`, и дальше вести **один** `vkcs_networking_port` + один `dynamic "network"` в инстансе, как сейчас, вместо дублирования ресурсов: ```hcl locals { cidr_roles = { for idx, cidr in var.private_network_cidrs : "priv${idx + 1}" => cidr } router_interfaces = merge( { for role, cidr in local.cidr_roles : role => { network_id = vkcs_networking_network.router_priv_net[role].id subnet_id = vkcs_networking_subnet.router_priv_subnet[role].id cidr = cidr fixed_ips = null # IPAM сам назначает адрес, как сейчас } }, { for role, net in var.router_networks : role => { network_id = net.network_id subnet_id = net.subnet_id cidr = net.cidr fixed_ips = net.ip_addresses # индекс = count.index } } ) router_interface_roles = keys(local.router_interfaces) } ``` `vkcs_networking_network.router_priv_net` / `vkcs_networking_subnet.router_priv_subnet` — без изменений (по `local.cidr_roles`, для `mvm-s3` карта пустая → 0 сетей/подсетей создаётся). `vkcs_networking_port.router_priv_port` — переименовать по смыслу (например `router_iface_port`) и обобщить: ```hcl resource "vkcs_networking_port" "router_iface_port" { for_each = { for pair in setproduct(range(var.router_count), local.router_interface_roles) : "router${pair[0] + 1}-${pair[1]}" => pair } name = "router-${each.key}-port" network_id = local.router_interfaces[each.value[1]].network_id admin_state_up = true port_security_enabled = false full_security_groups_control = true security_group_ids = [] sdn = "sprut" # проверить на первом plan/apply в mvm-s3 — актуально ли SDN "sprut" для чужой сети fixed_ip { subnet_id = local.router_interfaces[each.value[1]].subnet_id ip_address = local.router_interfaces[each.value[1]].fixed_ips == null ? null : local.router_interfaces[each.value[1]].fixed_ips[each.value[0]] } lifecycle { precondition { condition = ( local.router_interfaces[each.value[1]].fixed_ips == null || each.value[0] < length(local.router_interfaces[each.value[1]].fixed_ips) ) error_message = "router_networks[\"${each.value[1]}\"].ip_addresses must have at least var.router_count entries." } } } ``` `vkcs_compute_instance.router`: заменить `local.private_roles` → `local.router_interface_roles` в двух местах (`user_data` шаблон и `dynamic "network"`), ссылку на порт — на `router_iface_port`. Больше никаких изменений в ресурсе инстанса не требуется. ### 3. `terraform/scripts/network-init.sh.tpl` **Без изменений.** Скрипт уже определяет приватные интерфейсы по членству живого IP в ожидаемом CIDR (`ip_in_cidr`), а не по имени роли или способу назначения адреса — механизм одинаково работает и для IPAM-адресов, и для явно заданных статических. ### 4. Файлы окружения `mvm-s3` - `terraform/mvm-s3.tfvars` (новый, **коммитится** — по аналогии с тем, что `router_count`/`ssh_key_name`/`private_network_cidrs` для PROD сейчас открыто лежат в `terraform.tfvars`; UUID сетей и IP из диаграммы не секрет): ```hcl router_count = 4 ssh_key_name = "<уточнить у пользователя>" private_network_cidrs = [] router_networks = { primary = { network_id = "25532efe-2931-4666-a090-0da3a6f18224" subnet_id = "1fa5357c-1f11-4d20-89f4-b27a4fb56e6e" cidr = "172.16.252.8/29" ip_addresses = ["172.16.252.11", "172.16.252.12", "172.16.252.13", "172.16.252.14"] } backup = { network_id = "2b4cc25f-55a1-4d36-a3a2-5b16414af31a" subnet_id = "5eacbc86-e15d-4c49-8704-547a719acc23" cidr = "172.16.252.0/29" ip_addresses = ["172.16.252.3", "172.16.252.4", "172.16.252.5", "172.16.252.6"] } } ``` - `terraform/mvm-s3.secrets.tfvars.example` (новый, коммитится) — шаблон creds для `mvm-s3`, зеркало `prod.auto.tfvars.example` (`auth_url`, `username`, `password`, `project_id`, `region`, `user_domain_name`, `ssh_public_key`). - `terraform/mvm-s3.secrets.tfvars` — пользователь создаёт сам из примера (**не коммитится**, я его не создаю и не заполняю значениями). - Переименовать `terraform/prod.auto.tfvars` → `terraform/prod.secrets.tfvars` (и `prod.auto.tfvars.example` → `prod.secrets.tfvars.example`), чтобы оба окружения выбирались строго явными `-var-file`, без риска, что один набор creds «протечёт» в apply другого окружения через авто-загрузку. **Это переименование реального файла с секретами делает пользователь сам** (я не имею доступа/не должен его трогать) — в плане это шаг-инструкция, не мой git-коммит. - `.gitignore`: добавить `*.secrets.tfvars` / `*.secrets.tfvars.json`; старый паттерн `*.auto.tfvars` оставить (безвреден, ничего под него больше не подпадает). ### 5. Документация (по правилам проекта — `.claude/CLAUDE.md`) - `docs/changes/2026-09-09-mvm-s3-external-networks-plan.md` — копия согласованного плана. - `docs/changes/2026-09-09-mvm-s3-external-networks-summary.md` — по завершении, с фактическими `terraform plan`/`apply`-результатами (которые предоставит пользователь, т.к. я не могу их выполнить). - `README.md` — дополнить разделом про `mvm-s3` окружение: workspace, `-var-file`, схема сети (аналогично таблице в `DEPLOYMENT_SUMMARY.md`). - `docs/QUICKSTART.md` — обновить: явные `-var-file` вместо неявной авто-загрузки, инструкция `terraform workspace new/select`, где взять `mvm-s3.secrets.tfvars`. ### 6. Тесты (`terraform/tests/test_terraform_delivery.py`) Изучить существующие проверки для `private_network_cidrs` (в частности тест на «минимум 1 CIDR», который перестанет быть валидным) и дописать по аналогии: - `private_network_cidrs = []` + `router_networks` с 2 ролями → план создаёт 0 `router_priv_net`/`subnet`, 8 портов (4 роутера × 2 роли), каждый порт с явным `fixed_ip.ip_address` из диаграммы. - Смешанный кейс (не боевой, но для полноты обобщённого кода) — `private_network_cidrs` непустой И `router_networks` непустой одновременно не ломается (роли не пересекаются). - `router_networks` с `ip_addresses` короче `router_count` → `terraform plan` падает на precondition с понятной ошибкой. - IP вне CIDR роли → падает на validation. Прогнать `venv/bin/pytest terraform/tests -v` — офлайн, без обращения к облаку (как в прошлых изменениях). ## Проверка 1. `terraform fmt -check` / `terraform validate` в `terraform/`. 2. `venv/bin/pytest terraform/tests -v` — все тесты (старые + новые) зелёные. 3. `terraform workspace new mvm-s3` (если ещё не создан) → `terraform plan -var-file=terraform.tfvars -var-file=mvm-s3.tfvars -var-file=mvm-s3.secrets.tfvars` — **выполняет пользователь** (нет доступа к проекту `mvm-s3` отсюда); ожидаемо: 4 роутера, по 2 порта с точными IP из диаграммы, 0 создаваемых приватных сетей/подсетей. 4. Убедиться, что `terraform workspace select default` (PROD) + прежний `-var-file`/creds-файл по-прежнему даёт **пустой diff** (0 changes) — подтверждает, что PROD не задет.