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