diff --git a/site/app.py b/site/app.py index c5815e9..605b43f 100644 --- a/site/app.py +++ b/site/app.py @@ -2,21 +2,47 @@ polygon v0.1.0 — Mock Nubes API. Эмулятор REST API облачной платформы Nubes для интеграционных тестов. -Эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API. +Все эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API. Состояние инстансов/операций — в памяти (MockState). +Конфиги сервисов — из services/*.yaml (сгенерированы from_stands.py). Деплой: Nubes pythonk8s (managed service) с gunicorn: gunicorn app:app --workers 2 --bind 0.0.0.0:8000 + +Переменные окружения: + MOCK_OP_DELAY — задержка операции в секундах (по умолчанию 0.1) """ import os -from flask import Flask, jsonify, request +import time +from datetime import datetime, timezone + +from flask import Flask, jsonify, make_response, request + +import config_loader +import mock_state +import state_machine -# Версия полигона — независима от app-autotest VERSION = "0.1.0" +# Задержка операции (синхронный sleep в /run) +MOCK_OP_DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) + app = Flask(__name__, template_folder="templates", static_folder="static") +# Загружаем сервисы при старте +_SERVICES, _OPS_INDEX = config_loader.load_services("services") +_STATE = mock_state.state + + +def _now(): + """UTC-строка в ISO-формате с 'Z'.""" + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +# =========================================================================== +# Health / Root +# =========================================================================== @app.route("/health") def health(): @@ -26,41 +52,366 @@ def health(): @app.route("/") def index(): - """Корневой эндпоинт — информация о сервисе (HTML).""" + """Корневой эндпоинт — HTML с версией и статусом.""" + svc_count = len(_SERVICES) + inst_count = len(_STATE.instances) return f""" polygon v{VERSION} +h1{{color:#4af}}.ok{{color:#4a4}}.ver{{color:#888}}code{{background:#333;padding:2px 6px;border-radius:4px}}

polygon v{VERSION}

● running

-

Mock Nubes API — эмулятор REST API облачной платформы.

+

Сервисов: {svc_count}  |  Инстансов: {inst_count}  |  delay: {MOCK_OP_DELAY}s

Эндпоинты: /api/v1/svc/*

-

Сброс: POST /api/v1/svc/_mock/reset

""" -# --------------------------------------------------------------------------- -# Nubes API mock — префикс /api/v1/svc -# --------------------------------------------------------------------------- +# =========================================================================== +# Services (GET /services, GET /services/{id}) +# =========================================================================== + +@app.route("/api/v1/svc/services", methods=["GET"]) +def list_services(): + """GET /services — список всех сервисов.""" + results = [] + for svc_id, svc_def in _SERVICES.items(): + results.append({ + "svcId": svc_id, + "svc": svc_def.get("service_display_name", ""), + "svcShort": svc_def.get("service_short_name", svc_def.get("name", "")), + }) + return jsonify({"results": results}) + + +@app.route("/api/v1/svc/services/", methods=["GET"]) +def get_service(svc_id): + """GET /services/{id} — детали сервиса (список операций).""" + svc_def = _SERVICES.get(svc_id) + if not svc_def: + return jsonify({"error": "service not found"}), 404 + + ops = [] + for op in svc_def.get("operations", []): + ops.append({ + "svcOperationId": op["svcOperationId"], + "operation": op["operation"], + }) -@app.route("/api/v1/svc/instances", methods=["GET"]) -def list_instances(): - """GET /instances — пока пустой список.""" - page_size = request.args.get("pageSize", 200, type=int) return jsonify({ - "results": [], - "pageSize": min(page_size, 200), + "svc": { + "svc": svc_def.get("service_display_name", ""), + "svcShort": svc_def.get("service_short_name", svc_def.get("name", "")), + "operations": ops, + } }) +# =========================================================================== +# Instances (GET list, GET detail, POST create) +# =========================================================================== + +@app.route("/api/v1/svc/instances", methods=["GET"]) +def list_instances(): + """GET /instances — список с пагинацией.""" + page_size = request.args.get("pageSize", 200, type=int) + page = request.args.get("page", 1, type=int) + result = _STATE.list_instances(page_size=page_size, page=page) + return jsonify(result) + + +@app.route("/api/v1/svc/instances/", methods=["GET"]) +def get_instance(uid): + """GET /instances/{uid} — полные данные инстанса.""" + inst = _STATE.get_instance(uid) + if not inst: + return jsonify({"error": "instance not found"}), 404 + + return jsonify({"instance": dict(inst)}) + + +@app.route("/api/v1/svc/instances", methods=["POST"]) +def create_instance(): + """POST /instances — создать shell инстанса. + + Ожидает JSON: {serviceId, displayName} + Возвращает 201 + Location: ./{instanceUid} + {"instanceUid": uid} + """ + body = request.get_json(silent=True) or {} + service_id = body.get("serviceId") + display_name = body.get("displayName", "unnamed") + + if service_id is None: + return jsonify({"error": "serviceId is required"}), 400 + + svc_def = _SERVICES.get(service_id) + if not svc_def: + return jsonify({"error": f"service {service_id} not found"}), 404 + + uid = _STATE.create_instance(service_id, display_name, svc_def) + + resp = make_response(jsonify({"instanceUid": uid}), 201) + resp.headers["Location"] = f"./{uid}" + return resp + + +# =========================================================================== +# Instance Operations (create, get, params, validate, run) +# =========================================================================== + +# ВАЖНО: default/ ДОЛЖЕН быть выше — иначе Flask +# распарсит "default" как uid и уйдёт в get_operation с op_id="default". + +@app.route("/api/v1/svc/instanceOperations/default/", methods=["GET"]) +def get_operation_default(op_id): + """GET /instanceOperations/default/{id} — шаблон операции (cfsParams). + + Это ключевой эндпоинт для app-autotest: он получает отсюда + список параметров с dataDescriptor, valueList, isModifiable. + """ + svc_def = _OPS_INDEX.get(op_id) + if not svc_def: + return jsonify({"error": "operation not found"}), 404 + + # Найти операцию по svcOperationId + 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 для этой операции + 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: + cfs_list.append(dict(p)) + + return jsonify({ + "svcOperation": { + "svcOperationId": op_id, + "operation": target_op.get("operation", ""), + "cfsParams": cfs_list, + } + }) + + +@app.route("/api/v1/svc/instanceOperations", methods=["POST"]) +def create_operation(): + """POST /instanceOperations — создать операцию. + + Ожидает JSON: {instanceUid, svcOperationId?, operation} + Если operation="create" — svcOperationId может отсутствовать, + polygon находит его сам из service_def. + + Возвращает 201 + Location: ./{opUid} + {"instanceOperationUid": opUid} + """ + body = request.get_json(silent=True) or {} + instance_uid = body.get("instanceUid") + operation = body.get("operation", "") + svc_op_id = body.get("svcOperationId") + + if not instance_uid: + return jsonify({"error": "instanceUid is required"}), 400 + + inst = _STATE.get_instance(instance_uid) + if not inst: + return jsonify({"error": "instance not found"}), 404 + + # Если svcOperationId не передан — найти по operation name + 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 + + 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 + + op_uid = _STATE.create_operation(instance_uid, svc_op_id, operation, kind, action) + + resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201) + resp.headers["Location"] = f"./{op_uid}" + return resp + + +@app.route("/api/v1/svc/instanceOperations/", methods=["GET"]) +def get_operation(uid): + """GET /instanceOperations/{uid} — статус операции. + + Query params: ?fields=cfsParams,... — app-autotest фильтрует поля. + Мок отдаёт всё, фильтрация на клиенте. + """ + op = _STATE.get_operation(uid) + if not op: + return jsonify({"error": "operation not found"}), 404 + + result = dict(op) + + # Если запрошены cfsParams — добавить их + fields = request.args.get("fields", "") + if "cfsParams" in fields: + svc_op_id = op.get("svcOperationId") + svc_def = _OPS_INDEX.get(svc_op_id, {}) + 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(svc_op_id, []) + op_params = _STATE.get_params(uid) + + cfs_list = [] + for pid in param_ids: + p = cfs_params.get(pid) + if p: + entry = dict(p) + entry["paramValue"] = op_params.get(pid, "") + cfs_list.append(entry) + + result["cfsParams"] = cfs_list + + return jsonify({"instanceOperation": result}) + + +@app.route("/api/v1/svc/instanceOperationCfsParams", methods=["POST"]) +def set_operation_param(): + """POST /instanceOperationCfsParams — установить значение параметра. + + Ожидает JSON: {instanceOperationUid, svcOperationCfsParamId, paramValue} + """ + 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 _STATE.get_operation(op_uid): + return jsonify({"error": "operation not found"}), 404 + + _STATE.set_param(op_uid, param_id, value) + return jsonify({}) + + +@app.route("/api/v1/svc/instanceOperations//validate-cfs", methods=["GET"]) +def validate_cfs(uid): + """GET /instanceOperations/{uid}/validate-cfs — всегда OK. + + ⛔ КРИТИЧНО: возвращает пустое тело (200), НЕ jsonify. + app-autotest ловит JSONDecodeError и считает это успехом. + """ + if not _STATE.get_operation(uid): + return jsonify({"error": "operation not found"}), 404 + + return "", 200 + + +@app.route("/api/v1/svc/instanceOperations//run", methods=["POST"]) +def run_operation(uid): + """POST /instanceOperations/{uid}/run — выполнить операцию. + + Синхронный: sleep(MOCK_OP_DELAY) → apply_effect → dtFinish = now. + """ + op = _STATE.get_operation(uid) + if not op: + return jsonify({"error": "operation not found"}), 404 + + op["dtStart"] = _now() + + # Синхронная задержка + if MOCK_OP_DELAY > 0: + time.sleep(MOCK_OP_DELAY) + + # Применить эффект + state_machine.apply_effect(uid, _STATE, _SERVICES) + + op["dtFinish"] = _now() + op["isSuccessful"] = True + + return jsonify({"ok": True}) + + +# =========================================================================== +# Mock control (reset, state, services, delay) +# =========================================================================== + @app.route("/api/v1/svc/_mock/reset", methods=["POST"]) def mock_reset(): - """Сброс состояния (пока ничего нет).""" + """POST /_mock/reset — полный сброс состояния.""" + _STATE.reset() return jsonify({"reset": "ok"}) +@app.route("/api/v1/svc/_mock/state", methods=["GET"]) +def mock_state_view(): + """GET /_mock/state — отладочный дамп текущего состояния.""" + return jsonify({ + "instances": { + uid: { + "instanceUid": inst["instanceUid"], + "displayName": inst["displayName"], + "status": inst["status"], + "serviceId": inst["serviceId"], + } + for uid, inst in _STATE.instances.items() + }, + "operations": { + uid: { + "instanceOperationUid": op["instanceOperationUid"], + "instanceUid": op["instanceUid"], + "operation": op["operation"], + "dtFinish": op["dtFinish"], + "isSuccessful": op["isSuccessful"], + } + for uid, op in _STATE.operations.items() + }, + }) + + +@app.route("/api/v1/svc/_mock/services", methods=["GET"]) +def mock_services(): + """GET /_mock/services — список загруженных сервисов.""" + result = {} + for svc_id, svc_def in _SERVICES.items(): + result[str(svc_id)] = { + "name": svc_def.get("name", ""), + "displayName": svc_def.get("service_display_name", ""), + "operations": len(svc_def.get("operations", [])), + "params": len(svc_def.get("cfsParams", [])), + } + return jsonify({"count": len(result), "services": result}) + + +@app.route("/api/v1/svc/_mock/delay/", methods=["POST"]) +def mock_set_delay(seconds): + """POST /_mock/delay/{s} — изменить MOCK_OP_DELAY на лету.""" + global MOCK_OP_DELAY + MOCK_OP_DELAY = seconds + return jsonify({"delay": MOCK_OP_DELAY}) + + +# =========================================================================== +# Entry point +# =========================================================================== + # Локальный запуск (без gunicorn) — только для разработки if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)