v1.1.50: scenario_definitions DB+CRUD, seed, runner from DB, 409 lock, UI definitions, drop config.yaml scenarios
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ from routes.api_test import bp as api_test_bp
|
|||||||
from routes.api_scenario import bp as api_scenario_bp
|
from routes.api_scenario import bp as api_scenario_bp
|
||||||
|
|
||||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
||||||
VERSION = "1.1.49"
|
VERSION = "1.1.50"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc")
|
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc")
|
||||||
|
|||||||
@@ -26,13 +26,3 @@ services:
|
|||||||
- name: delete
|
- name: delete
|
||||||
enabled: false
|
enabled: false
|
||||||
params: {}
|
params: {}
|
||||||
|
|
||||||
scenarios:
|
|
||||||
dummy_test:
|
|
||||||
steps:
|
|
||||||
- svc: dummy
|
|
||||||
op: create
|
|
||||||
params:
|
|
||||||
durationMs: "5000"
|
|
||||||
- svc: dummy
|
|
||||||
op: delete
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
@@ -80,9 +81,65 @@ CREATE TABLE IF NOT EXISTS scenario_runs (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
||||||
ON scenario_runs (client_id, stand, created_at DESC);
|
ON scenario_runs (client_id, stand, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
|
||||||
|
ON scenario_runs (client_id, stand, status);
|
||||||
|
|
||||||
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
|
||||||
|
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
|
||||||
|
|
||||||
|
-- Таблица определений сценариев — редактируются без редеплоя
|
||||||
|
CREATE TABLE IF NOT EXISTS scenario_definitions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
stand VARCHAR(16) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
steps JSONB NOT NULL DEFAULT '[]',
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by VARCHAR(128)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique
|
||||||
|
ON scenario_definitions (client_id, stand, LOWER(name));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_defs_active
|
||||||
|
ON scenario_definitions (client_id, stand, is_active);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_scenarios():
|
||||||
|
"""Импорт дефолтных сценариев из scenario_seed.yaml при первом старте."""
|
||||||
|
try:
|
||||||
|
import yaml, os
|
||||||
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
seed_list = data.get("scenarios", [])
|
||||||
|
if not seed_list:
|
||||||
|
return
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
cur = conn.cursor()
|
||||||
|
for s in seed_list:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_definitions (client_id, stand, name, steps)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name)) DO NOTHING
|
||||||
|
""", (s.get("client_id", ""), s.get("stand", ""), s["name"], json.dumps(s.get("steps", []))))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
put_conn(conn)
|
||||||
|
print("[DB] Seed scenarios OK", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DB] Seed scenarios error: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова."""
|
"""Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова."""
|
||||||
if not os.getenv("DB_USER"):
|
if not os.getenv("DB_USER"):
|
||||||
@@ -107,3 +164,6 @@ def init_db():
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|
||||||
|
# Seed сценариев — только при первом старте (INSERT ON CONFLICT DO NOTHING)
|
||||||
|
_seed_scenarios()
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
CRUD для таблицы scenario_definitions.
|
||||||
|
|
||||||
|
Функции:
|
||||||
|
list_definitions(client_id, stand) — список активных
|
||||||
|
get_definition(def_id, cid, stand) — один
|
||||||
|
create_definition(cid, stand, name, steps, updated_by) → id
|
||||||
|
update_definition(def_id, cid, stand, name, steps, version, updated_by) → ok|conflict
|
||||||
|
delete_definition(def_id, cid, stand) — мягкое (is_active=false)
|
||||||
|
lock_check(cid, stand) — есть ли RUNNING запуск → 409 если да
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
|
|
||||||
|
def list_definitions(client_id, stand):
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, steps, version, is_active, created_at, updated_at
|
||||||
|
FROM scenario_definitions
|
||||||
|
WHERE client_id = %s AND stand = %s AND is_active = TRUE
|
||||||
|
ORDER BY name
|
||||||
|
""", (client_id, stand))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(zip(cols, r))
|
||||||
|
d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None
|
||||||
|
d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] list error: {e}", flush=True)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def get_definition(def_id, client_id, stand):
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, steps, version, is_active, created_at, updated_at, updated_by
|
||||||
|
FROM scenario_definitions
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (def_id, client_id, stand))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
d = dict(zip(cols, row))
|
||||||
|
d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None
|
||||||
|
d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None
|
||||||
|
return d
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] get error: {e}", flush=True)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def create_definition(client_id, stand, name, steps, updated_by=""):
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_definitions (client_id, stand, name, steps, updated_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name))
|
||||||
|
DO UPDATE SET steps = EXCLUDED.steps, version = scenario_definitions.version + 1,
|
||||||
|
is_active = TRUE, updated_at = NOW(), updated_by = EXCLUDED.updated_by
|
||||||
|
RETURNING id, version
|
||||||
|
""", (client_id, stand, name, json.dumps(steps), updated_by))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return {"id": row[0], "version": row[1]}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] create error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""):
|
||||||
|
"""Возвращает dict {ok, version} или {conflict: True}."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return {"error": "no db"}
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_definitions
|
||||||
|
SET name = %s, steps = %s, version = version + 1,
|
||||||
|
updated_at = NOW(), updated_by = %s
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s AND version = %s
|
||||||
|
RETURNING version
|
||||||
|
""", (name, json.dumps(steps), updated_by, def_id, client_id, stand, version))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
if row:
|
||||||
|
return {"ok": True, "version": row[0]}
|
||||||
|
return {"conflict": True}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] update error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_definition(def_id, client_id, stand):
|
||||||
|
"""Мягкое удаление."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_definitions
|
||||||
|
SET is_active = FALSE, updated_at = NOW()
|
||||||
|
WHERE id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (def_id, client_id, stand))
|
||||||
|
ok = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
return ok
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] delete error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_check(client_id, stand):
|
||||||
|
"""Проверить нет ли активного RUNNING запуска. True = можно запускать."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return True # без БД — разрешаем
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id FROM scenario_runs
|
||||||
|
WHERE client_id = %s AND stand = %s AND status = 'RUNNING'
|
||||||
|
LIMIT 1
|
||||||
|
""", (client_id, stand))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
return row is None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DEFS] lock_check error: {e}", flush=True)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
+28
-115
@@ -1,22 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Scenario runner — символические коды → API → шаги → результат.
|
Scenario runner — шаги из БД → API → результат.
|
||||||
|
|
||||||
Формат сценария в config.yaml:
|
Формат шага:
|
||||||
scenarios:
|
{ "service_id": 1, "operation": "create", "params": { "durationMs": "5000" } }
|
||||||
dummy_test:
|
|
||||||
steps:
|
|
||||||
- svc: dummy # символическое имя (из services: в config.yaml)
|
|
||||||
op: create
|
|
||||||
params:
|
|
||||||
durationMs: "5000"
|
|
||||||
- svc: dummy
|
|
||||||
op: delete
|
|
||||||
|
|
||||||
Для каждого шага:
|
Для каждого шага:
|
||||||
1. service имя → svc_id (из config.yaml services)
|
1. service_id уже в шаге
|
||||||
2. operation имя → svcOperationId (из GET /services/{id})
|
2. operation имя → svcOperationId (из GET /services/{id})
|
||||||
3. param коды → numeric IDs (из GET /instanceOperations/default/{opId})
|
3. param коды → numeric IDs (из GET /instanceOperations/default/{opId})
|
||||||
4. create: новый инстанс; остальные: переиспользовать инстанс этого сервиса
|
4. create: новый инстанс; остальные: переиспользовать по service_id
|
||||||
5. Выполнить через API (как в api_test)
|
5. Выполнить через API (как в api_test)
|
||||||
6. Сохранить результат в runs + scenario_runs
|
6. Сохранить результат в runs + scenario_runs
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +17,6 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from runner import load_config
|
|
||||||
from operations.get_services import get_service_detail
|
from operations.get_services import get_service_detail
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
from db.save_run import save_run
|
from db.save_run import save_run
|
||||||
@@ -52,25 +43,8 @@ def _uid_from_location(loc):
|
|||||||
return parts[-1] if parts[-1] else None
|
return parts[-1] if parts[-1] else None
|
||||||
|
|
||||||
|
|
||||||
def load_scenarios():
|
|
||||||
"""Загрузить словарь сценариев из config.yaml."""
|
|
||||||
cfg = load_config() or {}
|
|
||||||
return cfg.get("scenarios", {})
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_service(config, svc_name):
|
|
||||||
"""Найти svc_id по символическому имени сервиса из config.yaml services."""
|
|
||||||
for s in config.get("services", []):
|
|
||||||
if s.get("name") == svc_name:
|
|
||||||
return s
|
|
||||||
raise ValueError(f"Service '{svc_name}' not found in config.yaml services")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_params(cfs_params, symbolic_params):
|
def _resolve_params(cfs_params, symbolic_params):
|
||||||
"""Символические коды → {numeric_id: value}.
|
"""Символические коды → {numeric_id: value}."""
|
||||||
cfs_params — список из GET /instanceOperations/default/{opId}
|
|
||||||
symbolic_params — dict {"durationMs": "5000"}
|
|
||||||
"""
|
|
||||||
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
||||||
result = {}
|
result = {}
|
||||||
for code, val in symbolic_params.items():
|
for code, val in symbolic_params.items():
|
||||||
@@ -102,95 +76,39 @@ def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=Non
|
|||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
def _create_scenario_run(client_id, stand, user_email, scenario_name, total_steps, app_version):
|
def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name):
|
||||||
"""Создать запись в scenario_runs, вернуть ID."""
|
"""Главный исполнитель сценария. steps — список шагов из БД."""
|
||||||
conn = get_conn()
|
|
||||||
if not conn:
|
|
||||||
return None
|
|
||||||
rid = None
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name,
|
|
||||||
status, current_step, total_steps, app_version)
|
|
||||||
VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (client_id, stand, user_email, scenario_name, total_steps, app_version))
|
|
||||||
rid = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[SCENARIO] create_scenario_run error: {e}", flush=True)
|
|
||||||
conn.rollback()
|
|
||||||
finally:
|
|
||||||
put_conn(conn)
|
|
||||||
return rid
|
|
||||||
|
|
||||||
|
|
||||||
def _update_scenario_total(scenario_run_id, total_steps):
|
|
||||||
"""Обновить total_steps после того как стало известно точное число шагов."""
|
|
||||||
conn = get_conn()
|
|
||||||
if not conn:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("UPDATE scenario_runs SET total_steps = %s WHERE id = %s", (total_steps, scenario_run_id))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[SCENARIO] update_total error: {e}", flush=True)
|
|
||||||
conn.rollback()
|
|
||||||
finally:
|
|
||||||
put_conn(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def run_scenario(client, scenario_name, client_id, stand, user_email, app_version, scenario_run_id):
|
|
||||||
"""Главный исполнитель сценария. Вызывается в фоновом потоке.
|
|
||||||
scenario_run_id — уже созданная запись (из api_scenario_run)."""
|
|
||||||
config = load_config() or {}
|
|
||||||
scenarios = config.get("scenarios", {})
|
|
||||||
scenario = scenarios.get(scenario_name)
|
|
||||||
if not scenario:
|
|
||||||
_save_scenario_run(scenario_run_id, "FAIL", 0, 0, f"Scenario '{scenario_name}' not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
steps = scenario.get("steps", [])
|
|
||||||
total = len(steps)
|
total = len(steps)
|
||||||
if not steps:
|
|
||||||
_save_scenario_run(scenario_run_id, "FAIL", 0, 0, "No steps")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Обновить total_steps (стало известно только сейчас)
|
|
||||||
_update_scenario_total(scenario_run_id, total)
|
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария)
|
instance_map = {} # service_id → instanceUid (переиспользование внутри сценария)
|
||||||
|
|
||||||
for i, step in enumerate(steps):
|
for i, step in enumerate(steps):
|
||||||
step_num = i + 1
|
step_num = i + 1
|
||||||
svc_name = step.get("svc", "")
|
svc_id = step.get("service_id")
|
||||||
op_name = step.get("op", "")
|
op_name = (step.get("operation") or "").strip()
|
||||||
symbolic_params = step.get("params", {})
|
symbolic_params = step.get("params", {})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Сервис
|
if not isinstance(svc_id, int) or svc_id <= 0:
|
||||||
svc_info = _resolve_service(config, svc_name)
|
raise ValueError(f"Invalid service_id: {svc_id!r}")
|
||||||
svc_id = svc_info["service_id"]
|
if not op_name:
|
||||||
|
raise ValueError("Empty operation name")
|
||||||
|
|
||||||
# 2. Операция
|
# 1. Операция
|
||||||
detail = get_service_detail(client, svc_id)
|
detail = get_service_detail(client, svc_id)
|
||||||
ops = detail.get("operations", [])
|
ops = detail.get("operations", [])
|
||||||
op = next((o for o in ops if o.get("operation") == op_name), None)
|
op = next((o for o in ops if o.get("operation") == op_name), None)
|
||||||
if not op:
|
if not op:
|
||||||
raise ValueError(f"Operation '{op_name}' not found for service {svc_name}")
|
raise ValueError(f"Operation '{op_name}' not found for service {svc_id}")
|
||||||
svc_op_id = op["svcOperationId"]
|
svc_op_id = op["svcOperationId"]
|
||||||
|
|
||||||
# 3. Параметры
|
# 2. Параметры
|
||||||
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
||||||
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||||
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||||
|
|
||||||
# 4. Инстанс
|
# 3. Инстанс
|
||||||
if op_name == "create":
|
if op_name == "create":
|
||||||
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
||||||
descr = f"scenario {scenario_name} step {step_num}"
|
descr = f"scenario {scenario_name} step {step_num}"
|
||||||
@@ -199,20 +117,16 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", ""))
|
instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", ""))
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
raise RuntimeError(f"CREATE: no instanceUid in response: {json.dumps(resp)[:200]}")
|
raise RuntimeError(f"CREATE: no instanceUid in response: {json.dumps(resp)[:200]}")
|
||||||
instance_map[svc_name] = instance_uid
|
instance_map[svc_id] = instance_uid
|
||||||
else:
|
else:
|
||||||
instance_uid = instance_map.get(svc_name)
|
instance_uid = instance_map.get(svc_id)
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
raise RuntimeError(f"No instance for service '{svc_name}' — need CREATE first")
|
raise RuntimeError(f"No instance for service_id {svc_id} — need CREATE first")
|
||||||
|
|
||||||
# 5. Запуск операции
|
# 4. Запуск операции
|
||||||
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name}
|
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name}
|
||||||
op_resp = client.post("/instanceOperations", op_payload)
|
op_resp = client.post("/instanceOperations", op_payload)
|
||||||
op_uid = op_resp.get("instanceOperationUid") or op_resp.get("instanceOperation", {}).get("instanceOperationUid")
|
op_uid = op_resp.get("instanceOperationUid") or _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", ""))
|
||||||
if not op_uid:
|
|
||||||
op_uid = _find_uid(op_resp)
|
|
||||||
if not op_uid:
|
|
||||||
op_uid = _uid_from_location(op_resp.get("_location", ""))
|
|
||||||
if not op_uid:
|
if not op_uid:
|
||||||
raise RuntimeError(f"No opUid in response: {json.dumps(op_resp)[:200]}")
|
raise RuntimeError(f"No opUid in response: {json.dumps(op_resp)[:200]}")
|
||||||
|
|
||||||
@@ -221,7 +135,7 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
_send_params_terraform(client, op_uid, resolved_params)
|
_send_params_terraform(client, op_uid, resolved_params)
|
||||||
client.post(f"/instanceOperations/{op_uid}/run")
|
client.post(f"/instanceOperations/{op_uid}/run")
|
||||||
|
|
||||||
# 5b. Сохранить RUNNING в runs ДО поллинга (шаг не потеряется при ошибке)
|
# 5. Сохранить RUNNING в runs ДО поллинга
|
||||||
step_display = display_name if op_name == "create" else instance_uid
|
step_display = display_name if op_name == "create" else instance_uid
|
||||||
try:
|
try:
|
||||||
save_run(client_id, stand, user_email,
|
save_run(client_id, stand, user_email,
|
||||||
@@ -240,7 +154,7 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
step_error = ""
|
step_error = ""
|
||||||
step_duration = 0
|
step_duration = 0
|
||||||
step_stages = []
|
step_stages = []
|
||||||
op_data = {} # инициализировать на случай TIMEOUT
|
op_data = {}
|
||||||
|
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
@@ -260,8 +174,8 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
break
|
break
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
# 7. Сохранить финальный результат шага в runs
|
# 7. Сохранить финальный результат
|
||||||
svc_final = op_data.get("svc", svc_name) if op_data else svc_name
|
svc_final = op_data.get("svc", "") if op_data else ""
|
||||||
try:
|
try:
|
||||||
save_run(client_id, stand, user_email,
|
save_run(client_id, stand, user_email,
|
||||||
svc_id, svc_final, op_name, svc_op_id,
|
svc_id, svc_final, op_name, svc_op_id,
|
||||||
@@ -278,7 +192,6 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
step_error if step_status != "OK" else None)
|
step_error if step_status != "OK" else None)
|
||||||
|
|
||||||
if step_status != "OK":
|
if step_status != "OK":
|
||||||
# Прервать сценарий при ошибке
|
|
||||||
_save_scenario_run(scenario_run_id, step_status, step_num,
|
_save_scenario_run(scenario_run_id, step_status, step_num,
|
||||||
round(time.time() - t0, 1), step_error)
|
round(time.time() - t0, 1), step_error)
|
||||||
return
|
return
|
||||||
|
|||||||
+141
-30
@@ -1,66 +1,105 @@
|
|||||||
"""
|
"""
|
||||||
API для запуска сценариев автотестирования.
|
API сценариев: CRUD определений + запуск + поллинг.
|
||||||
|
|
||||||
POST /api/scenario/run — запустить сценарий (фон)
|
GET /api/scenarios — список из БД (для UI)
|
||||||
GET /api/scenario/status/<id> — поллинг статуса сценария
|
POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING)
|
||||||
GET /api/scenarios — список доступных сценариев
|
GET /api/scenario/run/<int:run_id> — статус конкретного запуска
|
||||||
GET /api/scenario/history — история запусков сценариев
|
GET /api/scenario/status — последние 10 запусков
|
||||||
|
|
||||||
|
CRUD определений:
|
||||||
|
GET /api/scenario/definitions — список
|
||||||
|
POST /api/scenario/definitions — создать
|
||||||
|
GET /api/scenario/definitions/<id> — один
|
||||||
|
PUT /api/scenario/definitions/<id> — обновить (version → 409)
|
||||||
|
DELETE /api/scenario/definitions/<id> — мягкое удаление
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify, request
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from api.auth import get_client, get_client_id, get_stand, get_token_info
|
from api.auth import get_client, get_client_id, get_stand, get_token_info
|
||||||
from operations.scenario import load_scenarios, run_scenario, _create_scenario_run
|
from operations.scenario import run_scenario
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
|
from db.scenario_defs import (
|
||||||
|
list_definitions, get_definition, create_definition,
|
||||||
|
update_definition, delete_definition, lock_check,
|
||||||
|
)
|
||||||
|
|
||||||
bp = Blueprint("api_scenario", __name__)
|
bp = Blueprint("api_scenario", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Запуск ──────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route("/api/scenarios")
|
@bp.route("/api/scenarios")
|
||||||
def api_scenarios():
|
def api_scenarios():
|
||||||
"""Список доступных сценариев из config.yaml."""
|
"""Список определений сценариев из БД (для UI запуска)."""
|
||||||
try:
|
try:
|
||||||
scenarios = load_scenarios()
|
defs = list_definitions(get_client_id(), get_stand())
|
||||||
result = [{"name": name, "steps": len(s.get("steps", []))} for name, s in scenarios.items()]
|
result = [{"id": d["id"], "name": d["name"], "steps": len(d.get("steps", []))} for d in defs]
|
||||||
return jsonify(sorted(result, key=lambda x: x["name"]))
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/scenario/run", methods=["POST"])
|
@bp.route("/api/scenario/run", methods=["POST"])
|
||||||
def api_scenario_run():
|
def api_scenario_run():
|
||||||
"""Запустить сценарий. Создаёт запись ДО потока, возвращает run_id (202)."""
|
"""Запустить сценарий по definition_id. 409 если уже RUNNING."""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
scenario_name = (data.get("scenario") or "").strip()
|
def_id = data.get("definition_id")
|
||||||
if not scenario_name:
|
if not def_id:
|
||||||
return jsonify({"error": "scenario name is required"}), 400
|
return jsonify({"error": "definition_id is required"}), 400
|
||||||
|
|
||||||
client = get_client()
|
|
||||||
cid = get_client_id()
|
cid = get_client_id()
|
||||||
stand = get_stand()
|
stand = get_stand()
|
||||||
|
|
||||||
|
# Lock 409
|
||||||
|
if not lock_check(cid, stand):
|
||||||
|
return jsonify({"error": "Another scenario is already running"}), 409
|
||||||
|
|
||||||
|
# Загрузить определение
|
||||||
|
defn = get_definition(def_id, cid, stand)
|
||||||
|
if not defn:
|
||||||
|
return jsonify({"error": "Definition not found"}), 404
|
||||||
|
|
||||||
|
steps = defn.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
return jsonify({"error": "Definition has no steps"}), 400
|
||||||
|
|
||||||
|
scenario_name = defn["name"]
|
||||||
user_email = get_token_info().get("email", "")
|
user_email = get_token_info().get("email", "")
|
||||||
app_version = current_app.config.get("VERSION", "")
|
app_version = current_app.config.get("VERSION", "")
|
||||||
|
|
||||||
# Валидация: сценарий существует?
|
# Создать запись ДО потока
|
||||||
scenarios = load_scenarios()
|
conn = get_conn()
|
||||||
if scenario_name not in scenarios:
|
if not conn:
|
||||||
return jsonify({"error": f"Scenario '{scenario_name}' not found"}), 404
|
return jsonify({"error": "no db"}), 500
|
||||||
|
run_id = None
|
||||||
total = len(scenarios[scenario_name].get("steps", []))
|
try:
|
||||||
if total == 0:
|
cur = conn.cursor()
|
||||||
return jsonify({"error": f"Scenario '{scenario_name}' has no steps"}), 400
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name,
|
||||||
# Создать запись ДО потока — вернуть run_id клиенту
|
status, current_step, total_steps, app_version,
|
||||||
run_id = _create_scenario_run(cid, stand, user_email, scenario_name, total, app_version)
|
definition_id, definition_version)
|
||||||
if run_id is None:
|
VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (cid, stand, user_email, scenario_name, len(steps), app_version,
|
||||||
|
def_id, defn["version"]))
|
||||||
|
run_id = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[API] create scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
return jsonify({"error": "Failed to create scenario_run"}), 500
|
return jsonify({"error": "Failed to create scenario_run"}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
# Запуск в фоне
|
# Запуск в фоне
|
||||||
|
client = get_client()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=run_scenario,
|
target=run_scenario,
|
||||||
args=(client, scenario_name, cid, stand, user_email, app_version, run_id),
|
args=(client, steps, cid, stand, user_email, app_version, run_id, scenario_name),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
@@ -73,7 +112,7 @@ def api_scenario_run():
|
|||||||
|
|
||||||
@bp.route("/api/scenario/run/<int:run_id>")
|
@bp.route("/api/scenario/run/<int:run_id>")
|
||||||
def api_scenario_run_status(run_id):
|
def api_scenario_run_status(run_id):
|
||||||
"""Статус конкретного запуска сценария."""
|
"""Статус конкретного запуска."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return jsonify({"error": "no db"}), 500
|
return jsonify({"error": "no db"}), 500
|
||||||
@@ -101,7 +140,7 @@ def api_scenario_run_status(run_id):
|
|||||||
|
|
||||||
@bp.route("/api/scenario/status")
|
@bp.route("/api/scenario/status")
|
||||||
def api_scenario_status():
|
def api_scenario_status():
|
||||||
"""Последний запущенный сценарий (или несколько последних)."""
|
"""Последние 10 запусков."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
@@ -128,3 +167,75 @@ def api_scenario_status():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
put_conn(conn)
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD определений ────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/definitions", methods=["GET", "POST"])
|
||||||
|
def api_definitions():
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
defs = list_definitions(cid, stand)
|
||||||
|
return jsonify(defs)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
return jsonify({"error": "steps must be array"}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = create_definition(cid, stand, name, steps, user)
|
||||||
|
if result is None:
|
||||||
|
return jsonify({"error": "create failed"}), 500
|
||||||
|
return jsonify(result), 201
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/definitions/<int:def_id>", methods=["GET", "PUT", "DELETE"])
|
||||||
|
def api_definition(def_id):
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
d = get_definition(def_id, cid, stand)
|
||||||
|
if not d:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(d)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
version = data.get("version")
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
if not isinstance(version, int):
|
||||||
|
return jsonify({"error": "version (int) is required"}), 400
|
||||||
|
steps = data.get("steps", [])
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
return jsonify({"error": "steps must be array"}), 400
|
||||||
|
user = get_token_info().get("email", "")
|
||||||
|
result = update_definition(def_id, cid, stand, name, steps, version, user)
|
||||||
|
if result.get("conflict"):
|
||||||
|
return jsonify({"error": "Conflict: definition was modified"}), 409
|
||||||
|
if result.get("error"):
|
||||||
|
return jsonify({"error": result["error"]}), 500
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
ok = delete_definition(def_id, cid, stand)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Seed-сценарии — импортируются в БД при первом старте.
|
||||||
|
# INSERT ON CONFLICT DO NOTHING — безопасно для повторных запусков.
|
||||||
|
# После rollout файл можно удалить из репозитория.
|
||||||
|
scenarios:
|
||||||
|
- name: dummy_test
|
||||||
|
client_id: ""
|
||||||
|
stand: ""
|
||||||
|
steps:
|
||||||
|
- service_id: 1
|
||||||
|
operation: create
|
||||||
|
params:
|
||||||
|
durationMs: "5000"
|
||||||
|
- service_id: 1
|
||||||
|
operation: delete
|
||||||
|
params: {}
|
||||||
+5
-5
@@ -491,11 +491,11 @@ async function loadScenarios(){
|
|||||||
if(sr.length){
|
if(sr.length){
|
||||||
html+='<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px;">';
|
html+='<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px;">';
|
||||||
sr.forEach(s=>{
|
sr.forEach(s=>{
|
||||||
html+=`<button class="btn btn-sm" style="font-size:11px;" onclick="runScenario('${_esc(s.name)}')">▶ ${_esc(s.name)} <span style="color:var(--muted);">(${s.steps} шагов)</span></button>`;
|
html+=`<button class="btn btn-sm" style="font-size:11px;" onclick="runScenario(${s.id})">▶ ${_esc(s.name)} <span style="color:var(--muted);">(${s.steps} шагов)</span></button>`;
|
||||||
});
|
});
|
||||||
html+='</div>';
|
html+='</div>';
|
||||||
}else{
|
}else{
|
||||||
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в config.yaml</span>';
|
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в БД</span>';
|
||||||
}
|
}
|
||||||
// Последний запуск
|
// Последний запуск
|
||||||
if(srHistory && srHistory.length){
|
if(srHistory && srHistory.length){
|
||||||
@@ -509,14 +509,14 @@ async function loadScenarios(){
|
|||||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runScenario(name){
|
async function runScenario(defId){
|
||||||
if(busy){ return; }
|
if(busy){ return; }
|
||||||
busy=true;
|
busy=true;
|
||||||
stopScenarioPoll();
|
stopScenarioPoll();
|
||||||
const body=document.getElementById('scenario-body');
|
const body=document.getElementById('scenario-body');
|
||||||
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск сценария ${_esc(name)}...</div>`;
|
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск...</div>`;
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scenario:name})});
|
const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({definition_id:defId})});
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
if(d.error){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`; busy=false; return; }
|
if(d.error){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`; busy=false; return; }
|
||||||
const runId=d.run_id;
|
const runId=d.run_id;
|
||||||
|
|||||||
Reference in New Issue
Block a user