Files
app-autotest/DOCS/sonnet-full-code-review.md
T

510 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ⚠️ 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/<int:svc_id>")
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/<int:op_id>")
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/<op_uid>")
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/<int:svc_id>")
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 → ОШИБКА СКРЫТА