# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ. # Полный обзор кода app-autotest — запрос к Sonnet Отправлено: 27.07.2026 --- ## Контекст Flask-приложение на Nubes pythonk8s (gunicorn, 1 worker, нет persistent volume). Репозиторий: https://gitea.services.ngcloud.ru/forcloud/app-autotest.git Деплой: git push → managed service редеплоит. Текущая версия: v1.0.48. ## ПРОБЛЕМА CREATE инстанса через UI: инстанс создаётся в Nubes (виден в UI платформы), НО в нашем списке инстансов НЕ появляется. Происходит СТАБИЛЬНО, 3 раза подряд (v1.0.46, v1.0.47, v1.0.48). Пробовали: - v1.0.46: tracker_add в фоновом daemon-потоке _finish_op - v1.0.47: tracker_add в _finish_op, но до установки _op_results[OK] (попытка победить гонку) - v1.0.48: tracker_add синхронно в api_test() до запуска потока, с try/except: pass НИ ОДИН вариант не сработал. Инстанс в трекере не появляется. Текущий код на проде показывает 4 инстанса из _INITIAL: ``` curl-final-test (suspended) curl-test-144834 (suspended) autotest-1 (suspended) autotest-1-first (running) ``` Новый инстанс (f192b10d-beb0-4a25-b79f-5dbd7de4712e, создан в 09:16) — отсутствует. --- ## ВЕСЬ КОД ### 1. site/app.py — точка входа ```python import os from flask import Flask from routes.main import bp as main_bp from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp VERSION = "1.0.48" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["VERSION"] = VERSION app.register_blueprint(main_bp) app.register_blueprint(api_bp) app.register_blueprint(api_test_bp) @app.route("/health") def health(): return "OK" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000) ``` ### 2. site/api/http_client.py — HTTP клиент ```python import requests class HttpClient: def __init__(self, endpoint, token): self._endpoint = endpoint.rstrip("/") self._session = requests.Session() self._session.headers.update({ "Authorization": f"Bearer {token}", "User-Agent": "Mozilla/5.0", }) def get(self, path, **kwargs): kwargs.setdefault("timeout", 10) r = self._session.get(f"{self._endpoint}{path}", **kwargs) r.raise_for_status() return r.json() def post(self, path, data=None, **kwargs): kwargs.setdefault("timeout", 30) url = f"{self._endpoint}{path}" r = self._session.post(url, json=(data if data is not None else {}), **kwargs) if not r.ok: raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}") result = {} try: parsed = r.json() if isinstance(parsed, dict): result = parsed except Exception: pass loc = r.headers.get("Location", "") if loc: result["_location"] = loc return result ``` ### 3. site/operations/tracker.py — трекер инстансов ```python """Трекер созданных инстансов — только те что породило приложение.""" import json import os import threading _LOCK = threading.Lock() _PATH = "/tmp/instances.json" _INITIAL = { "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d": {"svcId": 1, "displayName": "autotest-1", "instanceUid": "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d"}, "884356cf-5212-4395-b56b-27c58a5fc1fa": {"svcId": 1, "displayName": "autotest-1-first", "instanceUid": "884356cf-5212-4395-b56b-27c58a5fc1fa"}, "6528854d-a4ea-428c-9fa4-68e85fa9b3ac": {"svcId": 1, "displayName": "curl-test-144834", "instanceUid": "6528854d-a4ea-428c-9fa4-68e85fa9b3ac"}, "fdcb5887-39f7-4ef8-a9c0-d55e434a55ff": {"svcId": 1, "displayName": "curl-final-test", "instanceUid": "fdcb5887-39f7-4ef8-a9c0-d55e434a55ff"}, } def _load(): try: with open(_PATH) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): data = dict(_INITIAL) _save(data) return data def _save(data): with open(_PATH, "w") as f: json.dump(data, f, indent=2) def add(instance_uid, svc_id, display_name): with _LOCK: data = _load() data[instance_uid] = { "svcId": svc_id, "displayName": display_name, "instanceUid": instance_uid, } _save(data) def remove(instance_uid): with _LOCK: data = _load() data.pop(instance_uid, None) _save(data) def list_all(): with _LOCK: return list(_load().values()) ``` ### 4. site/routes/api_test.py — основной файл с проблемой ```python from flask import Blueprint, current_app, jsonify, request from api.http_client import HttpClient from operations.get_services import get_services, get_service_detail from operations.get_instances import get_instances from operations.tracker import add as tracker_add, list_all as tracker_list from operations.tracker import remove as tracker_remove bp = Blueprint("api_test", __name__) def _client(): token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] return HttpClient(current_app.config["NUBES_API_ENDPOINT"], token) @bp.route("/api/services") def api_services(): try: raw = get_services(_client()) svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] svc_list.sort(key=lambda s: s["svcId"]) return jsonify(svc_list) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/instances/list") def api_instances_list(): try: raw = get_instances(_client()) inst = [{"instanceUid": i["instanceUid"], "displayName": i["displayName"], "serviceId": i["serviceId"], "svc": i["svc"], "explainedStatus": i.get("explainedStatus", "?")} for i in raw] return jsonify(inst) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/operations/") def api_operations(svc_id): try: detail = get_service_detail(_client(), svc_id) ops = detail.get("operations", []) # только отслеживаемые инстансы этого сервиса tracked = tracker_list() tracked_uids = {t["instanceUid"] for t in tracked if t["svcId"] == svc_id} instances = get_instances(_client()) svc_instances = [i for i in instances if i.get("instanceUid") in tracked_uids and i.get("explainedStatus") not in ("deleted", "not created")] return jsonify({ "svc": detail.get("svc", ""), "operations": [{"svcOperationId": o["svcOperationId"], "operation": o["operation"]} for o in ops], "instances": svc_instances, }) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/params/") def api_params(op_id): try: data = _client().get(f"/instanceOperations/default/{op_id}") params = data["svcOperation"]["cfsParams"] result = [] for p in params: result.append({ "svcOperationCfsParamId": p["svcOperationCfsParamId"], "name": p.get("svcOperationCfsParam", ""), "dataType": p.get("dataType", ""), "isRequired": p.get("isRequired", False), "defaultValue": p.get("defaultValue"), "valueList": p.get("valueList"), "dataDescriptor": {k: {"dataType": v.get("dataType",""), "valueList": v.get("valueList",""), "isRequired": v.get("isRequired", False)} for k, v in p.get("dataDescriptor", {}).items()} if p.get("dataDescriptor") else None, }) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/test", methods=["POST"]) def api_test(): """Запустить операцию — возвращает opUid сразу, выполнение в фоне.""" import threading, time data = request.get_json() svc_id = data["serviceId"] op_name = data["operation"] svc_op_id = data["svcOperationId"] params = data.get("params", {}) instance_uid = data.get("instanceUid") display_name = data.get("displayName", f"autotest-{svc_id}") client = _client() try: if op_name == "create": payload = {"serviceId": svc_id, "displayName": display_name, "descr": ""} resp = client.post("/instances", payload) instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", "")) if not instance_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить instanceUid"}), 500 op_payload = {"instanceUid": instance_uid, "operation": "create"} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 for pid, pval in params.items(): client.post("/instanceOperationCfsParams", {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) client.post(f"/instanceOperations/{op_uid}/run") # Записать в трекер СРАЗУ, до фонового потока try: tracker_add(instance_uid, svc_id, display_name) except Exception: pass # ← МОЛЧА ПРОГЛАТЫВАЕТ ОШИБКУ # фоном ждать завершения threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid}) else: if not instance_uid: return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400 if op_name == "redeploy": op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 client.post(f"/instanceOperations/{op_uid}/run") else: op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} op_resp = client.post("/instanceOperations", op_payload) op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 for pid, pval in params.items(): client.post("/instanceOperationCfsParams", {"instanceOperationUid": op_uid, "svcOperationCfsParamId": int(pid), "paramValue": str(pval)}) client.post(f"/instanceOperations/{op_uid}/run") is_delete = (op_name == "delete") threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, False, is_delete), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid}) except Exception as e: import traceback return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]}) # Результаты фоновых операций: opUid → {status, error, stages, duration} _op_results = {} 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 "" print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True) if is_ok and 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)} @bp.route("/api/test/status/") def api_test_status(op_uid): """Получить текущий статус операции (поллинг с UI).""" if op_uid in _op_results: return jsonify(_op_results[op_uid]) try: data = _client().get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages") op = data.get("instanceOperation", {}) dt_finish = op.get("dtFinish") done = bool(dt_finish and str(dt_finish).strip()) return jsonify({ "status": "OK" if (done and op.get("isSuccessful")) else ("FAIL" if done else "RUNNING"), "done": done, "isSuccessful": op.get("isSuccessful"), "isInProgress": op.get("isInProgress"), "duration": op.get("duration"), "stages": op.get("stages", []), "errorLog": op.get("errorLog"), }) except Exception as e: return jsonify({"error": str(e)}), 500 def _find_uid(d): if isinstance(d, dict): for k in ("instanceUid", "instanceOperationUid", "uid", "Uid"): if k in d: return d[k] return None def _uid_from_location(loc): parts = loc.rstrip("/").split("/") return parts[-1] if parts else None ``` ### 5. site/routes/main.py — главная страница (фрагмент с /api/operations) ```python @bp.route("/api/operations/") def api_operations(svc_id): try: detail = get_service_detail(_client(), svc_id) ops = detail.get("operations", []) tracked = tracker_list() tracked_uids = {t["instanceUid"] for t in tracked if t["svcId"] == svc_id} instances = get_instances(_client()) svc_instances = [i for i in instances if i.get("instanceUid") in tracked_uids and i.get("explainedStatus") not in ("deleted", "not created")] return jsonify({ "svc": detail.get("svc", ""), "operations": [{"svcOperationId": o["svcOperationId"], "operation": o["operation"]} for o in ops], "instances": svc_instances, }) except Exception as e: return jsonify({"error": str(e)}), 500 ``` ### 6. site/templates/index.html — UI (ключевые функции) ```javascript // Инстансы + кнопки операций async function selectService(svcId){ selectedInst=null; selectedOp=null; stopPoll(); document.getElementById('params-card').style.display='none'; document.getElementById('stages-box').style.display='none'; const r=await fetch('/api/operations/'+svcId); const d=await r.json(); svcInstances=d.instances||[]; // ... рендерит список } async function executeOp(params){ stopPoll(); const displayName=document.getElementById('param-displayname')?.value||'autotest-1'; // ... очищает форму const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ serviceId:SVC_ID, operation:selectedOp.opName, svcOperationId:selectedOp.opId, params, instanceUid:selectedInst||'', displayName })}); const d=await r.json(); if(d.status==='FAIL'){/* ошибка */ return;} // start polling const opUid=d.opUid; pollTimer=setInterval(async()=>{ const sr=await fetch('/api/test/status/'+opUid); const sd=await sr.json(); showStages(sd.stages||[]); if(sd.status!=='RUNNING'){ stopPoll(); // ... показать результат if(sd.status==='OK'){ if(selectedOp.opName==='create'){await selectService(SVC_ID);} // ← ПЕРЕЗАГРУЖАЕТ ВСЁ else{await refreshInstances();} } } },2000); } async function refreshInstances(){ const r=await fetch('/api/operations/'+SVC_ID); const d=await r.json(); svcInstances=d.instances||[]; // обновить только бейджи статусов svcInstances.forEach(i=>{ const el=document.querySelector(`[data-iuid="${i.instanceUid}"] .badge`); if(el){ el.textContent=i.explainedStatus||'?'; el.className='badge '+(i.explainedStatus==='running'?'badge-success':''); } }); } ``` --- ## ЧТО ПРОИСХОДИТ ПРИ CREATE (трассировка) 1. UI: `executeOp()` → POST `/api/test` `{serviceId:1, operation:"create", svcOperationId:18, params:{...}, instanceUid:"", displayName:"autotest-1-lq5x3a"}` 2. `api_test()`: `op_name="create"`, `svc_id=1` (int), `display_name="autotest-1-lq5x3a"` (str) 3. `client.post("/instances", ...)` → ответ от Nubes → `instance_uid = "f192b10d-beb0-4a25-b79f-5dbd7de4712e"` 4. `client.post("/instanceOperations", ...)` → `op_uid = "d489348e-5d92-47cd-97f7-25f1c4d65ffc"` 5. Параметры → `client.post("/instanceOperationCfsParams", ...)` для каждого 6. `client.post("/instanceOperations/{op_uid}/run")` → запуск 7. **`tracker_add("f192b10d-...", 1, "autotest-1-lq5x3a")`** ← ЗДЕСЬ ПРОБЛЕМА 8. `threading.Thread(target=_finish_op, ...)` → фон 9. Возврат `{status:"RUNNING", opUid:"d489348e-...", instanceUid:"f192b10d-..."}` 10. UI поллит `/api/test/status/d489348e-...` 11. `_finish_op` получает `isSuccessful=true` → `_op_results[opUid] = {status:"OK", ...}` 12. UI получает OK → вызывает `selectService(1)` → `/api/operations/1` → `tracker_list()` → 4 инстанса → **нового НЕТ** --- ## ВОПРОСЫ ### Критический: почему tracker_add не работает? 1. Может ли `json.dump` в `/tmp/instances.json` падать молча на pythonk8s? (диск full, fs ro, quota) 2. Может ли `_save` записать, но `_load` прочитать старую версию из-за кеша ФС? 3. Может ли gunicorn preload создавать несколько копий модуля tracker.py с разными `_LOCK`? 4. Может ли `_INITIAL` перезаписывать файл при КАЖДОМ `_load`, если файл повреждён? 5. **ГЛАВНЫЙ ВОПРОС: как надёжно сохранять состояние на pythonk8s БЕЗ persistent volume?** ### Архитектурный 6. Не перейти ли на sqlite3 в `/tmp/`? Даст ли это атомарность? 7. Не заменить ли `/tmp/instances.json` на in-memory dict + seed из `_INITIAL` при старте? (без файла вообще) 8. Как правильно логировать ошибки на pythonk8s чтобы их было видно? ### UI 9. После успешного CREATE `selectService()` скрывает progress card — это бесит. Как обновить только список инстансов не скрывая stages? --- ## Что уже пробовали (НЕ ПОМОГЛО) - v1.0.46: tracker_add в daemon-потоке _finish_op → поток умирает под gunicorn - v1.0.47: tracker_add в _finish_op, но до _op_results[OK] → не помогло - v1.0.48: tracker_add синхронно в api_test() ДО потока, try/except: pass → ОШИБКА СКРЫТА