# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ. # Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок Отправлено: 27.07.2026 --- Ты — senior архитектор. Разбери проблему в Flask-приложении на Nubes pythonk8s (gunicorn, 1 worker). ## Контекст Приложение app-autotest — веб-интерфейс для автотестов операций облачных сервисов Nubes. Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git Версия: v1.0.47 ## Файлы для изучения | Файл | Назначение | |------|-----------| | `site/app.py` | Точка входа Flask, VERSION, blueprints | | `site/routes/api_test.py` | POST /api/test (запуск), GET /api/test/status (поллинг), _finish_op (фоновый поток) | | `site/routes/main.py` | Главная страница, /api/operations/ (список инстансов из трекера) | | `site/operations/tracker.py` | Запись/чтение /tmp/instances.json (_INITIAL, add, remove, list_all) | | `site/templates/index.html` | UI: executeOp(), showStages(), поллинг, refreshInstances() | | `site/api/http_client.py` | HTTP-клиент (Bearer auth, User-Agent) | Полные пути в репозитории: ``` app-autotest/site/app.py app-autotest/site/routes/api_test.py app-autotest/site/routes/main.py app-autotest/site/operations/tracker.py app-autotest/site/templates/index.html app-autotest/site/api/http_client.py ``` ## Поток CREATE (как работает сейчас) 1. UI: пользователь жмёт «Создать» → showParams(18, 'create') → заполняет форму → executeOp(params) 2. UI отправляет POST /api/test `{serviceId:1, operation:"create", svcOperationId:18, displayName, params, instanceUid:""}` 3. Бэкенд api_test(): - `client.post("/instances", {serviceId, displayName})` → получает instanceUid - `client.post("/instanceOperations", {instanceUid, operation:"create"})` → получает opUid - `client.post("/instanceOperationCfsParams", ...)` — для каждого параметра - `client.post("/instanceOperations/{opUid}/run")` — запуск - `threading.Thread(target=_finish_op, args=(client, opUid, instanceUid, ...), daemon=True).start()` - возвращает `{status:"RUNNING", opUid, instanceUid}` 4. UI начинает поллинг: `GET /api/test/status/` каждые 2 секунды 5. _finish_op (фоновый daemon-поток): - поллит `GET /instanceOperations/{opUid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages` - обновляет `_op_results[opUid]` (in-memory dict) - когда `dtFinish != null` и `isSuccessful == true`: - **вызывает tracker_add(instanceUid, svcId, displayName)** ← ПРОБЛЕМА ЗДЕСЬ - устанавливает `_op_results[opUid] = {status:"OK", stages, duration}` - таймаут 300 секунд 6. UI при status=="OK": вызывает `selectService(SVC_ID)` → `GET /api/operations/1` → читает tracker → показывает список ## ПРОБЛЕМА Инстанс создаётся в Nubes (виден в UI платформы), НО в списке инстансов приложения НЕ появляется. `tracker_add` не вызывается → `/tmp/instances.json` не обновляется → инстанс не в списке. **Происходило 2 раза подряд** (v1.0.46 и v1.0.47). Оба раза инстанс в Nubes есть, в трекере — нет. ## Моя гипотеза tracker_add вызывается внутри daemon-потока _finish_op. В gunicorn: - Воркер может быть перезапущен платформой в любой момент - Daemon-потоки умирают вместе с воркером — молча, без логов - Python не пишет traceback при смерти daemon-потока ## Текущий код _finish_op (api_test.py, строки 153-195) ```python def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, is_delete=False): """Фоном ждать dtFinish и сохранить результат.""" import time t0 = time.time() deadline = t0 + 300 while time.time() < deadline: try: data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") except Exception: time.sleep(5) continue op = data.get("instanceOperation", {}) dt_finish = op.get("dtFinish") _op_results[op_uid] = { "status": "RUNNING", "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), } if dt_finish and str(dt_finish).strip(): is_ok = op.get("isSuccessful") err = op.get("errorLog") or "" # Сначала трекер — чтобы UI при poll уже видел инстанс if is_ok: if is_create: tracker_add(instance_uid, svc_id, display_name) elif is_delete: tracker_remove(instance_uid) _op_results[op_uid] = { "status": "OK" if is_ok else "FAIL", "error": str(err) if err else "", "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), } return time.sleep(5) _op_results[op_uid] = {"status": "TIMEOUT", "duration": round(time.time() - t0, 1)} ``` ## Вопросы ### 1. Где вызывать tracker_add? Вариант А: синхронно в api_test(), сразу после получения instanceUid от API (шаг 3), до запуска потока. - Плюс: гарантированная запись, инстанс в трекере мгновенно - Минус: если операция потом упадёт — в трекере «мусорный» инстанс (но это лучше чем отсутствие) Вариант Б: subprocess.Popen вместо threading.Thread. - Плюс: процесс живёт независимо от gunicorn worker - Минус: сложнее, нужен IPC для _op_results Вариант В: sqlite3 с WAL-режимом. - Плюс: атомарная запись, конкурентный доступ - Минус: без persistent volume данные теряются при редеплое (как и /tmp/) ### 2. Что делать с _op_results? Сейчас это module-level dict. При нескольких gunicorn workers — каждый worker имеет свой dict. Нужен ли переход на sqlite/file-based storage для _op_results? ### 3. Надёжность под gunicorn Как правильно организовать фоновую работу под gunicorn на pythonk8s (1 worker, нет PV, нет Redis/RabbitMQ)? ## Дополнительно: UI кнопок Нужен CSS чтобы кнопки операций шли горизонтально в 1-2 ряда, как в Nubes UI: ``` delete | modify | suspend | resume | redeploy | reconcile ``` Сейчас они в vertical списке внутри `div.inst-ops`. Предложи стиль. ## Ограничения платформы - `site/` — НЕ пакет (без __init__.py, конфликт с stdlib site.py) - Импорты без префикса site.: `from api.http_client import ...` - `app.run(host="0.0.0.0", port=5000)` — обязательно - Gunicorn запускается платформой, предположительно 1 worker - Нет persistent volume — данные в /tmp/ теряются при редеплое - Деплой: git push → managed service подхватывает → редеплой