From 9da97663c687b76d9b045eefdfc2c55194d35c36 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 30 Sep 2026 20:53:14 +0300 Subject: [PATCH] =?UTF-8?q?fix(Q5):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20instanceUid=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20+=20parti?= =?UTF-8?q?al=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: CreateGenericInstanceUniversalV6 при ЛЮБОЙ ошибке после создания инстанса возвращал "", а шаблон Create при ошибке не писал ID в state => облачный инстанс осиротевал (Terraform о нём не знает, повторный apply упирается в страж дубликатов). - core/instance_create.go: ошибки после получения instanceUid возвращают uid вместе с ошибкой (POST /instanceOperations, пустой opUid, разбор cfsParams, resolve, отправка параметров, validate, run, waitForOperationFinish, ensureInstanceCreated). До создания uid — по-прежнему "". - templates/instance.go: при err != nil и id != "" -> data.ID + resp.State.Set (partial state), затем AddError. - client_test.go: TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails, TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails. - ARCHITECTURE.md: пункт про partial state. Проверено: 02 (dev) + dev-materialize -> 40 файлов resources_gen содержат фикс; go build ./... OK; go test ./internal/... -short PASS. --- TOOLS/ARCHITECTURE.md | 5 ++ .../internal/templates/instance.go | 7 +++ provider/internal/core/client_test.go | 47 ++++++++++++++++++- provider/internal/core/instance_create.go | 22 +++++---- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/TOOLS/ARCHITECTURE.md b/TOOLS/ARCHITECTURE.md index 547c773..a4a59c8 100644 --- a/TOOLS/ARCHITECTURE.md +++ b/TOOLS/ARCHITECTURE.md @@ -131,6 +131,11 @@ Each operation has a kind: ### Generated Code Resilience +- **Partial state при ошибке create** (шаблон `instance.go`): если инстанс успел создаться + (`CreateResourceWithTimeout` вернул непустой uid ВМЕСТЕ с ошибкой), uid фиксируется в state + до вывода ошибки. Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный + `apply` упрётся в страж дубликатов. Ядро (`CreateGenericInstanceUniversalV6`) возвращает uid + при любой ошибке ПОСЛЕ создания инстанса; пустой uid — только если создание не состоялось. - **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует в пользовательском `.tf`, подставлять zero-value по `dataType`: - `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""` diff --git a/TOOLS/resource-generator/internal/templates/instance.go b/TOOLS/resource-generator/internal/templates/instance.go index bb5eaaa..da84be2 100644 --- a/TOOLS/resource-generator/internal/templates/instance.go +++ b/TOOLS/resource-generator/internal/templates/instance.go @@ -313,6 +313,13 @@ func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.Cre } id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout) if err != nil { + // Partial state (Q5): если инстанс успел создаться (id != ""), фиксируем его в state. + // Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный apply + // упрётся в страж дубликатов. Read затем сверит/очистит состояние. + if id != "" { + data.ID = types.StringValue(id) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + } resp.Diagnostics.AddError("Ошибка клиента", err.Error()) return } diff --git a/provider/internal/core/client_test.go b/provider/internal/core/client_test.go index 0b0259b..068e6fb 100644 --- a/provider/internal/core/client_test.go +++ b/provider/internal/core/client_test.go @@ -79,8 +79,53 @@ func TestIsRetryable(t *testing.T) { } } -// ===== waitForOperationFinish tests ===== +// ===== Q5: partial-result tests (instance uid при ошибке после создания) ===== +// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает: +// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет. +func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) { + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/instances": + w.Header().Set("Location", "./inst-9") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"instanceUid":"inst-9"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"ERROR":"boom"}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + defer cleanup() + + uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil) + if err == nil { + t.Fatal("expected error from failed operation") + } + if uid != "inst-9" { + t.Fatalf("expected instanceUid preserved on error, got %q", uid) + } +} + +// Инстанс НЕ создан: uid обязан быть пустым (иначе запишем в state несуществующий ресурс). +func TestCreateGenericInstance_EmptyUIDWhenInstanceCreateFails(t *testing.T) { + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"ERROR":"bad request"}`)) + }) + defer cleanup() + + uid, err := c.CreateGenericInstanceUniversalV6(context.Background(), 21, "vm-1", nil) + if err == nil { + t.Fatal("expected error") + } + if uid != "" { + t.Fatalf("expected empty uid before creation, got %q", uid) + } +} + +// ===== waitForOperationFinish tests ===== func makeTestClient(handler http.HandlerFunc) (*UniversalClient, func()) { srv := httptest.NewServer(handler) var buf bytes.Buffer diff --git a/provider/internal/core/instance_create.go b/provider/internal/core/instance_create.go index ac1fe0e..9d83a30 100644 --- a/provider/internal/core/instance_create.go +++ b/provider/internal/core/instance_create.go @@ -45,7 +45,9 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) if err != nil { - return "", err + // Инстанс УЖЕ создан: возвращаем uid вместе с ошибкой, чтобы вызывающий + // мог зафиксировать его в state (иначе облачный инстанс осиротеет — Q5). + return instanceUid, err } opUid := extractUIDFromLocation(opHeaders.Get("Location")) @@ -63,7 +65,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, } } if opUid == "" { - return "", fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location")) + return instanceUid, fmt.Errorf("не удалось извлечь instanceOperationUid (Header: %s)", opHeaders.Get("Location")) } opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) @@ -71,12 +73,12 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, hasOpDetails := false if err == nil { if jsonErr := json.Unmarshal(opDetailsResp, &opDetails); jsonErr != nil { - return "", fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr) + return instanceUid, fmt.Errorf("не удалось разобрать детали операции: %w", jsonErr) } hasOpDetails = true params, err = c.resolveRefSvcParamValues(ctx, opDetails.InstanceOperation.CfsParams, params) if err != nil { - return "", err + return instanceUid, err } } @@ -89,7 +91,7 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, } _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) if err != nil { - return "", fmt.Errorf("не удалось установить параметр %d: %w", paramId, err) + return instanceUid, fmt.Errorf("не удалось установить параметр %d: %w", paramId, err) } sent[paramId] = true } @@ -118,27 +120,27 @@ func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, } _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) if err != nil { - return "", fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err) + return instanceUid, fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err) } } } _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) if err != nil { - return "", fmt.Errorf("валидация не пройдена: %w", err) + return instanceUid, fmt.Errorf("валидация не пройдена: %w", err) } _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) if err != nil { - return "", fmt.Errorf("выполнение не удалось: %w", err) + return instanceUid, fmt.Errorf("выполнение не удалось: %w", err) } // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish if err := c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, serviceId, "create")); err != nil { - return "", err + return instanceUid, err } if err := c.ensureInstanceCreated(ctx, instanceUid); err != nil { - return "", err + return instanceUid, err } return instanceUid, nil