doc: POLYGON-FULL.md — полное описание полигона для AI-агентов
This commit is contained in:
@@ -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/<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`:
|
||||
|
||||
```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`, коммитится, редеплоится
|
||||
Reference in New Issue
Block a user