All checks were successful
Базовый образ `python:3.12-slim` не содержит `curl`, поэтому текущий
healthcheck `["CMD", "curl", "-f", ...]` всегда падает (`exec: "curl":
executable file not found`), и контейнер `enduro-trails-app-1` висит
в статусе `unhealthy` (≥31 час, FailingStreak 3762 при RestartCount 0),
несмотря на то что приложение исправно отвечает HTTP 200 на /api/health.
Заменяем healthcheck на python one-liner через stdlib `urllib.request`
(ADR-020). Изменения:
• docker-compose.yml, сервис app:
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if
urllib.request.urlopen(...timeout=3).status == 200 else 1)"]
+ start_period: 20s
interval/timeout/retries сохранены (30s / 5s / 3).
Внутренний urlopen(timeout=3) строго меньше внешнего healthcheck
timeout=5s (AC-07).
• Dockerfile НЕ меняется (никаких apt-get install curl/wget — BRD §6,
AC-04). Деплой без ребилда: `docker compose up -d app` достаточно.
• src/api/main.py НЕ меняется. Контракт /api/health сохранён (AC-08).
Покрытие:
- tests/static/test_healthcheck_compose.py — 10 тестов (ST-01..ST-07
+ защита от регресса по target URL / start_period / baseline params).
- tests/unit/test_healthcheck_oneliner.py — 6 тестов (UT-01..UT-03),
исполняют ровно ту же one-liner-команду через subprocess против
локального мок-HTTPServer (200/301/404/500/503) и неиспользуемого
порта. URL подменяется через `_retarget`, чтобы тестировать живой
код из compose, а не его копию.
ADR: docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md
CHANGELOG: запись в [Unreleased] / Fixed.
Refs: ET-015
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
9.1 KiB
Python
182 lines
9.1 KiB
Python
"""Статические тесты healthcheck-конфигурации (ET-015).
|
||
|
||
Покрывает критерии приёмки:
|
||
AC-03 → ST-01 — в healthcheck нет `curl`.
|
||
AC-04 → ST-02 — Dockerfile не ставит `curl`/`wget` через apt-get.
|
||
AC-06 → ST-03 — healthcheck использует python + stdlib (urllib, sys).
|
||
AC-07 → ST-04 — внутренний `timeout=N` < внешнего YAML-`timeout`.
|
||
AC-09 → ST-06 — CHANGELOG содержит запись с упоминанием ET-015.
|
||
AC-10 → ST-07 — ADR по решению существует в work-item.
|
||
|
||
Источник правды по конфигурации:
|
||
docs/work-items/ET-015/02-trz.md §3.1
|
||
docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
|
||
DOCKERFILE_PATH = REPO_ROOT / "Dockerfile"
|
||
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
|
||
ADR_DIR = REPO_ROOT / "docs" / "work-items" / "ET-015" / "06-adr"
|
||
|
||
|
||
def _load_app_healthcheck() -> dict:
|
||
data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
|
||
services = data.get("services", {})
|
||
assert "app" in services, "docker-compose.yml must define service `app`"
|
||
hc = services["app"].get("healthcheck")
|
||
assert hc is not None, "service `app` must define `healthcheck`"
|
||
return hc
|
||
|
||
|
||
def _yaml_duration_to_seconds(value: str) -> float:
|
||
"""Парсит YAML-длительность вида '5s' / '500ms' / '2m' в секунды."""
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
s = str(value).strip()
|
||
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(ms|s|m|h)", s)
|
||
assert m, f"Не могу распарсить duration {value!r}"
|
||
n = float(m.group(1))
|
||
unit = m.group(2)
|
||
return {"ms": n / 1000, "s": n, "m": n * 60, "h": n * 3600}[unit]
|
||
|
||
|
||
# ───────────────────────── ST-01 (AC-03) ─────────────────────────
|
||
|
||
def test_st01_healthcheck_does_not_use_curl():
|
||
hc = _load_app_healthcheck()
|
||
test = hc.get("test")
|
||
assert test is not None, "healthcheck.test обязателен"
|
||
joined = " ".join(str(x) for x in test) if isinstance(test, list) else str(test)
|
||
assert "curl" not in joined, (
|
||
f"healthcheck.test содержит `curl`, ожидался python one-liner. test={test!r}"
|
||
)
|
||
|
||
|
||
# ───────────────────────── ST-02 (AC-04) ─────────────────────────
|
||
|
||
def test_st02_dockerfile_does_not_apt_install_curl_or_wget():
|
||
df = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||
# Ищем "apt-get install ... curl" / "... wget" — строго по слову.
|
||
bad_lines = [
|
||
line
|
||
for line in df.splitlines()
|
||
if re.search(r"apt-get\s+install[^\n]*\b(curl|wget)\b", line)
|
||
]
|
||
assert not bad_lines, (
|
||
f"Dockerfile устанавливает curl/wget через apt-get, "
|
||
f"что противоречит ADR-020. Найдено: {bad_lines!r}"
|
||
)
|
||
|
||
|
||
# ───────────────────────── ST-03 (AC-06) ─────────────────────────
|
||
|
||
def test_st03_healthcheck_uses_python_and_stdlib():
|
||
hc = _load_app_healthcheck()
|
||
test = hc["test"]
|
||
assert isinstance(test, list), f"healthcheck.test должен быть массивом, а не {type(test).__name__}"
|
||
assert len(test) >= 4, f"healthcheck.test должен иметь минимум 4 элемента, есть {len(test)}"
|
||
assert test[0] == "CMD", f"первый элемент должен быть 'CMD' (не CMD-SHELL), есть {test[0]!r}"
|
||
assert test[1] == "python", f"второй элемент должен быть 'python', есть {test[1]!r}"
|
||
assert test[2] == "-c", f"третий элемент должен быть '-c', есть {test[2]!r}"
|
||
|
||
code = test[3]
|
||
assert isinstance(code, str)
|
||
assert "urllib.request" in code, "one-liner должен использовать urllib.request"
|
||
assert "sys.exit" in code, "one-liner должен явно вызывать sys.exit для exit code"
|
||
|
||
# Запрещённые сторонние пакеты — гарантируем «только stdlib».
|
||
forbidden = ["requests", "httpx", "aiohttp", "urllib3"]
|
||
for pkg in forbidden:
|
||
# Ищем именно как импорт/обращение к пакету, а не подстроку.
|
||
assert not re.search(rf"\b{re.escape(pkg)}\b", code), (
|
||
f"one-liner ссылается на сторонний пакет {pkg!r}; должен использовать только stdlib"
|
||
)
|
||
|
||
|
||
# ───────────────────────── ST-04 (AC-07) ─────────────────────────
|
||
|
||
def test_st04_internal_timeout_less_than_external():
|
||
hc = _load_app_healthcheck()
|
||
code = hc["test"][3]
|
||
m = re.search(r"timeout\s*=\s*(\d+(?:\.\d+)?)", code)
|
||
assert m, f"в one-liner ожидается явный аргумент timeout=N, не нашли в {code!r}"
|
||
internal = float(m.group(1))
|
||
|
||
external_raw = hc.get("timeout")
|
||
assert external_raw is not None, "healthcheck.timeout должен быть задан"
|
||
external = _yaml_duration_to_seconds(external_raw)
|
||
|
||
assert internal < external, (
|
||
f"внутренний timeout={internal}s должен быть СТРОГО меньше "
|
||
f"внешнего healthcheck.timeout={external}s (TRZ §3.1, AC-07)"
|
||
)
|
||
|
||
|
||
# ───────────────────────── ST-06 (AC-09) ─────────────────────────
|
||
|
||
def test_st06_changelog_mentions_et015():
|
||
text = CHANGELOG_PATH.read_text(encoding="utf-8")
|
||
# Проверяем именно строку, не просто наличие подстроки в любом месте.
|
||
matches = [line for line in text.splitlines() if "ET-015" in line]
|
||
assert matches, "CHANGELOG.md должен содержать запись с упоминанием ET-015 (см. TRZ R-4 / AC-09)"
|
||
|
||
|
||
# ───────────────────────── ST-07 (AC-10) ─────────────────────────
|
||
|
||
def test_st07_adr_exists():
|
||
assert ADR_DIR.is_dir(), f"директория ADR должна существовать: {ADR_DIR}"
|
||
md_files = sorted(ADR_DIR.glob("*.md"))
|
||
assert md_files, f"в {ADR_DIR} должен быть хотя бы один ADR (.md)"
|
||
|
||
# Хотя бы один файл должен описывать решение healthcheck-via-python.
|
||
relevant = []
|
||
for path in md_files:
|
||
body = path.read_text(encoding="utf-8").lower()
|
||
if "healthcheck" in body and ("urllib" in body or "python" in body):
|
||
relevant.append(path.name)
|
||
assert relevant, (
|
||
f"в {ADR_DIR} нет ADR, описывающего решение healthcheck через python urllib. "
|
||
f"Найдены файлы: {[p.name for p in md_files]}"
|
||
)
|
||
|
||
|
||
# ───────────────────────── Дополнительная защита от регресса ─────────────────────────
|
||
|
||
def test_app_healthcheck_target_is_local_api_health():
|
||
"""one-liner должен бить именно в /api/health на localhost:5556 (TRZ §3.1)."""
|
||
hc = _load_app_healthcheck()
|
||
code = hc["test"][3]
|
||
assert "http://localhost:5556/api/health" in code, (
|
||
f"healthcheck должен обращаться к http://localhost:5556/api/health, "
|
||
f"чтобы корректно проверять loopback контейнера. Код: {code!r}"
|
||
)
|
||
|
||
|
||
def test_app_healthcheck_has_start_period():
|
||
"""ADR-020 добавляет start_period для смягчения окна холодного старта."""
|
||
hc = _load_app_healthcheck()
|
||
assert "start_period" in hc, "ADR-020 требует start_period для healthcheck (см. TRZ §3.1)"
|
||
sp = _yaml_duration_to_seconds(hc["start_period"])
|
||
assert sp >= 10, f"start_period слишком мал ({sp}s), ожидается ≥ 10s (TRZ §3.1)"
|
||
|
||
|
||
@pytest.mark.parametrize("field,minimum", [("interval", 30), ("retries", 3)])
|
||
def test_app_healthcheck_preserves_baseline_params(field, minimum):
|
||
"""ADR-020 «инвариант»: interval/retries не уменьшаются относительно текущих."""
|
||
hc = _load_app_healthcheck()
|
||
assert field in hc, f"healthcheck.{field} обязателен"
|
||
value = hc[field]
|
||
if field == "interval":
|
||
value = _yaml_duration_to_seconds(value)
|
||
assert value >= minimum, (
|
||
f"healthcheck.{field}={value} меньше базового {minimum} — нарушает инвариант ADR-020"
|
||
)
|