Files
CloudRouterAdvanced/docs/changes/2026-09-09-mvm-s3-external-networks-plan.md
ayurishchevandClaude Sonnet 5 b2d87c19d8 Add router_networks: fixed-IP router interfaces into external networks (mvm-s3)
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
2026-09-09 22:09:19 +03:00

16 KiB
Raw Permalink Blame History

Внешние сети с фиксированными 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 на роутер:
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" в инстансе, как сейчас, вместо дублирования ресурсов:

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) и обобщить:

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 из диаграммы не секрет):
    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 не задет.