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