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

23 KiB
Raw Blame History

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 воркер

# 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

Когда терраформ-репа обновилась (добавили сервис, изменили параметры):

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:

# В каждом роуте:
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-роутинга:

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:

@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 сервиса по имени:

curl -s "$URL/services" | jq '.results[] | select(.svc == "НазваниеСервиса") | .svcId'

Шаг 2. Узнать ID операции у этого сервиса:

curl -s "$URL/services/$SVC_ID" | jq '.svc.operations[] | select(.operation == "create") | .svcOperationId'

Шаг 3. Получить список входных параметров (cfsParams):

curl -s "$URL/instanceOperations/default/$OP_ID" | jq '.svcOperation.cfsParams[] | {svcOperationCfsParamId, svcOperationCfsParam, dataType, valueList, isRequired}'

Ответ показывает для каждого параметра: числовой ID, код, тип данных, список допустимых значений (если есть), обязательность.

Шаг 4. Создать инстанс:

curl -s -X POST "$URL/instances" \
  -H "Content-Type: application/json" \
  -d '{"serviceId": '$SVC_ID', "displayName": "мой тестовый инстанс"}' \
  | jq '.instanceUid'

Шаг 5. Создать операцию:

curl -s -X POST "$URL/instanceOperations" \
  -H "Content-Type: application/json" \
  -d '{"instanceUid": "'$INST_UID'", "operation": "create", "svcOperationId": '$OP_ID'}' \
  | jq '.instanceOperationUid'

Шаг 6. Установить параметры (повторить для каждого):

curl -s -X POST "$URL/instanceOperationCfsParams" \
  -H "Content-Type: application/json" \
  -d '{"instanceOperationUid": "'$OP_UID'", "svcOperationCfsParamId": '$PARAM_ID', "paramValue": "значение"}'

Шаг 7. Проверить валидацию и запустить:

# Проверить что все параметры валидны (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. Проверить результат:

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)

Юнит-тесты

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, коммитится, редеплоится