#!/usr/bin/env python3 """Гейт: каждый `import` в Caddy-конфигах покрыт volume-маунтом сервиса `caddy:` (#3102). ПОЧЕМУ. PR #3102 (observability) добавил `caddy/metrics-ingest.caddy.snippet` и `caddy/metrics-ui.caddy.snippet` и завёл `import ../metrics-*.caddy.snippet` в `caddy/sites/infra.caddy` — но НЕ добавил bind-mount этих файлов в `docker-compose.prod.yml`. Каталог `caddy/` внутрь контейнера ЦЕЛИКОМ не пробрасывается — только пофайлово (`caddy/users.caddy.snippet`) плюс каталоги `caddy/local` и `caddy/sites`. Итог постмортема 2026-08-26: caddy не смог адаптировать конфиг (`File to import not found: ../metrics-ingest.caddy.snippet`), ушёл в restart-loop — и легли ВСЕ сайты хоста, а не только metrics.gendsgn.ru. ЧТО ДЕЛАЕТ. Собирает бинд-маунты (`./host:...` -> `/container...`) сервиса `caddy:` из docker-compose.prod.yml, парсит все директивы `import ...` в Caddyfile + `caddy/sites/*.caddy` + `caddy/*.caddy.snippet`, резолвит каждый импорт относительно КОНТЕЙНЕРНОГО пути файла, который его содержит (Caddy сам резолвит import именно так — от каталога импортирующего файла, а не от CWD), и падает, если результат не покрыт ни одним volume-маунтом. Игнорируются: именованные сниппеты Caddy (`(name) { ... }` + `import name` — это не файл, а ссылка на блок внутри того же Caddyfile). Glob/placeholder- импорты (`caddy/sites/{$CADDY_SITES:*}.caddy`, `caddy/local/*.caddy`) проверяются по КАТАЛОГУ — Caddy подставляет реальные имена файлов в рантайме, а на этом уровне достаточно того, что каталог смонтирован. ГРАНИЦА: это статический разбор регулярками, а не настоящий парсер Caddyfile — достаточно для плоских `import ` в один токен, как во всех текущих конфигах репозитория. Импорты с переносами строк/кавычками не распознаются. Запуск: python3 scripts/check-caddy-snippet-mounts.py [--selftest] """ from __future__ import annotations import posixpath import re import sys from pathlib import Path COMPOSE_FILE = Path("docker-compose.prod.yml") CADDYFILE = Path("Caddyfile") SITES_DIR = Path("caddy/sites") SNIPPETS_DIR = Path("caddy") IMPORT_RE = re.compile(r"^\s*import\s+(\S+)") SNIPPET_DEF_RE = re.compile(r"^\s*\(([^)]+)\)\s*\{") # "- ./host/path:/container/path" или тот же вид с ":ro" на конце. # Именованные тома (без "./") сюда не попадают — сверять их не с чем. VOLUME_ITEM_RE = re.compile(r"^-\s*(\./\S+?):(/\S+?)(?::ro)?\s*$") def parse_caddy_bind_mounts(compose_text: str) -> list[tuple[str, str]]: """-> [(host_rel_path, container_path)] бинд-маунтов сервиса `caddy:`. Отступы не хардкодим числом — как и в check-workflow-ports.py, просто запоминаем фактический отступ строки-заголовка секции и выходим из неё, как только встречена строка на том же или меньшем отступе. """ mounts: list[tuple[str, str]] = [] in_service = False in_volumes = False service_indent = 0 volumes_indent = 0 for raw in compose_text.splitlines(): stripped = raw.strip() if not stripped or stripped.startswith("#"): continue indent = len(raw) - len(raw.lstrip()) if not in_service: if re.match(r"^\s*caddy:\s*$", raw): in_service = True service_indent = indent continue if indent <= service_indent: # Началось описание следующего сервиса (или конец блока services:). in_service = False in_volumes = False continue if in_volumes and (not stripped.startswith("- ") or indent <= volumes_indent): in_volumes = False if stripped == "volumes:": in_volumes = True volumes_indent = indent continue if in_volumes: m = VOLUME_ITEM_RE.match(stripped) if m: mounts.append((m.group(1)[2:], m.group(2))) # срез "./" return mounts def resolve_container_path(rel_path: str, mounts: list[tuple[str, str]]) -> str | None: """Repo-relative POSIX-путь -> путь ВНУТРИ контейнера caddy, если он покрыт файловым маунтом (точное совпадение) или каталожным (общий префикс). None — если файл ничем не смонтирован.""" for host, container in mounts: if host == rel_path: return container best: tuple[str, str] | None = None for host, container in mounts: prefix = host.rstrip("/") + "/" if rel_path.startswith(prefix): candidate = container.rstrip("/") + "/" + rel_path[len(prefix) :] if best is None or len(host) > len(best[0]): best = (host, candidate) return best[1] if best else None def is_covered(target: str, mounts: list[tuple[str, str]]) -> bool: """True, если абсолютный контейнерный путь `target` покрыт файловым или каталожным маунтом сервиса caddy:.""" for _host, container in mounts: if target == container: return True if target.startswith(container.rstrip("/") + "/"): return True return False def find_snippet_definitions(text: str) -> set[str]: """Имена именованных сниппетов Caddy, объявленных `(name) { ... }` — это ссылка на блок, а не на файл, `import name` их резолвить не нужно.""" return {m.group(1).strip() for m in SNIPPET_DEF_RE.finditer(text)} def find_imports(text: str) -> list[tuple[int, str]]: """-> [(номер строки, аргумент import)] для директив `import `.""" hits: list[tuple[int, str]] = [] for lineno, raw in enumerate(text.splitlines(), 1): stripped = raw.strip() if not stripped or stripped.startswith("#"): continue m = IMPORT_RE.match(raw) if m: hits.append((lineno, m.group(1))) return hits def resolve_import_target(importer_container_dir: str, spec: str) -> str: """Абсолютный контейнерный путь, на который указывает import `spec`, относительно каталога импортирующего файла. Для glob/placeholder-импортов (`*`, `{...}`) возвращает КАТАЛОГ — Caddy сам находит файлы внутри него, проверять есть смысл только то, что каталог смонтирован.""" is_pattern = "*" in spec or "{" in spec check_spec = posixpath.dirname(spec) if is_pattern else spec return posixpath.normpath(posixpath.join(importer_container_dir, check_spec)) def check_file( rel_path: str, text: str, mounts: list[tuple[str, str]], snippet_names: set[str], ) -> list[str]: """-> список сообщений об ошибках для одного Caddy-конфига.""" errors: list[str] = [] importer_container = resolve_container_path(rel_path, mounts) if importer_container is None: errors.append( f"{rel_path}: сам файл не покрыт ни одним volume-маунтом сервиса " f"caddy: в docker-compose.prod.yml — импорты внутри него нельзя " f"проверить (и Caddy их тоже не увидит)" ) return errors importer_dir = posixpath.dirname(importer_container) for lineno, spec in find_imports(text): if spec in snippet_names: continue # именованный сниппет Caddy, не файл target = resolve_import_target(importer_dir, spec) if not is_covered(target, mounts): what = "каталог" if ("*" in spec or "{" in spec) else "файл" errors.append( f"{rel_path}:{lineno}: import {spec!r} -> {what} {target} НЕ " f"смонтирован в сервисе caddy: (docker-compose.prod.yml). Caddy " f"упадёт 'File to import not found' и уйдёт в restart-loop, " f"роняя ВСЕ сайты хоста (постмортем #3102, 2026-08-26) — добавь " f"bind-mount рядом с соседними ./caddy/... строками." ) return errors def selftest() -> None: compose_snippet = ( "services:\n" " postgres:\n" " volumes:\n" " - postgres_data:/var/lib/postgresql/data\n" " caddy:\n" " image: caddy:2\n" " volumes:\n" " - ./Caddyfile:/etc/caddy/Caddyfile:ro\n" " - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro\n" " - ./caddy/local:/etc/caddy/caddy/local:ro\n" " - ./caddy/sites:/etc/caddy/caddy/sites:ro\n" " - ./preview:/srv/preview:ro\n" " - caddy_data:/data\n" " glitchtip-web:\n" " volumes:\n" " - ./should-not-leak:/should/not/leak\n" ) mounts = parse_caddy_bind_mounts(compose_snippet) assert ("Caddyfile", "/etc/caddy/Caddyfile") in mounts assert ("caddy/sites", "/etc/caddy/caddy/sites") in mounts # именованный том без "./" — пропущен, соседний сервис — не подмешан assert all(host != "should-not-leak" for host, _ in mounts) assert all("should" not in c for _, c in mounts) # resolve_container_path: точный файловый маунт и маунт каталога-родителя assert resolve_container_path("Caddyfile", mounts) == "/etc/caddy/Caddyfile" assert ( resolve_container_path("caddy/sites/infra.caddy", mounts) == "/etc/caddy/caddy/sites/infra.caddy" ) assert resolve_container_path("caddy/metrics-ingest.caddy.snippet", mounts) is None # is_covered: точное совпадение и вложенность в каталожный маунт assert is_covered("/etc/caddy/Caddyfile", mounts) assert is_covered("/etc/caddy/caddy/sites/apps.caddy", mounts) assert not is_covered("/etc/caddy/caddy/metrics-ingest.caddy.snippet", mounts) # find_imports: находит директиву, пропускает комментарий assert find_imports("import caddy/users.caddy.snippet\n# import x\n") == [ (1, "caddy/users.caddy.snippet") ] # find_snippet_definitions + именованные сниппеты не считаются файлами named = find_snippet_definitions("(common_headers) {\n header X-Test 1\n}\n") assert named == {"common_headers"} # resolve_import_target: обычный файл и glob/placeholder -> каталог assert ( resolve_import_target("/etc/caddy", "caddy/users.caddy.snippet") == "/etc/caddy/caddy/users.caddy.snippet" ) assert ( resolve_import_target("/etc/caddy/caddy/sites", "../metrics-ingest.caddy.snippet") == "/etc/caddy/caddy/metrics-ingest.caddy.snippet" ) assert ( resolve_import_target("/etc/caddy", "caddy/sites/{$CADDY_SITES:*}.caddy") == "/etc/caddy/caddy/sites" ) assert resolve_import_target("/etc/caddy", "caddy/local/*.caddy") == "/etc/caddy/caddy/local" # check_file: воспроизводим ровно баг #3102 — infra.caddy импортирует # ../metrics-ingest.caddy.snippet, которого нет в mounts выше -> падение. infra_text = ( "metrics.gendsgn.ru {\n" " handle_path /ingest/prometheus/* {\n" " import ../metrics-ingest.caddy.snippet\n" " reverse_proxy prometheus:9090\n" " }\n" "}\n" ) broken = check_file("caddy/sites/infra.caddy", infra_text, mounts, set()) assert len(broken) == 1 and "metrics-ingest.caddy.snippet" in broken[0] # тот же файл — но с добавленным маунтом снипета — гейт зелёный fixed_mounts = mounts + [ ("caddy/metrics-ingest.caddy.snippet", "/etc/caddy/caddy/metrics-ingest.caddy.snippet") ] assert check_file("caddy/sites/infra.caddy", infra_text, fixed_mounts, set()) == [] # именованный сниппет игнорируется, даже если файла с таким именем нет named_import_text = "(mysnippet) {\n respond 200\n}\nexample.com {\n import mysnippet\n}\n" assert check_file("Caddyfile", named_import_text, mounts, {"mysnippet"}) == [] # файл, который сам не смонтирован — отдельная явная ошибка orphan = check_file("caddy/unmounted.caddy", "import caddy/users.caddy.snippet\n", mounts, set()) assert len(orphan) == 1 and "не покрыт" in orphan[0] print("selftest OK") def main() -> int: if "--selftest" in sys.argv: selftest() return 0 if not COMPOSE_FILE.is_file() or not CADDYFILE.is_file(): print( f"::error::{COMPOSE_FILE} или {CADDYFILE} не найден — " f"запускать из корня репозитория" ) return 1 mounts = parse_caddy_bind_mounts(COMPOSE_FILE.read_text(encoding="utf-8")) if not mounts: print( f"::error file={COMPOSE_FILE}::не найдено ни одного bind-mount " f"(`./host:/container`) в сервисе caddy: — гейт не может ничего " f"проверить. Структура файла изменилась?" ) return 1 files: dict[str, str] = {"Caddyfile": CADDYFILE.read_text(encoding="utf-8")} for p in sorted(SITES_DIR.glob("*.caddy")): files[p.as_posix()] = p.read_text(encoding="utf-8") for p in sorted(SNIPPETS_DIR.glob("*.caddy.snippet")): files[p.as_posix()] = p.read_text(encoding="utf-8") snippet_names = set() for text in files.values(): snippet_names |= find_snippet_definitions(text) failed = False for rel_path, text in files.items(): for err in check_file(rel_path, text, mounts, snippet_names): failed = True print(f"::error file={rel_path}::{err}") if failed: return 1 print( f"✓ все import покрыты volume-маунтами caddy: " f"({len(files)} конфигов, {len(mounts)} bind-маунтов)" ) return 0 if __name__ == "__main__": raise SystemExit(main())