From 321dd84c8ebfec769b33e0126b747090c59f5c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 30 Jun 2026 15:43:07 +0400 Subject: [PATCH] add: CRUD logic (resources_core) --- .../internal/resources_core/crud.go | 178 +++++++ .../internal/resources_core/crud_test.go | 56 ++ .../resources_core/domain_collision.go | 188 +++++++ .../internal/resources_core/helpers.go | 80 +++ .../internal/resources_core/json_normalize.go | 121 +++++ .../resources_core/json_planmodifier.go | 53 ++ .../internal/resources_core/operation_ids.go | 36 ++ .../internal/resources_core/outputs.go | 201 +++++++ .../internal/resources_core/params_compare.go | 100 ++++ .../internal/resources_core/params_mapping.go | 91 ++++ .../resources_core/params_ref_mapping.go | 152 ++++++ .../params_validation_mapping.go | 109 ++++ .../internal/resources_core/ref_validation.go | 283 ++++++++++ .../resources_core/required_params.go | 91 ++++ .../resources_core/required_params_compare.go | 122 +++++ .../resources_core/resource_diagnostics.go | 131 +++++ .../resource_diagnostics_required.go | 499 ++++++++++++++++++ .../service_operation_resource.go | 159 ++++++ .../internal/resources_core/state_refresh.go | 204 +++++++ .../resources_core/subresource_guard.go | 207 ++++++++ .../resources_core/uuid_planmodifier.go | 48 ++ 21 files changed, 3109 insertions(+) create mode 100644 universal_rebuild/internal/resources_core/crud.go create mode 100644 universal_rebuild/internal/resources_core/crud_test.go create mode 100644 universal_rebuild/internal/resources_core/domain_collision.go create mode 100644 universal_rebuild/internal/resources_core/helpers.go create mode 100644 universal_rebuild/internal/resources_core/json_normalize.go create mode 100644 universal_rebuild/internal/resources_core/json_planmodifier.go create mode 100644 universal_rebuild/internal/resources_core/operation_ids.go create mode 100644 universal_rebuild/internal/resources_core/outputs.go create mode 100644 universal_rebuild/internal/resources_core/params_compare.go create mode 100644 universal_rebuild/internal/resources_core/params_mapping.go create mode 100644 universal_rebuild/internal/resources_core/params_ref_mapping.go create mode 100644 universal_rebuild/internal/resources_core/params_validation_mapping.go create mode 100644 universal_rebuild/internal/resources_core/ref_validation.go create mode 100644 universal_rebuild/internal/resources_core/required_params.go create mode 100644 universal_rebuild/internal/resources_core/required_params_compare.go create mode 100644 universal_rebuild/internal/resources_core/resource_diagnostics.go create mode 100644 universal_rebuild/internal/resources_core/resource_diagnostics_required.go create mode 100644 universal_rebuild/internal/resources_core/service_operation_resource.go create mode 100644 universal_rebuild/internal/resources_core/state_refresh.go create mode 100644 universal_rebuild/internal/resources_core/subresource_guard.go create mode 100644 universal_rebuild/internal/resources_core/uuid_planmodifier.go diff --git a/universal_rebuild/internal/resources_core/crud.go b/universal_rebuild/internal/resources_core/crud.go new file mode 100644 index 0000000..2dd30d3 --- /dev/null +++ b/universal_rebuild/internal/resources_core/crud.go @@ -0,0 +1,178 @@ +// ВНИМАНИЕ: НЕ ИЗМЕНЯТЬ НИЧЕГО В CRUD БЕЗ ПРЯМОГО РАЗРЕШЕНИЯ ОПЕРАТОРА. +package resources_core + +import ( + "context" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" +) + +// CreateResource uses the universal client flow for create/resume/adopt. +func CreateResource(ctx context.Context, client *core.UniversalClient, serviceID int, displayName string, resumeIfExists bool, params map[int]string) (string, error) { + return CreateResourceWithTimeout(ctx, client, serviceID, displayName, resumeIfExists, params, "") +} + +// CreateResourceWithTimeout uses optional operation timeout override for all create/resume waits. +func CreateResourceWithTimeout(ctx context.Context, client *core.UniversalClient, serviceID int, displayName string, resumeIfExists bool, params map[int]string, operationTimeout string) (string, error) { + ctxWithTimeout, err := core.WithOperationTimeout(ctx, operationTimeout) + if err != nil { + return "", err + } + + existing, err := client.FindInstanceByDisplayName(ctxWithTimeout, serviceID, displayName) + if err != nil { + return "", err + } + if existing != nil { + return adoptExistingInstanceOnCreate(ctxWithTimeout, client, serviceID, displayName, resumeIfExists, params, existing) + } + + return client.CreateGenericInstanceUniversalV6(ctxWithTimeout, serviceID, displayName, params) +} + +// UpdateResource uses universal modify flow with defaults. +func UpdateResource(ctx context.Context, client *core.UniversalClient, instanceID string, params map[int]string) error { + return UpdateResourceWithTimeout(ctx, client, instanceID, params, "") +} + +// UpdateResourceWithTimeout uses optional operation timeout override for modify waits. +func UpdateResourceWithTimeout(ctx context.Context, client *core.UniversalClient, instanceID string, params map[int]string, operationTimeout string) error { + if strings.TrimSpace(instanceID) == "" { + return fmt.Errorf("missing instance id for modify") + } + ctxWithTimeout, err := core.WithOperationTimeout(ctx, operationTimeout) + if err != nil { + return err + } + return client.RunInstanceOperationUniversalWithDefaults(ctxWithTimeout, instanceID, "modify", params) +} + +// DeleteResource runs destroy behavior (suspend/state-only) for instance resources. +func DeleteResource(ctx context.Context, client *core.UniversalClient, instanceID string, destroyBehavior string) error { + return DeleteResourceWithTimeout(ctx, client, instanceID, destroyBehavior, "") +} + +// DeleteResourceWithTimeout uses optional operation timeout override for suspend waits. +func DeleteResourceWithTimeout(ctx context.Context, client *core.UniversalClient, instanceID string, destroyBehavior string, operationTimeout string) error { + mode := strings.ToLower(strings.TrimSpace(destroyBehavior)) + if mode == "" { + mode = "state_only" + } + + ctxWithTimeout, err := core.WithOperationTimeout(ctx, operationTimeout) + if err != nil { + return err + } + + switch mode { + case "state_only", "detach": + return nil + case "suspend": + return client.RunInstanceOperationUniversal(ctxWithTimeout, instanceID, mode, nil) + case "delete": + return client.RunInstanceOperationUniversal(ctxWithTimeout, instanceID, mode, nil) + default: + return fmt.Errorf("invalid destroy behavior: %s", destroyBehavior) + } +} + +func isStatusSuspended(status string) bool { + return strings.Contains(status, "suspend") || strings.Contains(status, "suspended") +} + +func isStatusNonAdoptable(status string) bool { + if status == "" { + return true + } + if strings.Contains(status, "not created") { + return true + } + if strings.Contains(status, "pending") || strings.Contains(status, "creating") { + return true + } + if strings.Contains(status, "failed") || strings.Contains(status, "error") { + return true + } + return false +} + +// RunOperationByCode triggers a non-CRUD operation using params keyed by code. +func RunOperationByCode(ctx context.Context, client *core.UniversalClient, instanceID string, operation string, params map[string]string) error { + return RunOperationByCodeWithTimeout(ctx, client, instanceID, operation, params, "") +} + +// RunOperationByCodeWithTimeout triggers a non-CRUD operation using params keyed by code +// and optional per-request timeout override. +func RunOperationByCodeWithTimeout(ctx context.Context, client *core.UniversalClient, instanceID string, operation string, params map[string]string, operationTimeout string) error { + if strings.TrimSpace(instanceID) == "" { + return fmt.Errorf("missing instance id for operation") + } + if client == nil { + return fmt.Errorf("missing client for operation") + } + ctxWithTimeout, err := core.WithOperationTimeout(ctx, operationTimeout) + if err != nil { + return err + } + return client.RunInstanceOperationUniversalByCode(ctxWithTimeout, instanceID, operation, params) +} + +func adoptExistingInstanceOnCreate(ctx context.Context, client *core.UniversalClient, serviceID int, displayName string, resumeIfExists bool, params map[int]string, existing *core.InstanceStateResponse) (string, error) { + // Проверка: операция уже выполняется + if existing.OperationIsInProgress || existing.OperationIsPending { + return "", fmt.Errorf("инстанс с resource_name %s: операция в процессе (operation_in_progress=%v, operation_pending=%v). Дождитесь завершения текущей операции и повторите apply. %s", displayName, existing.OperationIsInProgress, existing.OperationIsPending, formatInstanceDetails(existing, serviceID, displayName)) + } + + if !resumeIfExists { + return "", fmt.Errorf("инстанс с resource_name %s уже существует; выберите другое имя, если нужен новый ресурс, или выполните импорт через усыновление: установите adopt_existing_on_create=true и повторите apply. %s", displayName, formatInstanceDetails(existing, serviceID, displayName)) + } + status := normalizeStatus(existing.ExplainedStatus) + if isStatusNotCreated(status) { + return "", fmt.Errorf("инстанс с resource_name %s найден в состоянии Not Created; удалите его в Личном кабинете (Управление облаком) и повторите apply. %s", displayName, formatInstanceDetails(existing, serviceID, displayName)) + } + if isStatusRunning(status) { + // Валидация ref-параметров для running инстанса при adopt + refIssues, err := ValidateRefParamsOnAdopt(ctx, client, serviceID, params, existing.InstanceUid) + if err != nil { + return "", fmt.Errorf("ошибка валидации ref-параметров при adopt: %s", err) + } + if HasHardErrors(refIssues) { + return "", fmt.Errorf("%s\n%s", FormatRefParamIssues(refIssues), formatInstanceDetails(existing, serviceID, displayName)) + } + return existing.InstanceUid, nil + } + if !isStatusSuspended(status) { + return "", fmt.Errorf("инстанс с resource_name %s найден, но статус не подходит для авто-усыновления: %s. %s", displayName, status, formatInstanceDetails(existing, serviceID, displayName)) + } + mismatches, err := RequiredParamsMismatch(ctx, client, serviceID, params, existing.InstanceUid) + if err != nil { + return "", err + } + if len(mismatches) > 0 { + return "", fmt.Errorf("required params mismatch for resource_name %s: %s. %s", displayName, FormatRequiredParamMismatchMessage(mismatches), formatInstanceDetails(existing, serviceID, displayName)) + } + if isStatusSuspended(status) { + if err := client.RunInstanceOperationUniversal(ctx, existing.InstanceUid, "resume", nil); err != nil { + return "", err + } + resumed, err := client.GetInstanceState(ctx, existing.InstanceUid) + if err != nil { + return "", err + } + resumedStatus := normalizeStatus(resumed.ExplainedStatus) + if isStatusNonAdoptable(resumedStatus) || isStatusSuspended(resumedStatus) { + return "", fmt.Errorf("resource not ready after resume: %s. %s", resumed.ExplainedStatus, formatInstanceDetails(resumed, serviceID, displayName)) + } + } + // Валидация ref-параметров после resume/adopt + refIssues, err := ValidateRefParamsOnAdopt(ctx, client, serviceID, params, existing.InstanceUid) + if err != nil { + return "", fmt.Errorf("ошибка валидации ref-параметров после resume: %s", err) + } + if HasHardErrors(refIssues) { + return "", fmt.Errorf("%s\n%s", FormatRefParamIssues(refIssues), formatInstanceDetails(existing, serviceID, displayName)) + } + return existing.InstanceUid, nil +} diff --git a/universal_rebuild/internal/resources_core/crud_test.go b/universal_rebuild/internal/resources_core/crud_test.go new file mode 100644 index 0000000..7f8c58d --- /dev/null +++ b/universal_rebuild/internal/resources_core/crud_test.go @@ -0,0 +1,56 @@ +package resources_core + +import ( + "context" + "testing" +) + +func TestIsStatusSuspended(t *testing.T) { + cases := []struct { + status string + expected bool + }{ + {"suspended", true}, + {"suspend", true}, + {"running", false}, + {"deleted", false}, + {"", false}, + } + for _, c := range cases { + result := isStatusSuspended(c.status) + if result != c.expected { + t.Errorf("isStatusSuspended(%q): expected %v, got %v", c.status, c.expected, result) + } + } +} + +func TestIsStatusNonAdoptable(t *testing.T) { + cases := []struct { + status string + expected bool + }{ + {"", true}, + {"not created", true}, + {"pending", true}, + {"creating", true}, + {"failed", true}, + {"error", true}, + {"running", false}, + {"suspended", false}, + } + for _, c := range cases { + result := isStatusNonAdoptable(c.status) + if result != c.expected { + t.Errorf("isStatusNonAdoptable(%q): expected %v, got %v", c.status, c.expected, result) + } + } +} + +func TestDeleteBehaviorDefault(t *testing.T) { + // Empty destroyBehavior defaults to "state_only" which returns nil immediately + // without making any API calls, so nil client is safe here. + err := DeleteResource(context.Background(), nil, "test-uid", "") + if err != nil { + t.Errorf("expected nil error for default (state_only) destroy behavior, got: %v", err) + } +} diff --git a/universal_rebuild/internal/resources_core/domain_collision.go b/universal_rebuild/internal/resources_core/domain_collision.go new file mode 100644 index 0000000..1303799 --- /dev/null +++ b/universal_rebuild/internal/resources_core/domain_collision.go @@ -0,0 +1,188 @@ +package resources_core + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "terraform-provider-nubes/internal/core" +) + +type domainOwnerInstance struct { + InstanceUID string + DisplayName string + ServiceID int +} + +type domainOwnersSnapshot struct { + ownersByDomain map[string]domainOwnerInstance + + expiresAt time.Time +} + +var domainOwnersCache = struct { + mu sync.Mutex + items map[string]domainOwnersSnapshot +}{ + items: make(map[string]domainOwnersSnapshot), +} + +func findDomainOwnerInstance(ctx context.Context, client *core.UniversalClient, domain string, allowedServiceIDs []int) (*domainOwnerInstance, error) { + targetDomain := strings.TrimSpace(domain) + if client == nil || targetDomain == "" { + return nil, nil + } + allowedSet := normalizeServiceIDSet(allowedServiceIDs) + cacheKey := buildDomainCacheKey(client, allowedSet) + + owners, err := getOrBuildDomainOwnersSnapshot(ctx, client, cacheKey, allowedSet) + if err != nil { + return nil, err + } + + owner, ok := owners[strings.ToLower(targetDomain)] + if !ok { + return nil, nil + } + ownerCopy := owner + return &ownerCopy, nil +} + +func getOrBuildDomainOwnersSnapshot(ctx context.Context, client *core.UniversalClient, cacheKey string, allowedSet map[int]struct{}) (map[string]domainOwnerInstance, error) { + now := time.Now() + + domainOwnersCache.mu.Lock() + if cached, ok := domainOwnersCache.items[cacheKey]; ok && now.Before(cached.expiresAt) { + ownersCopy := make(map[string]domainOwnerInstance, len(cached.ownersByDomain)) + for domainKey, owner := range cached.ownersByDomain { + ownersCopy[domainKey] = owner + } + domainOwnersCache.mu.Unlock() + return ownersCopy, nil + } + domainOwnersCache.mu.Unlock() + + built, err := collectDomainOwners(ctx, client, allowedSet) + if err != nil { + return nil, err + } + + domainOwnersCache.mu.Lock() + domainOwnersCache.items[cacheKey] = domainOwnersSnapshot{ + ownersByDomain: built, + expiresAt: now.Add(30 * time.Second), + } + domainOwnersCache.mu.Unlock() + + ownersCopy := make(map[string]domainOwnerInstance, len(built)) + for domainKey, owner := range built { + ownersCopy[domainKey] = owner + } + return ownersCopy, nil +} + +func collectDomainOwners(ctx context.Context, client *core.UniversalClient, allowedSet map[int]struct{}) (map[string]domainOwnerInstance, error) { + owners := map[string]domainOwnerInstance{} + + page := 1 + for { + url := fmt.Sprintf("%s/instances?page=%d&size=100", client.ApiEndpoint, page) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if client.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+client.ApiToken) + } + + resp, err := client.HttpClient.Do(req) + if err != nil { + return nil, err + } + + var parsed struct { + Results []struct { + InstanceUID string `json:"instanceUid"` + DisplayName string `json:"displayName"` + ServiceID int `json:"serviceId"` + } `json:"results"` + } + + decodeErr := json.NewDecoder(resp.Body).Decode(&parsed) + _ = resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP статус %d", resp.StatusCode) + } + if decodeErr != nil { + return nil, decodeErr + } + if len(parsed.Results) == 0 { + break + } + + for _, item := range parsed.Results { + if len(allowedSet) > 0 { + if _, ok := allowedSet[item.ServiceID]; !ok { + continue + } + } + + params, err := client.GetInstanceStateParams(ctx, item.InstanceUID) + if err != nil { + continue + } + for key, value := range params { + if !strings.EqualFold(strings.TrimSpace(key), "domain") { + continue + } + normalizedDomain := strings.ToLower(strings.TrimSpace(value)) + if normalizedDomain == "" { + continue + } + if _, exists := owners[normalizedDomain]; !exists { + owners[normalizedDomain] = domainOwnerInstance{ + InstanceUID: item.InstanceUID, + DisplayName: item.DisplayName, + ServiceID: item.ServiceID, + } + } + } + } + + page++ + if page > 100 { + break + } + } + + return owners, nil +} + +func normalizeServiceIDSet(serviceIDs []int) map[int]struct{} { + set := make(map[int]struct{}) + for _, serviceID := range serviceIDs { + if serviceID <= 0 { + continue + } + set[serviceID] = struct{}{} + } + return set +} + +func buildDomainCacheKey(client *core.UniversalClient, allowedSet map[int]struct{}) string { + ids := make([]int, 0, len(allowedSet)) + for serviceID := range allowedSet { + ids = append(ids, serviceID) + } + sort.Ints(ids) + parts := make([]string, 0, len(ids)) + for _, serviceID := range ids { + parts = append(parts, fmt.Sprintf("%d", serviceID)) + } + return client.ApiEndpoint + "|" + strings.Join(parts, ",") +} diff --git a/universal_rebuild/internal/resources_core/helpers.go b/universal_rebuild/internal/resources_core/helpers.go new file mode 100644 index 0000000..2c3a0d4 --- /dev/null +++ b/universal_rebuild/internal/resources_core/helpers.go @@ -0,0 +1,80 @@ +package resources_core + +import ( + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func FormatBool(v types.Bool) string { + if v.IsNull() || v.IsUnknown() { + return "" + } + if v.ValueBool() { + return "true" + } + return "false" +} + +func FormatInt64(v types.Int64) string { + if v.IsNull() || v.IsUnknown() { + return "" + } + return fmt.Sprintf("%d", v.ValueInt64()) +} + +func FormatString(v types.String) string { + if v.IsNull() || v.IsUnknown() { + return "" + } + return v.ValueString() +} + +// ParseBool converts string params from state_params into typed values. +func ParseBool(value string) types.Bool { + if strings.TrimSpace(value) == "" { + return types.BoolNull() + } + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return types.BoolNull() + } + return types.BoolValue(parsed) +} + +// ParseInt64 converts string params from state_params into typed values. +func ParseInt64(value string) types.Int64 { + if strings.TrimSpace(value) == "" { + return types.Int64Null() + } + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return types.Int64Null() + } + return types.Int64Value(parsed) +} + +// ParseString converts string params from state_params into typed values. +func ParseString(value string) types.String { + if strings.TrimSpace(value) == "" { + return types.StringNull() + } + return types.StringValue(value) +} + +// CompactParams drops empty values to avoid sending empty strings for optional params. +func CompactParams(params map[string]string) map[string]string { + if len(params) == 0 { + return params + } + out := make(map[string]string, len(params)) + for k, v := range params { + if strings.TrimSpace(v) == "" { + continue + } + out[k] = v + } + return out +} diff --git a/universal_rebuild/internal/resources_core/json_normalize.go b/universal_rebuild/internal/resources_core/json_normalize.go new file mode 100644 index 0000000..4dc7c23 --- /dev/null +++ b/universal_rebuild/internal/resources_core/json_normalize.go @@ -0,0 +1,121 @@ +package resources_core + +import ( + "bytes" + "encoding/json" + "sort" + "strconv" + "strings" +) + +// JSONStringsEquivalent compares JSON strings ignoring whitespace and map key order. +func JSONStringsEquivalent(a, b string) bool { + na, oka := normalizeJSONIfPossible(a) + if !oka { + return false + } + nb, okb := normalizeJSONIfPossible(b) + if !okb { + return false + } + return na == nb +} + +func normalizeJSONIfPossible(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", false + } + var v interface{} + if err := json.Unmarshal([]byte(trimmed), &v); err != nil { + return "", false + } + return encodeCanonicalJSON(normalizeJSONScalarsToStrings(v)), true +} + +func encodeCanonicalJSON(v interface{}) string { + var buf bytes.Buffer + writeCanonicalJSON(&buf, v) + return buf.String() +} + +func writeCanonicalJSON(buf *bytes.Buffer, v interface{}) { + switch val := v.(type) { + case map[string]interface{}: + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteString(strconv.Quote(k)) + buf.WriteByte(':') + writeCanonicalJSON(buf, val[k]) + } + buf.WriteByte('}') + case []interface{}: + buf.WriteByte('[') + for i, item := range val { + if i > 0 { + buf.WriteByte(',') + } + writeCanonicalJSON(buf, item) + } + buf.WriteByte(']') + case string: + buf.WriteString(strconv.Quote(val)) + case float64: + buf.WriteString(strconv.FormatFloat(val, 'g', -1, 64)) + case bool: + if val { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + default: + if encoded, err := json.Marshal(val); err == nil { + buf.Write(encoded) + } else { + buf.WriteString("null") + } + } +} + +func normalizeJSONScalarsToStrings(v interface{}) interface{} { + switch val := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(val)) + for k, child := range val { + out[k] = normalizeJSONScalarsToStrings(child) + } + return out + case []interface{}: + out := make([]interface{}, len(val)) + for i, child := range val { + out[i] = normalizeJSONScalarsToStrings(child) + } + return out + case string: + return val + case float64: + return strconv.FormatFloat(val, 'g', -1, 64) + case bool: + if val { + return "true" + } + return "false" + case nil: + return "null" + default: + if encoded, err := json.Marshal(val); err == nil { + return strings.TrimSpace(string(encoded)) + } + return "null" + } +} diff --git a/universal_rebuild/internal/resources_core/json_planmodifier.go b/universal_rebuild/internal/resources_core/json_planmodifier.go new file mode 100644 index 0000000..3a49f47 --- /dev/null +++ b/universal_rebuild/internal/resources_core/json_planmodifier.go @@ -0,0 +1,53 @@ +package resources_core + +// json_planmodifier.go — план-модификатор для нормализации JSON-атрибутов. +// +// Проблема: API всегда возвращает компактный JSON, но пользователь может написать +// json_parameters = "{ \"log_connections\": \"off\" }" с пробелами. +// Terraform сравнивает план (с пробелами) с результатом Apply (компактный) и видит расхождение → +// "Provider produced inconsistent result after apply". +// +// Решение: plan modifier JsonNormalize() сворачивает JSON до компактного вида ещё на этапе плана, +// чтобы план и результат Read всегда совпадали. + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// jsonNormalizePlanModifier реализует planmodifier.String. +// Сворачивает JSON-строку до компактного представления при планировании. +type jsonNormalizePlanModifier struct{} + +// JsonNormalize возвращает plan modifier, нормализующий JSON к компактному виду. +// Использовать на атрибутах с data_type: json. +func JsonNormalize() planmodifier.String { + return jsonNormalizePlanModifier{} +} + +func (m jsonNormalizePlanModifier) Description(_ context.Context) string { + return "Normalizes JSON string to compact form to prevent inconsistent-result errors." +} + +func (m jsonNormalizePlanModifier) MarkdownDescription(_ context.Context) string { + return "Normalizes JSON string to compact form to prevent inconsistent-result errors." +} + +// PlanModifyString сворачивает JSON до компактного вида. +// Если значение не является корректным JSON — оставляет как есть, не добавляет ошибку. +func (m jsonNormalizePlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() { + return + } + raw := req.PlanValue.ValueString() + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(raw)); err != nil { + // Невалидный JSON — оставляем значение без изменений, не прерываем планирование. + return + } + resp.PlanValue = types.StringValue(buf.String()) +} diff --git a/universal_rebuild/internal/resources_core/operation_ids.go b/universal_rebuild/internal/resources_core/operation_ids.go new file mode 100644 index 0000000..c1c3384 --- /dev/null +++ b/universal_rebuild/internal/resources_core/operation_ids.go @@ -0,0 +1,36 @@ +package resources_core + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" +) + +// BuildActionID builds a stable ID for action resources. +func BuildActionID(instanceUID string, operation string, runID string) string { + return strings.TrimSpace(instanceUID) + ":" + strings.TrimSpace(operation) + ":" + strings.TrimSpace(runID) +} + +// BuildSubresourceID builds a stable ID for subresource resources based on params. +func BuildSubresourceID(instanceUID string, subresource string, params map[string]string) string { + payload := canonicalParamsString(params) + h := sha256.Sum256([]byte(payload)) + return strings.TrimSpace(instanceUID) + ":" + strings.TrimSpace(subresource) + ":" + hex.EncodeToString(h[:]) +} + +func canonicalParamsString(params map[string]string) string { + if len(params) == 0 { + return "" + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, strings.ToLower(strings.TrimSpace(k))) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+strings.TrimSpace(params[k])) + } + return strings.Join(parts, ";") +} diff --git a/universal_rebuild/internal/resources_core/outputs.go b/universal_rebuild/internal/resources_core/outputs.go new file mode 100644 index 0000000..9871834 --- /dev/null +++ b/universal_rebuild/internal/resources_core/outputs.go @@ -0,0 +1,201 @@ +package resources_core + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type InstanceOutputs struct { + StateParams types.Map + StateOut types.Map + StateParamsFlat types.Map + StateOutFlat types.Map + VaultSecrets types.Map + VaultUrl types.String + VaultUserPath types.String + VaultFields types.List +} + +// FetchInstanceOutputs loads instance state params/out and vault secrets into TF types. +func FetchInstanceOutputs(ctx context.Context, client *core.UniversalClient, instanceID string) (InstanceOutputs, diag.Diagnostics) { + var diags diag.Diagnostics + outputs := emptyInstanceOutputs() + if client == nil || strings.TrimSpace(instanceID) == "" { + return outputs, diags + } + + details, err := client.GetInstanceStateDetails(ctx, instanceID) + if err != nil { + return outputs, diags + } + if details == nil { + return outputs, diags + } + + params := normalizeStringMap(details.Params) + out := normalizeStringMap(details.Out) + paramsFlat := flattenStringMap(details.RawParams) + outFlat := flattenStringMap(details.RawOut) + fields := normalizeStringSlice(details.Vault.Fields) + + outputs.StateParams, diags = mapValueFrom(ctx, params, diags) + outputs.StateOut, diags = mapValueFrom(ctx, out, diags) + outputs.StateParamsFlat, diags = mapValueFrom(ctx, paramsFlat, diags) + outputs.StateOutFlat, diags = mapValueFrom(ctx, outFlat, diags) + + secrets, err := client.GetInstanceVaultSecrets(ctx, instanceID, fields) + if err != nil { + secrets = nil + } + outputs.VaultSecrets, diags = mapValueFrom(ctx, normalizeStringMap(secrets), diags) + outputs.VaultFields, diags = listValueFrom(ctx, fields, diags) + outputs.VaultUrl = stringValue(details.Vault.Url) + outputs.VaultUserPath = stringValue(details.Vault.UserPath) + + return outputs, diags +} + +func emptyInstanceOutputs() InstanceOutputs { + return InstanceOutputs{ + StateParams: types.MapNull(types.StringType), + StateOut: types.MapNull(types.StringType), + StateParamsFlat: types.MapNull(types.StringType), + StateOutFlat: types.MapNull(types.StringType), + VaultSecrets: types.MapNull(types.StringType), + VaultUrl: types.StringNull(), + VaultUserPath: types.StringNull(), + VaultFields: types.ListNull(types.StringType), + } +} + +func flattenStringMap(value map[string]interface{}) map[string]string { + if value == nil { + return map[string]string{} + } + flat := make(map[string]string) + for key, val := range value { + flattenValue(flat, key, val) + } + return flat +} + +func flattenValue(out map[string]string, prefix string, value interface{}) { + switch v := value.(type) { + case map[string]interface{}: + for key, child := range v { + childKey := prefix + "." + key + flattenValue(out, childKey, child) + } + case []interface{}: + for idx, child := range v { + childKey := fmt.Sprintf("%s[%d]", prefix, idx) + flattenValue(out, childKey, child) + } + default: + out[prefix] = normalizeFlatValue(value) + } +} + +func normalizeFlatValue(value interface{}) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case bool: + if v { + return "true" + } + return "false" + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%v", v) + case json.Number: + return v.String() + default: + b, err := json.Marshal(v) + if err == nil { + return strings.TrimSpace(string(b)) + } + return fmt.Sprintf("%v", v) + } +} + +func normalizeStringMap(value map[string]string) map[string]string { + if value == nil { + return map[string]string{} + } + return value +} + +func normalizeStringSlice(value []string) []string { + if value == nil { + return []string{} + } + return value +} + +func stringValue(value string) types.String { + if strings.TrimSpace(value) == "" { + return types.StringNull() + } + return types.StringValue(value) +} + +func mapValueFrom(ctx context.Context, value map[string]string, diags diag.Diagnostics) (types.Map, diag.Diagnostics) { + result, d := types.MapValueFrom(ctx, types.StringType, value) + diags.Append(d...) + return result, diags +} + +func listValueFrom(ctx context.Context, value []string, diags diag.Diagnostics) (types.List, diag.Diagnostics) { + result, d := types.ListValueFrom(ctx, types.StringType, value) + diags.Append(d...) + return result, diags +} + +// StringMapFromTypesMap extracts a map[string]string from a TF map for state_params sync. +func StringMapFromTypesMap(ctx context.Context, value types.Map) (map[string]string, diag.Diagnostics) { + var diags diag.Diagnostics + if value.IsNull() || value.IsUnknown() { + return map[string]string{}, diags + } + + var result map[string]string + if d := value.ElementsAs(ctx, &result, false); d.HasError() { + diags.Append(d...) + return map[string]string{}, diags + } + if result == nil { + return map[string]string{}, diags + } + return result, diags +} + +func FixInternalConnectMasterSuffix(outFlat map[string]string, resourceRealm string) bool { + if len(outFlat) == 0 || strings.TrimSpace(resourceRealm) == "" { + return false + } + value := strings.TrimSpace(outFlat["internalConnect.master"]) + if value == "" { + return false + } + if !strings.HasSuffix(value, ".svc.") { + return false + } + fixed := value + strings.TrimPrefix(resourceRealm, ".") + if fixed == value { + return false + } + outFlat["internalConnect.master"] = fixed + return true +} diff --git a/universal_rebuild/internal/resources_core/params_compare.go b/universal_rebuild/internal/resources_core/params_compare.go new file mode 100644 index 0000000..6e65fb9 --- /dev/null +++ b/universal_rebuild/internal/resources_core/params_compare.go @@ -0,0 +1,100 @@ +package resources_core + +import ( + "context" + "regexp" + "strings" + + "terraform-provider-nubes/internal/core" +) + +// uuidLikeCompareRegex matches UUID-like strings (any case) so they can be +// normalised to lowercase before comparison (see normalizeCompareValue). +// +// Зачем: API облака принимает UUID в любом регистре, но сам всегда возвращает +// lowercase. Пользователь может написать "6214BA32-...", API вернёт "6214ba32-...". +// Чтобы ParamsMatchForResume (сравнение desired vs actual) работал корректно при +// suspend/resume, нормализуем UUID к lowercase с обеих сторон перед сравнением. +var uuidLikeCompareRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// ParamsMatchForResume compares desired create params to instance.state.params. +// It returns true only when all non-empty desired params match actual values. +func ParamsMatchForResume(ctx context.Context, client *core.UniversalClient, serviceID int, desiredParamsByID map[int]string, instanceUID string) (bool, error) { + if client == nil { + return false, nil + } + codeMap, err := getServiceParamCodeMap(serviceID) + if err != nil { + return false, err + } + if len(codeMap) == 0 { + return false, nil + } + refMap, err := getServiceParamRefMap(serviceID) + if err != nil { + return false, err + } + + desiredByCode := make(map[string]string, len(desiredParamsByID)) + for id, val := range desiredParamsByID { + code, ok := codeMap[id] + if !ok { + return false, nil + } + if refSvcId, ok := refMap[id]; ok && refSvcId > 0 { + resolved, err := client.ResolveRefSvcParamValue(ctx, refSvcId, val) + if err != nil { + return false, err + } + if strings.TrimSpace(resolved) != "" { + val = resolved + } + } + norm := normalizeCompareValue(val) + if norm == "" { + continue + } + desiredByCode[code] = norm + } + + if len(desiredByCode) == 0 { + return true, nil + } + + actualParams, err := client.GetInstanceStateParams(ctx, instanceUID) + if err != nil { + return false, err + } + + for code, desired := range desiredByCode { + actual := normalizeCompareValue(actualParams[code]) + if desired != actual { + return false, nil + } + } + + return true, nil +} + +func normalizeCompareValue(value string) string { + trimmed := strings.TrimSpace(value) + if strings.EqualFold(trimmed, "null") { + return "" + } + if trimmed == "\"\"" { + return "" + } + if strings.EqualFold(trimmed, "true") { + return "true" + } + if strings.EqualFold(trimmed, "false") { + return "false" + } + // FIX(uuid-case): нормализуем UUID к lowercase для сравнения c actual-значением + // из API. Без этого "6214BA32-..." (из config) != "6214ba32-..." (из API state), + // и resume считал бы параметры изменёнными, создавая новый инстанс вместо resume. + if uuidLikeCompareRegex.MatchString(trimmed) { + return strings.ToLower(trimmed) + } + return trimmed +} diff --git a/universal_rebuild/internal/resources_core/params_mapping.go b/universal_rebuild/internal/resources_core/params_mapping.go new file mode 100644 index 0000000..cac0c86 --- /dev/null +++ b/universal_rebuild/internal/resources_core/params_mapping.go @@ -0,0 +1,91 @@ +package resources_core + +import ( + "io/fs" + "sync" + + resourcesyaml "terraform-provider-nubes/resources_yaml" + + "gopkg.in/yaml.v3" +) + +type yamlParam struct { + ID int `yaml:"id"` + Code string `yaml:"code"` +} + +type yamlService struct { + ServiceID int `yaml:"service_id"` + Operations []struct { + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Params []yamlParam `yaml:"params"` + } `yaml:"operations"` +} + +var ( + serviceParamMap map[int]map[int]string + serviceParamMapOnce sync.Once + serviceParamMapErr error +) + +func getServiceParamCodeMap(serviceID int) (map[int]string, error) { + serviceParamMapOnce.Do(loadServiceParamMaps) + if serviceParamMapErr != nil { + return nil, serviceParamMapErr + } + if serviceParamMap == nil { + return nil, nil + } + return serviceParamMap[serviceID], nil +} + +func loadServiceParamMaps() { + serviceParamMap = make(map[int]map[int]string) + + entries, err := fs.ReadDir(resourcesyaml.Files, ".") + if err != nil { + serviceParamMapErr = err + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if len(name) == 0 || name[len(name)-5:] != ".yaml" { + continue + } + + b, err := resourcesyaml.Files.ReadFile(name) + if err != nil { + serviceParamMapErr = err + return + } + + var svc yamlService + if err := yaml.Unmarshal(b, &svc); err != nil { + serviceParamMapErr = err + return + } + if svc.ServiceID == 0 { + continue + } + + idToCode := make(map[int]string) + for _, op := range svc.Operations { + if op.Kind != "instance" || op.Action != "create" { + continue + } + for _, p := range op.Params { + if p.ID == 0 || p.Code == "" { + continue + } + idToCode[p.ID] = p.Code + } + break + } + serviceParamMap[svc.ServiceID] = idToCode + } +} diff --git a/universal_rebuild/internal/resources_core/params_ref_mapping.go b/universal_rebuild/internal/resources_core/params_ref_mapping.go new file mode 100644 index 0000000..cbf8d52 --- /dev/null +++ b/universal_rebuild/internal/resources_core/params_ref_mapping.go @@ -0,0 +1,152 @@ +package resources_core + +import ( + "context" + "io/fs" + "strings" + "sync" + + "terraform-provider-nubes/internal/core" + resourcesyaml "terraform-provider-nubes/resources_yaml" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "gopkg.in/yaml.v3" +) + +type yamlParamRef struct { + ID int `yaml:"id"` + RefSvcId int `yaml:"ref_svc_id"` +} + +type yamlServiceRef struct { + ServiceID int `yaml:"service_id"` + Operations []struct { + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Params []yamlParamRef `yaml:"params"` + } `yaml:"operations"` +} + +var ( + serviceParamRefMap map[int]map[int]int + serviceParamRefMapOnce sync.Once + serviceParamRefMapErr error +) + +func getServiceParamRefMap(serviceID int) (map[int]int, error) { + serviceParamRefMapOnce.Do(loadServiceParamRefMaps) + if serviceParamRefMapErr != nil { + return nil, serviceParamRefMapErr + } + if serviceParamRefMap == nil { + return nil, nil + } + return serviceParamRefMap[serviceID], nil +} + +func loadServiceParamRefMaps() { + serviceParamRefMap = make(map[int]map[int]int) + + entries, err := fs.ReadDir(resourcesyaml.Files, ".") + if err != nil { + serviceParamRefMapErr = err + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if len(name) == 0 || name[len(name)-5:] != ".yaml" { + continue + } + + b, err := resourcesyaml.Files.ReadFile(name) + if err != nil { + serviceParamRefMapErr = err + return + } + + var svc yamlServiceRef + if err := yaml.Unmarshal(b, &svc); err != nil { + serviceParamRefMapErr = err + return + } + if svc.ServiceID == 0 { + continue + } + + idToRef := make(map[int]int) + for _, op := range svc.Operations { + if op.Kind != "instance" || op.Action != "create" { + continue + } + for _, p := range op.Params { + if p.ID == 0 || p.RefSvcId == 0 { + continue + } + idToRef[p.ID] = p.RefSvcId + } + break + } + serviceParamRefMap[svc.ServiceID] = idToRef + } +} + +func getServiceParamRefCodeMap(serviceID int) (map[string]int, error) { + codeMap, err := getServiceParamCodeMap(serviceID) + if err != nil { + return nil, err + } + refMap, err := getServiceParamRefMap(serviceID) + if err != nil { + return nil, err + } + if len(codeMap) == 0 || len(refMap) == 0 { + return nil, nil + } + + codeToRef := make(map[string]int) + for id, refSvcId := range refMap { + code, ok := codeMap[id] + if !ok || strings.TrimSpace(code) == "" { + continue + } + if refSvcId <= 0 { + continue + } + codeToRef[code] = refSvcId + } + return codeToRef, nil +} + +// ResolveRefSvcParamDisplayNames maps UUIDs in state_params back to display_name values. +func ResolveRefSvcParamDisplayNames(ctx context.Context, client *core.UniversalClient, serviceID int, params map[string]string) (map[string]string, diag.Diagnostics) { + var diags diag.Diagnostics + if client == nil || len(params) == 0 { + return params, diags + } + + refCodeMap, err := getServiceParamRefCodeMap(serviceID) + if err != nil { + return params, diags + } + if len(refCodeMap) == 0 { + return params, diags + } + + resolved := make(map[string]string, len(params)) + for code, value := range params { + newValue := value + if refSvcId, ok := refCodeMap[code]; ok && refSvcId > 0 { + mapped, err := client.ResolveRefSvcParamDisplayName(ctx, refSvcId, value) + if err == nil && strings.TrimSpace(mapped) != "" { + newValue = mapped + } + } + resolved[code] = newValue + } + + return resolved, diags +} diff --git a/universal_rebuild/internal/resources_core/params_validation_mapping.go b/universal_rebuild/internal/resources_core/params_validation_mapping.go new file mode 100644 index 0000000..aa85f3b --- /dev/null +++ b/universal_rebuild/internal/resources_core/params_validation_mapping.go @@ -0,0 +1,109 @@ +package resources_core + +import ( + "io/fs" + "sync" + + resourcesyaml "terraform-provider-nubes/resources_yaml" + + "gopkg.in/yaml.v3" +) + +type yamlParamValidation struct { + ID int `yaml:"id"` + Code string `yaml:"code"` + RefSvcId int `yaml:"ref_svc_id"` + Func string `yaml:"func"` + ValueList []string `yaml:"value_list"` +} + +type yamlServiceValidation struct { + ServiceID int `yaml:"service_id"` + Operations []struct { + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Params []yamlParamValidation `yaml:"params"` + } `yaml:"operations"` +} + +type paramValidationSpec struct { + Code string + RefSvcId int + Func string + ValueList []string +} + +var ( + serviceParamValidationMap map[int]map[int]paramValidationSpec + serviceParamValidationMapOnce sync.Once + serviceParamValidationMapErr error +) + +func getServiceParamValidationSpecMap(serviceID int) (map[int]paramValidationSpec, error) { + serviceParamValidationMapOnce.Do(loadServiceParamValidationMaps) + if serviceParamValidationMapErr != nil { + return nil, serviceParamValidationMapErr + } + if serviceParamValidationMap == nil { + return nil, nil + } + return serviceParamValidationMap[serviceID], nil +} + +func loadServiceParamValidationMaps() { + serviceParamValidationMap = make(map[int]map[int]paramValidationSpec) + + entries, err := fs.ReadDir(resourcesyaml.Files, ".") + if err != nil { + serviceParamValidationMapErr = err + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if len(name) == 0 || name[len(name)-5:] != ".yaml" { + continue + } + + b, err := resourcesyaml.Files.ReadFile(name) + if err != nil { + serviceParamValidationMapErr = err + return + } + + var svc yamlServiceValidation + if err := yaml.Unmarshal(b, &svc); err != nil { + serviceParamValidationMapErr = err + return + } + if svc.ServiceID == 0 { + continue + } + + specByID := make(map[int]paramValidationSpec) + for _, op := range svc.Operations { + if op.Kind != "instance" || op.Action != "create" { + continue + } + for _, p := range op.Params { + if p.ID == 0 { + continue + } + cp := make([]string, 0, len(p.ValueList)) + cp = append(cp, p.ValueList...) + specByID[p.ID] = paramValidationSpec{ + Code: p.Code, + RefSvcId: p.RefSvcId, + Func: p.Func, + ValueList: cp, + } + } + break + } + + serviceParamValidationMap[svc.ServiceID] = specByID + } +} diff --git a/universal_rebuild/internal/resources_core/ref_validation.go b/universal_rebuild/internal/resources_core/ref_validation.go new file mode 100644 index 0000000..a42a9e3 --- /dev/null +++ b/universal_rebuild/internal/resources_core/ref_validation.go @@ -0,0 +1,283 @@ +// ref_validation.go — Валидация ref-параметров при adopt/resume. +// Проверяет что все ссылочные параметры (vappUid и т.п.) указывают на живые инстансы +// и совпадают с плановыми значениями. +package resources_core + +import ( + "context" + "fmt" + "sort" + "strings" + + "terraform-provider-nubes/internal/core" +) + +// RefParamIssue описывает проблему с ref-параметром при adopt. +type RefParamIssue struct { + ParamCode string // код параметра, напр. "vappUid" + PlannedValue string // UUID из Terraform плана + ActualValue string // UUID из state.params adopted-инстанса + RefSvcId int // service_id referenced ресурса + RefStatus string // статус referenced инстанса ("deleted", "suspended", "running", "not_found") + IsHardError bool // true = блокирует adopt, false = warning + HumanMessage string // человекочитаемое сообщение +} + +// ValidateRefParamsOnAdopt проверяет ref-параметры adopted/resumed инстанса. +// Сравнивает плановые значения (params) с фактическими из API (state.params), +// и для каждого ref-параметра проверяет статус referenced инстанса. +// +// Возвращает список проблем. Если есть хотя бы один IsHardError — adopt невозможен. +func ValidateRefParamsOnAdopt(ctx context.Context, client *core.UniversalClient, serviceID int, plannedParams map[int]string, adoptedInstanceUID string) ([]RefParamIssue, error) { + if client == nil || serviceID <= 0 || strings.TrimSpace(adoptedInstanceUID) == "" { + return nil, nil + } + + // Получить карту ref-параметров для этого сервиса (paramID → refSvcId) + refMap, err := getServiceParamRefMap(serviceID) + if err != nil { + return nil, err + } + if len(refMap) == 0 { + return nil, nil // нет ref-параметров у этого сервиса + } + + // Получить карту paramID → code + codeMap, err := getServiceParamCodeMap(serviceID) + if err != nil { + return nil, err + } + + // Получить фактические параметры из API + actualParams, err := client.GetInstanceStateParams(ctx, adoptedInstanceUID) + if err != nil { + return nil, err + } + + var issues []RefParamIssue + + for paramID, refSvcId := range refMap { + if refSvcId <= 0 { + continue + } + code := codeMap[paramID] + if strings.TrimSpace(code) == "" { + continue + } + + // Плановое значение (resolve через ResolveRefSvcParamValue) + plannedRaw, hasPlan := plannedParams[paramID] + if !hasPlan || strings.TrimSpace(plannedRaw) == "" { + continue + } + plannedResolved, err := client.ResolveRefSvcParamValue(ctx, refSvcId, plannedRaw) + if err != nil { + return nil, err + } + if strings.TrimSpace(plannedResolved) == "" { + plannedResolved = plannedRaw + } + + // Фактическое значение из adopted инстанса + actualRaw := strings.TrimSpace(actualParams[code]) + if actualRaw == "" { + continue // параметр не задан в API, пропускаем + } + actualResolved, err := client.ResolveRefSvcParamValue(ctx, refSvcId, actualRaw) + if err != nil { + return nil, err + } + if strings.TrimSpace(actualResolved) == "" { + actualResolved = actualRaw + } + + // Сравнение: план vs факт + if strings.EqualFold(plannedResolved, actualResolved) { + // UUID совпадают — проверить только статус referenced инстанса + issue := checkRefInstanceStatus(ctx, client, code, plannedResolved, actualResolved, refSvcId) + if issue != nil { + issues = append(issues, *issue) + } + continue + } + + // UUID НЕ совпадают — проверить статус обоих + actualStatus := getRefInstanceStatus(ctx, client, actualResolved) + plannedStatus := getRefInstanceStatus(ctx, client, plannedResolved) + + issues = append(issues, RefParamIssue{ + ParamCode: code, + PlannedValue: plannedResolved, + ActualValue: actualResolved, + RefSvcId: refSvcId, + RefStatus: actualStatus, + IsHardError: true, + HumanMessage: fmt.Sprintf( + "НЕСОВПАДЕНИЕ REF-ПАРАМЕТРА '%s' ПРИ ADOPT\n"+ + "\n"+ + "Инстанс хранит ссылку %s = %s (статус: %s),\n"+ + "но конфигурация Terraform указывает на %s (статус: %s).\n"+ + "\n"+ + "Adopt невозможен — ресурс привязан к другому зависимому инстансу.\n"+ + "\n"+ + "Рекомендации:\n"+ + " 1. Удалите suspended инстанс из облака (ЛК → Управление облаком) и повторите apply.\n"+ + " 2. Или восстановите старый зависимый инстанс (%s) через ЛК, если он нужен.\n"+ + " 3. Или выполните terraform destroy и затем terraform apply для полного пересоздания.", + code, + code, actualResolved, actualStatus, + plannedResolved, plannedStatus, + actualResolved, + ), + }) + } + + return issues, nil +} + +// checkRefInstanceStatus проверяет статус referenced инстанса при совпадающих UUID. +func checkRefInstanceStatus(ctx context.Context, client *core.UniversalClient, paramCode string, plannedUID string, actualUID string, refSvcId int) *RefParamIssue { + status := getRefInstanceStatus(ctx, client, actualUID) + + switch status { + case "deleted": + return &RefParamIssue{ + ParamCode: paramCode, + PlannedValue: plannedUID, + ActualValue: actualUID, + RefSvcId: refSvcId, + RefStatus: status, + IsHardError: true, + HumanMessage: fmt.Sprintf( + "REF-ПАРАМЕТР '%s' УКАЗЫВАЕТ НА УДАЛЁННЫЙ ИНСТАНС\n"+ + "\n"+ + "Параметр %s = %s, но этот инстанс в статусе 'deleted'.\n"+ + "Adopt невозможен — зависимый ресурс удалён из облака.\n"+ + "\n"+ + "Рекомендации:\n"+ + " 1. Удалите suspended инстанс из облака и пересоздайте всё через terraform apply.\n"+ + " 2. Или восстановите зависимый инстанс через ЛК.", + paramCode, paramCode, actualUID, + ), + } + case "suspended": + return &RefParamIssue{ + ParamCode: paramCode, + PlannedValue: plannedUID, + ActualValue: actualUID, + RefSvcId: refSvcId, + RefStatus: status, + IsHardError: false, + HumanMessage: fmt.Sprintf( + "ВНИМАНИЕ: ref-параметр '%s' указывает на приостановленный инстанс\n"+ + "\n"+ + "Параметр %s = %s, статус: 'suspended'.\n"+ + "Убедитесь, что зависимый ресурс будет активирован (resume) перед использованием.", + paramCode, paramCode, actualUID, + ), + } + case "not_found": + return &RefParamIssue{ + ParamCode: paramCode, + PlannedValue: plannedUID, + ActualValue: actualUID, + RefSvcId: refSvcId, + RefStatus: status, + IsHardError: true, + HumanMessage: fmt.Sprintf( + "REF-ПАРАМЕТР '%s' УКАЗЫВАЕТ НА НЕСУЩЕСТВУЮЩИЙ ИНСТАНС\n"+ + "\n"+ + "Параметр %s = %s, но инстанс не найден в API (404).\n"+ + "Проверьте облако — инстанс мог быть полностью удалён.\n"+ + "\n"+ + "Рекомендации:\n"+ + " 1. Удалите suspended инстанс из облака и пересоздайте всё.\n"+ + " 2. Проверьте зависимости в конфигурации Terraform.", + paramCode, paramCode, actualUID, + ), + } + } + // running или другой нормальный статус — всё ОК + return nil +} + +// getRefInstanceStatus возвращает упрощённый статус инстанса: "running", "suspended", "deleted", "not_found", или raw status. +func getRefInstanceStatus(ctx context.Context, client *core.UniversalClient, instanceUID string) string { + if strings.TrimSpace(instanceUID) == "" { + return "not_found" + } + state, err := client.GetInstanceStateRaw(ctx, instanceUID) + if err != nil { + return "not_found" + } + if state == nil { + return "not_found" + } + if state.IsDeleted { + return "deleted" + } + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if status == "deleted" { + return "deleted" + } + if strings.Contains(status, "suspend") { + return "suspended" + } + if strings.Contains(status, "running") || strings.Contains(status, "active") { + return "running" + } + return status +} + +// FormatRefParamIssues форматирует список проблем для diagnostic-сообщения. +func FormatRefParamIssues(issues []RefParamIssue) string { + if len(issues) == 0 { + return "" + } + var parts []string + for _, issue := range issues { + parts = append(parts, issue.HumanMessage) + } + return strings.Join(parts, "\n\n---\n\n") +} + +// HasHardErrors возвращает true если есть хотя бы одна блокирующая проблема. +func HasHardErrors(issues []RefParamIssue) bool { + for _, issue := range issues { + if issue.IsHardError { + return true + } + } + return false +} + +// FormatDuplicateInstancesError формирует сообщение о множественных инстансах с одним именем. +func FormatDuplicateInstancesError(displayName string, serviceID int, instances []core.InstanceStateResponse) string { + if len(instances) <= 1 { + return "" + } + + var parts []string + statusCounts := map[string]int{} + for _, inst := range instances { + status := strings.ToLower(strings.TrimSpace(inst.ExplainedStatus)) + statusCounts[status]++ + parts = append(parts, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, status)) + } + + sort.Strings(parts) + + return fmt.Sprintf( + "ОБНАРУЖЕНО %d ИНСТАНСОВ С ИМЕНЕМ '%s' (serviceId=%d)\n"+ + "\n"+ + "Список:\n"+ + "%s\n"+ + "\n"+ + "Невозможно определить какой инстанс adopt-ить.\n"+ + "\n"+ + "Рекомендации:\n"+ + " 1. Удалите лишние инстансы через ЛК (Управление облаком).\n"+ + " 2. Или укажите конкретный UUID через 'terraform import'.", + len(instances), displayName, serviceID, strings.Join(parts, "\n"), + ) +} diff --git a/universal_rebuild/internal/resources_core/required_params.go b/universal_rebuild/internal/resources_core/required_params.go new file mode 100644 index 0000000..9c1e617 --- /dev/null +++ b/universal_rebuild/internal/resources_core/required_params.go @@ -0,0 +1,91 @@ +package resources_core + +import ( + "io/fs" + "sync" + + resourcesyaml "terraform-provider-nubes/resources_yaml" + + "gopkg.in/yaml.v3" +) + +type yamlRequiredParam struct { + ID int `yaml:"id"` + Required bool `yaml:"required"` +} + +type yamlRequiredService struct { + ServiceID int `yaml:"service_id"` + Operations []struct { + Kind string `yaml:"kind"` + Action string `yaml:"action"` + Params []yamlRequiredParam `yaml:"params"` + } `yaml:"operations"` +} + +var ( + serviceRequiredParamMap map[int]map[int]bool + serviceRequiredParamMapOnce sync.Once + serviceRequiredParamMapErr error +) + +func getServiceRequiredParamIDs(serviceID int) (map[int]bool, error) { + serviceRequiredParamMapOnce.Do(loadServiceRequiredParamMaps) + if serviceRequiredParamMapErr != nil { + return nil, serviceRequiredParamMapErr + } + if serviceRequiredParamMap == nil { + return nil, nil + } + return serviceRequiredParamMap[serviceID], nil +} + +func loadServiceRequiredParamMaps() { + serviceRequiredParamMap = make(map[int]map[int]bool) + + entries, err := fs.ReadDir(resourcesyaml.Files, ".") + if err != nil { + serviceRequiredParamMapErr = err + return + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if len(name) == 0 || name[len(name)-5:] != ".yaml" { + continue + } + + b, err := resourcesyaml.Files.ReadFile(name) + if err != nil { + serviceRequiredParamMapErr = err + return + } + + var svc yamlRequiredService + if err := yaml.Unmarshal(b, &svc); err != nil { + serviceRequiredParamMapErr = err + return + } + if svc.ServiceID == 0 { + continue + } + + requiredIDs := make(map[int]bool) + for _, op := range svc.Operations { + if op.Kind != "instance" || op.Action != "create" { + continue + } + for _, p := range op.Params { + if p.ID == 0 || !p.Required { + continue + } + requiredIDs[p.ID] = true + } + break + } + serviceRequiredParamMap[svc.ServiceID] = requiredIDs + } +} diff --git a/universal_rebuild/internal/resources_core/required_params_compare.go b/universal_rebuild/internal/resources_core/required_params_compare.go new file mode 100644 index 0000000..4217744 --- /dev/null +++ b/universal_rebuild/internal/resources_core/required_params_compare.go @@ -0,0 +1,122 @@ +package resources_core + +import ( + "context" + "sort" + "strings" + + "terraform-provider-nubes/internal/core" +) + +type RequiredParamDiff struct { + Desired string + Actual string +} + +func RequiredParamsMismatch(ctx context.Context, client *core.UniversalClient, serviceID int, desiredParamsByID map[int]string, instanceUID string) (map[string]RequiredParamDiff, error) { + if client == nil || serviceID <= 0 || strings.TrimSpace(instanceUID) == "" { + return nil, nil + } + if len(desiredParamsByID) == 0 { + return nil, nil + } + + requiredIDs, err := getServiceRequiredParamIDs(serviceID) + if err != nil { + return nil, err + } + if len(requiredIDs) == 0 { + return nil, nil + } + + codeMap, err := getServiceParamCodeMap(serviceID) + if err != nil { + return nil, err + } + refMap, err := getServiceParamRefMap(serviceID) + if err != nil { + return nil, err + } + + desiredByCode := make(map[string]string) + for id, val := range desiredParamsByID { + if !requiredIDs[id] { + continue + } + code := codeMap[id] + if strings.TrimSpace(code) == "" { + continue + } + if refSvcId, ok := refMap[id]; ok && refSvcId > 0 { + resolved, err := client.ResolveRefSvcParamValue(ctx, refSvcId, val) + if err != nil { + return nil, err + } + if strings.TrimSpace(resolved) != "" { + val = resolved + } + } + norm := normalizeCompareValue(val) + if norm == "" { + continue + } + desiredByCode[code] = norm + } + if len(desiredByCode) == 0 { + return nil, nil + } + + actualParams, err := client.GetInstanceStateParams(ctx, instanceUID) + if err != nil { + return nil, err + } + + mismatches := make(map[string]RequiredParamDiff) + for code, desired := range desiredByCode { + actual := normalizeCompareValue(actualParams[code]) + if paramsEquivalent(desired, actual) { + continue + } + mismatches[code] = RequiredParamDiff{Desired: desired, Actual: actual} + } + if len(mismatches) == 0 { + return nil, nil + } + return mismatches, nil +} + +func paramsEquivalent(desired string, actual string) bool { + if desired == actual { + return true + } + if JSONStringsEquivalent(desired, actual) { + return true + } + return false +} + +func FormatRequiredParamMismatchMessage(mismatches map[string]RequiredParamDiff) string { + if len(mismatches) == 0 { + return "" + } + keys := make([]string, 0, len(mismatches)) + for code := range mismatches { + keys = append(keys, code) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, code := range keys { + diff := mismatches[code] + desired := diff.Desired + actual := diff.Actual + if strings.TrimSpace(desired) == "" { + desired = "" + } + if strings.TrimSpace(actual) == "" { + actual = "" + } + parts = append(parts, code+" (plan="+desired+", actual="+actual+")") + } + return strings.Join(parts, "; ") +} diff --git a/universal_rebuild/internal/resources_core/resource_diagnostics.go b/universal_rebuild/internal/resources_core/resource_diagnostics.go new file mode 100644 index 0000000..5c0fc81 --- /dev/null +++ b/universal_rebuild/internal/resources_core/resource_diagnostics.go @@ -0,0 +1,131 @@ +package resources_core + +import ( + "context" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// PlanExistingResourceDiagnostics returns diagnostics for existing resources during планирования. +func PlanExistingResourceDiagnostics(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool) diag.Diagnostics { + var diags diag.Diagnostics + if client == nil { + return diags + } + if strings.TrimSpace(resourceName) == "" { + return diags + } + + existing, err := client.FindInstanceByDisplayName(ctx, serviceID, resourceName) + if err != nil || existing == nil { + return diags + } + + status := normalizeStatus(existing.ExplainedStatus) + if isStatusNotCreated(status) { + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (NOT CREATED)", + "Инстанс найден, но находится в состоянии Not Created.\nУдалите его в Личном кабинете (Управление облаком), затем повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if isStatusRunning(status) { + if adoptExistingOnCreate { + return diags + } + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)", + "Инстанс с таким resource_name уже работает.\nАвто-усыновление отключено (adopt_existing_on_create=false).\nВыберите другое имя, если нужен новый ресурс.\nИли выполните импорт существующего ресурса через усыновление: установите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if isStatusSuspended(status) { + if adoptExistingOnCreate { + return diags + } + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)", + "Инстанс найден в состоянии Suspend, но adopt_existing_on_create=false.\nДля восстановления/импорта этим ресурсом включите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (НЕПОДДЕРЖИВАЕМЫЙ СТАТУС)", + "Инстанс найден, но статус не подходит для авто-усыновления.\nСтатус: "+status+". Выберите другое имя или устраните проблему в ЛК."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags +} + +// CreateExistingResourceDiagnostics returns warnings for existing resources during create. +func CreateExistingResourceDiagnostics(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool) diag.Diagnostics { + return CreateExistingResourceDiagnosticsWithDomain(ctx, client, serviceID, resourceName, adoptExistingOnCreate, "") +} + +// ShouldRemoveFromState checks instance status and indicates if TF state should be removed. +func ShouldRemoveFromState(ctx context.Context, client *core.UniversalClient, instanceID string) (bool, error) { + if client == nil { + return false, nil + } + if strings.TrimSpace(instanceID) == "" { + return false, nil + } + + state, err := client.GetInstanceState(ctx, instanceID) + if err != nil { + return false, err + } + if state != nil { + status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) + if state.IsDeleted || status == "deleted" { + return true, nil + } + } + + return false, nil +} + +func normalizeStatus(status string) string { + return strings.ToLower(strings.TrimSpace(status)) +} + +func isStatusRunning(status string) bool { + if status == "" { + return false + } + return strings.Contains(status, "running") || strings.Contains(status, "active") || strings.Contains(status, "работ") +} + +func isStatusNotCreated(status string) bool { + if status == "" { + return false + } + return strings.Contains(status, "not created") || strings.Contains(status, "not_created") || strings.Contains(status, "notcreated") +} + +func formatInstanceDetails(existing *core.InstanceStateResponse, serviceID int, resourceName string) string { + if existing == nil { + return "" + } + statusRaw := strings.TrimSpace(existing.ExplainedStatus) + statusNorm := normalizeStatus(statusRaw) + return "\nDetails:" + + "\n- resource_name: " + resourceName + + "\n- service_id: " + fmt.Sprintf("%d", serviceID) + + "\n- instance_uid: " + existing.InstanceUid + + "\n- status: \"" + statusNorm + "\"" + + "\n- status_raw: \"" + statusRaw + "\"" + + "\n- operation_pending: " + formatBool(existing.OperationIsPending) + + "\n- operation_in_progress: " + formatBool(existing.OperationIsInProgress) +} + +func formatBool(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/universal_rebuild/internal/resources_core/resource_diagnostics_required.go b/universal_rebuild/internal/resources_core/resource_diagnostics_required.go new file mode 100644 index 0000000..78f9b29 --- /dev/null +++ b/universal_rebuild/internal/resources_core/resource_diagnostics_required.go @@ -0,0 +1,499 @@ +package resources_core + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// PlanExistingResourceDiagnosticsWithParams returns diagnostics when resource exists and compares required params. +func PlanExistingResourceDiagnosticsWithParams(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool, desiredParams map[int]string) diag.Diagnostics { + return PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx, client, serviceID, resourceName, adoptExistingOnCreate, desiredParams, "", nil) +} + +func PlanExistingResourceDiagnosticsWithParamsAndDomain(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool, desiredParams map[int]string, desiredDomain string) diag.Diagnostics { + return PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx, client, serviceID, resourceName, adoptExistingOnCreate, desiredParams, desiredDomain, nil) +} + +func PlanExistingResourceDiagnosticsWithParamsAndDomainAndServices(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool, desiredParams map[int]string, desiredDomain string, allowedDomainServiceIDs []int) diag.Diagnostics { + var diags diag.Diagnostics + appendLocalPlanInputDiagnostics(serviceID, desiredParams, desiredDomain, &diags) + appendRemotePlanInputDiagnostics(ctx, client, serviceID, desiredParams, &diags) + + if client == nil { + return diags + } + if strings.TrimSpace(resourceName) == "" { + return diags + } + + existing, err := client.FindInstanceByDisplayName(ctx, serviceID, resourceName) + if err != nil || existing == nil { + appendDomainCollisionDiagnostics(ctx, client, serviceID, resourceName, desiredDomain, allowedDomainServiceIDs, nil, &diags) + return diags + } + appendDomainCollisionDiagnostics(ctx, client, serviceID, resourceName, desiredDomain, allowedDomainServiceIDs, existing, &diags) + if diags.HasError() { + return diags + } + if diags.HasError() { + return diags + } + + status := normalizeStatus(existing.ExplainedStatus) + if isStatusNotCreated(status) { + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (NOT CREATED)", + "Инстанс найден, но находится в состоянии Not Created.\nУдалите его в Личном кабинете (Управление облаком), затем повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if isStatusRunning(status) { + if adoptExistingOnCreate { + appendAdoptOrphanGuardDiagnostics(ctx, client, existing, serviceID, resourceName, &diags) + return diags + } + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)", + "Инстанс с таким resource_name уже работает.\nАвто-усыновление отключено (adopt_existing_on_create=false).\nВыберите другое имя, если нужен новый ресурс.\nИли выполните импорт существующего ресурса через усыновление: установите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if !isStatusSuspended(status) { + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (НЕПОДДЕРЖИВАЕМЫЙ СТАТУС)", + "Инстанс найден, но статус не подходит для авто-усыновления.\nСтатус: "+status+". Выберите другое имя или устраните проблему в ЛК."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + + if !adoptExistingOnCreate { + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)", + "Инстанс найден в состоянии Suspend, но adopt_existing_on_create=false.\nДля восстановления/импорта этим ресурсом включите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + + mismatches, err := RequiredParamsMismatch(ctx, client, serviceID, desiredParams, existing.InstanceUid) + if err != nil { + diags.AddError("Не удалось сравнить параметры", err.Error()) + return diags + } + if len(mismatches) == 0 { + return diags + } + + details := FormatRequiredParamMismatchMessage(mismatches) + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND, ПАРАМЕТРЫ ОТЛИЧАЮТСЯ)", + "Обязательные параметры не совпадают с существующим инстансом в состоянии Suspend: "+details+".\nИсправьте конфиг и повторите plan/apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags +} + +func appendRemotePlanInputDiagnostics(ctx context.Context, client *core.UniversalClient, serviceID int, desiredParams map[int]string, diags *diag.Diagnostics) { + if diags == nil || client == nil || serviceID <= 0 || len(desiredParams) == 0 { + return + } + + specByID, err := getServiceParamValidationSpecMap(serviceID) + if err != nil || len(specByID) == 0 { + return + } + + realmCache := []string(nil) + realmLoaded := false + refOptionsCache := map[int][]core.RefServiceInstanceOption{} + + for id, rawValue := range desiredParams { + spec, ok := specByID[id] + if !ok { + continue + } + value := strings.TrimSpace(rawValue) + if value == "" { + continue + } + code := strings.TrimSpace(spec.Code) + + if len(spec.ValueList) > 0 && !containsValueCaseSensitive(spec.ValueList, value) { + diags.AddError( + "Некорректное значение параметра", + "Параметр "+code+" содержит недопустимое значение: \""+value+"\".\nДоступные значения: "+joinLimited(spec.ValueList, 25), + ) + } + + // TODO: realm validation disabled — backend bug in /resourceRealms/available (500, available_resource_realms.cfc:79) + // Re-enable when backend fixes cyrillic 'п' in out.arпuments + + if strings.EqualFold(spec.Func, "getAvailableResourceRealms") { + if !realmLoaded { + loaded, loadErr := client.ListAvailableResourceRealms(ctx, serviceID) + if loadErr != nil { + diags.AddWarning( + "Проверка realm пропущена", + "API /resourceRealms/available вернул ошибку — список доступных realm получить не удалось. Проверка параметра "+code+" не выполнялась.", + ) + realmLoaded = true + realmCache = nil + } else { + realmCache = loaded + realmLoaded = true + } + } + if realmLoaded && realmCache != nil && len(realmCache) > 0 && !containsValueInsensitive(realmCache, value) { + diags.AddError( + "Некорректное значение параметра realm", + "Параметр "+code+" содержит недопустимое значение: \""+value+"\".\nДоступные realm: "+joinLimited(realmCache, 25), + ) + } + } + + if spec.RefSvcId > 0 { + options, cached := refOptionsCache[spec.RefSvcId] + if !cached { + loaded, loadErr := client.ListRefServiceInstances(ctx, spec.RefSvcId) + if loadErr != nil { + diags.AddWarning( + "Проверка ref-параметра пропущена", + "API /instances вернул ошибку для service_id="+fmt.Sprintf("%d", spec.RefSvcId)+" — список инстансов получить не удалось. Проверка значения \""+value+"\" не выполнялась.", + ) + refOptionsCache[spec.RefSvcId] = nil + continue + } + options = loaded + refOptionsCache[spec.RefSvcId] = options + } + + if !isValidRefValue(options, spec.RefSvcId, value) { + diags.AddError( + "Некорректное значение ссылочного параметра", + "Параметр "+code+" содержит недопустимое значение: \""+value+"\".\nВыберите значение из списка доступных инстансов service_id="+fmt.Sprintf("%d", spec.RefSvcId)+": "+formatRefOptions(options, spec.RefSvcId, 20), + ) + } + } + } +} + +func CreateExistingResourceDiagnosticsWithDomain(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool, desiredDomain string) diag.Diagnostics { + return CreateExistingResourceDiagnosticsWithDomainAndServices(ctx, client, serviceID, resourceName, adoptExistingOnCreate, desiredDomain, nil) +} + +func CreateExistingResourceDiagnosticsWithDomainAndServices(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, adoptExistingOnCreate bool, desiredDomain string, allowedDomainServiceIDs []int) diag.Diagnostics { + var diags diag.Diagnostics + if client == nil { + return diags + } + if strings.TrimSpace(resourceName) == "" { + return diags + } + + existing, err := client.FindInstanceByDisplayName(ctx, serviceID, resourceName) + if err != nil || existing == nil { + appendDomainCollisionDiagnostics(ctx, client, serviceID, resourceName, desiredDomain, allowedDomainServiceIDs, nil, &diags) + return diags + } + + appendDomainCollisionDiagnostics(ctx, client, serviceID, resourceName, desiredDomain, allowedDomainServiceIDs, existing, &diags) + if diags.HasError() { + return diags + } + + status := normalizeStatus(existing.ExplainedStatus) + if isStatusNotCreated(status) { + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (NOT CREATED)", + "Инстанс найден, но находится в состоянии Not Created.\nУдалите его в Личном кабинете (Управление облаком), затем повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if isStatusRunning(status) { + if adoptExistingOnCreate { + return diags + } + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (RUNNING)", + "Инстанс с таким resource_name уже работает.\nАвто-усыновление отключено (adopt_existing_on_create=false).\nВыберите другое имя, если нужен новый ресурс.\nИли выполните импорт существующего ресурса через усыновление: установите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + if isStatusSuspended(status) { + if adoptExistingOnCreate { + return diags + } + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (SUSPEND)", + "Инстанс найден в состоянии Suspend, но adopt_existing_on_create=false.\nДля восстановления/импорта этим ресурсом включите adopt_existing_on_create=true и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags + } + + diags.AddError( + "РЕСУРС С ТАКИМ ИМЕНЕМ УЖЕ СУЩЕСТВУЕТ (НЕПОДДЕРЖИВАЕМЫЙ СТАТУС)", + "Инстанс найден, но статус не подходит для авто-усыновления.\nСтатус: "+status+". Выберите другое имя или устраните проблему в ЛК."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return diags +} + +func appendLocalPlanInputDiagnostics(serviceID int, desiredParams map[int]string, desiredDomain string, diags *diag.Diagnostics) { + if diags == nil { + return + } + + if domain := strings.TrimSpace(desiredDomain); domain != "" { + if err := validateDomainValue(domain); err != nil { + diags.AddError("Некорректный domain", err.Error()) + } + } + + if len(desiredParams) == 0 || serviceID <= 0 { + return + } + + codeMap, err := getServiceParamCodeMap(serviceID) + if err != nil || len(codeMap) == 0 { + return + } + + for id, rawValue := range desiredParams { + value := strings.TrimSpace(rawValue) + code := strings.TrimSpace(codeMap[id]) + if code == "" { + continue + } + switch strings.ToLower(code) { + case "jsonenv", "jsonparameters": + if value == "" { + continue + } + if !json.Valid([]byte(value)) { + diags.AddError("Некорректный JSON", "Параметр "+code+" должен быть валидным JSON.") + } + case "domain": + if value == "" { + continue + } + if err := validateDomainValue(value); err != nil { + diags.AddError("Некорректный domain", "Параметр "+code+": "+err.Error()) + } + case "healthpath": + if value == "" { + continue + } + if !strings.HasPrefix(value, "/") { + diags.AddError("Некорректный health_path", "Параметр "+code+" должен начинаться с '/'.") + } + case "gitpath": + if value == "" { + continue + } + u, parseErr := url.Parse(value) + if parseErr != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || strings.TrimSpace(u.Host) == "" { + diags.AddError("Некорректный git_path", "Параметр "+code+" должен быть корректным http/https URL.") + } + case "resourcecpu", "resourcememory", "resourceinstances", "resourcedisk": + if value == "" { + continue + } + num, convErr := strconv.ParseInt(value, 10, 64) + if convErr != nil { + diags.AddError("Некорректное числовое значение", "Параметр "+code+" должен быть целым числом.") + continue + } + if num <= 0 { + diags.AddError("Некорректный диапазон", "Параметр "+code+" должен быть больше 0.") + } + } + } +} + +func appendAdoptOrphanGuardDiagnostics(ctx context.Context, client *core.UniversalClient, existing *core.InstanceStateResponse, serviceID int, resourceName string, diags *diag.Diagnostics) { + if diags == nil || client == nil || existing == nil { + return + } + instanceUID := strings.TrimSpace(existing.InstanceUid) + if instanceUID == "" { + diags.AddError( + "Невозможно выполнить adopt_existing_on_create", + "Найден инстанс без instance_uid. Усыновление невозможно, удалите объект в Личном кабинете и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return + } + params, err := client.GetInstanceStateParams(ctx, instanceUID) + if err != nil { + diags.AddError( + "Невозможно выполнить adopt_existing_on_create", + "Найден существующий инстанс, но не удалось прочитать его state-параметры.\nСчитаем ресурс orphan/broken для adopt; удалите объект в Личном кабинете и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + return + } + if len(params) == 0 { + diags.AddError( + "Невозможно выполнить adopt_existing_on_create", + "Найден существующий инстанс без state-параметров (orphan).\nУсыновление заблокировано: удалите инстанс в Личном кабинете и повторите apply."+formatInstanceDetails(existing, serviceID, resourceName), + ) + } +} + +var domainLabelRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + +func validateDomainValue(domain string) error { + trimmed := strings.TrimSpace(domain) + if trimmed == "" { + return nil + } + if trimmed != strings.ToLower(trimmed) { + return fmt.Errorf("domain должен быть в lowercase") + } + if strings.ContainsAny(trimmed, " \t\n\r") { + return fmt.Errorf("domain не должен содержать пробелы") + } + labels := strings.Split(trimmed, ".") + for _, label := range labels { + if !domainLabelRegex.MatchString(label) { + return fmt.Errorf("domain должен быть валидным label/FQDN") + } + } + return nil +} + +func appendDomainCollisionDiagnostics(ctx context.Context, client *core.UniversalClient, serviceID int, resourceName string, desiredDomain string, allowedDomainServiceIDs []int, existingByName *core.InstanceStateResponse, diags *diag.Diagnostics) { + if diags == nil { + return + } + if client == nil { + return + } + targetDomain := strings.TrimSpace(desiredDomain) + if targetDomain == "" { + return + } + + owner, err := findDomainOwnerInstance(ctx, client, targetDomain, allowedDomainServiceIDs) + if err != nil || owner == nil { + return + } + + if existingByName != nil && strings.TrimSpace(existingByName.InstanceUid) != "" && strings.EqualFold(existingByName.InstanceUid, owner.InstanceUID) { + return + } + if strings.TrimSpace(resourceName) != "" && strings.EqualFold(strings.TrimSpace(resourceName), strings.TrimSpace(owner.DisplayName)) { + return + } + + diags.AddError( + "ДОМЕН УЖЕ ЗАНЯТ ДРУГИМ РЕСУРСОМ", + "Запрошенный domain уже используется другим инстансом.\nВыберите уникальный domain.\nИли, если это тот же самый объект, используйте корректный resource_name и adopt_existing_on_create=true для усыновления.\nDetails:"+ + "\n- requested_domain: "+targetDomain+ + "\n- requested_service_id: "+fmt.Sprintf("%d", serviceID)+ + "\n- requested_resource_name: "+resourceName+ + "\n- owner_instance_uid: "+owner.InstanceUID+ + "\n- owner_service_id: "+fmt.Sprintf("%d", owner.ServiceID)+ + "\n- owner_resource_name: "+owner.DisplayName, + ) +} + +func containsValueCaseSensitive(values []string, target string) bool { + for _, v := range values { + if strings.TrimSpace(v) == target { + return true + } + } + return false +} + +func containsValueInsensitive(values []string, target string) bool { + for _, v := range values { + if strings.EqualFold(strings.TrimSpace(v), target) { + return true + } + } + return false +} + +func joinLimited(values []string, limit int) string { + if len(values) == 0 { + return "(пусто)" + } + if limit <= 0 || len(values) <= limit { + return strings.Join(values, ", ") + } + return strings.Join(values[:limit], ", ") + ", ..." +} + +func sortedCopy(values []string) []string { + copyValues := make([]string, 0, len(values)) + copyValues = append(copyValues, values...) + sort.Strings(copyValues) + return copyValues +} + +func isValidRefValue(options []core.RefServiceInstanceOption, refSvcId int, value string) bool { + if len(options) == 0 { + return true + } + for _, opt := range options { + if strings.EqualFold(strings.TrimSpace(opt.InstanceUID), value) { + return true + } + if refSvcId == 12 { + continue + } + if strings.EqualFold(strings.TrimSpace(opt.DisplayName), value) { + return true + } + } + return false +} + +func formatRefOptions(options []core.RefServiceInstanceOption, refSvcId int, limit int) string { + if len(options) == 0 { + if refSvcId == 12 { + return "требуется UUID существующего инстанса" + } + return "список пуст" + } + formatted := make([]string, 0, len(options)) + for _, opt := range options { + uid := strings.TrimSpace(opt.InstanceUID) + name := strings.TrimSpace(opt.DisplayName) + if refSvcId == 12 { + if uid != "" { + formatted = append(formatted, uid) + } + continue + } + if name != "" && uid != "" { + formatted = append(formatted, name+" ("+uid+")") + } else if uid != "" { + formatted = append(formatted, uid) + } else if name != "" { + formatted = append(formatted, name) + } + } + if len(formatted) == 0 { + return "список пуст" + } + return joinLimited(formatted, limit) +} + +func shouldSurfaceAuxValidationWarning(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if strings.Contains(msg, "ошибка api 500") || strings.Contains(msg, "ошибка api 502") || strings.Contains(msg, "ошибка api 503") || strings.Contains(msg, "ошибка api 504") { + return false + } + return true +} diff --git a/universal_rebuild/internal/resources_core/service_operation_resource.go b/universal_rebuild/internal/resources_core/service_operation_resource.go new file mode 100644 index 0000000..93405e0 --- /dev/null +++ b/universal_rebuild/internal/resources_core/service_operation_resource.go @@ -0,0 +1,159 @@ +package resources_core + +import ( + "context" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &ServiceOperationResource{} +var _ resource.ResourceWithConfigure = &ServiceOperationResource{} + +// ServiceOperationResource triggers a one-time operation for an existing instance. +type ServiceOperationResource struct { + client *core.UniversalClient +} + +type ServiceOperationModel struct { + ID types.String `tfsdk:"id"` + InstanceUid types.String `tfsdk:"instance_uid"` + Operation types.String `tfsdk:"operation"` + RunID types.String `tfsdk:"run_id"` + Params types.Map `tfsdk:"params"` +} + +func NewServiceOperationResource() resource.Resource { + return &ServiceOperationResource{} +} + +func (r *ServiceOperationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_operation" +} + +func (r *ServiceOperationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true}, + "instance_uid": schema.StringAttribute{ + Required: true, + }, + "operation": schema.StringAttribute{ + Required: true, + }, + "run_id": schema.StringAttribute{ + Required: true, + }, + "params": schema.MapAttribute{ + Optional: true, + ElementType: types.StringType, + }, + }, + } +} + +func (r *ServiceOperationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ServiceOperationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + instanceUID := strings.TrimSpace(plan.InstanceUid.ValueString()) + operation := strings.TrimSpace(plan.Operation.ValueString()) + runID := strings.TrimSpace(plan.RunID.ValueString()) + if instanceUID == "" || operation == "" || runID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "instance_uid, operation, and run_id are required") + return + } + + params, diags := StringMapFromTypesMap(ctx, plan.Params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError("Ошибка клиента", "клиент не инициализирован") + return + } + + if err := r.client.RunInstanceOperationUniversalByCode(ctx, instanceUID, operation, params); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(buildServiceOperationID(instanceUID, operation, runID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *ServiceOperationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ServiceOperationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *ServiceOperationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan ServiceOperationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + instanceUID := strings.TrimSpace(plan.InstanceUid.ValueString()) + operation := strings.TrimSpace(plan.Operation.ValueString()) + runID := strings.TrimSpace(plan.RunID.ValueString()) + if instanceUID == "" || operation == "" || runID == "" { + resp.Diagnostics.AddError("Ошибка клиента", "instance_uid, operation, and run_id are required") + return + } + + params, diags := StringMapFromTypesMap(ctx, plan.Params) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError("Ошибка клиента", "клиент не инициализирован") + return + } + + if err := r.client.RunInstanceOperationUniversalByCode(ctx, instanceUID, operation, params); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(buildServiceOperationID(instanceUID, operation, runID)) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *ServiceOperationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // No-op: removing the resource does not trigger a remote action. +} + +func (r *ServiceOperationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*core.UniversalClient) + if !ok { + resp.Diagnostics.AddError("Ошибка", "Неверный тип клиента, ожидается *core.UniversalClient") + return + } + + r.client = client +} + +func buildServiceOperationID(instanceUID string, operation string, runID string) string { + return fmt.Sprintf("%s:%s:%s", strings.TrimSpace(instanceUID), strings.TrimSpace(operation), strings.TrimSpace(runID)) +} diff --git a/universal_rebuild/internal/resources_core/state_refresh.go b/universal_rebuild/internal/resources_core/state_refresh.go new file mode 100644 index 0000000..679697a --- /dev/null +++ b/universal_rebuild/internal/resources_core/state_refresh.go @@ -0,0 +1,204 @@ +package resources_core + +import ( + "context" + "reflect" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type StateField struct { + Code string +} + +type InputField struct { + Code string + Field string + Type string +} + +var ( + typeString = reflect.TypeOf(types.String{}) + typeBool = reflect.TypeOf(types.Bool{}) + typeInt64 = reflect.TypeOf(types.Int64{}) + typeMap = reflect.TypeOf(types.Map{}) + typeList = reflect.TypeOf(types.List{}) +) + +func RefreshResourceState[T any](ctx context.Context, client *core.UniversalClient, instanceID string, serviceID int, state T, outputs []StateField, inputs []InputField) (T, diag.Diagnostics) { + var diags diag.Diagnostics + if client == nil || strings.TrimSpace(instanceID) == "" { + return state, diags + } + + out, outDiags := FetchInstanceOutputs(ctx, client, instanceID) + diags.Append(outDiags...) + + v := reflect.ValueOf(&state).Elem() + if v.Kind() != reflect.Struct { + return state, diags + } + + stateOutMap := map[string]string{} + if !out.StateOut.IsNull() && !out.StateOut.IsUnknown() { + mapped, mapDiags := StringMapFromTypesMap(ctx, out.StateOut) + diags.Append(mapDiags...) + if len(mapped) > 0 { + stateOutMap = mapped + } + } + + for _, field := range outputs { + code := strings.TrimSpace(field.Code) + if code == "" { + continue + } + fieldName := toCamel(code) + fv := v.FieldByName(fieldName) + if !fv.IsValid() || !fv.CanSet() { + continue + } + switch code { + case "state_params": + setFieldValue(fv, out.StateParams) + case "state_out": + setFieldValue(fv, out.StateOut) + case "state_params_flat": + setFieldValue(fv, out.StateParamsFlat) + case "state_out_flat": + setFieldValue(fv, out.StateOutFlat) + case "vault_secrets": + setFieldValue(fv, out.VaultSecrets) + case "vault_url": + setFieldValue(fv, out.VaultUrl) + case "vault_user_path": + setFieldValue(fv, out.VaultUserPath) + case "vault_fields": + setFieldValue(fv, out.VaultFields) + default: + if val, ok := stateOutMap[code]; ok { + setFieldValue(fv, ParseString(val)) + } + } + } + + paramsMap, paramsDiags := StringMapFromTypesMap(ctx, out.StateParams) + diags.Append(paramsDiags...) + if serviceID > 0 && len(paramsMap) > 0 { + paramsMap, paramsDiags = ResolveRefSvcParamDisplayNames(ctx, client, serviceID, paramsMap) + diags.Append(paramsDiags...) + } + + resourceRealm := strings.TrimSpace(paramsMap["resourceRealm"]) + if resourceRealm != "" { + outFlatMap, flatDiags := StringMapFromTypesMap(ctx, out.StateOutFlat) + diags.Append(flatDiags...) + if len(outFlatMap) > 0 { + fixed := FixInternalConnectMasterSuffix(outFlatMap, resourceRealm) + if fixed { + out.StateOutFlat, diags = mapValueFrom(ctx, outFlatMap, diags) + fv := v.FieldByName(toCamel("state_out_flat")) + if fv.IsValid() && fv.CanSet() { + setFieldValue(fv, out.StateOutFlat) + } + } + } + } + + for _, input := range inputs { + code := strings.TrimSpace(input.Code) + if code == "" { + continue + } + value, ok := paramsMap[code] + if !ok { + continue + } + fieldName := strings.TrimSpace(input.Field) + if fieldName == "" { + fieldName = toCamel(code) + } + fv := v.FieldByName(fieldName) + if !fv.IsValid() || !fv.CanSet() { + continue + } + + if strings.EqualFold(code, "jsonEnv") && fv.Type() == typeString { + // Preserve planned json_env when API returns equivalent JSON with different ordering. + if planned, ok := fv.Interface().(types.String); ok && !planned.IsNull() && !planned.IsUnknown() { + if JSONStringsEquivalent(planned.ValueString(), value) { + fv.Set(reflect.ValueOf(planned)) + continue + } + } + } + + switch strings.ToLower(strings.TrimSpace(input.Type)) { + case "bool": + if fv.Type() == typeBool { + fv.Set(reflect.ValueOf(ParseBool(value))) + } + case "int", "int64", "number": + if fv.Type() == typeInt64 { + fv.Set(reflect.ValueOf(ParseInt64(value))) + } + default: + if fv.Type() == typeString { + fv.Set(reflect.ValueOf(ParseString(value))) + } + } + } + + return state, diags +} + +func setFieldValue(field reflect.Value, value interface{}) { + switch field.Type() { + case typeString: + switch v := value.(type) { + case types.String: + field.Set(reflect.ValueOf(v)) + case string: + field.Set(reflect.ValueOf(ParseString(v))) + } + case typeBool: + if v, ok := value.(types.Bool); ok { + field.Set(reflect.ValueOf(v)) + } + case typeInt64: + if v, ok := value.(types.Int64); ok { + field.Set(reflect.ValueOf(v)) + } + case typeMap: + if v, ok := value.(types.Map); ok { + field.Set(reflect.ValueOf(v)) + } + case typeList: + if v, ok := value.(types.List); ok { + field.Set(reflect.ValueOf(v)) + } + } +} + +func toCamel(s string) string { + parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' }) + for i, p := range parts { + if len(p) == 0 { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + out := strings.Join(parts, "") + if out == "" { + return "R" + } + first := rune(out[0]) + if (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_' { + return out + } + return "R" + out +} diff --git a/universal_rebuild/internal/resources_core/subresource_guard.go b/universal_rebuild/internal/resources_core/subresource_guard.go new file mode 100644 index 0000000..226888b --- /dev/null +++ b/universal_rebuild/internal/resources_core/subresource_guard.go @@ -0,0 +1,207 @@ +package resources_core + +import ( + "context" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" +) + +// FindSubresourceInStateOut checks presence of a subresource in instance state_out. +// Returns (found, known, error). If known=false, the list key was not found. +func FindSubresourceInStateOut(ctx context.Context, client *core.UniversalClient, instanceID string, listKey string, idKey string, target string) (bool, bool, error) { + if client == nil { + return false, false, nil + } + if strings.TrimSpace(instanceID) == "" { + return false, false, nil + } + if strings.TrimSpace(listKey) == "" || strings.TrimSpace(idKey) == "" || strings.TrimSpace(target) == "" { + return false, false, nil + } + + details, err := client.GetInstanceStateDetails(ctx, instanceID) + if err != nil { + return false, false, err + } + if details == nil || details.RawOut == nil { + return false, false, nil + } + + listRaw, ok := details.RawOut[listKey] + if !ok { + return false, false, nil + } + + found := false + switch list := listRaw.(type) { + case map[string]interface{}: + for key, item := range list { + if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(target)) { + return true, true, nil + } + if m, ok := item.(map[string]interface{}); ok { + if matchSubresourceID(m, idKey, target) { + found = true + break + } + } + } + case []interface{}: + for _, item := range list { + if m, ok := item.(map[string]interface{}); ok { + if matchSubresourceID(m, idKey, target) { + found = true + break + } + } + } + default: + return false, true, nil + } + + return found, true, nil +} + +func matchSubresourceID(raw map[string]interface{}, idKey string, target string) bool { + if raw == nil { + return false + } + for key, val := range raw { + if !strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(idKey)) { + continue + } + value := strings.TrimSpace(normalizeRawValue(val)) + if value == "" { + return false + } + return strings.EqualFold(value, strings.TrimSpace(target)) + } + return false +} + +func normalizeRawValue(value interface{}) string { + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func IsSubresourceMissingError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + markers := []string{"не найден", "not found", "does not exist", "отсутствует"} + for _, marker := range markers { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +func IsSubresourceAlreadyExistsError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + markers := []string{"уже существует", "already exists", "duplicate", "conflict"} + for _, marker := range markers { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +func IsSubresourceTransientDependencyError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if msg == "" { + return false + } + markers := []string{"ошибка api 424", "missing dependency", "orchestrator communication error:404 not found"} + for _, marker := range markers { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +// ResolveSubresourceStateKeys returns state_out list key and identity key for a subresource. +// The second argument preserves generator-level identity priority and helps avoid hardcoding +// per-service logic in generated resources. +func ResolveSubresourceStateKeys(subresource string, identityCodes []string) (string, string) { + listKey := SubresourceListKey(subresource) + idKey := SubresourceIdentityKey(subresource, identityCodes) + return listKey, idKey +} + +// SubresourceListKey builds a best-effort list key in state_out. +func SubresourceListKey(subresource string) string { + name := strings.TrimSpace(subresource) + if name == "" { + return "" + } + if strings.HasSuffix(strings.ToLower(name), "s") { + return name + } + return name + "s" +} + +// SubresourceIdentityKey picks identity field key for lookup in state_out. +func SubresourceIdentityKey(subresource string, identityCodes []string) string { + name := strings.ToLower(strings.TrimSpace(subresource)) + if name == "" { + return "" + } + + special := map[string]string{ + "user": "username", + "database": "dbName", + } + if preferred, ok := special[name]; ok { + if matched := findMatchingCode(identityCodes, preferred); matched != "" { + return matched + } + return preferred + } + + priority := []string{"name", name, name + "name", "id", name + "id"} + for _, candidate := range priority { + if matched := findMatchingCode(identityCodes, candidate); matched != "" { + return matched + } + } + + if len(identityCodes) > 0 { + return strings.TrimSpace(identityCodes[0]) + } + + return "" +} + +func findMatchingCode(identityCodes []string, candidate string) string { + target := strings.ToLower(strings.TrimSpace(candidate)) + if target == "" { + return "" + } + for _, code := range identityCodes { + trimmed := strings.TrimSpace(code) + if strings.EqualFold(trimmed, target) { + return trimmed + } + } + return "" +} diff --git a/universal_rebuild/internal/resources_core/uuid_planmodifier.go b/universal_rebuild/internal/resources_core/uuid_planmodifier.go new file mode 100644 index 0000000..379c745 --- /dev/null +++ b/universal_rebuild/internal/resources_core/uuid_planmodifier.go @@ -0,0 +1,48 @@ +package resources_core + +// uuid_planmodifier.go — план-модификатор для нормализации UUID-атрибутов к нижнему регистру. +// +// Проблема: пользователь может написать UUID в верхнем регистре ("6214BA32-..."), +// но API всегда возвращает UUID в нижнем регистре ("6214ba32-..."). +// Terraform сравнивает план (uppercase) с результатом Apply (lowercase) и видит расхождение → +// "Provider produced inconsistent result after apply". +// +// Решение: plan modifier UUIDNormalize() нормализует UUID к нижнему регистру ещё на этапе плана, +// чтобы план и результат Read всегда совпадали. + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// uuidNormalizePlanModifier реализует planmodifier.String. +// Нормализует UUID-строку к нижнему регистру при планировании. +type uuidNormalizePlanModifier struct{} + +// UUIDNormalize возвращает plan modifier, нормализующий значение к нижнему регистру. +// Использовать на атрибутах с ref_svc (типа UUID). +func UUIDNormalize() planmodifier.String { + return uuidNormalizePlanModifier{} +} + +func (m uuidNormalizePlanModifier) Description(_ context.Context) string { + return "Normalizes UUID string to lowercase to prevent inconsistent-result errors." +} + +func (m uuidNormalizePlanModifier) MarkdownDescription(_ context.Context) string { + return "Normalizes UUID string to lowercase to prevent inconsistent-result errors." +} + +// PlanModifyString нормализует значение к нижнему регистру. +func (m uuidNormalizePlanModifier) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() { + return + } + normalized := strings.ToLower(req.PlanValue.ValueString()) + if normalized != req.PlanValue.ValueString() { + resp.PlanValue = types.StringValue(normalized) + } +}