diff --git a/TEST_STAND/CRUD/README.md b/TEST_STAND/CRUD/README.md index 0eaddf7..bf243a7 100644 --- a/TEST_STAND/CRUD/README.md +++ b/TEST_STAND/CRUD/README.md @@ -5,7 +5,7 @@ ## Как скачать ```bash -git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git +git clone https://gitea.services.ngcloud.ru/terraform/tf_examples.git cd tf_examples/CRUD ``` diff --git a/TEST_STAND/CRUD/locals.tf b/TEST_STAND/CRUD/locals.tf index 721c7df..2ff6ca1 100644 --- a/TEST_STAND/CRUD/locals.tf +++ b/TEST_STAND/CRUD/locals.tf @@ -9,52 +9,52 @@ locals { # PostgreSQL — общая БД для всех трёх приложений # ═══════════════════════════════════════════════════════════════════════════ - pg_resource_name = "pg4crud2" # имя ресурса в Nubes - pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра) - pg_memory = 512 # память в MB - pg_replicas = 1 # количество реплик - pg_disk = 10 # диск в GB - pg_version = "17" # версия PostgreSQL - pg_retain = 14 # дней хранения бэкапов - pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь) - pg_timeout = "11m" # таймаут операций create/modify + pg_resource_name = "pg4crud2" # имя ресурса в Nubes + pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра) + pg_memory = 512 # память в MB + pg_replicas = 1 # количество реплик + pg_disk = 10 # диск в GB + pg_version = "17" # версия PostgreSQL + pg_retain = 14 # дней хранения бэкапов + pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь) + pg_timeout = "11m" # таймаут операций create/modify # ═══════════════════════════════════════════════════════════════════════════ # PostgreSQL — пользователь и база данных # ═══════════════════════════════════════════════════════════════════════════ - pg_username = "user4crudpg" # имя пользователя БД - pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы) - pg_db_name = "db4crudpg" # имя базы данных + pg_username = "user4crudpg" # имя пользователя БД + pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы) + pg_db_name = "db4crudpg" # имя базы данных # ═══════════════════════════════════════════════════════════════════════════ # Lucee — CFML-приложение (сервис 94) # ═══════════════════════════════════════════════════════════════════════════ - lucee_resource_name = "crud-lucee" # имя ресурса в Nubes - lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё! - lucee_version = "5.4" # версия Lucee (CFML engine) - lucee_git_path = "https://gitea.services.ngcloud.ru/Nail/tfluceecrud.git" - lucee_cpu = 300 # CPU в millicores - lucee_memory = 512 # память в MB - lucee_replicas = 1 # количество реплик + lucee_resource_name = "crud-lucee" # имя ресурса в Nubes + lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё! + lucee_version = "5.4" # версия Lucee (CFML engine) + lucee_git_path = "https://gitea.services.ngcloud.ru/terraform/tfluceecrud.git" + lucee_cpu = 300 # CPU в millicores + lucee_memory = 512 # память в MB + lucee_replicas = 1 # количество реплик # ═══════════════════════════════════════════════════════════════════════════ # Таблица CRUD — общая для всех трёх приложений # ═══════════════════════════════════════════════════════════════════════════ - crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env) + crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env) # ═══════════════════════════════════════════════════════════════════════════ # Flask — Python-приложение (сервис 89) # ═══════════════════════════════════════════════════════════════════════════ - flask_resource_name = "crud-flask" # имя ресурса в Nubes - flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё! - flask_git_path = "https://gitea.services.ngcloud.ru/Nail/tfflaskcrud.git" - flask_cpu = 300 # CPU в millicores - flask_memory = 512 # память в MB - flask_replicas = 1 # количество реплик + flask_resource_name = "crud-flask" # имя ресурса в Nubes + flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё! + flask_git_path = "https://gitea.services.ngcloud.ru/terraform/tfflaskcrud.git" + flask_cpu = 300 # CPU в millicores + flask_memory = 512 # память в MB + flask_replicas = 1 # количество реплик # ═══════════════════════════════════════════════════════════════════════════ # Node.js — Express-приложение (сервис 95) @@ -62,11 +62,11 @@ locals { nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё! - nodejs_git_path = "https://gitea.services.ngcloud.ru/Nail/tfnodejscrud.git" - nodejs_cpu = 300 # CPU в millicores - nodejs_memory = 512 # память в MB - nodejs_replicas = 1 # количество реплик - nodejs_timeout = "15m" # таймаут операций create/modify + nodejs_git_path = "https://gitea.services.ngcloud.ru/terraform/tfnodejscrud.git" + nodejs_cpu = 300 # CPU в millicores + nodejs_memory = 512 # память в MB + nodejs_replicas = 1 # количество реплик + nodejs_timeout = "15m" # таймаут операций create/modify # ═══════════════════════════════════════════════════════════════════════════ # JDBC — параметры подключения Lucee к PostgreSQL @@ -75,14 +75,14 @@ locals { jdbc_class = "org.postgresql.Driver" jdbc_bundle_name = "org.postgresql.jdbc" jdbc_bundle_version = "42.6.0" - jdbc_conn_limit = "5" # макс. количество соединений - jdbc_live_timeout = "15" # таймаут неактивного соединения (минут) - jdbc_validate = "false" # валидация соединения при выдаче из пула + jdbc_conn_limit = "5" # макс. количество соединений + jdbc_live_timeout = "15" # таймаут неактивного соединения (минут) + jdbc_validate = "false" # валидация соединения при выдаче из пула # ═══════════════════════════════════════════════════════════════════════════ # PostgreSQL — общие параметры подключения # ═══════════════════════════════════════════════════════════════════════════ - pg_port = "5432" # порт PostgreSQL - pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS) + pg_port = "5432" # порт PostgreSQL + pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS) } diff --git a/TMP/backup_2026-09-30/TOOLS/ARCHITECTURE.md b/TMP/backup_2026-09-30/TOOLS/ARCHITECTURE.md new file mode 100644 index 0000000..0a3d12f --- /dev/null +++ b/TMP/backup_2026-09-30/TOOLS/ARCHITECTURE.md @@ -0,0 +1,277 @@ +# Provider Architecture (PRIMARY SOURCE OF TRUTH) + +**⛔ THIS FILE IS THE FOUNDATION. ALL CODE AND SCRIPTS ARE DERIVED FROM IT.** + +This document defines the project-wide architecture and rules for generation, +provider behavior, and documentation. Any change to provider logic MUST be +reflected here FIRST, then implemented in `gen_v2` and other tools. + +## Core Principles + +1) YAML per service is generated ONLY from API data. +2) The provider core (`provider/internal/core`) is universal and must not contain + service-specific logic. +3) Service-specific Go code (`provider/internal/resources_gen`) is fully generated + from YAML. No manual edits to generated code. +4) Documentation is generated from the same YAML. +5) Build artifacts for 3 OS targets are published to the registry, and docs are + published to the website. +6) `TOOLS/ARCHITECTURE.md` (this file) is the primary spec. Code follows. + +## Service Selection + +- The inclusion list is defined by: `TOOLS/config//services_list.txt` + (по одному списку на стенд; repo-relative path) +- Each line starts with service_id, followed by service name/alias. +- Operation timeouts source is defined by: `TOOLS/config//operation_timeouts.json`. + +## API Endpoint + +The provider supports two API styles, auto-detected by `NUBES_API_ENDPOINT`: +- **Legacy proxy**: contains `index.cfm` → `?endpoint=/path` +- **REST Gateway**: no `index.cfm` → direct path concatenation + +## Unified YAML (Per Service) + +One YAML file per service. This is the only input for: +- Provider code generation +- Documentation generation +- Validation rules + +Required top-level fields: +- name +- service_id +- service_display_name +- service_short_name +- lifecycle +- outputs +- operations +- service_man + +### Operations: Kinds and Rules + +Each operation has a kind: + +**instance** — CRUD for the service instance, plus suspend/resume: +| API action | Terraform behavior | +|---|---| +| create | `terraform apply` (new resource) | +| delete | `terraform destroy` | +| modify | `terraform apply` (params changed) | +| suspend | `terraform destroy` when `suspend_on_destroy=true` | +| resume | `terraform apply` when `adopt_existing_on_create=true` | + +**subresource** — CRUD for objects inside the service (users, databases, topics): +- Exposed as separate resources: `nubes_{service}_{subresource}` +- Identity = `{parent_instance_uid, subresource_key}` (e.g. `{postgres_id, username}`) +- Supports `adopt_existing_on_create` — if subresource already exists, adopt it instead of failing + +**action** — one-shot operations. **Only `redeploy` is included**: +- `redeploy` → **inline**: field `git_revision` in the main resource. When it changes, call redeploy instead of (or after) modify +- `restart`, `recovery`, `reconcile` → **excluded**. These are manual operational tasks, performed via UI only. Reason: + - `restart` — modify handles pod restart when needed + - `recovery` — creates a new instance from backup, not a modification of existing + - `reconcile` — sync after manual changes; Terraform owns its own state + +### Idempotency Rules + +- instance CRUD is idempotent via standard Terraform behavior. +- subresource CRUD is idempotent by resource identity. +- `redeploy`: idempotent via `git_revision` field — if unchanged, no redeploy + +### Lifecycle Behavior + +- For `instance` resources with `suspend`/`resume`, use explicit flags: + `adopt_existing_on_create` (default `false`) and `suspend_on_destroy` (default `true`). +- Apply decision matrix for suspend-capable services: + - cloud status `missing` or `deleted` -> `create` + - `suspend` + `adopt_existing_on_create=true` + key params match -> `resume` + `adopt` + - `suspend` + `adopt_existing_on_create=false` -> error (explicitly require flag for resume/adopt) + - `suspend` + key params mismatch -> error + - `running` + `adopt_existing_on_create=true` -> adopt/import behavior + - `running` + `adopt_existing_on_create=false` -> error + - `not created` -> error, no auto-adopt/create + - `creating`/`pending`/`failed` -> error +- Conflict diagnostics requirement: + - When `resource_name` already exists and `adopt_existing_on_create=false`, diagnostics must explicitly offer two choices: + 1) change `resource_name` to create a new resource; + 2) import/adopt existing one by setting `adopt_existing_on_create=true` and re-running `apply`. +- Destroy behavior for suspend-capable services: + - `suspend_on_destroy=true` -> call `suspend` + - `suspend_on_destroy=false` -> remove from Terraform state only (no API call) + +### Diagnostics Format + +- Lifecycle diagnostics for plan/apply must be multiline and human-readable. +- Include decision reason and controlling flag in message body. +- Print details as separate lines: `resource_name`, `service_id`, `instance_uid`, + `status`, `status_raw`, `operation_pending`, `operation_in_progress`. + +## Provider Model + +- Core (`provider/internal/core`) is universal: no service-specific logic inside it. +- Generated service resources (`provider/internal/resources_gen`) contain only + schema/params and references. +- Hand-written service resources live in `provider/internal/resources_core` and are + registered in `provider/internal/provider/provider.go` `Resources()`. They are + NOT generated; they must not contain arbitrary service logic, only: + - a schema, and + - wiring between schema fields and the universal core API + (`RunInstanceOperationUniversalByCode`, `ResolveRefSvcParamValue`, etc.). + +### API Resilience + +- Core MUST retry transient 401 errors from Gateway (3 attempts, exponential backoff). + Gateway may temporarily reject valid JWT tokens. +- **CURRENTLY NOT IMPLEMENTED** — `isRetryable` (`core/http.go`) retries only + {429, 502, 503, 504}, NOT 401, and only for GET. This is a known gap versus the + intent above; fix in `core/http.go` `isRetryable`. + +### Generated Code Resilience + +- **Zero-value fallback** (`normalizeUniversalValueV6`): если параметр отсутствует + в пользовательском `.tf`, подставлять zero-value по `dataType`: + - `integer` → `"0"`, `boolean` → `"false"`, `map-fixed` → `"{}"`, `array` → `"[]"`, `string` → `""` + - Это предотвращает NullPointerException на стороне API при добавлении новых полей. +- **map-fixed default из DataDescriptor** (`buildMapFixedDefault`): если API возвращает + `dataDescriptor` для `map-fixed`-параметра, а значение отсутствует или равно `"{}"`, + строится JSON из дефолтов sub-параметров (`{"type":"off","durationCA":"175200",...}`). + Это гарантирует что API получит все обязательные sub-поля с их значениями по умолчанию. +- **s3Uid-резолв в map-fixed** (`resolveS3UidInMapFixed`): для map-fixed-параметров + парсится JSON, ищутся ключи по паттерну `s3.*uid` (case-insensitive), значения-не-UUID + резолвятся в UUID через S3 (сервис 12). Не зависит от DataDescriptor API. + Соглашение об именах: любой sub-param с `s3`+`uid` в имени → S3 (12). +- **modify всегда через WithDefaults** (`RunInstanceOperationUniversalWithDefaults`): + modify-операции запрашивают `cfsParams` у API и отправляют все параметры, + включая новые, с дефолтами из API. +- **Nil-guard для nested-параметров** (шаблон `instance.go`): `map-fixed`-параметры + (указатели на вложенные структуры) проверяются на nil перед доступом к sub-полям. + Если состояние создано до добавления нового `map-fixed`-параметра — он будет nil, + и код не должен падать с nil pointer dereference. Вместо этого параметр пропускается, + и zero-value fallback подставит `{}`. +- **Nested-атрибуты с default — Optional без Computed** (`NestedSchemaBlock` в `helpers.go`): + `map-fixed`-параметры с default генерируются как `Optional: true` (без `Computed`), + потому что провайдер не вычисляет nested-значения из API. `Computed` приводил бы к + `unknown`-значению, которое нельзя декодировать в конкретный тип `*Struct`. +- **Merge SubParams union** (`params.go`): при совпадении `Code` параметра в разных + операциях (create/modify) с разными наборами sub-params, `Merge()` объединяет их + union'ом, а не отбрасывает второй. Это гарантирует что структура содержит все поля. + +### Subresource Resources + +Subresource operations are exposed as standard resources. +Example mapping: +- create_user/delete_user/modify_user => nubes__user +- create_database/delete_database => nubes__database + +Example HCL: + +resource "nubes_postgres_user" "user1" { + postgres_id = nubes_postgres.db.id + username = "app_user" + role = "app_user" +} + +### Redeploy (inline action) + +Services with `redeploy` operation get a `git_revision` field in the main resource. +Changing `git_revision` triggers `redeploy` instead of `modify`. + +Example HCL: + +resource "nubes_flask" "app" { + resource_name = "my-flask" + git_revision = "abc123" # ← change this to trigger redeploy + app_configuration = jsonencode({...}) +} + +## Concurrency and State Locking + +**Provider-level guarantees:** +- The provider does NOT implement distributed locking for instance operations. +- Two concurrent `terraform apply` with the same `resource_name` may create duplicate + instances, leading to a «multiple instances found» error on subsequent applies. + +**User responsibility:** +- Use Terraform backend with state locking (S3+DynamoDB, etc.). +- Do NOT run `terraform apply` from two workspaces against the same state simultaneously. +- If duplicates occur: delete extras via Cloud Console and re-apply. + +**API-side limitations:** +- Nubes API does not enforce unique `displayName` per serviceId. +- The provider cannot atomically guarantee «create-or-adopt» without API support + for conditional creation or name uniqueness constraints. + +## Documentation Model + +From the unified YAML, generate: +- Resource page: CRUD params, outputs, lifecycle defaults, operations summary +- MAN page: service_man + parameter man blocks +- Resources index: each resource links to its page and its MAN page + +## Pipeline Overview (DevOps) + +1) Generate unified YAML from API for services_list.txt. +2) Generate provider code from YAML. +3) Generate documentation from YAML. +4) Build provider for linux/windows/darwin. +5) Upload provider artifacts to registry. +6) Build and publish docs to site. + +## Lifecycle Vocabulary (single contract) + +⚠️ The destroy-behaviour vocabulary is currently INCONSISTENT across resource kinds: + +1. generated instance resources: runtime flags `suspend_on_destroy` / `keep_on_destroy`; +2. generated modifiers: compile-time `delete_strategy` (no runtime flag); +3. hand-written modifiers: runtime `keep_on_destroy` only. + +All three express the same intent ("what happens to the platform effect on destroy"). +Canonical direction: one unified vocabulary/contract for all resource kinds. + +## Non-Negotiable Rules + +- No manual edits to generated YAML or generated Go code. +- Any change to generated code must come from API or generator logic updates. +- The generator must enforce these rules and fail fast on drift. + +## Exception Registry (service-specific DATA, never logic) + +Principle: provider core and generator logic are universal for all stands and +services. Service-specific deviations are of two kinds: + +1. **Generated modifiers** — a `modify` op that should become a dedicated modifier + resource. The generator (`TOOLS/resource-generator/internal/loader/loader.go`) + supports `kind: modifier` with `delete_strategy` (`noop_warn`/`inverse`/`error`) + and `idempotency` (`none`/`check_before_run`). The overlay data file + `modifiers.yaml` that would drive this is **documented but NOT yet created**; + until then, modifiers are hand-written in `provider/internal/resources_core/`. + (The legacy registry `serviceSpecificModifiers` in `TOOLS/yaml-generator/main.go` + was REMOVED during the 2026-09-23 refactoring.) +2. **Doc examples** — named registry: + +| Registry | File | Declares | +|---|---|---| +| `serviceSpecificDocExamples` | `TOOLS/docs-generator/internal/writers/writers.go` | per-service doc examples, gated on service name + required state/vault keys | + +Rules: + +- Key by stable service NAME (slug), never by raw numeric ID. +- Each entry answers WHAT / WHAT IT DOES / WHY / WHERE (see code comments). +- Doc-example exception = editing the named registry above → visible in diff. +- Modifier exception (until `modifiers.yaml` exists) = hand-written resource in + `provider/internal/resources_core/` + registration in `provider.go`. +- Never annotate API-YAML: it is machine-regenerated and edits would be lost. + +Enforced by scripts (run before build/commit): + +- `TOOLS/scripts/check_generated_drift.sh ` — generated Go vs provider copy. +- `TOOLS/scripts/check_hardcoded_service_ids.sh` — forbids `svc.ID == N` / + `ServiceID == N` outside the registries. + +Build rule: `provider/internal/resources_gen` and `provider/resources_yaml` are +ephemeral by design and never a build source. Canonical build is +`03_build_and_upload_provider.sh` (temp copy from `generated//go`); +`build-provider.sh` refuses direct build from `provider/`. For local IDE, +`go build` and `go test`, materialize one stand first: +`TOOLS/scripts/dev-materialize.sh ` (output is git-ignored). diff --git a/TMP/backup_2026-09-30/TOOLS/scripts/check_hardcoded_service_ids.sh b/TMP/backup_2026-09-30/TOOLS/scripts/check_hardcoded_service_ids.sh new file mode 100755 index 0000000..12932c4 --- /dev/null +++ b/TMP/backup_2026-09-30/TOOLS/scripts/check_hardcoded_service_ids.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# check_hardcoded_service_ids.sh — запрет сервис-специфичных хардкодов по числовому ID. +# +# Ищет сравнения вида svc.ID == N / ServiceID == N / spec.ServiceID == N (N > 0) +# в Go-коде TOOLS/. Исключения должны жить ТОЛЬКО в именованных реестрах (данные): +# - TOOLS/yaml-generator/main.go (serviceSpecificModifiers) +# - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples) +# +# Выход: 0 — хардкодов нет; 1 — найдены. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" + +matches="$(grep -rnE '\.(ServiceID|ID)[[:space:]]*==[[:space:]]*[1-9][0-9]*' "$ROOT_DIR/TOOLS" --include='*.go' || true)" + +if [[ -n "$matches" ]]; then + echo "HARDCODED SERVICE ID FOUND (service-specific logic must live in a registry):" >&2 + echo "$matches" >&2 + echo "" >&2 + echo "Вынеси исключение в один из реестров:" >&2 + echo " - TOOLS/yaml-generator/main.go (serviceSpecificModifiers)" >&2 + echo " - TOOLS/docs-generator/internal/writers/writers.go (serviceSpecificDocExamples)" >&2 + echo "См. TOOLS/ARCHITECTURE.md, раздел «Реестр исключений»." >&2 + exit 1 +fi + +echo "OK: no hardcoded service IDs in TOOLS/." diff --git a/TMP/backup_2026-09-30/provider/internal/core/http.go b/TMP/backup_2026-09-30/provider/internal/core/http.go new file mode 100644 index 0000000..ba5f8c5 --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/core/http.go @@ -0,0 +1,169 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "os" + "strings" + "time" +) + +// ===== Internal HTTP helpers ===== + +func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) { + // User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md). + // Go-http-client по умолчанию блокируется фильтром ddos-guard. + const maxRetries = 3 + baseDelay := c.RetryBaseDelay + if baseDelay <= 0 { + baseDelay = 2 * time.Second + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + delay := baseDelay * time.Duration(1<<(attempt-1)) // 2s, 4s, 8s + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + } + + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + body = bytes.NewBuffer(b) + } + + reqURL := c.buildURL(path) + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) + if err != nil { + return nil, nil, err + } + + // Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive. + // GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard. + if method != "GET" { + req.Close = true + } + + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "*/*") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + // DEBUG + if isHTTPDebugEnabled() { + reqForDump := req.Clone(req.Context()) + reqForDump.Header = sanitizeAuthHeader(req.Header) + dump, _ := httputil.DumpRequestOut(reqForDump, body != nil) + fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump) + } + + // DEBUG в файл + if isHTTPDebugEnabled() { + f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if f != nil { + fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String()) + f.Close() + } + } + resp, err := c.HttpClient.Do(req) + if err != nil { + lastErr = err + // Сетевые ошибки: retry только для идемпотентного GET. + if attempt < maxRetries && method == "GET" { + continue + } + return nil, nil, err + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + if attempt < maxRetries { + continue + } + return nil, nil, err + } + + // Retry только для transient-ошибок и только для GET + if resp.StatusCode >= 400 { + if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) { + lastErr = formatAPIError(resp.StatusCode, respBody) + continue + } + return nil, nil, formatAPIError(resp.StatusCode, respBody) + } + + return respBody, resp.Header, nil + } + + return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr) +} + +// isRetryable returns true for transient HTTP errors that can be retried. +func isRetryable(statusCode int) bool { + return statusCode == http.StatusTooManyRequests || // 429 + statusCode == http.StatusServiceUnavailable || // 503 + statusCode == http.StatusBadGateway || // 502 + statusCode == http.StatusGatewayTimeout // 504 +} + +func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) { + respBody, headers, err := c.doRequest(ctx, "POST", path, payload) + if err != nil { + return "", err + } + + if returnLocation { + if loc := headers.Get("Location"); loc != "" { + return extractUIDFromLocation(loc), nil + } + } + + var justId string + if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" { + return justId, nil + } + + return "", nil +} + +func extractUIDFromLocation(loc string) string { + if loc == "" { + return "" + } + return strings.TrimPrefix(loc, "./") +} + +// formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение. +// Если тело не является JSON с полем ERROR — возвращает сырой текст. +func formatAPIError(statusCode int, body []byte) error { + var parsed struct { + Error string `json:"ERROR"` + Detail string `json:"DETAIL"` + } + if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" { + msg := parsed.Error + if d := strings.TrimSpace(parsed.Detail); d != "" { + msg += ": " + d + } + return fmt.Errorf("ошибка API %d: %s", statusCode, msg) + } + return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body))) +} diff --git a/TMP/backup_2026-09-30/provider/internal/core/operation_run.go b/TMP/backup_2026-09-30/provider/internal/core/operation_run.go new file mode 100644 index 0000000..60840cd --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/core/operation_run.go @@ -0,0 +1,249 @@ +package core + +import ( + "context" + "fmt" + "strings" + "time" +) + +// ===== Запуск операций инстанса (params по ID) ===== +// +// - RunInstanceOperationUniversal — операция с params по ID (без досылки дефолтов) +// - RunInstanceOperationUniversalWithDefaults — то же + досылка required-дефолтов +// - RunRedeployOperation — redeploy +// +// Операции с params по code — см. operation_run_bycode.go. +// Схема cfsParams (с fallback) — см. operation_cfs.go. + +// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible. +func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + if state.OperationIsPending || state.OperationIsInProgress { + if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil { + return err + } + } + + var opId int + for _, op := range state.AvailableOperations { + if strings.EqualFold(op.Operation, action) { + opId = op.SvcOperationId + break + } + } + if opId == 0 { + return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid) + } + + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": action, + } + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("не удалось создать операцию %s: %w", action, err) + } + if opUid == "" { + return fmt.Errorf("не удалось получить UID операции для %s", action) + } + + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err) + } + } + + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return err + } + + // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish + return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action)) +} + +// RunInstanceOperationUniversalWithDefaults runs an operation and submits required params (including defaults). +func (c *UniversalClient) RunInstanceOperationUniversalWithDefaults(ctx context.Context, instanceUid string, action string, params map[int]string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + if state.OperationIsPending || state.OperationIsInProgress { + if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil { + return err + } + } + + var opId int + for _, op := range state.AvailableOperations { + if strings.EqualFold(op.Operation, action) { + opId = op.SvcOperationId + break + } + } + if opId == 0 { + return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid) + } + + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": action, + } + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("не удалось создать операцию %s: %w", action, err) + } + if opUid == "" { + return fmt.Errorf("не удалось получить UID операции для %s", action) + } + + cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId) + if err != nil { + return err + } + + params, err = c.resolveRefSvcParamValues(ctx, cfsParams, params) + if err != nil { + return err + } + + sent := make(map[int]bool) + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err) + } + sent[paramId] = true + } + + live, liveErr := c.instanceLiveParams(ctx, instanceUid) + if liveErr != nil { + return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr) + } + for _, param := range cfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + + // Приоритет: live state.params инстанса → paramValue операции → defaultValue. + // paramValue из cfsParams — дефолт формы, не состояние инстанса (см. operation_cfs.go). + // Симметрично runInstanceOperationByCode: если ни одного источника нет — пропускаем + // (иначе уйдёт синтетический "0"/"false"/"[]" и нарушит constraint). + val, hasLive := lookupLiveParam(live, param) + if !hasLive { + if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") && + (param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") { + continue + } + if param.ParamValue != nil && strings.TrimSpace(*param.ParamValue) != "" { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + } + val = normalizeUniversalValueV6(val, param) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err) + } + } + + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return fmt.Errorf("валидация не пройдена: %w", err) + } + + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return err + } + + // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish + return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action)) +} + +// RunRedeployOperation запускает redeploy для сервисов, поддерживающих пересборку из git. +// Если params не nil — отправляет CFS-параметры перед запуском. +func (c *UniversalClient) RunRedeployOperation(ctx context.Context, instanceUid string, timeoutOverride string, params map[int]string) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + if state.OperationIsPending || state.OperationIsInProgress { + if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil { + return err + } + } + + opId := 0 + for _, op := range state.AvailableOperations { + if strings.EqualFold(op.Operation, "redeploy") { + opId = op.SvcOperationId + break + } + } + if opId == 0 { + return fmt.Errorf("операция redeploy недоступна для экземпляра %s", instanceUid) + } + + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": "redeploy", + } + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("не удалось создать операцию redeploy: %w", err) + } + if opUid == "" { + return fmt.Errorf("не удалось получить UID операции redeploy") + } + + for paramId, value := range params { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + if _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload); err != nil { + return fmt.Errorf("не удалось установить параметр %d для redeploy: %w", paramId, err) + } + } + + if _, _, err := c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}); err != nil { + return err + } + + timeout := c.operationTimeoutForContext(ctx, state.ServiceId, "redeploy") + if timeoutOverride != "" { + if d, parseErr := time.ParseDuration(timeoutOverride); parseErr == nil { + timeout = d + } + } + return c.waitForOperationFinish(ctx, opUid, timeout) +} diff --git a/TMP/backup_2026-09-30/provider/internal/core/operation_run_bycode.go b/TMP/backup_2026-09-30/provider/internal/core/operation_run_bycode.go new file mode 100644 index 0000000..964df75 --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/core/operation_run_bycode.go @@ -0,0 +1,162 @@ +package core + +import ( + "context" + "fmt" + "strings" +) + +// RunInstanceOperationUniversalByCode runs an operation using params keyed by code. +// It resolves param codes to IDs via operation manifest, applies defaults, validates, and runs. +func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Context, instanceUid string, action string, params map[string]string) error { + return c.runInstanceOperationByCode(ctx, instanceUid, action, params, false) +} + +// RunInstanceOperationUniversalByIdempotent — то же, но с idempotency pre-check: +// перед run сверяет desired==current и, если ВСЕ поля совпали, пропускает run. +// Применяется для модификаторов с idempotency: check_before_run. +func (c *UniversalClient) RunInstanceOperationUniversalByIdempotent(ctx context.Context, instanceUid string, action string, params map[string]string) error { + return c.runInstanceOperationByCode(ctx, instanceUid, action, params, true) +} + +func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instanceUid string, action string, params map[string]string, idempotent bool) error { + state, err := c.GetInstanceState(ctx, instanceUid) + if err != nil { + return err + } + if state.OperationIsPending || state.OperationIsInProgress { + if err := c.waitForInstanceIdle(ctx, instanceUid, c.idleTimeoutFor(state.ServiceId)); err != nil { + return err + } + } + + var opId int + for _, op := range state.AvailableOperations { + if strings.EqualFold(op.Operation, action) { + opId = op.SvcOperationId + break + } + } + if opId == 0 { + return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid) + } + + payload := map[string]interface{}{ + "instanceUid": instanceUid, + "svcOperationId": opId, + "operation": action, + } + + opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) + if err != nil { + return fmt.Errorf("не удалось создать операцию %s: %w", action, err) + } + if opUid == "" { + return fmt.Errorf("не удалось получить UID операции для %s", action) + } + + cfsParams, err := c.fetchOperationCfsParams(ctx, opUid, opId) + if err != nil { + return err + } + + // Idempotency pre-check: если все desired уже равны live-значениям — пропускаем run. + // desired = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки. + if idempotent && c.modifierDesiredEqualsCurrent(params, cfsParams) { + return nil + } + + codeToParam := make(map[string]universalCfsParam) + for _, p := range cfsParams { + if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" { + codeToParam[key] = p + } + if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" { + codeToParam[key] = p + } + } + + paramsByID := map[int]string{} + for code, value := range params { + key := strings.ToLower(strings.TrimSpace(code)) + p, ok := codeToParam[key] + if !ok { + return fmt.Errorf("код параметра %s не найден для операции %s", code, action) + } + paramsByID[p.SvcOperationCfsParamId] = value + } + + paramsByID, err = c.resolveRefSvcParamValues(ctx, cfsParams, paramsByID) + if err != nil { + return err + } + + sent := make(map[int]bool) + for paramId, value := range paramsByID { + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: paramId, + ParamValue: value, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("не удалось установить параметр %d: %w", paramId, err) + } + sent[paramId] = true + } + + live, liveErr := c.instanceLiveParams(ctx, instanceUid) + if liveErr != nil { + return fmt.Errorf("не удалось прочитать live-значения инстанса для досылки modify: %w", liveErr) + } + for _, param := range cfsParams { + if sent[param.SvcOperationCfsParamId] { + continue + } + // Дозаполняем ВСЕ незаданные параметры, чтобы бэкенд modify не трактовал + // пропущенный/null как reset-to-default (иначе частичный payload затирает + // create-поля, см. prompt_for_opus_modifier_null_bug.md). + // + // ПРИОРИТЕТ ИСТОЧНИКА: live state.params инстанса → paramValue операции → defaultValue. + // КРИТИЧНО: paramValue из ?fields=cfsParams — дефолт ФОРМЫ операции, не состояние + // инстанса (HAR/edge_.har: needEnableAVI paramValue="false" при live=true → стирало ALB). + // Если ни live, ни paramValue, ни defaultValue НЕТ — пропускаем (не шлём синтетический + // "0"/"false"/"[]", который может нарушить constraint "integer > 0"). + val, hasLive := lookupLiveParam(live, param) + if !hasLive { + if (param.ParamValue == nil || strings.TrimSpace(*param.ParamValue) == "") && + (param.DefaultValue == nil || strings.TrimSpace(*param.DefaultValue) == "") { + continue + } + if param.ParamValue != nil { + val = *param.ParamValue + } else if param.DefaultValue != nil { + val = *param.DefaultValue + } + } + val = normalizeUniversalValueV6(val, param) + + pPayload := genericParamReq{ + InstanceOperationUid: opUid, + SvcOperationCfsParamId: param.SvcOperationCfsParamId, + ParamValue: val, + } + _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) + if err != nil { + return fmt.Errorf("не удалось отправить параметр по умолчанию %d: %w", param.SvcOperationCfsParamId, err) + } + } + + _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) + if err != nil { + return fmt.Errorf("валидация не пройдена: %w", err) + } + + _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) + if err != nil { + return err + } + + // НЕ МЕНЯТЬ: завершение операции определяется по dtFinish + return c.waitForOperationFinish(ctx, opUid, c.operationTimeoutForContext(ctx, state.ServiceId, action)) +} diff --git a/TMP/backup_2026-09-30/provider/internal/core/params.go b/TMP/backup_2026-09-30/provider/internal/core/params.go new file mode 100644 index 0000000..6abb3c1 --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/core/params.go @@ -0,0 +1,61 @@ +package core + +import ( + "encoding/json" + "strings" +) + +// ===== Нормализация значений CFS-параметров ===== + +// buildMapFixedDefault строит JSON-объект из дефолтов sub-параметров map-fixed. +func buildMapFixedDefault(param universalCfsParam) string { + result := make(map[string]string, len(param.DataDescriptor)) + for key, sub := range param.DataDescriptor { + result[key] = sub.DefaultValue + } + b, err := json.Marshal(result) + if err != nil { + return "{}" + } + return string(b) +} + +func normalizeUniversalValueV6(val string, param universalCfsParam) string { + trimmed := strings.TrimSpace(val) + if strings.EqualFold(trimmed, "null") { + trimmed = "" + } + if trimmed == "\"\"" { + trimmed = "" + } + + // map-fixed с DataDescriptor: если значение пустое или "{}" — строим JSON из дефолтов sub-params. + dataType := strings.ToLower(param.DataType) + if (dataType == "map-fixed" || strings.HasPrefix(dataType, "map")) && len(param.DataDescriptor) > 0 { + if trimmed == "" || trimmed == "{}" { + return buildMapFixedDefault(param) + } + return trimmed + } + + if trimmed != "" { + return trimmed + } + + nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) + + if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { + return "[]" + } + if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { + return "{}" + } + if strings.Contains(dataType, "integer") || strings.Contains(dataType, "int") { + return "0" + } + if strings.Contains(dataType, "boolean") || strings.Contains(dataType, "bool") { + return "false" + } + + return trimmed +} diff --git a/TMP/backup_2026-09-30/provider/internal/resources_core/nsxt_snat_resource.go b/TMP/backup_2026-09-30/provider/internal/resources_core/nsxt_snat_resource.go new file mode 100644 index 0000000..d545348 --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/resources_core/nsxt_snat_resource.go @@ -0,0 +1,258 @@ +package resources_core + +import ( + "context" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &NsxtSnatResource{} +var _ resource.ResourceWithConfigure = &NsxtSnatResource{} +var _ resource.ResourceWithImportState = &NsxtSnatResource{} + +// NsxtSnatResource включает/выключает SNAT у СУЩЕСТВУЮЩЕГО сетевого шлюза периметра +// (сервис 22, vc_nsxt) через операцию modify с параметром ipSpaceName (id 372). +// +// Зачем отдельный ресурс: ipSpaceName есть ТОЛЬКО в операции modify (в create его нет), +// поэтому одним ресурсом «create + modify» в одном apply не сделать. +// +// Канонические значения (HAR/edge_.har, NOTES/30_analysis/HAR_SNAT_MODIFY_FINDINGS.md): +// - включить SNAT: ip_space_name = "<имя ipSpace из аллокации организации>"; +// - выключить SNAT: ip_space_name = "no-needed" (легальное значение платформы). +type NsxtSnatResource struct { + client *core.UniversalClient +} + +type NsxtSnatModel struct { + ID types.String `tfsdk:"id"` + NsxtUID types.String `tfsdk:"nsxt_uid"` + IpSpaceName types.String `tfsdk:"ip_space_name"` + KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"` +} + +// noNeededIpSpace — каноническое значение «SNAT не нужен». +const noNeededIpSpace = "no-needed" + +func NewNsxtSnatResource() resource.Resource { + return &NsxtSnatResource{} +} + +func (r *NsxtSnatResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vc_nsxt_snat" +} + +func (r *NsxtSnatResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "SNAT (ipSpaceName) на существующем сетевом шлюзе периметра. " + + "Шлюз создаётся отдельным ресурсом `nubes_vc_nsxt`, здесь задаётся только SNAT. " + + "Значение `no-needed` выключает SNAT.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "nsxt_uid": schema.StringAttribute{ + Required: true, + MarkdownDescription: "UUID существующей услуги «Сетевой шлюз периметра (Edge)».", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ip_space_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Имя ipSpace для внешнего IP (SNAT). Значение `no-needed` выключает SNAT. " + + "Имя должно быть выделено на организации (см. `nubes_vc_org_ip_allocation`).", + }, + "keep_on_destroy": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Не выключать SNAT при `destroy` (по умолчанию `false` — отправляется " + + "`ipSpaceName = \"no-needed\"`).", + }, + }, + } +} + +func (r *NsxtSnatResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan NsxtSnatModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *NsxtSnatResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan NsxtSnatModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.setSnat(ctx, plan.NsxtUID, plan.IpSpaceName); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(strings.TrimSpace(plan.NsxtUID.ValueString())) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *NsxtSnatResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state NsxtSnatModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString()) + if nsxtUID == "" || r.client == nil { + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.State.RemoveResource(ctx) + return + } + + live, err := r.client.GetInstanceStateParams(ctx, nsxtUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + // ВАЖНО: в Required-атрибут нельзя писать null — после apply state обязан совпасть с планом, + // иначе Terraform вернёт "Provider produced inconsistent result after apply". Если ключа ещё нет + // (SNAT ни разу не включали, HAR fresh-create) — оставляем текущее значение state. + if raw, ok := live["ipSpaceName"]; ok && strings.TrimSpace(raw) != "" { + state.IpSpaceName = types.StringValue(strings.TrimSpace(raw)) + } + + state.ID = types.StringValue(nsxtUID) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *NsxtSnatResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state NsxtSnatModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + nsxtUID := strings.TrimSpace(state.NsxtUID.ValueString()) + if nsxtUID == "" || r.client == nil { + return + } + + if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() { + resp.Diagnostics.AddWarning( + "SNAT не выключался", + fmt.Sprintf("keep_on_destroy = true: ipSpaceName шлюза %s оставлен без изменений.", nsxtUID), + ) + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, nsxtUID) + if err != nil { + // Реальная ошибка API (не «шлюза нет») — нельзя молча терять SNAT: ресурс уйдёт из state, + // а SNAT останется включённым. + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.Diagnostics.AddWarning( + "SNAT не выключался", + fmt.Sprintf("шлюз %s не найден — обратный modify пропущен.", nsxtUID), + ) + return + } + + unlock := r.client.LockInstance(nsxtUID) + defer unlock() + + // Обратный modify: каноническое «SNAT выключен» = no-needed (подтверждено HAR). + if err := r.client.RunInstanceOperationUniversalByCode(ctx, nsxtUID, "modify", map[string]string{ + "ipSpaceName": noNeededIpSpace, + }); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + resp.Diagnostics.AddWarning( + "SNAT выключен", + fmt.Sprintf("по шлюзу %s отправлен modify с ipSpaceName = %q.", nsxtUID, noNeededIpSpace), + ) +} + +func (r *NsxtSnatResource) 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 (r *NsxtSnatResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + uid := strings.TrimSpace(req.ID) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("nsxt_uid"), uid)...) +} + +// setSnat отправляет modify только с ipSpaceName. Остальные параметры операции +// (needEnableAVI, virtualServicesCount, qosProfile, routedNetConfiguration) досылаются +// клиентом из LIVE-состояния инстанса — приоритет live → paramValue формы → default +// (core/operation_run_bycode.go), поэтому частичный payload ничего не затирает. +func (r *NsxtSnatResource) setSnat(ctx context.Context, nsxtUID types.String, ipSpaceName types.String) error { + uid := strings.TrimSpace(nsxtUID.ValueString()) + if uid == "" { + return fmt.Errorf("nsxt_uid обязателен") + } + if r.client == nil { + return fmt.Errorf("клиент не инициализирован") + } + + // Пустую строку молча подменять нельзя (скрытое поведение + риск вечного diff). + // Выключение SNAT — явное каноническое значение "no-needed". + value := strings.TrimSpace(ipSpaceName.ValueString()) + if value == "" { + return fmt.Errorf("ip_space_name не может быть пустым: укажите имя ipSpace или %q для выключения SNAT", noNeededIpSpace) + } + + unlock := r.client.LockInstance(uid) + defer unlock() + + // ByCode, а не ByIdempotent: idempotency-сравнение идёт с paramValue ФОРМЫ операции, + // а не с live-состоянием инстанса — можно ложно пропустить modify. + return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{ + "ipSpaceName": value, + }) +} diff --git a/TMP/backup_2026-09-30/provider/internal/resources_core/org_ip_allocation_resource.go b/TMP/backup_2026-09-30/provider/internal/resources_core/org_ip_allocation_resource.go new file mode 100644 index 0000000..da5a11a --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/resources_core/org_ip_allocation_resource.go @@ -0,0 +1,413 @@ +package resources_core + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "terraform-provider-nubes/internal/core" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.Resource = &OrgIpAllocationResource{} +var _ resource.ResourceWithConfigure = &OrgIpAllocationResource{} +var _ resource.ResourceWithImportState = &OrgIpAllocationResource{} + +// OrgIpAllocationResource управляет аллокацией внешних IP на СУЩЕСТВУЮЩЕЙ организации +// (сервис 19, vc_org) через операцию modify с параметром vIPConfigure (id 662). +// +// Организация НЕ управляется Terraform: она создаётся один раз вручную в ЛК +// и адресуется здесь по uid. +// +// Семантика операции — replace всего массива: переданное значение полностью заменяет +// текущую аллокацию (проверено тестом NOTES/30_analysis/ORG_IP_MODIFIER_TEST_2026-09-22.md). +// Поэтому ресурс владеет массивом ЦЕЛИКОМ, а не отдельным элементом. +type OrgIpAllocationResource struct { + client *core.UniversalClient +} + +type OrgIpAllocationModel struct { + ID types.String `tfsdk:"id"` + Organization types.String `tfsdk:"organization"` + VIPConfigure types.String `tfsdk:"vip_configure"` + KeepOnDestroy types.Bool `tfsdk:"keep_on_destroy"` +} + +// vipAllocation — элемент массива vIPConfigure. count ВСЕГДА строка: +// ЛК присылает его строкой (HAR/globak.har), API принимает строкой. +type vipAllocation struct { + Name string + Count string +} + +func NewOrgIpAllocationResource() resource.Resource { + return &OrgIpAllocationResource{} +} + +func (r *OrgIpAllocationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vc_org_ip_allocation" +} + +func (r *OrgIpAllocationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Аллокация внешних IP (vIPConfigure) на существующей организации Cloud Director. " + + "Организация создаётся вручную в ЛК, в конфиге указывается её имя или UUID. " + + "Операция имеет replace-семантику: массив перезаписывается целиком.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Организация, на которой выделяются внешние IP: имя из ЛК (например `organ`) " + + "или её UUID.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "vip_configure": schema.StringAttribute{ + Required: true, + MarkdownDescription: "JSON-массив аллокаций: `[{\"name\":\"internet-ipv4-v1\",\"count\":\"3\"}]`. " + + "Значение перезаписывает текущую аллокацию целиком. `count` — строка. " + + "Порядок ключей и форматирование не важны (сравнение смысловое). " + + "Снять аллокацию (`[]`) через этот атрибут **нельзя** — только удалением ресурса (`destroy`).", + }, + "keep_on_destroy": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + MarkdownDescription: "Не снимать аллокацию IP при `destroy` (по умолчанию `false` — квота обнуляется, " + + "`count=0` по каждому элементу).", + }, + }, + } +} + +func (r *OrgIpAllocationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan OrgIpAllocationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(orgUID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *OrgIpAllocationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan OrgIpAllocationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + orgUID, err := r.resolveOrganizationUID(ctx, plan.Organization) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + if err := r.applyAllocation(ctx, orgUID, plan.VIPConfigure); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + plan.ID = types.StringValue(orgUID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *OrgIpAllocationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state OrgIpAllocationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil { + return + } + + orgUID, err := r.resolveOrganizationUID(ctx, state.Organization) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, orgUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + // Организации больше нет — ресурс тоже не нужен. + resp.State.RemoveResource(ctx) + return + } + + live, err := r.client.GetInstanceStateParams(ctx, orgUID) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + // Атрибут принадлежит пользователю: НЕ переписываем его, если смысл совпал — иначе Terraform + // увидит расхождение config vs state и покажет ложный дрейф (jsonencode отдаёт ключи по алфавиту). + // Писать null в Required-атрибут тоже нельзя (это даёт "Provider produced inconsistent result"). + raw, ok := live["vIPConfigure"] + if ok { + liveItems, parseErr := parseVipConfigure(raw) + if parseErr != nil { + resp.Diagnostics.AddError("Ошибка чтения состояния", parseErr.Error()) + return + } + stateItems, _ := parseVipConfigure(state.VIPConfigure.ValueString()) + if !vipAllocationsEqual(liveItems, stateItems) { + state.VIPConfigure = types.StringValue(formatVipConfigure(liveItems)) + } + } + + state.ID = types.StringValue(orgUID) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *OrgIpAllocationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state OrgIpAllocationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if strings.TrimSpace(state.Organization.ValueString()) == "" || r.client == nil { + return + } + + orgUID, err := r.resolveOrganizationUID(ctx, state.Organization) + if err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + if !state.KeepOnDestroy.IsNull() && !state.KeepOnDestroy.IsUnknown() && state.KeepOnDestroy.ValueBool() { + resp.Diagnostics.AddWarning( + "Аллокация IP не снималась", + fmt.Sprintf("keep_on_destroy = true: квота внешних IP организации %s оставлена без изменений.", orgUID), + ) + return + } + + remove, err := ShouldRemoveFromState(ctx, r.client, orgUID) + if err != nil { + // Реальная ошибка API (не «инстанса нет») — нельзя молча терять квоту: ресурс уйдёт из state, + // а выделенные IP останутся висеть. + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + if remove { + resp.Diagnostics.AddWarning( + "Аллокация IP не снималась", + fmt.Sprintf("организация %s не найдена — обратный modify пропущен.", orgUID), + ) + return + } + + unlock := r.client.LockInstance(orgUID) + defer unlock() + + // Имена берём из LIVE-состояния (что реально выделено), при неудаче — из конфигурации. + items := []vipAllocation{} + if live, liveErr := r.client.GetInstanceStateParams(ctx, orgUID); liveErr == nil { + if parsed, parseErr := parseVipConfigure(live["vIPConfigure"]); parseErr == nil { + items = parsed + } + } + if len(items) == 0 { + if parsed, parseErr := parseVipConfigure(state.VIPConfigure.ValueString()); parseErr == nil { + items = parsed + } + } + if len(items) == 0 { + resp.Diagnostics.AddWarning( + "Аллокация IP не снималась", + "не удалось определить выделенные ipSpace — обратный modify пропущен.", + ) + return + } + + // Обратный modify: тот же массив, но count=0 (форма проверена тестом 09-22). + // Пустой массив `[]` НЕ отправляем — его семантика на платформе не проверена. + zero := make([]vipAllocation, 0, len(items)) + for _, item := range items { + zero = append(zero, vipAllocation{Name: item.Name, Count: "0"}) + } + + if err := r.client.RunInstanceOperationUniversalByCode(ctx, orgUID, "modify", map[string]string{ + "vIPConfigure": formatVipConfigure(zero), + }); err != nil { + resp.Diagnostics.AddError("Ошибка клиента", err.Error()) + return + } + + resp.Diagnostics.AddWarning( + "Квота IP обнулена", + fmt.Sprintf("по организации %s отправлен modify с count=0: %s", orgUID, formatVipConfigure(zero)), + ) +} + +func (r *OrgIpAllocationResource) 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 (r *OrgIpAllocationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + uid := strings.TrimSpace(req.ID) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), uid)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization"), uid)...) +} + +// resolveOrganizationUID принимает имя организации из ЛК или её UUID и возвращает UUID. +// Резолв делает клиент — тем же путём, что сгенерированный nubes_vc_vdc +// (core.ResolveRefSvcParamValue, сравн. 21_vc_vdc_resource.go). +func (r *OrgIpAllocationResource) resolveOrganizationUID(ctx context.Context, organization types.String) (string, error) { + if r.client == nil { + return "", fmt.Errorf("клиент не инициализирован") + } + raw := strings.TrimSpace(organization.ValueString()) + if raw == "" { + return "", fmt.Errorf("organization обязателен") + } + + resolved, err := r.client.ResolveRefSvcParamValue(ctx, 19, raw) + if err != nil { + return "", fmt.Errorf("не удалось определить организацию %q: %w", raw, err) + } + resolved = strings.TrimSpace(resolved) + if resolved == "" { + return "", fmt.Errorf("организация %q не найдена", raw) + } + return resolved, nil +} + +// applyAllocation отправляет modify с массивом vIPConfigure целиком. +func (r *OrgIpAllocationResource) applyAllocation(ctx context.Context, orgUID string, vipConfigure types.String) error { + uid := strings.TrimSpace(orgUID) + if uid == "" { + return fmt.Errorf("organization обязателен") + } + if r.client == nil { + return fmt.Errorf("клиент не инициализирован") + } + + items, err := parseVipConfigure(vipConfigure.ValueString()) + if err != nil { + return err + } + if len(items) == 0 { + return fmt.Errorf("vip_configure не содержит ни одной аллокации (name+count)") + } + + unlock := r.client.LockInstance(uid) + defer unlock() + + // Именно ByCode (без idempotency-pre-check): pre-check сравнивает с paramValue ФОРМЫ + // операции, а это не live-состояние инстанса (см. core/modifier_compare.go и + // комментарий в core/operation_cfs.go) — можно было бы ложно пропустить modify. + return r.client.RunInstanceOperationUniversalByCode(ctx, uid, "modify", map[string]string{ + "vIPConfigure": formatVipConfigure(items), + }) +} + +// parseVipConfigure разбирает значение параметра vIPConfigure. +// Пустые элементы (`{}`) — легальное состояние «не выделено» у свежей орги +// (NOTES/30_analysis/HAR_FRESH_CREATE_2026-09-24.md) и отбрасываются. +func parseVipConfigure(raw string) ([]vipAllocation, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil + } + + var items []map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &items); err != nil { + return nil, fmt.Errorf("не удалось разобрать vIPConfigure %q: %w", trimmed, err) + } + + out := make([]vipAllocation, 0, len(items)) + for _, item := range items { + name := "" + if v, ok := item["name"]; ok && v != nil { + name = strings.TrimSpace(fmt.Sprint(v)) + } + if name == "" { + continue + } + count := "0" + if v, ok := item["count"]; ok && v != nil { + if parsed := strings.TrimSpace(fmt.Sprint(v)); parsed != "" { + count = parsed + } + } + out = append(out, vipAllocation{Name: name, Count: count}) + } + return out, nil +} + +// formatVipConfigure собирает канонический payload: [{"name":"…","count":"…"}] +// (порядок ключей name,count; count — строка). Канон ЕДИНЫЙ для отправки и для Read, +// иначе план и state расходятся по строке — см. vipConfigureCanonical. +func formatVipConfigure(items []vipAllocation) string { + if len(items) == 0 { + return "[]" + } + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, fmt.Sprintf(`{"name":%q,"count":%q}`, item.Name, item.Count)) + } + return "[" + strings.Join(parts, ",") + "]" +} + +// vipAllocationsEqual сравнивает аллокации по СМЫСЛУ: порядок элементов и формат не важны. +// Имена ipSpace в рамках организации уникальны, поэтому сравнение идёт по имени. +func vipAllocationsEqual(a, b []vipAllocation) bool { + if len(a) != len(b) { + return false + } + byName := make(map[string]string, len(b)) + for _, item := range b { + byName[item.Name] = item.Count + } + for _, item := range a { + count, ok := byName[item.Name] + if !ok || count != item.Count { + return false + } + } + return true +} diff --git a/TMP/backup_2026-09-30/provider/internal/resources_core/state_refresh.go b/TMP/backup_2026-09-30/provider/internal/resources_core/state_refresh.go new file mode 100644 index 0000000..fdef223 --- /dev/null +++ b/TMP/backup_2026-09-30/provider/internal/resources_core/state_refresh.go @@ -0,0 +1,257 @@ +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 + } + // Поле модели достаём ДО проверки наличия значения в API: оно нужно + // и в ветке "API не вернул код" (см. схлопывание unknown → null ниже). + fieldName := strings.TrimSpace(input.Field) + if fieldName == "" { + fieldName = toCamel(code) + } + fv := v.FieldByName(fieldName) + if !fv.IsValid() || !fv.CanSet() { + continue + } + + value, ok := paramsMap[code] + if !ok { + // ИНВАРИАНТ: Computed-атрибут обязан быть KNOWN после apply/read. + // + // Параметры, которые провайдер читает обратно, объявлены в схеме как + // Optional+Computed (см. helpers.ShouldBeOptionalComputed). Если + // пользователь такой параметр не задал, в плане он = unknown, и именно + // провайдер обязан проставить конкретное значение. Когда платформа + // не вернула код в state_params, единственное корректное конкретное + // значение — null. + // + // Если оставить unknown, Terraform упадёт с + // "Provider produced invalid result object after apply: ... was unknown". + if inputFieldUnknown(fv) { + setInputFieldNull(fv) + } + 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 +} + +// inputFieldUnknown сообщает, находится ли поле модели в состоянии unknown. +// +// Зачем: соблюдение инварианта «Computed-атрибут обязан быть known после +// apply/read». Если платформа не вернула значение в state_params, unknown +// оставлять нельзя — его надо схлопнуть в null (см. setInputFieldNull). +func inputFieldUnknown(fv reflect.Value) bool { + switch fv.Type() { + case typeString: + v, ok := fv.Interface().(types.String) + return ok && v.IsUnknown() + case typeBool: + v, ok := fv.Interface().(types.Bool) + return ok && v.IsUnknown() + case typeInt64: + v, ok := fv.Interface().(types.Int64) + return ok && v.IsUnknown() + } + return false +} + +// setInputFieldNull записывает в поле модели типизированный null. +// +// Зачем: null — это конкретное (known) значение, в отличие от unknown. Именно +// null приводит состояние Terraform в консистентный вид, когда платформа не +// сообщила значение для read-back параметра. +func setInputFieldNull(fv reflect.Value) { + switch fv.Type() { + case typeString: + fv.Set(reflect.ValueOf(types.StringNull())) + case typeBool: + fv.Set(reflect.ValueOf(types.BoolNull())) + case typeInt64: + fv.Set(reflect.ValueOf(types.Int64Null())) + } +} + +func setFieldValue(field reflect.Value, value interface{}) { + switch field.Type() { + case typeString: + switch v := value.(type) { + case types.String: + field.Set(reflect.ValueOf(v)) + case string: + field.Set(reflect.ValueOf(ParseString(v))) + } + case typeBool: + if v, ok := value.(types.Bool); ok { + field.Set(reflect.ValueOf(v)) + } + case typeInt64: + if v, ok := value.(types.Int64); ok { + field.Set(reflect.ValueOf(v)) + } + case typeMap: + if v, ok := value.(types.Map); ok { + field.Set(reflect.ValueOf(v)) + } + case typeList: + if v, ok := value.(types.List); ok { + field.Set(reflect.ValueOf(v)) + } + } +} + +func toCamel(s string) string { + parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' }) + for i, p := range parts { + if len(p) == 0 { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + out := strings.Join(parts, "") + if out == "" { + return "R" + } + first := rune(out[0]) + if (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_' { + return out + } + return "R" + out +} diff --git a/TOOLS/resource-generator/internal/templates/instance.go b/TOOLS/resource-generator/internal/templates/instance.go index da84be2..a1842b4 100644 --- a/TOOLS/resource-generator/internal/templates/instance.go +++ b/TOOLS/resource-generator/internal/templates/instance.go @@ -313,12 +313,11 @@ func (r *{{ToCamel .Name}}Resource) Create(ctx context.Context, req resource.Cre } id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout) if err != nil { - // Partial state (Q5): если инстанс успел создаться (id != ""), фиксируем его в state. - // Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный apply - // упрётся в страж дубликатов. Read затем сверит/очистит состояние. + // Partial state (Q5): фиксируем ТОЛЬКО id. Остальные атрибуты плана могут быть + // unknown/computed — запись их в state даёт "invalid new value ... unknown" и + // маскирует исходную ошибку. Read затем синхронизирует реальное состояние. if id != "" { - data.ID = types.StringValue(id) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(id))...) } resp.Diagnostics.AddError("Ошибка клиента", err.Error()) return diff --git a/provider/internal/core/client_test.go b/provider/internal/core/client_test.go index 068e6fb..0426403 100644 --- a/provider/internal/core/client_test.go +++ b/provider/internal/core/client_test.go @@ -79,8 +79,39 @@ func TestIsRetryable(t *testing.T) { } } -// ===== Q5: partial-result tests (instance uid при ошибке после создания) ===== +// Idempotent-пропуск НЕ должен создавать операцию: иначе останется «черновик» +// (POST /instanceOperations без run), и следующий modify будет ждать idle до таймаута. +func TestRunInstanceOperationByIdempotent_SkipsWithoutCreatingOperation(t *testing.T) { + var opPosts atomic.Int32 + c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/instances/inst-1": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"instance":{"instanceUid":"inst-1","serviceId":22,"explainedStatus":"running","isDeleted":false,"operationIsPending":false,"operationIsInProgress":false,"availableOperations":[{"svcOperationId":207,"operation":"modify"}],"state":{"params":{"ipSpaceName":"internet-ipv4-v1"}}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/instanceOperations/default/207": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"svcOperation":{"cfsParams":[{"svcOperationCfsParamId":372,"code":"ipSpaceName","dataType":"string"}]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations": + opPosts.Add(1) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"instanceOperationUid":"op-x"}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + defer cleanup() + err := c.RunInstanceOperationUniversalByIdempotent(context.Background(), "inst-1", "modify", + map[string]string{"ipSpaceName": "internet-ipv4-v1"}) + if err != nil { + t.Fatalf("expected nil error on idempotent skip, got: %v", err) + } + if n := opPosts.Load(); n != 0 { + t.Fatalf("operation must NOT be created when live already matches, got %d POST(s)", n) + } +} + +// ===== Q5: partial-result tests (instance uid при ошибке после создания) ===== // Инстанс уже создан (POST /instances отдал Location), но следующая операция падает: // uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет. func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) { diff --git a/provider/internal/core/http.go b/provider/internal/core/http.go index b310321..4b21fb2 100644 --- a/provider/internal/core/http.go +++ b/provider/internal/core/http.go @@ -118,7 +118,9 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa // isRetryable returns true for transient HTTP errors that can be retried. // 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md, -// «API Resilience»). Ретраится только для GET (см. условие в doRequest). +// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера), +// поэтому при постоянном 401 это даст 3 холостых повтора с backoff. +// Ретраится только для GET (см. условие в doRequest). func isRetryable(statusCode int) bool { return statusCode == http.StatusUnauthorized || // 401 statusCode == http.StatusTooManyRequests || // 429 diff --git a/provider/internal/core/modifier_compare.go b/provider/internal/core/modifier_compare.go index 415fec0..7057633 100644 --- a/provider/internal/core/modifier_compare.go +++ b/provider/internal/core/modifier_compare.go @@ -7,41 +7,6 @@ import ( "terraform-provider-nubes/internal/core/jsonutil" ) -// modifierDesiredEqualsCurrent сравнивает желаемые значения полей модификатора -// (keyed by code) с живыми значениями из cfsParams (ParamValue). -// -// Возвращает true, если ВСЕ поля совпали (можно пропустить run при idempotency). -// Семантика сравнения: -// - bool/int/string: нормализуются через normalizeUniversalValueV6 и сравниваются строками; -// - map-fixed: JSON-сравнение (порядок ключей не значим); -// - array-map-fixed: JSON-сравнение сырых значений (normalize не строит дефолт для массивов). -func (c *UniversalClient) modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool { - if len(desired) == 0 { - return false - } - - codeToParam := modifierCodeMap(cfsParams) - - for code, wanted := range desired { - param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))] - if !ok { - // Код не найден в схеме — не можем сравнить, считаем «не равно». - return false - } - - current := "" - if param.ParamValue != nil { - current = *param.ParamValue - } - - if !modifierValuesEqual(wanted, current, param) { - return false - } - } - - return true -} - // modifierValuesEqual сравнивает одно значение с учётом типа параметра: // - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим); // - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками. diff --git a/provider/internal/core/modifier_compare_test.go b/provider/internal/core/modifier_compare_test.go index 72b52fc..5173c1c 100644 --- a/provider/internal/core/modifier_compare_test.go +++ b/provider/internal/core/modifier_compare_test.go @@ -6,62 +6,44 @@ import ( "testing" ) -func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) { - c := &UniversalClient{} - cfsParams := []universalCfsParam{ - {SvcOperationCfsParamId: 340, Code: "needEnableAVI", DataType: "boolean", ParamValue: strPtr("false")}, - {SvcOperationCfsParamId: 369, Code: "virtualServicesCount", DataType: "integer > 0", ParamValue: strPtr("3")}, - {SvcOperationCfsParamId: 856, Code: "qosProfile", DataType: "string", ParamValue: strPtr("QoS-100Mbit")}, - } - +func TestModifierValuesEqual_Scalars(t *testing.T) { tests := []struct { - name string - desired map[string]string - want bool + name string + wanted, current string + param universalCfsParam + want bool }{ - {"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true}, - {"bool расхождение", map[string]string{"needEnableAVI": "true"}, false}, - {"int расхождение", map[string]string{"virtualServicesCount": "1"}, false}, - {"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true}, - {"код не найден в схеме", map[string]string{"неизвестный": "x"}, false}, + {"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true}, + {"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false}, + {"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true}, + {"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false}, + {"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want { - t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want) + if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want { + t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want) } }) } } -func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) { - c := &UniversalClient{} +func TestModifierValuesEqual_JSON(t *testing.T) { + mapParam := universalCfsParam{DataType: "map-fixed"} // map-fixed: порядок ключей не значим - cfsParams := []universalCfsParam{ - {SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed", - ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)}, - } - desired := map[string]string{ - "routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`, - } - if !c.modifierDesiredEqualsCurrent(desired, cfsParams) { + if !modifierValuesEqual( + `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`, + `{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`, + mapParam) { t.Errorf("map-fixed с разным порядком ключей должен считаться равным") } -} -func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) { - c := &UniversalClient{} + arrParam := universalCfsParam{DataType: "array-map-fixed"} // array-map-fixed: порядок элементов массива значим - cfsParams := []universalCfsParam{ - {SvcOperationCfsParamId: 662, Code: "vIPConfigure", DataType: "array-map-fixed", - ParamValue: strPtr(`[{"name":"a","count":1},{"name":"b","count":2}]`)}, - } - // тот же порядок — равно - if !c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"a","count":1},{"name":"b","count":2}]`}, cfsParams) { + if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) { t.Errorf("array-map-fixed с тем же порядком должен быть равен") } - // другой порядок — не равно - if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) { + if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) { t.Errorf("array-map-fixed с другим порядком не должен быть равен") } } diff --git a/provider/internal/core/operation_cfs.go b/provider/internal/core/operation_cfs.go index fccb41e..acfb361 100644 --- a/provider/internal/core/operation_cfs.go +++ b/provider/internal/core/operation_cfs.go @@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str } return def.SvcOperation.CfsParams, nil } + +// fetchOperationSchemaByID возвращает схему операции по svcOperationId БЕЗ создания +// операции: GET /instanceOperations/default/{opId}. +// +// Зачем: idempotency pre-check обязан выполняться ДО POST /instanceOperations — иначе +// при пропуске останется созданная-но-незапущенная операция («черновик», pending), +// и следующий modify будет ждать idle до таймаута. +func (c *UniversalClient) fetchOperationSchemaByID(ctx context.Context, opId int) ([]universalCfsParam, error) { + defResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil) + if err != nil { + return nil, fmt.Errorf("не удалось получить схему операции %d: %w", opId, err) + } + var def universalOpDefaultResponse + if uerr := json.Unmarshal(defResp, &def); uerr != nil { + return nil, fmt.Errorf("не удалось разобрать схему операции %d: %w", opId, uerr) + } + return def.SvcOperation.CfsParams, nil +} diff --git a/provider/internal/core/operation_run_bycode.go b/provider/internal/core/operation_run_bycode.go index d66e5c8..9e45c55 100644 --- a/provider/internal/core/operation_run_bycode.go +++ b/provider/internal/core/operation_run_bycode.go @@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid) } + // Схема операции нужна и для idempotency pre-check, и для маппинга кодов. + // Получаем её ДО создания операции (GET /instanceOperations/default/{opId}): иначе + // при пропуске останется созданная, но незапущенная операция («черновик», pending). + schemaParams, err := c.fetchOperationSchemaByID(ctx, opId) + if err != nil { + return err + } + + // Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса — + // выходим, НЕ создавая операцию вовсе (источник — state.params, см. instanceLiveParams). + if idempotent { + equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, schemaParams) + if liveErr == nil && equal { + return nil + } + // Ошибка/несовпадение live — не пропускаем: выполняем modify. + } + payload := map[string]interface{}{ "instanceUid": instanceUid, "svcOperationId": opId, @@ -60,28 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan return err } - // Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса — - // пропускаем run. Источник сравнения — state.params (live), НЕ paramValue формы - // операции: форма может не совпадать с состоянием (см. instanceLiveParams). - // желаемые = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки. - if idempotent { - equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, cfsParams) - if liveErr == nil && equal { - return nil - } - // Ошибка/несовпадение live — не пропускаем: выполняем modify. Если live - // действительно недоступен, следующий шаг вернёт ошибку явно (не молча). - } - - codeToParam := make(map[string]universalCfsParam) - for _, p := range cfsParams { - if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" { - codeToParam[key] = p - } - if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" { - codeToParam[key] = p - } - } + codeToParam := modifierCodeMap(cfsParams) paramsByID := map[int]string{} for code, value := range params {