diff --git a/.env.example b/.env.example index e398bea..0a79306 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,16 @@ ORCH_RECONCILE_INTERVAL_S=120 ORCH_RECONCILE_GRACE_DEFAULT_S=600 ORCH_RECONCILE_GRACE_OVERRIDES_JSON= ORCH_RECONCILE_NOTIFY_UNBLOCK=true + +# ORCH-058: staging-image provenance before the BUILD-ONCE retag to prod. Closes the +# "silent stale promote" bug (LESSONS_ORCH-036 §4): retag promoted the staging image +# to prod without proving it was built from the validated commit. Two layers (A+B), +# self-hosting only, gated as a WHOLE by a single switch (no "B without A" deadlock): +# A (liveness) -> QG sub-check check_staging_image_fresh rebuilds the staging image +# from the validated commit on the deploy-staging->deploy edge (after merge-gate). +# B (safety) -> deploy-hook fail-closes (exit 1) before `docker tag` if SOURCE_IMAGE +# OCI revision label != EXPECTED_REVISION (the validated SHA). +# ENABLED -> single kill-switch for the WHOLE feature; false -> legacy build-once. +# REPOS -> CSV of repos where the feature is REAL; empty -> only self-hosting. +ORCH_IMAGE_FRESHNESS_ENABLED=true +ORCH_IMAGE_FRESHNESS_REPOS= diff --git a/CHANGELOG.md b/CHANGELOG.md index 713a5bb..4520589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Провенанс staging-образа перед BUILD-ONCE retag в прод (защита от тихого регресса)** (ORCH-058): закрывает самый опасный баг self-deploy (LESSONS_ORCH-036 §4) — BUILD-ONCE retag (ORCH-36) промоутил `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод **без rebuild**, полагаясь на «staging-образ свеж», но конвейер нигде не пересобирал его из провалидированного коммита → деплой мог «зелёным» (health-ok) тихо откатить прод на устаревший код. Обеспечивается инвариант `INV-FRESH` (образ, уезжающий в прод, доказуемо собран из коммита, провалидированного `deploy-staging`) **двумя слоями (defense in depth)**, только для self-hosting, с ЕДИНЫМ якорем `validated_revision` (= `git rev-parse HEAD` worktree ПОСЛЕ merge-gate rebase), питающим оба слоя — они не могут разойтись. Новый leaf-модуль `src/image_freshness.py` (контракт «never-raise», fail-closed на любом сомнении): `validated_revision` / `expected_revision` (якорь и проброс ожидаемого SHA), `provenance_verdict` (чистая verdict-логика — Python-зеркало bash-сравнения хука, unit-тестируемая), `image_revision` (читает OCI-лейбл через `docker image inspect`, локально или по ssh), `rebuild_staging_image` (пересборка через хост-хук), `check_staging_image_fresh` (под-чек). **Слой A — liveness (пересборка):** детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS` через тонкую обёртку `_check_staging_image_fresh` в `src/qg/checks.py`; добавлен в snapshot-тест реестра) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A пересобирает `orchestrator-orchestrator-staging` из провалидированного коммита (хост-хук в режиме `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт контейнер 8501 и прогоняет health → валидируем и промоутим ОДИН артефакт (loop closed, AC-4). FAIL → откат на `development` (как merge-gate, БАГ-8); вызов через `_handle_image_freshness` в `src/stage_engine.py`. **Слой B — safety (fail-closed guard):** `src/self_deploy.py::build_deploy_command` пробрасывает `EXPECTED_REVISION` = `image_freshness.expected_revision(...)`; хост-хук `scripts/orchestrator-deploy-hook.sh` шагом ПЕРЕД `docker tag` сверяет лейбл `org.opencontainers.image.revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` (нормализует ``): несовпадение / пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `log` + `exit 1` → `map_exit_code_to_status` → FAILED → БАГ-8 откат, прод НЕ трогается. Делает тихий промоут устаревшего образа структурно невозможным даже если A отключён/проиграл гонку. `Dockerfile`: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA` (машиночитаемое происхождение образа). Единый kill-switch `image_freshness_enabled` включает A+B **как целое** (нет «B без A» = вечного fail-fast, BR-5/R-1); `image_freshness_repos` (CSV, пусто → только self-hosting `orchestrator`, как ORCH-35) — поэтапный раскат. Self-hosting safety: все сборки/recreate — ТОЛЬКО staging 8501, НИКОГДА прод 8500; `main` не пушится; при незаданном `EXPECTED_REVISION` хук сохраняет прежнее поведение (обратная совместимость для не-self репо). Контракты НЕ менялись: `STAGE_TRANSITIONS`, exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, merge-gate (ORCH-43), БАГ-8; схема БД без миграций (под-гейт ребра + лейбл образа). Новые настройки: `ORCH_IMAGE_FRESHNESS_ENABLED` (true), `ORCH_IMAGE_FRESHNESS_REPOS` (""). ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_stage_engine.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_hook_mapping.py`, `tests/test_config.py`. - **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`/.deploy-state-//`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`. - **Sweeper потерянных webhook (реконсиляция застрявших стадий)** (ORCH-053): фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`), который устраняет тихое застревание задач, когда конвейер не двигается из-за потерянного события (502 на ребилде инстанса, отсутствие ретраев у Plane/Gitea, неразрезолвленный `sha→branch` — класс инцидента ORCH-044). Реконсилятор периодически (`reconcile_interval_s`) доигрывает пропущенный переход **через те же штатные гейты/обработчики**, что и webhook, не дублируя логику конвейера: **F-1 gate-side** (`reconcile_gate_once`) — для задач `stage≠done`, без активного job и `age(updated_at) ≥ grace_for_stage(stage)` делает read-only пред-оценку канонического QG стадии; зелёный → продвижение строго через неизменный `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен — `advance_stage` на красном гейте не вызывается вовсе); `analysis` F-1 не трогает (человеческий гейт). **F-2 plane-side** (`reconcile_plane_once`) — опрос Plane API per-project (новый `plane_sync.list_issues_by_state`, курсорная пагинация, never-raise) и реплей In Progress / Approved / Rejected через существующие `webhooks.plane.handle_status_start` / `handle_verdict` (async-обработчики вызываются из sync-потока через `asyncio.run`). **F-3** — усиление `sha→branch` в `handle_ci_status`: при неразрезолвленном sha — БД-fallback по единственной development-задаче repo (`db.get_development_tasks_by_repo`; неоднозначность → не резолвим, ложного матча нет), `logger.debug`→`logger.info` для видимости потерянного CI-события. Анти-дубль на создании задачи (`db.create_task_atomic` под process-wide `threading.Lock`: SELECT-exists→INSERT, проигравший в гонке reconcile↔webhook не плодит второй task/branch/worktree/стартовый analyst-job). Старт/стоп в `main.lifespan` (после `worker.start()` / перед `worker.stop()`), restart-safe, never-raise на единицу работы. Наблюдаемость (F-4): при разблокировке — лог-строка `reconciler: разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`) и блок `reconcile` в `GET /queue`. Kill-switches: `ORCH_RECONCILE_ENABLED` (глобально), `ORCH_RECONCILE_PLANE_ENABLED` (гасит только F-2), `ORCH_RECONCILE_INTERVAL_S` (120), `ORCH_RECONCILE_GRACE_DEFAULT_S` (600), `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` (per-stage), `ORCH_RECONCILE_NOTIFY_UNBLOCK` (true). Схема БД и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) НЕ менялись. ADR `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`, глобальный `docs/architecture/adr/adr-0007-reconciler.md`. Тесты: `tests/test_reconciler.py`, `tests/test_reconciler_plane.py`, `tests/test_gitea_sha_resolve.py`, `tests/test_config.py`. - **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest ` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index da47217..8b18a63 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -196,4 +196,4 @@ never-raise на единицу работы; тишина при синхрон --- *Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-036: исполняемый самодеплой стадии `deploy` — design (см. adr-0007), реализация в ветке feature/ORCH-036.* *Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-053: reconciler — реализовано (см. adr-0007, src/reconciler.py).* -*ORCH-058: провенанс staging-образа перед BUILD-ONCE retag (check_staging_image_fresh + хук-guard) — design (см. adr-0008), реализация в ветке feature/ORCH-058. Обновлять также при изменении src/self_deploy.py, scripts/orchestrator-deploy-hook.sh, Dockerfile.* +*ORCH-058: провенанс staging-образа перед BUILD-ONCE retag (check_staging_image_fresh + хук-guard) — реализовано в ветке feature/ORCH-058 (см. adr-0008, src/image_freshness.py). Обновлять также при изменении src/self_deploy.py, scripts/orchestrator-deploy-hook.sh, Dockerfile.*