ci(caddy): гард — import обязан быть покрыт volume-маунтом #3104

Merged
lekss361 merged 1 commit from ci/caddy-import-mount-guard into main 2026-08-26 09:27:10 +00:00
2 changed files with 346 additions and 0 deletions

View file

@ -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.

View file

@ -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 <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())