gendesign/scripts/check-caddy-snippet-mounts.py
bot-backend 46b42c80db
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m10s
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI / backend-tests (pull_request) Successful in 17m41s
ci(caddy): гард — import обязан быть покрыт volume-маунтом
Follow-up к #3103. Прод лёг на ~30 минут потому, что PR завёл
`import ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил
bind-монты этих файлов в docker-compose.prod.yml.

Соседний гард `caddy validate` эту дыру не ловит принципиально: он копирует
каталог caddy/ целиком (`docker cp caddy ...`), а на проде смонтированы только
отдельные файлы плюс два каталога. Расхождение между «что лежит в репозитории»
и «что реально видит контейнер» видно только если сверять с маунтами.

check-caddy-snippet-mounts.py разбирает bind-монты сервиса caddy:, резолвит
каждый `import` в Caddyfile / caddy/sites/*.caddy / caddy/*.caddy.snippet
относительно КОНТЕЙНЕРНОГО пути импортирующего файла и падает, если цель не
покрыта ни одним маунтом. Именованные сниппеты `(name) { }` пропускаются,
для glob/placeholder-импортов (`caddy/sites/{$CADDY_SITES:*}.caddy`)
проверяется каталог. --selftest воспроизводит ровно баг #3102.
2026-08-26 12:07:29 +03:00

329 lines
15 KiB
Python
Raw Permalink 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.

#!/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 <path>` в один токен, как во всех текущих
конфигах репозитория. Импорты с переносами строк/кавычками не распознаются.
Запуск: 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 <path>`."""
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())