Фундамент тиража 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>
141 lines
7.2 KiB
Python
141 lines
7.2 KiB
Python
"""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}"
|
||
)
|