Files
orchestrator/tests/test_orch040_compose.py
claude-bot f1635ddb39
All checks were successful
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 55s
feat(replication): расхардкод хоста + секреты нового хоста + smoke-runbook
Фундамент тиража 10-common (эпик ORCH-10): платформа разворачивается на
новой инфре без правки кода — только env/конфиг. Каждый дефолт = боевому
значению (пустой .env => поведение 1:1, kill-switch-природа, NFR-2);
STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/схема БД не тронуты.

- config: agent_home_dir / agent_git_name / git_email_domain / staging_port
  (ADR-001 D2/D4); код-блокеры A1-A4 закрыты: plane_sync ссылки из
  gitea_public_url+gitea_owner, launcher - единый agent_git_env() (x2 места),
  self_deploy/post_deploy - HOME+домен из Settings (имена системных акторов -
  платформенные литералы)
- image_freshness: staging_port из конфига + fail-closed guard
  staging_port == прод-порт -> отказ ДО ssh/build (инвариант ORCH-058 AC-9
  стал исполняемым); REPO= передаётся хуку явно обоими инвокерами (D7)
- SELF_HOSTING_REPO - нормативная платформенная константа (D3, пин-тест)
- compose: полная ${VAR:-default}-интерполяция (реестр B, карта D6); группа
  ORCH-040 uid/gid/HOME/маунты двигается согласованно (build.args APP_*);
  group_add "МИНА 1" сохранён x3; оба app-сервиса с явным command:
- Dockerfile: ARG APP_UID/APP_GID/APP_USER/APP_HOME (CMD exec-form 8500
  сознательно не тронут - D5); deploy-hook: REPO="${REPO:-...}" (D1 реестра)
- секреты: stdlib scripts/gen_secrets.py (token_hex(32); печать по умолчанию;
  --write никогда не перезаписывает существующий .env молча, exit=2;
  перезапись только --force); .env.example дополнен до полноты ключей старта
- доки: новый docs/operations/REPLICATION.md (карта env, чек-лист секретов,
  smoke-процедура с PASS/FAIL, границы 10-common/Lite/Bundled), INFRA.md,
  README, CLAUDE.md, CHANGELOG
- анти-регресс: tests/test_no_host_hardcodes.py (tokenize-сканер запрещённых
  литералов, config-модули - структурное исключение, allowlist пуст,
  негативная самопроверка) + test_host_config_keys / test_infra_parametrization
  / test_secrets_gen / test_replication_smoke; согласованные структурные
  правки test_orch040_compose (судит резолв дефолтов) и
  test_deploy_hook_rollback_sim (REPO через env-override = контракт D7)

Полный регресс: 1764 passed.

Refs: ORCH-101

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:50:43 +03:00

141 lines
7.2 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-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}"
)