1
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 # количество реплик
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 и сравнение строками.
|
||||
|
||||
@@ -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 с другим порядком не должен быть равен")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user