v1.2.0: unified executor + flexible instance refs + params-render.js
Phase 1 — New modules: - api/utils.py: find_uid(), uid_from_location() (replaces 2 duplicates) - operations/poll.py: poll_until_done() (shared sync/async polling) - operations/executor.py: execute_operation() (single CREATE/non-CREATE flow) Phase 2 — Format + validation: - routes/api_scenario_defs.py: _validate_steps with output/instance_ref/instance_uid - operations/scenario.py: resolve instance_uid > instance_ref > service_id, use executor + poll_until_done, persist instance_bindings Phase 3 — Migration: - routes/api_test.py: create/non-create through executor, _finish_op through poll_until_done - db/init_db.py: startup cleanup of stuck scenario_runs (>1h) Phase 4 — UI shared module: - static/js/params-render.js: renderParamRow, renderMapFixedRow, collectParams - static/js/operations.js: use params-render.js (remove duplicates) - templates/index.html: include params-render.js - app.py: bump 1.1.57 → 1.2.0
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Единый executor операции Nubes.
|
||||
|
||||
Делает всё до /run включительно. НЕ поллит.
|
||||
Используется api_test.py (ручной) и scenario.py (сценарный).
|
||||
"""
|
||||
|
||||
from api.utils import find_uid, uid_from_location
|
||||
from operations.terraform import send_params_terraform
|
||||
|
||||
|
||||
def execute_operation(client, service_id, operation, instance_uid, params,
|
||||
svc_op_id=None, display_name=None):
|
||||
"""Запустить операцию и вернуть результат.
|
||||
|
||||
Args:
|
||||
client: HttpClient
|
||||
service_id: int — ID сервиса
|
||||
operation: str — create/delete/modify/suspend/resume/redeploy
|
||||
instance_uid: str|None — UUID существующего инстанса (None для create)
|
||||
params: dict — {numeric_param_id: value}
|
||||
svc_op_id: int|None — svcOperationId (игнорируется для create)
|
||||
display_name: str|None — displayName (только для create)
|
||||
|
||||
Returns:
|
||||
dict {ok, error, failed_step, instance_uid, op_uid, display_name}
|
||||
ok=True при успехе, ok=False при ошибке с failed_step.
|
||||
"""
|
||||
is_create = (operation == "create")
|
||||
|
||||
# --- Шаг 1: POST /instances (только create) ---
|
||||
if is_create:
|
||||
if not display_name:
|
||||
display_name = f"autotest-{service_id}"
|
||||
descr = f"created by autotest"
|
||||
payload = {"serviceId": service_id, "displayName": display_name, "descr": descr}
|
||||
try:
|
||||
resp = client.post("/instances", payload)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "failed_step": "instances",
|
||||
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||
instance_uid = resp.get("instanceUid") or find_uid(resp) or uid_from_location(resp.get("_location", ""))
|
||||
if not instance_uid:
|
||||
return {"ok": False, "error": "No instanceUid in response", "failed_step": "instances",
|
||||
"instance_uid": None, "op_uid": None, "display_name": display_name}
|
||||
|
||||
# --- Шаг 2: POST /instanceOperations ---
|
||||
if is_create:
|
||||
op_payload = {"instanceUid": instance_uid, "operation": operation}
|
||||
else:
|
||||
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": operation}
|
||||
try:
|
||||
op_resp = client.post("/instanceOperations", op_payload)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "failed_step": "instanceOperations",
|
||||
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||
op_uid = op_resp.get("instanceOperationUid") or find_uid(op_resp) or uid_from_location(op_resp.get("_location", ""))
|
||||
if not op_uid:
|
||||
return {"ok": False, "error": "No opUid in response", "failed_step": "instanceOperations",
|
||||
"instance_uid": instance_uid, "op_uid": None, "display_name": display_name}
|
||||
|
||||
# --- Шаг 3-6: Параметры + run ---
|
||||
try:
|
||||
send_params_terraform(client, op_uid, params)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "failed_step": "params",
|
||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||
try:
|
||||
client.post(f"/instanceOperations/{op_uid}/run")
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "failed_step": "run",
|
||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||
|
||||
return {"ok": True, "error": None, "failed_step": None,
|
||||
"instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Общий цикл поллинга операции Nubes.
|
||||
|
||||
Используется:
|
||||
- _finish_op (api_test.py) — асинхронно в threading.Thread
|
||||
- run_scenario (scenario.py) — синхронно в while
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def poll_until_done(client, op_uid, timeout=1800):
|
||||
"""Поллинг операции до завершения или таймаута.
|
||||
|
||||
Возвращает dict:
|
||||
{status, is_successful, error_log, stages, duration, svc}
|
||||
status ∈ {OK, FAIL, TIMEOUT}
|
||||
"""
|
||||
t0 = time.time()
|
||||
deadline = t0 + timeout
|
||||
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.get("instanceOperation", {})
|
||||
dt_finish = op.get("dtFinish")
|
||||
if dt_finish and str(dt_finish).strip():
|
||||
is_ok = op.get("isSuccessful")
|
||||
return {
|
||||
"status": "OK" if is_ok else "FAIL",
|
||||
"is_successful": is_ok,
|
||||
"error_log": op.get("errorLog") or "",
|
||||
"stages": op.get("stages", []),
|
||||
"duration": round(time.time() - t0, 1),
|
||||
"svc": op.get("svc", ""),
|
||||
}
|
||||
time.sleep(5)
|
||||
# Таймаут
|
||||
return {
|
||||
"status": "TIMEOUT",
|
||||
"is_successful": False,
|
||||
"error_log": f"TIMEOUT after {timeout}s",
|
||||
"stages": [],
|
||||
"duration": round(time.time() - t0, 1),
|
||||
"svc": "",
|
||||
}
|
||||
+77
-84
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
Scenario runner — шаги из БД → API → результат.
|
||||
|
||||
Формат шага:
|
||||
{ "service_id": 1, "operation": "create", "params": { "durationMs": "5000" } }
|
||||
Формат шага (новый):
|
||||
{ "service_id": 1, "operation": "create", "params": {...}, "output": "d1" }
|
||||
{ "service_id": 1, "operation": "modify", "instance_ref": "d1", "params": {...} }
|
||||
{ "service_id": 1, "operation": "delete", "instance_uid": "UUID", "params": {} }
|
||||
|
||||
Для каждого шага:
|
||||
1. service_id уже в шаге
|
||||
2. operation имя → svcOperationId (из GET /services/{id})
|
||||
3. param коды → numeric IDs (из GET /instanceOperations/default/{opId})
|
||||
4. create: новый инстанс; остальные: переиспользовать по service_id
|
||||
5. Выполнить через API (как в api_test)
|
||||
6. Сохранить результат в runs + scenario_runs
|
||||
Старый формат (без output/instance_ref/instance_uid) — fallback на service_id.
|
||||
|
||||
Резолвинг: instance_uid > instance_ref > instance_map[service_id]
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -18,31 +16,14 @@ import time
|
||||
import uuid
|
||||
|
||||
from operations.get_services import get_service_detail
|
||||
from operations.executor import execute_operation
|
||||
from operations.poll import poll_until_done
|
||||
from db.pool import get_conn, put_conn
|
||||
from db.save_run import save_run
|
||||
|
||||
AUTOTEST_PREFIX = "autotest-scenario-"
|
||||
|
||||
|
||||
def _find_uid(resp):
|
||||
"""Extract UUID from nested response (e.g. {instanceOperation: {instanceOperationUid: ...}})."""
|
||||
if isinstance(resp, dict):
|
||||
for v in resp.values():
|
||||
if isinstance(v, dict):
|
||||
uid = v.get("instanceOperationUid") or v.get("instanceUid")
|
||||
if uid:
|
||||
return uid
|
||||
return None
|
||||
|
||||
|
||||
def _uid_from_location(loc):
|
||||
"""Extract UUID from Location header."""
|
||||
if not loc:
|
||||
return None
|
||||
parts = str(loc).rstrip("/").split("/")
|
||||
return parts[-1] if parts[-1] else None
|
||||
|
||||
|
||||
def _resolve_params(cfs_params, symbolic_params):
|
||||
"""Символические коды → {numeric_id: value}."""
|
||||
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
||||
@@ -76,12 +57,46 @@ def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=Non
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
def _update_bindings(scenario_run_id, bindings):
|
||||
"""Сохранить output→instance_uid в scenario_runs.instance_bindings."""
|
||||
conn = get_conn()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE scenario_runs SET instance_bindings = %s WHERE id = %s
|
||||
""", (json.dumps(bindings), scenario_run_id))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
print(f"[SCENARIO] update_bindings error: {e}", flush=True)
|
||||
conn.rollback()
|
||||
finally:
|
||||
put_conn(conn)
|
||||
|
||||
|
||||
def _resolve_instance_uid(step, bindings, instance_map):
|
||||
"""Резолвинг instance_uid по приоритету: явный uid > instance_ref > service_id."""
|
||||
uid = (step.get("instance_uid") or "").strip()
|
||||
if uid:
|
||||
return uid
|
||||
ref = (step.get("instance_ref") or "").strip()
|
||||
if ref:
|
||||
if ref in bindings:
|
||||
return bindings[ref]
|
||||
raise ValueError(f"instance_ref '{ref}' not found in bindings (step order issue?)")
|
||||
# Fallback: старый формат по service_id
|
||||
svc_id = step.get("service_id")
|
||||
return instance_map.get(svc_id)
|
||||
|
||||
|
||||
def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name):
|
||||
"""Главный исполнитель сценария. steps — список шагов из БД."""
|
||||
total = len(steps)
|
||||
|
||||
t0 = time.time()
|
||||
instance_map = {} # service_id → instanceUid (переиспользование внутри сценария)
|
||||
bindings = {} # output_name → instance_uid (новый формат)
|
||||
instance_map = {} # service_id → instance_uid (fallback, старый формат)
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
step_num = i + 1
|
||||
@@ -108,38 +123,37 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
||||
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||
|
||||
# 3. Инстанс
|
||||
if op_name == "create":
|
||||
# 3. Резолвинг instance_uid
|
||||
is_create = (op_name == "create")
|
||||
if is_create:
|
||||
instance_uid = None
|
||||
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") or _find_uid(resp) or _uid_from_location(resp.get("_location", ""))
|
||||
if not instance_uid:
|
||||
raise RuntimeError(f"CREATE: no instanceUid in response: {json.dumps(resp)[:200]}")
|
||||
instance_map[svc_id] = instance_uid
|
||||
else:
|
||||
instance_uid = instance_map.get(svc_id)
|
||||
instance_uid = _resolve_instance_uid(step, bindings, instance_map)
|
||||
if not instance_uid:
|
||||
raise RuntimeError(f"No instance for service_id {svc_id} — need CREATE first")
|
||||
raise ValueError(f"No instance for step {step_num} — need CREATE before non-create operations")
|
||||
display_name = None
|
||||
|
||||
# 4. Запуск операции
|
||||
if op_name == "create":
|
||||
op_payload = {"instanceUid": instance_uid, "operation": op_name}
|
||||
else:
|
||||
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 _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", ""))
|
||||
if not op_uid:
|
||||
raise RuntimeError(f"No opUid in response: {json.dumps(op_resp)[:200]}")
|
||||
# 4. Запуск через executor
|
||||
result = execute_operation(
|
||||
client, svc_id, op_name, instance_uid, resolved_params,
|
||||
svc_op_id=svc_op_id, display_name=display_name
|
||||
)
|
||||
if not result["ok"]:
|
||||
raise RuntimeError(f"{op_name}: {result['error']} (step: {result['failed_step']})")
|
||||
|
||||
# Отправить параметры и запустить
|
||||
from operations.terraform import send_params_terraform
|
||||
send_params_terraform(client, op_uid, resolved_params)
|
||||
client.post(f"/instanceOperations/{op_uid}/run")
|
||||
instance_uid = result["instance_uid"]
|
||||
op_uid = result["op_uid"]
|
||||
step_display = result["display_name"]
|
||||
|
||||
# 5. Сохранить RUNNING в runs ДО поллинга
|
||||
step_display = display_name if op_name == "create" else instance_uid
|
||||
# Обновить карты
|
||||
instance_map[svc_id] = instance_uid
|
||||
output_name = (step.get("output") or "").strip()
|
||||
if is_create and output_name:
|
||||
bindings[output_name] = instance_uid
|
||||
_update_bindings(scenario_run_id, bindings)
|
||||
|
||||
# 5. Сохранить RUNNING
|
||||
try:
|
||||
save_run(client_id, stand, user_email,
|
||||
svc_id, "", op_name, svc_op_id,
|
||||
@@ -150,35 +164,15 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
||||
except Exception as e:
|
||||
print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True)
|
||||
|
||||
# 6. Поллинг завершения
|
||||
step_t0 = time.time()
|
||||
deadline = step_t0 + 1800
|
||||
step_status = "TIMEOUT"
|
||||
step_error = ""
|
||||
step_duration = 0
|
||||
step_stages = []
|
||||
op_data = {}
|
||||
|
||||
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)
|
||||
# 6. Поллинг
|
||||
poll_result = poll_until_done(client, op_uid)
|
||||
step_status = poll_result["status"]
|
||||
step_error = poll_result["error_log"]
|
||||
step_duration = poll_result["duration"]
|
||||
step_stages = poll_result["stages"]
|
||||
svc_final = poll_result["svc"]
|
||||
|
||||
# 7. Сохранить финальный результат
|
||||
svc_final = op_data.get("svc", "") if op_data else ""
|
||||
try:
|
||||
save_run(client_id, stand, user_email,
|
||||
svc_id, svc_final, op_name, svc_op_id,
|
||||
@@ -208,5 +202,4 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
|
||||
round(time.time() - t0, 1), err)
|
||||
return
|
||||
|
||||
# Все шаги OK
|
||||
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||
|
||||
Reference in New Issue
Block a user