Files
app-autotest/DOCS/ARCHITECTURE.md
T

407 lines
18 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.
# Архитектура app-autotest — полный документ
v1.0.62, 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 = '<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 (отправка)
```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()` |
### ✅ Закрыто в 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 редеплоит