256 lines
12 KiB
Python
256 lines
12 KiB
Python
"""
|
||
Terraform-совместимые операции с параметрами: нормализация, refSvc-резолв, отправка.
|
||
|
||
Эти шаги воспроизводят логику Terraform-провайдера Nubes при создании/изменении
|
||
инстанса через личный кабинет:
|
||
|
||
Шаг 3: Получить cfsParams шаблон операции
|
||
Шаг 4: resolveRefSvc — подставить UUID инстансов для refSvcId-параметров
|
||
Шаг 5: Отправить пользовательские значения параметров (те что ввёл пользователь)
|
||
Шаг 6: Отправить default-значения для незаполненных параметров
|
||
Шаг 7: validate-cfs — проверить что все обязательные параметры заполнены
|
||
|
||
Используется из executor.py (через send_params_terraform) — и ручной режим, и сценарий.
|
||
"""
|
||
|
||
import json
|
||
from operations.get_instances import get_instances
|
||
|
||
# Параметры, значения которых НЕЛЬЗЯ сохранять в БД в открытом виде.
|
||
# При сохранении в runs.params их значения заменяются на "***".
|
||
REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"}
|
||
|
||
|
||
def redact_params(params):
|
||
"""Заменить значения секретных полей на *** перед сохранением в БД.
|
||
|
||
Проверяет КЛЮЧИ параметров (не значения!) на вхождение секретных слов.
|
||
Пример: params={"adminPassword": "secret123"} → {"adminPassword": "***"}
|
||
|
||
Args:
|
||
params: dict — {param_id: value}
|
||
|
||
Returns:
|
||
dict с замаскированными секретами."""
|
||
if not params:
|
||
return params
|
||
result = {}
|
||
for k, v in params.items():
|
||
# Проверяем ключ (имя параметра) на敏感ные слова
|
||
if any(r in str(k).lower() for r in REDACT_KEYS):
|
||
result[k] = "***"
|
||
else:
|
||
result[k] = v
|
||
return result
|
||
|
||
|
||
def normalize_value(val, data_type, data_descriptor):
|
||
"""normalizeUniversalValueV6 — Terraform-эквивалент нормализации значения.
|
||
|
||
Что делает: приводит пустые значения к «нулю» соответствующего типа.
|
||
Terraform не принимает пустую строку для integer-параметра — нужно "0".
|
||
Для map-параметров собирает JSON из sub-param defaults.
|
||
|
||
Args:
|
||
val: str — значение параметра (может быть "", "null", ...)
|
||
data_type: str — тип параметра ("integer >= 0", "boolean", "map(...)", ...)
|
||
data_descriptor: dict|None — вложенная структура для map-параметров
|
||
|
||
Returns:
|
||
str — нормализованное значение."""
|
||
# Очистка: strip + специальные значения null / ""
|
||
val = (val or "").strip()
|
||
if val.lower() == "null" or val == '""':
|
||
val = ""
|
||
|
||
dt = (data_type or "").lower()
|
||
|
||
# Map-параметры с dataDescriptor:
|
||
# Если значение пустое — собираем JSON из sub-param defaults.
|
||
# Пример: dataDescriptor={"host": {defaultValue: "localhost"}, "port": {defaultValue: "5432"}}
|
||
# → "{\"host\":\"localhost\",\"port\":\"5432\"}"
|
||
if ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor:
|
||
if not val or val == "{}":
|
||
sub_obj = {}
|
||
for sk, sv in data_descriptor.items():
|
||
sd = sv.get("defaultValue")
|
||
# default может быть любым типом — приводим к строке для JSON
|
||
sub_obj[sk] = str(sd) if sd is not None else ""
|
||
return json.dumps(sub_obj)
|
||
return val # значение уже заполнено — не трогаем
|
||
|
||
# Значение не пустое — возвращаем как есть
|
||
if val:
|
||
return val
|
||
|
||
# Пустое значение — подставляем «ноль» типа:
|
||
if "array" in dt:
|
||
return "[]"
|
||
if "map" in dt or "json" in dt:
|
||
return "{}"
|
||
if "integer" in dt or "int" in dt:
|
||
return "0"
|
||
if "boolean" in dt or "bool" in dt:
|
||
return "false"
|
||
|
||
# string или неизвестный тип — пустая строка
|
||
return val
|
||
|
||
|
||
def resolve_ref_svc(client, cfs_params, params):
|
||
"""resolveRefSvcParamValues — Terraform step 4.
|
||
|
||
Некоторые параметры имеют тип «ссылка на другой сервис» (refSvcId).
|
||
Например: параметр «externalIp» ссылается на сервис External IP (svcId=25).
|
||
|
||
Если пользователь оставил такой параметр пустым — мы должны найти
|
||
существующий инстанс этого сервиса и подставить его UUID.
|
||
|
||
Это работает в двух местах:
|
||
1. Верхнеуровневый refSvcId — прямо в параметре
|
||
2. Вложенный refSvcId — внутри dataDescriptor (для map-параметров)
|
||
|
||
Args:
|
||
client: HttpClient
|
||
cfs_params: list — cfsParams из шаблона операции
|
||
params: dict — {param_id: value} — МУТИРУЕТСЯ (добавляем UUID)
|
||
|
||
Returns: None (мутирует params)."""
|
||
# instances загружаем ЛЕНИВО — только если нужен refSvc
|
||
instances = None
|
||
|
||
for p in cfs_params:
|
||
pid = str(p["svcOperationCfsParamId"])
|
||
|
||
# ── Уровень 1: верхнеуровневый refSvcId ──
|
||
top_ref = p.get("refSvcId")
|
||
if top_ref:
|
||
# Проверяем: параметр пустой?
|
||
cur = (params.get(pid) or "").strip()
|
||
if not cur or cur == '""':
|
||
# Загружаем инстансы если ещё не загружены
|
||
if instances is None:
|
||
try:
|
||
instances = get_instances(client)
|
||
except Exception:
|
||
return # не можем загрузить — пропускаем
|
||
# Ищем первый НЕ deleted инстанс с нужным serviceId
|
||
match = next((i for i in instances
|
||
if i.get("serviceId") == top_ref
|
||
and i.get("explainedStatus") not in ("deleted",)), None)
|
||
if match:
|
||
params[pid] = match["instanceUid"]
|
||
|
||
# ── Уровень 2: вложенный refSvcId в dataDescriptor ──
|
||
dd = p.get("dataDescriptor")
|
||
if not isinstance(dd, dict):
|
||
continue # нет dataDescriptor — нечего проверять
|
||
|
||
# Парсим текущее значение параметра как JSON (map-параметр)
|
||
user_val = {}
|
||
if pid in params:
|
||
try:
|
||
user_val = json.loads(params[pid]) if params[pid] else {}
|
||
except (json.JSONDecodeError, ValueError):
|
||
user_val = {}
|
||
|
||
# Проверяем каждый sub-ключ dataDescriptor на refSvcId
|
||
for sk, sv in dd.items():
|
||
ref_id = sv.get("refSvcId")
|
||
if not ref_id:
|
||
continue # не refSvc-параметр
|
||
|
||
# Текущее значение sub-ключа
|
||
cur = user_val.get(sk, "")
|
||
if cur and cur != '""':
|
||
continue # уже заполнено — не трогаем
|
||
|
||
# Загружаем инстансы лениво
|
||
if instances is None:
|
||
try:
|
||
instances = get_instances(client)
|
||
except Exception:
|
||
return
|
||
|
||
# Ищем подходящий инстанс
|
||
match = next((i for i in instances
|
||
if i.get("serviceId") == ref_id
|
||
and i.get("explainedStatus") not in ("deleted",)), None)
|
||
if match:
|
||
user_val[sk] = match["instanceUid"]
|
||
|
||
# Если были изменения — обновляем значение параметра
|
||
if user_val:
|
||
params[pid] = json.dumps(user_val)
|
||
|
||
|
||
def send_params_terraform(client, op_uid, params):
|
||
"""Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send defaults → validate.
|
||
|
||
Полный алгоритм отправки параметров операции:
|
||
|
||
3. GET /instanceOperations/{opUid}?fields=cfsParams — получить шаблон
|
||
4. resolveRefSvc — подставить UUID для refSvcId-параметров
|
||
5. Отправить пользовательские значения (те что переданы в params)
|
||
6. Отправить default-значения для НЕзаполненных параметров
|
||
(нормализуя через normalize_value)
|
||
7. GET /instanceOperations/{opUid}/validate-cfs — финальная проверка
|
||
|
||
Args:
|
||
client: HttpClient
|
||
op_uid: str — UUID операции
|
||
params: dict — {numeric_param_id: string_value} — пользовательские значения
|
||
|
||
Raises:
|
||
Exception при ошибке валидации (кроме пустого JSON-ответа — это ок)."""
|
||
# Шаг 3: получить cfsParams шаблон операции
|
||
op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams")
|
||
cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", [])
|
||
|
||
# Шаг 4: resolveRefSvc — мутирует params, подставляя UUID
|
||
resolve_ref_svc(client, cfs_params, params)
|
||
|
||
# Шаг 5: отправить пользовательские значения
|
||
# sent — множество paramId, чтобы не отправить повторно в шаге 6
|
||
sent = set()
|
||
for pid, pval in params.items():
|
||
client.post("/instanceOperationCfsParams", {
|
||
"instanceOperationUid": op_uid,
|
||
"svcOperationCfsParamId": int(pid),
|
||
"paramValue": str(pval)
|
||
})
|
||
sent.add(int(pid))
|
||
|
||
# Шаг 6: отправить default-значения для незаполненных параметров
|
||
for p in cfs_params:
|
||
pid = p["svcOperationCfsParamId"]
|
||
if pid in sent:
|
||
continue # пользователь уже заполнил — не перезаписываем
|
||
|
||
# Приоритет значений: paramValue → defaultValue → ""
|
||
val = p.get("paramValue")
|
||
if val is None:
|
||
val = p.get("defaultValue")
|
||
if val is None:
|
||
val = ""
|
||
|
||
# Нормализуем под тип (пустой integer → "0", map → "{}")
|
||
val = normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor"))
|
||
|
||
client.post("/instanceOperationCfsParams", {
|
||
"instanceOperationUid": op_uid,
|
||
"svcOperationCfsParamId": pid,
|
||
"paramValue": val
|
||
})
|
||
|
||
# Шаг 7: validate-cfs — финальная проверка
|
||
# Если параметры невалидны — API вернёт ошибку → исключение
|
||
# Если ответ 200 с пустым телом — это ОК (значит валидация прошла).
|
||
# HttpClient.get() для пустого тела возвращает {} без ошибок.
|
||
# Но если тело не-JSON — будет JSONDecodeError, это тоже ОК для validate-cfs.
|
||
try:
|
||
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
||
except json.JSONDecodeError:
|
||
pass # пустой/не-JSON ответ — норма для validate-cfs (успех)
|
||
# HTTPError (4xx/5xx) пробрасывается выше — это реальная ошибка валидации
|