Files
app-autotest/DOCS/ARCHITECTURE.md
T

18 KiB
Raw Blame History

Архитектура app-autotest — полный документ

v1.0.71, 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, конфликт с 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 редеплоит