"""ORCH-040: контейнер/агенты бегут под uid:gid хоста (1000:1000), не root. Валидируют docker-compose.yml (Вариант 1 из ADR-001) и согласованность с HOME, который форсит launcher. Чистые конфиг-тесты: парсят YAML и текст launcher, без запуска docker/агентов. См. 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. """ 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 агента (форсится launcher'ом); под ним должны лежать .ssh/.claude. EXPECTED_HOME = "/home/slin" @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) для сервиса.""" for vol in service.get("volumes", []): # формат "src:target[:mode]" parts = 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 или строку; нормализуем к строке. assert str(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 = {str(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/slin. """ source = LAUNCHER_PATH.read_text(encoding="utf-8") # launcher форсит HOME в двух местах (env Popen и git_env). occurrences = source.count(f'"HOME": "{EXPECTED_HOME}"') assert occurrences >= 2, ( f"launcher.py: ожидалось >=2 форсинга HOME={EXPECTED_HOME!r}, " f"найдено {occurrences}" ) # И 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}" )