Compare commits
10
Commits
721c3fcfab
...
418b5645e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418b5645e5 | ||
|
|
5bd197f031 | ||
|
|
bd5de0cead | ||
|
|
c3b82cf074 | ||
|
|
9590005914 | ||
|
|
caa55d9ff8 | ||
|
|
d76418303a | ||
|
|
6196a0119a | ||
|
|
e25ef02a1a | ||
|
|
807dfde287 |
@@ -13,4 +13,5 @@
|
||||
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
|
||||
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
||||
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||
org_uid = var.org_uid
|
||||
organization = var.organization
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{
|
||||
@@ -45,7 +45,7 @@ resource "nubes_vc_nsxt_snat" "snat" {
|
||||
output "allocated_org_ip" {
|
||||
description = "Выделено внешних IP на организации"
|
||||
value = {
|
||||
org_uid = var.org_uid
|
||||
organization = var.organization
|
||||
ip_space_name = var.ip_space_name
|
||||
ip_count = var.ip_count
|
||||
}
|
||||
|
||||
@@ -16,12 +16,6 @@ variable "organization" {
|
||||
description = "Имя или UUID организации (vc_org)"
|
||||
}
|
||||
|
||||
# UUID той же организации — нужен ресурсам-модификаторам (они адресуются строго по uid)
|
||||
variable "org_uid" {
|
||||
type = string
|
||||
description = "UUID организации (vc_org) для nubes_vc_org_ip_allocation"
|
||||
}
|
||||
|
||||
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
||||
|
||||
variable "ip_space_name" {
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
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) — **задокументировать** в описании атрибута.
|
||||
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
|
||||
|
||||
## Требуется сделать (по итогам ревью) — ВЫПОЛНЕНО (коммиты `ba6c4f5`, `4b497e6`, `1236c59`)
|
||||
## Итог по ревью: что сделано и где ревью ошиблось
|
||||
|
||||
1. ✅ Заменить `JsonNormalize()` на канонизирующий plan-modifier (`parse → formatVipConfigure`) —
|
||||
закрывает баг порядка ключей (в т.ч. для `jsonencode`).
|
||||
2. ✅ Убрать запись `null` в `Required`-атрибуты (`vip_configure`, `ip_space_name`) — при пустом live
|
||||
сохраняется текущее значение state (проверка «что отправили — то и в state»).
|
||||
3. ✅ `Delete`: ошибки API → `AddError`; warning оставлен только для отсутствующего родителя.
|
||||
4. ✅ `setSnat`: вместо тихой подмены — валидация пустой строки.
|
||||
**⚠️ Совет Opus (вариант «б», канонизация в plan-modifier) — НЕВЕРЕН.** Plan-modifier не имеет права
|
||||
менять значение пользовательского атрибута: Terraform отвечает
|
||||
`Provider produced invalid plan: planned value does not match config value`.
|
||||
Это правило описано в нашем же сгенерированном коде (`22_vc_nsxt_resource.go`, комментарий в `ModifyPlan`).
|
||||
Проверено живым `terraform plan` 2026-09-24 (ошибка воспроизведена).
|
||||
|
||||
Правильное решение (коммит `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`.
|
||||
6. ✅ Поправлены/добавлены тесты канонизации (`jsonencode`-форма, пробелы, `[{}]`, невалидный JSON).
|
||||
7. ⏳ Новый релиз провайдера (2.0.19) с повторной заливкой в `nubes-dev`.
|
||||
6. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
|
||||
7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
| Стенд | Namespace | Версия | Дата заливки |
|
||||
|---|---|---|---|
|
||||
| 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 | (новая нумерация) |
|
||||
|
||||
## Как проверить
|
||||
|
||||
@@ -12,19 +12,19 @@
|
||||
|
||||
| Атрибут | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `org_uid` | string, обязательный | UUID услуги «Организация в Cloud Director» |
|
||||
| `organization` | string, обязательный | Организация: имя из ЛК или её UUID |
|
||||
| `vip_configure` | string (JSON), обязательный | Массив аллокаций: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строка |
|
||||
| `keep_on_destroy` | bool, по умолчанию `false` | Не снимать квоту при `destroy` |
|
||||
|
||||
Порядок ключей и форматирование не важны — значение канонизируется при планировании
|
||||
(важно потому, что `jsonencode` сортирует ключи по алфавиту).
|
||||
Порядок ключей и форматирование не важны — сравнение смысловое (важно потому, что `jsonencode` сортирует
|
||||
ключи по алфавиту).
|
||||
|
||||
Снять аллокацию через `vip_configure` **нельзя** (пустой массив отклоняется): для этого удали ресурс —
|
||||
тогда отправится обратный `modify` с `count = "0"`.
|
||||
|
||||
```hcl
|
||||
resource "nubes_vc_org_ip_allocation" "this" {
|
||||
org_uid = var.org_uid
|
||||
organization = var.organization
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{ name = "internet-ipv4-v1", count = "3" }
|
||||
@@ -70,6 +70,6 @@ resource "nubes_vc_nsxt_snat" "this" {
|
||||
Оба ресурса импортируются по UUID родительской услуги:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
@@ -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
|
||||
- Проверенные примеры:
|
||||
- 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
|
||||
|
||||
@@ -36,7 +36,7 @@ type OrgIpAllocationResource struct {
|
||||
|
||||
type OrgIpAllocationModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
OrgUID types.String `tfsdk:"org_uid"`
|
||||
Organization types.String `tfsdk:"organization"`
|
||||
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||
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) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
|
||||
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
|
||||
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
@@ -68,9 +68,10 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"org_uid": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
|
||||
"organization": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
|
||||
"или её UUID.",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
@@ -79,11 +80,8 @@ func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.Schem
|
||||
Required: true,
|
||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
||||
"Порядок ключей и форматирование не важны — значение канонизируется при планировании. " +
|
||||
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
|
||||
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
vipConfigureCanonical(),
|
||||
},
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
@@ -103,12 +101,18 @@ func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.Creat
|
||||
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())
|
||||
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)...)
|
||||
}
|
||||
|
||||
@@ -119,12 +123,18 @@ func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.Updat
|
||||
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())
|
||||
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)...)
|
||||
}
|
||||
|
||||
@@ -135,8 +145,13 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
|
||||
return
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,18 +172,19 @@ func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadReq
|
||||
return
|
||||
}
|
||||
|
||||
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
|
||||
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если платформа
|
||||
// ещё не вернула значение (у свежей орги `vIPConfigure: [{}]`), оставляем текущее значение state.
|
||||
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
|
||||
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
|
||||
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
|
||||
raw, ok := live["vIPConfigure"]
|
||||
if ok {
|
||||
items, parseErr := parseVipConfigure(raw)
|
||||
liveItems, parseErr := parseVipConfigure(raw)
|
||||
if parseErr != nil {
|
||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||
return
|
||||
}
|
||||
if len(items) > 0 {
|
||||
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
|
||||
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
|
||||
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
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID, err := r.resolveOrganizationUID(ctx, state.Organization)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
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) {
|
||||
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("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 целиком.
|
||||
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
|
||||
uid := strings.TrimSpace(orgUID.ValueString())
|
||||
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error {
|
||||
uid := strings.TrimSpace(orgUID)
|
||||
if uid == "" {
|
||||
return fmt.Errorf("org_uid обязателен")
|
||||
return fmt.Errorf("organization обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
@@ -349,36 +393,21 @@ func formatVipConfigure(items []vipAllocation) string {
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
// canonicalVipConfigure приводит пользовательский ввод к каноническому виду.
|
||||
// Нужен потому, что Terraform `jsonencode` сортирует ключи по алфавиту (`count` раньше `name`),
|
||||
// а API/HAR дают порядок `name,count`: без канонизации план и Read расходятся → вечный diff.
|
||||
// Невалидный JSON возвращаем как есть — содержательную ошибку выдаст apply.
|
||||
func canonicalVipConfigure(raw string) string {
|
||||
items, err := parseVipConfigure(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
|
||||
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
|
||||
func vipAllocationsEqual(a, b []vipAllocation) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return formatVipConfigure(items)
|
||||
}
|
||||
|
||||
// vipConfigureCanonical — plan modifier для атрибута vip_configure.
|
||||
type vipConfigureCanonicalPlanModifier struct{}
|
||||
|
||||
func vipConfigureCanonical() planmodifier.String {
|
||||
return vipConfigureCanonicalPlanModifier{}
|
||||
}
|
||||
|
||||
func (m vipConfigureCanonicalPlanModifier) Description(_ context.Context) string {
|
||||
return "Приводит JSON-массив vIPConfigure к каноническому виду (чтобы план совпадал с результатом Read)."
|
||||
}
|
||||
|
||||
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
|
||||
byName := make(map[string]string, len(b))
|
||||
for _, item := range b {
|
||||
byName[item.Name] = item.Count
|
||||
}
|
||||
resp.PlanValue = types.StringValue(canonicalVipConfigure(req.PlanValue.ValueString()))
|
||||
for _, item := range a {
|
||||
count, ok := byName[item.Name]
|
||||
if !ok || count != item.Count {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -51,32 +51,36 @@ func TestFormatVipConfigure_Canonical(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Канонизация пользовательского ввода: terraform jsonencode сортирует ключи по алфавиту
|
||||
// (count раньше name), а канон у нас — name,count. Без канонизации план ≠ state → вечный diff
|
||||
// (баг воспроизведён через `terraform console`, см. NOTES/20_prompts/prompt_for_opus_review_modify_resources_2026-09-24.md).
|
||||
func TestCanonicalVipConfigure_NormalizesJsonencodeForm(t *testing.T) {
|
||||
raw := `[{"count":"3","name":"internet-ipv4-v1"}]` // так отдаёт jsonencode
|
||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
|
||||
if got := canonicalVipConfigure(raw); got != want {
|
||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
||||
// Сравнение смысловое: `jsonencode` сортирует ключи по алфавиту (count раньше name),
|
||||
// но для нас это то же самое значение — переписывать state нельзя (иначе ложный дрейф).
|
||||
func TestVipAllocationsEqual_OrderInsensitive(t *testing.T) {
|
||||
a, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
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) {
|
||||
raw := `[ { "count" : "4" , "name" : "internet-ipv4-v1" }, {} ]`
|
||||
want := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
||||
if got := canonicalVipConfigure(raw); got != want {
|
||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
||||
}
|
||||
if got := canonicalVipConfigure(`[{}]`); got != "[]" {
|
||||
t.Fatalf("для [{}] ожидалось \"[]\", получено %q", got)
|
||||
}
|
||||
}
|
||||
func TestVipAllocationsEqual_Differences(t *testing.T) {
|
||||
base, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"3"}]`)
|
||||
otherCount, _ := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":"2"}]`)
|
||||
otherName, _ := parseVipConfigure(`[{"name":"internet-antiddos-v1","count":"3"}]`)
|
||||
empty, _ := parseVipConfigure(`[{}]`)
|
||||
|
||||
func TestCanonicalVipConfigure_InvalidJSONLeftAsIs(t *testing.T) {
|
||||
raw := `{not json`
|
||||
if got := canonicalVipConfigure(raw); got != raw {
|
||||
t.Fatalf("невалидный JSON должен остаться как есть: %q → %q", raw, got)
|
||||
if vipAllocationsEqual(base, otherCount) {
|
||||
t.Fatal("разный count должен считаться разными значениями")
|
||||
}
|
||||
if vipAllocationsEqual(base, otherName) {
|
||||
t.Fatal("разное имя ipSpace должно считаться разными значениями")
|
||||
}
|
||||
if vipAllocationsEqual(base, empty) {
|
||||
t.Fatal("пустая аллокация должна отличаться от непустой")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user