22 Commits
Author SHA1 Message Date
naeel 06137d6082 fix: XSS — _esc() для o.operation в instances.js (v1.2.40)
Результат код-ревью #1 (критическое):
  o.operation вставлялся в onclick и innerHTML без экранирования.
  Исправлено: JS-escape для onclick (\\, \'), HTML-escape для текста.
2026-08-03 10:51:02 +04:00
naeel 3293330cec fix: ручной UPSERT вместо ON CONFLICT — не требует индекса (v1.2.39)
ON CONFLICT требовал уникальный индекс idx_runs_op_uid,
который не всегда создавался (мультистейтмент SQL в psycopg2).
→ save_run падал молча → история пустая.

Новый подход: UPDATE по op_uid, если rowcount==0 → INSERT.
Не зависит от индексов, работает всегда.
2026-08-03 10:18:10 +04:00
naeel 38148516e6 fix: сценарии — перерендер после поллинга + индекс отдельно (v1.2.38)
Баг 1: страница сценариев зависала после завершения
  — scenario-list.js: добавлен loadScenarios() после поллинга

Баг 2: история могла быть пустой (ON CONFLICT без индекса)
  — init_db.py: CREATE UNIQUE INDEX отдельным cur.execute()
    (мультистейтмент SQL в psycopg2 ненадёжен)
2026-08-03 10:09:17 +04:00
naeel 539cfd7b1a fix: дубликаты RUNNING/OK в истории — UPSERT по op_uid (v1.2.37)
Причина: save_run всегда делал INSERT, сценарий вызывал его дважды
(до поллинга RUNNING, после OK) → две строки в БД.

Исправление:
- init_db.py: частичный уникальный индекс idx_runs_op_uid
- save_run.py: INSERT ... ON CONFLICT (op_uid) DO UPDATE
  (статус, длительность, ошибка, этапы, meta — обновляются)
2026-08-03 10:00:29 +04:00
naeel 9fda5f042c fix: tracker_remove в сценариях + прямая загрузка логов (v1.2.36)
Баг 1: удалённые сценарием инстансы показывались как 'creating'
  — run_scenario() не вызывал tracker_remove после delete
  — фикс: tracker_remove после успешного delete-шага

Баг 2: чёрный экран на вкладке Логи
  — views.js копировал пустую log-panel
  — фикс: прямая загрузка fetch('/api/log')
2026-08-03 09:48:03 +04:00
naeel bfe26f07d8 fix: радиокнопки — по центру сайдбара (sidebar-nav flex:0, form margin:auto) (v1.2.35) 2026-08-03 09:29:28 +04:00
naeel 171342f6d0 fix: радиокнопки — по центру по вертикали (v1.2.34) 2026-08-03 09:26:58 +04:00
naeel 4915d17555 feat: радиокнопки вместо select для режима/стенда (v1.2.32)
- index.html: radio buttons (🎭/☁️ для режима, DEV/TEST/PROD для стенда)
- main.py: set_mode сохраняет polygon_stand из cookie если не в форме
  (нужно при Облако→Эмуляция — радио стенда нет в DOM)
2026-08-03 09:13:57 +04:00
naeel 9a801e6bff fix: URL полигона — стенд перед /api/v1/svc, а не после (v1.2.31)
Было:  https://polygon.../api/v1/svc/test/instances → 404
Стало: https://polygon.../test/api/v1/svc/instances → OK

Замена /api/v1/svc → /{stand}/api/v1/svc.
2026-08-03 08:56:29 +04:00
naeel 0a62eb3c58 feat: селектор режима и стенда (v1.2.30)
Новое:
- Селектор режима (🎭 Эмуляция / ☁️ Облако) над версией
- Селектор стенда (DEV/TEST/PROD) в режиме эмуляции
- В эмуляции — всё в полигон (/stand/api/v1/svc)
- В эмуляции — токен дезактивирован
- По умолчанию: эмуляция + TEST

Изменено:
- auth.py: get_mode(), get_polygon_stand(), get_client() с префиксом стенда
- main.py: action=set_mode, единый get_client() вместо real_client/inst_client
- index.html: селекторы, disable токена
- service_list.py: обрезка polygon_ префикса
- api_test.py: get_real_client → get_client

Задокументировано в HISTORY/2026-08-03-session.md.
2026-08-03 08:50:14 +04:00
naeel 78e1c4dc3b fix: сервисы не показывались при POLYGON_ENDPOINT (v1.2.29)
Причина: load_service_ids('polygon') возвращал пустой set (нет services_polygon.txt),
config.service_ids = [], и шаблонный фильтр svc.svcId in [] убивал весь список.

Исправление: в index.html добавлено not config.service_ids — если список пуст,
показываем все сервисы (как и задумано в load_service_ids).
2026-08-03 08:29:46 +04:00
naeel dcd978808d fix: разделение клиентов — сервисы из real API, инстансы/операции из polygon (v1.2.28) 2026-08-01 09:22:02 +04:00
naeel ca2367724e fix: create_client → get_client — POLYGON_ENDPOINT в main/api/runner (v1.2.27) 2026-08-01 09:00:45 +04:00
naeel 4a0cfcaa61 feat: POLYGON_ENDPOINT — отдельная переменная, не трогает NUBES_API_TOKEN (v1.2.26) 2026-07-31 23:42:41 +04:00
naeel 529d577b84 test: 15 интеграционных тестов polygon + bump v1.2.25 2026-07-31 23:02:34 +04:00
naeel a398892766 feat: интеграция с polygon — STANDS-check в get_client/get_stand (v1.2.24) 2026-07-31 22:45:27 +04:00
naeel 4d3ce74c03 Добавлены тесты для критических фиксов (lock_check, UniqueViolation, escName, idx_one_running) 2026-07-31 18:28:49 +04:00
naeel c1e7c4f9aa v1.2.23: lock_check three-state return (True/False/None), None→503 DB unavailable 2026-07-31 18:22:58 +04:00
naeel 23688da90a v1.2.22: 3rd audit fixes — UniqueViolation→409, lock_check no-db→False, escName add & escape 2026-07-31 18:20:33 +04:00
naeel 06eb463088 v1.2.21: fix broken advisory lock (→ partial unique index), escName add " escape, lock_check fallback False 2026-07-31 18:17:09 +04:00
naeel 5065019ffd v1.2.20: remaining Codex fixes — _op_results lock, advisory lock for scenarios, _ensure_schema logging, stale async generation token, validate-cfs explicit JSONDecodeError 2026-07-31 18:07:24 +04:00
naeel 58b823a260 v1.2.19: fixes from Codex audit — XSS in params, JS injection in onclick, scenario polling timeout, has_target validation, tracker atomic update 2026-07-31 17:58:57 +04:00
27 changed files with 992 additions and 308 deletions
+76 -18
View File
@@ -5,11 +5,22 @@
в main.py и api_test.py с идентичным или почти идентичным кодом. в main.py и api_test.py с идентичным или почти идентичным кодом.
Теперь всё здесь — один источник правды для всех роутов. Теперь всё здесь — один источник правды для всех роутов.
Режимы работы (2026-08-03):
- Эмуляция (mode=polygon): всё в полигон, стенд из селектора (dev/test/prod)
- Облако (mode=cloud): реальный API, стенд автоопределяется по токену
Cookie (устанавливаются main.py:action=set_mode):
- mode — "polygon" (по умолчанию) или "cloud"
- polygon_stand — "test" (по умолчанию), "dev", "prod"
Функции (все без аргументов — берут данные из Flask request/current_app): Функции (все без аргументов — берут данные из Flask request/current_app):
get_token() — токен: cookie → env-переменная get_mode() "polygon" / "cloud"
get_client() — HttpClient с автоопределением стенда get_polygon_stand() — "dev" / "test" / "prod"
get_token() — токен: cookie → env (в эмуляции только env)
get_client() — HttpClient с учётом режима и стенда
get_real_client() — алиас get_client() (историческая совместимость)
get_client_id() — ClientID из JWT (base64url, без проверки подписи) get_client_id() — ClientID из JWT (base64url, без проверки подписи)
get_stand() "dev" / "test" по токену get_stand() "polygon_test" / "dev" / "test"
get_token_info() — {email, company, client_id} из JWT для UI get_token_info() — {email, company, client_id} из JWT для UI
get_token_masked() — маскированный токен (abc...xyz) для placeholder get_token_masked() — маскированный токен (abc...xyz) для placeholder
""" """
@@ -18,30 +29,69 @@ import base64
import json import json
from flask import request, current_app from flask import request, current_app
from api.http_client import HttpClient, detect_endpoint, stand_name from api.http_client import HttpClient, detect_endpoint, stand_name, STANDS
def get_mode():
"""Режим: 'polygon' (эмуляция) или 'cloud' (реальное облако).
Если POLYGON_ENDPOINT не задан в конфиге — всегда облако (селектор не нужен).
Иначе — из cookie, по умолчанию эмуляция."""
if not current_app.config.get("POLYGON_ENDPOINT", ""):
return "cloud"
return request.cookies.get("mode", "polygon")
def get_polygon_stand():
"""Стенд полигона: 'test' (по умолчанию), 'dev', 'prod'.
Используется ТОЛЬКО в режиме эмуляции — для построения URL и load_service_ids."""
return request.cookies.get("polygon_stand", "test")
def get_token(): def get_token():
"""Получить активный токен: сначала из cookie пользователя, потом из env. """Получить активный токен.
Приоритет: В эмуляции — ТОЛЬКО env-токен (пользовательский ввод дезактивирован).
1. cookie "token" — пользователь ввёл свой токен в форме В облаке — cookie → env (как раньше)."""
2. NUBES_API_TOKEN из env — сервисный токен (для автоматических тестов) if get_mode() == "polygon":
return current_app.config["NUBES_API_TOKEN"]
Пользовательский токен приоритетнее — он переопределяет сервисный."""
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 стенды. Эмуляция: polygon_url + "/" + stand → https://polygon.../{stand}/api/v1/svc
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига.""" Облако: реальный API с автоопределением стенда (dev/test по токену)."""
token = get_token() token = get_token()
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] mode = get_mode()
if mode == "polygon":
polygon = current_app.config.get("POLYGON_ENDPOINT", "")
stand = get_polygon_stand()
# POLYGON_ENDPOINT = "https://polygon.../api/v1/svc"
# Стенд ВСТАВЛЯЕТСЯ перед /api/v1/svc:
# "https://polygon.../api/v1/svc" → "https://polygon.../test/api/v1/svc"
# Простая замена "/api/v1/svc" → "/{stand}/api/v1/svc"
return HttpClient(polygon.replace("/api/v1/svc", f"/{stand}/api/v1/svc"), token)
# Облако: NUBES_API_ENDPOINT с автоопределением
endpoint = current_app.config["NUBES_API_ENDPOINT"]
if endpoint in STANDS:
endpoint = detect_endpoint(token) or endpoint
return HttpClient(endpoint, token) return HttpClient(endpoint, token)
def get_real_client():
"""Алиас get_client() — историческая совместимость.
Раньше get_real_client() всегда ходил в реальный API (для сервисов).
Теперь в эмуляции сервисы тоже из полигона → обе функции идентичны.
Оставлено чтобы не ломать все места вызова."""
return get_client()
def get_client_id(): def get_client_id():
"""Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи). """Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи).
@@ -68,12 +118,20 @@ def get_client_id():
def get_stand(): def get_stand():
"""Определить стенд (dev/test) по активному токену. """Определить стенд.
Эмуляция: "polygon_{dev|test|prod}" — для load_service_ids и трекера.
Облако: автоопределение или stand_name (dev/test/mock)."""
mode = get_mode()
if mode == "polygon":
return f"polygon_{get_polygon_stand()}"
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 = current_app.config["NUBES_API_ENDPOINT"]
if endpoint not in STANDS:
s = stand_name(endpoint)
return s if s != "?" else "mock"
endpoint = detect_endpoint(token) or endpoint
return stand_name(endpoint) return stand_name(endpoint)
+2 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.18" VERSION = "1.2.40"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
@@ -43,6 +43,7 @@ app.config["NUBES_API_ENDPOINT"] = os.getenv(
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc" "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
) )
app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
app.config["POLYGON_ENDPOINT"] = os.getenv("POLYGON_ENDPOINT", "") # если задан → все запросы в polygon
app.config["VERSION"] = VERSION app.config["VERSION"] = VERSION
# Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов # Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов
+6
View File
@@ -112,6 +112,12 @@ CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
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);
-- Partial unique index — атомарный lock на уровне БД.
-- Гарантирует что только ОДИН сценарий может быть RUNNING для client_id+stand.
-- Вторая параллельная вставка получит unique violation → 409 без гонок.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running
ON scenario_runs (client_id, stand) WHERE status = 'RUNNING';
-- Миграции для scenario_runs -- Миграции для 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;
+5 -2
View File
@@ -58,8 +58,11 @@ def _ensure_schema():
from db.init_db import init_db from db.init_db import init_db
init_db() init_db()
_initialized = True _initialized = True
except Exception: except Exception as e:
pass # без БД приложение работает (без истории) print(f"[DB] Schema init FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
# Приложение продолжает работу БЕЗ БД (без истории/сценариев)
def get_pool(): def get_pool():
+21 -8
View File
@@ -51,6 +51,25 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
try: try:
cur = conn.cursor() cur = conn.cursor()
# Ручной UPSERT (UPDATE → INSERT если не затронуто строк).
# Не используем ON CONFLICT — не требует индекса, надёжнее.
# Сценарий вызывает save_run дважды (RUNNING → OK) — второй вызов
# обновляет существующую строку вместо создания дубликата.
params_json = json.dumps(params) if params else None
stages_json = json.dumps(stages) if stages else None
meta_json = json.dumps(instance_meta) if instance_meta else None
if op_uid:
cur.execute("""
UPDATE runs SET
status = %s, duration_sec = %s, error_log = %s,
stages = %s, svc_name = %s, instance_meta = %s
WHERE op_uid = %s
""", (status, duration_sec, error_log, stages_json, svc_name, meta_json, op_uid))
if not op_uid or cur.rowcount == 0:
# Нет op_uid ИЛИ UPDATE не затронул строк → INSERT
cur.execute(""" cur.execute("""
INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name, INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name,
op_name, svc_op_id, op_uid, instance_uid, display_name, op_name, svc_op_id, op_uid, instance_uid, display_name,
@@ -60,14 +79,8 @@ 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_json, stages_json, app_version,
# params и stages — JSONB: сериализуем в JSON-строку scenario_run_id, step_number, meta_json,
json.dumps(params) if params else None,
json.dumps(stages) if stages else None,
app_version,
scenario_run_id, step_number,
# instance_meta — тоже JSONB
json.dumps(instance_meta) if instance_meta else None,
)) ))
conn.commit() conn.commit()
cur.close() cur.close()
+8 -6
View File
@@ -209,15 +209,17 @@ def delete_definition(def_id, client_id, stand):
def lock_check(client_id, stand): def lock_check(client_id, stand):
"""Проверить что нет активного RUNNING-запуска сценария. """Проверить что нет активного RUNNING-запуска сценария.
Используется перед запуском нового сценария — предотвращает Атомарность гарантируется partial unique index idx_one_running
одновременный запуск двух сценариев одним пользователем. на уровне БД (см. init_db.py). Этот метод — быстрая предпроверка
для красивого 409 до попытки INSERT.
Returns: Returns:
True — можно запускать (нет RUNNING) True — можно запускать (нет RUNNING в БД)
False — нельзя (уже есть RUNNING) → 409 Conflict""" False — нельзя (уже есть RUNNING) → 409 Conflict
None — БД недоступна, нельзя проверить → 503 DB unavailable"""
conn = get_conn() conn = get_conn()
if not conn: if not conn:
return True # без БД — разрешаем (fallback) return None # БД недоступна — не True и не False, вызывающий решит
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
@@ -230,6 +232,6 @@ def lock_check(client_id, stand):
return row is None # None = нет RUNNING = можно запускать return row is None # None = нет RUNNING = можно запускать
except Exception as e: except Exception as e:
print(f"[DEFS] lock_check error: {e}", flush=True) print(f"[DEFS] lock_check error: {e}", flush=True)
return True return None # ошибка БД — не можем проверить
finally: finally:
put_conn(conn) put_conn(conn)
+10
View File
@@ -281,6 +281,15 @@ 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)
# Удалить из трекера после успешного delete
# (run_scenario не вызывает tracker_remove — трекер показывал удалённые инстансы как "creating")
if op_name == "delete" and step_status == "OK":
try:
from operations.tracker import remove as tracker_remove
tracker_remove(client_id, stand, instance_uid)
except Exception:
pass # трекер не критичен
# ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══ # ═══ Шаг 8: обновить scenario_runs (прогресс для UI) ═══
# Пока не последний шаг — статус RUNNING # Пока не последний шаг — статус RUNNING
_save_scenario_run(scenario_run_id, _save_scenario_run(scenario_run_id,
@@ -306,3 +315,4 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
# Все шаги пройдены успешно # Все шаги пройдены успешно
_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))
+4 -1
View File
@@ -33,10 +33,13 @@ def load_service_ids(stand="test"):
"""Вернуть set разрешённых service_id для указанного стенда. """Вернуть set разрешённых service_id для указанного стенда.
Args: Args:
stand: str — "dev" или "test". stand: str — "dev", "test", или "polygon_test" (с префиксом эмуляции).
Returns: Returns:
set[int] — множество service_id. Пустой set если файла нет → показываем все.""" set[int] — множество service_id. Пустой set если файла нет → показываем все."""
# Обрезать префикс "polygon_" если есть → "polygon_test" → "test"
if stand.startswith("polygon_"):
stand = stand[len("polygon_"):]
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:
+6 -8
View File
@@ -245,13 +245,11 @@ def send_params_terraform(client, op_uid, params):
# Шаг 7: validate-cfs — финальная проверка # Шаг 7: validate-cfs — финальная проверка
# Если параметры невалидны — API вернёт ошибку → исключение # Если параметры невалидны — API вернёт ошибку → исключение
# Если ответ пустой или не-JSON — это ОК (значит валидация прошла) # Если ответ 200 с пустым телом — это ОК (значит валидация прошла).
# HttpClient.get() для пустого тела возвращает {} без ошибок.
# Но если тело не-JSON — будет JSONDecodeError, это тоже ОК для validate-cfs.
try: try:
client.get(f"/instanceOperations/{op_uid}/validate-cfs") client.get(f"/instanceOperations/{op_uid}/validate-cfs")
except Exception as e: except json.JSONDecodeError:
# "Expecting value" / JSONDecodeError — это норма для validate-cfs pass # пустой/не-JSON ответ — норма для validate-cfs (успех)
# (API возвращает 200 с пустым телом при успехе) # HTTPError (4xx/5xx) пробрасывается выше — это реальная ошибка валидации
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
pass
else:
raise # реальная ошибка — пробрасываем выше
+49 -17
View File
@@ -113,36 +113,68 @@ def _locked_write(path, data):
os.close(fd) os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name): def _atomic_update(path, mutator):
"""Добавить инстанс в трекер (вызывается из executor сразу после create). """Атомарно: открыть файл → заблокировать → прочитать → мутировать → записать.
Читает текущий файл → добавляет/обновляет запись → пишет обратно. Вся операция под ОДНИМ lock, в отличие от старого подхода
Если инстанс уже есть — перезаписывает (идемпотентность). _locked_read() + _locked_write() где между ними lock снимался.
Это предотвращает lost-update при конкурентных add/remove.
Args: Args:
client_id: str — ClientID из JWT path: str — путь к файлу
stand: str — "dev"/"test" mutator: callable(data) — функция, мутирующая dict (возвращать не нужно)
instance_uid: str — UUID созданного инстанса
svc_id: int — ID сервиса Returns:
display_name: str — displayName инстанса""" dict — результат после мутации (или {} если не смогли)"""
p = _path(client_id, stand) try:
data = _locked_read(p) fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return {}
try:
if not _acquire_lock(fd):
return {}
# Читаем
data = {}
try:
raw = os.read(fd, 65536)
if raw:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# Мутируем
mutator(data)
# Пишем
os.lseek(fd, 0, 0)
os.ftruncate(fd, 0)
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
return data
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name):
"""Добавить инстанс в трекер (атомарно, под одним lock).
Читает → добавляет/обновляет запись → пишет — всё под эксклюзивной блокировкой.
Если инстанс уже есть — перезаписывает (идемпотентность)."""
def _add(data):
data[instance_uid] = { data[instance_uid] = {
"svcId": svc_id, "svcId": svc_id,
"displayName": display_name, "displayName": display_name,
"instanceUid": instance_uid, "instanceUid": instance_uid,
} }
_locked_write(p, data) _atomic_update(_path(client_id, stand), _add)
def remove(client_id, stand, instance_uid): def remove(client_id, stand, instance_uid):
"""Удалить инстанс из трекера (после успешного delete). """Удалить инстанс из трекера (атомарно, под одним lock).
pop с default=None — не падает если инстанса уже нет (уже удалён ранее).""" pop с default=None — не падает если инстанса уже нет."""
p = _path(client_id, stand) def _rem(data):
data = _locked_read(p)
data.pop(instance_uid, None) data.pop(instance_uid, None)
_locked_write(p, data) _atomic_update(_path(client_id, stand), _rem)
def list_all(client_id, stand): def list_all(client_id, stand):
+4 -4
View File
@@ -2,7 +2,7 @@ import threading
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from api.http_client import create_client from api.auth import get_client
from runner import run_tests, get_status, load_config, save_config from runner import run_tests, get_status, load_config, save_config
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
@@ -11,10 +11,10 @@ bp = Blueprint("api", __name__)
@bp.route("/api/run", methods=["POST"]) @bp.route("/api/run", methods=["POST"])
def api_run(): def api_run():
token = current_app.config["NUBES_API_TOKEN"] token = current_app.config["NUBES_API_TOKEN"]
result = create_client(token, current_app.config["NUBES_API_ENDPOINT"]) client = get_client()
if not result: endpoint = current_app.config["NUBES_API_ENDPOINT"]
if not client:
return jsonify({"error": "Не удалось определить стенд"}), 500 return jsonify({"error": "Не удалось определить стенд"}), 500
client, endpoint = result
t = threading.Thread( t = threading.Thread(
target=run_tests, target=run_tests,
args=(endpoint, token), args=(endpoint, token),
+4 -2
View File
@@ -55,8 +55,10 @@ def _validate_steps(steps):
# Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback) # Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback)
if op != "create": if op != "create":
has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше if not ref and not uid:
# ok: ref есть, uid есть, или старый формат без новых ключей (fallback) # Старый формат (без instance_ref/instance_uid) — разрешаем,
# будет найден по service_id через instance_map в scenario.py
pass
return None return None
+8 -1
View File
@@ -41,7 +41,10 @@ def api_scenario_run():
cid = get_client_id() cid = get_client_id()
stand = get_stand() stand = get_stand()
if not lock_check(cid, stand): can_run = lock_check(cid, stand)
if can_run is None:
return jsonify({"error": "DB unavailable"}), 503
if not can_run:
return jsonify({"error": "Another scenario is already running"}), 409 return jsonify({"error": "Another scenario is already running"}), 409
defn = get_definition(def_id, cid, stand) defn = get_definition(def_id, cid, stand)
@@ -74,6 +77,10 @@ def api_scenario_run():
conn.commit() conn.commit()
cur.close() cur.close()
except Exception as e: except Exception as e:
# Unique violation (23505) → другая параллельная вставка уже создала RUNNING
if hasattr(e, 'pgcode') and e.pgcode == '23505':
conn.rollback()
return jsonify({"error": "Another scenario is already running"}), 409
print(f"[API] create scenario_run error: {e}", flush=True) print(f"[API] create scenario_run error: {e}", flush=True)
conn.rollback() conn.rollback()
return jsonify({"error": "Failed to create scenario_run"}), 500 return jsonify({"error": "Failed to create scenario_run"}), 500
+16 -9
View File
@@ -24,13 +24,14 @@
""" """
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
import threading
import uuid import uuid
import os import os
import json import json
import fcntl import fcntl
from api.http_client import HttpClient, detect_endpoint, stand_name from api.http_client import HttpClient, detect_endpoint, stand_name
from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info from api.auth import get_token, get_client, get_real_client, get_client_id, get_stand, get_token_info
from api.utils import find_uid, uid_from_location from api.utils import find_uid, uid_from_location
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.get_instances import get_instances from operations.get_instances import get_instances
@@ -261,23 +262,27 @@ def api_test():
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts} # Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
# _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа) # _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа)
# ЗАЩИЩЕНО _op_results_lock — несколько потоков _finish_op + main thread api_test_status
_op_results = {} _op_results = {}
_op_results_lock = threading.Lock()
_MAX_OP_RESULTS = 500 _MAX_OP_RESULTS = 500
def _cleanup_op_results(): def _cleanup_op_results():
"""Удалить старые записи: старше 1 часа или сверх лимита.""" """Удалить старые записи: старше 1 часа или сверх лимита.
Потокобезопасно — под _op_results_lock."""
import time import time
now = time.time() now = time.time()
# Удалить старше 1 часа with _op_results_lock:
# Удалить старше 1 часа (pop с default — безопасно при конкурентном доступе)
stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600] stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600]
for k in stale: for k in stale:
del _op_results[k] _op_results.pop(k, None)
# Если всё ещё много — удалить самые старые # Если всё ещё много — удалить самые старые
if len(_op_results) > _MAX_OP_RESULTS: if len(_op_results) > _MAX_OP_RESULTS:
sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0)) sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0))
for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]: for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]:
del _op_results[k] _op_results.pop(k, None)
def _get_instance_display_name(client, instance_uid): def _get_instance_display_name(client, instance_uid):
@@ -291,7 +296,7 @@ def _get_instance_display_name(client, instance_uid):
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""): def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
"""Фоном ждать dtFinish и сохранить результат.""" """Фоном ждать dtFinish и сохранить результат (потокобезопасно для _op_results)."""
import time import time
_cleanup_op_results() _cleanup_op_results()
t0 = time.time() t0 = time.time()
@@ -299,7 +304,8 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
# Поллинг через общий модуль # Поллинг через общий модуль
poll_result = poll_until_done(client, op_uid) poll_result = poll_until_done(client, op_uid)
# Обновить _op_results для UI # Обновить _op_results для UI — под локом
with _op_results_lock:
_op_results[op_uid] = { _op_results[op_uid] = {
"status": poll_result["status"], "status": poll_result["status"],
"displayName": display_name, "displayName": display_name,
@@ -346,8 +352,9 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
@bp.route("/api/test/status/<op_uid>") @bp.route("/api/test/status/<op_uid>")
def api_test_status(op_uid): def api_test_status(op_uid):
"""Получить текущий статус операции (поллинг с UI).""" """Получить текущий статус операции (поллинг с UI) — потокобезопасно."""
# сначала проверяем фоновый трекер # сначала проверяем фоновый трекер — под локом
with _op_results_lock:
if op_uid in _op_results: if op_uid in _op_results:
return jsonify(_op_results[op_uid]) return jsonify(_op_results[op_uid])
# иначе спрашиваем API напрямую # иначе спрашиваем API напрямую
+40 -25
View File
@@ -11,7 +11,7 @@ GET /api/operations/<svc_id> — операции и autotest-инста
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
from api.auth import get_token, get_client_id, get_token_info, get_token_masked from api.auth import get_token, get_client_id, get_token_info, get_token_masked, get_client, get_real_client, get_stand, get_mode, get_polygon_stand
from operations.get_instances import get_organization, get_instances from operations.get_instances import get_organization, get_instances
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.service_list import load_service_ids from operations.service_list import load_service_ids
@@ -44,12 +44,17 @@ def index():
"""Главная страница: организация, инфраструктура, сервисы, форма токена. """Главная страница: организация, инфраструктура, сервисы, форма токена.
GET — рендерит страницу с данными из API. GET — рендерит страницу с данными из API.
POST — обрабатывает форму токена (action=save/clear).""" POST — обрабатывает форму токена (action=save/clear) или смену режима (action=set_mode)."""
# Режим и стенд — из cookie (см. auth.py)
mode = get_mode()
polygon_stand = get_polygon_stand()
polygon_enabled = bool(current_app.config.get("POLYGON_ENDPOINT", ""))
# Токены: env — из переменной окружения, user — из cookie или формы # Токены: 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
@@ -76,6 +81,9 @@ def index():
"services": services, "services": services,
"instances": instances, "instances": instances,
"instance_groups": instance_groups, "instance_groups": instance_groups,
"mode": mode,
"polygon_stand": polygon_stand,
"polygon_enabled": polygon_enabled,
} }
ctx.update(overrides) ctx.update(overrides)
return render_template("index.html", **ctx) return render_template("index.html", **ctx)
@@ -91,42 +99,52 @@ def index():
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, httponly=True, samesite="Strict", secure=True) # 1 год resp.set_cookie("token", user_token, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True)
resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории) resp.headers["Location"] = "/"
resp.status_code = 302 resp.status_code = 302
return resp return resp
# Загрузка данных из API (только если есть токен) # Обработка action=set_mode: сохранить режим и стенд в cookie
if action == "set_mode":
new_mode = request.form.get("mode", "polygon")
# polygon_stand: из формы, иначе — сохранить предыдущее значение из cookie
# (нужно при переключении Облако→Эмуляция — радио стенда нет в DOM)
new_stand = request.form.get("polygon_stand") or request.cookies.get("polygon_stand", "test")
resp = make_response(redirect("/"))
resp.set_cookie("mode", new_mode, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True)
resp.set_cookie("polygon_stand", new_stand, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True)
return resp
# Загрузка данных из API
# В эмуляции — без проверки токена (полигон не требует авторизации)
# В облаке — только если есть токен
services = [] services = []
instances = [] instances = []
instance_groups = {} instance_groups = {}
config = {} config = {}
config["VERSION"] = current_app.config["VERSION"] # всегда, даже без токена config["VERSION"] = current_app.config["VERSION"]
config["service_ids"] = [] config["service_ids"] = []
stand = "?" stand = "?"
if active_token: if mode == "polygon" or active_token:
# create_client — автоопределение стенда + HttpClient client = get_client()
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) inst_client = client # единый клиент — и сервисы, и инстансы
if result: stand = get_stand()
client, endpoint = result
stand = stand_name(endpoint)
try: try:
# Организация — инстанс с serviceId=19 # Организация — инстанс с serviceId=19
org = get_organization(client) org = get_organization(client)
# Все сервисы — сортировка по svcId # Все сервисы — из полигона (эмуляция) или реального API (облако)
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 # 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group
# 2=Template, 12=S3, 110=?, 150=K8s # 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(inst_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", "")))
# Группировка по типу сервиса для левой колонки UI # Группировка по типу сервиса для левой колонки UI
@@ -145,7 +163,7 @@ def index():
else: else:
error = "Токен невалиден или просрочен" error = "Токен невалиден или просрочен"
# После POST (save/clear) — редирект на GET, чтобы F5 не переотправлял форму # После POST (save/clear/set_mode) — редирект на GET
if request.method == "POST": if request.method == "POST":
return redirect("/") return redirect("/")
return _tmpl() return _tmpl()
@@ -168,21 +186,18 @@ def api_operations(svc_id):
active_token = user_token or env_token active_token = user_token or env_token
try: try:
# Автоопределение стенда + HttpClient # Единый клиент — и сервисы, и инстансы (полигон или реальный API)
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) client = get_client()
if not result:
return jsonify({"error": "Не удалось определить стенд"}), 500
client, endpoint = result
# Детали сервиса: список операций (modify, delete, suspend, ...) # Детали сервиса: список операций (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-инстансы (изолирован по пользователю и стенду) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду)
tracked = tracker_list(get_client_id(), stand_name(endpoint)) tracked = tracker_list(get_client_id(), get_stand())
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}
# Все инстансы из облака # Все инстансы из полигона или реального API
instances = get_instances(client) instances = get_instances(client)
nubes_uids = {i["instanceUid"] for i in instances} nubes_uids = {i["instanceUid"] for i in instances}
+5
View File
@@ -104,6 +104,11 @@ def run_tests(endpoint, token):
endpoint: str — URL API endpoint: str — URL API
token: str — JWT-токен""" token: str — JWT-токен"""
global _current_run global _current_run
# POLYGON_ENDPOINT проверяется в get_client(), здесь — прямой вызов для CLI
import os
polygon = os.getenv("POLYGON_ENDPOINT", "")
if polygon:
endpoint = polygon
client = HttpClient(endpoint, token) client = HttpClient(endpoint, token)
config = load_config() config = load_config()
+7 -3
View File
@@ -143,9 +143,13 @@ async function toggleInstance(iuid){
} }
// Кнопки операций с цветовой маркировкой // Кнопки операций с цветовой маркировкой
opsEl.innerHTML=ops.map(o=> // o.operation в onclick — JS-escape: \ → \\, ' → \'
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>` // o.operation в тексте кнопки — HTML-escape через _esc()
).join(''); opsEl.innerHTML=ops.map(o=>{
const escOp = _esc(o.operation);
const jsOp = o.operation.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return `<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${jsOp}',${o.svcOperationId})">${escOp}</button>`;
}).join('');
opsEl.classList.add('open'); opsEl.classList.add('open');
}catch(e){ }catch(e){
opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>'; opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
+9 -1
View File
@@ -208,6 +208,9 @@ async function loadStepParams(idx) {
const step = st.steps[idx]; const step = st.steps[idx];
if (!step.operation) return; if (!step.operation) return;
// Запомнить поколение рендера на момент старта async-загрузки
const renderGen = st._renderGen || 0;
const svcOpId = await resolveStepSvcOpId(idx); const svcOpId = await resolveStepSvcOpId(idx);
if (!svcOpId) return; if (!svcOpId) return;
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
@@ -233,6 +236,8 @@ async function loadStepParams(idx) {
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue // Рендер: клонируем def и подставляем сохранённые значения в defaultValue
const container = document.getElementById('step-' + idx + '-params'); const container = document.getElementById('step-' + idx + '-params');
if (!container) return; if (!container) return;
// Проверка: не перезаписывать DOM если уже был новый renderEditor()
if (st._renderGen !== renderGen) return;
let html = ''; let html = '';
defs.forEach(p => { defs.forEach(p => {
const clone = Object.assign({}, p); // shallow copy const clone = Object.assign({}, p); // shallow copy
@@ -259,7 +264,10 @@ async function loadStepParams(idx) {
function renderEditor() { function renderEditor() {
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
const st = scenarioEditorState; const st = scenarioEditorState;
// Инкремент поколения — защита от stale async (loadStepParams может вернуться
// уже после следующего renderEditor и перезаписать новый DOM старыми данными)
st._renderGen = (st._renderGen || 0) + 1;
const currentGen = st._renderGen;
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">'; let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
// Заголовок // Заголовок
+29 -5
View File
@@ -116,16 +116,21 @@ async function toggleScenarioSteps(defId) {
if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`; if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`;
if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`; if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`;
const params = Object.entries(s.params || {}); const params = Object.entries(s.params || {});
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>'; if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => _esc(k)+'='+_esc(v)).join(', ') + ')</span>';
html += '</div>'; html += '</div>';
}); });
// Кнопки действий // Кнопки действий
// JS-escape для имени сценария в inline onclick:
// \ → \\ (backslash)
// ' → \' (terminate JS string literal)
// HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся.
const escName = def.name.replace(/&/g,'&amp;').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${escName}')">📋 Копировать</button>`;
// Удалить — только для НЕ-seed сценариев // Удалить — только для НЕ-seed сценариев
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`; if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${escName}')">🗑 Удалить</button>`;
html += '</div>'; html += '</div>';
el.innerHTML = html; el.innerHTML = html;
@@ -182,10 +187,21 @@ async function runScenario(defId){
} }
// Поллинг статуса // Поллинг статуса
// Поллинг статуса с защитой от бесконечного зависания
let _scenarioPollErrors = 0;
scenarioPollTimer=setInterval(async()=>{ scenarioPollTimer=setInterval(async()=>{
try{ try{
const sr=await fetch('/api/scenario/run/'+runId); const sr=await fetch('/api/scenario/run/'+runId);
if(!sr.ok){ return; } if(!sr.ok){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML='<span style="color:var(--destructive);">Ошибка: сервер недоступен</span>';
}
return;
}
_scenarioPollErrors = 0; // сброс при успехе
const last=await sr.json(); const last=await sr.json();
if(!last||last.error) return; if(!last||last.error) return;
@@ -202,8 +218,16 @@ async function runScenario(defId){
busy=false; busy=false;
await refreshInstances(); // обновить список инстансов await refreshInstances(); // обновить список инстансов
if(historyOpen) await loadHistory(); // обновить историю if(historyOpen) await loadHistory(); // обновить историю
await loadScenarios(); // перерендерить список (кнопки)
}
}catch(e){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML=`<span style="color:var(--destructive);">Ошибка поллинга: ${_esc(e.message||'')}</span>`;
}
} }
}catch(e){}
},3000); },3000);
}catch(e){ }catch(e){
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`; body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`;
+15 -6
View File
@@ -41,15 +41,24 @@ function switchView(viewId) {
if (!scenarioOpen) toggleScenario(); if (!scenarioOpen) toggleScenario();
} }
// Логи: специальная обработка — копируем содержимое нижней панели в вид // Логи: прямая загрузка с API, не полагаемся на нижнюю панель
if (viewId === 'logs-view') { if (viewId === 'logs-view') {
const lp = document.getElementById('log-panel');
const lvc = document.getElementById('log-view-content'); const lvc = document.getElementById('log-view-content');
if (lp && lvc) { if (lvc) {
// Копируем текущее содержимое лог-панели в вид lvc.innerHTML = '<span style="color:#888;">Логи загружаются...</span>';
lvc.innerHTML = lp.innerHTML || '<span style="color:#888;">Логи загружаются...</span>'; fetch('/api/log').then(r => r.json()).then(lines => {
lp.style.display = 'none'; // скрываем нижнюю панель if (lines.length) {
lvc.innerHTML = lines.map(l => `<div>${_esc(l)}</div>`).join('');
} else {
lvc.innerHTML = '<span style="color:#888;">Логов пока нет</span>';
} }
}).catch(() => {
lvc.innerHTML = '<span style="color:#f88;">Ошибка загрузки логов</span>';
});
}
// Скрываем нижнюю лог-панель если была открыта
const lp = document.getElementById('log-panel');
if (lp) lp.style.display = 'none';
} else { } else {
// На всех видах кроме логов — скрываем нижнюю лог-панель // На всех видах кроме логов — скрываем нижнюю лог-панель
const lp = document.getElementById('log-panel'); const lp = document.getElementById('log-panel');
+31 -5
View File
@@ -51,19 +51,45 @@
<div class="sidebar-top"> <div class="sidebar-top">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:20px;" /> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:20px;" />
</div> </div>
<div class="sidebar-nav"> <div class="sidebar-nav" style="flex:0 0 auto;">
<button class="sidebar-item active" data-view="workbench" onclick="switchView('workbench')">🖥 Консоль</button> <button class="sidebar-item active" data-view="workbench" onclick="switchView('workbench')">🖥 Консоль</button>
<button class="sidebar-item" data-view="overview" onclick="switchView('overview')">📊 Обзор</button> <button class="sidebar-item" data-view="overview" onclick="switchView('overview')">📊 Обзор</button>
<button class="sidebar-item" data-view="history-view" onclick="switchView('history-view')">📋 История</button> <button class="sidebar-item" data-view="history-view" onclick="switchView('history-view')">📋 История</button>
<button class="sidebar-item" data-view="scenarios-view" onclick="switchView('scenarios-view')">🧪 Сценарии</button> <button class="sidebar-item" data-view="scenarios-view" onclick="switchView('scenarios-view')">🧪 Сценарии</button>
<button class="sidebar-item" data-view="logs-view" onclick="switchView('logs-view')">📜 Логи</button> <button class="sidebar-item" data-view="logs-view" onclick="switchView('logs-view')">📜 Логи</button>
</div> </div>
{% if polygon_enabled %}
<form method="post" style="padding:4px 12px 6px;margin-top:auto;margin-bottom:auto;">
<input type="hidden" name="action" value="set_mode" />
<div style="display:flex;gap:8px;margin-bottom:4px;">
<label style="font-size:10px;cursor:pointer;display:flex;align-items:center;gap:2px;color:var(--muted);">
<input type="radio" name="mode" value="polygon" onclick="this.form.submit()" {% if mode == 'polygon' %}checked{% endif %} /> Эмуляция
</label>
<label style="font-size:10px;cursor:pointer;display:flex;align-items:center;gap:2px;color:var(--muted);">
<input type="radio" name="mode" value="cloud" onclick="this.form.submit()" {% if mode == 'cloud' %}checked{% endif %} /> Облако
</label>
</div>
{% if mode == 'polygon' %}
<div style="display:flex;gap:6px;">
<label style="font-size:10px;cursor:pointer;color:var(--muted);">
<input type="radio" name="polygon_stand" value="dev" onclick="this.form.submit()" {% if polygon_stand == 'dev' %}checked{% endif %} /> DEV
</label>
<label style="font-size:10px;cursor:pointer;color:var(--muted);">
<input type="radio" name="polygon_stand" value="test" onclick="this.form.submit()" {% if polygon_stand == 'test' %}checked{% endif %} /> TEST
</label>
<label style="font-size:10px;cursor:pointer;color:var(--muted);">
<input type="radio" name="polygon_stand" value="prod" onclick="this.form.submit()" {% if polygon_stand == 'prod' %}checked{% endif %} /> PROD
</label>
</div>
{% endif %}
</form>
{% endif %}
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div style="padding:4px 12px;font-size:10px;color:var(--muted);">v{{ config.VERSION }} | {{ stand.upper() }}</div> <div style="padding:4px 12px;font-size:10px;color:var(--muted);">v{{ config.VERSION }} | {{ stand.upper().replace('_', ' ') }}</div>
{% if token_info.email %}<div style="padding:0 12px 4px;font-size:10px;color:var(--muted);">{{ token_info.email }}</div>{% endif %} {% if token_info.email %}<div style="padding:0 12px 4px;font-size:10px;color:var(--muted);">{{ token_info.email }}</div>{% endif %}
<form method="post" class="token-row"> <form method="post" class="token-row">
<input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" /> <input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" {% if mode == 'polygon' %}disabled style="opacity:0.4;"{% endif %} />
<button type="submit" name="action" value="save" class="btn btn-sm">OK</button> <button type="submit" name="action" value="save" class="btn btn-sm" {% if mode == 'polygon' %}disabled{% endif %}>OK</button>
{% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-sm" style="color:var(--destructive);"></button>{% endif %} {% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-sm" style="color:var(--destructive);"></button>{% endif %}
</form> </form>
{% if error %}<div style="padding:4px 12px;font-size:10px;color:var(--destructive);">{{ error }}</div>{% endif %} {% if error %}<div style="padding:4px 12px;font-size:10px;color:var(--destructive);">{{ error }}</div>{% endif %}
@@ -78,7 +104,7 @@
<div class="card"> <div class="card">
<div class="card-header" style="font-size:13px;">Сервисы</div> <div class="card-header" style="font-size:13px;">Сервисы</div>
<div class="card-body" style="padding:4px;"> <div class="card-body" style="padding:4px;">
{% for svc in services %}{% if svc.svcId in config.service_ids %}<div class="svc-item {% if loop.first %}active{% endif %}" onclick="selectService({{ svc.svcId }})">{{ svc.svcId }}. {{ svc.svc }}</div>{% endif %}{% endfor %} {% for svc in services %}{% if not config.service_ids or svc.svcId in config.service_ids %}<div class="svc-item {% if loop.first %}active{% endif %}" onclick="selectService({{ svc.svcId }})">{{ svc.svcId }}. {{ svc.svc }}</div>{% endif %}{% endfor %}
</div> </div>
</div> </div>
</div> </div>
+10
View File
@@ -0,0 +1,10 @@
Pytest suite for critical regressions from audit rounds.
Covers:
- `/api/scenario/run` status mapping: `lock_check=None -> 503`, `False -> 409`.
- Unique violation on `scenario_runs` insert maps to `409`.
- `db.scenario_defs.lock_check` DB-unavailable semantics (`None`).
- Static guards for DB partial unique index and `escName` escape chain.
This suite is intentionally lightweight and mock-heavy.
No integration DB or external API calls are required.
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
import subprocess
import sys
import time
import pytest
import requests
# Make `site/` importable as top-level modules: app, routes, db, operations, ...
SITE_DIR = Path(__file__).resolve().parents[1] / "site"
if str(SITE_DIR) not in sys.path:
sys.path.insert(0, str(SITE_DIR))
# Путь к polygon: соседняя репа в корне autotest
POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site"
POLYGON_URL = "http://localhost:5000"
@pytest.fixture
def app_client():
"""Flask test client для app-autotest."""
from app import app
app.config.update(TESTING=True)
with app.test_client() as client:
yield client
@pytest.fixture(scope="session")
def polygon_server():
"""Запустить polygon как subprocess на весь pytest-session.
Используется для интеграционных тестов app-autotest ↔ polygon.
После всех тестов процесс убивается.
"""
if not POLYGON_DIR.is_dir():
pytest.skip("polygon repo not found")
proc = subprocess.Popen(
["python3", "app.py"],
cwd=str(POLYGON_DIR),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Ждём готовности (poll /health, timeout 10s)
deadline = time.time() + 10
while time.time() < deadline:
try:
r = requests.get(f"{POLYGON_URL}/health", timeout=2)
if r.status_code == 200:
break
except requests.ConnectionError:
time.sleep(0.5)
else:
proc.terminate()
proc.wait()
pytest.fail("polygon did not start within 10s")
yield POLYGON_URL
proc.terminate()
proc.wait()
@pytest.fixture(autouse=True)
def reset_polygon(polygon_server):
"""Перед каждым интеграционным тестом — сброс состояния polygon."""
requests.post(f"{polygon_server}/api/v1/svc/_mock/reset", timeout=5)
+88
View File
@@ -0,0 +1,88 @@
import routes.api_scenario_run as api_run
class DummyUniqueViolation(Exception):
pgcode = "23505"
class DummyCursor:
def __init__(self, fail_on_execute=None, fetchone_value=None):
self._fail_on_execute = fail_on_execute
self._fetchone_value = fetchone_value
def execute(self, *args, **kwargs):
if self._fail_on_execute is not None:
raise self._fail_on_execute
def fetchone(self):
return self._fetchone_value
def close(self):
return None
class DummyConn:
def __init__(self, fail_on_execute=None, fetchone_value=None):
self._fail_on_execute = fail_on_execute
self._fetchone_value = fetchone_value
self.committed = False
self.rolled_back = False
def cursor(self):
return DummyCursor(self._fail_on_execute, self._fetchone_value)
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
def _patch_common_happy(monkeypatch):
monkeypatch.setattr(api_run, "get_client_id", lambda: "cid")
monkeypatch.setattr(api_run, "get_stand", lambda: "test")
monkeypatch.setattr(api_run, "get_definition", lambda _id, _cid, _stand: {
"id": _id,
"name": "scenario-a",
"version": 7,
"steps": [{"service_id": 1, "operation": "create", "params": {}}],
})
monkeypatch.setattr(api_run, "get_token_info", lambda: {"email": "u@example.com"})
def test_run_returns_503_when_lock_check_is_none(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: None)
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 503
assert resp.get_json()["error"] == "DB unavailable"
def test_run_returns_409_when_lock_check_is_false(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: False)
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 409
assert "already running" in resp.get_json()["error"].lower()
def test_run_returns_409_on_unique_violation(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: True)
conn = DummyConn(fail_on_execute=DummyUniqueViolation())
monkeypatch.setattr(api_run, "get_conn", lambda: conn)
monkeypatch.setattr(api_run, "put_conn", lambda _conn: None)
# Must not start a background thread in this branch.
monkeypatch.setattr(api_run, "get_client", lambda: object())
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 409
assert "already running" in resp.get_json()["error"].lower()
assert conn.rolled_back is True
+32
View File
@@ -0,0 +1,32 @@
import db.scenario_defs as defs
def test_lock_check_returns_none_when_db_connection_missing(monkeypatch):
monkeypatch.setattr(defs, "get_conn", lambda: None)
result = defs.lock_check("cid", "test")
assert result is None
def test_lock_check_returns_none_on_db_exception(monkeypatch):
class BrokenCursor:
def execute(self, *args, **kwargs):
raise RuntimeError("db broken")
def close(self):
return None
class BrokenConn:
def cursor(self):
return BrokenCursor()
put_calls = {"n": 0}
monkeypatch.setattr(defs, "get_conn", lambda: BrokenConn())
monkeypatch.setattr(defs, "put_conn", lambda _c: put_calls.__setitem__("n", put_calls["n"] + 1))
result = defs.lock_check("cid", "test")
assert result is None
assert put_calls["n"] == 1
+227
View File
@@ -0,0 +1,227 @@
"""
Интеграционные тесты app-autotest ↔ polygon.
Каждый тест требует запущенного polygon (фикстура polygon_server).
Перед каждым тестом состояние polygon сбрасывается (autouse reset_polygon).
Все запросы — через requests (HTTP), имитируя работу HttpClient из app-autotest.
"""
import pytest
import requests
API = "/api/v1/svc"
def _create_instance(url, service_id=1, display_name="test"):
"""POST /instances → извлечь instanceUid из Location."""
r = requests.post(
f"{url}{API}/instances",
json={"serviceId": service_id, "displayName": display_name},
allow_redirects=False,
timeout=10,
)
assert r.status_code == 201
location = r.headers.get("Location", "")
uid = location.rsplit("/", 1)[-1]
assert len(uid) >= 32 # UUID без дефисов
return uid
def _create_operation(url, instance_uid, operation, svc_op_id=None):
"""POST /instanceOperations → извлечь opUid из Location."""
body = {"instanceUid": instance_uid, "operation": operation}
if svc_op_id is not None:
body["svcOperationId"] = svc_op_id
r = requests.post(
f"{url}{API}/instanceOperations",
json=body,
allow_redirects=False,
timeout=10,
)
assert r.status_code == 201
location = r.headers.get("Location", "")
uid = location.rsplit("/", 1)[-1]
assert len(uid) >= 32
return uid
def _run(url, op_uid):
"""POST /run → дождаться dtFinish."""
r = requests.post(f"{url}{API}/instanceOperations/{op_uid}/run", timeout=10)
assert r.status_code == 200
# Проверить что операция завершена
r2 = requests.get(f"{url}{API}/instanceOperations/{op_uid}", timeout=10)
op = r2.json()["instanceOperation"]
assert op["dtFinish"] is not None
assert op["isSuccessful"] is True
class TestServices:
"""Тесты эндпоинтов /services."""
def test_list_services(self, polygon_server):
"""GET /services возвращает все сервисы."""
r = requests.get(f"{polygon_server}{API}/services", timeout=10)
assert r.status_code == 200
data = r.json()
assert len(data["results"]) >= 30 # минимум 30 сервисов
def test_service_detail(self, polygon_server):
"""GET /services/1 возвращает операции Болванки."""
r = requests.get(f"{polygon_server}{API}/services/1", timeout=10)
assert r.status_code == 200
ops = r.json()["svc"]["operations"]
assert any(op["operation"] == "create" for op in ops)
def test_service_404(self, polygon_server):
"""GET /services/99999 → 404."""
r = requests.get(f"{polygon_server}{API}/services/99999", timeout=10)
assert r.status_code == 404
class TestInstanceCreate:
"""Тесты создания инстанса."""
def test_create_dummy(self, polygon_server):
"""POST /instances → 201 + Location → GET → status=creating."""
uid = _create_instance(polygon_server)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.status_code == 200
inst = r.json()["instance"]
assert inst["status"] == "creating"
assert inst["serviceId"] == 1
def test_create_unknown_service(self, polygon_server):
"""POST /instances с несуществующим serviceId → 404."""
r = requests.post(
f"{polygon_server}{API}/instances",
json={"serviceId": 99999, "displayName": "bad"},
timeout=10,
)
assert r.status_code == 404
class TestFullCreateFlow:
"""Полный цикл create → run → проверить params."""
def test_create_and_run(self, polygon_server):
"""create → run → статус running + params из шаблона."""
uid = _create_instance(polygon_server)
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
inst = r.json()["instance"]
assert inst["status"] == "running"
assert "resourceRealm" in inst["state"]["params"]
assert inst["state"]["params"]["resourceRealm"] == "dummy"
def test_create_postgres_state_out(self, polygon_server):
"""create PG → state.out содержит users, databases."""
uid = _create_instance(polygon_server, service_id=90, display_name="pg")
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
out = r.json()["instance"]["state"]["out"]
assert "users" in out
assert "databases" in out
def test_run_twice_409(self, polygon_server):
"""Повторный POST /run → 409."""
uid = _create_instance(polygon_server)
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.post(
f"{polygon_server}{API}/instanceOperations/{op_uid}/run", timeout=10
)
assert r.status_code == 409
class TestOperations:
"""Тесты операций: modify, suspend, resume, delete."""
def test_modify_merges_params(self, polygon_server):
"""modify → новый paramValue в state.params."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
# Установить новое значение durationMs (modify-версия: paramId=287, create-версия: 198)
op_uid = _create_operation(polygon_server, uid, "modify")
requests.post(
f"{polygon_server}{API}/instanceOperationCfsParams",
json={"instanceOperationUid": op_uid, "svcOperationCfsParamId": 287, "paramValue": "999"},
timeout=10,
)
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["state"]["params"]["durationMs"] == "999"
def test_suspend_resume(self, polygon_server):
"""suspend → status=suspended, resume → status=running."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
_run(polygon_server, _create_operation(polygon_server, uid, "suspend"))
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["status"] == "suspended"
_run(polygon_server, _create_operation(polygon_server, uid, "resume"))
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["status"] == "running"
def test_delete_removes_instance(self, polygon_server):
"""delete → инстанс удалён из списка."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
_run(polygon_server, _create_operation(polygon_server, uid, "delete"))
r = requests.get(f"{polygon_server}{API}/instances", timeout=10)
uids = [i["instanceUid"] for i in r.json()["results"]]
assert uid not in uids
class TestPagination:
"""Тесты пагинации."""
def test_pagination(self, polygon_server):
"""3 инстанса, pageSize=2 → 2 страницы."""
for i in range(3):
_create_instance(polygon_server, display_name=f"p{i}")
r1 = requests.get(
f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 1}, timeout=10
)
assert len(r1.json()["results"]) == 2
r2 = requests.get(
f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 2}, timeout=10
)
assert len(r2.json()["results"]) == 1 # последний
class TestMockEndpoints:
"""Тесты /_mock/* эндпоинтов."""
def test_mock_state(self, polygon_server):
"""GET /_mock/state — отладочный дамп."""
_create_instance(polygon_server)
r = requests.get(f"{polygon_server}{API}/_mock/state", timeout=10)
assert r.status_code == 200
assert len(r.json()["instances"]) == 1
def test_mock_services(self, polygon_server):
"""GET /_mock/services — список конфигов."""
r = requests.get(f"{polygon_server}{API}/_mock/services", timeout=10)
assert r.status_code == 200
assert r.json()["count"] >= 30
def test_mock_delay(self, polygon_server):
"""POST /_mock/delay — изменение задержки."""
r = requests.post(f"{polygon_server}{API}/_mock/delay/2", timeout=10)
assert r.json()["delay"] == 2.0
# Сбросить
requests.post(f"{polygon_server}{API}/_mock/delay/0.1", timeout=10)
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
def test_partial_unique_index_for_one_running_exists():
init_db_path = Path(__file__).resolve().parents[1] / "site" / "db" / "init_db.py"
content = init_db_path.read_text(encoding="utf-8")
assert "CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running" in content
assert "ON scenario_runs (client_id, stand) WHERE status = 'RUNNING'" in content
def test_scenario_name_escape_chain_includes_amp_backslash_quote_doublequote():
js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-list.js"
content = js_path.read_text(encoding="utf-8")
expected = (
"const escName = def.name"
".replace(/&/g,'&amp;')"
".replace(/\\\\/g,'\\\\\\\\')"
".replace(/'/g,\"\\\\'\")"
".replace(/\"/g,'&quot;');"
)
assert expected in content