From af2cede717cd6fa4aca5498c7234c5945ceaa5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 3 Aug 2026 07:59:28 +0400 Subject: [PATCH] =?UTF-8?q?doc:=20POLYGON-FULL.md=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B8=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20AI-=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- polygon-docs/POLYGON-FULL.md | 389 +++++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 polygon-docs/POLYGON-FULL.md diff --git a/polygon-docs/POLYGON-FULL.md b/polygon-docs/POLYGON-FULL.md new file mode 100644 index 0000000..f7489bf --- /dev/null +++ b/polygon-docs/POLYGON-FULL.md @@ -0,0 +1,389 @@ +# 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`, коммитится, редеплоится