Полный план мок-полигона: 3 фазы, 12 шагов, 10 решений, спецификация YAML, реальные ID из HAR

This commit is contained in:
2026-07-31 18:51:01 +04:00
parent fd55c50753
commit 42e3ca1ab3
+369
View File
@@ -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)