Both compose services (orchestrator, orchestrator-staging) now declare user: "1000:1000" so pipeline artifacts (git worktree, docs/work-items commits) are created as slin:slin on the host — git pull/reset under slin no longer fail with permission errors. docker.sock access preserved via group_add: ["999"]. SSH mount target aligned with the launcher-forced HOME=/home/slin (/root/.ssh -> /home/slin/.ssh). launcher.py and Dockerfile unchanged. INFRA.md and CHANGELOG.md updated; host-prerequisites (P-1..P-4) documented. Refs: ORCH-040 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
113 lines
5.1 KiB
Python
113 lines
5.1 KiB
Python
"""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}"
|
||
)
|