ci(caddy): гард — import обязан быть покрыт volume-маунтом
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
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.
This commit is contained in:
parent
41f5be2136
commit
46b42c80db
2 changed files with 346 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
329
scripts/check-caddy-snippet-mounts.py
Normal file
329
scripts/check-caddy-snippet-mounts.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue