From 94c4c44ba41fbde3afa44b1be9030f5a9fd46348 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Tue, 22 Sep 2026 21:44:16 +0300 Subject: [PATCH] =?UTF-8?q?fix(core):=20ShouldRemoveFromState=20=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20deleted/404=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20GetInstanceStateRaw=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources_core/resource_diagnostics.go | 49 ++++++- .../resource_diagnostics_test.go | 123 ++++++++++++++++++ 2 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 provider/internal/resources_core/resource_diagnostics_test.go diff --git a/provider/internal/resources_core/resource_diagnostics.go b/provider/internal/resources_core/resource_diagnostics.go index 3299c15..5f279c3 100644 --- a/provider/internal/resources_core/resource_diagnostics.go +++ b/provider/internal/resources_core/resource_diagnostics.go @@ -67,6 +67,14 @@ func CreateExistingResourceDiagnostics(ctx context.Context, client *core.Univers } // ShouldRemoveFromState checks instance status and indicates if TF state should be removed. +// +// Возвращает (true, nil) ТОЛЬКО когда инстанс реально отсутствует на платформе: +// - состояние ответило deleted (IsDeleted=true или ExplainedStatus=="deleted"), или +// - API ответил 404 / "not found" (инстанс полностью стёрт). +// +// В остальных случаях (живой инстанс, сетевая ошибка, 5xx, 403 — нет прав) +// возвращает (false, nil) или (false, err). Это принципиально: при временной +// недоступности API нельзя молча вычищать ресурсы из state. func ShouldRemoveFromState(ctx context.Context, client *core.UniversalClient, instanceID string) (bool, error) { if client == nil { return false, nil @@ -75,20 +83,49 @@ func ShouldRemoveFromState(ctx context.Context, client *core.UniversalClient, in return false, nil } - state, err := client.GetInstanceState(ctx, instanceID) + // Читаем БЕЗ валидации статуса: нужно отличить deleted/404 (убрать из state) + // от прочих ошибок (пробросить как ошибку). + state, err := client.GetInstanceStateRaw(ctx, instanceID) if err != nil { - return false, err - } - if state != nil { - status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) - if state.IsDeleted || status == "deleted" { + if isInstanceNotFoundError(err) { return true, nil } + return false, err + } + if state == nil { + return false, nil + } + + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if state.IsDeleted || status == "deleted" { + return true, nil } return false, nil } +// isInstanceNotFoundError определяет, является ли ошибка чтения инстанса +// признаком того, что инстанс полностью удалён (HTTP 404 / not found), +// а не временным сбоем или отсутствием прав. +func isInstanceNotFoundError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + // Точный статус 404 — основной признак «инстанс стёрт». + if strings.Contains(msg, "ошибка api 404") || strings.Contains(msg, "error:404") || strings.Contains(msg, "status 404") { + return true + } + // Дополнительные маркеры «not found» (как в IsSubresourceMissingError). + if strings.Contains(msg, "not found") && !strings.Contains(msg, "permission") { + return true + } + return false +} + func normalizeStatus(status string) string { return strings.ToLower(strings.TrimSpace(status)) } diff --git a/provider/internal/resources_core/resource_diagnostics_test.go b/provider/internal/resources_core/resource_diagnostics_test.go new file mode 100644 index 0000000..27326eb --- /dev/null +++ b/provider/internal/resources_core/resource_diagnostics_test.go @@ -0,0 +1,123 @@ +package resources_core + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "terraform-provider-nubes/internal/core" +) + +func makeShouldRemoveClient(handler http.HandlerFunc) (*core.UniversalClient, func()) { + srv := httptest.NewServer(handler) + c := &core.UniversalClient{ + HttpClient: srv.Client(), + ApiEndpoint: srv.URL, + } + return c, func() { srv.Close() } +} + +func TestShouldRemoveFromState(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + wantRemove bool + wantErr bool + }{ + { + name: "deleted статус", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"instance":{"instanceUid":"u1","explainedStatus":"deleted","isDeleted":true}}`)) + }, + wantRemove: true, + wantErr: false, + }, + { + name: "isDeleted=true без статуса deleted", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"instance":{"instanceUid":"u1","explainedStatus":"running","isDeleted":true}}`)) + }, + wantRemove: true, + wantErr: false, + }, + { + name: "HTTP 404 not found", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`"Instance not found or no permission to view"`)) + }, + wantRemove: true, + wantErr: false, + }, + { + name: "живой инстанс", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"instance":{"instanceUid":"u1","explainedStatus":"running","isDeleted":false}}`)) + }, + wantRemove: false, + wantErr: false, + }, + { + name: "HTTP 500 — НЕ удалять", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + }, + wantRemove: false, + wantErr: true, + }, + { + name: "HTTP 403 — НЕ удалять", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`"permission denied"`)) + }, + wantRemove: false, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, cleanup := makeShouldRemoveClient(tc.handler) + defer cleanup() + + remove, err := ShouldRemoveFromState(context.Background(), client, "u1") + if (err != nil) != tc.wantErr { + t.Fatalf("expected err=%v, got err=%v", tc.wantErr, err) + } + if remove != tc.wantRemove { + t.Fatalf("expected remove=%v, got remove=%v", tc.wantRemove, remove) + } + }) + } +} + +func TestShouldRemoveFromState_NilClient(t *testing.T) { + remove, err := ShouldRemoveFromState(context.Background(), nil, "u1") + if err != nil { + t.Fatalf("expected nil error for nil client, got %v", err) + } + if remove { + t.Fatalf("expected remove=false for nil client") + } +} + +func TestShouldRemoveFromState_EmptyID(t *testing.T) { + client, cleanup := makeShouldRemoveClient(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("should not make request for empty id") + }) + defer cleanup() + + remove, err := ShouldRemoveFromState(context.Background(), client, " ") + if err != nil { + t.Fatalf("expected nil error for empty id, got %v", err) + } + if remove { + t.Fatalf("expected remove=false for empty id") + } +}