fix(infra): run orchestrator containers as host uid 1000:1000 (not root)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s

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>
This commit is contained in:
2026-06-06 15:02:33 +00:00
parent fe5eb38af2
commit f81715bd39
4 changed files with 146 additions and 3 deletions

View File

@@ -25,6 +25,7 @@
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **Контейнер и агенты бегут под uid хоста (1000:1000), не root** (ORCH-040): оба сервиса в `docker-compose.yml` (`orchestrator`, `orchestrator-staging`) получили `user: "1000:1000"` (slin) — устраняет корень проблемы, при которой Claude-CLI агенты, запускаемые через `subprocess.Popen` внутри root-контейнера, создавали все артефакты конвейера (git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) с владельцем `root:root` на хосте, из-за чего `git pull`/`git reset` под slin падали с `insufficient permission for adding an object` и каждый деплой требовал ручного `chown`. Теперь файлы сразу `slin:slin`. Доступ к docker.sock сохранён через `group_add: ["999"]` (МИНА 1 — НЕ удалена). SSH-маунт приведён к единому HOME агента: target `/root/.ssh``/home/slin/.ssh` (`/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`), синхронно с `HOME=/home/slin`, который launcher форсит в env Popen и git_env — устранён скрытый рассинхрон SSH-маунта с форсимым HOME. `src/agents/launcher.py` и `Dockerfile` НЕ менялись (numeric uid работает без записи в `/etc/passwd`; `safe.directory '*'` уже покрывает git над bind-mount). Требует host-prerequisites Owner (P-1…P-4, вне кода): блокер P-1 — `chown -R 1000:1000 /home/slin/.claude` для доступа uid 1000 к claude creds (иначе preflight заворачивает конвейер); прод-рестарт self — только в окно тишины (общий инстанс с enduro-trails), страховка — staging-гейт (adr-0003). ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`, глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`; INFRA.md обновлён (рантайм-uid, volumes/SSH target, host-prerequisites). Тесты: `tests/test_orch040_compose.py`.
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md``result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.

View File

@@ -3,6 +3,11 @@ services:
build: .
container_name: orchestrator
restart: unless-stopped
# ORCH-040: бежим под uid:gid хоста (slin=1000:1000), а не root, чтобы
# артефакты конвейера (worktree + docs) создавались как slin:slin и git на
# хосте работал без ручного chown. Доступ к docker.sock сохранён через
# group_add: ["999"] (МИНА 1 — НЕ удалять). См. ADR-001 ORCH-040.
user: "1000:1000"
# init: true injects docker-init (tini) as PID 1 so reparented grandchild
# processes from the claude/node subprocess tree are reaped (no zombies, B-2).
init: true
@@ -15,7 +20,8 @@ services:
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
env_file: .env
environment:
- ORCH_REPOS_DIR=/repos
@@ -35,6 +41,8 @@ services:
build: .
container_name: orchestrator-staging
restart: unless-stopped
# ORCH-040: тот же uid хоста, что и у prod (см. комментарий выше / ADR-001).
user: "1000:1000"
init: true
network_mode: host
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
@@ -46,7 +54,8 @@ services:
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
env_file: .env.staging
environment:
- ORCH_REPOS_DIR=/repos

View File

@@ -30,12 +30,33 @@
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
### Рантайм-uid (ORCH-040)
Оба сервиса бегут под `user: "1000:1000"` (slin), **не** root. Артефакты конвейера
(git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) создаются как
`slin:slin`, поэтому `git pull` / `git reset` на хосте под slin работают без ручного
`chown`. Доступ к docker.sock сохранён через `group_add: ["999"]` (gid docker, **не**
через root — НЕ удалять). При переносе на другой хост uid пересматривается. См.
ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и глобальный
`docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`.
**Host-prerequisites (обязательная процедура Owner, в git не коммитятся):**
- **P-1 (блокер):** uid 1000 читает claude creds — `chown -R 1000:1000 /home/slin/.claude`;
проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json`. Без этого
preflight (ORCH-044) заворачивает весь конвейер.
- **P-2:** ssh-ключи в `/home/slin/.orchestrator-ssh` читаемы uid 1000 (маунт ведёт в `/home/slin/.ssh`).
- **P-3:** `id slin``1000:1000`; `/repos`, `/app/data` уже `1000:1000`.
- **P-4:** прод-рестарт self — только в окно тишины (`GET /status` без активных задач):
общий инстанс с enduro-trails.
- Разовый разгребающий `chown -R 1000:1000 /home/slin/repos/orchestrator` для старых
`root:root` файлов из истории (вне объёма кода).
### Тома (volumes)
- `./data``/app/data` (БД; у staging — `./data/staging`)
- `/home/slin/repos``/repos` (рабочие репозитории проектов)
- `/var/run/docker.sock` (для docker-операций деплоя)
- claude-code, node, `~/.claude*` (CLI агентов, ro)
- `~/.orchestrator-ssh``/root/.ssh` (ro, деплой по ssh)
- `~/.orchestrator-ssh``/home/slin/.ssh` (ro, деплой по ssh; target в HOME агента,
согласован с `HOME=/home/slin` из launcher — ORCH-040, ранее `/root/.ssh`)
## Переменные окружения (карта; значения — в `.env`)

View File

@@ -0,0 +1,112 @@
"""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}"
)