390 lines
20 KiB
Markdown
390 lines
20 KiB
Markdown
# 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`, коммитится, редеплоится
|