From 01684a89df88ea3e307e0f0bdf7402a4e8b9aba3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 13:39:02 +0000 Subject: [PATCH] fix(docker): drop COPY of gitignored data/ so staging image builds from a worktree The staging-image rebuild (check_staging_image_fresh, ORCH-058) uses the task git worktree as the docker build context. `data/` is gitignored (runtime SQLite DB + backups) so it is absent in every worktree -> `COPY data/ ./data/` failed the build (rc=1) -> deploy-staging rolled back to development (the loop ORCH-061 targets, surfaced one step later once the C9a/C9b waiver let the pipeline reach the rebuild). The DB always arrives via the compose bind mount, so baking it in was pointless (and leaked a stale host DB into the image). Replace `COPY data/ ./data/` with `RUN mkdir -p /app/data` and add a static regression guard asserting the Dockerfile never COPYs a gitignored path. Refs: ORCH-061 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + Dockerfile | 11 ++- docs/architecture/README.md | 8 ++ docs/work-items/ORCH-061/10-tech-risks.md | 1 + tests/test_dockerfile_worktree_buildable.py | 90 +++++++++++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/test_dockerfile_worktree_buildable.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 655c084..1af4c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). ### Fixed +- **Staging-образ снова собирается из git-воркти (петля `deploy-staging → development` на `docker build` rc=1)** (ORCH-061): после устранения ложного infra-FAIL (C9a/C9b) конвейер впервые дошёл до пересборки staging-образа (`check_staging_image_fresh`, ORCH-058) и упал на следующем шаге той же петли: `Dockerfile` содержал `COPY data/ ./data/`, но `data/` **в `.gitignore`** (рантайм-БД SQLite + бэкапы) → отсутствует в КАЖДОМ git-воркти. Staging-rebuild (`hook --build-staging`) использует **воркти задачи** как build-context, где `data/` нет → `docker build` падает с rc=1 (`BUILD-STAGING: docker build failed`) → откат `deploy-staging → development` → петля. Прод-сборка из основного чекаута (`/repos/orchestrator`, где `data/` существует как рантайм-каталог) маскировала дефект и заодно бесполезно «запекала» хостовую БД (~100 МБ бэкапов, утечка устаревшего состояния) в образ — рантайм всё равно перекрывает её bind-mount'ом compose (`./data:/app/data` прод, `./data/staging:/app/data` staging). Фикс: `COPY data/ ./data/` заменён на `RUN mkdir -p /app/data` — цель монтирования существует в образе, сборка не зависит от gitignore-каталога, SQLite создаёт `.db` сам. Контракты не тронуты: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_staging_image_fresh`/`check_staging_status`, OCI-лейбл `org.opencontainers.image.revision` (ORCH-058), exit-code хука; схема БД и compose-тома — без изменений. Регрессия-гард (статически, без docker): `tests/test_dockerfile_worktree_buildable.py` — `Dockerfile` не должен `COPY` ни одного gitignore-каталога (иначе сборка из воркти снова сломается). - **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development` — `scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`. - **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053). - **Re-deploy после отката больше не зависает на `deploy`; `.env.example` дополнен** (ORCH-036, review-fix): sentinel-маркеры самодеплоя (`approve-requested`/`initiated`/`result`) ключуются по стабильному `work_item_id`, поэтому при FAILED-деплое и откате БАГ-8 (`deploy → development`) они оставались на диске — после фикса developer-ом и повторного захода задачи на `deploy` Фаза B по idempotency-guard видела STALE `initiated` и становилась no-op: detached-хук не перезапускался, finalizer не ставился, задача висела на `deploy` навсегда (нарушался retry-контракт стадии, AC-4/AC-10; устаревший `result` к тому же был бы перечитан новым finalizer'ом). Добавлен `self_deploy.clear_state(repo, work_item_id)` (never-raise, idempotent, рекурсивное удаление `/.deploy-state-//`), вызывается в ветке БАГ-8-отката `check_deploy_status` FAILED (`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`) — каждый новый прод-деплой-проход стартует с чистого состояния. Отдельно: канонический `.env.example` (CLAUDE.md правило №8, ТЗ §2.6) дополнен полным блоком новых дескрипторов `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*` (плейсхолдеры, секреты не коммитятся) по образцу merge-gate ORCH-043. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` / `_parse_deploy_status` / БАГ-8 / merge-gate не тронуты. Тесты: `tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`, `tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`. diff --git a/Dockerfile b/Dockerfile index b8b34dc..c61c5d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,15 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ -COPY data/ ./data/ +# ORCH-061: do NOT `COPY data/ ./data/`. `data/` is gitignored (runtime SQLite DB +# + backups), so it is ABSENT in every git worktree. The staging-image rebuild of +# ORCH-058 (`check_staging_image_fresh` / hook `--build-staging`) uses the task +# WORKTREE as the build context, where `data/` does not exist -> `COPY data/` +# fails the build (rc=1) -> deploy-staging rolls back to development (the loop this +# task fixes). It is also pointless: the DB always arrives via the compose bind +# mount (`./data:/app/data` prod, `./data/staging:/app/data` staging), which +# overrides anything baked in (and baking the host DB into the image leaks stale +# state). Just ensure the mount target exists; sqlite creates the .db file. +RUN mkdir -p /app/data ENV PYTHONPATH=/app CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"] diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c8b2de5..0124ad9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -118,6 +118,14 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md), детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. +**Инвариант build-context (ORCH-061):** staging-rebuild собирает образ из **git-воркти** +задачи, а воркти содержит только git-tracked файлы. Поэтому `Dockerfile` НЕ должен +`COPY` ни одного gitignore-пути — иначе `docker build` падает (rc=1) и `deploy-staging` +зацикливается на откате в `development`. В частности `data/` (рантайм-БД + бэкапы) +gitignore'нут и приходит исключительно через compose bind-mount (`./data:/app/data`), +поэтому образ лишь создаёт каталог монтирования (`RUN mkdir -p /app/data`), а не копирует +его. Гард — `tests/test_dockerfile_worktree_buildable.py`. + ### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано) Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча diff --git a/docs/work-items/ORCH-061/10-tech-risks.md b/docs/work-items/ORCH-061/10-tech-risks.md index ae78c5e..3095d47 100644 --- a/docs/work-items/ORCH-061/10-tech-risks.md +++ b/docs/work-items/ORCH-061/10-tech-risks.md @@ -12,6 +12,7 @@ Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting) | **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). | | **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. | | **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). | +| **R-9** (realized) | Та же петля `deploy-staging → development` по ВТОРОЙ причине: `docker build` staging-образа падает (rc=1), т.к. `Dockerfile` `COPY data/ ./data/` ссылается на gitignore-каталог, отсутствующий в build-context воркти. Всплыло, когда waiver C9a/C9b впервые пропустил конвейер до пересборки образа (`check_staging_image_fresh`, ORCH-058). | — (произошло) | Высокое | `COPY data/ ./data/` → `RUN mkdir -p /app/data`. `data/` приходит через compose bind-mount, в образ запекать нечего. Инвариант: `Dockerfile` не `COPY` gitignore-путей (иначе сборка из воркти ломается). Гард — `tests/test_dockerfile_worktree_buildable.py`. | ## Контрактные инварианты (не нарушать) - `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений. diff --git a/tests/test_dockerfile_worktree_buildable.py b/tests/test_dockerfile_worktree_buildable.py new file mode 100644 index 0000000..94d4ea4 --- /dev/null +++ b/tests/test_dockerfile_worktree_buildable.py @@ -0,0 +1,90 @@ +"""ORCH-061 regression: the image must build from a git WORKTREE context. + +The staging-image rebuild of ORCH-058 (``check_staging_image_fresh`` / the deploy +hook's ``--build-staging`` mode) uses the task **worktree** as the ``docker build`` +context. A git worktree only contains git-TRACKED files, so any ``COPY`` of a +gitignored path makes ``docker build`` fail (rc=1) -> ``deploy-staging`` rolls back +to ``development`` (the exact loop ORCH-061 fixes). + +The concrete regression: ``COPY data/ ./data/`` referenced ``data/`` which is +gitignored (runtime SQLite DB + backups) and therefore absent in every worktree. +At runtime ``data/`` always arrives via the compose bind mount +(``./data:/app/data`` / ``./data/staging:/app/data``), so baking it in was both +build-breaking and pointless. + +These tests guard the invariant statically (no docker required): the Dockerfile +must not ``COPY`` a path that ``.gitignore`` excludes. +""" + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOCKERFILE = REPO_ROOT / "Dockerfile" +GITIGNORE = REPO_ROOT / ".gitignore" + + +def _dockerfile_copy_sources() -> list[str]: + """Source paths from every ``COPY `` line in the Dockerfile. + + ``--from`` (multi-stage / build-context) COPYs are skipped — they do not read + the worktree build context. The last token on a COPY line is the destination. + """ + sources: list[str] = [] + for raw in DOCKERFILE.read_text().splitlines(): + line = raw.strip() + if not line.upper().startswith("COPY "): + continue + if "--from" in line: + continue + tokens = line.split()[1:] # drop the COPY keyword + tokens = [t for t in tokens if not t.startswith("--")] + if len(tokens) >= 2: + sources.extend(tokens[:-1]) # all but the destination + return sources + + +def _gitignored_dirs() -> set[str]: + """Top-level directory names excluded by ``.gitignore`` (e.g. ``data``).""" + dirs: set[str] = set() + for raw in GITIGNORE.read_text().splitlines(): + entry = raw.strip() + if not entry or entry.startswith("#"): + continue + entry = entry.rstrip("/") + # only care about simple top-level dir patterns (no globs / nested paths) + if entry and "/" not in entry and "*" not in entry: + dirs.add(entry) + return dirs + + +def test_dockerfile_does_not_copy_gitignored_data(): + """``data/`` (gitignored runtime dir) must never be a Dockerfile COPY source.""" + copy_sources = _dockerfile_copy_sources() + offending = [s for s in copy_sources if s.rstrip("/") == "data"] + assert not offending, ( + "Dockerfile COPYs gitignored 'data/' -> build fails from a worktree " + f"context (rc=1). Offending COPY sources: {offending}. " + "Use `RUN mkdir -p /app/data` and rely on the compose bind mount instead." + ) + + +def test_dockerfile_copies_only_git_tracked_sources(): + """No Dockerfile COPY source may be a gitignored top-level directory.""" + gitignored = _gitignored_dirs() + copy_sources = [s.rstrip("/") for s in _dockerfile_copy_sources()] + leaking = sorted(set(copy_sources) & gitignored) + assert not leaking, ( + "Dockerfile COPYs gitignored path(s) absent from git worktrees: " + f"{leaking}. The staging rebuild (ORCH-058) builds from the worktree and " + "will fail (rc=1)." + ) + + +def test_data_dir_mount_target_is_created(): + """The image must create the /app/data mount target (no COPY dependency).""" + text = DOCKERFILE.read_text() + assert re.search(r"mkdir\s+-p\s+/app/data", text), ( + "Dockerfile must `RUN mkdir -p /app/data` so the compose bind-mount " + "target exists without depending on a (gitignored) host data/ dir." + )