""" routes/operations_routes.py — эндпоинты операций (instanceOperations). Blueprint "operations" с префиксом /api/v1/svc. GET /instanceOperations/default/ — шаблон операции (cfsParams) POST /instanceOperations — создать операцию → 201 + Location GET /instanceOperations/ — статус операции (+cfsParams) POST /instanceOperationCfsParams — установить параметр GET /instanceOperations//validate-cfs — пустое тело, 200 Порядок маршрутов: default/ (целое число) и (строка UUID) различаются Flask/Werkzeug автоматически — конфликта нет, но default размещён выше для ясности. """ from flask import Blueprint, jsonify, make_response, request import mock_state import state_machine from config.loader import SERVICES, OPS_INDEX bp = Blueprint("operations", __name__, url_prefix="/api/v1/svc") # --------------------------------------------------------------------------- # Хелпер: сборка cfsParams-списка для операции # --------------------------------------------------------------------------- def _build_cfs_list(svc_def, op_id, op_params=None): """Собрать список cfsParams для заданной операции. Используется в двух местах: 1. get_operation_default — шаблон БЕЗ paramValue 2. get_operation — статус операции С paramValue из op_params Args: svc_def — конфиг сервиса (из SERVICES или OPS_INDEX) op_id — svcOperationId op_params — {paramId: value} или None (если не нужны текущие значения) Returns: [{svcOperationCfsParamId, svcOperationCfsParam, dataType, ..., paramValue?}] """ cfs_params_by_op = svc_def.get("cfsParamsByOp", {}) cfs_params = {p["svcOperationCfsParamId"]: p for p in svc_def.get("cfsParams", [])} param_ids = cfs_params_by_op.get(op_id, []) cfs_list = [] for pid in param_ids: p = cfs_params.get(pid) if p: entry = dict(p) # Если переданы op_params — добавить текущее значение параметра if op_params is not None: entry["paramValue"] = op_params.get(pid, "") cfs_list.append(entry) return cfs_list # --------------------------------------------------------------------------- # GET /instanceOperations/default/ # --------------------------------------------------------------------------- @bp.route("/instanceOperations/default/", methods=["GET"]) def get_operation_default(op_id): """GET /instanceOperations/default/{id} — шаблон операции. КЛЮЧЕВОЙ эндпоинт для app-autotest. Именно отсюда executor берёт список параметров (cfsParams) с: - dataDescriptor (подполя map-fixed: cpu, memory, replicas, ...) - valueList (допустимые значения для select/дропдаунов) - isModifiable (какие параметры можно менять при modify) - isRequired, defaultValue Без этого эндпоинта app-autotest не сможет отрендерить форму параметров. Если op_id не найден ни в одном сервисе — 404. """ # Ищем сервис по ID операции через OPS_INDEX (построен в config/loader.py) svc_def = OPS_INDEX.get(op_id) if not svc_def: return jsonify({"error": "operation not found"}), 404 # Найти операцию в списке операций сервиса target_op = None for op in svc_def.get("operations", []): if op["svcOperationId"] == op_id: target_op = op break if not target_op: return jsonify({"error": "operation not found"}), 404 # Собрать cfsParams без paramValue (шаблон) cfs_list = _build_cfs_list(svc_def, op_id) return jsonify({ "svcOperation": { "svcOperationId": op_id, "operation": target_op.get("operation", ""), "cfsParams": cfs_list, } }) # --------------------------------------------------------------------------- # POST /instanceOperations # --------------------------------------------------------------------------- @bp.route("/instanceOperations", methods=["POST"]) def create_operation(): """POST /instanceOperations — создать операцию для инстанса. Ожидает JSON: {instanceUid: str, operation: str, svcOperationId?: int} Особый случай: когда operation="create", app-autotest НЕ передаёт svcOperationId (потому что на этом этапе executor ещё не знает ID create-операции). Polygon находит svcOperationId сам через service_def. ⛔ КРИТИЧНО: возвращает 201 + заголовок Location: ./{opUid}. HttpClient.post() достаёт instanceOperationUid из Location. Также заполняет kind и action операции из конфига сервиса — они нужны state_machine.apply_effect() чтобы понять что делать при run. """ body = request.get_json(silent=True) or {} instance_uid = body.get("instanceUid") operation = body.get("operation", "") svc_op_id = body.get("svcOperationId") # Валидация: instanceUid обязателен if not instance_uid: return jsonify({"error": "instanceUid is required"}), 400 # Проверка что инстанс существует inst = mock_state.state.get_instance(instance_uid) if not inst: return jsonify({"error": "instance not found"}), 404 # Если svcOperationId не передан — найти по имени операции # (например app-autotest при create не знает ID, передаёт только "create") if svc_op_id is None: svc_id = inst.get("serviceId") svc_def = SERVICES.get(svc_id, {}) for op in svc_def.get("operations", []): if op["operation"] == operation: svc_op_id = op["svcOperationId"] break # Если и после поиска не нашли — операция не поддерживается сервисом if svc_op_id is None: return jsonify({"error": f"operation '{operation}' not found for service"}), 404 # Определить kind и action операции из конфига # (один проход — поиск по svcOperationId, не по имени) svc_def = OPS_INDEX.get(svc_op_id, {}) kind = "instance" action = operation for op in svc_def.get("operations", []): if op["svcOperationId"] == svc_op_id: kind = op.get("kind", "instance") action = op.get("action", operation) break # Создать операцию в MockState (dtFinish=None — ещё не выполнена) op_uid = mock_state.state.create_operation(instance_uid, svc_op_id, operation, kind, action) # ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201) resp.headers["Location"] = f"./{op_uid}" return resp # --------------------------------------------------------------------------- # GET /instanceOperations/ # --------------------------------------------------------------------------- @bp.route("/instanceOperations/", methods=["GET"]) def get_operation(uid): """GET /instanceOperations/{uid} — статус операции. Query-параметры: ?fields=cfsParams,... — если содержит "cfsParams", добавляет в ответ список параметров с ТЕКУЩИМИ paramValue. Возвращает {instanceOperation: {instanceOperationUid, instanceUid, svcOperationId, operation, kind, action, dtStart, dtFinish, isSuccessful, errorLog, svc, stages, cfsParams?}} app-autotest использует ?fields=dtFinish,isSuccessful для поллинга — ждёт пока dtFinish != None. """ op = mock_state.state.get_operation(uid) if not op: return jsonify({"error": "operation not found"}), 404 result = dict(op) # Если клиент запросил cfsParams — добавить их с текущими paramValue fields = request.args.get("fields", "") if "cfsParams" in fields: svc_op_id = op.get("svcOperationId") svc_def = OPS_INDEX.get(svc_op_id, {}) op_params = mock_state.state.get_params(uid) result["cfsParams"] = _build_cfs_list(svc_def, svc_op_id, op_params) return jsonify({"instanceOperation": result}) # --------------------------------------------------------------------------- # POST /instanceOperationCfsParams # --------------------------------------------------------------------------- @bp.route("/instanceOperationCfsParams", methods=["POST"]) def set_operation_param(): """POST /instanceOperationCfsParams — установить значение параметра операции. Ожидает JSON: {instanceOperationUid, svcOperationCfsParamId, paramValue} Вызывается N раз (по одному на каждый параметр) перед /run. Значения сохраняются в mock_state.op_params[opUid][paramId] = value. При apply_effect (modify) значения мержатся в state.params инстанса. """ body = request.get_json(silent=True) or {} op_uid = body.get("instanceOperationUid") param_id = body.get("svcOperationCfsParamId") value = body.get("paramValue", "") # Валидация: оба поля обязательны if not op_uid or param_id is None: return jsonify({"error": "instanceOperationUid and svcOperationCfsParamId required"}), 400 # Проверка что операция существует if not mock_state.state.get_operation(op_uid): return jsonify({"error": "operation not found"}), 404 # Сохранить параметр (param_id — int, value — str) mock_state.state.set_param(op_uid, param_id, value) return jsonify({}) # --------------------------------------------------------------------------- # GET /instanceOperations//validate-cfs # --------------------------------------------------------------------------- @bp.route("/instanceOperations//validate-cfs", methods=["GET"]) def validate_cfs(uid): """GET /instanceOperations/{uid}/validate-cfs — валидация параметров. ⛔ КРИТИЧНО: возвращает ПУСТОЕ тело с кодом 200. НЕ использовать jsonify() — app-autotest делает requests.get().json() и ловит JSONDecodeError, считая пустой ответ = успех. Если вернуть jsonify({}), app-autotest распарсит но логика та же. Пустое тело — канонический ответ реального Nubes API для validate-cfs. """ if not mock_state.state.get_operation(uid): return jsonify({"error": "operation not found"}), 404 return "", 200