diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 84a6924c..93f3d075 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -65,6 +65,23 @@ jobs: python3 scripts/check-workflow-ports.py --selftest python3 scripts/check-workflow-ports.py + - name: "Guard: Caddy import покрыт volume-маунтом (#3102)" + # Тем же шагом-соседом и по той же причине: дёшево, на каждом PR, + # падение блокирует merge. + # + # ЗАЧЕМ. 2026-08-26 сюда доехал PR, который завёл `import + # ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил + # bind-mount этих файлов в docker-compose.prod.yml. `caddy validate` + # ниже эту дыру НЕ ловит: он копирует ВЕСЬ каталог caddy/ как есть + # (`docker cp caddy ...`), а на проде смонтированы только отдельные + # файлы и два каталога — расхождение между "что лежит в репозитории" и + # "что реально видит контейнер" видно только на реальных маунтах. + # Итог того PR: Caddy на проде не смог адаптировать конфиг, ушёл в + # restart-loop и уронил ВСЕ сайты хоста на ~30 минут. + run: | + python3 scripts/check-caddy-snippet-mounts.py --selftest + python3 scripts/check-caddy-snippet-mounts.py + - name: "Guard: Caddyfile синтаксически валиден" # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # на КАЖДОМ PR, стоит секунды, падение блокирует merge. diff --git a/scripts/check-caddy-snippet-mounts.py b/scripts/check-caddy-snippet-mounts.py new file mode 100644 index 00000000..29fdbe64 --- /dev/null +++ b/scripts/check-caddy-snippet-mounts.py @@ -0,0 +1,329 @@ +#!/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())