v1.1.48: fix 4 scenario bugs — save_run: scenario_run_id+step_number, run_id before thread (202), TIMEOUT op_data init, RUNNING before API call, GET /run/<id>
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.47"
|
VERSION = "1.1.48"
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
+8
-4
@@ -8,8 +8,10 @@ from db.pool import get_conn, put_conn
|
|||||||
|
|
||||||
def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
def save_run(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, params, stages, app_version):
|
error_log, params, stages, app_version,
|
||||||
"""Сохранить один запуск в таблицу runs."""
|
scenario_run_id=None, step_number=None):
|
||||||
|
"""Сохранить один запуск в таблицу runs.
|
||||||
|
Опционально: scenario_run_id + step_number для шагов сценария."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
print("[DB] save_run SKIP: no connection", flush=True)
|
print("[DB] save_run SKIP: no connection", flush=True)
|
||||||
@@ -20,8 +22,9 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
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,
|
||||||
status, duration_sec, error_log, params, stages, app_version)
|
status, duration_sec, error_log, params, stages, app_version,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
scenario_run_id, step_number)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""", (
|
""", (
|
||||||
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,
|
||||||
@@ -29,6 +32,7 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id,
|
|||||||
json.dumps(params) if params else None,
|
json.dumps(params) if params else None,
|
||||||
json.dumps(stages) if stages else None,
|
json.dumps(stages) if stages else None,
|
||||||
app_version,
|
app_version,
|
||||||
|
scenario_run_id, step_number,
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
+45
-14
@@ -127,25 +127,41 @@ def _create_scenario_run(client_id, stand, user_email, scenario_name, total_step
|
|||||||
return rid
|
return rid
|
||||||
|
|
||||||
|
|
||||||
def run_scenario(client, scenario_name, client_id, stand, user_email, app_version):
|
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 для поллинга."""
|
scenario_run_id — уже созданная запись (из api_scenario_run)."""
|
||||||
config = load_config() or {}
|
config = load_config() or {}
|
||||||
scenarios = config.get("scenarios", {})
|
scenarios = config.get("scenarios", {})
|
||||||
scenario = scenarios.get(scenario_name)
|
scenario = scenarios.get(scenario_name)
|
||||||
if not scenario:
|
if not scenario:
|
||||||
raise ValueError(f"Scenario '{scenario_name}' not found")
|
_save_scenario_run(scenario_run_id, "FAIL", 0, 0, f"Scenario '{scenario_name}' not found")
|
||||||
|
return
|
||||||
|
|
||||||
steps = scenario.get("steps", [])
|
steps = scenario.get("steps", [])
|
||||||
if not steps:
|
|
||||||
raise ValueError(f"Scenario '{scenario_name}' has no steps")
|
|
||||||
|
|
||||||
total = len(steps)
|
total = len(steps)
|
||||||
|
if not steps:
|
||||||
|
_save_scenario_run(scenario_run_id, "FAIL", 0, 0, "No steps")
|
||||||
|
return
|
||||||
|
|
||||||
# Создать запись в scenario_runs
|
# Обновить total_steps (стало известно только сейчас)
|
||||||
scenario_run_id = _create_scenario_run(client_id, stand, user_email, scenario_name, total, app_version)
|
_update_scenario_total(scenario_run_id, total)
|
||||||
if scenario_run_id is None:
|
|
||||||
raise RuntimeError("Failed to create scenario_run record")
|
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария)
|
instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария)
|
||||||
@@ -205,6 +221,18 @@ 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 ДО поллинга (шаг не потеряется при ошибке)
|
||||||
|
step_display = display_name if op_name == "create" else instance_uid
|
||||||
|
try:
|
||||||
|
save_run(client_id, stand, user_email,
|
||||||
|
svc_id, "", op_name, svc_op_id,
|
||||||
|
op_uid, instance_uid, step_display,
|
||||||
|
"RUNNING", 0, "",
|
||||||
|
resolved_params, [], app_version,
|
||||||
|
scenario_run_id, step_num)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
# 6. Поллинг завершения
|
# 6. Поллинг завершения
|
||||||
step_t0 = time.time()
|
step_t0 = time.time()
|
||||||
deadline = step_t0 + 1800
|
deadline = step_t0 + 1800
|
||||||
@@ -212,6 +240,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
|
||||||
|
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
@@ -231,13 +260,15 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio
|
|||||||
break
|
break
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
# 7. Сохранить шаг в runs
|
# 7. Сохранить финальный результат шага в runs
|
||||||
|
svc_final = op_data.get("svc", svc_name) if op_data else svc_name
|
||||||
try:
|
try:
|
||||||
save_run(client_id, stand, user_email,
|
save_run(client_id, stand, user_email,
|
||||||
svc_id, op_data.get("svc", svc_name), op_name, svc_op_id,
|
svc_id, svc_final, op_name, svc_op_id,
|
||||||
op_uid, instance_uid, display_name if op_name == "create" else instance_uid,
|
op_uid, instance_uid, step_display,
|
||||||
step_status, step_duration, step_error,
|
step_status, step_duration, step_error,
|
||||||
resolved_params, step_stages, app_version)
|
resolved_params, step_stages, app_version,
|
||||||
|
scenario_run_id, step_num)
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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
|
from operations.scenario import load_scenarios, run_scenario, _create_scenario_run
|
||||||
from db.pool import get_conn, put_conn
|
from db.pool import get_conn, put_conn
|
||||||
|
|
||||||
bp = Blueprint("api_scenario", __name__)
|
bp = Blueprint("api_scenario", __name__)
|
||||||
@@ -30,7 +30,7 @@ def api_scenarios():
|
|||||||
|
|
||||||
@bp.route("/api/scenario/run", methods=["POST"])
|
@bp.route("/api/scenario/run", methods=["POST"])
|
||||||
def api_scenario_run():
|
def api_scenario_run():
|
||||||
"""Запустить сценарий. Возвращает scenario_run_id сразу, выполнение в фоне."""
|
"""Запустить сценарий. Создаёт запись ДО потока, возвращает run_id (202)."""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
scenario_name = (data.get("scenario") or "").strip()
|
scenario_name = (data.get("scenario") or "").strip()
|
||||||
@@ -38,23 +38,67 @@ def api_scenario_run():
|
|||||||
return jsonify({"error": "scenario name is required"}), 400
|
return jsonify({"error": "scenario name is required"}), 400
|
||||||
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
cid = get_client_id()
|
||||||
|
stand = get_stand()
|
||||||
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()
|
||||||
|
if scenario_name not in scenarios:
|
||||||
|
return jsonify({"error": f"Scenario '{scenario_name}' not found"}), 404
|
||||||
|
|
||||||
|
total = len(scenarios[scenario_name].get("steps", []))
|
||||||
|
if total == 0:
|
||||||
|
return jsonify({"error": f"Scenario '{scenario_name}' has no steps"}), 400
|
||||||
|
|
||||||
|
# Создать запись ДО потока — вернуть run_id клиенту
|
||||||
|
run_id = _create_scenario_run(cid, stand, user_email, scenario_name, total, app_version)
|
||||||
|
if run_id is None:
|
||||||
|
return jsonify({"error": "Failed to create scenario_run"}), 500
|
||||||
|
|
||||||
# Запуск в фоне
|
# Запуск в фоне
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=run_scenario,
|
target=run_scenario,
|
||||||
args=(client, scenario_name, get_client_id(), get_stand(), user_email, app_version),
|
args=(client, scenario_name, cid, stand, user_email, app_version, run_id),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
return jsonify({"status": "RUNNING", "scenario": scenario_name})
|
return jsonify({"status": "RUNNING", "scenario": scenario_name, "run_id": run_id}), 202
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True)
|
print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True)
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/run/<int:run_id>")
|
||||||
|
def api_scenario_run_status(run_id):
|
||||||
|
"""Статус конкретного запуска сценария."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "no db"}), 500
|
||||||
|
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 id = %s AND client_id = %s AND stand = %s
|
||||||
|
""", (run_id, get_client_id(), get_stand()))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
d = dict(zip(cols, row))
|
||||||
|
if d["created_at"]:
|
||||||
|
d["created_at"] = d["created_at"].isoformat()
|
||||||
|
return jsonify(d)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/scenario/status")
|
@bp.route("/api/scenario/status")
|
||||||
def api_scenario_status():
|
def api_scenario_status():
|
||||||
"""Последний запущенный сценарий (или несколько последних)."""
|
"""Последний запущенный сценарий (или несколько последних)."""
|
||||||
|
|||||||
+7
-5
@@ -519,13 +519,15 @@ async function runScenario(name){
|
|||||||
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({scenario:name})});
|
||||||
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;
|
||||||
|
if(!runId){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`; busy=false; return; }
|
||||||
|
// Поллинг конкретного запуска
|
||||||
scenarioPollTimer=setInterval(async()=>{
|
scenarioPollTimer=setInterval(async()=>{
|
||||||
try{
|
try{
|
||||||
const sr=await fetch('/api/scenario/status');
|
const sr=await fetch('/api/scenario/run/'+runId);
|
||||||
const rows=await sr.json();
|
if(!sr.ok){ return; }
|
||||||
if(!rows||!rows.length) return;
|
const last=await sr.json();
|
||||||
const last=rows[0];
|
if(!last||last.error) return;
|
||||||
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||||
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
let html=`<div style="font-size:11px;">${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
let html=`<div style="font-size:11px;">${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user