developer(ET): auto-commit from developer run_id=627
This commit is contained in:
264
tests/test_bundle_compose.py
Normal file
264
tests/test_bundle_compose.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""ORCH-103 (TC-01…TC-04, AC-1/AC-6/AC-9): анти-дрейф bundle-compose Bundled-тиража.
|
||||
|
||||
Структурные проверки `deploy/bundled/docker-compose.yml` (ADR-001 D1–D4) и его
|
||||
конфиг-канона `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
|
||||
Reference in New Issue
Block a user