v1.0.88: new ARCHITECTURE.md (current state) + HISTORY updated + legacy docs marked
This commit is contained in:
@@ -0,0 +1,417 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. См. ARCHITECTURE.md для текущего состояния.
|
||||||
|
|
||||||
|
# Архитектура app-autotest — полный документ (v1.0.76)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Обзор
|
||||||
|
|
||||||
|
Flask-приложение для автотестов операций сервисов облачной платформы Nubes.
|
||||||
|
Деплой: Nubes pythonk8s (gunicorn), тестовый стенд `atest.pythonk8s.dev.nubes.ru`.
|
||||||
|
Репозиторий: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`.
|
||||||
|
|
||||||
|
Текущий срез потока операций:
|
||||||
|
- CREATE и обычные операции разделены в UI и backend.
|
||||||
|
- CREATE использует фиксированный префикс `autotest-`, non-create работает по выбранному инстансу.
|
||||||
|
- Финальный статус показывает статус, операцию, длительность и имя инстанса.
|
||||||
|
- Список autotest-инстансов строится cloud-first; tracker нужен только как короткий fallback после CREATE.
|
||||||
|
- **Modify** (и другие операции с параметрами) показывает **текущие** значения параметров инстанса: создаётся pending-операция, читаются `cfsParams.paramValue`, форма заполняется реальными данными. При submit операция переиспользуется через `previewOpUid`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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()` |
|
||||||
|
| 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 редеплоит
|
||||||
+127
-355
@@ -1,21 +1,26 @@
|
|||||||
# Архитектура app-autotest — полный документ
|
# Архитектура app-autotest — текущее состояние
|
||||||
|
|
||||||
v1.0.76, 27.07.2026
|
v1.0.87, 27.07.2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Обзор
|
## 1. Обзор
|
||||||
|
|
||||||
Flask-приложение для автотестов операций сервисов облачной платформы Nubes.
|
Flask-приложение (один HTML-файл, без SPA-фреймворка) для ручного тестирования операций
|
||||||
Деплой: Nubes pythonk8s (gunicorn), тестовый стенд `atest.pythonk8s.dev.nubes.ru`.
|
сервисов облачной платформы Nubes. Работает через REST API Nubes, автоопределяет стенд
|
||||||
Репозиторий: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`.
|
(dev/test) по JWT-токену.
|
||||||
|
|
||||||
Текущий срез потока операций:
|
**Деплой**: Nubes pythonk8s (gunicorn, несколько воркеров).
|
||||||
- CREATE и обычные операции разделены в UI и backend.
|
**URL**: `https://atest.pythonk8s.dev.nubes.ru`
|
||||||
- CREATE использует фиксированный префикс `autotest-`, non-create работает по выбранному инстансу.
|
**Репозиторий**: `https://gitea.services.ngcloud.ru/forcloud/app-autotest.git`
|
||||||
- Финальный статус показывает статус, операцию, длительность и имя инстанса.
|
|
||||||
- Список autotest-инстансов строится cloud-first; tracker нужен только как короткий fallback после CREATE.
|
### Ключевые принципы
|
||||||
- **Modify** (и другие операции с параметрами) показывает **текущие** значения параметров инстанса: создаётся pending-операция, читаются `cfsParams.paramValue`, форма заполняется реальными данными. При submit операция переиспользуется через `previewOpUid`.
|
|
||||||
|
1. **Источник правды — Nubes API**. Все данные (инстансы, параметры, статусы) получаются
|
||||||
|
только из API. Никаких локальных копий или кеша (кроме краткосрочного трекера).
|
||||||
|
2. **Разделение CREATE и non-CREATE**. Это два принципиально разных потока.
|
||||||
|
3. **Multi-worker safety**. Все общие ресурсы (/tmp файлы) защищены `fcntl.flock`.
|
||||||
|
4. **Мелкие функции**. Каждое действие — отдельная функция.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,395 +28,162 @@ Flask-приложение для автотестов операций серв
|
|||||||
|
|
||||||
```
|
```
|
||||||
app-autotest/
|
app-autotest/
|
||||||
├── site/ ← НЕ пакет (без __init__.py, конфликт с stdlib)
|
├── site/ ← НЕ пакет (без __init__.py)
|
||||||
│ ├── app.py ← Flask(__name__), VERSION, blueprints, /health
|
│ ├── app.py ← Flask(__name__), VERSION, blueprints, /health
|
||||||
|
│ ├── runner.py ← [LEGACY] Раннер тестов
|
||||||
|
│ ├── config.yaml ← [LEGACY] Конфиг раннера
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── http_client.py ← HttpClient + STANDS + detect_endpoint()
|
│ │ └── http_client.py ← HttpClient, автостенд
|
||||||
│ ├── operations/
|
│ ├── operations/
|
||||||
│ │ ├── tracker.py ← Трекер: /tmp/instances.json + fcntl.flock
|
|
||||||
│ │ ├── get_services.py ← get_services(), get_service_detail()
|
│ │ ├── get_services.py ← get_services(), get_service_detail()
|
||||||
│ │ └── get_instances.py ← get_instances(), get_organization()
|
│ │ ├── get_instances.py ← get_instances() (пагинация), get_organization()
|
||||||
|
│ │ ├── get_params.py ← get_params_with_current_values() — слияние state.params + шаблон
|
||||||
|
│ │ └── tracker.py ← /tmp/instances-{clientId}-{stand}.json + flock
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── main.py ← GET/POST / (главная), _tmpl()
|
│ │ ├── main.py ← GET/POST /, /api/operations/<svc_id>
|
||||||
│ │ ├── api.py ← /api/run, /api/status, /api/config
|
│ │ ├── api.py ← [LEGACY] /api/run, /api/status, /api/config
|
||||||
│ │ └── api_test.py ← /api/test, /api/test/status, _finish_op
|
│ │ └── api_test.py ← /api/test, /api/params, /api/log
|
||||||
│ ├── templates/
|
│ ├── templates/
|
||||||
│ │ └── index.html ← UI: Jinja2 + JS (поллинг)
|
│ │ └── index.html ← Весь UI (Jinja2 + ванильный JS)
|
||||||
│ └── static/
|
│ └── static/
|
||||||
│ └── style.css
|
│ ├── style.css
|
||||||
|
│ ├── logo.svg
|
||||||
|
│ └── favicon.svg
|
||||||
└── secrets/
|
└── secrets/
|
||||||
├── dev.token ← Токен для DEV стенда
|
├── dev.token
|
||||||
├── test.token ← Токен для TEST стенда
|
└── test.token
|
||||||
└── test.token.new
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Поток CREATE — полная трассировка (14 шагов)
|
## 3. Бэкенд: HTTP-клиент и автостенд
|
||||||
|
|
||||||
### Шаг 1: UI — пользователь начинает CREATE
|
### `http_client.py`
|
||||||
|
|
||||||
```javascript
|
- `HttpClient(endpoint, token)` — обёртка над `requests.Session`:
|
||||||
// index.html: startCreate()
|
- Заголовки: `Authorization: Bearer`, `User-Agent: Mozilla/5.0` (DDoS-Guard)
|
||||||
function startCreate(){
|
- `get(path)` → `raise_for_status()` → `.json()`
|
||||||
selectedInst=null; // нет выбранного инстанса
|
- `post(path, data)` → проверка `r.ok`, парсинг JSON, извлечение `Location`
|
||||||
stopPoll(); // остановить предыдущий поллинг
|
- `detect_endpoint(token)` — пробует dev→test стенды, возвращает рабочий URL
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
- `stand_name(endpoint)` — "dev"/"test" по URL
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
- `create_client(token, fallback)` — HttpClient + endpoint
|
||||||
showParams(18,'create'); // 18 = svcOperationId для create у сервиса "Болванка"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`selectedOp = {opId: 18, opName: "create", svcId: 1}`
|
### `get_instances.py`
|
||||||
|
|
||||||
### Шаг 2: UI — загрузка параметров и показ формы
|
- `get_instances(client)` — `GET /instances` с пагинацией (pageSize=200). Остановка по `len(batch) < pageSize`.
|
||||||
|
- `get_organization(client)` — первый инстанс с `serviceId == 19`
|
||||||
|
|
||||||
```javascript
|
### `get_services.py`
|
||||||
// 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 = () => {
|
- `get_services(client)` → `GET /services` → список
|
||||||
// Сбор params из формы
|
- `get_service_detail(client, svc_id)` → `GET /services/{id}` → детали + операции
|
||||||
const pp = {};
|
|
||||||
document.querySelectorAll('#params-form [name^="p_"]').forEach(el => {
|
|
||||||
pp[el.name.replace('p_','')] = el.value;
|
|
||||||
});
|
|
||||||
executeOp(pp); // ← запуск
|
|
||||||
};
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Бэкенд: `GET /instanceOperations/default/18` → возвращает список `cfsParams`.
|
### `get_params.py` — КЛЮЧЕВОЙ модуль
|
||||||
|
|
||||||
### Шаг 3: UI — executeOp (отправка)
|
`get_params_with_current_values(client, op_id, instance_uid)`:
|
||||||
|
1. `GET /instances/{uid}` → `state.params` (текущие значения: `{"whereFail":"1",...}`)
|
||||||
|
2. `GET /instanceOperations/default/{opId}` → шаблон (коды, типы, valueList, dataDescriptor)
|
||||||
|
3. Слияние: `defaultValue = state.params["код"] ?? template.defaultValue`
|
||||||
|
4. Приведение типов: `bool` → `"true"/"false"`, `dict` → `json.dumps()`
|
||||||
|
|
||||||
```javascript
|
### `tracker.py`
|
||||||
// index.html: executeOp(params)
|
|
||||||
const displayName = document.getElementById('param-displayname')?.value || 'autotest-1';
|
|
||||||
// Захват ДО очистки формы! (v1.0.48)
|
|
||||||
|
|
||||||
document.getElementById('params-form').innerHTML = ''; // очистка
|
- Файл: `/tmp/instances-{clientId}-{stand}.json` (изолирован по пользователю и стенду)
|
||||||
|
- Блокировка: `fcntl.flock(LOCK_EX | LOCK_NB)` + retry 2s
|
||||||
fetch('/api/test', {
|
- `add(client_id, stand, uid, svc_id, name)`, `remove(...)`, `list_all(...)`
|
||||||
method:'POST',
|
- Нужен ТОЛЬКО как fallback — когда инстанс только создан и ещё не в `/instances`
|
||||||
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. Бэкенд: Роуты
|
||||||
|
|
||||||
### 4.1 Назначение
|
### `main.py`
|
||||||
|
|
||||||
Хранит UID инстансов, созданных приложением. Нужен чтобы показывать в UI только «наши» инстансы, а не все в организации.
|
- **`GET/POST /`** — главная: организация, инфраструктура (serviceId 2,12,19,21,22,25,26,29,110,150), сервисы
|
||||||
|
- `action=save` → сохранить токен в cookie
|
||||||
|
- `action=clear` → удалить cookie
|
||||||
|
- **`GET /api/operations/<svc_id>`** — операции + autotest-инстансы:
|
||||||
|
- Cloud-first: фильтр по префиксу `autotest-`
|
||||||
|
- Tracker-fallback: статус "creating" для ещё невидимых
|
||||||
|
|
||||||
### 4.2 Реализация (v1.0.53+)
|
### `api_test.py` — основной модуль операций
|
||||||
|
|
||||||
Файл: `site/operations/tracker.py`
|
- **`GET /api/params/<op_id>[?instanceUid=xxx]`**:
|
||||||
|
- Без `instanceUid` → шаблонные `defaultValue`
|
||||||
|
- С `instanceUid` → `get_params_with_current_values()`
|
||||||
|
- **`POST /api/test`** — запуск операции:
|
||||||
|
- **CREATE**: `POST /instances` → `instanceUid` → `POST /instanceOperations` → `opUid` → `tracker_add` → params → `/run`
|
||||||
|
- **non-CREATE**: `POST /instanceOperations` → params → `/run` (redeploy: сразу `/run`)
|
||||||
|
- `displayName` = `_get_instance_display_name()` (из API, не UUID!)
|
||||||
|
- Фоновый поток: `_finish_op()` — поллинг до `dtFinish` (300s таймаут)
|
||||||
|
- **`GET /api/test/status/<op_uid>`** — поллинг: in-memory `_op_results` → прямой API
|
||||||
|
- **`GET /api/log`** — последние 200 строк из `/tmp/app-autotest.log`
|
||||||
|
|
||||||
- Хранилище: `/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 Эволюция
|
`_log(msg)` в `api_test.py`:
|
||||||
|
- stdout (gunicorn) + `/tmp/app-autotest.log` (flock, общий для воркеров)
|
||||||
| Версия | Реализация | Проблема |
|
- Ротация при 512 КБ
|
||||||
|--------|-----------|----------|
|
- UI: скрытая панель, кнопка `log` (правый нижний угол)
|
||||||
| 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. Автоопределение стенда
|
## 5. Фронтенд
|
||||||
|
|
||||||
Файл: `site/api/http_client.py`
|
### Структура
|
||||||
|
|
||||||
```python
|
Один HTML-файл, три колонки:
|
||||||
STANDS = [
|
- Левая (280px): инфраструктура
|
||||||
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
|
- Средняя (240px): сервисы
|
||||||
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
|
- Правая (flex): инстансы + параметры + кнопка + этапы
|
||||||
]
|
|
||||||
|
|
||||||
def detect_endpoint(token):
|
### Глобальное состояние (JS)
|
||||||
for ep in STANDS:
|
|
||||||
try:
|
```
|
||||||
c = HttpClient(ep, token)
|
svcInstances — кеш инстансов (из /api/operations)
|
||||||
data = c.get("/instances", params={"pageSize": 1, "page": 1})
|
selectedInst — UID выбранного инстанса
|
||||||
if data.get("results") is not None:
|
selectedOp — {opId, opName, svcId}
|
||||||
return ep
|
pollTimer — таймер поллинга
|
||||||
except Exception:
|
SVC_ID = 1 — фиксированный сервис (Болванка)
|
||||||
continue
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Используется в:
|
### Потоки операций
|
||||||
- `main.py`: при загрузке главной страницы
|
|
||||||
- `api_test.py: _client()`: при каждом API-запросе
|
**CREATE**: `startCreate()` → `showParams(18,'create')` → форма → `executeOp(pp)` → POST /api/test → поллинг → refreshInstances
|
||||||
|
|
||||||
|
**MODIFY**: `toggleInstance()` → кнопки → `runOp('modify',opId)` → `showParams(opId,'modify')` → GET /api/params?instanceUid= → форма с ТЕКУЩИМИ значениями → валидация map → `executeOp(pp)` → POST /api/test → поллинг
|
||||||
|
|
||||||
|
**Без параметров** (suspend/delete/resume/redeploy): `runOp()` → confirm → `executeOp({})` → POST /api/test → поллинг
|
||||||
|
|
||||||
|
### Валидация JSON
|
||||||
|
|
||||||
|
- `_esc(s)` — HTML-экранирование (`"` → `"`, `&` → `&`, `<` → `<`)
|
||||||
|
- `validateJson(el, quiet)` — `JSON.parse()` на `onblur` (красная рамка + текст)
|
||||||
|
- Batch-проверка перед отправкой — ошибка → запрос не уходит
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Все известные баги (найдено/исправлено)
|
## 6. Nubes API — используемые endpoint'ы
|
||||||
|
|
||||||
### Исправленные
|
| Метод | Путь | Назначение |
|
||||||
|
|-------|------|-----------|
|
||||||
| # | Версия | Баг | Причина | Исправление |
|
| GET | `/instances?pageSize=200&page=N` | Все инстансы (пагинация) |
|
||||||
|---|--------|-----|---------|-------------|
|
| GET | `/instances/{uid}` | Детали + state.params |
|
||||||
| 1 | v1.0.46 | `_finish_op()` недостающие параметры | Не передавались `op_name`, `svc_op_id` | Добавлены в сигнатуру |
|
| GET | `/services` | Список сервисов |
|
||||||
| 2 | v1.0.48 | displayName терялся | `params-form.innerHTML=''` до чтения `param-displayname` | Захват до очистки |
|
| GET | `/services/{id}` | Детали + операции |
|
||||||
| 3 | v1.0.48 | tracker_add в daemon-потоке | Поток умирает под gunicorn | Перенос в синхронный код |
|
| GET | `/instanceOperations/default/{opId}` | Шаблон параметров |
|
||||||
| 4 | v1.0.50 | ❌ для этапов в процессе | `isSuccessful===false` для in-progress | Проверка `dtFinish` |
|
| POST | `/instances` | Создать инстанс |
|
||||||
| 5 | v1.0.50 | Инстанс не в списке | `explainedStatus:"not created"` фильтровался | Убран фильтр + tracked-сироты |
|
| POST | `/instanceOperations` | Создать операцию |
|
||||||
| 6 | v1.0.50 | `selectService()` скрывал stages | Полный перерендер после OK | `refreshInstances()` с диффом |
|
| POST | `/instanceOperationCfsParams` | Установить параметр |
|
||||||
| 7 | v1.0.51 | `instance_groups` UnboundLocalError | Не иниц. до `if active_token:` | `instance_groups = {}` |
|
| POST | `/instanceOperations/{opUid}/run` | Запустить операцию |
|
||||||
| 8 | v1.0.51 | `_finish_op` молча умирал | `data.get()` вне try/except | Весь цикл в try/except |
|
| GET | `/instanceOperations/{opUid}?fields=...` | Поллинг статуса |
|
||||||
| 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-клиент
|
## 7. Ограничения платформы
|
||||||
|
|
||||||
Файл: `site/api/http_client.py`
|
- `site/` — НЕ пакет (без `__init__.py`), импорты без префикса `site.`
|
||||||
|
- `app.run(host="0.0.0.0", port=5000)`
|
||||||
```python
|
- Gunicorn multi-worker → общие данные через файлы + flock
|
||||||
class HttpClient:
|
- `/tmp/` теряется при редеплое
|
||||||
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)
|
- User-Agent: `Mozilla/5.0` обязателен (DDoS-Guard)
|
||||||
- Деплой: `git push` → managed service редеплоит
|
|
||||||
|
|||||||
+46
-18
@@ -1,28 +1,56 @@
|
|||||||
# История разработки app-autotest
|
# История разработки app-autotest
|
||||||
|
|
||||||
## v1.0.76 (27.07.2026) — modify показывает актуальные значения параметров инстанса
|
## v1.0.87 (27.07.2026) — документация: ARCHITECTURE.md переписан, legacy-доки помечены
|
||||||
|
|
||||||
|
## v1.0.84 (27.07.2026) — лог-панель скрыта по умолчанию
|
||||||
|
|
||||||
### Что изменилось
|
### Что изменилось
|
||||||
- В `site/routes/api_test.py` `GET /api/params/<op_id>` теперь принимает `?instanceUid=xxx&opName=xxx`:
|
- Лог-панель теперь `display:none`, показывается кнопкой `log` (правый нижний угол).
|
||||||
- Создаёт pending-операцию через `POST /instanceOperations`, читает `GET /instanceOperations/{opUid}` и возвращает **текущие** `paramValue` из `cfsParams` вместо шаблонных `defaultValue`.
|
- При скрытой панели `/api/log` не поллится.
|
||||||
- В ответ добавлен `previewOpUid` — UID созданной preview-операции.
|
|
||||||
- В `site/routes/api_test.py` `POST /api/test` принимает опциональный `previewOpUid`:
|
## v1.0.83 (27.07.2026) — HTML-escape + JSON-валидация map-полей
|
||||||
- Если передан — переиспользует уже созданную операцию, пропускает `POST /instanceOperations`.
|
|
||||||
- В `site/templates/index.html` `showParams()` для не-create операций передаёт `?instanceUid=&opName=` в запрос параметров и сохраняет `previewOpUid`.
|
### Что изменилось
|
||||||
- В `site/templates/index.html` `executeOp()` передаёт `previewOpUid` в теле запроса.
|
- `_esc(s)` — HTML-экранирование значений (`"` → `"`, `&` → `&`, `<` → `<`).
|
||||||
- Версия поднята до `1.0.76`.
|
- `validateJson(el, quiet)` — проверка `JSON.parse()` при `onblur` (красная рамка + текст ошибки).
|
||||||
|
- Batch-проверка всех map-полей перед отправкой — ошибка → запрос не уходит.
|
||||||
|
|
||||||
### Что это чинит
|
### Что это чинит
|
||||||
- Форма modify теперь показывает **реальные текущие значения** параметров инстанса (а не шаблонные `0`, `false`, `""`).
|
- Поломку HTML при значениях с кавычками (напр. `{"f":1}`).
|
||||||
- Раньше: `GET /instanceOperations/default/{opId}` → `defaultValue` из шаблона.
|
- Отправку битого JSON в API.
|
||||||
- Теперь: `POST /instanceOperations` → `GET /instanceOperations/{opUid}` → `paramValue` из инстанса.
|
|
||||||
|
|
||||||
### Как это работает
|
## v1.0.82 (27.07.2026) — displayName из API для non-create операций
|
||||||
1. Пользователь нажимает modify → `showParams(opId, 'modify')`
|
|
||||||
2. Фронт вызывает `GET /api/params/{opId}?instanceUid={iuid}&opName=modify`
|
### Что изменилось
|
||||||
3. Бэкенд создаёт pending-операцию и читает её `cfsParams` с актуальными `paramValue`
|
- `_get_instance_display_name(client, uid)` — `GET /instances/{uid}` → `displayName`.
|
||||||
4. Форма заполняется реальными значениями, `previewOpUid` сохраняется
|
- Используется в не-create ветке `POST /api/test` вместо `instance_uid` как fallback.
|
||||||
5. При submit → `POST /api/test` с `previewOpUid` → переиспользуется та же операция, выставляются новые params → run
|
|
||||||
|
### Что это чинит
|
||||||
|
- UUID вместо имени инстанса в финальном статусе.
|
||||||
|
|
||||||
|
## v1.0.81 (27.07.2026) — get_params.py: текущие значения из state.params
|
||||||
|
|
||||||
|
### Что изменилось
|
||||||
|
- Новый файл `site/operations/get_params.py` — независимый модуль.
|
||||||
|
- `get_params_with_current_values(client, op_id, instance_uid)`:
|
||||||
|
- `GET /instances/{uid}` → `state.params` (текущие значения)
|
||||||
|
- `GET /instanceOperations/default/{opId}` → шаблон
|
||||||
|
- Слияние: `defaultValue = state.params["код"] ?? template.defaultValue`
|
||||||
|
- Убран `previewOpUid` полностью (и из бэкенда, и из фронтенда).
|
||||||
|
|
||||||
|
## v1.0.80 (27.07.2026) — попытка fix через svcOperationId (НЕ СРАБОТАЛО)
|
||||||
|
|
||||||
|
## v1.0.79 (27.07.2026) — файловый лог для multi-worker
|
||||||
|
|
||||||
|
### Что изменилось
|
||||||
|
- `_log()` пишет в `/tmp/app-autotest.log` под `fcntl.flock` (вместо in-memory deque).
|
||||||
|
- `/api/log` читает из файла последние 200 строк.
|
||||||
|
- Ротация при 512 КБ.
|
||||||
|
- UI-панель логов (180px, автоскролл, поллинг 2s).
|
||||||
|
|
||||||
|
## v1.0.77 (27.07.2026) — debug-логирование в api_params
|
||||||
|
|
||||||
|
## v1.0.76 (27.07.2026) — modify: preview-операция для получения paramValue (НЕ СРАБОТАЛО)
|
||||||
|
|
||||||
# v1.0.74 (27.07.2026) — version bump for push
|
# v1.0.74 (27.07.2026) — version bump for push
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
||||||
|
|
||||||
# Полный обзор кода app-autotest — запрос к Sonnet
|
# Полный обзор кода app-autotest — запрос к Sonnet
|
||||||
|
|
||||||
Отправлено: 27.07.2026
|
Отправлено: 27.07.2026
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
||||||
|
|
||||||
# Sonnet — почему ты пропустил эти баги?
|
# Sonnet — почему ты пропустил эти баги?
|
||||||
|
|
||||||
Отправлено: 27.07.2026, после v1.0.49 (твой анализ выполнен, НЕ ПОМОГЛО)
|
Отправлено: 27.07.2026, после v1.0.49 (твой анализ выполнен, НЕ ПОМОГЛО)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
||||||
|
|
||||||
# Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок
|
# Вопрос к Sonnet — архитектура трекера инстансов + UI кнопок
|
||||||
|
|
||||||
Отправлено: 27.07.2026
|
Отправлено: 27.07.2026
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
||||||
|
|
||||||
# Sonnet — ответ 27.07.2026 (v1.0.49, раунд 2)
|
# Sonnet — ответ 27.07.2026 (v1.0.49, раунд 2)
|
||||||
|
|
||||||
## Найденные баги
|
## Найденные баги
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# ⚠️ LEGACY — НЕАКТУАЛЬНО. Исторический документ.
|
||||||
|
|
||||||
# Пошаговый аудит кода — 27.07.2026 (v1.0.50)
|
# Пошаговый аудит кода — 27.07.2026 (v1.0.50)
|
||||||
|
|
||||||
## Методология
|
## Методология
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ from routes.main import bp as main_bp
|
|||||||
from routes.api import bp as api_bp
|
from routes.api import bp as api_bp
|
||||||
from routes.api_test import bp as api_test_bp
|
from routes.api_test import bp as api_test_bp
|
||||||
|
|
||||||
VERSION = "1.0.86"
|
VERSION = "1.0.88"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
|
||||||
|
|||||||
Reference in New Issue
Block a user