From 6563d755d82705048e30f7afcb1fc512452e79a7 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Tue, 21 Jul 2026 15:05:52 +0300 Subject: [PATCH] build v_1.0.1 --- .gitignore | 3 + README.md | 231 ++++++ input.csv | 1 + ipsec_migrator/__init__.py | 1 + ipsec_migrator/__main__.py | 6 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 176 bytes .../__pycache__/__main__.cpython-311.pyc | Bin 0 -> 362 bytes .../__pycache__/audit.cpython-311.pyc | Bin 0 -> 38172 bytes .../__pycache__/cli.cpython-311.pyc | Bin 0 -> 8050 bytes .../__pycache__/constants.cpython-311.pyc | Bin 0 -> 4141 bytes .../__pycache__/keystone_auth.cpython-311.pyc | Bin 0 -> 7621 bytes .../__pycache__/models.cpython-311.pyc | Bin 0 -> 2785 bytes .../neutron_client.cpython-311.pyc | Bin 0 -> 7649 bytes .../__pycache__/redact.cpython-311.pyc | Bin 0 -> 1644 bytes .../__pycache__/sprut_client.cpython-311.pyc | Bin 0 -> 4688 bytes .../__pycache__/sync.cpython-311.pyc | Bin 0 -> 18128 bytes .../__pycache__/tables.cpython-311.pyc | Bin 0 -> 2302 bytes ipsec_migrator/audit.py | 763 ++++++++++++++++++ ipsec_migrator/cli.py | 153 ++++ ipsec_migrator/constants.py | 51 ++ ipsec_migrator/keystone_auth.py | 129 +++ ipsec_migrator/models.py | 53 ++ ipsec_migrator/neutron_client.py | 109 +++ ipsec_migrator/redact.py | 24 + ipsec_migrator/sprut_client.py | 75 ++ ipsec_migrator/sync.py | 332 ++++++++ ipsec_migrator/tables.py | 43 + 27 files changed, 1974 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 input.csv create mode 100644 ipsec_migrator/__init__.py create mode 100644 ipsec_migrator/__main__.py create mode 100644 ipsec_migrator/__pycache__/__init__.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/__main__.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/audit.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/cli.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/constants.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/keystone_auth.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/models.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/neutron_client.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/redact.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/sprut_client.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/sync.cpython-311.pyc create mode 100644 ipsec_migrator/__pycache__/tables.cpython-311.pyc create mode 100644 ipsec_migrator/audit.py create mode 100644 ipsec_migrator/cli.py create mode 100644 ipsec_migrator/constants.py create mode 100644 ipsec_migrator/keystone_auth.py create mode 100644 ipsec_migrator/models.py create mode 100644 ipsec_migrator/neutron_client.py create mode 100644 ipsec_migrator/redact.py create mode 100644 ipsec_migrator/sprut_client.py create mode 100644 ipsec_migrator/sync.py create mode 100644 ipsec_migrator/tables.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2a7d35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +artifacts +.claude +sources diff --git a/README.md b/README.md new file mode 100644 index 0000000..73b57aa --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# VPNaaS-миграция: Neutron → Sprut (`ipsec_migrator`) + +Инструмент для миграции VPNaaS (IPsec site-to-site) с Neutron-роутера OpenStack на «продвинутый» роутер (DC Router) во внешней SDN **Sprut** (`infra.mail.ru:9696`). + +Инструмент роутероцентричен: каждый Neutron-роутер из входного CSV сопоставлен с advanced-роутером в Sprut. Переносятся все IPsec-туннели, IKE/IPsec policy и endpoint groups, связанные с этим Neutron-роутером; объекты других роутеров того же OpenStack-проекта не затрагиваются. + +В репозитории две реализации с идентичным CLI, логикой и форматом `audit.json`: + +- **`ipsec_migrator_v2.sh`** — исходный bash-скрипт. Требует бинари `openstack`/`jq`/`curl`. +- **`ipsec_migrator/`** — Python-порт, актуальная реализация. Не использует бинарь `openstack` (ходит в Keystone/Neutron напрямую по REST) и не требует `jq`/`curl`. См. раздел [Python-порт](#python-порт-ipsec_migrator) ниже. + +## Статус проекта + +Python-порт — рекомендуемая реализация для новых миграций. История: + +1. Faithful-порт bash-скрипта на Python через subprocess-обёртку над `openstack` CLI (см. `port_summary.md`). +2. Переход с `openstack` CLI на прямые REST-вызовы к Keystone/Neutron (см. `session_2026-07-21_summary.md`). +3. Добавлено автосоздание DC Router в Sprut, когда `advanced_router_id` в CSV пуст (см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера) ниже). + +Пункт 3 прошёл боевую проверку 2026-07-21: полный цикл (`--audit-only` → ревью → `--from-audit`) выполнен против настоящего OpenStack-тенанта и Sprut — DC Router + публичный интерфейс, IKE/IPsec policy, endpoint groups, VPN service и IPsec site connection созданы успешно для одного роутера. В ходе этого прогона найдены и исправлены три бага, не проявлявшиеся в офлайн-тестах (подробности и точные фиксы — в `session_2026-07-21_summary.md` и в разделе про авто-создание ниже): + +- Задвоенный `/v2.0` в пути запроса списка сетей Sprut (`.../v2.0/v2.0/networks`) — 404 при любом не-`--audit-only` прогоне. +- Публичная сеть в реальном тенанте называется `internet` (нижний регистр), а сравнение было регистрозависимым и искало `Internet`. +- Sprut отклоняет `subnet_id`/`ip_address` при подключении интерфейса к внешней сети (`400 Bad dc_interface request`) — `subnet_id` для такого интерфейса отправлять не нужно, Sprut назначает его сам. + +## Требования (bash-скрипт `ipsec_migrator_v2.sh`) + +- `bash` ≥ 4.3 (используются ассоциативные массивы и namerefs) +- `openstack` CLI, аутентифицированный в нужном проекте (переменные окружения `OS_*` / `clouds.yaml`) +- `jq` +- `curl` +- Сетевой доступ к `https://infra.mail.ru:9696` + +Скрипт проверяет версию bash и наличие всех трёх бинарей при старте и завершается с понятной ошибкой, если что-то не так. + +Требования для Python-порта — в его собственном разделе ниже; он ничего из списка выше не требует. + +## Формат входного CSV + +``` +neutron_router_id,advanced_router_id +neutron_router_id2,advanced_router_id2 +... +``` + +Пустые строки пропускаются; строки без второй колонки — с предупреждением, но не останавливая скрипт. + +Python-порт дополнительно поддерживает пустой `advanced_router_id` (авто-создание DC Router) и необязательные 3-ю/4-ю колонки — см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера). + +## Режимы запуска + +### 1. Полный прогон (аудит + настройка Sprut) + +```bash +./ipsec_migrator_v2.sh [output.json] [--dry-run] +python3 -m ipsec_migrator [output.json] [--dry-run] +``` + +Выполняет всё последовательно: аудит конфигурации Neutron (STAGE 1) → сбор существующих объектов в Sprut (STAGE 2) → создание недостающих объектов и site connection'ов (STAGE 3–4). `output.json` необязателен, по умолчанию — `vpnaas_audit_.json` в текущей директории. + +### 2. Только аудит (без обращений к Sprut на запись) + +```bash +./ipsec_migrator_v2.sh --audit-only [output.json] +python3 -m ipsec_migrator --audit-only [output.json] +``` + +Выполняет только STAGE 1: проверяет, что Neutron- и advanced-роутеры существуют, собирает полную конфигурацию VPNaaS из Neutron и пишет её в JSON-файл. STAGE 2–4 (создание объектов в Sprut) не запускаются — скрипт завершается сразу после записи аудита. + +### 3. Dry-run по ранее собранному аудиту + +```bash +./ipsec_migrator_v2.sh --from-audit --dry-run +python3 -m ipsec_migrator --from-audit --dry-run +``` + +Пропускает повторный опрос OpenStack: все данные (IKE/IPsec policy, endpoint groups, VPN service, site connections) берутся из ранее сохранённого `audit.json`. Заново получает Keystone-токен и перепроверяет, что advanced-роутеры из аудита всё ещё существуют в Sprut (аудит мог быть сделан заранее). Дальше выполняются STAGE 2–4 в режиме симуляции: `GET`-запросы к Sprut выполняются по-настоящему (для сверки, что уже существует), но ни один объект не создаётся — вместо `POST` в лог пишется, что было бы отправлено (с маскированным PSK). + +### 4. Реальная настройка продвинутого роутера по аудиту + +```bash +./ipsec_migrator_v2.sh --from-audit +python3 -m ipsec_migrator --from-audit +``` + +То же самое, что режим 3, но без `--dry-run` — объекты реально создаются в Sprut (STAGE 2–4 выполняются по-боевому). + +`--audit-only` и `--from-audit` взаимоисключающие — совместное указание завершает скрипт с ошибкой. + +**Рекомендуемый порядок для боевого прогона:** `--audit-only` → ревью получившегося `audit.json` → `--from-audit` (без `--dry-run`). Так реальное создание объектов в Sprut — отдельный, осознанный шаг после проверки того, что именно будет создано. + +## Что делает каждый STAGE + +| Stage | Действие | +|---|---| +| **STAGE 1** (STEP 1–10) | Читает CSV, проверяет существование роутеров в OpenStack и Sprut (STEP 3; для Python-порта здесь же — авто-создание DC Router, если `advanced_router_id` пуст), собирает все IKE/IPsec policy, endpoint groups, VPN services и IPsec site connections по каждому роутеру из CSV, пишет самодостаточный JSON-аудит (атомарная запись, права `600`). | +| **STAGE 2** | Забирает текущий список объектов из Sprut API (`/vpn/ikepolicies`, `/vpn/ipsecpolicies`, `/vpn/endpoint-groups`, `/vpn/vpnservices`, `/vpn/ipsec-site-connections`) — основа для идемпотентной сверки. | +| **STAGE 3** | Сравнивает Neutron-объекты с уже существующими в Sprut (по имени / router_id / набору endpoints) и создаёт недостающие: IKE policy → IPsec policy → Endpoint Groups → VPN service. | +| **STAGE 4** | Создаёт IPsec site connections в Sprut, транслируя все связанные ID через карты соответствия из STAGE 3. Идемпотентно по полю `name`. | + +## Формат audit.json + +```json +{ + "audit_metadata": { "generated_at": "...", "input_file": "routers.csv", "stage": "STAGE1_AUDIT" }, + "subnets": { "": "" }, + "routers": [ + { + "neutron_router_id": "...", + "advanced_router_id": "...", + "pending_dc_router": { "name": "...", "description": "...", "availability_zone": "...", "flavor": "..." }, + "vpn_service": { "...": "полный объект vpnservice из Neutron API, либо null" }, + "ipsec_site_connections": [ + { + "id": "...", + "raw": { "...": "полный объект ipsec_site_connection из Neutron API" }, + "ike_policy": { "...": "полный объект ikepolicy" }, + "ipsec_policy": { "...": "полный объект ipsecpolicy" }, + "local_endpoint_group": { "id": "...", "raw": {}, "resolved_endpoints": ["10.0.0.0/24"], "already_migrated": false }, + "peer_endpoint_group": { "...": "та же структура" } + } + ] + } + ] +} +``` + +`advanced_router_id` равен `null`, а `pending_dc_router` заполнен, только когда CSV-строка не задавала advanced-роутер и он ещё не создан (Python-порт, `--audit-only`) — см. [Авто-создание целевого роутера](#авто-создание-целевого-advanced-роутера). + +Это тот же файл, что пишет режим `--audit-only` / полный прогон, и его же читает `--from-audit`. `--from-audit` проверяет `audit_metadata.stage == "STAGE1_AUDIT"` перед использованием. + +## Безопасность + +- **Файл аудита содержит PSK в открытом виде** (`ipsec_site_connections[].raw.psk` в Python-порте / `..."Pre-shared Key"` в bash-выводе). Пишется атомарно с правами `600` (только владелец). Храните и передавайте его как секрет. +- В stdout-логах PSK всегда маскируется (`redact_psk`), в т.ч. в теле запросов к Sprut и в ответах, содержащих `psk`. +- В `--dry-run` не-`GET` запросы к Sprut не отправляются — в лог пишется только предполагаемый метод/URL/тело (с маскированным PSK). `GET`-запросы (в т.ч. проверка существования advanced-роутера и поиск публичной сети для авто-создания DC Router) выполняются по-настоящему даже в `--dry-run`. +- HTTP-код ответа Sprut/Neutron всегда проверяется; не-2xx останавливает скрипт с ошибкой вместо тихого продолжения. + +## Известные ограничения + +- Токен Keystone не обновляется в течение STAGE 2–4 — для очень долгих миграций возможен `401` в середине прогона. +- Сравнение endpoint group'ов чувствительно к порядку адресов в массиве. +- Совпадающие имена IKE/IPsec policy у разных объектов в Neutron могут сломать сопоставление в Sprut. То же верно и для DC Router: авто-создание идемпотентно по имени Neutron-роутера — два разных роутера с одинаковым именем будут ошибочно сведены к одному DC Router. +- Между проверкой существования объекта (`GET`) и его созданием (`POST`) есть окно гонки — не запускайте миграцию параллельно против одного проекта. + +## Python-порт (`ipsec_migrator/`) + +Порт `ipsec_migrator_v2.sh` на Python, запуск `python3 -m ipsec_migrator `, тот же CLI и формат `audit.json`, что описаны выше. Ключевое отличие от bash-версии — способ обращения к OpenStack и Sprut: порт **не использует бинарь `openstack`** (и не требует `jq`/`curl`), а ходит в Keystone/Neutron/Sprut напрямую по REST через `requests`. + +Требования: + +- `python3`, `requests` — больше ничего не нужно. +- Аутентификация — только переменные окружения `OS_*` (password auth, + `clouds.yaml` не поддерживается): `OS_AUTH_URL`, `OS_USERNAME`, + `OS_PASSWORD`, `OS_USER_DOMAIN_NAME` (или `OS_USER_DOMAIN_ID`), + `OS_PROJECT_ID` (либо `OS_PROJECT_NAME` + `OS_PROJECT_DOMAIN_NAME`/`_ID`); + опционально `OS_REGION_NAME`, `OS_INTERFACE` (по умолчанию `public`) — + см. `test_openrc.sh` для примера. Эндпоинт Neutron резолвится из каталога + сервисов Keystone, а не хардкодится (с нормализацией хвостового `/v2.0`/`/v2` — + разные облака отдают его в каталоге по-разному). +- Списки Neutron-объектов (routers, subnets, ike/ipsec policies, endpoint groups, + vpn services, ipsec site connections) собираются с пагинацией — порт идёт по + `_links` (`rel: next`), пока не соберёт все страницы, а не только + первую. + +**Несовместимость `--from-audit`:** внутри `audit.json` вложенные объекты +(`raw`, `ike_policy`, `ipsec_policy`, endpoint group'ы) хранят имена полей +как их отдаёт Neutron API (`id`, `name`, `admin_state_up`, ...), а не +человекочитаемые заголовки колонок `openstack ... -f json` (`ID`, `Name`, +`State`, ...). Audit-файл, сгенерированный более ранней версией порта (той, +что ходила в OpenStack через `openstack` CLI), нельзя скормить в +`--from-audit` этой версии — нужно перегенерировать аудит заново. + +### Авто-создание целевого (advanced) роутера + +В `input.csv` для порта вторая колонка (`advanced_router_id`) может быть +пустой: + +``` +neutron_router_id1,advanced_router_id1 +neutron_router_id2,,MS1,standard +neutron_router_id3 +``` + +Если `advanced_router_id` пуст, порт сам создаёт DC Router в Sprut +(`POST /direct_connect/dc_routers`, `enable_snat: true`, `name`/`description` +берутся из самого Neutron-роутера) и подключает к нему публичный интерфейс +на внешнюю сеть Sprut (`POST /direct_connect/dc_interfaces`; сеть ищется по +имени `internet`/`Internet` без учёта регистра — реальное имя в проверенном +тенанте оказалось строчным `internet`). `subnet_id` для этого интерфейса не +указывается: Sprut отклоняет `subnet_id`/`ip_address` для интерфейса на +внешнюю сеть (`400 Bad dc_interface request: Specifying subnet_id or +ip_address for external network is restricted`) и назначает адрес сам. +Обе операции (DC Router, DC Interface) идемпотентны — по имени и по паре +`(dc_router_id, network_id)` соответственно — повторный прогон не создаёт +дублей. Точные пути и схемы запросов сверены с `neutron-sprut-api.json` +в корне репозитория. + +- `availability_zone` и `flavor` (обязательные поля Sprut, `flavor` — одно из + `basic`/`standard`/`advanced`) берутся из 3-й/4-й колонки CSV; если их там + нет — порт спросит интерактивно (`input()`). Для неинтерактивных запусков + (cron/CI) указывайте их в CSV заранее — иначе скрипт завершится ошибкой + вместо зависания на `stdin`. +- В режиме `--audit-only` DC Router **не создаётся** (режим по-прежнему + ничего не пишет в Sprut) — вместо этого в `audit.json` записывается + `"advanced_router_id": null` и блок `"pending_dc_router"` с именем, + описанием, AZ и flavor. Реальное создание происходит при последующем + `--from-audit` (без `--dry-run`) или в полном прогоне. +- Если в Sprut найдено несколько сетей с именем `internet`/`Internet` без + учёта регистра, используется первая с предупреждением в stderr — сознательный + упрощённый выбор (без дополнительной проверки `router:external`), см. + «Известные ограничения» выше про аналогичный риск коллизии имён. +- Реальное (не `--dry-run`) создание роутера и интерфейса — необратимое + изменение инфраструктуры Sprut, выполняется как часть обычного потока + STEP 3 (внутри STAGE 1). + +Статус: боевой цикл (`--audit-only` → `--from-audit`) успешно прогнан против +реального Sprut 2026-07-21 — детали в `session_2026-07-21_summary.md`. + +## Файлы в репозитории + +| Файл/директория | Назначение | +|---|---| +| `ipsec_migrator_v2.sh` | Исходный bash-скрипт миграции | +| `ipsec_migrator/` | Python-порт (пакет), запуск `python3 -m ipsec_migrator` | +| `input.csv` | Пример/рабочий входной CSV для текущей миграции | +| `test_openrc.sh` | Пример переменных окружения `OS_*` для аутентификации в OpenStack | +| `neutron-sprut-api.json` | OpenAPI-спека Sprut API — источник истины для путей/схем запросов (онлайн-документация `cloud.vk.com` недоступна из окружения разработки) | +| `port_summary.md` | Как и с какими оговорками бэш-скрипт был портирован на Python (2026-07-20) | +| `session_2026-07-21_summary.md` | Переход Python-порта на прямые Keystone/Neutron REST-вызовы и добавление авто-создания DC Router, включая боевую проверку и найденные баги (2026-07-21) | +| `vpnaas_audit_*.json` | Результаты прогонов (`--audit-only`/полный прогон) — содержат PSK, права `600` | diff --git a/input.csv b/input.csv new file mode 100644 index 0000000..192a942 --- /dev/null +++ b/input.csv @@ -0,0 +1 @@ +625d6d56-b2dc-4c83-b96f-e4e28af6b928,,MS1,standard diff --git a/ipsec_migrator/__init__.py b/ipsec_migrator/__init__.py new file mode 100644 index 0000000..8c0d5d5 --- /dev/null +++ b/ipsec_migrator/__init__.py @@ -0,0 +1 @@ +__version__ = "2.0.0" diff --git a/ipsec_migrator/__main__.py b/ipsec_migrator/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/ipsec_migrator/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ipsec_migrator/__pycache__/__init__.cpython-311.pyc b/ipsec_migrator/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8cb484551b2fed6757072f87cc24a205b6edb35 GIT binary patch literal 176 zcmZ3^%ge<81TPKZvc!P&V-N=h7@>^MASKfoQW&BbQW%37G?}Vcjr0uk4E!`1Z*j-R zm!%dJXXfX{$FF4g4ASvSU%x0nzeK+*J2@x6G)2FxATKeoSUget<47z( literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/__main__.cpython-311.pyc b/ipsec_migrator/__pycache__/__main__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b6cc26fde7607b47272123f0d4e440aa5b82573 GIT binary patch literal 362 zcmZ3^%ge<81ZBo?S;0X1F^B^LEKtVhC?I1xLkdF*V-7;Ln?C$ zOEwcoU6B+MLn=cSOb-K)$FvM6z8WM62FnRCr45AlM(F11gj;c${k{6gIZ?Nz*IJbCogmi>-hV_J9WD&W- UB60zSJ}@(|NC8z9aRW^S0D-z#hX4Qo literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/audit.cpython-311.pyc b/ipsec_migrator/__pycache__/audit.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b8ec450076ca92ce8c6b2509d462a875c56a634 GIT binary patch literal 38172 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 Hz#RVpuzHD6 literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/keystone_auth.cpython-311.pyc b/ipsec_migrator/__pycache__/keystone_auth.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b62e4d36674e662dd97387cc2f8f16848a12b5f GIT binary patch literal 7621 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 literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/models.cpython-311.pyc b/ipsec_migrator/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3eb2d742ec349368f2032c37bbabbb491370331e GIT binary patch literal 2785 zcmah~OHUj}5boLS<*__%gMr}09X|o>vVcjzl4B{zHbj82So=bR#Yio6kBo=nqni4vx~ls1M@L5%!Pj?9 ze|D)2p}*zehUan9GJI=`GOjonUo%V}E|J|%YMK7eo-cB6pNQ3{*I^Z3 za7XvKZqO}V@QTkwH0V`I4)b-3N<)2MP%4<`?RK5UklJe0a}p%>)=N9!81>diCY*G6 zLi7#G@xACW9C>*^>C~*xCiy23z^}idZ!+EGaKr4>BYxGexE?b$$}BY`Ko^F|lQtOeQ+`;aFDOphp8IanF)efxA7YeXuQ0b#-+$8a#2R$AD;- zO4p*^X>BHVocO+uGu&$Ee{FwpRbXR0?%ng$!DkNN_} zhp=T@blrh#P}ifZu9x9%w58sz>(48O9b1Ha<{~VM6|GY3()Ba-{&i%sHrfKsEEx;-m zhF6H|EUWF#MFA3Lwm0hTh-T?8LC){x;imod%+GOdeDD6jwIDYe=4Pw8*#Hj&7*I-x zXOAYP_OBn}U}7*;0NOi z;rK#zd?CPJ2N+OFh!7wc&pp+07kCMwf9?I`!-k8DAOgJ=C z9hwR7V1NOogm~uY3+9o+-t~iYFmf*(xmO*zr;-Dd65@MDYVEHExd&nHK{fY4r3NS^ zR0xXr_GJG&9Bz)9b)9lEJ{xNlzarpl2>FahZH+rxc}U{jN2I3WTmkKU%boaG z-4!=rD4%!lH-K7N)3jQuS;I9Xp&Mye19auA9HXJ&a{_HnwbfELv{|i&K96tJ|Nm9^ OHVyO_1D`smB>w}~t~gBq literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/neutron_client.cpython-311.pyc b/ipsec_migrator/__pycache__/neutron_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08c1dea5704410a0b834ad4dd41744830a760ec1 GIT binary patch literal 7649 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 literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/sprut_client.cpython-311.pyc b/ipsec_migrator/__pycache__/sprut_client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40f13ec130f100f60a54ed2d9a5df82deee9dd65 GIT binary patch literal 4688 zcmb7HO>7&-6`tiTe?(Cx{U&iB3lq5+Y{_i@ZTt^m>SpoA4L3=@?U|NG7I7;gHGk zsXR3-liMCGI)Zv7PN%A(=FzD$6ZDx2&yB?6zc7VxgiBoVngvxxg9KJ!&|%>xr7- znT|~l30}2y!?b5NHlDSd8QpT}u!sB7(fIJ0$xDUFmnP^a9o`U1?k>2_=uDmR%Uskc z%1K?*;~6}k>cHEx7LN=5T8+D&J2J&x7mqbvDtOIBfehieF2#ko+LcxT5p5m_D5ud} zj_UClUeYg{j(}eQ@Mg>#u#(6-PE>e!qbHDOU0H{fJig+V+8 zjGPCTBjp3ZZ9Y$n zx@py5|BA!#kT?h_99AZ7@*9^dvmuTf<|7o&hVsUOHp2p9hf%#yo!Haf&t{ ziQmQ4kWu`8-G*6mCwJnm5)6XD891U)b?1yAg#=U$I_2I`VBRB~Oe>J5LTne?&HCO5 zLv|WuLu@;68cVYsZ)lL89%4J8kFYpopgn%S1kGuge*P8gcH*QT!$>YbocJmv20tt< z;YT5z6d;}W+Kl5^esm2qez<5kx@Uxg<&s8#f^f<`ADH(@Lutz42MJTIgN8RzOW7Kv zu)&AmZ9Qc+RCrrcmym`AZ)*vf5D!v!x0Uf^*{%b-J{9iF1xP?$0&At{*G1Ac&v@&RFLLxWYCw+=`` z%IhcI(l%EF3Tdd~XPb5jZw+fN*_8`G{QxLg~W35kXR;ivvK z)KG26HZWh-P$<={*mcnG=DLSpS3JdkF$~De{|$9Siz-Wu7dkm&m-r5{+^;7gMi59hieU@K^L@S#8``DBNLM(X@E)$5RfgkI}oB&Eazds}I}DgnSK?rOb1S z+sCnz7+Wi1pzNiKeX?TUY>Wzo)ZNd4m|5_}ViIpq2xPe0W#P0KpIoa*#%N}Hko^#8 zez+>2%79y}4mmO?O+-e1V6{rs6{xO2eEQL~JS(LUyKpGZ7Yts-lF#*1lR?2c-W~Ea ztZ@8(7w+nsTQH!e@imVxc$uhp2zPxH$zCLw8hsUSQ%L4P;pD5Zw~J84gbYpW!ATmH zqKm2C{E8zv_~w2EibHPQzWd!rTiXscH6ZX@RqymCm!F38*f)6X*(QJg{I&D96Wd!m zjx~P?1fCm4(EoM+Uk}qQ8ntEfnb$F~f+#LQ?`<#7YbySkheM3U&ZQmlfdhY7E@BSSjsdPKJubn*BA)&tX z29d&_Y8G2NPQi1-4f;=}g8m;nzu41C0<+&eG6`NHxGS9^ZnLFCA~2f^ud@s^b#I=2_8HY`%j;2pME)Dzk1bZzgli3=2jAO z?Zg}~a>>ZGg%6inJ4WF7csaPQwv{Pfg8^4Y;%r=M0o8+-Ef6YAfe2m^1dS3occqd;C#Bf#J#)r9{0 zjVTRf^YsED8=4TqF!duvJPBPU6o3@*>EaPx1gf*#TTJ!>5C+cTEQ}JsUvlT$^|$KR zb~m+^*D9ie;tD8Aae-mx%=#1Mjh*{F0ok z!SI#}QUcxx8D#<{%gB~fWOz#sV_Z-qKS8n&NJb07Uw+>hjr5GlRXDe2RTL{NTd(j! z!H*XT@Xu1sLOxL_yjIh#;EQ+|7ojJ`Q%Ft#`G`oRWF`@qS|pJkw&Cr{`mYm8grS1k zf;^HAKh_~zkmE}DAmDcsi74S5j%`T-QBaCZpC2t0m}3O`6l;*UK_7u`l(|@Bk&_u0 z>NEU5CrHaug;V$q@g>!|AA35m#|iN)j6p^u?(cwfR7Fubp&g3WAxI7qWq*t8>wWH! o{wwmgO5#`K?+!^`k-s}+Z)@|@iR2Ve>-*#z9Q$^Q4^qbe04i6PtN;K2 literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/sync.cpython-311.pyc b/ipsec_migrator/__pycache__/sync.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad46bdeaa3658e5f2b2175d69aec70f237226639 GIT binary patch literal 18128 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 literal 0 HcmV?d00001 diff --git a/ipsec_migrator/__pycache__/tables.cpython-311.pyc b/ipsec_migrator/__pycache__/tables.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d2a0615a45e54f417d1063a4be3b8070ff17481 GIT binary patch literal 2302 zcma)7&1)M+6rYuLC3zz$PU@I^HJ#Y5W4V@%^Ff^^Eu>A-=HiAr$-!y59_@^*u~)n5 z>`2B&h8%j3!3Db%Vh9DF(v+NgD74T&K>{DbLLg9b$W0J>@u_cCmTD1jJ3IUI-pu>> z&6~GRKc`YAf^mEDn}w?eLVqctJ)$i}dvhL`M~I?XL^T?5v|?m7qA5P=M2q^YuA!dr zq3_T>dh|PE)}ZEJ@B`xFonMxjR2@nqRS!Lu}?_ zSztIv`~vm_pXV-d&iR^ml5;$J(Z?e*cW&IqlVkY9F>@+| ziAzIQE-5m>@>y`nXe*#qBqfXZR)}FFgA2?lF`*n>`}_vJkikn_7BIm+Aaj^G&s-+B zjq_#KmfUm4@$F?<@LXIVK6X9qGH{&tM2H%|?nT7hrChMk1ZT4fj7hmIG57I8*_XIP zdaa3RN`ydI%H( z9a7=%j;a*j_oaY?Nbvwyaa--aESDVi@79Arw>@Vvh=N|oo(~MkrNeejSOLRn&t*Yu ziBnksEhyAI;d~GiOqPWk=t{+oDSUgQ2Y#anX>UG-ntz0(*4{v5d z^kDF7xP#TiYEKtZy_)EX+;ec`^`S27{}VeZdiUFO-z+p$G!wp=p6Olf-zBX{!>k)86SmY&8$UhcL7g5J+*NG$W}j)&fLBW?9+Q4xy3XRmv<7E>)|{oQkVqI z=mqy{@%4Bue40^MQL%g9K*~`jIxSR}2}vg1DhO3tt*TIMpj8?n7uH8ot%XIBR$N#q zA=j(KBS12-AZ}UIvn?wyEh}G!#>gy7s0J1sh6d}j{sH}QlCy=b%v25hg2aI0F_7KF z)uKns4*Nu$15vd!-v$EkHLV#rr0Gqh$Z@0%*3rQJ`4SD)yJs_vPQ3g4XdldG>VyWz JKZt}N{s9{NR1E+C literal 0 HcmV?d00001 diff --git a/ipsec_migrator/audit.py b/ipsec_migrator/audit.py new file mode 100644 index 0000000..edf82e4 --- /dev/null +++ b/ipsec_migrator/audit.py @@ -0,0 +1,763 @@ +"""STAGE 1: collecting Neutron VPNaaS configuration into an audit JSON file, +plus the --from-audit path that reconstructs the same in-memory state from a +previously written audit file instead of re-querying OpenStack. + +This module owns the audit JSON schema definition (to_audit_dict / +load_from_audit_json), so it's defined exactly once and shared by both the +write path (STEP 10) and the read path (--from-audit). +""" +from __future__ import annotations + +import json +import os +import re +import sys +from datetime import datetime, timezone + +from . import keystone_auth, neutron_client +from .models import MigrationState +from .neutron_client import NeutronClient +from .redact import redact_psk +from .sprut_client import SprutClient +from .tables import print_kv_table + +_UUID_LIKE = re.compile(r"^[0-9a-fA-F-]{36}$") + + +def _dump(obj) -> str: + return json.dumps(obj, indent=2) + + +def _rule() -> None: + print("*" * 86) + + +# -------------------------------------------------------------------------- +# STEP 1: read the input CSV and build the neutron -> advanced router map. +# A row's advanced_router_id may be blank -- that router will be created in +# Sprut later (STEP 3). Optional 3rd/4th columns (availability_zone, +# flavor) only matter for such rows; they're a hint for that creation, +# used instead of the interactive prompt when present. +# -------------------------------------------------------------------------- +def read_input_csv(input_csv: str) -> tuple[dict[str, str], dict[str, dict]]: + print(f"Executing STEP 1: Reading config file {input_csv}") + + mapping: dict[str, str] = {} + creation_hints: dict[str, dict] = {} + try: + handle = open(input_csv, encoding="utf-8") + except OSError as exc: + print(f"Error: could not open input file '{input_csv}': {exc}", file=sys.stderr) + sys.exit(1) + + with handle: + for raw_line in handle: + line = raw_line.rstrip("\n").rstrip("\r") + fields = [f.strip() for f in line.split(",")] + neutron_router = fields[0] if fields else "" + advanced_router = fields[1] if len(fields) > 1 else "" + availability_zone = fields[2] if len(fields) > 2 else "" + flavor = fields[3] if len(fields) > 3 else "" + + if not neutron_router: + continue + mapping[neutron_router] = advanced_router + if not advanced_router: + hint = {} + if availability_zone: + hint["availability_zone"] = availability_zone + if flavor: + hint["flavor"] = flavor + if hint: + creation_hints[neutron_router] = hint + print( + f"Neutron router '{neutron_router}' has no advanced router — " + "a DC Router will be created for it in Sprut.", + file=sys.stderr, + ) + + print("Reading values from config:") + print() + print_kv_table( + {k: (v or "") for k, v in mapping.items()}, "Neutron router", "Advanced Router" + ) + print() + + print("STEP 1 complete (config read)") + _rule() + return mapping, creation_hints + + +# -------------------------------------------------------------------------- +# STEP 2: verify every Neutron router from the CSV exists in OpenStack. +# -------------------------------------------------------------------------- +def check_neutron_routers_exist(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 2: Checking Neutron routers") + + listing = neutron_client.router_list(nc) + state.neutron_router_by_id = {entry["id"]: entry for entry in listing} + existing_ids = set(state.neutron_router_by_id) + + for neutron_router_id in state.neutron_to_adv_router: + if neutron_router_id not in existing_ids: + # No stderr redirect here -- matches the bash original's + # (inconsistent, but preserved) use of stdout for this message. + print(f"Error: Neutron router ID {neutron_router_id} not found in OpenStack tenant.") + sys.exit(1) + print(f"Neutron router ID {neutron_router_id} exists in OpenStack tenant.") + + print("STEP 2 complete (Neutron routers checked)") + _rule() + + +_DC_FLAVORS = ("basic", "standard", "advanced") + + +def _require_interactive(neutron_router_id: str, field_name: str) -> None: + if not sys.stdin.isatty(): + print( + f"Error: router '{neutron_router_id}' needs a DC Router created but '{field_name}' " + "isn't set in the CSV and stdin is not interactive. Add it as a CSV column " + "(3rd=availability_zone, 4th=flavor) or run interactively.", + file=sys.stderr, + ) + sys.exit(1) + + +def _resolve_az_flavor(neutron_router_id: str, hints: dict[str, dict]) -> tuple[str, str]: + hint = hints.get(neutron_router_id, {}) + + availability_zone = hint.get("availability_zone") or "" + if not availability_zone: + _require_interactive(neutron_router_id, "availability_zone") + while not availability_zone: + availability_zone = input(f"Router '{neutron_router_id}': enter availability_zone: ").strip() + + flavor = hint.get("flavor") or "" + if flavor not in _DC_FLAVORS: + _require_interactive(neutron_router_id, "flavor") + while flavor not in _DC_FLAVORS: + flavor = input(f"Router '{neutron_router_id}': enter flavor ({'/'.join(_DC_FLAVORS)}): ").strip() + + return availability_zone, flavor + + +def _find_internet_network(sprut_client: SprutClient) -> str: + """Locates Sprut's public network (name "internet"/"Internet"), for + attaching a DC Router's public interface. Looked up once per run, + lazily, only when at least one router actually needs creation. + + Deliberately does not resolve or send a subnet_id: Sprut rejects + dc_interface creation with subnet_id set for an external network + ("Specifying subnet_id or ip_address for external network is + restricted", confirmed against the real API) -- it auto-assigns one. + """ + networks = sprut_client.get("/networks?limit=10000").get("networks", []) + matches = [n for n in networks if (n.get("name") or "").lower() == "internet"] + if not matches: + print("Error: no Sprut network named 'Internet' found (needed for the public interface).", file=sys.stderr) + sys.exit(1) + if len(matches) > 1: + print( + f"Warning: {len(matches)} Sprut networks named 'Internet' found -- " + f"using the first one ({matches[0].get('id')}).", + file=sys.stderr, + ) + return matches[0]["id"] + + +def _find_or_create_dc_router( + neutron_router_id: str, + name: str, + description: str, + availability_zone: str, + flavor: str, + sprut_client: SprutClient, + existing_dc_routers_by_name: dict[str, str], +) -> str: + sprut_id = existing_dc_routers_by_name.get(name) + if sprut_id: + print(f"DC Router '{name}' already exists in Sprut (id {sprut_id}) -- reusing") + return sprut_id + + print(f"Creating DC Router '{name}' in Sprut (az={availability_zone}, flavor={flavor}, enable_snat=true)") + request_body = { + "dc_router": { + "availability_zone": availability_zone, + "flavor": flavor, + "enable_snat": True, + "name": name, + "description": description, + } + } + response = sprut_client.post("/direct_connect/dc_routers", request_body) + sprut_id = (response.get("dc_router") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-{neutron_router_id}" + return sprut_id + + +def _find_or_create_dc_interface( + dc_router_id: str, network_id: str, name: str, sprut_client: SprutClient +) -> str: + existing = sprut_client.get("/direct_connect/dc_interfaces").get("dc_interfaces", []) + match = next( + (i for i in existing if i.get("dc_router_id") == dc_router_id and i.get("network_id") == network_id), + None, + ) + if match: + print(f"Public interface already attached to DC Router {dc_router_id} (id {match.get('id')}) -- reusing") + return match["id"] + + print(f"Attaching public interface (network 'Internet') to DC Router {dc_router_id}") + # subnet_id deliberately omitted -- Sprut rejects it for external + # networks (see _find_internet_network's docstring). + request_body = { + "dc_interface": { + "dc_router_id": dc_router_id, + "network_id": network_id, + "name": name, + } + } + response = sprut_client.post("/direct_connect/dc_interfaces", request_body) + sprut_id = (response.get("dc_interface") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-iface-{dc_router_id}" + return sprut_id + + +def _ensure_dc_router( + neutron_router_id: str, + name: str, + description: str, + availability_zone: str, + flavor: str, + sprut_client: SprutClient, + existing_dc_routers_by_name: dict[str, str], + internet_network_id: str, +) -> str: + """Creates (or reuses, by name) a DC Router and attaches its public + interface. Shared by the fresh-collect path (STEP 3) and the + --from-audit path, which both need identical create-or-reuse logic.""" + sprut_id = _find_or_create_dc_router( + neutron_router_id, name, description, availability_zone, flavor, sprut_client, existing_dc_routers_by_name + ) + _find_or_create_dc_interface(sprut_id, internet_network_id, f"{name}-public", sprut_client) + existing_dc_routers_by_name[name] = sprut_id + return sprut_id + + +# -------------------------------------------------------------------------- +# STEP 3: verify every advanced router exists in Sprut, using the Keystone +# token issued once up front by collect(). For CSV rows with a blank +# advanced_router_id, either creates the DC Router now (full run) or just +# records what it would need (--audit-only, which must not write to +# Sprut). Returns the SprutClient constructed with that token, reused by +# STAGE 2-4 (the token is never refreshed after this point). +# -------------------------------------------------------------------------- +def check_advanced_routers_exist( + state: MigrationState, token: str, sprut_base_url: str, dry_run: bool, audit_only: bool +) -> SprutClient: + print("Executing STEP 3: Checking Advanced routers") + + sprut_client = SprutClient(sprut_base_url, token, dry_run) + + dc_routers = sprut_client.get("/direct_connect/dc_routers").get("dc_routers", []) + existing_ids = {entry["id"] for entry in dc_routers} + existing_by_name = {entry["name"]: entry["id"] for entry in dc_routers if entry.get("name")} + + internet_network_id: str | None = None + + for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items(): + if advanced_router_id: + if advanced_router_id not in existing_ids: + print(f"Error: Advanced router ID {advanced_router_id} not found in SDN.") + sys.exit(1) + print(f"Advanced router ID {advanced_router_id} exists in SDN.") + continue + + neutron_router = state.neutron_router_by_id.get(neutron_router_id, {}) + name = neutron_router.get("name") or neutron_router_id + description = neutron_router.get("description") or "" + availability_zone, flavor = _resolve_az_flavor(neutron_router_id, state.router_creation_hints) + + if audit_only: + state.pending_dc_router_by_neutron_id[neutron_router_id] = { + "name": name, + "description": description, + "availability_zone": availability_zone, + "flavor": flavor, + } + print( + f"Neutron router '{neutron_router_id}': no advanced router -- recorded as pending " + "DC Router creation in the audit (no Sprut writes in --audit-only)." + ) + continue + + if internet_network_id is None: + internet_network_id = _find_internet_network(sprut_client) + + state.neutron_to_adv_router[neutron_router_id] = _ensure_dc_router( + neutron_router_id, + name, + description, + availability_zone, + flavor, + sprut_client, + existing_by_name, + internet_network_id, + ) + + print("STEP 3 complete (Advanced routers checked)") + _rule() + return sprut_client + + +def recheck_advanced_routers_from_audit(state: MigrationState, sprut_client: SprutClient) -> None: + """Re-verifies advanced routers recorded in a --from-audit file still + exist in Sprut, since the audit may have been generated earlier. Also + creates any DC Router left pending by a prior --audit-only run, using + the name/description/az/flavor cached in that audit's + "pending_dc_router" block -- Neutron is never re-queried here.""" + print("Issuing OpenStack token and re-checking advanced routers in SDN...") + + dc_routers = sprut_client.get("/direct_connect/dc_routers").get("dc_routers", []) + existing_ids = {entry["id"] for entry in dc_routers} + existing_by_name = {entry["name"]: entry["id"] for entry in dc_routers if entry.get("name")} + + internet_network_id: str | None = None + + for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items(): + if advanced_router_id: + if advanced_router_id not in existing_ids: + print( + f"Error: Advanced router ID {advanced_router_id} (from audit file) not found in SDN.", + file=sys.stderr, + ) + sys.exit(1) + print(f"Advanced router ID {advanced_router_id} exists in SDN.") + continue + + pending = state.pending_dc_router_by_neutron_id.get(neutron_router_id) + if not pending: + print( + f"Error: router '{neutron_router_id}' has no advanced_router_id and no " + "pending_dc_router info in the audit file -- file is incompatible or corrupted.", + file=sys.stderr, + ) + sys.exit(1) + + if internet_network_id is None: + internet_network_id = _find_internet_network(sprut_client) + + state.neutron_to_adv_router[neutron_router_id] = _ensure_dc_router( + neutron_router_id, + pending["name"], + pending.get("description", ""), + pending["availability_zone"], + pending["flavor"], + sprut_client, + existing_by_name, + internet_network_id, + ) + + +# -------------------------------------------------------------------------- +# STEP 4-6: IPsec policies, IKE policies, Endpoint groups (project-wide). +# One list call each -- Neutron's list endpoints return full objects, so no +# per-item show call is needed (unlike the old CLI-based version). +# -------------------------------------------------------------------------- +def collect_ipsec_policies(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 4: Collecting info about IPsec policies") + + for details in neutron_client.ipsec_policy_list(nc): + state.neutron_ipsec_policy_by_id[details["id"]] = details + + print("Stored IPsec policies:") + for policy_id, details in state.neutron_ipsec_policy_by_id.items(): + print(f"Policy ID: {policy_id}") + print(f"Details: {_dump(details)}") + print() + + print("STEP 4 complete: Info about IPsec policies stored") + _rule() + + +def collect_ike_policies(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 5: Collecting info about IKE policies") + + for details in neutron_client.ike_policy_list(nc): + state.neutron_ike_policy_by_id[details["id"]] = details + + print("Stored IKE policies:") + for policy_id, details in state.neutron_ike_policy_by_id.items(): + print(f"Policy ID: {policy_id}") + print(f"Details: {_dump(details)}") + print() + + print("STEP 5 complete: Info about IKE policies stored") + _rule() + + +def collect_endpoint_groups(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 6: Collecting info about Endpoint Groups") + + for details in neutron_client.endpoint_group_list(nc): + eg_id = details["id"] + state.neutron_endpoint_group_by_id[eg_id] = details + + if details.get("sdn") == "sprut": + print(f"Endpoint Group {eg_id} has sdn 'sprut' with the following network addresses:") + for address in details.get("endpoints") or []: + print(address) + + print("Stored Endpoint Groups:") + for eg_id, details in state.neutron_endpoint_group_by_id.items(): + print(f"Endpoint Group ID: {eg_id}") + print(f"Details: {_dump(details)}") + print() + + print("STEP 6 complete: Info about Endpoint Groups stored") + _rule() + + +# -------------------------------------------------------------------------- +# STEP 7: subnet id -> CIDR map, excluding ext-subnet* names. +# -------------------------------------------------------------------------- +def collect_subnets(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 7: Creating a map of Subnet ID to Subnet Address") + + for entry in neutron_client.subnet_list(nc): + name = entry.get("name") or "" + if name.startswith("ext-subnet"): + continue + state.subnet_id_to_subnet_address[entry["id"]] = entry.get("cidr") + + print("Stored Subnet ID to Subnet Address mapping:") + print_kv_table(state.subnet_id_to_subnet_address, "Subnet ID", "Subnet Address", width1=36, width2=18) + print() + + print("STEP 7 complete: Subnet ID to Subnet Address map created") + _rule() + + +# -------------------------------------------------------------------------- +# STEP 8: router <-> vpn service maps. router_id_to_vpn_service_id is +# filtered to in-scope (CSV) routers; the reverse map is deliberately left +# project-wide since STEP 9 needs it to resolve every connection's owning +# router before in-scope narrowing happens. +# -------------------------------------------------------------------------- +def collect_vpn_services(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 8: Collecting info about vpn services") + listing = neutron_client.vpn_service_list(nc) + + for entry in listing: + router_id = entry["router_id"] + vpn_id = entry["id"] + state.router_id_to_vpn_service_id.setdefault(router_id, vpn_id) + state.neutron_vpn_service_by_id.setdefault(vpn_id, entry) + + print("Remove router_ids that are not in config") + for router_id in list(state.router_id_to_vpn_service_id): + if router_id not in state.neutron_to_adv_router: + del state.router_id_to_vpn_service_id[router_id] + + print_kv_table(state.router_id_to_vpn_service_id, "Router ID", "VPN service ID") + + print("build backwards dictionary") + for entry in listing: + state.vpn_service_id_to_router_id[entry["id"]] = entry["router_id"] + + print_kv_table(state.vpn_service_id_to_router_id, "VPN service ID", "Router ID") + + print("STEP 8 complete (Router ID to VPN Service ID map built)") + _rule() + + +# -------------------------------------------------------------------------- +# STEP 9: ipsec site connections + router -> connection-ids map. +# -------------------------------------------------------------------------- +def collect_ipsec_site_connections(state: MigrationState, nc: NeutronClient) -> None: + print("Executing STEP 9: Collecting info about ipsec") + + for details in neutron_client.ipsec_site_connection_list(nc): + conn_id = details["id"] + + vpn_service_id = details.get("vpnservice_id") + router_id = state.vpn_service_id_to_router_id.get(vpn_service_id, "") + state.neutron_router_to_ipsec_ids.setdefault(router_id, []).append(conn_id) + state.neutron_connection_by_id[conn_id] = details + + print("Stored IPsec connections:") + for conn_id, details in state.neutron_connection_by_id.items(): + print(f"ID: {conn_id}") + print(f"Details: {_dump(redact_psk(details))}") + print() + + print("Neutron Router to IPsec IDs mapping:") + for router_id, conn_ids in state.neutron_router_to_ipsec_ids.items(): + print(f"Router ID: {router_id}") + print(f"IPsec IDs: {', '.join(conn_ids)}") + + print("STEP 9 complete: Info about ipsec stored") + _rule() + + +# -------------------------------------------------------------------------- +# In-scope computation: only routers/objects tied to the input CSV are +# actually created in Sprut by STAGE 3/4, even though STEP 4-9 collect +# project-wide (needed for matching against what Sprut already has). +# -------------------------------------------------------------------------- +def compute_in_scope(state: MigrationState) -> None: + for neutron_router_id in state.neutron_to_adv_router: + for conn_id in state.neutron_router_to_ipsec_ids.get(neutron_router_id, []): + if not conn_id: + continue + state.in_scope_connection_ids.add(conn_id) + + conn_raw = state.neutron_connection_by_id[conn_id] + ike_id = conn_raw.get("ikepolicy_id") + ipsec_id = conn_raw.get("ipsecpolicy_id") + local_eg_id = conn_raw.get("local_ep_group_id") + peer_eg_id = conn_raw.get("peer_ep_group_id") + + if ike_id: + state.in_scope_ike_policy_ids.add(ike_id) + if ipsec_id: + state.in_scope_ipsec_policy_ids.add(ipsec_id) + if local_eg_id: + state.in_scope_endpoint_group_ids.add(local_eg_id) + if peer_eg_id: + state.in_scope_endpoint_group_ids.add(peer_eg_id) + + print(f"In-scope IPsec site connections for this migration: {len(state.in_scope_connection_ids)}") + + +# -------------------------------------------------------------------------- +# Endpoint-group helpers, shared by STEP 10 (audit assembly) and STAGE 3 +# (endpoint-group compare&create). +# -------------------------------------------------------------------------- +def resolve_endpoint_addresses(details: dict, subnet_map: dict[str, str]) -> list[str]: + name = details.get("name") + converted: list[str] = [] + for endpoint in details.get("endpoints") or []: + if _UUID_LIKE.match(endpoint): + print(f"Converting subnet UUID {endpoint} in {name} endpoints", file=sys.stderr) + address = subnet_map.get(endpoint) + if address: + print(f"Converted to {address}", file=sys.stderr) + converted.append(address) + else: + print(f"Warning: Subnet ID {endpoint} not found in subnet_id_to_subnet_address map.", file=sys.stderr) + else: + converted.append(endpoint) + return converted + + +def build_endpoint_group_block(raw: dict | None, subnet_map: dict[str, str]) -> dict | None: + if not raw: + return None + resolved = resolve_endpoint_addresses(raw, subnet_map) + return { + "id": raw.get("id"), + "raw": raw, + "resolved_endpoints": resolved, + "already_migrated": raw.get("sdn") == "sprut", + } + + +# -------------------------------------------------------------------------- +# STEP 10: assemble and write the audit JSON. +# -------------------------------------------------------------------------- +def to_audit_dict(state: MigrationState, input_file: str) -> dict: + router_blocks = [] + for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items(): + vpn_id = state.router_id_to_vpn_service_id.get(neutron_router_id) + vpn_service_json = state.neutron_vpn_service_by_id.get(vpn_id) if vpn_id else None + + connection_blocks = [] + for conn_id in state.neutron_router_to_ipsec_ids.get(neutron_router_id, []): + if not conn_id: + continue + conn_raw = state.neutron_connection_by_id[conn_id] + + ike_id = conn_raw.get("ikepolicy_id") + ipsec_id = conn_raw.get("ipsecpolicy_id") + local_eg_id = conn_raw.get("local_ep_group_id") + peer_eg_id = conn_raw.get("peer_ep_group_id") + + ike_json = state.neutron_ike_policy_by_id.get(ike_id) + ipsec_json = state.neutron_ipsec_policy_by_id.get(ipsec_id) + + local_eg_raw = state.neutron_endpoint_group_by_id.get(local_eg_id) + peer_eg_raw = state.neutron_endpoint_group_by_id.get(peer_eg_id) + + connection_blocks.append( + { + "id": conn_raw.get("id"), + "raw": conn_raw, + "ike_policy": ike_json, + "ipsec_policy": ipsec_json, + "local_endpoint_group": build_endpoint_group_block(local_eg_raw, state.subnet_id_to_subnet_address), + "peer_endpoint_group": build_endpoint_group_block(peer_eg_raw, state.subnet_id_to_subnet_address), + } + ) + + router_blocks.append( + { + "neutron_router_id": neutron_router_id, + "advanced_router_id": advanced_router_id or None, + "pending_dc_router": state.pending_dc_router_by_neutron_id.get(neutron_router_id), + "vpn_service": vpn_service_json, + "ipsec_site_connections": connection_blocks, + } + ) + + return { + "audit_metadata": { + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "input_file": input_file, + "stage": "STAGE1_AUDIT", + }, + "subnets": dict(state.subnet_id_to_subnet_address), + "routers": router_blocks, + } + + +def write_audit_json(data: dict, output_file: str) -> None: + tmp_path = f"{output_file}.tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + try: + with open(tmp_path, encoding="utf-8") as f: + json.load(f) + except (OSError, json.JSONDecodeError): + print("Error: failed to assemble valid JSON audit output", file=sys.stderr) + os.remove(tmp_path) + sys.exit(1) + + os.replace(tmp_path, output_file) + os.chmod(output_file, 0o600) + print(f"Audit JSON written to {output_file} (permissions restricted to owner: contains PSK values)") + + +# -------------------------------------------------------------------------- +# --from-audit: reconstruct MigrationState from a previously written audit +# file, without any OpenStack query. Pure (no network I/O) -- token +# reissuance and the Sprut recheck are the orchestration's job (cli.py), +# via recheck_advanced_routers_from_audit() above. +# -------------------------------------------------------------------------- +def load_from_audit_json(audit_file: str) -> MigrationState: + print(f"Loading audit data from {audit_file} (skipping OpenStack collection)") + + try: + with open(audit_file, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + print(f"Error: '{audit_file}' is not valid JSON.", file=sys.stderr) + sys.exit(1) + + schema_stage = (data.get("audit_metadata") or {}).get("stage") + if schema_stage != "STAGE1_AUDIT": + print( + f"Error: '{audit_file}' does not look like a STAGE1 audit file " + "(audit_metadata.stage != STAGE1_AUDIT).", + file=sys.stderr, + ) + sys.exit(1) + + state = MigrationState() + + for subnet_id, subnet_cidr in (data.get("subnets") or {}).items(): + if not subnet_id: + continue + state.subnet_id_to_subnet_address[subnet_id] = subnet_cidr + + for router_json in data.get("routers") or []: + neutron_router_id = router_json.get("neutron_router_id") + advanced_router_id = router_json.get("advanced_router_id") or "" + if not neutron_router_id: + continue + state.neutron_to_adv_router[neutron_router_id] = advanced_router_id + + if not advanced_router_id: + pending = router_json.get("pending_dc_router") + if pending: + state.pending_dc_router_by_neutron_id[neutron_router_id] = pending + + vpn_service_json = router_json.get("vpn_service") + if vpn_service_json: + vpn_service_id = vpn_service_json.get("id") + state.router_id_to_vpn_service_id[neutron_router_id] = vpn_service_id + + for conn_json in router_json.get("ipsec_site_connections") or []: + conn_id = conn_json.get("id") + if not conn_id: + continue + + state.neutron_connection_by_id[conn_id] = conn_json.get("raw") + state.in_scope_connection_ids.add(conn_id) + + ike_json = conn_json.get("ike_policy") + if ike_json: + ike_id = ike_json.get("id") + state.neutron_ike_policy_by_id[ike_id] = ike_json + state.in_scope_ike_policy_ids.add(ike_id) + + ipsec_json = conn_json.get("ipsec_policy") + if ipsec_json: + ipsec_id = ipsec_json.get("id") + state.neutron_ipsec_policy_by_id[ipsec_id] = ipsec_json + state.in_scope_ipsec_policy_ids.add(ipsec_id) + + local_eg_json = conn_json.get("local_endpoint_group") + if local_eg_json: + local_eg_id = local_eg_json.get("id") + state.neutron_endpoint_group_by_id[local_eg_id] = local_eg_json.get("raw") + state.in_scope_endpoint_group_ids.add(local_eg_id) + + peer_eg_json = conn_json.get("peer_endpoint_group") + if peer_eg_json: + peer_eg_id = peer_eg_json.get("id") + state.neutron_endpoint_group_by_id[peer_eg_id] = peer_eg_json.get("raw") + state.in_scope_endpoint_group_ids.add(peer_eg_id) + + print( + f"Loaded from audit: {len(state.neutron_to_adv_router)} router(s), " + f"{len(state.in_scope_connection_ids)} in-scope IPsec site connection(s)." + ) + + return state + + +# -------------------------------------------------------------------------- +# Full STAGE 1 orchestration (STEP 1-9 + in-scope computation). STEP 10 +# (write) is invoked separately by the caller so --audit-only and the full +# run can share this without duplicating the write step. +# -------------------------------------------------------------------------- +def collect( + input_csv: str, sprut_base_url: str, dry_run: bool, audit_only: bool +) -> tuple[MigrationState, SprutClient]: + state = MigrationState() + state.neutron_to_adv_router, state.router_creation_hints = read_input_csv(input_csv) + + # Authenticate once, up front: STEP 2 now needs a Neutron token just as + # much as STEP 3 needs one for Sprut, since neither goes through the + # `openstack` CLI's own per-call auth resolution anymore. + token, catalog = keystone_auth.authenticate() + neutron_base_url = keystone_auth.resolve_endpoint(catalog, service_type="network") + nc = NeutronClient(neutron_base_url, token) + + check_neutron_routers_exist(state, nc) + sprut_client = check_advanced_routers_exist(state, token, sprut_base_url, dry_run, audit_only) + + collect_ipsec_policies(state, nc) + collect_ike_policies(state, nc) + collect_endpoint_groups(state, nc) + collect_subnets(state, nc) + collect_vpn_services(state, nc) + collect_ipsec_site_connections(state, nc) + compute_in_scope(state) + + return state, sprut_client diff --git a/ipsec_migrator/cli.py b/ipsec_migrator/cli.py new file mode 100644 index 0000000..8d73452 --- /dev/null +++ b/ipsec_migrator/cli.py @@ -0,0 +1,153 @@ +"""Argument parsing and top-level orchestration of STAGE 1-4. + +Mirrors the bash original's literal top-to-bottom execution order: banner +and the "STAGE 1: Collecting information" header print before anything +else, then argument parsing, then mode dispatch. The `openstack` CLI +binary check that used to run here is gone -- OpenStack access is now +direct Neutron/Keystone REST calls (`keystone_auth.py`, `neutron_client.py`), +so no external binary is required at all. +""" +from __future__ import annotations + +import argparse +import os +import sys + +from . import audit, keystone_auth, sync +from .constants import BANNER, SPRUT_API_BASE, USAGE +from .sprut_client import SprutClient + + +def print_banner() -> None: + print(BANNER) + + +def print_usage() -> None: + print(USAGE) + + +def _rule() -> None: + print("*" * 86) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--dry-run", action="store_true", dest="dry_run") + parser.add_argument("--audit-only", action="store_true", dest="audit_only") + parser.add_argument("--from-audit", dest="from_audit", default=None) + parser.add_argument("-h", "--help", action="store_true", dest="help") + parser.add_argument("positional", nargs="*") + + args = parser.parse_args(argv) + + if args.help: + print_usage() + sys.exit(0) + + audit_only_seen = args.audit_only + from_audit_seen = args.from_audit is not None + + if audit_only_seen and from_audit_seen: + print("Error: --audit-only and --from-audit are mutually exclusive.", file=sys.stderr) + sys.exit(1) + + if from_audit_seen: + mode = "from-audit" + elif audit_only_seen: + mode = "audit-only" + else: + mode = "full" + + input_csv = None + output_file = None + + if mode == "from-audit": + if not args.from_audit: + print("Error: --from-audit requires a path to an audit JSON file.", file=sys.stderr) + print_usage() + sys.exit(1) + if not os.path.isfile(args.from_audit): + print(f"Error: audit file '{args.from_audit}' not found.", file=sys.stderr) + sys.exit(1) + else: + if not args.positional: + print("Error: No input file provided.") + print_usage() + sys.exit(1) + input_csv = args.positional[0] + if len(args.positional) > 1: + output_file = args.positional[1] + + return argparse.Namespace( + mode=mode, + dry_run=args.dry_run, + from_audit=args.from_audit, + input_csv=input_csv, + output_file=output_file, + ) + + +def _default_output_file() -> str: + from datetime import datetime + + return f"vpnaas_audit_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + + +def main(argv: list[str] | None = None) -> int: + # Python buffers stdout in blocks (not line-by-line) whenever it isn't a + # TTY, while stderr stays unbuffered -- under redirection (e.g. `2>&1 | + # tee log`) that visibly reorders stdout/stderr lines relative to each + # other. Bash's `echo` has no such issue (each call is its own unbuffered + # write), so line-buffer stdout here to keep interleaving faithful. + sys.stdout.reconfigure(line_buffering=True) + + print_banner() + print("{====================STAGE 1: Collecting information====================}") + print() + print() + + args = parse_args(argv if argv is not None else sys.argv[1:]) + + if args.dry_run: + print("Running in --dry-run mode: no objects will be created in Sprut.") + + if args.mode == "from-audit": + state = audit.load_from_audit_json(args.from_audit) + token, _catalog = keystone_auth.authenticate() + sprut_client = SprutClient(SPRUT_API_BASE, token, args.dry_run) + audit.recheck_advanced_routers_from_audit(state, sprut_client) + else: + state, sprut_client = audit.collect( + args.input_csv, SPRUT_API_BASE, args.dry_run, audit_only=(args.mode == "audit-only") + ) + + output_file = args.output_file or _default_output_file() + print(f"Executing STEP 10: Writing audit JSON to {output_file}") + audit_data = audit.to_audit_dict(state, args.input_csv) + audit.write_audit_json(audit_data, output_file) + print("STEP 10 complete: audit JSON exported") + _rule() + + print("{====================STAGE 1: COMPLETE====================}") + print() + + if args.mode == "audit-only": + print("Mode --audit-only: stopping after STAGE 1 (Sprut configuration stages skipped).") + return 0 + + print("{====================STAGE 2: Collecting Existing Sprut Objects====================}") + sprut_state = sync.collect_sprut_state(sprut_client) + print("STAGE 2 complete: Sprut objects collected") + _rule() + + print("{====================STAGE 3: Comparing and Creating Missing Objects in Sprut====================}") + correspondence = sync.compare_and_create_all(state, sprut_client, sprut_state) + print("STAGE 3 complete: Missing Sprut objects created") + _rule() + + print("{====================STAGE 4: Creating IPsec Site Connections in Sprut====================}") + sync.create_ipsec_site_connections(state, sprut_client, sprut_state, correspondence) + print("STAGE 4 complete: IPsec site connections created in Sprut") + _rule() + + return 0 diff --git a/ipsec_migrator/constants.py b/ipsec_migrator/constants.py new file mode 100644 index 0000000..962b58f --- /dev/null +++ b/ipsec_migrator/constants.py @@ -0,0 +1,51 @@ +"""Fixed text constants: banner, usage text, Sprut API base URL. Reproduced +verbatim from the bash original (ipsec_migrator_v2.sh) except for the +invocation name, which is "python -m ipsec_migrator" here instead of "$0" +since this port has no console-script entry point. +""" + +SPRUT_API_BASE = "https://infra.mail.ru:9696/v2.0" + +PROG = "python -m ipsec_migrator" + +BANNER = r""" +██╗██████╗ ███████╗███████╗ ██████╗ ██╗ ██╗██████╗ ███╗ ██╗ +██║██╔══██╗██╔════╝██╔════╝██╔════╝ ██║ ██║██╔══██╗████╗ ██║ +██║██████╔╝███████╗█████╗ ██║ ██║ ██║██████╔╝██╔██╗ ██║ +██║██╔═══╝ ╚════██║██╔══╝ ██║ ╚██╗ ██╔╝██╔═══╝ ██║╚██╗██║ +██║██║ ███████║███████╗╚██████╗ ╚████╔╝ ██║ ██║ ╚████║ +╚═╝╚═╝ ╚══════╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═══╝ + +███╗ ███╗██╗ ██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ ███████╗ ██████╗██████╗ ██╗██████╗ ████████╗ +████╗ ████║██║██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ ██╔════╝██╔════╝██╔══██╗██║██╔══██╗╚══██╔══╝ +██╔████╔██║██║██║ ███╗██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ ███████╗██║ ██████╔╝██║██████╔╝ ██║ +██║╚██╔╝██║██║██║ ██║██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ ╚════██║██║ ██╔══██╗██║██╔═══╝ ██║ +██║ ╚═╝ ██║██║╚██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ ███████║╚██████╗██║ ██║██║██║ ██║ +╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ + + +Input file format: + +,,, +XXXX6d56-b2dc-4c83-b96f-e4e28af6XXXX,,MS1,standard + +where DC_Router_UUID - optional (script will create a new one (DC_Router) if UUID won't be provided) + +""" + +USAGE = f""" + +Usage: + + Prepare input CSV file then use option: + + 1. Full run (audit Neutron + configure Sprut): + {PROG} [output.json] [--dry-run] + + 2. Audit only (collect Neutron VPNaaS config, write JSON, skip Sprut entirely): + {PROG} --audit-only [output.json] + + 3. Configure Sprut from a previously generated audit JSON (no OpenStack re-query): + {PROG} --from-audit [--dry-run] + +""" diff --git a/ipsec_migrator/keystone_auth.py b/ipsec_migrator/keystone_auth.py new file mode 100644 index 0000000..d3a318a --- /dev/null +++ b/ipsec_migrator/keystone_auth.py @@ -0,0 +1,129 @@ +"""Keystone v3 password authentication via `requests`, replacing the +`openstack token issue` subprocess call. + +Mirrors the auth flow the `openstack` CLI itself performs when reading +`OS_*` environment variables: POST a project-scoped password auth request +to `{OS_AUTH_URL}/auth/tokens`, read the token back from the +`X-Subject-Token` response header, and keep the returned service catalog +around so the Neutron endpoint can be resolved from it (same as the CLI +does) instead of being hardcoded. + +No `clouds.yaml` support -- the only auth pattern actually in use here +(`test_openrc.sh`) is password auth via `OS_*` env vars. +""" +from __future__ import annotations + +import os +import sys + +import requests + +_REQUIRED = ("OS_AUTH_URL", "OS_USERNAME", "OS_PASSWORD") + + +def _env(name: str) -> str | None: + value = os.environ.get(name) + return value if value else None + + +def _user_domain() -> dict: + if domain_id := _env("OS_USER_DOMAIN_ID"): + return {"id": domain_id} + return {"name": _env("OS_USER_DOMAIN_NAME") or "Default"} + + +def _project_scope() -> dict: + if project_id := _env("OS_PROJECT_ID"): + return {"id": project_id} + + project_name = _env("OS_PROJECT_NAME") + if not project_name: + print( + "Error: neither OS_PROJECT_ID nor OS_PROJECT_NAME is set.", + file=sys.stderr, + ) + sys.exit(1) + + if project_domain_id := _env("OS_PROJECT_DOMAIN_ID"): + domain = {"id": project_domain_id} + else: + domain = {"name": _env("OS_PROJECT_DOMAIN_NAME") or "Default"} + + return {"name": project_name, "domain": domain} + + +def authenticate() -> tuple[str, list[dict]]: + """Returns (token, service_catalog).""" + missing = [name for name in _REQUIRED if not _env(name)] + if missing: + print(f"Error: required environment variable(s) not set: {', '.join(missing)}", file=sys.stderr) + sys.exit(1) + + auth_url = _env("OS_AUTH_URL").rstrip("/") + body = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": _env("OS_USERNAME"), + "domain": _user_domain(), + "password": _env("OS_PASSWORD"), + } + }, + }, + "scope": {"project": _project_scope()}, + } + } + + try: + response = requests.post(f"{auth_url}/auth/tokens", json=body, timeout=30.0) + except requests.exceptions.RequestException as exc: + print(f"Error: Keystone authentication request failed: {exc}", file=sys.stderr) + sys.exit(1) + + if not 200 <= response.status_code < 300: + print(f"Error: Keystone authentication failed (HTTP {response.status_code})", file=sys.stderr) + print(f"Response: {response.text}", file=sys.stderr) + sys.exit(1) + + token = response.headers.get("X-Subject-Token") + if not token: + print("Error: Keystone response did not include X-Subject-Token.", file=sys.stderr) + sys.exit(1) + + catalog = (response.json().get("token") or {}).get("catalog") or [] + return token, catalog + + +def resolve_endpoint(catalog: list[dict], service_type: str = "network") -> str: + interface = _env("OS_INTERFACE") or "public" + region = _env("OS_REGION_NAME") + + service = next((entry for entry in catalog if entry.get("type") == service_type), None) + if service is None: + print(f"Error: service type '{service_type}' not found in Keystone catalog.", file=sys.stderr) + sys.exit(1) + + candidates = [ep for ep in service.get("endpoints", []) if ep.get("interface") == interface] + if region: + region_matches = [ep for ep in candidates if ep.get("region") == region] + if region_matches: + candidates = region_matches + + if not candidates: + print( + f"Error: no '{interface}' endpoint for service type '{service_type}' in Keystone catalog" + + (f" (region {region})" if region else ""), + file=sys.stderr, + ) + sys.exit(1) + + url = candidates[0]["url"].rstrip("/") + # Neutron's catalog entry is registered with the /v2.0 suffix already on + # some clouds and without it on others; neutron_client.py always adds + # /v2.0/... itself, so strip a pre-existing suffix here to avoid + # double-versioned URLs (.../v2.0/v2.0/routers). + if service_type == "network" and (url.endswith("/v2.0") or url.endswith("/v2")): + url = url.rsplit("/", 1)[0] + return url diff --git a/ipsec_migrator/models.py b/ipsec_migrator/models.py new file mode 100644 index 0000000..b32d688 --- /dev/null +++ b/ipsec_migrator/models.py @@ -0,0 +1,53 @@ +"""In-memory state shared between STAGE 1 collection/loading and STAGE 3/4. + +Replaces the bash original's `declare -gA` global associative-array soup +with one explicit, typed object. Populated either by audit.collect() (real +OpenStack/Sprut queries) or audit.load_from_audit_json() (parsing a +previously written audit file) -- both paths produce the same shape so +STAGE 3/4 don't need to know which one ran. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class MigrationState: + neutron_to_adv_router: dict[str, str] = field(default_factory=dict) + subnet_id_to_subnet_address: dict[str, str] = field(default_factory=dict) + + # STEP 2: full Neutron router objects (id -> raw dict), so a router + # whose advanced_router_id is blank in the CSV can be auto-created in + # Sprut using this router's own name/description, without a second + # per-item show call. + neutron_router_by_id: dict[str, dict] = field(default_factory=dict) + # STEP 1: optional availability_zone/flavor CSV columns (3rd/4th), + # keyed by neutron_router_id, only present for rows whose + # advanced_router_id was blank. + router_creation_hints: dict[str, dict] = field(default_factory=dict) + # STEP 3 (collect()) or --from-audit load: resolved name/description/ + # availability_zone/flavor for routers still awaiting DC Router + # creation. Populated either freshly (collect()) or from a previously + # written audit.json's "pending_dc_router" block (--from-audit). + pending_dc_router_by_neutron_id: dict[str, dict] = field(default_factory=dict) + + neutron_ike_policy_by_id: dict[str, dict] = field(default_factory=dict) + neutron_ipsec_policy_by_id: dict[str, dict] = field(default_factory=dict) + neutron_endpoint_group_by_id: dict[str, dict] = field(default_factory=dict) + + # STEP 8: filtered to in-scope (CSV) routers only. + router_id_to_vpn_service_id: dict[str, str] = field(default_factory=dict) + # STEP 8: reverse map, deliberately NOT filtered -- stays project-wide + # because STEP 9 needs it to resolve every connection's owning router + # before in-scope narrowing happens. Do not filter this one. + vpn_service_id_to_router_id: dict[str, str] = field(default_factory=dict) + neutron_vpn_service_by_id: dict[str, dict] = field(default_factory=dict) + + # STEP 9: router id -> list of ipsec site connection ids it owns. + neutron_router_to_ipsec_ids: dict[str, list[str]] = field(default_factory=dict) + neutron_connection_by_id: dict[str, dict] = field(default_factory=dict) + + in_scope_connection_ids: set[str] = field(default_factory=set) + in_scope_ike_policy_ids: set[str] = field(default_factory=set) + in_scope_ipsec_policy_ids: set[str] = field(default_factory=set) + in_scope_endpoint_group_ids: set[str] = field(default_factory=set) diff --git a/ipsec_migrator/neutron_client.py b/ipsec_migrator/neutron_client.py new file mode 100644 index 0000000..e55f0f5 --- /dev/null +++ b/ipsec_migrator/neutron_client.py @@ -0,0 +1,109 @@ +"""REST client for the Neutron API, replacing the `openstack` CLI subprocess +wrapper (`openstack_client.py`, removed). + +Mirrors `SprutClient`'s shape: a single `requests.Session`, token/base URL +fixed at construction, `.get()`/`.post()` raising a clear error and exiting +on a non-2xx response. + +Every list function below does exactly one GET and returns the collection +unwrapped -- no per-item `show` call. Unlike the `openstack` CLI's table +output (which truncates to a handful of display columns unless `--long` is +given), Neutron's list endpoints return the full attribute set for every +item by default, so the per-item `show` calls the CLI-based version needed +are redundant here. +""" +from __future__ import annotations + +import sys +from typing import Any + +import requests + + +class NeutronClient: + def __init__(self, base_url: str, token: str, timeout: float = 30.0): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = requests.Session() + self._session.headers.update({"X-Auth-Token": token}) + + def call(self, method: str, path: str, data: dict | None = None) -> Any: + url = f"{self.base_url}{path}" + try: + response = self._session.request( + method, url, json=data if data is not None else None, timeout=self.timeout + ) + except requests.exceptions.RequestException as exc: + print(f"Error: Neutron API {method} {url} failed: {exc}", file=sys.stderr) + sys.exit(1) + + if not 200 <= response.status_code < 300: + print(f"Error: Neutron API {method} {url} returned HTTP {response.status_code}", file=sys.stderr) + print(f"Response: {response.text}", file=sys.stderr) + sys.exit(1) + + if not response.text: + return {} + return response.json() + + def get(self, path: str) -> Any: + return self.call("GET", path) + + def get_absolute(self, url: str) -> Any: + """Like get(), but for an already-absolute URL (pagination "next" + links come back as full URLs, not paths relative to base_url).""" + try: + response = self._session.get(url, timeout=self.timeout) + except requests.exceptions.RequestException as exc: + print(f"Error: Neutron API GET {url} failed: {exc}", file=sys.stderr) + sys.exit(1) + + if not 200 <= response.status_code < 300: + print(f"Error: Neutron API GET {url} returned HTTP {response.status_code}", file=sys.stderr) + print(f"Response: {response.text}", file=sys.stderr) + sys.exit(1) + + return response.json() if response.text else {} + + def list_all(self, path: str, collection_key: str) -> list[dict]: + """GETs a collection and follows Neutron's pagination "next" links + (`_links` with `rel: next`) until exhausted. Plain + deployments without pagination enabled just return one page with no + `_links` key, so this is a no-op there.""" + items: list[dict] = [] + body = self.get(path) + while True: + items.extend(body.get(collection_key, [])) + links = body.get(f"{collection_key}_links", []) + next_url = next((link["href"] for link in links if link.get("rel") == "next"), None) + if not next_url: + return items + body = self.get_absolute(next_url) + + +def router_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/routers", "routers") + + +def subnet_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/subnets", "subnets") + + +def ike_policy_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/vpn/ikepolicies", "ikepolicies") + + +def ipsec_policy_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/vpn/ipsecpolicies", "ipsecpolicies") + + +def endpoint_group_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/vpn/endpoint-groups", "endpoint_groups") + + +def vpn_service_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/vpn/vpnservices", "vpnservices") + + +def ipsec_site_connection_list(client: NeutronClient) -> list[dict]: + return client.list_all("/v2.0/vpn/ipsec-site-connections", "ipsec_site_connections") diff --git a/ipsec_migrator/redact.py b/ipsec_migrator/redact.py new file mode 100644 index 0000000..f7857fc --- /dev/null +++ b/ipsec_migrator/redact.py @@ -0,0 +1,24 @@ +"""PSK redaction for safe logging. + +Walks any nested dict/list structure and blanks out the value of any dict +key literally named "psk" (Neutron's and Sprut's shared field name) when +that value is not null. Used only for logging -- the real value is always +used wherever the original object is read for actual API calls/storage. +""" +from __future__ import annotations + +from typing import Any + +_PSK_KEYS = {"psk"} +_REDACTED = "***REDACTED***" + + +def redact_psk(value: Any) -> Any: + if isinstance(value, dict): + return { + key: (_REDACTED if key in _PSK_KEYS and val is not None else redact_psk(val)) + for key, val in value.items() + } + if isinstance(value, list): + return [redact_psk(item) for item in value] + return value diff --git a/ipsec_migrator/sprut_client.py b/ipsec_migrator/sprut_client.py new file mode 100644 index 0000000..8574e56 --- /dev/null +++ b/ipsec_migrator/sprut_client.py @@ -0,0 +1,75 @@ +"""REST client for the Sprut SDN API. + +Mirrors sprut_api_call() from the bash original. Token, dry-run flag and +base URL are fixed at construction (replacing the bash globals $token / +$DRY_RUN / $sprut_api_base read by every call site) and a single +requests.Session is reused across all calls for connection reuse -- a +behaviorally-neutral efficiency improvement over one curl process per call. + +The only deliberate behavioral deviation from the bash original (confirmed +with the user): a 30s timeout is applied to every HTTP call. curl in the +original has no --max-time at all, so a hung Sprut endpoint would hang the +whole script indefinitely; a timeout here is treated exactly like a non-2xx +response (error message to stderr, exit 1), so no new failure mode is +introduced, just an upper bound on how long a stuck call can hang. + +The Keystone token is never refreshed after construction -- this directly +carries over the "token not refreshed across STAGE 2-4" known limitation +from the bash original. Do not add retry-on-401 here. +""" +from __future__ import annotations + +import json +import sys + +import requests + +from .redact import redact_psk + + +class SprutClient: + def __init__(self, base_url: str, token: str, dry_run: bool, timeout: float = 30.0): + self.base_url = base_url.rstrip("/") + self.token = token + self.dry_run = dry_run + self.timeout = timeout + self._session = requests.Session() + self._session.headers.update( + { + "Content-Type": "application/json", + "X-Auth-Token": token, + "X-SDN": "SPRUT", + } + ) + + def call(self, method: str, path: str, data: dict | None = None) -> dict: + url = f"{self.base_url}{path}" + + if self.dry_run and method != "GET": + print(f"[DRY-RUN] Would {method} {url}", file=sys.stderr) + if data: + print(f"[DRY-RUN] Body: {json.dumps(redact_psk(data), indent=2)}", file=sys.stderr) + return {} + + try: + response = self._session.request( + method, url, json=data if data is not None else None, timeout=self.timeout + ) + except requests.exceptions.RequestException as exc: + print(f"Error: Sprut API {method} {url} failed: {exc}", file=sys.stderr) + sys.exit(1) + + if not 200 <= response.status_code < 300: + print(f"Error: Sprut API {method} {url} returned HTTP {response.status_code}", file=sys.stderr) + print(f"Response: {response.text}", file=sys.stderr) + sys.exit(1) + + if not response.text: + return {} + return response.json() + + def get(self, path: str) -> dict: + return self.call("GET", path) + + def post(self, path: str, data: dict) -> dict: + return self.call("POST", path, data) diff --git a/ipsec_migrator/sync.py b/ipsec_migrator/sync.py new file mode 100644 index 0000000..6d6f2bc --- /dev/null +++ b/ipsec_migrator/sync.py @@ -0,0 +1,332 @@ +"""STAGE 2-4: collecting existing Sprut objects, comparing/creating missing +IKE/IPsec policies, endpoint groups and VPN services, and finally creating +IPsec site connections. + +STAGE 3's four compare_and_create_* functions each return a +neutron_id -> sprut_id correspondence dict (replacing the bash originals' +`declare -A neutron_to_sprut_*` globals). STAGE 4 looks these up by the +raw connection's referenced IDs to build its request body. +""" +from __future__ import annotations + +import json + +from . import audit +from .models import MigrationState +from .redact import redact_psk +from .sprut_client import SprutClient +from .tables import print_kv_table + + +def _dump(obj) -> str: + return json.dumps(obj, indent=2) + + +def _jq_r_str(value) -> str: + """Mirrors `jq -r`: a JSON null stringifies to the 4-char string "null" + (not Python None/absence), a JSON boolean stringifies to lowercase + "true"/"false" (not Python's "True"/"False" -- confirmed against a real + tenant, where Neutron's ipsec site connection ".State" is a genuine JSON + boolean), everything else via str(). STAGE 4's request body in the bash + original is built entirely with `jq -n --arg`, which means every field + -- even ones that look numeric/boolean -- is sent to Sprut as a JSON + string. Do not "fix" this to native types.""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +# -------------------------------------------------------------------------- +# STAGE 2: collect existing Sprut objects (always real GETs, even in +# dry-run -- dry-run only suppresses non-GET calls). +# -------------------------------------------------------------------------- +def collect_sprut_state(sprut_client: SprutClient) -> dict: + print("Collecting IKE policies from Sprut...") + ike_policies = sprut_client.get("/vpn/ikepolicies") + print("IKE policies collected:") + print(_dump(ike_policies)) + print() + + print("Collecting IPsec policies from Sprut...") + ipsec_policies = sprut_client.get("/vpn/ipsecpolicies") + print("IPsec policies collected:") + print(_dump(ipsec_policies)) + print() + + print("Collecting Endpoint Groups from Sprut...") + endpoint_groups = sprut_client.get("/vpn/endpoint-groups") + print("Endpoint Groups collected:") + print(_dump(endpoint_groups)) + print() + + print("Collecting VPN services from Sprut...") + vpn_services = sprut_client.get("/vpn/vpnservices") + print("VPN services collected:") + print(_dump(vpn_services)) + print() + + print("Collecting IPsec site connections from Sprut...") + ipsec_site_connections = sprut_client.get("/vpn/ipsec-site-connections") + print("IPsec site connections collected:") + print(_dump(redact_psk(ipsec_site_connections))) + print() + + return { + "ike_policies": ike_policies, + "ipsec_policies": ipsec_policies, + "endpoint_groups": endpoint_groups, + "vpn_services": vpn_services, + "ipsec_site_connections": ipsec_site_connections, + } + + +# -------------------------------------------------------------------------- +# STAGE 3: compare & create missing objects. Each function is idempotent by +# whatever key Sprut exposes (name, router_id, or exact endpoints array). +# -------------------------------------------------------------------------- +def compare_and_create_ike_policies( + state: MigrationState, sprut_client: SprutClient, sprut_ike_policies: dict +) -> dict[str, str]: + mapping: dict[str, str] = {} + for neutron_id in state.in_scope_ike_policy_ids: + neutron_policy = state.neutron_ike_policy_by_id[neutron_id] + name = neutron_policy.get("name") + + sprut_id = next( + (p.get("id") for p in sprut_ike_policies.get("ikepolicies", []) if p.get("name") == name), + None, + ) + + if not sprut_id: + print(f"Creating IKE policy '{name}' in Sprut") + request_body = { + "ikepolicy": { + "name": neutron_policy.get("name"), + "phase1_negotiation_mode": neutron_policy.get("phase1_negotiation_mode"), + "auth_algorithm": neutron_policy.get("auth_algorithm"), + "encryption_algorithm": neutron_policy.get("encryption_algorithm"), + "pfs": neutron_policy.get("pfs"), + "lifetime": neutron_policy.get("lifetime"), + "ike_version": neutron_policy.get("ike_version"), + } + } + response = sprut_client.post("/vpn/ikepolicies", request_body) + sprut_id = (response.get("ikepolicy") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-{neutron_id}" + else: + print(f"IKE policy '{name}' already exists in Sprut") + + mapping[neutron_id] = sprut_id + return mapping + + +def compare_and_create_ipsec_policies( + state: MigrationState, sprut_client: SprutClient, sprut_ipsec_policies: dict +) -> dict[str, str]: + mapping: dict[str, str] = {} + for neutron_id in state.in_scope_ipsec_policy_ids: + neutron_policy = state.neutron_ipsec_policy_by_id[neutron_id] + name = neutron_policy.get("name") + + sprut_id = next( + (p.get("id") for p in sprut_ipsec_policies.get("ipsecpolicies", []) if p.get("name") == name), + None, + ) + + if not sprut_id: + print(f"Creating IPsec policy '{name}' in Sprut") + request_body = { + "ipsecpolicy": { + "name": neutron_policy.get("name"), + "transform_protocol": neutron_policy.get("transform_protocol"), + "auth_algorithm": neutron_policy.get("auth_algorithm"), + "encryption_algorithm": neutron_policy.get("encryption_algorithm"), + "encapsulation_mode": neutron_policy.get("encapsulation_mode"), + "pfs": neutron_policy.get("pfs"), + "lifetime": neutron_policy.get("lifetime"), + } + } + response = sprut_client.post("/vpn/ipsecpolicies", request_body) + sprut_id = (response.get("ipsecpolicy") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-{neutron_id}" + else: + print(f"IPsec policy '{name}' already exists in Sprut") + + mapping[neutron_id] = sprut_id + return mapping + + +def compare_and_create_endpoint_groups( + state: MigrationState, sprut_client: SprutClient, sprut_endpoint_groups: dict +) -> dict[str, str]: + mapping: dict[str, str] = {} + for neutron_id in state.in_scope_endpoint_group_ids: + neutron_group = state.neutron_endpoint_group_by_id[neutron_id] + name = neutron_group.get("name") + + converted = audit.resolve_endpoint_addresses(neutron_group, state.subnet_id_to_subnet_address) + print(f"Total converted UUIDs in endpoints: {', '.join(converted)}") + + matching = next( + (g for g in sprut_endpoint_groups.get("endpoint_groups", []) if g.get("endpoints") == converted), + None, + ) + sprut_id = matching.get("id") if matching else None + + print(f"Comparing Neutron endpoint group '{name}' with endpoints: {', '.join(converted)}") + if matching: + print(f" -> Found corresponding Sprut endpoint group with endpoints: {matching.get('endpoints')}") + else: + print(" -> No corresponding Sprut endpoint group found for these endpoints") + + if not sprut_id: + print(f"Creating Endpoint Group '{name}' in Sprut") + request_body = {"endpoint_group": {"name": name, "endpoints": converted, "type": "cidr"}} + print(f"Request body: {json.dumps(request_body)}") + response = sprut_client.post("/vpn/endpoint-groups", request_body) + sprut_id = (response.get("endpoint_group") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-{neutron_id}" + print(f"Created Sprut Endpoint group: {sprut_id}") + else: + print(f"Endpoint Group with matching endpoints already exists in Sprut with id {sprut_id}") + + mapping[neutron_id] = sprut_id + print() + return mapping + + +def compare_and_create_vpn_services( + state: MigrationState, sprut_client: SprutClient, sprut_vpn_services: dict +) -> dict[str, str]: + mapping: dict[str, str] = {} + for router_id, vpn_service_id in state.router_id_to_vpn_service_id.items(): + advanced_router_id = state.neutron_to_adv_router.get(router_id) + + sprut_id = next( + ( + s.get("id") + for s in sprut_vpn_services.get("vpnservices", []) + if s.get("router_id") == advanced_router_id + ), + None, + ) + + if not sprut_id: + print(f"Creating VPN Service for router '{router_id}' in Sprut") + request_body = {"vpnservice": {"router_id": advanced_router_id, "admin_state_up": True}} + response = sprut_client.post("/vpn/vpnservices", request_body) + sprut_id = (response.get("vpnservice") or {}).get("id") + if sprut_client.dry_run and not sprut_id: + sprut_id = f"DRY-RUN-{router_id}" + else: + print(f"VPN Service for router '{router_id}' already exists in Sprut") + + # Keyed by the neutron VPN-service id (not the router id) -- STAGE 4 + # looks this map up by the vpn-service id pulled off each connection. + mapping[vpn_service_id] = sprut_id + return mapping + + +def compare_and_create_all( + state: MigrationState, sprut_client: SprutClient, sprut_state: dict +) -> dict[str, dict[str, str]]: + ike_map = compare_and_create_ike_policies(state, sprut_client, sprut_state["ike_policies"]) + print_kv_table(ike_map, "Neutron IKE Policy ID", "Sprut IKE Policy ID", title="Neutron to Sprut IKE Policies") + + ipsec_map = compare_and_create_ipsec_policies(state, sprut_client, sprut_state["ipsec_policies"]) + print_kv_table( + ipsec_map, "Neutron IPsec Policy ID", "Sprut IPsec Policy ID", title="Neutron to Sprut IPsec Policies" + ) + + endpoint_group_map = compare_and_create_endpoint_groups(state, sprut_client, sprut_state["endpoint_groups"]) + print_kv_table( + endpoint_group_map, + "Neutron Endpoint Group ID", + "Sprut Endpoint Group ID", + title="Neutron to Sprut Endpoint Groups", + ) + + vpn_service_map = compare_and_create_vpn_services(state, sprut_client, sprut_state["vpn_services"]) + print_kv_table( + vpn_service_map, "Neutron VPN Service ID", "Sprut VPN Service ID", title="Neutron to Sprut VPN Services" + ) + + return { + "ike_policy": ike_map, + "ipsec_policy": ipsec_map, + "endpoint_group": endpoint_group_map, + "vpn_service": vpn_service_map, + } + + +# -------------------------------------------------------------------------- +# STAGE 4: create IPsec site connections, idempotent by name. +# -------------------------------------------------------------------------- +def create_ipsec_site_connections( + state: MigrationState, + sprut_client: SprutClient, + sprut_state: dict, + correspondence: dict[str, dict[str, str]], +) -> None: + sprut_connections = sprut_state["ipsec_site_connections"] + + for conn_id in state.in_scope_connection_ids: + details = state.neutron_connection_by_id[conn_id] + name = details.get("name") + + print(f"Processing IPsec site connection ID: {conn_id}") + print("IPsec site connection details:") + print(_dump(redact_psk(details))) + + existing_id = next( + ( + c.get("id") + for c in (sprut_connections.get("ipsec_site_connections") or []) + if c.get("name") == name + ), + None, + ) + if existing_id: + print(f"IPsec site connection '{name}' already exists in Sprut (id {existing_id}) — skipping") + print() + continue + + sprut_ipsecpolicy_id = correspondence["ipsec_policy"].get(details.get("ipsecpolicy_id")) + sprut_ikepolicy_id = correspondence["ike_policy"].get(details.get("ikepolicy_id")) + sprut_local_ep_group_id = correspondence["endpoint_group"].get(details.get("local_ep_group_id")) + sprut_peer_ep_group_id = correspondence["endpoint_group"].get(details.get("peer_ep_group_id")) + sprut_vpn_service_id = correspondence["vpn_service"].get(details.get("vpnservice_id")) + + # Every field below is stringified (jq --arg semantics in the bash + # original) -- do not switch mtu/admin_state_up to native types. + request_body = { + "ipsec_site_connection": { + "psk": _jq_r_str(details.get("psk")), + "initiator": _jq_r_str(details.get("initiator")), + "ipsecpolicy_id": _jq_r_str(sprut_ipsecpolicy_id), + "admin_state_up": _jq_r_str(details.get("admin_state_up")), + "mtu": _jq_r_str(details.get("mtu")), + "peer_ep_group_id": _jq_r_str(sprut_peer_ep_group_id), + "ikepolicy_id": _jq_r_str(sprut_ikepolicy_id), + "vpnservice_id": _jq_r_str(sprut_vpn_service_id), + "local_ep_group_id": _jq_r_str(sprut_local_ep_group_id), + "peer_address": _jq_r_str(details.get("peer_address")), + "peer_id": _jq_r_str(details.get("peer_id")), + "name": _jq_r_str(name), + } + } + + print(f"Creating IPsec site connection '{name}' in Sprut") + print("Executing request with body:") + print(_dump(redact_psk(request_body))) + + response = sprut_client.post("/vpn/ipsec-site-connections", request_body) + + print("API response:") + print(_dump(redact_psk(response))) + print() diff --git a/ipsec_migrator/tables.py b/ipsec_migrator/tables.py new file mode 100644 index 0000000..2aa4db5 --- /dev/null +++ b/ipsec_migrator/tables.py @@ -0,0 +1,43 @@ +"""ASCII key/value table printing. + +Replaces the bash original's ad-hoc `echo`/`printf` table blocks (STEP 1, 7, +8) and the nameref-based print_map_as_table() helper (STAGE 3) with a single +generic function. Python has no need for bash's nameref indirection -- the +dict is just passed directly. + +The bash version used a Unicode combining-overline row as an ersatz bottom +border; that's purely decorative with no functional role, so it's replaced +here with a plain ASCII divider of matching visual weight -- a deliberate, +low-risk cosmetic judgment call, not a fidelity gap. +""" +from __future__ import annotations + +from collections.abc import Iterable + + +def print_kv_table( + rows: dict[str, str] | Iterable[tuple[str, str]], + col1: str, + col2: str, + title: str | None = None, + width1: int = 36, + width2: int = 36, +) -> None: + items = list(rows.items()) if isinstance(rows, dict) else list(rows) + border = "=" * (width1 + width2 + 7) + + if title: + print(border) + print(title) + print(border) + else: + print("_" * (width1 + width2 + 7)) + + print(f"| {col1:<{width1}} | {col2:<{width2}} |") + print("-" * (width1 + width2 + 7)) + for key, value in items: + print(f"| {key:<{width1}} | {value:<{width2}} |") + print("-" * (width1 + width2 + 7)) + + if title: + print()