diff --git a/site/app.py b/site/app.py index da368da..6eb77ba 100644 --- a/site/app.py +++ b/site/app.py @@ -14,18 +14,16 @@ import os from flask import Flask from routes.main import bp as main_bp -from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.1.44" +VERSION = "1.1.45" 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_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["VERSION"] = VERSION app.register_blueprint(main_bp) -app.register_blueprint(api_bp) app.register_blueprint(api_test_bp) diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 5f5bb11..f57e139 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -46,6 +46,22 @@ MAX_LOG_SIZE = 512 * 1024 # 512 KB — ротация MAX_LOG_LINES = 200 # сколько отдавать в /api/log +REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"} + + +def _redact_params(params): + """Заменить значения секретных полей на *** перед сохранением в БД.""" + if not params: + return params + result = {} + for k, v in params.items(): + if any(r in str(k).lower() for r in REDACT_KEYS): + result[k] = "***" + else: + result[k] = v + return result + + def _log(msg): """Пишет в stdout (gunicorn) и в файл (UI-панель, общий для всех воркеров).""" print(msg, flush=True) @@ -163,12 +179,24 @@ def api_test(): import threading, time data = request.get_json() - svc_id = data["serviceId"] - op_name = data["operation"] - svc_op_id = data["svcOperationId"] + svc_id = data.get("serviceId") + op_name = (data.get("operation") or "").strip() + svc_op_id = data.get("svcOperationId") params = data.get("params", {}) - instance_uid = data.get("instanceUid") - display_name = data.get("displayName") or "" + instance_uid = (data.get("instanceUid") or "").strip() + display_name = (data.get("displayName") or "").strip() + + # --- Validation --- + if not isinstance(svc_id, int) or svc_id <= 0: + return jsonify({"status": "FAIL", "error": "invalid serviceId"}), 400 + if not op_name: + return jsonify({"status": "FAIL", "error": "invalid operation name"}), 400 + if not isinstance(svc_op_id, int) or svc_op_id <= 0: + return jsonify({"status": "FAIL", "error": "invalid svcOperationId"}), 400 + if instance_uid and len(instance_uid) != 36: + return jsonify({"status": "FAIL", "error": "invalid instanceUid (must be UUID)"}), 400 + if not isinstance(params, dict): + return jsonify({"status": "FAIL", "error": "invalid params (must be object)"}), 400 client = get_client() @@ -253,7 +281,8 @@ def api_test(): except Exception as e: import traceback - return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]}) + _log(f"[api_test] EXCEPTION: {traceback.format_exc()}") + return jsonify({"status": "FAIL", "error": str(e)}), 500 # Результаты фоновых операций: opUid → {status, error, stages, duration, _ts} @@ -295,14 +324,30 @@ def _normalize_value(val, data_type, data_descriptor): def _resolve_ref_svc(client, cfs_params, params): """resolveRefSvcParamValues — Terraform step 4. - Для параметров с refSvcId в dataDescriptor: если значение пустое — - подставить UUID существующего инстанса этого сервиса.""" + Для параметров с refSvcId (верхнеуровневый ИЛИ в dataDescriptor): + если значение пустое — подставить UUID существующего инстанса этого сервиса.""" instances = None # lazy load for p in cfs_params: + pid = str(p["svcOperationCfsParamId"]) + # --- Верхнеуровневый refSvcId --- + top_ref = p.get("refSvcId") + if top_ref: + cur = (params.get(pid) or "").strip() + if not cur or cur == '""': + if instances is None: + try: + instances = get_instances(client) + except Exception: + return + match = next((i for i in instances + if i.get("serviceId") == top_ref + and i.get("explainedStatus") not in ("deleted",)), None) + if match: + params[pid] = match["instanceUid"] + # --- Вложенный refSvcId в dataDescriptor --- dd = p.get("dataDescriptor") if not isinstance(dd, dict): continue - pid = str(p["svcOperationCfsParamId"]) user_val = {} if pid in params: try: @@ -321,7 +366,9 @@ def _resolve_ref_svc(client, cfs_params, params): instances = get_instances(client) except Exception: return - match = next((i for i in instances if i.get("serviceId") == ref_id and i.get("explainedStatus") not in ("deleted",)), None) + match = next((i for i in instances + if i.get("serviceId") == ref_id + and i.get("explainedStatus") not in ("deleted",)), None) if match: user_val[sk] = match["instanceUid"] if user_val: @@ -438,6 +485,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ # Сохранить в БД историю try: from db.save_run import save_run + saved_params = _redact_params(params or {}) save_run(client_id, stand, user_email, svc_id, op.get("svc", ""), op_name, svc_op_id, @@ -445,7 +493,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ "OK" if is_ok else "FAIL", round(time.time() - t0, 1), str(err) if err else "", - params or {}, + saved_params, op.get("stages", []), app_version) except Exception as e: @@ -459,6 +507,19 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ print(f"[ERROR] _finish_op crashed: {traceback.format_exc()}", flush=True) time.sleep(5) _op_results[op_uid] = {"status": "TIMEOUT", "displayName": display_name, "duration": round(time.time() - t0, 1), "_ts": time.time()} + # Сохранить TIMEOUT в БД + try: + from db.save_run import save_run + saved_params = _redact_params(params or {}) + save_run(client_id, stand, user_email, + svc_id, "", op_name, svc_op_id, + op_uid, instance_uid, display_name, + "TIMEOUT", round(time.time() - t0, 1), + "TIMEOUT after 1800s", saved_params, [], app_version) + except Exception as e: + import traceback + print(f"[DB] TIMEOUT save_run FAILED: {e}", flush=True) + traceback.print_exc() @bp.route("/api/test/status/") diff --git a/site/static/app.js b/site/static/app.js index d8ee292..899ce27 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -164,7 +164,7 @@ function showParams(opId,opName){ } const form=document.getElementById('params-form'); const displayNameValue=isCreate?makeCreateDisplayName():''; - const createHeader=isCreate?`
Создание: ${currentSvcName}
`:''; + const createHeader=isCreate?`
Создание: ${_esc(currentSvcName)}
`:''; const opHeader=!isCreate?`
${_esc(opName)} → ${_esc(findInstName())}
`:''; form.innerHTML=createHeader+opHeader+(opName==='create'?`
${AUTOTEST_PREFIX}
`:'') + params.map(p=>{ @@ -291,8 +291,8 @@ function setFinishedState(statusText, statusClass, statusError, statusDuration, btn.onclick=null; const opName=selectedOp?.opName||'операция'; const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s'; - const extra=statusError||''; - const instName=instanceName||findInstName()||selectedInst||''; + const extra=_esc(statusError||''); + const instName=_esc(instanceName||findInstName()||selectedInst||''); document.getElementById('test-status').innerHTML=` ${statusText} ${opName} ${durationText} ${instName} ${extra}`; } @@ -337,7 +337,7 @@ async function executeOp(params){ displayName:isCreate ? displayName : '' })}); const d=await r.json(); - if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${d.error||''}`;btn.disabled=false;return;} + if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${_esc(d.error||'')}`;btn.disabled=false;return;} // CMDB delete — мгновенное удаление, не надо поллинга if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ busy=false; @@ -457,7 +457,7 @@ function startLogPoll(){ try{ const r=await fetch('/api/log'); const lines=await r.json(); - el.innerHTML=lines.map(l=>`
${l}
`).join(''); + el.innerHTML=lines.map(l=>`
${_esc(l)}
`).join(''); el.scrollTop=el.scrollHeight; }catch(e){} },2000);