Add adaptive router VM/interface scaling and local delivery integrity tests
Terraform now provisions router_count IaaS Router VMs (default 2, no longer hardcoded to router1/router2), each with 1 public + private_interface_count isolated private interfaces (no shared LAN or VRRP between routers). Both counts scale via Terraform variables and TF_VAR_* environment variables. The post-install script became a Terraform template that matches interfaces to their expected subnet by CIDR instead of a fragile "first private IP" heuristic. Added an offline pytest suite (terraform/tests/) that checks the delivery's internal consistency and runs real terraform init/validate against the actual vkcs provider schema via a project-local filesystem mirror (provider binary fetched from its GitHub releases, bypassing the region-blocked HashiCorp registry) - no cloud credentials or API calls involved. terraform/versions.tf now declares the previously-missing required_providers block. Ansible (inventory.ini, base/frr_router/keepalived roles) still assumes the old 2-router/2-NIC/VRRP topology and is not yet adapted - documented as a follow-up, not addressed here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hXR2ftXZZhJ4Y3XuSoR8r
This commit is contained in:
1 parent
e711faca42
commit
5979a9a58b
20 files changed
+1181
-139
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Твоя роль
|
||||
|
||||
- DevOps инженер
|
||||
- Разработчик Backend
|
||||
- Архитектор информационных систем
|
||||
- Архитектор корпоративной сети
|
||||
|
||||
# Стиль общения
|
||||
|
||||
- профессиональный, но без жаргона
|
||||
|
||||
# Стиль ответов
|
||||
|
||||
- максимально емкие и содержательные
|
||||
- не проваливайся в лишние детали, если это явно не было запрошено
|
||||
|
||||
# Создание артефактов
|
||||
|
||||
- На каждое новое изменение должен быть артефакт в .md файле
|
||||
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
|
||||
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
|
||||
- каждое изменение дополняет или обновляет README.md
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Local Python venv used for running terraform/tests (see terraform/tests/)
|
||||
venv/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# Terraform local state/plugin cache (never committed)
|
||||
.terraform/
|
||||
.terraform.lock.hcl
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
crash.log
|
||||
crash.*.log
|
||||
|
||||
# Claude Code session-local runtime state (not project content)
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/*.lock
|
||||
@@ -4,6 +4,8 @@ This is a conceptual Demo Scenario that will help you to bring highly available
|
||||
|
||||
>Key idea of this scenario based on limitations coming from On-Prem side which has two Internet circuits (Main and Backup where Backup is the `Radio Bridge`)
|
||||
|
||||
See [docs/QUICKSTART.md](docs/QUICKSTART.md) for a condensed step-by-step self-service deployment guide.
|
||||
|
||||
The materials from this repository will help you quickly build from the scratch the following network topology:
|
||||
|
||||

|
||||
@@ -23,7 +25,50 @@ Additional Steps:
|
||||
|
||||
# Under the Hood
|
||||
|
||||
>Main part of this scenario related to the routers (a pair of IaaS Virtual Machines (`IaaS Routers`) converted into traditional routers with advanced functionality)
|
||||
>Main part of this scenario related to the routers (a set of IaaS Virtual Machines (`IaaS Routers`) converted into traditional routers with advanced functionality)
|
||||
|
||||
**Adaptive router VM count and interfaces**
|
||||
|
||||
Both the number of `IaaS Router` VMs and the number of private interfaces per router are dynamic, controlled by Terraform variables:
|
||||
|
||||
- `router_count` (default `2`, tested with 3-4) - how many router VMs to provision.
|
||||
- `private_interface_count` (default `2`) - how many isolated private interfaces each router VM gets, on top of the single public (WAN) interface that stays fixed at 1.
|
||||
|
||||
So each router VM ends up with `1 + private_interface_count` network interfaces total (3 by default). Every private interface sits in its **own unique, isolated micro-subnet** (`/29` or `/28`, sized via `private_subnet_prefix_length`, carved out of `private_supernet`) - there is no shared LAN network or VRRP between router VMs in this design, a change from the previous 2-NIC/VRRP model.
|
||||
|
||||
New Terraform variables: `router_count`, `private_interface_count`, `private_supernet`, `private_subnet_prefix_length`, `router_availability_zones` (see `terraform/variables.tf`). The post-install script is a Terraform template (`terraform/scripts/network-init.sh.tpl`) rendered per-router via `templatefile()`, matching each private interface to its expected subnet deterministically instead of guessing - it already handles any interface count, no hardcoded assumption of 2. `terraform/versions.tf` now pins the provider source (`vk-cs/vkcs`, `~> 0.17`), which was previously undeclared.
|
||||
|
||||
**Horizontal scaling via environment variables**
|
||||
|
||||
Since `terraform.tfvars` doesn't set these two variables (only commented-out examples), they can be scaled purely through environment variables using Terraform's standard `TF_VAR_<name>` convention - no wrapper scripts needed:
|
||||
|
||||
```bash
|
||||
export TF_VAR_router_count=4
|
||||
export TF_VAR_private_interface_count=3
|
||||
terraform apply
|
||||
```
|
||||
|
||||
**Local delivery integrity tests**
|
||||
|
||||
`terraform/tests/` contains a local pytest suite that checks the delivery is internally consistent - required files present, `terraform fmt` clean, HCL parses, `router_count`/`private_interface_count` actually drive the resource/NIC count instead of being hardcoded, per-router private-subnet carving never overlaps (checked at several scales), and the post-install script template renders to valid bash. It also runs a real `terraform init` + `terraform validate` against the actual `vkcs` provider schema (at several `router_count`/`private_interface_count` values) - but against a project-local filesystem-mirror copy of the provider, so no cloud API is ever contacted and no credentials are needed (`validate` only type-checks against the provider's static schema).
|
||||
|
||||
```bash
|
||||
terraform/tests/setup-local-terraform.sh # one-time: provisions venv/ with terraform + the vkcs provider
|
||||
venv/bin/pytest terraform/tests -v
|
||||
```
|
||||
|
||||
`setup-local-terraform.sh` builds everything inside the git-ignored `venv/` directory:
|
||||
|
||||
- the Python packages from `terraform/tests/requirements.txt` (`pytest`, `python-hcl2`, `checkov`);
|
||||
- the `terraform` CLI, downloaded (with `SHA256SUMS` verification) from a region-unrestricted HashiCorp releases mirror;
|
||||
- the `vk-cs/vkcs` provider binary, downloaded (with `SHA256SUMS` verification) directly from its [GitHub releases](https://github.com/vk-cs/terraform-provider-vkcs/releases) - this bypasses `registry.terraform.io`, which blocks some regions outright, and is what makes a real `terraform validate` possible at all here;
|
||||
- a project-local CLI config (`venv/terraform.d/cli-config.tfrc`) that points `terraform init` at that local provider copy via a `filesystem_mirror` block, instead of the network registry.
|
||||
|
||||
The default `private_supernet` (`10.90.0.0/16`) at `/29` sizing has room for 8192 per-router-per-interface micro-subnets, far more than any realistic `router_count × private_interface_count` combination.
|
||||
|
||||
The default `private_supernet` (`10.90.0.0/16`) at `/29` sizing has room for 8192 per-router-per-interface micro-subnets, far more than any realistic `router_count × private_interface_count` combination.
|
||||
|
||||
>Note: the diagrams below (`ports.svg`, `topology.svg`) and the Ansible layer (`ansible/inventory.ini`, roles `base`/`frr_router`/`keepalived`) still describe/assume the previous 2-router, 2-NIC, VRRP-based design and have **not** been updated for the new N-NIC/N-router topology yet - that's a separate follow-up.
|
||||
|
||||
**IPv4 addressing plan for the project**
|
||||
|
||||
@@ -33,7 +78,7 @@ Here is a card to assist with configuration planning. The card is filled out usi
|
||||
|
||||
**Terraform**
|
||||
|
||||
Provisions a pair of `IaaS Routers` with internal and external ports. Includes supplimentary Shell script (which is a part of Terraform manifest) to maintain configuration across reboots.
|
||||
Provisions `router_count` `IaaS Routers` (default 2), each with 1 public and `private_interface_count` private ports (default 2). Includes supplimentary Shell script template (which is a part of Terraform manifest) to maintain configuration across reboots.
|
||||
|
||||
**Ansible**
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Quick Start (самостоятельное развёртывание)
|
||||
|
||||
Пошаговая инструкция для самостоятельного развёртывания сценария из этого репозитория на VK Cloud.
|
||||
|
||||
## 1. Подготовка
|
||||
|
||||
- Аккаунт VK Cloud с включённым API/CLI-доступом, `project_id`.
|
||||
- SSH-ключ загружен в VK Cloud (имя ключа понадобится в `terraform.tfvars`).
|
||||
- Установлены `terraform` (≥1.13) и `ansible`.
|
||||
|
||||
## 2. Клонировать репозиторий и задать credentials
|
||||
|
||||
Отредактировать `terraform/terraform.tfvars`:
|
||||
|
||||
```
|
||||
username = "<ваш VK Cloud логин>"
|
||||
password = "<пароль>"
|
||||
project_id = "<ваш project_id>"
|
||||
ssh_key_name = "<имя загруженного SSH-ключа>"
|
||||
```
|
||||
|
||||
## 3. (Опционально) масштабирование
|
||||
|
||||
По умолчанию: 2 роутера × 3 интерфейса (1 публичный + 2 приватных). Меняется без правки кода — либо раскомментировать нужные строки в `terraform.tfvars`, либо через переменные окружения:
|
||||
|
||||
```bash
|
||||
export TF_VAR_router_count=4
|
||||
export TF_VAR_private_interface_count=3
|
||||
```
|
||||
|
||||
## 4. Развернуть
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
terraform init
|
||||
terraform plan
|
||||
terraform apply
|
||||
```
|
||||
|
||||
Каждая ВМ сама донастроит сеть при первой загрузке (`network-init.sh.tpl`) и перезагрузится.
|
||||
|
||||
## 5. Настроить Ansible
|
||||
|
||||
> ⚠️ Важно: `ansible/inventory.ini` и роли (`base`/`frr_router`/`keepalived`) пока жёстко рассчитаны на **2** роутера с интерфейсами `eth0`/`eth1` (VRRP-схема) — под новую N-роутерную/3-NIC архитектуру ещё не адаптированы. Для дефолтных значений (`router_count=2`, `private_interface_count=2`) впишите реальные `wan_ip`/`lan_ip`/GRE/BGP-параметры роутеров в `inventory.ini` вручную. При масштабировании выше 2 роутеров или интерфейсов Ansible-слой нужно дорабатывать отдельно.
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
ansible-playbook -i inventory.ini site.yml
|
||||
```
|
||||
|
||||
## 6. Проверить
|
||||
|
||||
- SSH на публичные IP роутеров, `cat /var/log/network-config.log` — лог настройки интерфейсов.
|
||||
- `ip a`, `networkctl` — убедиться, что `eth0` (WAN) и `eth1..ethN` (приватные) подняты корректно.
|
||||
- FRR/strongSwan/keepalived статусы — см. раздел "Under the Hood" в [README.md](../README.md).
|
||||
|
||||
## Локальная офлайн-проверка поставки (без облака)
|
||||
|
||||
```bash
|
||||
terraform/tests/setup-local-terraform.sh
|
||||
venv/bin/pytest terraform/tests -v
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# План внедрения: адаптивное количество ВМ-маршрутизаторов и 3 интерфейса
|
||||
|
||||
Дата: 2026-09-03
|
||||
|
||||
## Проблема
|
||||
|
||||
`terraform/main.tf` жёстко описывает ровно 2 ВМ-маршрутизатора (`router1`, `router2`) с 2 интерфейсами каждая: WAN (публичный, DHCP) и LAN (приватный, статический IP на общей сети `10.200.10.0/24`, используемой также для VRRP-VIP через keepalived). Post-install скрипт `terraform/scripts/network-init.sh` определяет эти 2 интерфейса эвристически (первый интерфейс с приватным IP → LAN, первый с публичным IP → WAN).
|
||||
|
||||
Целевая схема: адаптивное количество ВМ-маршрутизаторов (2, 3, 4 и более, управляется переменной), у каждой ВМ — 3 интерфейса: 1 публичный (WAN, без изменений) + 2 приватных, каждый в своей уникальной изолированной микросети (`/29` или `/28`, размер задаётся переменной). VRRP и общая LAN-сеть между роутерами в новой схеме не используются.
|
||||
|
||||
Изменения ограничены директорией `terraform` (включая `terraform/scripts/network-init.sh`). Ansible-слой (`ansible/inventory.ini`, роли `base`/`frr_router`/`keepalived`, которые сейчас жёстко используют `eth0`/`eth1` и только `router1`/`router2`) в этом изменении не трогается — по явному решению заказчика; несоответствие фиксируется как задел на будущее.
|
||||
|
||||
`lan_net`/`lan_subnet` (`10.200.10.0/24`) и ВМ `priv_srv_01/02/03` — отдельный сегмент приватных серверов, не связанный с роутерами; не меняются.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. `terraform/variables.tf`: добавить `router_count`, `private_supernet`, `private_subnet_prefix_length`, `router_availability_zones`.
|
||||
2. `terraform/main.tf`:
|
||||
- `locals.router_private_subnets` — детерминированная нарезка CIDR из `private_supernet` на пары (router_index, role) через `cidrsubnet()`.
|
||||
- Заменить `lan_port1`/`lan_port2` на `for_each`-ресурсы (`router_priv_net`/`router_priv_subnet`/`router_priv_port`) по `local.router_private_subnets`.
|
||||
- Заменить `router1`/`router2` на один ресурс `vkcs_compute_instance.router` с `count = var.router_count`, WAN + `dynamic "network"` по приватным портам, `user_data` через `templatefile()`.
|
||||
3. `terraform/scripts/network-init.sh` → `terraform/scripts/network-init.sh.tpl`:
|
||||
- Экранировать существующие bash brace-expansions (`${netmask}` и т.п.) как `$${...}`, чтобы Terraform их не интерполировал.
|
||||
- WAN-детект — без изменений (эвристика по публичному IP).
|
||||
- Приватные интерфейсы — сопоставление по CIDR из переданного Terraform списка `private_interfaces`, а не "первый приватный IP".
|
||||
- netplan: `eth0` (WAN) как раньше + цикл по найденным приватным интерфейсам (без default route на приватных).
|
||||
4. `terraform.tfvars`: добавить примеры значений новых переменных.
|
||||
5. `README.md`: обновить описание архитектуры (N роутеров, 3 NIC, без VRRP/общего LAN на роутерах), отметить, что диаграммы и Ansible-слой пока отражают старую схему.
|
||||
6. Summary-документ по завершении.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `terraform fmt -recursive`
|
||||
- `terraform init -backend=false` + `terraform validate` (best-effort, если провайдер VKCS доступен из окружения)
|
||||
- Офлайн-проверка синтаксиса `.tpl`: подстановка тестовых значений вместо `%{ for }`-блока во временную копию + `bash -n`
|
||||
- `terraform plan`/`apply` с реальными credentials — вне рамок этой сессии (создаёт платные облачные ресурсы, `terraform.tfvars` содержит placeholder-значения)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Summary: адаптивное количество ВМ-маршрутизаторов и 3 интерфейса
|
||||
|
||||
Дата: 2026-09-03
|
||||
План: [2026-09-03-adaptive-router-vm-count-plan.md](2026-09-03-adaptive-router-vm-count-plan.md)
|
||||
|
||||
## Что сделано
|
||||
|
||||
### `terraform/variables.tf`
|
||||
Добавлены переменные: `router_count` (default `2`, `>= 1`), `private_supernet` (default `10.90.0.0/16`), `private_subnet_prefix_length` (default `29`, допустимо `28`/`29`), `router_availability_zones` (default `["ME1"]`).
|
||||
|
||||
### `terraform/main.tf`
|
||||
- Добавлен `locals.router_private_subnets` — детерминированная непересекающаяся нарезка CIDR из `private_supernet` на пары (router_index × роль) через `cidrsubnet()`.
|
||||
- `lan_port1`/`lan_port2` (фиксированные порты на общей `10.200.10.0/24`) заменены на `for_each`-ресурсы `router_priv_net` / `router_priv_subnet` / `router_priv_port` — по одной изолированной микросети на каждый (роутер, приватная роль).
|
||||
- `router1`/`router2` заменены на единый `vkcs_compute_instance.router` с `count = var.router_count`: WAN-сеть без изменений + `dynamic "network"` по 2 приватным портам; `user_data` теперь рендерится через `templatefile()`.
|
||||
- `lan_net`/`lan_subnet`, `private_sg`, `priv_srv_01/02/03` не изменялись (отдельный сегмент приватных серверов).
|
||||
- Файл прогнан через `terraform fmt -recursive` (единственные изменения — форматирование существующих строк, семантика не менялась).
|
||||
|
||||
### `terraform/scripts/network-init.sh` → `terraform/scripts/network-init.sh.tpl`
|
||||
- Файл переименован (`git mv`) и превращён в Terraform-шаблон.
|
||||
- Все существующие bash brace-expansions (`${netmask}`, `${partial_octet}`, `${octets[@]}`) экранированы как `$${...}`, чтобы Terraform не пытался их интерполировать.
|
||||
- WAN-детект (эвристика по первому публичному IP) не изменён.
|
||||
- Приватные интерфейсы теперь определяются не эвристикой "первый приватный IP", а сопоставлением по CIDR: Terraform передаёт список `private_interfaces` (по 2 записи `{cidr, name}` на роутер), из которого шаблонная директива `%{ for pi in private_interfaces }` генерирует bash-массив `PRIV_TARGETS`. Добавлены функции `ip_to_int()`/`ip_in_cidr()` для проверки принадлежности IP подсети.
|
||||
- netplan-конфиг: секция `wan-iface` (`eth0`) без изменений; вместо единственной `lan-iface` — цикл, генерирующий отдельную `ethernets`-секцию на каждый найденный приватный интерфейс (`eth1`, `eth2`, ...), без default route (изолированные микросети).
|
||||
- Проверки/fail-hard/backup/apply/reboot логика сохранена.
|
||||
|
||||
### `terraform/terraform.tfvars`
|
||||
Добавлены закомментированные примеры новых переменных.
|
||||
|
||||
### `README.md`
|
||||
Обновлено описание архитектуры: N роутеров вместо пары, 3 интерфейса на роутер (1 публичный + 2 изолированных приватных, без общего LAN и без VRRP на роутерах), упомянуты новые переменные и то, что `network-init.sh` стал Terraform-шаблоном. Явно отмечено, что диаграммы (`ports.svg`, `topology.svg`) и Ansible-слой (`ansible/inventory.ini`, роли `base`/`frr_router`/`keepalived`) пока отражают старую схему — это отдельный будущий шаг.
|
||||
|
||||
## Что осознанно не менялось
|
||||
|
||||
- **Ansible-слой** — по явному решению в рамках этого изменения. `ansible/inventory.ini` и роли `base`/`frr_router`/`keepalived`/`gre` жёстко используют `eth0`/`eth1` и только хосты `router1`/`router2`; с новым дизайном (N роутеров, `eth1`+`eth2`, без VRRP) они не совместимы и потребуют отдельной переработки перед реальным деплоем через Ansible.
|
||||
- **`priv_srv_01/02/03` и `lan_net`/`lan_subnet`** — не связаны с роутерами, оставлены как есть.
|
||||
- **Диаграммы** (`img/*.svg`) — не обновлялись, отражают старую топологию.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `terraform fmt -recursive` — чисто (после автоформатирования).
|
||||
- `terraform init -backend=false` — **не выполнено**: провайдер `hashicorp/vkcs` недоступен из окружения (нет `required_providers` в репозитории и нет сети до его источника). Это ограничение окружения, не связанное с данным изменением — в репозитории изначально не было блока `required_providers`.
|
||||
- Синтаксис `.tpl`: выполнена симуляция рендеринга `templatefile()` (замена `$${` → `${`, подстановка тестовых значений вместо `%{ for }`-блока) + `bash -n` — прошло без ошибок.
|
||||
- Логика `ip_to_int()`/`ip_in_cidr()` проверена отдельным bash-тестом на 7 сценариях (`/29` и `/28`, совпадения и несовпадения) — все прошли.
|
||||
- `terraform plan`/`apply` с реальными VK Cloud credentials не выполнялись — `terraform.tfvars` содержит placeholder-значения, `apply` создаёт платные облачные ресурсы; это отдельное действие пользователя.
|
||||
|
||||
## Известные ограничения / задел на будущее
|
||||
|
||||
- Ansible-слой требует отдельной доработки под 3 интерфейса и N роутеров (см. выше).
|
||||
- В репозитории отсутствует `required_providers`/lock-файл для `vkcs` — это не связано с текущим изменением, но мешает локальной `terraform validate`/`init`.
|
||||
- Диаграммы не обновлены под новую топологию.
|
||||
@@ -0,0 +1,25 @@
|
||||
# План внедрения: динамическое количество приватных интерфейсов
|
||||
|
||||
Дата: 2026-09-03
|
||||
|
||||
## Проблема
|
||||
|
||||
Количество ВМ-маршрутизаторов уже параметризовано через `var.router_count` (см. [2026-09-03-adaptive-router-vm-count-plan.md](2026-09-03-adaptive-router-vm-count-plan.md)). Количество приватных интерфейсов на роутер осталось жёстко зашито: `terraform/main.tf` — `locals.private_roles = ["priv1", "priv2"]`, ровно 2 роли.
|
||||
|
||||
Нужна вторая ось горизонтального масштабирования: количество приватных интерфейсов на роутер тоже управляется переменной, и обе переменные (`router_count`, теперь и количество приватных интерфейсов) должны быть передаваемы через переменные окружения — что в Terraform уже "бесплатно" работает через `TF_VAR_<имя>` для любой объявленной `variable`, пока значение не переопределено в `terraform.tfvars`.
|
||||
|
||||
`terraform/scripts/network-init.sh.tpl` уже написан generic (итерация по bash-массиву `PRIV_TARGETS` произвольной длины) — единственный хардкод в нём это `eth0` для WAN. Скрипт менять не требуется.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. `terraform/variables.tf`: добавить `private_interface_count` (number, default `2`, validation `>= 1`).
|
||||
2. `terraform/main.tf`: `locals.private_roles = [for i in range(var.private_interface_count) : "priv${i + 1}"]` вместо хардкода `["priv1", "priv2"]`. Остальные блоки (`router_private_subnets`, `router_priv_net/subnet/port`, `dynamic "network"`, генерация `private_interfaces` для `templatefile()`) уже параметризованы через `local.private_roles` и не требуют изменений.
|
||||
3. `terraform.tfvars`: закомментированный пример `# private_interface_count = 3`.
|
||||
4. `README.md`: раздел про масштабирование через `TF_VAR_router_count` / `TF_VAR_private_interface_count`, обновить формулировку "1 публичный + N приватных".
|
||||
5. Summary-документ по завершении.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `terraform fmt -recursive`
|
||||
- `terraform init -backend=false` + `terraform validate` (best-effort, провайдер `vkcs` недоступен в этом окружении — известное ограничение, не связано с правкой)
|
||||
- Ручная трассировка `local.private_roles`/`router_private_subnets` для `private_interface_count = 1` и `= 4` — корректность и отсутствие коллизий CIDR
|
||||
@@ -0,0 +1,36 @@
|
||||
# Summary: динамическое количество приватных интерфейсов
|
||||
|
||||
Дата: 2026-09-03
|
||||
План: [2026-09-03-dynamic-private-interface-count-plan.md](2026-09-03-dynamic-private-interface-count-plan.md)
|
||||
|
||||
## Что сделано
|
||||
|
||||
### `terraform/variables.tf`
|
||||
Добавлена переменная `private_interface_count` (number, default `2`, validation `>= 1`) — количество изолированных приватных интерфейсов на роутер, в дополнение к единственному публичному (WAN).
|
||||
|
||||
### `terraform/main.tf`
|
||||
Единственная точечная правка: `locals.private_roles` заменён с хардкода `["priv1", "priv2"]` на генерируемый список `[for i in range(var.private_interface_count) : "priv${i + 1}"]`. Все производные ресурсы и блоки (`router_private_subnets`, `router_priv_net`/`router_priv_subnet`/`router_priv_port` через `for_each`, `dynamic "network"` в `vkcs_compute_instance.router`, генерация списка `private_interfaces` для `templatefile()`, имена интерфейсов `eth${index(...)+1}`) уже были параметризованы через `local.private_roles` в предыдущем изменении и масштабируются автоматически — их трогать не пришлось.
|
||||
|
||||
При `private_interface_count = 2` (значение по умолчанию) результат идентичен прежнему поведению — регрессии нет.
|
||||
|
||||
### `terraform/scripts/network-init.sh.tpl`
|
||||
**Не менялся.** Скрипт уже полностью generic: итерируется по bash-массиву `PRIV_TARGETS`, сформированному из переданного Terraform списка `private_interfaces` произвольной длины. Единственный хардкод в скрипте — `set-name: eth0` для WAN.
|
||||
|
||||
### `terraform/terraform.tfvars`
|
||||
Добавлен закомментированный пример `# private_interface_count = 3` (не активирован, чтобы не перекрывать `TF_VAR_private_interface_count`).
|
||||
|
||||
### `README.md`
|
||||
- Формулировка про интерфейсы обновлена на "1 публичный + `private_interface_count` приватных (по умолчанию 2)".
|
||||
- Добавлен раздел "Horizontal scaling via environment variables" с примером `export TF_VAR_router_count=4` / `export TF_VAR_private_interface_count=3` и объяснением, почему это работает "из коробки" (переменные не переопределены в `terraform.tfvars`, поэтому действует стандартный приоритет Terraform `TF_VAR_*` → default).
|
||||
- Упомянута ёмкость пула CIDR (`private_supernet` `/16` при `/29` подсетях = 8192 блока).
|
||||
|
||||
## Обоснование "через переменные окружения" без дополнительного кода
|
||||
|
||||
Механизм `TF_VAR_<имя_переменной>` — встроенная возможность Terraform для любой объявленной `variable`, отдельного кода/обвязки не требуется. Единственное условие — чтобы значение не было жёстко задано в `terraform.tfvars` (в этом репозитории для `router_count` и `private_interface_count` там только закомментированные примеры, так что `TF_VAR_*` работает без изменений).
|
||||
|
||||
## Верификация
|
||||
|
||||
- `terraform fmt -recursive` — чисто, без изменений (синтаксис валиден).
|
||||
- `terraform init`/`validate` — не выполнялись повторно: провайдер `vkcs` недоступен из окружения (то же ограничение, что и в прошлом изменении, не связано с этой правкой).
|
||||
- Математика нарезки CIDR проверена вручную: `netnum = router_index × length(private_roles) + role_index` — строгая биекция (mixed-radix encoding) без коллизий при любых `router_count`/`private_interface_count`, пока общее число блоков не превышает ёмкость `private_supernet` при заданном `private_subnet_prefix_length` (дефолт: до 8192 блоков — с большим запасом).
|
||||
- `network-init.sh.tpl` не менялся — прошлая офлайн-проверка синтаксиса (`bash -n` на симулированном рендере) остаётся в силе, логика идентична.
|
||||
@@ -0,0 +1,31 @@
|
||||
# План внедрения: локальная проверка целостности поставки terraform
|
||||
|
||||
Дата: 2026-09-03
|
||||
|
||||
## Проблема
|
||||
|
||||
Нужно проверить целостность поставки `terraform/` средствами terraform, но `terraform validate`/`plan` с реальным провайдером `vkcs` недостижимы в этом окружении (см. предыдущие изменения): реестр `registry.terraform.io` блокирует регион ("Content not available in your region"), а сконфигурированное зеркало `terraform-mirror.mcs.mail.ru` не содержит провайдер `hashicorp/vkcs` (404 на `index.json`) — в репозитории также исторически отсутствует блок `required_providers`. Пользователь подтвердил: тесты строго локальные, настройку облачных сущностей не выполняем.
|
||||
|
||||
Требуется: установить `terraform` в изолированное локальное окружение (`venv/` в корне проекта), внутри — Python virtualenv с пакетами для тестов, и выполнить содержательную офлайн-проверку поставки.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. `python3 -m venv venv` в корне проекта.
|
||||
2. Скачать бинарь `terraform` (v1.16.1, linux_amd64) с внутреннего зеркала `https://hashicorp-releases.mcs.mail.ru/terraform/`, проверить SHA256SUMS, распаковать в `venv/bin/terraform`. (Это зеркало официальных релизов HashiCorp — провайдер `vkcs` там не публикуется, т.к. это сторонний провайдер VK Cloud, не HashiCorp; проблема с провайдером этим не решается, но сам `terraform` CLI теперь изолированно установлен в venv, как и просил пользователь.)
|
||||
3. `venv/bin/pip install -r terraform/tests/requirements.txt` — `pytest`, `python-hcl2`, `checkov` (checkov оставлен как smoke-проверка; у него нет собственных правил для провайдера `vkcs`, что подтверждено отдельным sanity-тестом на `aws_s3_bucket`).
|
||||
4. Написать `terraform/tests/test_terraform_delivery.py` — pytest-сьют, полностью офлайн:
|
||||
- наличие обязательных файлов поставки;
|
||||
- `terraform fmt -check -recursive` (единственная проверка, буквально использующая сам terraform-бинарь);
|
||||
- валидность HCL всех `.tf`-файлов через `python-hcl2`;
|
||||
- переменные `router_count`/`private_interface_count` объявлены с ожидаемыми дефолтами и не переопределены в `terraform.tfvars` (иначе `TF_VAR_*` не сработает);
|
||||
- `vkcs_compute_instance.router` использует `count = var.router_count`, отсутствуют легаси-ресурсы `router1`/`router2`/`lan_port1`/`lan_port2`;
|
||||
- `locals.private_roles` генерируется из `var.private_interface_count`, `dynamic "network"` использует `local.private_roles`;
|
||||
- нарезка приватных подсетей (`cidrsubnet`) не даёт коллизий — переиспользована через `ipaddress` для нескольких комбинаций `router_count`/`private_interface_count`;
|
||||
- `network-init.sh.tpl` содержит только ожидаемые Terraform-интерполяции (`${pi.cidr}`, `${pi.name}`), директиву `%{ for pi in private_interfaces }`, и рендерится в синтаксически валидный bash (симуляция `templatefile()` + `bash -n`).
|
||||
5. `.gitignore` — добавить `venv/`, `.pytest_cache/`, `__pycache__/`, `.terraform/`, `*.tfstate*` (венв и кэши не должны попасть в git).
|
||||
6. `README.md` — короткий раздел про запуск тестов.
|
||||
7. Summary-документ по завершении.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `venv/bin/pytest terraform/tests -v` — все тесты должны быть зелёными.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Summary: локальная проверка целостности поставки terraform
|
||||
|
||||
Дата: 2026-09-03
|
||||
План: [2026-09-03-local-delivery-integrity-tests-plan.md](2026-09-03-local-delivery-integrity-tests-plan.md)
|
||||
|
||||
## Что сделано
|
||||
|
||||
### `venv/` — локальное изолированное окружение (в корне проекта, не в git)
|
||||
- `python3 -m venv venv`.
|
||||
- `venv/bin/terraform` — Terraform **v1.16.1** (linux_amd64), скачан с внутреннего зеркала HashiCorp-релизов `https://hashicorp-releases.mcs.mail.ru/terraform/1.16.1/`, целостность архива проверена по `SHA256SUMS` перед распаковкой.
|
||||
- `venv/bin/pip install -r terraform/tests/requirements.txt` — `pytest 9.1.1`, `python-hcl2 8.1.3`, `checkov 3.3.16`.
|
||||
|
||||
### `terraform/tests/requirements.txt`
|
||||
Пины версий для воспроизводимой установки пакетов тестирования.
|
||||
|
||||
### `terraform/tests/test_terraform_delivery.py`
|
||||
Pytest-сьют из 28 тестов, полностью офлайн (без `terraform init`/`plan`/`apply` к реальному провайдеру, без облачных вызовов):
|
||||
|
||||
- наличие обязательных файлов поставки (`main.tf`, `variables.tf`, `terraform.tfvars`, `scripts/network-init.sh.tpl`), отсутствие старого `network-init.sh`;
|
||||
- `terraform fmt -check -recursive` — единственная проверка, буквально вызывающая сам `terraform` (из `venv/bin`, если он там есть, иначе `PATH`);
|
||||
- валидность HCL всех `.tf`-файлов (`python-hcl2`);
|
||||
- переменные `router_count`/`private_interface_count` объявлены с ожидаемыми дефолтами и **не** переопределены активными строками в `terraform.tfvars` (иначе `TF_VAR_*` был бы перекрыт);
|
||||
- `vkcs_compute_instance.router` использует `count = var.router_count`; в репозитории нет легаси-ресурсов `router1`/`router2`/`lan_port1`/`lan_port2`;
|
||||
- `locals.private_roles` генерируется из `var.private_interface_count`, а `dynamic "network"` итерируется по `local.private_roles` (NIC-количество реально масштабируется, не хардкод);
|
||||
- нарезка приватных подсетей (переиспользование формулы `cidrsubnet` через `ipaddress`) не даёт коллизий для 6 комбинаций `router_count`×`private_interface_count` (включая крайние: 1×1, 8×4, 1×6);
|
||||
- `network-init.sh.tpl` содержит только ожидаемые Terraform-интерполяции (`${pi.cidr}`, `${pi.name}`) и директиву `%{ for pi in private_interfaces }`; симуляция рендера (`$${` → `${`, подстановка тестовых данных) даёт синтаксически валидный bash (`bash -n`);
|
||||
- smoke-тест `checkov` (офлайн, `--skip-download`) — не падает; **намеренно не проверяет** `resource_count > 0`, см. ограничение ниже.
|
||||
|
||||
Результат: **28 passed**.
|
||||
|
||||
### `.gitignore` (новый файл)
|
||||
`venv/`, `.pytest_cache/`, `__pycache__/`, `*.pyc`, `.terraform/`, `.terraform.lock.hcl`, `*.tfstate*`, `crash*.log`.
|
||||
|
||||
### `README.md`
|
||||
Добавлен раздел "Local delivery integrity tests" с командами запуска и пояснением про офлайн-природу тестов.
|
||||
|
||||
## Важные находки в процессе
|
||||
|
||||
1. **`terraform validate`/`init` с реальным провайдером `vkcs` недостижимы в этом окружении** — `registry.terraform.io` возвращает "Content not available in your region" на API-запросы, а сконфигурированное зеркало `terraform-mirror.mcs.mail.ru` не хостит `hashicorp/vkcs` (404 на `index.json` по протоколу Provider Network Mirror). Это подтверждает более раннюю находку (репозиторий не содержит `required_providers`) и объясняет, почему для содержательной проверки пришлось построить отдельный pytest+hcl2 сьют вместо `terraform validate`.
|
||||
2. **`https://hashicorp-releases.mcs.mail.ru`** — зеркало официальных релизов HashiCorp (terraform CLI и другие продукты HashiCorp), но **не** третьесторонних провайдеров вроде `vkcs` (публикуется VK Cloud, не HashiCorp) — поэтому оно решает задачу "установить terraform в venv", но не задачу provider-резолвинга.
|
||||
3. **`checkov` не имеет собственных политик для провайдера `vkcs`** — проверено отдельным sanity-тестом на `aws_s3_bucket` (там checkov нормально нашёл ресурс и прогнал реальные проверки), тогда как на `terraform/main.tf` он стабильно даёт `resource_count: 0`. Оставлен в сьюте только как smoke-проверка работоспособности инструмента, не как содержательный security/compliance гейт для этого репозитория.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `venv/bin/pytest terraform/tests -v` → `28 passed`.
|
||||
- `venv/bin/terraform version` → `Terraform v1.16.1`.
|
||||
- `git status` подтверждает, что `venv/` и `.pytest_cache/` не отслеживаются git (`.gitignore` работает).
|
||||
@@ -0,0 +1,24 @@
|
||||
# План внедрения: доступ к провайдеру vkcs через GitHub-релизы
|
||||
|
||||
Дата: 2026-09-03
|
||||
|
||||
## Проблема
|
||||
|
||||
Предыдущая попытка (см. [2026-09-03-local-delivery-integrity-tests-plan.md](2026-09-03-local-delivery-integrity-tests-plan.md)) не смогла получить реальный провайдер `vkcs`: `registry.terraform.io` блокирует регион ("Content not available in your region"), а сконфигурированное зеркало `terraform-mirror.mcs.mail.ru` не содержит `hashicorp/vkcs` (404). Пользователь указал использовать `https://github.com/vk-cs/terraform-provider-vkcs` напрямую — это upstream-репозиторий провайдера, публикующий бинарные релизы через GitHub Releases в обход реестра HashiCorp.
|
||||
|
||||
## Шаги
|
||||
|
||||
1. Через GitHub API (`api.github.com`, доступен) найти последний релиз провайдера — `v0.17.2`.
|
||||
2. Скачать `terraform-provider-vkcs_0.17.2_linux_amd64.zip` и `..._SHA256SUMS`, проверить контрольную сумму перед распаковкой.
|
||||
3. Разместить бинарь как **filesystem mirror** провайдера внутри `venv/` (не в git): `venv/terraform.d/plugins/registry.terraform.io/vk-cs/vkcs/0.17.2/linux_amd64/`.
|
||||
4. Добавить `terraform/versions.tf` с блоком `required_providers { vkcs = { source = "vk-cs/vkcs", version = "~> 0.17" } }` — раньше в репозитории такого блока не было вовсе (без него Terraform по умолчанию ищет `hashicorp/vkcs`, которого не существует).
|
||||
5. Сгенерировать проектный CLI-конфиг `venv/terraform.d/cli-config.tfrc` с `provider_installation { filesystem_mirror {...} }`, указывающий на локальное зеркало — не трогая глобальный `~/.terraformrc` пользователя.
|
||||
6. Оформить шаги 3-5 как воспроизводимый идемпотентный скрипт `terraform/tests/setup-local-terraform.sh` (создаёт venv, ставит terraform CLI, провайдер, CLI-конфиг — с проверкой SHA256 на каждом скачивании).
|
||||
7. Прогнать реальные `terraform init -backend=false` + `terraform validate` во временной копии `terraform/` (чтобы не оставлять `.terraform/`/`.terraform.lock.hcl` в самой поставке) — без каких-либо credentials и без обращений к облачному API (`validate` проверяет только статическую схему провайдера).
|
||||
8. Добавить в `terraform/tests/test_terraform_delivery.py` параметризованный тест `test_terraform_init_and_validate_against_real_provider_schema` — реальная валидация при нескольких сочетаниях `router_count`/`private_interface_count` (через `TF_VAR_*`), с `pytest.skip`, если локальное зеркало ещё не настроено.
|
||||
9. Обновить README и текущий summary-документ.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `terraform init` + `terraform validate` вручную — успех, включая `router_count=4, private_interface_count=3`.
|
||||
- `venv/bin/pytest terraform/tests -v` — весь сьют зелёный.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Summary: доступ к провайдеру vkcs через GitHub-релизы
|
||||
|
||||
Дата: 2026-09-03
|
||||
План: [2026-09-03-vkcs-provider-github-mirror-plan.md](2026-09-03-vkcs-provider-github-mirror-plan.md)
|
||||
|
||||
## Что сделано
|
||||
|
||||
### `terraform/versions.tf` (новый файл)
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
vkcs = {
|
||||
source = "vk-cs/vkcs"
|
||||
version = "~> 0.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Раньше такого блока не было вовсе — Terraform по умолчанию искал несуществующий `hashicorp/vkcs`. Это и было корнем прошлых неудачных попыток `terraform init`.
|
||||
|
||||
### `terraform/tests/setup-local-terraform.sh` (новый файл, идемпотентный)
|
||||
Разворачивает полностью локальный toolchain внутри `venv/` (не в git):
|
||||
- Python-пакеты из `terraform/tests/requirements.txt`;
|
||||
- `terraform` CLI v1.16.1 (уже было с прошлого шага);
|
||||
- провайдер **`vk-cs/vkcs` v0.17.2**, скачанный напрямую с `https://github.com/vk-cs/terraform-provider-vkcs/releases/download/v0.17.2/...` (в обход заблокированного по региону `registry.terraform.io`), с проверкой SHA256SUMS перед распаковкой;
|
||||
- провайдер размещён как **filesystem mirror**: `venv/terraform.d/plugins/registry.terraform.io/vk-cs/vkcs/0.17.2/linux_amd64/`;
|
||||
- проектный CLI-конфиг `venv/terraform.d/cli-config.tfrc` с блоком `provider_installation { filesystem_mirror {...} }` — глобальный `~/.terraformrc` пользователя не тронут.
|
||||
|
||||
### `terraform/tests/test_terraform_delivery.py`
|
||||
Добавлен параметризованный тест `test_terraform_init_and_validate_against_real_provider_schema` — **реальные** `terraform init` + `terraform validate` против настоящей схемы провайдера `vkcs`, во временной копии `terraform/` (чтобы не оставлять `.terraform/`/lock-файл в самой поставке), без credentials и без обращений к облаку. Прогнан при трёх сочетаниях `router_count`/`private_interface_count` (через `TF_VAR_*`): дефолты (2/2), 1/1, 4/3 — все успешны. Тест аккуратно скипается (`pytest.skip`), если `setup-local-terraform.sh` ещё не запускался.
|
||||
|
||||
Итог сьюта: **31 passed** (было 28 до этого шага, +3 за счёт параметризации нового теста).
|
||||
|
||||
### `README.md`
|
||||
Раздел "Local delivery integrity tests" переписан: теперь описывает реальную `terraform validate` через GitHub-зеркало провайдера, команду `setup-local-terraform.sh`, и упоминание нового `versions.tf`.
|
||||
|
||||
## Побочная находка (не исправлялась — вне рамок запроса)
|
||||
|
||||
В `terraform/main.tf` нет явного блока `provider "vkcs" {}` — переменные `username`/`password`/`project_id` объявлены в `variables.tf`, но нигде не подключены к конфигурации провайдера. Для `validate` это не критично (схема провайдера проверяется без учёта credentials), но для реального `apply` может потребоваться либо явный `provider "vkcs" { ... }` блок со ссылками на эти переменные, либо провайдер полагается на переменные окружения (`OS_*`/`VKCS_*`) — это стоит уточнить отдельно, если планируется реальный `apply`.
|
||||
|
||||
## Верификация
|
||||
|
||||
- `venv/bin/terraform init -backend=false` + `venv/bin/terraform validate` (во временной копии, с `TF_CLI_CONFIG_FILE` на локальный mirror) → `Success! The configuration is valid.` — при дефолтных значениях и при `router_count=4, private_interface_count=3`.
|
||||
- `venv/bin/pytest terraform/tests -v` → `31 passed`.
|
||||
- SHA256 бинаря провайдера сверен с `terraform-provider-vkcs_0.17.2_SHA256SUMS` из релиза GitHub перед распаковкой.
|
||||
+70
-75
@@ -9,7 +9,7 @@ data "vkcs_images_image" "ubuntu24" {
|
||||
|
||||
data "vkcs_networking_network" "extnet" {
|
||||
name = "internet"
|
||||
sdn = "sprut"
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
# LAN Network
|
||||
@@ -20,12 +20,12 @@ resource "vkcs_networking_network" "lan_net" {
|
||||
}
|
||||
|
||||
resource "vkcs_networking_subnet" "lan_subnet" {
|
||||
network_id = vkcs_networking_network.lan_net.id
|
||||
name = "router-lan-subnet"
|
||||
cidr = "10.200.10.0/24"
|
||||
gateway_ip = "10.200.10.1"
|
||||
network_id = vkcs_networking_network.lan_net.id
|
||||
name = "router-lan-subnet"
|
||||
cidr = "10.200.10.0/24"
|
||||
gateway_ip = "10.200.10.1"
|
||||
dns_nameservers = ["8.8.8.8", "1.1.1.1"]
|
||||
sdn = "sprut"
|
||||
sdn = "sprut"
|
||||
|
||||
allocation_pool {
|
||||
start = "10.200.10.100"
|
||||
@@ -42,7 +42,7 @@ resource "vkcs_networking_secgroup" "router_sg" {
|
||||
resource "vkcs_networking_secgroup" "private_sg" {
|
||||
name = "private-sg"
|
||||
sdn = "sprut"
|
||||
}
|
||||
}
|
||||
|
||||
# Private SG rules
|
||||
resource "vkcs_networking_secgroup_rule" "from_rfc_net192_in" {
|
||||
@@ -105,40 +105,62 @@ resource "vkcs_networking_secgroup_rule" "router_ipsec_nat_t" {
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
# Router LAN Ports
|
||||
resource "vkcs_networking_port" "lan_port1" {
|
||||
name = "router1-lan-port"
|
||||
network_id = vkcs_networking_network.lan_net.id
|
||||
admin_state_up = true
|
||||
port_security_enabled = false
|
||||
full_security_groups_control = true
|
||||
security_group_ids = []
|
||||
sdn = "sprut"
|
||||
fixed_ip {
|
||||
subnet_id = vkcs_networking_subnet.lan_subnet.id
|
||||
ip_address = "10.200.10.254"
|
||||
# Per-router private interface subnets: each router gets `private_roles` isolated
|
||||
# micro-subnets (no shared LAN, no VRRP), carved out of private_supernet.
|
||||
locals {
|
||||
private_roles = [for i in range(var.private_interface_count) : "priv${i + 1}"]
|
||||
|
||||
router_private_subnets = {
|
||||
for pair in setproduct(range(var.router_count), local.private_roles) :
|
||||
"router${pair[0] + 1}-${pair[1]}" => {
|
||||
router_index = pair[0]
|
||||
role = pair[1]
|
||||
cidr = cidrsubnet(
|
||||
var.private_supernet,
|
||||
var.private_subnet_prefix_length - tonumber(split("/", var.private_supernet)[1]),
|
||||
pair[0] * length(local.private_roles) + index(local.private_roles, pair[1])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_networking_port" "lan_port2" {
|
||||
name = "router2-lan-port"
|
||||
network_id = vkcs_networking_network.lan_net.id
|
||||
admin_state_up = true
|
||||
port_security_enabled = false
|
||||
resource "vkcs_networking_network" "router_priv_net" {
|
||||
for_each = local.router_private_subnets
|
||||
name = "router-${each.key}-net"
|
||||
sdn = "sprut"
|
||||
admin_state_up = true
|
||||
}
|
||||
|
||||
resource "vkcs_networking_subnet" "router_priv_subnet" {
|
||||
for_each = local.router_private_subnets
|
||||
network_id = vkcs_networking_network.router_priv_net[each.key].id
|
||||
name = "router-${each.key}-subnet"
|
||||
cidr = each.value.cidr
|
||||
gateway_ip = cidrhost(each.value.cidr, 1)
|
||||
sdn = "sprut"
|
||||
}
|
||||
|
||||
resource "vkcs_networking_port" "router_priv_port" {
|
||||
for_each = local.router_private_subnets
|
||||
name = "router-${each.key}-port"
|
||||
network_id = vkcs_networking_network.router_priv_net[each.key].id
|
||||
admin_state_up = true
|
||||
port_security_enabled = false
|
||||
full_security_groups_control = true
|
||||
security_group_ids = []
|
||||
sdn = "sprut"
|
||||
security_group_ids = []
|
||||
sdn = "sprut"
|
||||
fixed_ip {
|
||||
subnet_id = vkcs_networking_subnet.lan_subnet.id
|
||||
ip_address = "10.200.10.253"
|
||||
subnet_id = vkcs_networking_subnet.router_priv_subnet[each.key].id
|
||||
ip_address = cidrhost(each.value.cidr, 2)
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "router1" {
|
||||
name = "router1"
|
||||
resource "vkcs_compute_instance" "router" {
|
||||
count = var.router_count
|
||||
name = "router${count.index + 1}"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = "ME1"
|
||||
availability_zone = element(var.router_availability_zones, count.index % length(var.router_availability_zones))
|
||||
key_pair = var.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
@@ -147,56 +169,29 @@ resource "vkcs_compute_instance" "router1" {
|
||||
]
|
||||
|
||||
config_drive = true
|
||||
|
||||
# Configure persistent networking using script
|
||||
user_data = file("${path.module}/scripts/network-init.sh")
|
||||
|
||||
# Configure persistent networking using script: WAN via heuristic public-IP
|
||||
# detection, private interfaces matched by their expected CIDR.
|
||||
user_data = templatefile("${path.module}/scripts/network-init.sh.tpl", {
|
||||
private_interfaces = [
|
||||
for role in local.private_roles : {
|
||||
name = "eth${index(local.private_roles, role) + 1}"
|
||||
cidr = local.router_private_subnets["router${count.index + 1}-${role}"].cidr
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# WAN: dynamically created port
|
||||
network {
|
||||
uuid = data.vkcs_networking_network.extnet.id
|
||||
}
|
||||
|
||||
# LAN: pre-created port
|
||||
network {
|
||||
port = vkcs_networking_port.lan_port1.id
|
||||
}
|
||||
|
||||
block_device {
|
||||
uuid = data.vkcs_images_image.ubuntu24.id
|
||||
source_type = "image"
|
||||
volume_size = 20
|
||||
boot_index = 0
|
||||
destination_type = "volume"
|
||||
volume_type = "ceph-ssd"
|
||||
delete_on_termination = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "vkcs_compute_instance" "router2" {
|
||||
name = "router2"
|
||||
image_id = data.vkcs_images_image.ubuntu24.id
|
||||
flavor_name = "STD3-4-4"
|
||||
availability_zone = "ME1"
|
||||
key_pair = var.ssh_key_name
|
||||
|
||||
security_group_ids = [
|
||||
vkcs_networking_secgroup.router_sg.id,
|
||||
"d479b4d7-55b3-4ff1-bf8d-24d826a38f11"
|
||||
]
|
||||
|
||||
config_drive = true
|
||||
|
||||
# Configure persistent networking using script
|
||||
user_data = file("${path.module}/scripts/network-init.sh")
|
||||
|
||||
# WAN: dynamically created port
|
||||
network {
|
||||
uuid = data.vkcs_networking_network.extnet.id
|
||||
}
|
||||
|
||||
# LAN: pre-created port
|
||||
network {
|
||||
port = vkcs_networking_port.lan_port2.id
|
||||
# Private: one pre-created isolated port per role
|
||||
dynamic "network" {
|
||||
for_each = local.private_roles
|
||||
content {
|
||||
port = vkcs_networking_port.router_priv_port["router${count.index + 1}-${network.value}"].id
|
||||
}
|
||||
}
|
||||
|
||||
block_device {
|
||||
|
||||
@@ -69,15 +69,15 @@ cidr_to_netmask() {
|
||||
|
||||
for ((i=0; i<4; i++)); do
|
||||
if [ "$i" -lt "$full_octets" ]; then
|
||||
netmask="${netmask}255"
|
||||
netmask="$${netmask}255"
|
||||
elif [ "$i" -eq "$full_octets" ] && [ "$remaining_bits" -gt 0 ]; then
|
||||
netmask="${netmask}${partial_octet}"
|
||||
netmask="$${netmask}$${partial_octet}"
|
||||
else
|
||||
netmask="${netmask}0"
|
||||
netmask="$${netmask}0"
|
||||
fi
|
||||
|
||||
|
||||
if [ "$i" -lt 3 ]; then
|
||||
netmask="${netmask}."
|
||||
netmask="$${netmask}."
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -91,8 +91,8 @@ netmask_to_cidr() {
|
||||
|
||||
# Use -a for array, not -o
|
||||
IFS='.' read -ra octets <<< "$netmask"
|
||||
|
||||
for octet in "${octets[@]}"; do
|
||||
|
||||
for octet in "$${octets[@]}"; do
|
||||
case $octet in
|
||||
255) cidr=$((cidr + 8)) ;;
|
||||
254) cidr=$((cidr + 7)) ;;
|
||||
@@ -207,12 +207,48 @@ has_private_ip() {
|
||||
return 1 # No private IP
|
||||
}
|
||||
|
||||
# Identify LAN and WAN interfaces with enhanced logic
|
||||
LAN_IFACE=""
|
||||
LAN_MAC=""
|
||||
LAN_IFACE_IPv4=""
|
||||
LAN_NETMASK=""
|
||||
LAN_CIDR=""
|
||||
# Convert a dotted IPv4 address to a 32-bit integer
|
||||
ip_to_int() {
|
||||
local ip="$1"
|
||||
local a b c d
|
||||
IFS='.' read -r a b c d <<< "$ip"
|
||||
echo $(( (a << 24) | (b << 16) | (c << 8) | d ))
|
||||
}
|
||||
|
||||
# Check whether IPv4 address $1 falls inside CIDR network $2 (e.g. 10.90.0.8/29)
|
||||
ip_in_cidr() {
|
||||
local ip="$1"
|
||||
local cidr="$2"
|
||||
local net="$${cidr%/*}"
|
||||
local prefix="$${cidr#*/}"
|
||||
local ip_int net_int mask
|
||||
|
||||
ip_int=$(ip_to_int "$ip")
|
||||
net_int=$(ip_to_int "$net")
|
||||
|
||||
if [ "$prefix" -eq 0 ]; then
|
||||
mask=0
|
||||
else
|
||||
mask=$(( (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF ))
|
||||
fi
|
||||
|
||||
[ $(( ip_int & mask )) -eq $(( net_int & mask )) ]
|
||||
}
|
||||
|
||||
# Expected private interfaces, injected by Terraform: one CIDR + target
|
||||
# ethX name per entry. Populated below via a template directive.
|
||||
PRIV_TARGETS=()
|
||||
%{ for pi in private_interfaces ~}
|
||||
PRIV_TARGETS+=("${pi.cidr}|${pi.name}")
|
||||
%{ endfor ~}
|
||||
|
||||
# Identify private and WAN interfaces
|
||||
declare -A PRIV_IFACE # target name -> interface
|
||||
declare -A PRIV_MAC # target name -> MAC
|
||||
declare -A PRIV_IPv4 # target name -> IP
|
||||
declare -A PRIV_NETMASK # target name -> netmask
|
||||
declare -A PRIV_CIDR # target name -> CIDR prefix length
|
||||
MATCHED_PRIV_IFACES=""
|
||||
WAN_IFACE=""
|
||||
WAN_MAC=""
|
||||
WAN_IFACE_IPv4=""
|
||||
@@ -220,33 +256,45 @@ WAN_NETMASK=""
|
||||
WAN_CIDR=""
|
||||
WAN_GW_IPv4=""
|
||||
|
||||
# First pass: Look for interfaces with private IPs (LAN candidates)
|
||||
# First pass: match each interface with a private IP against the expected
|
||||
# private target CIDRs (each router interface lives in its own unique
|
||||
# micro-subnet, so matching by CIDR membership is unambiguous).
|
||||
for iface in $(ls /sys/class/net/ | grep -v lo); do
|
||||
if has_private_ip "$iface"; then
|
||||
if [ -z "$LAN_IFACE" ]; then
|
||||
LAN_IFACE="$iface"
|
||||
LAN_MAC=$(cat "/sys/class/net/$iface/address" 2>/dev/null)
|
||||
|
||||
# Get IP, netmask, and CIDR
|
||||
lan_ip_netmask_cidr=$(get_private_ip_netmask_cidr "$iface")
|
||||
if [ -n "$lan_ip_netmask_cidr" ]; then
|
||||
LAN_IFACE_IPv4=$(echo "$lan_ip_netmask_cidr" | cut -d',' -f1)
|
||||
LAN_NETMASK=$(echo "$lan_ip_netmask_cidr" | cut -d',' -f2)
|
||||
LAN_CIDR=$(echo "$lan_ip_netmask_cidr" | cut -d',' -f3)
|
||||
priv_ip_netmask_cidr=$(get_private_ip_netmask_cidr "$iface")
|
||||
[ -z "$priv_ip_netmask_cidr" ] && continue
|
||||
priv_ip=$(echo "$priv_ip_netmask_cidr" | cut -d',' -f1)
|
||||
|
||||
for target in "$${PRIV_TARGETS[@]}"; do
|
||||
target_cidr="$${target%%|*}"
|
||||
target_name="$${target##*|}"
|
||||
|
||||
if ip_in_cidr "$priv_ip" "$target_cidr"; then
|
||||
if [ -n "$${PRIV_IFACE[$target_name]:-}" ]; then
|
||||
log "Multiple interfaces matched target $target_name ($target_cidr): $${PRIV_IFACE[$target_name]} and $iface"
|
||||
continue
|
||||
fi
|
||||
|
||||
PRIV_IFACE[$target_name]="$iface"
|
||||
PRIV_MAC[$target_name]=$(cat "/sys/class/net/$iface/address" 2>/dev/null)
|
||||
PRIV_IPv4[$target_name]=$priv_ip
|
||||
PRIV_NETMASK[$target_name]=$(echo "$priv_ip_netmask_cidr" | cut -d',' -f2)
|
||||
PRIV_CIDR[$target_name]=$(echo "$priv_ip_netmask_cidr" | cut -d',' -f3)
|
||||
MATCHED_PRIV_IFACES="$MATCHED_PRIV_IFACES $iface"
|
||||
|
||||
log "Identified private interface $target_name: $iface (MAC: $${PRIV_MAC[$target_name]}) with IP: $priv_ip, CIDR: /$${PRIV_CIDR[$target_name]} (target $target_cidr)"
|
||||
fi
|
||||
|
||||
log "Identified LAN interface: $iface (MAC: $LAN_MAC) with private IP: $LAN_IFACE_IPv4, Netmask: $LAN_NETMASK, CIDR: /$LAN_CIDR"
|
||||
else
|
||||
log "Multiple LAN interface candidates found: $LAN_IFACE and $iface"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Second pass: Look for WAN interface
|
||||
for iface in $(ls /sys/class/net/ | grep -v lo); do
|
||||
# Skip if this is already identified as LAN
|
||||
[ "$iface" = "$LAN_IFACE" ] && continue
|
||||
|
||||
# Skip interfaces already matched to a private target
|
||||
case " $MATCHED_PRIV_IFACES " in
|
||||
*" $iface "*) continue ;;
|
||||
esac
|
||||
|
||||
ip_addrs=$(ip addr show "$iface" 2>/dev/null | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}')
|
||||
|
||||
if [ -n "$ip_addrs" ]; then
|
||||
@@ -286,33 +334,38 @@ for iface in $(ls /sys/class/net/ | grep -v lo); do
|
||||
fi
|
||||
done
|
||||
|
||||
# If no WAN found but we have LAN, pick first non-LAN interface
|
||||
if [ -z "$WAN_IFACE" ] && [ -n "$LAN_IFACE" ]; then
|
||||
# If no WAN found but we matched at least one private interface, pick the
|
||||
# first still-unmatched interface as a fallback WAN candidate
|
||||
if [ -z "$WAN_IFACE" ] && [ -n "$MATCHED_PRIV_IFACES" ]; then
|
||||
for iface in $(ls /sys/class/net/ | grep -v lo); do
|
||||
if [ "$iface" != "$LAN_IFACE" ]; then
|
||||
WAN_IFACE="$iface"
|
||||
WAN_MAC=$(cat "/sys/class/net/$iface/address" 2>/dev/null)
|
||||
WAN_GW_IPv4=$(get_interface_gateway "$iface")
|
||||
log "Assumed WAN interface: $iface (MAC: $WAN_MAC) - default selection, Gateway: $WAN_GW_IPv4"
|
||||
break
|
||||
fi
|
||||
case " $MATCHED_PRIV_IFACES " in
|
||||
*" $iface "*) continue ;;
|
||||
esac
|
||||
WAN_IFACE="$iface"
|
||||
WAN_MAC=$(cat "/sys/class/net/$iface/address" 2>/dev/null)
|
||||
WAN_GW_IPv4=$(get_interface_gateway "$iface")
|
||||
log "Assumed WAN interface: $iface (MAC: $WAN_MAC) - default selection, Gateway: $WAN_GW_IPv4"
|
||||
break
|
||||
done
|
||||
fi
|
||||
|
||||
# Final assignment and logging
|
||||
log "Final interface assignment:"
|
||||
log " LAN Interface: $LAN_IFACE (MAC: $LAN_MAC)"
|
||||
|
||||
if [ -n "$LAN_IFACE_IPv4" ]; then
|
||||
log " LAN IPv4: $LAN_IFACE_IPv4"
|
||||
log " LAN Netmask: $LAN_NETMASK"
|
||||
log " LAN CIDR: /$LAN_CIDR"
|
||||
else
|
||||
log " LAN IPv4: Not assigned"
|
||||
LAN_IFACE_IPv4="" # Ensure it's empty if no IP found
|
||||
LAN_NETMASK="" # Ensure netmask is also empty
|
||||
LAN_CIDR="" # Ensure CIDR is also empty
|
||||
fi
|
||||
MISSING_PRIV_TARGETS=""
|
||||
for target in "$${PRIV_TARGETS[@]}"; do
|
||||
target_cidr="$${target%%|*}"
|
||||
target_name="$${target##*|}"
|
||||
|
||||
if [ -n "$${PRIV_IFACE[$target_name]:-}" ]; then
|
||||
log " $target_name Interface: $${PRIV_IFACE[$target_name]} (MAC: $${PRIV_MAC[$target_name]})"
|
||||
log " $target_name IPv4: $${PRIV_IPv4[$target_name]}"
|
||||
log " $target_name CIDR: /$${PRIV_CIDR[$target_name]} (expected network: $target_cidr)"
|
||||
else
|
||||
log " $target_name Interface: Not detected (expected network: $target_cidr)"
|
||||
MISSING_PRIV_TARGETS="$MISSING_PRIV_TARGETS $target_name"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$WAN_IFACE" ]; then
|
||||
# Only set WAN IP, Netmask, CIDR and Gateway if WAN interface is detected
|
||||
@@ -347,16 +400,14 @@ else
|
||||
fi
|
||||
|
||||
# Validate that we have the required information before proceeding
|
||||
if [ -z "$WAN_MAC" ] || [ -z "$WAN_IFACE_IPv4" ] || [ -z "$WAN_CIDR" ] || [ -z "$WAN_GW_IPv4" ] || [ -z "$LAN_MAC" ] || [ -z "$LAN_IFACE_IPv4" ] || [ -z "$LAN_CIDR" ]; then
|
||||
if [ -z "$WAN_MAC" ] || [ -z "$WAN_IFACE_IPv4" ] || [ -z "$WAN_CIDR" ] || [ -z "$WAN_GW_IPv4" ] || [ -n "$MISSING_PRIV_TARGETS" ]; then
|
||||
log "ERROR: Required network information is missing. Cannot proceed with network configuration."
|
||||
log "Missing information:"
|
||||
[ -z "$WAN_MAC" ] && log " - WAN MAC address"
|
||||
[ -z "$WAN_IFACE_IPv4" ] && log " - WAN IP address"
|
||||
[ -z "$WAN_CIDR" ] && log " - WAN CIDR"
|
||||
[ -z "$WAN_GW_IPv4" ] && log " - WAN Gateway"
|
||||
[ -z "$LAN_MAC" ] && log " - LAN MAC address"
|
||||
[ -z "$LAN_IFACE_IPv4" ] && log " - LAN IP address"
|
||||
[ -z "$LAN_CIDR" ] && log " - LAN CIDR"
|
||||
[ -n "$MISSING_PRIV_TARGETS" ] && log " - Unmatched private interfaces:$MISSING_PRIV_TARGETS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -375,7 +426,9 @@ EOF
|
||||
|
||||
log "Creating network config file"
|
||||
|
||||
# Creating netplan config with proper validation
|
||||
# Creating netplan config with proper validation: WAN interface first, then
|
||||
# one ethernets entry per matched private interface (no default route on
|
||||
# private interfaces - each is an isolated point-to-point micro-subnet).
|
||||
cat > /etc/netplan/50-cloud-init.yaml << EOF
|
||||
network:
|
||||
version: 2
|
||||
@@ -392,14 +445,24 @@ network:
|
||||
via: $WAN_GW_IPv4
|
||||
nameservers:
|
||||
addresses: [8.8.8.8, 1.1.1.1]
|
||||
lan-iface:
|
||||
EOF
|
||||
|
||||
for target in "$${PRIV_TARGETS[@]}"; do
|
||||
target_name="$${target##*|}"
|
||||
priv_mac="$${PRIV_MAC[$target_name]}"
|
||||
priv_ip4="$${PRIV_IPv4[$target_name]}"
|
||||
priv_cidr="$${PRIV_CIDR[$target_name]}"
|
||||
|
||||
cat >> /etc/netplan/50-cloud-init.yaml << EOF
|
||||
$target_name-iface:
|
||||
match:
|
||||
macaddress: "$LAN_MAC"
|
||||
set-name: eth1
|
||||
macaddress: "$priv_mac"
|
||||
set-name: $target_name
|
||||
dhcp4: false
|
||||
addresses:
|
||||
- $LAN_IFACE_IPv4/$LAN_CIDR
|
||||
- $priv_ip4/$priv_cidr
|
||||
EOF
|
||||
done
|
||||
|
||||
log "Network config file has been created"
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
username = "user@domain.local"
|
||||
password = "DemoUserPassw"
|
||||
project_id = "XXXXXd424998422XXXXXf13ac9XXXXX"
|
||||
ssh_key_name = "AdminSSH"
|
||||
ssh_key_name = "AdminSSH"
|
||||
|
||||
# Adaptive router VM count and per-router private interface sizing.
|
||||
# All have defaults (see variables.tf) - uncomment to override.
|
||||
# router_count = 3
|
||||
# private_supernet = "10.90.0.0/16"
|
||||
# private_subnet_prefix_length = 29
|
||||
# router_availability_zones = ["ME1"]
|
||||
# private_interface_count = 3
|
||||
@@ -0,0 +1,5 @@
|
||||
# Local, offline test tooling for terraform/ delivery integrity checks.
|
||||
# Install into the project-root venv: venv/bin/pip install -r terraform/tests/requirements.txt
|
||||
pytest==9.1.1
|
||||
python-hcl2==8.1.3
|
||||
checkov==3.3.16
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# Sets up a fully local, offline-capable Terraform toolchain for this repo:
|
||||
# - a Python venv at <repo>/venv (also used for the pytest suite)
|
||||
# - the terraform CLI, downloaded (with checksum verification) from a
|
||||
# region-unrestricted HashiCorp releases mirror
|
||||
# - the vkcs provider binary, downloaded (with checksum verification)
|
||||
# directly from its GitHub releases (bypasses the HashiCorp provider
|
||||
# registry, which is region-blocked in some environments), installed as
|
||||
# a local filesystem-mirror provider
|
||||
# - a project-local CLI config (venv/terraform.d/cli-config.tfrc) that
|
||||
# points `terraform init` at that filesystem mirror instead of the
|
||||
# network registry
|
||||
#
|
||||
# Nothing here talks to any cloud API or touches real infrastructure -
|
||||
# `terraform init`/`validate` only need the provider's static schema.
|
||||
#
|
||||
# Usage: terraform/tests/setup-local-terraform.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
VENV_DIR="$REPO_ROOT/venv"
|
||||
TF_VERSION="1.16.1"
|
||||
TF_RELEASES_MIRROR="https://hashicorp-releases.mcs.mail.ru"
|
||||
VKCS_PROVIDER_VERSION="0.17.2"
|
||||
VKCS_PROVIDER_NAMESPACE="vk-cs"
|
||||
MIRROR_BASE="$VENV_DIR/terraform.d/plugins"
|
||||
CLI_CONFIG="$VENV_DIR/terraform.d/cli-config.tfrc"
|
||||
|
||||
echo "==> Python venv at $VENV_DIR"
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
"$VENV_DIR/bin/pip" install -q --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install -q -r "$REPO_ROOT/terraform/tests/requirements.txt"
|
||||
|
||||
echo "==> terraform $TF_VERSION CLI at $VENV_DIR/bin/terraform"
|
||||
if [ ! -x "$VENV_DIR/bin/terraform" ]; then
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
zip_name="terraform_${TF_VERSION}_linux_amd64.zip"
|
||||
curl -sL -o "$tmp/$zip_name" "$TF_RELEASES_MIRROR/terraform/$TF_VERSION/$zip_name"
|
||||
curl -sL -o "$tmp/SHA256SUMS" "$TF_RELEASES_MIRROR/terraform/$TF_VERSION/terraform_${TF_VERSION}_SHA256SUMS"
|
||||
(cd "$tmp" && grep "$zip_name\$" SHA256SUMS | sha256sum -c -)
|
||||
unzip -o -q "$tmp/$zip_name" -d "$VENV_DIR/bin" terraform
|
||||
chmod +x "$VENV_DIR/bin/terraform"
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
echo "==> vkcs provider $VKCS_PROVIDER_VERSION from GitHub releases (bypasses the region-blocked HashiCorp registry)"
|
||||
PROVIDER_DIR="$MIRROR_BASE/registry.terraform.io/$VKCS_PROVIDER_NAMESPACE/vkcs/$VKCS_PROVIDER_VERSION/linux_amd64"
|
||||
if [ ! -d "$PROVIDER_DIR" ]; then
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
zip_name="terraform-provider-vkcs_${VKCS_PROVIDER_VERSION}_linux_amd64.zip"
|
||||
base_url="https://github.com/$VKCS_PROVIDER_NAMESPACE/terraform-provider-vkcs/releases/download/v${VKCS_PROVIDER_VERSION}"
|
||||
curl -sL -o "$tmp/$zip_name" "$base_url/$zip_name"
|
||||
curl -sL -o "$tmp/SHA256SUMS" "$base_url/terraform-provider-vkcs_${VKCS_PROVIDER_VERSION}_SHA256SUMS"
|
||||
(cd "$tmp" && grep "$zip_name\$" SHA256SUMS | sha256sum -c -)
|
||||
mkdir -p "$PROVIDER_DIR"
|
||||
unzip -o -q "$tmp/$zip_name" -d "$PROVIDER_DIR"
|
||||
chmod +x "$PROVIDER_DIR"/terraform-provider-vkcs*
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
echo "==> CLI config at $CLI_CONFIG"
|
||||
mkdir -p "$(dirname "$CLI_CONFIG")"
|
||||
cat > "$CLI_CONFIG" <<EOF
|
||||
provider_installation {
|
||||
filesystem_mirror {
|
||||
path = "$MIRROR_BASE"
|
||||
include = ["registry.terraform.io/$VKCS_PROVIDER_NAMESPACE/vkcs"]
|
||||
}
|
||||
direct {
|
||||
exclude = ["registry.terraform.io/$VKCS_PROVIDER_NAMESPACE/vkcs"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "==> Done. To use:"
|
||||
echo " export TF_CLI_CONFIG_FILE=$CLI_CONFIG"
|
||||
echo " $VENV_DIR/bin/terraform -chdir=$REPO_ROOT/terraform init -backend=false"
|
||||
echo " $VENV_DIR/bin/terraform -chdir=$REPO_ROOT/terraform validate"
|
||||
echo " or simply: $VENV_DIR/bin/pytest $REPO_ROOT/terraform/tests -v"
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Local integrity tests for the terraform/ delivery.
|
||||
|
||||
None of these tests run terraform plan/apply or call any cloud API - no
|
||||
credentials and no network access to VK Cloud itself are required or used.
|
||||
`terraform init`/`validate` do run for real, but against a project-local
|
||||
filesystem-mirror copy of the vkcs provider binary (see
|
||||
setup-local-terraform.sh), so they only need the provider's static schema,
|
||||
never a live cloud endpoint. Checks performed:
|
||||
|
||||
* the expected files exist,
|
||||
* terraform fmt reports the tree as already formatted,
|
||||
* the .tf files parse as valid HCL,
|
||||
* `terraform init` + `terraform validate` succeed against the real vkcs
|
||||
provider schema (via the local filesystem mirror - skipped if that
|
||||
mirror hasn't been set up),
|
||||
* router VM count and private-interface count are wired to variables
|
||||
(not hardcoded), and those variables aren't shadowed by terraform.tfvars,
|
||||
* the per-router/per-interface CIDR carving never produces overlapping
|
||||
subnets, for a range of router_count / private_interface_count values,
|
||||
* the post-install script template only contains the intended Terraform
|
||||
interpolations and renders to syntactically valid bash.
|
||||
|
||||
Run via: venv/bin/pytest terraform/tests -v
|
||||
(first run terraform/tests/setup-local-terraform.sh once to provision the
|
||||
local terraform binary + vkcs provider mirror used by the init/validate test)
|
||||
"""
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import hcl2
|
||||
import pytest
|
||||
|
||||
TERRAFORM_DIR = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = TERRAFORM_DIR.parent
|
||||
TERRAFORM_BIN = (
|
||||
str(REPO_ROOT / "venv" / "bin" / "terraform")
|
||||
if (REPO_ROOT / "venv" / "bin" / "terraform").is_file()
|
||||
else shutil.which("terraform")
|
||||
)
|
||||
CLI_CONFIG_FILE = REPO_ROOT / "venv" / "terraform.d" / "cli-config.tfrc"
|
||||
CHECKOV_BIN = (
|
||||
str(REPO_ROOT / "venv" / "bin" / "checkov")
|
||||
if (REPO_ROOT / "venv" / "bin" / "checkov").is_file()
|
||||
else shutil.which("checkov")
|
||||
)
|
||||
|
||||
|
||||
def load_tf(relpath):
|
||||
with open(TERRAFORM_DIR / relpath) as f:
|
||||
return hcl2.load(f)
|
||||
|
||||
|
||||
def find_resources(doc, rtype):
|
||||
"""Yield (name, attrs) for every resource of a given type in a parsed doc."""
|
||||
for block in doc.get("resource", []):
|
||||
if rtype in block:
|
||||
yield from block[rtype].items()
|
||||
|
||||
|
||||
def find_variable(doc, name):
|
||||
for block in doc.get("variable", []):
|
||||
if name in block:
|
||||
return block[name]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delivery layout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUIRED_FILES = [
|
||||
"main.tf",
|
||||
"variables.tf",
|
||||
"terraform.tfvars",
|
||||
"scripts/network-init.sh.tpl",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relpath", REQUIRED_FILES)
|
||||
def test_required_file_exists(relpath):
|
||||
assert (TERRAFORM_DIR / relpath).is_file(), f"missing terraform/{relpath}"
|
||||
|
||||
|
||||
def test_legacy_post_install_script_removed():
|
||||
assert not (TERRAFORM_DIR / "scripts" / "network-init.sh").exists(), (
|
||||
"old non-templated network-init.sh should have been replaced by "
|
||||
"network-init.sh.tpl"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# terraform fmt - the one check here that actually shells out to the
|
||||
# terraform binary itself ("средствами terraform"); everything else below
|
||||
# is local HCL parsing / pure-Python re-implementation of the expressions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_terraform_fmt_clean():
|
||||
assert TERRAFORM_BIN, "no terraform binary found (checked venv/bin and PATH)"
|
||||
result = subprocess.run(
|
||||
[TERRAFORM_BIN, "fmt", "-check", "-diff", "-recursive", str(TERRAFORM_DIR)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"terraform fmt found unformatted files:\n{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"router_count,private_interface_count",
|
||||
[
|
||||
(None, None), # defaults: 2, 2
|
||||
(1, 1),
|
||||
(4, 3),
|
||||
],
|
||||
)
|
||||
def test_terraform_init_and_validate_against_real_provider_schema(
|
||||
router_count, private_interface_count
|
||||
):
|
||||
"""Real `terraform init` + `terraform validate` against the actual vkcs
|
||||
provider, using the project-local filesystem-mirror copy of the
|
||||
provider binary (downloaded from its GitHub releases by
|
||||
setup-local-terraform.sh, bypassing the region-blocked HashiCorp
|
||||
registry). Runs in a throwaway temp copy of terraform/ so it never
|
||||
leaves .terraform/ or .terraform.lock.hcl behind in the real delivery.
|
||||
No credentials are supplied and no cloud API is contacted - validate
|
||||
only needs the provider's static schema to type-check the config.
|
||||
|
||||
Parametrized over router_count/private_interface_count (set via
|
||||
TF_VAR_*, exactly how horizontal scaling is meant to be driven) to
|
||||
prove the delivery actually validates at other scales, not just the
|
||||
defaults.
|
||||
"""
|
||||
assert TERRAFORM_BIN, "no terraform binary found (checked venv/bin and PATH)"
|
||||
if not CLI_CONFIG_FILE.is_file():
|
||||
pytest.skip(
|
||||
"local vkcs provider mirror not set up - run "
|
||||
"terraform/tests/setup-local-terraform.sh once"
|
||||
)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["TF_CLI_CONFIG_FILE"] = str(CLI_CONFIG_FILE)
|
||||
if router_count is not None:
|
||||
env["TF_VAR_router_count"] = str(router_count)
|
||||
if private_interface_count is not None:
|
||||
env["TF_VAR_private_interface_count"] = str(private_interface_count)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
for item in TERRAFORM_DIR.iterdir():
|
||||
if item.name == "tests":
|
||||
continue
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, tmp_path / item.name)
|
||||
else:
|
||||
shutil.copy2(item, tmp_path / item.name)
|
||||
|
||||
init = subprocess.run(
|
||||
[TERRAFORM_BIN, f"-chdir={tmp_path}", "init", "-backend=false", "-input=false"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert init.returncode == 0, f"terraform init failed:\n{init.stdout}\n{init.stderr}"
|
||||
|
||||
validate = subprocess.run(
|
||||
[TERRAFORM_BIN, f"-chdir={tmp_path}", "validate"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert validate.returncode == 0, (
|
||||
f"terraform validate failed:\n{validate.stdout}\n{validate.stderr}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HCL parses cleanly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relpath", ["main.tf", "variables.tf", "images.tf"])
|
||||
def test_hcl_file_parses(relpath):
|
||||
# images.tf is intentionally fully commented out (disabled helper data
|
||||
# source) - it must still parse cleanly, just possibly to an empty doc.
|
||||
doc = load_tf(relpath)
|
||||
assert isinstance(doc, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# variables.tf: the scaling knobs exist with sane defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_router_count_variable():
|
||||
v = find_variable(load_tf("variables.tf"), "router_count")
|
||||
assert v is not None, "variable router_count is missing"
|
||||
assert v["type"] == ["${number}"]
|
||||
assert v["default"] == [2]
|
||||
|
||||
|
||||
def test_private_interface_count_variable():
|
||||
v = find_variable(load_tf("variables.tf"), "private_interface_count")
|
||||
assert v is not None, "variable private_interface_count is missing"
|
||||
assert v["type"] == ["${number}"]
|
||||
assert v["default"] == [2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["router_count", "private_interface_count"])
|
||||
def test_scaling_variable_not_pinned_in_tfvars(name):
|
||||
"""TF_VAR_<name> env-var overrides only take effect if terraform.tfvars
|
||||
doesn't set an active (non-comment) value for the same variable."""
|
||||
tfvars_text = (TERRAFORM_DIR / "terraform.tfvars").read_text()
|
||||
active_lines = [
|
||||
line for line in tfvars_text.splitlines() if not line.strip().startswith("#")
|
||||
]
|
||||
assert not any(re.match(rf"^\s*{name}\s*=", line) for line in active_lines), (
|
||||
f"{name} must not be set in terraform.tfvars, or the "
|
||||
f"TF_VAR_{name} environment variable would be shadowed"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.tf: router VM count and NIC count are wired to those variables, not
|
||||
# hardcoded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_router_resource_uses_count_variable():
|
||||
main = load_tf("main.tf")
|
||||
instances = dict(find_resources(main, "vkcs_compute_instance"))
|
||||
assert "router" in instances, "expected a single vkcs_compute_instance.router resource"
|
||||
assert instances["router"]["count"] == ["${var.router_count}"]
|
||||
|
||||
|
||||
def test_no_legacy_hardcoded_router_resources():
|
||||
main = load_tf("main.tf")
|
||||
instance_names = {name for name, _ in find_resources(main, "vkcs_compute_instance")}
|
||||
assert instance_names.isdisjoint({"router1", "router2"}), (
|
||||
"found legacy hardcoded router1/router2 instances instead of the "
|
||||
"count-based router resource"
|
||||
)
|
||||
port_names = {name for name, _ in find_resources(main, "vkcs_networking_port")}
|
||||
assert port_names.isdisjoint({"lan_port1", "lan_port2"}), (
|
||||
"found legacy hardcoded lan_port1/lan_port2 instead of the "
|
||||
"for_each-based router_priv_port"
|
||||
)
|
||||
|
||||
|
||||
def test_private_roles_local_driven_by_variable():
|
||||
main = load_tf("main.tf")
|
||||
locals_block = main["locals"][0]
|
||||
assert locals_block["private_roles"] == [
|
||||
'${[for i in range(var.private_interface_count) : "priv${i + 1}"]}'
|
||||
], "locals.private_roles must be generated from var.private_interface_count"
|
||||
|
||||
|
||||
def test_router_network_blocks_scale_with_private_roles():
|
||||
main = load_tf("main.tf")
|
||||
router = dict(find_resources(main, "vkcs_compute_instance"))["router"]
|
||||
dynamic_network = router["dynamic"][0]["network"]
|
||||
assert dynamic_network["for_each"] == ["${local.private_roles}"], (
|
||||
"the dynamic private network blocks must iterate local.private_roles "
|
||||
"so the NIC count scales with private_interface_count"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CIDR carving: router_index/role_index -> netnum must never collide, for a
|
||||
# range of router_count / private_interface_count combinations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cidrsubnet(supernet, newbits, netnum):
|
||||
"""Pure-Python re-implementation of Terraform's cidrsubnet() built-in."""
|
||||
net = ipaddress.ip_network(supernet)
|
||||
subnets = list(net.subnets(new_prefix=net.prefixlen + newbits))
|
||||
return subnets[netnum]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"router_count,private_interface_count",
|
||||
[(1, 1), (2, 2), (3, 2), (4, 3), (8, 4), (1, 6)],
|
||||
)
|
||||
def test_private_subnet_carving_has_no_collisions(router_count, private_interface_count):
|
||||
supernet = "10.90.0.0/16"
|
||||
prefix_length = 29
|
||||
newbits = prefix_length - ipaddress.ip_network(supernet).prefixlen
|
||||
|
||||
subnets = [
|
||||
cidrsubnet(
|
||||
supernet, newbits, router_index * private_interface_count + role_index
|
||||
)
|
||||
for router_index in range(router_count)
|
||||
for role_index in range(private_interface_count)
|
||||
]
|
||||
|
||||
assert len(subnets) == len(set(subnets)), "duplicate per-router private subnets"
|
||||
for i, a in enumerate(subnets):
|
||||
for b in subnets[i + 1 :]:
|
||||
assert not a.overlaps(b), f"{a} overlaps {b}"
|
||||
|
||||
|
||||
def test_private_subnet_pool_capacity_is_generous():
|
||||
supernet = ipaddress.ip_network("10.90.0.0/16")
|
||||
prefix_length = 29
|
||||
capacity = 2 ** (prefix_length - supernet.prefixlen)
|
||||
assert capacity >= 1000, "default private_supernet/prefix combo has too little headroom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# network-init.sh.tpl: only the intended Terraform interpolations remain
|
||||
# un-escaped, and the rendered result is syntactically valid bash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _script_template_text():
|
||||
return (TERRAFORM_DIR / "scripts" / "network-init.sh.tpl").read_text()
|
||||
|
||||
|
||||
def test_network_init_template_only_intended_interpolations():
|
||||
text = _script_template_text()
|
||||
# A real Terraform interpolation is "${" not preceded by another "$".
|
||||
# Pre-existing bash brace-expansions must be escaped as "$${".
|
||||
real_interpolations = re.findall(r"(?<!\$)\$\{([^}]*)\}", text)
|
||||
assert real_interpolations, "expected at least the pi.cidr/pi.name interpolations"
|
||||
for expr in real_interpolations:
|
||||
assert expr.startswith("pi."), (
|
||||
f"unexpected un-escaped Terraform interpolation in the script "
|
||||
f"template: ${{{expr}}} (bash brace-expansions must use $${{...}})"
|
||||
)
|
||||
|
||||
|
||||
def test_network_init_template_has_for_directive():
|
||||
text = _script_template_text()
|
||||
assert "%{ for pi in private_interfaces" in text
|
||||
assert "%{ endfor" in text
|
||||
|
||||
|
||||
def test_network_init_template_renders_to_valid_bash():
|
||||
"""Simulate templatefile() rendering (unescape $${ -> ${, substitute a
|
||||
fake private_interfaces list for the %{ for } directive) and check the
|
||||
result is syntactically valid bash. Fully offline, no cloud calls."""
|
||||
text = _script_template_text()
|
||||
rendered = text.replace("$${", "${")
|
||||
|
||||
for_block = re.compile(
|
||||
r"%\{ for pi in private_interfaces ~\}\n.*?\n%\{ endfor ~\}\n", re.S
|
||||
)
|
||||
assert for_block.search(rendered), "template for-directive not found for rendering"
|
||||
rendered = for_block.sub(
|
||||
'PRIV_TARGETS+=("10.90.0.8/29|eth1")\n'
|
||||
'PRIV_TARGETS+=("10.90.0.16/29|eth2")\n'
|
||||
'PRIV_TARGETS+=("10.90.0.24/29|eth3")\n',
|
||||
rendered,
|
||||
)
|
||||
|
||||
assert "%{" not in rendered
|
||||
assert "${pi." not in rendered
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-n"], input=rendered, capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0, f"rendered script has a bash syntax error:\n{result.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checkov smoke check (documented limitation: checkov ships no policies for
|
||||
# the vkcs provider, so this only guards against the tool itself breaking -
|
||||
# it intentionally does not assert resource_count > 0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_checkov_runs_offline_without_error():
|
||||
if CHECKOV_BIN is None:
|
||||
pytest.skip("checkov not installed in this environment")
|
||||
result = subprocess.run(
|
||||
[CHECKOV_BIN, "-d", str(TERRAFORM_DIR), "--framework", "terraform",
|
||||
"--skip-download", "--compact", "-o", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode in (0, 1), (
|
||||
f"checkov exited unexpectedly ({result.returncode}):\n{result.stderr}"
|
||||
)
|
||||
@@ -24,3 +24,48 @@ variable "ssh_key_name" {
|
||||
description = "Name of SSH key pair in VK Cloud"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "router_count" {
|
||||
description = "Number of IaaS Router VMs to provision"
|
||||
type = number
|
||||
default = 2
|
||||
|
||||
validation {
|
||||
condition = var.router_count >= 1
|
||||
error_message = "router_count must be at least 1."
|
||||
}
|
||||
}
|
||||
|
||||
variable "private_supernet" {
|
||||
description = "Address pool from which each router's private per-interface subnets are carved (must not overlap router-lan-subnet 10.200.10.0/24)"
|
||||
type = string
|
||||
default = "10.90.0.0/16"
|
||||
}
|
||||
|
||||
variable "private_subnet_prefix_length" {
|
||||
description = "Prefix length of each router's private interface subnet (/29 or /28)"
|
||||
type = number
|
||||
default = 29
|
||||
|
||||
validation {
|
||||
condition = contains([28, 29], var.private_subnet_prefix_length)
|
||||
error_message = "private_subnet_prefix_length must be 28 or 29."
|
||||
}
|
||||
}
|
||||
|
||||
variable "router_availability_zones" {
|
||||
description = "Availability zones to spread router VMs across (cycled via count.index)"
|
||||
type = list(string)
|
||||
default = ["ME1"]
|
||||
}
|
||||
|
||||
variable "private_interface_count" {
|
||||
description = "Number of isolated private interfaces per router VM (in addition to the single public/WAN interface)"
|
||||
type = number
|
||||
default = 2
|
||||
|
||||
validation {
|
||||
condition = var.private_interface_count >= 1
|
||||
error_message = "private_interface_count must be at least 1."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
vkcs = {
|
||||
source = "vk-cs/vkcs"
|
||||
version = "~> 0.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user