add: CRUD logic (resources_core)

This commit is contained in:
“Naeel”
2026-06-30 15:43:07 +04:00
parent 04a5610aa9
commit 321dd84c8e
21 changed files with 3109 additions and 0 deletions
@@ -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)
}
}