Files
enduro-trails/tests/unit/test_healthcheck_oneliner.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

151 lines
5.8 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.
"""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}"
)