1
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
## Как скачать
|
## Как скачать
|
||||||
|
|
||||||
```bash
|
```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
|
cd tf_examples/CRUD
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+36
-36
@@ -9,52 +9,52 @@ locals {
|
|||||||
# PostgreSQL — общая БД для всех трёх приложений
|
# PostgreSQL — общая БД для всех трёх приложений
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
|
pg_resource_name = "pg4crud2" # имя ресурса в Nubes
|
||||||
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
|
pg_cpu = 500 # CPU в millicores (500 = 0.5 ядра)
|
||||||
pg_memory = 512 # память в MB
|
pg_memory = 512 # память в MB
|
||||||
pg_replicas = 1 # количество реплик
|
pg_replicas = 1 # количество реплик
|
||||||
pg_disk = 10 # диск в GB
|
pg_disk = 10 # диск в GB
|
||||||
pg_version = "17" # версия PostgreSQL
|
pg_version = "17" # версия PostgreSQL
|
||||||
pg_retain = 14 # дней хранения бэкапов
|
pg_retain = 14 # дней хранения бэкапов
|
||||||
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
|
pg_schedule = "0 0 * * *" # cron расписание бэкапов (ежедневно в полночь)
|
||||||
pg_timeout = "11m" # таймаут операций create/modify
|
pg_timeout = "11m" # таймаут операций create/modify
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PostgreSQL — пользователь и база данных
|
# PostgreSQL — пользователь и база данных
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_username = "user4crudpg" # имя пользователя БД
|
pg_username = "user4crudpg" # имя пользователя БД
|
||||||
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
|
pg_role = "ddl_user" # роль (ddl_user = может создавать таблицы)
|
||||||
pg_db_name = "db4crudpg" # имя базы данных
|
pg_db_name = "db4crudpg" # имя базы данных
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Lucee — CFML-приложение (сервис 94)
|
# Lucee — CFML-приложение (сервис 94)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
|
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
|
||||||
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
lucee_version = "5.4" # версия Lucee (CFML engine)
|
lucee_version = "5.4" # версия Lucee (CFML engine)
|
||||||
lucee_git_path = "https://gitea.services.ngcloud.ru/Nail/tfluceecrud.git"
|
lucee_git_path = "https://gitea.services.ngcloud.ru/terraform/tfluceecrud.git"
|
||||||
lucee_cpu = 300 # CPU в millicores
|
lucee_cpu = 300 # CPU в millicores
|
||||||
lucee_memory = 512 # память в MB
|
lucee_memory = 512 # память в MB
|
||||||
lucee_replicas = 1 # количество реплик
|
lucee_replicas = 1 # количество реплик
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Таблица CRUD — общая для всех трёх приложений
|
# Таблица CRUD — общая для всех трёх приложений
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
|
crud_table_name = "crud_items" # имя таблицы (TABLE_NAME в env)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Flask — Python-приложение (сервис 89)
|
# Flask — Python-приложение (сервис 89)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
flask_resource_name = "crud-flask" # имя ресурса в Nubes
|
flask_resource_name = "crud-flask" # имя ресурса в Nubes
|
||||||
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
flask_git_path = "https://gitea.services.ngcloud.ru/Nail/tfflaskcrud.git"
|
flask_git_path = "https://gitea.services.ngcloud.ru/terraform/tfflaskcrud.git"
|
||||||
flask_cpu = 300 # CPU в millicores
|
flask_cpu = 300 # CPU в millicores
|
||||||
flask_memory = 512 # память в MB
|
flask_memory = 512 # память в MB
|
||||||
flask_replicas = 1 # количество реплик
|
flask_replicas = 1 # количество реплик
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Node.js — Express-приложение (сервис 95)
|
# Node.js — Express-приложение (сервис 95)
|
||||||
@@ -62,11 +62,11 @@ locals {
|
|||||||
|
|
||||||
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
|
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
|
||||||
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
|
||||||
nodejs_git_path = "https://gitea.services.ngcloud.ru/Nail/tfnodejscrud.git"
|
nodejs_git_path = "https://gitea.services.ngcloud.ru/terraform/tfnodejscrud.git"
|
||||||
nodejs_cpu = 300 # CPU в millicores
|
nodejs_cpu = 300 # CPU в millicores
|
||||||
nodejs_memory = 512 # память в MB
|
nodejs_memory = 512 # память в MB
|
||||||
nodejs_replicas = 1 # количество реплик
|
nodejs_replicas = 1 # количество реплик
|
||||||
nodejs_timeout = "15m" # таймаут операций create/modify
|
nodejs_timeout = "15m" # таймаут операций create/modify
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# JDBC — параметры подключения Lucee к PostgreSQL
|
# JDBC — параметры подключения Lucee к PostgreSQL
|
||||||
@@ -75,14 +75,14 @@ locals {
|
|||||||
jdbc_class = "org.postgresql.Driver"
|
jdbc_class = "org.postgresql.Driver"
|
||||||
jdbc_bundle_name = "org.postgresql.jdbc"
|
jdbc_bundle_name = "org.postgresql.jdbc"
|
||||||
jdbc_bundle_version = "42.6.0"
|
jdbc_bundle_version = "42.6.0"
|
||||||
jdbc_conn_limit = "5" # макс. количество соединений
|
jdbc_conn_limit = "5" # макс. количество соединений
|
||||||
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
|
jdbc_live_timeout = "15" # таймаут неактивного соединения (минут)
|
||||||
jdbc_validate = "false" # валидация соединения при выдаче из пула
|
jdbc_validate = "false" # валидация соединения при выдаче из пула
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# PostgreSQL — общие параметры подключения
|
# PostgreSQL — общие параметры подключения
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
pg_port = "5432" # порт PostgreSQL
|
pg_port = "5432" # порт PostgreSQL
|
||||||
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
|
pg_ssl_mode = "require" # SSL-режим (require = обязательно TLS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<dev|test|prod>/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/<dev|test|prod>/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_<service>_user
|
||||||
|
- create_database/delete_database => nubes_<service>_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 <stand>` — 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/<stand>/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 <stand>` (output is git-ignored).
|
||||||
@@ -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/."
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
id, err := resources_core.CreateResourceWithTimeout(ctx, r.client, {{.ServiceID}}, resourceName, data.AdoptExistingOnCreate.ValueBool(), params, operationTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Partial state (Q5): если инстанс успел создаться (id != ""), фиксируем его в state.
|
// Partial state (Q5): фиксируем ТОЛЬКО id. Остальные атрибуты плана могут быть
|
||||||
// Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный apply
|
// unknown/computed — запись их в state даёт "invalid new value ... unknown" и
|
||||||
// упрётся в страж дубликатов. Read затем сверит/очистит состояние.
|
// маскирует исходную ошибку. Read затем синхронизирует реальное состояние.
|
||||||
if id != "" {
|
if id != "" {
|
||||||
data.ID = types.StringValue(id)
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(id))...)
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
|
|
||||||
}
|
}
|
||||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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), но следующая операция падает:
|
// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает:
|
||||||
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
|
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
|
||||||
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
|
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
|
||||||
|
|||||||
@@ -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.
|
// isRetryable returns true for transient HTTP errors that can be retried.
|
||||||
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
|
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
|
||||||
// «API Resilience»). Ретраится только для GET (см. условие в doRequest).
|
// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера),
|
||||||
|
// поэтому при постоянном 401 это даст 3 холостых повтора с backoff.
|
||||||
|
// Ретраится только для GET (см. условие в doRequest).
|
||||||
func isRetryable(statusCode int) bool {
|
func isRetryable(statusCode int) bool {
|
||||||
return statusCode == http.StatusUnauthorized || // 401
|
return statusCode == http.StatusUnauthorized || // 401
|
||||||
statusCode == http.StatusTooManyRequests || // 429
|
statusCode == http.StatusTooManyRequests || // 429
|
||||||
|
|||||||
@@ -7,41 +7,6 @@ import (
|
|||||||
"terraform-provider-nubes/internal/core/jsonutil"
|
"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 сравнивает одно значение с учётом типа параметра:
|
// modifierValuesEqual сравнивает одно значение с учётом типа параметра:
|
||||||
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
|
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
|
||||||
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
|
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
|
||||||
|
|||||||
@@ -6,62 +6,44 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) {
|
func TestModifierValuesEqual_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")},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
desired map[string]string
|
wanted, current string
|
||||||
want bool
|
param universalCfsParam
|
||||||
|
want bool
|
||||||
}{
|
}{
|
||||||
{"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true},
|
{"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true},
|
||||||
{"bool расхождение", map[string]string{"needEnableAVI": "true"}, false},
|
{"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false},
|
||||||
{"int расхождение", map[string]string{"virtualServicesCount": "1"}, false},
|
{"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true},
|
||||||
{"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true},
|
{"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false},
|
||||||
{"код не найден в схеме", map[string]string{"неизвестный": "x"}, false},
|
{"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want {
|
if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want {
|
||||||
t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want)
|
t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) {
|
func TestModifierValuesEqual_JSON(t *testing.T) {
|
||||||
c := &UniversalClient{}
|
mapParam := universalCfsParam{DataType: "map-fixed"}
|
||||||
// map-fixed: порядок ключей не значим
|
// map-fixed: порядок ключей не значим
|
||||||
cfsParams := []universalCfsParam{
|
if !modifierValuesEqual(
|
||||||
{SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed",
|
`{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
|
||||||
ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)},
|
`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`,
|
||||||
}
|
mapParam) {
|
||||||
desired := map[string]string{
|
|
||||||
"routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
|
|
||||||
}
|
|
||||||
if !c.modifierDesiredEqualsCurrent(desired, cfsParams) {
|
|
||||||
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
|
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) {
|
arrParam := universalCfsParam{DataType: "array-map-fixed"}
|
||||||
c := &UniversalClient{}
|
|
||||||
// array-map-fixed: порядок элементов массива значим
|
// array-map-fixed: порядок элементов массива значим
|
||||||
cfsParams := []universalCfsParam{
|
if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) {
|
||||||
{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) {
|
|
||||||
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
|
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
|
||||||
}
|
}
|
||||||
// другой порядок — не равно
|
if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) {
|
||||||
if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) {
|
|
||||||
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
|
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str
|
|||||||
}
|
}
|
||||||
return def.SvcOperation.CfsParams, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
|
|||||||
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
|
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{}{
|
payload := map[string]interface{}{
|
||||||
"instanceUid": instanceUid,
|
"instanceUid": instanceUid,
|
||||||
"svcOperationId": opId,
|
"svcOperationId": opId,
|
||||||
@@ -60,28 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
|
codeToParam := modifierCodeMap(cfsParams)
|
||||||
// пропускаем 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
paramsByID := map[int]string{}
|
paramsByID := map[int]string{}
|
||||||
for code, value := range params {
|
for code, value := range params {
|
||||||
|
|||||||
Reference in New Issue
Block a user