doc: README для DOCS/HISTORY, polygon-docs/, +.venv в gitignore

This commit is contained in:
2026-07-31 20:39:48 +04:00
parent 633537a775
commit 6978d13d0e
7 changed files with 850 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
# Ответ 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/<float>` — изменить 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 &gt;= 0`, `&quot;` и т.д. `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/<uid>` | Детали инстанса |
| 5 | POST | `/api/v1/svc/instances` | Создать shell → 201 + Location |
| 6 | GET | `/api/v1/svc/instanceOperations/default/<int:op_id>` | Шаблон операции |
| 7 | POST | `/api/v1/svc/instanceOperations` | Создать операцию → 201 + Location |
| 8 | GET | `/api/v1/svc/instanceOperations/<uid>` | Детали операции + cfsParams |
| 9 | POST | `/api/v1/svc/instanceOperationCfsParams` | Задать значение параметра |
| 10 | GET | `/api/v1/svc/instanceOperations/<uid>/validate-cfs` | Валидация → 200 **пустое тело** |
| 11 | POST | `/api/v1/svc/instanceOperations/<uid>/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/<float:s>` | Изменить 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/<int:op_id>` должен стоять **выше** `/instanceOperations/<uid>` в 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()`.