refactor: decouple — blueprint'ы + CSS/HTML разделение (по шаблону app-autotest)

This commit is contained in:
2026-07-31 22:16:22 +04:00
parent 6a0d5d00a2
commit 944238d224
16 changed files with 803 additions and 464 deletions
+89
View File
@@ -0,0 +1,89 @@
"""
routes/instances_routes.py — эндпоинты инстансов.
Blueprint "instances" с префиксом /api/v1/svc/instances.
GET /instances — список с пагинацией
GET /instances/<uid> — полные данные инстанса (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("/<uid>", 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
+98
View File
@@ -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/<s> — изменить 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/<float:seconds>", 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})
+263
View File
@@ -0,0 +1,263 @@
"""
routes/operations_routes.py — эндпоинты операций (instanceOperations).
Blueprint "operations" с префиксом /api/v1/svc.
GET /instanceOperations/default/<int:op_id> — шаблон операции (cfsParams)
POST /instanceOperations — создать операцию → 201 + Location
GET /instanceOperations/<uid> — статус операции (+cfsParams)
POST /instanceOperationCfsParams — установить параметр
GET /instanceOperations/<uid>/validate-cfs — пустое тело, 200
ВАЖНО: порядок маршрутов в蓝图 критичен.
/instanceOperations/default/<int:op_id> ДОЛЖЕН быть выше
/instanceOperations/<uid>
Иначе 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/<int:op_id>
# ---------------------------------------------------------------------------
@bp.route("/instanceOperations/default/<int:op_id>", 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/<uid>
# ---------------------------------------------------------------------------
@bp.route("/instanceOperations/<uid>", 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/<uid>/validate-cfs
# ---------------------------------------------------------------------------
@bp.route("/instanceOperations/<uid>/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
+47
View File
@@ -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,
)
+62
View File
@@ -0,0 +1,62 @@
"""
routes/run.py — запуск операции.
Blueprint "run" с префиксом /api/v1/svc.
POST /instanceOperations/<uid>/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/<uid>/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})
+62
View File
@@ -0,0 +1,62 @@
"""
routes/services_routes.py — эндпоинты сервисов.
Blueprint "services" с префиксом /api/v1/svc/services.
GET /services — список всех загруженных сервисов
GET /services/<id> — детали конкретного сервиса (список операций)
"""
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("/<int:svc_id>", 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,
}
})