Полный план мок-полигона: 3 фазы, 12 шагов, 10 решений, спецификация YAML, реальные ID из HAR
This commit is contained in:
@@ -0,0 +1,369 @@
|
|||||||
|
# План: мок-полигон для интеграционных тестов
|
||||||
|
|
||||||
|
Дата: 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)
|
||||||
Reference in New Issue
Block a user