Files
autotest/polygon-docs/POLYGON-FULL.md
T

459 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}` |
### Типовой сценарий: создать инстанс и запустить операцию
Это основной флоу, который повторяет логику реального облачного API.
Все примеры — с jq для наглядности, но работают и с `python3 -m json.tool`.
**Шаг 1. Узнать ID сервиса по имени:**
```bash
curl -s "$URL/services" | jq '.results[] | select(.svc == "НазваниеСервиса") | .svcId'
```
**Шаг 2. Узнать ID операции у этого сервиса:**
```bash
curl -s "$URL/services/$SVC_ID" | jq '.svc.operations[] | select(.operation == "create") | .svcOperationId'
```
**Шаг 3. Получить список входных параметров (cfsParams):**
```bash
curl -s "$URL/instanceOperations/default/$OP_ID" | jq '.svcOperation.cfsParams[] | {svcOperationCfsParamId, svcOperationCfsParam, dataType, valueList, isRequired}'
```
Ответ показывает для каждого параметра: числовой ID, код, тип данных, список допустимых значений (если есть), обязательность.
**Шаг 4. Создать инстанс:**
```bash
curl -s -X POST "$URL/instances" \
-H "Content-Type: application/json" \
-d '{"serviceId": '$SVC_ID', "displayName": "мой тестовый инстанс"}' \
| jq '.instanceUid'
```
**Шаг 5. Создать операцию:**
```bash
curl -s -X POST "$URL/instanceOperations" \
-H "Content-Type: application/json" \
-d '{"instanceUid": "'$INST_UID'", "operation": "create", "svcOperationId": '$OP_ID'}' \
| jq '.instanceOperationUid'
```
**Шаг 6. Установить параметры (повторить для каждого):**
```bash
curl -s -X POST "$URL/instanceOperationCfsParams" \
-H "Content-Type: application/json" \
-d '{"instanceOperationUid": "'$OP_UID'", "svcOperationCfsParamId": '$PARAM_ID', "paramValue": "значение"}'
```
**Шаг 7. Проверить валидацию и запустить:**
```bash
# Проверить что все параметры валидны (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. Проверить результат:**
```bash
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)
### Юнит-тесты
```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`, коммитится, редеплоится