From 1cc3f6eb7e2d8191b8e1f95ef0664b6a21c08803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 27 Jul 2026 22:02:51 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.89:=20detailed=20code=20comments=20?= =?UTF-8?q?=E2=80=94=20all=20Python=20files=20+=20index.html=20architectur?= =?UTF-8?q?e=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/api/http_client.py | 56 +++++++++++++++-- site/app.py | 14 ++++- site/operations/get_instances.py | 15 ++++- site/operations/get_params.py | 6 +- site/operations/get_services.py | 11 ++++ site/operations/tracker.py | 60 ++++++++++++++---- site/routes/api_test.py | 25 ++++++++ site/routes/main.py | 104 +++++++++++++++++++++++++------ site/templates/index.html | 37 +++++++++++ 9 files changed, 290 insertions(+), 38 deletions(-) diff --git a/site/api/http_client.py b/site/api/http_client.py index e431fa4..6de5998 100644 --- a/site/api/http_client.py +++ b/site/api/http_client.py @@ -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 +# Список стендов для автоопределения. Порядок важен: dev первый (быстрее). STANDS = [ "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc", @@ -7,7 +22,11 @@ STANDS = [ def detect_endpoint(token): - """Пробуем токен против dev и test стендов, возвращаем рабочий URL.""" + """Пробуем токен против dev и test стендов, возвращаем рабочий URL. + + Делает GET /instances?pageSize=1 на каждый стенд. + Если results != None — стенд рабочий, возвращаем его URL. + Если ни один не подошёл — возвращаем None.""" for ep in STANDS: try: c = HttpClient(ep, token) @@ -20,7 +39,10 @@ def detect_endpoint(token): def stand_name(endpoint): - """dev/test по URL стенда.""" + """dev/test по URL стенда. + + Ищет подстроку 'dev' или 'test' в URL. + Если не найдено — возвращает '?'.""" for name in ("dev", "test"): if name in (endpoint or ""): return name @@ -28,7 +50,10 @@ def stand_name(endpoint): 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 if not ep: return None @@ -36,8 +61,17 @@ def create_client(token, fallback_endpoint=None): class HttpClient: + """HTTP-клиент для Nubes REST API. + + Использует requests.Session для keep-alive соединений. + Добавляет обязательные заголовки: + - Authorization: Bearer (аутентификация) + - User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)""" + def __init__(self, endpoint, token): + # Убираем trailing slash чтобы потом добавлять "/path" self._endpoint = endpoint.rstrip("/") + # Session — переиспользует TCP-соединения между запросами self._session = requests.Session() self._session.headers.update({ "Authorization": f"Bearer {token}", @@ -45,14 +79,27 @@ class HttpClient: }) def get(self, path, **kwargs): + """GET-запрос. Таймаут по умолчанию 10 секунд. + + Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError. + Возвращает распарсенный JSON (dict/list).""" kwargs.setdefault("timeout", 10) r = self._session.get(f"{self._endpoint}{path}", **kwargs) r.raise_for_status() 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).""" kwargs.setdefault("timeout", 30) 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) if not r.ok: raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}") @@ -62,8 +109,9 @@ class HttpClient: if isinstance(parsed, dict): result = parsed except Exception: - pass + pass # тело не JSON или не dict — ок, Location всё равно извлечём loc = r.headers.get("Location", "") if loc: + # Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID" result["_location"] = loc return result diff --git a/site/app.py b/site/app.py index 0e2c2d5..1179fde 100644 --- a/site/app.py +++ b/site/app.py @@ -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 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_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.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") diff --git a/site/operations/get_instances.py b/site/operations/get_instances.py index 4837ab9..c9ad31d 100644 --- a/site/operations/get_instances.py +++ b/site/operations/get_instances.py @@ -1,7 +1,16 @@ +""" +Операции с инстансами Nubes — получение списка и организации через API. +""" + from api.http_client import HttpClient def get_instances(client): + """GET /instances с пагинацией → ВСЕ инстансы пользователя. + + pageSize=200 — максимум за один запрос. + Остановка по len(batch) < pageSize (НЕ доверяет total — бывали баги). + Возвращает полный list[dict].""" results = [] page = 1 page_size = 200 @@ -9,7 +18,8 @@ def get_instances(client): data = client.get("/instances", params={"pageSize": page_size, "page": page}) batch = data.get("results", []) or [] results.extend(batch) - # Не доверяем total: если страница вернула меньше pageSize, значит данные закончились. + # Если страница неполная — данные закончились. + # Не проверяем total, потому что API иногда врёт про total. if len(batch) < page_size: break page += 1 @@ -17,6 +27,9 @@ def get_instances(client): def get_organization(client): + """Первый инстанс с serviceId=19 (Организация). + + Организация всегда одна на пользователя. Используется в UI: название, статус, ClientID.""" for inst in get_instances(client): if inst.get("serviceId") == 19: return inst diff --git a/site/operations/get_params.py b/site/operations/get_params.py index 6aec8a7..8f7a640 100644 --- a/site/operations/get_params.py +++ b/site/operations/get_params.py @@ -64,8 +64,12 @@ def get_params_with_current_values(client, op_id, instance_uid): "name": code, "dataType": data_type, "isRequired": required, - "defaultValue": merged_value, + "defaultValue": merged_value, # ← фронт использует это для value="..." в инпутах "valueList": value_list, + # dataDescriptor — только для map/map-fixed типов. + # Содержит словарь {sub_code: {dataType, valueList, isRequired}} — + # это нужно фронту чтобы отрисовать вложенную таблицу подполей. + # Для не-map типов — None. "dataDescriptor": ( { k: { diff --git a/site/operations/get_services.py b/site/operations/get_services.py index 9eb8ce9..c52c563 100644 --- a/site/operations/get_services.py +++ b/site/operations/get_services.py @@ -1,11 +1,22 @@ +""" +Операции с сервисами Nubes — получение списка и деталей через API. +""" + from api.http_client import HttpClient def get_services(client): + """GET /services → список ВСЕХ сервисов пользователя. + + Возвращает list[dict], каждый с ключами: svcId, svc, svcExtendedName, operations, ...""" data = client.get("/services") return data.get("results", []) def get_service_detail(client, svc_id): + """GET /services/{svcId} → детали одного сервиса. + + Возвращает dict с ключами: svc, operations[], ... + operations — список доступных операций (create, modify, delete, ...).""" data = client.get(f"/services/{svc_id}") return data.get("svc", {}) diff --git a/site/operations/tracker.py b/site/operations/tracker.py index 9809256..a2ef8b7 100644 --- a/site/operations/tracker.py +++ b/site/operations/tracker.py @@ -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 json import os @@ -6,11 +23,15 @@ import time def _path(client_id, stand): + """Путь к файлу трекера для конкретного пользователя и стенда.""" return f"/tmp/instances-{client_id}-{stand}.json" def _acquire_lock(fd): - """LOCK_EX с таймаутом 2 секунды (LOCK_NB + retry).""" + """Эксклюзивная блокировка файла с таймаутом 2 секунды. + + Использует LOCK_NB (неблокирующий) + retry с шагом 50ms. + Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно).""" deadline = time.time() + 2 while True: try: @@ -23,44 +44,55 @@ def _acquire_lock(fd): def _locked_read(path): - """Читает файл под эксклюзивной блокировкой, пустой dict если нет.""" + """Читает JSON-файл под эксклюзивной блокировкой. + + Если файла нет — создаёт и возвращает пустой {}. + Если JSON битый — возвращает {} (переживёт перезапись). + Максимальный размер: 65536 байт.""" try: + # O_RDWR — чтение+запись, O_CREAT — создать если нет fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644) except OSError: - return {} + return {} # нет прав — молча возвращаем пустой try: if not _acquire_lock(fd): - return {} + return {} # не смогли заблокировать за 2с — не рискуем try: data = os.read(fd, 65536) if data: return json.loads(data.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): - pass + pass # битый файл — переживём, при следующей записи перезапишется return {} finally: - fcntl.flock(fd, fcntl.LOCK_UN) + fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку os.close(fd) def _locked_write(path, data): - """Пишет файл под эксклюзивной блокировкой.""" + """Пишет JSON-файл под эксклюзивной блокировкой. + + Полностью перезаписывает файл: lseek(0) + ftruncate + write.""" try: fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644) except OSError: return try: if not _acquire_lock(fd): - return - os.lseek(fd, 0, 0) - os.ftruncate(fd, 0) + return # не смогли заблокировать — не пишем (данные не потеряются, просто не сохранятся) + os.lseek(fd, 0, 0) # в начало файла + os.ftruncate(fd, 0) # обрезать старый контент os.write(fd, json.dumps(data, indent=2).encode("utf-8")) finally: - fcntl.flock(fd, fcntl.LOCK_UN) + fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку os.close(fd) def add(client_id, stand, instance_uid, svc_id, display_name): + """Добавить инстанс в трекер. + + Читает текущий файл → добавляет запись → пишет обратно. + Если инстанс уже есть — перезаписывает (идемпотентно).""" p = _path(client_id, stand) data = _locked_read(p) 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): + """Удалить инстанс из трекера (после успешного delete). + + pop с default=None — не падает если инстанса уже нет.""" p = _path(client_id, stand) data = _locked_read(p) data.pop(instance_uid, None) @@ -79,4 +114,5 @@ def remove(client_id, stand, instance_uid): def list_all(client_id, stand): + """Все записи трекера — список dict'ов {svcId, displayName, instanceUid}.""" return list(_locked_read(_path(client_id, stand)).values()) diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 42c7b7a..ba594da 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -1,3 +1,28 @@ +""" +Основной модуль: запуск операций, параметры, поллинг статуса, логирование. + +Эндпоинты: + GET /api/services — список сервисов + GET /api/instances/list — все инстансы + GET /api/operations/ — операции + tracked-инстансы сервиса + GET /api/params/[?instanceUid=xxx] — параметры операции (шаблон или текущие) + POST /api/test — запустить операцию (CREATE или non-CREATE) + GET /api/test/status/ — поллинг статуса операции + 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 import uuid import os diff --git a/site/routes/main.py b/site/routes/main.py index 1512607..716e7f1 100644 --- a/site/routes/main.py +++ b/site/routes/main.py @@ -1,3 +1,16 @@ +""" +Роуты главной страницы и списка операций. + +GET/POST / — главная страница: организация, инфраструктура, токен +GET /api/operations/ — операции и 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 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 bp = Blueprint("main", __name__) + +# Префикс для autotest-инстансов — по нему фильтруем «наши» инстансы в облаке. AUTOTEST_PREFIX = "autotest-" def _resolve_instance_status(instance, tracked=None): - """Вернуть канонический статус инстанса для API и UI.""" + """Канонический статус инстанса: explainedStatus из облака, или "creating" из трекера.""" tracked = tracked or {} cloud_status = str(instance.get("explainedStatus") or "").strip() display_name = str(instance.get("displayName") or "") if cloud_status: - return cloud_status + return cloud_status # облако знает статус — доверяем if tracked and display_name.startswith(AUTOTEST_PREFIX): - return "creating" + return "creating" # в трекере есть, в облаке ещё нет — создаётся return "unknown" def _mask(s): + """Маскировка токена для показа в placeholder: abc...xyz.""" if not s or len(s) < 8: return "" return s[:4] + "*" * (len(s) - 8) + s[-4:] def _client_id(token): + """Извлечение ClientID из payload JWT-токена (base64, без проверки подписи).""" import base64, json try: - parts = token.split(".") + parts = token.split(".") # header.payload.signature if len(parts) >= 2: - payload = base64.urlsafe_b64decode(parts[1] + "==") + payload = base64.urlsafe_b64decode(parts[1] + "==") # padding return json.loads(payload).get("ClientID", "") except Exception: pass @@ -44,6 +61,7 @@ def _client_id(token): def _token_info(token): + """Извлечение email, company, client_id из JWT для показа в топбаре.""" import base64, json try: parts = token.split(".") @@ -62,9 +80,15 @@ def _token_info(token): @bp.route("/", methods=["GET", "POST"]) def index(): + """Главная страница: организация, инфраструктура, сервисы, форма токена. + + GET — рендерит страницу с данными из API. + POST — обрабатывает форму токена (action=save/clear).""" + + # Токены: env — из переменной окружения, user — из cookie или формы env_token = current_app.config["NUBES_API_TOKEN"] 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 error = None @@ -75,7 +99,10 @@ def index(): action = request.form.get("action", "") def _tmpl(**overrides): - """Собрать контекст шаблона со всеми обязательными переменными.""" + """Хелпер: собрать контекст для render_template. + + Гарантирует что ВСЕ переменные переданы, даже если API не отработал. + overrides — можно переопределить отдельные переменные.""" ctx = { "organization": org, "error": error, @@ -92,48 +119,60 @@ def index(): ctx.update(overrides) return render_template("index.html", **ctx) + # Обработка action=clear: удалить cookie с токеном if action == "clear": resp = make_response(redirect("/")) resp.delete_cookie("token") return resp + # Обработка action=save: сохранить токен в cookie (на год) if action == "save": user_token = request.form.get("token", "") active_token = user_token or env_token resp = make_response() - resp.set_cookie("token", user_token, max_age=60*60*24*365) - # redirect to GET - resp.headers["Location"] = "/" + resp.set_cookie("token", user_token, max_age=60*60*24*365) # 1 год + resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории) resp.status_code = 302 return resp + # Загрузка данных из API (только если есть токен) services = [] instances = [] instance_groups = {} config = {} stand = "?" if active_token: + # create_client — автоопределение стенда + HttpClient result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) if result: client, endpoint = result stand = stand_name(endpoint) try: + # Организация — инстанс с serviceId=19 org = get_organization(client) + + # Все сервисы — сортировка по svcId raw_svc = get_services(client) 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} raw_inst = get_instances(client) instances = [i for i in raw_inst if i.get("explainedStatus") not in ("deleted", "not created") 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", ""))) - # group by service type + # Группировка по типу сервиса для левой колонки UI instance_groups = {} for i in instances: svc_name = i.get("svc", "Прочее") instance_groups.setdefault(svc_name, []).append(i) + + # Конфиг из config.yaml + версия приложения config = dict(load_config() or {}) config["VERSION"] = current_app.config["VERSION"] except Exception as e: @@ -141,6 +180,7 @@ def index(): else: error = "Токен невалиден или просрочен" + # После POST (save/clear) — редирект на GET, чтобы F5 не переотправлял форму if request.method == "POST": return redirect("/") return _tmpl() @@ -148,49 +188,75 @@ def index(): @bp.route("/api/operations/") 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"] user_token = request.cookies.get("token") or "" active_token = user_token or env_token + try: + # Автоопределение стенда + HttpClient result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) if not result: return jsonify({"error": "Не удалось определить стенд"}), 500 client, endpoint = result + + # Детали сервиса: список операций (modify, delete, suspend, ...) detail = get_service_detail(client, svc_id) ops = detail.get("operations", []) + + # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) 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} + + # Все инстансы из облака instances = get_instances(client) nubes_uids = {i["instanceUid"] for i in instances} svc_instances = [] - cloud_names = set() + cloud_names = set() # для дедупликации с tracker + # --- Шаг 1: Cloud-first — фильтруем по префиксу autotest- --- for i in instances: display_name = str(i.get("displayName", "") or "") if not display_name.startswith(AUTOTEST_PREFIX): - continue + continue # не наш инстанс if i.get("explainedStatus") in ("deleted",): - continue + continue # удалённые не показываем item = dict(i) item["status"] = _resolve_instance_status(item, tracked_by_uid.get(i.get("instanceUid"))) svc_instances.append(item) cloud_names.add(display_name) - # tracker — только временный fallback, если cloud ещё не отдал конкретный autotest-инстанс + # --- Шаг 2: Tracker-fallback --- + # Инстансы из трекера, которых ещё нет в облаке (только что созданы). + # Но если displayName уже есть в cloud_names — дубль, пропускаем. for uid, t in tracked_by_uid.items(): if uid not in nubes_uids: display_name = str(t.get("displayName", "") or "") if display_name in cloud_names: - continue + continue # дубль — облако уже показало этот инстанс svc_instances.append({ "instanceUid": uid, "displayName": display_name, "serviceId": svc_id, "svc": detail.get("svc", ""), "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: return jsonify({"error": str(e)}), 500 diff --git a/site/templates/index.html b/site/templates/index.html index 9bf5857..cba1cf5 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -41,6 +41,19 @@ + +
Nubes v{{ config.VERSION }} @@ -111,6 +124,30 @@