"""ORCH-040: контейнер/агенты бегут под uid:gid хоста (1000:1000), не root. Валидируют docker-compose.yml (Вариант 1 из ADR-001) и согласованность с HOME, который форсит launcher. Чистые конфиг-тесты: парсят YAML и текст launcher, без запуска docker/агентов. ORCH-101 (согласованная структурная правка): compose параметризован `${VAR:-default}`-интерполяцией с дефолтами = боевым значениям, а HOME launcher'а читается из `settings.agent_home_dir` (тот же дефолт). Тесты судят РЕЗОЛВ при пустом окружении (эквивалент `docker compose config` без переменных) — сам инвариант ORCH-040 (uid хоста, group_add 999, SSH-маунт под HOME) не ослаблен: смена дефолта по-прежнему валит тест. См. docs/work-items/ORCH-040/{02-trz.md,03-acceptance-criteria.md, 04-test-plan.yaml} и 06-adr/ADR-001-run-agents-as-host-uid.md. """ import re from pathlib import Path import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[1] COMPOSE_PATH = REPO_ROOT / "docker-compose.yml" LAUNCHER_PATH = REPO_ROOT / "src" / "agents" / "launcher.py" # Сервисы, которые исполняют конвейер и обязаны бежать под uid хоста. PIPELINE_SERVICES = ("orchestrator", "orchestrator-staging") # Единый HOME агента (дефолт settings.agent_home_dir, ORCH-101); под ним # должны лежать .ssh/.claude. EXPECTED_HOME = "/home/slin" # ORCH-101: ${VAR:-default} -> default (поведение compose при пустом env). _INTERP_RE = re.compile(r"\$\{[A-Z0-9_]+:-([^}]*)\}") def _resolve(value) -> str: """Резолв compose-интерполяции при ПУСТОМ окружении (дефолты).""" return _INTERP_RE.sub(lambda m: m.group(1), str(value)) @pytest.fixture(scope="module") def compose() -> dict: """Распарсенный docker-compose.yml.""" with COMPOSE_PATH.open(encoding="utf-8") as fh: data = yaml.safe_load(fh) assert "services" in data, "docker-compose.yml без секции services" return data def _service(compose: dict, name: str) -> dict: services = compose["services"] assert name in services, f"сервис {name} отсутствует в docker-compose.yml" return services[name] def _ssh_mount_target(service: dict) -> str: """Target SSH-маунта (источник .orchestrator-ssh) для сервиса. ORCH-101: volume-строка резолвится из интерполяции ДО разбора src:target. """ for vol in service.get("volumes", []): # формат "src:target[:mode]" (после резолва дефолтов) parts = _resolve(vol).split(":") src = parts[0] if src.endswith(".orchestrator-ssh"): assert len(parts) >= 2, f"SSH-маунт без target: {vol}" return parts[1] raise AssertionError("SSH-маунт (.orchestrator-ssh) не найден в volumes") # --- TC-01: user: "1000:1000" в обоих сервисах --------------------------------- @pytest.mark.parametrize("name", PIPELINE_SERVICES) def test_tc01_service_runs_as_host_uid(compose, name): """TC-01/AC-1: сервис бежит под uid:gid хоста 1000:1000, а не root.""" service = _service(compose, name) assert "user" in service, f"{name}: отсутствует ключ user (нужен '1000:1000')" # docker допускает int или строку; нормализуем к строке. ORCH-101: судим # резолв дефолтов интерполяции (= docker compose config при пустом env). assert _resolve(service["user"]) == "1000:1000", ( f"{name}: user={service['user']!r}, резолв должен давать '1000:1000'" ) # --- TC-02: group_add сохраняет "999" (docker.sock — МИНА 1) -------------------- @pytest.mark.parametrize("name", PIPELINE_SERVICES) def test_tc02_group_add_keeps_docker_gid(compose, name): """TC-02/AC-4: group_add содержит 999 (доступ к docker.sock не потерян).""" service = _service(compose, name) group_add = service.get("group_add", []) normalized = {_resolve(g) for g in group_add} assert "999" in normalized, ( f"{name}: group_add={group_add!r}, резолв должен содержать '999' (docker.sock)" ) # --- TC-03: SSH-маунт согласован с HOME (под /home/slin, не /root) -------------- @pytest.mark.parametrize("name", PIPELINE_SERVICES) def test_tc03_ssh_mount_under_home(compose, name): """TC-03/AC-5: target SSH-маунта лежит в HOME агента (/home/slin/.ssh).""" service = _service(compose, name) target = _ssh_mount_target(service) assert target == f"{EXPECTED_HOME}/.ssh", ( f"{name}: SSH target={target!r}, ожидалось '{EXPECTED_HOME}/.ssh' " f"(не /root/.ssh — иначе рассинхрон с HOME агента)" ) assert not target.startswith("/root/"), ( f"{name}: SSH target указывает на чужой HOME (/root): {target}" ) # --- TC-04: HOME launcher'а совместим с SSH/claude-маунтами --------------------- def test_tc04_launcher_home_matches_mounts(compose): """TC-04: HOME, форсимый launcher'ом, совпадает с базой SSH/claude-маунтов. Нет рассинхрона HOME vs uid: и env Popen, и git_env, и target SSH-маунта все указывают на один HOME. ORCH-101: launcher читает HOME из `settings.agent_home_dir` через единый `agent_git_env()` (оба места — Popen агента и git commit/push), а маунты compose интерполируют ТОТ ЖЕ `ORCH_AGENT_HOME_DIR` — рассинхрон структурно невозможен; здесь судим, что дефолт ключа и резолв маунтов сходятся в /home/slin. """ from src.config import Settings # Дефолт ключа = канонический HOME (BR-5 ORCH-101 / AC-5 ORCH-040). assert Settings.model_fields["agent_home_dir"].default == EXPECTED_HOME source = LAUNCHER_PATH.read_text(encoding="utf-8") # Оба места запуска используют единый Settings-driven env-словарь. assert '"HOME": settings.agent_home_dir' in source assert source.count("agent_git_env()") >= 2, ( "launcher.py: env Popen и git_env должны строиться единым agent_git_env()" ) # И SSH-маунты обоих сервисов ведут в этот же HOME (резолв дефолтов). for name in PIPELINE_SERVICES: target = _ssh_mount_target(_service(compose, name)) assert target.startswith(f"{EXPECTED_HOME}/"), ( f"{name}: SSH target {target} не под HOME агента {EXPECTED_HOME}" )