This commit is contained in:
Repinoid
2026-09-30 21:01:05 +03:00
parent 575f1e29a4
commit 4197a76aba
18 changed files with 2009 additions and 140 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
## Как скачать
```bash
git clone https://gitea.services.ngcloud.ru/Nail/tf_examples.git
git clone https://gitea.services.ngcloud.ru/terraform/tf_examples.git
cd tf_examples/CRUD
```
+3 -3
View File
@@ -34,7 +34,7 @@ locals {
lucee_resource_name = "crud-lucee" # имя ресурса в Nubes
lucee_domain = "tflucee" # домен (станет tflucee.luceek8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
lucee_version = "5.4" # версия Lucee (CFML engine)
lucee_git_path = "https://gitea.services.ngcloud.ru/Nail/tfluceecrud.git"
lucee_git_path = "https://gitea.services.ngcloud.ru/terraform/tfluceecrud.git"
lucee_cpu = 300 # CPU в millicores
lucee_memory = 512 # память в MB
lucee_replicas = 1 # количество реплик
@@ -51,7 +51,7 @@ locals {
flask_resource_name = "crud-flask" # имя ресурса в Nubes
flask_domain = "tfflask" # домен (станет tfflask.pythonk8s.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
flask_git_path = "https://gitea.services.ngcloud.ru/Nail/tfflaskcrud.git"
flask_git_path = "https://gitea.services.ngcloud.ru/terraform/tfflaskcrud.git"
flask_cpu = 300 # CPU в millicores
flask_memory = 512 # память в MB
flask_replicas = 1 # количество реплик
@@ -62,7 +62,7 @@ locals {
nodejs_resource_name = "crud-nodejs" # имя ресурса в Nubes
nodejs_domain = "tfnodejs" # домен (станет tfnodejs.<суффикс>.dev.nubes.ru). Имя должно быть уникальным — заменяйте на своё!
nodejs_git_path = "https://gitea.services.ngcloud.ru/Nail/tfnodejscrud.git"
nodejs_git_path = "https://gitea.services.ngcloud.ru/terraform/tfnodejscrud.git"
nodejs_cpu = 300 # CPU в millicores
nodejs_memory = 512 # память в MB
nodejs_replicas = 1 # количество реплик
+277
View File
@@ -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)
if err != nil {
// Partial state (Q5): если инстанс успел создаться (id != ""), фиксируем его в state.
// Иначе облачный инстанс «осиротеет»: Terraform о нём не знает, а повторный apply
// упрётся в страж дубликатов. Read затем сверит/очистит состояние.
// Partial state (Q5): фиксируем ТОЛЬКО id. Остальные атрибуты плана могут быть
// unknown/computed — запись их в state даёт "invalid new value ... unknown" и
// маскирует исходную ошибку. Read затем синхронизирует реальное состояние.
if id != "" {
data.ID = types.StringValue(id)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(id))...)
}
resp.Diagnostics.AddError("Ошибка клиента", err.Error())
return
+32 -1
View File
@@ -79,8 +79,39 @@ func TestIsRetryable(t *testing.T) {
}
}
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
// Idempotent-пропуск НЕ должен создавать операцию: иначе останется «черновик»
// (POST /instanceOperations без run), и следующий modify будет ждать idle до таймаута.
func TestRunInstanceOperationByIdempotent_SkipsWithoutCreatingOperation(t *testing.T) {
var opPosts atomic.Int32
c, cleanup := makeTestClient(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/instances/inst-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"instance":{"instanceUid":"inst-1","serviceId":22,"explainedStatus":"running","isDeleted":false,"operationIsPending":false,"operationIsInProgress":false,"availableOperations":[{"svcOperationId":207,"operation":"modify"}],"state":{"params":{"ipSpaceName":"internet-ipv4-v1"}}}}`))
case r.Method == http.MethodGet && r.URL.Path == "/instanceOperations/default/207":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"svcOperation":{"cfsParams":[{"svcOperationCfsParamId":372,"code":"ipSpaceName","dataType":"string"}]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/instanceOperations":
opPosts.Add(1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"instanceOperationUid":"op-x"}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
defer cleanup()
err := c.RunInstanceOperationUniversalByIdempotent(context.Background(), "inst-1", "modify",
map[string]string{"ipSpaceName": "internet-ipv4-v1"})
if err != nil {
t.Fatalf("expected nil error on idempotent skip, got: %v", err)
}
if n := opPosts.Load(); n != 0 {
t.Fatalf("operation must NOT be created when live already matches, got %d POST(s)", n)
}
}
// ===== Q5: partial-result tests (instance uid при ошибке после создания) =====
// Инстанс уже создан (POST /instances отдал Location), но следующая операция падает:
// uid должен вернуться ВМЕСТЕ с ошибкой, иначе облачный инстанс осиротеет.
func TestCreateGenericInstance_KeepsUIDWhenOperationCreateFails(t *testing.T) {
+3 -1
View File
@@ -118,7 +118,9 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
// isRetryable returns true for transient HTTP errors that can be retried.
// 401 включён: Gateway может транзиентно отклонять валидный JWT (см. ARCHITECTURE.md,
// «API Resilience»). Ретраится только для GET (см. условие в doRequest).
// «API Resilience»). Токен между попытками НЕ обновляется (обновление — вне провайдера),
// поэтому при постоянном 401 это даст 3 холостых повтора с backoff.
// Ретраится только для GET (см. условие в doRequest).
func isRetryable(statusCode int) bool {
return statusCode == http.StatusUnauthorized || // 401
statusCode == http.StatusTooManyRequests || // 429
@@ -7,41 +7,6 @@ import (
"terraform-provider-nubes/internal/core/jsonutil"
)
// modifierDesiredEqualsCurrent сравнивает желаемые значения полей модификатора
// (keyed by code) с живыми значениями из cfsParams (ParamValue).
//
// Возвращает true, если ВСЕ поля совпали (можно пропустить run при idempotency).
// Семантика сравнения:
// - bool/int/string: нормализуются через normalizeUniversalValueV6 и сравниваются строками;
// - map-fixed: JSON-сравнение (порядок ключей не значим);
// - array-map-fixed: JSON-сравнение сырых значений (normalize не строит дефолт для массивов).
func (c *UniversalClient) modifierDesiredEqualsCurrent(desired map[string]string, cfsParams []universalCfsParam) bool {
if len(desired) == 0 {
return false
}
codeToParam := modifierCodeMap(cfsParams)
for code, wanted := range desired {
param, ok := codeToParam[strings.ToLower(strings.TrimSpace(code))]
if !ok {
// Код не найден в схеме — не можем сравнить, считаем «не равно».
return false
}
current := ""
if param.ParamValue != nil {
current = *param.ParamValue
}
if !modifierValuesEqual(wanted, current, param) {
return false
}
}
return true
}
// modifierValuesEqual сравнивает одно значение с учётом типа параметра:
// - array*/map*/json — смысловое JSON-сравнение (порядок ключей не значим);
// - скаляры — нормализация через normalizeUniversalValueV6 и сравнение строками.
+19 -37
View File
@@ -6,62 +6,44 @@ import (
"testing"
)
func TestModifierDesiredEqualsCurrent_Scalars(t *testing.T) {
c := &UniversalClient{}
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 340, Code: "needEnableAVI", DataType: "boolean", ParamValue: strPtr("false")},
{SvcOperationCfsParamId: 369, Code: "virtualServicesCount", DataType: "integer > 0", ParamValue: strPtr("3")},
{SvcOperationCfsParamId: 856, Code: "qosProfile", DataType: "string", ParamValue: strPtr("QoS-100Mbit")},
}
func TestModifierValuesEqual_Scalars(t *testing.T) {
tests := []struct {
name string
desired map[string]string
wanted, current string
param universalCfsParam
want bool
}{
{"все совпали", map[string]string{"needEnableAVI": "false", "virtualServicesCount": "3", "qosProfile": "QoS-100Mbit"}, true},
{"bool расхождение", map[string]string{"needEnableAVI": "true"}, false},
{"int расхождение", map[string]string{"virtualServicesCount": "1"}, false},
{"нормализация пробела", map[string]string{"virtualServicesCount": " 3 "}, true},
{"код не найден в схеме", map[string]string{"неизвестный": "x"}, false},
{"bool совпал", "false", "false", universalCfsParam{DataType: "boolean"}, true},
{"bool расхождение", "true", "false", universalCfsParam{DataType: "boolean"}, false},
{"int с пробелом", " 3 ", "3", universalCfsParam{DataType: "integer > 0"}, true},
{"int расхождение", "1", "3", universalCfsParam{DataType: "integer > 0"}, false},
{"string совпал", "QoS-100Mbit", "QoS-100Mbit", universalCfsParam{DataType: "string"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := c.modifierDesiredEqualsCurrent(tt.desired, cfsParams); got != tt.want {
t.Errorf("modifierDesiredEqualsCurrent() = %v, want %v", got, tt.want)
if got := modifierValuesEqual(tt.wanted, tt.current, tt.param); got != tt.want {
t.Errorf("modifierValuesEqual() = %v, want %v", got, tt.want)
}
})
}
}
func TestModifierDesiredEqualsCurrent_MapFixed(t *testing.T) {
c := &UniversalClient{}
func TestModifierValuesEqual_JSON(t *testing.T) {
mapParam := universalCfsParam{DataType: "map-fixed"}
// map-fixed: порядок ключей не значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 1112, Code: "routedNetConfiguration", DataType: "map-fixed",
ParamValue: strPtr(`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`)},
}
desired := map[string]string{
"routedNetConfiguration": `{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
}
if !c.modifierDesiredEqualsCurrent(desired, cfsParams) {
if !modifierValuesEqual(
`{"mainDns":"81.22.46.22","ipAddrPool":"10.10.102.0/24"}`,
`{"ipAddrPool":"10.10.102.0/24","mainDns":"81.22.46.22"}`,
mapParam) {
t.Errorf("map-fixed с разным порядком ключей должен считаться равным")
}
}
func TestModifierDesiredEqualsCurrent_ArrayPreservesOrder(t *testing.T) {
c := &UniversalClient{}
arrParam := universalCfsParam{DataType: "array-map-fixed"}
// array-map-fixed: порядок элементов массива значим
cfsParams := []universalCfsParam{
{SvcOperationCfsParamId: 662, Code: "vIPConfigure", DataType: "array-map-fixed",
ParamValue: strPtr(`[{"name":"a","count":1},{"name":"b","count":2}]`)},
}
// тот же порядок — равно
if !c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"a","count":1},{"name":"b","count":2}]`}, cfsParams) {
if !modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"a","count":1},{"name":"b","count":2}]`, arrParam) {
t.Errorf("array-map-fixed с тем же порядком должен быть равен")
}
// другой порядок — не равно
if c.modifierDesiredEqualsCurrent(map[string]string{"vIPConfigure": `[{"name":"b","count":2},{"name":"a","count":1}]`}, cfsParams) {
if modifierValuesEqual(`[{"name":"a","count":1},{"name":"b","count":2}]`, `[{"name":"b","count":2},{"name":"a","count":1}]`, arrParam) {
t.Errorf("array-map-fixed с другим порядком не должен быть равен")
}
}
+18
View File
@@ -74,3 +74,21 @@ func (c *UniversalClient) fetchOperationCfsParams(ctx context.Context, opUid str
}
return def.SvcOperation.CfsParams, nil
}
// fetchOperationSchemaByID возвращает схему операции по svcOperationId БЕЗ создания
// операции: GET /instanceOperations/default/{opId}.
//
// Зачем: idempotency pre-check обязан выполняться ДО POST /instanceOperations — иначе
// при пропуске останется созданная-но-незапущенная операция («черновик», pending),
// и следующий modify будет ждать idle до таймаута.
func (c *UniversalClient) fetchOperationSchemaByID(ctx context.Context, opId int) ([]universalCfsParam, error) {
defResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/default/%d", opId), nil)
if err != nil {
return nil, fmt.Errorf("не удалось получить схему операции %d: %w", opId, err)
}
var def universalOpDefaultResponse
if uerr := json.Unmarshal(defResp, &def); uerr != nil {
return nil, fmt.Errorf("не удалось разобрать схему операции %d: %w", opId, uerr)
}
return def.SvcOperation.CfsParams, nil
}
+19 -22
View File
@@ -41,6 +41,24 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return fmt.Errorf("операция %s недоступна для экземпляра %s", action, instanceUid)
}
// Схема операции нужна и для idempotency pre-check, и для маппинга кодов.
// Получаем её ДО создания операции (GET /instanceOperations/default/{opId}): иначе
// при пропуске останется созданная, но незапущенная операция («черновик», pending).
schemaParams, err := c.fetchOperationSchemaByID(ctx, opId)
if err != nil {
return err
}
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
// выходим, НЕ создавая операцию вовсе (источник — state.params, см. instanceLiveParams).
if idempotent {
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, schemaParams)
if liveErr == nil && equal {
return nil
}
// Ошибка/несовпадение live — не пропускаем: выполняем modify.
}
payload := map[string]interface{}{
"instanceUid": instanceUid,
"svcOperationId": opId,
@@ -60,28 +78,7 @@ func (c *UniversalClient) runInstanceOperationByCode(ctx context.Context, instan
return err
}
// Idempotency pre-check: если все desired уже равны LIVE-значениям инстанса —
// пропускаем run. Источник сравнения — state.params (live), НЕ paramValue формы
// операции: форма может не совпадать с состоянием (см. instanceLiveParams).
// желаемые = явно заданные пользователем коды (params, keyed by code), БЕЗ досылки.
if idempotent {
equal, liveErr := c.modifierDesiredEqualsLive(ctx, instanceUid, params, cfsParams)
if liveErr == nil && equal {
return nil
}
// Ошибка/несовпадение live — не пропускаем: выполняем modify. Если live
// действительно недоступен, следующий шаг вернёт ошибку явно (не молча).
}
codeToParam := make(map[string]universalCfsParam)
for _, p := range cfsParams {
if key := strings.ToLower(strings.TrimSpace(p.Code)); key != "" {
codeToParam[key] = p
}
if key := strings.ToLower(strings.TrimSpace(p.SvcOperationCfsParam)); key != "" {
codeToParam[key] = p
}
}
codeToParam := modifierCodeMap(cfsParams)
paramsByID := map[int]string{}
for code, value := range params {