diff --git a/DOCS/polygon-plan.md b/DOCS/polygon-plan.md new file mode 100644 index 0000000..99274d2 --- /dev/null +++ b/DOCS/polygon-plan.md @@ -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: ./` на: +- `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): + svc: "имя_сервиса" # обязательное + svcShort: "короткое_имя" # опциональное + svcExtendedName: "полное" # опциональное + operations: # обязательное + - svcOperationId: # обязательное + operation: # create|modify|delete|suspend|resume|redeploy + isCreate: # опциональное (true для create) + cfsParams: # опциональное (мин. поля обязательны) + - svcOperationCfsParamId: # обязательное + svcOperationCfsParam: # обязательное (код параметра) + dataType: # обязательное + defaultValue: # опц. (достраивается по типу) + isRequired: # опц. (достраивается) + valueList: [, ...] # опц. + refSvcId: # опц. + dataDescriptor: {: {...}} # опц. (для map-параметров) + stateParams: # опциональное (значения после create) + <код>: <значение> + stateOut: # опциональное (доп. данные) + users: {: {}} + databases: {: {}} +``` + +Правила достройки (`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)