Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418b5645e5 | ||
|
|
5bd197f031 | ||
|
|
bd5de0cead | ||
|
|
c3b82cf074 | ||
|
|
9590005914 | ||
|
|
caa55d9ff8 | ||
|
|
d76418303a | ||
|
|
6196a0119a | ||
|
|
e25ef02a1a | ||
|
|
807dfde287 |
@@ -14,3 +14,4 @@
|
|||||||
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
||||||
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
||||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||||
|
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||||
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||||
org_uid = var.org_uid
|
organization = var.organization
|
||||||
|
|
||||||
vip_configure = jsonencode([
|
vip_configure = jsonencode([
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ resource "nubes_vc_nsxt_snat" "snat" {
|
|||||||
output "allocated_org_ip" {
|
output "allocated_org_ip" {
|
||||||
description = "Выделено внешних IP на организации"
|
description = "Выделено внешних IP на организации"
|
||||||
value = {
|
value = {
|
||||||
org_uid = var.org_uid
|
organization = var.organization
|
||||||
ip_space_name = var.ip_space_name
|
ip_space_name = var.ip_space_name
|
||||||
ip_count = var.ip_count
|
ip_count = var.ip_count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ variable "organization" {
|
|||||||
description = "Имя или UUID организации (vc_org)"
|
description = "Имя или UUID организации (vc_org)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# UUID той же организации — нужен ресурсам-модификаторам (они адресуются строго по uid)
|
|
||||||
variable "org_uid" {
|
|
||||||
type = string
|
|
||||||
description = "UUID организации (vc_org) для nubes_vc_org_ip_allocation"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
||||||
|
|
||||||
variable "ip_space_name" {
|
variable "ip_space_name" {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
nubes = {
|
nubes = {
|
||||||
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
|
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
|
||||||
version = "2.0.19"
|
version = "2.0.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ПЛАН: живой прогон цепочки на DEV_STAND/FullPipe (2026-09-24)
|
||||||
|
|
||||||
|
> Стенд: dev, орга **`organ`** (`57eeacd1-dc7f-4a52-b903-7e5f7d3c1164`, realm `sandbox.nubes.ru`, тип `saas`,
|
||||||
|
> CD-имя `WZ01325-saas`). Провайдер `2.0.19` (`terraform init -upgrade` уже сделан, `validate` — Success).
|
||||||
|
> **`apply`/`destroy` запускает только пользователь.**
|
||||||
|
|
||||||
|
## 0. Что уже готово
|
||||||
|
|
||||||
|
- Ресурсы `nubes_vc_org_ip_allocation` (modify `vIPConfigure`) и `nubes_vc_nsxt_snat` (modify `ipSpaceName`) —
|
||||||
|
в провайдере, собраны в `2.0.19`, залиты в `nubes-dev`, есть unit-тесты канонизации.
|
||||||
|
- Конфиг стенда: `DEV_STAND/FullPipe/` — `vdc.tf`, `edge.tf`, `modifiers.tf` (аллокация после эджа, затем SNAT),
|
||||||
|
`organization = "organ"` + `org_uid`.
|
||||||
|
- Орга создана вручную (в tf её нет) — по решению пользователя.
|
||||||
|
|
||||||
|
## 1. Цель прогона
|
||||||
|
|
||||||
|
Проверить **одним `apply`**: `vdc → edge → IP на орге → SNAT`, затем чистый повторный `plan` и корректный
|
||||||
|
`destroy`. Это первый живой прогон обоих новых ресурсов: CRUD до сих пор не проверялся.
|
||||||
|
|
||||||
|
## 2. Перед прогоном (проверить значения)
|
||||||
|
|
||||||
|
1. `vdc_network_provider` (`snb1`), `vdc_provider_vdc` (`Intel Broadwell 2.4`), `vdc_storage_config` (`SATA`) —
|
||||||
|
убедиться в ЛК, что доступны для орги `organ` (значения брались из ЛК для прежней орги).
|
||||||
|
2. `ip_space_name` — сначала может быть недоступен: **список ipSpace в ЛК падает** (`Can't cast Complex Object
|
||||||
|
Type Struct to String`), пока нет vDC/эджа. Брать имя из прежних HAR: `internet-ipv4-v1`.
|
||||||
|
3. `ip_count` — `"3"` (строка).
|
||||||
|
|
||||||
|
## 3. Шаги прогона (пользователь)
|
||||||
|
|
||||||
|
| # | Команда | Ожидаемый результат |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `terraform plan` | создание: `nubes_vc_vdc.vdc` → `nubes_vc_nsxt.edge` → `nubes_vc_org_ip_allocation.org_ip` → `nubes_vc_nsxt_snat.snat`; порядка не меньше |
|
||||||
|
| 2 | `terraform apply` | всё создаётся за один проход |
|
||||||
|
| 3 | `terraform plan` (повторно) | **пустой** — главный тест канонизации (иначе вечный diff) |
|
||||||
|
| 4 | проверить API (см. §4) | `vIPConfigure` и `ipSpaceName` в live-состоянии |
|
||||||
|
| 5 | изменить `ip_count` 3 → 2, `plan`+`apply` | меняется только аллокация, state сходится |
|
||||||
|
| 6 | `terraform destroy` | порядок `snat (no-needed)` → `org_ip (count=0)` → `edge` → `vdc`; орги не касается |
|
||||||
|
|
||||||
|
## 4. Что проверять и чем
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOK=$(tr -d '\n' < secrets/narodDEV.token) # токен орги organ
|
||||||
|
# состояние орги
|
||||||
|
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<org_uid>'
|
||||||
|
# состояние эджа
|
||||||
|
curl -s -H "Authorization: Bearer $TOK" 'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/<nsxt_uid>'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Гипотезы, которые прогон подтверждает/опровергает:**
|
||||||
|
|
||||||
|
1. **Имена live-ключей**: `state.params.vIPConfigure` (орга) и `state.params.ipSpaceName` (эдж) — взяты из HAR,
|
||||||
|
кодом не проверены. Если Read вернёт не то → увидим дрейф/пустое значение.
|
||||||
|
2. **Частичный payload не затирает остальное**: SNAT-модификация шлёт только `372`; `needEnableAVI`
|
||||||
|
и `virtualServicesCount` должны остаться прежними (`true` / `1`), т.к. досылаются из live
|
||||||
|
(`core/operation_run_bycode.go`). Проверить в состоянии эджа до/после.
|
||||||
|
3. **Один `apply`** проходит целиком без второго прогона (ради этого и делались ресурсы).
|
||||||
|
4. **Нет вечного diff** после apply (канонизация `vip_configure`).
|
||||||
|
5. **`Required` + пустое live** не даёт ошибок (лечение из ревью).
|
||||||
|
|
||||||
|
## 5. Точки отказа и что делать
|
||||||
|
|
||||||
|
| Симптом | Вероятная причина | Действие |
|
||||||
|
|---|---|---|
|
||||||
|
| аллокация падает `Can't cast ... Struct to String` | платформа ещё не видит `job.vcd.networkProvider`/`providerGateway` (эдж/VDC не в состоянии) | проверить порядок и фактическое состояние эджа; при необходимости — пауза/повторный `apply` |
|
||||||
|
| `Provider produced inconsistent result after apply` на `vdc`/`edge` | read-back перекрыл план (известный класс дефектов) | записать в NOTES, разбирать отдельно (это уже не про наши ресурсы) |
|
||||||
|
| повторный `plan` не пустой | порядок ключей/формат не сошлись | сверить, что вернул live, с `formatVipConfigure` |
|
||||||
|
| SNAT не включился | `372` не доехал / неверное имя ipSpace | проверить `state.params.ipSpaceName` эджа и лог операции |
|
||||||
|
| `destroy` падает | обратный modify на живой/мёртвый родитель | смотреть тексты диагностик ресурсов (мы развели: ошибка API ≠ «родителя нет») |
|
||||||
|
|
||||||
|
## 6. После прогона
|
||||||
|
|
||||||
|
1. Отчёт в `NOTES/30_analysis/` — что прошло, что упало, с HAR/логами.
|
||||||
|
2. Обновить память репозитория (подтверждённые факты вместо гипотез).
|
||||||
|
3. Если найдутся баги — отдельные коммиты + при необходимости новый релиз провайдера.
|
||||||
|
4. Публикация документации (`04_build_and_publish_docs.sh`) — отдельной командой.
|
||||||
|
|
||||||
|
**Не входит в этот прогон:** кластер Штурвал (`nubes_k8s_shturval_cluster`) — отдельным шагом, после того как
|
||||||
|
SNAT подтверждён.
|
||||||
@@ -808,14 +808,24 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
|
|||||||
(только destroy) — **задокументировать** в описании атрибута.
|
(только destroy) — **задокументировать** в описании атрибута.
|
||||||
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
|
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
|
||||||
|
|
||||||
## Требуется сделать (по итогам ревью) — ВЫПОЛНЕНО (коммиты `ba6c4f5`, `4b497e6`, `1236c59`)
|
## Итог по ревью: что сделано и где ревью ошиблось
|
||||||
|
|
||||||
1. ✅ Заменить `JsonNormalize()` на канонизирующий plan-modifier (`parse → formatVipConfigure`) —
|
**⚠️ Совет Opus (вариант «б», канонизация в plan-modifier) — НЕВЕРЕН.** Plan-modifier не имеет права
|
||||||
закрывает баг порядка ключей (в т.ч. для `jsonencode`).
|
менять значение пользовательского атрибута: Terraform отвечает
|
||||||
2. ✅ Убрать запись `null` в `Required`-атрибуты (`vip_configure`, `ip_space_name`) — при пустом live
|
`Provider produced invalid plan: planned value does not match config value`.
|
||||||
сохраняется текущее значение state (проверка «что отправили — то и в state»).
|
Это правило описано в нашем же сгенерированном коде (`22_vc_nsxt_resource.go`, комментарий в `ModifyPlan`).
|
||||||
3. ✅ `Delete`: ошибки API → `AddError`; warning оставлен только для отсутствующего родителя.
|
Проверено живым `terraform plan` 2026-09-24 (ошибка воспроизведена).
|
||||||
4. ✅ `setSnat`: вместо тихой подмены — валидация пустой строки.
|
|
||||||
|
Правильное решение (коммит `807dfde`):
|
||||||
|
- plan-modifier удалён полностью (`JsonNormalize` тоже снят — он компактит, то есть тоже менял бы значение);
|
||||||
|
- в `Read` — смысловое сравнение `vipAllocationsEqual`: если смысл совпал (порядок ключей/формат не важны),
|
||||||
|
значение пользователя НЕ переписывается; пишется только реальный дрейф.
|
||||||
|
|
||||||
|
**Выполнено корректно:**
|
||||||
|
1. ✅ Убран plan-modifier, менявший пользовательское значение; сравнение — смысловое (коммит `807dfde`).
|
||||||
|
2. ✅ `null` в `Required`-атрибуты не пишется — при пустом live сохраняется текущее значение state.
|
||||||
|
3. ✅ `Delete`: ошибки API → `AddError`; warning только для отсутствующего родителя.
|
||||||
|
4. ✅ `setSnat`: валидация пустой строки вместо тихой подмены на `no-needed`.
|
||||||
5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`.
|
5. ✅ Задокументировано: «снять всё» через `vip_configure` нельзя, только `destroy`.
|
||||||
6. ✅ Поправлены/добавлены тесты канонизации (`jsonencode`-форма, пробелы, `[{}]`, невалидный JSON).
|
6. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
|
||||||
7. ⏳ Новый релиз провайдера (2.0.19) с повторной заливкой в `nubes-dev`.
|
7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
| Стенд | Namespace | Версия | Дата заливки |
|
| Стенд | Namespace | Версия | Дата заливки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
|
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
|
||||||
| DEV | `nubes-dev` | `2.0.19` | 2026-09-24 | (fix: канонизация `vip_configure` — баг порядка ключей `jsonencode`; запрет `null` в Required-атрибутах; ошибки API в Delete → error) |
|
| DEV | `nubes-dev` | `2.0.21` | 2026-09-24 | (feat: ресурс аллокации принимает ИМЯ организации с резолвом в UUID — как `nubes_vc_vdc`; `org_uid` убран) |
|
||||||
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
|
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
|
||||||
|
|
||||||
## Как проверить
|
## Как проверить
|
||||||
|
|||||||
@@ -12,19 +12,19 @@
|
|||||||
|
|
||||||
| Атрибут | Тип | Описание |
|
| Атрибут | Тип | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `org_uid` | string, обязательный | UUID услуги «Организация в Cloud Director» |
|
| `organization` | string, обязательный | Организация: имя из ЛК или её UUID |
|
||||||
| `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка |
|
| `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка |
|
||||||
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
|
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
|
||||||
|
|
||||||
Порядок ключей и форматирование не важны — значение канонизируется при планировании
|
Порядок ключей и форматирование не важны — сравнение смысловое (важно потому, что `jsonencode` сортирует
|
||||||
(важно потому, что `jsonencode` сортирует ключи по алфавиту).
|
ключи по алфавиту).
|
||||||
|
|
||||||
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
|
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
|
||||||
тогда отправится обратный `modify` с `count = "0"`.
|
тогда отправится обратный `modify` с `count = "0"`.
|
||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
resource "nubes_vc_org_ip_allocation" "this" {
|
resource "nubes_vc_org_ip_allocation" "this" {
|
||||||
org_uid = var.org_uid
|
organization = var.organization
|
||||||
|
|
||||||
vip_configure = jsonencode([
|
vip_configure = jsonencode([
|
||||||
{ name = "internet-ipv4-v1", count = "3" }
|
{ name = "internet-ipv4-v1", count = "3" }
|
||||||
@@ -70,6 +70,6 @@ resource "nubes_vc_nsxt_snat" "this" {
|
|||||||
Оба ресурса импортируются по UUID родительской услуги:
|
Оба ресурса импортируются по UUID родительской услуги:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
terraform import nubes_vc_org_ip_allocation.this <org_uid>
|
terraform import nubes_vc_org_ip_allocation.this organ
|
||||||
terraform import nubes_vc_nsxt_snat.this <nsxt_uid>
|
terraform import nubes_vc_nsxt_snat.this <nsxt_uid>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Как развернуть vDC, Edge, внешние IP и SNAT
|
||||||
|
|
||||||
|
Пошаговая инструкция. Готовые файлы — в репозитории примеров.
|
||||||
|
|
||||||
|
Организацию создайте заранее в ЛК: Terraform её не создаёт.
|
||||||
|
Кластер Штурвал в эту инструкцию не входит — он разворачивается долго, отдельным шагом.
|
||||||
|
|
||||||
|
## 1. Скопируйте пример
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
|
||||||
|
cd tf_examples/fullpipe_chain
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Возьмите значения в ЛК
|
||||||
|
|
||||||
|
| Что | Где взять | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| Токен API | ЛК → Профиль → Токены → «Технический» | `eyJhbGciOi...` |
|
||||||
|
| Имя организации | ЛК → услуга «Организация в Cloud Director» → название услуги | `organ` |
|
||||||
|
| Сетевой провайдер | ЛК → vDC → «Сетевой провайдер» | `snb1` |
|
||||||
|
| Provider VDC | ЛК → vDC → «Provider VDC» | `Intel Broadwell 2.4` |
|
||||||
|
| Дисковая политика | ЛК → vDC → доступные дисковые политики | `SATA` |
|
||||||
|
| Имя ipSpace | ЛК → организация → внешние IP | `internet-ipv4-v1` |
|
||||||
|
|
||||||
|
## 3. Заполните значения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
```
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
api_token = "eyJhbGciOi..." # токен из шага 2
|
||||||
|
organization = "organ" # имя организации
|
||||||
|
ip_space_name = "internet-ipv4-v1"
|
||||||
|
ip_count = "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Выполните команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform init # один раз — скачает провайдер
|
||||||
|
terraform plan # покажет, что будет создано
|
||||||
|
terraform apply # создаст (подтвердить: yes)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Проверьте результат
|
||||||
|
|
||||||
|
- в ЛК появились виртуальный датацентр и сетевой шлюз;
|
||||||
|
- на организации выделены внешние IP;
|
||||||
|
- на шлюзе включён SNAT;
|
||||||
|
- повторный `terraform plan` изменений не показывает.
|
||||||
|
|
||||||
|
## 6. Удаление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
SNAT выключается, квота IP обнуляется, затем удаляются шлюз и vDC. Организация не удаляется.
|
||||||
|
|
||||||
|
## Файлы примера
|
||||||
|
|
||||||
|
| Файл | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `main.tf` | провайдер и переменные |
|
||||||
|
| `vdc.tf` | виртуальный датацентр |
|
||||||
|
| `edge.tf` | сетевой шлюз периметра (Edge) |
|
||||||
|
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
|
||||||
|
| `terraform.tfvars.example` | шаблон значений из ЛК |
|
||||||
@@ -60,4 +60,5 @@ nav:
|
|||||||
- Глоссарий: 30_registry/guides/glossary.md
|
- Глоссарий: 30_registry/guides/glossary.md
|
||||||
- Проверенные примеры:
|
- Проверенные примеры:
|
||||||
- PostgreSQL: curated/postgres/pg_user_db.md
|
- PostgreSQL: curated/postgres/pg_user_db.md
|
||||||
|
- Пайплайн vDC → Edge → IP → SNAT: curated/pipeline/vdc_edge_ip_snat.md
|
||||||
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
|
- Ресурсы-модификаторы (IP организации, SNAT): curated/modifiers/org_ip_and_snat.md
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type OrgIpAllocationResource struct {
|
|||||||
|
|
||||||
type OrgIpAllocationModel struct {
|
type OrgIpAllocationModel struct {
|
||||||
ID types.String `tfsdk:"id"`
|
ID types.String `tfsdk:"id"`
|
||||||
OrgUID types.String `tfsdk:"org_uid"`
|
Organization types.String `tfsdk:"organization"`
|
||||||
VIPConfigure types.String `tfsdk:"vip_configure"`
|
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.Met
|
|||||||
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
resp.Schema = schema.Schema{
|
resp.Schema = schema.Schema{
|
||||||
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||||
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
|
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
|
||||||
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||||
Attributes: map[string]schema.Attribute{
|
Attributes: map[string]schema.Attribute{
|
||||||
"id": schema.StringAttribute{
|
"id": schema.StringAttribute{
|
||||||
@@ -68,9 +68,10 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
|
|||||||
stringplanmodifier.UseStateForUnknown(),
|
stringplanmodifier.UseStateForUnknown(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"org_uid": schema.StringAttribute{
|
"organization": schema.StringAttribute{
|
||||||
Required: true,
|
Required: true,
|
||||||
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
|
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
|
||||||
|
"или её UUID.",
|
||||||
PlanModifiers: []planmodifier.String{
|
PlanModifiers: []planmodifier.String{
|
||||||
stringplanmodifier.RequiresReplace(),
|
stringplanmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
@@ -79,11 +80,8 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
|
|||||||
Required: true,
|
Required: true,
|
||||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
||||||
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " +
|
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
|
||||||
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
||||||
PlanModifiers: []planmodifier.String{
|
|
||||||
vipConfigureCanonical(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"keep_on_destroy": schema.BoolAttribute{
|
"keep_on_destroy": schema.BoolAttribute{
|
||||||
Optional: true,
|
Optional: true,
|
||||||
@@ -103,12 +101,18 @@ func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.Creat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||||
|
if err != nil {
|
||||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(orgUID)
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +123,18 @@ func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.Updat
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||||
|
if err != nil {
|
||||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(orgUID)
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +145,13 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||||
if orgUID == "" || r.client == nil {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,18 +172,19 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
|
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
|
||||||
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа
|
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
|
||||||
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state.
|
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
|
||||||
raw, ok := live["vIPConfigure"]
|
raw, ok := live["vIPConfigure"]
|
||||||
if ok {
|
if ok {
|
||||||
items, parseErr := parseVipConfigure(raw)
|
liveItems, parseErr := parseVipConfigure(raw)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(items) > 0 {
|
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
|
||||||
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
|
if !vipAllocationsEqual(liveItems, stateItems) {
|
||||||
|
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +199,13 @@ func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.Delet
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||||
if orgUID == "" || r.client == nil {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,14 +290,37 @@ func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.Conf
|
|||||||
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
func (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||||
uid := strings.TrimSpace(req.ID)
|
uid := strings.TrimSpace(req.ID)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...)
|
||||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_uid"), uid)...)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID.
|
||||||
|
// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc
|
||||||
|
// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go).
|
||||||
|
func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
return "", fmt.Errorf("клиент не инициализирован")
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(organization.ValueString())
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("organization обязателен")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
resolved = strings.TrimSpace(resolved)
|
||||||
|
if resolved == "" {
|
||||||
|
return "", fmt.Errorf("организация %q не найдена", raw)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
|
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
|
||||||
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
|
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
|
||||||
uid := strings.TrimSpace(orgUID.ValueString())
|
uid := strings.TrimSpace(orgUID)
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
return fmt.Errorf("org_uid обязателен")
|
return fmt.Errorf("organization обязателен")
|
||||||
}
|
}
|
||||||
if r.client == nil {
|
if r.client == nil {
|
||||||
return fmt.Errorf("клиент не инициализирован")
|
return fmt.Errorf("клиент не инициализирован")
|
||||||
@@ -349,36 +393,21 @@ func formatVipConfigure(items []vipAllocation) string {
|
|||||||
return "[" + strings.Join(parts, ",") + "]"
|
return "[" + strings.Join(parts, ",") + "]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// canonicalVipConfigure приводит пользовательский ввод к каноническому виду.
|
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
|
||||||
// Нужен потому, что Terraform `jsonencode` сортирует ключи по алфавиту (`count` раньше `name`),
|
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
|
||||||
// а API/HAR дают порядок `name,count`: без канонизации план и Read расходятся → вечный diff.
|
func vipAllocationsEqual(a, b []vipAllocation) bool {
|
||||||
// Невалидный JSON возвращаем как есть — содержательную ошибку выдаст apply.
|
if len(a) != len(b) {
|
||||||
func canonicalVipConfigure(raw string) string {
|
return false
|
||||||
items, err := parseVipConfigure(raw)
|
|
||||||
if err != nil {
|
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
return formatVipConfigure(items)
|
byName := make(map[string]string, len(b))
|
||||||
|
for _, item := range b {
|
||||||
|
byName[item.Name] = item.Count
|
||||||
}
|
}
|
||||||
|
for _, item := range a {
|
||||||
// vipConfigureCanonical — plan modifier для атрибута vip_configure.
|
count, ok := byName[item.Name]
|
||||||
type vipConfigureCanonicalPlanModifier struct{}
|
if !ok || count != item.Count {
|
||||||
|
return false
|
||||||
func vipConfigureCanonical() planmodifier.String {
|
|
||||||
return vipConfigureCanonicalPlanModifier{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m vipConfigureCanonicalPlanModifier) Description(_ context.Context) string {
|
|
||||||
return "Приводит JSON-массив vIPConfigure к каноническому виду (чтобы план совпадал с результатом Read)."
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
func (m vipConfigureCanonicalPlanModifier) MarkdownDescription(ctx context.Context) string {
|
|
||||||
return m.Description(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m vipConfigureCanonicalPlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
|
|
||||||
if req.PlanValue.IsNull() || req.PlanValue.IsUnknown() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp.PlanValue = types.StringValue(canonicalVipConfigure(req.PlanValue.ValueString()))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,32 +51,36 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Канонизация пользовательского ввода: terraform jsonencode сортирует ключи по алфавиту
|
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
|
||||||
// (count раньше name), а канон у нас — name,count. Без канонизации план ≠ state → вечный diff
|
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
|
||||||
// (баг воспроизведён через `terraform console`, см. NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md).
|
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
|
||||||
func TestCanonicalVipConfigure_NormalizesJsonencodeForm(t *testing.T) {
|
a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||||
raw := `[{"count":"3","name":"internet-ipv4-v1"}]` // так отдаёт jsonencode
|
if err != nil {
|
||||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
|
t.Fatalf("неожиданная ошибка: %v", err)
|
||||||
if got := canonicalVipConfigure(raw); got != want {
|
}
|
||||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
b, err := parseVipConfigure(`[{"count":"3","name":"internet-ipv4-v1"}]`) // так отдаёт jsonencode
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("неожиданная ошибка: %v", err)
|
||||||
|
}
|
||||||
|
if !vipAllocationsEqual(a, b) {
|
||||||
|
t.Fatal("значения должны считаться равными несмотря на порядок ключей")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCanonicalVipConfigure_CompactsAndDropsEmptyElements(t *testing.T) {
|
func TestVipAllocationsEqual_Differences(t *testing.T) {
|
||||||
raw := `[ { "count" : "4" , "name" : "internet-ipv4-v1" }, {} ]`
|
base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||||
want := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
|
||||||
if got := canonicalVipConfigure(raw); got != want {
|
otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
|
||||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
empty, _ := parseVipConfigure(`[{}]`)
|
||||||
}
|
|
||||||
if got := canonicalVipConfigure(`[{}]`); got != "[]" {
|
|
||||||
t.Fatalf("для [{}] ожидалось \"[]\", получено %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) {
|
if vipAllocationsEqual(base, otherCount) {
|
||||||
raw := `{not json`
|
t.Fatal("разный count должен считаться разными значениями")
|
||||||
if got := canonicalVipConfigure(raw); got != raw {
|
}
|
||||||
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got)
|
if vipAllocationsEqual(base, otherName) {
|
||||||
|
t.Fatal("разное имя ipSpace должно считаться разными значениями")
|
||||||
|
}
|
||||||
|
if vipAllocationsEqual(base, empty) {
|
||||||
|
t.Fatal("пустая аллокация должна отличаться от непустой")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user