mvm-s3 is a separate VK Cloud project whose admin pre-created two private networks/subnets with a known IP per router. Unify project-managed (private_network_cidrs, IPAM-assigned) and externally-owned (router_networks, fixed-IP) private interfaces into one local.router_interfaces so both share the existing port/dynamic-network mechanism instead of duplicating it. Switch from implicit *.auto.tfvars loading to explicit -var-file per environment (now two share this terraform/ directory) plus a dedicated Terraform workspace for mvm-s3, so PROD's state and credentials are never touched by mvm-s3 applies. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHfG9FgpMrGdrvC1QUewTw
190 lines
16 KiB
Markdown
190 lines
16 KiB
Markdown
# Внешние сети с фиксированными 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 не задет.
|