Files
orchestrator/tests/test_bundle_compose.py

265 lines
13 KiB
Python
Raw 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.
"""ORCH-103 (TC-01…TC-04, AC-1/AC-6/AC-9): анти-дрейф bundle-compose Bundled-тиража.
Структурные проверки `deploy/bundled/docker-compose.yml` (ADR-001 D1D4) и его
конфиг-канона `deploy/bundled/.env.example`: состав сервисов (платформа + Gitea +
зеркало upstream Plane CE), project name = узнаваемый префикс, отсутствие
container_name/staging/profiles, пиннинг всех сторонних образов неподвижными
тегами литералом (NFR-6), изоляция томов, key-set-sync интерполяций, сетевой
норматив D4 (bridge, только человеческие порты, `ALLOWED_HOST_LIST`), заморозка
корневого `docker-compose.yml` (зеркало TC-04 `test_lite_setup_doc.py` — bundle
живёт строго отдельным файлом). Детерминировано: yaml.safe_load, без
docker/сети/LLM/subprocess (тест-план 04, scope).
"""
import re
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml"
BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example"
ROOT_COMPOSE = REPO_ROOT / "docker-compose.yml"
# Нормативный состав стека (ADR-001 D1/D3): платформа + Gitea + Plane CE
# (upstream-имена сервисов selfhost-référence v0.23.1 — анти-дрейф к их докам).
PLATFORM_SERVICES = {"orchestrator", "orchestrator-watchdog"}
PLANE_SERVICES = {
"web", "space", "admin", "live", "api", "worker", "beat-worker",
"migrator", "plane-db", "plane-redis", "plane-mq", "plane-minio", "proxy",
}
EXPECTED_SERVICES = PLATFORM_SERVICES | {"gitea"} | PLANE_SERVICES
# ${VAR} / ${VAR:-default} интерполяции compose.
_INTERP_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)")
def _raw() -> str:
assert BUNDLE_COMPOSE.is_file(), "deploy/bundled/docker-compose.yml отсутствует (FR-1)"
return BUNDLE_COMPOSE.read_text(encoding="utf-8")
def _doc() -> dict:
return yaml.safe_load(_raw())
def _services() -> dict:
return _doc()["services"]
def _env_keys(path: Path) -> set:
keys = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
keys.add(line.split("=", 1)[0].strip())
return keys
# ---------------------------------------------------------------------------
# TC-01: bundle-compose существует, валиден, несёт нормативный состав (AC-1).
# ---------------------------------------------------------------------------
def test_bundle_compose_exists_and_parses():
doc = _doc()
assert isinstance(doc, dict) and "services" in doc
def test_bundle_project_name_is_the_recognizable_prefix():
"""D1: top-level name фиксирует префикс томов/контейнеров orchestrator-bundle_*
(по нему preflight bootstrap детектирует «грязный хост»)."""
assert _doc().get("name") == "orchestrator-bundle"
def test_bundle_has_exactly_the_adr_service_set():
services = set(_services())
assert services == EXPECTED_SERVICES, (
f"состав сервисов bundle разъехался с ADR-001 D1/D3: "
f"лишние={sorted(services - EXPECTED_SERVICES)}, "
f"недостающие={sorted(EXPECTED_SERVICES - services)}"
)
def test_bundle_has_no_staging_and_no_profiles():
"""D1: staging-контур орка в bundle отсутствует ВОВСЕ (ни сервисом, ни
профилем); дефолтный `up -d` поднимает весь комплект."""
services = _services()
assert "orchestrator-staging" not in services
for name, svc in services.items():
assert not svc.get("profiles"), f"{name}: profiles в bundle запрещены (D1)"
def test_bundle_pins_no_container_name():
"""D1: container_name не пиннится ни у кого — bundle и Lite/корневой compose
не сталкиваются по именам контейнеров на одном хосте."""
for name, svc in _services().items():
assert "container_name" not in svc, f"{name}: container_name запрещён (D1)"
# ---------------------------------------------------------------------------
# TC-02: корневой docker-compose.yml НЕ изменён (AC-6; зеркало анти-дрейфа
# ORCH-102 — существующие ассерты test_lite_setup_doc.py не ослаблены).
# ---------------------------------------------------------------------------
def test_root_compose_is_untouched_lite_set():
services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["services"]
assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}, (
"корневой docker-compose.yml изменён — bundle обязан жить отдельным файлом (AC-6)"
)
def test_root_compose_has_no_plane_or_gitea_components():
services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["services"]
for name, svc in services.items():
blob = " ".join(
[name, str(svc.get("image", "")), str(svc.get("container_name", ""))]
).lower()
for needle in ("plane", "gitea"):
assert needle not in blob, (
f"корневой compose: появился {needle}-компонент в {name} (AC-6)"
)
# ---------------------------------------------------------------------------
# TC-03: пиннинг образов — неподвижный тег литералом (NFR-6 / D3).
# ---------------------------------------------------------------------------
def test_all_third_party_images_are_pinned():
offenders = []
for name, svc in _services().items():
image = svc.get("image")
if image is None:
continue
if "${" in image:
offenders.append(f"{name}: версия через интерполяцию ({image!r}) — тег литералом (D3)")
elif ":" not in image:
offenders.append(f"{name}: образ без тега ({image!r})")
elif image.rsplit(":", 1)[1] in ("latest", "stable"):
offenders.append(f"{name}: плавающий тег ({image!r})")
assert not offenders, "непиннованные образы bundle (NFR-6):\n" + "\n".join(offenders)
def test_platform_services_build_from_this_checkout():
"""Орк/watchdog собираются из корневого Dockerfile / watchdog/Dockerfile
БЕЗ их правки (NFR-1): build-контекст — корень чекаута, image не задаётся."""
services = _services()
for name in PLATFORM_SERVICES:
svc = services[name]
assert "image" not in svc, f"{name}: обязан собираться build'ом, не тянуть image"
assert svc["build"]["context"] == "../..", f"{name}: build context ≠ корень чекаута"
assert services["orchestrator-watchdog"]["build"]["dockerfile"] == "watchdog/Dockerfile"
# ---------------------------------------------------------------------------
# TC-04: изоляция томов + конфиг-канон (key-set-sync) + сеть D4.
# ---------------------------------------------------------------------------
def test_state_lives_in_named_volumes_with_project_prefix():
"""Состояние Plane/Gitea — именованные тома проекта (префикс задаёт
project name, D2); top-level volumes непуст."""
volumes = _doc().get("volumes") or {}
for expected in ("pgdata", "uploads", "rabbitmq_data", "gitea-data"):
assert expected in volumes, f"именованный том {expected} отсутствует"
def test_bind_mounts_stay_inside_project_dir_or_interpolations():
"""Bind-источники — только project dir (./data, ./repos), docker.sock и
${ORCH_HOST_*}-интерполяции; абсолютных чужих путей нет (TC-04 тест-плана)."""
offenders = []
for name, svc in _services().items():
for vol in svc.get("volumes") or []:
v = str(vol)
if (
v.startswith("${")
or v.startswith("./")
or v.startswith("~")
or v.startswith("/var/run/docker.sock")
or re.match(r"^[A-Za-z0-9_-]+:", v)
):
continue
offenders.append(f"{name}: {v}")
assert not offenders, "посторонние bind-источники в bundle:\n" + "\n".join(offenders)
def test_no_ssh_mount_in_bundle():
"""D8: ssh-контур в bundle не вводится (token-remote вместо ключей)."""
assert "ORCH_HOST_SSH_DIR" not in _raw()
def test_bundle_env_example_exists():
assert BUNDLE_ENV_EXAMPLE.is_file(), "deploy/bundled/.env.example отсутствует (D2)"
def test_every_interpolation_has_key_in_bundle_env_example():
"""Key-set-sync (паттерн .env.watchdog.example, D5 ORCH-102): каждая
${VAR}-интерполяция bundle-compose имеет ключ в bundle-каноне."""
canon = _env_keys(BUNDLE_ENV_EXAMPLE)
# Судим КОНФИГ, не комментарии: строки `# ...` (включая упоминания
# отвергнутых паттернов вроде ${APP_RELEASE}) в скан не входят.
config_only = "\n".join(
line for line in _raw().splitlines() if not line.strip().startswith("#")
)
mentioned = set(_INTERP_RE.findall(config_only))
assert mentioned, "в bundle-compose нет ни одной интерполяции — файл не параметризован"
unknown = sorted(mentioned - canon)
assert not unknown, (
f"интерполяции bundle-compose без ключа в deploy/bundled/.env.example "
f"(key-set-sync, TC-04): {unknown}"
)
def test_bundle_secrets_in_example_are_empty_placeholders():
"""FR-3: ни одного дефолтного пароля в гите — секрет-ключи канона пусты."""
values = {}
for line in BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
values[k.strip()] = v.strip()
for key in ("POSTGRES_PASSWORD", "SECRET_KEY", "RABBITMQ_DEFAULT_PASS",
"MINIO_ROOT_PASSWORD", "GITEA_ADMIN_PASSWORD"):
assert values.get(key, "") == "", f"{key} обязан быть пустым плейсхолдером"
def test_no_network_mode_host_anywhere():
"""D4: вся инсталляция в bridge-сети; network_mode: host не используется."""
for name, svc in _services().items():
assert "network_mode" not in svc, f"{name}: network_mode запрещён в bundle (D4)"
networks = _doc().get("networks") or {}
assert networks.get("default", {}).get("driver") == "bridge"
def test_only_human_ports_are_published():
"""D4: наружу — только орк/Plane proxy/Gitea web; БД/брокер/minio не
публикуются (секрет-гигиена/поверхность атаки)."""
publishers = {name for name, svc in _services().items() if svc.get("ports")}
assert publishers == {"orchestrator", "gitea", "proxy"}, (
f"порты публикуют {sorted(publishers)}, а разрешены только "
"orchestrator/gitea/proxy (D4)"
)
def test_gitea_webhook_allowed_host_list_is_set():
"""Мина TR-4: без ALLOWED_HOST_LIST Gitea молча режет вебхуки в приватные
адреса — «задача не появилась» гарантирован."""
env = _services()["gitea"].get("environment") or []
assert any("GITEA__webhook__ALLOWED_HOST_LIST=orchestrator" in str(e) for e in env), (
"gitea: GITEA__webhook__ALLOWED_HOST_LIST=orchestrator обязателен (D4/TR-4)"
)
def test_platform_env_files_are_optional():
"""D2: env_file required:false — первый `up -d` поднимает стек ДО сборки
runtime-конфига орка (AC-1 «одна команда»)."""
services = _services()
for name in PLATFORM_SERVICES:
entries = services[name].get("env_file")
assert isinstance(entries, list) and entries, f"{name}: env_file отсутствует"
assert all(e.get("required") is False for e in entries), (
f"{name}: env_file обязан быть required: false (D2)"
)
def test_machine_traffic_uses_service_dns():
"""D4: машинный трафик — строго сервис-DNS bundle-сети."""
raw = _raw()
assert "http://orchestrator:8500/metrics" in raw # watchdog → орк
assert "plane-db" in raw and "plane-redis" in raw and "plane-mq" in raw