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
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.
329 lines
15 KiB
Python
329 lines
15 KiB
Python
#!/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())
|