Files
enduro-trails/tests/static/test_healthcheck_compose.py
claude-bot 543099b740
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 12s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 12s
CI / build (pull_request) Successful in 2s
fix(infra): use python urllib for container healthcheck (ET-015)
Базовый образ `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>
2026-06-05 15:32:34 +00:00

182 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Статические тесты 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"
)