from flask import Blueprint, current_app, jsonify, request import uuid import os import fcntl from api.http_client import HttpClient, detect_endpoint, stand_name from operations.get_services import get_services, get_service_detail from operations.get_instances import get_instances from operations.tracker import add as tracker_add, list_all as tracker_list from operations.tracker import remove as tracker_remove bp = Blueprint("api_test", __name__) AUTOTEST_PREFIX = "autotest-" LOG_FILE = "/tmp/app-autotest.log" MAX_LOG_SIZE = 512 * 1024 # 512 KB — ротация MAX_LOG_LINES = 200 # сколько отдавать в /api/log def _log(msg): """Пишет в stdout (gunicorn) и в файл (UI-панель, общий для всех воркеров).""" print(msg, flush=True) try: with open(LOG_FILE, "a") as f: fcntl.flock(f, fcntl.LOCK_EX) f.write(msg + "\n") fcntl.flock(f, fcntl.LOCK_UN) # Ротация если разросся st = os.stat(LOG_FILE) if st.st_size > MAX_LOG_SIZE: with open(LOG_FILE, "r+") as f: fcntl.flock(f, fcntl.LOCK_EX) f.seek(st.st_size // 2) f.readline() # доесть строку rest = f.read() f.seek(0) f.truncate() f.write(rest) fcntl.flock(f, fcntl.LOCK_UN) except Exception: pass # молча — не ронять запрос из-за лога def _with_prefix(name): name = (name or "").strip() or "instance" return name if name.startswith(AUTOTEST_PREFIX) else f"{AUTOTEST_PREFIX}{name}" def _unique_display_name(client, requested_name): base_name = _with_prefix(requested_name) try: existing = { i.get("displayName", "") for i in get_instances(client) if str(i.get("displayName", "")).startswith(AUTOTEST_PREFIX) } except Exception: existing = set() if base_name not in existing: return base_name while True: candidate = f"{base_name}-{uuid.uuid4().hex[:6]}" if candidate not in existing: return candidate def _client(): token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] return HttpClient(endpoint, token) def _client_id(): """Извлечь ClientID из JWT токена (из cookie или env).""" import base64, json token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] try: parts = token.split(".") if len(parts) >= 2: payload = base64.urlsafe_b64decode(parts[1] + "==") return json.loads(payload).get("ClientID", "?") except Exception: pass return "?" def _stand(): """dev/test — по токену.""" token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] return stand_name(endpoint) @bp.route("/api/services") def api_services(): try: raw = get_services(_client()) svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] svc_list.sort(key=lambda s: s["svcId"]) return jsonify(svc_list) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/instances/list") def api_instances_list(): try: raw = get_instances(_client()) inst = [{"instanceUid": i["instanceUid"], "displayName": i["displayName"], "serviceId": i["serviceId"], "svc": i["svc"], "explainedStatus": i.get("explainedStatus", "?")} for i in raw] return jsonify(inst) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/operations/") def api_operations(svc_id): try: detail = get_service_detail(_client(), svc_id) ops = detail.get("operations", []) # только отслеживаемые инстансы этого сервиса tracked = tracker_list(_client_id(), _stand()) tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} tracked_uids = set(tracked_by_uid.keys()) instances = get_instances(_client()) nubes_uids = {i["instanceUid"] for i in instances} svc_instances = [i for i in instances if i.get("instanceUid") in tracked_uids and i.get("explainedStatus") not in ("deleted",)] # Добавить tracked инстансы, которых Nubes ещё не отдаёт (creating) for uid, t in tracked_by_uid.items(): if uid not in nubes_uids: svc_instances.append({ "instanceUid": uid, "displayName": t["displayName"], "serviceId": svc_id, "svc": detail.get("svc", ""), "explainedStatus": "creating", }) return jsonify({ "svc": detail.get("svc", ""), "operations": [{"svcOperationId": o["svcOperationId"], "operation": o["operation"]} for o in ops], "instances": svc_instances, }) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/params/") def api_params(op_id): """Параметры операции. Если передан instanceUid — с текущими значениями из API.""" try: instance_uid = request.args.get("instanceUid") _log(f"[api_params] op_id={op_id} instanceUid={instance_uid!r}") if instance_uid: # Текущие значения: GET /instances/{uid} → state.params + шаблон → слияние result = get_params_with_current_values(_client(), op_id, instance_uid) _log(f"[api_params] MERGED returning {len(result)} params from state.params") for p in result: _log(f"[api_params] {p['name']}: defaultValue={p['defaultValue']!r}") return jsonify({"params": result}) # Без instanceUid — шаблонные значения по умолчанию (CREATE) _log(f"[api_params] DEFAULT branch: template defaults") data = _client().get(f"/instanceOperations/default/{op_id}") params = data["svcOperation"]["cfsParams"] result = [] for p in params: dd = p.get("dataDescriptor") result.append({ "svcOperationCfsParamId": p["svcOperationCfsParamId"], "name": p.get("svcOperationCfsParam", ""), "dataType": p.get("dataType", ""), "isRequired": p.get("isRequired", False), "defaultValue": p.get("defaultValue"), "valueList": p.get("valueList"), "dataDescriptor": {k: {"dataType": v.get("dataType",""), "valueList": v.get("valueList",""), "isRequired": v.get("isRequired", False)} for k, v in dd.items()} if isinstance(dd, dict) else None, }) _log(f"[api_params] DEFAULT returning {len(result)} params") return jsonify({"params": result}) except Exception as e: import traceback _log(f"[api_params] EXCEPTION: {e}") traceback.print_exc() return jsonify({"error": str(e)}), 500 @bp.route("/api/test", methods=["POST"]) def api_test(): """Запустить операцию — возвращает opUid сразу, выполнение в фоне.""" import threading, time data = request.get_json() svc_id = data["serviceId"] op_name = data["operation"] svc_op_id = data["svcOperationId"] params = data.get("params", {}) instance_uid = data.get("instanceUid") display_name = data.get("displayName") or "" client = _client() try: if op_name == "create": if not display_name: display_name = f"autotest-{svc_id}" display_name = _unique_display_name(client, display_name) payload = {"serviceId": svc_id, "displayName": display_name, "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: return jsonify({"status": "FAIL", "error": "Не удалось получить instanceUid"}), 500 op_payload = {"instanceUid": instance_uid, "operation": "create"} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 # Записать в трекер СРАЗУ после получения instanceUid, до params/run # (если params упадут — инстанс уже отслежен, сирот не будет) try: tracker_add(_client_id(), _stand(), instance_uid, svc_id, display_name) except Exception as e: import traceback print(f"[TRACKER ERROR] add failed: {e}", flush=True) traceback.print_exc() for pid, pval in params.items(): client.post("/instanceOperationCfsParams", {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) client.post(f"/instanceOperations/{op_uid}/run") # фоном ждать завершения threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, _client_id(), _stand()), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid, "displayName": display_name}) else: if not instance_uid: return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400 if op_name == "redeploy": op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 client.post(f"/instanceOperations/{op_uid}/run") else: op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 for pid, pval in params.items(): client.post("/instanceOperationCfsParams", {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) client.post(f"/instanceOperations/{op_uid}/run") # фоном ждать завершения is_delete = (op_name == "delete") # Получить реальное имя инстанса из API (не UUID!) if not display_name: display_name = _get_instance_display_name(client, instance_uid) threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, False, _client_id(), _stand(), is_delete), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid, "displayName": display_name}) except Exception as e: import traceback return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]}) # Результаты фоновых операций: opUid → {status, error, stages, duration} _op_results = {} def _get_instance_display_name(client, instance_uid): """Получить displayName инстанса из GET /instances/{uid}.""" try: data = client.get(f"/instances/{instance_uid}") name = data.get("instance", {}).get("displayName", "") return name or instance_uid # fallback — UUID если имя пустое except Exception: return instance_uid def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False): """Фоном ждать dtFinish и сохранить результат.""" import time t0 = time.time() deadline = t0 + 300 while time.time() < deadline: try: try: data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") except Exception: time.sleep(5) continue op = data.get("instanceOperation", {}) dt_finish = op.get("dtFinish") # Обновляем stages по мере появления _op_results[op_uid] = { "status": "RUNNING", "displayName": display_name, "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), } if dt_finish and str(dt_finish).strip(): is_ok = op.get("isSuccessful") err = op.get("errorLog") or "" print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True) # tracker_add для create уже вызван синхронно в api_test() if is_ok and is_delete: tracker_remove(client_id, stand, instance_uid) _op_results[op_uid] = { "status": "OK" if is_ok else "FAIL", "displayName": display_name, "error": str(err) if err else "", "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), } return time.sleep(5) except Exception: import traceback 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)} @bp.route("/api/test/status/") def api_test_status(op_uid): """Получить текущий статус операции (поллинг с UI).""" # сначала проверяем фоновый трекер if op_uid in _op_results: return jsonify(_op_results[op_uid]) # иначе спрашиваем API напрямую try: data = _client().get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") op = data.get("instanceOperation", {}) dt_finish = op.get("dtFinish") done = bool(dt_finish and str(dt_finish).strip()) return jsonify({ "status": "OK" if (done and op.get("isSuccessful")) else ("FAIL" if done else "RUNNING"), "done": done, "displayName": op.get("displayName"), "isSuccessful": op.get("isSuccessful"), "isInProgress": op.get("isInProgress"), "duration": op.get("duration"), "stages": op.get("stages", []), "errorLog": op.get("errorLog"), }) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/log") def api_log(): """Отдать последние MAX_LOG_LINES строк лога для UI-панели.""" try: with open(LOG_FILE, "r") as f: fcntl.flock(f, fcntl.LOCK_SH) lines = f.readlines() fcntl.flock(f, fcntl.LOCK_UN) return jsonify([l.rstrip("\n") for l in lines[-MAX_LOG_LINES:]]) except FileNotFoundError: return jsonify([]) except Exception: return jsonify([]) def _find_uid(resp): """Извлечь instanceUid или instanceOperationUid из ответа API.""" if isinstance(resp, dict): for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"): v = resp.get(key) if isinstance(v, str) and v: return v return None def _uid_from_location(loc): if loc: parts = loc.rstrip("/").split("/") if len(parts[-1]) == 36: return parts[-1] return None