- loader.py: STANDS dict, get_services/get_ops_index per stand, POLYGON_STANDS env
- mock_state.py: get_state(stand_id) — ленивая инициализация
- app.py: StandMiddleware — перезапись PATH_INFO до Flask-роутинга
- Все роуты: LocalProxy(state), LocalProxy(SERVICES/OPS_INDEX) через g.stand_id
- openapi.py: _build_servers() — по одному серверу на стенд
- services/stand{1,2,3}/ — 37 YAML на стенд из STANDS/dev и STANDS/test
- tests/test_api.py: +4 multi-stand теста (изоляция, fail-next, OpenAPI servers)
- Обратная совместимость: /api/v1/svc/... → стенд по умолчанию
268 lines
12 KiB
Python
268 lines
12 KiB
Python
"""
|
||
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
|
||
|
||
Порядок маршрутов: default/<int:op_id> (целое число) и <uid> (строка UUID)
|
||
различаются Flask/Werkzeug автоматически — конфликта нет, но default размещён
|
||
выше для ясности.
|
||
"""
|
||
|
||
from flask import Blueprint, g, jsonify, make_response, request
|
||
from werkzeug.local import LocalProxy
|
||
|
||
import mock_state
|
||
import state_machine
|
||
import config.loader as _cfg
|
||
|
||
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
|
||
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
|
||
OPS_INDEX = LocalProxy(lambda: _cfg.get_ops_index(g.stand_id))
|
||
|
||
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 = 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 = 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 = 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 = 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 state.get_operation(op_uid):
|
||
return jsonify({"error": "operation not found"}), 404
|
||
|
||
# Сохранить параметр (param_id — int, value — str)
|
||
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 state.get_operation(uid):
|
||
return jsonify({"error": "operation not found"}), 404
|
||
|
||
return "", 200
|