analyst(ET): auto-commit from analyst run_id=101
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 9s
CI / build (push) Successful in 3s

This commit is contained in:
2026-06-05 15:11:28 +00:00
parent 41dfc4e150
commit c2cf8280ca
4 changed files with 641 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
# BRD: Healthcheck enduro-trails-app падает: в контейнере нет curl
**Work Item:** ET-015
**Тип:** Bugfix / Infrastructure
**Приоритет:** Низкий (приложение работает) / Важно для мониторинга
**Дата:** 2026-06-05
**Запросил:** Слава
## 1. Контекст
Контейнер `enduro-trails-app-1` (запускается из репозитория `enduro-trails`)
на тестовой среде `mva154` (https://openclaw.mva154.duckdns.org/enduro/)
показывает в Docker статус `unhealthy` уже ~31 час с `FailingStreak=3762`,
при том что само приложение работает:
- `curl снаружи :5556 → HTTP 200` (~7 мс отклик);
- в логах живой трафик `200 OK`;
- `RestartCount=0` (контейнер не перезапускался).
## 2. Корень проблемы
В `docker-compose.yml` healthcheck настроен как:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
```
Базовый образ — `python:3.12-slim` (см. `Dockerfile`). В `slim`-варианте
**нет** утилиты `curl`. Каждый цикл healthcheck завершается:
```
exec: "curl": executable file not found in $PATH
exit code = -1
```
Docker трактует это как «проверка провалена» и через `retries=3` помечает
контейнер `unhealthy`. На самом деле приложение здорово.
Дополнительный факт: эндпоинт `/api/health` **существует** в коде
(`src/api/main.py:1224`, отдаёт `{"status": "ok", ...}`), так что
двойной поломки (несуществующий путь) нет — проблема исключительно
в отсутствии `curl`.
## 3. Бизнес-проблема
1. **Ложные алерты в мониторинге.** Любая система оповещений, опирающаяся
на `docker inspect ... .State.Health.Status`, будет постоянно кричать
об инциденте, который не существует.
2. **Эрозия доверия к мониторингу.** Если `unhealthy` всегда ложный, его
игнорируют — и пропустят настоящий инцидент, когда он случится.
3. **Невозможность построения SLO/SLA.** Метрика «доступность контейнера»
деградирована и непригодна для отчётности.
## 4. Цель
Healthcheck контейнера `app` должен **честно** отражать состояние
приложения: `healthy`, когда HTTP-эндпоинт `/api/health` на `:5556`
отвечает `200 OK`; `unhealthy`, когда не отвечает.
## 5. Стейкхолдеры
| Роль | Имя / Группа | Интерес |
|------|--------------|---------|
| Заказчик | Слава | Корректный мониторинг тестовой и будущей prod-среды |
| Исполнитель | claude-bot | Реализация фикса |
| Эксплуатация | mva154 host owner | Минимальный размер образа, никаких лишних пакетов |
## 6. Ограничения и нефункциональные требования
- **Размер образа** не должен заметно расти. Добавление `curl` через
`apt-get install` тянет ~10 МБ зависимостей + слой APT-кэша →
нежелательно. Предпочтительно использовать то, что уже есть в образе
(Python).
- **Время выполнения healthcheck** не должно превышать `timeout: 5s`
(текущее значение в compose). Реальное время отклика `/api/health`
~7 мс, запас огромный.
- **Совместимость** с Docker Engine ≥ 20.10 (на mva154 стоит свежий).
- **Никаких изменений** в логике приложения — эндпоинт `/api/health`
уже существует и его поведение менять не нужно.
## 7. Out of scope
- Доработка содержимого `/api/health` (например, добавление проверки
OSRM, тайлов, диска) — отдельный work item, если понадобится.
- Healthcheck для сервиса `gps-collector` (batch profile) — у него
нет открытого порта и `restart: "no"`, healthcheck неуместен.
- Healthcheck-настройки на стороне Gitea Actions / CI.
## 8. Сценарий «как должно стать»
1. Образ собирается без добавления `curl`.
2. `docker compose up -d app` поднимает контейнер.
3. Через ≤ `interval * retries` (= 30s × 3 = 90s, с учётом
`start_period` если задан) `docker inspect ... .State.Health.Status`
возвращает `healthy`.
4. Если приложение «зависает» (порт не отвечает) — healthcheck
честно фиксирует `unhealthy` за то же окно.
## 9. Связи
- Затрагивает: `Dockerfile`, `docker-compose.yml`.
- Не затрагивает: `src/api/`, `src/web/`, БД, тайлы.
- Соседние ADR: глобальных архитектурных решений не требует —
локальное инженерное решение, оформляется в `06-adr/` work-item.

View File

@@ -0,0 +1,169 @@
# ТЗ: Healthcheck enduro-trails-app — заменить curl на python-проверку
**Work Item:** ET-015
**Базовый документ:** [01-brd.md](01-brd.md)
**Версия:** 1.0
**Дата:** 2026-06-05
## 1. Постановка
Заменить в `docker-compose.yml` (сервис `app`) healthcheck-команду
так, чтобы она:
- использовала средства, **уже доступные** в образе `python:3.12-slim`
(т.е. интерпретатор `python3`), без установки дополнительных пакетов;
- обращалась к `http://localhost:5556/api/health` и трактовала
HTTP-код **2xx** как «healthy», любой иной отклик и любую ошибку
соединения — как «unhealthy»;
- укладывалась в `timeout: 5s`.
## 2. Текущее состояние
`docker-compose.yml`, строки 2226:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
interval: 30s
timeout: 5s
retries: 3
```
`Dockerfile`: базовый образ `python:3.12-slim`. `curl` отсутствует.
Установлен `pip`, доступен `python3` (и алиас `python`).
`src/api/main.py:1224`:
```python
@app.get("/api/health")
async def health():
return {
"status": "ok",
"db_path": DATA_PATH,
"db_exists": os.path.exists(DATA_PATH),
}
```
Возвращает HTTP 200 + JSON. Менять не требуется.
## 3. Целевое состояние
### 3.1. Изменение в `docker-compose.yml`
Секция `healthcheck` сервиса `app` приводится к виду:
```yaml
healthcheck:
test:
- "CMD"
- "python"
- "-c"
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5556/api/health', timeout=3).status == 200 else 1)"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
```
Пояснения:
- `CMD` (а не `CMD-SHELL`) — никакого shell-парсинга, аргументы передаются
как массив, экранирование не нужно.
- `python` — алиас, имеющийся в `python:3.12-slim` (есть и `python3`,
оба указывают на один интерпретатор).
- `urllib.request.urlopen(..., timeout=3)` — стандартная библиотека,
без зависимостей; внутренний `timeout=3` короче внешнего
`timeout: 5s`, остаётся запас на старт интерпретатора.
- `sys.exit(0 if ... == 200 else 1)` — корректное преобразование
статуса HTTP в exit code. Любой raise (URLError, HTTPError, timeout)
пробросится наверх, процесс завершится ненулевым кодом → `unhealthy`.
- `start_period: 20s` — добавляется, чтобы Docker не считал ранние
ошибки запуска приложения «провалом» healthcheck в окне старта.
Uvicorn поднимается за < 2 c, 20 с — комфортный запас.
### 3.2. Изменения в `Dockerfile`
**Не требуются.** Добавлять `curl` через `apt-get` нельзя — раздувает
образ и противоречит выбранному подходу.
### 3.3. Изменения в `src/api/main.py`
**Не требуются.** Эндпоинт `/api/health` существует и отдаёт 200.
## 4. Альтернативы (рассмотрены и отклонены)
| Вариант | Плюсы | Минусы | Решение |
|---------|-------|--------|---------|
| `apt-get install curl` в Dockerfile | Привычная команда | +~10 МБ к образу, новый APT-слой, противоречит slim-философии | Отклонено |
| `wget --spider` | Однострочник | `wget` тоже отсутствует в `python:3.12-slim` (проверено: пакет `wget` не входит в slim) | Отклонено |
| HEALTHCHECK в Dockerfile | Декларативно | Дублирует compose, при изменении нужно пересобирать образ | Отклонено, держим в compose |
| Отдельный health-скрипт `scripts/healthcheck.py` | Чище YAML | Лишний файл для одной строки, мутит образ | Отклонено |
Принятый вариант: **inline python one-liner** через `urllib.request`.
## 5. Реализационные требования
### R-1. Изменение `docker-compose.yml`
- В сервисе `app` секция `healthcheck` заменяется на конструкцию из
п. 3.1.
- Остальные параметры сервиса (ports, volumes, environment) не
затрагиваются.
### R-2. Идемпотентность пересборки
- Изменения не требуют ребилда образа (`docker compose build`).
Достаточно `docker compose up -d app` для пересоздания контейнера
с новой healthcheck-командой.
- Допускается ребилд при необходимости — это не должно ломать сборку.
### R-3. Обратная совместимость
- Никаких ENV-переменных, влияющих на путь healthcheck, не вводится.
Адрес `http://localhost:5556/api/health` зашит в строку.
(Локальный — `localhost` внутри контейнера; порт всегда 5556,
как ENV `PORT` в Dockerfile.)
### R-4. Документация
- В `docs/work-items/ET-015/06-adr/healthcheck-via-python.md` зафиксировать
решение «использовать python-one-liner вместо curl». Автор ADR —
следующий этап (Architecture), не Анализ.
- Обновить `CHANGELOG.md` в секции «Unreleased» строкой формата
`fix(infra): use python urllib for container healthcheck (ET-015)`.
### R-5. Линт и форматирование
- YAML-валидность `docker-compose.yml` проверяется `make lint`.
- Длина строки python one-liner допустима в YAML (нет лимита 120 для
строковых значений multi-line array).
## 6. Тестирование
См. [04-test-plan.yaml](04-test-plan.yaml). Кратко:
- **integration-1**: после `docker compose up -d app` контейнер должен
выйти в `healthy` за ≤ 120 с.
- **integration-2**: при остановке uvicorn (или искусственном блоке
порта) — за ≤ 120 с переходит в `unhealthy`.
- **unit-1** (опционально): smoke-тест python-one-liner вне Docker
через `python -c "..."` против поднятого локально `make dev`.
## 7. Деплой и откат
- Деплой: `make deploy-test` (как обычно). При деплое compose
пересоздаст контейнер `enduro-trails-app-1`.
- Проверка: `docker inspect enduro-trails-app-1 --format
'{{.State.Health.Status}}'` → `healthy` в течение нескольких циклов
(`interval=30s × 3 = 90s` плюс `start_period=20s`).
- Откат: `git revert` коммита; `docker compose up -d app`. Старая
(поломанная) healthcheck-команда вернётся, но сам сервис продолжит
работать.
## 8. Риски
| Риск | Вероятность | Митигация |
|------|-------------|-----------|
| Python one-liner крэшится на каком-то Docker-движке из-за квотинга | низкая | YAML-массив `["CMD", "python", "-c", "..."]` — без shell, без экранирования |
| Длинная строка усложняет редактирование | средняя | Использовать YAML block-scalar (`>-` или `|`) при необходимости, но в текущей форме строка читаемая |
| Эндпоинт `/api/health` в будущем сделают «дорогим» и timeout=3s не хватит | низкая | Эндпоинт сейчас отдаёт ~7 мс; при изменении — пересмотр timeout |
| На prod-среде iptables/сеть отличаются и localhost внутри контейнера ведёт себя иначе | очень низкая | `localhost` в network namespace контейнера = loopback контейнера, не зависит от хоста |
## 9. Definition of Ready (для следующего этапа)
- BRD прочитан, ТЗ согласовано.
- Доступ к тестовой среде mva154 для проверки `docker inspect`.
- `make deploy-test` и `docker compose` доступны из ветки.

View File

@@ -0,0 +1,111 @@
# Acceptance Criteria: ET-015
**Work Item:** ET-015 — Healthcheck enduro-trails-app
**Базовые документы:** [01-brd.md](01-brd.md), [02-trz.md](02-trz.md)
## Формат
Каждый критерий записан как Gherkin-сценарий (`Given/When/Then`) и
имеет уникальный идентификатор `AC-NN`. Все критерии — обязательные,
если не указано иное.
---
## AC-01. Контейнер становится healthy после деплоя
**Given** на тестовой среде `mva154` смерджена ветка
`feature/ET-015-healthcheck-enduro-trails-app-`
**And** выполнен `make deploy-test`
**When** проходит ≤ 120 секунд после `docker compose up -d app`
**Then** `docker inspect enduro-trails-app-1 --format
'{{.State.Health.Status}}'` возвращает `healthy`
**And** `docker inspect ... --format '{{.State.Health.FailingStreak}}'`
возвращает `0`.
## AC-02. Контейнер остаётся healthy при штатной работе
**Given** контейнер `enduro-trails-app-1` в статусе `healthy`
**When** проходит 10 минут без вмешательства
**Then** статус остаётся `healthy`
**And** `FailingStreak == 0`
**And** в `docker inspect ... --format '{{json .State.Health.Log}}'`
последние 5 записей имеют `ExitCode: 0`.
## AC-03. Healthcheck не использует curl
**Given** ветка `feature/ET-015-healthcheck-enduro-trails-app-` смерджена
**When** выполняется `grep -n curl docker-compose.yml`
**Then** в выводе нет строки в секции `healthcheck` сервиса `app`
содержащей `curl`.
## AC-04. Образ не растёт за счёт установки curl/wget
**Given** PR с фиксом
**When** выполняется `git diff main -- Dockerfile`
**Then** в diff нет строк `apt-get install` для пакетов `curl` или
`wget`.
## AC-05. Healthcheck честно фиксирует unhealthy при отказе приложения
**Given** контейнер `enduro-trails-app-1` в статусе `healthy`
**When** uvicorn останавливается внутри контейнера (`docker exec
enduro-trails-app-1 sh -c 'kill -STOP 1'` или эквивалент через
останов python-процесса), и приложение перестаёт отвечать на
`http://localhost:5556/api/health`
**Then** в течение ≤ 120 секунд статус становится `unhealthy`.
> Примечание: в рамках интеграционного теста допускается имитировать
> отказ путём временной остановки контейнера-приложения и проверки,
> что Docker фиксирует переход.
## AC-06. Healthcheck-команда использует stdlib python
**Given** YAML `docker-compose.yml`
**When** парсится секция `app.healthcheck.test`
**Then** первый аргумент — `"CMD"`, второй — `"python"`, третий —
`"-c"`, четвёртый — однострочник, использующий **только** модули из
стандартной библиотеки Python 3.12 (`urllib`, `sys`).
## AC-07. Внутренний таймаут запроса меньше внешнего
**Given** секция `healthcheck` сервиса `app`
**When** читаются `timeout` (YAML-параметр) и `timeout=N` внутри
`urlopen(...)`
**Then** внутренний `timeout` строго меньше внешнего `timeout`
(`internal < external`), чтобы python успел корректно завершиться
и отдать exit code.
## AC-08. Эндпоинт /api/health не изменён
**Given** PR с фиксом
**When** выполняется `git diff main -- src/api/main.py`
**Then** в diff отсутствуют изменения функции `health()` и декоратора
`@app.get("/api/health")` (либо они затронуты тривиально — например,
вынос в роутер — но контракт ответа сохраняется: HTTP 200 + JSON с
полем `status`).
## AC-09. CHANGELOG обновлён
**Given** PR с фиксом
**When** открывается `CHANGELOG.md`
**Then** в секции `Unreleased` (или ближайшего невыпущенного релиза)
присутствует запись формата `fix(infra): ... healthcheck ... ET-015`.
## AC-10. ADR зафиксирован
**Given** PR с фиксом
**When** проверяется `docs/work-items/ET-015/06-adr/`
**Then** существует файл с ADR, описывающий решение «использовать
python urllib вместо curl/wget» с контекстом, решением, последствиями.
---
## Критерии приёмки выполнены, если
Все AC-01 … AC-10 проходят. Owner вручную проверяет на mva154:
```bash
ssh mva154 'docker inspect enduro-trails-app-1 \
--format "{{.State.Health.Status}} (streak {{.State.Health.FailingStreak}})"'
# → healthy (streak 0)
```

View File

@@ -0,0 +1,256 @@
# Test Plan: ET-015 — Healthcheck enduro-trails-app
#
# Базовые документы:
# - 01-brd.md
# - 02-trz.md
# - 03-acceptance-criteria.md
#
# Категории тестов:
# unit — изолированный, без Docker
# integration — с реальным docker compose
# e2e — на тестовой среде mva154
# static — статический анализ файлов в репо
work_item: ET-015
version: "1.0"
date: "2026-06-05"
tests:
# ─────────────────────────────────────────────────────────────────
# STATIC
# ─────────────────────────────────────────────────────────────────
- id: ST-01
name: "docker-compose.yml не содержит curl в healthcheck"
type: static
covers: [AC-03]
given: "Ветка feature/ET-015-... в рабочем дереве"
steps:
- "grep -nE '\\bcurl\\b' docker-compose.yml || true"
expected:
- "В выводе нет строк, попадающих в секцию app.healthcheck."
automatable: true
tooling: "make lint / отдельная проверка в scripts/"
- id: ST-02
name: "Dockerfile не устанавливает curl/wget"
type: static
covers: [AC-04]
given: "Текущий Dockerfile"
steps:
- "grep -nE 'apt-get +install.*\\b(curl|wget)\\b' Dockerfile || true"
expected:
- "Совпадений нет."
automatable: true
- id: ST-03
name: "Healthcheck использует python и stdlib"
type: static
covers: [AC-06]
given: "YAML docker-compose.yml"
steps:
- "Распарсить YAML (python -c 'import yaml,sys; print(yaml.safe_load(open(\"docker-compose.yml\"))[\"services\"][\"app\"][\"healthcheck\"][\"test\"])')"
expected:
- "Массив начинается с ['CMD', 'python', '-c', ...]."
- "Четвёртый элемент содержит 'urllib.request' и 'sys.exit'."
- "Не импортируются сторонние пакеты (нет 'requests', 'httpx', и т.п.)."
automatable: true
- id: ST-04
name: "Внутренний timeout urlopen меньше внешнего timeout healthcheck"
type: static
covers: [AC-07]
given: "Парсенный healthcheck"
steps:
- "Извлечь N из 'timeout=N' внутри строки python -c."
- "Извлечь M из YAML-поля healthcheck.timeout (например, '5s' → 5)."
expected:
- "N < M (по умолчанию 3 < 5)."
automatable: true
- id: ST-05
name: "Эндпоинт /api/health не сломан изменениями"
type: static
covers: [AC-08]
given: "PR с фиксом против main"
steps:
- "git diff main -- src/api/main.py | grep -E '^[+-].*(api/health|async def health)' || true"
expected:
- "Либо нет изменений, либо рефакторинг без слома контракта."
automatable: true
- id: ST-06
name: "CHANGELOG обновлён"
type: static
covers: [AC-09]
given: "CHANGELOG.md"
steps:
- "grep -nE 'ET-015' CHANGELOG.md"
expected:
- "Минимум одна строка с упоминанием ET-015 в Unreleased/ближайшей версии."
automatable: true
- id: ST-07
name: "ADR существует"
type: static
covers: [AC-10]
given: "docs/work-items/ET-015/06-adr/"
steps:
- "ls docs/work-items/ET-015/06-adr/*.md"
expected:
- "Минимум один .md-файл с описанием решения healthcheck-via-python."
automatable: true
# ─────────────────────────────────────────────────────────────────
# UNIT
# ─────────────────────────────────────────────────────────────────
- id: UT-01
name: "Python one-liner возвращает 0 при HTTP 200"
type: unit
covers: [AC-06]
given: "Запущен локально `make dev` (uvicorn на :5556) или мок-сервер"
steps:
- "Скопировать строку python -c '...' из healthcheck."
- "Запустить `python -c '...'` на хосте против http://localhost:5556/api/health."
- "Проверить $? == 0."
expected:
- "exit code = 0."
automatable: true
- id: UT-02
name: "Python one-liner возвращает не 0 при недоступном порту"
type: unit
covers: [AC-05, AC-06]
given: "Никто не слушает :5556 (uvicorn остановлен)"
steps:
- "Запустить ту же команду python -c '...'"
- "Проверить exit code."
expected:
- "exit code != 0 (URLError → ненулевой код)."
automatable: true
- id: UT-03
name: "Python one-liner возвращает не 0 при HTTP 500"
type: unit
covers: [AC-06]
given: "Мок-сервер на :5556, отдающий 500 на /api/health"
steps:
- "Запустить python one-liner."
expected:
- "exit code != 0 (HTTPError или sys.exit(1))."
automatable: true
# ─────────────────────────────────────────────────────────────────
# INTEGRATION
# ─────────────────────────────────────────────────────────────────
- id: IT-01
name: "docker compose up: контейнер становится healthy за ≤ 120s"
type: integration
covers: [AC-01]
given: "Чистая локальная машина с Docker и доступом к данным /home/slin/enduro-trails/data"
steps:
- "docker compose down -v"
- "docker compose up -d app"
- "Запустить цикл: `while true; do status=$(docker inspect $(docker compose ps -q app) --format '{{.State.Health.Status}}'); echo $status; [ \"$status\" = \"healthy\" ] && break; sleep 5; done` с таймаутом 120s"
expected:
- "Статус становится healthy в течение 120 секунд."
- "FailingStreak == 0 после перехода."
automatable: true
- id: IT-02
name: "Healthy остаётся стабильным 5 минут"
type: integration
covers: [AC-02]
given: "Контейнер в статусе healthy"
steps:
- "Подождать 5 минут (10 циклов healthcheck при interval=30s)."
- "docker inspect ... --format '{{.State.Health.Status}}'"
- "docker inspect ... --format '{{json .State.Health.Log}}' | jq '.[-5:] | map(.ExitCode)'"
expected:
- "Статус == healthy."
- "Все 5 последних ExitCode == 0."
- id: IT-03
name: "Переход в unhealthy при остановке приложения"
type: integration
covers: [AC-05]
given: "Контейнер healthy"
steps:
- "docker exec <container> sh -c 'pkill -STOP -f uvicorn' (или эквивалент: остановить главный процесс)"
- "Ждать до 120 секунд."
- "docker inspect ... --format '{{.State.Health.Status}}'"
expected:
- "Статус становится unhealthy в течение 120 секунд."
- "FailingStreak >= retries (>= 3)."
teardown:
- "docker compose restart app — вернуть в рабочее состояние."
- id: IT-04
name: "Healthcheck не требует ребилда образа"
type: integration
covers: [AC-04]
given: "Старый образ (с поломанным curl-healthcheck) уже собран локально"
steps:
- "Применить новый docker-compose.yml без `docker compose build`."
- "docker compose up -d app (только пересоздание контейнера)."
- "Подождать до 120 секунд."
expected:
- "Контейнер healthy без пересборки образа."
# ─────────────────────────────────────────────────────────────────
# E2E (на mva154)
# ─────────────────────────────────────────────────────────────────
- id: E2E-01
name: "После make deploy-test контейнер healthy на mva154"
type: e2e
covers: [AC-01, AC-02]
given: "Ветка смерджена в main, CI прошёл, выполнен make deploy-test"
steps:
- "ssh mva154 'docker inspect enduro-trails-app-1 --format \"{{.State.Health.Status}}\"'"
- "Повторить через 5 и 10 минут."
expected:
- "Все три замера: healthy."
- "FailingStreak == 0."
automatable: false
owner: "ops"
- id: E2E-02
name: "Приложение продолжает отвечать снаружи"
type: e2e
covers: [AC-08]
given: "Контейнер healthy на mva154"
steps:
- "curl -sS -o /dev/null -w '%{http_code} %{time_total}\\n' https://openclaw.mva154.duckdns.org/enduro/api/health"
expected:
- "HTTP 200, time_total < 1s."
automatable: true
# ─────────────────────────────────────────────────────────────────
# Покрытие критериев приёмки
# ─────────────────────────────────────────────────────────────────
coverage_matrix:
AC-01: [IT-01, E2E-01]
AC-02: [IT-02, E2E-01]
AC-03: [ST-01]
AC-04: [ST-02, IT-04]
AC-05: [UT-02, IT-03]
AC-06: [ST-03, UT-01, UT-03]
AC-07: [ST-04]
AC-08: [ST-05, E2E-02]
AC-09: [ST-06]
AC-10: [ST-07]
# ─────────────────────────────────────────────────────────────────
# Definition of Done для тестирования
# ─────────────────────────────────────────────────────────────────
done_when:
- "Все ST-* и UT-* проходят в make test / CI."
- "IT-01, IT-02, IT-03, IT-04 пройдены вручную или в integration-CI."
- "E2E-01 подтверждён ops после deploy-test."
- "E2E-02 возвращает HTTP 200."