370 lines
17 KiB
Markdown
370 lines
17 KiB
Markdown
# План: мок-полигон для интеграционных тестов
|
||
|
||
Дата: 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 (распарсены Опусом):
|
||
|
||
```yaml
|
||
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()`:
|
||
```python
|
||
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`:
|
||
```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`:
|
||
```python
|
||
@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→delete** — `POST /api/scenario/run` проходит целиком
|
||
5. **PG create** — `state.out.users` и `state.out.databases` заполнены
|
||
|
||
---
|
||
|
||
## 6. Формат services.yaml (спецификация)
|
||
|
||
```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_ENDPOINT` — `http://localhost:5001/api/v1/svc` (уже есть)
|
||
- **Порт**: 5001 (не конфликтует с основным приложением на 5000/8000)
|
||
- **Память**: всё в `MockState`, без БД, без файлов
|
||
|
||
---
|
||
|
||
## 9. Проверка (Verification)
|
||
|
||
```bash
|
||
# 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)
|