diff --git a/DOCS/step-by-step-audit-v1.0.50.md b/DOCS/step-by-step-audit-v1.0.50.md new file mode 100644 index 0000000..f8bde83 --- /dev/null +++ b/DOCS/step-by-step-audit-v1.0.50.md @@ -0,0 +1,131 @@ +# Пошаговый аудит кода — 27.07.2026 (v1.0.50) + +## Методология + +Полная трассировка CREATE-потока: UI → api_test() → _finish_op() → api_test_status() → refreshInstances() → api_operations(). Каждый if, try, except, присваивание. + +--- + +## Результат: найдено 3 бага + +### 🔴 Баг #1 (КРИТИЧЕСКИЙ): Multi-worker gunicorn — in-memory dict не работает + +**Файл:** `site/operations/tracker.py` + +```python +_data = { + "408b7f96-...": {...}, # 4 initial items +} + +def add(instance_uid, svc_id, display_name): + with _LOCK: + _data[instance_uid] = {...} +``` + +`_data` — module-level dict. У каждого gunicorn worker своя копия модуля → свой `_data`. + +**Трассировка:** +1. POST /api/test → попадает на **воркер A** +2. `tracker_add(...)` → `_data` воркера A = 5 элементов ✅ +3. GET /api/operations/1 → попадает на **воркер B** +4. `tracker_list()` → `_data` воркера B = 4 элемента ❌ + +**Почему это объясняет ВСЕ симптомы:** +- После F5 — рандомный воркер → опять 4 +- v1.0.46-1.0.49 — ни одно решение не помогало (in-memory принципиально не跨-process) +- Инстанс в Nubes есть, в трекере нет — разные воркеры + +**Решение:** вернуть файловый трекер (`/tmp/instances.json`) с межпроцессной блокировкой (`fcntl.flock` вместо `threading.Lock`). + +--- + +### 🔴 Баг #2 (КРИТИЧЕСКИЙ): _finish_op() умирает молча на не-словаре + +**Файл:** `site/routes/api_test.py`, строки 176-179 + +```python +def _finish_op(...): + while time.time() < deadline: + try: + data = client.get(...) # ← except ловит ТОЛЬКО это + except Exception: + time.sleep(5) + continue + op = data.get("instanceOperation", {}) # ← если data не dict → AttributeError! +``` + +Если Nubes API возвращает `list`, `str`, `None` или любой не-словарь — `data.get()` → **AttributeError**. Этот except НЕ покрывает строку 179. Поток умирает молча. Python daemon-потоки не пишут traceback. + +**Решение:** обернуть ВСЁ тело цикла (строки 166-195) в `try/except Exception: print(traceback)`. + +--- + +### 🟡 Баг #3 (НЕКРИТИЧНЫЙ): _op_results — та же multi-worker проблема + +**Файл:** `site/routes/api_test.py`, строка 152 + +```python +_op_results = {} # module-level + +# В _finish_op (воркер A): +_op_results[op_uid] = {"status": "OK", ...} + +# В api_test_status (воркер B): +if op_uid in _op_results: # ← False! (другой воркер) + return jsonify(_op_results[op_uid]) +# fallback → прямой запрос в Nubes API +``` + +**Некритично** потому что есть fallback: если `_op_results` не содержит op_uid, `api_test_status()` делает прямой GET в Nubes API и возвращает статус. UI получает stages напрямую из Nubes, не из `_op_results`. + +**НО:** fallback не обновляет `tracker_remove` для delete. При multi-worker delete не удалит инстанс из трекера. + +--- + +## Полная трассировка CREATE (все шаги) + +| Шаг | Код | Результат | Статус | +|-----|-----|-----------|--------| +| 1 | `data = request.get_json()` | `svc_id=1, op_name="create", display_name="autotest-1-xxx"` | ✅ | +| 2 | `client.post("/instances", ...)` | `instance_uid = "f192b10d-..."` | ✅ | +| 3 | `client.post("/instanceOperations", ...)` | `op_uid = "d489348e-..."` | ✅ | +| 4 | `for pid,pval: client.post("/instanceOperationCfsParams", ...)` | Параметры установлены | ✅ | +| 5 | `client.post("/instanceOperations/{op_uid}/run")` | Операция запущена | ✅ | +| 6 | `tracker_add(instance_uid, svc_id, display_name)` | `_data[uid] = {...}` (in-memory) | ✅ | +| 7 | `threading.Thread(target=_finish_op, ...)` | Поток запущен | ✅ | +| 8 | `return {status:"RUNNING", opUid, instanceUid}` | Ответ UI | ✅ | +| 9 | UI poll `/api/test/status/` | `_op_results[opUid]` или fallback API | ⚠️ разн. воркеры | +| 10 | `_finish_op` детектит `dtFinish` | `_op_results[opUid] = {status:"OK"}` | ⚠️ воркер A | +| 11 | UI: `refreshInstances()` → `/api/operations/1` | `tracker_list()` → 4 элемента | ❌ воркер B | +| 12 | `api_operations()` возвращает 4 инстанса | Нового нет | ❌ | +| 13 | F5 → `selectService(1)` → `/api/operations/1` | Опять 4 | ❌ | + +--- + +## Дополнительные находки (не критические) + +### ⚠️ params loop — orphaned resources при ошибке +Если `client.post("/instanceOperationCfsParams", ...)` падает на mid-param: +- Инстанс УЖЕ создан в Nubes (сирота) +- Операция УЖЕ создана (сирота) +- `tracker_add` НЕ вызван (он после цикла) +- Ответ: FAIL + +### ⚠️ `data["svcOperationId"]` — KeyError если поле отсутствует +Вызов API без `svcOperationId` в JSON → KeyError → FAIL. Обработано внешним try/except. + +### ⚠️ `int(pid)` — ValueError если param ID не число +`int("abc")` → ValueError → FAIL. Обработано внешним try/except. + +### ⚠️ `_find_uid()` итерация по ВСЕМ значениям +Может случайно найти uid во вложенном объекте. Низкий риск, но нечисто. + +--- + +## План исправлений для v1.0.51 + +| # | Что | Как | +|---|-----|-----| +| 1 | Файловый трекер с fcntl.flock | Вернуть `/tmp/instances.json`, заменить `threading.Lock` на `fcntl.flock` | +| 2 | _finish_op: try/except на всё тело | Обернуть строки 166-195 в `try/except: print(traceback)` | +| 3 | (опционально) `svc_id = int(data["serviceId"])` | Защита от строкового "1" в JSON | diff --git a/site/app.py b/site/app.py index 9c76316..fc542df 100644 --- a/site/app.py +++ b/site/app.py @@ -6,7 +6,7 @@ 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.50" +VERSION = "1.0.51" 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") diff --git a/site/routes/api_test.py b/site/routes/api_test.py index 18b0bec..19763a0 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -178,33 +178,38 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ 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") - # Обновляем stages по мере появления - _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) - # tracker_add для create уже вызван синхронно в api_test() - if is_ok and is_delete: - tracker_remove(instance_uid) + 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") + # Обновляем stages по мере появления _op_results[op_uid] = { - "status": "OK" if is_ok else "FAIL", - "error": str(err) if err else "", + "status": "RUNNING", "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), } - return - time.sleep(5) + 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) + # tracker_add для create уже вызван синхронно в api_test() + 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) + except Exception: + import traceback + print(f"[ERROR] _finish_op crashed: {traceback.format_exc()}", flush=True) + time.sleep(5) _op_results[op_uid] = {"status": "TIMEOUT", "duration": round(time.time() - t0, 1)} diff --git a/site/routes/main.py b/site/routes/main.py index 84f6d72..3efc0ac 100644 --- a/site/routes/main.py +++ b/site/routes/main.py @@ -8,6 +8,24 @@ from runner import load_config bp = Blueprint("main", __name__) +STANDS = [ + "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc", + "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc", +] + + +def _detect_endpoint(token): + """Пробуем токен против dev и test стендов, возвращаем рабочий URL.""" + for ep in STANDS: + try: + c = HttpClient(ep, token) + data = c.get("/instances", params={"pageSize": 1, "page": 1}) + if data.get("results") is not None: + return ep + except Exception: + continue + return None + def _mask(s): if not s or len(s) < 8: @@ -74,32 +92,34 @@ def index(): services = [] instances = [] + instance_groups = {} config = {} if active_token: - try: - client = HttpClient( - current_app.config["NUBES_API_ENDPOINT"], - active_token, - ) - org = get_organization(client) - raw_svc = get_services(client) - services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) - # инфраструктурные сервисы (платформа/среда) - infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150} - raw_inst = get_instances(client) - instances = [i for i in raw_inst - if i.get("explainedStatus") not in ("deleted", "not created") - and i.get("serviceId") in infra_ids] - instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", ""))) + endpoint = _detect_endpoint(active_token) + if endpoint: + try: + client = HttpClient(endpoint, active_token) + org = get_organization(client) + raw_svc = get_services(client) + services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) + # инфраструктурные сервисы (платформа/среда) + infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150} + raw_inst = get_instances(client) + instances = [i for i in raw_inst + if i.get("explainedStatus") not in ("deleted", "not created") + and i.get("serviceId") in infra_ids] + instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", ""))) - # group by service type - instance_groups = {} - for i in instances: - svc_name = i.get("svc", "Прочее") - instance_groups.setdefault(svc_name, []).append(i) - config = load_config() - except Exception as e: - error = str(e) + # group by service type + instance_groups = {} + for i in instances: + svc_name = i.get("svc", "Прочее") + instance_groups.setdefault(svc_name, []).append(i) + config = load_config() + except Exception as e: + error = str(e) + else: + error = "Токен невалиден или просрочен" return render_template("index.html", organization=org, error=error,