17 KiB
План: мок-полигон для интеграционных тестов
Дата: 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 /instances→Location: ./{instanceUid}POST /instanceOperations→Location: ./{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:
- create Болванку —
instanceUidне пуст,GET /instances/{uid}→ статусrunning - modify параметр —
state.paramsпоказывает новое значение - delete — инстанс исчез из
GET /instances - Сценарий create→modify→delete —
POST /api/scenario/runпроходит целиком - PG create —
state.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,""для stringisRequiredотсутствует →falsevalueListотсутствует →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_ENDPOINT—http://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)