1
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 и сравнение строками.
|
||||
|
||||
@@ -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 с другим порядком не должен быть равен")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user