fix(Q5): сохранять instanceUid при ошибке после создания + partial state
Проблема: 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.
This commit is contained in:
@@ -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` → `""`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user