fix(infra): run orchestrator containers as host uid 1000:1000 (not root)
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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
112
tests/test_orch040_compose.py
Normal file
112
tests/test_orch040_compose.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user