Рефакторинг API-слоя, операций и БД
This commit is contained in:
+45
-17
@@ -3,15 +3,15 @@
|
|||||||
|
|
||||||
Раньше _client(), _client_id(), _stand(), _token_info() были продублированы
|
Раньше _client(), _client_id(), _stand(), _token_info() были продублированы
|
||||||
в main.py и api_test.py с идентичным или почти идентичным кодом.
|
в main.py и api_test.py с идентичным или почти идентичным кодом.
|
||||||
Теперь всё здесь — один источник правды.
|
Теперь всё здесь — один источник правды для всех роутов.
|
||||||
|
|
||||||
Функции:
|
Функции (все без аргументов — берут данные из Flask request/current_app):
|
||||||
get_token() — токен из cookie или env
|
get_token() — токен: cookie → env-переменная
|
||||||
get_client() — HttpClient с автоопределением стенда
|
get_client() — HttpClient с автоопределением стенда
|
||||||
get_client_id() — ClientID из JWT (base64, без проверки подписи)
|
get_client_id() — ClientID из JWT (base64url, без проверки подписи)
|
||||||
get_stand() — "dev"/"test" по токену
|
get_stand() — "dev" / "test" по токену
|
||||||
get_token_info() — {email, company, client_id} из JWT
|
get_token_info() — {email, company, client_id} из JWT для UI
|
||||||
get_token_masked() — маскированный токен (abc...xyz)
|
get_token_masked() — маскированный токен (abc...xyz) для placeholder
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -22,13 +22,19 @@ from api.http_client import HttpClient, detect_endpoint, stand_name
|
|||||||
|
|
||||||
|
|
||||||
def get_token():
|
def get_token():
|
||||||
"""Токен: сначала из cookie, потом из env-переменной."""
|
"""Получить активный токен: сначала из cookie пользователя, потом из env.
|
||||||
|
|
||||||
|
Приоритет:
|
||||||
|
1. cookie "token" — пользователь ввёл свой токен в форме
|
||||||
|
2. NUBES_API_TOKEN из env — сервисный токен (для автоматических тестов)
|
||||||
|
|
||||||
|
Пользовательский токен приоритетнее — он переопределяет сервисный."""
|
||||||
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
|
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
|
||||||
|
|
||||||
|
|
||||||
def get_client():
|
def get_client():
|
||||||
"""HttpClient с автоопределением стенда по токену.
|
"""HttpClient с автоопределением стенда по активному токену.
|
||||||
|
|
||||||
Использует detect_endpoint() — пробует dev→test стенды.
|
Использует detect_endpoint() — пробует dev→test стенды.
|
||||||
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига."""
|
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
@@ -37,27 +43,45 @@ def get_client():
|
|||||||
|
|
||||||
|
|
||||||
def get_client_id():
|
def get_client_id():
|
||||||
"""Извлечение ClientID из payload JWT-токена (base64url, без проверки подписи)."""
|
"""Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи).
|
||||||
|
|
||||||
|
JWT состоит из трёх частей: header.payload.signature, разделённых точкой.
|
||||||
|
Нам нужен ТОЛЬКО payload — он в base64url (не base64!).
|
||||||
|
|
||||||
|
ClientID используется для:
|
||||||
|
- Изоляции данных в БД (runs.client_id)
|
||||||
|
- Изоляции трекера инстансов (/tmp/instances-{clientId}-{stand}.json)
|
||||||
|
- Отображения в UI
|
||||||
|
|
||||||
|
Безопасность: мы НЕ проверяем подпись — это не нужно.
|
||||||
|
Токен уже проверен Nubes API (detect_endpoint делает реальный запрос)."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
try:
|
try:
|
||||||
parts = token.split(".") # header.payload.signature
|
parts = token.split(".") # [header, payload, signature]
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
payload = base64.urlsafe_b64decode(parts[1] + "==") # padding
|
# base64url → добавляем padding ("==") на случай если длина не кратна 4
|
||||||
|
payload = base64.urlsafe_b64decode(parts[1] + "==")
|
||||||
return json.loads(payload).get("ClientID", "")
|
return json.loads(payload).get("ClientID", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # битый токен — не критично, вернём пустую строку
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def get_stand():
|
def get_stand():
|
||||||
"""dev/test — по токену (detect_endpoint → stand_name)."""
|
"""Определить стенд (dev/test) по активному токену.
|
||||||
|
|
||||||
|
detect_endpoint → stand_name. Если автоопределение не сработало —
|
||||||
|
fallback на NUBES_API_ENDPOINT из конфига."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
||||||
return stand_name(endpoint)
|
return stand_name(endpoint)
|
||||||
|
|
||||||
|
|
||||||
def get_token_info():
|
def get_token_info():
|
||||||
"""{email, company, client_id} из JWT — для отображения в топбаре UI."""
|
"""Извлечь {email, company, client_id} из JWT — для отображения в топбаре UI.
|
||||||
|
|
||||||
|
Возвращает dict с ключами: email, company, client_id.
|
||||||
|
Если JWT невалиден — возвращает пустой {}."""
|
||||||
token = get_token()
|
token = get_token()
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
@@ -75,8 +99,12 @@ def get_token_info():
|
|||||||
|
|
||||||
|
|
||||||
def get_token_masked():
|
def get_token_masked():
|
||||||
"""Маскированный токен для placeholder: abc...xyz."""
|
"""Маскированный env-токен для placeholder в форме: abc...xyz.
|
||||||
|
|
||||||
|
Используется ТОЛЬКО env-токен (не пользовательский!).
|
||||||
|
Если токен короче 8 символов — возвращает пустую строку."""
|
||||||
token = current_app.config["NUBES_API_TOKEN"]
|
token = current_app.config["NUBES_API_TOKEN"]
|
||||||
if not token or len(token) < 8:
|
if not token or len(token) < 8:
|
||||||
return ""
|
return ""
|
||||||
|
# Первые 4 символа + звёздочки + последние 4 символа
|
||||||
return token[:4] + "*" * (len(token) - 8) + token[-4:]
|
return token[:4] + "*" * (len(token) - 8) + token[-4:]
|
||||||
|
|||||||
+96
-49
@@ -1,20 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
HTTP-клиент для Nubes API + автоопределение стенда.
|
HTTP-клиент для Nubes API — тонкая обёртка над requests.Session.
|
||||||
|
|
||||||
Класс HttpClient — тонкая обёртка над requests.Session:
|
HttpClient:
|
||||||
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
|
- Добавляет обязательные заголовки:
|
||||||
- GET: raise_for_status → .json()
|
Authorization: Bearer <token> — аутентификация
|
||||||
- POST: проверка r.ok, извлечение Location-заголовка
|
User-Agent: Mozilla/5.0 — DDoS-Guard блокирует python-requests по умолчанию
|
||||||
|
- GET: raise_for_status → .json() — автоматически проверяет HTTP-статус
|
||||||
|
- POST: проверка r.ok, извлечение Location-заголовка + UUID
|
||||||
|
- raw_delete: DELETE без авторизации (для CMDB)
|
||||||
|
|
||||||
Функции автостенда:
|
Функции автоопределения стенда:
|
||||||
- detect_endpoint(token) — пробует dev→test стенды, возвращает URL
|
- detect_endpoint(token) — пробует dev→test стенды по токену
|
||||||
- stand_name(endpoint) — "dev" / "test" по URL
|
- stand_name(endpoint) — "dev" / "test" по URL
|
||||||
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
|
- create_client(token) — HttpClient + endpoint одним вызовом
|
||||||
|
|
||||||
|
Зачем автоопределение: пользователь вводит токен, мы не знаем dev это или test.
|
||||||
|
Пробуем оба стенда — какой ответит с results != None, тот и рабочий.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
|
# Список стендов для автоопределения.
|
||||||
|
# Порядок ВАЖЕН: dev первый — он быстрее (меньше нагрузка), test — резервный.
|
||||||
STANDS = [
|
STANDS = [
|
||||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
||||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
||||||
@@ -23,10 +30,16 @@ STANDS = [
|
|||||||
|
|
||||||
def detect_endpoint(token):
|
def detect_endpoint(token):
|
||||||
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
||||||
|
|
||||||
Делает GET /instances?pageSize=1 на каждый стенд.
|
Алгоритм:
|
||||||
Если results != None — стенд рабочий, возвращаем его URL.
|
1. Для каждого стенда создаём HttpClient + делаем GET /instances?pageSize=1.
|
||||||
Если ни один не подошёл — возвращаем None."""
|
2. Если results != None — стенд рабочий, токен валиден → возвращаем URL.
|
||||||
|
3. Если Exception (401/403/таймаут) — пробуем следующий.
|
||||||
|
4. Ни один не подошёл → None.
|
||||||
|
|
||||||
|
Почему results != None а не просто HTTP 200:
|
||||||
|
API может вернуть 200 с пустым списком (нет инстансов) — это норм.
|
||||||
|
results=None — признак что ответ не соответствует ожидаемой структуре."""
|
||||||
for ep in STANDS:
|
for ep in STANDS:
|
||||||
try:
|
try:
|
||||||
c = HttpClient(ep, token)
|
c = HttpClient(ep, token)
|
||||||
@@ -39,10 +52,10 @@ def detect_endpoint(token):
|
|||||||
|
|
||||||
|
|
||||||
def stand_name(endpoint):
|
def stand_name(endpoint):
|
||||||
"""dev/test по URL стенда.
|
"""Определить имя стенда по URL.
|
||||||
|
|
||||||
Ищет подстроку 'dev' или 'test' в URL.
|
Ищет подстроку 'dev' или 'test' в URL.
|
||||||
Если не найдено — возвращает '?'."""
|
Если не найдено — '?' (неизвестный стенд)."""
|
||||||
for name in ("dev", "test"):
|
for name in ("dev", "test"):
|
||||||
if name in (endpoint or ""):
|
if name in (endpoint or ""):
|
||||||
return name
|
return name
|
||||||
@@ -50,10 +63,11 @@ def stand_name(endpoint):
|
|||||||
|
|
||||||
|
|
||||||
def create_client(token, fallback_endpoint=None):
|
def create_client(token, fallback_endpoint=None):
|
||||||
"""HttpClient с автоопределением стенда по токену.
|
"""HttpClient с автоопределением стенда.
|
||||||
|
|
||||||
Сначала detect_endpoint(token), если не сработало — fallback_endpoint.
|
Сначала detect_endpoint(token) — dev→test.
|
||||||
Возвращает кортеж (HttpClient, endpoint_url) или None если стенд не определён."""
|
Если не сработало — fallback_endpoint (из конфига).
|
||||||
|
Возвращает (HttpClient, endpoint_url) или None."""
|
||||||
ep = detect_endpoint(token) or fallback_endpoint
|
ep = detect_endpoint(token) or fallback_endpoint
|
||||||
if not ep:
|
if not ep:
|
||||||
return None
|
return None
|
||||||
@@ -62,43 +76,66 @@ def create_client(token, fallback_endpoint=None):
|
|||||||
|
|
||||||
class HttpClient:
|
class HttpClient:
|
||||||
"""HTTP-клиент для Nubes REST API.
|
"""HTTP-клиент для Nubes REST API.
|
||||||
|
|
||||||
Использует requests.Session для keep-alive соединений.
|
Использует requests.Session для:
|
||||||
Добавляет обязательные заголовки:
|
- Keep-alive (переиспользование TCP+TLS между запросами)
|
||||||
- Authorization: Bearer <token> (аутентификация)
|
- Единые заголовки для всех запросов
|
||||||
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
|
|
||||||
|
Обязательные заголовки:
|
||||||
|
- Authorization: Bearer <token> — без этого API вернёт 401
|
||||||
|
- User-Agent: Mozilla/5.0 — DDoS-Guard блокирует "python-requests/2.x"
|
||||||
|
|
||||||
|
Таймауты: GET=10с (лёгкие), POST=30с (создание ресурсов дольше)."""
|
||||||
|
|
||||||
def __init__(self, endpoint, token):
|
def __init__(self, endpoint, token):
|
||||||
# Убираем trailing slash чтобы потом добавлять "/path"
|
# Убираем trailing slash (".../svc/" → ".../svc")
|
||||||
|
# чтобы path добавлялся как "/path", а не "path"
|
||||||
self._endpoint = endpoint.rstrip("/")
|
self._endpoint = endpoint.rstrip("/")
|
||||||
# Session — переиспользует TCP-соединения между запросами
|
# Session переиспользует TCP + TLS handshake между запросами.
|
||||||
|
# Без Session каждый запрос делал бы новый connect → медленно.
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._session.headers.update({
|
self._session.headers.update({
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0", # DDoS-Guard: не python-requests
|
||||||
})
|
})
|
||||||
|
|
||||||
def get(self, path, **kwargs):
|
def get(self, path, **kwargs):
|
||||||
"""GET-запрос. Таймаут по умолчанию 10 секунд.
|
"""GET-запрос к Nubes API.
|
||||||
|
|
||||||
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
|
Особенности:
|
||||||
Возвращает распарсенный JSON (dict/list)."""
|
- Таймаут 10 секунд по умолчанию.
|
||||||
|
- raise_for_status() — 4xx/5xx → HTTPError (ловим выше).
|
||||||
|
- Пустой ответ (нет body) → {} (норма для validate-cfs).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: str — путь относительно endpoint (напр. "/instances").
|
||||||
|
**kwargs — params, timeout, headers и т.д.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict/list — распарсенный JSON, или {} если ответ пустой."""
|
||||||
kwargs.setdefault("timeout", 10)
|
kwargs.setdefault("timeout", 10)
|
||||||
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
|
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
if not r.text or not r.text.strip():
|
if not r.text or not r.text.strip():
|
||||||
return {} # пустой ответ (напр. validate-cfs успех)
|
return {} # пустой ответ — норма для некоторых эндпоинтов
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def post(self, path, data=None, **kwargs):
|
def post(self, path, data=None, **kwargs):
|
||||||
"""POST-запрос. Таймаут по умолчанию 30 секунд.
|
"""POST-запрос к Nubes API.
|
||||||
|
|
||||||
Отправляет data как JSON (json=...).
|
Особенности:
|
||||||
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
|
- Таймаут 30 секунд по умолчанию.
|
||||||
Возвращает dict:
|
- data → json=... — requests сам ставит Content-Type: application/json.
|
||||||
- Распарсенный JSON (если ответ — валидный JSON-объект)
|
- Извлекает Location-заголовок → UUID → кладёт в instanceUid/instanceOperationUid.
|
||||||
- + ключ "_location" со значением заголовка Location (если есть)
|
Это нужно чтобы не полагаться только на тело ответа (которое может быть пустым).
|
||||||
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
|
- При HTTP-ошибке: Exception с кодом и телом ответа.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: str — путь (напр. "/instances").
|
||||||
|
data: dict|None — тело запроса (сериализуется в JSON).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с полями ответа + _status + _location (+ UUID если найден)."""
|
||||||
kwargs.setdefault("timeout", 30)
|
kwargs.setdefault("timeout", 30)
|
||||||
url = f"{self._endpoint}{path}"
|
url = f"{self._endpoint}{path}"
|
||||||
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
||||||
@@ -112,15 +149,17 @@ class HttpClient:
|
|||||||
result = parsed
|
result = parsed
|
||||||
result["_status"] = r.status_code
|
result["_status"] = r.status_code
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # тело не JSON или не dict — ок, Location всё равно извлечём
|
pass # тело не JSON — ок, UUID извлечём из Location
|
||||||
|
# Location-заголовок: "./UUID" или "/api/v1/svc/.../UUID"
|
||||||
loc = r.headers.get("Location", "")
|
loc = r.headers.get("Location", "")
|
||||||
if loc:
|
if loc:
|
||||||
# Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID"
|
|
||||||
result["_location"] = loc
|
result["_location"] = loc
|
||||||
# Извлечь UUID и положить в правильное поле ответа
|
# Извлекаем UUID — последний сегмент после split("/")
|
||||||
parts = loc.rstrip("/").split("/")
|
parts = loc.rstrip("/").split("/")
|
||||||
uid = parts[-1]
|
uid = parts[-1]
|
||||||
|
# Проверка: не ".", длина >= 32 (UUID = 36 символов с дефисами)
|
||||||
if uid and uid != "." and len(uid) >= 32:
|
if uid and uid != "." and len(uid) >= 32:
|
||||||
|
# Кладём UUID в правильное поле в зависимости от эндпоинта
|
||||||
if "/instanceOperations" in path:
|
if "/instanceOperations" in path:
|
||||||
result["instanceOperationUid"] = uid
|
result["instanceOperationUid"] = uid
|
||||||
elif "/instances" in path:
|
elif "/instances" in path:
|
||||||
@@ -128,9 +167,17 @@ class HttpClient:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def raw_delete(self, url):
|
def raw_delete(self, url):
|
||||||
"""DELETE-запрос к произвольному URL (CMDB API).
|
"""DELETE-запрос к произвольному URL — БЕЗ авторизации.
|
||||||
Использует отдельную сессию БЕЗ auth-заголовков.
|
|
||||||
Возвращает кортеж (ok: bool, status_code: int)."""
|
Используется для CMDB API (cmdb-api.deck.nubes.ru) —
|
||||||
|
жёсткое удаление недосозданных/зависших инстансов.
|
||||||
|
CMDB не требует Bearer-токена.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: str — полный URL (не path, другой хост!)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ok: bool, status_code: int|str)."""
|
||||||
try:
|
try:
|
||||||
r = requests.delete(url, timeout=10)
|
r = requests.delete(url, timeout=10)
|
||||||
return r.ok, r.status_code
|
return r.ok, r.status_code
|
||||||
|
|||||||
+70
-12
@@ -1,45 +1,103 @@
|
|||||||
"""
|
"""
|
||||||
Общие утилиты: извлечение UUID из ответов API Nubes.
|
Общие утилиты: извлечение UUID из ответов API Nubes.
|
||||||
|
|
||||||
Используется: api_test.py, scenario.py, executor.py.
|
Проблема: Nubes API возвращает UUID инстанса/операции в разных форматах
|
||||||
Заменяет разрозненные реализации _find_uid / _uid_from_location.
|
в зависимости от эндпоинта и фазы жизненного цикла:
|
||||||
|
- POST /instances → {"instanceUid": "..."} (прямой ключ)
|
||||||
|
- POST /instances → Location: "./uuid" (заголовок)
|
||||||
|
- POST /instanceOperations → {"instanceOperationUid": "..."} (другой ключ)
|
||||||
|
- GET /instanceOperations → {"instanceOperation": {"instanceOperationUid": "..."}} (вложенный)
|
||||||
|
|
||||||
|
Эти две функции — единый способ достать UUID из ЛЮБОГО ответа.
|
||||||
|
Раньше каждая точка вызова имела свою реализацию _find_uid / _uid_from_location.
|
||||||
|
Теперь всё здесь — один источник правды.
|
||||||
|
|
||||||
|
Используется: executor.py, api_test.py, scenario.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def find_uid(resp):
|
def find_uid(resp):
|
||||||
"""Извлечь instanceUid или instanceOperationUid из ответа API.
|
"""Извлечь instanceUid или instanceOperationUid из dict-ответа API.
|
||||||
|
|
||||||
Ищет в нескольких местах:
|
Стратегия поиска (в порядке приоритета):
|
||||||
1. На верхнем уровне: instanceOperationUid → instanceUid → uid → Uid
|
1. Верхний уровень — прямые ключи (стиль api_test.py):
|
||||||
2. Во вложенных dict-значениях: {key: {instanceOperationUid: ..., instanceUid: ...}}
|
instanceOperationUid → instanceUid → uid → Uid
|
||||||
|
2. Вложенные dict-значения (стиль scenario.py):
|
||||||
|
Для каждого значения-словаря проверяем instanceOperationUid → instanceUid
|
||||||
|
|
||||||
|
Почему такой порядок:
|
||||||
|
- instanceOperationUid приоритетнее — это ответ на POST /instanceOperations
|
||||||
|
- instanceUid — ответ на POST /instances или GET /instances
|
||||||
|
- uid / Uid — legacy-форматы, почти не встречаются
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resp: dict — распарсенный JSON-ответ API (или что угодно).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str или None — UUID (36 символов) или None если не нашли.
|
||||||
"""
|
"""
|
||||||
|
# Защита: если передали не dict (например, list или str) — сразу None
|
||||||
if not isinstance(resp, dict):
|
if not isinstance(resp, dict):
|
||||||
return None
|
return None
|
||||||
# Верхний уровень — прямые ключи (api_test.py-стиль)
|
|
||||||
|
# ── Уровень 1: прямые ключи на верхнем уровне ──
|
||||||
|
# Пример: {"instanceUid": "abc-123", ...}
|
||||||
|
# Проверяем по порядку: сначала специфичные, потом общие
|
||||||
for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"):
|
for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"):
|
||||||
v = resp.get(key)
|
v = resp.get(key)
|
||||||
|
# isinstance(str) — отсекаем числа, None, пустые строки
|
||||||
if isinstance(v, str) and v:
|
if isinstance(v, str) and v:
|
||||||
return v
|
return v
|
||||||
# Вложенные dict-значения (scenario.py-стиль)
|
|
||||||
|
# ── Уровень 2: вложенные dict-значения ──
|
||||||
|
# Пример: {"instanceOperation": {"instanceOperationUid": "abc-123"}}
|
||||||
|
# Перебираем ВСЕ значения resp, ищем вложенные словари
|
||||||
for v in resp.values():
|
for v in resp.values():
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
|
# Внутри вложенного dict — те же ключи что на верхнем уровне
|
||||||
uid = v.get("instanceOperationUid") or v.get("instanceUid")
|
uid = v.get("instanceOperationUid") or v.get("instanceUid")
|
||||||
if uid:
|
if uid:
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
# Ничего не нашли
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def uid_from_location(loc):
|
def uid_from_location(loc):
|
||||||
"""Извлечь UUID из Location-заголовка.
|
"""Извлечь UUID из Location-заголовка HTTP-ответа.
|
||||||
|
|
||||||
Location бывает: "./UUID", "/api/v1/svc/instances/UUID", "/api/v1/svc/instanceOperations/UUID"
|
Location — это HTTP-заголовок, который Nubes API возвращает при создании
|
||||||
Берём последний сегмент после split("/").
|
ресурса (201 Created). Форматы:
|
||||||
|
- "./uuid" (относительный)
|
||||||
|
- "/api/v1/svc/instances/uuid" (абсолютный путь)
|
||||||
|
- "/api/v1/svc/instanceOperations/uuid" (полный путь)
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Убираем trailing slash.
|
||||||
|
2. Split по "/" — берём ПОСЛЕДНИЙ сегмент.
|
||||||
|
3. Проверяем что это похоже на UUID (>= 32 символа) и не ".".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loc: str или None — значение заголовка Location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str или None — UUID или None если не нашли.
|
||||||
"""
|
"""
|
||||||
|
# Пустой Location → нечего извлекать
|
||||||
if not loc:
|
if not loc:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Убираем trailing slash (напр. "./uuid/" → "./uuid")
|
||||||
|
# Split по "/" → [".", "uuid"] или ["", "api", "v1", "svc", "instances", "uuid"]
|
||||||
parts = str(loc).rstrip("/").split("/")
|
parts = str(loc).rstrip("/").split("/")
|
||||||
|
|
||||||
|
# Последний сегмент — это UUID
|
||||||
uid = parts[-1]
|
uid = parts[-1]
|
||||||
# UUID — 36 символов с 4 дефисами (уже проверено в http_client.py)
|
|
||||||
|
# Проверки:
|
||||||
|
# uid != "." — Location "./" даст пустой последний сегмент
|
||||||
|
# len >= 32 — UUID всегда 36 символов (8-4-4-4-12), но на всякий случай >= 32
|
||||||
if uid and uid != "." and len(uid) >= 32:
|
if uid and uid != "." and len(uid) >= 32:
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
+71
-20
@@ -1,25 +1,45 @@
|
|||||||
"""
|
"""
|
||||||
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS).
|
Инициализация схемы БД — идемпотентно (CREATE IF NOT EXISTS) + startup cleanup.
|
||||||
|
|
||||||
Вызывается при старте приложения (в app.py, до register_blueprint).
|
Вызывается при ПЕРВОМ обращении к БД в каждом gunicorn-воркере.
|
||||||
Безопасно для нескольких gunicorn-воркеров — PostgreSQL корректно
|
Безопасно для конкурентного вызова из нескольких воркеров — PostgreSQL
|
||||||
обрабатывает конкурентный DDL.
|
корректно обрабатывает одновременный CREATE IF NOT EXISTS.
|
||||||
|
|
||||||
Таблица runs — история запусков операций:
|
ТАБЛИЦЫ:
|
||||||
- id, created_at
|
|
||||||
- client_id, stand (изоляция по пользователю и стенду)
|
runs — история ВСЕХ запусков операций:
|
||||||
- user_email (кто запустил)
|
- Ручной режим: каждая операция (create/modify/delete/...) — одна строка
|
||||||
- svc_id, svc_name, op_name, svc_op_id
|
- Сценарный режим: каждый шаг сценария — одна строка
|
||||||
- instance_uid, display_name, op_uid
|
- Колонки: id, client_id, stand, user_email, svc_id, svc_name, op_name,
|
||||||
- status (OK/FAIL/TIMEOUT), duration_sec, error_log
|
svc_op_id, op_uid, instance_uid, display_name, status, duration_sec,
|
||||||
- params (JSONB), stages (JSONB)
|
error_log, params (JSONB), stages (JSONB), app_version,
|
||||||
- app_version (версия приложения)
|
scenario_run_id, step_number, instance_meta (JSONB)
|
||||||
|
|
||||||
|
scenario_runs — история запусков сценариев:
|
||||||
|
- Одна строка = один запуск сценария
|
||||||
|
- Колонки: id, client_id, stand, scenario_name, status (RUNNING/OK/FAIL/TIMEOUT),
|
||||||
|
current_step, total_steps, instance_bindings (JSONB), duration_sec, error_log,
|
||||||
|
definition_id, definition_version
|
||||||
|
|
||||||
|
scenario_definitions — определения сценариев (редактируются через UI):
|
||||||
|
- Колонки: id, client_id, stand, name, steps (JSONB), version,
|
||||||
|
is_active, created_at, updated_at, updated_by
|
||||||
|
- Уникальность: (client_id, stand, LOWER(name)) — нельзя два сценария с одинаковым именем
|
||||||
|
|
||||||
|
STARTUP CLEANUP:
|
||||||
|
При старте приложения все зависшие RUNNING-сценарии (старше 1 часа)
|
||||||
|
переводятся в TIMEOUT. Это чистит последствия падения gunicorn-воркера.
|
||||||
|
|
||||||
|
SEED:
|
||||||
|
При первом старте импортируются дефолтные сценарии из scenario_seed.yaml.
|
||||||
|
INSERT ON CONFLICT DO NOTHING — не перезаписывает существующие.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
|
# ── DDL для таблицы runs (история операций) ──
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS runs (
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -42,17 +62,21 @@ CREATE TABLE IF NOT EXISTS runs (
|
|||||||
app_version VARCHAR(16)
|
app_version VARCHAR(16)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Индекс для фильтрации по пользователю и стенду (основной запрос истории)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_client_stand
|
CREATE INDEX IF NOT EXISTS idx_runs_client_stand
|
||||||
ON runs (client_id, stand, created_at DESC);
|
ON runs (client_id, stand, created_at DESC);
|
||||||
|
|
||||||
|
-- Индекс для поиска по instance_uid (связь с scenario_runs)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_instance
|
CREATE INDEX IF NOT EXISTS idx_runs_instance
|
||||||
ON runs (instance_uid);
|
ON runs (instance_uid);
|
||||||
|
|
||||||
|
-- Индекс по дате создания (для очистки старых записей)
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_created
|
CREATE INDEX IF NOT EXISTS idx_runs_created
|
||||||
ON runs (created_at);
|
ON runs (created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Миграция для существующих таблиц (добавляем колонки если их нет)
|
# ── Миграции: добавляем колонки которых может не быть в старых БД ──
|
||||||
|
# ALTER TABLE ADD COLUMN IF NOT EXISTS — безопасно, не ломает существующие данные
|
||||||
MIGRATION_SQL = """
|
MIGRATION_SQL = """
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
||||||
@@ -62,7 +86,7 @@ ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER;
|
|||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB;
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS instance_meta JSONB;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Таблица сценариев — один запуск = одна строка
|
# ── DDL для scenario_runs (запуски сценариев) ──
|
||||||
SCENARIO_RUNS_SQL = """
|
SCENARIO_RUNS_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS scenario_runs (
|
CREATE TABLE IF NOT EXISTS scenario_runs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -80,16 +104,19 @@ CREATE TABLE IF NOT EXISTS scenario_runs (
|
|||||||
app_version VARCHAR(16)
|
app_version VARCHAR(16)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Индекс для списка последних запусков (основной запрос)
|
||||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
||||||
ON scenario_runs (client_id, stand, created_at DESC);
|
ON scenario_runs (client_id, stand, created_at DESC);
|
||||||
|
|
||||||
|
-- Индекс для проверки lock (есть ли RUNNING)
|
||||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
||||||
ON scenario_runs (client_id, stand, status);
|
ON scenario_runs (client_id, stand, status);
|
||||||
|
|
||||||
|
-- Миграции для scenario_runs
|
||||||
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
||||||
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
||||||
|
|
||||||
-- Таблица определений сценариев — редактируются без редеплоя
|
-- ── DDL для scenario_definitions (определения, редактируются через UI) ──
|
||||||
CREATE TABLE IF NOT EXISTS scenario_definitions (
|
CREATE TABLE IF NOT EXISTS scenario_definitions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
client_id VARCHAR(64) NOT NULL,
|
client_id VARCHAR(64) NOT NULL,
|
||||||
@@ -103,16 +130,28 @@ CREATE TABLE IF NOT EXISTS scenario_definitions (
|
|||||||
updated_by VARCHAR(128)
|
updated_by VARCHAR(128)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Уникальность имени в рамках пользователя и стенда
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique
|
||||||
ON scenario_definitions (client_id, stand, LOWER(name));
|
ON scenario_definitions (client_id, stand, LOWER(name));
|
||||||
|
|
||||||
|
-- Индекс для фильтрации активных
|
||||||
CREATE INDEX IF NOT EXISTS idx_scenario_defs_active
|
CREATE INDEX IF NOT EXISTS idx_scenario_defs_active
|
||||||
ON scenario_definitions (client_id, stand, is_active);
|
ON scenario_definitions (client_id, stand, is_active);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _seed_scenarios():
|
def _seed_scenarios():
|
||||||
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте."""
|
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте.
|
||||||
|
|
||||||
|
Формат YAML:
|
||||||
|
scenarios:
|
||||||
|
- name: "dummy_test"
|
||||||
|
client_id: "" # пустой = seed (доступен всем)
|
||||||
|
stand: "" # пустой = seed
|
||||||
|
steps: [...]
|
||||||
|
|
||||||
|
INSERT ON CONFLICT DO NOTHING — если пользователь уже создал сценарий
|
||||||
|
с таким именем, seed не перезапишет его."""
|
||||||
try:
|
try:
|
||||||
import yaml, os
|
import yaml, os
|
||||||
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml")
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml")
|
||||||
@@ -142,7 +181,15 @@ def _seed_scenarios():
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова."""
|
"""Применить схему + миграции — идемпотентно, безопасно для конкуретного вызова.
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
1. Создать таблицы (CREATE IF NOT EXISTS)
|
||||||
|
2. Применить миграции (ALTER TABLE ADD COLUMN IF NOT EXISTS)
|
||||||
|
3. Startup cleanup: зависшие RUNNING → TIMEOUT
|
||||||
|
4. Seed дефолтных сценариев
|
||||||
|
"""
|
||||||
|
# Без DB_USER приложение работает без БД (без истории)
|
||||||
if not os.getenv("DB_USER"):
|
if not os.getenv("DB_USER"):
|
||||||
print("[DB] DB_USER not set — skipping init", flush=True)
|
print("[DB] DB_USER not set — skipping init", flush=True)
|
||||||
return
|
return
|
||||||
@@ -154,6 +201,8 @@ def init_db():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Шаг 1-2: схема + миграции
|
||||||
cur.execute(SCHEMA_SQL)
|
cur.execute(SCHEMA_SQL)
|
||||||
cur.execute(MIGRATION_SQL)
|
cur.execute(MIGRATION_SQL)
|
||||||
cur.execute(SCENARIO_RUNS_SQL)
|
cur.execute(SCENARIO_RUNS_SQL)
|
||||||
@@ -161,7 +210,9 @@ def init_db():
|
|||||||
cur.close()
|
cur.close()
|
||||||
print("[DB] Schema initialized", flush=True)
|
print("[DB] Schema initialized", flush=True)
|
||||||
|
|
||||||
# Startup cleanup: зависшие RUNNING сценарии старше 1 часа → TIMEOUT
|
# Шаг 3: Startup cleanup — перевести зависшие RUNNING в TIMEOUT
|
||||||
|
# Если gunicorn упал во время выполнения сценария, статус остался RUNNING.
|
||||||
|
# Чистим всё что старше 1 часа — операция точно не могла длиться дольше.
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -183,5 +234,5 @@ def init_db():
|
|||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|
||||||
# Seed сценариев — только при первом старте (INSERT ON CONFLICT DO NOTHING)
|
# Шаг 4: Seed сценариев (вне основной транзакции)
|
||||||
_seed_scenarios()
|
_seed_scenarios()
|
||||||
|
|||||||
+46
-11
@@ -1,26 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
Connection pool для PostgreSQL (psycopg2).
|
Connection pool для PostgreSQL (psycopg2) — lazy-init в каждом gunicorn-воркере.
|
||||||
|
|
||||||
Lazy-init: пул создаётся при первом обращении к БД в каждом gunicorn-воркере.
|
Почему lazy-init: gunicorn форкает воркеры, и если создать пул до fork,
|
||||||
|
все воркеры будут делить ОДНО соединение → гонки и падения.
|
||||||
|
Поэтому _pool создаётся при ПЕРВОМ обращении к БД в каждом воркере отдельно.
|
||||||
|
|
||||||
Переменные окружения (КАЖДАЯ отдельно):
|
Переменные окружения (КАЖДАЯ отдельно, не DSN-строкой):
|
||||||
DB_HOST — хост
|
DB_HOST — хост PostgreSQL
|
||||||
DB_PORT — порт (5432)
|
DB_PORT — порт (по умолчанию 5432)
|
||||||
DB_NAME — имя БД
|
DB_NAME — имя базы данных
|
||||||
DB_USER — пользователь
|
DB_USER — пользователь
|
||||||
DB_PASSWORD — пароль
|
DB_PASSWORD — пароль
|
||||||
DB_SSLMODE — sslmode (require)
|
DB_SSLMODE — sslmode (по умолчанию "require")
|
||||||
|
|
||||||
|
ThreadedConnectionPool(1, 5):
|
||||||
|
- Минимум 1 соединение (всегда готово)
|
||||||
|
- Максимум 5 одновременных соединений на воркер
|
||||||
|
- При 2 воркерах gunicorn: макс. 10 соединений к БД всего
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2 import pool
|
from psycopg2 import pool
|
||||||
|
|
||||||
|
# _pool — по одному на gunicorn-воркер (создаётся при первом get_conn)
|
||||||
_pool = None
|
_pool = None
|
||||||
|
# _initialized — схема уже применена в этом воркере
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
|
||||||
def _dsn():
|
def _dsn():
|
||||||
|
"""Построить DSN-строку из отдельных переменных окружения.
|
||||||
|
|
||||||
|
DSN (Data Source Name) — это строка подключения для psycopg2:
|
||||||
|
"host=... port=... dbname=... user=... password=... sslmode=..."
|
||||||
|
|
||||||
|
Каждое поле отдельной переменной — безопаснее чем один DATABASE_URL."""
|
||||||
return (
|
return (
|
||||||
f"host={os.getenv('DB_HOST')} "
|
f"host={os.getenv('DB_HOST')} "
|
||||||
f"port={os.getenv('DB_PORT', '5432')} "
|
f"port={os.getenv('DB_PORT', '5432')} "
|
||||||
@@ -32,7 +47,10 @@ def _dsn():
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_schema():
|
def _ensure_schema():
|
||||||
"""Инициализировать схему при первом обращении к БД."""
|
"""Инициализировать схему БД при первом обращении в этом воркере.
|
||||||
|
|
||||||
|
Вызывает init_db() который делает CREATE IF NOT EXISTS — идемпотентно.
|
||||||
|
_initialized — глобальный флаг чтобы не дёргать init_db при каждом запросе."""
|
||||||
global _initialized
|
global _initialized
|
||||||
if _initialized:
|
if _initialized:
|
||||||
return
|
return
|
||||||
@@ -41,25 +59,42 @@ def _ensure_schema():
|
|||||||
init_db()
|
init_db()
|
||||||
_initialized = True
|
_initialized = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # без БД приложение работает (без истории)
|
||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def get_pool():
|
||||||
|
"""Получить connection pool (создать при первом вызове).
|
||||||
|
|
||||||
|
ThreadedConnectionPool — каждый поток получает своё соединение.
|
||||||
|
Для gunicorn с sync-воркерами (не threads) это эквивалентно SimpleConnectionPool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ThreadedConnectionPool или None если DB_USER не задан."""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
if not os.getenv("DB_USER"):
|
if not os.getenv("DB_USER"):
|
||||||
return None
|
return None # нет переменных БД — работаем без истории
|
||||||
_pool = pool.ThreadedConnectionPool(1, 5, _dsn())
|
_pool = pool.ThreadedConnectionPool(1, 5, _dsn())
|
||||||
_ensure_schema()
|
_ensure_schema() # при первом соединении применяем схему
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
"""Взять соединение из пула.
|
||||||
|
|
||||||
|
Вызывается перед КАЖДЫМ запросом к БД.
|
||||||
|
Возвращает None если БД не настроена.
|
||||||
|
|
||||||
|
ВАЖНО: после использования ОБЯЗАТЕЛЬНО вернуть через put_conn()."""
|
||||||
p = get_pool()
|
p = get_pool()
|
||||||
return p.getconn() if p else None
|
return p.getconn() if p else None
|
||||||
|
|
||||||
|
|
||||||
def put_conn(conn):
|
def put_conn(conn):
|
||||||
|
"""Вернуть соединение в пул.
|
||||||
|
|
||||||
|
ВСЕГДА вызывается в finally-блоке после get_conn.
|
||||||
|
Если не вернуть — пул исчерпается и приложение встанет."""
|
||||||
p = get_pool()
|
p = get_pool()
|
||||||
if p and conn:
|
if p and conn:
|
||||||
p.putconn(conn)
|
p.putconn(conn)
|
||||||
|
|||||||
+37
-4
@@ -1,5 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Сохранение результатов тестов в БД.
|
Сохранение результатов тестов в БД (таблица runs).
|
||||||
|
|
||||||
|
Вызывается после КАЖДОЙ операции:
|
||||||
|
- Ручной режим: _finish_op() в api_test.py
|
||||||
|
- Сценарный режим: run_scenario() в scenario.py (после каждого шага)
|
||||||
|
|
||||||
|
Сохраняет ВСЕ параметры запроса и ответа — для истории и анализа ошибок.
|
||||||
|
Секретные поля (password, token, ...) маскируются через redact_params().
|
||||||
|
instance_meta — полный JSON ответа GET /instances/{uid} (для post-mortem анализа).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -11,8 +19,31 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
error_log, params, stages, app_version,
|
error_log, params, stages, app_version,
|
||||||
scenario_run_id=None, step_number=None, instance_meta=None):
|
scenario_run_id=None, step_number=None, instance_meta=None):
|
||||||
"""Сохранить один запуск в таблицу runs.
|
"""Сохранить один запуск в таблицу runs.
|
||||||
Опционально: scenario_run_id + step_number для шагов сценария.
|
|
||||||
instance_meta — JSONB с полной информацией об инстансе."""
|
Эта функция — единственная точка записи в runs.
|
||||||
|
Вызывается и из ручного режима, и из сценариев.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: str — ClientID пользователя
|
||||||
|
stand: str — "dev"/"test"
|
||||||
|
user_email: str — email из JWT
|
||||||
|
svc_id: int — ID сервиса
|
||||||
|
svc_name: str — имя сервиса (из ответа poll)
|
||||||
|
op_name: str — "create"/"modify"/"delete"/"suspend"/"resume"/"redeploy"
|
||||||
|
svc_op_id: int — числовой ID операции
|
||||||
|
op_uid: str — UUID операции
|
||||||
|
instance_uid: str — UUID инстанса
|
||||||
|
display_name: str — displayName инстанса
|
||||||
|
status: str — "OK"/"FAIL"/"TIMEOUT"/"RUNNING"
|
||||||
|
duration_sec: float — длительность операции
|
||||||
|
error_log: str — текст ошибки (если FAIL)
|
||||||
|
params: dict — параметры операции (уже маскированные)
|
||||||
|
stages: list — этапы выполнения
|
||||||
|
app_version: str — версия приложения
|
||||||
|
scenario_run_id: int|None — ID запуска сценария
|
||||||
|
step_number: int|None — номер шага в сценарии
|
||||||
|
instance_meta: dict|None — полная информация об инстансе (JSONB)
|
||||||
|
"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
print("[DB] save_run SKIP: no connection", flush=True)
|
print("[DB] save_run SKIP: no connection", flush=True)
|
||||||
@@ -30,10 +61,12 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
||||||
op_uid, instance_uid, display_name, status, duration_sec,
|
op_uid, instance_uid, display_name, status, duration_sec,
|
||||||
error_log,
|
error_log,
|
||||||
|
# params и stages — JSONB: сериализуем в JSON-строку
|
||||||
json.dumps(params) if params else None,
|
json.dumps(params) if params else None,
|
||||||
json.dumps(stages) if stages else None,
|
json.dumps(stages) if stages else None,
|
||||||
app_version,
|
app_version,
|
||||||
scenario_run_id, step_number,
|
scenario_run_id, step_number,
|
||||||
|
# instance_meta — тоже JSONB
|
||||||
json.dumps(instance_meta) if instance_meta else None,
|
json.dumps(instance_meta) if instance_meta else None,
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -43,4 +76,4 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
print(f"[DB] save_run error: {e}", flush=True)
|
print(f"[DB] save_run error: {e}", flush=True)
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn) # ВСЕГДА возвращаем соединение в пул
|
||||||
|
|||||||
+103
-22
@@ -1,9 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Единый executor операции Nubes.
|
Единый executor операции Nubes — ЕДИНСТВЕННАЯ точка запуска операций.
|
||||||
|
|
||||||
Делает всё до /run включительно. НЕ поллит.
|
Выполняет ВСЕ шаги от начала до /run (включительно):
|
||||||
Вызывает tracker_add для create сразу после получения instance_uid (защита от сирот).
|
1. CREATE: POST /instances → получает instanceUid
|
||||||
Используется api_test.py (ручной) и scenario.py (сценарный).
|
2. CREATE + NON-CREATE: POST /instanceOperations → получает opUid
|
||||||
|
3. Параметры: send_params_terraform → нормализация + refSvc-резолв + отправка
|
||||||
|
4. POST /instanceOperations/{opUid}/run → запуск
|
||||||
|
|
||||||
|
НЕ делает поллинг — поллинг вынесен в poll.py (используется отдельно).
|
||||||
|
|
||||||
|
Критический инвариант: tracker_add вызывается СРАЗУ после получения instanceUid
|
||||||
|
(до params и run), чтобы даже при падении gunicorn инстанс не стал сиротой.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- api_test.py → ручной режим (фронтенд → POST /api/test → execute_operation)
|
||||||
|
- scenario.py → сценарный режим (каждый шаг → execute_operation)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.utils import find_uid, uid_from_location
|
from api.utils import find_uid, uid_from_location
|
||||||
@@ -14,75 +25,145 @@ from operations.tracker import add as tracker_add
|
|||||||
def execute_operation(client, service_id, operation, instance_uid, params,
|
def execute_operation(client, service_id, operation, instance_uid, params,
|
||||||
svc_op_id=None, display_name=None, descr=None,
|
svc_op_id=None, display_name=None, descr=None,
|
||||||
client_id="", stand=""):
|
client_id="", stand=""):
|
||||||
"""Запустить операцию и вернуть результат.
|
"""Запустить операцию и вернуть результат (без поллинга).
|
||||||
|
|
||||||
|
Эта функция — «единый шлюз»: и ручной тест, и сценарий идут через неё.
|
||||||
|
Это гарантирует что поведение create/modify/delete идентично в обоих режимах.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: HttpClient
|
client: HttpClient (уже с токеном и endpoint)
|
||||||
service_id: int — ID сервиса
|
service_id: int — ID сервиса Nubes (напр. 1 для Болванки)
|
||||||
operation: str — create/delete/modify/suspend/resume/redeploy
|
operation: str — "create" | "modify" | "delete" | "suspend" | "resume" | "redeploy"
|
||||||
instance_uid: str|None — UUID существующего инстанса (None для create)
|
instance_uid: str|None — UUID существующего инстанса.
|
||||||
params: dict — {numeric_param_id: value}
|
None для create (инстанса ещё нет).
|
||||||
svc_op_id: int|None — svcOperationId (игнорируется для create)
|
params: dict — {numeric_param_id: string_value}
|
||||||
display_name: str|None — displayName (только для create)
|
Пример: {"198": "0", "199": "test-value"}
|
||||||
descr: str|None — описание инстанса
|
svc_op_id: int|None — svcOperationId (числовой ID операции в Nubes).
|
||||||
client_id: str — для tracker_add
|
Для create игнорируется (там свой ID из шаблона сервиса).
|
||||||
stand: str — для tracker_add
|
display_name: str|None — displayName для create.
|
||||||
|
Если None → генерируется "autotest-{service_id}".
|
||||||
|
descr: str|None — описание инстанса (только для create).
|
||||||
|
Если None → "created by autotest".
|
||||||
|
client_id: str — ID пользователя (для tracker_add).
|
||||||
|
stand: str — "dev" / "test" (для tracker_add).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict {ok, error, failed_step, instance_uid, op_uid, display_name}
|
dict с ВСЕГДА одинаковыми ключами:
|
||||||
ok=True при успехе, ok=False при ошибке с failed_step.
|
{ok, error, failed_step, instance_uid, op_uid, display_name}
|
||||||
|
|
||||||
|
ok=True → успех, можно поллить
|
||||||
|
ok=False → ошибка на шаге failed_step:
|
||||||
|
"instances" — не смогли создать инстанс (POST /instances)
|
||||||
|
"instanceOperations"— не смогли создать операцию (POST /instanceOperations)
|
||||||
|
"params" — ошибка в send_params_terraform
|
||||||
|
"run" — не смогли запустить (/run)
|
||||||
"""
|
"""
|
||||||
|
# ── Определяем режим: create или нет ──
|
||||||
|
# Все не-create операции требуют существующий instance_uid
|
||||||
is_create = (operation == "create")
|
is_create = (operation == "create")
|
||||||
|
|
||||||
# --- Шаг 1: POST /instances (только create) ---
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 1: POST /instances — только для create
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Создаём инстанс в Nubes, получаем его UUID.
|
||||||
|
# Это аналог нажатия «Создать» в личном кабинете.
|
||||||
if is_create:
|
if is_create:
|
||||||
|
# display_name — обязателен для create.
|
||||||
|
# Если не передан — генерируем из service_id.
|
||||||
if not display_name:
|
if not display_name:
|
||||||
display_name = f"autotest-{service_id}"
|
display_name = f"autotest-{service_id}"
|
||||||
|
|
||||||
|
# descr — описание, видно в личном кабинете.
|
||||||
if not descr:
|
if not descr:
|
||||||
descr = "created by autotest"
|
descr = "created by autotest"
|
||||||
|
|
||||||
|
# payload для POST /instances:
|
||||||
|
# serviceId — какой сервис создаём
|
||||||
|
# displayName — имя инстанса (уникально в рамках пользователя)
|
||||||
|
# descr — текстовое описание
|
||||||
payload = {"serviceId": service_id, "displayName": display_name, "descr": descr}
|
payload = {"serviceId": service_id, "displayName": display_name, "descr": descr}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.post("/instances", payload)
|
resp = client.post("/instances", payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Сетевая ошибка или HTTP-ошибка → сразу FAIL
|
||||||
return {"ok": False, "error": str(e), "failed_step": "instances",
|
return {"ok": False, "error": str(e), "failed_step": "instances",
|
||||||
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# Извлекаем instanceUid из ответа API.
|
||||||
|
# Nubes API может вернуть UUID в разных местах ответа:
|
||||||
|
# 1. resp["instanceUid"] — прямой ключ
|
||||||
|
# 2. resp["instance"]["instanceUid"] — вложенный (через find_uid)
|
||||||
|
# 3. Location-заголовок — "./uuid" (через uid_from_location)
|
||||||
instance_uid = resp.get("instanceUid") or find_uid(resp) or uid_from_location(resp.get("_location", ""))
|
instance_uid = resp.get("instanceUid") or find_uid(resp) or uid_from_location(resp.get("_location", ""))
|
||||||
|
|
||||||
|
# Если ни один метод не дал UUID — это баг API, не можем продолжать
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
return {"ok": False, "error": "No instanceUid in response", "failed_step": "instances",
|
return {"ok": False, "error": "No instanceUid in response", "failed_step": "instances",
|
||||||
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
# tracker_add СРАЗУ после instance_uid, до params/run — защита от сирот
|
# ⚠️ КРИТИЧЕСКИ: tracker_add СРАЗУ после получения instanceUid.
|
||||||
|
# Зачем: если gunicorn упадёт между create и run, инстанс останется в Nubes
|
||||||
|
# но не будет виден в UI (GET /instances иногда задерживает новый инстанс).
|
||||||
|
# Трекер — краткосрочный fallback, пока облако не подхватит.
|
||||||
|
# try/except: не ронять операцию если /tmp/ переполнен или нет прав.
|
||||||
try:
|
try:
|
||||||
tracker_add(client_id, stand, instance_uid, service_id, display_name)
|
tracker_add(client_id, stand, instance_uid, service_id, display_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # не ронять операцию из-за трекера
|
pass # silently ignore — трекер не критичен для операции
|
||||||
|
|
||||||
# --- Шаг 2: POST /instanceOperations ---
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 2: POST /instanceOperations — создаём операцию
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Для create: {instanceUid, operation} — svcOperationId не нужен
|
||||||
|
# Для не-create: {instanceUid, svcOperationId, operation} — нужен ID операции
|
||||||
|
#
|
||||||
|
# Разница: для create система сама знает какой svcOperationId использовать
|
||||||
|
# (он единственный для create у каждого сервиса).
|
||||||
|
# Для modify/delete/suspend/resume — их много, нужно указать конкретный.
|
||||||
if is_create:
|
if is_create:
|
||||||
op_payload = {"instanceUid": instance_uid, "operation": operation}
|
op_payload = {"instanceUid": instance_uid, "operation": operation}
|
||||||
else:
|
else:
|
||||||
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": operation}
|
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": operation}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
op_resp = client.post("/instanceOperations", op_payload)
|
op_resp = client.post("/instanceOperations", op_payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "failed_step": "instanceOperations",
|
return {"ok": False, "error": str(e), "failed_step": "instanceOperations",
|
||||||
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
|
# Извлекаем opUid — UUID операции, нужен для поллинга и /run
|
||||||
op_uid = op_resp.get("instanceOperationUid") or find_uid(op_resp) or uid_from_location(op_resp.get("_location", ""))
|
op_uid = op_resp.get("instanceOperationUid") or find_uid(op_resp) or uid_from_location(op_resp.get("_location", ""))
|
||||||
|
|
||||||
if not op_uid:
|
if not op_uid:
|
||||||
return {"ok": False, "error": "No opUid in response", "failed_step": "instanceOperations",
|
return {"ok": False, "error": "No opUid in response", "failed_step": "instanceOperations",
|
||||||
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||||
|
|
||||||
# --- Шаг 3-6: Параметры + run ---
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 3: Параметры — отправка значений CFS-параметров
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# send_params_terraform делает 4 вещи:
|
||||||
|
# 1. Получает cfsParams шаблон операции
|
||||||
|
# 2. resolveRefSvc — подставляет UUID инстансов для refSvcId-параметров
|
||||||
|
# 3. Отправляет пользовательские значения
|
||||||
|
# 4. Отправляет defaults для незаполненных параметров + validate-cfs
|
||||||
try:
|
try:
|
||||||
send_params_terraform(client, op_uid, params)
|
send_params_terraform(client, op_uid, params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "failed_step": "params",
|
return {"ok": False, "error": str(e), "failed_step": "params",
|
||||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# Шаг 4: POST /instanceOperations/{opUid}/run — запуск
|
||||||
|
# ═══════════════════════════════════════════════════════
|
||||||
|
# После этого операция начинает выполняться в Nubes.
|
||||||
|
# Дальше — поллинг (poll_until_done), который делает вызывающий код.
|
||||||
try:
|
try:
|
||||||
client.post(f"/instanceOperations/{op_uid}/run")
|
client.post(f"/instanceOperations/{op_uid}/run")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "failed_step": "run",
|
return {"ok": False, "error": str(e), "failed_step": "run",
|
||||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
|
|
||||||
|
# Успех — все 4 шага пройдены
|
||||||
return {"ok": True, "error": None, "failed_step": None,
|
return {"ok": True, "error": None, "failed_step": None,
|
||||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Операции с инстансами Nubes — получение списка и организации через API.
|
Операции с инстансами Nubes — получение списка и организации через API.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- main.py → отображение инфраструктуры на главной
|
||||||
|
- api_test.py → список инстансов для выбора
|
||||||
|
- scenario.py → поиск refSvcId-инстансов
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
@@ -7,10 +12,17 @@ from api.http_client import HttpClient
|
|||||||
|
|
||||||
def get_instances(client):
|
def get_instances(client):
|
||||||
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
|
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
|
||||||
|
|
||||||
pageSize=200 — максимум за один запрос.
|
pageSize=200 — максимум за один запрос (API лимит).
|
||||||
Остановка по len(batch) < pageSize (НЕ доверяет total — бывали баги).
|
Остановка по len(batch) < pageSize — НЕ доверяет total из ответа.
|
||||||
Возвращает полный list[dict]."""
|
|
||||||
|
Почему не доверяем total:
|
||||||
|
Бывали баги где API возвращал total=500, а реально инстансов 3.
|
||||||
|
Проверка по размеру страницы надёжнее.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — каждый dict содержит instanceUid, displayName, serviceId,
|
||||||
|
svc, explainedStatus, instanceConfigDtCreated и др."""
|
||||||
results = []
|
results = []
|
||||||
page = 1
|
page = 1
|
||||||
page_size = 200
|
page_size = 200
|
||||||
@@ -18,8 +30,8 @@ def get_instances(client):
|
|||||||
data = client.get("/instances", params={"pageSize": page_size, "page": page})
|
data = client.get("/instances", params={"pageSize": page_size, "page": page})
|
||||||
batch = data.get("results", []) or []
|
batch = data.get("results", []) or []
|
||||||
results.extend(batch)
|
results.extend(batch)
|
||||||
# Если страница неполная — данные закончились.
|
# Если страница неполная — данных больше нет.
|
||||||
# Не проверяем total, потому что API иногда врёт про total.
|
# Это надёжнее чем проверка page * pageSize >= total.
|
||||||
if len(batch) < page_size:
|
if len(batch) < page_size:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
@@ -28,8 +40,12 @@ def get_instances(client):
|
|||||||
|
|
||||||
def get_organization(client):
|
def get_organization(client):
|
||||||
"""Первый инстанс с serviceId=19 (Организация).
|
"""Первый инстанс с serviceId=19 (Организация).
|
||||||
|
|
||||||
Организация всегда одна на пользователя. Используется в UI: название, статус, ClientID."""
|
Организация всегда одна на пользователя — это его «корневой» инстанс.
|
||||||
|
Используется в UI: название организации, статус, ClientID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict или None — инстанс организации или None если не найден."""
|
||||||
for inst in get_instances(client):
|
for inst in get_instances(client):
|
||||||
if inst.get("serviceId") == 19:
|
if inst.get("serviceId") == 19:
|
||||||
return inst
|
return inst
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Операция: получить параметры операции с ТЕКУЩИМИ значениями инстанса.
|
Операция: получить параметры операции с ТЕКУЩИМИ значениями инстанса.
|
||||||
|
|
||||||
|
Зачем: когда пользователь выбирает modify/suspend/resume, мы показываем
|
||||||
|
ТЕКУЩИЕ значения параметров (из state.params инстанса), а не шаблонные defaults.
|
||||||
|
Это позволяет видеть что реально настроено, а не то что было при создании.
|
||||||
|
|
||||||
Источник правды — Nubes API:
|
Источник правды — Nubes API:
|
||||||
1. GET /instances/{instanceUid} → state.params (текущие значения, ключ = код параметра)
|
1. GET /instances/{instanceUid} → state.params (текущие значения, ключ = код параметра)
|
||||||
2. GET /instanceOperations/default/{opId} (шаблон: код, тип, valueList, dataDescriptor)
|
2. GET /instanceOperations/default/{opId} (шаблон: код, тип, valueList, dataDescriptor)
|
||||||
3. Смержить: defaultValue = state.params["код"] ?? template.defaultValue
|
3. Смержить: defaultValue = state.params["код"] ?? template.defaultValue
|
||||||
|
|
||||||
|
Также заполняет пустые valueList из state.out (users/databases для PG и др.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -12,50 +18,78 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
"""
|
"""
|
||||||
Возвращает список параметров (dict) для заполнения формы операции.
|
Возвращает список параметров (dict) для заполнения формы операции.
|
||||||
|
|
||||||
Каждый элемент:
|
Каждый элемент результирующего списка:
|
||||||
svcOperationCfsParamId — числовой ID параметра
|
svcOperationCfsParamId — числовой ID параметра (напр. 198)
|
||||||
name — код параметра (напр. "durationMs")
|
name — код параметра (напр. "durationMs")
|
||||||
dataType — тип (напр. "integer >= 0", "boolean", "string")
|
dataType — тип (напр. "integer >= 0", "boolean", "string")
|
||||||
isRequired — обязательный?
|
isRequired — обязательный?
|
||||||
defaultValue — ТЕКУЩЕЕ значение из state.params или шаблонный default
|
defaultValue — ТЕКУЩЕЕ значение из state.params или шаблонный default
|
||||||
valueList — список допустимых значений (если есть)
|
valueList — список допустимых значений (если есть)
|
||||||
|
refSvcId — ID сервиса для refSvc-параметров
|
||||||
dataDescriptor — вложенная структура для map-параметров (если есть)
|
dataDescriptor — вложенная структура для map-параметров (если есть)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
op_id: int — svcOperationId (напр. 18 для modify Болванки)
|
||||||
|
instance_uid: str — UUID инстанса (для получения state.params)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — параметры со смерженными текущими значениями
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# --- Шаг 1: текущие значения из инстанса ---
|
# --- Шаг 1: текущие значения из инстанса ---
|
||||||
|
# GET /instances/{uid} возвращает полную информацию об инстансе
|
||||||
|
# Нас интересует instance.state.params — dict вида:
|
||||||
|
# {"whereFail": "1", "durationMs": "0", "diskSizeGb": "10"}
|
||||||
|
# Ключи — СИМВОЛИЧЕСКИЕ имена параметров (не числовые ID!)
|
||||||
inst_data = client.get(f"/instances/{instance_uid}")
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
state_params = inst_data.get("instance", {}).get("state", {}).get("params", {}) or {}
|
state_params = inst_data.get("instance", {}).get("state", {}).get("params", {}) or {}
|
||||||
# state_params — dict вида {"whereFail": "1", "durationMs": "0", ...}
|
|
||||||
|
# state.out — дополнительные данные (users, databases для PG-сервисов)
|
||||||
|
# Используется для заполнения пустых valueList (см. ниже)
|
||||||
state_out = inst_data.get("instance", {}).get("state", {}).get("out", {}) or {}
|
state_out = inst_data.get("instance", {}).get("state", {}).get("out", {}) or {}
|
||||||
|
|
||||||
# --- Шаг 2: шаблон параметров операции ---
|
# --- Шаг 2: шаблон параметров операции ---
|
||||||
|
# GET /instanceOperations/default/{opId} → svcOperation.cfsParams[]
|
||||||
|
# Это полный список параметров с типами, default-значениями, valueList и т.д.
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{op_id}")
|
tmpl_data = client.get(f"/instanceOperations/default/{op_id}")
|
||||||
tmpl_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) or []
|
tmpl_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) or []
|
||||||
|
|
||||||
# --- Шаг 3: слияние ---
|
# --- Шаг 3: слияние шаблона с текущими значениями ---
|
||||||
result = []
|
result = []
|
||||||
for p in tmpl_params:
|
for p in tmpl_params:
|
||||||
pid = p["svcOperationCfsParamId"] # числовой ID (напр. 198)
|
pid = p["svcOperationCfsParamId"] # числовой ID (напр. 198)
|
||||||
code = p.get("svcOperationCfsParam", "") # код/имя (напр. "durationMs")
|
code = p.get("svcOperationCfsParam", "") # символическое имя (напр. "durationMs")
|
||||||
data_type = p.get("dataType", "")
|
data_type = p.get("dataType", "")
|
||||||
required = p.get("isRequired", False)
|
required = p.get("isRequired", False)
|
||||||
tmpl_default = p.get("defaultValue") # шаблонный default
|
tmpl_default = p.get("defaultValue") # шаблонный default
|
||||||
value_list = p.get("valueList")
|
value_list = p.get("valueList")
|
||||||
# Заполнить пустые valueList из state.out (пользователи/базы PG и др.)
|
|
||||||
|
# Заполнить ПУСТЫЕ valueList из state.out.
|
||||||
|
# Некоторые сервисы (PG) возвращают valueList=[] в шаблоне,
|
||||||
|
# но реальный список значений (пользователи/базы) лежит в state.out.
|
||||||
|
# Пример: state.out.users = {"pgadmin": {...}, "appuser": {...}}
|
||||||
|
# → valueList = ["pgadmin", "appuser"]
|
||||||
if value_list is not None and not value_list:
|
if value_list is not None and not value_list:
|
||||||
code_lower = code.lower()
|
code_lower = code.lower()
|
||||||
|
# Параметр типа "пользователь" / "владелец" → state.out.users
|
||||||
if ("user" in code_lower or "owner" in code_lower) and isinstance(state_out.get("users"), dict):
|
if ("user" in code_lower or "owner" in code_lower) and isinstance(state_out.get("users"), dict):
|
||||||
value_list = sorted(state_out["users"].keys())
|
value_list = sorted(state_out["users"].keys())
|
||||||
|
# Параметр типа "база данных" → state.out.databases
|
||||||
elif "db" in code_lower and isinstance(state_out.get("databases"), dict):
|
elif "db" in code_lower and isinstance(state_out.get("databases"), dict):
|
||||||
value_list = sorted(state_out["databases"].keys())
|
value_list = sorted(state_out["databases"].keys())
|
||||||
dd = p.get("dataDescriptor") # вложенная структура (map-параметры)
|
|
||||||
|
|
||||||
# Берём текущее значение из state.params по коду,
|
dd = p.get("dataDescriptor") # вложенная структура (для map-параметров)
|
||||||
# если нет — используем шаблонный defaultValue.
|
|
||||||
|
# Выбираем defaultValue:
|
||||||
|
# Если в state.params есть значение для этого кода → используем его
|
||||||
|
# Иначе → шаблонный defaultValue
|
||||||
current = state_params.get(code)
|
current = state_params.get(code)
|
||||||
if current is not None:
|
if current is not None:
|
||||||
# Приводим к строке — фронт ожидает строки в полях ввода.
|
# Приводим к строке — фронтенд ожидает строки в полях ввода.
|
||||||
# bool → "true"/"false", dict → JSON, остальное → str()
|
# bool → "true"/"false" (не "True"/"False" — JS не поймёт)
|
||||||
|
# dict → JSON-строка
|
||||||
|
# остальное → str()
|
||||||
if isinstance(current, bool):
|
if isinstance(current, bool):
|
||||||
current = "true" if current else "false"
|
current = "true" if current else "false"
|
||||||
elif isinstance(current, dict):
|
elif isinstance(current, dict):
|
||||||
@@ -76,6 +110,7 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
"valueList": _normalize_value_list(value_list),
|
"valueList": _normalize_value_list(value_list),
|
||||||
"refSvcId": p.get("refSvcId"), # если параметр ссылается на другой сервис
|
"refSvcId": p.get("refSvcId"), # если параметр ссылается на другой сервис
|
||||||
"dataDescriptor": (
|
"dataDescriptor": (
|
||||||
|
# Для map-параметров: рекурсивно нормализуем вложенные поля
|
||||||
{
|
{
|
||||||
k: {
|
k: {
|
||||||
"dataType": v.get("dataType", ""),
|
"dataType": v.get("dataType", ""),
|
||||||
@@ -93,12 +128,21 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_value_list(vl):
|
def _normalize_value_list(vl):
|
||||||
"""Nubes API возвращает valueList как CSV-строку или массив.
|
"""Нормализовать valueList: API может вернуть CSV-строку или массив.
|
||||||
Приводим к list для фронтенда."""
|
|
||||||
|
Приводим к list для фронтенда (всегда массив строк).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vl: None | str | list — valueList из API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list или None — нормализованный список или None если пусто."""
|
||||||
if vl is None:
|
if vl is None:
|
||||||
return None
|
return None
|
||||||
|
# Уже массив — возвращаем как есть
|
||||||
if isinstance(vl, list):
|
if isinstance(vl, list):
|
||||||
return vl
|
return vl
|
||||||
|
# CSV-строка: "val1,val2,val3" → ["val1", "val2", "val3"]
|
||||||
if isinstance(vl, str):
|
if isinstance(vl, str):
|
||||||
return [x.strip() for x in vl.split(',') if x.strip()] or None
|
return [x.strip() for x in vl.split(',') if x.strip()] or None
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Операции с сервисами Nubes — получение списка и деталей через API.
|
Операции с сервисами Nubes — получение списка и деталей через API.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- main.py → список сервисов для UI
|
||||||
|
- api_test.py → операции сервиса (modify/delete/...)
|
||||||
|
- scenario.py → поиск svcOperationId для операции
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
@@ -7,16 +12,26 @@ from api.http_client import HttpClient
|
|||||||
|
|
||||||
def get_services(client):
|
def get_services(client):
|
||||||
"""GET /services → список ВСЕХ сервисов пользователя.
|
"""GET /services → список ВСЕХ сервисов пользователя.
|
||||||
|
|
||||||
Возвращает list[dict], каждый с ключами: svcId, svc, svcExtendedName, operations, ..."""
|
Возвращает list[dict], каждый с ключами:
|
||||||
|
svcId, svc (короткое имя), svcExtendedName (полное имя), operations[]
|
||||||
|
|
||||||
|
operations — это ДОСТУПНЫЕ операции, не путать с запущенными."""
|
||||||
data = client.get("/services")
|
data = client.get("/services")
|
||||||
return data.get("results", [])
|
return data.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
def get_service_detail(client, svc_id):
|
def get_service_detail(client, svc_id):
|
||||||
"""GET /services/{svcId} → детали одного сервиса.
|
"""GET /services/{svcId} → детали одного сервиса.
|
||||||
|
|
||||||
Возвращает dict с ключами: svc, operations[], ...
|
Возвращает dict с ключами:
|
||||||
operations — список доступных операций (create, modify, delete, ...)."""
|
svc, svcShort, operations[]
|
||||||
|
|
||||||
|
operations — список доступных операций:
|
||||||
|
[{svcOperationId, operation, isCreate, ...}, ...]
|
||||||
|
|
||||||
|
svcOperationId — ЧИСЛОВОЙ ID операции, нужен для:
|
||||||
|
- POST /instanceOperations (поле svcOperationId)
|
||||||
|
- GET /instanceOperations/default/{svcOperationId} (шаблон параметров)"""
|
||||||
data = client.get(f"/services/{svc_id}")
|
data = client.get(f"/services/{svc_id}")
|
||||||
return data.get("svc", {})
|
return data.get("svc", {})
|
||||||
|
|||||||
+69
-9
@@ -1,9 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Общий цикл поллинга операции Nubes.
|
Общий цикл поллинга операции Nubes — ждём завершения или таймаута.
|
||||||
|
|
||||||
Используется:
|
Поллинг — это периодический опрос API:
|
||||||
- _finish_op (api_test.py) — асинхронно в threading.Thread
|
GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc
|
||||||
- run_scenario (scenario.py) — синхронно в while
|
|
||||||
|
Каждые 5 секунд проверяем не появился ли dtFinish (дата завершения).
|
||||||
|
Как только dtFinish есть → операция завершена → возвращаем результат.
|
||||||
|
|
||||||
|
Используется в двух режимах:
|
||||||
|
- _finish_op (api_test.py) — асинхронно, в threading.Thread.
|
||||||
|
Запускается после возврата HTTP-ответа пользователю.
|
||||||
|
Пользователь видит "RUNNING" и поллит через /api/test/status/<opUid>.
|
||||||
|
- run_scenario (scenario.py) — синхронно, в основном потоке сценария.
|
||||||
|
Сценарий ждёт завершения шага перед переходом к следующему.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -12,23 +21,69 @@ import time
|
|||||||
def poll_until_done(client, op_uid, timeout=1800):
|
def poll_until_done(client, op_uid, timeout=1800):
|
||||||
"""Поллинг операции до завершения или таймаута.
|
"""Поллинг операции до завершения или таймаута.
|
||||||
|
|
||||||
Возвращает dict:
|
Алгоритм:
|
||||||
{status, is_successful, error_log, stages, duration, svc}
|
1. Запоминаем время старта (t0).
|
||||||
status ∈ {OK, FAIL, TIMEOUT}
|
2. В цикле: GET /instanceOperations/{opUid} с нужными полями.
|
||||||
|
3. Если dtFinish есть и не пустой → операция завершена.
|
||||||
|
- isSuccessful=True → status="OK"
|
||||||
|
- isSuccessful=False → status="FAIL"
|
||||||
|
4. Если dtFinish нет → sleep(5) и повторяем.
|
||||||
|
5. Если вышли за deadline → status="TIMEOUT".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
op_uid: str — UUID операции (instanceOperationUid)
|
||||||
|
timeout: int — максимальное время ожидания в секундах.
|
||||||
|
По умолчанию 1800 (30 минут).
|
||||||
|
Столько может длиться create сложного сервиса.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с ВСЕГДА одинаковыми ключами:
|
||||||
|
{status, is_successful, error_log, stages, duration, svc}
|
||||||
|
|
||||||
|
status ∈ {"OK", "FAIL", "TIMEOUT"}
|
||||||
|
OK — операция завершилась успешно
|
||||||
|
FAIL — операция завершилась с ошибкой
|
||||||
|
TIMEOUT — не дождались за timeout секунд
|
||||||
|
|
||||||
|
is_successful: bool — True только для OK
|
||||||
|
error_log: str — текст ошибки из API (или "TIMEOUT after ...")
|
||||||
|
stages: list — этапы выполнения [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
duration: float — реальное время поллинга в секундах (округлено до 0.1)
|
||||||
|
svc: str — имя сервиса (из ответа API)
|
||||||
"""
|
"""
|
||||||
|
# Фиксируем время начала поллинга
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
deadline = t0 + timeout
|
deadline = t0 + timeout # крайний срок
|
||||||
|
|
||||||
|
# Основной цикл поллинга
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
|
# GET с fields — запрашиваем только нужные поля (экономия трафика)
|
||||||
|
# dtFinish — ключевое: если есть → операция завершена
|
||||||
|
# isSuccessful — true/false
|
||||||
|
# errorLog — текст ошибки если FAIL
|
||||||
|
# duration — сколько заняла операция по версии API
|
||||||
|
# stages — этапы (Terraform: plan → apply → ...)
|
||||||
|
# svc — имя сервиса (может отличаться от того что знаем)
|
||||||
data = client.get(
|
data = client.get(
|
||||||
f"/instanceOperations/{op_uid}"
|
f"/instanceOperations/{op_uid}"
|
||||||
"?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc"
|
"?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Сетевая ошибка или API временно недоступен — не падаем.
|
||||||
|
# Ждём 5 секунд и пробуем снова.
|
||||||
|
# Это нормально: Nubes API иногда отдаёт 502 при высокой нагрузке.
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Ответ API: {"instanceOperation": {...}}
|
||||||
op = data.get("instanceOperation", {})
|
op = data.get("instanceOperation", {})
|
||||||
dt_finish = op.get("dtFinish")
|
dt_finish = op.get("dtFinish")
|
||||||
|
|
||||||
|
# dtFinish может быть None, пустой строкой, или датой.
|
||||||
|
# Проверяем что dtFinish не пустой:
|
||||||
|
# str(dt_finish).strip() — отсекаем None→"None" и пустые строки
|
||||||
if dt_finish and str(dt_finish).strip():
|
if dt_finish and str(dt_finish).strip():
|
||||||
is_ok = op.get("isSuccessful")
|
is_ok = op.get("isSuccessful")
|
||||||
return {
|
return {
|
||||||
@@ -39,8 +94,13 @@ def poll_until_done(client, op_uid, timeout=1800):
|
|||||||
"duration": round(time.time() - t0, 1),
|
"duration": round(time.time() - t0, 1),
|
||||||
"svc": op.get("svc", ""),
|
"svc": op.get("svc", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Операция ещё не завершена — ждём 5 секунд
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
# Таймаут
|
|
||||||
|
# Вышли из while по deadline — таймаут.
|
||||||
|
# 30 минут истекли, операция всё ещё выполняется.
|
||||||
|
# Это редкий случай (обычно create занимает 2-10 минут).
|
||||||
return {
|
return {
|
||||||
"status": "TIMEOUT",
|
"status": "TIMEOUT",
|
||||||
"is_successful": False,
|
"is_successful": False,
|
||||||
|
|||||||
+121
-32
@@ -1,14 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
Scenario runner — шаги из БД → API → результат.
|
Scenario runner — запуск сценария: шаги из БД → API Nubes → результат.
|
||||||
|
|
||||||
Формат шага (новый):
|
Сценарий — это последовательность операций над сервисами.
|
||||||
{ "service_id": 1, "operation": "create", "params": {...}, "output": "d1" }
|
Пример: create Болванку → modify параметры → delete Болванку.
|
||||||
{ "service_id": 1, "operation": "modify", "instance_ref": "d1", "params": {...} }
|
|
||||||
{ "service_id": 1, "operation": "delete", "instance_uid": "UUID", "params": {} }
|
|
||||||
|
|
||||||
Старый формат (без output/instance_ref/instance_uid) — fallback на service_id.
|
ШАГИ (новый формат с именованными ссылками):
|
||||||
|
{"service_id": 1, "operation": "create", "params": {...}, "output": "d1"}
|
||||||
|
— создать инстанс сервиса 1, дать ему имя "d1" для ссылок из следующих шагов
|
||||||
|
{"service_id": 1, "operation": "modify", "instance_ref": "d1", "params": {...}}
|
||||||
|
— модифицировать инстанс, созданный в шаге с output="d1"
|
||||||
|
{"service_id": 1, "operation": "delete", "instance_uid": "UUID", "params": {}}
|
||||||
|
— удалить конкретный инстанс по UUID
|
||||||
|
|
||||||
Резолвинг: instance_uid > instance_ref > instance_map[service_id]
|
Старый формат (без output/instance_ref/instance_uid):
|
||||||
|
{"service_id": 1, "operation": "create", "params": {...}}
|
||||||
|
{"service_id": 1, "operation": "modify", "params": {...}}
|
||||||
|
— fallback на service_id (instance_map[service_id] → последний созданный)
|
||||||
|
|
||||||
|
РЕЗОЛВИНГ instance_uid (приоритет):
|
||||||
|
1. Явный instance_uid в шаге
|
||||||
|
2. instance_ref → поиск в bindings (output предыдущего create-шага)
|
||||||
|
3. instance_map[service_id] → последний инстанс этого сервиса (fallback)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -21,11 +33,28 @@ from operations.poll import poll_until_done
|
|||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
from db.save_run import save_run
|
from db.save_run import save_run
|
||||||
|
|
||||||
|
# Все инстансы созданные сценарием имеют такой префикс в displayName
|
||||||
AUTOTEST_PREFIX = "autotest-scenario-"
|
AUTOTEST_PREFIX = "autotest-scenario-"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_params(cfs_params, symbolic_params):
|
def _resolve_params(cfs_params, symbolic_params):
|
||||||
"""Символические коды → {numeric_id: value}."""
|
"""Конвертировать символические имена параметров → {numeric_id: value}.
|
||||||
|
|
||||||
|
В БД сценарии хранят параметры с СИМВОЛИЧЕСКИМИ именами (напр. "durationMs": "0").
|
||||||
|
Но executor.execute_operation требует ЧИСЛОВЫЕ ID (напр. "198": "0").
|
||||||
|
|
||||||
|
Эта функция делает обратный маппинг: code → svcOperationCfsParamId.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfs_params: list — cfsParams из шаблона операции (содержит svcOperationCfsParam)
|
||||||
|
symbolic_params: dict — {code: value} из БД
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — {numeric_id: value}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError если код параметра не найден в шаблоне (опечатка в сценарии)."""
|
||||||
|
# Строим словарь: code → numeric_id
|
||||||
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
||||||
result = {}
|
result = {}
|
||||||
for code, val in symbolic_params.items():
|
for code, val in symbolic_params.items():
|
||||||
@@ -37,7 +66,10 @@ def _resolve_params(cfs_params, symbolic_params):
|
|||||||
|
|
||||||
|
|
||||||
def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=None, error_log=None):
|
def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=None, error_log=None):
|
||||||
"""Обновить запись scenario_runs."""
|
"""Обновить запись scenario_runs в БД.
|
||||||
|
|
||||||
|
Вызывается после КАЖДОГО шага чтобы UI видел прогресс.
|
||||||
|
При ошибке — пишем в stderr и продолжаем (не роняем сценарий из-за БД)."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return
|
return
|
||||||
@@ -58,7 +90,10 @@ def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=Non
|
|||||||
|
|
||||||
|
|
||||||
def _update_bindings(scenario_run_id, bindings):
|
def _update_bindings(scenario_run_id, bindings):
|
||||||
"""Сохранить output→instance_uid в scenario_runs.instance_bindings."""
|
"""Сохранить output→instance_uid в scenario_runs.instance_bindings (JSONB).
|
||||||
|
|
||||||
|
bindings — это словарь: {"d1": "uuid-1", "d2": "uuid-2"}
|
||||||
|
Нужен для отладки: видеть какие инстансы были созданы на каждом шаге."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return
|
return
|
||||||
@@ -77,40 +112,82 @@ def _update_bindings(scenario_run_id, bindings):
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_instance_uid(step, bindings, instance_map):
|
def _resolve_instance_uid(step, bindings, instance_map):
|
||||||
"""Резолвинг instance_uid по приоритету: явный uid > instance_ref > service_id."""
|
"""Резолвинг instance_uid по приоритету: явный uid > instance_ref > service_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step: dict — шаг сценария
|
||||||
|
bindings: dict — {output_name: instance_uid} (накопленные за сценарий)
|
||||||
|
instance_map: dict — {service_id: instance_uid} (fallback, последний инстанс сервиса)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — instance_uid
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError если instance_ref не найден в bindings (шаги не в том порядке)."""
|
||||||
|
# Приоритет 1: явный instance_uid в шаге
|
||||||
uid = (step.get("instance_uid") or "").strip()
|
uid = (step.get("instance_uid") or "").strip()
|
||||||
if uid:
|
if uid:
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
# Приоритет 2: instance_ref → поиск в bindings
|
||||||
ref = (step.get("instance_ref") or "").strip()
|
ref = (step.get("instance_ref") or "").strip()
|
||||||
if ref:
|
if ref:
|
||||||
if ref in bindings:
|
if ref in bindings:
|
||||||
return bindings[ref]
|
return bindings[ref]
|
||||||
raise ValueError(f"instance_ref '{ref}' not found in bindings (step order issue?)")
|
raise ValueError(f"instance_ref '{ref}' not found in bindings (step order issue?)")
|
||||||
# Fallback: старый формат по service_id
|
|
||||||
|
# Приоритет 3: fallback — последний инстанс этого сервиса
|
||||||
svc_id = step.get("service_id")
|
svc_id = step.get("service_id")
|
||||||
return instance_map.get(svc_id)
|
return instance_map.get(svc_id)
|
||||||
|
|
||||||
|
|
||||||
def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name):
|
def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name):
|
||||||
"""Главный исполнитель сценария. steps — список шагов из БД."""
|
"""Главный исполнитель сценария — последовательно выполняет шаги.
|
||||||
|
|
||||||
|
Алгоритм для КАЖДОГО шага:
|
||||||
|
1. Получить детали сервиса → найти svcOperationId для операции
|
||||||
|
2. Получить шаблон параметров → резолвить символические имена в числовые ID
|
||||||
|
3. Резолвить instance_uid (create → None, не-create → из bindings/instance_map)
|
||||||
|
4. Вызвать execute_operation (единый executor)
|
||||||
|
5. Сохранить RUNNING в БД
|
||||||
|
6. Поллить через poll_until_done
|
||||||
|
7. Сохранить финальный результат (OK/FAIL/TIMEOUT) + instance_meta
|
||||||
|
8. Обновить scenario_runs (прогресс для UI)
|
||||||
|
|
||||||
|
Если любой шаг FAIL — сценарий останавливается.
|
||||||
|
Если все шаги OK — сценарий завершается успешно.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
steps: list[dict] — шаги из БД (scenario_definitions.steps)
|
||||||
|
client_id: str — ClientID
|
||||||
|
stand: str — "dev"/"test"
|
||||||
|
user_email: str — email пользователя
|
||||||
|
app_version: str — версия приложения
|
||||||
|
scenario_run_id: int — ID записи в scenario_runs
|
||||||
|
scenario_name: str — имя сценария
|
||||||
|
"""
|
||||||
total = len(steps)
|
total = len(steps)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
bindings = {} # output_name → instance_uid (новый формат)
|
|
||||||
|
# Две карты для резолвинга instance_uid:
|
||||||
|
bindings = {} # output_name → instance_uid (новый формат, именованные ссылки)
|
||||||
instance_map = {} # service_id → instance_uid (fallback, старый формат)
|
instance_map = {} # service_id → instance_uid (fallback, старый формат)
|
||||||
|
|
||||||
for i, step in enumerate(steps):
|
for i, step in enumerate(steps):
|
||||||
step_num = i + 1
|
step_num = i + 1 # шаги нумеруются с 1 (для пользователя)
|
||||||
svc_id = step.get("service_id")
|
svc_id = step.get("service_id")
|
||||||
op_name = (step.get("operation") or "").strip()
|
op_name = (step.get("operation") or "").strip()
|
||||||
symbolic_params = step.get("params", {})
|
symbolic_params = step.get("params", {})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Валидация базовых полей шага
|
||||||
if not isinstance(svc_id, int) or svc_id <= 0:
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
||||||
if not op_name:
|
if not op_name:
|
||||||
raise ValueError("Empty operation name")
|
raise ValueError("Empty operation name")
|
||||||
|
|
||||||
# 1. Операция
|
# ═══ Шаг 1: получить svcOperationId для операции ═══
|
||||||
detail = get_service_detail(client, svc_id)
|
detail = get_service_detail(client, svc_id)
|
||||||
ops = detail.get("operations", [])
|
ops = detail.get("operations", [])
|
||||||
op = next((o for o in ops if o.get("operation") == op_name), None)
|
op = next((o for o in ops if o.get("operation") == op_name), None)
|
||||||
@@ -118,45 +195,51 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
||||||
svc_op_id = op["svcOperationId"]
|
svc_op_id = op["svcOperationId"]
|
||||||
|
|
||||||
# 2. Параметры
|
# ═══ Шаг 2: резолвить параметры (символ. имена → числовые ID) ═══
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
||||||
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||||
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||||
|
|
||||||
# 3. Резолвинг instance_uid
|
# ═══ Шаг 3: резолвинг instance_uid ═══
|
||||||
is_create = (op_name == "create")
|
is_create = (op_name == "create")
|
||||||
if is_create:
|
if is_create:
|
||||||
|
# Для create: instance_uid=None (создаём новый)
|
||||||
|
# displayName генерируем уникальный: autotest-scenario-{name}-{random6}
|
||||||
instance_uid = None
|
instance_uid = None
|
||||||
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
||||||
descr = f"scenario {scenario_name} step {step_num}"
|
descr = f"scenario {scenario_name} step {step_num}"
|
||||||
else:
|
else:
|
||||||
|
# Для не-create: резолвим instance_uid
|
||||||
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
raise ValueError(f"No instance for step {step_num} — need CREATE before non-create operations")
|
raise ValueError(
|
||||||
|
f"No instance for step {step_num} — need CREATE before non-create operations")
|
||||||
display_name = None
|
display_name = None
|
||||||
descr = None
|
descr = None
|
||||||
|
|
||||||
# 4. Запуск через executor
|
# ═══ Шаг 4: запуск через ЕДИНЫЙ executor ═══
|
||||||
|
# Это гарантирует идентичное поведение с ручным режимом
|
||||||
result = execute_operation(
|
result = execute_operation(
|
||||||
client, svc_id, op_name, instance_uid, resolved_params,
|
client, svc_id, op_name, instance_uid, resolved_params,
|
||||||
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
svc_op_id=svc_op_id, display_name=display_name, descr=descr,
|
||||||
client_id=client_id, stand=stand
|
client_id=client_id, stand=stand
|
||||||
)
|
)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
raise RuntimeError(f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
raise RuntimeError(
|
||||||
|
f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
||||||
|
|
||||||
instance_uid = result["instance_uid"]
|
instance_uid = result["instance_uid"]
|
||||||
op_uid = result["op_uid"]
|
op_uid = result["op_uid"]
|
||||||
step_display = result["display_name"]
|
step_display = result["display_name"]
|
||||||
|
|
||||||
# Обновить карты
|
# Обновить карты для резолвинга следующих шагов
|
||||||
instance_map[svc_id] = instance_uid
|
instance_map[svc_id] = instance_uid # fallback: последний инстанс сервиса
|
||||||
output_name = (step.get("output") or "").strip()
|
output_name = (step.get("output") or "").strip()
|
||||||
if is_create and output_name:
|
if is_create and output_name:
|
||||||
bindings[output_name] = instance_uid
|
bindings[output_name] = instance_uid # именованная ссылка
|
||||||
_update_bindings(scenario_run_id, bindings)
|
_update_bindings(scenario_run_id, bindings)
|
||||||
|
|
||||||
# 5. Сохранить RUNNING
|
# ═══ Шаг 5: сохранить RUNNING в БД (до поллинга) ═══
|
||||||
try:
|
try:
|
||||||
save_run(client_id, stand, user_email,
|
save_run(client_id, stand, user_email,
|
||||||
svc_id, "", op_name, svc_op_id,
|
svc_id, "", op_name, svc_op_id,
|
||||||
@@ -167,26 +250,27 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
# 6. Поллинг
|
# ═══ Шаг 6: поллинг до завершения ═══
|
||||||
poll_result = poll_until_done(client, op_uid)
|
poll_result = poll_until_done(client, op_uid)
|
||||||
step_status = poll_result["status"]
|
step_status = poll_result["status"] # OK / FAIL / TIMEOUT
|
||||||
step_error = poll_result["error_log"]
|
step_error = poll_result["error_log"]
|
||||||
step_duration = poll_result["duration"]
|
step_duration = poll_result["duration"]
|
||||||
step_stages = poll_result["stages"]
|
step_stages = poll_result["stages"]
|
||||||
svc_final = poll_result["svc"]
|
svc_final = poll_result["svc"]
|
||||||
|
|
||||||
# 7. Сохранить финальный результат
|
# ═══ Шаг 7: сохранить финальный результат + instance_meta ═══
|
||||||
try:
|
try:
|
||||||
# Получить полную информацию об инстансе
|
# Получить полную информацию об инстансе для анализа
|
||||||
instance_meta = None
|
instance_meta = None
|
||||||
try:
|
try:
|
||||||
inst_data = client.get(f"/instances/{instance_uid}")
|
inst_data = client.get(f"/instances/{instance_uid}")
|
||||||
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
inst = inst_data.get("instance", {}) if isinstance(inst_data, dict) else {}
|
||||||
if inst:
|
if inst:
|
||||||
|
# Сохраняем ВСЁ кроме очевидных/избыточных полей
|
||||||
instance_meta = {k: v for k, v in inst.items()
|
instance_meta = {k: v for k, v in inst.items()
|
||||||
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
if k not in ('instanceUid', 'displayName', 'svc', 'serviceId', 'explainedStatus')}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # не смогли получить meta — не критично
|
||||||
save_run(client_id, stand, user_email,
|
save_run(client_id, stand, user_email,
|
||||||
svc_id, svc_final, op_name, svc_op_id,
|
svc_id, svc_final, op_name, svc_op_id,
|
||||||
op_uid, instance_uid, step_display,
|
op_uid, instance_uid, step_display,
|
||||||
@@ -197,17 +281,21 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
# Обновить scenario_runs
|
# ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══
|
||||||
_save_scenario_run(scenario_run_id, "RUNNING" if step_num < total else step_status,
|
# Пока не последний шаг — статус RUNNING
|
||||||
|
_save_scenario_run(scenario_run_id,
|
||||||
|
"RUNNING" if step_num < total else step_status,
|
||||||
step_num, round(time.time() - t0, 1),
|
step_num, round(time.time() - t0, 1),
|
||||||
step_error if step_status != "OK" else None)
|
step_error if step_status != "OK" else None)
|
||||||
|
|
||||||
|
# Если шаг FAIL — останавливаем сценарий
|
||||||
if step_status != "OK":
|
if step_status != "OK":
|
||||||
_save_scenario_run(scenario_run_id, step_status, step_num,
|
_save_scenario_run(scenario_run_id, step_status, step_num,
|
||||||
round(time.time() - t0, 1), step_error)
|
round(time.time() - t0, 1), step_error)
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Любая ошибка → FAIL, сценарий остановлен
|
||||||
import traceback
|
import traceback
|
||||||
err = f"Step {step_num}: {e}"
|
err = f"Step {step_num}: {e}"
|
||||||
print(f"[SCENARIO] {err}", flush=True)
|
print(f"[SCENARIO] {err}", flush=True)
|
||||||
@@ -216,4 +304,5 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
|||||||
round(time.time() - t0, 1), err)
|
round(time.time() - t0, 1), err)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Все шаги пройдены успешно
|
||||||
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Чтение списка разрешённых сервисов из services_{stand}.txt.
|
Чтение списка разрешённых сервисов из services_{stand}.txt.
|
||||||
|
|
||||||
Формат файла: service_id service_name # описание
|
Зачем: не все сервисы должны быть доступны для тестирования.
|
||||||
Закомментированные (#) строки — исключены.
|
Файл services_test.txt (или services_dev.txt) содержит только те сервисы,
|
||||||
Пустые строки — игнорируются.
|
которые настроены и готовы к автотестам.
|
||||||
|
|
||||||
|
Формат файла:
|
||||||
|
# Комментарий
|
||||||
|
1 dummy # Болванка
|
||||||
|
21 vdc # Virtual Data Center
|
||||||
|
|
||||||
|
Закомментированные (#) строки исключаются.
|
||||||
|
Пустые строки игнорируются.
|
||||||
|
Первое слово в строке — service_id (число).
|
||||||
|
|
||||||
Файл лежит в site/config/, пушится с кодом, деплоится вместе с приложением.
|
Файл лежит в site/config/, пушится с кодом, деплоится вместе с приложением.
|
||||||
При изменении списка: edit → commit → push → redeploy.
|
При изменении списка: edit → commit → push → redeploy.
|
||||||
@@ -12,23 +21,36 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
# Путь к папке config относительно этого файла:
|
||||||
|
# service_list.py → site/operations/
|
||||||
|
# dirname → site/operations
|
||||||
|
# dirname(dirname) → site
|
||||||
|
# + "config" → site/config
|
||||||
_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
_CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||||
|
|
||||||
|
|
||||||
def load_service_ids(stand="test"):
|
def load_service_ids(stand="test"):
|
||||||
"""Вернуть set разрешённых service_id для указанного стенда."""
|
"""Вернуть set разрешённых service_id для указанного стенда.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stand: str — "dev" или "test".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[int] — множество service_id. Пустой set если файла нет → показываем все."""
|
||||||
path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt")
|
path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt")
|
||||||
ids = set()
|
ids = set()
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
# Пропускаем пустые строки и комментарии
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith("#"):
|
||||||
continue
|
continue
|
||||||
# Формат: "1 dummy # Болванка"
|
# Формат: "1 dummy # Болванка"
|
||||||
|
# Берём первое слово, проверяем что это число
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if parts and parts[0].isdigit():
|
if parts and parts[0].isdigit():
|
||||||
ids.add(int(parts[0]))
|
ids.add(int(parts[0]))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass # файла нет — показываем все сервисы
|
pass # файла нет — возвращаем пустой set → показываем все сервисы
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
+133
-18
@@ -1,21 +1,42 @@
|
|||||||
"""
|
"""
|
||||||
Terraform-совместимые операции: нормализация параметров, refSvc-резолв, отправка.
|
Terraform-совместимые операции с параметрами: нормализация, refSvc-резолв, отправка.
|
||||||
|
|
||||||
Используется из api_test.py и scenario.py.
|
Эти шаги воспроизводят логику Terraform-провайдера Nubes при создании/изменении
|
||||||
|
инстанса через личный кабинет:
|
||||||
|
|
||||||
|
Шаг 3: Получить cfsParams шаблон операции
|
||||||
|
Шаг 4: resolveRefSvc — подставить UUID инстансов для refSvcId-параметров
|
||||||
|
Шаг 5: Отправить пользовательские значения параметров (те что ввёл пользователь)
|
||||||
|
Шаг 6: Отправить default-значения для незаполненных параметров
|
||||||
|
Шаг 7: validate-cfs — проверить что все обязательные параметры заполнены
|
||||||
|
|
||||||
|
Используется из executor.py (через send_params_terraform) — и ручной режим, и сценарий.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from operations.get_instances import get_instances
|
from operations.get_instances import get_instances
|
||||||
|
|
||||||
|
# Параметры, значения которых НЕЛЬЗЯ сохранять в БД в открытом виде.
|
||||||
|
# При сохранении в runs.params их значения заменяются на "***".
|
||||||
REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"}
|
REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"}
|
||||||
|
|
||||||
|
|
||||||
def redact_params(params):
|
def redact_params(params):
|
||||||
"""Заменить значения секретных полей на *** перед сохранением в БД."""
|
"""Заменить значения секретных полей на *** перед сохранением в БД.
|
||||||
|
|
||||||
|
Проверяет КЛЮЧИ параметров (не значения!) на вхождение секретных слов.
|
||||||
|
Пример: params={"adminPassword": "secret123"} → {"adminPassword": "***"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: dict — {param_id: value}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с замаскированными секретами."""
|
||||||
if not params:
|
if not params:
|
||||||
return params
|
return params
|
||||||
result = {}
|
result = {}
|
||||||
for k, v in params.items():
|
for k, v in params.items():
|
||||||
|
# Проверяем ключ (имя параметра) на敏感ные слова
|
||||||
if any(r in str(k).lower() for r in REDACT_KEYS):
|
if any(r in str(k).lower() for r in REDACT_KEYS):
|
||||||
result[k] = "***"
|
result[k] = "***"
|
||||||
else:
|
else:
|
||||||
@@ -24,23 +45,45 @@ def redact_params(params):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_value(val, data_type, data_descriptor):
|
def normalize_value(val, data_type, data_descriptor):
|
||||||
"""normalizeUniversalValueV6 — Terraform-equivalent.
|
"""normalizeUniversalValueV6 — Terraform-эквивалент нормализации значения.
|
||||||
Пустые значения нормализуются по типу: integer→"0", boolean→"false",
|
|
||||||
array→"[]", map/json→"{}", map-fixed→сборка из sub-param defaults."""
|
Что делает: приводит пустые значения к «нулю» соответствующего типа.
|
||||||
|
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()
|
val = (val or "").strip()
|
||||||
if val.lower() == "null" or val == '""':
|
if val.lower() == "null" or val == '""':
|
||||||
val = ""
|
val = ""
|
||||||
|
|
||||||
dt = (data_type or "").lower()
|
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 ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor:
|
||||||
if not val or val == "{}":
|
if not val or val == "{}":
|
||||||
sub_obj = {}
|
sub_obj = {}
|
||||||
for sk, sv in data_descriptor.items():
|
for sk, sv in data_descriptor.items():
|
||||||
sd = sv.get("defaultValue")
|
sd = sv.get("defaultValue")
|
||||||
|
# default может быть любым типом — приводим к строке для JSON
|
||||||
sub_obj[sk] = str(sd) if sd is not None else ""
|
sub_obj[sk] = str(sd) if sd is not None else ""
|
||||||
return json.dumps(sub_obj)
|
return json.dumps(sub_obj)
|
||||||
return val
|
return val # значение уже заполнено — не трогаем
|
||||||
|
|
||||||
|
# Значение не пустое — возвращаем как есть
|
||||||
if val:
|
if val:
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
# Пустое значение — подставляем «ноль» типа:
|
||||||
if "array" in dt:
|
if "array" in dt:
|
||||||
return "[]"
|
return "[]"
|
||||||
if "map" in dt or "json" in dt:
|
if "map" in dt or "json" in dt:
|
||||||
@@ -49,67 +92,126 @@ def normalize_value(val, data_type, data_descriptor):
|
|||||||
return "0"
|
return "0"
|
||||||
if "boolean" in dt or "bool" in dt:
|
if "boolean" in dt or "bool" in dt:
|
||||||
return "false"
|
return "false"
|
||||||
|
|
||||||
|
# string или неизвестный тип — пустая строка
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
def resolve_ref_svc(client, cfs_params, params):
|
def resolve_ref_svc(client, cfs_params, params):
|
||||||
"""resolveRefSvcParamValues — Terraform step 4.
|
"""resolveRefSvcParamValues — Terraform step 4.
|
||||||
Для параметров с refSvcId: если значение пустое — подставить UUID
|
|
||||||
существующего инстанса этого сервиса."""
|
Некоторые параметры имеют тип «ссылка на другой сервис» (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
|
instances = None
|
||||||
|
|
||||||
for p in cfs_params:
|
for p in cfs_params:
|
||||||
pid = str(p["svcOperationCfsParamId"])
|
pid = str(p["svcOperationCfsParamId"])
|
||||||
# Верхнеуровневый refSvcId
|
|
||||||
|
# ── Уровень 1: верхнеуровневый refSvcId ──
|
||||||
top_ref = p.get("refSvcId")
|
top_ref = p.get("refSvcId")
|
||||||
if top_ref:
|
if top_ref:
|
||||||
|
# Проверяем: параметр пустой?
|
||||||
cur = (params.get(pid) or "").strip()
|
cur = (params.get(pid) or "").strip()
|
||||||
if not cur or cur == '""':
|
if not cur or cur == '""':
|
||||||
|
# Загружаем инстансы если ещё не загружены
|
||||||
if instances is None:
|
if instances is None:
|
||||||
try:
|
try:
|
||||||
instances = get_instances(client)
|
instances = get_instances(client)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return # не можем загрузить — пропускаем
|
||||||
|
# Ищем первый НЕ deleted инстанс с нужным serviceId
|
||||||
match = next((i for i in instances
|
match = next((i for i in instances
|
||||||
if i.get("serviceId") == top_ref
|
if i.get("serviceId") == top_ref
|
||||||
and i.get("explainedStatus") not in ("deleted",)), None)
|
and i.get("explainedStatus") not in ("deleted",)), None)
|
||||||
if match:
|
if match:
|
||||||
params[pid] = match["instanceUid"]
|
params[pid] = match["instanceUid"]
|
||||||
# Вложенный refSvcId в dataDescriptor
|
|
||||||
|
# ── Уровень 2: вложенный refSvcId в dataDescriptor ──
|
||||||
dd = p.get("dataDescriptor")
|
dd = p.get("dataDescriptor")
|
||||||
if not isinstance(dd, dict):
|
if not isinstance(dd, dict):
|
||||||
continue
|
continue # нет dataDescriptor — нечего проверять
|
||||||
|
|
||||||
|
# Парсим текущее значение параметра как JSON (map-параметр)
|
||||||
user_val = {}
|
user_val = {}
|
||||||
if pid in params:
|
if pid in params:
|
||||||
try:
|
try:
|
||||||
user_val = json.loads(params[pid]) if params[pid] else {}
|
user_val = json.loads(params[pid]) if params[pid] else {}
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
user_val = {}
|
user_val = {}
|
||||||
|
|
||||||
|
# Проверяем каждый sub-ключ dataDescriptor на refSvcId
|
||||||
for sk, sv in dd.items():
|
for sk, sv in dd.items():
|
||||||
ref_id = sv.get("refSvcId")
|
ref_id = sv.get("refSvcId")
|
||||||
if not ref_id:
|
if not ref_id:
|
||||||
continue
|
continue # не refSvc-параметр
|
||||||
|
|
||||||
|
# Текущее значение sub-ключа
|
||||||
cur = user_val.get(sk, "")
|
cur = user_val.get(sk, "")
|
||||||
if cur and cur != '""':
|
if cur and cur != '""':
|
||||||
continue
|
continue # уже заполнено — не трогаем
|
||||||
|
|
||||||
|
# Загружаем инстансы лениво
|
||||||
if instances is None:
|
if instances is None:
|
||||||
try:
|
try:
|
||||||
instances = get_instances(client)
|
instances = get_instances(client)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ищем подходящий инстанс
|
||||||
match = next((i for i in instances
|
match = next((i for i in instances
|
||||||
if i.get("serviceId") == ref_id
|
if i.get("serviceId") == ref_id
|
||||||
and i.get("explainedStatus") not in ("deleted",)), None)
|
and i.get("explainedStatus") not in ("deleted",)), None)
|
||||||
if match:
|
if match:
|
||||||
user_val[sk] = match["instanceUid"]
|
user_val[sk] = match["instanceUid"]
|
||||||
|
|
||||||
|
# Если были изменения — обновляем значение параметра
|
||||||
if user_val:
|
if user_val:
|
||||||
params[pid] = json.dumps(user_val)
|
params[pid] = json.dumps(user_val)
|
||||||
|
|
||||||
|
|
||||||
def send_params_terraform(client, op_uid, params):
|
def send_params_terraform(client, op_uid, params):
|
||||||
"""Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send all unsent → validate."""
|
"""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")
|
op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams")
|
||||||
cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", [])
|
cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", [])
|
||||||
|
|
||||||
|
# Шаг 4: resolveRefSvc — мутирует params, подставляя UUID
|
||||||
resolve_ref_svc(client, cfs_params, params)
|
resolve_ref_svc(client, cfs_params, params)
|
||||||
|
|
||||||
|
# Шаг 5: отправить пользовательские значения
|
||||||
|
# sent — множество paramId, чтобы не отправить повторно в шаге 6
|
||||||
sent = set()
|
sent = set()
|
||||||
for pid, pval in params.items():
|
for pid, pval in params.items():
|
||||||
client.post("/instanceOperationCfsParams", {
|
client.post("/instanceOperationCfsParams", {
|
||||||
@@ -118,25 +220,38 @@ def send_params_terraform(client, op_uid, params):
|
|||||||
"paramValue": str(pval)
|
"paramValue": str(pval)
|
||||||
})
|
})
|
||||||
sent.add(int(pid))
|
sent.add(int(pid))
|
||||||
|
|
||||||
|
# Шаг 6: отправить default-значения для незаполненных параметров
|
||||||
for p in cfs_params:
|
for p in cfs_params:
|
||||||
pid = p["svcOperationCfsParamId"]
|
pid = p["svcOperationCfsParamId"]
|
||||||
if pid in sent:
|
if pid in sent:
|
||||||
continue
|
continue # пользователь уже заполнил — не перезаписываем
|
||||||
|
|
||||||
|
# Приоритет значений: paramValue → defaultValue → ""
|
||||||
val = p.get("paramValue")
|
val = p.get("paramValue")
|
||||||
if val is None:
|
if val is None:
|
||||||
val = p.get("defaultValue")
|
val = p.get("defaultValue")
|
||||||
if val is None:
|
if val is None:
|
||||||
val = ""
|
val = ""
|
||||||
|
|
||||||
|
# Нормализуем под тип (пустой integer → "0", map → "{}")
|
||||||
val = normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor"))
|
val = normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor"))
|
||||||
|
|
||||||
client.post("/instanceOperationCfsParams", {
|
client.post("/instanceOperationCfsParams", {
|
||||||
"instanceOperationUid": op_uid,
|
"instanceOperationUid": op_uid,
|
||||||
"svcOperationCfsParamId": pid,
|
"svcOperationCfsParamId": pid,
|
||||||
"paramValue": val
|
"paramValue": val
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Шаг 7: validate-cfs — финальная проверка
|
||||||
|
# Если параметры невалидны — API вернёт ошибку → исключение
|
||||||
|
# Если ответ пустой или не-JSON — это ОК (значит валидация прошла)
|
||||||
try:
|
try:
|
||||||
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
client.get(f"/instanceOperations/{op_uid}/validate-cfs")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# "Expecting value" / JSONDecodeError — это норма для validate-cfs
|
||||||
|
# (API возвращает 200 с пустым телом при успехе)
|
||||||
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
|
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise
|
raise # реальная ошибка — пробрасываем выше
|
||||||
|
|||||||
+67
-32
@@ -1,20 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
Трекер созданных инстансов.
|
Трекер созданных инстансов — краткосрочный JSON-файловый кеш.
|
||||||
|
|
||||||
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
|
ПРОБЛЕМА: когда мы создаём инстанс через POST /instances, он появляется
|
||||||
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
|
в Nubes НЕ МГНОВЕННО. GET /instances может задержать новый инстанс на 5-30 секунд.
|
||||||
не появился в ответе GET /instances (облако может задержать на секунды).
|
В это время пользователь не видит свой инстанс в списке → думает что create не сработал.
|
||||||
|
|
||||||
|
РЕШЕНИЕ: трекер — это JSON-файл в /tmp/, куда мы пишем UID инстанса СРАЗУ после
|
||||||
|
успешного create. UI сначала ищет инстансы в облаке (cloud-first), а то чего
|
||||||
|
нет в облаке — добирает из трекера (tracker-fallback).
|
||||||
|
|
||||||
Файлы изолированы по пользователю и стенду:
|
Файлы изолированы по пользователю и стенду:
|
||||||
/tmp/instances-{clientId}-{stand}.json
|
/tmp/instances-{clientId}-{stand}.json
|
||||||
|
|
||||||
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
|
Блокировка fcntl.flock (эксклюзивная, неблокирующая с retry) —
|
||||||
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
|
для безопасной конкурентной работы нескольких gunicorn-воркеров.
|
||||||
|
|
||||||
|
ЖИЗНЕННЫЙ ЦИКЛ:
|
||||||
|
- add() — executor вызывает сразу после получения instanceUid
|
||||||
|
- remove() — после успешного delete (в _finish_op и CMDB-delete)
|
||||||
|
- При редеплое /tmp/ теряется — это НЕ критично (cloud-first всё покажет)
|
||||||
|
|
||||||
Функции:
|
Функции:
|
||||||
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
||||||
remove(client_id, stand, uid) — удалить (после успешного delete)
|
remove(client_id, stand, uid) — удалить (после успешного delete)
|
||||||
list_all(client_id, stand) — все записи списком
|
list_all(client_id, stand) — все записи списком {svcId, displayName, instanceUid}
|
||||||
"""
|
"""
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
@@ -23,46 +32,57 @@ import time
|
|||||||
|
|
||||||
|
|
||||||
def _path(client_id, stand):
|
def _path(client_id, stand):
|
||||||
"""Путь к файлу трекера для конкретного пользователя и стенда."""
|
"""Путь к файлу трекера: /tmp/instances-{clientId}-{stand}.json.
|
||||||
|
|
||||||
|
Изоляция по clientId + stand гарантирует что пользователи не видят чужие инстансы."""
|
||||||
return f"/tmp/instances-{client_id}-{stand}.json"
|
return f"/tmp/instances-{client_id}-{stand}.json"
|
||||||
|
|
||||||
|
|
||||||
def _acquire_lock(fd):
|
def _acquire_lock(fd):
|
||||||
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
||||||
|
|
||||||
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
||||||
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
|
Почему не LOCK_EX с бесконечным ожиданием:
|
||||||
|
- При подвисании воркера все остальные повиснут навсегда.
|
||||||
|
- Лучше не записать в трекер, чем уронить запрос.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — блокировка взята.
|
||||||
|
False — не смогли за 2 секунды (другой воркер держит слишком долго)."""
|
||||||
deadline = time.time() + 2
|
deadline = time.time() + 2
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
return True
|
return True # блокировка взята
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
if time.time() >= deadline:
|
if time.time() >= deadline:
|
||||||
return False
|
return False # таймаут — сдаёмся
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
def _locked_read(path):
|
def _locked_read(path):
|
||||||
"""Читает JSON-файл под эксклюзивной блокировкой.
|
"""Читает JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
Если файла нет — создаёт и возвращает пустой {}.
|
Если файла нет — создаёт (O_CREAT) и возвращает {}.
|
||||||
Если JSON битый — возвращает {} (переживёт перезапись).
|
Если JSON битый — возвращает {} (переживёт перезапись при следующем add).
|
||||||
Максимальный размер: 65536 байт."""
|
Максимальный размер чтения: 65536 байт (64KB) — больше трекеру не нужно.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — содержимое файла или {}."""
|
||||||
try:
|
try:
|
||||||
# O_RDWR — чтение+запись, O_CREAT — создать если нет
|
# O_RDWR — чтение+запись, O_CREAT — создать если нет
|
||||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||||
except OSError:
|
except OSError:
|
||||||
return {} # нет прав — молча возвращаем пустой
|
return {} # нет прав (например /tmp/ только read) — молча
|
||||||
try:
|
try:
|
||||||
if not _acquire_lock(fd):
|
if not _acquire_lock(fd):
|
||||||
return {} # не смогли заблокировать за 2с — не рискуем
|
return {} # не смогли заблокировать → не читаем (гонка данных)
|
||||||
try:
|
try:
|
||||||
data = os.read(fd, 65536)
|
data = os.read(fd, 65536)
|
||||||
if data:
|
if data:
|
||||||
return json.loads(data.decode("utf-8"))
|
return json.loads(data.decode("utf-8"))
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
pass # битый файл — переживём, при следующей записи перезапишется
|
pass # битый JSON — при следующей записи перезапишется
|
||||||
return {}
|
return {}
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||||||
@@ -71,28 +91,40 @@ def _locked_read(path):
|
|||||||
|
|
||||||
def _locked_write(path, data):
|
def _locked_write(path, data):
|
||||||
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
|
Полностью перезаписывает файл: lseek(0) + ftruncate + write.
|
||||||
|
Это атомарно под локом — другие воркеры увидят либо старую, либо новую версию.
|
||||||
|
|
||||||
|
Если не смогли взять лок — НЕ пишем.
|
||||||
|
Данные не потеряются — инстанс создан в Nubes, cloud-first его подхватит."""
|
||||||
try:
|
try:
|
||||||
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if not _acquire_lock(fd):
|
if not _acquire_lock(fd):
|
||||||
return # не смогли заблокировать — не пишем (данные не потеряются, просто не сохранятся)
|
return # не смогли заблокировать — пропускаем запись
|
||||||
os.lseek(fd, 0, 0) # в начало файла
|
# Три шага атомарной перезаписи:
|
||||||
os.ftruncate(fd, 0) # обрезать старый контент
|
os.lseek(fd, 0, 0) # 1. В начало файла
|
||||||
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
|
os.ftruncate(fd, 0) # 2. Обрезать старый контент
|
||||||
|
os.write(fd, json.dumps(data, indent=2).encode("utf-8")) # 3. Записать новый
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def add(client_id, stand, instance_uid, svc_id, display_name):
|
def add(client_id, stand, instance_uid, svc_id, display_name):
|
||||||
"""Добавить инстанс в трекер.
|
"""Добавить инстанс в трекер (вызывается из executor сразу после create).
|
||||||
|
|
||||||
Читает текущий файл → добавляет запись → пишет обратно.
|
Читает текущий файл → добавляет/обновляет запись → пишет обратно.
|
||||||
Если инстанс уже есть — перезаписывает (идемпотентно)."""
|
Если инстанс уже есть — перезаписывает (идемпотентность).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: str — ClientID из JWT
|
||||||
|
stand: str — "dev"/"test"
|
||||||
|
instance_uid: str — UUID созданного инстанса
|
||||||
|
svc_id: int — ID сервиса
|
||||||
|
display_name: str — displayName инстанса"""
|
||||||
p = _path(client_id, stand)
|
p = _path(client_id, stand)
|
||||||
data = _locked_read(p)
|
data = _locked_read(p)
|
||||||
data[instance_uid] = {
|
data[instance_uid] = {
|
||||||
@@ -105,8 +137,8 @@ def add(client_id, stand, instance_uid, svc_id, display_name):
|
|||||||
|
|
||||||
def remove(client_id, stand, instance_uid):
|
def remove(client_id, stand, instance_uid):
|
||||||
"""Удалить инстанс из трекера (после успешного delete).
|
"""Удалить инстанс из трекера (после успешного delete).
|
||||||
|
|
||||||
pop с default=None — не падает если инстанса уже нет."""
|
pop с default=None — не падает если инстанса уже нет (уже удалён ранее)."""
|
||||||
p = _path(client_id, stand)
|
p = _path(client_id, stand)
|
||||||
data = _locked_read(p)
|
data = _locked_read(p)
|
||||||
data.pop(instance_uid, None)
|
data.pop(instance_uid, None)
|
||||||
@@ -114,5 +146,8 @@ def remove(client_id, stand, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def list_all(client_id, stand):
|
def list_all(client_id, stand):
|
||||||
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
|
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}.
|
||||||
|
|
||||||
|
Используется в main.py → api_operations для tracker-fallback.
|
||||||
|
Конвертирует dict (indexed by UUID) в list."""
|
||||||
return list(_locked_read(_path(client_id, stand)).values())
|
return list(_locked_read(_path(client_id, stand)).values())
|
||||||
|
|||||||
Reference in New Issue
Block a user