Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbaadccc50 | ||
|
|
c808a3b345 | ||
|
|
aaf87d966b | ||
|
|
10520670a5 | ||
|
|
208d97e2ce | ||
|
|
03fff05117 | ||
|
|
621280a530 | ||
|
|
c9d73450b0 | ||
|
|
5fd64b68d0 | ||
|
|
4bdf03a531 | ||
|
|
eaff056d9c | ||
|
|
77de8cece6 | ||
|
|
cab606b90e | ||
|
|
c29df2173f | ||
|
|
ba3887fa69 | ||
|
|
40aef879e4 | ||
|
|
22c6c83a0f | ||
|
|
0de72e09f0 | ||
|
|
3df93ad07f | ||
|
|
57abb7bfa4 | ||
|
|
72d5771ffc | ||
|
|
d98f6036d1 | ||
|
|
1a90c7737d | ||
|
|
b8adeb6582 | ||
|
|
5a2a5e7487 | ||
|
|
1611f7afa8 | ||
|
|
418b5645e5 | ||
|
|
5bd197f031 | ||
|
|
bd5de0cead | ||
|
|
c3b82cf074 | ||
|
|
9590005914 | ||
|
|
caa55d9ff8 | ||
|
|
d76418303a | ||
|
|
6196a0119a | ||
|
|
e25ef02a1a | ||
|
|
807dfde287 | ||
|
|
721c3fcfab | ||
|
|
e6675be906 | ||
|
|
ed4493c0ee | ||
|
|
1236c59e18 | ||
|
|
4b497e61db | ||
|
|
ba6c4f5122 | ||
|
|
3374bf4e08 | ||
|
|
648db99628 | ||
|
|
9412106e3f | ||
|
|
6e6d223c22 | ||
|
|
62abcd64f5 | ||
|
|
3973f912fc | ||
|
|
73a7459a38 | ||
|
|
80d82a145a | ||
|
|
22cf2595ee | ||
|
|
574e300476 | ||
|
|
cb8389c17f | ||
|
|
664f04eb49 | ||
|
|
602b27ee1a | ||
|
|
97d5ca818e | ||
|
|
bccf8f7320 | ||
|
|
129dab97a0 | ||
|
|
75700a92da | ||
|
|
51ff9b3751 |
@@ -13,4 +13,5 @@
|
||||
коммитить после каждой правки, чтобы зафиксировать текущее состояние и избежать потери изменений. Использовать осмысленные сообщения коммитов, отражающие суть изменений.
|
||||
ВСЕГДА СОХРАНЯТЬ РЕЗЕРВНЫЕ КОПИИ ВАЖНЫХ ФАЙЛОВ ПЕРЕД ВНЕСЕНИЕМ ИЗМЕНЕНИЙ.
|
||||
НИКОГДА НЕ ПОЛАГАЙСЯ НА ПАМЯТЬ — ВСЕГДА ПРОВЕРЯЙ АКТУАЛЬНОСТЬ ИНСТРУКЦИЙ.
|
||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||
ВСЕГДА СОБЛЮДАЙ ИНСТРУКЦИИ, ДАВАЙТЕ ПОДТВЕРЖДЕНИЯ И НЕ ДЕЛАЙТЕ САМОСТОЯТЕЛЬНЫХ ИЗМЕНЕНИЙ.
|
||||
Если не на 100% уверен в распоряжениях - СПРОСИ СНОВА И ПОДТВЕРДИ. НЕ ГАДАЙ ЧТО Я ИМЛ ВВИДУ !!!!
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
.terraform.lock.hcl
|
||||
|
||||
# === Generated files (NOT code — regenerate from API) ===
|
||||
# ВАЖНО: provider/internal/provider/operation_timeouts.json — НЕ артефакт.
|
||||
# Это дефолтный конфиг таймаутов для go build/go test (см. operation_timeouts_embed.go),
|
||||
# поэтому он намеренно отслеживается git. Профильные значения — в TOOLS/config/<profile>/.
|
||||
provider/resources_yaml/
|
||||
provider/internal/resources_gen/
|
||||
|
||||
@@ -30,6 +33,9 @@ provider/generated/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
# Локально собранный провайдер под dev_overrides (см. TMP/terraformrc.dev)
|
||||
TMP/devbin/
|
||||
terraform-provider-nubes
|
||||
|
||||
# === Build artifacts (generated by devops scripts) ===
|
||||
devops/profiles/*/generated/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
resource "nubes_vc_nsxt" "edge" {
|
||||
resource_name = var.nsxt_resource_name
|
||||
|
||||
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
|
||||
vdc_type = var.nsxt_vdc_type
|
||||
|
||||
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
|
||||
# чтобы Edge гарантированно создавался после vDC.
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
|
||||
need_enable_avi = var.nsxt_need_enable_avi
|
||||
virtual_services_count = var.nsxt_virtual_services_count
|
||||
|
||||
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
|
||||
routed_net_configuration = {
|
||||
ip_addr_pool = var.nsxt_ip_addr_pool
|
||||
main_dns = var.nsxt_main_dns
|
||||
second_dns = var.nsxt_second_dns
|
||||
}
|
||||
|
||||
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
|
||||
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
|
||||
keep_on_destroy = true
|
||||
|
||||
# Повторный apply усыновляет уже работающий эдж, а не падает с
|
||||
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
|
||||
adopt_existing_on_create = true
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# =============================================================================
|
||||
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
|
||||
#
|
||||
# Порядок строго такой:
|
||||
# орга (создана вручную в ЛК)
|
||||
# -> nubes_vc_vdc.vdc
|
||||
# -> nubes_vc_nsxt.edge
|
||||
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
|
||||
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
|
||||
#
|
||||
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
|
||||
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
|
||||
# созданный vDC и Edge. Иначе modify на орге падает
|
||||
# («Can't cast Complex Object Type Struct to String»).
|
||||
# =============================================================================
|
||||
|
||||
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||
organization = var.organization
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{
|
||||
name = var.ip_space_name
|
||||
count = var.ip_count
|
||||
}
|
||||
])
|
||||
|
||||
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
|
||||
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
|
||||
# (и только после удаления кластера).
|
||||
keep_on_destroy = true
|
||||
|
||||
depends_on = [nubes_vc_nsxt.edge]
|
||||
}
|
||||
|
||||
# 2. SNAT на эдже (modify: ipSpaceName)
|
||||
resource "nubes_vc_nsxt_snat" "snat" {
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
ip_space_name = var.ip_space_name
|
||||
|
||||
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
|
||||
keep_on_destroy = true
|
||||
|
||||
# ipSpace должен быть уже выделен на организации
|
||||
depends_on = [nubes_vc_org_ip_allocation.org_ip]
|
||||
}
|
||||
|
||||
output "allocated_org_ip" {
|
||||
description = "Выделено внешних IP на организации"
|
||||
value = {
|
||||
organization = var.organization
|
||||
ip_space_name = var.ip_space_name
|
||||
ip_count = var.ip_count
|
||||
}
|
||||
}
|
||||
|
||||
output "snat_ip_space" {
|
||||
description = "ipSpace, включённый как SNAT на эдже"
|
||||
value = nubes_vc_nsxt_snat.snat.ip_space_name
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
output "vdc_id" {
|
||||
description = "UID созданного VDC"
|
||||
value = nubes_vc_vdc.vdc.id
|
||||
}
|
||||
|
||||
output "vdc_name" {
|
||||
description = "Имя VDC"
|
||||
value = nubes_vc_vdc.vdc.resource_name
|
||||
}
|
||||
|
||||
output "vdc_state_params" {
|
||||
description = "Параметры состояния VDC из API"
|
||||
value = nubes_vc_vdc.vdc.state_params
|
||||
}
|
||||
|
||||
output "nsxt_id" {
|
||||
description = "UID созданного Edge (vc_nsxt)"
|
||||
value = nubes_vc_nsxt.edge.id
|
||||
}
|
||||
|
||||
output "nsxt_name" {
|
||||
description = "Имя Edge (vc_nsxt)"
|
||||
value = nubes_vc_nsxt.edge.resource_name
|
||||
}
|
||||
|
||||
output "nsxt_state_params" {
|
||||
description = "Параметры состояния Edge (vc_nsxt) из API"
|
||||
value = nubes_vc_nsxt.edge.state_params
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
provider "nubes" {
|
||||
api_token = var.api_token
|
||||
api_endpoint = var.api_endpoint
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
# =============================================================================
|
||||
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
|
||||
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
|
||||
#
|
||||
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
|
||||
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
|
||||
# или закомментировать ресурс.
|
||||
#
|
||||
# Порядок (чек-лист из инструкции на услугу в ЛК):
|
||||
# 1) Организация в Cloud Director — создана вручную в ЛК
|
||||
# 2) nubes_vc_vdc.vdc — есть
|
||||
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
|
||||
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
|
||||
# 5) SNAT на Edge — nubes_vc_nsxt_snat
|
||||
# 6) Kubernetes кластер Штурвал — этот ресурс
|
||||
#
|
||||
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
|
||||
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
|
||||
# =============================================================================
|
||||
|
||||
# --- Переменные Штурвала ---
|
||||
|
||||
variable "shturval_resource_name" {
|
||||
type = string
|
||||
default = "shturval-dev"
|
||||
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
|
||||
}
|
||||
|
||||
variable "shturval_cluster_name" {
|
||||
type = string
|
||||
default = "shturval-dev-00"
|
||||
description = "Имя кластера внутри Штурвала"
|
||||
}
|
||||
|
||||
variable "shturval_app_version" {
|
||||
type = string
|
||||
default = "2.14.0"
|
||||
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск control plane, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество мастер-нод: 1, 3 или 5"
|
||||
}
|
||||
|
||||
variable "shturval_worker_group_name" {
|
||||
type = string
|
||||
default = "workers-shturval-dev"
|
||||
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск воркеров, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество воркер-нод (минимум 1)"
|
||||
}
|
||||
|
||||
# --- Значения, которые собираются из переменных ---
|
||||
|
||||
locals {
|
||||
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
|
||||
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
|
||||
# sizingDisk, count, autoscale, labelDeck.
|
||||
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
|
||||
# в snake_case — это ошибка генератора, платформа на них падает с
|
||||
# «Cannot invoke method split() on null object» (не находит groupName → null).
|
||||
shturval_worker_config = jsonencode([
|
||||
{
|
||||
groupName = var.shturval_worker_group_name
|
||||
sizingPolicy = var.shturval_worker_sizing_policy
|
||||
sizingDisk = var.shturval_worker_sizing_disk
|
||||
count = var.shturval_worker_count
|
||||
autoscale = false # автоскейл выключен
|
||||
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
# --- Ресурс Штурвала ---
|
||||
|
||||
resource "nubes_k8s_sthutrval_cluster" "shturval" {
|
||||
resource_name = var.shturval_resource_name
|
||||
|
||||
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
|
||||
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
|
||||
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
|
||||
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
|
||||
adopt_existing_on_create = true
|
||||
|
||||
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
|
||||
# Следующий apply усыновит его и разморозит (resume).
|
||||
suspend_on_destroy = true
|
||||
|
||||
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
|
||||
# иначе провайдер сдаётся на дефолтных 600 с.
|
||||
operation_timeout = "60m"
|
||||
|
||||
startup_configuration = {
|
||||
# vDC и Edge из этого же конфига (обязательные поля)
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
|
||||
cluster_name = var.shturval_cluster_name
|
||||
|
||||
# Дополнительные возможности кластера (в ЛК — галочки при создании)
|
||||
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
|
||||
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
|
||||
ex_local_csi = true
|
||||
ex_vip = true
|
||||
ex_update = true
|
||||
ex_ingress = true
|
||||
ex_named_csi = true
|
||||
}
|
||||
|
||||
cluster_configuration = {
|
||||
app_version = var.shturval_app_version
|
||||
}
|
||||
|
||||
control_plane_configuration = {
|
||||
sizing_policy = var.shturval_cp_sizing_policy
|
||||
sizing_disk = var.shturval_cp_sizing_disk
|
||||
count = var.shturval_cp_count
|
||||
}
|
||||
|
||||
worker_configuration = local.shturval_worker_config
|
||||
|
||||
access_configuration = {
|
||||
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
|
||||
access_ip_list_api = jsonencode([]) # пусто = доступ всем
|
||||
need_external_address_ingress = true # внешний адрес для Ingress
|
||||
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
|
||||
}
|
||||
|
||||
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
|
||||
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
api_token = "ВАШ_ТОКЕН_ИЗ_ЛК"
|
||||
|
||||
# Имя или UUID организации:
|
||||
organization = "kontora"
|
||||
|
||||
vdc_resource_name = "fullpipe-vdc"
|
||||
vdc_network_provider = "snb1"
|
||||
vdc_provider_vdc = "Intel Broadwell 2.4"
|
||||
vdc_cpu_allocated = 8
|
||||
vdc_cpu_guaranteed = 0
|
||||
vdc_mem_allocated = 32
|
||||
|
||||
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
|
||||
@@ -0,0 +1,116 @@
|
||||
variable "api_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "API-токен Nubes"
|
||||
}
|
||||
|
||||
variable "api_endpoint" {
|
||||
type = string
|
||||
default = "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc"
|
||||
description = "API Gateway URL"
|
||||
}
|
||||
|
||||
# Имя (display_name, напр. "kontora") ИЛИ UUID организации из ЛК
|
||||
variable "organization" {
|
||||
type = string
|
||||
description = "Имя или UUID организации (vc_org)"
|
||||
}
|
||||
|
||||
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
||||
|
||||
variable "ip_space_name" {
|
||||
type = string
|
||||
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
|
||||
}
|
||||
|
||||
variable "ip_count" {
|
||||
type = string
|
||||
default = "3"
|
||||
description = "Сколько внешних IP выделить на организации (count — строка)"
|
||||
}
|
||||
|
||||
variable "vdc_resource_name" {
|
||||
type = string
|
||||
default = "fullpipe-vdc"
|
||||
description = "Имя VDC"
|
||||
}
|
||||
|
||||
variable "vdc_network_provider" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Сетевой провайдер. Заполнить значением из текущей страницы ЛК"
|
||||
}
|
||||
|
||||
variable "vdc_provider_vdc" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Provider VDC. Заполнить значением из текущей страницы ЛК"
|
||||
}
|
||||
|
||||
variable "vdc_cpu_allocated" {
|
||||
type = number
|
||||
default = 8
|
||||
description = "vCPU (шт.)"
|
||||
}
|
||||
|
||||
variable "vdc_cpu_guaranteed" {
|
||||
type = number
|
||||
default = 0
|
||||
description = "Резервирование vCPU (%, допустимо: 0, 50, 80)"
|
||||
}
|
||||
|
||||
variable "vdc_mem_allocated" {
|
||||
type = number
|
||||
default = 32
|
||||
description = "RAM (GB)"
|
||||
}
|
||||
|
||||
variable "vdc_storage_config" {
|
||||
type = string
|
||||
default = "[{\"name\":\"SATA\",\"size\":\"200\"}]"
|
||||
description = "Дисковое хранилище (JSON-массив, size в GB). Имя политики должно существовать в ресурсном пуле (например, SATA, SSD)"
|
||||
}
|
||||
|
||||
# --- vc_nsxt (Сетевой шлюз периметра / Edge) ---
|
||||
|
||||
variable "nsxt_resource_name" {
|
||||
type = string
|
||||
default = "fullpipe-edge"
|
||||
description = "Имя Edge (vc_nsxt)"
|
||||
}
|
||||
|
||||
variable "nsxt_vdc_type" {
|
||||
type = string
|
||||
default = "vdc"
|
||||
description = "Тип родительской услуги: vdc или vdcGroup"
|
||||
}
|
||||
|
||||
variable "nsxt_need_enable_avi" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Включить AVI Load Balancer (ALB)"
|
||||
}
|
||||
|
||||
variable "nsxt_virtual_services_count" {
|
||||
type = number
|
||||
default = 3
|
||||
description = "Кол-во виртуальных сервисов на AVI (1..4; Штурвал: ≥ 3)"
|
||||
}
|
||||
|
||||
variable "nsxt_ip_addr_pool" {
|
||||
type = string
|
||||
default = "10.10.102.0/24"
|
||||
description = "Адресный пул routed-сети (маска /24 обязательна)"
|
||||
}
|
||||
|
||||
variable "nsxt_main_dns" {
|
||||
type = string
|
||||
default = "81.22.46.22"
|
||||
description = "Основной DNS"
|
||||
}
|
||||
|
||||
variable "nsxt_second_dns" {
|
||||
type = string
|
||||
default = "185.247.187.77"
|
||||
description = "Второй DNS"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
resource "nubes_vc_vdc" "vdc" {
|
||||
resource_name = var.vdc_resource_name
|
||||
|
||||
# Организация: имя из ЛК ("kontora") или точный UUID
|
||||
organization_uid = var.organization
|
||||
|
||||
network_provider = var.vdc_network_provider
|
||||
provider_vdc = var.vdc_provider_vdc
|
||||
|
||||
cpu_allocated = var.vdc_cpu_allocated
|
||||
cpu_guaranteed = var.vdc_cpu_guaranteed
|
||||
mem_allocated = var.vdc_mem_allocated
|
||||
|
||||
# JSON-массив дисковых политик (size в GB)
|
||||
storage_config = var.vdc_storage_config
|
||||
|
||||
suspend_on_destroy = true
|
||||
|
||||
adopt_existing_on_create = true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
|
||||
version = "2.0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,12 @@ resource "nubes_vc_nsxt" "edge" {
|
||||
main_dns = var.nsxt_main_dns
|
||||
second_dns = var.nsxt_second_dns
|
||||
}
|
||||
|
||||
# «Заморозка»: destroy НЕ удаляет эдж (у платформы для эджа нет операции suspend),
|
||||
# а только убирает его из состояния. Для полного удаления — keep_on_destroy = false.
|
||||
keep_on_destroy = true
|
||||
|
||||
# Повторный apply усыновляет уже работающий эдж, а не падает с
|
||||
# «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)».
|
||||
adopt_existing_on_create = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# =============================================================================
|
||||
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
|
||||
#
|
||||
# Порядок строго такой:
|
||||
# орга (создана вручную в ЛК)
|
||||
# -> nubes_vc_vdc.vdc
|
||||
# -> nubes_vc_nsxt.edge
|
||||
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
|
||||
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
|
||||
#
|
||||
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
|
||||
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
|
||||
# созданный vDC и Edge. Иначе modify на орге падает
|
||||
# («Can't cast Complex Object Type Struct to String»).
|
||||
# =============================================================================
|
||||
|
||||
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||
organization = var.organization
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{
|
||||
name = var.ip_space_name
|
||||
count = var.ip_count
|
||||
}
|
||||
])
|
||||
|
||||
# true = «заморозка»: destroy не трогает квоту внешних IP (кластер Штурвала держит
|
||||
# адреса, опустить count ниже занятых платформа не даёт). Для полного удаления — false
|
||||
# (и только после удаления кластера).
|
||||
keep_on_destroy = true
|
||||
|
||||
depends_on = [nubes_vc_nsxt.edge]
|
||||
}
|
||||
|
||||
# 2. SNAT на эдже (modify: ipSpaceName)
|
||||
resource "nubes_vc_nsxt_snat" "snat" {
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
ip_space_name = var.ip_space_name
|
||||
|
||||
# true = «заморозка»: destroy не выключает SNAT на эдже. Для полного удаления — false.
|
||||
keep_on_destroy = true
|
||||
|
||||
# ipSpace должен быть уже выделен на организации
|
||||
depends_on = [nubes_vc_org_ip_allocation.org_ip]
|
||||
}
|
||||
|
||||
output "allocated_org_ip" {
|
||||
description = "Выделено внешних IP на организации"
|
||||
value = {
|
||||
organization = var.organization
|
||||
ip_space_name = var.ip_space_name
|
||||
ip_count = var.ip_count
|
||||
}
|
||||
}
|
||||
|
||||
output "snat_ip_space" {
|
||||
description = "ipSpace, включённый как SNAT на эдже"
|
||||
value = nubes_vc_nsxt_snat.snat.ip_space_name
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
# =============================================================================
|
||||
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
|
||||
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
|
||||
#
|
||||
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
|
||||
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
|
||||
# или закомментировать ресурс.
|
||||
#
|
||||
# Порядок (чек-лист из инструкции на услугу в ЛК):
|
||||
# 1) Организация в Cloud Director — создана вручную в ЛК
|
||||
# 2) nubes_vc_vdc.vdc — есть
|
||||
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
|
||||
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
|
||||
# 5) SNAT на Edge — nubes_vc_nsxt_snat
|
||||
# 6) Kubernetes кластер Штурвал — этот ресурс
|
||||
#
|
||||
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
|
||||
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
|
||||
# =============================================================================
|
||||
|
||||
# --- Переменные Штурвала ---
|
||||
|
||||
variable "shturval_resource_name" {
|
||||
type = string
|
||||
default = "shturval-dev"
|
||||
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
|
||||
}
|
||||
|
||||
variable "shturval_cluster_name" {
|
||||
type = string
|
||||
default = "shturval-dev-00"
|
||||
description = "Имя кластера внутри Штурвала"
|
||||
}
|
||||
|
||||
variable "shturval_app_version" {
|
||||
type = string
|
||||
default = "2.14.0"
|
||||
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск control plane, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество мастер-нод: 1, 3 или 5"
|
||||
}
|
||||
|
||||
variable "shturval_worker_group_name" {
|
||||
type = string
|
||||
default = "workers-shturval-dev"
|
||||
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск воркеров, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество воркер-нод (минимум 1)"
|
||||
}
|
||||
|
||||
# --- Значения, которые собираются из переменных ---
|
||||
|
||||
locals {
|
||||
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
|
||||
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
|
||||
# sizingDisk, count, autoscale, labelDeck.
|
||||
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
|
||||
# в snake_case — это ошибка генератора, платформа на них падает с
|
||||
# «Cannot invoke method split() on null object» (не находит groupName → null).
|
||||
shturval_worker_config = jsonencode([
|
||||
{
|
||||
groupName = var.shturval_worker_group_name
|
||||
sizingPolicy = var.shturval_worker_sizing_policy
|
||||
sizingDisk = var.shturval_worker_sizing_disk
|
||||
count = var.shturval_worker_count
|
||||
autoscale = false # автоскейл выключен
|
||||
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
# --- Ресурс Штурвала ---
|
||||
|
||||
resource "nubes_k8s_sthutrval_cluster" "shturval" {
|
||||
resource_name = var.shturval_resource_name
|
||||
|
||||
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
|
||||
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
|
||||
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
|
||||
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
|
||||
adopt_existing_on_create = true
|
||||
|
||||
# «Заморозка»: destroy приостанавливает кластер (suspend), а не удаляет.
|
||||
# Следующий apply усыновит его и разморозит (resume).
|
||||
suspend_on_destroy = true
|
||||
|
||||
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
|
||||
# иначе провайдер сдаётся на дефолтных 600 с.
|
||||
operation_timeout = "60m"
|
||||
|
||||
startup_configuration = {
|
||||
# vDC и Edge из этого же конфига (обязательные поля)
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
|
||||
cluster_name = var.shturval_cluster_name
|
||||
|
||||
# Дополнительные возможности кластера (в ЛК — галочки при создании)
|
||||
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
|
||||
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
|
||||
ex_local_csi = true
|
||||
ex_vip = true
|
||||
ex_update = true
|
||||
ex_ingress = true
|
||||
ex_named_csi = true
|
||||
}
|
||||
|
||||
cluster_configuration = {
|
||||
app_version = var.shturval_app_version
|
||||
}
|
||||
|
||||
control_plane_configuration = {
|
||||
sizing_policy = var.shturval_cp_sizing_policy
|
||||
sizing_disk = var.shturval_cp_sizing_disk
|
||||
count = var.shturval_cp_count
|
||||
}
|
||||
|
||||
worker_configuration = local.shturval_worker_config
|
||||
|
||||
access_configuration = {
|
||||
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
|
||||
access_ip_list_api = jsonencode([]) # пусто = доступ всем
|
||||
need_external_address_ingress = true # внешний адрес для Ingress
|
||||
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
|
||||
}
|
||||
|
||||
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
|
||||
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||
}
|
||||
@@ -16,6 +16,19 @@ variable "organization" {
|
||||
description = "Имя или UUID организации (vc_org)"
|
||||
}
|
||||
|
||||
# --- Модификаторы (IP на орге + SNAT на эдже) ---
|
||||
|
||||
variable "ip_space_name" {
|
||||
type = string
|
||||
description = "Имя ipSpace, доступное организации (смотреть в ЛК, напр. internet-ipv4-v1)"
|
||||
}
|
||||
|
||||
variable "ip_count" {
|
||||
type = string
|
||||
default = "3"
|
||||
description = "Сколько внешних IP выделить на организации (count — строка)"
|
||||
}
|
||||
|
||||
variable "vdc_resource_name" {
|
||||
type = string
|
||||
default = "fullpipe-vdc"
|
||||
|
||||
@@ -4,7 +4,7 @@ terraform {
|
||||
required_providers {
|
||||
nubes = {
|
||||
source = "tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes"
|
||||
version = "2.0.17"
|
||||
version = "2.0.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 2026-09-24 — Штурвал dev-00: диагностика, adopt и дизайн «freeze on destroy»
|
||||
|
||||
Краткая запись по дню. Разбор с источниками (файл:строка, ответы API, логи) —
|
||||
`NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md`,
|
||||
резюме для продолжения — `NOTES/40_chat_summaries/CHAT_RESUME_2026-09-24_shturval_freeze.md`.
|
||||
|
||||
## Изменения в репозитории
|
||||
|
||||
| Что | Файл | Коммит |
|
||||
|---|---|---|
|
||||
| `adopt_existing_on_create = true` для кластера Штурвала (иначе apply падал на существующем suspended-инстансе) | `DEV_STAND/FullPipe/shturval.tf` | `57abb7b` |
|
||||
| Документация сессии (диагностика + дизайн freeze) | `NOTES/30_analysis/…`, `NOTES/40_chat_summaries/…` | `3df93ad` |
|
||||
| Универсальный третий режим destroy `keep_on_destroy` (`state_only`) для всех instance-ресурсов + предупреждения в `Delete` | `TOOLS/resource-generator/{types.go,loader.go,templates/instance.go}` | `22c6c83` |
|
||||
| Режим «заморозки» в конфиге стенда: `keep_on_destroy=true` (эдж/SNAT/квота IP), adopt для эджа, явный `suspend_on_destroy` у кластера | `DEV_STAND/FullPipe/{edge.tf,modifiers.tf,shturval.tf}` | `40aef87` |
|
||||
| Релиз dev-провайдера `2.0.22` (три платформы + SHA256SUMS/подпись, залито в реестр) | `VERSIONS.md` | `c29df21` |
|
||||
|
||||
## Баг после заморозки: регистр UUID внутри JSON (исправлен)
|
||||
|
||||
- Первый `apply` после freeze упал: `required params mismatch … startupConfiguration` — `nsxtUid` в плане
|
||||
(`2c37fed1-…`, lowercase из пересозданного эджа) против `2C37FED1-…` (UPPERCASE) в живом инстансе.
|
||||
- Причина: регистр UUID нормализовался в 5 местах (отправка в API, одиночные значения, create-only сравнение,
|
||||
state), но **внутри JSON** — нет; adopt приостановленного инстанса сравнивает параметр целиком как JSON.
|
||||
- Проведён аудит (8 мест, таблица в `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` §5.3).
|
||||
- Фикс: `jsonutil.LowercaseUUIDsInText` + нормализация строк внутри JSON (закрывает adopt-suspended, modifier-compare,
|
||||
state_refresh, диагностику), UUID-подстроки в `JsonNormalize()`; тесты в `jsonutil` и `resources_core`.
|
||||
- Открыто: ref-параметр внутри JSON не валидируется при adopt; регистр ключей в `lookupLiveParam`.
|
||||
|
||||
## Проверка цикла на живом стенде
|
||||
|
||||
- `terraform destroy` (провайдер `2.0.22`): `0 added, 0 changed, 5 destroyed`, ошибок нет.
|
||||
Кластер и vDC ушли в `suspend`, эдж остался `running` с `ipSpaceName=internet-ipv4-v1`, квота IP — `count=3`,
|
||||
state пуст. Предупреждения: «заморожен, а не удалён» ×2 (кластер, vDC), «оставлен как есть» (эдж),
|
||||
«Аллокация IP не снималась» (квота), «SNAT не выключался».
|
||||
- Обратный ход (`apply` → adopt + `resume`) — следующий шаг, запускает пользователь.
|
||||
|
||||
Бэкап перед правкой: `TMP/backup_2026-09-24/shturval.tf.before-adopt`.
|
||||
|
||||
## Итоги диагностики кластера `shturval-dev-00`
|
||||
|
||||
- Кластер здоров: 2 ноды Ready (k8s v1.35.1, платформа 2.14.0), `shturvalserviceconfigs` 41/41 `ready`,
|
||||
`nodeconfigitems` 4/4, endpoints есть у всех 35 сервисов.
|
||||
- Единственный «мусор» — 4 подвисших пода `kube-system/shturval-init-job` (3 Error + 1 Unknown) при
|
||||
`Complete 1/1` у Job. Причина: webhook-и Штурвала недоступны, пока Cilium не поднял сеть
|
||||
(`connect: operation not permitted`). Самоочистка по `ttlSecondsAfterFinished: 86400` (~25.09 14:31 UTC).
|
||||
- Счётчики ЛК расшифрованы: `Pods` = готовые/всего (без Completed), «Системные сервисы» = число сервисов в режиме
|
||||
`auto` (17/24 во время установки → 24/24), «Ingress» — домен-шаблон, «Конфигурация узлов» — NodeConfigItems.
|
||||
|
||||
## Итоги разбора destroy
|
||||
|
||||
- `nubes_vc_org_ip_allocation` при `keep_on_destroy = false` отправляет `count=0` и падает, если квота занята
|
||||
(2 адреса держит кластер: `.146` API, `.148` ingress; `suspend` их не освобождает).
|
||||
- Упавший destroy оставляет «рваное» состояние: SNAT снят, edge/vDC/квота — нет.
|
||||
- `adopt_existing_on_create = true` решает восстановление: apply усыновил инстанс `94627ff4-…` и сам сделал
|
||||
`resume`; SNAT восстановлен (`internet-ipv4-v1`). Проверено на живом стенде.
|
||||
|
||||
## Принятое направление (дизайн)
|
||||
|
||||
Три режима destroy в одной общей логике: `delete` (дефолт), `suspend` (где сервис умеет),
|
||||
`keep` → `state_only` (эдж, SNAT, квота IP). Реализация — через генератор
|
||||
(`TOOLS/resource-generator`), без ручных правок `resources_gen/`. Дефолты провайдера остаются разрушающими,
|
||||
freeze включается явно в `.tf` стенда; в `Delete` обязательны предупреждения («заморожено», «оставлено как есть»).
|
||||
Полный teardown — только явный opt-out и в порядке: кластер → `count=0` → SNAT → эдж → vDC.
|
||||
@@ -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 подтверждён.
|
||||
@@ -0,0 +1,227 @@
|
||||
# ПЛАН: два ресурса-модификатора для цепочки Штурвала (2026-09-24)
|
||||
|
||||
> Статус: **план, не реализовано**. Отправляется на ревью Opus.
|
||||
> Решения приняты пользователем: 2 ресурса сейчас, универсальность потом; орга — не наша (адресация по uid);
|
||||
> «один ресурс = весь массив `vIPConfigure`»; тип атрибута — String+JSON; apply — только пользователь.
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Дать клиенту возможность собрать цепочку **одним `apply`**:
|
||||
|
||||
```
|
||||
nubes_vc_org (вне state, адресация по uid)
|
||||
nubes_vc_vdc → nubes_vc_nsxt
|
||||
nubes_vc_org_ip_allocation (modify 662, vIPConfigure) ← новый ресурс
|
||||
nubes_vc_nsxt_snat (modify 372, ipSpaceName) ← новый ресурс
|
||||
nubes_k8s_shturval_cluster
|
||||
```
|
||||
|
||||
Сейчас это невозможно: `Create` не отправляет modify-only параметры, а `Update` — второй прогон.
|
||||
|
||||
## 2. Вне scope
|
||||
|
||||
- Универсальный механизм (реестр модификаторов, генераторные метки) — потом.
|
||||
- `vcExternalIp` — не разбирали.
|
||||
- Правка генератора по modify-only (см. §7) — отдельный этап, требует решения.
|
||||
|
||||
## 3. Ресурс 1 — `nubes_vc_org_ip_allocation`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Файл | `provider/internal/resources_core/org_ip_allocation_resource.go` (новый, hand-written) |
|
||||
| Регистрация | `provider/internal/provider/provider.go`, `Resources()` (рядом с `NewServiceOperationResource`) |
|
||||
| Атрибуты | `org_uid` — String, Required; `vip_configure` — String (JSON `[{"name":..,"count":..}]`), Required, нормализация JSON как в `resources_core/json_planmodifier.go`; `keep_on_destroy` — Bool, Optional, default `false` |
|
||||
| ID | `org_uid` (один ресурс на оргу; массив целиком) |
|
||||
| Create/Update | `modify` на инстансе орги: `vIPConfigure` = JSON-массив целиком (replace-семантика). Путь: `core.RunInstanceOperationUniversalByCode` (или обёртка `resources_core`), под `LockInstance(org_uid)` |
|
||||
| Read | `core.GetInstanceStateParams(org_uid)` → ключ `vIPConfigure`; пустое/`[{}]`/`count=0` → нормализовать; родитель 404/deleted → `RemoveResource` (`resources_core.ShouldRemoveFromState`). **Нужен нормализующий planmodifier** (аналог JSON-модификатора), иначе вечный дрейф при плановом 3→0 (ревью Opus, п.3) |
|
||||
| Delete | `keep_on_destroy=true` → no-op + Warning. Иначе: родитель жив → modify с `count="0"` по каждому элементу (**строкой**, как в HAR; форма проверена тестом 09-22) + Warning; родитель мёртв → no-op + Warning. Массив `[]` НЕ отправлять — не проверен (ревью Opus, п.2) |
|
||||
| Import | passthrough по `org_uid` |
|
||||
|
||||
## 4. Ресурс 2 — `nubes_vc_nsxt_snat`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Файл | `provider/internal/resources_core/nsxt_snat_resource.go` (новый) |
|
||||
| Атрибуты | `nsxt_uid` — String, Required; `ip_space_name` — String, Required (`no-needed` = SNAT выключен, канон из HAR); `keep_on_destroy` — Bool, Optional, default `false` |
|
||||
| ID | `nsxt_uid` |
|
||||
| Create/Update | `modify` 372 = `ip_space_name`. Отправляется **только** 372 (остальные досыпаются из live — проверить, см. §8 вопрос 1) |
|
||||
| Read | live `ipSpaceName` из `state.params`; отсутствует или `no-needed` → null; родитель мёртв → `RemoveResource` |
|
||||
| Delete | inverse: `modify` с `ipSpaceName = "no-needed"` (канон, подтверждён HAR) |
|
||||
| Import | passthrough по `nsxt_uid` |
|
||||
|
||||
## 5. Зависимости и порядок
|
||||
|
||||
```
|
||||
nubes_vc_nsxt → nubes_vc_org_ip_allocation → nubes_vc_nsxt_snat → k8s cluster
|
||||
```
|
||||
|
||||
- SNAT обязан зависеть от org-IP: имя ipSpace берётся из аллокации (ребра графа TF не видит — связь по имени).
|
||||
- Destroy пойдёт обратно: cluster → SNAT (`no-needed`) → org-IP (`count=0`) → nsxt → vdc.
|
||||
- Инвариант: destroy модификаторов **не трогает** саму оргу.
|
||||
|
||||
## 6. Этапы работ (последовательность)
|
||||
|
||||
1. **Проверка по коду** (чтение): приоритет live→paramValue→default при дозаполнении параметров; `instance.go:478` (что именно эмитит Update).
|
||||
2. `nubes_vc_org_ip_allocation` + регистрация + unit-тесты (нормализация JSON, чтение `[{}]`, Delete-ветки).
|
||||
3. `nubes_vc_nsxt_snat` + регистрация + unit-тесты.
|
||||
4. Общие хелперы в `resources_core` (если дублируются).
|
||||
5. Живой прогон на dev (**apply — пользователь**): `FullPipe`, орга **saas** (`organization_type = "saas"`, иначе коллизия имени `WZ03709-iaas`).
|
||||
6. Проверки после прогона: `plan` чистый (нет дрейфа), SNAT включён в одном apply, `destroy` не падает.
|
||||
7. Документация: `HOW_TO/`/`docs/`, `VERSIONS.md`, коммиты по смыслу.
|
||||
|
||||
## 7. Отдельный этап (требует решения): генератор
|
||||
|
||||
Причина — инцидент: создание `nubes_vc_org` с `v_ip_configure` даёт `inconsistent result after apply`
|
||||
(платформа после create отдаёт `vIPConfigure: [{}]`, read-back перекрывает план).
|
||||
|
||||
Минимальные правки генератора (по Opus):
|
||||
- **а)** modify-only параметр → **Optional+Computed** + `Deprecated` + не отправлять в `Update` (переход без breaking; удаление атрибута — только в следующем major);
|
||||
- **б)** исключить modify-only поля из **create-read-back** (`InputField`).
|
||||
|
||||
**Не реализуем в этом этапе** — ждём решения пользователя (правка генератора задевает все сервисы).
|
||||
|
||||
> ⚠️ По ревью Opus (2026-09-24) пункт **§7б — обязательное условие**, а не опциональное:
|
||||
> без исключения modify-only из create-read-back при переходном варианте будет борьба за поле
|
||||
> между instance-ресурсом и модификатором. Пункт остаётся обязательным follow-up.
|
||||
>
|
||||
> 📌 Раунд 3: §7б выделяется в **отдельный релиз A** (универсально, схема не меняется, non-breaking,
|
||||
> полностью закрывает инцидент `inconsistent result` на create vc_org). Пункты §7а + §7в — **релиз B**
|
||||
> вместе с новыми ресурсами.
|
||||
|
||||
## 8. Вопросы для ревью Opus
|
||||
|
||||
1. Верно ли, что `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры из **live `state.params`**
|
||||
(а не из дефолтов формы)? Если да — SNAT-ресурс может шлать только 372. Если нет — нужен явный pre-read+merge.
|
||||
2. Delete для «весь массив»: слать `[{name, count:"0"}]` (проверено тестом) или `[]` (не проверено)? Что безопаснее
|
||||
и не оставит ли `[]` элемент в state платформы?
|
||||
3. Read-нормализация: считать ли `count="0"` и `[{}]` одним состоянием «пусто»? Не даст ли это ложный дрейф
|
||||
при плановом уменьшении 3 → 0?
|
||||
4. Переходный вариант (Deprecated + Optional+Computed, instance больше не шлёт параметр): не появится ли дрейф,
|
||||
когда значение выставил модификатор, а instance-ресурс его только читает?
|
||||
5. Достаточно ли `depends_on` (SNAT → org-IP) для корректного destroy, если org-IP-модификатор должен
|
||||
уничтожиться **до** эджа? Нужны ли дополнительные рёбра?
|
||||
|
||||
---
|
||||
|
||||
## 9. Ревью Opus (2026-09-24, отдельный чат)
|
||||
|
||||
**Вердикт фактуры:** оба документа (план и `HAR_FRESH_CREATE_2026-09-24.md`) проверены по коду — факты верны,
|
||||
ссылки на пути точны.
|
||||
|
||||
**Ответы на вопросы §8:**
|
||||
|
||||
1. **Подтверждено кодом.** `operation_run_bycode.go:108-142` дозаполняет все незаданные параметры по приоритету
|
||||
**live `state.params` → `paramValue` формы → `defaultValue`**; если ничего нет — параметр пропускается.
|
||||
SNAT-ресурс может шлать только 372, pre-read+merge НЕ нужен.
|
||||
2. Слать `[{name, count:"0"}]`. `[]` не проверен, риск пустого payload/reset.
|
||||
3. `count="0"`, `[{}]`, пустой массив — одно состояние «пусто» при Read. Иначе `[{}]` после create даёт ложный
|
||||
дрейф; и для случая 3→0 нужен нормализующий planmodifier.
|
||||
4. **Дрейф возможен** в переходном варианте (борьба за поле с read-back instance-ресурса) → §7б обязателен.
|
||||
5. `depends_on` достаточно: TF развернёт граф, SNAT уничтожится до org-IP. Доп. рёбер не нужно при условии,
|
||||
что оба модификатора зависят от `nubes_vc_nsxt`, а кластер — от SNAT.
|
||||
|
||||
**Замечания кодеру:**
|
||||
- Два новых ресурса **не закрывают** инцидент `inconsistent result` на `nubes_vc_org` (Required-поле остаётся):
|
||||
§7 — обязательный follow-up, не «потом».
|
||||
- `count` в payload — **строка** `"0"` (в HAR всегда строка); зафиксировать тип явно.
|
||||
- Стенд: орга **`saas`**, иначе коллизия `WZ03709-iaas`.
|
||||
|
||||
**Фиксатор:** эпоха `kind: modifier` отменена — ветку не переиспользовать; новые ресурсы hand-written
|
||||
в `resources_core`, без реестра модификаторов.
|
||||
|
||||
---
|
||||
|
||||
## 10. Раунд 3 — вопрос Опусу: «это не поломает ничего?» (составлен 2026-09-24)
|
||||
|
||||
**Контекст (факт).** Правка шаблона `templates/instance.go` действует на все ресурсы. Замер по
|
||||
`generated/dev/resources_yaml/*.yaml`: modify-only параметры есть только у **5 сервисов** —
|
||||
`19_vc_org` (`vIPConfigure`), `22_vc_nsxt` (`ipSpaceName`), `12_s3` (`maxBucketsPerUser`,
|
||||
`maxObjectsPerBucket`, `maxSizeGbPerUser`), `90_postgres` (`refreshCert`), `109_zones_v2` (`records`).
|
||||
Цель правки — только первые два; у остальных трёх это рабочие атрибуты `Update`.
|
||||
|
||||
**Вопросы:**
|
||||
|
||||
1. **Критерий отбора.** Предлагается признак в спеке (`owned_by_modifier: true`). Это доменная метка в
|
||||
универсальном YAML, что противоречит прежнему канону «YAML без доменных меток». Какой критерий корректен
|
||||
в вашей архитектуре: spec-флаг, «required только в modify» (тогда ловится `vIPConfigure`, но **не**
|
||||
`ipSpaceName` — он `required: false`), или явный список в генераторе?
|
||||
2. **Безопасность (б)** (исключить modify-only из create-read-back): безопасно ли это для всех 5 сервисов,
|
||||
или у s3/postgres/zones read-back нужен (иначе drift/потеря значения в state)?
|
||||
3. **Поведение для существующих конфигов.** У тех, кто уже пишет `v_ip_configure`/`ip_space_name` в `.tf`,
|
||||
после (в) модификация молча перестанет отправляться. Правильно ли молчание, или нужно явное падение
|
||||
(ошибка «параметр управляется ресурсом `…ip_allocation`») — и как это сделать, если схема общая?
|
||||
4. **Снятие Required у 5 сервисов** — не ломает ли `UseStateForUnknown`/JSON-planmodifier и не порождает
|
||||
ли drift у тех, у кого поле было обязательным и уже заполнено?
|
||||
5. **Порядок релиза.** Правильно ли разводить: релиз A — только (б) (чинит create орги, ничего больше
|
||||
не трогает), релиз B — (а)+(в) вместе с новыми ресурсами-модификаторами?
|
||||
|
||||
---
|
||||
|
||||
## 11. Ответы Opus (раунд 3)
|
||||
|
||||
1. **Критерий отбора.** Структурный признак «modify-only = есть в `modifyParams`, нет в `createParams`»
|
||||
(симметрично `ComputeCreateOnly`) — факт спеки, но он ловит **все 5** сервисов и не отличает
|
||||
«управляется модификатором» от «рабочий Update-атрибут». «Required только в modify» неполон
|
||||
(пропускает `ipSpaceName`, `required:false`). **Автопризнака не существует — это доменное знание.**
|
||||
`owned_by_modifier: true` в пер-сервисном YAML — отвергнуть (нарушает канон);
|
||||
правильно — **явный список в конфиге генератора**.
|
||||
2. **Безопасность (б): безопасно для всех 5.** Read-back в create кладёт в state пост-create дефолт
|
||||
(`[{}]`), которого юзер не задавал — это и есть источник `inconsistent result`. Create их и так не шлёт.
|
||||
**Steady-state Read и Update read-back их по-прежнему перечитывают**, поэтому дрейф не теряется;
|
||||
(б) убирает только бессмысленную перезапись сразу после create. s3/postgres/zones не страдают.
|
||||
3. **Существующие конфиги.** Жёстко падать нельзя (схема общая, «владелец» — доменное знание, сломает state).
|
||||
Правильно — `Deprecated` с текстом «управляется ресурсом `…ip_allocation`» → warning на каждом plan.
|
||||
Молчаливое прекращение отправки — плохой UX, не делать. Удаление атрибута — только в следующий major.
|
||||
4. **Снятие Required.** Затрагивает только `vIPConfigure` (`ipSpaceName` уже Optional).
|
||||
`Optional+Computed` — штатный безопасный переход; `UseStateForUnknown` гасит unknown и drift не создаёт;
|
||||
у заполненных полей значение удержится через read-back. Борьба за поле снимается (б)+(в).
|
||||
5. **Порядок релиза — подтверждён:**
|
||||
- **A — только (б):** универсально, схема не меняется, non-breaking, **полностью закрывает** инцидент
|
||||
`inconsistent result` на create `nubes_vc_org`; s3/postgres/zones не трогает.
|
||||
- **B — (а)+(в) + новые ресурсы** (Deprecated на delegated-параметры, отцеп от read-back/send).
|
||||
|
||||
**Следствие для наших решений:** критерий «кто делегируется» задаётся явным списком в конфиге генератора;
|
||||
работа разбивается на релиз A (маленький, безопасный) и релиз B (ресурсы + отцепка).
|
||||
|
||||
---
|
||||
|
||||
## 12. ⚠️ Уточнение пользователя (2026-09-24): оргу делаем РУКАМИ в ЛК
|
||||
|
||||
**Факт:** орга создаётся вручную в ЛК и **в Terraform не заводится** — она одна на всё.
|
||||
В tf она используется только как uid для модификаций.
|
||||
|
||||
**Что это меняет:**
|
||||
|
||||
1. `nubes_vc_org` в конфигурации **не используется** → дефект «`inconsistent result after apply` при create орги»
|
||||
для этой задачи **не блокер** (остаётся латентным дефектом ресурса).
|
||||
2. **Релиз A (правка create-read-back) становится необязательным** для цепочки Штурвала.
|
||||
3. `nubes_vc_nsxt`: править генератор **тоже не нужно** — достаточно **не задавать** `ip_space_name` в `.tf`.
|
||||
Атрибут Optional+Computed: SNAT выставит модификатор, read-back подхватит значение в state, дрейфа не будет.
|
||||
4. Итог: для задачи нужны **только два новых ресурса** (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`),
|
||||
оба адресуются по uid родителя. Правки генератора (§7, релизы A/B) — **отдельная тема**, не вход в эту работу.
|
||||
|
||||
**Открытый вопрос:** эдж (`nubes_vc_nsxt`) создаётся Terraform или тоже руками? На состав работ не влияет
|
||||
(в обоих случаях нужны те же два ресурса), влияет только на пример конфигурации.
|
||||
|
||||
---
|
||||
|
||||
## 13. Статус работ (обновлено 2026-09-24)
|
||||
|
||||
**Сделано:**
|
||||
- ✅ Проверка по коду: `RunInstanceOperationUniversalByCode` дозаполняет незаданные параметры
|
||||
(live → paramValue → default) — частичный payload безопасен.
|
||||
- ✅ `nubes_vc_org_ip_allocation` — `provider/internal/resources_core/org_ip_allocation_resource.go`
|
||||
(коммит `22cf259`) + unit-тесты нормализации (`[{}]` → «пусто»).
|
||||
- ✅ `nubes_vc_nsxt_snat` — `provider/internal/resources_core/nsxt_snat_resource.go` (коммит `80d82a1`).
|
||||
- ✅ Регистрация в `provider/internal/provider/provider.go` (коммит `73a7459`).
|
||||
- ✅ Пример конфигурации: `tf_examples/modify_resources/` (README, `main.tf`, `terraform.tfvars.example`).
|
||||
⚠️ Каталог `tf_examples/` в `.gitignore:16` — пример локальный, как и остальные примеры в этом каталоге.
|
||||
- ✅ Публичная страница: `docs/curated/modifiers/org_ip_and_snat.md` + nav (коммит `62abcd6`).
|
||||
- ✅ Ветка-снимок состояния: `save/state-before-modify-resources-2026-09-24`.
|
||||
- ✅ `go build` / `go vet` / `go test ./...` — зелёные.
|
||||
|
||||
**Не сделано (ждёт команды пользователя):**
|
||||
- ⏳ Живой прогон на dev (`FullPipe`, орга `saas`; `apply` — только пользователь).
|
||||
- ⏳ Бамп версии провайдера, сборка и заливка (`TOOLS/scripts/03_build_and_upload_provider.sh`).
|
||||
- ⏳ Публикация документации (`04_build_and_publish_docs.sh`).
|
||||
- ⏳ Решение по правке генератора (релизы A/B, §7) — отдельная тема.
|
||||
@@ -0,0 +1,831 @@
|
||||
# Ревью Opus: два новых ресурса-модификатора (2026-09-24)
|
||||
|
||||
> Что приложено: полный код двух новых ресурсов, тестов, фрагмент регистрации, известные проблемы и вопросы.
|
||||
> Репо: `tf_provider`, коммиты `22cf259`, `80d82a1`, `73a7459`. Провайдер DEV `2.0.18` собран и залит.
|
||||
> **Просьба: ревью полное, включая то, что я не вижу. Код не писался под ревью — можно предлагать переписать.**
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
- Организация Cloud Director (сервис 19) и сетевой шлюз периметра (сервис 22) создаются **вручную в ЛК**.
|
||||
В Terraform их нет — адресуются по `uid`.
|
||||
- В схемах `nubes_vc_org` / `nubes_vc_nsxt` modify-параметры **есть** (генератор мержит create+modify),
|
||||
но `Create` их не отправляет → в одном `apply` цепочку не собрать. Поэтому сделаны два отдельных ресурса,
|
||||
которые делают только `modify`.
|
||||
- Орга и эдж — единственные ресурсы своего типа (одна орга на realm, один эдж на vDC).
|
||||
|
||||
## 2. Известный баг (найден после заливки, ещё НЕ исправлен)
|
||||
|
||||
`formatVipConfigure` (файл 1, строка 348) собирает `{"name":…,"count":…}`.
|
||||
Terraform `jsonencode` сортирует ключи по алфавиту:
|
||||
```
|
||||
$ terraform console
|
||||
> jsonencode([{name="internet-ipv4-v1", count="3"}])
|
||||
"[{\"count\":\"3\",\"name\":\"internet-ipv4-v1\"}]"
|
||||
```
|
||||
`JsonNormalize` (приложен ниже) только компактит JSON, порядок ключей не меняет.
|
||||
→ план (`count,name`) ≠ state после Read (`name,count`) → **вечный diff**.
|
||||
|
||||
## 3. Риски, которые я не могу проверить без живой платформы
|
||||
|
||||
1. `vip_configure` и `ip_space_name` — **Required**, а `Read` может вернуть `null` («аллокации нет»).
|
||||
Корректно ли это для Required-атрибута (не будет ли ошибки/вечного diff)?
|
||||
2. `Update` **не делает read-back** после modify — не приведёт ли это к inconsistent result / дрейфу.
|
||||
3. Имена live-ключей (`vIPConfigure`, `ipSpaceName`) взяты из HAR ЛК, не сверены с кодом.
|
||||
4. `setSnat`: пустая строка молча заменяется на `no-needed` (скрытое поведение).
|
||||
5. CRUD живым прогоном **не проверялся вообще** — только `go build`/`vet`/юнит-тесты парсинга.
|
||||
|
||||
## 4. Приложенный код
|
||||
|
||||
### 4.1. `provider/internal/resources_core/org_ip_allocation_resource.go`
|
||||
|
||||
```go
|
||||
package resources_core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
||||
|
||||
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
||||
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
||||
//
|
||||
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
|
||||
// и адресуется здесь по uid.
|
||||
//
|
||||
// Семантика операции — replace всего массива: переданное значение полностью заменяет
|
||||
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
|
||||
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
|
||||
type OrgIpAllocationResource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type OrgIpAllocationModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
OrgUID types.String `tfsdk:"org_uid"`
|
||||
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||
}
|
||||
|
||||
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
|
||||
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
|
||||
type vipAllocation struct {
|
||||
Name string
|
||||
Count string
|
||||
}
|
||||
|
||||
func NewOrgIpAllocationResource() resource.Resource {
|
||||
return &OrgIpAllocationResource{}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||
"Организация создаётся вручную в ЛК, ресурс адресует её по `org_uid`. " +
|
||||
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"org_uid": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "UUID существующей услуги «Организация в Cloud Director».",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"vip_configure": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка.",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
JsonNormalize(),
|
||||
},
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(false),
|
||||
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
|
||||
"`count=0` по каждому элементу).",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.applyAllocation(ctx, plan.OrgUID, plan.VIPConfigure); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.OrgUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
// Организации больше нет — ресурс тоже не нужен.
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
raw, ok := live["vIPConfigure"]
|
||||
if !ok {
|
||||
// Платформа не вернула параметр — считаем, что аллокации нет
|
||||
// (у свежей орги ключ присутствует со значением `[{}]`, что тоже «пусто»).
|
||||
state.VIPConfigure = types.StringNull()
|
||||
} else {
|
||||
items, parseErr := parseVipConfigure(raw)
|
||||
if parseErr != nil {
|
||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
state.VIPConfigure = types.StringNull()
|
||||
} else {
|
||||
state.VIPConfigure = types.StringValue(formatVipConfigure(items))
|
||||
}
|
||||
}
|
||||
|
||||
state.ID = types.StringValue(orgUID)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID := strings.TrimSpace(state.OrgUID.ValueString())
|
||||
if orgUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("не удалось проверить существование организации %s: %s", orgUID, err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(orgUID)
|
||||
defer unlock()
|
||||
|
||||
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
|
||||
items := []vipAllocation{}
|
||||
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
|
||||
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
|
||||
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
|
||||
zero := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
|
||||
}
|
||||
|
||||
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(zero),
|
||||
}); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Квота IP обнулена",
|
||||
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
|
||||
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)...)
|
||||
}
|
||||
|
||||
// applyAllocation отправляет modify с массивом vIPConfigure целиком.
|
||||
func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID types.String, vipConfigure types.String) error {
|
||||
uid := strings.TrimSpace(orgUID.ValueString())
|
||||
if uid == "" {
|
||||
return fmt.Errorf("org_uid обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
}
|
||||
|
||||
items, err := parseVipConfigure(vipConfigure.ValueString())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(uid)
|
||||
defer unlock()
|
||||
|
||||
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
|
||||
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
|
||||
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
|
||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(items),
|
||||
})
|
||||
}
|
||||
|
||||
// parseVipConfigure разбирает значение параметра vIPConfigure.
|
||||
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
|
||||
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
|
||||
func parseVipConfigure(raw string) ([]vipAllocation, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
|
||||
}
|
||||
|
||||
out := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := ""
|
||||
if v, ok := item["name"]; ok && v != nil {
|
||||
name = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
count := "0"
|
||||
if v, ok := item["count"]; ok && v != nil {
|
||||
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
|
||||
count = parsed
|
||||
}
|
||||
}
|
||||
out = append(out, vipAllocation{Name: name, Count: count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
|
||||
// (порядок ключей как в HAR; count — строка).
|
||||
func formatVipConfigure(items []vipAllocation) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count)) // ← строка 348, ИСТОЧНИК БАГА
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2. `provider/internal/resources_core/nsxt_snat_resource.go`
|
||||
|
||||
```go
|
||||
package resources_core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &NsxtSnatResource{}
|
||||
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
|
||||
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
|
||||
|
||||
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
|
||||
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
|
||||
//
|
||||
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
|
||||
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
|
||||
//
|
||||
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
|
||||
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
|
||||
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
|
||||
type NsxtSnatResource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type NsxtSnatModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
NsxtUID types.String `tfsdk:"nsxt_uid"`
|
||||
IpSpaceName types.String `tfsdk:"ip_space_name"`
|
||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||
}
|
||||
|
||||
// noNeededIpSpace — каноническое значение «SNAT не нужен».
|
||||
const noNeededIpSpace = "no-needed"
|
||||
|
||||
func NewNsxtSnatResource() resource.Resource {
|
||||
return &NsxtSnatResource{}
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
|
||||
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
|
||||
"Значение `no-needed` выключает SNAT.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"nsxt_uid": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"ip_space_name": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
|
||||
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(false),
|
||||
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
|
||||
"`ipSpaceName = \"no-needed\"`).",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||
if nsxtUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Ключа ipSpaceName нет, пока SNAT ни разу не включали (HAR fresh-create),
|
||||
// поэтому отсутствие ключа = null. Значение "no-needed" (SNAT выключен) — реальное.
|
||||
if raw, ok := live["ipSpaceName"]; !ok || strings.TrimSpace(raw) == "" {
|
||||
state.IpSpaceName = types.StringNull()
|
||||
} else {
|
||||
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
state.ID = types.StringValue(nsxtUID)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||
if nsxtUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT не выключался",
|
||||
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT не выключался",
|
||||
fmt.Sprintf("не удалось проверить существование шлюза %s: %s", nsxtUID, err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT не выключался",
|
||||
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(nsxtUID)
|
||||
defer unlock()
|
||||
|
||||
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
|
||||
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
|
||||
"ipSpaceName": noNeededIpSpace,
|
||||
}); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT выключен",
|
||||
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) 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("nsxt_uid"), uid)...)
|
||||
}
|
||||
|
||||
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
|
||||
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
|
||||
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
|
||||
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
|
||||
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
|
||||
uid := strings.TrimSpace(nsxtUID.ValueString())
|
||||
if uid == "" {
|
||||
return fmt.Errorf("nsxt_uid обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(ipSpaceName.ValueString())
|
||||
if value == "" {
|
||||
value = noNeededIpSpace
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(uid)
|
||||
defer unlock()
|
||||
|
||||
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
|
||||
// а не с live-состоянием инстанса — можно ложно пропустить modify.
|
||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||
"ipSpaceName": value,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. `provider/internal/resources_core/org_ip_allocation_test.go`
|
||||
|
||||
```go
|
||||
package resources_core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"пустая строка", "", 0},
|
||||
{"пустой массив", "[]", 0},
|
||||
{"пустой элемент (свежая орга)", "[{}]", 0},
|
||||
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
|
||||
{"элемент без name", `[{"count":"3"}]`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseVipConfigure(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != tc.want {
|
||||
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_CountAsString(t *testing.T) {
|
||||
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != "4" {
|
||||
t.Fatalf("ожидался count=\"4\", получено %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatVipConfigure_Canonical(t *testing.T) {
|
||||
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
|
||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]` // ← ожидание неверное: Terraform даёт count,name
|
||||
if got != want {
|
||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
||||
}
|
||||
if empty := formatVipConfigure(nil); empty != "[]" {
|
||||
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
|
||||
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
||||
items, err := parseVipConfigure(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if again := formatVipConfigure(items); again != raw {
|
||||
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
|
||||
t.Fatal("ожидалась ошибка на объект вместо массива")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4. Регистрация — `provider/internal/provider/provider.go`
|
||||
|
||||
```go
|
||||
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
|
||||
resources := resources_gen.AllResources()
|
||||
resources = append(resources, resources_core.NewServiceOperationResource)
|
||||
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
|
||||
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
|
||||
resources = append(resources, resources_core.NewOrgIpAllocationResource)
|
||||
resources = append(resources, resources_core.NewNsxtSnatResource)
|
||||
return resources
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5. Существующий plan-modifier `JsonNormalize` (`resources_core/json_planmodifier.go`)
|
||||
|
||||
```go
|
||||
// PlanModifyString сворачивает JSON до компактного вида.
|
||||
// Если значение не является корректным JSON — оставляет как есть, не добавляет ошибку.
|
||||
func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
|
||||
if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() {
|
||||
return
|
||||
}
|
||||
raw := req.PlanValue.ValueString()
|
||||
var buf bytes.Buffer
|
||||
if err := json.Compact(&buf, []byte(raw)); err != nil {
|
||||
return
|
||||
}
|
||||
resp.PlanValue = types.StringValue(buf.String())
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Вопросы на ревью
|
||||
|
||||
1. **Как правильно закрыть баг порядка ключей** — (а) сортировать ключи в обоих местах (`count`,`name`);
|
||||
(б) свой plan-modifier, канонизирующий ввод через parse→canonical, чтобы любой порядок от юзера сходился;
|
||||
(в) отказаться от JSON-строки и сделать nested-атрибут (тогда `jsonencode` у юзера не нужен)?
|
||||
Что правильно и что меньше ломает?
|
||||
2. **`Required` vs `Optional+Computed`** для `vip_configure` / `ip_space_name`: `Read` может вернуть «пусто».
|
||||
Корректно ли писать `null` в state для Required-атрибута, или это неверно и надо другой тип?
|
||||
3. Нужен ли **read-back после Create/Update** (сейчас его нет)? Не приведёт ли отсутствие read-back
|
||||
к inconsistent result или наоборот — к тому, что мы храним в state не то, что на платформе?
|
||||
4. **Delete**: последовательность «`ShouldRemoveFromState` → `LockInstance` → `ByCode`» корректна?
|
||||
Ошибки API при destroy — warning (как сейчас) или error?
|
||||
5. **Идемпотентность**: сознательно не используем `ByIdempotent`, потому что его сравнение идёт с `paramValue`
|
||||
формы, а не с live. Согласен, или есть другой способ не гонять лишний modify?
|
||||
6. **Имена live-ключей** (`vIPConfigure`, `ipSpaceName`): где проверить, чтобы не полагаться на HAR?
|
||||
7. **Что ещё в этом коде сломается**, чего я не вижу? Особенно: имена/семантика диагностик,
|
||||
поведение `void`-возвратов, `RemoveResource` vs `RemoveResource`-в-Delete, импорт.
|
||||
|
||||
---
|
||||
|
||||
# 6. Ответ Opus на ревью (2026-09-24)
|
||||
|
||||
**Вердикт:** главный блокер — **баг порядка ключей + `Required` с `null`**. Оба чинятся
|
||||
канонизирующим plan-modifier'ом. Всё остальное (Configure/Import/Lock/diagnostics) — корректно.
|
||||
|
||||
**По вопросам:**
|
||||
|
||||
1. **Баг порядка ключей → вариант (б):** plan-modifier, прогоняющий значение через
|
||||
`parseVipConfigure → formatVipConfigure`. Сортировка ключей (а) не спасает: `jsonencode` юзера даст
|
||||
`count,name`, а `formatVipConfigure` — `name,count`; минус только nested (в). Чинить и тест
|
||||
`TestFormatVipConfigure_Canonical` (ожидание в нём неверное).
|
||||
2. **`Required` + `null` в `Read` = источник `Provider produced inconsistent result`.** После apply
|
||||
state обязан совпасть с планом. Правильно: **не писать `null`**, хранить конфиг-значение; либо делать
|
||||
атрибут `Optional`, а не `Required`.
|
||||
3. **Read-back не обязателен**, но **канонизация ввода обязательна** — иначе inconsistent-result при первом
|
||||
`refresh` (там и всплывёт баг п.1).
|
||||
4. **Delete:** последовательность `ShouldRemoveFromState → Lock → ByCode` корректна. Но ошибки API при destroy
|
||||
должны быть **error, а не warning**: иначе реальный сбой обнуления квоты замалчивается, ресурс уходит из
|
||||
state, квота висит. Warning — только для «родителя уже нет».
|
||||
5. **Идемпотентность:** `ByCode` выбран правильно (`ByIdempotent` сравнивает с `paramValue` формы, ложно
|
||||
пропустит modify).
|
||||
6. **Имена live-ключей:** в коде провайдера их нет — только HAR; сверить можно исключительно живым
|
||||
`GetInstanceStateParams` (прогон). Пока это риск, а не факт.
|
||||
7. **Дополнительно:**
|
||||
- `setSnat`: тихая подмена `""` → `no-needed` — заменить на валидацию (ошибку).
|
||||
- `nsxt_snat.Read`: `no-needed` пишется в state как реальное значение — согласовать с решением п.2.
|
||||
- `applyAllocation` при пустом массиве → error, значит «снять всё» через `vip_configure` нельзя
|
||||
(только destroy) — **задокументировать** в описании атрибута.
|
||||
- Раздел 3 (риски живой платформы) без прогона не закрывается — остаётся открытым.
|
||||
|
||||
## Итог по ревью: что сделано и где ревью ошиблось
|
||||
|
||||
**⚠️ Совет 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. ✅ Тесты: смысловое сравнение (порядок ключей, разный count/имя, пустая аллокация).
|
||||
7. ⚠️ Релиз `2.0.19` залит, но **содержит сломанный plan-modifier** — для работы из реестра нужен `2.0.20`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# HAR fresh-create: что происходит при создании орги/эджа (dev, 2026-09-24)
|
||||
|
||||
> Источники: `HAR/globak.har` (ЛК: создание орги + vDC + эджа, затем два modify),
|
||||
> `HAR/org_already exists.har` (отказ создания орги из-за коллизии имени).
|
||||
> Стенд: `lk-api-gateway-dev.ngcloud.ru`, realm `sandbox.nubes.ru`.
|
||||
> Цель разбора: понять, что реально приходит в `state.params` после `create`
|
||||
> (влияет на read-back в сгенерированных ресурсах).
|
||||
|
||||
## 1. Поток создания в ЛК
|
||||
|
||||
Инстанс создаётся **в два шага**, не одним запросом:
|
||||
|
||||
1. `POST /instances` — тело **только** `{"serviceId":N,"displayName":"…","descr":""}`. Никаких параметров.
|
||||
2. `POST /instanceOperations` — `{"instanceUid":"…","operation":"create"}` → возвращает `instanceOperationUid`.
|
||||
3. `POST /instanceOperationCfsParams` — по одному запросу на параметр: `{"paramValue":"…","instanceOperationUid":"…","svcOperationCfsParamId":NNN}`.
|
||||
4. `GET /instanceOperations/{opUid}/validate-cfs`.
|
||||
5. `POST /instanceOperations/{opUid}/run`.
|
||||
6. Поллинг `GET /instanceOperations/{opUid}` до `dtFinish`.
|
||||
|
||||
Это в точности тот же набор эндпоинтов, что использует наш провайдер (`core/operation_run.go`, `operation_cfs.go`).
|
||||
|
||||
## 2. Параметры операций (из HAR)
|
||||
|
||||
| Сервис | Операция | Параметры |
|
||||
|---|---|---|
|
||||
| Орга (19) | create | `418 resourceRealm=sandbox.nubes.ru`, `556 organizationType`, `1125 orgSuffix` |
|
||||
| vDC (21) | create | `30`, `746`, `335`, `397`, `557`, `558`, `361` (+ `8` = uid орги) |
|
||||
| Эдж / vc_nsxt (22) | create | `621 vdcType=vdc`, `8 vdcUid`, `622`, `340 needEnableAVI`, `341 virtualServicesCount`, `825 qosProfile`, `1110 routedNetConfiguration` |
|
||||
| Орга (19) | modify (207) | `662 vIPConfigure = [{"name":"internet-ipv4-v1","count":"3"}]` |
|
||||
| Эдж (22) | modify (111) | `368 needEnableAVI`, `369 virtualServicesCount=4`, `856 qosProfile`, **`372 ipSpaceName=internet-ipv4-v1`**, `1112 routedNetConfiguration` |
|
||||
|
||||
`372 ipSpaceName` **не участвует в create** — только в modify. Ровно как в нашем `Update`
|
||||
(`22_vc_nsxt_resource.go`), который шлёт 368/369/372/856/1112.
|
||||
|
||||
## 3. `state.params` до и после modify
|
||||
|
||||
Ответ `GET /instances/{uid}`: параметры лежат в **`instance.state.params`**
|
||||
(`instance.params` = `null`). Наш `GetInstanceStateParams` (`core/instance_params.go:35-45`)
|
||||
читает именно этот путь — то есть read-back их видит.
|
||||
|
||||
| Инстанс | Сразу после create | После modify |
|
||||
|---|---|---|
|
||||
| Орга `df5ec5f2…` («kontra») | `{"admins":[], "vIPConfigure":[{}], "resourceRealm":"sandbox.nubes.ru", "organizationType":"saas"}` | `vIPConfigure=[{"name":"internet-ipv4-v1","count":"3"}]`, state version 3 → 4 |
|
||||
| Эдж `ad0ab577…` («tedj») | `vdcUid`, `vdcType`, `qosProfile="QoS-100Mbit"`, `vdcGroupUid=""`, `needEnableAVI=true`, `virtualServicesCount="1"`, `routedNetConfiguration` — **ключа `ipSpaceName` НЕТ** | `ipSpaceName="internet-ipv4-v1"`, `virtualServicesCount="4"`, version 1 → 2 |
|
||||
|
||||
Ключевое: у орги `vIPConfigure` **присутствует и равен `[{}]`** (пустой элемент);
|
||||
у эджа `ipSpaceName` **отсутствует** до первого modify.
|
||||
|
||||
## 4. Провал операции приходит внутри тела, а не HTTP-кодом
|
||||
|
||||
`HAR/org_already exists.har`: создание орги с `organizationType=iaas` и `orgSuffix=suff`:
|
||||
|
||||
- `POST /instances` → 201, `POST /instanceOperations` → 201, `validate-cfs` → 204, `run` → 201;
|
||||
- финальный `GET /instanceOperations/{opUid}`: `submitResult="201"`, `isSuccessful=false`,
|
||||
`errorLog="Организация с именем 'WZ03709-iaas' уже существует в рамках ресурсной платформы sandbox.nubes.ru"`.
|
||||
|
||||
Вывод: **ошибку операции нужно читать из `errorLog`/`isSuccessful`** поллинга; HTTP-код ничего не скажет.
|
||||
|
||||
Дополнительно: имя орги формируется как `<suffix>-<тип>` (`WZ03709-iaas` / `WZ03709-saas`),
|
||||
то есть в одном realm — по одной орге каждого типа; повтор даёт ту же ошибку.
|
||||
|
||||
## 5. Выводы для нашего провайдера
|
||||
|
||||
1. `nubes_vc_org.v_ip_configure` — **Required** в схеме (generator мержит create+modify, `loader.go:96`),
|
||||
но при `Create` не отправляется, а read-back после create вернёт `[{}]` вместо планового значения
|
||||
→ риск `Provider produced inconsistent result after apply` на создании орги. **Прогоном не проверено.**
|
||||
2. `nubes_vc_nsxt.ip_space_name` — Optional+Computed: при create ключа в state нет, значение сохраняется
|
||||
в state, но **SNAT не включается**; включается только следующим `apply` (Update → 372). **Прогоном не проверено.**
|
||||
3. `RefreshResourceState` (`resources_core/state_refresh.go`) перезаписывает поля из `state.params`;
|
||||
для modify-only параметров это поведение опасное — в create его включать не следует (универсальная правка генератора).
|
||||
4. Из п.1–2 следует, что одной правкой «добавить два ресурса-модификатора» инцидент может не закрыться:
|
||||
схема `nubes_vc_org` останется с Required-полем.
|
||||
|
||||
## 6. Ограничения разбора
|
||||
|
||||
- `apply`/`plan` не запускались: пункты 1–2 — вывод из кода + HAR, не подтверждены живым прогоном.
|
||||
- Проверено на одном стенде (dev), одной орге (`NarodOrg` — во втором HAR имя `WZ03709-iaas` уже занято).
|
||||
- `qosProfile` в create ЛК отправляет пустым, после modify в state = `QoS-100Mbit`.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Q&A с Opus: дизайн ресурсов-модификаторов (2026-09-24)
|
||||
|
||||
> Кто: вопросы составлены нами (Copilot), ответы — Opus (внешний агент, по разрешению пользователя).
|
||||
> Контекст: решено делать два ресурса-модификатора (`nubes_vc_org_ip_allocation`, `nubes_vc_nsxt_snat`).
|
||||
> Статус: ответы приняты к сведению, **код не писался**, часть утверждений Opus мною не проверена (пометки ниже).
|
||||
|
||||
## Вопросы и ответы
|
||||
|
||||
### 1. Инварианты Read/Delete ресурса-модификатора
|
||||
**Ответ Opus:**
|
||||
- Read: `RemoveResource` только если родитель исчез (404/deleted) — у нас есть `ShouldRemoveFromState`
|
||||
(Opus ссылается на `modifier.go`). Расхождение значения параметра ≠ повод удалять ресурс: это дрейф,
|
||||
обновить поле в state.
|
||||
- Delete = inverse modify (`count=0` / `needEnableAVI=false` / `ipSpaceName="no-needed"`) — «шаблон
|
||||
`DeleteStrategy=inverse` + `override` уже реализован».
|
||||
- Если родитель уже удалён: inverse пропустить, ресурс убрать из state (no-op + Warning), не падать на ошибке API.
|
||||
|
||||
### 2. Reset-to-default в `*WithDefaults`
|
||||
**Ответ Opus:** защита «уже встроена»: и `RunInstanceOperationUniversalWithDefaults` (`operation_run.go:138`),
|
||||
и by-code путь (`operation_run_bycode.go:108`) досылают незаданные параметры с приоритетом
|
||||
**live `state.params` → `paramValue` формы → `defaultValue`**. Достаточно шлать только `ipSpaceName`.
|
||||
Отдельный pre-read live + merge делать не нужно; `ByCode`/`ByIdempotent` — не нужны.
|
||||
Дополнительно `RunOperationByCodeIdempotent` (`check_before_run`) сверяет desired == current и пропускает лишний run.
|
||||
|
||||
### 3. Генератор: modify-only параметр с `required: true`
|
||||
**Ответ Opus (минимальный набор):**
|
||||
- **(а)** modify-only → всегда Optional (снять Required в схеме). Обязательно.
|
||||
- **(б)** исключить modify-only из create-read-back (не добавлять его `InputField` в Create/Read). Обязательно.
|
||||
- **(в)** «после create догонять modify» — **не нужно**: это ответственность отдельного modifier-ресурса.
|
||||
- Breaking: снятие Required — не breaking (Optional шире). Breaking — если **удалить** атрибут из схемы
|
||||
instance у тех, кто его уже прописал в `.tf`. Формулировка Opus: «modify-only параметров в схеме instance
|
||||
быть не должно вовсе — их место в modifier-ресурсе».
|
||||
|
||||
### 4. Диагноз «inconsistent result after apply» на создании орги
|
||||
**Ответ Opus: подтверждает.** `state_refresh.go`, цикл `inputs`: берёт `paramsMap["vIPConfigure"]` из
|
||||
`state.params` (платформа отдаёт `[{}]`), через `setFieldValue`/`ParseString` перекрывает план; для
|
||||
Required-атрибута TF требует final == config → ошибка. Корректно: не читать modify-only обратно в Create
|
||||
(п.3б) и вернуть запланированное значение, либо Optional+Computed со схлопыванием `[{}]`→null.
|
||||
|
||||
### 5. Порядок destroy
|
||||
**Ответ Opus:** явный `depends_on` нужен — связь между org-IP и SNAT идёт по **имени** ipSpace, ребра графа
|
||||
TF не видит. Цепочка: `vdc → org → org-IP → edge → SNAT → кластер`; при корректных `depends_on` destroy
|
||||
пойдёт в обратном порядке. Обязательные рёбра: SNAT → org-IP, modifier → родитель. Достаточно при условии,
|
||||
что inverse-Delete терпит уже удалённого родителя (п.1).
|
||||
|
||||
### 6. Трактовка `[{}]` в Read
|
||||
**Ответ Opus:** `[{}]` = «не выделено», нормализовать в null/пусто. `count=0` и `[{}]` — одно состояние
|
||||
«пусто», иначе ложный дрейф на каждом plan.
|
||||
|
||||
## Мои замечания к ответам (не проверено кодом, требует внимания)
|
||||
|
||||
1. **Opus опирается на machinery отменённого захода.** Он говорит про `modifier.go`, `DeleteStrategy=inverse`,
|
||||
`override`, «уже реализовано». Это шаблон генератора из эпохи `kind: modifier`, которую мы **сознательно
|
||||
отменили** (см. баннер LEGACY в `NOTES/20_prompts/**`, `docs/60_strategy/modifier_resources_ideology_and_specification.md`).
|
||||
Ответы про «уже встроено» нельзя принимать как готовое решение — это код отменённой ветки.
|
||||
2. **Противоречие внутри п.3:** сначала «modify-only → всегда Optional (оставить в схеме instance)»,
|
||||
потом «modify-only в схеме instance быть не должно вовсе». Это разные изменения: Optional+Computed vs удаление.
|
||||
Нужно выбрать одно, иначе получим двух владельцев одного параметра (instance-ресурс и модификатор).
|
||||
3. **Два владельца параметра.** Если `ip_space_name` остаётся в `nubes_vc_nsxt` **и** появляется
|
||||
`nubes_vc_nsxt_snat`, Terraform не увидит конфликт: оба будут шлать 372. Значит, из `Update`
|
||||
сгенерированного `nubes_vc_nsxt` параметр надо убирать — иначе fight/drift. В ответах Opus этого нет.
|
||||
4. **П.2 не проверял сам.** Утверждение «приоритет live → paramValue → defaultValue уже встроен» противоречит
|
||||
комментарию в `19_vc_org_resource.go` про reset-баг (`state_params["needEnableAVI"]="false"`, когда на
|
||||
платформе `true`). Нужна проверка `operation_run.go:138` и `operation_run_bycode.go:108` по коду.
|
||||
5. **Политика destroy для не-нашей орги.** Орга не в state (адресация по uid). При `destroy` конфигурации
|
||||
родитель не удаляется — но org-IP-модификатор по §1 выполнит inverse (`count=0`). Нужно решение:
|
||||
снимать квоту или оставлять (`keep_on_destroy`)— у Opus этого нет.
|
||||
6. **Один элемент vs весь массив.** `vIPConfigure` — массив. Если ресурс управляет одним элементом
|
||||
(по `ip_space_name`), то два ресурса на разные ipSpace возможны; если всем массивом — нет. `count=0`
|
||||
как inverse предполагает поэлементную модель, но в ответах это не зафиксировано.
|
||||
|
||||
---
|
||||
|
||||
# Раунд 2 (те же сутки): ответы Opus на 5 уточняющих вопросов
|
||||
|
||||
### Про противоречие в п.3 (раунд 1)
|
||||
Opus признал: это были две несовместимые опции.
|
||||
- **Канон (цель):** modify-only параметра в схеме instance быть не должно — владелец отдельный modifier-ресурс.
|
||||
- **«Всегда Optional»** — только переходный вариант, если параметр временно оставлен в instance.
|
||||
- Одновременно оба тезиса не действуют.
|
||||
|
||||
### 1. Два владельца 372/662
|
||||
Один владелец. Instance **перестаёт слать** 372/662: убрать из `ModifyParams` генератора
|
||||
(не эмитить в `params` map в `instance.go:478`, Update). Незаданные параметры при этом не сбросятся —
|
||||
досылаются из live `state.params` (см. п.2 раунда 1). Поле в instance остаётся максимум как read-back
|
||||
(Computed) либо убирается вовсе.
|
||||
|
||||
### 2. Массив vs элемент
|
||||
`vIPConfigure` — `array-map-fixed` с **replace-семантикой всего массива**: отправка `[{name,count}]`
|
||||
перезаписывает массив целиком.
|
||||
- **Один ресурс = весь массив** — просто и безопасно.
|
||||
- Два ресурса на разные ipSpace — только с read→merge→send-full-array; без merge last-write-wins.
|
||||
- **Рекомендация MVP Opus: один ресурс = весь массив.** Мультиресурс по имени — отдельная фича.
|
||||
|
||||
### 3. Destroy, когда родитель не наш
|
||||
Флаг `keep_on_destroy` (bool, Optional):
|
||||
- родитель жив и `keep_on_destroy=false` (дефолт) → inverse (`count=0`);
|
||||
- родитель 404 / вне нашего контроля → пропустить + Warning (не падать);
|
||||
- `keep_on_destroy=true` → всегда no-op + Warning.
|
||||
|
||||
### 4. Вывод атрибута из instance-схемы (не breaking)
|
||||
Два шага:
|
||||
- **сейчас**: `Deprecated: "..."` + **Optional+Computed** + прекратить отправку в modify (read-back остаётся);
|
||||
- **следующий major**: удалить атрибут.
|
||||
|
||||
### 5. Тип атрибута в новом ресурсе
|
||||
**String + JSON + `JsonNormalize()`** (как сейчас `v_ip_configure`), потому что:
|
||||
- wire-формат `array-map-fixed` — JSON-строка;
|
||||
- `json_planmodifier.go` — `planmodifier.String` (на list/nested не встанет);
|
||||
- `RefreshResourceState` читает input-поля только как scalar string/bool/int.
|
||||
Nested list даёт лучший UX, но требует нового кода в `state_refresh.go`. Для MVP — String+JsonNormalize.
|
||||
|
||||
## Мои замечания к раунду 2
|
||||
|
||||
1. **П.2 меняет интерфейс заявленного ресурса.** Мы планировали `nubes_vc_org_ip_allocation`
|
||||
с `ip_space_name` + `count` (по элементу). Opus рекомендует «один ресурс = весь массив»
|
||||
(list `{name,count}`). Это разные ресурсы по UX и по семантике Delete — требует решения пользователя.
|
||||
2. **Проверяемость.** Утверждение про `instance.go:478` и про приоритет live при дозаполнении я не
|
||||
проверял по коду — числовой якорь может быть неточным (в прошлом ответе он ссылался на
|
||||
`modifier.go` отменённой ветки).
|
||||
3. **`Deprecated` + `Optional+Computed`** — единственный вариант, который проходит без breaking, согласен;
|
||||
но это правка сгенерированной схемы → правится в генераторе, не в `resources_gen/*.go`.
|
||||
|
||||
## Что дальше
|
||||
|
||||
- Решение пользователя по п.2 замечаний (элемент vs весь массив).
|
||||
- Проверка по коду приоритета live-дозаполнения и строки `instance.go:478` (чтение, без правок).
|
||||
- После решения — план реализации 2 ресурсов.
|
||||
@@ -0,0 +1,307 @@
|
||||
# Штурвал dev-00: диагностика, destroy-засада с квотой IP и дизайн «freeze on destroy» (2026-09-24)
|
||||
|
||||
> Источники: `kubectl` из локали (контекст `tazet@narod.ru@shturval-dev-00`), API ЛК dev
|
||||
> (`https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc`, токен `secrets/narodDEV.token`), код провайдера
|
||||
> (`provider/`), генератор (`TOOLS/resource-generator/`), конфиг стенда `DEV_STAND/FullPipe/`.
|
||||
> Все выводы — только из этих источников; где не проверено, отмечено «не проверено».
|
||||
|
||||
---
|
||||
|
||||
## 1. Стенд и кластер
|
||||
|
||||
- Услуга **150 «Kubernetes кластер Штурвал»**, инстанс `shturval-dev`, uid `94627ff4-33a5-48f2-aca1-695741e0b6a2`.
|
||||
Создан 24.09.2026 17:18:01, операция `create` завершена 17:36:58 (`isSuccessful=true`, `errorLog=null`).
|
||||
- `state.out`: `webUrl=https://k8s.ngcloud.ru/clusters/shturval-dev-00/dashboard`,
|
||||
`kubernetesApiAddress=185.247.187.146`, `ingressAddress=185.247.187.148`.
|
||||
- Параметры: `clusterName=shturval-dev-00`, `vdcUid=d0937335-…` (`fullpipe-vdc`),
|
||||
`nsxtUid=2C37FED1-E8F8-4A84-8434-7851C7C8B5D6` (эдж `fullpipe-edge`), `appVersion=2.14.0`,
|
||||
`exLogging/exMonitoring/exLocalCsi/exVip/exUpdate/exIngress/exNamedCsi = true`, CP 1× `TKG 4CPU 8RAM` / 50 ГБ,
|
||||
workers 1× `TKG 4CPU 8RAM` / 50 ГБ (`workers-shturval-dev`, labelDeck=true).
|
||||
- kubeconfig: сервер `https://185.247.187.146:6443`; client v1.34.1, server v1.35.1; узлы 2× Ready
|
||||
(control-plane + worker), Ubuntu 24.04.5, containerd 2.2.1.
|
||||
- Организация `organ` (uid `57eeacd1-dc7f-4a52-b903-7e5f7d3c1164`, CD-имя `WZ01325-saas`, realm `sandbox.nubes.ru`).
|
||||
|
||||
### Состояние кластера (снимок 17:52 MSK)
|
||||
|
||||
- Подов 49 (готовых 44). Не-Running остались только подвисшие поды установщика:
|
||||
`shturval-init-job-489mk`, `-98n4k`, `-vkwk7` (Error), `-l8457` (Unknown); рядом `-9587k` (Completed).
|
||||
- Job `kube-system/shturval-init-job`: label `shturval.tech/init`, **без ownerReferences**,
|
||||
`backoffLimit: 10`, `ttlSecondsAfterFinished: 86400`, nodeSelector `control-plane`,
|
||||
образ `r.shturval.tech/shturval-install:2.14.0`, `/scripts/run.sh`. Итог: `failed: 4`, `succeeded: 1`,
|
||||
завершён 14:31:46 UTC (17:31 MSK) → поды удалятся сами ~25.09 14:31 UTC.
|
||||
- Причина падений (лог пода): `UPGRADE FAILED: failed to create resource: conversion webhook for
|
||||
ops.shturval.tech/v1beta1, Kind=ShturvalRepoConfig failed: Post
|
||||
"https://shturval-services-webhook-service.shturval-services-system.svc:443/convert?timeout=30s":
|
||||
dial tcp 10.97.147.91:443: connect: operation not permitted`. То же в логе оператора:
|
||||
`failed calling webhook "vshturvalupdate.kb.io" … connect: operation not permitted` — до готовности Cilium
|
||||
webhook-и недоступны. Следующая попытка прошла (релиз `shturval-services.v3`), всё поднялось.
|
||||
- Остальное зелёное: `shturvalserviceconfigs` 41/41 `ready=true` (24 в режиме `auto`, 17 в `absent`),
|
||||
`nodeconfigitems` 4/4 `ready=true`, `nodeconfigs` 2/2, у всех 35 сервисов есть endpoints,
|
||||
IngressClass `nginx` 1, ingress-controller 1/1.
|
||||
|
||||
### UI-счётчики (расшифровка)
|
||||
|
||||
| Колонка | Что это на самом деле |
|
||||
|---|---|
|
||||
| `Pods 44/48` | готовые/всего поды (48 = все поды минус `Completed`) |
|
||||
| «Системные сервисы» | число `ShturvalServiceConfig` в режиме `auto`: 17/24 во время установки → 24/24 |
|
||||
| «Конфигурация узлов» 4/4 | `nodeconfigitems.node.shturval.tech` `ready=true` |
|
||||
| «Ingress» | домен `*.shturval-dev-00.ip-185-247-187-148.shturval.link`, **не** счётчик |
|
||||
| ⚠️ на «Pods» | ровно 4 подвисших пода `shturval-init-job` |
|
||||
|
||||
Статус «Работает с ошибками» в первом снимке (31/53 подов) был снят во время установки; после догрузки — «Работает».
|
||||
|
||||
---
|
||||
|
||||
## 2. Что DevOps может сделать с мусором init-job
|
||||
|
||||
Ответ на вопрос «что выставить в настройках деплоя Штурвала»:
|
||||
|
||||
- В услуге 150 и в конфиге стенда таких ручек **нет** (есть только `vdc_uid`/`nsxt_uid`, `cluster_name`,
|
||||
галочки `ex_*`, sizing/count, внешние адреса). `backoffLimit` и `ttlSecondsAfterFinished` зашиты
|
||||
в манифест установщика платформы.
|
||||
- Поэтому вариантов два: подождать самоочистку по `ttlSecondsAfterFinished: 86400`, либо тикет в команду
|
||||
Штурвала: уменьшить TTL и/или не запускать установку компонентов до готовности Cilium
|
||||
(иначе снова `operation not permitted` на webhook-ах).
|
||||
|
||||
---
|
||||
|
||||
## 3. Destroy стенда и засада с квотой IP
|
||||
|
||||
Порядок destroy: `nubes_vc_nsxt_snat.snat` → `nubes_vc_org_ip_allocation.org_ip` → `nubes_vc_nsxt.edge` → `nubes_vc_vdc.vdc`.
|
||||
|
||||
- `snat` удалился успешно (2m27s), отправив modify `ipSpaceName = "no-needed"` (warning «SNAT выключен»).
|
||||
- `org_ip_allocation` упал:
|
||||
`Error: Ошибка клиента — операция D9FB606D-C86E-4D30-A58D-44282C4508AE завершилась с ошибкой:
|
||||
Кол-во зантяых Ip в тенанте 'WZ01325-saas': 2. Невозможно выставить параметр count ниже этого параметра`.
|
||||
- Причина в коде: `Delete` аллокации при `keep_on_destroy = false` отправляет обратный modify
|
||||
`vIPConfigure = [{"name":"internet-ipv4-v1","count":"0"}]`
|
||||
(`provider/internal/resources_core/org_ip_allocation_resource.go:258-274`); при `keep_on_destroy = true`
|
||||
ничего не отправляется (строки 212-215). В стенде сейчас `keep_on_destroy = false`
|
||||
(`DEV_STAND/FullPipe/modifiers.tf:39` для квоты, `:29` для SNAT).
|
||||
- Кто держит 2 адреса: инстанс кластера Штурвала — `.146` (API) и `.148` (ingress). `suspend` адреса
|
||||
**не** освобождает (suspend выполнен 17:59:59 MSK успешно, `isDeleted=false`, `uptime=0`, адреса в `state.out` остались).
|
||||
- Последствие упавшего destroy: прерван, до edge/vDC дело не дошло; в state остались `vdc`, `edge`,
|
||||
`org_ip_allocation`, а `snat` уже удалён — «рваное» состояние.
|
||||
|
||||
### Метаданные платформы (проверено через API ЛК)
|
||||
|
||||
- Обязательные заголовки: `Authorization: Bearer <secrets/narodDEV.token>`,
|
||||
браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/` — без них DDoS-Guard отдаёт `403 Forbidden`.
|
||||
- Эндпоинты: `GET /instances?page=1&size=200`, `GET /instances/{uid}`; параметры — в
|
||||
`instance.state.params` (верхнеуровневый `instance.params = null`), статус — `explainedStatus`.
|
||||
- `availableOperations`: кластер — `delete, modify, suspend, resume, reconcile, create_user, delete_user`;
|
||||
vDC — `delete, modify, suspend, resume, reconcile`; **эдж — `delete, modify, reconcile` (suspend отсутствует)**.
|
||||
- `dependencies`/`dependentInstances`: у кластера и эджа пусто; у `fullpipe-vdc` в зависимых три инстанса
|
||||
`fullpipe-edge` (`b289beb8…`, `86a01033…`, `2c37fed1…`) — рабочий только `2c37fed1…`, два других сироты
|
||||
от прошлых прогонов. Кластера в зависимых нет → платформа не блокирует удаление эджа/квоты при живом кластере.
|
||||
- vDC (услуга 21) по инструкции удаляется только через 14 дней после `suspend`; при живых Edge/vApp/VM/кластере
|
||||
удаление — через поддержку.
|
||||
|
||||
---
|
||||
|
||||
## 4. `adopt_existing_on_create` — как усыновление реально работает
|
||||
|
||||
- Кластера не было в state, а ресурс есть в `shturval.tf` → план показывал `will be created`. Это **не**
|
||||
доказательство отсутствия adopt: проверка/adopt выполняются в `Create` на apply
|
||||
(`provider/internal/resources_gen/150_k8s_sthutrval_cluster_resource.go:211`).
|
||||
- Дефолт adopt — `false` (там же, строка 136). При существующем инстансе:
|
||||
`running` либо `suspended` без adopt → hard error «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (…RUNNING/SUSPEND)»
|
||||
(`provider/internal/resources_core/resource_diagnostics_required.go:241-259`). Дубль при этом не создаётся.
|
||||
- Исправление: добавлен `adopt_existing_on_create = true` в `DEV_STAND/FullPipe/shturval.tf`
|
||||
(коммит `57abb7b`; бэкап `TMP/backup_2026-09-24/shturval.tf.before-adopt`).
|
||||
- Результат apply (проверено): state получил `id=94627ff4-…`; провайдер сам выполнил `resume`
|
||||
(18:26:30, success) — инстанс `running`, `isSuspended=false`; кластер жив (2 ноды Ready);
|
||||
`nubes_vc_nsxt_snat.snat` в state (`internet-ipv4-v1`), live эдж `ipSpaceName=internet-ipv4-v1`
|
||||
(modify 18:23:12, success). План после apply: действий по ресурсам нет, только дрейф state
|
||||
(`edge.state_params.ipSpaceName`: `no-needed` → `internet-ipv4-v1`) и `Changes to Outputs`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Дизайн «freeze on destroy» (решение)
|
||||
|
||||
**Требование заказчика:** пользователь стенда не должен ничего делать руками и не должен звать DevOps.
|
||||
`destroy` не удаляет, а «замораживает»: кластер → `suspend`, vDC → `suspend`, эдж → оставить как есть,
|
||||
SNAT → не выключать, квота IP → не трогать. Следующий `apply` возвращает всё в работу.
|
||||
|
||||
**Что уже есть в ядре:**
|
||||
|
||||
- `resources_core/crud.go:64-96` — `DeleteResourceWithTimeout(..., destroyBehavior, ...)`, режимы
|
||||
`state_only`/`detach` (ничего не делаем, ресурс забывается) и `suspend` (шлём операцию `suspend`).
|
||||
- `crud.go:162-214` — adopt на create: для `StateSuspended` при `resumeIfExists` шлёт `resume` и ждёт готовности.
|
||||
- `nubes_k8s_sthutrval_cluster` и `nubes_vc_vdc` — `suspend_on_destroy` (default `true`) + `adopt_existing_on_create`.
|
||||
- `nubes_vc_nsxt_snat` и `nubes_vc_org_ip_allocation` — `keep_on_destroy` (в стенде `false`).
|
||||
- `nubes_vc_nsxt` (эдж) — только `adopt_existing_on_create`; в `provider/resources_yaml/22_vc_nsxt.yaml`
|
||||
нет операции `suspend` → генератор ставит `deleteMode := "delete"`
|
||||
(`TOOLS/resource-generator/internal/templates/instance.go:546-552`), т.е. эдж удаляется по-настоящему.
|
||||
|
||||
**Решение:** три режима в одной общей логике — `delete` (дефолт), `suspend` (где сервис умеет),
|
||||
`keep` → `state_only` (эдж, SNAT, IP-квота). Дефолты провайдера остаются разрушающими; freeze включается
|
||||
явно в `.tf` стенда. Обязательны предупреждения в выводе destroy («заморожено (suspend)», «оставлен как есть:
|
||||
эдж», «квота IP не изменена») — иначе freeze выглядит как успешное удаление.
|
||||
|
||||
**Реализация — только через генератор (ручные правки `resources_gen/` затрёт регенерация):**
|
||||
|
||||
1. `TOOLS/resource-generator/internal/types/types.go` — в `LifecycleSpec` добавить
|
||||
`KeepOnDestroyDefault *bool \`yaml:"keep_on_destroy_default"\``, в `GenResource` — `KeepOnDestroy bool`.
|
||||
2. `TOOLS/resource-generator/internal/loader/loader.go` — читать новый ключ (дефолт `false`),
|
||||
как сейчас читается `suspend_on_destroy_default` (строки ~114-118).
|
||||
3. `TOOLS/resource-generator/internal/templates/instance.go` — эмитить атрибут `keep_on_destroy`
|
||||
(Optional+Computed, дефолт из YAML) в schema и модель; в `Delete` собирать режим:
|
||||
`suspend` → `state_only` (keep) → `delete`.
|
||||
4. `provider/resources_yaml/22_vc_nsxt.yaml` — `keep_on_destroy_default: false`.
|
||||
5. Регенерация + проверка воспроизводимости (`TOOLS/scripts/10_yaml_stability_run.sh`) → сборка/релиз.
|
||||
|
||||
`resources_core`-ресурсы (SNAT, квота IP) менять не нужно — флаг там уже есть.
|
||||
|
||||
**Конфиг стенда для freeze:**
|
||||
|
||||
| Файл / ресурс | Сейчас | Надо |
|
||||
|---|---|---|
|
||||
| `modifiers.tf` → `nubes_vc_org_ip_allocation.org_ip` | `keep_on_destroy = false` (:39) | `true` |
|
||||
| `modifiers.tf` → `nubes_vc_nsxt_snat.snat` | `keep_on_destroy = false` (:29) | `true` |
|
||||
| `edge.tf` → `nubes_vc_nsxt.edge` | атрибутов нет | `keep_on_destroy = true` + `adopt_existing_on_create = true` |
|
||||
| `shturval.tf` → `nubes_k8s_sthutrval_cluster.shturval` | `adopt=true`, `suspend_on_destroy` дефолт | `adopt=true` (есть) + `suspend_on_destroy = true` явно |
|
||||
| `vdc.tf` → `nubes_vc_vdc.vdc` | `suspend_on_destroy = true`, `adopt = true` (:17,19) | без изменений |
|
||||
|
||||
**Что будет при destroy в режиме freeze:** из state ресурсы уйдут, но в облаке ничего не изменится —
|
||||
SNAT останется включённым (Delete при `keep=true` печатает «SNAT не выключался», `nsxt_snat_resource.go:185-190`),
|
||||
квота IP — `count=3`, эдж — running, vDC и кластер — suspended. При следующем `apply` ресурсы создадутся заново
|
||||
и усыновят живые объекты (`suspend` → `resume`, `running` → просто UID), SNAT/квота отправят те же значения → no-op.
|
||||
|
||||
**Полный teardown** — только явный opt-out (`keep_on_destroy=false` / `suspend_on_destroy=false`) и в порядке:
|
||||
кластер → `count=0` → SNAT → эдж → vDC. Иначе `count` ниже занятых не опустить, а удаление эджа оставит кластер
|
||||
без внешнего API/ingress.
|
||||
|
||||
---
|
||||
|
||||
## 5.1. Реализовано (вечер 24.09)
|
||||
|
||||
**Генератор (коммит `22c6c83`):**
|
||||
|
||||
- `TOOLS/resource-generator/internal/types/types.go` — в `ServiceSpec.Lifecycle` добавлен `KeepOnDestroyDefault *bool`
|
||||
(`yaml:"keep_on_destroy_default"`), в `GenResource` — `KeepOnDestroy bool`.
|
||||
- `TOOLS/resource-generator/internal/loader/loader.go` — читает ключ из YAML (дефолт `false`) и передаёт в генератор.
|
||||
- `TOOLS/resource-generator/internal/templates/instance.go` — атрибут `keep_on_destroy` (Optional+Computed, дефолт из YAML)
|
||||
во всех instance-ресурсах; в `Delete` режим выбирается так: `keep_on_destroy` → `state_only` (приоритет),
|
||||
иначе `suspend_on_destroy` (где сервис умеет) → `suspend`, иначе `delete`; после успешного удаления печатаются
|
||||
предупреждения «Ресурс заморожен, а не удалён» / «Ресурс оставлен как есть, а не удалён».
|
||||
- Флаг получили **все 40 instance-ресурсов** (проверено: `grep -l keep_on_destroy generated/dev/go/*_resource.go`).
|
||||
Subresource-ресурсы (пользователи/БД/бэкапы) — без него: другой шаблон, у них нет своего suspend.
|
||||
|
||||
**YAML-спеки не правим:** `01_generate_yamls.sh` перезаписывает `generated/<stand>/resources_yaml/*.yaml` из API,
|
||||
поэтому ручной ключ там не живёт. Ключ `keep_on_destroy_default` поддержан, но не используется:
|
||||
дефолт `false` берётся из нулевого значения Go.
|
||||
|
||||
**Проверка:**
|
||||
|
||||
- `02_generate_resources_and_docs_v2.sh --profile TOOLS/config/dev` → `dev-materialize.sh dev` →
|
||||
`go build ./...` в `provider/` — OK, `go test ./...` — OK.
|
||||
- Локальная проверка конфига без релиза: собран свой бинарь в `TMP/devbin/`, `dev_overrides` —
|
||||
`TMP/terraformrc.dev`; `TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate` — Success,
|
||||
`terraform plan` — `0 to add, 5 to change, 0 to destroy`, у ресурсов меняется только новый
|
||||
атрибут (`keep_on_destroy = false -> true` у квоты, `+ keep_on_destroy = false` у vDC/кластера/эджа/SNAT) плюс
|
||||
пересчёт outputs.
|
||||
|
||||
**Конфиг стенда (коммит `40aef87`):** `modifiers.tf` — `keep_on_destroy = true` у квоты IP (`:31`) и SNAT (`:42`);
|
||||
`edge.tf` — `keep_on_destroy = true` (`:23`) + `adopt_existing_on_create = true` (`:27`);
|
||||
`shturval.tf` — явные `adopt_existing_on_create = true` (`:113`) и `suspend_on_destroy = true` (`:117`);
|
||||
у vDC в `vdc.tf:17,19` оба флага уже были.
|
||||
|
||||
**Релиз выполнен:** `03_build_and_upload_provider.sh --profile TOOLS/config/dev 2.0.22` → три платформы (linux/darwin/windows amd64) + `SHA256SUMS`/`.sig` залиты, версия видна в реестре (проверено `GET /v1/providers/nubes-dev/nubes/versions` → `2.0.22`); `VERSIONS.md` обновлён (коммит `c29df21`).
|
||||
|
||||
**Что осталось:** проверить цикл на живом стенде: `destroy` = заморозка (кластер/vDC → suspend, эдж/SNAT/квота IP → state_only с предупреждениями) и `apply` = разморозка (adopt + resume). `apply`/`destroy` запускает только пользователь.
|
||||
|
||||
**Состояние на 19:4x:** пин в `DEV_STAND/FullPipe/versions.tf` поднят до `2.0.22`, `terraform plan` → «No changes» (дрейф по `edge.state_params.ipSpaceName` ушёл после apply SNAT). Флаги в state: кластер — `adopt=true`, `suspend_on_destroy=true`, `keep=false`; vDC — то же; эдж — `keep=true`, `adopt=true`; SNAT — `keep=true`; квота IP — `keep=true`. Кластер жив: 2 ноды Ready, подов 49 (готовых 44 — те же 4 мусорных пода init-job).
|
||||
|
||||
---
|
||||
|
||||
## 5.2. Проверено вживую: `destroy` = заморозка (24.09, вечер)
|
||||
|
||||
`terraform destroy` на `DEV_STAND/FullPipe` (провайдер `2.0.22`) — «Apply complete! Resources: 0 added, 0 changed, 5 destroyed», ошибок нет. Предупреждения вывода:
|
||||
|
||||
- `SNAT не выключался` — `keep_on_destroy = true`: `ipSpaceName` шлюза оставлен без изменений (NSXT-логика, `nsxt_snat_resource.go`);
|
||||
- `Ресурс оставлен как есть, а не удалён` — эдж (`vc_nsxt`, service_id=22) не менялся в облаке;
|
||||
- `Аллокация IP не снималась` — квота внешних IP организации оставлена без изменений;
|
||||
- `Ресурс заморожен, а не удалён` (×2) — vDC (`vc_vdc`, 21) и кластер (`k8s_sthutrval_cluster`, 150) переведены в `suspend`.
|
||||
|
||||
Состояние после destroy (проверено kubectl + API ЛК):
|
||||
|
||||
| Объект | Статус |
|
||||
|---|---|
|
||||
| `terraform state list` | пусто (все 5 ресурсов убраны из стейта) |
|
||||
| Кластер `94627ff4…` | `suspended`, `isSuspended=true`, не удалён |
|
||||
| vDC `d0937335…` | `suspended`, `isSuspended=true`, не удалён |
|
||||
| Эдж `2c37fed1…` | `running`, `ipSpaceName=internet-ipv4-v1` (SNAT включён) |
|
||||
| Орга `57eeacd1…` | `running`, `vIPConfigure=[{name:internet-ipv4-v1,count:3}]` (квота не тронута) |
|
||||
| Кластерный API `.146:6443` | TCP принимается эджем, но k8s не отвечает (`connection reset by peer`) — ВМ кластера спят |
|
||||
| Ingress `.148:443` | открыт (эдж/AVI живут) |
|
||||
|
||||
Осталось проверить обратный ход: `terraform apply` должен усыновить те же инстансы (`adopt_existing_on_create=true`)
|
||||
и разморозить их (`resume`) — запускает пользователь.
|
||||
|
||||
---
|
||||
|
||||
## 5.3. Баг: регистр UUID внутри JSON (первый `apply` после заморозки)
|
||||
|
||||
**Симптом.** `apply` после destroy (провайдер `2.0.22`) упал:
|
||||
`Error: Ошибка клиента … required params mismatch for resource_name shturval-dev: startupConfiguration
|
||||
(plan={…"nsxtUid":"2c37fed1-…"}, actual={…"nsxtUid":"2C37FED1-…"})`.
|
||||
Эдж после пересоздания вернул UUID в lowercase, а в живом инстансе кластера тот же UUID лежит в UPPERCASE.
|
||||
|
||||
**Почему вылезло именно сейчас.** Регистр ранее учли в пяти местах — `core/refsvc.go:20` (lowercase при отправке),
|
||||
`core/refsvc_resolve.go:28-29`, `resources_core/params_compare.go` (`normalizeCompareValue` — одиночные значения),
|
||||
шаблон `instance.go:204` (`strings.EqualFold` для create-only), плюс восстановление регистра в state.
|
||||
Ни одно из них не смотрит **внутрь JSON**, а adopt **приостановленного** инстанса сравнивает параметр целиком как JSON
|
||||
(`RequiredParamsMismatch` → `paramsEquivalent` → `JSONStringsEquivalent` → `normalizeJSONScalarsToStrings`,
|
||||
где было `case string: return val`). У Штурвала ref-параметры упакованы в JSON (`startupConfiguration`),
|
||||
а путь adopt-suspended задействован впервые.
|
||||
|
||||
**Аудит: где ещё может вылезти.**
|
||||
|
||||
| # | Место | Что ломает |
|
||||
|---|---|---|
|
||||
| 1 | `resources_core/required_params_compare.go:94` | adopt suspended — hard error (сегодняшний кейс) |
|
||||
| 2 | `core/modifier_compare.go:47,53` | ложное «не равно» → лишний `modify` при каждом apply (сейчас спит: у `org_ip_allocation` UUID внутри `vip_configure` нет) |
|
||||
| 3 | `resources_core/state_refresh.go:150` | сохранение планового JSON при эквивалентности → в стейт уедет регистр API |
|
||||
| 4 | `resources_core/resource_diagnostics_required.go:104` | та же `RequiredParamsMismatch` в create-диагностике |
|
||||
| 5 | `resources_core/params_compare.go` (`ParamsMatchForResume`) | одиночный UUID ок, JSON — та же дыра (в сгенерированном коде не вызывается) |
|
||||
| 6 | `resources_core/json_planmodifier.go` (`JsonNormalize`) | только `json.Compact` → для user-facing JSON-атрибутов с UUID риск вечного diff |
|
||||
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, не проверяется вовсе → чужой инстанс не отловится (открыто) |
|
||||
| 8 | `core/operation_run.go:151`, `operation_run_bycode.go:125` (`lookupLiveParam`) | подстановка live-значений по ключам; при другом регистре ключа молча не сработает (надо проверить, открыто) |
|
||||
|
||||
**Фикс (коммит — см. ниже).**
|
||||
|
||||
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (regex по UUID-подстроке) и строковые значения
|
||||
внутри JSON теперь нормализуются (`normalizeJSONScalarsToStrings`, `case string`) — закрывает пункты 1–5 сразу.
|
||||
- `internal/resources_core/json_planmodifier.go`: `JsonNormalize()` после `json.Compact` приводит UUID-подстроки
|
||||
к lowercase (типы и порядок ключей НЕ меняются) — закрывает пункт 6.
|
||||
- Тесты: `internal/core/jsonutil/jsonutil_test.go` (UUID внутри вложенного JSON, регистр, разные UUID, числа/bool,
|
||||
текст без UUID), `internal/resources_core/params_compare_test.go` (`paramsEquivalent` на реальном `startupConfiguration`).
|
||||
|
||||
**Открыто (7–8):** валидация ref-параметров внутри JSON и регистр ключей в `lookupLiveParam` — отдельная задача
|
||||
(требует решения, что делать при mismatch, и живой проверки).
|
||||
|
||||
**Релиз:** `2.0.23` собран и залит в dev-реестр (`03_build_and_upload_provider.sh`), версия видна в реестре;
|
||||
`VERSIONS.md` обновлён. После него нужно повторить `apply` на стенде (усыновление + `resume`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Мои ошибки в этой сессии (обязательно к фиксации)
|
||||
|
||||
1. Сказал, что apply «либо даст ошибку, либо создаст дубль кластера» — **неверно**: будет hard error
|
||||
«РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ», дубль не создаётся (проверено в коде).
|
||||
2. Интерпретировал `will be created` в плане как доказательство отсутствия adopt — adopt работает в `Create`, не в plan.
|
||||
3. Перепутал колонки UI: «17/24» — это «Системные сервисы», а «Ingress» — домен-шаблон, а не счётчик.
|
||||
4. Предлагал ручные обходы (`terraform state rm`, `removed`-блок, `-target`) там, где требуется автоматический
|
||||
freeze флагами — пользователь это отклонил.
|
||||
|
||||
---
|
||||
|
||||
## 7. Открытые вопросы / тикет в платформу
|
||||
|
||||
1. Job установщика: Failed-поды живут сутки (`ttlSecondsAfterFinished: 86400`), `backoffLimit: 10`,
|
||||
очистки нет; установка компонентов идёт до готовности Cilium → EPERM на webhook-ах.
|
||||
2. Эдж: в `availableOperations` нет `suspend` → «заморозить» его платформенно невозможно, только «не трогать».
|
||||
3. Квота IP: `count` нельзя опустить ниже занятых, штатного API «занято N» нет — только текст ошибки.
|
||||
4. vDC: полное удаление только через 14 дней после `suspend`; при живых сущностях — через поддержку.
|
||||
@@ -0,0 +1,45 @@
|
||||
# CHAT RESUME — Штурвал dev-00: диагностика + дизайн «freeze on destroy» (2026-09-24)
|
||||
|
||||
> Полная версия с источниками: `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md`
|
||||
|
||||
## Что сделано
|
||||
|
||||
1. **Проверка кластера из локали** (контекст `tazet@narod.ru@shturval-dev-00`, API `185.247.187.146:6443`):
|
||||
всё зелёное — 2 ноды Ready, `shturvalserviceconfigs` 41/41 `ready`, `nodeconfigitems` 4/4,
|
||||
у всех 35 сервисов есть endpoints. Остался только «мусор»: 4 подвисших пода `kube-system/shturval-init-job`
|
||||
(3 Error + 1 Unknown) — Job уже `Complete 1/1`, поды уйдут сами по `ttlSecondsAfterFinished: 86400`
|
||||
(~25.09 14:31 UTC). Причина падений — webhook-вызовы до готовности Cilium: `connect: operation not permitted`.
|
||||
2. **Расшифрованы счётчики ЛК**: `Pods 44/48` = готовые/всего (48 = поды без Completed); «Системные сервисы»
|
||||
= число сервисов в режиме `auto` (17/24 → 24/24 после установки); «Ingress» = домен, не счётчик;
|
||||
⚠️ на «Pods» = те 4 подвисших пода.
|
||||
3. **Разобрана ошибка destroy**: `nubes_vc_org_ip_allocation` шлёт `count=0`, платформа не даёт опустить
|
||||
`count` ниже занятых (2 адреса держит кластер: `.146` API и `.148` ingress; `suspend` адреса не освобождает).
|
||||
Destroy прервался на аллокации, SNAT успел сняться → «рваное» состояние.
|
||||
4. **Adopt починен**: добавлен `adopt_existing_on_create = true` в `DEV_STAND/FullPipe/shturval.tf`
|
||||
(коммит `57abb7b`). Apply усыновил существующий инстанс `94627ff4-…` и сам сделал `resume` (18:26:30) —
|
||||
кластер снова running, SNAT восстановлен (`internet-ipv4-v1`, modify 18:23:12).
|
||||
5. **Решение по дизайну** (Опус + наше): три режима destroy в одной логике — `delete` (дефолт),
|
||||
`suspend` (где сервис умеет), `keep` → `state_only` (эдж, SNAT, квота IP). Реализация — **через генератор**
|
||||
(`TOOLS/resource-generator`: types/loader/templates + `keep_on_destroy_default` в YAML), не ручными правками
|
||||
`resources_gen/`. Дефолты провайдера остаются разрушающими; freeze включается явно в `.tf` стенда.
|
||||
|
||||
## Что осталось сделать (по команде)
|
||||
|
||||
1. Правка генератора: `keep_on_destroy` для инстанс-ресурсов (эдж в первую очередь) + предупреждения в `Delete`.
|
||||
2. `DEV_STAND/FullPipe`: `keep_on_destroy = true` в `modifiers.tf` (:29 snat, :39 квота) и в `edge.tf`
|
||||
(+ `adopt_existing_on_create = true`), кластеру — явный `suspend_on_destroy = true`.
|
||||
3. Регенерация + проверка воспроизводимости `10_yaml_stability_run.sh`, сборка/релиз провайдера.
|
||||
4. Тикет в платформу: TTL/очистка Failed-подов установщика, отсутствие `suspend` у эджа, `count` ниже занятых.
|
||||
|
||||
## Полезное для воспроизведения
|
||||
|
||||
```bash
|
||||
# состояние кластера
|
||||
kubectl get nodes; kubectl get pods -A | grep -v -E "Running|Completed"
|
||||
kubectl -n kube-system get job shturval-init-job -o json | jq '.spec.backoffLimit,.spec.ttlSecondsAfterFinished,.status'
|
||||
# API ЛК dev (нужны User-Agent и Referer, иначе 403)
|
||||
TOK=$(tr -d '\n' < secrets/narodDEV.token)
|
||||
curl -s -H "Authorization: Bearer $TOK" -H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
|
||||
-H "Referer: https://deck-dev.ngcloud.ru/" \
|
||||
'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/94627ff4-33a5-48f2-aca1-695741e0b6a2'
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# CHAT RESUME — Штурвал + «freeze on destroy»: состояние на 2026-09-25
|
||||
|
||||
> Цель файла: начать новый чат **без уточняющих вопросов** — здесь всё, что сделано, где живёт
|
||||
> документация, что в каком состоянии и что делать дальше.
|
||||
|
||||
## 0. Где что лежит (точки входа)
|
||||
|
||||
| Что | Путь |
|
||||
|---|---|
|
||||
| Полный разбор сессии (диагностика, пайплайн, аудит регистра UUID) | `NOTES/30_analysis/SHTURVAL_DEV00_DIAG_AND_FREEZE_DESIGN_2026-09-24.md` |
|
||||
| Страница для пользователя/DevOps: поведение и отличия от канонического Terraform | `docs/30_registry/guides/provider-behavior.md` |
|
||||
| Регистр UUID (все нерабочие подходы + кейс UUID внутри JSON, §10) | `docs/60_strategy/terraform_case_sensitivity_fix.md` |
|
||||
| Запись дня с коммитами и результатами | `HISTORY/2026-09-24_shturval_dev00_adopt_and_freeze_design.md` |
|
||||
| Память репозитория (факты, шпаргалки, уроки) | `/memories/repo/shturval-destroy-freeze.md` |
|
||||
| Версии провайдеров по стендам | `VERSIONS.md` |
|
||||
|
||||
Репозиторий: `/home/naeel/TF/tf_provider`, ветка `master`, remote `origin`
|
||||
(`https://gitea.services.ngcloud.ru/terraform/tf_provider.git`). Снапшот состояния — ветка
|
||||
`snapshot/2026-09-25-shturval-freeze-state`.
|
||||
|
||||
## 1. Что уже сделано
|
||||
|
||||
1. **Диагностика стенда `shturval-dev-00`** (услуга 150, инстанс `shturval-dev`, uid `94627ff4-…`):
|
||||
кластер здоров (2 ноды Ready, 41/41 Shturval-сервисов `ready`, NodeConfigItems 4/4, у всех сервисов
|
||||
есть endpoints). Единственный мусор — 4 подвисших пода `kube-system/shturval-init-job`
|
||||
(3 Error + 1 Unknown при `Complete 1/1`), причина — webhook-и Штурвала недоступны до готовности Cilium
|
||||
(`connect: operation not permitted`); самоочистка по `ttlSecondsAfterFinished: 86400` (≈25.09 14:31 UTC).
|
||||
2. **Расшифрованы счётчики ЛК/Штурвала**: `Pods X/Y` = готовые/всего (без `Completed`);
|
||||
«Системные сервисы» = число сервисов в режиме `auto`; «Конфигурация узлов» = NodeConfigItems;
|
||||
«Ingress» = домен-шаблон, не счётчик.
|
||||
3. **Разобран провал `destroy`**: `nubes_vc_org_ip_allocation` отправлял `count=0`, платформа не даёт
|
||||
опустить `count` ниже занятых адресов — их держит кластер (`.146` API и `.148` ingress), `suspend`
|
||||
адреса не освобождает.
|
||||
4. **Усыновление исправлено**: в `DEV_STAND/FullPipe/shturval.tf` добавлен `adopt_existing_on_create = true`
|
||||
(коммит `57abb7b`); проверено вживую — apply усыновил существующий инстанс и сам сделал `resume`.
|
||||
5. **Реализован третий режим destroy** (коммит `22c6c83`, генератор, универсально для всех instance-ресурсов):
|
||||
`keep_on_destroy` → `state_only` (приоритет), иначе `suspend_on_destroy` → `suspend`, иначе `delete`;
|
||||
в `Delete` добавлены предупреждения «Ресурс заморожен, а не удалён» / «оставлен как есть».
|
||||
6. **Конфиг стенда переведён в режим «заморозки»** (коммит `40aef87`): `keep_on_destroy = true` у квоты IP,
|
||||
SNAT и эджа; `adopt_existing_on_create = true` и `suspend_on_destroy = true` у кластера; у vDC оба флага
|
||||
уже стояли.
|
||||
7. **Проверен цикл `destroy` = заморозка** на живом стенде (`2.0.22`): `0 added, 0 changed, 5 destroyed`,
|
||||
ошибок нет; кластер и vDC → `suspended`, эдж `running` с `ipSpaceName=internet-ipv4-v1`, квота IP `count=3`,
|
||||
state пуст; в выводе — 5 предупреждений.
|
||||
8. **Найден и исправлен баг регистра UUID внутри JSON** (коммит `621280a`, релиз `2.0.23`): первый `apply`
|
||||
после заморозки падал на `required params mismatch … startupConfiguration` (`2c37fed1-…` в плане против
|
||||
`2C37FED1-…` в живом инстансе). Проведён аудит 8 мест (см. §10 в `terraform_case_sensitivity_fix.md`),
|
||||
добавлены `jsonutil.LowercaseUUIDsInText` и нормализация строк внутри JSON, `JsonNormalize()` приводит
|
||||
UUID-подстроки к lowercase; покрыто тестами (`jsonutil_test.go`, `params_compare_test.go`), `go test ./...` зелёный.
|
||||
9. **Документация**: страница `docs/30_registry/guides/provider-behavior.md`, обновлённый §10 в
|
||||
`terraform_case_sensitivity_fix.md`, записи в `NOTES`/`HISTORY`, память репозитория.
|
||||
|
||||
## 2. Текущее состояние (на момент записи)
|
||||
|
||||
- `DEV_STAND/FullPipe`: **`terraform state list` пуст** (после проверочного `destroy`).
|
||||
- В облаке: кластер `shturval-dev-00` — `suspended`; vDC — `suspended`; эдж — `running`
|
||||
(`ipSpaceName = internet-ipv4-v1`, SNAT включён); квота IP организации — `count = 3`.
|
||||
- Кластер «спит»: `.146:6443` TCP принимается эджем, но k8s не отвечает (`connection reset by peer`);
|
||||
`.148:443` открыт (эдж/AVI живут).
|
||||
- Версия провайдера: в реестре dev — **`2.0.23`**; пины `versions.tf` в `DEV_STAND/FullPipe` и
|
||||
`DEV_STAND/FPipeGmail` — `2.0.23`.
|
||||
- Новый (не проверенный вживую) стенд пользователя: `DEV_STAND/FPipeGmail/`.
|
||||
|
||||
## 3. Что делать дальше
|
||||
|
||||
1. **Проверить обратный ход** (запускает только пользователь): `terraform apply` в `DEV_STAND/FullPipe`.
|
||||
Ожидание: `adopt` по имени + `resume` для кластера и vDC; эдж/SNAT/квота — no-op; затем `plan` = `No changes`.
|
||||
Проверки: `terraform state list`, `terraform state show`, статусы инстансов в API ЛК, `kubectl get nodes`
|
||||
(снова Ready), поды `44/48` (+ мусор `shturval-init-job`, уйдёт сам).
|
||||
2. **Доку при необходимости**: добавить страницу `provider-behavior.md` в `nav` (`mkdocs.yml`) и запустить
|
||||
`TOOLS/scripts/04_build_and_publish_docs.sh` — **не запускать без прямой команды**.
|
||||
3. **Открытые техдолги:**
|
||||
- ref-параметр внутри JSON **не валидируется** при adopt (`resources_core/ref_validation.go`);
|
||||
- регистр ключей в `lookupLiveParam` (`core/operation_run.go`, `operation_run_bycode.go`) — требует живой проверки;
|
||||
- тикет в платформу: TTL/очистка Failed-подов установщика + порядок установки компонентов до готовности Cilium;
|
||||
- тикет в платформу: у Эджа нет операции `suspend` (в `availableOperations` только `delete/modify/reconcile`).
|
||||
4. **Полный teardown** — осознанно: `keep_on_destroy = false` и `suspend_on_destroy = false`, порядок
|
||||
кластер → `count=0` → SNAT → эдж → vDC (для vDC действует правило «14 дней после suspend»).
|
||||
|
||||
## 4. Шпаргалка: режимы destroy
|
||||
|
||||
| Ресурс | Флаг | Поведение при destroy | Предупреждение |
|
||||
|---|---|---|---|
|
||||
| `nubes_k8s_sthutrval_cluster` | `suspend_on_destroy = true` | `suspend` | «Ресурс заморожен, а не удалён» |
|
||||
| `nubes_vc_vdc` | `suspend_on_destroy = true` | `suspend` | то же |
|
||||
| `nubes_vc_nsxt` (эдж) | `keep_on_destroy = true` | не трогается | «Ресурс оставлен как есть, а не удалён» |
|
||||
| `nubes_vc_nsxt_snat` | `keep_on_destroy = true` | не трогается | «SNAT не выключался» |
|
||||
| `nubes_vc_org_ip_allocation` | `keep_on_destroy = true` | не трогается | «Аллокация IP не снималась» |
|
||||
|
||||
Приоритет: `keep_on_destroy` > `suspend_on_destroy` > обычное удаление. Дефолты провайдера — разрушающие;
|
||||
«заморозка» включается в `.tf` стенда.
|
||||
|
||||
## 5. Факты платформы (проверено)
|
||||
|
||||
- 1 кластер Штурвала = 1 vDC; vDC удаляется только через 14 дней после `suspend`.
|
||||
- Квоту IP нельзя опустить ниже занятых адресов; адреса кластера `suspend` не освобождает.
|
||||
- У эджа нет `suspend`; удаление эджа при живых зависимых (vApp/VM/кластер) недопустимо.
|
||||
- API ЛК: `GET /api/v1/svc/instances/{uid}`, состояние — `instance.state.params`, статус — `explainedStatus`,
|
||||
операции — `availableOperations`; ошибки операции — в теле (`isSuccessful=false`, `errorLog`), HTTP 200/201.
|
||||
- Обязательные заголовки API ЛК: браузерный `User-Agent`, `Referer: https://deck-dev.ngcloud.ru/`,
|
||||
`Authorization: Bearer <secrets/narodDEV.token>` — иначе `403`.
|
||||
- Регистр UUID: облако отдаёт один и тот же UUID в разных регистрах → сравнивать всегда без учёта регистра
|
||||
(в т.ч. **внутри JSON**).
|
||||
|
||||
## 6. Релизы
|
||||
|
||||
- Схема: prod `1.*`, dev `2.*`, test `3.*`; актуальная dev — `2.0.23` (`VERSIONS.md`).
|
||||
- Релиз: `TOOLS/scripts/03_build_and_upload_provider.sh --profile TOOLS/config/dev <версия>`
|
||||
(сам прогоняет `01` + `02`, собирает, подписывает, заливает, обновить `VERSIONS.md` вручную).
|
||||
- Локальная проверка без релиза: `go build -o TMP/devbin/terraform-provider-nubes .` в `provider/`
|
||||
и `TF_CLI_CONFIG_FILE=TMP/terraformrc.dev terraform validate|plan` (dev_overrides).
|
||||
- YAML-спеки (`generated/*/resources_yaml/`) **не редактировать руками** — `01_generate_yamls.sh` перезапишет их из API.
|
||||
|
||||
## 7. Правила работы (для нового чата)
|
||||
|
||||
- `terraform apply` / `destroy` — только пользователь. Мне доступны `init/plan/validate/show/state show`.
|
||||
- Никаких правок, коммитов, релизов и публикаций без прямой команды; после каждой правки — коммит.
|
||||
- Перед правками важных файлов — бэкап в `TMP/backup_<дата>/`.
|
||||
- Не «улучшать» соседние стенды/сервисы без команды (scope creep запрещён).
|
||||
|
||||
## 8. Быстрые команды
|
||||
|
||||
```bash
|
||||
# состояние стенда
|
||||
cd DEV_STAND/FullPipe && terraform state list && terraform plan
|
||||
terraform state show nubes_k8s_sthutrval_cluster.shturval | grep -E "id|adopt|suspend|keep"
|
||||
|
||||
# кластер
|
||||
kubectl get nodes
|
||||
kubectl get pods -A --no-headers | awk '{split($3,a,"/"); tot++; if($4=="Running"&&a[1]==a[2]) ok++} END{print tot, ok}'
|
||||
kubectl get pods -A | grep -v -E "Running|Completed"
|
||||
|
||||
# API ЛК (dev)
|
||||
TOK=$(tr -d '\n' < secrets/narodDEV.token)
|
||||
curl -s -H "Authorization: Bearer $TOK" \
|
||||
-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
|
||||
-H "Referer: https://deck-dev.ngcloud.ru/" \
|
||||
'https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc/instances/94627ff4-33a5-48f2-aca1-695741e0b6a2'
|
||||
|
||||
# реестр провайдера (dev)
|
||||
curl -s https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/nubes-dev/nubes/versions
|
||||
```
|
||||
@@ -51,8 +51,13 @@ k8sShturval -> create
|
||||
|
||||
## 3. Подтверждённые факты (с источниками)
|
||||
|
||||
1. **Схема tf-ресурса строится ТОЛЬКО из `create`** (генератор `TOOLS/resource-generator`).
|
||||
→ modify-only параметры в схему не попадают.
|
||||
1. ⛔ **ИСПРАВЛЕНО 2026-09-24. Прежняя формулировка «схема строится ТОЛЬКО из `create`» — НЕВЕРНА.**
|
||||
Генератор **мержит** create+modify: `TOOLS/resource-generator/internal/loader/loader.go:96` →
|
||||
`schemaParams := params.Merge(createParams, modifyParams)`; коммит `261809b` (2026-09-22)
|
||||
«is_modifiable=true → параметр НЕ create-only».
|
||||
Следствие: modify-параметры **уже в схемах** и применяются в `Update` —
|
||||
`nubes_vc_org.v_ip_configure` (шлёт `662`), `nubes_vc_nsxt.ip_space_name` (шлёт `372`).
|
||||
Разбор и live-факты: `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
|
||||
2. **`vIPConfigure`** (vc_org, modify id **207**, param id **662**, `array-map-fixed`, sub: `name`=39, `count`=40)
|
||||
есть **только** в modify. В `create` (id 136) — только `resourceRealm`(418), `organizationType`(556), `orgSuffix`(1125).
|
||||
Файл: `generated/dev/resources_yaml/19_vc_org.yaml`.
|
||||
@@ -141,6 +146,11 @@ k8sShturval -> create
|
||||
«Update = no-op». **Файл перезаписан** актуальными фактами. НЕ использовать старую формулировку.
|
||||
3. **Старые «модификаторы» были написаны и даже работали** (09-22), но заход признан негодным:
|
||||
доменную логику вшили в универсальный генератор (метки в YAML). Соответствующие документы помечены баннером LEGACY.
|
||||
4. **Ложный «факт» §3.1 («схема только из `create`»).** Проверено в коде 2026-09-24: генератор мержит
|
||||
create+modify (`loader.go:96`), поэтому `v_ip_configure` и `ip_space_name` **уже есть** в схемах
|
||||
`nubes_vc_org` / `nubes_vc_nsxt` и работают через `Update`. Вывод «прописать поле в .tf → падает на plan»
|
||||
относится максимум к провайдеру, собранному до коммита `261809b` (2026-09-22). Детали —
|
||||
`NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +161,9 @@ k8sShturval -> create
|
||||
- `NOTES/30_analysis/SHTURVAL_IAC_MODIFY_ANALYSIS_2026-09-23.md` — анализ, варианты A–E, мнение
|
||||
- `NOTES/30_analysis/OPUS_ANSWER_IAC_SHTURVAL_MODIFY_2026-09-23.md` — ответ Opus + поправки (ложные блокеры сняты)
|
||||
- `NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md` — проверенные факты по vIPConfigure
|
||||
- `NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md` — разбор create орги/эджа + свежий `state.params` (`vIPConfigure: [{}]`, отсутствие `ipSpaceName`)
|
||||
- `NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md` — правки/ограничения (часть опровергнута тестом; раньше в карте отсутствовал)
|
||||
- `HAR/globak.har`, `HAR/org_already exists.har` — записи ЛК от 2026-09-24
|
||||
- `NOTES/20_prompts/prompt_for_opus_iac_shturval_modify.md` — промпт (факты исправлены)
|
||||
- `generated/dev/resources_yaml/19_vc_org.yaml`, `22_vc_nsxt.yaml` — спеки (факты по операциям/параметрам)
|
||||
- `HAR/org_enough_.har`, `HAR/org2.har`, `HAR/edge_.har` — live-семантика modify
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
name: vc_nsxt
|
||||
service_id: 22
|
||||
service_display_name: Сетевой шлюз периметра (Edge)
|
||||
service_short_name: vc_nsxt
|
||||
service_man: '# Инструкция по развертыванию Сетевой шлюз периметра (Edge) через платформу<br/><br/>## 1. Общая информация<br/><br/>Сервис **Сетевой шлюз периметра (Edge)** предназначен для создания периметрового сетевого шлюза в рамках одного виртуального датацентра (vDC) или группы виртуальных датацентров (groupvDC). <br/>При создании автоматически разворачивается routed-сеть с адресным пространством `10.10.102.0/24`. <br/>Сервис обеспечивает сетевую изоляцию, маршрутизацию, а также может включать функциональность балансировщика нагрузки AVI (ALB) для последующей интеграции, включая поддержку кластеров Штурвал.<br/><br/>### Доступные операции<br/><br/>**create** — Создание нового Edge с привязкой к vDC или groupvDC и развёртыванием routed-сети. <br/>**delete** — Удаление ранее созданного Edge. Недоступно при наличии зависимых услуг (vApp, VM, Кластер Штурвал). <br/>**modify** — Изменение параметров и сетевых настроек существующего Edge.<br/><br/>---<br/><br/>## 2. Параметры развертывания<br/><br/>Ниже приведены параметры операции **create**.<br/><br/>### Тип родительской услуги<br/>Определяет контекст размещения Edge. <br/>Допустимые значения: `vdc`, `groupvdc`. <br/>Использование:<br/>- При выборе `vdc` обязателен параметр **UUID VDC**.<br/>- При выборе `groupvdc` обязателен параметр **UUID Группы VDC**.<br/><br/>### UUID VDC<br/>Идентификатор виртуального датацентра. <br/>Необходимо предварительно создать vDC через услугу «Виртуальный датацентр (vDC)». <br/>Указывается только при выборе родительского типа `vdc`.<br/><br/>### UUID Группы VDC<br/>Идентификатор группы виртуальных датацентров. <br/>Создаётся через услугу «Группа виртуальных датацентров (groupvDC)». <br/>Используется при выборе родительского типа `groupvdc`.<br/><br/>### Включить ALB<br/>Флаг активации AVI Load Balancer в Cloud Director. <br/>Пример значения: `true`/`false`. <br/>Нужен для развёртывания кластера Штурвал и возможности создания Virtual Services.<br/><br/>### Наименование segroup<br/>Имя сервиса групп (segroup) на уровне ресурсной платформы. <br/>Обязательно при включённом ALB. <br/>Определяет группу, в которой будут выделяться пулы AVI для Virtual Services.<br/><br/>### virtualServicesCount<br/>Количество резервируемых виртуальных сервисов (Virtual Services) на AVI. <br/>Типичное значение: 1–10 в зависимости от нагрузки.<br/><br/>---<br/><br/>## 3. Рекомендованные характеристики<br/><br/>### Тестовое окружение (Test)<br/><br/>- Тип родительской услуги: `vdc` <br/>- virtualServicesCount: 1–2 <br/>- ALB: выключен по умолчанию (включать только при необходимости тестирования Штурвала) <br/>- segroup: задаётся только при включённом ALB <br/>- VDC гарантии CPU/RAM: минимальные (поскольку изменить их после создания нельзя)<br/><br/>### Промышленное окружение (Production)<br/><br/>- Тип родительской услуги: `groupvdc` при работе с распределёнными нагрузками или `vdc` для локализованных проектов <br/>- virtualServicesCount: 3–10, исходя из предполагаемого количества публикаций <br/>- ALB: включён, если требуется высокая доступность сервисов или используется кластер Штурвал <br/>- segroup: рекомендуется использовать выделенную группу под проект <br/>- VDC гарантии CPU/RAM: повышенные, с учётом того, что изменить их после создания невозможно без пересоздания vDC<br/><br/>---<br/><br/>## 4. Выходные параметры<br/><br/>### Имя Edge<br/>Уникальное название созданного сетевого шлюза, используется для ссылок и дальнейших операций.<br/><br/>### Имя routed-сети<br/>Автоматически созданная routed-сеть с подсетью `10.10.102.0/24`, используется для подключения ресурсов.<br/><br/>### Параметры Edge<br/>Набор параметров, указанных пользователем при создании: тип услуги, ALB, segroup, virtualServicesCount и др. <br/>Используются в операциях modify и для анализа состояния сервиса.<br/><br/>---<br/><br/>## 5. Дополнительная информация<br/><br/>- В рамках сервиса **один раз при создании задаётся гарантированная доля CPU/RAM vDC**. Изменить её невозможно — требуется пересоздание vDC. <br/>- Удаление Edge недоступно при наличии зависимых ресурсов (vApp, VM, кластер Штурвал). <br/>- При использовании ALB необходимо корректно указывать segroup, чтобы обеспечить корректное выделение пулов AVI. <br/>- При выборе режима `groupvdc` следует учитывать распределение нагрузки между несколькими vDC. <br/>- SNAT для всех ресурсов внутри Edge можно активировать через операцию modify (параметры выделения VIP и ipSpace).<br/><br/>'
|
||||
lifecycle:
|
||||
suspend_on_destroy_default: false
|
||||
adopt_existing_on_create_default: false
|
||||
outputs:
|
||||
params:
|
||||
- code: state_params
|
||||
type: map
|
||||
- code: state_out
|
||||
type: map
|
||||
- code: state_params_flat
|
||||
type: map
|
||||
- code: state_out_flat
|
||||
type: map
|
||||
- code: vault_secrets
|
||||
type: map
|
||||
sensitive: true
|
||||
- code: vault_url
|
||||
type: string
|
||||
- code: vault_user_path
|
||||
type: string
|
||||
- code: vault_fields
|
||||
type: list
|
||||
operations:
|
||||
- name: create
|
||||
id: 10
|
||||
kind: instance
|
||||
action: create
|
||||
params:
|
||||
- id: 8
|
||||
code: vdcUid
|
||||
data_type: string
|
||||
required: false
|
||||
ref_svc_id: 21
|
||||
descr: UUID Услуги `Виртуальный датацентр (vDC)`
|
||||
man: 'Необходимо, если выбран тип подключаемой VDC: `vdc`'
|
||||
sort: 10
|
||||
- id: 340
|
||||
code: needEnableAVI
|
||||
data_type: boolean
|
||||
required: true
|
||||
default: "false"
|
||||
value_list:
|
||||
- "false"
|
||||
- "true"
|
||||
descr: Включение Load Balancer
|
||||
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
|
||||
sort: 30
|
||||
is_modifiable: true
|
||||
- id: 341
|
||||
code: virtualServicesCount
|
||||
data_type: integer > 0
|
||||
required: false
|
||||
default: "1"
|
||||
maxvalue: 4
|
||||
minvalue: 1
|
||||
descr: Кол-во виртуальных сервисов, которые **резервируются** на AVI
|
||||
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
|
||||
sort: 50
|
||||
is_modifiable: true
|
||||
- id: 621
|
||||
code: vdcType
|
||||
data_type: string
|
||||
required: true
|
||||
default: vdc
|
||||
value_list:
|
||||
- vdc
|
||||
- vdcGroup
|
||||
descr: Тип родительской услуги
|
||||
man: При выборе vdc обеспечивает сетевую доступность только в рамках этого vdc<br/>При выборе groupvdc обеспечивает сетевую доступность между всеми vdc, которые включены в groupvdc
|
||||
sort: 0
|
||||
- id: 622
|
||||
code: vdcGroupUid
|
||||
data_type: string
|
||||
required: false
|
||||
ref_svc_id: 29
|
||||
descr: UUID Услуги `Группа виртуальных датацентров (groupvDC)`
|
||||
man: 'Необходимо, если выбран тип подключаемой VDC: `groupvdc`'
|
||||
sort: 20
|
||||
- id: 825
|
||||
code: qosProfile
|
||||
required: false
|
||||
descr: Параметр пока не работает<br/>Должен возвращать поле из ресурсной платформы типа vc из .vcd.hardware.gatewayQoSProfiles<br/><br/>Как по идее должен работать.<br/>Нужно заполнить или CFS vdcUid, или vdcGroupUid<br/>Идеально конечно проверять `if (params.vdcType == "vdc" && params.vdcUid != "")` или `if (params.vdcType == "vdcGroup" && params.vdcGroupUid != "")`<br/><br/>Далее надо пойти по стейту vdc -> org -> resPlatform, взять gatewayQoSProfiles<br/>Или пойти по стейту vdcgroup -> vdc -> org -> resPlatform, взять gatewayQoSProfiles<br/><br/>Регулярку могу написать (Виталя)
|
||||
man: if (vdcUid != '") {<br/> наборфункций1<br/>} <br/><br/>elif (vdcGroupUid != "") {<br/> наборфункций2<br/>}<br/><br/>else {<br/> return "Необходимо выбрать vdc или vdcgroup"<br/>}
|
||||
sort: 60
|
||||
depends_on: vdcUid,vdcGroupUid
|
||||
is_modifiable: true
|
||||
- id: 1110
|
||||
code: routedNetConfiguration
|
||||
data_type: map-fixed
|
||||
required: true
|
||||
sort: 70
|
||||
is_modifiable: true
|
||||
sub_params:
|
||||
- id: 649
|
||||
code: ipAddrPool
|
||||
data_type: string
|
||||
required: true
|
||||
default: 10.10.102.0/24
|
||||
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
|
||||
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
|
||||
is_modifiable: false
|
||||
- id: 650
|
||||
code: mainDns
|
||||
data_type: string
|
||||
required: true
|
||||
default: 81.22.46.22
|
||||
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
|
||||
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
|
||||
is_modifiable: false
|
||||
- id: 651
|
||||
code: secondDns
|
||||
data_type: string
|
||||
required: true
|
||||
default: 185.247.187.77
|
||||
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
|
||||
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
|
||||
is_modifiable: false
|
||||
- name: delete
|
||||
id: 25
|
||||
kind: instance
|
||||
action: delete
|
||||
man: Невозможно удалить если routed-сеть в Edge связана с:<br/>- vApp<br/>- VM<br/>- Kubernetes-кластер Штурвал<br/><br/>Запустить операцию возможно, но будет ошибка
|
||||
params: []
|
||||
- name: modify
|
||||
id: 111
|
||||
kind: instance
|
||||
action: modify
|
||||
man: Для создания SNAT Правила убедитесь, что в организации есть свободные IP<br/>Редактировать кол-во свободных IP можно в услуге `Организация в Cloud Director` -> `modify`
|
||||
params:
|
||||
- id: 368
|
||||
code: needEnableAVI
|
||||
data_type: boolean
|
||||
required: false
|
||||
value_list:
|
||||
- "false"
|
||||
- "true"
|
||||
descr: Включение Load Balancer
|
||||
man: Параметры `Наименование segroup`, `Кол-во VS на AVI` необходимо также указать
|
||||
sort: 10
|
||||
- id: 369
|
||||
code: virtualServicesCount
|
||||
data_type: integer > 0
|
||||
required: false
|
||||
maxvalue: 4
|
||||
minvalue: 1
|
||||
descr: Кол-во виртуальных сервисов, которые выделяются на AVI
|
||||
man: Во избежании коллапса пока выделяется до 4<br/>Необходимо указывать, если включён параметр `Включить ALB`
|
||||
sort: 30
|
||||
- id: 372
|
||||
code: ipSpaceName
|
||||
data_type: string
|
||||
required: false
|
||||
descr: Имя ip Space для внешнего IP
|
||||
man: Необходимо указывать, если включён параметр `Выделить VIP для SNAT`
|
||||
sort: 50
|
||||
- id: 856
|
||||
code: qosProfile
|
||||
data_type: string
|
||||
required: false
|
||||
sort: 40
|
||||
- id: 1112
|
||||
code: routedNetConfiguration
|
||||
data_type: map-fixed
|
||||
required: true
|
||||
sort: 70
|
||||
sub_params:
|
||||
- id: 652
|
||||
code: ipAddrPool
|
||||
data_type: string
|
||||
required: true
|
||||
default: ""
|
||||
regex: ^((25[0-4]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}0/24$
|
||||
man: '**Параметр не изменяемый**<br/>Адрессный пул, которая будет присвоена routed-сети. Описывается как (10.10.10.0/24). Маска 24 обязательна. (.1) - шлюз. (.2-.254) - Под адресацию для ВМ'
|
||||
is_modifiable: false
|
||||
- id: 653
|
||||
code: mainDns
|
||||
data_type: string
|
||||
required: true
|
||||
default: ""
|
||||
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
|
||||
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
|
||||
is_modifiable: false
|
||||
- id: 654
|
||||
code: secondDns
|
||||
data_type: string
|
||||
required: true
|
||||
default: ""
|
||||
regex: ^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?|0)$
|
||||
man: Подставляется автоматически в resolv.conf при создании виртуальных машин
|
||||
is_modifiable: false
|
||||
- name: reconcile
|
||||
id: 252
|
||||
kind: action
|
||||
action: reconcile
|
||||
params: []
|
||||
@@ -0,0 +1,20 @@
|
||||
resource "nubes_vc_nsxt" "edge" {
|
||||
resource_name = var.nsxt_resource_name
|
||||
|
||||
# Тип родительской услуги: "vdc" (нужен vdc_uid) или "vdcGroup" (нужен vdc_group_uid)
|
||||
vdc_type = var.nsxt_vdc_type
|
||||
|
||||
# refSvc-поле: принимает UUID или имя. Здесь берём UID созданного VDC,
|
||||
# чтобы Edge гарантированно создавался после vDC.
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
|
||||
need_enable_avi = var.nsxt_need_enable_avi
|
||||
virtual_services_count = var.nsxt_virtual_services_count
|
||||
|
||||
# routed-сеть, которую разворачивает Edge (SingleNestedAttribute -> объект)
|
||||
routed_net_configuration = {
|
||||
ip_addr_pool = var.nsxt_ip_addr_pool
|
||||
main_dns = var.nsxt_main_dns
|
||||
second_dns = var.nsxt_second_dns
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
package templates
|
||||
|
||||
const Instance = `package resources_gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .NeedsStringsImport }}
|
||||
"strings"
|
||||
{{- end }}
|
||||
{{- if .NeedsFmtImport }}
|
||||
"fmt"
|
||||
{{- end }}
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
"terraform-provider-nubes/internal/resources_core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
{{- if .NeedsInt64Default }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
|
||||
{{- end }}
|
||||
{{- if .NeedsStringDefault }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||
{{- end }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
{{- if .NeedsBoolUseStateForUnknown }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
|
||||
{{- end }}
|
||||
{{- if .NeedsInt64UseStateForUnknown }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
|
||||
{{- end }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
// Code generated by TOOLS/resource-generator. DO NOT EDIT.
|
||||
// Service: {{.Name}}
|
||||
// Service ID: {{.ServiceID}}
|
||||
|
||||
var _ resource.Resource = &{{ToCamel .Name}}Resource{}
|
||||
var _ resource.ResourceWithModifyPlan = &{{ToCamel .Name}}Resource{}
|
||||
var _ resource.ResourceWithImportState = &{{ToCamel .Name}}Resource{}
|
||||
|
||||
type {{ToCamel .Name}}Resource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
{{- range .SchemaParams }}
|
||||
{{- if (IsNested .) }}
|
||||
// {{NestedModelName $.Name .Code}} — вложенная модель для map-fixed параметра {{.Code}}.
|
||||
type {{NestedModelName $.Name .Code}} struct {
|
||||
{{- range .SubParams }}
|
||||
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}" json:"{{.Code}}"` + "`" + `
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
type {{ToCamel .Name}}Model struct {
|
||||
ID types.String ` + "`" + `tfsdk:"id"` + "`" + `
|
||||
ResourceName types.String ` + "`" + `tfsdk:"resource_name"` + "`" + `
|
||||
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
|
||||
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
|
||||
{{- if .HasRedeploy }}
|
||||
// --- Redeploy support (ARCHITECTURE.md) ---
|
||||
// git_revision: при изменении вызывает redeploy вместо modify.
|
||||
// Опциональное поле — если не задано, modify работает как обычно.
|
||||
GitRevision types.String ` + "`" + `tfsdk:"git_revision"` + "`" + `
|
||||
{{- end }}
|
||||
{{- range .SchemaParams }}
|
||||
{{- if (IsNested .) }}
|
||||
{{ToCamel .Code}} {{NestedTfType . $.Name}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
|
||||
{{- else }}
|
||||
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
|
||||
{{- end }}
|
||||
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
|
||||
{{- range .OutputParams }}
|
||||
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
func New{{ToCamel .Name}}Resource() resource.Resource {
|
||||
return &{{ToCamel .Name}}Resource{}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_{{.Name}}"
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
attrs := map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
|
||||
"resource_name": schema.StringAttribute{Required: true},
|
||||
"operation_timeout": schema.StringAttribute{Optional: true},
|
||||
"log_level": schema.StringAttribute{Optional: true, MarkdownDescription: "Operation stages log level: none (default), info, debug. Overrides provider-level log_level."},
|
||||
{{- range .SchemaParams }}
|
||||
{{- if (IsNested .) }}
|
||||
"{{ToSnake .Code}}": {{NestedSchemaBlock .}}
|
||||
{{- range .SubParams }}
|
||||
"{{ToSnake .Code}}": schema.{{SubSchemaType .}}Attribute{
|
||||
{{- if .Default }}Optional: true, Computed: true, Default: {{SubDefaultExpr .}},{{else if .Required}}Required: true,{{else}}Optional: true,{{end}}
|
||||
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
|
||||
},
|
||||
{{- end }}
|
||||
{{NestedSchemaEnd .}}
|
||||
{{- else }}
|
||||
"{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") (eq .RefSvcId 0) }}Required: true,{{else}}Optional: true,{{end}}
|
||||
{{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- else if or .IsJson (ShouldBeOptionalComputed .) }}Computed: true,{{- end }}
|
||||
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
|
||||
{{- if .Sensitive }}Sensitive: true,{{end}}
|
||||
{{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{- else if ShouldUseStateForUnknown . }}PlanModifiers: []planmodifier.{{if eq (ParamType .) "types.Bool"}}Bool{boolplanmodifier.UseStateForUnknown()}{{else if eq (ParamType .) "types.Int64"}}Int64{int64planmodifier.UseStateForUnknown()}{{else}}String{stringplanmodifier.UseStateForUnknown()}{{end}},{{- end }}
|
||||
},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .HasRedeploy }}
|
||||
// --- Redeploy support (ARCHITECTURE.md) ---
|
||||
// При изменении git_revision вызывается redeploy вместо modify.
|
||||
// Если не задано — modify работает без изменений.
|
||||
"git_revision": schema.StringAttribute{Optional: true, MarkdownDescription: "Git revision (commit hash/tag). Changing this triggers redeploy instead of modify."},
|
||||
{{- end }}
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
|
||||
{{- end }}
|
||||
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})},
|
||||
{{- range .OutputParams }}
|
||||
{{- if or (OutputIsMap .) (OutputIsList .) }}
|
||||
"{{ToSnake .Code}}": schema.{{if OutputIsMap .}}Map{{else}}List{{end}}Attribute{Computed: true, ElementType: types.StringType{{if OutputSensitive .}}, Sensitive: true{{end}}},
|
||||
{{- else }}
|
||||
"{{ToSnake .Code}}": schema.StringAttribute{Computed: true{{if OutputSensitive .}}, Sensitive: true{{end}}},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
resp.Schema = schema.Schema{Attributes: attrs}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
||||
if r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var config *{{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var state *{{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
// Destroy-план (plan == null): create-time проверку «уже существует / adopt»
|
||||
// запускать нельзя — удаление не валидируется через существование инстанса.
|
||||
if req.Plan.Raw.IsNull() {
|
||||
return
|
||||
}
|
||||
|
||||
if state != nil && !state.ID.IsNull() && !state.ID.IsUnknown() {
|
||||
var plan {{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
{{- if .HasRefSvcParams }}
|
||||
// FIX(uuid-case): resolve ref_svc params НЕ делаем в ModifyPlan.
|
||||
// Terraform правило: plan ОБЯЗАН равняться config для user-provided атрибутов.
|
||||
// Resolve (displayName → UUID или uppercase → lowercase) нужен только для
|
||||
// API-вызова в Create/Update. Делать его здесь = менять plan = ошибка
|
||||
// "Provider produced invalid plan: planned value does not match config value".
|
||||
{{- end }}
|
||||
if !plan.ResourceName.IsNull() && !plan.ResourceName.IsUnknown() && !state.ResourceName.IsNull() && !state.ResourceName.IsUnknown() {
|
||||
if plan.ResourceName.ValueString() != state.ResourceName.ValueString() {
|
||||
resp.Diagnostics.AddError("Нельзя изменить resource_name", "Параметр resource_name задается при создании и не может быть изменен. Создайте новый ресурс с другим именем.")
|
||||
return
|
||||
}
|
||||
}
|
||||
{{- range .CreateOnlyParams }}
|
||||
{{- if not (IsNested .) }}
|
||||
if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
|
||||
{{- if eq (ParamType .) "types.Bool" }}
|
||||
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
|
||||
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
|
||||
return
|
||||
}
|
||||
{{- else if eq (ParamType .) "types.Int64" }}
|
||||
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
|
||||
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
|
||||
return
|
||||
}
|
||||
{{- else }}
|
||||
// FIX(uuid-case): сравниваем без учёта регистра — API возвращает UUID
|
||||
// в lowercase, пользователь мог написать upper/mixed. Это одно и то же
|
||||
// значение, менять его нельзя только если оно реально другое.
|
||||
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
|
||||
resp.Diagnostics.AddError("Нельзя изменить {{ToSnake .Code}}", "Параметр задается при создании и не может быть изменен.")
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
return
|
||||
}
|
||||
{{- range .SchemaParams }}
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
|
||||
if config.{{ToCamel .Code}}.IsNull() {
|
||||
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
|
||||
return
|
||||
}
|
||||
if config.{{ToCamel .Code}}.IsUnknown() {
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
// ⛔ Create-time проверка существования/усыновления здесь СОЗНАТЕЛЬНО НЕ вызывается.
|
||||
//
|
||||
// Причина: при tainted-ресурсе Terraform планирует ЗАМЕНУ (destroy+create), и
|
||||
// create-узел замены приходит в ModifyPlan с prior state = null — ровно как у
|
||||
// нового ресурса. Отличить «замену» от «создания» на этом уровне невозможно,
|
||||
// поэтому проверка «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ» ложно срабатывала на
|
||||
// ещё не удалённый инстанс и блокировала plan/destroy.
|
||||
//
|
||||
// Проверка осталась в Create (CreateExistingResourceDiagnosticsWithDomainAndServices):
|
||||
// на apply она выполняется ПОСЛЕ удаления старого инстанса, поэтому конфликта уже нет.
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var data {{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
{{- range .SchemaParams }}
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") (not (IsNested .)) }}
|
||||
if data.{{ToCamel .Code}}.IsNull() || data.{{ToCamel .Code}}.IsUnknown() {
|
||||
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .HasRefSvcParams }}
|
||||
{{- range .CreateParams }}
|
||||
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
|
||||
// refSvc-поле резолвим только для API-запроса.
|
||||
// config не перезаписываем: пользовательский display name или UUID должен
|
||||
// пройти в state ровно в том виде, в котором его передал Terraform.
|
||||
resolved{{ToCamel .Code}} := data.{{ToCamel .Code}}.ValueString()
|
||||
if !data.{{ToCamel .Code}}.IsNull() && !data.{{ToCamel .Code}}.IsUnknown() {
|
||||
var err error
|
||||
resolved{{ToCamel .Code}}, err = r.client.ResolveRefSvcParamValue(ctx, {{.RefSvcId}}, data.{{ToCamel .Code}}.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
resourceName := data.ResourceName.ValueString()
|
||||
desiredDomain := ""
|
||||
{{- if .HasDomainParam }}
|
||||
if !data.Domain.IsNull() && !data.Domain.IsUnknown() {
|
||||
desiredDomain = data.Domain.ValueString()
|
||||
}
|
||||
{{- end }}
|
||||
domainServiceIDs := []int{ {{- range .DomainServiceIDs }}{{.}}, {{- end }} }
|
||||
resp.Diagnostics.Append(resources_core.CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), desiredDomain, domainServiceIDs, {{.SupportsSuspendDestroy}})...)
|
||||
// ⛔ Проверяем HasError ДО create — при hard-error (running без adopt, suspend без adopt,
|
||||
// not created, конфликт) сайд-эффект create не должен выполняться.
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
params := map[int]string{
|
||||
{{- range .CreateParams }}
|
||||
{{- if not (IsNested .) }}
|
||||
{{- if and (gt .RefSvcId 0) (eq (ParamType .) "types.String") }}
|
||||
{{.ID}}: resolved{{ToCamel .Code}},
|
||||
{{- else }}
|
||||
{{.ID}}: {{ParamFormat . (printf "data.%s" (ToCamel .Code))}},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
{{- range .CreateParams }}
|
||||
{{- if (IsNested .) }}
|
||||
if data.{{ToCamel .Code}} != nil {
|
||||
params[{{.ID}}] = {{NestedJSONExpr . "data"}}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
operationTimeout := ""
|
||||
if !data.OperationTimeout.IsNull() && !data.OperationTimeout.IsUnknown() {
|
||||
operationTimeout = data.OperationTimeout.ValueString()
|
||||
}
|
||||
if !data.LogLevel.IsNull() && !data.LogLevel.IsUnknown() {
|
||||
ctx = core.CtxWithLogLevel(ctx, data.LogLevel.ValueString())
|
||||
}
|
||||
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
data.ID = types.StringValue(id)
|
||||
|
||||
state, diags := resources_core.RefreshResourceState(ctx, r.client, id, {{.ServiceID}}, data, []resources_core.StateField{
|
||||
{{- range .OutputParams }}
|
||||
{Code: "{{.Code}}"},
|
||||
{{- end }}
|
||||
}, []resources_core.InputField{
|
||||
{{- range .SchemaParams }}
|
||||
{{- if eq .RefSvcId 0 }}
|
||||
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
})
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state {{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
if state.ID.IsNull() || state.ID.IsUnknown() {
|
||||
return
|
||||
}
|
||||
if r.client != nil {
|
||||
remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.ID.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.ID.ValueString(), {{.ServiceID}}, state, []resources_core.StateField{
|
||||
{{- range .OutputParams }}
|
||||
{Code: "{{.Code}}"},
|
||||
{{- end }}
|
||||
}, []resources_core.InputField{
|
||||
{{- range .SchemaParams }}
|
||||
{{- if eq .RefSvcId 0 }}
|
||||
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
})
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan {{ToCamel .Name}}Model
|
||||
var state {{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := state.ID
|
||||
if instanceID.IsNull() || instanceID.IsUnknown() {
|
||||
instanceID = plan.ID
|
||||
}
|
||||
if instanceID.IsNull() || instanceID.IsUnknown() {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра для modify")
|
||||
return
|
||||
}
|
||||
|
||||
hasServiceParamChanges := false
|
||||
{{- range .ModifyParams }}
|
||||
{{- if not (IsNested .) }}
|
||||
if !hasServiceParamChanges {
|
||||
if plan.{{ToCamel .Code}}.IsNull() != state.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() != state.{{ToCamel .Code}}.IsUnknown() {
|
||||
hasServiceParamChanges = true
|
||||
} else if !plan.{{ToCamel .Code}}.IsNull() && !plan.{{ToCamel .Code}}.IsUnknown() && !state.{{ToCamel .Code}}.IsNull() && !state.{{ToCamel .Code}}.IsUnknown() {
|
||||
{{- if eq (ParamType .) "types.Bool" }}
|
||||
if plan.{{ToCamel .Code}}.ValueBool() != state.{{ToCamel .Code}}.ValueBool() {
|
||||
hasServiceParamChanges = true
|
||||
}
|
||||
{{- else if eq (ParamType .) "types.Int64" }}
|
||||
if plan.{{ToCamel .Code}}.ValueInt64() != state.{{ToCamel .Code}}.ValueInt64() {
|
||||
hasServiceParamChanges = true
|
||||
}
|
||||
{{- else }}
|
||||
if !strings.EqualFold(plan.{{ToCamel .Code}}.ValueString(), state.{{ToCamel .Code}}.ValueString()) {
|
||||
hasServiceParamChanges = true
|
||||
}
|
||||
{{- end }}
|
||||
}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .HasRedeploy }}
|
||||
// --- Redeploy support (ARCHITECTURE.md) ---
|
||||
// Проверяем изменился ли git_revision.
|
||||
// Если да — вызываем redeploy вместо modify.
|
||||
// git_revision = "" (не задано) → обычный modify.
|
||||
redeployRequested := false
|
||||
if !plan.GitRevision.IsNull() && !plan.GitRevision.IsUnknown() {
|
||||
if state.GitRevision.IsNull() || state.GitRevision.IsUnknown() || plan.GitRevision.ValueString() != state.GitRevision.ValueString() {
|
||||
redeployRequested = true
|
||||
}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
if !hasServiceParamChanges{{if .HasRedeploy}} && !redeployRequested{{end}} {
|
||||
// Сервисные параметры не изменились → modify НЕ вызываем.
|
||||
//
|
||||
// НО read-back поля (state_params*, state_out*, vault_*) обязаны быть
|
||||
// перечитаны из инстанса. Раньше здесь слепо копировались значения из
|
||||
// старого state, из-за чего tfstate хранил устаревшее значение:
|
||||
// пример — state_params["needEnableAVI"]="false", когда на платформе уже
|
||||
// true (модификатор включил ALB). Это давало ложный дрейф
|
||||
// «Objects have changed outside of Terraform» на каждом plan.
|
||||
plan.ID = instanceID
|
||||
refreshed, refreshDiags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
|
||||
{{- range .OutputParams }}
|
||||
{Code: "{{.Code}}"},
|
||||
{{- end }}
|
||||
}, []resources_core.InputField{
|
||||
{{- range .SchemaParams }}
|
||||
{{- if eq .RefSvcId 0 }}
|
||||
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
})
|
||||
resp.Diagnostics.Append(refreshDiags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...)
|
||||
return
|
||||
}
|
||||
|
||||
operationTimeout := ""
|
||||
if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() {
|
||||
operationTimeout = plan.OperationTimeout.ValueString()
|
||||
}
|
||||
if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() {
|
||||
ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString())
|
||||
}
|
||||
|
||||
{{- if .HasRedeploy }}
|
||||
// --- Redeploy support (ARCHITECTURE.md) ---
|
||||
// 1. Modify — только если изменились параметры сервиса
|
||||
if hasServiceParamChanges {
|
||||
{{- end }}
|
||||
params := map[int]string{
|
||||
{{- range .ModifyParams }}
|
||||
{{- if not (IsNested .) }}
|
||||
{{.ID}}: {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
{{- range .ModifyParams }}
|
||||
{{- if (IsNested .) }}
|
||||
if plan.{{ToCamel .Code}} != nil {
|
||||
params[{{.ID}}] = {{NestedJSONExpr . "plan"}}
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
if err := resources_core.UpdateResourceWithTimeout(ctx, r.client, instanceID.ValueString(), params, operationTimeout); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
{{- if .HasRedeploy }}
|
||||
}
|
||||
|
||||
// 2. Redeploy — только если изменился git_revision
|
||||
if redeployRequested {
|
||||
redeployParams := map[int]string{}
|
||||
{{- range .RedeployParams }}
|
||||
{{- if (IsNested .) }}
|
||||
if plan.{{ToCamel .Code}} != nil {
|
||||
redeployParams[{{.ID}}] = {{NestedJSONExpr . "plan"}}
|
||||
}
|
||||
{{- else }}
|
||||
redeployParams[{{.ID}}] = {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
if err := r.client.RunRedeployOperation(ctx, instanceID.ValueString(), operationTimeout, redeployParams); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
plan.ID = instanceID
|
||||
state, diags := resources_core.RefreshResourceState(ctx, r.client, instanceID.ValueString(), {{.ServiceID}}, plan, []resources_core.StateField{
|
||||
{{- range .OutputParams }}
|
||||
{Code: "{{.Code}}"},
|
||||
{{- end }}
|
||||
}, []resources_core.InputField{
|
||||
{{- range .SchemaParams }}
|
||||
{{- if eq .RefSvcId 0 }}
|
||||
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
})
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state {{ToCamel .Name}}Model
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
if state.ID.IsNull() || state.ID.IsUnknown() {
|
||||
return
|
||||
}
|
||||
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
deleteMode := "state_only"
|
||||
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
|
||||
deleteMode = "suspend"
|
||||
}
|
||||
{{- else }}
|
||||
deleteMode := "delete"
|
||||
{{- end }}
|
||||
|
||||
operationTimeout := ""
|
||||
if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() {
|
||||
operationTimeout = state.OperationTimeout.ValueString()
|
||||
}
|
||||
if !state.LogLevel.IsNull() && !state.LogLevel.IsUnknown() {
|
||||
ctx = core.CtxWithLogLevel(ctx, state.LogLevel.ValueString())
|
||||
}
|
||||
if err := resources_core.DeleteResourceWithTimeout(ctx, r.client, state.ID.ValueString(), deleteMode, operationTimeout); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Error", "Invalid client type")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,473 @@
|
||||
// Package loader — загрузка YAML-спеков и построение GenResource/GenSubresource/GenAction.
|
||||
//
|
||||
// LoadSpecs — главная функция. Для каждого YAML:
|
||||
// 1. Парсит операции (create/modify/delete/suspend/resume/...)
|
||||
// 2. Классифицирует их на instance/subresource/action
|
||||
// 3. Сливает параметры, вычисляет CreateOnly и ForceNew
|
||||
// 4. Строит GenResource/GenSubresource/GenAction
|
||||
// 5. Сортирует результат по имени сервиса
|
||||
//
|
||||
// ValidateSpec — fail-fast валидация (паника при неизвестном Kind).
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"resource-generator/internal/params"
|
||||
"resource-generator/internal/types"
|
||||
"tf-tools/lib"
|
||||
)
|
||||
|
||||
// LoadSpecs загружает все YAML-спеки из директории и строит модели всех ресурсов.
|
||||
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, []types.GenModifier, error) {
|
||||
var services []types.GenResource
|
||||
var subs []types.GenSubresource
|
||||
var actions []types.GenAction
|
||||
var modifiers []types.GenModifier
|
||||
domainServiceIDsSet := map[int]struct{}{}
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spec types.ServiceSpec
|
||||
if err := yaml.Unmarshal(b, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// P1.4: fail-fast на неизвестных kind'ах и отсутствующих обязательных полях.
|
||||
if err := ValidateSpec(path, &spec); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
createParams := []types.Param{}
|
||||
modifyParams := []types.Param{}
|
||||
supportsSuspendDestroy := false
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind == "modifier" {
|
||||
name := strings.TrimSpace(op.Modifier)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(op.Action)
|
||||
}
|
||||
modifier := types.GenModifier{
|
||||
ServiceName: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
ModifierName: name,
|
||||
OperationName: op.Action,
|
||||
Params: ConvertParams(op.Params),
|
||||
DeleteStrategy: normalizeDeleteStrategy(op.DeleteStrategy),
|
||||
Idempotency: normalizeIdempotency(op.Idempotency),
|
||||
}
|
||||
modifier.DeleteParams = convertDeleteParams(op.DeleteParams)
|
||||
modifier.SchemaParams = modifier.Params
|
||||
for idx := range modifier.SchemaParams {
|
||||
if modifier.SchemaParams[idx].HasSubParams {
|
||||
modifier.SchemaParams[idx].IsJson = true
|
||||
}
|
||||
}
|
||||
params.Analyze(&modifier.UsesBool, &modifier.UsesInt64, &modifier.UsesString, &modifier.HasDefaults, &modifier.NeedsBoolDefault, &modifier.NeedsInt64Default, &modifier.NeedsStringDefault, modifier.SchemaParams)
|
||||
modifier.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(modifier.SchemaParams)
|
||||
modifiers = append(modifiers, modifier)
|
||||
continue
|
||||
}
|
||||
if op.Kind != "instance" {
|
||||
continue
|
||||
}
|
||||
switch op.Action {
|
||||
case "create":
|
||||
createParams = ConvertParams(op.Params)
|
||||
case "modify":
|
||||
modifyParams = ConvertParams(op.Params)
|
||||
case "suspend":
|
||||
supportsSuspendDestroy = true
|
||||
}
|
||||
}
|
||||
schemaParams := params.Merge(createParams, modifyParams)
|
||||
createParams = params.AlignParamTypes(createParams, schemaParams)
|
||||
modifyParams = params.AlignParamTypes(modifyParams, schemaParams)
|
||||
createOnly := params.ComputeCreateOnly(createParams, modifyParams)
|
||||
schemaParams = params.MarkCreateOnly(schemaParams, createOnly)
|
||||
hasDomainParam := false
|
||||
for _, param := range schemaParams {
|
||||
if strings.EqualFold(strings.TrimSpace(param.Code), "domain") {
|
||||
hasDomainParam = true
|
||||
domainServiceIDsSet[spec.ServiceID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
adoptExistingOnCreate := false
|
||||
|
||||
suspendOnDestroy := true
|
||||
if spec.Lifecycle.SuspendOnDestroyDefault != nil {
|
||||
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
|
||||
}
|
||||
|
||||
gr := types.GenResource{
|
||||
Name: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
CreateParams: createParams,
|
||||
ModifyParams: modifyParams,
|
||||
SchemaParams: schemaParams,
|
||||
CreateOnlyParams: params.FilterCreateOnly(schemaParams),
|
||||
CreateOnlyRequiredParams: params.FilterCreateOnlyRequired(schemaParams),
|
||||
OutputParams: NormalizeOutputParams(spec.Outputs.Params),
|
||||
HasRefSvcParams: HasRefSvcParams(schemaParams),
|
||||
SupportsSuspendDestroy: supportsSuspendDestroy,
|
||||
SuspendOnDestroy: suspendOnDestroy,
|
||||
AdoptExistingOnCreate: adoptExistingOnCreate,
|
||||
HasDomainParam: hasDomainParam,
|
||||
}
|
||||
params.Analyze(&gr.UsesBool, &gr.UsesInt64, &gr.UsesString, &gr.HasDefaults, &gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams)
|
||||
gr.NeedsBoolUseStateForUnknown, gr.NeedsInt64UseStateForUnknown = analyzeUseStateForUnknown(gr.SchemaParams)
|
||||
// Анализируем nested sub-params для default-импортов
|
||||
if params.AnalyzeNestedDefaults(&gr.NeedsBoolDefault, &gr.NeedsInt64Default, &gr.NeedsStringDefault, gr.SchemaParams) {
|
||||
gr.NeedsFmtImport = true
|
||||
}
|
||||
gr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(gr.SchemaParams)
|
||||
// Строковый import нужен только для обычных строковых сравнений и
|
||||
// обработок в шаблоне. Старый restore-хук для refSvc больше не
|
||||
// генерируется, поэтому отдельный флаг под него не нужен.
|
||||
gr.NeedsStringsImport = params.AnalyzeNeedsStrings(gr.ModifyParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyRequiredParams) || params.AnalyzeNeedsStrings(gr.CreateOnlyParams)
|
||||
gr.NeedsFmtImport = params.HasNestedParams(gr.SchemaParams)
|
||||
|
||||
// --- Action processing (ДО append, чтобы HasRedeploy попал в слайс) ---
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind != "action" {
|
||||
continue
|
||||
}
|
||||
switch op.Action {
|
||||
case "redeploy":
|
||||
gr.HasRedeploy = true
|
||||
gr.RedeployParams = ConvertParams(op.Params)
|
||||
case "restart", "recovery", "reconcile":
|
||||
// Исключены
|
||||
default:
|
||||
act := types.GenAction{
|
||||
ServiceName: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
ActionName: op.Action,
|
||||
OperationName: op.Name,
|
||||
Params: ConvertParams(op.Params),
|
||||
}
|
||||
act.SchemaParams = act.Params
|
||||
params.Analyze(&act.UsesBool, &act.UsesInt64, &act.UsesString, &act.HasDefaults, &act.NeedsBoolDefault, &act.NeedsInt64Default, &act.NeedsStringDefault, act.SchemaParams)
|
||||
act.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(act.SchemaParams)
|
||||
act.NeedsStringsImport = params.AnalyzeNeedsStrings(act.SchemaParams)
|
||||
actions = append(actions, act)
|
||||
}
|
||||
}
|
||||
|
||||
services = append(services, gr)
|
||||
|
||||
srByName := map[string]*types.GenSubresource{}
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind != "subresource" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(op.Subresource)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
sr := srByName[name]
|
||||
if sr == nil {
|
||||
sr = &types.GenSubresource{ServiceName: spec.Name, ServiceID: spec.ServiceID, SubName: name}
|
||||
srByName[name] = sr
|
||||
}
|
||||
switch op.Action {
|
||||
case "create":
|
||||
sr.CreateOpName = op.Name
|
||||
sr.CreateParams = ConvertParams(op.Params)
|
||||
case "modify":
|
||||
sr.ModifyOpName = op.Name
|
||||
sr.ModifyParams = ConvertParams(op.Params)
|
||||
case "delete":
|
||||
sr.DeleteOpName = op.Name
|
||||
sr.DeleteParams = ConvertParams(op.Params)
|
||||
}
|
||||
}
|
||||
for _, sr := range srByName {
|
||||
sr.SchemaParams = params.Merge(sr.CreateParams, sr.ModifyParams, sr.DeleteParams)
|
||||
sr.CreateParams = params.AlignParamTypes(sr.CreateParams, sr.SchemaParams)
|
||||
sr.ModifyParams = params.AlignParamTypes(sr.ModifyParams, sr.SchemaParams)
|
||||
sr.DeleteParams = params.AlignParamTypes(sr.DeleteParams, sr.SchemaParams)
|
||||
createOnly := params.ComputeCreateOnly(sr.CreateParams, sr.ModifyParams)
|
||||
sr.SchemaParams = params.MarkCreateOnly(sr.SchemaParams, createOnly)
|
||||
forceNewCodes := params.BuildSubresourceForceNewCodes(sr, createOnly)
|
||||
sr.SchemaParams = params.MarkForceNew(sr.SchemaParams, forceNewCodes)
|
||||
sr.IdentityParams = params.FilterForceNew(sr.SchemaParams)
|
||||
if len(sr.IdentityParams) == 0 {
|
||||
sr.IdentityParams = sr.SchemaParams
|
||||
}
|
||||
sr.HasRefSvcParams = HasRefSvcParams(sr.SchemaParams)
|
||||
params.Analyze(&sr.UsesBool, &sr.UsesInt64, &sr.UsesString, &sr.HasDefaults, &sr.NeedsBoolDefault, &sr.NeedsInt64Default, &sr.NeedsStringDefault, sr.SchemaParams)
|
||||
params.AnalyzePlanModifiers(&sr.NeedsBoolPlanMod, &sr.NeedsInt64PlanMod, &sr.NeedsStringPlanMod, sr.SchemaParams)
|
||||
sr.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(sr.SchemaParams)
|
||||
sr.NeedsStringsImport = params.AnalyzeNeedsStrings(sr.ModifyParams)
|
||||
subs = append(subs, *sr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if walkErr != nil {
|
||||
return nil, nil, nil, nil, walkErr
|
||||
}
|
||||
|
||||
domainServiceIDs := make([]int, 0, len(domainServiceIDsSet))
|
||||
for serviceID := range domainServiceIDsSet {
|
||||
domainServiceIDs = append(domainServiceIDs, serviceID)
|
||||
}
|
||||
sort.Ints(domainServiceIDs)
|
||||
for idx := range services {
|
||||
if len(domainServiceIDs) == 0 {
|
||||
services[idx].DomainServiceIDs = nil
|
||||
continue
|
||||
}
|
||||
services[idx].DomainServiceIDs = append([]int(nil), domainServiceIDs...)
|
||||
}
|
||||
|
||||
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
|
||||
sort.Slice(subs, func(i, j int) bool {
|
||||
if subs[i].ServiceName == subs[j].ServiceName {
|
||||
return subs[i].SubName < subs[j].SubName
|
||||
}
|
||||
return subs[i].ServiceName < subs[j].ServiceName
|
||||
})
|
||||
sort.Slice(actions, func(i, j int) bool {
|
||||
if actions[i].ServiceName == actions[j].ServiceName {
|
||||
return actions[i].ActionName < actions[j].ActionName
|
||||
}
|
||||
return actions[i].ServiceName < actions[j].ServiceName
|
||||
})
|
||||
sort.Slice(modifiers, func(i, j int) bool {
|
||||
if modifiers[i].ServiceName == modifiers[j].ServiceName {
|
||||
return modifiers[i].ModifierName < modifiers[j].ModifierName
|
||||
}
|
||||
return modifiers[i].ServiceName < modifiers[j].ServiceName
|
||||
})
|
||||
|
||||
return services, subs, actions, modifiers, nil
|
||||
}
|
||||
|
||||
// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов.
|
||||
func ConvertParams(params []types.ParamSpec) []types.Param {
|
||||
out := make([]types.Param, 0, len(params))
|
||||
for _, p := range params {
|
||||
typeName := NormalizeParamType(p.DataType)
|
||||
defVal := NormalizeDefault(p.Default)
|
||||
ref := 0
|
||||
if p.RefSvcID != nil {
|
||||
ref = *p.RefSvcID
|
||||
}
|
||||
isNested := len(p.SubParams) > 0
|
||||
// Рекурсивно конвертируем SubParams для map-fixed/array-map-fixed.
|
||||
subParams := make([]types.Param, 0, len(p.SubParams))
|
||||
for _, sp := range p.SubParams {
|
||||
sub := ConvertParams([]types.ParamSpec{sp})
|
||||
if len(sub) > 0 {
|
||||
subParams = append(subParams, sub[0])
|
||||
}
|
||||
}
|
||||
out = append(out, types.Param{
|
||||
ID: p.ID,
|
||||
Code: p.Code,
|
||||
Type: typeName,
|
||||
Required: p.Required,
|
||||
Default: defVal,
|
||||
RefSvcId: ref,
|
||||
Descr: strings.TrimSpace(p.Descr),
|
||||
Man: strings.TrimSpace(p.Man),
|
||||
Sensitive: p.IsSensitive,
|
||||
IsJson: strings.EqualFold(strings.TrimSpace(p.DataType), "json"),
|
||||
HasSubParams: isNested,
|
||||
SubParams: subParams,
|
||||
IsModifiable: p.IsModifiable,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeParamType нормализует строку data_type → bool | int64 | string | map-fixed | array-map-fixed.
|
||||
func NormalizeParamType(value string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "map-fixed" || v == "array-map-fixed" {
|
||||
return v
|
||||
}
|
||||
if strings.Contains(v, "bool") {
|
||||
return "bool"
|
||||
}
|
||||
if strings.Contains(v, "int") || strings.Contains(v, "number") {
|
||||
return "int64"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// NormalizeDefault нормализует значение по умолчанию в строку.
|
||||
func NormalizeDefault(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeOutputParams нормализует выходные параметры.
|
||||
func NormalizeOutputParams(params []types.OutputParam) []types.OutputParam {
|
||||
if len(params) == 0 {
|
||||
return []types.OutputParam{}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// HasRefSvcParams проверяет наличие ref_svc-параметров.
|
||||
func HasRefSvcParams(params []types.Param) bool {
|
||||
for _, p := range params {
|
||||
if p.RefSvcId > 0 && strings.EqualFold(strings.TrimSpace(p.Type), "string") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownKinds — допустимые значения Kind операций.
|
||||
var KnownKinds = map[string]bool{
|
||||
"instance": true,
|
||||
"subresource": true,
|
||||
"action": true,
|
||||
"modifier": true,
|
||||
}
|
||||
|
||||
// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds.
|
||||
// P1.4: fail-fast — паника при неизвестном kind вместо тихого игнорирования.
|
||||
func ValidateSpec(path string, spec *types.ServiceSpec) error {
|
||||
if spec.Name == "" {
|
||||
return fmt.Errorf("missing required field: name")
|
||||
}
|
||||
if spec.ServiceID <= 0 {
|
||||
return fmt.Errorf("missing required field: service_id")
|
||||
}
|
||||
for i, op := range spec.Operations {
|
||||
if op.Kind == "" {
|
||||
return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name)
|
||||
}
|
||||
if !KnownKinds[op.Kind] {
|
||||
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action, modifier)", i, op.Name, op.Kind)
|
||||
}
|
||||
if op.Action == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind)
|
||||
}
|
||||
if op.Kind == "subresource" && op.Subresource == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name)
|
||||
}
|
||||
if op.Kind == "modifier" && strings.TrimSpace(op.Action) == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): missing required field: action", i, op.Name)
|
||||
}
|
||||
if op.Kind == "modifier" {
|
||||
if err := validateModifierOperation(i, &op); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeDeleteStrategy возвращает каноническое значение delete_strategy.
|
||||
// Пусто → noop_warn; неизвестное — как есть (валидация в ValidateSpec отклонит раньше).
|
||||
func normalizeDeleteStrategy(raw string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
if v == "" {
|
||||
return "noop_warn"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeIdempotency возвращает каноническое значение idempotency.
|
||||
// Пусто → none.
|
||||
func normalizeIdempotency(raw string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
if v == "" {
|
||||
return "none"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// convertDeleteParams переносит lib.DeleteParam → types.DeleteParam (wire-строки).
|
||||
func convertDeleteParams(raw []lib.DeleteParam) []types.DeleteParam {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]types.DeleteParam, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
out = append(out, types.DeleteParam{Code: strings.TrimSpace(p.Code), Value: p.Value})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateModifierOperation — fail-fast для полей modifier-операции.
|
||||
func validateModifierOperation(i int, op *lib.OperationSpec) error {
|
||||
ds := normalizeDeleteStrategy(op.DeleteStrategy)
|
||||
switch ds {
|
||||
case "noop_warn", "inverse", "error":
|
||||
default:
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown delete_strategy %q (valid: noop_warn, inverse, error)", i, op.Name, op.DeleteStrategy)
|
||||
}
|
||||
|
||||
idem := normalizeIdempotency(op.Idempotency)
|
||||
switch idem {
|
||||
case "none", "check_before_run":
|
||||
default:
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): unknown idempotency %q (valid: none, check_before_run)", i, op.Name, op.Idempotency)
|
||||
}
|
||||
|
||||
if ds == "inverse" && len(op.DeleteParams) == 0 {
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_strategy=inverse требует delete_params", i, op.Name)
|
||||
}
|
||||
|
||||
// каждый delete_params.code обязан существовать среди params (по lower-code)
|
||||
paramCodes := make(map[string]struct{}, len(op.Params))
|
||||
for _, p := range op.Params {
|
||||
paramCodes[strings.ToLower(strings.TrimSpace(p.Code))] = struct{}{}
|
||||
}
|
||||
for _, dp := range op.DeleteParams {
|
||||
if _, ok := paramCodes[strings.ToLower(strings.TrimSpace(dp.Code))]; !ok {
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): delete_params.code %q отсутствует в params", i, op.Name, dp.Code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyzeUseStateForUnknown определяет, нужны ли импорты boolplanmodifier/int64planmodifier:
|
||||
// есть ли среди SchemaParams скалярный Optional+Computed (без Default) параметр
|
||||
// соответствующего типа, для которого генерируется UseStateForUnknown().
|
||||
func analyzeUseStateForUnknown(schemaParams []types.Param) (needsBool, needsInt64 bool) {
|
||||
for _, p := range schemaParams {
|
||||
if p.IsJson || p.Required || p.RefSvcId != 0 || p.Default != "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(p.Type) {
|
||||
case "bool":
|
||||
needsBool = true
|
||||
case "int", "int64", "number":
|
||||
needsInt64 = true
|
||||
}
|
||||
}
|
||||
return needsBool, needsInt64
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# =============================================================================
|
||||
# Ресурсы-модификаторы (операции modify, которых нет в create-схеме ресурсов)
|
||||
#
|
||||
# Порядок строго такой:
|
||||
# орга (создана вручную в ЛК)
|
||||
# -> nubes_vc_vdc.vdc
|
||||
# -> nubes_vc_nsxt.edge
|
||||
# -> nubes_vc_org_ip_allocation (выделение внешних IP на орге)
|
||||
# -> nubes_vc_nsxt_snat (SNAT на эдже этим ipSpace)
|
||||
#
|
||||
# Почему аллокация ПОСЛЕ эджа: платформа строит список ipSpace из состояния
|
||||
# `job.vcd.networkProvider` / `job.vcd.providerGateway`, то есть требует уже
|
||||
# созданный vDC и Edge. Иначе modify на орге падает
|
||||
# («Can't cast Complex Object Type Struct to String»).
|
||||
# =============================================================================
|
||||
|
||||
# 1. Внешние IP на организации (modify: vIPConfigure, массив перезаписывается целиком)
|
||||
resource "nubes_vc_org_ip_allocation" "org_ip" {
|
||||
organization = var.organization
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{
|
||||
name = var.ip_space_name
|
||||
count = var.ip_count
|
||||
}
|
||||
])
|
||||
|
||||
# false = при destroy отправить обратный modify с count=0 (квота обнулится)
|
||||
keep_on_destroy = false
|
||||
|
||||
depends_on = [nubes_vc_nsxt.edge]
|
||||
}
|
||||
|
||||
# 2. SNAT на эдже (modify: ipSpaceName)
|
||||
resource "nubes_vc_nsxt_snat" "snat" {
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
ip_space_name = var.ip_space_name
|
||||
|
||||
keep_on_destroy = false
|
||||
|
||||
# ipSpace должен быть уже выделен на организации
|
||||
depends_on = [nubes_vc_org_ip_allocation.org_ip]
|
||||
}
|
||||
|
||||
output "allocated_org_ip" {
|
||||
description = "Выделено внешних IP на организации"
|
||||
value = {
|
||||
organization = var.organization
|
||||
ip_space_name = var.ip_space_name
|
||||
ip_count = var.ip_count
|
||||
}
|
||||
}
|
||||
|
||||
output "snat_ip_space" {
|
||||
description = "ipSpace, включённый как SNAT на эдже"
|
||||
value = nubes_vc_nsxt_snat.snat.ip_space_name
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# =============================================================================
|
||||
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
|
||||
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
|
||||
#
|
||||
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
|
||||
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
|
||||
# или закомментировать ресурс.
|
||||
#
|
||||
# Порядок (чек-лист из инструкции на услугу в ЛК):
|
||||
# 1) Организация в Cloud Director — создана вручную в ЛК
|
||||
# 2) nubes_vc_vdc.vdc — есть
|
||||
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
|
||||
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
|
||||
# 5) SNAT на Edge — nubes_vc_nsxt_snat
|
||||
# 6) Kubernetes кластер Штурвал — этот ресурс
|
||||
#
|
||||
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
|
||||
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
|
||||
# =============================================================================
|
||||
|
||||
# --- Переменные Штурвала ---
|
||||
|
||||
variable "shturval_resource_name" {
|
||||
type = string
|
||||
default = "shturval-dev"
|
||||
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
|
||||
}
|
||||
|
||||
variable "shturval_cluster_name" {
|
||||
type = string
|
||||
default = "shturval-dev-00"
|
||||
description = "Имя кластера внутри Штурвала"
|
||||
}
|
||||
|
||||
variable "shturval_app_version" {
|
||||
type = string
|
||||
default = "2.14.0"
|
||||
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск control plane, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество мастер-нод: 1, 3 или 5"
|
||||
}
|
||||
|
||||
variable "shturval_worker_group_name" {
|
||||
type = string
|
||||
default = "workers-shturval-dev"
|
||||
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск воркеров, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество воркер-нод (минимум 1)"
|
||||
}
|
||||
|
||||
# --- Значения, которые собираются из переменных ---
|
||||
|
||||
locals {
|
||||
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
|
||||
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
|
||||
# sizingDisk, count, autoscale, labelDeck.
|
||||
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
|
||||
# в snake_case — это ошибка генератора, платформа на них падает с
|
||||
# «Cannot invoke method split() on null object» (не находит groupName → null).
|
||||
shturval_worker_config = jsonencode([
|
||||
{
|
||||
groupName = var.shturval_worker_group_name
|
||||
sizingPolicy = var.shturval_worker_sizing_policy
|
||||
sizingDisk = var.shturval_worker_sizing_disk
|
||||
count = var.shturval_worker_count
|
||||
autoscale = false # автоскейл выключен
|
||||
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
# --- Ресурс Штурвала ---
|
||||
|
||||
resource "nubes_k8s_sthutrval_cluster" "shturval" {
|
||||
resource_name = var.shturval_resource_name
|
||||
|
||||
# Кластер Штурвала уже существует (инстанс «shturval-dev») и в проде не
|
||||
# удаляется неделями, поэтому ресурс должен УСЫНОВИТЬ существующий инстанс,
|
||||
# а не падать с «РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)».
|
||||
# Проверка/adopt выполняются в Create на apply (в plan будет «will be created»).
|
||||
adopt_existing_on_create = true
|
||||
|
||||
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
|
||||
# иначе провайдер сдаётся на дефолтных 600 с.
|
||||
operation_timeout = "60m"
|
||||
|
||||
startup_configuration = {
|
||||
# vDC и Edge из этого же конфига (обязательные поля)
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
|
||||
cluster_name = var.shturval_cluster_name
|
||||
|
||||
# Дополнительные возможности кластера (в ЛК — галочки при создании)
|
||||
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
|
||||
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
|
||||
ex_local_csi = true
|
||||
ex_vip = true
|
||||
ex_update = true
|
||||
ex_ingress = true
|
||||
ex_named_csi = true
|
||||
}
|
||||
|
||||
cluster_configuration = {
|
||||
app_version = var.shturval_app_version
|
||||
}
|
||||
|
||||
control_plane_configuration = {
|
||||
sizing_policy = var.shturval_cp_sizing_policy
|
||||
sizing_disk = var.shturval_cp_sizing_disk
|
||||
count = var.shturval_cp_count
|
||||
}
|
||||
|
||||
worker_configuration = local.shturval_worker_config
|
||||
|
||||
access_configuration = {
|
||||
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
|
||||
access_ip_list_api = jsonencode([]) # пусто = доступ всем
|
||||
need_external_address_ingress = true # внешний адрес для Ingress
|
||||
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
|
||||
}
|
||||
|
||||
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
|
||||
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package types — структуры данных для генератора Terraform-провайдера.
|
||||
//
|
||||
// Моделирует три вида ресурсов:
|
||||
// - GenResource — основной CRUD инстанса (nubes_{service})
|
||||
// - GenSubresource — подресурсы (nubes_{service}_{user}, ...)
|
||||
// - GenAction — действия (почти не используется, redeploy встроен в GenResource.HasRedeploy)
|
||||
//
|
||||
// Правила обработки action'ов из ARCHITECTURE.md:
|
||||
// - redeploy → HasRedeploy=true, поле git_revision в основном ресурсе
|
||||
// - restart/recovery/reconcile → исключены (ручные, только UI)
|
||||
// - всё остальное → отдельный GenAction
|
||||
package types
|
||||
|
||||
import "tf-tools/lib"
|
||||
|
||||
// ─── Алиасы к lib (общий YAML-контракт) ─────────────────────────────────────
|
||||
|
||||
type OutputParam = lib.OutputParam
|
||||
type OperationSpec = lib.OperationSpec
|
||||
type ParamSpec = lib.ParamSpec
|
||||
|
||||
// ─── Входные структуры (из YAML) — локальное определение ────────────────────
|
||||
|
||||
// ServiceSpec — локальное определение (отличается вложенными типами от lib).
|
||||
// Использует *bool для Lifecycle (совместимость с YAML-парсингом).
|
||||
type ServiceSpec struct {
|
||||
Name string `yaml:"name"` // snake_case имя (postgres, s3bucket, ...)
|
||||
ServiceID int `yaml:"service_id"` // числовой ID сервиса в Nubes
|
||||
Outputs struct {
|
||||
Params []OutputParam `yaml:"params"`
|
||||
} `yaml:"outputs"`
|
||||
Lifecycle struct {
|
||||
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
|
||||
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
|
||||
} `yaml:"lifecycle"`
|
||||
Operations []OperationSpec `yaml:"operations"`
|
||||
}
|
||||
|
||||
// Param — нормализованный параметр (после конвертации из ParamSpec).
|
||||
type Param struct {
|
||||
ID int
|
||||
Code string
|
||||
Type string
|
||||
Required bool
|
||||
Default string
|
||||
RefSvcId int
|
||||
Descr string
|
||||
Man string
|
||||
Sensitive bool
|
||||
CreateOnly bool
|
||||
ForceNew bool
|
||||
// IsModifiable — флаг is_modifiable из API (можно менять в UI → можно менять в Terraform).
|
||||
// nil означает «не указано» (как правило, изменяемо).
|
||||
IsModifiable *bool
|
||||
// IsJson — атрибут является JSON-строкой (data_type: json в YAML-спеке).
|
||||
// При IsJson=true в схему добавляется JsonNormalize план-модификатор,
|
||||
// чтобы план и API-ответ (компактный JSON) всегда совпадали.
|
||||
IsJson bool
|
||||
// HasSubParams — параметр имеет подполя (map-fixed или array-map-fixed).
|
||||
HasSubParams bool
|
||||
// SubParams — подполя для map-fixed/array-map-fixed параметров.
|
||||
SubParams []Param
|
||||
}
|
||||
|
||||
// GenResource — ресурс инстанса (nubes_{service}).
|
||||
type GenResource struct {
|
||||
Name string
|
||||
ServiceID int
|
||||
CreateParams []Param
|
||||
ModifyParams []Param
|
||||
SchemaParams []Param
|
||||
CreateOnlyParams []Param
|
||||
CreateOnlyRequiredParams []Param
|
||||
OutputParams []OutputParam
|
||||
HasRefSvcParams bool
|
||||
SupportsSuspendDestroy bool
|
||||
SuspendOnDestroy bool
|
||||
AdoptExistingOnCreate bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsBoolUseStateForUnknown bool
|
||||
NeedsInt64UseStateForUnknown bool
|
||||
HasDomainParam bool
|
||||
DomainServiceIDs []int
|
||||
// HasRedeploy: сервис поддерживает redeploy (пересборка из git).
|
||||
// Если true — в основной ресурс добавляется поле git_revision.
|
||||
// При изменении git_revision вызывается redeploy вместо modify.
|
||||
// Правило из ARCHITECTURE.md: только redeploy включается как inline action.
|
||||
// restart, recovery, reconcile — исключены (ручные операции, только UI).
|
||||
HasRedeploy bool
|
||||
// RedeployParams — параметры redeploy-операции (если есть).
|
||||
RedeployParams []Param
|
||||
// NeedsJsonPlanMod: хотя бы один атрибут имеет data_type: json.
|
||||
// Управляет добавлением импорта planmodifier в сгенерированный файл.
|
||||
NeedsJsonPlanMod bool
|
||||
// NeedsStringsImport: есть строковые ModifyParams (нужен EqualFold в Update).
|
||||
NeedsStringsImport bool
|
||||
// NeedsFmtImport: есть nested (map-fixed) параметры, нужен fmt.Sprintf.
|
||||
NeedsFmtImport bool
|
||||
}
|
||||
|
||||
// GenSubresource — подресурс (nubes_{service}_{sub}).
|
||||
type GenSubresource struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
SubName string
|
||||
CreateOpName string
|
||||
ModifyOpName string
|
||||
DeleteOpName string
|
||||
CreateParams []Param
|
||||
ModifyParams []Param
|
||||
DeleteParams []Param
|
||||
SchemaParams []Param
|
||||
IdentityParams []Param
|
||||
HasRefSvcParams bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsBoolPlanMod bool
|
||||
NeedsInt64PlanMod bool
|
||||
NeedsStringPlanMod bool
|
||||
NeedsJsonPlanMod bool
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
|
||||
// GenAction — action-ресурс (почти не используется, redeploy встроен в GenResource).
|
||||
type GenAction struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ActionName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsJsonPlanMod bool
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
|
||||
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
|
||||
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
|
||||
type GenModifier struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ModifierName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
// DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn).
|
||||
DeleteStrategy string
|
||||
// Idempotency — none | check_before_run (нормализовано из YAML, пусто → none).
|
||||
Idempotency string
|
||||
// DeleteParams — обратные значения (wire-строки), только при DeleteStrategy==inverse.
|
||||
DeleteParams []DeleteParam
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsJsonPlanMod bool
|
||||
}
|
||||
|
||||
// DeleteParam — обратное значение параметра inverse-Delete (code → wire-строка).
|
||||
type DeleteParam struct {
|
||||
Code string
|
||||
Value string
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# =============================================================================
|
||||
# Kubernetes кластер Штурвал — сервис 150, ресурс nubes_k8s_sthutrval_cluster
|
||||
# (НЕ 148 «Менеджмент Kubernetes кластер Штурвал» — это другой сервис)
|
||||
#
|
||||
# Всё, что относится к Штурвалу, лежит ТОЛЬКО в этом файле: переменные, их
|
||||
# значения по умолчанию и сам ресурс. Чтобы выключить Штурвал — удалить файл
|
||||
# или закомментировать ресурс.
|
||||
#
|
||||
# Порядок (чек-лист из инструкции на услугу в ЛК):
|
||||
# 1) Организация в Cloud Director — создана вручную в ЛК
|
||||
# 2) nubes_vc_vdc.vdc — есть
|
||||
# 3) nubes_vc_nsxt.edge — есть, обязательно ALB + AVI VS >= 3
|
||||
# 4) внешние адреса в организации — суммарно >= 3 (nubes_vc_org_ip_allocation)
|
||||
# 5) SNAT на Edge — nubes_vc_nsxt_snat
|
||||
# 6) Kubernetes кластер Штурвал — этот ресурс
|
||||
#
|
||||
# Минимальные требования к кластеру: мастер-нод >= 1, воркер-нод >= 1,
|
||||
# 4 vCPU / 8 GB RAM / 50 GB диска на ноду.
|
||||
# =============================================================================
|
||||
|
||||
# --- Переменные Штурвала ---
|
||||
|
||||
variable "shturval_resource_name" {
|
||||
type = string
|
||||
default = "shturval-dev"
|
||||
description = "Имя услуги «Kubernetes кластер Штурвал» в ЛК"
|
||||
}
|
||||
|
||||
variable "shturval_cluster_name" {
|
||||
type = string
|
||||
default = "shturval-dev-00"
|
||||
description = "Имя кластера внутри Штурвала"
|
||||
}
|
||||
|
||||
variable "shturval_app_version" {
|
||||
type = string
|
||||
default = "2.14.0"
|
||||
description = "Версия Штурвала (значение по умолчанию платформы — 2.14.0)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера control plane: 4 vCPU / 8 GB (минимум по инструкции). Должна существовать в ресурсной платформе vDC — список политик берётся из услуги «Виртуальный датацентр»"
|
||||
}
|
||||
|
||||
variable "shturval_cp_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск control plane, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_cp_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество мастер-нод: 1, 3 или 5"
|
||||
}
|
||||
|
||||
variable "shturval_worker_group_name" {
|
||||
type = string
|
||||
default = "workers-shturval-dev"
|
||||
description = "Имя группы воркеров (уникальное в кластере; допустимы строчные латинские буквы, цифры и дефис)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_policy" {
|
||||
type = string
|
||||
default = "TKG 4CPU 8RAM"
|
||||
description = "Политика размера воркеров: 4 vCPU / 8 GB (минимум по инструкции)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_sizing_disk" {
|
||||
type = number
|
||||
default = 50
|
||||
description = "Диск воркеров, ГБ (минимум 50)"
|
||||
}
|
||||
|
||||
variable "shturval_worker_count" {
|
||||
type = number
|
||||
default = 1
|
||||
description = "Количество воркер-нод (минимум 1)"
|
||||
}
|
||||
|
||||
# --- Значения, которые собираются из переменных ---
|
||||
|
||||
locals {
|
||||
# Группы воркеров передаются JSON-строкой ВНУТРЬ услуги как есть, поэтому ключи
|
||||
# должны быть ровно такими, как в манифесте услуги 150: groupName, sizingPolicy,
|
||||
# sizingDisk, count, autoscale, labelDeck.
|
||||
# ВНИМАНИЕ: в сгенерированном примере провайдера (docs → Example) ключи показаны
|
||||
# в snake_case — это ошибка генератора, платформа на них падает с
|
||||
# «Cannot invoke method split() on null object» (не находит groupName → null).
|
||||
shturval_worker_config = jsonencode([
|
||||
{
|
||||
groupName = var.shturval_worker_group_name
|
||||
sizingPolicy = var.shturval_worker_sizing_policy
|
||||
sizingDisk = var.shturval_worker_sizing_disk
|
||||
count = var.shturval_worker_count
|
||||
autoscale = false # автоскейл выключен
|
||||
labelDeck = true # разрешить разворачивать услуги из ЛК на этих нодах
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
# --- Ресурс Штурвала ---
|
||||
|
||||
resource "nubes_k8s_sthutrval_cluster" "shturval" {
|
||||
resource_name = var.shturval_resource_name
|
||||
|
||||
# Штурвал создаётся долго (десятки минут) — поднимаем таймаут ожидания,
|
||||
# иначе провайдер сдаётся на дефолтных 600 с.
|
||||
operation_timeout = "60m"
|
||||
|
||||
startup_configuration = {
|
||||
# vDC и Edge из этого же конфига (обязательные поля)
|
||||
vdc_uid = nubes_vc_vdc.vdc.id
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id
|
||||
|
||||
cluster_name = var.shturval_cluster_name
|
||||
|
||||
# Дополнительные возможности кластера (в ЛК — галочки при создании)
|
||||
ex_logging = true # логи в Loki (без него логи услуг не видны в ЛК)
|
||||
ex_monitoring = true # метрики в VictoriaMetrics (без него метрик в ЛК нет)
|
||||
ex_local_csi = true
|
||||
ex_vip = true
|
||||
ex_update = true
|
||||
ex_ingress = true
|
||||
ex_named_csi = true
|
||||
}
|
||||
|
||||
cluster_configuration = {
|
||||
app_version = var.shturval_app_version
|
||||
}
|
||||
|
||||
control_plane_configuration = {
|
||||
sizing_policy = var.shturval_cp_sizing_policy
|
||||
sizing_disk = var.shturval_cp_sizing_disk
|
||||
count = var.shturval_cp_count
|
||||
}
|
||||
|
||||
worker_configuration = local.shturval_worker_config
|
||||
|
||||
access_configuration = {
|
||||
need_external_address_api = true # внешний адрес для Kubernetes API (false недопустим)
|
||||
access_ip_list_api = jsonencode([]) # пусто = доступ всем
|
||||
need_external_address_ingress = true # внешний адрес для Ingress
|
||||
access_ip_list_ingress = jsonencode([]) # пусто = доступ всем
|
||||
}
|
||||
|
||||
# Кластер поднимается только после готовой сети: vDC -> Edge -> внешние IP -> SNAT
|
||||
depends_on = [nubes_vc_nsxt_snat.snat]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
provider_installation {
|
||||
dev_overrides {
|
||||
"tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes" = "/home/naeel/TF/tf_provider/TMP/devbin"
|
||||
}
|
||||
direct {}
|
||||
}
|
||||
@@ -116,6 +116,11 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
||||
suspendOnDestroy = *spec.Lifecycle.SuspendOnDestroyDefault
|
||||
}
|
||||
|
||||
keepOnDestroy := false
|
||||
if spec.Lifecycle.KeepOnDestroyDefault != nil {
|
||||
keepOnDestroy = *spec.Lifecycle.KeepOnDestroyDefault
|
||||
}
|
||||
|
||||
gr := types.GenResource{
|
||||
Name: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
@@ -128,6 +133,7 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
||||
HasRefSvcParams: HasRefSvcParams(schemaParams),
|
||||
SupportsSuspendDestroy: supportsSuspendDestroy,
|
||||
SuspendOnDestroy: suspendOnDestroy,
|
||||
KeepOnDestroy: keepOnDestroy,
|
||||
AdoptExistingOnCreate: adoptExistingOnCreate,
|
||||
HasDomainParam: hasDomainParam,
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ type {{ToCamel .Name}}Model struct {
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
SuspendOnDestroy types.Bool ` + "`" + `tfsdk:"suspend_on_destroy"` + "`" + `
|
||||
{{- end }}
|
||||
KeepOnDestroy types.Bool ` + "`" + `tfsdk:"keep_on_destroy"` + "`" + `
|
||||
AdoptExistingOnCreate types.Bool ` + "`" + `tfsdk:"adopt_existing_on_create"` + "`" + `
|
||||
{{- range .OutputParams }}
|
||||
{{ToCamel .Code}} {{OutputType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
|
||||
@@ -128,6 +129,7 @@ func (r *{{ToCamel .Name}}Resource) Schema(ctx context.Context, req resource.Sch
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
"suspend_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.SuspendOnDestroy}})},
|
||||
{{- end }}
|
||||
"keep_on_destroy": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.KeepOnDestroy}}), MarkdownDescription: "Режим state_only при destroy: ресурс не удаляется и не меняется в облаке, только убирается из состояния. Имеет приоритет над suspend_on_destroy. false = обычное удаление."},
|
||||
"adopt_existing_on_create": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool({{.AdoptExistingOnCreate}})},
|
||||
{{- range .OutputParams }}
|
||||
{{- if or (OutputIsMap .) (OutputIsList .) }}
|
||||
@@ -543,14 +545,15 @@ func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.Del
|
||||
return
|
||||
}
|
||||
|
||||
deleteMode := "delete"
|
||||
{{- if .SupportsSuspendDestroy }}
|
||||
deleteMode := "state_only"
|
||||
if !state.SuspendOnDestroy.IsNull() && !state.SuspendOnDestroy.IsUnknown() && state.SuspendOnDestroy.ValueBool() {
|
||||
deleteMode = "suspend"
|
||||
}
|
||||
{{- else }}
|
||||
deleteMode := "delete"
|
||||
{{- end }}
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
deleteMode = "state_only"
|
||||
}
|
||||
|
||||
operationTimeout := ""
|
||||
if !state.OperationTimeout.IsNull() && !state.OperationTimeout.IsUnknown() {
|
||||
@@ -563,6 +566,19 @@ func (r *{{ToCamel .Name}}Resource) Delete(ctx context.Context, req resource.Del
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch deleteMode {
|
||||
case "suspend":
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Ресурс заморожен, а не удалён",
|
||||
"destroy: {{.Name}} (service_id={{.ServiceID}}) переведён в suspend, удаление не выполнялось. Для полного удаления выставьте suspend_on_destroy = false.",
|
||||
)
|
||||
case "state_only":
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Ресурс оставлен как есть, а не удалён",
|
||||
"destroy: {{.Name}} (service_id={{.ServiceID}}) не изменялся в облаке и только убран из состояния (keep_on_destroy = true). Для полного удаления выставьте keep_on_destroy = false.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel .Name}}Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
|
||||
@@ -32,6 +32,9 @@ type ServiceSpec struct {
|
||||
Lifecycle struct {
|
||||
SuspendOnDestroyDefault *bool `yaml:"suspend_on_destroy_default"`
|
||||
AdoptExistingOnCreateDefault *bool `yaml:"adopt_existing_on_create_default"`
|
||||
// KeepOnDestroyDefault: дефолт ресурса для режима state_only (ничего не менять в облаке).
|
||||
// Нужен там, где сервис не умеет suspend (например, Эдж) — иначе destroy удаляет объект.
|
||||
KeepOnDestroyDefault *bool `yaml:"keep_on_destroy_default"`
|
||||
} `yaml:"lifecycle"`
|
||||
Operations []OperationSpec `yaml:"operations"`
|
||||
}
|
||||
@@ -75,18 +78,21 @@ type GenResource struct {
|
||||
HasRefSvcParams bool
|
||||
SupportsSuspendDestroy bool
|
||||
SuspendOnDestroy bool
|
||||
AdoptExistingOnCreate bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsBoolUseStateForUnknown bool
|
||||
NeedsInt64UseStateForUnknown bool
|
||||
HasDomainParam bool
|
||||
DomainServiceIDs []int
|
||||
// KeepOnDestroy: destroy только убирает ресурс из состояния (state_only),
|
||||
// ничего не меняя в облаке. Приоритетнее suspend_on_destroy.
|
||||
KeepOnDestroy bool
|
||||
AdoptExistingOnCreate bool
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsBoolUseStateForUnknown bool
|
||||
NeedsInt64UseStateForUnknown bool
|
||||
HasDomainParam bool
|
||||
DomainServiceIDs []int
|
||||
// HasRedeploy: сервис поддерживает redeploy (пересборка из git).
|
||||
// Если true — в основной ресурс добавляется поле git_revision.
|
||||
// При изменении git_revision вызывается redeploy вместо modify.
|
||||
@@ -154,12 +160,12 @@ type GenAction struct {
|
||||
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
|
||||
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
|
||||
type GenModifier struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ModifierName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ModifierName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
// DeleteStrategy — noop_warn | inverse | error (нормализовано из YAML, пусто → noop_warn).
|
||||
DeleteStrategy string
|
||||
// Idempotency — none | check_before_run (нормализовано из YAML, пусто → none).
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
| Стенд | Namespace | Версия | Дата заливки |
|
||||
|---|---|---|---|
|
||||
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
|
||||
| DEV | `nubes-dev` | `2.0.13` | 2026-09-22 | (fix: ShouldRemoveFromState читает deleted/404 через GetInstanceStateRaw — тихое удаление из state без падения на дрейфе) |
|
||||
| DEV | `nubes-dev` | `2.0.23` | 2026-09-24 | (fix: UUID внутри JSON нормализуется к lowercase при сравнении (adopt suspended-инстанса падал на регистре `nsxtUid`) + `JsonNormalize` приводит UUID-подстроки; тесты) |
|
||||
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
|
||||
|
||||
## Как проверить
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Как работает провайдер Nubes: поведение и отличия от канонического Terraform
|
||||
|
||||
> Страница для тех, кто уже работал с Terraform и хочет понять, чего ожидать от провайдера Nubes,
|
||||
> и для DevOps, которым важно знать, что реально произойдёт в облаке при `plan`, `apply` и `destroy`.
|
||||
> Здесь описан наблюдаемый контракт (что уже проверено на живых стендах) и ограничения платформы.
|
||||
|
||||
## 1. Суть в одном абзаце
|
||||
|
||||
Провайдер Nubes — это не плагин к гипервизору, а **обёртка над API личного кабинета**: каждый Terraform-ресурс
|
||||
соответствует *инстансу услуги* в облаке, а `create` / `update` / `delete` транслируются в **асинхронные операции**
|
||||
платформы (`create`, `modify`, `suspend`, `resume`, `delete`). Отсюда и все отличия от привычного Terraform:
|
||||
|
||||
- операции **долгие** — провайдер ждёт их завершения (поллинг);
|
||||
- меняется **уже созданный объект**, а не создаётся заново;
|
||||
- ошибки приходят **внутри тела ответа**, а не HTTP-кодом;
|
||||
- ресурс ищется **по имени**, а не только по `id`;
|
||||
- удаление может быть **«мягким»** — объект остаётся в облаке.
|
||||
|
||||
## 2. Какие ресурсы за что отвечают
|
||||
|
||||
| Услуга облака | Ресурс Terraform | Что делает |
|
||||
|---|---|---|
|
||||
| Организация в Cloud Director (19) | `nubes_vc_org_ip_allocation` | **модификатор**: меняет квоту внешних IP в организации (саму организацию создают в ЛК, Terraform её не трогает) |
|
||||
| Виртуальный датацентр (21) | `nubes_vc_vdc` | создаёт vDC с ресурсами (CPU/RAM/Storage) |
|
||||
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt` | создаёт Edge (routed-сеть, ALB/AVI) |
|
||||
| Сетевой шлюз периметра (22) | `nubes_vc_nsxt_snat` | **модификатор**: включает/переключает SNAT (`ipSpaceName`) на существующем Edge |
|
||||
| Kubernetes кластер Штурвал (150) | `nubes_k8s_sthutrval_cluster` | создаёт кластер (control plane + группы воркеров) |
|
||||
|
||||
Общее правило. Ресурс, у которого есть свой «объект в облаке», **создаёт** этот объект. А то, что платформа
|
||||
меняет операцией `modify` (квота IP, включение SNAT, настройки шлюза), собрано в отдельные ресурсы-
|
||||
**модификаторы**: они не создают ничего нового, а правят уже существующий объект.
|
||||
|
||||
## 3. Отличия от «учебного» Terraform
|
||||
|
||||
| Ожидание по канону | Как в Nubes | Причина |
|
||||
|---|---|---|
|
||||
| `create` = один вызов API | Создание инстанса — **последовательность**: `POST /instances` → `POST /instanceOperations` → параметры (`instanceOperationCfsParams`) → `run` → поллинг до `dtFinish` | Так устроен API платформы |
|
||||
| `id` — произвольная строка | `id` = UUID инстанса в облаке; провайдер читает его из ответа платформы | Состояние живёт в облаке, не в Terraform |
|
||||
| `update` = замена при несовместимых параметрах | `update` = операция **`modify`** над тем же инстансом; часть параметров — **create-only** (менять нельзя → ошибка на этапе plan) | Платформа не пересоздаёт объекты «из коробки» |
|
||||
| есть `data sources` для поиска существующего | Поиск/ссылки — через **ref-параметры**: можно указать UUID **или имя** инстанса | Экономит data sources, но требует дисциплины в значениях |
|
||||
| `import` — явная команда | Плюс к `import` есть **авто-усыновление** `adopt_existing_on_create = true`: ресурс сам находит инстанс **по имени** | В проде объекты живут неделями, и пересоздавать их нельзя |
|
||||
| `plan` показывает, что ресурс уже существует | **Нет**: проверка «инстанс с таким именем уже есть / adopt» выполняется в `Create`, то есть на `apply` | Иначе ломается `destroy` и работа с tainted-ресурсами |
|
||||
| `delete` удаляет | `delete` может быть `delete` / `suspend` / `state_only` — см. §5 | У платформы не всё удаляется, а часть объектов удалять нельзя, пока жив потребитель |
|
||||
| ошибка приходит HTTP-кодом | Ошибка операции — **в теле**: `isSuccessful=false` + `errorLog`, при успешном HTTP-коде | API платформы отвечает 200/201 почти всегда |
|
||||
| операции быстрые | Операции асинхронные и долгие (Штурвал — десятки минут) → параметр `operation_timeout`; одновременные операции на одном инстансе не поддерживаются | Наследство платформы (внутри — CFS-оркестратор) |
|
||||
| план обязан совпадать с config | Тоже, но: **ref-параметры в плане остаются как написаны** (`name` не подменяется на `uuid`), а уже перед вызовом API провайдер сам разберётся, имя это или UUID | Иначе Terraform упадёт с «Provider produced invalid plan» |
|
||||
| параметры сравниваются как строки | JSON-параметры сравниваются **канонично**: порядок ключей и пробелы не важны, скаляры приводятся к строке, UUID — без учёта регистра | API возвращает JSON в своём виде, иначе будет «вечный diff» |
|
||||
| идентичность — по `id` | Дополнительно: инстанс ищется по `resource_name` (displayName) → **переименование = новый ресурс** | Имя задаётся при создании и не меняется |
|
||||
|
||||
### Что из этого следует на практике
|
||||
|
||||
- `terraform plan` **не может** проверить, что «такой объект уже есть»: он это покажет как `will be created`,
|
||||
а разбираться будет `apply`.
|
||||
- `terraform apply` на существующей инфраструктуре — это нормальный сценарий (adopt), если включён
|
||||
`adopt_existing_on_create`; без флага вы получите явную ошибку-конфликт, а не дубль ресурса.
|
||||
- Долгие операции требуют терпения: смотрите `operation_timeout`, не запускайте вторую операцию по тому же объекту.
|
||||
|
||||
## 4. Создание, чтение, изменение, удаление
|
||||
|
||||
**Создание.** Провайдер формирует параметры операции, отправляет её и ждёт завершения. Параметры, зависящие
|
||||
от других ресурсов (например `vdc_uid`, `nsxt_uid`), передаются как ref-значения: UUID или имя.
|
||||
|
||||
**Чтение (refresh).** Значения `state_params` / `state_out` приходят из облака: так в state попадают
|
||||
адреса (`kubernetesApiAddress`, `ingressAddress`), имена, текущие параметры.
|
||||
|
||||
**Изменение.** Если параметры поменялись, вызывается `modify` (для модификаторов — обратный `modify` при удалении).
|
||||
Параметры, помеченные как create-only, изменить нельзя — провайдер скажет об этом до обращения к API.
|
||||
|
||||
**Удаление.** См. следующий раздел — это самое неочевидное место.
|
||||
|
||||
## 5. Удаление: `delete`, `suspend` и «оставить как есть»
|
||||
|
||||
У платформы три разных исхода, и провайдер умеет все три. Управляется флагами:
|
||||
|
||||
| Флаг | Где применим | Поведение при `destroy` | Предупреждение в выводе |
|
||||
|---|---|---|---|
|
||||
| `suspend_on_destroy = true` | кластер Штурвала, vDC (по умолчанию `true`) | объект **приостанавливается**, не удаляется | «Ресурс заморожен, а не удалён» |
|
||||
| `keep_on_destroy = true` | Edge, SNAT, квота IP (по умолчанию `false`) | объект **не трогается в облаке**, только убирается из state | «Оставлен как есть» (для SNAT — «SNAT не выключался», для квоты IP — «Аллокация IP не снималась») |
|
||||
| оба `false` | любой | обычное удаление | — |
|
||||
|
||||
Если выставлены оба флага, победит `keep_on_destroy` (объект просто не тронут). По умолчанию провайдер
|
||||
удаляет по-настоящему (`keep_on_destroy = false`), а «заморозку» или «оставить как есть» включают явно
|
||||
в конфигурации стенда.
|
||||
|
||||
### Почему Edge остаётся `running`
|
||||
|
||||
Edge **физически не умеет `suspend`**: в списке доступных операций шлюза есть только `delete`, `modify`,
|
||||
`reconcile`. Усыпить его платформа не даёт, поэтому единственный корректный способ «не удалять шлюз» —
|
||||
не трогать его: `keep_on_destroy = true`. Побочный эффект — шлюз продолжает работать и тарифицироваться,
|
||||
и через него продолжают публиковаться внешние адреса (API кластера и ingress). Именно поэтому удаление Edge
|
||||
при живом кластере Штурвала (или других зависимых объектах: vApp, VM) считается недопустимым.
|
||||
|
||||
### Почему SNAT остаётся включённым, а квота IP не обнуляется
|
||||
|
||||
- SNAT-модификатор с `keep_on_destroy = true` **не отправляет** обратный `modify` с `ipSpaceName = "no-needed"`,
|
||||
то есть SNAT для виртуальных машин остаётся в прежнем состоянии.
|
||||
- Квота внешних IP: уменьшать `count` **ниже фактически занятых адресов платформа не разрешает**
|
||||
(ошибка вида «Кол-во занятых Ip … Невозможно выставить параметр count ниже этого параметра»).
|
||||
Адреса держит кластер Штурвала (API + ingress), и `suspend` кластера их **не освобождает**.
|
||||
Поэтому при «заморозке» квота не изменяется вовсе, а реальное освобождение адресов возможно только после
|
||||
удаления кластера — отдельным шагом.
|
||||
|
||||
### Почему кластер и vDC уходят в `suspend`
|
||||
|
||||
Для них `suspend` поддерживается платформой, и это самый близкий к «выключить» вариант: ВМ останавливаются,
|
||||
объекты не удаляются, адреса и конфигурация сохраняются. Обратный ход делает `apply`:
|
||||
|
||||
| Что | При `destroy` (заморозка) | При следующем `apply` |
|
||||
|---|---|---|
|
||||
| Кластер Штурвала | `suspend` | adopt по имени + `resume` |
|
||||
| vDC | `suspend` | adopt по имени + `resume` |
|
||||
| Edge | не трогается (`running`) | adopt (инстанс уже работает) |
|
||||
| SNAT | не трогается (включён) | повторный `modify` теми же значениями (фактически no-op) |
|
||||
| Квота IP | не трогается | `modify` с тем же `count` (no-op) |
|
||||
|
||||
Итого цикл «`destroy` → `apply`» на стенде выглядит как «усыпить → разбудить», а не «снести → поднять заново».
|
||||
Полностью удалить такой стенд можно только явным отказом от заморозки (`keep_on_destroy = false` /
|
||||
`suspend_on_destroy = false`) и в правильном порядке (см. §6).
|
||||
|
||||
## 6. Конкретный пайплайн стенда: vDC → Edge → внешние IP → SNAT → Штурвал
|
||||
|
||||
Порядок создания и зависимости:
|
||||
|
||||
1. Организация — **вручную в ЛК** (Terraform её не создаёт).
|
||||
2. `nubes_vc_vdc` — виртуальный датацентр.
|
||||
3. `nubes_vc_nsxt` — Edge. Обязательно включать балансировщик (параметр `need_enable_avi = true`)
|
||||
и задать не меньше 3 виртуальных сервисов (`virtual_services_count >= 3`) — это нужно кластеру Штурвала.
|
||||
4. `nubes_vc_org_ip_allocation` — внешние адреса в организации (минимум 3 по инструкции услуги Штурвала).
|
||||
5. `nubes_vc_nsxt_snat` — SNAT на Edge (по `depends_on` после аллокации адресов).
|
||||
6. `nubes_k8s_sthutrval_cluster` — кластер Штурвала (по `depends_on` после SNAT: нодам нужен выход в интернет).
|
||||
|
||||
При удалении Terraform идёт в обратном порядке. Ограничения платформы, которые встречаются на этом пути:
|
||||
|
||||
- **1 кластер Штурвала = 1 vDC** (действующее ограничение услуги).
|
||||
- vDC удаляется только **через 14 дней после `suspend`**; при живых Edge/vApp/VM/кластере — через поддержку.
|
||||
- Edge не удаляется при живых зависимых объектах (по инструкции услуги).
|
||||
- Квота IP не опускается ниже занятых адресов (см. §5).
|
||||
|
||||
Как проверить, что конфигурация корректна: `terraform plan` после `apply` должен говорить
|
||||
`No changes`. Если появляется стабильный diff — сверяйте его с `terraform state show` и с тем,
|
||||
что реально показывается в личном кабинете.
|
||||
|
||||
## 7. Регистр UUID и канонизация значений
|
||||
|
||||
UUID в облаке не имеет «правильного» регистра: один и тот же идентификатор может прийти как `2c37fed1-…`
|
||||
и как `2C37FED1-…`. Поэтому провайдер сравнивает UUID **без учёта регистра** — в том числе внутри JSON-параметров
|
||||
(например, `startupConfiguration` кластера Штурвала). Подробный разбор проблемы и всех мест, где она может
|
||||
проявиться, — в `60_strategy/terraform_case_sensitivity_fix.md` (внутренний документ).
|
||||
|
||||
Для JSON-параметров сравнение также игнорирует порядок ключей и пробелы, а скаляры приводит к строковому виду —
|
||||
это нужно, чтобы `plan` не показывал «изменение» там, где API вернул то же значение в другом формате.
|
||||
|
||||
## 8. Чек-лист DevOps
|
||||
|
||||
- Пин версии провайдера привязан к стенду: **prod `1.*`, dev `2.*`, test `3.*`**; после смены версии —
|
||||
`terraform init -upgrade`.
|
||||
- Перед `apply` и после — смотрите `plan`; ожидаемый финальный результат — `No changes`.
|
||||
- **Читайте предупреждения `destroy`**: «заморожен», «оставлен как есть», «SNAT не выключался» — это не косметика,
|
||||
а отчёт о том, что объекты продолжают жить и тарифицироваться.
|
||||
- Не удаляйте объекты стенда руками в ЛК между `destroy` и `apply`: авто-усыновление ищет их по имени, и на удалённом
|
||||
объекте упрётся в состояние `not created`.
|
||||
- Долгие операции: задавайте `operation_timeout` (для Штурвала — `60m`), не запускайте параллельные операции
|
||||
по одному объекту.
|
||||
- Проверяя состояние через API ЛК, помните про обязательные заголовки (браузерный `User-Agent` и `Referer`) —
|
||||
иначе отдаёт `403`.
|
||||
|
||||
## 9. FAQ
|
||||
|
||||
**Почему `plan` пишет `will be created`, если объект уже есть?**
|
||||
Потому что проверка существования и усыновление выполняются на `apply` (в `Create`). В state ресурса нет → план
|
||||
честно планирует создание. Решение — `adopt_existing_on_create = true`.
|
||||
|
||||
**Почему `destroy` сказал `5 destroyed`, а в облаке всё живо?**
|
||||
Сработала «заморозка»: кластер и vDC приостановлены, Edge, SNAT и квота IP не изменялись. Terraform «удалил»
|
||||
ресурсы только из своего состояния. Смотрите предупреждения в выводе.
|
||||
|
||||
**Почему IP не освободились?**
|
||||
Квота не может стать меньше числа занятых адресов, а их держит кластер. Пока кластер существует (даже в `suspend`),
|
||||
платформа не даст уменьшить `count`.
|
||||
|
||||
**Почему Edge нельзя приостановить?**
|
||||
У платформы для шлюза нет операции `suspend` — только `delete`, `modify`, `reconcile`.
|
||||
|
||||
**Почему после `apply` кластер ожил сам?**
|
||||
Сработало усыновление: ресурс нашёл инстанс по имени (`adopt_existing_on_create`), увидел статус `suspended`
|
||||
и выполнил `resume`.
|
||||
|
||||
**State пуст, а объекты в облаке есть. Что делать?**
|
||||
Это ожидаемый результат «заморозки». `terraform apply` в том же каталоге усыновит объекты обратно.
|
||||
|
||||
**Кто-то удалил объект в ЛК. Что будет?**
|
||||
Усыновление не найдёт его и провайдер завершится ошибкой с указанием статуса (`not created`) — нужно либо создать
|
||||
объект заново, либо разбираться с конфигурацией.
|
||||
|
||||
**А можно всё-таки удалить стенд полностью?**
|
||||
Да, но осознанно: выставить `keep_on_destroy = false` и `suspend_on_destroy = false`, затем удалять в порядке
|
||||
кластер → квота IP → SNAT → Edge → vDC, при необходимости — через поддержку (для vDC действует правило 14 дней).
|
||||
|
||||
## 10. Куда смотреть дальше
|
||||
|
||||
- `curated/pipeline/vdc_edge_ip_snat.md` — пошаговый разбор цепочки vDC → Edge → IP → SNAT.
|
||||
- `curated/modifiers/org_ip_and_snat.md` — ресурсы-модификаторы.
|
||||
- `30_registry/guides/terraform-structure.md` — структура манифестов.
|
||||
- Внутренние документы (не публикуются): `60_strategy/provider_philosophy.md`,
|
||||
`60_strategy/modifier_resources_ideology_and_specification.md`,
|
||||
`60_strategy/adopt_ref_validation.md`, `60_strategy/terraform_case_sensitivity_fix.md`.
|
||||
@@ -239,5 +239,59 @@ gr.NeedsStringsImport = hasRestoreCasingParams(gr.SchemaParams) || analyzeNeedsS
|
||||
|
||||
## 9. Версия
|
||||
|
||||
Фикс введён в версии провайдера **5.0.46**.
|
||||
Фикс введён в версии провайдера **5.0.46** (старая линия; актуальные линии — prod `1.*`, dev `2.*`, test `3.*`, см. §10).
|
||||
Сгенерированные ресурсы пересозданы после изменения генератора.
|
||||
|
||||
---
|
||||
|
||||
## 10. Обновление 2026-09-24: UUID внутри JSON (важно)
|
||||
|
||||
**Что уточнилось.** Посылка «API всегда возвращает lowercase» **неверна**. На живом стенде (Shturval dev-00)
|
||||
один и тот же UUID приходил в разных регистрах: `vdcUid` — `d0937335-…` (lowercase), а `nsxtUid` кластера —
|
||||
`2C37FED1-…` (UPPERCASE). Значит ориентироваться на «API нормализует» нельзя: сравнивать нужно всегда
|
||||
без учёта регистра.
|
||||
|
||||
**Где вылезло.** Первый `apply` после «заморозки» стенда упал на усыновлении приостановленного кластера:
|
||||
|
||||
```
|
||||
Error: required params mismatch for resource_name shturval-dev: startupConfiguration
|
||||
(plan={… "nsxtUid":"2c37fed1-…" }, actual={… "nsxtUid":"2C37FED1-…" }).
|
||||
```
|
||||
|
||||
**Почему предыдущие пять фиксов не помогли.** Они закрывали: отправку в API (`core/refsvc.go`,
|
||||
`core/refsvc_resolve.go`), сравнение **одиночных** значений (`resources_core/params_compare.go`,
|
||||
`normalizeCompareValue`), сравнение create-only атрибутов (`strings.EqualFold` в шаблоне генератора)
|
||||
и восстановление регистра в state. Ни один из них не смотрит **внутрь JSON**, а adopt приостановленного
|
||||
инстанса сравнивает параметр целиком как JSON:
|
||||
`RequiredParamsMismatch` → `paramsEquivalent` → `JSONStringsEquivalent` → `jsonutil.normalizeJSONScalarsToStrings`,
|
||||
где строки возвращались как есть (`case string: return val`). У Штурвала ref-параметры упакованы в JSON
|
||||
(`startupConfiguration`), а путь adopt-suspended задействован впервые.
|
||||
|
||||
**Аудит: где регистр UUID может вылезти (проверено 24.09).**
|
||||
|
||||
| # | Место | Что ломает |
|
||||
|---|---|---|
|
||||
| 1 | `resources_core/required_params_compare.go` (`paramsEquivalent` → `JSONStringsEquivalent`) | adopt приостановленного инстанса — hard error (этот кейс) |
|
||||
| 2 | `core/modifier_compare.go` | ложное «параметр изменился» → лишний `modify` на каждом apply |
|
||||
| 3 | `resources_core/state_refresh.go` (сохранение планового JSON) | в state уедет регистр API вместо значения из config |
|
||||
| 4 | `resources_core/resource_diagnostics_required.go` (create-диагностика) | та же `RequiredParamsMismatch` |
|
||||
| 5 | `resources_core/params_compare.go` (`ParamsMatchForResume`) | одиночный UUID ок, JSON — та же дыра |
|
||||
| 6 | `resources_core/json_planmodifier.go` (`JsonNormalize()`) | для JSON-атрибутов с UUID внутри — риск вечного diff |
|
||||
| 7 | `resources_core/ref_validation.go` (`ValidateRefParamsOnAdopt`) | ref-параметр, зашитый внутрь JSON, **не проверяется вообще** (открыто) |
|
||||
| 8 | `core/operation_run*.go` (`lookupLiveParam`) | подстановка live-значений по ключам: при другом регистре ключа может молча не сработать (открыто, требует живой проверки) |
|
||||
|
||||
**Фикс (провайдер `2.0.23`, dev).**
|
||||
|
||||
- `internal/core/jsonutil/jsonutil.go`: добавлен `LowercaseUUIDsInText` (UUID-подстрока → lowercase), и строковые
|
||||
значения **внутри JSON** теперь нормализуются в `normalizeJSONScalarsToStrings` — закрывает пункты 1–5.
|
||||
- `internal/resources_core/json_planmodifier.go`: `JsonNormalize()` после `json.Compact` приводит UUID-подстроки
|
||||
к lowercase (типы и порядок ключей **не** меняются — план обязан совпадать с config) — закрывает пункт 6.
|
||||
- Тесты: `internal/core/jsonutil/jsonutil_test.go`, `internal/resources_core/params_compare_test.go`
|
||||
(в т.ч. на реальном `startupConfiguration` кластера).
|
||||
|
||||
**Дополнение к алгоритму диагностики (§8):**
|
||||
|
||||
7. Если расхождение — внутри JSON-параметра, проверьте именно UUID-подстроки и их регистр (не только одиночные
|
||||
значения); смотрите `jsonutil.LowercaseUUIDsInText`.
|
||||
8. Не «лечите» это нормализацией плана целиком (скаляры → строки, сортировка ключей): для атрибутов из config
|
||||
допустимо менять только регистр UUID-подстрок, иначе Terraform ругнётся на несоответствие плана конфигу.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# Что НЕ надо класть в Terraform и почему
|
||||
|
||||
## Не управляется Terraform — делается в Личном кабинете (один раз)
|
||||
|
||||
### vcOrg / vcVdc / vcNsxt — облачный фундамент
|
||||
|
||||
| Сервис | Почему не Terraform |
|
||||
|--------|---------------------|
|
||||
| **Организация в Cloud Director** (vcOrg) | Одна на resources realm. Нельзя создать вторую. При destroy — карантин на 14 дней, блокирует всё. |
|
||||
| **Виртуальный датацентр vDC** (vcVdc) | Квота: 1 на организацию. Создание через Terraform ломается на API (500 на `GET cfsParams`). |
|
||||
| **Сетевой шлюз Edge** (vcNsxt) | Квота: 1 на vDC. Требует AVI, SNAT, ipSpace — настройка в 3 шага (create → modify org → modify edge). Terraform не умеет create+modify в одном apply. |
|
||||
|
||||
**Вывод**: фундамент всегда уже есть у любого клиента. Создаётся один раз через ЛК.
|
||||
|
||||
### Штурвал (Kubernetes-кластер)
|
||||
|
||||
| Почему не Terraform |
|
||||
|---------------------|
|
||||
| Одноразовая операция. Модификаций почти нет — workers, версия, диски, и то редко. |
|
||||
| Destroy = потеря кластера со всеми подами и данными. `suspend_on_destroy` спасает, но риск велик. |
|
||||
| Процесс создания длительный, чувствителен к таймаутам и сбоям — в ЛК виднее что происходит. |
|
||||
|
||||
## Что Управляется Terraform — PaaS/SaaS слой
|
||||
|
||||
```
|
||||
PostgreSQL Redis Kafka ClickHouse RabbitMQ MariaDB MongoDB
|
||||
S3 Buckets Node.js Lucee Gitea Nextcloud Harbor ...
|
||||
```
|
||||
|
||||
Эти сервисы создаются/меняются/удаляются многократно, имеют повторяемые конфигурации и связаны зависимостями (app → db, app → s3).
|
||||
|
||||
## Схема
|
||||
|
||||
```
|
||||
┌─ Личный кабинет (один раз) ──────────┐
|
||||
│ vcOrg → vcVdc → vcNsxt (Edge+AVI) │
|
||||
│ Штурвал (Kubernetes-кластер) │
|
||||
└──────────────────────────────────────┘
|
||||
│ edge_uid
|
||||
▼
|
||||
┌─ Terraform (IaC, повторяемо) ────────┐
|
||||
│ Postgres, S3, Redis, Kafka, │
|
||||
│ RabbitMQ, ClickHouse, MariaDB, │
|
||||
│ MongoDB, Node.js, Lucee, Gitea ... │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# Ресурсы-модификаторы: внешние IP организации и SNAT на шлюзе
|
||||
|
||||
Два ресурса работают с **уже существующими** услугами:
|
||||
|
||||
- `nubes_vc_org_ip_allocation` — выделяет внешние IP на организации;
|
||||
- `nubes_vc_nsxt_snat` — включает SNAT на сетевом шлюзе периметра.
|
||||
|
||||
Эти параметры нельзя задать при создании услуги обычным ресурсом, поэтому они вынесены в отдельные
|
||||
ресурсы: так вся цепочка поднимается одним `apply` и в правильном порядке.
|
||||
|
||||
Оба ресурса проверены живым прогоном: создание, проверка состояния в ЛК и удаление.
|
||||
|
||||
Полный рабочий пример (vDC + Edge + IP + SNAT) — на странице
|
||||
[«Как развернуть vDC, Edge, внешние IP и SNAT»](../pipeline/vdc_edge_ip_snat.md),
|
||||
файлы — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
||||
|
||||
## `nubes_vc_org_ip_allocation` — внешние IP на организации
|
||||
|
||||
| Атрибут | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `organization` | string, обязательный | Организация: название услуги из ЛК или её UUID |
|
||||
| `vip_configure` | string (JSON), обязательный | Сколько IP выделить: `[{"name":"internet-ipv4-v1","count":"3"}]`. `count` — строкой |
|
||||
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении квота обнуляется; `true` — остаётся как есть |
|
||||
|
||||
```hcl
|
||||
resource "nubes_vc_org_ip_allocation" "this" {
|
||||
organization = "organ" # название услуги из ЛК
|
||||
|
||||
vip_configure = jsonencode([
|
||||
{ name = "internet-ipv4-v1", count = "3" }
|
||||
])
|
||||
|
||||
keep_on_destroy = false
|
||||
}
|
||||
```
|
||||
|
||||
Как это работает:
|
||||
|
||||
- массив `vip_configure` записывается **целиком**, поэтому ресурс владеет всей аллокацией организации:
|
||||
если нужно поменять количество — меняйте значение и делайте `apply`;
|
||||
- снять аллокацию через пустой массив нельзя — для этого удалите ресурс
|
||||
(`terraform destroy -target=nubes_vc_org_ip_allocation.this`), тогда отправится запрос с `count = "0"`.
|
||||
|
||||
## `nubes_vc_nsxt_snat` — SNAT на шлюзе
|
||||
|
||||
| Атрибут | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `nsxt_uid` | string, обязательный | UUID услуги «Сетевой шлюз периметра (Edge)» |
|
||||
| `ip_space_name` | string, обязательный | Имя ipSpace для внешнего IP; значение `no-needed` выключает SNAT |
|
||||
| `keep_on_destroy` | bool, по умолчанию `false` | `false` — при удалении SNAT выключается; `true` — остаётся как есть |
|
||||
|
||||
```hcl
|
||||
resource "nubes_vc_nsxt_snat" "this" {
|
||||
nsxt_uid = nubes_vc_nsxt.edge.id # UUID шлюза из этого же конфига
|
||||
ip_space_name = "internet-ipv4-v1"
|
||||
|
||||
depends_on = [nubes_vc_org_ip_allocation.this]
|
||||
}
|
||||
```
|
||||
|
||||
Если шлюз создаётся в этом же конфиге, UUID искать вручную не нужно — используйте
|
||||
`nubes_vc_nsxt.edge.id`. Если шлюз уже существует, UUID виден в карточке услуги в ЛК.
|
||||
|
||||
Имя ipSpace должно быть сначала выделено на организации, поэтому у ресурса есть `depends_on`
|
||||
на аллокацию IP.
|
||||
|
||||
## Импорт
|
||||
|
||||
Если услуга уже настроена (например, в ЛК вручную), ресурс можно подключить к Terraform:
|
||||
|
||||
```bash
|
||||
terraform import nubes_vc_org_ip_allocation.this organ # организация: имя или UUID
|
||||
terraform import nubes_vc_nsxt_snat.this <uuid услуги шлюза> # UUID из карточки услуги в ЛК
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Как развернуть vDC, Edge, внешние IP и SNAT
|
||||
|
||||
Пошаговая инструкция. Готовые файлы примера — в репозитории `tf_examples`, папка `fullpipe_chain`.
|
||||
|
||||
Что получится в итоге:
|
||||
|
||||
- виртуальный датацентр (vDC);
|
||||
- сетевой шлюз периметра (Edge);
|
||||
- внешние IP на организации;
|
||||
- SNAT на шлюзе.
|
||||
|
||||
Кластер Штурвал в эту инструкцию не входит — он разворачивается долго, отдельным шагом.
|
||||
|
||||
**Организацию создайте заранее в ЛК** — Terraform её не создаёт и не удаляет.
|
||||
|
||||
## 1. Скачайте пример
|
||||
|
||||
```bash
|
||||
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
|
||||
cd tf_examples/fullpipe_chain
|
||||
```
|
||||
|
||||
Понадобится Terraform 1.5 или новее. Провайдер скачается сам при `terraform init`.
|
||||
|
||||
## 2. Возьмите значения в ЛК
|
||||
|
||||
| Значение | Где взять | Пример |
|
||||
|---|---|---|
|
||||
| `api_token` | ЛК → Профиль → Токены → «Технический» | `eyJhbGciOi...` |
|
||||
| `organization` | ЛК → услуга «Организация в Cloud Director» → название услуги | `organ` |
|
||||
| `vdc_network_provider` | ЛК → создание vDC → «Сетевой провайдер» | `snb1` |
|
||||
| `vdc_provider_vdc` | ЛК → создание vDC → «Provider VDC» | `Intel Broadwell 2.4` |
|
||||
| `vdc_storage_config` | ЛК → создание vDC → доступные дисковые политики | `SATA` |
|
||||
| `ip_space_name` | ЛК → карточка организации → внешние IP | `internet-ipv4-v1` |
|
||||
|
||||
## 3. Заполните значения
|
||||
|
||||
```bash
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
nano terraform.tfvars
|
||||
```
|
||||
|
||||
Файл `terraform.tfvars` выглядит так:
|
||||
|
||||
```hcl
|
||||
# Токен из ЛК
|
||||
api_token = "eyJhbGciOi..."
|
||||
|
||||
# Организация из ЛК (создана заранее)
|
||||
organization = "organ"
|
||||
|
||||
# Внешние IP
|
||||
ip_space_name = "internet-ipv4-v1"
|
||||
ip_count = "3"
|
||||
|
||||
# vDC
|
||||
vdc_resource_name = "fullpipe-vdc" # имя услуги в ЛК, любое
|
||||
vdc_network_provider = "snb1" # ЛК → создание vDC → «Сетевой провайдер»
|
||||
vdc_provider_vdc = "Intel Broadwell 2.4" # ЛК → создание vDC → «Provider VDC»
|
||||
vdc_cpu_allocated = 8 # vCPU, шт.
|
||||
vdc_cpu_guaranteed = 0 # резервирование vCPU, %: 0, 50 или 80
|
||||
vdc_mem_allocated = 32 # RAM, ГБ
|
||||
vdc_storage_config = "[{\"name\":\"SATA\",\"size\":\"200\"}]" # политика и размер, ГБ
|
||||
|
||||
# Edge (сетевой шлюз периметра)
|
||||
nsxt_resource_name = "fullpipe-edge" # имя услуги в ЛК, любое
|
||||
nsxt_vdc_type = "vdc" # родитель: vdc или vdcGroup
|
||||
nsxt_need_enable_avi = true # балансировщик AVI (ALB)
|
||||
nsxt_virtual_services_count = 3 # виртуальных сервисов AVI: 1..4 (Штурвал: не меньше 3)
|
||||
nsxt_ip_addr_pool = "10.10.102.0/24" # пул адресов routed-сети, маска /24
|
||||
nsxt_main_dns = "81.22.46.22" # основной DNS
|
||||
nsxt_second_dns = "185.247.187.77" # второй DNS
|
||||
```
|
||||
|
||||
Описание всех параметров — в `variables.tf`. `terraform.tfvars` с токеном никому не передавайте
|
||||
и не коммитьте в git.
|
||||
|
||||
## 4. Выполните команды
|
||||
|
||||
```bash
|
||||
terraform init # один раз — скачает провайдер
|
||||
terraform plan # покажет, что будет создано: 4 ресурса
|
||||
terraform apply # создаст (подтвердить: yes)
|
||||
```
|
||||
|
||||
## 5. Проверьте результат
|
||||
|
||||
```bash
|
||||
terraform output # UUID и имена созданных услуг
|
||||
```
|
||||
|
||||
И в ЛК: появились vDC и Edge, на организации выделены внешние IP, на шлюзе включён SNAT.
|
||||
Повторный `terraform plan` должен показать `No changes`.
|
||||
|
||||
## 6. Удаление
|
||||
|
||||
```bash
|
||||
terraform destroy
|
||||
```
|
||||
|
||||
Порядок обратный: SNAT выключается, квота внешних IP обнуляется, затем удаляется шлюз,
|
||||
а vDC приостанавливается (данные сохраняются). **Организация не удаляется.**
|
||||
|
||||
## Файлы примера
|
||||
|
||||
| Файл | Что делает |
|
||||
|---|---|
|
||||
| `versions.tf` | версия Terraform и провайдера Nubes |
|
||||
| `provider.tf` | подключение к API (токен, адрес) |
|
||||
| `variables.tf` | все параметры с описанием |
|
||||
| `vdc.tf` | виртуальный датацентр |
|
||||
| `edge.tf` | сетевой шлюз периметра (Edge) |
|
||||
| `modifiers.tf` | внешние IP на организации + SNAT на шлюзе |
|
||||
| `outputs.tf` | UUID и имена созданных услуг |
|
||||
| `terraform.tfvars.example` | шаблон значений (копируется в `terraform.tfvars`) |
|
||||
|
||||
Подробнее про два последних ресурса — на странице
|
||||
[«Ресурсы-модификаторы (IP организации, SNAT)»](../modifiers/org_ip_and_snat.md).
|
||||
@@ -60,3 +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
|
||||
|
||||
@@ -8,11 +8,28 @@ package jsonutil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// uuidAnywhereRegex — UUID-подобная подстрока внутри произвольного текста
|
||||
// (например, внутри JSON-строки). Регистр не учитывается.
|
||||
var uuidAnywhereRegex = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
|
||||
|
||||
// LowercaseUUIDsInText приводит все UUID-подобные подстроки к lowercase.
|
||||
//
|
||||
// Зачем: облако отдаёт один и тот же UUID то в lowercase (vdcUid), то в UPPERCASE
|
||||
// (nsxtUid у кластера Штурвала), а пользователь может написать в config любой
|
||||
// регистр. Сравнение plan vs actual обязано игнорировать регистр UUID.
|
||||
func LowercaseUUIDsInText(value string) string {
|
||||
if !uuidAnywhereRegex.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
return uuidAnywhereRegex.ReplaceAllStringFunc(value, strings.ToLower)
|
||||
}
|
||||
|
||||
// JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order.
|
||||
func JSONStringsEquivalent(a, b string) bool {
|
||||
na, oka := normalizeJSONIfPossible(a)
|
||||
@@ -107,7 +124,7 @@ func normalizeJSONScalarsToStrings(v interface{}) interface{} {
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return val
|
||||
return LowercaseUUIDsInText(val)
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'g', -1, 64)
|
||||
case bool:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package jsonutil
|
||||
|
||||
import "testing"
|
||||
|
||||
// FIX(uuid-case) регрессия: облако отдаёт один и тот же UUID то в lowercase (vdcUid),
|
||||
// то в UPPERCASE (nsxtUid у кластера Штурвала). Сравнение plan vs actual должно
|
||||
// игнорировать регистр UUID — и для одиночного значения, и внутри JSON.
|
||||
func TestJSONStringsEquivalentUUIDCase(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "uuid внутри JSON: разный регистр — эквивалентно",
|
||||
a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","clusterName":"shturval-dev-00"}`,
|
||||
b: `{"clusterName":"shturval-dev-00","nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "вложенный JSON и смешанный регистр",
|
||||
a: `{"a":{"b":["2C37FED1-E8F8-4A84-8434-7851C7C8B5D6"]}}`,
|
||||
b: `{"a":{"b":["2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"]}}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "разные UUID — не эквивалентно",
|
||||
a: `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6"}`,
|
||||
b: `{"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed"}`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "разные значения не-UUID — не эквивалентно",
|
||||
a: `{"clusterName":"shturval-dev-00"}`,
|
||||
b: `{"clusterName":"shturval-dev-01"}`,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "числа и bool остаются различимыми",
|
||||
a: `{"exVip":true,"count":3}`,
|
||||
b: `{"exVip":true,"count":"3"}`,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := JSONStringsEquivalent(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("JSONStringsEquivalent(%s, %s) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowercaseUUIDsInText(t *testing.T) {
|
||||
in := `{"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","name":"internet-ipv4-v1"}`
|
||||
want := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","name":"internet-ipv4-v1"}`
|
||||
if got := LowercaseUUIDsInText(in); got != want {
|
||||
t.Fatalf("LowercaseUUIDsInText() = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
// Текст без UUID не меняется (в т.ч. имя ipSpace с дефисами и цифрами).
|
||||
plain := `{"ipSpaceName":"internet-ipv4-v1","qosProfile":"QoS-100Mbit"}`
|
||||
if got := LowercaseUUIDsInText(plain); got != plain {
|
||||
t.Fatalf("LowercaseUUIDsInText() изменил текст без UUID: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,15 @@ package provider
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// operation_timeouts.json — ДЕФОЛТНЫЙ конфиг таймаутов операций для сборки из
|
||||
// исходников (без него падает go build / go test в provider/).
|
||||
//
|
||||
// Это НЕ сгенерированный артефакт и он СОЗНАТЕЛЬНО лежит в git:
|
||||
// при релизе скрипт TOOLS/scripts/03_build_and_upload_provider.sh копирует
|
||||
// провайдер во временный каталог и ПОДМЕНЯЕТ там этот файл профильным
|
||||
// (TOOLS/config/<profile>/operation_timeouts.json) — рабочее дерево не меняется.
|
||||
//
|
||||
// Менять значения для релиза нужно в TOOLS/config/<profile>/, а не здесь.
|
||||
//
|
||||
//go:embed operation_timeouts.json
|
||||
var operationTimeoutsConfigEmbedded []byte
|
||||
|
||||
@@ -182,6 +182,10 @@ func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureReq
|
||||
func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource {
|
||||
resources := resources_gen.AllResources()
|
||||
resources = append(resources, resources_core.NewServiceOperationResource)
|
||||
// Ресурсы-модификаторы для операций, которых нет в create-схеме ресурсов-инстансов.
|
||||
// Организация и шлюз создаются вручную в ЛК, поэтому адресуются по uid, а не ссылкой на ресурс.
|
||||
resources = append(resources, resources_core.NewOrgIpAllocationResource)
|
||||
resources = append(resources, resources_core.NewNsxtSnatResource)
|
||||
return resources
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
|
||||
"terraform-provider-nubes/internal/core/jsonutil"
|
||||
)
|
||||
|
||||
// jsonNormalizePlanModifier реализует planmodifier.String.
|
||||
@@ -49,5 +51,7 @@ func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planm
|
||||
// Невалидный JSON — оставляем значение без изменений, не прерываем планирование.
|
||||
return
|
||||
}
|
||||
resp.PlanValue = types.StringValue(buf.String())
|
||||
// FIX(uuid-case): UUID-подстроки — к lowercase, чтобы план не расходился с
|
||||
// состоянием только из-за регистра UUID (облако отдаёт их в разных регистрах).
|
||||
resp.PlanValue = types.StringValue(jsonutil.LowercaseUUIDsInText(buf.String()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package resources_core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &NsxtSnatResource{}
|
||||
var _ resource.ResourceWithConfigure = &NsxtSnatResource{}
|
||||
var _ resource.ResourceWithImportState = &NsxtSnatResource{}
|
||||
|
||||
// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра
|
||||
// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372).
|
||||
//
|
||||
// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет),
|
||||
// поэтому одним ресурсом «create + modify» в одном apply не сделать.
|
||||
//
|
||||
// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md):
|
||||
// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>";
|
||||
// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы).
|
||||
type NsxtSnatResource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type NsxtSnatModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
NsxtUID types.String `tfsdk:"nsxt_uid"`
|
||||
IpSpaceName types.String `tfsdk:"ip_space_name"`
|
||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||
}
|
||||
|
||||
// noNeededIpSpace — каноническое значение «SNAT не нужен».
|
||||
const noNeededIpSpace = "no-needed"
|
||||
|
||||
func NewNsxtSnatResource() resource.Resource {
|
||||
return &NsxtSnatResource{}
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat"
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " +
|
||||
"Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " +
|
||||
"Значение `no-needed` выключает SNAT.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"nsxt_uid": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"ip_space_name": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " +
|
||||
"Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).",
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(false),
|
||||
MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " +
|
||||
"`ipSpaceName = \"no-needed\"`).",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString()))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||
if nsxtUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := r.client.GetInstanceStateParams(ctx, nsxtUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом,
|
||||
// иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет
|
||||
// (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state.
|
||||
if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" {
|
||||
state.IpSpaceName = types.StringValue(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
state.ID = types.StringValue(nsxtUID)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state NsxtSnatModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString())
|
||||
if nsxtUID == "" || r.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT не выключался",
|
||||
fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID)
|
||||
if err != nil {
|
||||
// Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state,
|
||||
// а SNAT останется включённым.
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT не выключался",
|
||||
fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(nsxtUID)
|
||||
defer unlock()
|
||||
|
||||
// Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR).
|
||||
if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{
|
||||
"ipSpaceName": noNeededIpSpace,
|
||||
}); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.AddWarning(
|
||||
"SNAT выключен",
|
||||
fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
|
||||
func (r *NsxtSnatResource) 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("nsxt_uid"), uid)...)
|
||||
}
|
||||
|
||||
// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции
|
||||
// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются
|
||||
// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default
|
||||
// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает.
|
||||
func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error {
|
||||
uid := strings.TrimSpace(nsxtUID.ValueString())
|
||||
if uid == "" {
|
||||
return fmt.Errorf("nsxt_uid обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
}
|
||||
|
||||
// Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff).
|
||||
// Выключение SNAT — явное каноническое значение "no-needed".
|
||||
value := strings.TrimSpace(ipSpaceName.ValueString())
|
||||
if value == "" {
|
||||
return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace)
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(uid)
|
||||
defer unlock()
|
||||
|
||||
// ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции,
|
||||
// а не с live-состоянием инстанса — можно ложно пропустить modify.
|
||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||
"ipSpaceName": value,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package resources_core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ resource.Resource = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{}
|
||||
var _ resource.ResourceWithImportState = &OrgIpAllocationResource{}
|
||||
|
||||
// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации
|
||||
// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662).
|
||||
//
|
||||
// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК
|
||||
// и адресуется здесь по uid.
|
||||
//
|
||||
// Семантика операции — replace всего массива: переданное значение полностью заменяет
|
||||
// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md).
|
||||
// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом.
|
||||
type OrgIpAllocationResource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type OrgIpAllocationModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
Organization types.String `tfsdk:"organization"`
|
||||
VIPConfigure types.String `tfsdk:"vip_configure"`
|
||||
KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"`
|
||||
}
|
||||
|
||||
// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка:
|
||||
// ЛК присылает его строкой (HAR/globak.har), API принимает строкой.
|
||||
type vipAllocation struct {
|
||||
Name string
|
||||
Count string
|
||||
}
|
||||
|
||||
func NewOrgIpAllocationResource() resource.Resource {
|
||||
return &OrgIpAllocationResource{}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation"
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " +
|
||||
"Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " +
|
||||
"Операция имеет replace-семантику: массив перезаписывается целиком.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"organization": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " +
|
||||
"или её UUID.",
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"vip_configure": schema.StringAttribute{
|
||||
Required: true,
|
||||
MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " +
|
||||
"Значение перезаписывает текущую аллокацию целиком. `count` — строка. " +
|
||||
"Порядок ключей и форматирование не важны (сравнение смысловое). " +
|
||||
"Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).",
|
||||
},
|
||||
"keep_on_destroy": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(false),
|
||||
MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " +
|
||||
"`count=0` по каждому элементу).",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
// Организации больше нет — ресурс тоже не нужен.
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := r.client.GetInstanceStateParams(ctx, orgUID)
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform
|
||||
// увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту).
|
||||
// Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result").
|
||||
raw, ok := live["vIPConfigure"]
|
||||
if ok {
|
||||
liveItems, parseErr := parseVipConfigure(raw)
|
||||
if parseErr != nil {
|
||||
resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error())
|
||||
return
|
||||
}
|
||||
stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString())
|
||||
if !vipAllocationsEqual(liveItems, stateItems) {
|
||||
state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems))
|
||||
}
|
||||
}
|
||||
|
||||
state.ID = types.StringValue(orgUID)
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state OrgIpAllocationModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
remove, err := ShouldRemoveFromState(ctx, r.client, orgUID)
|
||||
if err != nil {
|
||||
// Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state,
|
||||
// а выделенные IP останутся висеть.
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
if remove {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(orgUID)
|
||||
defer unlock()
|
||||
|
||||
// Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации.
|
||||
items := []vipAllocation{}
|
||||
if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil {
|
||||
if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil {
|
||||
items = parsed
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Аллокация IP не снималась",
|
||||
"не удалось определить выделенные ipSpace — обратный modify пропущен.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22).
|
||||
// Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена.
|
||||
zero := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
zero = append(zero, vipAllocation{Name: item.Name, Count: "0"})
|
||||
}
|
||||
|
||||
if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(zero),
|
||||
}); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.AddWarning(
|
||||
"Квота IP обнулена",
|
||||
fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *OrgIpAllocationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient")
|
||||
return
|
||||
}
|
||||
r.client = client
|
||||
}
|
||||
|
||||
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("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 string, vipConfigure types.String) error {
|
||||
uid := strings.TrimSpace(orgUID)
|
||||
if uid == "" {
|
||||
return fmt.Errorf("organization обязателен")
|
||||
}
|
||||
if r.client == nil {
|
||||
return fmt.Errorf("клиент не инициализирован")
|
||||
}
|
||||
|
||||
items, err := parseVipConfigure(vipConfigure.ValueString())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)")
|
||||
}
|
||||
|
||||
unlock := r.client.LockInstance(uid)
|
||||
defer unlock()
|
||||
|
||||
// Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ
|
||||
// операции, а это не live-состояние инстанса (см. core/modifier_compare.go и
|
||||
// комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify.
|
||||
return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{
|
||||
"vIPConfigure": formatVipConfigure(items),
|
||||
})
|
||||
}
|
||||
|
||||
// parseVipConfigure разбирает значение параметра vIPConfigure.
|
||||
// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги
|
||||
// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются.
|
||||
func parseVipConfigure(raw string) ([]vipAllocation, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err)
|
||||
}
|
||||
|
||||
out := make([]vipAllocation, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := ""
|
||||
if v, ok := item["name"]; ok && v != nil {
|
||||
name = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
count := "0"
|
||||
if v, ok := item["count"]; ok && v != nil {
|
||||
if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" {
|
||||
count = parsed
|
||||
}
|
||||
}
|
||||
out = append(out, vipAllocation{Name: name, Count: count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}]
|
||||
// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read,
|
||||
// иначе план и state расходятся по строке — см. vipConfigureCanonical.
|
||||
func formatVipConfigure(items []vipAllocation) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count))
|
||||
}
|
||||
return "[" + strings.Join(parts, ",") + "]"
|
||||
}
|
||||
|
||||
// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны.
|
||||
// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени.
|
||||
func vipAllocationsEqual(a, b []vipAllocation) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
byName := make(map[string]string, len(b))
|
||||
for _, item := range b {
|
||||
byName[item.Name] = item.Count
|
||||
}
|
||||
for _, item := range a {
|
||||
count, ok := byName[item.Name]
|
||||
if !ok || count != item.Count {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package resources_core
|
||||
|
||||
import "testing"
|
||||
|
||||
// Проверяем нормализацию vIPConfigure: свежая орга отдаёт `[{}]` (пустой элемент) —
|
||||
// это «не выделено», а не аллокация (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md).
|
||||
func TestParseVipConfigure_EmptyAndBroken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"пустая строка", "", 0},
|
||||
{"пустой массив", "[]", 0},
|
||||
{"пустой элемент (свежая орга)", "[{}]", 0},
|
||||
{"только name без count", `[{"name":"internet-ipv4-v1"}]`, 1},
|
||||
{"элемент без name", `[{"count":"3"}]`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseVipConfigure(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != tc.want {
|
||||
t.Fatalf("получено %d элементов, ожидалось %d (%+v)", len(got), tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_CountAsString(t *testing.T) {
|
||||
// count у платформы может прийти и числом (в старых стейтах) — нормализуем в строку.
|
||||
got, err := parseVipConfigure(`[{"name":"internet-ipv4-v1","count":4}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Count != "4" {
|
||||
t.Fatalf("ожидался count=\"4\", получено %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatVipConfigure_Canonical(t *testing.T) {
|
||||
got := formatVipConfigure([]vipAllocation{{Name: "internet-ipv4-v1", Count: "3"}})
|
||||
want := `[{"name":"internet-ipv4-v1","count":"3"}]`
|
||||
if got != want {
|
||||
t.Fatalf("получено %q, ожидалось %q", got, want)
|
||||
}
|
||||
if empty := formatVipConfigure(nil); empty != "[]" {
|
||||
t.Fatalf("для пустого списка ожидалось \"[]\", получено %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// Сравнение смысловое: `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 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(`[{}]`)
|
||||
|
||||
if vipAllocationsEqual(base, otherCount) {
|
||||
t.Fatal("разный count должен считаться разными значениями")
|
||||
}
|
||||
if vipAllocationsEqual(base, otherName) {
|
||||
t.Fatal("разное имя ipSpace должно считаться разными значениями")
|
||||
}
|
||||
if vipAllocationsEqual(base, empty) {
|
||||
t.Fatal("пустая аллокация должна отличаться от непустой")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_RoundTripIsStable(t *testing.T) {
|
||||
raw := `[{"name":"internet-ipv4-v1","count":"4"}]`
|
||||
items, err := parseVipConfigure(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if again := formatVipConfigure(items); again != raw {
|
||||
t.Fatalf("round-trip не стабилен: %q → %q", raw, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVipConfigure_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseVipConfigure(`{"name":"x"}`); err == nil {
|
||||
t.Fatal("ожидалась ошибка на объект вместо массива")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package resources_core
|
||||
|
||||
import "testing"
|
||||
|
||||
// FIX(uuid-case): регрессия today's bug — adopt приостановленного инстанса Штурвала
|
||||
// падал с "required params mismatch ... startupConfiguration", потому что UUID
|
||||
// внутри JSON сравнивался с учётом регистра (plan — lowercase из config,
|
||||
// actual — UPPERCASE от API). Теперь UUID внутри JSON эквивалентны.
|
||||
func TestParamsEquivalentUUIDCaseInsideJSON(t *testing.T) {
|
||||
plan := `{"nsxtUid":"2c37fed1-e8f8-4a84-8434-7851c7c8b5d6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51","clusterName":"shturval-dev-00","exVip":true}`
|
||||
actual := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"2C37FED1-E8F8-4A84-8434-7851C7C8B5D6","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}`
|
||||
|
||||
if !paramsEquivalent(plan, actual) {
|
||||
t.Fatalf("paramsEquivalent() = false, ожидалось true: регистр UUID внутри JSON не должен влиять")
|
||||
}
|
||||
|
||||
// Реально другой nsxtUid — по-прежнему не эквивалентно.
|
||||
other := `{"clusterName":"shturval-dev-00","exVip":true,"nsxtUid":"86a01033-adac-424f-8296-aa10a001d6ed","vdcUid":"d0937335-276b-475b-baa4-d8e6d16bad51"}`
|
||||
if paramsEquivalent(plan, other) {
|
||||
t.Fatalf("paramsEquivalent() = true для другого nsxtUid — неверно")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user