3 Commits
8 changed files with 159 additions and 2 deletions
+3
View File
@@ -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/
@@ -14,6 +14,14 @@
| Режим «заморозки» в конфиге стенда: `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` |
## Проверка цикла на живом стенде
- `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`
@@ -216,6 +216,32 @@ SNAT останется включённым (Delete при `keep=true` печа
---
## 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`) — запускает пользователь.
---
## 6. Мои ошибки в этой сессии (обязательно к фиксации)
1. Сказал, что apply «либо даст ошибку, либо создаст дубль кластера» — **неверно**: будет hard error
+18 -1
View File
@@ -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
@@ -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,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 — неверно")
}
}