v0.5.0: 3 стенда — изоляция состояния, WSGI middleware, OpenAPI server selector, POLYGON_STANDS env

- 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/... → стенд по умолчанию
This commit is contained in:
2026-08-01 20:10:19 +04:00
parent 11f98738d0
commit 58d54f2a95
122 changed files with 25223 additions and 76 deletions
+9 -5
View File
@@ -10,10 +10,14 @@ Blueprint "instances" с префиксом /api/v1/svc/instances.
HttpClient.post() в app-autotest достаёт instanceUid ИМЕННО из Location.
"""
from flask import Blueprint, jsonify, make_response, request
from flask import Blueprint, g, jsonify, make_response, request
from werkzeug.local import LocalProxy
import mock_state
from config.loader import SERVICES
import config.loader as _cfg
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
bp = Blueprint("instances", __name__, url_prefix="/api/v1/svc/instances")
@@ -32,7 +36,7 @@ def list_instances():
"""
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)
result = state.list_instances(page_size=page_size, page=page)
return jsonify(result)
@@ -45,7 +49,7 @@ def get_instance(uid):
Если инстанс не найден — 404 с JSON-ошибкой.
"""
inst = mock_state.state.get_instance(uid)
inst = state.get_instance(uid)
if not inst:
return jsonify({"error": "instance not found"}), 404
@@ -81,7 +85,7 @@ def create_instance():
return jsonify({"error": f"service {service_id} not found"}), 404
# Создать shell инстанса (статус "creating")
uid = mock_state.state.create_instance(service_id, display_name, svc_def)
uid = state.create_instance(service_id, display_name, svc_def)
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит instanceUid
resp = make_response(jsonify({"instanceUid": uid}), 201)
+9 -5
View File
@@ -10,10 +10,14 @@ Blueprint "mock" с префиксом /api/v1/svc/_mock.
import os
from flask import Blueprint, jsonify, request, abort
from flask import Blueprint, g, jsonify, request, abort
from werkzeug.local import LocalProxy
import mock_state
import config.loader as _cfg # для чтения/записи модульных переменных DELAY, SERVICES
import config.loader as _cfg
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock")
@@ -44,7 +48,7 @@ def mock_reset():
Удаляет ВСЕ инстансы, операции и параметры из MockState.
Используется в тестах (autouse fixture) для изоляции тестов друг от друга.
"""
mock_state.state.reset()
state.reset()
return jsonify({"reset": "ok"})
@@ -59,7 +63,7 @@ def mock_state_view():
Полезно для отладки тестов: «какие инстансы сейчас живы?», «завершилась ли
операция?». НЕ для production-использования.
"""
st = mock_state.state
st = state
return jsonify({
"instances": {
uid: {
@@ -146,5 +150,5 @@ def mock_fail_next():
После использования флаг сбрасывается (one-shot).
Позволяет тестировать негативные сценарии.
"""
mock_state.state.fail_next = True
state.fail_next = True
return jsonify({"fail_next": True})
+15 -7
View File
@@ -20,6 +20,17 @@ bp = Blueprint("openapi", __name__, url_prefix="/api/v1/svc")
_SERVER_URL = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru")
def _build_servers():
"""Список серверов — по одному на каждый стенд."""
servers = []
for sid in _cfg.list_stands():
servers.append({
"url": f"{_SERVER_URL}/{sid}",
"description": f"Стенд: {sid}",
})
return servers
@bp.route("/openapi.json", methods=["GET"])
def openapi_spec():
"""GET /api/v1/svc/openapi.json — OpenAPI 3.1.0 спецификация.
@@ -56,18 +67,15 @@ def _build_spec():
"- Управляемый сброс через `/_mock/reset`\n"
"- Симуляция ошибок через `/_mock/fail-next`\n\n"
"### Авторизация\n"
f"Bearer-токен: `test-token-123` (настроен через `MOCK_AUTH_TOKEN`)"
"- Основные эндпоинты — без авторизации\n"
"- `_mock/*` — заголовок `X-Mock-Auth: test-token-123` "
"(настроен через `MOCK_AUTH_TOKEN`)"
),
"contact": {
"name": "Nubes Platform",
},
},
"servers": [
{
"url": _SERVER_URL,
"description": "Polygon (mock)",
},
],
"servers": _build_servers(),
"security": [],
"tags": [
{"name": "services", "description": "Сервисы (каталог)"},
+14 -9
View File
@@ -13,11 +13,16 @@ Blueprint "operations" с префиксом /api/v1/svc.
выше для ясности.
"""
from flask import Blueprint, jsonify, make_response, request
from flask import Blueprint, g, jsonify, make_response, request
from werkzeug.local import LocalProxy
import mock_state
import state_machine
from config.loader import SERVICES, OPS_INDEX
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")
@@ -135,7 +140,7 @@ def create_operation():
return jsonify({"error": "instanceUid is required"}), 400
# Проверка что инстанс существует
inst = mock_state.state.get_instance(instance_uid)
inst = state.get_instance(instance_uid)
if not inst:
return jsonify({"error": "instance not found"}), 404
@@ -165,7 +170,7 @@ def create_operation():
break
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
op_uid = mock_state.state.create_operation(instance_uid, svc_op_id, operation, kind, action)
op_uid = state.create_operation(instance_uid, svc_op_id, operation, kind, action)
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
@@ -192,7 +197,7 @@ def get_operation(uid):
app-autotest использует ?fields=dtFinish,isSuccessful для поллинга —
ждёт пока dtFinish != None.
"""
op = mock_state.state.get_operation(uid)
op = state.get_operation(uid)
if not op:
return jsonify({"error": "operation not found"}), 404
@@ -203,7 +208,7 @@ def get_operation(uid):
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)
op_params = state.get_params(uid)
result["cfsParams"] = _build_cfs_list(svc_def, svc_op_id, op_params)
return jsonify({"instanceOperation": result})
@@ -233,11 +238,11 @@ def set_operation_param():
return jsonify({"error": "instanceOperationUid and svcOperationCfsParamId required"}), 400
# Проверка что операция существует
if not mock_state.state.get_operation(op_uid):
if not 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)
state.set_param(op_uid, param_id, value)
return jsonify({})
@@ -256,7 +261,7 @@ def validate_cfs(uid):
Если вернуть jsonify({}), app-autotest распарсит но логика та же.
Пустое тело — канонический ответ реального Nubes API для validate-cfs.
"""
if not mock_state.state.get_operation(uid):
if not state.get_operation(uid):
return jsonify({"error": "operation not found"}), 404
return "", 200
+6 -3
View File
@@ -7,12 +7,15 @@ Blueprint "root" регистрируется в app.py БЕЗ url_prefix.
"""
import os
from flask import Blueprint, render_template
from flask import Blueprint, g, render_template
from werkzeug.local import LocalProxy
import mock_state
import config.loader as _cfg
# Версия — из config/loader.py (единый источник правды)
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
VERSION = _cfg.VERSION
# Токен для авторизации в Swagger UI (предзаполняется)
@@ -44,7 +47,7 @@ def index():
Использует Jinja2-шаблон templates/index.html и внешний CSS из static/style.css.
"""
svc_count = len(_cfg.SERVICES)
inst_count = len(mock_state.state.instances)
inst_count = len(state.instances)
# Список сервисов для таблицы: name, id, кол-во операций
svc_list = []
+7 -3
View File
@@ -10,13 +10,17 @@ Blueprint "run" с префиксом /api/v1/svc.
import time
from flask import Blueprint, jsonify
from flask import Blueprint, g, jsonify
from werkzeug.local import LocalProxy
import mock_state
import state_machine
import config.loader as _cfg
from utils.now import now as _now
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
bp = Blueprint("run", __name__, url_prefix="/api/v1/svc")
@@ -37,7 +41,7 @@ def run_operation(uid):
Возвращает {"ok": True} — формат как в реальном Nubes API.
Повторный вызов → 409 (operation already completed).
"""
op = mock_state.state.get_operation(uid)
op = state.get_operation(uid)
if not op:
return jsonify({"error": "operation not found"}), 404
@@ -55,7 +59,7 @@ def run_operation(uid):
time.sleep(_cfg.DELAY)
# Проверка /_mock/fail-next — симуляция падения операции
st = mock_state.state
st = state
if st.fail_next:
st.fail_next = False # one-shot: сбросить после использования
op["dtFinish"] = _now()
+5 -2
View File
@@ -6,9 +6,12 @@ Blueprint "services" с префиксом /api/v1/svc/services.
GET /services/<id> — детали конкретного сервиса (список операций)
"""
from flask import Blueprint, jsonify
from flask import Blueprint, g, jsonify
from werkzeug.local import LocalProxy
from config.loader import SERVICES
import config.loader as _cfg
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
bp = Blueprint("services", __name__, url_prefix="/api/v1/svc/services")