From 944238d22492324a05f7add4126a948955873a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 31 Jul 2026 22:16:22 +0400 Subject: [PATCH] =?UTF-8?q?refactor:=20decouple=20=E2=80=94=20blueprint'?= =?UTF-8?q?=D1=8B=20+=20CSS/HTML=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20(=D0=BF=D0=BE=20=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D1=83=20app-autotest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 418 ++----------------------------- site/config/loader.py | 67 +++++ site/config_loader.py | 52 ---- site/from_stands.py | 8 +- site/mock_state.py | 6 +- site/routes/instances_routes.py | 89 +++++++ site/routes/mock_routes.py | 98 ++++++++ site/routes/operations_routes.py | 263 +++++++++++++++++++ site/routes/root.py | 47 ++++ site/routes/run.py | 62 +++++ site/routes/services_routes.py | 62 +++++ site/state_machine.py | 8 +- site/static/style.css | 31 +++ site/templates/index.html | 14 ++ site/utils/now.py | 17 ++ site/utils/pluralize.py | 25 ++ 16 files changed, 803 insertions(+), 464 deletions(-) create mode 100644 site/config/loader.py delete mode 100644 site/config_loader.py create mode 100644 site/routes/instances_routes.py create mode 100644 site/routes/mock_routes.py create mode 100644 site/routes/operations_routes.py create mode 100644 site/routes/root.py create mode 100644 site/routes/run.py create mode 100644 site/routes/services_routes.py create mode 100644 site/static/style.css create mode 100644 site/templates/index.html create mode 100644 site/utils/now.py create mode 100644 site/utils/pluralize.py diff --git a/site/app.py b/site/app.py index 66038c1..82ea7ba 100644 --- a/site/app.py +++ b/site/app.py @@ -1,10 +1,14 @@ """ -polygon v0.2.0 — Mock Nubes API. +polygon v0.2.1 — Mock Nubes API. Эмулятор REST API облачной платформы Nubes для интеграционных тестов. -Все эндпоинты с префиксом /api/v1/svc притворяются реальным Nubes API. -Состояние инстансов/операций — в памяти (MockState). -Конфиги сервисов — из services/*.yaml (сгенерированы from_stands.py). +Blueprint'ы (каждый — отдельный файл в routes/): + - root_bp → /health, / (HTML-страница) + - services_bp → /api/v1/svc/services, /services/ + - instances_bp → /api/v1/svc/instances (GET list, GET detail, POST create) + - operations_bp → /api/v1/svc/instanceOperations/* (create, default, status, params, validate) + - run_bp → /api/v1/svc/instanceOperations//run + - mock_bp → /api/v1/svc/_mock/* (reset, state, services, delay) Деплой: Nubes pythonk8s (managed service) с gunicorn: gunicorn app:app --workers 1 --bind 0.0.0.0:8000 @@ -16,408 +20,36 @@ polygon v0.2.0 — Mock Nubes API. """ import os -import time -from datetime import datetime, timezone -from flask import Flask, jsonify, make_response, request +from flask import Flask # ⛔ Принудительно 1 воркер — состояние в памяти, иначе инстансы теряются. # setdefault не перезапишет если Nubes/Procfile уже выставил WEB_CONCURRENCY=1. os.environ.setdefault("WEB_CONCURRENCY", "1") -import config_loader -import mock_state -import state_machine +# Импорт blueprint'ов — каждый отвечает за свою группу маршрутов +from routes.root import bp as root_bp +from routes.services_routes import bp as services_bp +from routes.instances_routes import bp as instances_bp +from routes.operations_routes import bp as operations_bp +from routes.run import bp as run_bp +from routes.mock_routes import bp as mock_bp VERSION = "0.2.1" -# Задержка операции (синхронный sleep в /run) -MOCK_OP_DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) - +# Flask-приложение с Jinja2-шаблонами и статикой app = Flask(__name__, template_folder="templates", static_folder="static") -# Загружаем сервисы при старте -_SERVICES, _OPS_INDEX = config_loader.load_services("services") -_STATE = mock_state.state +# Регистрируем blueprint'ы. +# Префиксы уже заданы в url_prefix каждого blueprint'а (кроме root — он без префикса). +app.register_blueprint(root_bp) # /health, / +app.register_blueprint(services_bp) # /api/v1/svc/services +app.register_blueprint(instances_bp) # /api/v1/svc/instances +app.register_blueprint(operations_bp) # /api/v1/svc/instanceOperations + /instanceOperationCfsParams +app.register_blueprint(run_bp) # /api/v1/svc/instanceOperations//run +app.register_blueprint(mock_bp) # /api/v1/svc/_mock/* -def _now(): - """UTC-строка в ISO-формате с 'Z'.""" - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -# =========================================================================== -# Health / Root -# =========================================================================== - -@app.route("/health") -def health(): - """Healthcheck для Nubes — всегда возвращает 200 OK.""" - return "OK" - - -@app.route("/") -def index(): - """Корневой эндпоинт — HTML с версией и статусом.""" - svc_count = len(_SERVICES) - inst_count = len(_STATE.instances) - return f""" - -polygon v{VERSION} - - -

polygon v{VERSION}

-

● running

-

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

-

Эндпоинты: /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"], - }) - - return jsonify({ - "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) diff --git a/site/config/loader.py b/site/config/loader.py new file mode 100644 index 0000000..f4f55b2 --- /dev/null +++ b/site/config/loader.py @@ -0,0 +1,67 @@ +""" +config/loader.py — загрузка сервисов и глобальные конфигурационные переменные. + +Этот модуль загружает все YAML-конфиги сервисов при первом импорте и хранит их +как модульные переменные. Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY +отсюда — без дублирования загрузки и без циклических импортов. + +Переменные модуля (вычисляются ОДИН раз при старте): + SERVICES — {service_id: service_def} все сервисы + OPS_INDEX — {svcOperationId: service_def} поиск сервиса по ID операции + DELAY — float, задержка операции из MOCK_OP_DELAY (по умолчанию 0.1) +""" + +import os +import yaml + +# Константы для загрузки YAML +_YAML_EXTS = (".yaml", ".yml") +_DEFAULT_SERVICES_DIR = "services" + + +def _load_all(services_dir=_DEFAULT_SERVICES_DIR): + """Загружает все YAML-файлы из директории сервисов. + + Возвращает кортеж (services, ops_index): + services — {service_id: {name, operations, cfsParams, stateParams, stateOut, ...}} + ops_index — {svcOperationId: service_def} для быстрого поиска «чей это opId» + """ + if not os.path.isdir(services_dir): + raise FileNotFoundError(f"Services directory not found: {services_dir}") + + services = {} + ops_index = {} + + # Итерируем ВСЕ .yaml/.yml — без хардкода количества сервисов + for fname in sorted(os.listdir(services_dir)): + if not fname.lower().endswith(_YAML_EXTS): + continue + + path = os.path.join(services_dir, fname) + with open(path, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) + + if not cfg: + continue + + svc_id = cfg["service_id"] + services[svc_id] = cfg + + # Индекс: svcOperationId → service_def (нужен для поиска «чей это opId») + for op in cfg.get("operations", []): + ops_index[op["svcOperationId"]] = cfg + + return services, ops_index + + +# --------------------------------------------------------------------------- +# Модульные переменные — инициализируются ОДИН раз при первом импорте. +# Все routes/*.py импортируют SERVICES, OPS_INDEX, DELAY отсюда. +# --------------------------------------------------------------------------- + +# Загружаем конфиги сервисов (37+ YAML из services/) +SERVICES, OPS_INDEX = _load_all() + +# Задержка операции в секундах (для синхронного sleep в /run). +# Меняется на лету через POST /_mock/delay/ — см. mock_routes.py. +DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) diff --git a/site/config_loader.py b/site/config_loader.py deleted file mode 100644 index 42f65eb..0000000 --- a/site/config_loader.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -config_loader.py — загрузка сгенерированных YAML-конфигов сервисов. - -Читает все .yaml из services/, строит два индекса: - - services: {service_id: service_def} - - ops_index: {svcOperationId: service_def} (для поиска по opId) -""" - -import os - -import yaml - -YAML_EXTS = (".yaml", ".yml") - - -def load_services(services_dir="services"): - """Загружает все сервисы из директории YAML-конфигов. - - Возвращает (services, ops_index): - services — dict[service_id] = service_def - ops_index — dict[svcOperationId] = service_def - - service_def содержит: - name, service_id, service_display_name, service_short_name, - lifecycle, operations, cfsParams, cfsParamsByOp, - stateParams, stateOut - """ - if not os.path.isdir(services_dir): - raise FileNotFoundError(f"Services directory not found: {services_dir}") - - services = {} - ops_index = {} - - for fname in sorted(os.listdir(services_dir)): - if not fname.lower().endswith(YAML_EXTS): - continue - - path = os.path.join(services_dir, fname) - with open(path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) - - if not cfg: - continue - - svc_id = cfg["service_id"] - services[svc_id] = cfg - - # Индекс по svcOperationId — для поиска «какому сервису принадлежит операция» - for op in cfg.get("operations", []): - ops_index[op["svcOperationId"]] = cfg - - return services, ops_index diff --git a/site/from_stands.py b/site/from_stands.py index 3b7120e..76d3fd1 100644 --- a/site/from_stands.py +++ b/site/from_stands.py @@ -37,13 +37,7 @@ def _ue(s): return s -def _pluralize(word): - """Простейшая плюрализация: user→users, database→databases.""" - if word.endswith("s"): - return word - if word.endswith("y") and word[-2] not in "aeiou": - return word[:-1] + "ies" - return word + "s" +from utils.pluralize import pluralize as _pluralize # --------------------------------------------------------------------------- diff --git a/site/mock_state.py b/site/mock_state.py index 6ed737e..c2a14a7 100644 --- a/site/mock_state.py +++ b/site/mock_state.py @@ -10,12 +10,8 @@ mock_state.py — состояние мок-полигона в памяти. """ import uuid -from datetime import datetime, timezone - -def _now(): - """UTC-строка в ISO-формате с 'Z' (как в реальном API).""" - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +from utils.now import now as _now class MockState: diff --git a/site/routes/instances_routes.py b/site/routes/instances_routes.py new file mode 100644 index 0000000..14dfc1f --- /dev/null +++ b/site/routes/instances_routes.py @@ -0,0 +1,89 @@ +""" +routes/instances_routes.py — эндпоинты инстансов. + +Blueprint "instances" с префиксом /api/v1/svc/instances. + GET /instances — список с пагинацией + GET /instances/ — полные данные инстанса (state.params + state.out) + POST /instances — создать shell инстанса → 201 + Location: ./{uid} + +КРИТИЧНО: POST /instances ОБЯЗАН возвращать заголовок Location. +HttpClient.post() в app-autotest достаёт instanceUid ИМЕННО из Location. +""" + +from flask import Blueprint, jsonify, make_response, request + +import mock_state +from config.loader import SERVICES + +bp = Blueprint("instances", __name__, url_prefix="/api/v1/svc/instances") + + +@bp.route("", methods=["GET"]) +@bp.route("/", methods=["GET"]) +def list_instances(): + """GET /api/v1/svc/instances — список инстансов с пагинацией. + + Query-параметры: + pageSize — размер страницы (по умолчанию 200, максимум 200) + page — номер страницы (по умолчанию 1) + + Возвращает {results, pageSize, page, total}. + Пагинация: стоп по len(batch) < pageSize (как в реальном API). + """ + page_size = request.args.get("pageSize", 200, type=int) + page = request.args.get("page", 1, type=int) + result = mock_state.state.list_instances(page_size=page_size, page=page) + return jsonify(result) + + +@bp.route("/", methods=["GET"]) +def get_instance(uid): + """GET /api/v1/svc/instances/{uid} — полные данные инстанса. + + Возвращает {instance: {instanceUid, serviceId, displayName, status, + explainedStatus, svc, dtCreate, state: {params: {...}, out: {...}}}}. + + Если инстанс не найден — 404 с JSON-ошибкой. + """ + inst = mock_state.state.get_instance(uid) + if not inst: + return jsonify({"error": "instance not found"}), 404 + + return jsonify({"instance": dict(inst)}) + + +@bp.route("", methods=["POST"]) +@bp.route("/", methods=["POST"]) +def create_instance(): + """POST /api/v1/svc/instances — создать shell инстанса. + + Ожидает JSON: {serviceId: int, displayName: str?} + + ⛔ КРИТИЧНО: возвращает 201 + заголовок Location: ./{instanceUid}. + HttpClient.post() в app-autotest парсит Location чтобы получить UUID: + - Берёт последний сегмент после / + - Проверяет len(сегмент) >= 32 (длина UUID без дефисов) + + Также возвращает {"instanceUid": uid} в теле — двойная защита + на случай если заголовок не дойдёт. + """ + body = request.get_json(silent=True) or {} + service_id = body.get("serviceId") + display_name = body.get("displayName", "unnamed") + + # Валидация: serviceId обязателен + 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 + + # Создать shell инстанса (статус "creating") + uid = mock_state.state.create_instance(service_id, display_name, svc_def) + + # ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит instanceUid + resp = make_response(jsonify({"instanceUid": uid}), 201) + resp.headers["Location"] = f"./{uid}" + return resp diff --git a/site/routes/mock_routes.py b/site/routes/mock_routes.py new file mode 100644 index 0000000..5ee2c7a --- /dev/null +++ b/site/routes/mock_routes.py @@ -0,0 +1,98 @@ +""" +routes/mock_routes.py — служебные эндпоинты /_mock/*. + +Blueprint "mock" с префиксом /api/v1/svc/_mock. + POST /reset — полный сброс состояния (для тестов) + GET /state — отладочный дамп instances + operations + GET /services — отладочный список загруженных сервисов + POST /delay/ — изменить MOCK_OP_DELAY на лету (не перезапуская сервер) +""" + +from flask import Blueprint, jsonify + +import mock_state +from config.loader import SERVICES, DELAY as _DELAY +import config.loader as _cfg # для модификации модульной переменной DELAY + +bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock") + + +@bp.route("/reset", methods=["POST"]) +def mock_reset(): + """POST /api/v1/svc/_mock/reset — полный сброс состояния. + + Удаляет ВСЕ инстансы, операции и параметры из MockState. + Используется в тестах (autouse fixture) для изоляции тестов друг от друга. + """ + mock_state.state.reset() + return jsonify({"reset": "ok"}) + + +@bp.route("/state", methods=["GET"]) +def mock_state_view(): + """GET /api/v1/svc/_mock/state — отладочный дамп состояния. + + Возвращает {instances: {uid: {instanceUid, displayName, status, serviceId}}, + operations: {uid: {instanceOperationUid, instanceUid, operation, + dtFinish, isSuccessful}}}. + + Полезно для отладки тестов: «какие инстансы сейчас живы?», «завершилась ли + операция?». НЕ для production-использования. + """ + st = mock_state.state + return jsonify({ + "instances": { + uid: { + "instanceUid": inst["instanceUid"], + "displayName": inst["displayName"], + "status": inst["status"], + "serviceId": inst["serviceId"], + } + for uid, inst in st.instances.items() + }, + "operations": { + uid: { + "instanceOperationUid": op["instanceOperationUid"], + "instanceUid": op["instanceUid"], + "operation": op["operation"], + "dtFinish": op["dtFinish"], + "isSuccessful": op["isSuccessful"], + } + for uid, op in st.operations.items() + }, + }) + + +@bp.route("/services", methods=["GET"]) +def mock_services(): + """GET /api/v1/svc/_mock/services — отладочный список сервисов. + + Возвращает {count: N, services: {svcId: {name, displayName, operations, params}}}. + + Полезно чтобы быстро проверить что все 37+ сервисов загрузились, + не дёргая реальный /api/v1/svc/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}) + + +@bp.route("/delay/", methods=["POST"]) +def mock_set_delay(seconds): + """POST /api/v1/svc/_mock/delay/{s} — изменить задержку операции. + + Меняет config.loader.DELAY «на лету», без перезапуска сервера. + Полезно в тестах: установить delay=0 для мгновенных операций, + или delay=5 чтобы проверить таймауты поллинга. + + ВАЖНО: меняет модульную переменную config.loader.DELAY, которую читает + routes/run.py при каждом вызове /run. + """ + _cfg.DELAY = seconds + return jsonify({"delay": _cfg.DELAY}) diff --git a/site/routes/operations_routes.py b/site/routes/operations_routes.py new file mode 100644 index 0000000..ef82aa0 --- /dev/null +++ b/site/routes/operations_routes.py @@ -0,0 +1,263 @@ +""" +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 + +ВАЖНО: порядок маршрутов в蓝图 критичен. + /instanceOperations/default/ ДОЛЖЕН быть выше + /instanceOperations/ +Иначе Flask распарсит "default" как uid и уйдёт не в тот обработчик. +""" + +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 diff --git a/site/routes/root.py b/site/routes/root.py new file mode 100644 index 0000000..a8a7baf --- /dev/null +++ b/site/routes/root.py @@ -0,0 +1,47 @@ +""" +routes/root.py — корневые эндпоинты (/health, /). + +Blueprint "root" регистрируется в app.py БЕЗ url_prefix. +Отвечает за healthcheck (для Nubes) и HTML-страницу с информацией о сервисе. +""" + +from flask import Blueprint, render_template + +import mock_state +from config.loader import SERVICES, DELAY +from utils.now import now as _now + +# Версия — показывается на HTML-странице и в healthcheck (опционально) +VERSION = "0.2.1" + +# Blueprint без префикса — роуты /health и / будут на корне домена +bp = Blueprint("root", __name__) + + +@bp.route("/health") +def health(): + """Healthcheck для Nubes. + + Платформа периодически дёргает этот эндпоинт. Если вернёт не 200 — + контейнер будет перезапущен. Возвращаем просто "OK", без JSON. + """ + return "OK" + + +@bp.route("/") +def index(): + """Корневая HTML-страница с информацией о сервисе. + + Показывает: версию, количество загруженных сервисов, количество инстансов, + текущую задержку операций. Использует Jinja2-шаблон templates/index.html + и внешний CSS из static/style.css. + """ + svc_count = len(SERVICES) + inst_count = len(mock_state.state.instances) + return render_template( + "index.html", + version=VERSION, + svc_count=svc_count, + inst_count=inst_count, + delay=DELAY, + ) diff --git a/site/routes/run.py b/site/routes/run.py new file mode 100644 index 0000000..4a40c75 --- /dev/null +++ b/site/routes/run.py @@ -0,0 +1,62 @@ +""" +routes/run.py — запуск операции. + +Blueprint "run" с префиксом /api/v1/svc. + POST /instanceOperations//run — выполнить операцию. + +Вынесен в отдельный файл потому что логика run критична и специфична: +синхронный sleep, вызов state_machine.apply_effect, простановка dtFinish. +""" + +import time + +from flask import Blueprint, jsonify + +import mock_state +import state_machine +from config.loader import SERVICES, DELAY +from utils.now import now as _now + +bp = Blueprint("run", __name__, url_prefix="/api/v1/svc") + + +@bp.route("/instanceOperations//run", methods=["POST"]) +def run_operation(uid): + """POST /instanceOperations/{uid}/run — выполнить операцию. + + Алгоритм (синхронный, без потоков): + 1. Проверить что операция существует (→ 404 если нет) + 2. Записать dtStart = now() + 3. sleep(DELAY) — эмуляция времени выполнения + 4. Вызвать state_machine.apply_effect() — мутировать состояние инстанса + 5. Записать dtFinish = now(), isSuccessful = True + + После этого GET /instanceOperations/{uid} вернёт dtFinish, + и poll_until_done() в app-autotest завершит поллинг. + + ⛔ Нет защиты от повторного run — если вызвать дважды, dtStart + перезапишется и эффект применится повторно. Для тестовых целей + (MOCK_OP_DELAY ≤ 0.5с) вероятность минимальна. + + Возвращает {"ok": True} — формат как в реальном Nubes API. + """ + op = mock_state.state.get_operation(uid) + if not op: + return jsonify({"error": "operation not found"}), 404 + + # Фиксируем время старта + op["dtStart"] = _now() + + # Эмулируем задержку выполнения (по умолчанию 0.1с) + if DELAY > 0: + time.sleep(DELAY) + + # Применить эффект операции к состоянию инстанса + # (create → running+params, delete → удалить, modify → мерж params, ...) + state_machine.apply_effect(uid, mock_state.state, SERVICES) + + # Фиксируем время завершения — операция выполнена успешно + op["dtFinish"] = _now() + op["isSuccessful"] = True + + return jsonify({"ok": True}) diff --git a/site/routes/services_routes.py b/site/routes/services_routes.py new file mode 100644 index 0000000..3924d99 --- /dev/null +++ b/site/routes/services_routes.py @@ -0,0 +1,62 @@ +""" +routes/services_routes.py — эндпоинты сервисов. + +Blueprint "services" с префиксом /api/v1/svc/services. + GET /services — список всех загруженных сервисов + GET /services/ — детали конкретного сервиса (список операций) +""" + +from flask import Blueprint, jsonify + +from config.loader import SERVICES + +bp = Blueprint("services", __name__, url_prefix="/api/v1/svc/services") + + +@bp.route("", methods=["GET"]) +@bp.route("/", methods=["GET"]) +def list_services(): + """GET /api/v1/svc/services — список всех сервисов. + + Возвращает {results: [{svcId, svc, svcShort}, ...]}. + Формат совпадает с реальным Nubes API: + svcId — числовой ID сервиса (1, 90, 91, ...) + svc — человекочитаемое имя (Болванка, PostgreSQL, ...) + svcShort — короткое имя (dummy, postgres, ...) + """ + 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}) + + +@bp.route("/", methods=["GET"]) +def get_service(svc_id): + """GET /api/v1/svc/services/{id} — детали сервиса. + + Возвращает {svc: {svc, svcShort, operations: [{svcOperationId, operation}]}}. + Если сервис не найден — 404 с JSON-ошибкой. + """ + svc_def = SERVICES.get(svc_id) + if not svc_def: + return jsonify({"error": "service not found"}), 404 + + # Собрать список операций сервиса (create, modify, delete, suspend, ...) + ops = [] + for op in svc_def.get("operations", []): + ops.append({ + "svcOperationId": op["svcOperationId"], + "operation": op["operation"], + }) + + return jsonify({ + "svc": { + "svc": svc_def.get("service_display_name", ""), + "svcShort": svc_def.get("service_short_name", svc_def.get("name", "")), + "operations": ops, + } + }) diff --git a/site/state_machine.py b/site/state_machine.py index 0607752..4d134ce 100644 --- a/site/state_machine.py +++ b/site/state_machine.py @@ -20,13 +20,7 @@ state_machine.py — apply_effect: применение результата о """ -def _pluralize(word): - """Простейшая плюрализация.""" - if word.endswith("s"): - return word - if word.endswith("y") and len(word) > 2 and word[-2] not in "aeiou": - return word[:-1] + "ies" - return word + "s" +from utils.pluralize import pluralize as _pluralize def apply_effect(op_uid, mock_state, services): diff --git a/site/static/style.css b/site/static/style.css new file mode 100644 index 0000000..97e8baf --- /dev/null +++ b/site/static/style.css @@ -0,0 +1,31 @@ +/* + * polygon — тёмная тема для корневой страницы (/). + * Минимальный CSS, без фреймворков. + */ + +body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 600px; + margin: 40px auto; + padding: 0 20px; + background: #111; + color: #eee; +} + +h1 { + color: #4af; +} + +.ok { + color: #4a4; +} + +.ver { + color: #888; +} + +code { + background: #333; + padding: 2px 6px; + border-radius: 4px; +} diff --git a/site/templates/index.html b/site/templates/index.html new file mode 100644 index 0000000..96367ee --- /dev/null +++ b/site/templates/index.html @@ -0,0 +1,14 @@ + + + + + polygon v{{ version }} + + + +

polygon v{{ version }}

+

● running

+

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

+

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

+ + diff --git a/site/utils/now.py b/site/utils/now.py new file mode 100644 index 0000000..73b275d --- /dev/null +++ b/site/utils/now.py @@ -0,0 +1,17 @@ +""" +now.py — утилита времени. + +Единственная функция: now() возвращает текущее UTC-время в ISO-формате с 'Z' +(как в реальном Nubes API). Используется в app.py (run_operation) и mock_state.py. +""" + +from datetime import datetime, timezone + + +def now(): + """Текущее UTC-время в ISO-формате с суффиксом 'Z'. + + Формат: "2026-07-31T20:30:00.123456Z" + Реальное Nubes API возвращает dtFinish/dtCreate именно в таком виде. + """ + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/site/utils/pluralize.py b/site/utils/pluralize.py new file mode 100644 index 0000000..2b408f8 --- /dev/null +++ b/site/utils/pluralize.py @@ -0,0 +1,25 @@ +""" +pluralize.py — плюрализация английских слов. + +Единственная функция: pluralize(word) — простейшая эвристика для образования +множественного числа. Используется в: + - from_stands.py: при генерации state_out_template (user→users, database→databases) + - state_machine.py: при apply_effect для subresource-операций +""" + + +def pluralize(word): + """Простейшая плюрализация английского слова. + + Правила: + - уже заканчивается на 's' → не меняем (status → status) + - согласная + 'y' → 'ies' (city → cities) + - всё остальное → + 's' (user → users, database → databases) + + НЕ полная морфология — только для predictable subresource-имён из STANDS YAML. + """ + if word.endswith("s"): + return word # уже во множественном (например "status") + if word.endswith("y") and len(word) > 2 and word[-2] not in "aeiou": + return word[:-1] + "ies" # "category" → "categories" + return word + "s" # "user" → "users", "database" → "databases"