# 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 воркер ```python # 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 Когда терраформ-репа обновилась (добавили сервис, изменили параметры): ```bash 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//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`: ```python # В каждом роуте: 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-роутинга: ```python 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`: ```python @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}` | ### Валидация входных данных - `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) ### Юнит-тесты ```bash 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 ``` **Процесс редеплоя:** 1. Закоммитить и запушить изменения в `master` 2. В Nubes UI нажать «Redeploy» 3. Nubes: стягивает репу, билдит, запускает gunicorn на порту 8000 4. Healthcheck: `GET /health` → должен вернуть `OK` **Важно:** YAML-конфиги (`services/`) скоммичены в git. При редеплое Nubes клонирует репу → YAML уже на месте. Не нужно запускать `from_stands.py` при деплое. --- ## 11. Чеклист для нового AI-агента При старте нового чата прочитай: 1. Этот документ 2. `polygon/site/app.py` — точка входа 3. `polygon/site/config/loader.py` — загрузка конфигов 4. `polygon/site/routes/openapi.py` — OpenAPI-спека 5. `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`, коммитится, редеплоится