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
+53 -6
View File
@@ -1,7 +1,11 @@
"""
polygon v0.2.2 — Mock Nubes API.
polygon v0.5.0 — Mock Nubes API.
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
Поддержка нескольких стендов:
URL: /<stand_id>/api/v1/svc/... (stand_id из POLYGON_STANDS)
Состояние изолировано: у каждого стенда свой набор инстансов и операций.
Blueprint'ы (каждый — отдельный файл в routes/):
- root_bp → /health, / (HTML-страница)
- services_bp → /api/v1/svc/services, /services/<id>
@@ -15,16 +19,16 @@ Blueprint'ы (каждый — отдельный файл в routes/):
⛔ РОВНО 1 ВОРКЕР — состояние в памяти, не shared между процессами.
Переменные окружения:
POLYGON_STANDS — список стендов: name:path,name:path,...
MOCK_OP_DELAY — задержка операции в секундах (по умолчанию 0.1)
WEB_CONCURRENCY — число gunicorn-воркеров (принудительно 1)
"""
import os
from flask import Flask
from flask import Flask, g, request
# ⛔ Принудительно 1 воркер — состояние в памяти, иначе инстансы теряются.
# setdefault не перезапишет если Nubes/Procfile уже выставил WEB_CONCURRENCY=1.
os.environ.setdefault("WEB_CONCURRENCY", "1")
# Импорт blueprint'ов — каждый отвечает за свою группу маршрутов
@@ -36,14 +40,57 @@ from routes.run import bp as run_bp
from routes.mock_routes import bp as mock_bp
from routes.openapi import bp as openapi_bp
# Версия — единый источник правды в config/loader.py
from config.loader import VERSION
# config
import config.loader as _cfg
# Flask-приложение с Jinja2-шаблонами и статикой
app = Flask(__name__, template_folder="templates", static_folder="static")
# ═══════════════════════════════════════════════════════════════
# WSGI Middleware — перезапись PATH_INFO до Flask-роутинга
# ═══════════════════════════════════════════════════════════════
class StandMiddleware:
"""WSGI middleware: извлекает stand_id из URL и перезаписывает PATH_INFO.
/stand1/api/v1/svc/... → environ['stand_id']='stand1', PATH_INFO=/api/v1/svc/...
/api/v1/svc/... → environ['stand_id']=DEFAULT_STAND, без изменений
/health, /swagger, / → environ['stand_id']=DEFAULT_STAND, без изменений
Должен быть ДО Flask-роутинга, поэтому это middleware, а не before_request.
"""
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
path = environ.get("PATH_INFO", "/")
parts = path.split("/")
if len(parts) > 1 and parts[1] in _cfg.STANDS:
environ["polygon.stand_id"] = parts[1]
new_path = "/" + "/".join(parts[2:]) if len(parts) > 2 else "/"
environ["PATH_INFO"] = new_path
else:
environ["polygon.stand_id"] = _cfg.DEFAULT_STAND
return self.wsgi_app(environ, start_response)
app.wsgi_app = StandMiddleware(app.wsgi_app)
@app.before_request
def _set_stand():
"""Копирует stand_id из environ в g."""
g.stand_id = request.environ.get("polygon.stand_id", _cfg.DEFAULT_STAND)
# ═══════════════════════════════════════════════════════════════
# Регистрируем 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