v1.0.49: in-memory tracker (no file IO); traceback on tracker error; refreshInstances adds new items; no selectService on OK
This commit is contained in:
@@ -0,0 +1,507 @@
|
|||||||
|
# Полный обзор кода 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 → ОШИБКА СКРЫТА
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from routes.main import bp as main_bp
|
|||||||
from routes.api import bp as api_bp
|
from routes.api import bp as api_bp
|
||||||
from routes.api_test import bp as api_test_bp
|
from routes.api_test import bp as api_test_bp
|
||||||
|
|
||||||
VERSION = "1.0.48"
|
VERSION = "1.0.49"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
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_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""Трекер созданных инстансов — только те что породило приложение."""
|
"""Трекер созданных инстансов — in-memory dict (один gunicorn worker)."""
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
_PATH = "/tmp/instances.json"
|
|
||||||
|
|
||||||
_INITIAL = {
|
_data = {
|
||||||
"408b7f96-6ed7-4985-be1b-f5bdcb6ab44d": {"svcId": 1, "displayName": "autotest-1", "instanceUid": "408b7f96-6ed7-4985-be1b-f5bdcb6ab44d"},
|
"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"},
|
"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"},
|
"6528854d-a4ea-428c-9fa4-68e85fa9b3ac": {"svcId": 1, "displayName": "curl-test-144834", "instanceUid": "6528854d-a4ea-428c-9fa4-68e85fa9b3ac"},
|
||||||
@@ -14,39 +11,20 @@ _INITIAL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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):
|
def add(instance_uid, svc_id, display_name):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
data = _load()
|
_data[instance_uid] = {
|
||||||
data[instance_uid] = {
|
|
||||||
"svcId": svc_id,
|
"svcId": svc_id,
|
||||||
"displayName": display_name,
|
"displayName": display_name,
|
||||||
"instanceUid": instance_uid,
|
"instanceUid": instance_uid,
|
||||||
}
|
}
|
||||||
_save(data)
|
|
||||||
|
|
||||||
|
|
||||||
def remove(instance_uid):
|
def remove(instance_uid):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
data = _load()
|
_data.pop(instance_uid, None)
|
||||||
data.pop(instance_uid, None)
|
|
||||||
_save(data)
|
|
||||||
|
|
||||||
|
|
||||||
def list_all():
|
def list_all():
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
return list(_load().values())
|
return list(_data.values())
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ def api_test():
|
|||||||
# Записать в трекер СРАЗУ, до фонового потока (поток может умереть под gunicorn)
|
# Записать в трекер СРАЗУ, до фонового потока (поток может умереть под gunicorn)
|
||||||
try:
|
try:
|
||||||
tracker_add(instance_uid, svc_id, display_name)
|
tracker_add(instance_uid, svc_id, display_name)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
import traceback
|
||||||
|
print(f"[TRACKER ERROR] add failed: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
# фоном ждать завершения
|
# фоном ждать завершения
|
||||||
threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start()
|
threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True), daemon=True).start()
|
||||||
|
|||||||
@@ -249,8 +249,7 @@ async function executeOp(params){
|
|||||||
btn.disabled=false;
|
btn.disabled=false;
|
||||||
btn.textContent='Готово';
|
btn.textContent='Готово';
|
||||||
if(sd.status==='OK'){
|
if(sd.status==='OK'){
|
||||||
if(selectedOp.opName==='create'){await selectService(SVC_ID);}
|
await refreshInstances();
|
||||||
else{await refreshInstances();}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},2000);
|
},2000);
|
||||||
@@ -278,8 +277,11 @@ function stopPoll(){
|
|||||||
async function refreshInstances(){
|
async function refreshInstances(){
|
||||||
const r=await fetch('/api/operations/'+SVC_ID);
|
const r=await fetch('/api/operations/'+SVC_ID);
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
svcInstances=d.instances||[];
|
const newInsts=d.instances||[];
|
||||||
// обновить только бейджи статусов
|
const oldUids=new Set(svcInstances.map(i=>i.instanceUid));
|
||||||
|
const added=newInsts.filter(i=>!oldUids.has(i.instanceUid));
|
||||||
|
svcInstances=newInsts;
|
||||||
|
// обновить бейджи существующих
|
||||||
svcInstances.forEach(i=>{
|
svcInstances.forEach(i=>{
|
||||||
const el=document.querySelector(`[data-iuid="${i.instanceUid}"] .badge`);
|
const el=document.querySelector(`[data-iuid="${i.instanceUid}"] .badge`);
|
||||||
if(el){
|
if(el){
|
||||||
@@ -287,6 +289,21 @@ async function refreshInstances(){
|
|||||||
el.className='badge '+(i.explainedStatus==='running'?'badge-success':'');
|
el.className='badge '+(i.explainedStatus==='running'?'badge-success':'');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// добавить новые инстансы в список (перед кнопкой "+ Создать")
|
||||||
|
if(added.length){
|
||||||
|
const list=document.getElementById('inst-list');
|
||||||
|
const addBtn=list.querySelector('[onclick="startCreate()"]');
|
||||||
|
let html='';
|
||||||
|
added.forEach(i=>{
|
||||||
|
const sc=i.explainedStatus==='running'?'badge-success':'';
|
||||||
|
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
||||||
|
<span style="flex:1;">${i.displayName}</span>
|
||||||
|
<span class="badge ${sc}" style="font-size:9px;">${i.explainedStatus||'?'}</span>
|
||||||
|
</div>`;
|
||||||
|
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
||||||
|
});
|
||||||
|
if(addBtn){addBtn.insertAdjacentHTML('beforebegin',html);}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user