Compare commits
10
Commits
4ee0433e28
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78bae571e | ||
|
|
366a6754fe | ||
|
|
9ca9f8ba54 | ||
|
|
f67facae20 | ||
|
|
cd06040d62 | ||
|
|
af2cede717 | ||
|
|
c64812b039 | ||
|
|
18d08ccccd | ||
|
|
cfc341db44 | ||
|
|
333a3d5e63 |
@@ -0,0 +1,132 @@
|
||||
# Code Review: app-autotest v1.2.39 — Соннет
|
||||
|
||||
Дата: 2026-08-03
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Критические
|
||||
|
||||
**1. XSS в instances.js — `o.operation` в innerHTML без `_esc()`**
|
||||
|
||||
instances.js (функция `toggleInstance`):
|
||||
```js
|
||||
opsEl.innerHTML = ops.map(o =>
|
||||
`<button ... onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
||||
).join('');
|
||||
```
|
||||
`o.operation` вставляется **три раза без `_esc()`**: в onclick-атрибут (`'${...}'`), в текст кнопки (`>${...}<`). Если API вернёт `operation = "'; alert(1)//"` — onclick-атрибут ломается. На практике операции — это "modify"/"delete" из Nubes API, но принцип нарушен. `_esc()` определён специально для этого.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Важные
|
||||
|
||||
**2. Двойной вызов `detect_endpoint()` при каждом HTTP-запросе**
|
||||
|
||||
auth.py — `get_client()` вызывает `detect_endpoint(token)`, и `get_stand()` вызывает `detect_endpoint(token)` независимо. В api_test.py при `POST /api/test`:
|
||||
```python
|
||||
client = get_client() # detect_endpoint() #1
|
||||
...
|
||||
get_client_id(), get_stand() # detect_endpoint() #2
|
||||
```
|
||||
Итого **2 лишних HTTP-запроса к Nubes API** (dev + test) при каждом запросе в облачном режиме. Оба блока `if endpoint in STANDS` срабатывают при стандартной конфигурации.
|
||||
|
||||
**3. `mode` и `polygon_stand` не валидируются при `set_mode`**
|
||||
|
||||
main.py:
|
||||
```python
|
||||
new_mode = request.form.get("mode", "polygon")
|
||||
new_stand = request.form.get("polygon_stand") or request.cookies.get("polygon_stand", "test")
|
||||
```
|
||||
Любое значение пишется в cookie. Если `polygon_stand = "evil/../../../etc"`, то в `get_client()` формируется URL:
|
||||
```
|
||||
polygon_url.replace("/api/v1/svc", "/evil/../../../etc/api/v1/svc")
|
||||
```
|
||||
Путь нормализуется HTTP-клиентом/сервером. Допустимые значения известны: `mode` ∈ {"polygon","cloud"}, `polygon_stand` ∈ {"dev","test","prod"} — надо добавить whitelist.
|
||||
|
||||
**4. В polygon-режиме у всех пользователей одинаковый `client_id`**
|
||||
|
||||
auth.py:
|
||||
```python
|
||||
def get_token():
|
||||
if get_mode() == "polygon":
|
||||
return current_app.config["NUBES_API_TOKEN"] # env-токен
|
||||
```
|
||||
`get_client_id()` тоже парсит env-токен → у всех пользователей в эмуляции одинаковый ClientID. Вся изоляция данных в БД (`runs`, `scenario_runs`, `scenario_definitions`) — по `(client_id, stand)`. Если два пользователя переключатся в polygon-режим — они видят историю и сценарии **друг друга**. Если приложение использует только один человек — не проблема. Если несколько — критично.
|
||||
|
||||
**5. `api_history` — соединение не в `finally`**
|
||||
|
||||
api_test.py:
|
||||
```python
|
||||
try:
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(...)
|
||||
...
|
||||
cur.close()
|
||||
put_conn(conn) # явный return соединения
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
try: cur.close() # NameError если исключение до cur = ...
|
||||
except: pass
|
||||
try: put_conn(conn) # NameError если исключение до conn = ...
|
||||
except: pass
|
||||
return jsonify({"error": str(e)}), 500
|
||||
```
|
||||
Все остальные функции (`api_scenario_run_status`, `api_scenario_status`) используют `finally: put_conn(conn)` — корректный паттерн. Здесь — нет. При ошибке до `cur.close()` соединение возвращается, но `cur` не закрывается. Не утечка (psycopg2 закроет при возврате conn в пул), но несоответствие стилю.
|
||||
|
||||
**6. `stand_name()` — подстрочный поиск**
|
||||
|
||||
http_client.py:
|
||||
```python
|
||||
for name in ("dev", "test"):
|
||||
if name in (endpoint or ""):
|
||||
return name
|
||||
```
|
||||
Если URL содержит "dev" как часть другого слова (например, "development", "devnull"), вернёт неверное значение. URL-то сейчас конкретные, но хрупко.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Рекомендации
|
||||
|
||||
**7. Двойной отступ в scenario.py**
|
||||
|
||||
scenario.py:
|
||||
```python
|
||||
for i, step in enumerate(steps):
|
||||
step_num = i + 1 # 8 пробелов вместо 4
|
||||
```
|
||||
Весь цикл имеет нестандартный отступ (8 пробелов). Работает корректно (Python), но выглядит как след удалённого `try:` или `with:` блока, который был внутри for.
|
||||
|
||||
**8. `get_real_client()` — мёртвый алиас**
|
||||
|
||||
auth.py: функция идентична `get_client()`, есть только для обратной совместимости. В main.py и других файлах есть вызовы `get_real_client()`. Стоит постепенно заменить на `get_client()` и убрать алиас.
|
||||
|
||||
**9. Нет `UNIQUE` на `runs.op_uid`**
|
||||
|
||||
init_db.py: ручной UPSERT в save_run.py (UPDATE → INSERT) предполагает уникальность `op_uid`. Индекса нет. При маловероятной гонке двух параллельных `save_run` с одним `op_uid` — оба сделают UPDATE (rowcount=0) → оба сделают INSERT → дубль. Добавить `CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_op_uid ON runs (op_uid) WHERE op_uid IS NOT NULL`.
|
||||
|
||||
**10. Ротация лога: `flock(LOCK_UN)` перед flush буфера**
|
||||
|
||||
api_test.py:
|
||||
```python
|
||||
f.write(rest)
|
||||
fcntl.flock(f, fcntl.LOCK_UN) # лок снят, но буфер ещё не сброшен
|
||||
```
|
||||
Python-буфер сбрасывается при закрытии файла (`with`-блок), но лок уже снят. Другой воркер может прочитать неполные данные. Добавить `f.flush()` перед `LOCK_UN`.
|
||||
|
||||
---
|
||||
|
||||
## Итого по приоритетам
|
||||
|
||||
| # | Файл | Проблема | Серьёзность |
|
||||
|---|------|----------|-------------|
|
||||
| 1 | `static/js/instances.js` | `o.operation` в innerHTML без `_esc()` | 🔴 |
|
||||
| 2 | `api/auth.py` | Двойной `detect_endpoint()` на запрос | 🟡 |
|
||||
| 3 | `routes/main.py` | `mode`/`polygon_stand` без whitelist-валидации | 🟡 |
|
||||
| 4 | `api/auth.py` | Shared `client_id` в polygon-режиме | 🟡 |
|
||||
| 5 | `routes/api_test.py` | `api_history` без `finally` | 🟡 |
|
||||
| 6 | `api/http_client.py` | `stand_name()` substring match | 🟡 |
|
||||
| 7 | `operations/scenario.py` | Двойной отступ в for-цикле | 🟢 |
|
||||
| 8 | `api/auth.py` | `get_real_client()` мёртвый алиас | 🟢 |
|
||||
| 9 | `db/init_db.py` | Нет UNIQUE индекса на `runs.op_uid` | 🟢 |
|
||||
| 10 | `routes/api_test.py` | `flock(LOCK_UN)` перед flush в ротации | 🟢 |
|
||||
@@ -0,0 +1,124 @@
|
||||
# 2026-08-03-session — Полигон: интеграция, баг с сервисами
|
||||
|
||||
## Контекст
|
||||
|
||||
Сессия началась с чтения двух файлов AI-агентом:
|
||||
- `AGENT_BRIEFING.md` — контекст app-autotest (v1.2.28)
|
||||
- `polygon-docs/POLYGON-FULL.md` — полное описание полигона (v0.5.9)
|
||||
|
||||
jsonEnv автотеста на проде:
|
||||
```json
|
||||
{
|
||||
"POLYGON_ENDPOINT": "https://polygon.pythonk8s.dev.nubes.ru/api/v1/svc",
|
||||
"NUBES_API_ENDPOINT": "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
|
||||
}
|
||||
```
|
||||
|
||||
## Архитектурное решение: разделение сервисы/инстансы
|
||||
|
||||
**Зафиксировано (2026-08-03):**
|
||||
|
||||
| Режим | Сервисы (метаданные) | Инстансы |
|
||||
|--------|---------------------|-----------|
|
||||
| Без полигона | Реальный API | Реальное облако |
|
||||
| С полигоном | Реальный API | Виртуальные (полигон) |
|
||||
|
||||
`POLYGON_ENDPOINT` подменяет ТОЛЬКО инстансы/операции. Сервисы — это «справочник», всегда из реального API.
|
||||
|
||||
### Где это в коде
|
||||
|
||||
`auth.py:_make_client(use_polygon)`:
|
||||
- `use_polygon=True` → `get_client()`: если `POLYGON_ENDPOINT` задан → полигон, иначе → `NUBES_API_ENDPOINT`
|
||||
- `use_polygon=False` → `get_real_client()`: **всегда** `NUBES_API_ENDPOINT`
|
||||
|
||||
## Баг: «нет сервисов» при POLYGON_ENDPOINT
|
||||
|
||||
### Причина
|
||||
|
||||
Цепочка:
|
||||
1. `get_stand()` → `"polygon"` (auth.py:141, POLYGON_ENDPOINT задан)
|
||||
2. `load_service_ids("polygon")` → `services_polygon.txt` не существует → пустой `set`
|
||||
3. `sorted(пустой_set)` → `[]`
|
||||
4. Шаблон `index.html:81`: `{% if svc.svcId in config.service_ids %}` → `svc.svcId in []` → **всегда False** → ни один сервис не показывается
|
||||
|
||||
Хотя `get_real_client()` корректно получает сервисы из реального API, фильтр в шаблоне убивает весь список.
|
||||
|
||||
### Исправление (v1.2.29)
|
||||
|
||||
Шаблон `index.html:81`: добавить `or not config.service_ids`:
|
||||
|
||||
```jinja2
|
||||
{# Было #}
|
||||
{% if svc.svcId in config.service_ids %}
|
||||
|
||||
{# Стало #}
|
||||
{% if not config.service_ids or svc.svcId in config.service_ids %}
|
||||
```
|
||||
|
||||
Это соответствует изначальному замыслу `load_service_ids`: «пустой set → показываем все».
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: селектор режима и стенда (v1.2.30)
|
||||
|
||||
### Требования
|
||||
|
||||
1. **Селектор режима** над версией: 🎭 Эмуляция / ☁️ Облако (только если POLYGON_ENDPOINT задан)
|
||||
2. **Селектор стенда** (DEV/TEST/PROD) — только в режиме эмуляции
|
||||
3. **По умолчанию:** эмуляция + TEST
|
||||
4. **В эмуляции:** токен дезактивирован
|
||||
5. **В облаке:** стенд автоопределяется по токену (как раньше)
|
||||
6. При смене режима/стенда → сервисы обновляются
|
||||
|
||||
### Новая архитектура
|
||||
|
||||
| Режим | Сервисы | Инстансы | Стенд |
|
||||
|--------|---------|----------|-------|
|
||||
| Эмуляция | Полигон (/{stand}/api/v1/svc) | Полигон | Выбор (dev/test/prod) |
|
||||
| Облако | Реальный API | Реальный API | Авто (токен→dev/test) |
|
||||
|
||||
В эмуляции **всё** идёт в полигон — и сервисы, и инстансы. Полигон имеет полный набор эндпоинтов (/services, /instances, /instanceOperations).
|
||||
|
||||
`get_real_client()` упраздняется — становится алиасом `get_client()`.
|
||||
|
||||
### Cookie
|
||||
|
||||
- `mode` — `"polygon"` (по умолчанию) или `"cloud"`
|
||||
- `polygon_stand` — `"test"` (по умолчанию), `"dev"`, `"prod"`
|
||||
|
||||
### Изменённые файлы
|
||||
|
||||
1. **`auth.py`** — новые функции `get_mode()`, `get_polygon_stand()`; `get_token()` в режиме полигона только env-токен; `get_client()` строит URL с префиксом стенда; `get_real_client()` → алиас
|
||||
2. **`main.py`** — `action=set_mode` (cookie), single `get_client()` вместо real_client/inst_client, переменные `mode`/`polygon_stand`/`polygon_enabled`
|
||||
3. **`index.html`** — селекторы над версией, disable токена в эмуляции
|
||||
4. **`service_list.py`** — обрезать `polygon_` префикс
|
||||
5. **`api_test.py`** — `get_real_client()` → `get_client()`
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3: Код-ревью Соннета 4.6 (v1.2.39)
|
||||
|
||||
### Промпт
|
||||
|
||||
```
|
||||
Ты — senior backend-разработчик, делаешь код-ревью веб-приложения app-autotest.
|
||||
Flask 3.1 + vanilla JS + PostgreSQL. Два режима: эмуляция (Polygon) / облако.
|
||||
Проверь внимательно: безопасность, URL, гонки, целостность данных, ошибки, архитектуру, JS.
|
||||
Для каждой находки: 🔴 критическое / 🟡 важное / 🟢 рекомендация.
|
||||
Если неясно — спроси, устроим диалог.
|
||||
```
|
||||
|
||||
### Результаты
|
||||
|
||||
| # | Файл | Проблема | Серьёзность |
|
||||
|---|------|----------|-------------|
|
||||
| 1 | `static/js/instances.js` | `o.operation` в innerHTML без `_esc()` | 🔴 |
|
||||
| 2 | `api/auth.py` | Двойной `detect_endpoint()` на запрос | 🟡 |
|
||||
| 3 | `routes/main.py` | `mode`/`polygon_stand` без whitelist-валидации | 🟡 |
|
||||
| 4 | `api/auth.py` | Общий `client_id` в polygon-режиме (env-токен) | 🟡 |
|
||||
| 5 | `routes/api_test.py` | `api_history` без `finally` для conn | 🟡 |
|
||||
| 6 | `api/http_client.py` | `stand_name()` substring match | 🟡 |
|
||||
| 7 | `operations/scenario.py` | Двойной отступ в for-цикле | 🟢 |
|
||||
| 8 | `api/auth.py` | `get_real_client()` мёртвый алиас | 🟢 |
|
||||
| 9 | `db/init_db.py` | Нет UNIQUE индекса на `runs.op_uid` | 🟢 |
|
||||
| 10 | `routes/api_test.py` | `flock(LOCK_UN)` перед flush в ротации лога | 🟢 |
|
||||
@@ -0,0 +1,458 @@
|
||||
# Polygon — эмулятор Nubes API
|
||||
|
||||
> Версия: v0.5.9 | Деплой: `polygon.pythonk8s.dev.nubes.ru` | Формат: Nubes managed Flask
|
||||
|
||||
---
|
||||
|
||||
## 1. Что такое Polygon
|
||||
|
||||
Polygon — **эмулятор REST API облачной платформы Nubes** для интеграционных тестов.
|
||||
|
||||
Он полностью повторяет контракты реального API (сервисы, инстансы, операции, параметры, валидацию),
|
||||
но работает **без реальной инфраструктуры** — в памяти, с мгновенным откликом.
|
||||
|
||||
### Зачем нужен
|
||||
|
||||
- Тестировать создание/изменение/удаление инстансов без реальных облачных ресурсов
|
||||
- Отлаживать UI автодеплоя (`app-autotest`) на мок-данных
|
||||
- Писать интеграционные тесты с детерминированным состоянием
|
||||
- Проверять краевые случаи (ошибки валидации, сбои операций)
|
||||
- Демонстрировать API заказчикам через Swagger UI
|
||||
|
||||
### Что НЕ делает
|
||||
|
||||
- Не управляет реальной инфраструктурой
|
||||
- Не хранит данные между перезапусками (всё в памяти)
|
||||
- Не авторизует пользователей (кроме `_mock/*` служебных эндпоинтов)
|
||||
- Не повторяет ВСЕ эндпоинты реального API — только те что нужны для тестов
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
### Технологический стек
|
||||
|
||||
| Компонент | Технология | Зачем |
|
||||
|-----------|-----------|-------|
|
||||
| Веб-фреймворк | Flask 3.0 | Маршрутизация, шаблоны, JSON-ответы |
|
||||
| WSGI-сервер | gunicorn | Запуск на проде (встроен в Nubes managed Flask) |
|
||||
| Конфигурация | YAML (PyYAML) | Описание сервисов, операций, параметров |
|
||||
| Состояние | In-memory dict | Инстансы, операции, параметры |
|
||||
| Шаблоны | Jinja2 | HTML-страницы (главная, Swagger) |
|
||||
| OpenAPI | OpenAPI 3.1.0 | Спека API (генерится динамически) |
|
||||
| Swagger UI | Swagger UI 5 (CDN) | Интерактивная документация |
|
||||
|
||||
### Ключевое ограничение: 1 воркер
|
||||
|
||||
```python
|
||||
# app.py
|
||||
os.environ.setdefault("WEB_CONCURRENCY", "1")
|
||||
```
|
||||
|
||||
**Почему:** состояние (инстансы, операции) хранится в памяти Python-процесса.
|
||||
Два воркера = два набора инстансов = хаос. Поэтому жёстко 1 gunicorn-воркер.
|
||||
|
||||
**Следствия:**
|
||||
- Только 1 запрос обрабатывается одновременно
|
||||
- Большие ответы (>20KB) могут обрываться из-за таймаута сети
|
||||
- Swagger-спека встроена прямо в HTML чтобы избежать второго запроса
|
||||
- Нельзя горизонтально масштабировать
|
||||
|
||||
### Модель данных в памяти
|
||||
|
||||
```
|
||||
MockState (синглтон на стенд)
|
||||
├── instances: {instanceUid → {instanceUid, serviceId, displayName, status, state, ...}}
|
||||
├── operations: {opUid → {instanceOperationUid, instanceUid, operation, dtStart, dtFinish, ...}}
|
||||
├── op_params: {opUid → {paramId(int) → paramValue(str)}}
|
||||
└── fail_next: bool (one-shot флаг для симуляции ошибок)
|
||||
```
|
||||
|
||||
### Жизненный цикл запроса
|
||||
|
||||
```
|
||||
HTTP-запрос → Nubes ingress → gunicorn (1 воркер) → Flask
|
||||
→ StandMiddleware (извлекает stand_id из URL, перезаписывает PATH_INFO)
|
||||
→ before_request (копирует stand_id в flask.g)
|
||||
→ Blueprint-роут (через LocalProxy резолвит state/SERVICES под текущий стенд)
|
||||
→ JSON-ответ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Стенды (dev / test / prod)
|
||||
|
||||
Polygon эмулирует **три изолированных стенда** облака:
|
||||
|
||||
| Стенд | URL-префикс | Сервисов | YAML из |
|
||||
|-------|------------|----------|---------|
|
||||
| dev | `/dev/api/v1/svc/...` | 37 | `~/tf_provider/generated/dev/` |
|
||||
| test | `/test/api/v1/svc/...` | 37 | `~/tf_provider/generated/test/` |
|
||||
| prod | `/prod/api/v1/svc/...` | 35 | `~/tf_provider/generated/prod/` |
|
||||
|
||||
**Изоляция:** у каждого стенда **свои** инстансы, операции, параметры.
|
||||
`fail-next` на dev не влияет на test. Состояние каждого стенда независимо.
|
||||
|
||||
**Переключение в Swagger:** выпадающий список серверов (Server selector).
|
||||
|
||||
**Переключение в URL:** просто добавь префикс — `/dev/`, `/test/`, `/prod/`.
|
||||
Без префикса — стенд по умолчанию (dev).
|
||||
|
||||
**Настройка:** переменная окружения `POLYGON_STANDS`:
|
||||
```
|
||||
POLYGON_STANDS=dev:services/dev,test:services/test,prod:services/prod
|
||||
```
|
||||
Формат: `имя_стенда:путь_к_YAML,имя_стенда:путь_к_YAML,...`
|
||||
|
||||
---
|
||||
|
||||
## 4. YAML-пайплайн (откуда берутся сервисы)
|
||||
|
||||
Polygon не знает о сервисах сам — он читает их из YAML-конфигов,
|
||||
которые генерируются из терраформ-репы.
|
||||
|
||||
### Цепочка
|
||||
|
||||
```
|
||||
~/tf_provider/generated/{dev,test,prod}/resources_yaml/*.yaml ← источник (терраформ)
|
||||
│
|
||||
│ from_stands.py — конвертация формата (ВРУЧНУЮ)
|
||||
│ • дедупликация параметров (один param в create/modify/delete → одна запись)
|
||||
│ • построение cfsParamsByOp (какие параметры к какой операции)
|
||||
│ • сбор stateParams из create-операции
|
||||
│ • генерация stateOut из subresource-операций
|
||||
│ • раскодирование HTML-entities (" → ")
|
||||
│
|
||||
▼
|
||||
polygon/site/services/{dev,test,prod}/*.yaml ← скоммичены в git
|
||||
│
|
||||
│ config/loader.py — загрузка ВСЕХ YAML в память при старте
|
||||
│ • _load_one() для каждой папки стенда
|
||||
│ • STANDS = {dev: {SERVICES, OPS_INDEX}, test: {...}, prod: {...}}
|
||||
│ • ~0.7 MB на 3 стенда
|
||||
│
|
||||
▼
|
||||
Память Flask-процесса → API отдаёт данные из памяти (без диска)
|
||||
```
|
||||
|
||||
### Когда обновлять YAML
|
||||
|
||||
Когда терраформ-репа обновилась (добавили сервис, изменили параметры):
|
||||
|
||||
```bash
|
||||
cd polygon/site
|
||||
python from_stands.py ~/tf_provider/generated/dev/resources_yaml services/dev
|
||||
python from_stands.py ~/tf_provider/generated/test/resources_yaml services/test
|
||||
python from_stands.py ~/tf_provider/generated/prod/resources_yaml services/prod
|
||||
cd .. && git add services/ && git commit -m "regenerate YAML" && git push
|
||||
# → редеплоить polygon через Nubes UI
|
||||
```
|
||||
|
||||
**Это ручная операция.** Нет автоматического триггера.
|
||||
|
||||
---
|
||||
|
||||
## 5. Структура кода
|
||||
|
||||
```
|
||||
polygon/
|
||||
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
|
||||
├── Procfile # web: gunicorn site.app:app --bind 0.0.0.0:8000
|
||||
├── tests/
|
||||
│ ├── test_api.py # 39 smoke-тестов против деплоя
|
||||
│ ├── fuzz_test.py # 147 фаззинг-тестов (злонамеренные входные данные)
|
||||
│ ├── compare_test.py # Сравнение полигона с реальным Nubes API
|
||||
│ ├── test_converter.py # Юнит-тесты from_stands.py
|
||||
│ └── test_state_machine.py # Юнит-тесты apply_effect()
|
||||
└── site/
|
||||
├── app.py # Flask-приложение: StandMiddleware, blueprint'ы
|
||||
├── mock_state.py # MockState — хранилище инстансов/операций
|
||||
├── state_machine.py # apply_effect() — мутация состояния инстанса
|
||||
├── from_stands.py # Конвертер terraform YAML → polygon YAML
|
||||
├── config/
|
||||
│ └── loader.py # Загрузка YAML → SERVICES, OPS_INDEX, STANDS
|
||||
├── routes/
|
||||
│ ├── root.py # /health, /, /swagger
|
||||
│ ├── services_routes.py # /api/v1/svc/services
|
||||
│ ├── instances_routes.py # /api/v1/svc/instances
|
||||
│ ├── operations_routes.py # /api/v1/svc/instanceOperations/*
|
||||
│ ├── run.py # /api/v1/svc/instanceOperations/<uid>/run
|
||||
│ ├── mock_routes.py # /api/v1/svc/_mock/*
|
||||
│ └── openapi.py # /api/v1/svc/openapi.json (OpenAPI 3.1.0 спека)
|
||||
├── services/
|
||||
│ ├── dev/ # 37 YAML-конфигов (стенд dev)
|
||||
│ ├── test/ # 37 YAML-конфигов (стенд test)
|
||||
│ └── prod/ # 35 YAML-конфигов (стенд prod)
|
||||
├── static/
|
||||
│ ├── style.css # Дизайн-система Nubes
|
||||
│ ├── logo.svg # Логотип
|
||||
│ └── favicon.svg # Иконка
|
||||
├── templates/
|
||||
│ ├── index.html # Главная страница (инфо + ссылки на стенды)
|
||||
│ └── swagger.html # Swagger UI (спека встроена в HTML)
|
||||
└── utils/
|
||||
├── now.py # now() — UTC ISO с 'Z'
|
||||
└── pluralize.py # pluralize() — плюрализация
|
||||
```
|
||||
|
||||
### Ключевые архитектурные решения
|
||||
|
||||
**LocalProxy** — все роуты используют `werkzeug.local.LocalProxy` для доступа к `state` и `SERVICES`:
|
||||
|
||||
```python
|
||||
# В каждом роуте:
|
||||
from flask import g
|
||||
from werkzeug.local import LocalProxy
|
||||
import mock_state, config.loader as _cfg
|
||||
|
||||
state = LocalProxy(lambda: mock_state.get_state(g.stand_id))
|
||||
SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
|
||||
```
|
||||
|
||||
Это позволяет одному и тому же коду роута работать с разными стендами — `g.stand_id` определяет какой стенд используется.
|
||||
|
||||
**StandMiddleware** (app.py) — WSGI-middleware который извлекает `stand_id` из URL ДО Flask-роутинга:
|
||||
|
||||
```python
|
||||
class StandMiddleware:
|
||||
def __call__(self, environ, start_response):
|
||||
path = environ["PATH_INFO"]
|
||||
parts = path.split("/")
|
||||
if parts[1] in STANDS:
|
||||
environ["polygon.stand_id"] = parts[1]
|
||||
environ["PATH_INFO"] = "/" + "/".join(parts[2:]) # /dev/api/... → /api/...
|
||||
else:
|
||||
environ["polygon.stand_id"] = DEFAULT_STAND
|
||||
return self.wsgi_app(environ, start_response)
|
||||
```
|
||||
|
||||
**before_request** (app.py) — копирует stand_id в `flask.g`:
|
||||
```python
|
||||
@app.before_request
|
||||
def _set_stand():
|
||||
g.stand_id = request.environ.get("polygon.stand_id", DEFAULT_STAND)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API — 17 эндпоинтов
|
||||
|
||||
| Метод | Путь | Назначение | Ответ |
|
||||
|-------|------|------------|-------|
|
||||
| GET | `/health` | Healthcheck для Nubes | `OK` (text) |
|
||||
| GET | `/` | HTML с инфо + ссылки на стенды | HTML |
|
||||
| GET | `/swagger` | Swagger UI (спека в HTML) | HTML |
|
||||
| GET | `/api/v1/svc/openapi.json` | OpenAPI 3.1.0 спека | JSON |
|
||||
| GET | `/api/v1/svc/services` | Список сервисов | `{results: [...]}` |
|
||||
| GET | `/api/v1/svc/services/{id}` | Детали сервиса (операции) | `{svc: {operations: [...]}}` |
|
||||
| GET | `/api/v1/svc/instances` | Список инстансов (пагинация) | `{results, pageSize, page, total}` |
|
||||
| GET | `/api/v1/svc/instances/{uid}` | Полные данные инстанса | `{instance: {...}}` |
|
||||
| POST | `/api/v1/svc/instances` | Создать инстанс | `201 + Location + {instanceUid}` |
|
||||
| GET | `/api/v1/svc/instanceOperations/default/{id}` | Шаблон операции (cfsParams) | `{svcOperation: {cfsParams: [...]}}` |
|
||||
| POST | `/api/v1/svc/instanceOperations` | Создать операцию | `201 + Location + {instanceOperationUid}` |
|
||||
| GET | `/api/v1/svc/instanceOperations/{uid}` | Статус операции | `{instanceOperation: {...}}` |
|
||||
| POST | `/api/v1/svc/instanceOperationCfsParams` | Установить параметр | `{}` |
|
||||
| GET | `/api/v1/svc/instanceOperations/{uid}/validate-cfs` | Валидация параметров | `""` (пустое тело) |
|
||||
| POST | `/api/v1/svc/instanceOperations/{uid}/run` | Выполнить операцию | `{ok: true/false, error?}` |
|
||||
| POST | `/api/v1/svc/_mock/reset` | Сброс состояния | `{reset: "ok"}` |
|
||||
| GET | `/api/v1/svc/_mock/state` | Дамп состояния (отладка) | `{instances, operations}` |
|
||||
| GET | `/api/v1/svc/_mock/services` | Список загруженных сервисов (отладка) | `{count, services}` |
|
||||
| POST | `/api/v1/svc/_mock/delay/{s}` | Задать задержку операций (max 5s) | `{delay: N}` |
|
||||
| POST | `/api/v1/svc/_mock/fail-next` | Следующая операция упадёт (one-shot) | `{fail_next: true}` |
|
||||
|
||||
### Типовой сценарий: создать инстанс и запустить операцию
|
||||
|
||||
Это основной флоу, который повторяет логику реального облачного API.
|
||||
Все примеры — с jq для наглядности, но работают и с `python3 -m json.tool`.
|
||||
|
||||
**Шаг 1. Узнать ID сервиса по имени:**
|
||||
|
||||
```bash
|
||||
curl -s "$URL/services" | jq '.results[] | select(.svc == "НазваниеСервиса") | .svcId'
|
||||
```
|
||||
|
||||
**Шаг 2. Узнать ID операции у этого сервиса:**
|
||||
|
||||
```bash
|
||||
curl -s "$URL/services/$SVC_ID" | jq '.svc.operations[] | select(.operation == "create") | .svcOperationId'
|
||||
```
|
||||
|
||||
**Шаг 3. Получить список входных параметров (cfsParams):**
|
||||
|
||||
```bash
|
||||
curl -s "$URL/instanceOperations/default/$OP_ID" | jq '.svcOperation.cfsParams[] | {svcOperationCfsParamId, svcOperationCfsParam, dataType, valueList, isRequired}'
|
||||
```
|
||||
|
||||
Ответ показывает для каждого параметра: числовой ID, код, тип данных, список допустимых значений (если есть), обязательность.
|
||||
|
||||
**Шаг 4. Создать инстанс:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$URL/instances" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"serviceId": '$SVC_ID', "displayName": "мой тестовый инстанс"}' \
|
||||
| jq '.instanceUid'
|
||||
```
|
||||
|
||||
**Шаг 5. Создать операцию:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$URL/instanceOperations" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"instanceUid": "'$INST_UID'", "operation": "create", "svcOperationId": '$OP_ID'}' \
|
||||
| jq '.instanceOperationUid'
|
||||
```
|
||||
|
||||
**Шаг 6. Установить параметры (повторить для каждого):**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$URL/instanceOperationCfsParams" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"instanceOperationUid": "'$OP_UID'", "svcOperationCfsParamId": '$PARAM_ID', "paramValue": "значение"}'
|
||||
```
|
||||
|
||||
**Шаг 7. Проверить валидацию и запустить:**
|
||||
|
||||
```bash
|
||||
# Проверить что все параметры валидны (200 = OK)
|
||||
curl -s -o /dev/null -w "%{http_code}" "$URL/instanceOperations/$OP_UID/validate-cfs"
|
||||
|
||||
# Запустить операцию (будет ждать DELAY секунд)
|
||||
curl -s -X POST "$URL/instanceOperations/$OP_UID/run" | jq '{ok, error}'
|
||||
```
|
||||
|
||||
**Шаг 8. Проверить результат:**
|
||||
|
||||
```bash
|
||||
curl -s "$URL/instanceOperations/$OP_UID?fields=dtFinish,isSuccessful" | jq '.instanceOperation | {dtFinish, isSuccessful}'
|
||||
```
|
||||
|
||||
Полигон полностью повторяет эту последовательность: те же эндпоинты, те же поля, те же статус-коды (201 при создании, 409 при повторном run, 404 при несуществующем ресурсе).
|
||||
|
||||
### Валидация входных данных
|
||||
|
||||
- `serviceId` — только integer > 0 (строка/float/отрицательное → 400)
|
||||
- Тело запроса — должно быть JSON-объектом (массив → 400)
|
||||
- `svcOperationCfsParamId` — только integer (строка → 400)
|
||||
- Повторный `run` — 409 (already completed)
|
||||
|
||||
### Аутентификация
|
||||
|
||||
- Основные эндпоинты — **без авторизации**
|
||||
- `_mock/*` — заголовок `X-Mock-Auth` (если `MOCK_AUTH_TOKEN` задан в env)
|
||||
- По умолчанию `MOCK_AUTH_TOKEN` не задан → `_mock/*` открыты
|
||||
|
||||
---
|
||||
|
||||
## 7. Тесты
|
||||
|
||||
### test_api.py — 39 smoke-тестов
|
||||
|
||||
Запуск: `python3 tests/test_api.py`
|
||||
|
||||
Проверяет деплоенный полигон по всем 17 эндпоинтам:
|
||||
- Health, services, instances (CRUD + pagination + edge cases)
|
||||
- Operations (create → params → validate → run → double-run 409)
|
||||
- Fail-next (one-shot error simulation)
|
||||
- Auth (без токена, неверный токен, верный токен)
|
||||
- Mock state (reset, state dump, delay bounds)
|
||||
- Multi-stand (изоляция dev/test/prod, fail-next изоляция)
|
||||
|
||||
### fuzz_test.py — 147 фаззинг-тестов
|
||||
|
||||
Запуск: `cd site && PYTHONPATH=. python3 ../tests/fuzz_test.py` (локально, Flask test client)
|
||||
|
||||
Злонамеренные и экстремальные сценарии:
|
||||
- SQL injection, XSS, Unicode-emoji, null-байты, 10000-символьные строки
|
||||
- Неверные типы (строка вместо int, float, отрицательные)
|
||||
- Отсутствующие поля, null-поля
|
||||
- Path traversal, двойные слеши
|
||||
- Быстрые повторы (10 инстансов подряд)
|
||||
- Невалидные UUID
|
||||
- Неправильные HTTP-методы (PUT на GET, DELETE на POST)
|
||||
|
||||
### compare_test.py — сравнение с реальным API
|
||||
|
||||
Запуск: `python3 tests/compare_test.py`
|
||||
|
||||
Сравнивает read-only эндпоинты полигона с реальным Nubes API (dev/test/prod):
|
||||
- `GET /services` — одинаковый ли список сервисов
|
||||
- `GET /services/{id}` — одинаковые ли операции (svcOperationId, operation, kind, action)
|
||||
- `GET /instanceOperations/default/{id}` — одинаковые ли cfsParams
|
||||
|
||||
Классификация расхождений:
|
||||
- 🔴 BUG — полигон неправ (лишний/неверный параметр)
|
||||
- 🟡 LAG — реальный API обогнал (новый параметр, YAML устарел)
|
||||
- 🟢 BETTER — полигон правильнее реального API (null→"string", HTML entities)
|
||||
|
||||
### Юнит-тесты
|
||||
|
||||
```bash
|
||||
pytest tests/test_converter.py -v # 10 тестов from_stands.py
|
||||
pytest tests/test_state_machine.py -v # 9 тестов apply_effect()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|-----------|-------------|----------|
|
||||
| `POLYGON_STANDS` | (пусто) | Стенды: `dev:services/dev,test:services/test,prod:services/prod` |
|
||||
| `MOCK_AUTH_TOKEN` | (пусто) | Токен для `_mock/*`. Если пусто — auth отключена |
|
||||
| `MOCK_OP_DELAY` | `0.1` | Задержка операции в секундах |
|
||||
| `POLYGON_ENDPOINT` | `https://polygon.pythonk8s.dev.nubes.ru` | URL для OpenAPI-спеке |
|
||||
| `WEB_CONCURRENCY` | `1` | ⛔ НЕ менять — сломает изоляцию состояния |
|
||||
|
||||
---
|
||||
|
||||
## 9. Ограничения и известные проблемы
|
||||
|
||||
| Ограничение | Причина | Обход |
|
||||
|------------|---------|-------|
|
||||
| 1 gunicorn-воркер | Состояние в памяти | Не менять WEB_CONCURRENCY |
|
||||
| Большие ответы (>20KB) обрываются | 1 воркер + сетевой таймаут | Swagger-спека встроена в HTML |
|
||||
| Нет персистентности | Всё в памяти | Использовать `_mock/reset` в тестах |
|
||||
| Auth не включена на проде | `MOCK_AUTH_TOKEN` не задан | Добавить в env деплоя |
|
||||
| YAML обновляется вручную | Нет автотриггера | Запускать `from_stands.py` при изменении терраформа |
|
||||
| Нет HTTPS-валидации сертификатов | Dev-инструмент | — |
|
||||
| Swagger загружается с CDN (jsdelivr) | Нет офлайн-версии | — |
|
||||
|
||||
---
|
||||
|
||||
## 10. Деплой
|
||||
|
||||
Polygon — managed Flask на Nubes pythonk8s.
|
||||
|
||||
**Procfile:**
|
||||
```
|
||||
web: gunicorn site.app:app --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
**Процесс редеплоя:**
|
||||
1. Закоммитить и запушить изменения в `master`
|
||||
2. В Nubes UI нажать «Redeploy»
|
||||
3. Nubes: стягивает репу, билдит, запускает gunicorn на порту 8000
|
||||
4. Healthcheck: `GET /health` → должен вернуть `OK`
|
||||
|
||||
**Важно:** YAML-конфиги (`services/`) скоммичены в git.
|
||||
При редеплое Nubes клонирует репу → YAML уже на месте.
|
||||
Не нужно запускать `from_stands.py` при деплое.
|
||||
|
||||
---
|
||||
|
||||
## 11. Чеклист для нового AI-агента
|
||||
|
||||
При старте нового чата прочитай:
|
||||
1. Этот документ
|
||||
2. `polygon/site/app.py` — точка входа
|
||||
3. `polygon/site/config/loader.py` — загрузка конфигов
|
||||
4. `polygon/site/routes/openapi.py` — OpenAPI-спека
|
||||
5. `polygon/tests/test_api.py` — основной тест-сьют
|
||||
|
||||
Ключевые инварианты (НЕ нарушать):
|
||||
- WEB_CONCURRENCY = 1 всегда
|
||||
- `state` и `SERVICES` — через LocalProxy, не напрямую
|
||||
- `_mock/*` требует X-Mock-Auth (если задан MOCK_AUTH_TOKEN)
|
||||
- `serviceId` валидируется как int > 0
|
||||
- YAML обновляется через `from_stands.py`, коммитится, редеплоится
|
||||
@@ -90,3 +90,95 @@
|
||||
Я хочу чтобы в итоге получился **конкретный план действий**:
|
||||
что тестировать, как часто, что считать ошибкой, что — допустимым
|
||||
расхождением.
|
||||
|
||||
---
|
||||
|
||||
## Ответ Соннета (2026-08-02)
|
||||
|
||||
### 4. dataType: None — первопричина найдена
|
||||
|
||||
`dict.get(key, default)` возвращает `default` **только если ключ отсутствует**.
|
||||
Если в YAML написано `data_type: null` — ключ *есть*, значение — `None`,
|
||||
дефолт `"string"` не срабатывает.
|
||||
|
||||
### Мои ответы
|
||||
|
||||
**Q4.1 — data_type: null в YAML?** Проверил — в терраформ-YAML нет
|
||||
`data_type: null`. Реальный API возвращает `dataType: null` для параметра
|
||||
`nestedRefExample` (param 396). Полигон возвращает `"string"` — он ПРАВИЛЬНО
|
||||
применяет дефолт там, где реальный API отдаёт null. Это не баг полигона,
|
||||
а улучшение.
|
||||
|
||||
**Q4.2 — _convert_sub_params?** Та же уязвимость потенциально есть, но не
|
||||
проявляется — sub_params всегда имеют data_type.
|
||||
|
||||
**Q1.1 — частота регенерации YAML?** ВРУЧНУЮ. `from_stands.py` запускается
|
||||
человеком когда он вспомнит. Никакого cron/webhook.
|
||||
|
||||
**Q1.2 — лаг от реального API до YAML?** Непредсказуемо. От часов до недель.
|
||||
Зависит от того когда кто-то запустит `from_stands.py`.
|
||||
|
||||
**Q1.3 — потребитель результатов?** Разработчик. Ему нужно знать «полигон
|
||||
устарел, перегенери YAML», а не «полигон сломан».
|
||||
|
||||
**Q2.1 — дополнительные эндпоинты в реальном API?** Не проверял. Надо
|
||||
сравнить полный список эндпоинтов.
|
||||
|
||||
**Q2.2 — lifecycle поля?** Не сравниваются в текущем compare_test.py. Надо
|
||||
добавить.
|
||||
|
||||
---
|
||||
|
||||
## Ответ Соннета — раунд 2
|
||||
|
||||
### Три категории расхождений — 👍 принимаю
|
||||
|
||||
| Категория | Значение | Реакция |
|
||||
|---|---|---|
|
||||
| 🔴 REAL BUG | полигон ≠ реальный API, полигон неправ | FAIL |
|
||||
| 🟡 LAG | новый параметр в реальном API, нет в полигоне | WARN |
|
||||
| 🟢 POLYGON BETTER | реальный API отдаёт null/entities, полигон — правильно | INFO |
|
||||
|
||||
Для prod 🟡 LAG тоже должен быть заметен.
|
||||
|
||||
### Мои ответы — раунд 2
|
||||
|
||||
**Q5.1 — сервис есть в полигоне, пропал из реального API?**
|
||||
Теоретически да — если сервис удалили из реального API, а terraform ещё
|
||||
не обновили. Это 🔴 REAL BUG и должно быть FAIL. Полигон не должен
|
||||
эмулировать несуществующие сервисы.
|
||||
|
||||
**Q5.2 — HTML-entities?**
|
||||
Нормализовать при сравнении: `html.unescape()` для real API перед сравнением.
|
||||
Считать 🟢 POLYGON BETTER, не ошибка.
|
||||
|
||||
**Q5.3 — lifecycle поля?**
|
||||
Проверил — ни реальный API, ни полигон НЕ возвращают `lifecycle` в
|
||||
`GET /services/{id}`. Сравнивать нечего, вопрос снят.
|
||||
|
||||
---
|
||||
|
||||
## Ответ Соннета — раунд 3 (финальный)
|
||||
|
||||
### Q6.1 — defaultValue, valueList и др.
|
||||
|
||||
Реальный API возвращает **29 полей** на каждый cfsParam: `defaultValue`,
|
||||
`valueList`, `isModifiable`, `isRequired`, `isHidden`, `descr`, `man`,
|
||||
`regex`, `maxlength`, `minvalue` и т.д. Полигон возвращает подмножество
|
||||
из ~6-8 полей.
|
||||
|
||||
Сравнивать нужно только те поля, которые `from_stands.py` реально генерирует:
|
||||
`defaultValue`, `valueList`, `isModifiable`, `isRequired`. Остальные либо
|
||||
отсутствуют в терраформ-YAML, либо не имеют смысла для мока.
|
||||
|
||||
### Q6.2 — cfsParamsByOp
|
||||
|
||||
Это **внутренний индекс** полигона, не API-эндпоинт. Связь «какие параметры
|
||||
к какой операции» уже проверяется через `GET /instanceOperations/default/{id}`
|
||||
— если в ответе правильный набор параметров, значит cfsParamsByOp правильный.
|
||||
Отдельно сравнивать не нужно.
|
||||
|
||||
### Q6.3 — формат вывода
|
||||
|
||||
stdout + exit code — достаточно. Разработчик запускает вручную, смотрит
|
||||
глазами. Файл отчёта переусложнит. Если понадобится история — можно потом.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Соннет: полный аудит Polygon v0.5.5 + Swagger + тесты
|
||||
|
||||
> Адресат: Claude Sonnet 4.6 (новый чат)
|
||||
> ⛔ Только анализ и советы в чат. Не редактировать файлы.
|
||||
|
||||
---
|
||||
|
||||
## Что такое Polygon
|
||||
|
||||
Эмулятор REST API облачной платформы Nubes для интеграционных тестов.
|
||||
Задеплоен на `polygon.pythonk8s.dev.nubes.ru`. Flask 3.0 + gunicorn, 1 воркер.
|
||||
|
||||
**3 изолированных стенда:** dev (37 сервисов), test (37), prod (35).
|
||||
Состояние в памяти, URL: `/dev/api/v1/svc/...`, `/test/...`, `/prod/...`.
|
||||
В Swagger — выпадайка выбора стенда.
|
||||
|
||||
YAML-конфиги сервисов генерируются из терраформ-репы
|
||||
(`~/tf_provider/generated/{dev,test,prod}/resources_yaml/`) через `from_stands.py`.
|
||||
|
||||
**Файлы для анализа:**
|
||||
- `polygon/site/routes/openapi.py` — OpenAPI 3.1.0 спека (~500 строк)
|
||||
- `polygon/site/templates/swagger.html` — Swagger UI 5
|
||||
- `polygon/site/templates/index.html` — главная страница
|
||||
- `polygon/site/static/style.css` — дизайн-система
|
||||
- `polygon/site/routes/` — все роуты (7 blueprint'ов)
|
||||
- `polygon/tests/test_api.py` — 35 smoke-тестов
|
||||
- `polygon/tests/fuzz_test.py` — 147 фаззинг-тестов
|
||||
- `polygon/tests/compare_test.py` — сравнение с реальным API
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
- 17 эндпоинтов, полный CRUD инстансов и операций
|
||||
- Аутентификация `X-Mock-Auth` для `_mock/*`
|
||||
- Валидация serviceId (int > 0, не массив, не null)
|
||||
- 35 smoke + 147 fuzz тестов — 0 реальных багов
|
||||
- Сравнение с реальным API: YAML ↔ API — 0 расхождений
|
||||
|
||||
## Что нужно от тебя
|
||||
|
||||
### 1. Swagger/OpenAPI
|
||||
|
||||
Открой `polygon/site/routes/openapi.py` и `polygon/site/templates/swagger.html`.
|
||||
Проанализируй:
|
||||
|
||||
- Полнота схем — все ли поля ответов описаны?
|
||||
- Правильные ли status codes (200/201/400/404/409)?
|
||||
- Удобство Try it out — example'ы, enum'ы, default'ы
|
||||
- Группировка тегов — логично ли?
|
||||
- Авторизация в Swagger UI — правильно ли работает?
|
||||
- Нет ли лишнего или недостающего?
|
||||
- Русские описания — понятны ли, не слишком ли длинные?
|
||||
|
||||
### 2. Сравнительное тестирование
|
||||
|
||||
У нас есть 3 read-only эндпоинта для сравнения:
|
||||
- `GET /services`
|
||||
- `GET /services/{id}`
|
||||
- `GET /instanceOperations/default/{id}`
|
||||
|
||||
Какие ещё эндпоинты можно безопасно сравнивать с реальным API?
|
||||
Что ещё можно проверить не делая мутирующих запросов?
|
||||
|
||||
### 3. Дополнительные тесты
|
||||
|
||||
Что мы упустили? Какие сценарии, краевые случаи, негативные тесты
|
||||
стоит добавить? В том числе:
|
||||
- Тесты через Swagger UI (браузерные)
|
||||
- Нагрузочные/параллельные
|
||||
- Специфичные для отдельных сервисов
|
||||
- Тесты на совместимость с app-autotest
|
||||
|
||||
### 4. Замечания по коду/архитектуре
|
||||
|
||||
Что можно улучшить не переписывая всё? Любые баги, уязвимости,
|
||||
потенциальные проблемы которые ты видишь.
|
||||
|
||||
---
|
||||
|
||||
Формат: свободный. Главное — **конкретные советы** с указанием что и где
|
||||
менять, а не общие рассуждения.
|
||||
Reference in New Issue
Block a user