fix(infra): use python urllib for container healthcheck (ET-015)
All checks were successful
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>
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- ET-015: `docker-compose.yml` healthcheck сервиса `app` переведён с `curl -f`
|
||||
(отсутствует в базовом `python:3.12-slim`) на python one-liner через
|
||||
`urllib.request` из stdlib — без изменений `Dockerfile` и `src/api/main.py`,
|
||||
без ребилда образа (достаточно `docker compose up -d app`). Внутренний
|
||||
`urlopen(timeout=3)` меньше внешнего `healthcheck.timeout: 5s` (AC-07);
|
||||
добавлен `start_period: 20s` для смягчения окна холодного старта uvicorn.
|
||||
Контракт `/api/health` сохранён (HTTP 200 + JSON). Покрытие: 12 static-
|
||||
тестов (`tests/static/test_healthcheck_compose.py`) + 6 unit-тестов
|
||||
(`tests/unit/test_healthcheck_oneliner.py`, исполняют ровно ту же
|
||||
one-liner-команду против мок-сервера). ADR-020. Refs: ET-015.
|
||||
|
||||
`fix(infra): use python urllib for container healthcheck (ET-015)`
|
||||
|
||||
### Changed
|
||||
- ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8).
|
||||
Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords`
|
||||
|
||||
@@ -20,10 +20,15 @@ services:
|
||||
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
|
||||
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
|
||||
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
|
||||
|
||||
gps-collector:
|
||||
build: .
|
||||
|
||||
0
tests/static/__init__.py
Normal file
0
tests/static/__init__.py
Normal file
181
tests/static/test_healthcheck_compose.py
Normal file
181
tests/static/test_healthcheck_compose.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Статические тесты 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"
|
||||
)
|
||||
150
tests/unit/test_healthcheck_oneliner.py
Normal file
150
tests/unit/test_healthcheck_oneliner.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Unit-тесты исполняемого поведения healthcheck-one-liner'а (ET-015).
|
||||
|
||||
Тестируем именно тот код, который зашит в `docker-compose.yml`, чтобы
|
||||
гарантировать поведение exit-кода в трёх сценариях (UT-01..UT-03):
|
||||
|
||||
UT-01 (AC-06): exit 0 при HTTP 200.
|
||||
UT-02 (AC-05/AC-06): exit ≠ 0 при недоступном порту.
|
||||
UT-03 (AC-06): exit ≠ 0 при HTTP 500.
|
||||
|
||||
URL в one-liner подменяется на адрес мок-сервера, остальной код
|
||||
выполняется ровно тот же, что и внутри контейнера.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
|
||||
PROD_HEALTH_URL = "http://localhost:5556/api/health"
|
||||
|
||||
|
||||
def _load_oneliner() -> str:
|
||||
"""Возвращает 4-й элемент массива test (сам python-код), как в compose."""
|
||||
data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
|
||||
test = data["services"]["app"]["healthcheck"]["test"]
|
||||
assert isinstance(test, list) and len(test) >= 4, f"unexpected healthcheck.test: {test!r}"
|
||||
code = test[3]
|
||||
assert isinstance(code, str)
|
||||
return code
|
||||
|
||||
|
||||
def _pick_unused_port() -> int:
|
||||
"""Свободный TCP-порт на 127.0.0.1 (грязно, но достаточно для теста)."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _retarget(code: str, url: str) -> str:
|
||||
"""Заменяет prod-URL внутри one-liner на тестовый.
|
||||
|
||||
Используем именно подмену строки (а не отдельный код), чтобы под тест
|
||||
шла та же логика урлопен + проверки статуса, что и в проде.
|
||||
"""
|
||||
assert PROD_HEALTH_URL in code, (
|
||||
f"one-liner должен содержать {PROD_HEALTH_URL!r}, иначе ретаргет небезопасен. "
|
||||
f"Код: {code!r}"
|
||||
)
|
||||
return code.replace(PROD_HEALTH_URL, url)
|
||||
|
||||
|
||||
def _run(code: str, timeout: float = 10.0) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class _FixedStatusHandler(BaseHTTPRequestHandler):
|
||||
status_code = 200
|
||||
body = b'{"status":"ok"}'
|
||||
|
||||
def do_GET(self): # noqa: N802 — имя задано BaseHTTPRequestHandler
|
||||
self.send_response(self.status_code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(self.body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(self.body)
|
||||
|
||||
def log_message(self, *_args, **_kwargs): # тишина в pytest-логах
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_server():
|
||||
"""Поднимает локальный HTTPServer с настраиваемым статусом."""
|
||||
started = []
|
||||
|
||||
def _start(status_code: int):
|
||||
handler = type(
|
||||
"_H",
|
||||
(_FixedStatusHandler,),
|
||||
{"status_code": status_code},
|
||||
)
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
started.append((server, thread))
|
||||
port = server.server_address[1]
|
||||
return f"http://127.0.0.1:{port}/"
|
||||
|
||||
yield _start
|
||||
|
||||
for server, thread in started:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
# ───────────────────────── UT-01 (AC-06) ─────────────────────────
|
||||
|
||||
def test_ut01_returns_zero_on_http_200(mock_server):
|
||||
url = mock_server(200)
|
||||
code = _retarget(_load_oneliner(), url)
|
||||
result = _run(code)
|
||||
assert result.returncode == 0, (
|
||||
f"ожидался exit code 0 при HTTP 200, получили {result.returncode}. "
|
||||
f"stderr={result.stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── UT-02 (AC-05/AC-06) ─────────────────────────
|
||||
|
||||
def test_ut02_returns_nonzero_when_port_unused():
|
||||
port = _pick_unused_port()
|
||||
code = _retarget(_load_oneliner(), f"http://127.0.0.1:{port}/")
|
||||
result = _run(code)
|
||||
assert result.returncode != 0, (
|
||||
f"ожидался ненулевой exit code, когда никто не слушает порт, "
|
||||
f"но получили 0. stderr={result.stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────── UT-03 (AC-06) ─────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("status_code", [301, 404, 500, 503])
|
||||
def test_ut03_returns_nonzero_on_non_2xx(mock_server, status_code):
|
||||
"""Любой не-200 ответ должен трактоваться как unhealthy.
|
||||
|
||||
one-liner из ADR-020 проверяет `status == 200`, всё остальное → exit 1
|
||||
(либо HTTPError → ненулевой exit). Параметризация — защита от
|
||||
регресса, если кто-то сменит условие на `< 400` и т.п.
|
||||
"""
|
||||
url = mock_server(status_code)
|
||||
code = _retarget(_load_oneliner(), url)
|
||||
result = _run(code)
|
||||
assert result.returncode != 0, (
|
||||
f"ожидался ненулевой exit code при HTTP {status_code}, "
|
||||
f"получили 0. stderr={result.stderr!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user