This commit is contained in:
Repinoid
2026-09-30 21:01:05 +03:00
parent 575f1e29a4
commit 4197a76aba
18 changed files with 2009 additions and 140 deletions
+32 -1
View File
@@ -79,8 +79,39 @@ func TestIsRetryable(t *testing.T) {
}
}
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
// Idempotent-пропуск НЕ должен создавать операцию: иначе останется «черновик»
// (POST /instanceOperations без run), и следующий modify будет ждать idle до таймаута.
func TestRunInstanceOperationByIdempotent_SkipsWithoutCreatingOperation(t *testing.T) {
var opPosts atomic.Int32
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/instances/inst-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"instance":{"instanceUid":"inst-1","serviceId":22,"explainedStatus":"running","isDeleted":false,"operationIsPending":false,"operationIsInProgress":false,"availableOperations":[{"svcOperationId":207,"operation":"modify"}],"state":{"params":{"ipSpaceName":"internet-ipv4-v1"}}}}`))
case r.Method == http.MethodGet && r.URL.Path == "/instanceOperations/default/207":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"svcOperation":{"cfsParams":[{"svcOperationCfsParamId":372,"code":"ipSpaceName","dataType":"string"}]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
opPosts.Add(1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"instanceOperationUid":"op-x"}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
defer cleanup()
err := c.RunInstanceOperationUniversalByIdempotent(context.Background(), "inst-1", "modify",
map[string]string{"ipSpaceName": "internet-ipv4-v1"})
if err != nil {
t.Fatalf("expected nil error on idempotent skip, got: %v", err)
}
if n := opPosts.Load(); n != 0 {
t.Fatalf("operation must NOT be created when live already matches, got %d POST(s)", n)
}
}
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает:
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
+3 -1
View File
@@ -118,7 +118,9 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
// isRetryable returns true for transient HTTP errors that can be retried.
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
// «API Resilience»). Ретраится только для GET (см. условие в doRequest).
// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера),
// поэтому при постоянном 401 это даст 3 холостых повтора с backoff.
// Ретраится только для GET (см. условие в doRequest).
func isRetryable(statusCode int) bool {
return statusCode == http.StatusUnauthorized || // 401
statusCode == http.StatusTooManyRequests || // 429
@@ -7,41 +7,6 @@ import (
"terraform-provider-nubes/internal/core/jsonutil"
)
// modifierDesiredEqualsCurrent сравнивает желаемые значения полей модификатора
// (keyed by code) с живыми значениями из cfsParams (ParamValue).
//
// Возвращает true, если ВСЕ поля совпали (можно пропустить run при idempotency).
// Семантика сравнения:
// - bool/int/string: нормализуются через normalizeUniversalValueV6 и сравниваются строками;
// - map-fixed: JSON-сравнение (порядок ключей не значим);
// - array-map-fixed: JSON-сравнение сырых значений (normalize не строит дефолт для массивов).
func (c *UniversalClient) modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool {
if len(desired) == 0 {
return false
}
codeToParam := modifierCodeMap(cfsParams)
for code, wanted := range desired {
param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))]
if !ok {
// Код не найден в схеме — не можем сравнить, считаем «не равно».
return false
}
current := ""
if param.ParamValue != nil {
current = *param.ParamValue
}
if !modifierValuesEqual(wanted, current, param) {
return false
}
}
return true
}
// modifierValuesEqual сравнивает одно значение с учётом типа параметра:
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
+21 -39
View File
@@ -6,62 +6,44 @@ import (
"testing"
)
func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) {
c := &UniversalClient{}
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 340, Code: "needEnableAVI", DataType: "boolean", ParamValue: strPtr("false")},
{SvcOperationCfsParamId: 369, Code: "virtualServicesCount", DataType: "integer > 0", ParamValue: strPtr("3")},
{SvcOperationCfsParamId: 856, Code: "qosProfile", DataType: "string", ParamValue: strPtr("QoS-100Mbit")},
}
func TestModifierValuesEqual_Scalars(t *testing.T) {
tests := []struct {
name string
desired map[string]string
want bool
name string
wanted, current string
param universalCfsParam
want bool
}{
{"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true},
{"bool расхождение", map[string]string{"needEnableAVI": "true"}, false},
{"int расхождение", map[string]string{"virtualServicesCount": "1"}, false},
{"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true},
{"код не найден в схеме", map[string]string{"неизвестный": "x"}, false},
{"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true},
{"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false},
{"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true},
{"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false},
{"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want {
t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want)
if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want {
t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want)
}
})
}
}
func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) {
c := &UniversalClient{}
func TestModifierValuesEqual_JSON(t *testing.T) {
mapParam := universalCfsParam{DataType: "map-fixed"}
// map-fixed: порядок ключей не значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed",
ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)},
}
desired := map[string]string{
"routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
}
if !c.modifierDesiredEqualsCurrent(desired, cfsParams) {
if !modifierValuesEqual(
`{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`,
mapParam) {
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
}
}
func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) {
c := &UniversalClient{}
arrParam := universalCfsParam{DataType: "array-map-fixed"}
// array-map-fixed: порядок элементов массива значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 662, Code: "vIPConfigure", DataType: "array-map-fixed",
ParamValue: strPtr(`[{"name":"a","count":1},{"name":"b","count":2}]`)},
}
// тот же порядок — равно
if !c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"a","count":1},{"name":"b","count":2}]`}, cfsParams) {
if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) {
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
}
// другой порядок — не равно
if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) {
if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) {
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
}
}
+18
View File
@@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str
}
return def.SvcOperation.CfsParams, nil
}
// fetchOperationSchemaByID возвращает схему операции по svcOperationId БЕЗ создания
// операции: GET /instanceOperations/default/{opId}.
//
// Зачем: idempotency pre-check обязан выполняться ДО POST /instanceOperations — иначе
// при пропуске останется созданная-но-незапущенная операция («черновик», pending),
// и следующий modify будет ждать idle до таймаута.
func (c *UniversalClient) fetchOperationSchemaByID(ctx context.Context, opId int) ([]universalCfsParam, error) {
defResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil)
if err != nil {
return nil, fmt.Errorf("не удалось получить схему операции %d: %w", opId, err)
}
var def universalOpDefaultResponse
if uerr := json.Unmarshal(defResp, &def); uerr != nil {
return nil, fmt.Errorf("не удалось разобрать схему операции %d: %w", opId, uerr)
}
return def.SvcOperation.CfsParams, nil
}
+19 -22
View File
@@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
// Схема операции нужна и для idempotency pre-check, и для маппинга кодов.
// Получаем её ДО создания операции (GET /instanceOperations/default/{opId}): иначе
// при пропуске останется созданная, но незапущенная операция («черновик», pending).
schemaParams, err := c.fetchOperationSchemaByID(ctx, opId)
if err != nil {
return err
}
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
// выходим, НЕ создавая операцию вовсе (источник — state.params, см. instanceLiveParams).
if idempotent {
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, schemaParams)
if liveErr == nil && equal {
return nil
}
// Ошибка/несовпадение live — не пропускаем: выполняем modify.
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
@@ -60,28 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return err
}
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
// пропускаем run. Источник сравнения — state.params (live), НЕ paramValue формы
// операции: форма может не совпадать с состоянием (см. instanceLiveParams).
// желаемые = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
if idempotent {
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, cfsParams)
if liveErr == nil && equal {
return nil
}
// Ошибка/несовпадение live — не пропускаем: выполняем modify. Если live
// действительно недоступен, следующий шаг вернёт ошибку явно (не молча).
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
}
codeToParam := modifierCodeMap(cfsParams)
paramsByID := map[int]string{}
for code, value := range params {