v1.0.89: detailed code comments — all Python files + index.html architecture overview
This commit is contained in:
@@ -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
|
||||
import uuid
|
||||
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 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/<int: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"]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user