Files
autotest/DOCS/polygon-plan.md
T

17 KiB
Raw Blame History

План: мок-полигон для интеграционных тестов

Дата: 2026-07-31. Архитектор: Опус. Утверждено: все 10 решений.


1. Концепция

Отдельный Flask-процесс на порту 5001, притворяющийся Nubes API. Data-driven: сервисы описаны в polygon/services/*.yaml, эмулятор достраивает недостающие поля по типу. Состояние инстансов/операций — в памяти, dtFinish вычисляется лениво. Приложение ходит в мок реальным HTTP через существующий http_client.

Никакой БД, никакого облака, никакого Kubernetes. Только HTTP-ответы.


2. Принятые решения (10/10)

Q Решение Обоснование
Q1 Отдельный процесс :5001 (A) http_client делает реальные GET/POST/Location — blueprint не проверит
Q2 Папка polygon/services/*.yaml Каждый сервис в своём файле, легко добавлять
Q3 Мин. поля (id+код+тип), остальное достраивается 20+ параметров вручную — ад. default_for(dataType)
Q4 Ленивый dtFinish (A) Без потоков, детерминированно, MOCK_OP_DELAY (по умолчанию 0.1с)
Q5 Единая стейт-машина create→running→suspended→deleted, без кастомизаций
Q6 Реальный мерж params Иначе тест modify→проверить state.params бессмысленен
Q7 Статический stateOut из YAML Для MVP, генерация из параметров — потом
Q8 /_mock/reset Без сброса тесты влияют друг на друга
Q9 Тесты через app_client Проверяет реальную связку app-autotest ↔ эмулятор
Q10 refSvcId игнорируем в MVP validate-cfs всегда OK, ссылки не проверяются

3. Критические точки интеграции

3.1 Location обязателен

HttpClient.post достаёт UUID из заголовка Location (последний сегмент, len >= 32). Мок ОБЯЗАН отдавать Location: ./<uuid-36> на:

  • POST /instancesLocation: ./{instanceUid}
  • POST /instanceOperationsLocation: ./{instanceOperationUid}

Без Location executor не получит instanceUid/opUid.

3.2 Поллинг спит 5с

poll_until_done делает GET, затем time.sleep(5) в цикле. При MOCK_OP_DELAY=0 dtFinish появится на первом же GET — вторая итерация со сном не случится.

3.3 Short-circuit localhost

detect_endpoint() хардкодит dev/test стенды и не читает NUBES_API_ENDPOINT. Даже при NUBES_API_ENDPOINT=http://localhost:5001 он будет долбиться в реальные стенды.

Решение: проверка localhost/127.0.0.1 в get_client() и get_stand() (auth.py):

если endpoint.startswith("http://localhost") или "http://127.0.0.1":
    пропустить detect_endpoint()
    вернуть HttpClient(endpoint, token)
    stand → "mock"
иначе:
    прежняя логика

Ноль новых env-переменных. NUBES_API_ENDPOINT уже есть в конфиге.

3.4 validate-cfs = пустое тело

send_params_terraform считает успехом пустой/не-JSON ответ. Мок отдаёт 200 с пустым телом.

3.5 state.params по коду, cfsParams по числовому id

get_params_with_current_values мержит state.params[код] с шаблоном из GET /instanceOperations/default/{opId}. YAML должен связывать числовой svcOperationCfsParamId ↔ код параметра.


4. Структура файлов

app-autotest/
├── site/
│   ├── api/
│   │   └── auth.py            # ИЗМЕНИТЬ: +short-circuit localhost
│   └── ...
├── polygon/                   # НОВАЯ папка
│   ├── server.py              # Flask-приложение эмулятора
│   ├── state.py               # MockState (в памяти)
│   ├── config_loader.py       # загрузка YAML + достройка defaults
│   ├── defaults.py            # default_for(dataType)
│   └── services/
│       ├── dummy.yaml         # Болванка (реальные ID из HAR)
│       └── postgresql.yaml    # PostgreSQL (stateOut: users, databases)
└── tests/
    ├── conftest.py            # ИЗМЕНИТЬ: +фикстура поднятия мока
    └── test_mock_integration.py  # НОВЫЙ: интеграционные тесты

5. План реализации (3 фазы, 12 шагов)

Фаза 1 — MVP (create + поллинг)

Шаг 1. polygon/defaults.py Функция default_for(dataType): int→"0", bool→"false", array→"[]", map/json→"{}", иначе "". Зеркалит normalize_value из terraform.py.

Шаг 2. polygon/config_loader.py

  • Грузит все polygon/services/*.yaml
  • Для каждого cfsParam достраивает недостающие поля (defaultValue, valueList, isRequired, refSvcId, dataDescriptor) через default_for()
  • Возвращает dict: {service_id: {svc, operations, cfsParams, stateParams, stateOut}}

Шаг 3. polygon/state.py — MockState

class MockState:
    instances:    dict[uid] → {serviceId, displayName, status, params, ...}
    operations:   dict[opUid] → {instanceUid, svcOperationId, operation, dtRunStart, params, ...}

    create_instance(service_id, display_name) → instanceUid
    create_operation(instanceUid, svcOperationId, operation) → opUid
    set_param(opUid, paramId, value)
    run(opUid) — записывает dtRunStart
    get_operation(opUid) → {dtFinish, isSuccessful, ...}  (ленивый dtFinish)
    apply_effect(opUid) — modify→мерж params, delete→удаление инстанса, suspend/resume→статус
    reset()

Ленивый dtFinish: при GET /instanceOperations/{uid} сравнивает now - dtRunStart >= MOCK_OP_DELAY. Если да — выставляет dtFinish=now, isSuccessful=True и вызывает apply_effect.

Шаг 4. polygon/server.py Flask-приложение, префикс /api/v1/svc. На этом шаге — минимальный набор:

  • POST /instances — создаёт инстанс, возвращает 201 + Location: ./{uid}
  • POST /instanceOperations — создаёт операцию, Location: ./{opUid}
  • POST /instanceOperationCfsParams — устанавливает параметр
  • POST /instanceOperations/{uid}/run — запускает операцию
  • GET /instanceOperations/{uid}?fields=... — статус операции (ленивый dtFinish)
  • GET /instanceOperations/{uid}/validate-cfs — 200 OK, пустое тело

Шаг 5. polygon/services/dummy.yaml Реальные ID из HAR (распарсены Опусом):

1:  # serviceId
  svc: "Болванка"
  svcShort: "dummy"
  svcExtendedName: "Болванка"
  operations:
    - {svcOperationId: 18, operation: create, isCreate: true}
    - {svcOperationId: 92, operation: modify}
    - {svcOperationId: 71, operation: delete}
    - {svcOperationId: 93, operation: suspend}
    - {svcOperationId: 94, operation: resume}
    - {svcOperationId: 240, operation: redeploy}
  cfsParams:
    - {svcOperationCfsParamId: 242, svcOperationCfsParam: "resourceRealm", dataType: "string", valueList: ["dummy"], isRequired: true, defaultValue: "dummy"}
    - {svcOperationCfsParamId: 198, svcOperationCfsParam: "durationMs", dataType: "integer >= 0", defaultValue: "0"}
    - {svcOperationCfsParamId: 199, svcOperationCfsParam: "param199", dataType: "boolean", defaultValue: "false"}
    - {svcOperationCfsParamId: 200, svcOperationCfsParam: "param200", dataType: "boolean", defaultValue: "false"}
    - {svcOperationCfsParamId: 201, svcOperationCfsParam: "param201", dataType: "integer", defaultValue: "1"}
    - {svcOperationCfsParamId: 286, svcOperationCfsParam: "param286", dataType: "string"}
    - {svcOperationCfsParamId: 321, svcOperationCfsParam: "param321", dataType: "map", dataDescriptor: {subparam1: {dataType: "string"}, secret: {dataType: "string"}, subparam2: {dataType: "string"}}}
    - {svcOperationCfsParamId: 322, svcOperationCfsParam: "param322", dataType: "string"}
    - {svcOperationCfsParamId: 396, svcOperationCfsParam: "param396", dataType: "string"}
    - {svcOperationCfsParamId: 647, svcOperationCfsParam: "param647", dataType: "map", dataDescriptor: {bol1: {dataType: "boolean"}, minStr1: {dataType: "string"}, param1: {dataType: "string"}, param2: {dataType: "string"}, param3: {dataType: "string"}}}
    - {svcOperationCfsParamId: 654, svcOperationCfsParam: "param654", dataType: "array", dataDescriptor: {bol1: {dataType: "boolean"}, minStr1: {dataType: "string"}, param1: {dataType: "string"}, param2: {dataType: "string"}, param3: {dataType: "string"}}}
    - {svcOperationCfsParamId: 863, svcOperationCfsParam: "param863", dataType: "array"}
  stateParams:
    resourceRealm: "dummy"
    durationMs: "0"
    param199: "false"
    param200: "false"
    param201: "1"
  stateOut: {}

Шаг 6. Интеграция в auth.py Добавить short-circuit в get_client() и get_stand():

def _is_localhost(endpoint):
    return (endpoint or "").startswith(("http://localhost", "http://127.0.0.1"))

def get_client():
    token = get_token()
    endpoint = current_app.config["NUBES_API_ENDPOINT"]
    if not _is_localhost(endpoint):
        endpoint = detect_endpoint(token) or endpoint
    return HttpClient(endpoint, token)

def get_stand():
    token = get_token()
    endpoint = current_app.config["NUBES_API_ENDPOINT"]
    if _is_localhost(endpoint):
        return "mock"
    endpoint = detect_endpoint(token) or endpoint
    return stand_name(endpoint)

Фаза 2 — полный CRUD + сервисы

Шаг 7. GET-эндпоинты

  • GET /instances?pageSize=N&page=P — список с пагинацией (pageSize≤200, стоп по len(batch)<pageSize)
  • GET /instances/{uid}{instance: {instanceUid, displayName, serviceId, svc, explainedStatus, state: {params: {...}, out: {...}}}}

Шаг 8. GET-эндпоинты (сервисы)

  • GET /services{results: [{svcId, svc, svcExtendedName}]}
  • GET /services/{id}{svc: {svc, svcShort, operations: [...]}}
  • GET /instanceOperations/default/{id}{svcOperation: {cfsParams: [...]}}

Шаг 9. apply_effect в MockState

  • modify → мерж params в state.params инстанса
  • delete → удаление инстанса из state.instances
  • suspend → статус suspended
  • resume → статус running
  • redeploy → статус running

Шаг 10. /_mock/reset + postgresql.yaml

  • POST /_mock/reset — сброс всего состояния
  • polygon/services/postgresql.yaml:
21:  # serviceId (подставить реальный)
  svc: "postgresql"
  svcShort: "pg"
  operations:
    - {svcOperationId: 300, operation: create}
    - {svcOperationId: 301, operation: modify}
    - {svcOperationId: 302, operation: delete}
    - {svcOperationId: 303, operation: suspend}
    - {svcOperationId: 304, operation: resume}
  cfsParams:
    - {svcOperationCfsParamId: 401, svcOperationCfsParam: "dbName", dataType: "string", defaultValue: "mydb"}
    - {svcOperationCfsParamId: 402, svcOperationCfsParam: "dbUser", dataType: "string", defaultValue: "pgadmin"}
    - {svcOperationCfsParamId: 403, svcOperationCfsParam: "dbPassword", dataType: "string", defaultValue: "***"}
    - {svcOperationCfsParamId: 404, svcOperationCfsParam: "version", dataType: "string", valueList: ["14", "15", "16"], defaultValue: "15"}
  stateParams:
    dbName: "mydb"
    dbUser: "pgadmin"
    version: "15"
  stateOut:
    users: {pgadmin: {}, appuser: {}}
    databases: {mydb: {}}

Фаза 3 — тесты

Шаг 11. tests/conftest.py Фикстура поднятия мок-процесса + автосброс через /_mock/reset:

@pytest.fixture(scope="session")
def mock_server():
    # Запустить polygon/server.py на порту 5001
    # Дождаться готовности
    # yield
    # Остановить процесс

@pytest.fixture(autouse=True)
def reset_mock(mock_server):
    requests.post("http://localhost:5001/_mock/reset")

Шаг 12. tests/test_mock_integration.py 5 сценариев через app_client:

  1. create БолванкуinstanceUid не пуст, GET /instances/{uid} → статус running
  2. modify параметрstate.params показывает новое значение
  3. delete — инстанс исчез из GET /instances
  4. Сценарий create→modify→deletePOST /api/scenario/run проходит целиком
  5. PG createstate.out.users и state.out.databases заполнены

6. Формат services.yaml (спецификация)

<serviceId>:
  svc: "имя_сервиса"            # обязательное
  svcShort: "короткое_имя"      # опциональное
  svcExtendedName: "полное"     # опциональное
  operations:                   # обязательное
    - svcOperationId: <int>     # обязательное
      operation: <str>          # create|modify|delete|suspend|resume|redeploy
      isCreate: <bool>          # опциональное (true для create)
  cfsParams:                    # опциональное (мин. поля обязательны)
    - svcOperationCfsParamId: <int>     # обязательное
      svcOperationCfsParam: <str>       # обязательное (код параметра)
      dataType: <str>                   # обязательное
      defaultValue: <str>               # опц. (достраивается по типу)
      isRequired: <bool>                # опц. (достраивается)
      valueList: [<str>, ...]           # опц.
      refSvcId: <int>                   # опц.
      dataDescriptor: {<key>: {...}}    # опц. (для map-параметров)
  stateParams:                  # опциональное (значения после create)
    <код>: <значение>
  stateOut:                     # опциональное (доп. данные)
    users: {<name>: {}}
    databases: {<name>: {}}

Правила достройки (default_for):

  • defaultValue отсутствует → "0" для integer, "false" для boolean, "[]" для array, "{}" для map/json, "" для string
  • isRequired отсутствует → false
  • valueList отсутствует → null (не select, а input)

7. Машина состояний

create  → running
suspend → suspended
resume  → running
modify  → running (после мержа params)
delete  → УДАЛЁН (исчезает из GET /instances)
redeploy→ running

Статусы: creating (пока dtFinish не появился), running, suspended, deleted.


8. Зависимости и окружение

  • Зависимости: PyYAML (для config_loader), Flask (уже есть)
  • Env-переменные:
    • MOCK_OP_DELAY — задержка операции в секундах (по умолчанию 0.1)
    • NUBES_API_ENDPOINThttp://localhost:5001/api/v1/svc (уже есть)
  • Порт: 5001 (не конфликтует с основным приложением на 5000/8000)
  • Память: всё в MockState, без БД, без файлов

9. Проверка (Verification)

# 1. Запуск полигона
cd app-autotest && NUBES_API_ENDPOINT=http://localhost:5001/api/v1/svc \
  python polygon/server.py

# 2. Дымовой тест
curl http://localhost:5001/api/v1/svc/instances?pageSize=1
# → {"results": []}

# 3. Интеграционные тесты
pytest tests/test_mock_integration.py -v
# Все 5 зелёные

# 4. Полный прогон (старые тесты не сломаны)
pytest tests/ -v

10. Что НЕ делаем

  • Реальное выполнение операций (не Terraform, не Ansible)
  • Валидация параметров (всегда validate-cfs = OK)
  • База данных
  • refSvcId-резолв в MVP
  • Многопоточность
  • Деплой полигона (только локально/CI)