add: CRUD logic (resources_core)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ",")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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, ";")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "<empty>"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(actual) == "" {
|
||||||
|
actual = "<empty>"
|
||||||
|
}
|
||||||
|
parts = append(parts, code+" (plan="+desired+", actual="+actual+")")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user