# Ответ Claude Sonnet 4.6 — архитектура и план polygon Дата: 2026-07-31 --- ## Ответы на Q1–Q10 **Q1: map-fixed → dataDescriptor** Конвертер ГЕНЕРИРУЕТ `dataDescriptor` из `sub_params`. Формат каждой записи: ``` {sub_param_code: {dataType, defaultValue, valueList, isRequired}} ``` Это обязательно: `normalize_value` в app-autotest использует `dataDescriptor` для генерации JSON из defaults при пустом значении map-параметра. **Q2: subresources** Правило простое и точное: `kind == "subresource"` (это явное поле в STANDS YAML). `apply_effect` смотрит на `kind`: - `kind == "instance"` → меняем статус + `state.params` - `kind == "subresource"` → меняем `state.out[plural]`, статус НЕ меняем **Q3: stateOut auto-generation** Да, конвертер выводит автоматически. Сканирует операции с `kind=subresource, action=create`, строит `state_out_template`. Пример для postgres: `{users: {}, databases: {}, backups: {}}`. При create инстанса мок копирует этот шаблон в `instance.state.out`. **Q4: "лишние" операции** Включить все (restart, recovery, etc.) — иначе `GET /instanceOperations/default/{id}` вернёт 404 и app-autotest упадёт. Реализация: no-op transition (статус остаётся `running`, dtFinish ставится, isSuccessful=True). Если operation_id вообще не найден → 404 с `{"error": "operation not found"}`. **Q5: valueList** Хранить и отдавать обязательно. `get_params_with_current_values` в app-autotest заполняет из него дропдауны для `delete_user`/`delete_database`. **Q6: Подход A (generate YAML → services/)** Выбрать A. `from_stands.py` генерирует `site/services/N_name.yaml`, `config_loader.py` читает при старте Flask. Плюс: ручная правка для сложных случаев, прозрачность. `from_stands.py` запускается один раз при setup. **Q7: Mock-эндпоинты** Все 4: - `POST /_mock/reset` — обязателен для тестовой изоляции - `GET /_mock/state` — текущее состояние (отладка) - `GET /_mock/services` — список загруженных сервисов - `POST /_mock/delay/` — изменить MOCK_OP_DELAY на лету **Q8: UUID** → `uuid.uuid4()`. Детерминированные не нужны. **Q9: Пагинация** → stop when `len(batch) < pageSize`, cap at 200, page=1 by default. Response: `{"results": [...], "pageSize": N, "page": P, "total": len(all)}`. **Q10: Тесты** К 5 сценариям Опуса добавить: (a) тест пагинации (3 инстанса, pageSize=2), (b) тест subresource create→verify state.out→delete→verify removed, (c) тест modify→verify params merged, (d) тест /_mock/state, (e) тест 404 на невалидный op_id. Каждый тест начинается с `POST /_mock/reset` (через `autouse` fixture). --- ## Структура файлов ``` polygon/ ├── requirements.txt └── site/ ├── app.py # Flask app + все 15 маршрутов (один файл) ├── mock_state.py # MockState: instances, operations, op_params ├── state_machine.py # apply_effect() ├── config_loader.py # load_services() → services_dict + ops_index ├── from_stands.py # CLI-конвертер STANDS YAML → services/*.yaml ├── services/ # 37 YAML-файлов (generated) └── templates/ └── index.html ``` **Ключевое ограничение Nubes:** - `site/__init__.py` — ЗАПРЕЩЁН - `from site.xxx import` — ЗАПРЕЩЁН - В app.py: только `import mock_state`, `import config_loader`, `import state_machine` (работает т.к. Python добавляет директорию скрипта в `sys.path[0]`) --- ## Схема данных в памяти ```python # mock_state.py — синглтон state = MockState() class MockState: instances = {} # instanceUid → instance_dict operations = {} # opUid → operation_dict op_params = {} # opUid → {paramId(int): paramValue(str)} ``` **instance_dict:** `{instanceUid, serviceId, displayName, descr, status, explainedStatus, svc, dtCreate, state: {params: {code: val}, out: {users: {}, databases: {}}}}` **operation_dict:** `{instanceOperationUid, instanceUid, svcOperationId, operation, kind, action, subresource|None, dtStart, dtFinish|None, isSuccessful|None, errorLog, svc, stages}` --- ## Конвертер from_stands.py **HTML entities:** STANDS YAML содержит `integer >= 0`, `"` и т.д. `from_stands.py` **обязан** применять `html.unescape()` ко всем строковым полям (`data_type`, `value_list` элементы, `default`). **Маппинг STANDS → cfsParam формат:** | STANDS | polygon/cfsParam | |---|---| | `id` | `svcOperationCfsParamId` | | `code` | `svcOperationCfsParam` | | `data_type` (unescape) | `dataType` | | `required` | `isRequired` | | `default` | `defaultValue` | | `value_list` | `valueList` | | `sub_params` (map-fixed) | → `dataDescriptor: {code: {dataType,defaultValue,valueList,isRequired}}` | | `sub_params` (map) | → `sub_params` (хранить как есть для normalize_value) | **stateOut auto-generation:** ```python for op in operations: if op.kind == "subresource" and op.action == "create": plural = op.subresource + "s" # user→users, database→databases state_out_template[plural] = {} ``` --- ## Полный список API эндпоинтов (15 шт.) | # | Метод | Путь | Назначение | |---|---|---|---| | 1 | GET | `/health` | `"OK"` | | 2 | GET | `/` | HTML-страница | | 3 | GET | `/api/v1/svc/instances` | Список инстансов (paged) | | 4 | GET | `/api/v1/svc/instances/` | Детали инстанса | | 5 | POST | `/api/v1/svc/instances` | Создать shell → 201 + Location | | 6 | GET | `/api/v1/svc/instanceOperations/default/` | Шаблон операции | | 7 | POST | `/api/v1/svc/instanceOperations` | Создать операцию → 201 + Location | | 8 | GET | `/api/v1/svc/instanceOperations/` | Детали операции + cfsParams | | 9 | POST | `/api/v1/svc/instanceOperationCfsParams` | Задать значение параметра | | 10 | GET | `/api/v1/svc/instanceOperations//validate-cfs` | Валидация → 200 **пустое тело** | | 11 | POST | `/api/v1/svc/instanceOperations//run` | Выполнить операцию | | 12 | POST | `/api/v1/svc/_mock/reset` | Сброс состояния | | 13 | GET | `/api/v1/svc/_mock/state` | Debug: текущее состояние | | 14 | GET | `/api/v1/svc/_mock/services` | Debug: загруженные сервисы | | 15 | POST | `/api/v1/svc/_mock/delay/` | Изменить MOCK_OP_DELAY | **Критические детали:** - **Эндпоинт 5** (`POST /instances`): возвращает `201` + заголовок `Location: ./{uid}`. `HttpClient.post()` берёт uid из последнего сегмента Location, проверяет `len >= 32`. Body: `{"instanceUid": uid}` (двойная защита). - **Эндпоинт 7** (`POST /instanceOperations`): при `operation=="create"` тело НЕ содержит `svcOperationId` — polygon находит его сам из `svc_def.operations["create"]["id"]`. - **Эндпоинт 10** (validate-cfs): `return "", 200` (НЕ `jsonify`). app-autotest ловит `JSONDecodeError` и считает это успехом. - **Эндпоинт 11** (run): синхронный — `time.sleep(MOCK_OP_DELAY)`, затем `apply_effect`, затем `dtFinish = datetime.utcnow().isoformat() + "Z"`. Первый poll после run увидит dtFinish. - **Маршруты Flask:** `/instanceOperations/default/` должен стоять **выше** `/instanceOperations/` в app.py. --- ## Поток запроса (полный цикл create) ``` executor.py polygon ───────── ─────── POST /instances {serviceId,displayName} → создать instance shell (status="creating") → response 201 + Location: ./uid1 ← instanceUid = uid1 POST /instanceOperations {instanceUid, operation:"create"} → найти create op_id из service_def → создать operation_dict (dtFinish=None) → response 201 + Location: ./uid2 ← opUid = uid2 GET /instanceOperations/uid2?fields=cfsParams → response {"instanceOperation": {"cfsParams": [...]}} cfsParams из service_def + пустые paramValue POST /instanceOperationCfsParams × N → state.op_params[uid2][paramId] = val GET /instanceOperations/uid2/validate-cfs → response "" 200 POST /instanceOperations/uid2/run → sleep(0.1s) → apply_effect: state.params←codes, status="running" → dtFinish = now → response {"ok": true} GET /instanceOperations/uid2?fields=dtFinish,... → response {"instanceOperation": {"dtFinish": "2026-...", "isSuccessful": true, ...}} ← poll done: status="OK" ``` --- ## Фазы реализации **Этап 1 — Конвертер** (`from_stands.py`): html.unescape, convert_param рекурсивный, build_state_out_template, pluralize, CLI. Запустить: `cd polygon/site && python from_stands.py`. Критерий: 37 YAML в `site/services/`, у postgres `state_out_template: {users:{}, databases:{}, backups:{}}`. **Этап 2 — MockState + config_loader + state_machine**: три отдельных модуля. Критерий: `python -c "import config_loader; s,i=config_loader.load_services('services'); print(len(s))"` → 37. **Этап 3 — Flask API** в app.py: реализовать все 15 эндпоинтов в порядке от простых к сложным (см. таблицу). Критерий: `python -m py_compile app.py` без ошибок, `curl localhost:5000/health` → OK. **Этап 4 — Тесты**: - В polygon: `tests/test_converter.py`, `tests/test_state_machine.py` (юнит) - В app-autotest: `tests/conftest.py` (+fixture `polygon_server` subprocess), `tests/test_polygon_integration.py` (интеграционные) ## Уточнения (второй раунд) ### Уточнение 1: `map` с `sub_params` → тоже `dataDescriptor` Правило: **если у параметра есть `sub_params` — генерировать `dataDescriptor`**, независимо от того `map` это или `map-fixed`. | `data_type` | `sub_params` | Действие | |---|---|---| | `map-fixed` | есть | генерировать `dataDescriptor` | | `map` | есть | генерировать `dataDescriptor` | | `array-map-fixed` | есть | генерировать `dataDescriptor` | | любой | нет | `dataDescriptor: null` | ### Уточнение 2: `state.out` после create — пустые `{}` Достаточно пустых `{}` из `state_out_template`. STANDS YAML не описывает runtime-дефолты (`pgadmin`, `mydb`) — это эффект реального Terraform, не мок. ### Уточнение 3: расположение тестов ``` app-autotest/ tests/ conftest.py # + fixture polygon_server (subprocess) test_polygon_integration.py # интеграционные тесты polygon/ tests/ test_converter.py # юнит: from_stands.py test_state_machine.py # юнит: apply_effect ``` `polygon_server` fixture: subprocess → poll `/health` (timeout 5s) → после тестов `terminate()`.