fix(core): ShouldRemoveFromState читает deleted/404 через GetInstanceStateRaw без падения

This commit is contained in:
Repinoid
2026-09-22 21:44:16 +03:00
parent 4220f4f483
commit 94c4c44ba4
2 changed files with 166 additions and 6 deletions
@@ -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))
}
@@ -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")
}
}