From f81715bd39a847c3f368d178887dace67a7dc47f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 6 Jun 2026 15:02:33 +0000 Subject: [PATCH] fix(infra): run orchestrator containers as host uid 1000:1000 (not root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + docker-compose.yml | 13 +++- docs/operations/INFRA.md | 23 ++++++- tests/test_orch040_compose.py | 112 ++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/test_orch040_compose.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cea7f17..2140968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/docker-compose.yml b/docker-compose.yml index 21ab1c7..071bc4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index 90bd8e0..38901b5 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -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`) diff --git a/tests/test_orch040_compose.py b/tests/test_orch040_compose.py new file mode 100644 index 0000000..914df03 --- /dev/null +++ b/tests/test_orch040_compose.py @@ -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}" + )