18 KiB
Архитектура app-autotest — полный документ
v1.0.68, 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
// 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 — загрузка параметров и показ формы
// 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 = '<input id="param-displayname" value="' + displayName + '">'
+ params.map(p => '<input name="p_' + p.svcOperationCfsParamId + '">').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 (отправка)
// 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() разбор запроса
# 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() с автоопределением стенда
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
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: Бэкенд — создание операции
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!)
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: Бэкенд — установка параметров и запуск
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: Бэкенд — фоновый поток
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 — поллинг статуса
// 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 (фоновый)
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
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)
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
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
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, конфликт с stdlibsite.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 редеплоит