Files
autotest/DOCS/polygon-plan.md
T

370 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: мок-полигон для интеграционных тестов
Дата: 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)