23 KiB
Polygon — эмулятор Nubes API
Версия: v0.5.9 | Деплой:
polygon.pythonk8s.dev.nubes.ru| Формат: Nubes managed Flask
1. Что такое Polygon
Polygon — эмулятор REST API облачной платформы Nubes для интеграционных тестов.
Он полностью повторяет контракты реального API (сервисы, инстансы, операции, параметры, валидацию), но работает без реальной инфраструктуры — в памяти, с мгновенным откликом.
Зачем нужен
- Тестировать создание/изменение/удаление инстансов без реальных облачных ресурсов
- Отлаживать UI автодеплоя (
app-autotest) на мок-данных - Писать интеграционные тесты с детерминированным состоянием
- Проверять краевые случаи (ошибки валидации, сбои операций)
- Демонстрировать API заказчикам через Swagger UI
Что НЕ делает
- Не управляет реальной инфраструктурой
- Не хранит данные между перезапусками (всё в памяти)
- Не авторизует пользователей (кроме
_mock/*служебных эндпоинтов) - Не повторяет ВСЕ эндпоинты реального API — только те что нужны для тестов
2. Архитектура
Технологический стек
| Компонент | Технология | Зачем |
|---|---|---|
| Веб-фреймворк | Flask 3.0 | Маршрутизация, шаблоны, JSON-ответы |
| WSGI-сервер | gunicorn | Запуск на проде (встроен в Nubes managed Flask) |
| Конфигурация | YAML (PyYAML) | Описание сервисов, операций, параметров |
| Состояние | In-memory dict | Инстансы, операции, параметры |
| Шаблоны | Jinja2 | HTML-страницы (главная, Swagger) |
| OpenAPI | OpenAPI 3.1.0 | Спека API (генерится динамически) |
| Swagger UI | Swagger UI 5 (CDN) | Интерактивная документация |
Ключевое ограничение: 1 воркер
# app.py
os.environ.setdefault("WEB_CONCURRENCY", "1")
Почему: состояние (инстансы, операции) хранится в памяти Python-процесса. Два воркера = два набора инстансов = хаос. Поэтому жёстко 1 gunicorn-воркер.
Следствия:
- Только 1 запрос обрабатывается одновременно
- Большие ответы (>20KB) могут обрываться из-за таймаута сети
- Swagger-спека встроена прямо в HTML чтобы избежать второго запроса
- Нельзя горизонтально масштабировать
Модель данных в памяти
MockState (синглтон на стенд)
├── instances: {instanceUid → {instanceUid, serviceId, displayName, status, state, ...}}
├── operations: {opUid → {instanceOperationUid, instanceUid, operation, dtStart, dtFinish, ...}}
├── op_params: {opUid → {paramId(int) → paramValue(str)}}
└── fail_next: bool (one-shot флаг для симуляции ошибок)
Жизненный цикл запроса
HTTP-запрос → Nubes ingress → gunicorn (1 воркер) → Flask
→ StandMiddleware (извлекает stand_id из URL, перезаписывает PATH_INFO)
→ before_request (копирует stand_id в flask.g)
→ Blueprint-роут (через LocalProxy резолвит state/SERVICES под текущий стенд)
→ JSON-ответ
3. Стенды (dev / test / prod)
Polygon эмулирует три изолированных стенда облака:
| Стенд | URL-префикс | Сервисов | YAML из |
|---|---|---|---|
| dev | /dev/api/v1/svc/... |
37 | ~/tf_provider/generated/dev/ |
| test | /test/api/v1/svc/... |
37 | ~/tf_provider/generated/test/ |
| prod | /prod/api/v1/svc/... |
35 | ~/tf_provider/generated/prod/ |
Изоляция: у каждого стенда свои инстансы, операции, параметры.
fail-next на dev не влияет на test. Состояние каждого стенда независимо.
Переключение в Swagger: выпадающий список серверов (Server selector).
Переключение в URL: просто добавь префикс — /dev/, /test/, /prod/.
Без префикса — стенд по умолчанию (dev).
Настройка: переменная окружения POLYGON_STANDS:
POLYGON_STANDS=dev:services/dev,test:services/test,prod:services/prod
Формат: имя_стенда:путь_к_YAML,имя_стенда:путь_к_YAML,...
4. YAML-пайплайн (откуда берутся сервисы)
Polygon не знает о сервисах сам — он читает их из YAML-конфигов, которые генерируются из терраформ-репы.
Цепочка
~/tf_provider/generated/{dev,test,prod}/resources_yaml/*.yaml ← источник (терраформ)
│
│ from_stands.py — конвертация формата (ВРУЧНУЮ)
│ • дедупликация параметров (один param в create/modify/delete → одна запись)
│ • построение cfsParamsByOp (какие параметры к какой операции)
│ • сбор stateParams из create-операции
│ • генерация stateOut из subresource-операций
│ • раскодирование HTML-entities (" → ")
│
▼
polygon/site/services/{dev,test,prod}/*.yaml ← скоммичены в git
│
│ config/loader.py — загрузка ВСЕХ YAML в память при старте
│ • _load_one() для каждой папки стенда
│ • STANDS = {dev: {SERVICES, OPS_INDEX}, test: {...}, prod: {...}}
│ • ~0.7 MB на 3 стенда
│
▼
Память Flask-процесса → API отдаёт данные из памяти (без диска)
Когда обновлять YAML
Когда терраформ-репа обновилась (добавили сервис, изменили параметры):
cd polygon/site
python from_stands.py ~/tf_provider/generated/dev/resources_yaml services/dev
python from_stands.py ~/tf_provider/generated/test/resources_yaml services/test
python from_stands.py ~/tf_provider/generated/prod/resources_yaml services/prod
cd .. && git add services/ && git commit -m "regenerate YAML" && git push
# → редеплоить polygon через Nubes UI
Это ручная операция. Нет автоматического триггера.
5. Структура кода
polygon/
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
├── Procfile # web: gunicorn site.app:app --bind 0.0.0.0:8000
├── tests/
│ ├── test_api.py # 39 smoke-тестов против деплоя
│ ├── fuzz_test.py # 147 фаззинг-тестов (злонамеренные входные данные)
│ ├── compare_test.py # Сравнение полигона с реальным Nubes API
│ ├── test_converter.py # Юнит-тесты from_stands.py
│ └── test_state_machine.py # Юнит-тесты apply_effect()
└── site/
├── app.py # Flask-приложение: StandMiddleware, blueprint'ы
├── mock_state.py # MockState — хранилище инстансов/операций
├── state_machine.py # apply_effect() — мутация состояния инстанса
├── from_stands.py # Конвертер terraform YAML → polygon YAML
├── config/
│ └── loader.py # Загрузка YAML → SERVICES, OPS_INDEX, STANDS
├── routes/
│ ├── root.py # /health, /, /swagger
│ ├── services_routes.py # /api/v1/svc/services
│ ├── instances_routes.py # /api/v1/svc/instances
│ ├── operations_routes.py # /api/v1/svc/instanceOperations/*
│ ├── run.py # /api/v1/svc/instanceOperations/<uid>/run
│ ├── mock_routes.py # /api/v1/svc/_mock/*
│ └── openapi.py # /api/v1/svc/openapi.json (OpenAPI 3.1.0 спека)
├── services/
│ ├── dev/ # 37 YAML-конфигов (стенд dev)
│ ├── test/ # 37 YAML-конфигов (стенд test)
│ └── prod/ # 35 YAML-конфигов (стенд prod)
├── static/
│ ├── style.css # Дизайн-система Nubes
│ ├── logo.svg # Логотип
│ └── favicon.svg # Иконка
├── templates/
│ ├── index.html # Главная страница (инфо + ссылки на стенды)
│ └── swagger.html # Swagger UI (спека встроена в HTML)
└── utils/
├── now.py # now() — UTC ISO с 'Z'
└── pluralize.py # pluralize() — плюрализация
Ключевые архитектурные решения
LocalProxy — все роуты используют werkzeug.local.LocalProxy для доступа к state и SERVICES:
# В каждом роуте:
from flask import g
from werkzeug.local import LocalProxy
import mock_state, config.loader as _cfg
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
Это позволяет одному и тому же коду роута работать с разными стендами — g.stand_id определяет какой стенд используется.
StandMiddleware (app.py) — WSGI-middleware который извлекает stand_id из URL ДО Flask-роутинга:
class StandMiddleware:
def __call__(self, environ, start_response):
path = environ["PATH_INFO"]
parts = path.split("/")
if parts[1] in STANDS:
environ["polygon.stand_id"] = parts[1]
environ["PATH_INFO"] = "/" + "/".join(parts[2:]) # /dev/api/... → /api/...
else:
environ["polygon.stand_id"] = DEFAULT_STAND
return self.wsgi_app(environ, start_response)
before_request (app.py) — копирует stand_id в flask.g:
@app.before_request
def _set_stand():
g.stand_id = request.environ.get("polygon.stand_id", DEFAULT_STAND)
6. API — 17 эндпоинтов
| Метод | Путь | Назначение | Ответ |
|---|---|---|---|
| GET | /health |
Healthcheck для Nubes | OK (text) |
| GET | / |
HTML с инфо + ссылки на стенды | HTML |
| GET | /swagger |
Swagger UI (спека в HTML) | HTML |
| GET | /api/v1/svc/openapi.json |
OpenAPI 3.1.0 спека | JSON |
| GET | /api/v1/svc/services |
Список сервисов | {results: [...]} |
| GET | /api/v1/svc/services/{id} |
Детали сервиса (операции) | {svc: {operations: [...]}} |
| GET | /api/v1/svc/instances |
Список инстансов (пагинация) | {results, pageSize, page, total} |
| GET | /api/v1/svc/instances/{uid} |
Полные данные инстанса | {instance: {...}} |
| POST | /api/v1/svc/instances |
Создать инстанс | 201 + Location + {instanceUid} |
| GET | /api/v1/svc/instanceOperations/default/{id} |
Шаблон операции (cfsParams) | {svcOperation: {cfsParams: [...]}} |
| POST | /api/v1/svc/instanceOperations |
Создать операцию | 201 + Location + {instanceOperationUid} |
| GET | /api/v1/svc/instanceOperations/{uid} |
Статус операции | {instanceOperation: {...}} |
| POST | /api/v1/svc/instanceOperationCfsParams |
Установить параметр | {} |
| GET | /api/v1/svc/instanceOperations/{uid}/validate-cfs |
Валидация параметров | "" (пустое тело) |
| POST | /api/v1/svc/instanceOperations/{uid}/run |
Выполнить операцию | {ok: true/false, error?} |
| POST | /api/v1/svc/_mock/reset |
Сброс состояния | {reset: "ok"} |
| GET | /api/v1/svc/_mock/state |
Дамп состояния (отладка) | {instances, operations} |
| GET | /api/v1/svc/_mock/services |
Список загруженных сервисов (отладка) | {count, services} |
| POST | /api/v1/svc/_mock/delay/{s} |
Задать задержку операций (max 5s) | {delay: N} |
| POST | /api/v1/svc/_mock/fail-next |
Следующая операция упадёт (one-shot) | {fail_next: true} |
Типовой сценарий: создать инстанс и запустить операцию
Это основной флоу, который повторяет логику реального облачного API.
Все примеры — с jq для наглядности, но работают и с python3 -m json.tool.
Шаг 1. Узнать ID сервиса по имени:
curl -s "$URL/services" | jq '.results[] | select(.svc == "НазваниеСервиса") | .svcId'
Шаг 2. Узнать ID операции у этого сервиса:
curl -s "$URL/services/$SVC_ID" | jq '.svc.operations[] | select(.operation == "create") | .svcOperationId'
Шаг 3. Получить список входных параметров (cfsParams):
curl -s "$URL/instanceOperations/default/$OP_ID" | jq '.svcOperation.cfsParams[] | {svcOperationCfsParamId, svcOperationCfsParam, dataType, valueList, isRequired}'
Ответ показывает для каждого параметра: числовой ID, код, тип данных, список допустимых значений (если есть), обязательность.
Шаг 4. Создать инстанс:
curl -s -X POST "$URL/instances" \
-H "Content-Type: application/json" \
-d '{"serviceId": '$SVC_ID', "displayName": "мой тестовый инстанс"}' \
| jq '.instanceUid'
Шаг 5. Создать операцию:
curl -s -X POST "$URL/instanceOperations" \
-H "Content-Type: application/json" \
-d '{"instanceUid": "'$INST_UID'", "operation": "create", "svcOperationId": '$OP_ID'}' \
| jq '.instanceOperationUid'
Шаг 6. Установить параметры (повторить для каждого):
curl -s -X POST "$URL/instanceOperationCfsParams" \
-H "Content-Type: application/json" \
-d '{"instanceOperationUid": "'$OP_UID'", "svcOperationCfsParamId": '$PARAM_ID', "paramValue": "значение"}'
Шаг 7. Проверить валидацию и запустить:
# Проверить что все параметры валидны (200 = OK)
curl -s -o /dev/null -w "%{http_code}" "$URL/instanceOperations/$OP_UID/validate-cfs"
# Запустить операцию (будет ждать DELAY секунд)
curl -s -X POST "$URL/instanceOperations/$OP_UID/run" | jq '{ok, error}'
Шаг 8. Проверить результат:
curl -s "$URL/instanceOperations/$OP_UID?fields=dtFinish,isSuccessful" | jq '.instanceOperation | {dtFinish, isSuccessful}'
Полигон полностью повторяет эту последовательность: те же эндпоинты, те же поля, те же статус-коды (201 при создании, 409 при повторном run, 404 при несуществующем ресурсе).
Валидация входных данных
serviceId— только integer > 0 (строка/float/отрицательное → 400)- Тело запроса — должно быть JSON-объектом (массив → 400)
svcOperationCfsParamId— только integer (строка → 400)- Повторный
run— 409 (already completed)
Аутентификация
- Основные эндпоинты — без авторизации
_mock/*— заголовокX-Mock-Auth(еслиMOCK_AUTH_TOKENзадан в env)- По умолчанию
MOCK_AUTH_TOKENне задан →_mock/*открыты
7. Тесты
test_api.py — 39 smoke-тестов
Запуск: python3 tests/test_api.py
Проверяет деплоенный полигон по всем 17 эндпоинтам:
- Health, services, instances (CRUD + pagination + edge cases)
- Operations (create → params → validate → run → double-run 409)
- Fail-next (one-shot error simulation)
- Auth (без токена, неверный токен, верный токен)
- Mock state (reset, state dump, delay bounds)
- Multi-stand (изоляция dev/test/prod, fail-next изоляция)
fuzz_test.py — 147 фаззинг-тестов
Запуск: cd site && PYTHONPATH=. python3 ../tests/fuzz_test.py (локально, Flask test client)
Злонамеренные и экстремальные сценарии:
- SQL injection, XSS, Unicode-emoji, null-байты, 10000-символьные строки
- Неверные типы (строка вместо int, float, отрицательные)
- Отсутствующие поля, null-поля
- Path traversal, двойные слеши
- Быстрые повторы (10 инстансов подряд)
- Невалидные UUID
- Неправильные HTTP-методы (PUT на GET, DELETE на POST)
compare_test.py — сравнение с реальным API
Запуск: python3 tests/compare_test.py
Сравнивает read-only эндпоинты полигона с реальным Nubes API (dev/test/prod):
GET /services— одинаковый ли список сервисовGET /services/{id}— одинаковые ли операции (svcOperationId, operation, kind, action)GET /instanceOperations/default/{id}— одинаковые ли cfsParams
Классификация расхождений:
- 🔴 BUG — полигон неправ (лишний/неверный параметр)
- 🟡 LAG — реальный API обогнал (новый параметр, YAML устарел)
- 🟢 BETTER — полигон правильнее реального API (null→"string", HTML entities)
Юнит-тесты
pytest tests/test_converter.py -v # 10 тестов from_stands.py
pytest tests/test_state_machine.py -v # 9 тестов apply_effect()
8. Переменные окружения
| Переменная | По умолчанию | Описание |
|---|---|---|
POLYGON_STANDS |
(пусто) | Стенды: dev:services/dev,test:services/test,prod:services/prod |
MOCK_AUTH_TOKEN |
(пусто) | Токен для _mock/*. Если пусто — auth отключена |
MOCK_OP_DELAY |
0.1 |
Задержка операции в секундах |
POLYGON_ENDPOINT |
https://polygon.pythonk8s.dev.nubes.ru |
URL для OpenAPI-спеке |
WEB_CONCURRENCY |
1 |
⛔ НЕ менять — сломает изоляцию состояния |
9. Ограничения и известные проблемы
| Ограничение | Причина | Обход |
|---|---|---|
| 1 gunicorn-воркер | Состояние в памяти | Не менять WEB_CONCURRENCY |
| Большие ответы (>20KB) обрываются | 1 воркер + сетевой таймаут | Swagger-спека встроена в HTML |
| Нет персистентности | Всё в памяти | Использовать _mock/reset в тестах |
| Auth не включена на проде | MOCK_AUTH_TOKEN не задан |
Добавить в env деплоя |
| YAML обновляется вручную | Нет автотриггера | Запускать from_stands.py при изменении терраформа |
| Нет HTTPS-валидации сертификатов | Dev-инструмент | — |
| Swagger загружается с CDN (jsdelivr) | Нет офлайн-версии | — |
10. Деплой
Polygon — managed Flask на Nubes pythonk8s.
Procfile:
web: gunicorn site.app:app --bind 0.0.0.0:8000
Процесс редеплоя:
- Закоммитить и запушить изменения в
master - В Nubes UI нажать «Redeploy»
- Nubes: стягивает репу, билдит, запускает gunicorn на порту 8000
- Healthcheck:
GET /health→ должен вернутьOK
Важно: YAML-конфиги (services/) скоммичены в git.
При редеплое Nubes клонирует репу → YAML уже на месте.
Не нужно запускать from_stands.py при деплое.
11. Чеклист для нового AI-агента
При старте нового чата прочитай:
- Этот документ
polygon/site/app.py— точка входаpolygon/site/config/loader.py— загрузка конфиговpolygon/site/routes/openapi.py— OpenAPI-спекаpolygon/tests/test_api.py— основной тест-сьют
Ключевые инварианты (НЕ нарушать):
- WEB_CONCURRENCY = 1 всегда
stateиSERVICES— через LocalProxy, не напрямую_mock/*требует X-Mock-Auth (если задан MOCK_AUTH_TOKEN)serviceIdвалидируется как int > 0- YAML обновляется через
from_stands.py, коммитится, редеплоится