v1.0.89: detailed code comments — all Python files + index.html architecture overview
This commit is contained in:
+52
-4
@@ -1,5 +1,20 @@
|
|||||||
|
"""
|
||||||
|
HTTP-клиент для Nubes API + автоопределение стенда.
|
||||||
|
|
||||||
|
Класс HttpClient — тонкая обёртка над requests.Session:
|
||||||
|
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
|
||||||
|
- GET: raise_for_status → .json()
|
||||||
|
- POST: проверка r.ok, извлечение Location-заголовка
|
||||||
|
|
||||||
|
Функции автостенда:
|
||||||
|
- detect_endpoint(token) — пробует dev→test стенды, возвращает URL
|
||||||
|
- stand_name(endpoint) — "dev" / "test" по URL
|
||||||
|
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
|
||||||
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
|
||||||
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",
|
||||||
@@ -7,7 +22,11 @@ STANDS = [
|
|||||||
|
|
||||||
|
|
||||||
def detect_endpoint(token):
|
def detect_endpoint(token):
|
||||||
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL."""
|
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
|
||||||
|
|
||||||
|
Делает GET /instances?pageSize=1 на каждый стенд.
|
||||||
|
Если results != None — стенд рабочий, возвращаем его URL.
|
||||||
|
Если ни один не подошёл — возвращаем None."""
|
||||||
for ep in STANDS:
|
for ep in STANDS:
|
||||||
try:
|
try:
|
||||||
c = HttpClient(ep, token)
|
c = HttpClient(ep, token)
|
||||||
@@ -20,7 +39,10 @@ def detect_endpoint(token):
|
|||||||
|
|
||||||
|
|
||||||
def stand_name(endpoint):
|
def stand_name(endpoint):
|
||||||
"""dev/test по 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
|
||||||
@@ -28,7 +50,10 @@ 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.
|
||||||
|
Возвращает кортеж (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
|
||||||
@@ -36,8 +61,17 @@ def create_client(token, fallback_endpoint=None):
|
|||||||
|
|
||||||
|
|
||||||
class HttpClient:
|
class HttpClient:
|
||||||
|
"""HTTP-клиент для Nubes REST API.
|
||||||
|
|
||||||
|
Использует requests.Session для keep-alive соединений.
|
||||||
|
Добавляет обязательные заголовки:
|
||||||
|
- Authorization: Bearer <token> (аутентификация)
|
||||||
|
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
|
||||||
|
|
||||||
def __init__(self, endpoint, token):
|
def __init__(self, endpoint, token):
|
||||||
|
# Убираем trailing slash чтобы потом добавлять "/path"
|
||||||
self._endpoint = endpoint.rstrip("/")
|
self._endpoint = endpoint.rstrip("/")
|
||||||
|
# Session — переиспользует TCP-соединения между запросами
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._session.headers.update({
|
self._session.headers.update({
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -45,14 +79,27 @@ class HttpClient:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def get(self, path, **kwargs):
|
def get(self, path, **kwargs):
|
||||||
|
"""GET-запрос. Таймаут по умолчанию 10 секунд.
|
||||||
|
|
||||||
|
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
|
||||||
|
Возвращает распарсенный JSON (dict/list)."""
|
||||||
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()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def post(self, path, data=None, **kwargs):
|
def post(self, path, data=None, **kwargs):
|
||||||
|
"""POST-запрос. Таймаут по умолчанию 30 секунд.
|
||||||
|
|
||||||
|
Отправляет data как JSON (json=...).
|
||||||
|
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
|
||||||
|
Возвращает dict:
|
||||||
|
- Распарсенный JSON (если ответ — валидный JSON-объект)
|
||||||
|
- + ключ "_location" со значением заголовка Location (если есть)
|
||||||
|
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
|
||||||
kwargs.setdefault("timeout", 30)
|
kwargs.setdefault("timeout", 30)
|
||||||
url = f"{self._endpoint}{path}"
|
url = f"{self._endpoint}{path}"
|
||||||
|
# json=... — requests сам сериализует и ставит Content-Type: application/json
|
||||||
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
|
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
|
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
|
||||||
@@ -62,8 +109,9 @@ class HttpClient:
|
|||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
result = parsed
|
result = parsed
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass # тело не JSON или не dict — ок, Location всё равно извлечём
|
||||||
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
|
||||||
return result
|
return result
|
||||||
|
|||||||
+13
-1
@@ -1,3 +1,14 @@
|
|||||||
|
"""
|
||||||
|
app-autotest — точка входа Flask-приложения для автотестов Nubes.
|
||||||
|
|
||||||
|
Регистрирует три blueprint'а:
|
||||||
|
- main_bp → / (главная, токен, инфраструктура)
|
||||||
|
- api_bp → /api/run, /api/status, /api/config [LEGACY]
|
||||||
|
- api_test_bp → /api/test, /api/params, /api/log (основная логика)
|
||||||
|
|
||||||
|
Деплой: Nubes pythonk8s (gunicorn, несколько воркеров).
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -6,7 +17,8 @@ from routes.main import bp as main_bp
|
|||||||
from routes.api import bp as api_bp
|
from routes.api import bp as api_bp
|
||||||
from routes.api_test import bp as api_test_bp
|
from routes.api_test import bp as api_test_bp
|
||||||
|
|
||||||
VERSION = "1.0.88"
|
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
||||||
|
VERSION = "1.0.89"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Операции с инстансами Nubes — получение списка и организации через API.
|
||||||
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
|
|
||||||
|
|
||||||
def get_instances(client):
|
def get_instances(client):
|
||||||
|
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
|
||||||
|
|
||||||
|
pageSize=200 — максимум за один запрос.
|
||||||
|
Остановка по len(batch) < pageSize (НЕ доверяет total — бывали баги).
|
||||||
|
Возвращает полный list[dict]."""
|
||||||
results = []
|
results = []
|
||||||
page = 1
|
page = 1
|
||||||
page_size = 200
|
page_size = 200
|
||||||
@@ -9,7 +18,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: если страница вернула меньше pageSize, значит данные закончились.
|
# Если страница неполная — данные закончились.
|
||||||
|
# Не проверяем total, потому что API иногда врёт про total.
|
||||||
if len(batch) < page_size:
|
if len(batch) < page_size:
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
@@ -17,6 +27,9 @@ def get_instances(client):
|
|||||||
|
|
||||||
|
|
||||||
def get_organization(client):
|
def get_organization(client):
|
||||||
|
"""Первый инстанс с serviceId=19 (Организация).
|
||||||
|
|
||||||
|
Организация всегда одна на пользователя. Используется в UI: название, статус, ClientID."""
|
||||||
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
|
||||||
|
|||||||
@@ -64,8 +64,12 @@ def get_params_with_current_values(client, op_id, instance_uid):
|
|||||||
"name": code,
|
"name": code,
|
||||||
"dataType": data_type,
|
"dataType": data_type,
|
||||||
"isRequired": required,
|
"isRequired": required,
|
||||||
"defaultValue": merged_value,
|
"defaultValue": merged_value, # ← фронт использует это для value="..." в инпутах
|
||||||
"valueList": value_list,
|
"valueList": value_list,
|
||||||
|
# dataDescriptor — только для map/map-fixed типов.
|
||||||
|
# Содержит словарь {sub_code: {dataType, valueList, isRequired}} —
|
||||||
|
# это нужно фронту чтобы отрисовать вложенную таблицу подполей.
|
||||||
|
# Для не-map типов — None.
|
||||||
"dataDescriptor": (
|
"dataDescriptor": (
|
||||||
{
|
{
|
||||||
k: {
|
k: {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Операции с сервисами Nubes — получение списка и деталей через API.
|
||||||
|
"""
|
||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
|
|
||||||
|
|
||||||
def get_services(client):
|
def get_services(client):
|
||||||
|
"""GET /services → список ВСЕХ сервисов пользователя.
|
||||||
|
|
||||||
|
Возвращает list[dict], каждый с ключами: svcId, svc, svcExtendedName, 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} → детали одного сервиса.
|
||||||
|
|
||||||
|
Возвращает dict с ключами: svc, operations[], ...
|
||||||
|
operations — список доступных операций (create, modify, delete, ...)."""
|
||||||
data = client.get(f"/services/{svc_id}")
|
data = client.get(f"/services/{svc_id}")
|
||||||
return data.get("svc", {})
|
return data.get("svc", {})
|
||||||
|
|||||||
+48
-12
@@ -1,4 +1,21 @@
|
|||||||
"""Трекер созданных инстансов — по юзеру и стенду: /tmp/instances-{client_id}-{stand}.json."""
|
"""
|
||||||
|
Трекер созданных инстансов.
|
||||||
|
|
||||||
|
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
|
||||||
|
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
|
||||||
|
не появился в ответе GET /instances (облако может задержать на секунды).
|
||||||
|
|
||||||
|
Файлы изолированы по пользователю и стенду:
|
||||||
|
/tmp/instances-{clientId}-{stand}.json
|
||||||
|
|
||||||
|
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
|
||||||
|
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
|
||||||
|
|
||||||
|
Функции:
|
||||||
|
add(client_id, stand, uid, svc_id, name) — добавить инстанс
|
||||||
|
remove(client_id, stand, uid) — удалить (после успешного delete)
|
||||||
|
list_all(client_id, stand) — все записи списком
|
||||||
|
"""
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -6,11 +23,15 @@ import time
|
|||||||
|
|
||||||
|
|
||||||
def _path(client_id, stand):
|
def _path(client_id, 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):
|
||||||
"""LOCK_EX с таймаутом 2 секунды (LOCK_NB + retry)."""
|
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
|
||||||
|
|
||||||
|
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
|
||||||
|
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
|
||||||
deadline = time.time() + 2
|
deadline = time.time() + 2
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -23,44 +44,55 @@ def _acquire_lock(fd):
|
|||||||
|
|
||||||
|
|
||||||
def _locked_read(path):
|
def _locked_read(path):
|
||||||
"""Читает файл под эксклюзивной блокировкой, пустой dict если нет."""
|
"""Читает JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
|
Если файла нет — создаёт и возвращает пустой {}.
|
||||||
|
Если JSON битый — возвращает {} (переживёт перезапись).
|
||||||
|
Максимальный размер: 65536 байт."""
|
||||||
try:
|
try:
|
||||||
|
# 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 {} # нет прав — молча возвращаем пустой
|
||||||
try:
|
try:
|
||||||
if not _acquire_lock(fd):
|
if not _acquire_lock(fd):
|
||||||
return {}
|
return {} # не смогли заблокировать за 2с — не рискуем
|
||||||
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 # битый файл — переживём, при следующей записи перезапишется
|
||||||
return {}
|
return {}
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def _locked_write(path, data):
|
def _locked_write(path, data):
|
||||||
"""Пишет файл под эксклюзивной блокировкой."""
|
"""Пишет JSON-файл под эксклюзивной блокировкой.
|
||||||
|
|
||||||
|
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
|
||||||
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.lseek(fd, 0, 0) # в начало файла
|
||||||
os.ftruncate(fd, 0)
|
os.ftruncate(fd, 0) # обрезать старый контент
|
||||||
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
|
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
|
||||||
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):
|
||||||
|
"""Добавить инстанс в трекер.
|
||||||
|
|
||||||
|
Читает текущий файл → добавляет запись → пишет обратно.
|
||||||
|
Если инстанс уже есть — перезаписывает (идемпотентно)."""
|
||||||
p = _path(client_id, stand)
|
p = _path(client_id, stand)
|
||||||
data = _locked_read(p)
|
data = _locked_read(p)
|
||||||
data[instance_uid] = {
|
data[instance_uid] = {
|
||||||
@@ -72,6 +104,9 @@ 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).
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -79,4 +114,5 @@ def remove(client_id, stand, instance_uid):
|
|||||||
|
|
||||||
|
|
||||||
def list_all(client_id, stand):
|
def list_all(client_id, stand):
|
||||||
|
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
|
||||||
return list(_locked_read(_path(client_id, stand)).values())
|
return list(_locked_read(_path(client_id, stand)).values())
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Основной модуль: запуск операций, параметры, поллинг статуса, логирование.
|
||||||
|
|
||||||
|
Эндпоинты:
|
||||||
|
GET /api/services — список сервисов
|
||||||
|
GET /api/instances/list — все инстансы
|
||||||
|
GET /api/operations/<svc_id> — операции + tracked-инстансы сервиса
|
||||||
|
GET /api/params/<op_id>[?instanceUid=xxx] — параметры операции (шаблон или текущие)
|
||||||
|
POST /api/test — запустить операцию (CREATE или non-CREATE)
|
||||||
|
GET /api/test/status/<op_uid> — поллинг статуса операции
|
||||||
|
GET /api/log — последние строки лога
|
||||||
|
|
||||||
|
Ключевые функции:
|
||||||
|
_client() — HttpClient с токеном из cookie/env
|
||||||
|
_client_id() — ClientID из JWT
|
||||||
|
_stand() — dev/test по токену
|
||||||
|
_with_prefix(name) — гарантирует префикс autotest-
|
||||||
|
_unique_display_name(client, name)— проверка на дубликат + суффикс
|
||||||
|
_find_uid(resp) — извлечение UUID из ответа API
|
||||||
|
_uid_from_location(loc) — извлечение UUID из Location-заголовка
|
||||||
|
_get_instance_display_name(...) — GET /instances/{uid} → displayName
|
||||||
|
_finish_op(...) — фоновый поллинг до dtFinish
|
||||||
|
_log(msg) — лог в stdout + /tmp/app-autotest.log
|
||||||
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, request
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
|||||||
+85
-19
@@ -1,3 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Роуты главной страницы и списка операций.
|
||||||
|
|
||||||
|
GET/POST / — главная страница: организация, инфраструктура, токен
|
||||||
|
GET /api/operations/<svc_id> — операции и autotest-инстансы сервиса (cloud-first)
|
||||||
|
|
||||||
|
Вспомогательные функции:
|
||||||
|
_resolve_instance_status() — канонический статус (cloud или "creating" из трекера)
|
||||||
|
_mask() — маскировка токена (env: abc...xyz)
|
||||||
|
_client_id() — извлечение ClientID из JWT
|
||||||
|
_token_info() — email, компания, clientId из JWT
|
||||||
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect
|
from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect
|
||||||
|
|
||||||
from api.http_client import HttpClient, detect_endpoint, create_client, stand_name
|
from api.http_client import HttpClient, detect_endpoint, create_client, stand_name
|
||||||
@@ -7,36 +20,40 @@ from operations.tracker import list_all as tracker_list
|
|||||||
from runner import load_config
|
from runner import load_config
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
# Префикс для autotest-инстансов — по нему фильтруем «наши» инстансы в облаке.
|
||||||
AUTOTEST_PREFIX = "autotest-"
|
AUTOTEST_PREFIX = "autotest-"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_instance_status(instance, tracked=None):
|
def _resolve_instance_status(instance, tracked=None):
|
||||||
"""Вернуть канонический статус инстанса для API и UI."""
|
"""Канонический статус инстанса: explainedStatus из облака, или "creating" из трекера."""
|
||||||
tracked = tracked or {}
|
tracked = tracked or {}
|
||||||
cloud_status = str(instance.get("explainedStatus") or "").strip()
|
cloud_status = str(instance.get("explainedStatus") or "").strip()
|
||||||
display_name = str(instance.get("displayName") or "")
|
display_name = str(instance.get("displayName") or "")
|
||||||
|
|
||||||
if cloud_status:
|
if cloud_status:
|
||||||
return cloud_status
|
return cloud_status # облако знает статус — доверяем
|
||||||
|
|
||||||
if tracked and display_name.startswith(AUTOTEST_PREFIX):
|
if tracked and display_name.startswith(AUTOTEST_PREFIX):
|
||||||
return "creating"
|
return "creating" # в трекере есть, в облаке ещё нет — создаётся
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _mask(s):
|
def _mask(s):
|
||||||
|
"""Маскировка токена для показа в placeholder: abc...xyz."""
|
||||||
if not s or len(s) < 8:
|
if not s or len(s) < 8:
|
||||||
return ""
|
return ""
|
||||||
return s[:4] + "*" * (len(s) - 8) + s[-4:]
|
return s[:4] + "*" * (len(s) - 8) + s[-4:]
|
||||||
|
|
||||||
|
|
||||||
def _client_id(token):
|
def _client_id(token):
|
||||||
|
"""Извлечение ClientID из payload JWT-токена (base64, без проверки подписи)."""
|
||||||
import base64, json
|
import base64, json
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".") # header.payload.signature
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
payload = base64.urlsafe_b64decode(parts[1] + "==")
|
payload = base64.urlsafe_b64decode(parts[1] + "==") # padding
|
||||||
return json.loads(payload).get("ClientID", "")
|
return json.loads(payload).get("ClientID", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -44,6 +61,7 @@ def _client_id(token):
|
|||||||
|
|
||||||
|
|
||||||
def _token_info(token):
|
def _token_info(token):
|
||||||
|
"""Извлечение email, company, client_id из JWT для показа в топбаре."""
|
||||||
import base64, json
|
import base64, json
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
@@ -62,9 +80,15 @@ def _token_info(token):
|
|||||||
|
|
||||||
@bp.route("/", methods=["GET", "POST"])
|
@bp.route("/", methods=["GET", "POST"])
|
||||||
def index():
|
def index():
|
||||||
|
"""Главная страница: организация, инфраструктура, сервисы, форма токена.
|
||||||
|
|
||||||
|
GET — рендерит страницу с данными из API.
|
||||||
|
POST — обрабатывает форму токена (action=save/clear)."""
|
||||||
|
|
||||||
|
# Токены: env — из переменной окружения, user — из cookie или формы
|
||||||
env_token = current_app.config["NUBES_API_TOKEN"]
|
env_token = current_app.config["NUBES_API_TOKEN"]
|
||||||
user_token = request.cookies.get("token") or request.form.get("token") or ""
|
user_token = request.cookies.get("token") or request.form.get("token") or ""
|
||||||
active_token = user_token or env_token
|
active_token = user_token or env_token # пользовательский приоритетнее
|
||||||
|
|
||||||
org = None
|
org = None
|
||||||
error = None
|
error = None
|
||||||
@@ -75,7 +99,10 @@ def index():
|
|||||||
action = request.form.get("action", "")
|
action = request.form.get("action", "")
|
||||||
|
|
||||||
def _tmpl(**overrides):
|
def _tmpl(**overrides):
|
||||||
"""Собрать контекст шаблона со всеми обязательными переменными."""
|
"""Хелпер: собрать контекст для render_template.
|
||||||
|
|
||||||
|
Гарантирует что ВСЕ переменные переданы, даже если API не отработал.
|
||||||
|
overrides — можно переопределить отдельные переменные."""
|
||||||
ctx = {
|
ctx = {
|
||||||
"organization": org,
|
"organization": org,
|
||||||
"error": error,
|
"error": error,
|
||||||
@@ -92,48 +119,60 @@ def index():
|
|||||||
ctx.update(overrides)
|
ctx.update(overrides)
|
||||||
return render_template("index.html", **ctx)
|
return render_template("index.html", **ctx)
|
||||||
|
|
||||||
|
# Обработка action=clear: удалить cookie с токеном
|
||||||
if action == "clear":
|
if action == "clear":
|
||||||
resp = make_response(redirect("/"))
|
resp = make_response(redirect("/"))
|
||||||
resp.delete_cookie("token")
|
resp.delete_cookie("token")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Обработка action=save: сохранить токен в cookie (на год)
|
||||||
if action == "save":
|
if action == "save":
|
||||||
user_token = request.form.get("token", "")
|
user_token = request.form.get("token", "")
|
||||||
active_token = user_token or env_token
|
active_token = user_token or env_token
|
||||||
resp = make_response()
|
resp = make_response()
|
||||||
resp.set_cookie("token", user_token, max_age=60*60*24*365)
|
resp.set_cookie("token", user_token, max_age=60*60*24*365) # 1 год
|
||||||
# redirect to GET
|
resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории)
|
||||||
resp.headers["Location"] = "/"
|
|
||||||
resp.status_code = 302
|
resp.status_code = 302
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Загрузка данных из API (только если есть токен)
|
||||||
services = []
|
services = []
|
||||||
instances = []
|
instances = []
|
||||||
instance_groups = {}
|
instance_groups = {}
|
||||||
config = {}
|
config = {}
|
||||||
stand = "?"
|
stand = "?"
|
||||||
if active_token:
|
if active_token:
|
||||||
|
# create_client — автоопределение стенда + HttpClient
|
||||||
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"])
|
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"])
|
||||||
if result:
|
if result:
|
||||||
client, endpoint = result
|
client, endpoint = result
|
||||||
stand = stand_name(endpoint)
|
stand = stand_name(endpoint)
|
||||||
try:
|
try:
|
||||||
|
# Организация — инстанс с serviceId=19
|
||||||
org = get_organization(client)
|
org = get_organization(client)
|
||||||
|
|
||||||
|
# Все сервисы — сортировка по svcId
|
||||||
raw_svc = get_services(client)
|
raw_svc = get_services(client)
|
||||||
services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", "")))
|
services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", "")))
|
||||||
# инфраструктурные сервисы (платформа/среда)
|
|
||||||
|
# Инфраструктурные инстансы — только определённые serviceId
|
||||||
|
# 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group
|
||||||
|
# 2=Template, 12=S3, 110=?, 150=K8s
|
||||||
infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150}
|
infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150}
|
||||||
raw_inst = get_instances(client)
|
raw_inst = get_instances(client)
|
||||||
instances = [i for i in raw_inst
|
instances = [i for i in raw_inst
|
||||||
if i.get("explainedStatus") not in ("deleted", "not created")
|
if i.get("explainedStatus") not in ("deleted", "not created")
|
||||||
and i.get("serviceId") in infra_ids]
|
and i.get("serviceId") in infra_ids]
|
||||||
|
# Сортировка: организация (svcId=19) первая, остальные по имени
|
||||||
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
|
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
|
||||||
|
|
||||||
# group by service type
|
# Группировка по типу сервиса для левой колонки UI
|
||||||
instance_groups = {}
|
instance_groups = {}
|
||||||
for i in instances:
|
for i in instances:
|
||||||
svc_name = i.get("svc", "Прочее")
|
svc_name = i.get("svc", "Прочее")
|
||||||
instance_groups.setdefault(svc_name, []).append(i)
|
instance_groups.setdefault(svc_name, []).append(i)
|
||||||
|
|
||||||
|
# Конфиг из config.yaml + версия приложения
|
||||||
config = dict(load_config() or {})
|
config = dict(load_config() or {})
|
||||||
config["VERSION"] = current_app.config["VERSION"]
|
config["VERSION"] = current_app.config["VERSION"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -141,6 +180,7 @@ def index():
|
|||||||
else:
|
else:
|
||||||
error = "Токен невалиден или просрочен"
|
error = "Токен невалиден или просрочен"
|
||||||
|
|
||||||
|
# После POST (save/clear) — редирект на GET, чтобы F5 не переотправлял форму
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
return _tmpl()
|
return _tmpl()
|
||||||
@@ -148,49 +188,75 @@ def index():
|
|||||||
|
|
||||||
@bp.route("/api/operations/<int:svc_id>")
|
@bp.route("/api/operations/<int:svc_id>")
|
||||||
def api_operations(svc_id):
|
def api_operations(svc_id):
|
||||||
|
"""Список операций и autotest-инстансов сервиса (cloud-first с fallback на трекер).
|
||||||
|
|
||||||
|
Вызывается из UI: selectService(), toggleInstance(), refreshInstances().
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Cloud-first: фильтруем GET /instances по префиксу autotest-
|
||||||
|
2. Tracker-fallback: если инстанс есть в трекере но нет в облаке → status="creating"
|
||||||
|
3. Дедупликация: если displayName уже есть в cloud — tracker-запись пропускается"""
|
||||||
|
|
||||||
|
# Токен из cookie или env
|
||||||
env_token = current_app.config["NUBES_API_TOKEN"]
|
env_token = current_app.config["NUBES_API_TOKEN"]
|
||||||
user_token = request.cookies.get("token") or ""
|
user_token = request.cookies.get("token") or ""
|
||||||
active_token = user_token or env_token
|
active_token = user_token or env_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Автоопределение стенда + HttpClient
|
||||||
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"])
|
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"])
|
||||||
if not result:
|
if not result:
|
||||||
return jsonify({"error": "Не удалось определить стенд"}), 500
|
return jsonify({"error": "Не удалось определить стенд"}), 500
|
||||||
client, endpoint = result
|
client, endpoint = result
|
||||||
|
|
||||||
|
# Детали сервиса: список операций (modify, delete, suspend, ...)
|
||||||
detail = get_service_detail(client, svc_id)
|
detail = get_service_detail(client, svc_id)
|
||||||
ops = detail.get("operations", [])
|
ops = detail.get("operations", [])
|
||||||
|
|
||||||
|
# Трекер: наши autotest-инстансы (изолирован по пользователю и стенду)
|
||||||
tracked = tracker_list(_client_id(active_token), stand_name(endpoint))
|
tracked = tracker_list(_client_id(active_token), stand_name(endpoint))
|
||||||
tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id}
|
tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id}
|
||||||
|
|
||||||
|
# Все инстансы из облака
|
||||||
instances = get_instances(client)
|
instances = get_instances(client)
|
||||||
nubes_uids = {i["instanceUid"] for i in instances}
|
nubes_uids = {i["instanceUid"] for i in instances}
|
||||||
|
|
||||||
svc_instances = []
|
svc_instances = []
|
||||||
cloud_names = set()
|
cloud_names = set() # для дедупликации с tracker
|
||||||
|
|
||||||
|
# --- Шаг 1: Cloud-first — фильтруем по префиксу autotest- ---
|
||||||
for i in instances:
|
for i in instances:
|
||||||
display_name = str(i.get("displayName", "") or "")
|
display_name = str(i.get("displayName", "") or "")
|
||||||
if not display_name.startswith(AUTOTEST_PREFIX):
|
if not display_name.startswith(AUTOTEST_PREFIX):
|
||||||
continue
|
continue # не наш инстанс
|
||||||
if i.get("explainedStatus") in ("deleted",):
|
if i.get("explainedStatus") in ("deleted",):
|
||||||
continue
|
continue # удалённые не показываем
|
||||||
item = dict(i)
|
item = dict(i)
|
||||||
item["status"] = _resolve_instance_status(item, tracked_by_uid.get(i.get("instanceUid")))
|
item["status"] = _resolve_instance_status(item, tracked_by_uid.get(i.get("instanceUid")))
|
||||||
svc_instances.append(item)
|
svc_instances.append(item)
|
||||||
cloud_names.add(display_name)
|
cloud_names.add(display_name)
|
||||||
|
|
||||||
# tracker — только временный fallback, если cloud ещё не отдал конкретный autotest-инстанс
|
# --- Шаг 2: Tracker-fallback ---
|
||||||
|
# Инстансы из трекера, которых ещё нет в облаке (только что созданы).
|
||||||
|
# Но если displayName уже есть в cloud_names — дубль, пропускаем.
|
||||||
for uid, t in tracked_by_uid.items():
|
for uid, t in tracked_by_uid.items():
|
||||||
if uid not in nubes_uids:
|
if uid not in nubes_uids:
|
||||||
display_name = str(t.get("displayName", "") or "")
|
display_name = str(t.get("displayName", "") or "")
|
||||||
if display_name in cloud_names:
|
if display_name in cloud_names:
|
||||||
continue
|
continue # дубль — облако уже показало этот инстанс
|
||||||
svc_instances.append({
|
svc_instances.append({
|
||||||
"instanceUid": uid,
|
"instanceUid": uid,
|
||||||
"displayName": display_name,
|
"displayName": display_name,
|
||||||
"serviceId": svc_id,
|
"serviceId": svc_id,
|
||||||
"svc": detail.get("svc", ""),
|
"svc": detail.get("svc", ""),
|
||||||
"explainedStatus": "",
|
"explainedStatus": "",
|
||||||
"status": "creating",
|
"status": "creating", # наш статус — облако ещё не знает
|
||||||
})
|
})
|
||||||
return jsonify({"svc": detail.get("svc", ""), "operations": ops, "instances": svc_instances})
|
|
||||||
|
return jsonify({
|
||||||
|
"svc": detail.get("svc", ""),
|
||||||
|
"operations": ops, # [{svcOperationId, operation}, ...]
|
||||||
|
"instances": svc_instances, # [{instanceUid, displayName, status, ...}, ...]
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|||||||
@@ -41,6 +41,19 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
=== ПЕРЕМЕННЫЕ ШАБЛОНА JINJA2 ===
|
||||||
|
config.VERSION — версия приложения (топбар)
|
||||||
|
token_info.email — email пользователя (топбар)
|
||||||
|
token_info.company — профиль/компания (топбар)
|
||||||
|
stand — стенд: dev/test (топбар)
|
||||||
|
env_token_masked — маскированный env-токен (placeholder)
|
||||||
|
has_user_token — True если пользователь ввёл свой токен
|
||||||
|
organization — организация (serviceId=19)
|
||||||
|
instance_groups — {svc_name: [instances]} для левой колонки
|
||||||
|
error — сообщение об ошибке
|
||||||
|
-->
|
||||||
|
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:22px;" />
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:22px;" />
|
||||||
<span style="font-size:11px;color:var(--muted);">v{{ config.VERSION }}</span>
|
<span style="font-size:11px;color:var(--muted);">v{{ config.VERSION }}</span>
|
||||||
@@ -111,6 +124,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/*
|
||||||
|
* === АРХИТЕКТУРА ФРОНТЕНДА ===
|
||||||
|
*
|
||||||
|
* Один HTML-файл, ванильный JS (без фреймворков).
|
||||||
|
* Три колонки: инфраструктура | сервисы | инстансы+операции+параметры.
|
||||||
|
*
|
||||||
|
* Глобальное состояние:
|
||||||
|
* svcInstances — кеш списка инстансов из /api/operations/{svcId}
|
||||||
|
* selectedInst — UID выбранного инстанса (null если не выбран)
|
||||||
|
* selectedOp — {opId, opName, svcId} выбранная операция
|
||||||
|
* pollTimer — setInterval таймер поллинга статуса операции
|
||||||
|
* SVC_ID = 1 — фиксированный сервис (Болванка)
|
||||||
|
* AUTOTEST_PREFIX — префикс имён autotest-инстансов
|
||||||
|
*
|
||||||
|
* Потоки:
|
||||||
|
* CREATE: startCreate() → showParams(18,'create') → executeOp(pp) → poll → refresh
|
||||||
|
* MODIFY: toggleInstance() → runOp('modify',id) → showParams(id,'modify') → validate → executeOp
|
||||||
|
* БЕЗ ПАРАМЕТРОВ (delete/suspend/resume/redeploy): runOp() → confirm → executeOp({})
|
||||||
|
*
|
||||||
|
* Хелперы:
|
||||||
|
* _esc(s) — HTML-экранирование (" → " & → & < → <)
|
||||||
|
* validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой)
|
||||||
|
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
|
||||||
|
*/
|
||||||
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
||||||
const SVC_ID=1;
|
const SVC_ID=1;
|
||||||
const AUTOTEST_PREFIX='autotest-';
|
const AUTOTEST_PREFIX='autotest-';
|
||||||
|
|||||||
Reference in New Issue
Block a user