Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06137d6082 | ||
|
|
3293330cec | ||
|
|
38148516e6 | ||
|
|
539cfd7b1a | ||
|
|
9fda5f042c | ||
|
|
bfe26f07d8 | ||
|
|
171342f6d0 | ||
|
|
4915d17555 | ||
|
|
9a801e6bff | ||
|
|
0a62eb3c58 | ||
|
|
78e1c4dc3b | ||
|
|
dcd978808d | ||
|
|
ca2367724e | ||
|
|
4a0cfcaa61 | ||
|
|
529d577b84 | ||
|
|
a398892766 | ||
|
|
4d3ce74c03 | ||
|
|
c1e7c4f9aa | ||
|
|
23688da90a | ||
|
|
06eb463088 | ||
|
|
5065019ffd | ||
|
|
58b823a260 |
+76
-18
@@ -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
@@ -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'ы — каждый отвечает за свою группу маршрутов
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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;">';
|
||||||
|
|
||||||
// Заголовок
|
// Заголовок
|
||||||
|
|||||||
@@ -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,'&').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||||||
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
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,'&')"
|
||||||
|
".replace(/\\\\/g,'\\\\\\\\')"
|
||||||
|
".replace(/'/g,\"\\\\'\")"
|
||||||
|
".replace(/\"/g,'"');"
|
||||||
|
)
|
||||||
|
assert expected in content
|
||||||
Reference in New Issue
Block a user