""" 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) пробрасывается выше — это реальная ошибка валидации