# Архитектура app-autotest — полный документ v1.0.65, 27.07.2026 --- ## 1. Обзор Flask-приложение для автотестов операций сервисов облачной платформы Nubes. Деплой: Nubes pythonk8s (gunicorn), тестовый стенд `atest.pythonk8s.dev.nubes.ru`. Репозиторий: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`. --- ## 2. Структура файлов ``` app-autotest/ ├── site/ ← НЕ пакет (без __init__.py, конфликт с stdlib) │ ├── app.py ← Flask(__name__), VERSION, blueprints, /health │ ├── api/ │ │ └── http_client.py ← HttpClient + STANDS + detect_endpoint() │ ├── operations/ │ │ ├── tracker.py ← Трекер: /tmp/instances.json + fcntl.flock │ │ ├── get_services.py ← get_services(), get_service_detail() │ │ └── get_instances.py ← get_instances(), get_organization() │ ├── routes/ │ │ ├── main.py ← GET/POST / (главная), _tmpl() │ │ ├── api.py ← /api/run, /api/status, /api/config │ │ └── api_test.py ← /api/test, /api/test/status, _finish_op │ ├── templates/ │ │ └── index.html ← UI: Jinja2 + JS (поллинг) │ └── static/ │ └── style.css └── secrets/ ├── dev.token ← Токен для DEV стенда ├── test.token ← Токен для TEST стенда └── test.token.new ``` --- ## 3. Поток CREATE — полная трассировка (14 шагов) ### Шаг 1: UI — пользователь начинает CREATE ```javascript // index.html: startCreate() function startCreate(){ selectedInst=null; // нет выбранного инстанса stopPoll(); // остановить предыдущий поллинг document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); showParams(18,'create'); // 18 = svcOperationId для create у сервиса "Болванка" } ``` `selectedOp = {opId: 18, opName: "create", svcId: 1}` ### Шаг 2: UI — загрузка параметров и показ формы ```javascript // index.html: showParams(18, 'create') fetch('/api/params/18') // GET /api/params/18 .then(r=>r.json()) .then(params=>{ // Генерация формы: displayName + поля из params const displayName = 'autotest-1-' + Date.now().toString(36); form.innerHTML = '' + params.map(p => '').join(''); btn.onclick = () => { // Сбор params из формы const pp = {}; document.querySelectorAll('#params-form [name^="p_"]').forEach(el => { pp[el.name.replace('p_','')] = el.value; }); executeOp(pp); // ← запуск }; }); ``` Бэкенд: `GET /instanceOperations/default/18` → возвращает список `cfsParams`. ### Шаг 3: UI — executeOp (отправка) ```javascript // index.html: executeOp(params) const displayName = document.getElementById('param-displayname')?.value || 'autotest-1'; // Захват ДО очистки формы! (v1.0.48) document.getElementById('params-form').innerHTML = ''; // очистка fetch('/api/test', { method:'POST', body: JSON.stringify({ serviceId: 1, // SVC_ID operation: "create", svcOperationId: 18, params: {...}, // собранные параметры instanceUid: "", // пусто для create displayName: displayName }) }) ``` JSON: `{"serviceId":1, "operation":"create", "svcOperationId":18, "params":{...}, "instanceUid":"", "displayName":"autotest-1-lq5x3a"}` ### Шаг 4: Бэкенд — api_test() разбор запроса ```python # api_test.py: POST /api/test data = request.get_json() svc_id = data["serviceId"] # 1 (int) op_name = data["operation"] # "create" (str) svc_op_id = data["svcOperationId"] # 18 (int) params = data.get("params", {}) # {...} (dict) instance_uid = data.get("instanceUid") # "" (falsy) display_name = data.get("displayName", f"autotest-{svc_id}") # "autotest-1-lq5x3a" ``` ### Шаг 5: Бэкенд — _client() с автоопределением стенда ```python def _client(): token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] return HttpClient(endpoint, token) ``` `detect_endpoint(token)` пробует dev→test стенды, возвращает рабочий URL или None. ### Шаг 6: Бэкенд — создание инстанса в Nubes ```python payload = {"serviceId": 1, "displayName": "autotest-1-lq5x3a", "descr": ""} resp = client.post("/instances", payload) # Ответ: {"instanceUid": "93b0b65d-..."} instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(...) # → "93b0b65d-e080-416d-bbb8-463f7adbda80" ``` ### Шаг 7: Бэкенд — создание операции ```python op_payload = {"instanceUid": "93b0b65d-...", "operation": "create"} op_resp = client.post("/instanceOperations", op_payload) # Ответ: {"instanceOperationUid": "0b0596d8-..."} op_uid = _find_uid(op_resp) or _uid_from_location(...) # _find_uid ищет: instanceOperationUid → instanceUid → uid → Uid # → "0b0596d8-b48d-4694-98c3-65f2f8eeb27d" ``` ### Шаг 8: Бэкенд — tracker_add (v1.0.54: ДО params!) ```python try: tracker_add("93b0b65d-...", 1, "autotest-1-lq5x3a") except Exception as e: print(f"[TRACKER ERROR] ...") ``` `tracker.py`: `_locked_read()` → `data["93b0b65d-..."] = {...}` → `_locked_write(data)`. Файл `/tmp/instances.json` обновлён под `fcntl.LOCK_EX | LOCK_NB`. ### Шаг 9: Бэкенд — установка параметров и запуск ```python 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") ``` Если params пустые (например, повторный клик на «Готово») → `/run` → 422 "required CFS parameter ... is missing". ### Шаг 10: Бэкенд — фоновый поток ```python 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}) ``` ### Шаг 11: UI — поллинг статуса ```javascript // index.html: executeOp() — после получения RUNNING pollTimer = setInterval(async () => { const sr = await fetch('/api/test/status/' + opUid); const sd = await sr.json(); showStages(sd.stages || []); if (sd.status !== 'RUNNING') { stopPoll(); // показать OK/FAIL btn.textContent = 'Готово'; btn.disabled = false; // ⚠️ БАГ: btn.onclick всё ещё активен! if (sd.status === 'OK') { await refreshInstances(); } } }, 2000); ``` ### Шаг 12: Бэкенд — _finish_op (фоновый) ```python while time.time() < deadline: # 300 секунд data = client.get(f"/instanceOperations/{op_uid}?fields=...") op = data.get("instanceOperation", {}) dt_finish = op.get("dtFinish") _op_results[op_uid] = {"status": "RUNNING", "stages": [...], "duration": ...} if dt_finish and str(dt_finish).strip(): is_ok = op.get("isSuccessful") # tracker_add уже вызван синхронно! if is_ok and is_delete: tracker_remove(instance_uid) _op_results[op_uid] = {"status": "OK" if is_ok else "FAIL", ...} return time.sleep(5) ``` Весь цикл обёрнут в `try/except: print(traceback)` (v1.0.51). ### Шаг 13: UI — refreshInstances ```javascript async function refreshInstances(){ const r = await fetch('/api/operations/1'); const d = await r.json(); const newInsts = d.instances || []; // Найти новые (отсутствуют в svcInstances) const added = newInsts.filter(i => !oldUids.has(i.instanceUid)); // Обновить бейджи существующих // Добавить новые в DOM перед кнопкой "+ Создать" } ``` ### Шаг 14: Бэкенд — api_operations (GET /api/operations/1) ```python tracked = tracker_list() # читает /tmp/instances.json tracked_by_uid = {uid: item for uid in tracked if svcId == 1} instances = get_instances(_client()) # запрос в Nubes API nubes_uids = {i["instanceUid"] for i in instances} # Инстансы из Nubes которые есть в трекере (кроме deleted) svc_instances = [i for i in instances if i.get("instanceUid") in tracked_uids and i.get("explainedStatus") not in ("deleted",)] # Инстансы из трекера которых Nubes ещё не отдаёт → status "creating" for uid, t in tracked_by_uid.items(): if uid not in nubes_uids: svc_instances.append({ "instanceUid": uid, "displayName": t["displayName"], "explainedStatus": "creating", ... }) ``` --- ## 4. Трекер инстансов ### 4.1 Назначение Хранит UID инстансов, созданных приложением. Нужен чтобы показывать в UI только «наши» инстансы, а не все в организации. ### 4.2 Реализация (v1.0.53+) Файл: `site/operations/tracker.py` - Хранилище: `/tmp/instances.json` (JSON-файл) - Блокировка: `fcntl.flock(fd, LOCK_EX | LOCK_NB)` с retry до 2 сек - Seed: `_INITIAL` (4 инстанса) при пустом/битом файле - Функции: `add(uid, svc_id, name)`, `remove(uid)`, `list_all()` ### 4.3 Эволюция | Версия | Реализация | Проблема | |--------|-----------|----------| | v1.0.45 | `/tmp/instances.json` + `threading.Lock` | `except: pass` скрывал ошибки | | v1.0.49 | In-memory dict | Multi-worker gunicorn: у каждого свой dict | | v1.0.53 | `/tmp/instances.json` + `fcntl.flock(LOCK_EX)` | Без таймаута: потенциальный зависон | | v1.0.54 | `/tmp/instances.json` + `fcntl.flock(LOCK_EX\|LOCK_NB)` + retry 2s | ✅ Текущий | --- ## 5. Автоопределение стенда Файл: `site/api/http_client.py` ```python 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): 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 ``` Используется в: - `main.py`: при загрузке главной страницы - `api_test.py: _client()`: при каждом API-запросе --- ## 6. Все известные баги (найдено/исправлено) ### Исправленные | # | Версия | Баг | Причина | Исправление | |---|--------|-----|---------|-------------| | 1 | v1.0.46 | `_finish_op()` недостающие параметры | Не передавались `op_name`, `svc_op_id` | Добавлены в сигнатуру | | 2 | v1.0.48 | displayName терялся | `params-form.innerHTML=''` до чтения `param-displayname` | Захват до очистки | | 3 | v1.0.48 | tracker_add в daemon-потоке | Поток умирает под gunicorn | Перенос в синхронный код | | 4 | v1.0.50 | ❌ для этапов в процессе | `isSuccessful===false` для in-progress | Проверка `dtFinish` | | 5 | v1.0.50 | Инстанс не в списке | `explainedStatus:"not created"` фильтровался | Убран фильтр + tracked-сироты | | 6 | v1.0.50 | `selectService()` скрывал stages | Полный перерендер после OK | `refreshInstances()` с диффом | | 7 | v1.0.51 | `instance_groups` UnboundLocalError | Не иниц. до `if active_token:` | `instance_groups = {}` | | 8 | v1.0.51 | `_finish_op` молча умирал | `data.get()` вне try/except | Весь цикл в try/except | | 9 | v1.0.51 | Автостенд только в main.py | `_client()` в api_test.py не использовал | `detect_endpoint()` в http_client.py | | 10 | v1.0.52 | `token_info` UndefinedError | Не передан в шаблон при action=clear | `_tmpl()` helper со всеми переменными | | 11 | v1.0.53 | In-memory dict + multi-worker | У каждого воркера свой `_data` | `/tmp/instances.json` + `fcntl.flock` | | 12 | v1.0.54 | `_find_uid()` возвращал не тот UUID | Итерация по всем значениям dict | Поиск по ключам: instanceOperationUid→instanceUid | | 13 | v1.0.54 | Сироты при ошибке params | `tracker_add` ПОСЛЕ params loop | `tracker_add` ДО params loop | | 14 | v1.0.54 | `flock` без таймаута | `LOCK_EX` блокируется навсегда | `LOCK_EX \| LOCK_NB` + retry 2s | | 15 | v1.0.55 | F5 = предупреждение браузера | clear/stray POST → HTML без редиректа | `redirect("/")` для всех POST | | 16 | v1.0.56 | Кнопка «Готово» запускала повторный CREATE | `btn.onclick` оставался привязанным после завершения операции | `btn.onclick = null` после финального статуса | | 17 | v1.0.57 | Хрупкость финального состояния кнопки | Повторяемый код финализации статуса | `setFinishedState()` централизует финальный UI-состояние | | 18 | v1.0.59 | После F5 список терял tracked-инстанс | `api_operations()` не добирал tracked-сирот обратно из трекера | `tracked_by_uid` + добавление отсутствующих tracked-инстансов | | 19 | v1.0.60 | CREATE не появлялся сразу в списке после OK | UI делал diff и мог не перерисовать список целиком | Полная перерисовка `inst-list` в `refreshInstances()` | | 20 | v1.0.63 | Дубли и чужие инстансы | Принадлежность определялась не по namespace | `autotest-` prefix + уникализация `displayName` при CREATE | | 21 | v1.0.64 | Версия не показывалась в шапке | `load_config()` затирал VERSION | `config["VERSION"] = current_app.config["VERSION"]` | | 22 | v1.0.64 | displayName был editable value, а не placeholder | CREATE подставлял имя в value | Placeholder `autotest-...`, значение вводит пользователь | | 23 | v1.0.65 | `creating` из-за недочитанной страницы | `get_instances()` доверял `total` и мог не перейти на следующую страницу | Остановка по `len(batch) < pageSize` | ### ✅ Закрыто в v1.0.56 | Баг | Статус | Что поменяли | |-----|--------|--------------| | Кнопка «Готово» запускала повторный CREATE | закрыто | После финального статуса обработчик снимается через `btn.onclick = null` | | displayName "autotest-1" при повторном клике | закрыто | Повторный клик больше не вызывает `executeOp()` на очищенной форме | --- ## 7. HTTP-клиент Файл: `site/api/http_client.py` ```python class HttpClient: def __init__(self, endpoint, token): # Bearer auth + User-Agent: Mozilla/5.0 (DDoS-Guard) def get(self, path, timeout=10): # GET → r.raise_for_status() → r.json() def post(self, path, data, timeout=30): # POST → проверка r.ok → парсинг JSON → Location header ``` --- ## 8. Шаблон Jinja2 — используемые переменные | Переменная | Где | Назначение | |-----------|-----|-----------| | `config.VERSION` | Строка 32 | Версия в топбаре | | `token_info.email` | Строка 33 | Email пользователя | | `token_info.company` | Строка 33 | Компания | | `env_token_masked` | Строка 36 | Маскированный токен в placeholder | | `has_user_token` | Строка 36 | Показать токен или маску | | `error` | Строка 40 | Ошибка | | `organization.displayName` | Строка 50 | Название организации | | `organization.explainedStatus` | Строка 51 | Статус организации | | `client_id` | Строка 50 | ID клиента | | `instance_groups` | Строки 55-62 | Группы инфраструктурных инстансов | --- ## 9. Ограничения платформы Nubes pythonk8s - `site/` — НЕ пакет (без `__init__.py`, конфликт с stdlib `site.py`) - Импорты: `from api.http_client import ...` (без префикса `site.`) - `app.run(host="0.0.0.0", port=5000)` — обязательно - Gunicorn: запускается платформой, количество воркеров неизвестно - Нет persistent volume — `/tmp/` теряется при редеплое - User-Agent: `Mozilla/5.0` обязателен (DDoS-Guard) - Деплой: `git push` → managed service редеплоит