diff --git a/site/app.py b/site/app.py index 6eb77ba..9292933 100644 --- a/site/app.py +++ b/site/app.py @@ -15,9 +15,10 @@ from flask import Flask from routes.main import bp as main_bp from routes.api_test import bp as api_test_bp +from routes.api_scenario import bp as api_scenario_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.1.45" +VERSION = "1.1.46" 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") @@ -25,6 +26,7 @@ app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["VERSION"] = VERSION app.register_blueprint(main_bp) app.register_blueprint(api_test_bp) +app.register_blueprint(api_scenario_bp) @app.route("/health") diff --git a/site/config.yaml b/site/config.yaml index 2d9de3f..541529a 100644 --- a/site/config.yaml +++ b/site/config.yaml @@ -26,3 +26,13 @@ services: - name: delete enabled: false params: {} + +scenarios: + dummy_test: + steps: + - svc: dummy + op: create + params: + durationMs: "5000" + - svc: dummy + op: delete diff --git a/site/db/init_db.py b/site/db/init_db.py index 6d8e32e..9c2eb18 100644 --- a/site/db/init_db.py +++ b/site/db/init_db.py @@ -56,6 +56,30 @@ MIGRATION_SQL = """ ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64); ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128); ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16); +ALTER TABLE runs ADD COLUMN IF NOT EXISTS scenario_run_id INTEGER; +ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER; +""" + +# Таблица сценариев — один запуск = одна строка +SCENARIO_RUNS_SQL = """ +CREATE TABLE IF NOT EXISTS scenario_runs ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + client_id VARCHAR(64) NOT NULL, + stand VARCHAR(16) NOT NULL, + user_email VARCHAR(128), + scenario_name VARCHAR(200) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'RUNNING', + current_step INTEGER NOT NULL DEFAULT 0, + total_steps INTEGER NOT NULL, + instance_bindings JSONB DEFAULT '{}', + duration_sec DOUBLE PRECISION, + error_log TEXT, + app_version VARCHAR(16) +); + +CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand + ON scenario_runs (client_id, stand, created_at DESC); """ @@ -74,6 +98,7 @@ def init_db(): cur = conn.cursor() cur.execute(SCHEMA_SQL) cur.execute(MIGRATION_SQL) + cur.execute(SCENARIO_RUNS_SQL) conn.commit() cur.close() print("[DB] Schema initialized", flush=True) diff --git a/site/operations/scenario.py b/site/operations/scenario.py new file mode 100644 index 0000000..777e835 --- /dev/null +++ b/site/operations/scenario.py @@ -0,0 +1,248 @@ +""" +Scenario runner — символические коды → API → шаги → результат. + +Формат сценария в config.yaml: + scenarios: + dummy_test: + steps: + - svc: dummy # символическое имя (из services: в config.yaml) + op: create + params: + durationMs: "5000" + - svc: dummy + op: delete + +Для каждого шага: + 1. service имя → svc_id (из config.yaml services) + 2. operation имя → svcOperationId (из GET /services/{id}) + 3. param коды → numeric IDs (из GET /instanceOperations/default/{opId}) + 4. create: новый инстанс; остальные: переиспользовать инстанс этого сервиса + 5. Выполнить через API (как в api_test) + 6. Сохранить результат в runs + scenario_runs +""" + +import json +import time +import uuid + +from runner import load_config +from operations.get_services import get_service_detail +from operations.get_instances import get_instances +from db.pool import get_conn, put_conn +from db.save_run import save_run + +AUTOTEST_PREFIX = "autotest-scenario-" + + +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): + """Символические коды → {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} + result = {} + for code, val in symbolic_params.items(): + pid = code_to_id.get(code) + if pid is None: + raise ValueError(f"Param code '{code}' not found in operation template") + result[str(pid)] = str(val) + return result + + +def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=None, error_log=None): + """Обновить запись scenario_runs.""" + conn = get_conn() + if not conn: + return + try: + cur = conn.cursor() + cur.execute(""" + UPDATE scenario_runs + SET status = %s, current_step = %s, duration_sec = %s, error_log = %s + WHERE id = %s + """, (status, current_step, duration_sec, error_log, scenario_run_id)) + conn.commit() + cur.close() + except Exception as e: + print(f"[SCENARIO] save_scenario_run error: {e}", flush=True) + conn.rollback() + finally: + put_conn(conn) + + +def _create_scenario_run(client_id, stand, user_email, scenario_name, total_steps, app_version): + """Создать запись в scenario_runs, вернуть ID.""" + 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 run_scenario(client, scenario_name, client_id, stand, user_email, app_version): + """Главный исполнитель сценария. Вызывается в фоновом потоке. + Возвращает scenario_run_id для поллинга.""" + config = load_config() or {} + scenarios = config.get("scenarios", {}) + scenario = scenarios.get(scenario_name) + if not scenario: + raise ValueError(f"Scenario '{scenario_name}' not found") + + steps = scenario.get("steps", []) + if not steps: + raise ValueError(f"Scenario '{scenario_name}' has no steps") + + total = len(steps) + + # Создать запись в scenario_runs + scenario_run_id = _create_scenario_run(client_id, stand, user_email, scenario_name, total, app_version) + if scenario_run_id is None: + raise RuntimeError("Failed to create scenario_run record") + + t0 = time.time() + instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария) + + for i, step in enumerate(steps): + step_num = i + 1 + svc_name = step.get("svc", "") + op_name = step.get("op", "") + symbolic_params = step.get("params", {}) + + try: + # 1. Сервис + svc_info = _resolve_service(config, svc_name) + svc_id = svc_info["service_id"] + + # 2. Операция + detail = get_service_detail(client, svc_id) + ops = detail.get("operations", []) + op = next((o for o in ops if o.get("operation") == op_name), None) + if not op: + raise ValueError(f"Operation '{op_name}' not found for service {svc_name}") + svc_op_id = op["svcOperationId"] + + # 3. Параметры + tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}") + cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) + resolved_params = _resolve_params(cfs_params, symbolic_params) + + # 4. Инстанс + if op_name == "create": + display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}" + descr = f"scenario {scenario_name} step {step_num}" + payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr} + resp = client.post("/instances", payload) + instance_uid = resp.get("instanceUid") + if not instance_uid: + raise RuntimeError("CREATE: no instanceUid in response") + instance_map[svc_name] = instance_uid + else: + instance_uid = instance_map.get(svc_name) + if not instance_uid: + raise RuntimeError(f"No instance for service '{svc_name}' — need CREATE first") + + # 5. Запуск операции + op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} + op_resp = client.post("/instanceOperations", op_payload) + op_uid = op_resp.get("instanceOperationUid") or op_resp.get("instanceOperation", {}).get("instanceOperationUid") + if not op_uid: + op_uid = next((v for k, v in op_resp.items() if "Uid" in k or "uid" in k), None) + if not op_uid and "_location" in op_resp: + loc = op_resp["_location"] + op_uid = loc.rsplit("/", 1)[-1] if "/" in loc else loc + if not op_uid: + raise RuntimeError("No opUid in response") + + # Отправить параметры и запустить + from routes.api_test import _send_params_terraform + _send_params_terraform(client, op_uid, resolved_params) + client.post(f"/instanceOperations/{op_uid}/run") + + # 6. Поллинг завершения + step_t0 = time.time() + deadline = step_t0 + 1800 + step_status = "TIMEOUT" + step_error = "" + step_duration = 0 + step_stages = [] + + while time.time() < deadline: + try: + data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc") + except Exception: + time.sleep(5) + continue + op_data = data.get("instanceOperation", {}) + dt_finish = op_data.get("dtFinish") + if dt_finish and str(dt_finish).strip(): + is_ok = op_data.get("isSuccessful") + err = op_data.get("errorLog") or "" + step_status = "OK" if is_ok else "FAIL" + step_error = str(err) if err else "" + step_duration = round(time.time() - step_t0, 1) + step_stages = op_data.get("stages", []) + break + time.sleep(5) + + # 7. Сохранить шаг в runs + try: + save_run(client_id, stand, user_email, + svc_id, op_data.get("svc", svc_name), op_name, svc_op_id, + op_uid, instance_uid, display_name if op_name == "create" else instance_uid, + step_status, step_duration, step_error, + resolved_params, step_stages, app_version) + except Exception as e: + print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True) + + # Обновить scenario_runs + _save_scenario_run(scenario_run_id, "RUNNING" if step_num < total else step_status, + step_num, round(time.time() - t0, 1), + step_error if step_status != "OK" else None) + + if step_status != "OK": + # Прервать сценарий при ошибке + _save_scenario_run(scenario_run_id, step_status, step_num, + round(time.time() - t0, 1), step_error) + return + + except Exception as e: + import traceback + err = f"Step {step_num}: {e}" + print(f"[SCENARIO] {err}", flush=True) + traceback.print_exc() + _save_scenario_run(scenario_run_id, "FAIL", step_num, + round(time.time() - t0, 1), err) + return + + # Все шаги OK + _save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1)) diff --git a/site/routes/api_scenario.py b/site/routes/api_scenario.py new file mode 100644 index 0000000..ab7b32d --- /dev/null +++ b/site/routes/api_scenario.py @@ -0,0 +1,86 @@ +""" +API для запуска сценариев автотестирования. + +POST /api/scenario/run — запустить сценарий (фон) +GET /api/scenario/status/ — поллинг статуса сценария +GET /api/scenarios — список доступных сценариев +GET /api/scenario/history — история запусков сценариев +""" + +from flask import Blueprint, current_app, jsonify, request +import threading + +from api.auth import get_client, get_client_id, get_stand, get_token_info +from operations.scenario import load_scenarios, run_scenario +from db.pool import get_conn, put_conn + +bp = Blueprint("api_scenario", __name__) + + +@bp.route("/api/scenarios") +def api_scenarios(): + """Список доступных сценариев из config.yaml.""" + try: + scenarios = load_scenarios() + result = [{"name": name, "steps": len(s.get("steps", []))} for name, s in scenarios.items()] + return jsonify(sorted(result, key=lambda x: x["name"])) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/scenario/run", methods=["POST"]) +def api_scenario_run(): + """Запустить сценарий. Возвращает scenario_run_id сразу, выполнение в фоне.""" + try: + data = request.get_json() + scenario_name = (data.get("scenario") or "").strip() + if not scenario_name: + return jsonify({"error": "scenario name is required"}), 400 + + client = get_client() + user_email = get_token_info().get("email", "") + app_version = current_app.config.get("VERSION", "") + + # Запуск в фоне + threading.Thread( + target=run_scenario, + args=(client, scenario_name, get_client_id(), get_stand(), user_email, app_version), + daemon=True, + ).start() + + return jsonify({"status": "RUNNING", "scenario": scenario_name}) + except Exception as e: + import traceback + print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True) + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/scenario/status") +def api_scenario_status(): + """Последний запущенный сценарий (или несколько последних).""" + conn = get_conn() + if not conn: + return jsonify([]) + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, created_at, scenario_name, status, current_step, total_steps, + duration_sec, error_log + FROM scenario_runs + WHERE client_id = %s AND stand = %s + ORDER BY created_at DESC + LIMIT 10 + """, (get_client_id(), get_stand())) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + result = [] + for r in rows: + d = dict(zip(cols, r)) + if d["created_at"]: + d["created_at"] = d["created_at"].isoformat() + result.append(d) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + put_conn(conn) diff --git a/site/static/app.js b/site/static/app.js index 899ce27..23c4849 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -468,4 +468,84 @@ function toggleLog(){ if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); } else el.style.display='none'; } + +// === Сценарии (автотесты) === +let scenarioOpen=false, scenarioPollTimer=null; + +async function toggleScenario(){ + const body=document.getElementById('scenario-body'); + scenarioOpen=!scenarioOpen; + body.style.display=scenarioOpen?'block':'none'; + if(!scenarioOpen){ stopScenarioPoll(); return; } + await loadScenarios(); +} + +async function loadScenarios(){ + const body=document.getElementById('scenario-body'); + try{ + const [sr, srHistory]=await Promise.all([ + fetch('/api/scenarios').then(r=>r.json()), + fetch('/api/scenario/status').then(r=>r.json()), + ]); + let html=''; + if(sr.length){ + html+='
'; + sr.forEach(s=>{ + html+=``; + }); + html+='
'; + }else{ + html+='Нет сценариев в config.yaml'; + } + // Последний запуск + if(srHistory && srHistory.length){ + const last=srHistory[0]; + const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; + const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; + html+=`
Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; + if(last.error_log) html+=`
${_esc(last.error_log)}
`; + } + body.innerHTML=html; + }catch(e){body.innerHTML='Ошибка загрузки';} +} + +async function runScenario(name){ + if(busy){ return; } + busy=true; + stopScenarioPoll(); + const body=document.getElementById('scenario-body'); + body.innerHTML=`
⏳ Запуск сценария ${_esc(name)}...
`; + try{ + const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scenario:name})}); + const d=await r.json(); + if(d.error){ body.innerHTML=`Ошибка: ${_esc(d.error)}`; busy=false; return; } + // Поллинг статуса + scenarioPollTimer=setInterval(async()=>{ + try{ + const sr=await fetch('/api/scenario/status'); + const rows=await sr.json(); + if(!rows||!rows.length) return; + const last=rows[0]; + const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; + const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; + let html=`
${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; + if(last.error_log) html+=`
${_esc(last.error_log)}
`; + body.innerHTML=html; + if(last.status!=='RUNNING'){ + stopScenarioPoll(); + busy=false; + await refreshInstances(); + if(historyOpen) await loadHistory(); + } + }catch(e){} + },3000); + }catch(e){ + body.innerHTML=`Ошибка: ${_esc(e.message)}`; + busy=false; + } +} + +function stopScenarioPoll(){ + if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; } +} startLogPoll(); \ No newline at end of file diff --git a/site/templates/index.html b/site/templates/index.html index 4b45137..de08ffb 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -130,6 +130,12 @@ Загрузка... +
+
Сценарии (автотесты)
+ +