diff --git a/provider/internal/core/modifier_compare.go b/provider/internal/core/modifier_compare.go new file mode 100644 index 0000000..ea87492 --- /dev/null +++ b/provider/internal/core/modifier_compare.go @@ -0,0 +1,68 @@ +package core + +import ( + "strings" + + "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 := 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 + } + } + + 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 + } + + dataType := strings.ToLower(strings.TrimSpace(param.DataType)) + if strings.HasPrefix(dataType, "array") { + // array-map-fixed: сравнивать сырые значения как JSON. + if !jsonutil.JSONStringsEquivalent(wanted, current) { + return false + } + continue + } + if strings.HasPrefix(dataType, "map") || strings.Contains(dataType, "json") { + if !jsonutil.JSONStringsEquivalent(wanted, current) { + return false + } + continue + } + + // Скаляры: нормализуем обе стороны. + nw := normalizeUniversalValueV6(wanted, param) + nc := normalizeUniversalValueV6(current, param) + if nw != nc { + return false + } + } + + return true +} diff --git a/provider/internal/core/operation_run_bycode.go b/provider/internal/core/operation_run_bycode.go index 1d6962f..9b7cd3e 100644 --- a/provider/internal/core/operation_run_bycode.go +++ b/provider/internal/core/operation_run_bycode.go @@ -9,6 +9,17 @@ import ( // 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 @@ -49,6 +60,12 @@ func (c *UniversalClient) RunInstanceOperationUniversalByCode(ctx context.Contex 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 != "" { diff --git a/provider/internal/resources_core/crud.go b/provider/internal/resources_core/crud.go index 89b9066..9c2c2f2 100644 --- a/provider/internal/resources_core/crud.go +++ b/provider/internal/resources_core/crud.go @@ -139,6 +139,26 @@ func RunOperationByCodeWithTimeout(ctx context.Context, client *core.UniversalCl return client.RunInstanceOperationUniversalByCode(ctxWithTimeout, instanceID, operation, params) } +// RunOperationByCodeIdempotent — то же, что RunOperationByCodeWithTimeout, но +// с idempotency pre-check (desired==current → skip run). Для модификаторов +// с idempotency: check_before_run. +func RunOperationByCodeIdempotent(ctx context.Context, client *core.UniversalClient, instanceID string, operation string, params map[string]string, operationTimeout string) error { + if strings.TrimSpace(instanceID) == "" { + return fmt.Errorf("missing instance id for operation") + } + if client == nil { + return fmt.Errorf("missing client for operation") + } + unlock := client.LockInstance(instanceID) + defer unlock() + + ctxWithTimeout, err := core.WithOperationTimeout(ctx, operationTimeout) + if err != nil { + return err + } + return client.RunInstanceOperationUniversalByIdempotent(ctxWithTimeout, instanceID, operation, params) +} + func adoptExistingInstanceOnCreate(ctx context.Context, client *core.UniversalClient, serviceID int, displayName string, resumeIfExists bool, params map[int]string, existing *core.InstanceStateResponse) (string, error) { // Проверка: операция уже выполняется if existing.OperationIsInProgress || existing.OperationIsPending {