1
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user