"""Статические тесты 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" )