405 lines
17 KiB
Markdown
405 lines
17 KiB
Markdown
# Архитектура app-autotest — полный документ
|
||
|
||
v1.0.58, 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-состояние |
|
||
|
||
### ✅ Закрыто в 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 редеплоит
|