Рефакторинг API-слоя, операций и БД
This commit is contained in:
+45
-17
@@ -3,15 +3,15 @@
|
||||
|
||||
Раньше _client(), _client_id(), _stand(), _token_info() были продублированы
|
||||
в main.py и api_test.py с идентичным или почти идентичным кодом.
|
||||
Теперь всё здесь — один источник правды.
|
||||
Теперь всё здесь — один источник правды для всех роутов.
|
||||
|
||||
Функции:
|
||||
get_token() — токен из cookie или env
|
||||
Функции (все без аргументов — берут данные из Flask request/current_app):
|
||||
get_token() — токен: cookie → env-переменная
|
||||
get_client() — HttpClient с автоопределением стенда
|
||||
get_client_id() — ClientID из JWT (base64, без проверки подписи)
|
||||
get_stand() — "dev"/"test" по токену
|
||||
get_token_info() — {email, company, client_id} из JWT
|
||||
get_token_masked() — маскированный токен (abc...xyz)
|
||||
get_client_id() — ClientID из JWT (base64url, без проверки подписи)
|
||||
get_stand() — "dev" / "test" по токену
|
||||
get_token_info() — {email, company, client_id} из JWT для UI
|
||||
get_token_masked() — маскированный токен (abc...xyz) для placeholder
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -22,13 +22,19 @@ from api.http_client import HttpClient, detect_endpoint, stand_name
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
def get_client():
|
||||
"""HttpClient с автоопределением стенда по токену.
|
||||
|
||||
"""HttpClient с автоопределением стенда по активному токену.
|
||||
|
||||
Использует detect_endpoint() — пробует dev→test стенды.
|
||||
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига."""
|
||||
token = get_token()
|
||||
@@ -37,27 +43,45 @@ def get_client():
|
||||
|
||||
|
||||
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()
|
||||
try:
|
||||
parts = token.split(".") # header.payload.signature
|
||||
parts = token.split(".") # [header, payload, signature]
|
||||
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", "")
|
||||
except Exception:
|
||||
pass
|
||||
pass # битый токен — не критично, вернём пустую строку
|
||||
return ""
|
||||
|
||||
|
||||
def get_stand():
|
||||
"""dev/test — по токену (detect_endpoint → stand_name)."""
|
||||
"""Определить стенд (dev/test) по активному токену.
|
||||
|
||||
detect_endpoint → stand_name. Если автоопределение не сработало —
|
||||
fallback на NUBES_API_ENDPOINT из конфига."""
|
||||
token = get_token()
|
||||
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
|
||||
return stand_name(endpoint)
|
||||
|
||||
|
||||
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()
|
||||
try:
|
||||
parts = token.split(".")
|
||||
@@ -75,8 +99,12 @@ def get_token_info():
|
||||
|
||||
|
||||
def get_token_masked():
|
||||
"""Маскированный токен для placeholder: abc...xyz."""
|
||||
"""Маскированный env-токен для placeholder в форме: abc...xyz.
|
||||
|
||||
Используется ТОЛЬКО env-токен (не пользовательский!).
|
||||
Если токен короче 8 символов — возвращает пустую строку."""
|
||||
token = current_app.config["NUBES_API_TOKEN"]
|
||||
if not token or len(token) < 8:
|
||||
return ""
|
||||
# Первые 4 символа + звёздочки + последние 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:
|
||||
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
|
||||
- GET: raise_for_status → .json()
|
||||
- POST: проверка r.ok, извлечение Location-заголовка
|
||||
HttpClient:
|
||||
- Добавляет обязательные заголовки:
|
||||
Authorization: Bearer <token> — аутентификация
|
||||
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
|
||||
- stand_name(endpoint) — "dev" / "test" по URL
|
||||
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
|
||||
Функции автоопределения стенда:
|
||||
- detect_endpoint(token) — пробует dev→test стенды по токену
|
||||
- stand_name(endpoint) — "dev" / "test" по URL
|
||||
- create_client(token) — HttpClient + endpoint одним вызовом
|
||||
|
||||
Зачем автоопределение: пользователь вводит токен, мы не знаем dev это или test.
|
||||
Пробуем оба стенда — какой ответит с results != None, тот и рабочий.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
|
||||
# Список стендов для автоопределения.
|
||||
# Порядок ВАЖЕН: dev первый — он быстрее (меньше нагрузка), test — резервный.
|
||||
STANDS = [
|
||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
||||
@@ -23,10 +30,16 @@ STANDS = [
|
||||
|
||||
def detect_endpoint(token):
|
||||
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
||||
|
||||
Делает GET /instances?pageSize=1 на каждый стенд.
|
||||
Если results != None — стенд рабочий, возвращаем его URL.
|
||||
Если ни один не подошёл — возвращаем None."""
|
||||
|
||||
Алгоритм:
|
||||
1. Для каждого стенда создаём HttpClient + делаем GET /instances?pageSize=1.
|
||||
2. Если results != None — стенд рабочий, токен валиден → возвращаем URL.
|
||||
3. Если Exception (401/403/таймаут) — пробуем следующий.
|
||||
4. Ни один не подошёл → None.
|
||||
|
||||
Почему results != None а не просто HTTP 200:
|
||||
API может вернуть 200 с пустым списком (нет инстансов) — это норм.
|
||||
results=None — признак что ответ не соответствует ожидаемой структуре."""
|
||||
for ep in STANDS:
|
||||
try:
|
||||
c = HttpClient(ep, token)
|
||||
@@ -39,10 +52,10 @@ def detect_endpoint(token):
|
||||
|
||||
|
||||
def stand_name(endpoint):
|
||||
"""dev/test по URL стенда.
|
||||
|
||||
"""Определить имя стенда по URL.
|
||||
|
||||
Ищет подстроку 'dev' или 'test' в URL.
|
||||
Если не найдено — возвращает '?'."""
|
||||
Если не найдено — '?' (неизвестный стенд)."""
|
||||
for name in ("dev", "test"):
|
||||
if name in (endpoint or ""):
|
||||
return name
|
||||
@@ -50,10 +63,11 @@ def stand_name(endpoint):
|
||||
|
||||
|
||||
def create_client(token, fallback_endpoint=None):
|
||||
"""HttpClient с автоопределением стенда по токену.
|
||||
|
||||
Сначала detect_endpoint(token), если не сработало — fallback_endpoint.
|
||||
Возвращает кортеж (HttpClient, endpoint_url) или None если стенд не определён."""
|
||||
"""HttpClient с автоопределением стенда.
|
||||
|
||||
Сначала detect_endpoint(token) — dev→test.
|
||||
Если не сработало — fallback_endpoint (из конфига).
|
||||
Возвращает (HttpClient, endpoint_url) или None."""
|
||||
ep = detect_endpoint(token) or fallback_endpoint
|
||||
if not ep:
|
||||
return None
|
||||
@@ -62,43 +76,66 @@ def create_client(token, fallback_endpoint=None):
|
||||
|
||||
class HttpClient:
|
||||
"""HTTP-клиент для Nubes REST API.
|
||||
|
||||
Использует requests.Session для keep-alive соединений.
|
||||
Добавляет обязательные заголовки:
|
||||
- Authorization: Bearer <token> (аутентификация)
|
||||
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
|
||||
|
||||
|
||||
Использует requests.Session для:
|
||||
- Keep-alive (переиспользование TCP+TLS между запросами)
|
||||
- Единые заголовки для всех запросов
|
||||
|
||||
Обязательные заголовки:
|
||||
- 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):
|
||||
# Убираем trailing slash чтобы потом добавлять "/path"
|
||||
# Убираем trailing slash (".../svc/" → ".../svc")
|
||||
# чтобы path добавлялся как "/path", а не "path"
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
# Session — переиспользует TCP-соединения между запросами
|
||||
# Session переиспользует TCP + TLS handshake между запросами.
|
||||
# Без Session каждый запрос делал бы новый connect → медленно.
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"User-Agent": "Mozilla/5.0", # DDoS-Guard: не python-requests
|
||||
})
|
||||
|
||||
def get(self, path, **kwargs):
|
||||
"""GET-запрос. Таймаут по умолчанию 10 секунд.
|
||||
|
||||
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
|
||||
Возвращает распарсенный JSON (dict/list)."""
|
||||
"""GET-запрос к Nubes API.
|
||||
|
||||
Особенности:
|
||||
- Таймаут 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)
|
||||
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
|
||||
r.raise_for_status()
|
||||
if not r.text or not r.text.strip():
|
||||
return {} # пустой ответ (напр. validate-cfs успех)
|
||||
return {} # пустой ответ — норма для некоторых эндпоинтов
|
||||
return r.json()
|
||||
|
||||
def post(self, path, data=None, **kwargs):
|
||||
"""POST-запрос. Таймаут по умолчанию 30 секунд.
|
||||
|
||||
Отправляет data как JSON (json=...).
|
||||
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
|
||||
Возвращает dict:
|
||||
- Распарсенный JSON (если ответ — валидный JSON-объект)
|
||||
- + ключ "_location" со значением заголовка Location (если есть)
|
||||
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
|
||||
"""POST-запрос к Nubes API.
|
||||
|
||||
Особенности:
|
||||
- Таймаут 30 секунд по умолчанию.
|
||||
- data → json=... — requests сам ставит Content-Type: application/json.
|
||||
- Извлекает Location-заголовок → UUID → кладёт в instanceUid/instanceOperationUid.
|
||||
Это нужно чтобы не полагаться только на тело ответа (которое может быть пустым).
|
||||
- При HTTP-ошибке: Exception с кодом и телом ответа.
|
||||
|
||||
Args:
|
||||
path: str — путь (напр. "/instances").
|
||||
data: dict|None — тело запроса (сериализуется в JSON).
|
||||
|
||||
Returns:
|
||||
dict с полями ответа + _status + _location (+ UUID если найден)."""
|
||||
kwargs.setdefault("timeout", 30)
|
||||
url = f"{self._endpoint}{path}"
|
||||
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
||||
@@ -112,15 +149,17 @@ class HttpClient:
|
||||
result = parsed
|
||||
result["_status"] = r.status_code
|
||||
except Exception:
|
||||
pass # тело не JSON или не dict — ок, Location всё равно извлечём
|
||||
pass # тело не JSON — ок, UUID извлечём из Location
|
||||
# Location-заголовок: "./UUID" или "/api/v1/svc/.../UUID"
|
||||
loc = r.headers.get("Location", "")
|
||||
if loc:
|
||||
# Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID"
|
||||
result["_location"] = loc
|
||||
# Извлечь UUID и положить в правильное поле ответа
|
||||
# Извлекаем UUID — последний сегмент после split("/")
|
||||
parts = loc.rstrip("/").split("/")
|
||||
uid = parts[-1]
|
||||
# Проверка: не ".", длина >= 32 (UUID = 36 символов с дефисами)
|
||||
if uid and uid != "." and len(uid) >= 32:
|
||||
# Кладём UUID в правильное поле в зависимости от эндпоинта
|
||||
if "/instanceOperations" in path:
|
||||
result["instanceOperationUid"] = uid
|
||||
elif "/instances" in path:
|
||||
@@ -128,9 +167,17 @@ class HttpClient:
|
||||
return result
|
||||
|
||||
def raw_delete(self, url):
|
||||
"""DELETE-запрос к произвольному URL (CMDB API).
|
||||
Использует отдельную сессию БЕЗ auth-заголовков.
|
||||
Возвращает кортеж (ok: bool, status_code: int)."""
|
||||
"""DELETE-запрос к произвольному URL — БЕЗ авторизации.
|
||||
|
||||
Используется для CMDB API (cmdb-api.deck.nubes.ru) —
|
||||
жёсткое удаление недосозданных/зависших инстансов.
|
||||
CMDB не требует Bearer-токена.
|
||||
|
||||
Args:
|
||||
url: str — полный URL (не path, другой хост!)
|
||||
|
||||
Returns:
|
||||
(ok: bool, status_code: int|str)."""
|
||||
try:
|
||||
r = requests.delete(url, timeout=10)
|
||||
return r.ok, r.status_code
|
||||
|
||||
+70
-12
@@ -1,45 +1,103 @@
|
||||
"""
|
||||
Общие утилиты: извлечение UUID из ответов API Nubes.
|
||||
|
||||
Используется: api_test.py, scenario.py, executor.py.
|
||||
Заменяет разрозненные реализации _find_uid / _uid_from_location.
|
||||
Проблема: Nubes API возвращает UUID инстанса/операции в разных форматах
|
||||
в зависимости от эндпоинта и фазы жизненного цикла:
|
||||
- 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):
|
||||
"""Извлечь instanceUid или instanceOperationUid из ответа API.
|
||||
"""Извлечь instanceUid или instanceOperationUid из dict-ответа API.
|
||||
|
||||
Ищет в нескольких местах:
|
||||
1. На верхнем уровне: instanceOperationUid → instanceUid → uid → Uid
|
||||
2. Во вложенных dict-значениях: {key: {instanceOperationUid: ..., instanceUid: ...}}
|
||||
Стратегия поиска (в порядке приоритета):
|
||||
1. Верхний уровень — прямые ключи (стиль api_test.py):
|
||||
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):
|
||||
return None
|
||||
# Верхний уровень — прямые ключи (api_test.py-стиль)
|
||||
|
||||
# ── Уровень 1: прямые ключи на верхнем уровне ──
|
||||
# Пример: {"instanceUid": "abc-123", ...}
|
||||
# Проверяем по порядку: сначала специфичные, потом общие
|
||||
for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"):
|
||||
v = resp.get(key)
|
||||
# isinstance(str) — отсекаем числа, None, пустые строки
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
# Вложенные dict-значения (scenario.py-стиль)
|
||||
|
||||
# ── Уровень 2: вложенные dict-значения ──
|
||||
# Пример: {"instanceOperation": {"instanceOperationUid": "abc-123"}}
|
||||
# Перебираем ВСЕ значения resp, ищем вложенные словари
|
||||
for v in resp.values():
|
||||
if isinstance(v, dict):
|
||||
# Внутри вложенного dict — те же ключи что на верхнем уровне
|
||||
uid = v.get("instanceOperationUid") or v.get("instanceUid")
|
||||
if uid:
|
||||
return uid
|
||||
|
||||
# Ничего не нашли
|
||||
return None
|
||||
|
||||
|
||||
def uid_from_location(loc):
|
||||
"""Извлечь UUID из Location-заголовка.
|
||||
"""Извлечь UUID из Location-заголовка HTTP-ответа.
|
||||
|
||||
Location бывает: "./UUID", "/api/v1/svc/instances/UUID", "/api/v1/svc/instanceOperations/UUID"
|
||||
Берём последний сегмент после split("/").
|
||||
Location — это HTTP-заголовок, который Nubes API возвращает при создании
|
||||
ресурса (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:
|
||||
return None
|
||||
|
||||
# Убираем trailing slash (напр. "./uuid/" → "./uuid")
|
||||
# Split по "/" → [".", "uuid"] или ["", "api", "v1", "svc", "instances", "uuid"]
|
||||
parts = str(loc).rstrip("/").split("/")
|
||||
|
||||
# Последний сегмент — это UUID
|
||||
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:
|
||||
return uid
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user