diff --git a/.env.example b/.env.example index e398bea..a5f54b3 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,19 @@ ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator ORCH_DEPLOY_PROD_COMPOSE_PROFILE= ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod +# ORCH-058: staging-image provenance before the BUILD-ONCE prod retag (INV-FRESH). +# Guarantees the staging image promoted to prod is the EXACT artefact rebuilt from the +# validated commit — two layers, self-hosting only: +# A (liveness): QG sub-check `check_staging_image_fresh` on the deploy-staging->deploy +# edge rebuilds orchestrator-orchestrator-staging from the validated commit + recreates +# 8501; FAIL -> rollback to development. (builds/recreate STAGING only, never prod.) +# B (safety): the Dockerfile stamps `org.opencontainers.image.revision`; the prod hook +# fail-closes (exit 1) before `docker tag` if SOURCE_IMAGE's label != EXPECTED_REVISION. +# ENABLED -> single kill-switch for A+B as a WHOLE (never "B without A"); false -> legacy. +# REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting (orchestrator). +ORCH_IMAGE_FRESHNESS_ENABLED=true +ORCH_IMAGE_FRESHNESS_REPOS= + # ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon # replays a missed stage transition through the SAME gates/handlers a webhook would, # fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea diff --git a/CHANGELOG.md b/CHANGELOG.md index 713a5bb..a8830f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. 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`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.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/Dockerfile b/Dockerfile index 504e06a..b8b34dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,12 @@ FROM python:3.12-slim +# ORCH-058 (Strategy B): stamp the image with the git commit it was built from so +# the deploy hook can fail-close if a stale staging image would be promoted to prod +# (INV-FRESH). Passed at build time via `--build-arg GIT_SHA=` (the staging +# rebuild in check_staging_image_fresh / the --build-staging hook mode supplies it). +# Without the build-arg the label is empty -> the hook treats it as a mismatch +# (fail-closed). The OCI-standard key is read by `docker image inspect`. +ARG GIT_SHA="" +LABEL org.opencontainers.image.revision=$GIT_SHA WORKDIR /app RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/* # git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8e5e7e4..c20ab5d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -35,7 +35,7 @@ created → analysis → architecture → development → review → testing → | deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) | | done | — | — | — | -**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043). +**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058). **Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`. @@ -80,6 +80,34 @@ terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe с sentinel-файлы (`/.deploy-state-//`), без миграции БД. Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально — `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`. + +### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано) +BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод +**без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет: +конвейер нигде не пересобирает staging-образ из провалидированного коммита → retag мог тихо +промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча +откатывал прод). ORCH-058 обеспечивает инвариант `INV-FRESH` **двумя слоями** (defense in +depth), только для self-hosting: +- **A — пересборка (liveness):** детерминированный QG-под-чек `check_staging_image_fresh` на + ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A пересобирает + `orchestrator-orchestrator-staging` из worktree валидированного коммита + (`--build-arg GIT_SHA=`, OCI-лейбл `org.opencontainers.image.revision`), пересоздаёт + 8501 и прогоняет `staging_check` против свежего образа → валидируем и промоутим один + артефакт. FAIL → откат на `development` (как merge-gate). Сборки/recreate — ТОЛЬКО staging. +- **B — fail-closed guard (safety):** хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл `revision` + у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`). Несовпадение + / пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED (БАГ-8 откат), + прод не трогается. Делает тихий промоут устаревшего образа структурно невозможным даже при + отключённой/проигравшей гонку A. + +Якорь «провалидированного коммита» — `git rev-parse HEAD` worktree ПОСЛЕ merge-gate (один +helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` B). Единый kill-switch +`image_freshness_enabled` включает A+B **как целое** (нет «B без A» = вечного fail-fast); +`image_freshness_repos` (пусто → self-hosting). `STAGE_TRANSITIONS`, exit-code хука (0/1/2), +`check_deploy_status`, БАГ-8, merge-gate, схема БД — НЕ меняются (под-гейт ребра + лейбл +образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md), +детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. + ### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано) Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча @@ -166,5 +194,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 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).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).* diff --git a/docs/architecture/adr/adr-0008-staging-image-provenance.md b/docs/architecture/adr/adr-0008-staging-image-provenance.md new file mode 100644 index 0000000..eaddbf6 --- /dev/null +++ b/docs/architecture/adr/adr-0008-staging-image-provenance.md @@ -0,0 +1,77 @@ +# ADR-0008: Провенанс staging-образа перед BUILD-ONCE retag в прод (ORCH-058) + +## Статус +Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`. +Метка: `arch:major-change`. + +> Примечание о нумерации: в `adr/` исторически два файла `adr-0007-*` +> (`executable-self-deploy`, `reconciler`) — пред-существующая коллизия. Этот ADR берёт +> следующий свободный номер **0008**; коллизию 0007 не трогаем (вне объёма ORCH-058). + +## Контекст + +ORCH-36 (`adr-0007-executable-self-deploy`) сделал стадию `deploy` исполняемой для +self-hosting: Phase B запускает host-хук, который шагом **2b** (BUILD-ONCE) делает +`docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без rebuild** — «прод = ровно тот артефакт, +что прошёл staging». Предпосылка: staging-образ свеж и собран из провалидированного кода. + +**Этой гарантии нет.** Конвейер нигде не пересобирает `orchestrator-orchestrator-staging` +из провалидированного коммита; `deploy-staging` лишь гоняет `staging_check.py` против уже +работающего 8501. Инцидент (LESSONS_ORCH-036 п.4): staging-образ не пересобрали → проверка +прошла против старого кода → retag промоутнул СТАРЫЙ образ → прод **молча** откатился на +2-дневный код. Зелёный гейт = ложный позитив. Самый опасный из 4 багов: не падает, а тихо +откатывает инструмент, обслуживающий все проекты. + +## Решение + +Гарантировать `INV-FRESH`: в прод промоутится только образ, собранный из коммита, +провалидированного `deploy-staging` для данной задачи; иначе fail-fast (`FAILED` → откат на +`development`, БАГ-8), прод не трогается. Достигается **двумя взаимодополняющими слоями** +(defense in depth), только для self-hosting (условность как ORCH-35/36/43): + +- **A — пересборка (liveness).** На ребре `deploy-staging → deploy`, ПОСЛЕ merge-gate и ДО + Phase A, детерминированный QG-под-чек `check_staging_image_fresh` пересобирает + `orchestrator-orchestrator-staging` из worktree валидированного коммита + (`--build-arg GIT_SHA=`, лейбл `org.opencontainers.image.revision`), пересоздаёт 8501 + и прогоняет `staging_check`. FAIL → откат на `development`. Так валидируемый и промоутимый + артефакт — один и тот же; гарантирует наличие зелёного пути (нет вечного fail-fast). +- **B — fail-closed guard (safety).** Хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл + `revision` образа `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`). + Несовпадение / пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED. + Делает тихий промоут устаревшего образа структурно невозможным даже при отключённой/ + проигравшей гонку A. + +**Якорь провалидированного коммита** — `git rev-parse HEAD` в worktree ПОСЛЕ merge-gate +(post-rebase tree, который ре-тестирован и сольётся в `main`). Один helper +`validated_revision(repo, branch)` питает и штамп сборки (A), и `EXPECTED_REVISION` (B). + +**Условность и kill-switch:** единый `image_freshness_enabled` (вкл/выкл A+B как целое, +чтобы не было «B без A» = вечный fail-fast), `image_freshness_repos` (CSV; пусто → +self-hosting). Все настройки с префиксом `ORCH_`. + +### Что НЕ меняется +`STAGE_TRANSITIONS` (набор стадий — под-гейт ребра, не стадия), exit-code хука (0/1/2), +`map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, +merge-gate, Phase A/B/C. Схема БД — без миграций (провенанс в лейбле образа, не в БД). + +### Что добавляется (сквозное) +- QG `check_staging_image_fresh` в реестре `QG_CHECKS` (+ snapshot-тест), wired через + `_handle_image_freshness` в `stage_engine` (рядом с merge-gate). +- Режим хука `--build-staging` (build из worktree + recreate 8501; STAGING-safe дефолты). +- OCI-лейбл `org.opencontainers.image.revision` в `Dockerfile` (`ARG GIT_SHA`). +- Helpers `validated_revision` / `rebuild_staging_image` в `self_deploy.py` (never-raise). + +## Последствия + +- Класс «тихого регресса прод» закрыт структурно (B); валидный деплой всегда доходит до + зелёного (A) — устранён ручной bootstrap-разрыв пересборки staging. +- Латентность ребра растёт (build + recreate + повторный staging_check); `staging_check` + гоняется дважды (soft pre-check агента + авторитетный код) — плата за «валидируем = + промоутим». +- Все сборки/recreate — ТОЛЬКО staging (8501); прод (8500) не трогается; `main` не пушится. + Новая под-компонента → `arch:major-change`. + +## Связанные ADR +`adr-0007-executable-self-deploy` (BUILD-ONCE, Phase A/B/C), `adr-0006-merge-gate` (образец +edge-под-гейта), `adr-0003-staging-gate` (условность self-hosting), `adr-0005` +(run-as-host-uid). Детальный per-work-item: `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. diff --git a/docs/operations/DEPLOY_HOOK.md b/docs/operations/DEPLOY_HOOK.md index 0f81102..522d0e7 100644 --- a/docs/operations/DEPLOY_HOOK.md +++ b/docs/operations/DEPLOY_HOOK.md @@ -9,6 +9,7 @@ 1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен). 2. **git pull** — обновляет код репозитория. 2b. **Build-once retag** (ORCH-036, BR-6) — если задан `$SOURCE_IMAGE`, хук ретегает его на `$TARGET_IMAGE` (`docker tag $SOURCE_IMAGE $TARGET_IMAGE`) и поднимает контейнер на этом образе через `up -d --no-build`. Это деплой РОВНО того образа, что прошёл staging, **без `docker build`**. Если `$SOURCE_IMAGE` не задан (дефолт) — шаг пропускается (обратная совместимость). + - **Fail-closed провенанс-guard** (ORCH-058, Strategy B) — ПЕРЕД `docker tag`, если задан `$EXPECTED_REVISION`, хук сверяет OCI-лейбл `org.opencontainers.image.revision` у `$SOURCE_IMAGE` с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл (``) / ошибка inspect → лог + `exit 1` (FAILED → авто-rollback), **прод не трогается**. Не задан `$EXPECTED_REVISION` (дефолт) → проверка пропускается (обратная совместимость для не-self репозиториев). 3. **Рестарт контейнера** — `docker compose --profile $COMPOSE_PROFILE up -d --no-build $TARGET_SERVICE`. 4. **Health-цикл** — 10 попыток × 6с = до 60с. Критерий: HTTP 200 + тело содержит `"status":"ok"`. - **Успех** → `exit 0`, лог "Deploy SUCCESS". @@ -17,6 +18,17 @@ - Если восстановился → `exit 1` (деплой провалился, откат успешен). - Если и откат не помог → `exit 2` (критично). +### Режим `--build-staging` (ORCH-058, Strategy A) + +Пересобирает **staging-образ** из провалидированного коммита и пересоздаёт 8501, чтобы артефакт, который мы валидируем, был РОВНО тем, что позже build-once ретегается в прод (инвариант `INV-FRESH`). Собирает/пересоздаёт **только staging (8501)** — никогда прод (8500). + +1. `docker build --build-arg GIT_SHA=$GIT_SHA -t $TARGET_IMAGE $BUILD_CONTEXT` — пересборка из host-worktree валидированного коммита; `GIT_SHA` штампуется в OCI-лейбл `org.opencontainers.image.revision`. +2. `docker compose [--profile $COMPOSE_PROFILE] up -d --no-build $TARGET_SERVICE` — пересоздание staging на свежем образе. +3. Health-цикл 10×6с. Провал сборки/health → `exit 1`. +4. **`staging_check` против СВЕЖЕГО образа** (Strategy A, шаг 3 — ADR-001, AC-4) — после health хук запускает `docker exec $STAGING_CONTAINER python3 $STAGING_CHECK_PATH --base-url http://localhost:$TARGET_PORT --mode $STAGING_CHECK_MODE` (дефолт `--mode stub`, без LLM-трат). Запуск **внутри** staging-контейнера канонический (ORCH-048): suite читает реестр из собственного env контейнера, а `staging_check.py` берётся из bind-mount (`/repos/orchestrator/scripts/...`, не из образа). Это ровно тот артефакт, что позже build-once ретегается в прод → валидируем то, что промоутим (AC-4). PASS → `exit 0`; любой не-ноль (FAIL чека или safety-abort `ORCH_STAGING≠true`) → `exit 1`. + +Запускается оркестратором на ребре `deploy-staging → deploy` (QG-под-чек `check_staging_image_fresh` → `rebuild_staging_image` пробрасывает явный staging-таргет, см. `INFRA.md`). Тот же контракт кодов выхода (0 = здоров **и** staging_check PASS). + ### Режим `--rollback` Вручную откатывает сервис на предыдущий образ из `$PREV_IMAGE_FILE`. @@ -31,6 +43,12 @@ | `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) | | `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа | | `SOURCE_IMAGE` | _(unset)_ | Build-once (ORCH-036): провалидированный образ для retag на `$TARGET_IMAGE` перед рестартом (без rebuild). Не задан → шаг пропущен. | +| `EXPECTED_REVISION` | _(unset)_ | Build-once (ORCH-058, Strategy B): ожидаемый git-SHA `$SOURCE_IMAGE` (лейбл `org.opencontainers.image.revision`). Задан → fail-closed guard перед `docker tag`. Не задан → проверка пропущена. | +| `GIT_SHA` | _(unset)_ | `--build-staging` (ORCH-058, Strategy A): коммит, штампуемый в OCI-лейбл `revision` при пересборке staging-образа. | +| `BUILD_CONTEXT` | `$REPO` | `--build-staging`: docker build context (host-worktree валидированного коммита). | +| `STAGING_CONTAINER` | `$TARGET_SERVICE` (`orchestrator-staging`) | `--build-staging` (ORCH-058): контейнер, внутри которого `docker exec` запускает `staging_check`. | +| `STAGING_CHECK_PATH` | `/repos/orchestrator/scripts/staging_check.py` | `--build-staging` (ORCH-058): путь к `staging_check.py` внутри контейнера (bind-mount, не образ). | +| `STAGING_CHECK_MODE` | `stub` | `--build-staging` (ORCH-058): режим `staging_check` (`stub` — быстро, без LLM; `full-real` — дожидается аналитика). | | `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) | > ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env. diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index 90ab13b..3eb70aa 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -83,6 +83,8 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл | `ORCH_DEPLOY_HOOK_SCRIPT` / `_HOST_REPO_PATH` | путь к хук-скрипту (отн. репо) и чекаут orchestrator на хосте | | `ORCH_DEPLOY_PROD_SOURCE_IMAGE` | staging-образ для build-once retag на прод-тег (без rebuild) | | `ORCH_DEPLOY_PROD_TARGET_SERVICE` / `_TARGET_PORT` / `_TARGET_IMAGE` / `_COMPOSE_PROFILE` / `_PREV_IMAGE_FILE` | прод-цель хука + снапшот для авто-rollback | +| `ORCH_IMAGE_FRESHNESS_ENABLED` | ORCH-058 единый kill-switch провенанса staging-образа (A+B как целое); дефолт `true`, false → legacy build-once без проверки свежести | +| `ORCH_IMAGE_FRESHNESS_REPOS` | CSV репозиториев с реальным гейтом свежести; пусто → только self-hosting `orchestrator` | | `ORCH_RECONCILE_ENABLED` | kill-switch sweeper потерянных webhook (ORCH-053); дефолт `true`. **При инциденте/раскатке** — `false` глушит весь фоновый reconciler | | `ORCH_RECONCILE_PLANE_ENABLED` | отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 продолжает работать; дефолт `true` | | `ORCH_RECONCILE_INTERVAL_S` | период фонового прохода reconciler, сек; дефолт `120` | @@ -131,6 +133,7 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл **Страховки:** - Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op. +- **Свежесть staging-образа (ORCH-058):** на ребре `deploy-staging → deploy` (ПОСЛЕ merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` пересобирает staging-образ из валидированного коммита и пересоздаёт 8501 (Strategy A), а хук перед build-once retag fail-closed сверяет OCI-лейбл `revision` с `EXPECTED_REVISION` (Strategy B). Гарантирует: в прод промоутится РОВНО провалидированный артефакт (инцидент LESSONS_ORCH-036 п.4 — тихий промоут устаревшего образа). Сборки/recreate — ТОЛЬКО staging (8501); FAIL → откат на `development`. Условный: реален только для self-hosting. **Правила для агентов при задачах ORCH:** 1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи. diff --git a/docs/operations/STAGING.md b/docs/operations/STAGING.md index f750b20..471ae9a 100644 --- a/docs/operations/STAGING.md +++ b/docs/operations/STAGING.md @@ -75,6 +75,27 @@ completely invisible to commands that do not pass `--profile staging`. docker logs -f orchestrator-staging ``` +## Staging-образ как источник прод-артефакта (ORCH-058) + +Прод-деплой орка — **build-once**: хук ретегает провалидированный staging-образ +(`orchestrator-orchestrator-staging`) на прод-тег **без rebuild** (ORCH-036). Чтобы +в прод не попал устаревший образ (инцидент LESSONS_ORCH-036 п.4), ORCH-058 гарантирует +свежесть staging-образа **двумя слоями** (только self-hosting): + +- **A — пересборка staging (liveness):** на ребре `deploy-staging → deploy` (ПОСЛЕ + merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` через хук + `--build-staging` пересобирает staging-образ из worktree валидированного коммита + (`--build-arg GIT_SHA=`, OCI-лейбл `org.opencontainers.image.revision`) и + пересоздаёт 8501. Так валидируем РОВНО тот артефакт, что промоутится в прод. + FAIL → откат на `development`. Сборки/recreate — **только staging (8501)**. +- **B — fail-closed guard (safety):** прод-хук перед `docker tag` сверяет лейбл + `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает оркестратор); + несовпадение / пустой лейбл / ошибка inspect → `exit 1`, прод не трогается. + +Kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` включает A+B **как целое**; область — +`ORCH_IMAGE_FRESHNESS_REPOS` (пусто → только `orchestrator`). Детали — `DEPLOY_HOOK.md`, +`docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. + ## Roadmap | Task | Description | diff --git a/docs/work-items/ORCH-058/00-business-request.md b/docs/work-items/ORCH-058/00-business-request.md new file mode 100644 index 0000000..0721164 --- /dev/null +++ b/docs/work-items/ORCH-058/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Self-deploy: retag берёт устаревший staging-образ (риск тихого регресса) + +Work Item ID: ORCH-058 + +## Description + +TBD diff --git a/docs/work-items/ORCH-058/01-brd.md b/docs/work-items/ORCH-058/01-brd.md new file mode 100644 index 0000000..4ba0763 --- /dev/null +++ b/docs/work-items/ORCH-058/01-brd.md @@ -0,0 +1,87 @@ +# BRD — ORCH-058: Self-deploy retag берёт устаревший staging-образ (риск тихого регресса) + +Work Item ID: ORCH-058 +Тип: bug / техдолг инфраструктуры self-deploy +Источник: `docs/history/LESSONS_ORCH-036-selfdeploy.md` п.4 (самый опасный из 4 багов bootstrap ORCH-36) + +## 1. Контекст + +ORCH-36 сделал стадию `deploy` исполняемой для self-hosting репозитория `orchestrator`: +- Phase B (`src/self_deploy.py::build_deploy_command`) запускает детачед host-хук + `scripts/orchestrator-deploy-hook.sh` с параметром `SOURCE_IMAGE=orchestrator-orchestrator-staging`. +- Хук (шаг **2b**, BUILD-ONCE, ORCH-36 BR-6) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` + **без `docker build`** — «прод получает ровно тот артефакт, что прошёл staging». + +Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и провалидирован**. На практике этой +гарантии НЕТ. + +## 2. Проблема (корень) + +Конвейер **нигде не пересобирает** образ `orchestrator-orchestrator-staging` из текущего +кода (HEAD `main` / провалидированной ветки): +- Стадия `deploy-staging` запускает только `scripts/staging_check.py` (e2e-проверка) + против **уже работающего** контейнера `orchestrator-staging` (8501) — что бы в нём ни + крутилось. Сборка staging-образа — ручная операция (STAGING.md / ORCH-34), вне конвейера. +- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом. + +Следствие (инцидент ORCH-36): staging-образ не пересобрали из нового `main` → +`staging_check` прошёл против СТАРОГО кода → BUILD-ONCE retag промоутнул СТАРЫЙ образ в прод. +Деплой «зелёный» (`result=0`, health ok), но прод молча откатился на код 2-дневной давности: +пропал `deploy-finalizer` → задача не закрылась → бесконечная петля Phase B. + +## 3. Почему это критично + +> Это **самый опасный** из четырёх багов self-deploy: он **не падает**, а **тихо откатывает +> прод**. Зелёный гейт = ложный позитив. Орк обслуживает все проекты (enduro-trails) из одного +> прод-инстанса → тихий регресс инструмента = групповой инцидент для всех проектов. + +Текущая защита (staging-гейт, merge-gate, health-check хука) НЕ ловит этот класс: все они +зелёные, потому что проверяют не тот артефакт, что уезжает в прод. + +## 4. Бизнес-цель + +Гарантировать инвариант: **в прод никогда не промоутится образ, не собранный из +провалидированного для данной задачи коммита; при невозможности это доказать — деплой +fail-fast (вердикт FAILED → откат на development), а не «тихо зелёный»**. + +## 5. Объём (scope) + +В объёме: +- Привязка артефакта (staging-образ → прод-retag) к провалидированному коммиту. +- Fail-fast при рассинхроне образа и кода (никаких тихих промоутов устаревшего). +- Условность как ORCH-35/36/43: реально только для `orchestrator`; прочие репо — no-op / + прежнее поведение. +- Контракт never-raise и fail-closed (на сомнении — не деплоить). + +Вне объёма: +- Полный авто-approve прод-деплоя (ORCH-54). +- Изменение exit-code-контракта хука (0/1/2) и реестров `STAGE_TRANSITIONS` / `QG_CHECKS` как + набора стадий. +- Миграции схемы БД. +- Деплой/рестарт **прод**-контейнера `orchestrator` (8500) в рамках задачи. + +## 6. Бизнес-требования (BR) + +- **BR-1.** Образ, который BUILD-ONCE retag промоутит в прод, ДОЛЖЕН соответствовать коду, + провалидированному стадией `deploy-staging` для данной задачи (тот же git-коммит). +- **BR-2.** Если соответствие НЕ доказуемо (staging-образ собран не из провалидированного + коммита, либо провенанс невозможно прочесть) — деплой ОБЯЗАН fail-fast: вердикт `FAILED`, + штатный откат на `development` (контракт БАГ-8), без рестарта прода. +- **BR-3.** `staging_check.py` (e2e-валидация) ДОЛЖЕН прогоняться против артефакта, + соответствующего тому же провалидированному коммиту, что уедет в прод (нельзя валидировать + один образ, а катить другой). +- **BR-4.** Поведение условно: реально для `orchestrator`; для прочих репозиториев — no-op / + без регрессий прежнего синхронного деплоя. +- **BR-5.** Выбранное решение НЕ должно приводить к вечной блокировке деплоя (если механизм + свежести отсутствует — нужен путь, который доводит до зелёного, а не fail-fast'ит навсегда). +- **BR-6.** Контракт never-raise: сбой проверки свежести/провенанса не должен валить + stage_engine; на любом сомнении — fail-closed (трактуем как несоответствие). +- **BR-7.** Документация-голден-сорс: INFRA / DEPLOY_HOOK / STAGING / architecture README + + CHANGELOG обновляются в том же PR; решение оформляется ADR. + +## 7. Связанные материалы + +- `docs/history/LESSONS_ORCH-036-selfdeploy.md` (п.4 — корень) +- `docs/architecture/adr/adr-0007-executable-self-deploy.md`, `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md` +- `src/self_deploy.py`, `scripts/orchestrator-deploy-hook.sh`, `src/config.py` +- `docs/operations/STAGING.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/INFRA.md` diff --git a/docs/work-items/ORCH-058/02-trz.md b/docs/work-items/ORCH-058/02-trz.md new file mode 100644 index 0000000..dcada7d --- /dev/null +++ b/docs/work-items/ORCH-058/02-trz.md @@ -0,0 +1,126 @@ +# ТЗ — ORCH-058: провенанс staging-образа перед BUILD-ONCE retag в прод + +Work Item ID: ORCH-058 + +> Примечание: ТЗ фиксирует ТРЕБУЕМЫЕ изменения и точки в коде. **Выбор стратегии** +> (пересборка из HEAD `main` ПЕРЕД валидацией vs. fail-fast по провенансу образа, либо их +> комбинация) — решение **архитектора** (ADR в `06-adr/`). Ниже перечислены точки +> касания для обеих стратегий; архитектор выбирает и при необходимости сужает. + +## 1. Инвариант, который нужно обеспечить + +`INV-FRESH`: образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в прод, +собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если это +недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`, БАГ-8), +прод не трогается. + +Якорь «провалидированного коммита» (architect фиксирует точно в ADR): SHA HEAD ветки задачи +после merge-gate rebase на `origin/main` (то, что валидировал `deploy-staging` + merge-gate). + +## 2. Текущее поведение (что чинить) + +| Место | Сейчас | Проблема | +|---|---|---| +| `scripts/orchestrator-deploy-hook.sh` шаг 2b | `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` без проверки происхождения образа | промоутит любой образ под именем `orchestrator-orchestrator-staging`, даже устаревший | +| Стадия `deploy-staging` (`.openclaw/agents/deployer.md` + `staging_check.py`) | гоняет e2e против уже запущенного 8501, не пересобирая образ | валидирует не тот артефакт, что уедет в прод | +| `src/self_deploy.py::build_deploy_command` | передаёт `SOURCE_IMAGE`, `TARGET_*`, `COMPOSE_PROFILE`, `PREV_IMAGE_FILE`; провенанс/SHA не передаёт | хук не знает, какой коммит ожидать | +| `Dockerfile` | без OCI-лейбла `revision`/git-SHA | у образа нет машиночитаемого происхождения для проверки | + +## 3. Задействованные модули `src/` и файлы + +- `src/self_deploy.py` — основной (provenance-helpers + проброс ожидаемого SHA в команду хука). +- `src/config.py` — новые настройки (`ORCH_`-префикс обязателен, урок ORCH-36 п.2). +- `scripts/orchestrator-deploy-hook.sh` — fail-fast по провенансу и/или пересборка перед retag. +- `Dockerfile` — лейбл происхождения образа (для стратегии «провенанс по labels/sha»). +- `src/qg/checks.py` — опц. новый детерминированный под-чек свежести (если стратегия «гейт»). +- `src/stage_engine.py` — опц. точка вызова под-чека на ребре `deploy-staging → deploy` + (рядом с merge-gate, строки ~262–288). **Реестр `STAGE_TRANSITIONS` не меняется.** +- `.openclaw/agents/deployer.md` — шаги стадии `deploy-staging` (если выбран rebuild-перед-валидацией). +- `docker-compose.yml` — опц. build-args/labels для staging-сервиса (если стратегия rebuild). + +## 4. Требуемые изменения — стратегия A (пересборка из HEAD main перед валидацией) + +A1. Перед прогоном `staging_check.py` стадия `deploy-staging` для `orchestrator` пересобирает + образ `orchestrator-orchestrator-staging` из провалидированного коммита (worktree ветки + после merge-gate rebase) и пересоздаёт контейнер 8501 на свежем образе. +A2. `staging_check.py` гоняется против свежего контейнера; на `SUCCESS` ровно ЭТОТ образ + становится `SOURCE_IMAGE` для прод-retag (loop closed). +A3. Детерминированно (без LLM в критическом пути): сборку/recreate выполняет код стадии или + host-хук в staging-режиме, не агент-деплойер «руками». +A4. Безопасность: операция трогает ТОЛЬКО staging (8501), НИКОГДА прод (8500). + +## 5. Требуемые изменения — стратегия B (fail-fast по провенансу образа) + +B1. `Dockerfile`: добавить лейбл происхождения, напр. + `LABEL org.opencontainers.image.revision=$GIT_SHA` через `ARG GIT_SHA` (build-arg). +B2. Сборка staging-образа (ручная или из стратегии A) проставляет `GIT_SHA` = коммит сборки. +B3. `src/self_deploy.py::build_deploy_command`: вычислить ожидаемый SHA провалидированного + коммита и пробросить в команду хука новым env (напр. `EXPECTED_REVISION=`). + Новый pure-helper, напр. `expected_revision(repo, branch) -> str` (never-raise). +B4. `scripts/orchestrator-deploy-hook.sh` шаг 2b: ПЕРЕД `docker tag` прочитать лейбл + `$SOURCE_IMAGE` (`docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'`) + и сравнить с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл / пустой ожидаемый SHA → + `log` + `exit 1` (fail-fast). Поведение обратносовместимо: при незаданном + `EXPECTED_REVISION` — текущее поведение (без проверки), чтобы не сломать не-self репо. +B5. exit 1 хука уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется), + Phase C пишет `14-deploy-log.md` `deploy_status: FAILED` → откат на `development` (БАГ-8). + +## 6. Требуемые изменения — опц. под-гейт (если архитектор выберет gate-side для B) + +- Новый детерминированный (без LLM) под-чек, напр. `check_staging_image_fresh`, по образцу + `check_branch_mergeable` (ORCH-043): pure verdict-logic + условность (`self_deploy_applies` + / `is_self_hosting_repo`), never-raise, для прочих репо → `(True, "N/A")`. +- Вызов на ребре `deploy-staging → deploy` ПЕРЕД Phase A (рядом с merge-gate, `stage_engine` + ~268–288). FAIL → откат на `development` (как merge-gate). Реестр стадий неизменен — + это под-гейт ребра, не новая стадия. +- Если выбран чисто хуковый fail-fast (раздел 5) — под-гейт не нужен. + +## 7. Изменения API + +Нет. Эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) не меняются. Опц.: в снимок +`GET /queue` можно добавить диагностическое поле о свежести образа — НЕ обязательно. + +## 8. Изменения схемы БД + +Нет. Состояние deploy — sentinel-файлы (`.deploy-state-//`, ORCH-36). Миграции +запрещены (как ORCH-36/43/53). + +## 9. Конфигурация (`src/config.py`, ВСЕ с префиксом `ORCH_`) + +Кандидаты (architect финализирует имена и дефолты): +- `image_freshness_enabled: bool = True` — kill-switch проверки (поэтапный раскат). +- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как `self_deploy_repos`). +- (для стратегии B) проброс `EXPECTED_REVISION` строится в `build_deploy_command`, отдельной + настройки может не требоваться. +- (для стратегии A) при необходимости — имя/тег staging-образа уже есть + (`deploy_prod_source_image`). + +Урок ORCH-36 п.2: любая настройка, читаемая pydantic Settings, ОБЯЗАНА иметь префикс `ORCH_`. + +## 10. Новые QG checks (если применимо) + +- Опц. `check_staging_image_fresh` (см. §6) — добавить в реестр `QG_CHECKS` и в + snapshot-тест реестра (`tests/test_qg_registry_snapshot.py`). Только если выбран gate-side. + +## 11. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR) + +- `06-adr/ADR-001-.md` — выбор стратегии (A / B / A+B), якорь «провалидированного + коммита», точки fail-fast, условность, never-raise, отсутствие deadlock (BR-5). +- `docs/operations/DEPLOY_HOOK.md` — описание провенанс-проверки / пересборки и новых env. +- `docs/operations/STAGING.md` — как и когда пересобирается staging-образ в конвейере. +- `docs/operations/INFRA.md` — обновить топологию/риск self-deploy (закрыт п.4 каскада). +- `docs/architecture/README.md` — секция ORCH-36/58 (свежесть артефакта в BUILD-ONCE). +- `CHANGELOG.md` — запись ORCH-058. +- При выборе стратегии A: bootstrap-чеклист (урок ORCH-36 «сквозной»: реальный staging-прогон + до мержа). + +## 12. Инварианты / ограничения (self-hosting safety) + +- Никогда не рестартовать/ронять прод 8500 в рамках задачи (CLAUDE.md). Любая сборка/recreate — + только staging 8501. +- Никогда не пушить/форс-пушить `main` (как merge-gate). +- Контракты НЕ меняются: exit-code хука (0/1/2), `map_exit_code_to_status`, + `check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback, terminal-sync, merge-gate. +- Fail-closed: на любом сомнении (нет лейбла, нет ожидаемого SHA, ошибка inspect) — + трактовать как несоответствие → FAILED, никогда не промоутить «на авось». +- never-raise: helpers и под-чек не должны пробрасывать исключение в stage_engine. diff --git a/docs/work-items/ORCH-058/03-acceptance-criteria.md b/docs/work-items/ORCH-058/03-acceptance-criteria.md new file mode 100644 index 0000000..e97a68f --- /dev/null +++ b/docs/work-items/ORCH-058/03-acceptance-criteria.md @@ -0,0 +1,71 @@ +# Критерии приёмки — ORCH-058 + +Work Item ID: ORCH-058 + +Критерии сформулированы вокруг инварианта `INV-FRESH` и **не зависят** от выбранной +архитектором стратегии (A — пересборка, B — fail-fast по провенансу, A+B). Каждый — с +чётким условием PASS/FAIL. + +## AC-1 — Соответствие артефакта коду (центральный инвариант) +- PASS: образ, который BUILD-ONCE retag промоутит в прод (`SOURCE_IMAGE`), доказуемо собран + из коммита, провалидированного стадией `deploy-staging` для этой задачи. +- FAIL: в прод может уехать образ, собранный не из провалидированного коммита. + +## AC-2 — Fail-fast при рассинхроне (никаких тихих зелёных) +- PASS: если staging-образ собран НЕ из провалидированного коммита (или провенанс нечитаем), + деплой завершается `deploy_status: FAILED` и откатом на `development` (БАГ-8); прод НЕ + рестартуется на устаревший образ. +- FAIL: при рассинхроне деплой завершается `SUCCESS` / «зелёным», прод тихо откатывается. + +## AC-3 — Fail-closed на сомнении +- PASS: при отсутствии лейбла происхождения, пустом ожидаемом SHA, ошибке `docker image + inspect` или любой неоднозначности — трактуется как несоответствие → FAILED (никогда не + промоутится «на авось»). +- FAIL: сомнительный/непроверяемый случай трактуется как «свежий» и промоутится. + +## AC-4 — Валидация и промоут — один и тот же артефакт +- PASS: `staging_check.py` прогоняется против образа/контейнера, соответствующего тому же + провалидированному коммиту, который затем уезжает в прод. +- FAIL: валидируется один образ, а в прод retag'ается другой. + +## AC-5 — Условность (self-hosting only) +- PASS: проверка/пересборка реальна только для `orchestrator` (и репо из `image_freshness_repos`, + если задан); для прочих репо — no-op, синхронный деплой не-self репо без регрессий. +- FAIL: логика срабатывает для не-self репозиториев или ломает их деплой. + +## AC-6 — Никакого deadlock деплоя (BR-5) +- PASS: при штатном прогоне (staging-образ корректно отражает провалидированный коммит) + деплой доходит до `SUCCESS` и `deploy → done`; механизм свежести не блокирует валидный + деплой навсегда. +- FAIL: валидный деплой вечно fail-fast'ится / задача зависает на `deploy`. + +## AC-7 — Контракты не изменены +- PASS: `STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2), + `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback, + terminal-sync, merge-gate — без изменений; схема БД без миграций. +- FAIL: затронут любой из перечисленных контрактов или добавлена миграция БД. + +## AC-8 — never-raise +- PASS: сбой проверки свежести/провенанса (битый образ, ssh/docker error, отсутствующий + worktree) не пробрасывает исключение в `stage_engine`; возвращается безопасный вердикт. +- FAIL: исключение из новой логики всплывает и валит обработку стадии. + +## AC-9 — Self-hosting safety +- PASS: новая логика НЕ рестартует/не роняет прод-контейнер `orchestrator` (8500) и не + пушит/форс-пушит `main`; любые сборки/recreate — только staging (8501). +- FAIL: нарушено любое из ограничений выше. + +## AC-10 — Конфигурация и kill-switch +- PASS: новые настройки имеют префикс `ORCH_`; есть kill-switch (напр. `image_freshness_enabled`) + для поэтапного раската; при выключенном флаге — прежнее поведение. +- FAIL: настройка без `ORCH_`-префикса (не читается pydantic) или нет способа отключить. + +## AC-11 — Документация (golden source) +- PASS: в том же PR обновлены DEPLOY_HOOK.md, STAGING.md, INFRA.md, architecture/README.md, + CHANGELOG.md и заведён ADR `06-adr/ADR-001-*`. +- FAIL: функционал изменён, документация/ADR не обновлены (→ reviewer REQUEST_CHANGES). + +## AC-12 — Тесты зелёные +- PASS: `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и + snapshot-тест реестра QG (если добавлен под-чек). +- FAIL: любой тест из плана красный или регрессия существующих. diff --git a/docs/work-items/ORCH-058/04-test-plan.yaml b/docs/work-items/ORCH-058/04-test-plan.yaml new file mode 100644 index 0000000..f783828 --- /dev/null +++ b/docs/work-items/ORCH-058/04-test-plan.yaml @@ -0,0 +1,124 @@ +work_item: ORCH-058 +description: > + Провенанс staging-образа перед BUILD-ONCE retag в прод. Тесты покрывают инвариант + INV-FRESH: соответствие промоутируемого образа провалидированному коммиту, fail-fast + и fail-closed при рассинхроне, условность self-hosting, never-raise, неизменность + контрактов. Часть кейсов помечена strategy-зависимыми (A=пересборка, B=fail-fast по + провенансу) — финальный набор подтверждает архитектор в ADR; пишутся тесты для + выбранной стратегии. +tests: + - id: TC-01 + type: unit + description: > + Pure provenance-verdict: SHA образа == ожидаемый SHA -> свежий (PASS). + Совпадающие revision дают вердикт "соответствует". + module: tests/test_image_freshness.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Pure provenance-verdict: SHA образа != ожидаемый SHA -> НЕ свежий -> + вердикт несоответствия (вход для fail-fast). + module: tests/test_image_freshness.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Fail-closed: пустой/отсутствующий лейбл образа ИЛИ пустой ожидаемый SHA -> + трактуется как несоответствие (никогда не "свежий по умолчанию"). + module: tests/test_image_freshness.py + expected: PASS + + - id: TC-04 + type: unit + description: > + never-raise: provenance-helper при docker/ssh/inspect ошибке или отсутствующем + worktree возвращает безопасный вердикт (несоответствие), не пробрасывает исключение. + module: tests/test_image_freshness.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Условность: для не-self репозитория проверка свежести = no-op (True/"N/A"); + для orchestrator (или репо из image_freshness_repos) — реальна. + module: tests/test_image_freshness.py + expected: PASS + + - id: TC-06 + type: unit + description: > + [Стратегия B] build_deploy_command пробрасывает EXPECTED_REVISION= + в remote-команду хука рядом с SOURCE_IMAGE; формат env корректен (shlex-quote). + module: tests/test_deploy_build_once.py + expected: PASS + + - id: TC-07 + type: unit + description: > + [Стратегия B] Хук содержит ветку fail-fast: при заданном EXPECTED_REVISION и + несовпадении revision лейбла SOURCE_IMAGE -> exit 1 ПЕРЕД docker tag; при пустом + EXPECTED_REVISION -> обратносовместимое поведение (без проверки). Статическая + проверка текста scripts/orchestrator-deploy-hook.sh (паттерн test_deploy_build_once). + module: tests/test_deploy_hook_provenance.py + expected: PASS + + - id: TC-08 + type: unit + description: > + [Стратегия B] Dockerfile объявляет ARG GIT_SHA и LABEL + org.opencontainers.image.revision=$GIT_SHA (статическая проверка текста Dockerfile). + module: tests/test_deploy_hook_provenance.py + expected: PASS + + - id: TC-09 + type: unit + description: > + Маппинг контракта: exit 1 хука (fail-fast по провенансу) -> + map_exit_code_to_status == "FAILED" (контракт ORCH-36 не изменён). + module: tests/test_deploy_hook_mapping.py + expected: PASS + + - id: TC-10 + type: integration + description: > + Stale-образ -> fail-fast end-to-end: на ребре deploy-staging->deploy при + несоответствии образа Phase B/хук дают FAILED -> advance_stage откатывает на + development (БАГ-8), прод не "зелёный". Прод-рестарт замокан. + module: tests/test_stage_engine.py + expected: PASS + + - id: TC-11 + type: integration + description: > + Свежий образ -> happy path: соответствие revision -> деплой доходит до SUCCESS и + deploy->done; механизм свежести не блокирует валидный деплой (anti-deadlock, AC-6). + Host-процесс/хук замокан. + module: tests/test_stage_engine.py + expected: PASS + + - id: TC-12 + type: unit + description: > + [Если выбран gate-side] check_staging_image_fresh зарегистрирован в QG_CHECKS; + snapshot-тест реестра обновлён и зелёный. + module: tests/test_qg_registry_snapshot.py + expected: PASS + + - id: TC-13 + type: unit + description: > + Конфигурация: новые настройки (image_freshness_enabled / image_freshness_repos) + читаются с префиксом ORCH_ и имеют дефолты; kill-switch off -> прежнее поведение. + module: tests/test_config.py + expected: PASS + + - id: TC-14 + type: unit + description: > + Регрессия контрактов: STAGE_TRANSITIONS (набор стадий) и exit-code-контракт хука + (0/1/2) не изменены существующими правками. + module: tests/test_stages.py + expected: PASS diff --git a/docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md b/docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md new file mode 100644 index 0000000..4b164b0 --- /dev/null +++ b/docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md @@ -0,0 +1,209 @@ +# ADR-001 (ORCH-058): Провенанс staging-образа перед BUILD-ONCE retag в прод + +## Статус +Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`. +Метка: `arch:major-change` (новая deploy-safety модель + новый QG + новый режим хука). + +## Контекст + +ORCH-36 сделал стадию `deploy` исполняемой для self-hosting (`orchestrator`): Phase B +(`self_deploy.build_deploy_command`) запускает детачед host-хук, который шагом **2b** +(BUILD-ONCE) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без `docker build`** — +«прод получает ровно тот артефакт, что прошёл staging». + +Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и собран из провалидированного кода**. +На практике этой гарантии НЕТ (BRD §2): + +- Стадия `deploy-staging` запускает только `scripts/staging_check.py` против **уже + работающего** контейнера 8501 — что бы в нём ни крутилось. Пересборка staging-образа — + ручная операция (STAGING.md / ORCH-34), вне конвейера. +- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом. + +Инцидент (LESSONS_ORCH-036 п.4 — **самый опасный** из 4 багов bootstrap): staging-образ +не пересобрали из нового `main` → `staging_check` прошёл против СТАРОГО кода → BUILD-ONCE +retag промоутнул СТАРЫЙ образ в прод. Деплой «зелёный» (`result=0`, health ok), но прод +**молча откатился** на код 2-дневной давности. Орк обслуживает все проекты из одного +прод-инстанса → тихий регресс инструмента = групповой инцидент. + +Текущая защита (staging-гейт, merge-gate, health-check хука) этот класс НЕ ловит: все +гейты зелёные, потому что проверяют **не тот артефакт**, что уезжает в прод. + +## Инвариант, который нужно обеспечить + +`INV-FRESH` (ТЗ §1): образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в +прод, собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если +это недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`, +БАГ-8), прод не трогается. + +### Якорь «провалидированного коммита» + +**SHA = `git rev-parse HEAD` в worktree ветки задачи ПОСЛЕ merge-gate** (т.е. после +возможного `auto_rebase_onto_main` + `push --force-with-lease`). Это ровно тот tree, +который merge-gate ре-тестировал зелёным и который сольётся в `main`. Один helper +`validated_revision(repo, branch)` (never-raise) вычисляет SHA и служит ЕДИНСТВЕННЫМ +источником и для штампа сборки (Стратегия A), и для ожидаемого ревижна (Стратегия B) — +два потребителя одного якоря не могут разойтись. + +## Решение: A + B (defense in depth) + +Ни одна стратегия по отдельности не закрывает задачу: + +- **B в одиночку** (fail-fast по провенансу) делает тихий промоут структурно невозможным, + НО если staging-образ устарел — fail-fast'ит **навсегда** (нет пути к зелёному без + ручной пересборки) → нарушает BR-5 / AC-6 (deadlock), воспроизводит ровно тот + bootstrap-разрыв, который мы устраняем. +- **A в одиночку** (пересборка из провалидированного коммита) закрывает петлю «валидируем = + промоутим», НО не имеет утверждения В МОМЕНТ retag: гонка/отключение/сбой пересборки + снова даст тихий промоут. + +Поэтому берём **обе**, как взаимодополняющие слои: + +### Стратегия A — пересборка staging-образа из провалидированного коммита (liveness, AC-4/AC-6) + +Для self-hosting на ребре `deploy-staging → deploy`, **после merge-gate** (когда +валидированный HEAD финализирован) и **до Phase A**, детерминированный код: + +1. Вычисляет `sha = validated_revision(repo, branch)`. +2. Пересобирает `orchestrator-orchestrator-staging` из **worktree ветки** (build-context = + валидированный tree) с `--build-arg GIT_SHA=` и пересоздаёт контейнер 8501 на + свежем образе (`--no-build`). +3. Прогоняет `staging_check.py --mode stub` против свежего 8501. + +Результат: ровно ЭТОТ образ (с лейблом `revision=`) становится `SOURCE_IMAGE` для +прод-retag → петля замкнута, валидируем и промоутим один артефакт (AC-4). Пересборка/ +recreate трогают **ТОЛЬКО staging (8501)**, НИКОГДА прод (8500) (AC-9). + +Исполнение — через host (ssh, синхронно): docker CLI / compose доступны на ХОСТЕ, не в +контейнере (Dockerfile ставит только `openssh-client git`; staging_check уже гоняется +`docker exec`-ом на хосте). Новый режим хука `--build-staging` (см. ниже) выполняет сборку +и recreate. Синхронный ssh достаточен — рестарт staging не убивает прод-worker (в отличие +от Phase B, где нужен detached + finalizer). + +Реализуется как **детерминированный QG-под-чек `check_staging_image_fresh`** (по образцу +`check_branch_mergeable`, ORCH-043): pure-условность + never-raise; для прочих репо → +`(True, "N/A")`. Регистрируется в `QG_CHECKS` и в `tests/test_qg_registry_snapshot.py`. +Вызов — на ребре через `_handle_image_freshness(...)` в `stage_engine` (рядом с +`_handle_merge_gate`, ПОСЛЕ него, ДО Phase A). FAIL → откат на `development` + release +merge-lease (как merge-gate). **`STAGE_TRANSITIONS` (набор стадий) НЕ меняется** — это +под-гейт ребра. + +### Стратегия B — fail-closed провенанс-guard в хуке (safety, AC-1/AC-2/AC-3) + +1. **`Dockerfile`**: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`. + Без build-arg лейбл пустой → fail-closed на стороне B (см. ниже). +2. **`build_deploy_command`**: вычисляет `EXPECTED_REVISION = validated_revision(repo, + branch)` и пробрасывает в env команды хука. +3. **`orchestrator-deploy-hook.sh` шаг 2b** — ПЕРЕД `docker tag`: + - читает лейбл `SOURCE_IMAGE`: + `docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$SOURCE_IMAGE"`; + - сравнивает с `$EXPECTED_REVISION`; + - несовпадение / пустой лейбл / пустой `EXPECTED_REVISION` / ошибка inspect → + `log` + `exit 1` (**fail-closed**, никогда не промоутить «на авось»). + - **Обратная совместимость:** при НЕзаданном `EXPECTED_REVISION` — текущее поведение + (проверка пропускается), чтобы не сломать не-self репо и legacy-вызовы. +4. `exit 1` уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется), Phase C + пишет `deploy_status: FAILED` → откат на `development` (БАГ-8). Прод не рестартуется на + устаревший образ — guard срабатывает ДО `docker tag`/restart. + +### Новый режим хука `--build-staging` (для Стратегии A) + +`orchestrator-deploy-hook.sh --build-staging` (env: `GIT_SHA`, `BUILD_CONTEXT` = host-путь +worktree, `TARGET_IMAGE=orchestrator-orchestrator-staging`, `TARGET_SERVICE`, +`COMPOSE_PROFILE=staging`, `TARGET_PORT=8501`): +`docker build --build-arg GIT_SHA= -t ` → +`docker compose --profile staging up -d --no-build orchestrator-staging` → health 8501. +Тот же exit-code-контракт (0=ok). Дефолты режима — STAGING-safe (как у `--deploy`). + +Host-путь build-context выводится из container-пути worktree заменой +`repos_dir → host_repos_dir` (как `host_state_dir` в `self_deploy.py`); требуется +производный helper host-worktree-пути (или новая настройка `ORCH_HOST_WORKTREES_DIR`). + +## Конфигурация (`src/config.py`, все с префиксом `ORCH_` — урок ORCH-36 п.2) + +- `image_freshness_enabled: bool = True` — **единый** kill-switch ВСЕЙ фичи (A и B вместе). + `False` → ни пересборки, ни проброса `EXPECTED_REVISION` → поведение ровно как ORCH-36 + (BUILD-ONCE без guard). A и B включаются/выключаются **как одно целое**, чтобы не было + опасной полу-конфигурации «B без A» (вечный fail-fast). +- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как + `self_deploy_repos` / `merge_gate_repos`). + +> **Инвариант конфигурации (AC-6):** B активен ТОЛЬКО когда активен A. По умолчанию +> (`image_freshness_enabled=True`) валидный деплой всегда доходит до зелёного (A пересобирает +> → лейбл == EXPECTED → B пропускает). Полное выключение → legacy ORCH-36 поведение. + +## Порядок на ребре `deploy-staging → deploy` (self-hosting) + +1. `check_staging_status` (существующий) — первичный staging-вердикт агента (smoke, + что staging-инфра жива). +2. merge-gate `check_branch_mergeable` (существующий) — финализирует валидированный HEAD + (rebase если позади, ре-тест зелёный, lease HELD). DEFER на busy-lock → возврат без + пересборки. +3. **`check_staging_image_fresh` (НОВЫЙ, Стратегия A)** — пересборка из валидированного + HEAD + recreate 8501 + `staging_check`. FAIL → откат на `development` + release lease. +4. Phase A (существующий) → запрос approve. +5. Phase B (human Approved) → `build_deploy_command` с `EXPECTED_REVISION` → хук-guard (B) + → BUILD-ONCE retag только при совпадении → restart прод → Phase C finalizer. + +> Двойной прогон `staging_check` (агент на стадии + код на шаге 3) — **намеренный**: первый +> валидирует УЖЕ работающий (потенциально устаревший) 8501 как soft pre-check; авторитетный +> — шаг 3 против СВЕЖЕГО образа, который и уедет в прод. `--mode stub` быстр и без LLM-трат. + +## Контракты, которые НЕ меняются (AC-7) + +`STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2), +`map_exit_code_to_status`, `check_deploy_status` / `_parse_deploy_status` (frontmatter-only), +БАГ-8 rollback, terminal-sync `deploy → done`, merge-gate (ORCH-43), Phase A/B/C ORCH-36. +**Схема БД — без миграций** (состояние свежести не персистится в БД; провенанс живёт в +лейбле образа). Добавление `check_staging_image_fresh` в `QG_CHECKS` — ожидаемое расширение +реестра (ТЗ §10), не входит в замороженный список AC-7. + +## Last-line-of-defence / fail-closed (AC-2/AC-3) + +Даже если A отключена/проиграла гонку/сбойнула — **B (хук-guard) делает тихий промоут +устаревшего образа структурно невозможным**: рассинхрон лейбла и `EXPECTED_REVISION` → +`exit 1` ДО retag → FAILED → откат. На любом сомнении (нет лейбла, пустой ожидаемый SHA, +ошибка inspect) — трактуется как несоответствие. Прод никогда не трогается «на авось». + +## never-raise (AC-8) + +`validated_revision`, `rebuild_staging_image`, `check_staging_image_fresh`, +`build_deploy_command` (проброс EXPECTED) — все защищены try/except, любая ошибка → безопасный +вердикт (для A-под-чека: `(False, reason)` с release lease; пустой `EXPECTED_REVISION` на +сомнении → B fail-closed). Исключение никогда не всплывает в `stage_engine`. + +## Последствия + +**Плюсы** +- Класс «тихого регресса прод» закрыт структурно (B), а валидный деплой всегда доходит до + зелёного (A) — bootstrap-разрыв «ручная пересборка staging» устранён. +- Валидируем и промоутим один и тот же артефакт (AC-4); провенанс машиночитаем (лейбл). +- Единый kill-switch, поэтапный раскат, условность только для self-hosting — без регрессий + для не-self репо. + +**Минусы / ограничения** +- Латентность ребра растёт: +`docker build` staging + recreate 8501 + повторный + `staging_check` перед Phase A. Приемлемо (выполняется в monitor-треде, как merge-gate + re-test; bounded timeouts). +- `staging_check` гоняется дважды (soft pre-check агента + авторитетный код) — осознанная + плата за AC-4. Возможная будущая оптимизация: облегчить шаг 3 до health+revision-smoke, + если merge-gate re-test признать достаточным для кода. +- Требуется host-доступ к `docker build`/`compose` под slin (как для `--deploy`) и writable + build-context (worktree) — заложено инфра-требованиями (07). +- Новая под-компонента (QG `check_staging_image_fresh` + режим хука `--build-staging`) → + `arch:major-change`. + +## Альтернативы (отклонены) + +- **Только B.** Deadlock без авто-пересборки (BR-5/AC-6). ❌ +- **Только A.** Нет утверждения в момент retag → гонка/отключение снова даёт тихий промоут + (AC-2/AC-3). ❌ +- **Rebuild в хуке на Phase B (прод-сторона).** Уничтожает BUILD-ONCE (прод-rebuild) и + промоутит образ, который staging-e2e никогда не валидировал. ❌ +- **Rebuild напрямую из контейнера через docker.sock.** В образе нет docker CLI/compose; + staging-операции и так host-side (ssh). ❌ + +## Связанные ADR +Глобальный: `docs/architecture/adr/adr-0008-staging-image-provenance.md`. +`adr-0007-executable-self-deploy` (ORCH-36, BUILD-ONCE), `adr-0006-merge-gate` (ORCH-43, +образец edge-под-гейта), `adr-0003-staging-gate` (ORCH-35, условность), `adr-0005` +(run-as-host-uid). diff --git a/docs/work-items/ORCH-058/07-infra-requirements.md b/docs/work-items/ORCH-058/07-infra-requirements.md new file mode 100644 index 0000000..6b182a1 --- /dev/null +++ b/docs/work-items/ORCH-058/07-infra-requirements.md @@ -0,0 +1,71 @@ +# Инфра-требования — ORCH-058 + +Work Item ID: ORCH-058 + +Топология не меняется (тот же сервер mva154, те же контейнеры 8500/8501, общая БД). Меняется +**что делает self-deploy на ребре `deploy-staging → deploy`** для self-hosting. Полная +топология/риски — `docs/operations/INFRA.md` (обновить в том же PR). + +## IR-1. Host-сборка staging-образа (Стратегия A) + +Шаг свежести пересобирает `orchestrator-orchestrator-staging` на ХОСТЕ (docker CLI/compose +есть на хосте, НЕ в контейнере — образ ставит только `openssh-client git`). Требуется: + +- Рабочий ssh `slin@127.0.0.1` (уже есть, ORCH-36 / LESSONS п.1–2: passwd-запись uid 1000, + ключ смонтирован, `ORCH_DEPLOY_*` префиксы). +- На хосте под `slin` доступны `docker build` и `docker compose --profile staging` + (recreate 8501). Группа docker (`group_add: "999"` / host-доступ к `docker.sock`) — уже + настроено. +- **Build-context = host-путь worktree** валидированной ветки + (`/home/slin/repos/_wt//`), читаемый под `slin`. Worktree уже + создаётся launcher'ом/merge-gate под slin (ADR-0005 run-as-host-uid) — права ок. +- Лог-директория хука writable под slin (`/var/log/orchestrator`, LESSONS п.3) — уже. + +## IR-2. Вывод host-пути worktree + +В контейнере worktree виден как `ORCH_WORKTREES_DIR=/repos/_wt/...`; на хосте — как +`/home/slin/repos/_wt/...`. Маппинг = замена `repos_dir → host_repos_dir` (как +`self_deploy.host_state_dir`). Реализация: производный helper host-worktree-пути, либо новая +настройка `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`). Без неё — деривация из +`host_repos_dir`. + +## IR-3. OCI-лейбл происхождения (Стратегия B) + +`Dockerfile`: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`. Сборки БЕЗ +build-arg (ручные/legacy) дают пустой лейбл → B fail-closed (это by design, не регрессия: +прод-retag без доказуемого провенанса должен падать). Любой существующий способ сборки прод/ +staging-образа (CI, ручной) при включённой фиче ОБЯЗАН передавать `--build-arg GIT_SHA=`, +иначе деплой задачи fail-fast'нется на guard. Шаг A это делает автоматически. + +## IR-4. ssh-режим хука `--build-staging` + +Новый режим `orchestrator-deploy-hook.sh --build-staging` запускается синхронно (рестарт +staging безопасен, detached/finalizer не нужны — в отличие от Phase B прод). Дефолты режима — +STAGING-safe (`TARGET_PORT=8501`, `--profile staging`). Прод (8500) этим режимом НЕ +затрагивается. + +## IR-5. Конфигурация (env, префикс `ORCH_`) + +- `ORCH_IMAGE_FRESHNESS_ENABLED` (дефолт true) — единый kill-switch A+B. +- `ORCH_IMAGE_FRESHNESS_REPOS` (дефолт пусто → self-hosting). +- (опц.) `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`). + +`EXPECTED_REVISION` для хука строится в `build_deploy_command` — отдельной настройки не +требует. `deploy_prod_source_image` (= `orchestrator-orchestrator-staging`) переиспользуется. + +## IR-6. Безопасность self-hosting (инварианты) + +- Любые `docker build` / `compose up` / recreate — ТОЛЬКО staging (8501); прод (8500) не + рестартуется в рамках шага свежести. +- `main` не пушится; force-only — `--force-with-lease` на ветку задачи (merge-gate, без + изменений). Шаг A не пушит ничего (только локальный `docker build`). +- B-guard срабатывает ДО `docker tag`/restart — прод не трогается на сомнении. + +## IR-7. Bootstrap-чеклист (урок ORCH-36 «сквозной») + +Перед мержем ORCH-058 — **реальный** прогон в staging-петле (не только бумажные гейты): +сборка staging из worktree с GIT_SHA → лейбл присутствует +(`docker image inspect ... revision`) → recreate 8501 → `staging_check` зелёный → +`build_deploy_command` отдаёт непустой `EXPECTED_REVISION` → хук-guard пропускает при +совпадении и `exit 1` при подмене `SOURCE_IMAGE` на устаревший. Зафиксировать в bootstrap- +заметке (как LESSONS_ORCH-036). diff --git a/docs/work-items/ORCH-058/10-tech-risks.md b/docs/work-items/ORCH-058/10-tech-risks.md new file mode 100644 index 0000000..b488ef9 --- /dev/null +++ b/docs/work-items/ORCH-058/10-tech-risks.md @@ -0,0 +1,16 @@ +# Технические риски — ORCH-058 + +Work Item ID: ORCH-058 + +| ID | Риск | Вероятность / Влияние | Митигация | +|----|------|----------------------|-----------| +| R-1 | **Полу-конфигурация «B без A»** → вечный fail-fast деплоя (B падает, никто не пересобирает) | Низк. / Высок. (deadlock, BR-5) | Единый kill-switch `image_freshness_enabled` включает/выключает A и B **как целое**; раздельных флагов A/B нет. Дефолт — оба включены. AC-6. | +| R-2 | **Рассинхрон якоря**: merge-gate делает rebase ПОСЛЕ того, как агент прогнал staging_check → HEAD изменился | Сред. / Сред. | Якорь берётся ПОСЛЕ merge-gate; шаг A пересобирает из post-rebase HEAD; авторитетный staging_check — против свежего образа. Pre-check агента — soft. | +| R-3 | **Гонка**: между пересборкой A и Phase B human-approve worktree HEAD сместился | Низк. / Высок. | B сверяет лейбл образа с `EXPECTED_REVISION`=validated_revision на момент Phase B; рассинхрон → fail-closed `exit 1`, прод не трогается. AC-2/AC-3. | +| R-4 | **Пустой лейбл** (ручная/legacy/CI-сборка без `--build-arg GIT_SHA`) | Сред. / Высок. | Fail-closed: пустой лейбл → несоответствие → `exit 1`. By design. Шаг A всегда передаёт GIT_SHA. IR-3 фиксирует требование к любым сборкам. | +| R-5 | **Латентность ребра**: +docker build staging +recreate +повторный staging_check перед approve | Высок. / Низк. | Bounded timeouts; выполняется в monitor-треде (как merge-gate re-test). `staging_check --mode stub` без LLM-трат. Приемлемо. | +| R-6 | **Сборка/recreate случайно затронет прод (8500)** | Низк. / Критич. | Режим хука `--build-staging` со STAGING-safe дефолтами (8501, `--profile staging`); код шага A никогда не передаёт прод-параметры. AC-9. Тест-инвариант: цель != прод. | +| R-7 | **docker build на хосте падает** (нет места, недоступен daemon, битый worktree) | Низк. / Сред. | never-raise: `check_staging_image_fresh` → `(False, reason)` + release lease → откат на `development` (не зависание, не тихий промоут). AC-8. | +| R-8 | **Двойной staging_check** воспринят как баг/лишняя трата | Сред. / Низк. | Документировано как намеренное (soft pre-check агента vs авторитетный код против промоутимого образа). Будущая оптимизация — облегчить шаг A. | +| R-9 | **Самохостинг-bootstrap**: фича не действует, пока сама не в проде (старый прод-образ без лейбла) | Высок. (однократно) / Сред. | Bootstrap-чеклист (IR-7): первый реальный staging-прогон + ручной разрыв; B обратносовместим (без `EXPECTED_REVISION` — старое поведение), раскат поэтапный через флаг. | +| R-10 | **Деградация не-self репо** | Низк. / Высок. | Условность (`image_freshness_repos` пусто → только orchestrator); для прочих — `(True, "N/A")` + хук без `EXPECTED_REVISION` = прежний путь. AC-5. | diff --git a/scripts/orchestrator-deploy-hook.sh b/scripts/orchestrator-deploy-hook.sh index c6d8ca8..f72af9c 100755 --- a/scripts/orchestrator-deploy-hook.sh +++ b/scripts/orchestrator-deploy-hook.sh @@ -13,11 +13,25 @@ # When set, the prevalidated (staging) image is retagged onto # TARGET_IMAGE instead of rebuilding — guarantees prod runs the # exact artefact that passed staging (no `docker build`). +# EXPECTED_REVISION- expected git SHA of SOURCE_IMAGE (default: unset; ORCH-58) +# Strategy B fail-closed provenance guard: when set, the +# SOURCE_IMAGE's org.opencontainers.image.revision label MUST +# equal this value before the BUILD-ONCE retag, else exit 1 +# (a stale image is never promoted). Unset -> no check (legacy). +# GIT_SHA - build-arg for --build-staging (default: unset; ORCH-58) +# BUILD_CONTEXT - docker build context dir (default: $REPO; --build-staging) +# STAGING_CONTAINER- container to docker-exec staging_check in (--build-staging; +# default: $TARGET_SERVICE → orchestrator-staging; ORCH-58) +# STAGING_CHECK_PATH- staging_check.py path inside that container (--build-staging; +# default: /repos/orchestrator/scripts/staging_check.py; ORCH-58) +# STAGING_CHECK_MODE- staging_check mode stub|full-real (--build-staging; +# default: stub — fast, no LLM spend; ORCH-58) # LOG - log file path (default: /var/log/orchestrator/deploy-hook.log) # # Usage: -# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default) -# ./orchestrator-deploy-hook.sh --rollback # manual rollback +# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default) +# ./orchestrator-deploy-hook.sh --rollback # manual rollback +# ./orchestrator-deploy-hook.sh --build-staging # ORCH-58: rebuild staging image (8501) set -euo pipefail @@ -32,6 +46,11 @@ PREV_IMAGE_FILE="${PREV_IMAGE_FILE:-$REPO/.deploy-prev-image-staging}" # Build-once (ORCH-36): optional prevalidated source image to retag onto # TARGET_IMAGE. Unset -> backward-compatible (no retag), exit-code contract intact. SOURCE_IMAGE="${SOURCE_IMAGE:-}" +# Provenance guard (ORCH-58, Strategy B): expected git SHA of SOURCE_IMAGE. Unset +# -> backward-compatible (no provenance check), exit-code contract intact. +EXPECTED_REVISION="${EXPECTED_REVISION:-}" +# The OCI-standard label key the Dockerfile stamps with the build commit. +REVISION_LABEL="org.opencontainers.image.revision" # ---- Log setup ------------------------------------------------------------- LOG_DIR=/var/log/orchestrator @@ -129,6 +148,57 @@ if [[ "${1:-}" == "--rollback" ]]; then fi fi +# ============================================================================ +# --build-staging mode (ORCH-58, Strategy A): rebuild the STAGING image from the +# VALIDATED commit, recreate 8501, and run the AUTHORITATIVE staging_check against +# the fresh image, so the artefact we validate is the exact one later BUILD-ONCE +# retagged to prod (INV-FRESH, AC-4). Builds/recreates STAGING ONLY (8501) — never +# prod (8500). Same exit-code contract (0 = healthy + staging_check PASS). +# GIT_SHA - commit stamped into the image revision label (build-arg). +# BUILD_CONTEXT - docker build context (host worktree of the validated commit). +# Steps: (1) docker build → (2) recreate 8501 → (3a) health-check → +# (3b) staging_check.py --mode stub against the fresh 8501 (ADR-001 step 3). +# ============================================================================ +if [[ "${1:-}" == "--build-staging" ]]; then + BUILD_CONTEXT="${BUILD_CONTEXT:-$REPO}" + GIT_SHA="${GIT_SHA:-}" + log "BUILD-STAGING: rebuilding $TARGET_IMAGE from $BUILD_CONTEXT (GIT_SHA=$GIT_SHA, port=$TARGET_PORT)" + if ! docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT" >> "$LOG" 2>&1; then + log "BUILD-STAGING: docker build failed - aborting (exit 1)" + exit 1 + fi + log "BUILD-STAGING: recreating $TARGET_SERVICE (profile=$COMPOSE_PROFILE) on the fresh image" + if [[ -n "$COMPOSE_PROFILE" ]]; then + docker compose --profile "$COMPOSE_PROFILE" up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1 + else + docker compose up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1 + fi + log "BUILD-STAGING: running health-check on port $TARGET_PORT (10x6s)" + if ! health_check 10 6 "build-staging-health"; then + log "BUILD-STAGING: health FAILED after rebuild (exit 1)" + exit 1 + fi + log "BUILD-STAGING: $TARGET_SERVICE healthy on fresh image" + # (3b) ORCH-58 (Strategy A, step 3 — ADR-001): authoritative e2e validation of + # the FRESH image. Run staging_check.py against the just-rebuilt 8501 INSIDE the + # staging container (ORCH-048 canonical: it reads its OWN staging registry env, so + # B6 is correct; the script lives at /repos/... via bind-mount, not in /app). This + # is the same artefact later BUILD-ONCE retagged to prod, so we validate exactly + # what we promote (AC-4). Any non-zero (FAIL or ORCH_STAGING safety-abort) -> exit 1 + # -> freshness gate FAIL -> rollback to development. Same exit-code contract. + STAGING_CONTAINER="${STAGING_CONTAINER:-$TARGET_SERVICE}" + STAGING_CHECK_PATH="${STAGING_CHECK_PATH:-/repos/orchestrator/scripts/staging_check.py}" + STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}" + log "BUILD-STAGING: running staging_check (--mode $STAGING_CHECK_MODE) against fresh http://localhost:$TARGET_PORT inside $STAGING_CONTAINER" + if docker exec "$STAGING_CONTAINER" python3 "$STAGING_CHECK_PATH" \ + --base-url "http://localhost:$TARGET_PORT" --mode "$STAGING_CHECK_MODE" >> "$LOG" 2>&1; then + log "BUILD-STAGING: staging_check PASS on fresh image (exit 0)" + exit 0 + fi + log "BUILD-STAGING: staging_check FAILED on fresh image - artefact not promotable (exit 1)" + exit 1 +fi + # ============================================================================ # NORMAL DEPLOY mode (--deploy or no argument) # ============================================================================ @@ -156,6 +226,20 @@ git pull origin main >> "$LOG" 2>&1 # Backward compatible: skipped when SOURCE_IMAGE is unset. if [[ -n "$SOURCE_IMAGE" ]]; then if docker image inspect "$SOURCE_IMAGE" >/dev/null 2>&1; then + # ORCH-58 (Strategy B): fail-closed provenance guard BEFORE docker tag. + # When EXPECTED_REVISION is set, SOURCE_IMAGE's git-commit label MUST match, + # else exit 1 (FAILED -> БАГ-8 rollback); prod is NEVER touched. Empty label + # / inspect error / mismatch all fail-close. Unset EXPECTED_REVISION -> no + # check (backward-compatible for non-self repos / legacy calls). + if [[ -n "$EXPECTED_REVISION" ]]; then + IMG_REV=$(docker image inspect --format "{{ index .Config.Labels \"$REVISION_LABEL\" }}" "$SOURCE_IMAGE" 2>/dev/null || true) + if [[ "$IMG_REV" == "" ]]; then IMG_REV=""; fi + if [[ -z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION" ]]; then + log "PROVENANCE: SOURCE_IMAGE revision '$IMG_REV' != expected '$EXPECTED_REVISION' (fail-closed) - aborting (exit 1)" + exit 1 + fi + log "PROVENANCE: SOURCE_IMAGE revision matches expected ($EXPECTED_REVISION) - retag allowed" + fi log "BUILD-ONCE: retagging $SOURCE_IMAGE -> $TARGET_IMAGE (no rebuild)" docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" >> "$LOG" 2>&1 else diff --git a/src/config.py b/src/config.py index df8bcc1..dd30d4a 100644 --- a/src/config.py +++ b/src/config.py @@ -195,6 +195,30 @@ class Settings(BaseSettings): deploy_prod_target_image: str = "orchestrator-orchestrator" deploy_prod_compose_profile: str = "" deploy_prod_prev_image_file: str = ".deploy-prev-image-prod" + + # ORCH-058: staging-image provenance before the BUILD-ONCE retag to prod. + # Closes the INV-FRESH gap (ADR-001): the BUILD-ONCE retag (ORCH-36) promotes + # the staging image to prod WITHOUT a rebuild, assuming the staging image is + # fresh — a guarantee the pipeline never had (a stale image could be silently + # promoted, LESSONS_ORCH-036 §4). Two complementary layers, self-hosting only: + # A (liveness): the QG sub-check check_staging_image_fresh rebuilds the + # staging image from the VALIDATED commit (worktree HEAD after merge-gate) + # and recreates 8501 on the deploy-staging -> deploy edge, so we validate + # and promote ONE artefact. + # B (safety): build_deploy_command passes EXPECTED_REVISION and the hook + # fail-closes (exit 1) if SOURCE_IMAGE's revision label != EXPECTED_REVISION + # before `docker tag`, making a silent stale promote structurally impossible. + # + # image_freshness_enabled -> SINGLE kill-switch for the WHOLE feature (A + B + # together; never "B without A" = a deadlock). False + # -> legacy ORCH-36 behaviour (BUILD-ONCE, no guard, + # no EXPECTED_REVISION). Env ORCH_IMAGE_FRESHNESS_ENABLED. + # image_freshness_repos -> CSV of repos where the feature is REAL; empty -> + # only the self-hosting repo (orchestrator). Mirrors + # self_deploy_repos / merge_gate_repos. + image_freshness_enabled: bool = True + image_freshness_repos: str = "" + # ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background # daemon thread reconciles the "source of truth (gate / Plane) != task stage" # drift left behind by a dropped webhook (502 on rebuild, no Plane/Gitea diff --git a/src/image_freshness.py b/src/image_freshness.py new file mode 100644 index 0000000..fc783d6 --- /dev/null +++ b/src/image_freshness.py @@ -0,0 +1,333 @@ +"""Staging-image provenance for the BUILD-ONCE retag to prod (ORCH-058). + +ORCH-36 made the ``deploy`` stage promote the staging image to prod by a plain +``docker tag`` (BUILD-ONCE, no rebuild), assuming "the staging image is fresh and +built from the validated code". That guarantee never existed: nothing in the +pipeline rebuilt the staging image from the validated commit, so a STALE image +could be silently promoted — the most dangerous bootstrap bug of LESSONS_ORCH-036 +(§4): a green deploy that quietly rolled prod back to 2-day-old code. + +This module provides the deterministic (no-LLM) primitives that enforce the +``INV-FRESH`` invariant (ADR-001), as **two complementary layers** wired only for +self-hosting: + + * **A — liveness:** :func:`check_staging_image_fresh` is a QG sub-check on the + ``deploy-staging -> deploy`` edge (composed by ``stage_engine`` AFTER the + merge-gate, BEFORE Phase A). It rebuilds ``orchestrator-orchestrator-staging`` + from the VALIDATED commit (worktree HEAD after the merge-gate rebase), recreates + the 8501 container, and runs ``staging_check.py --mode stub`` against that fresh + 8501 (ADR-001 step 3), so we validate exactly the ONE artefact later retagged to + prod (AC-4). FAIL -> rollback to ``development`` (mirrors the merge-gate). + * **B — safety:** :func:`expected_revision` feeds the validated SHA to + ``self_deploy.build_deploy_command`` as ``EXPECTED_REVISION``; the host hook + fail-closes (``exit 1``) before ``docker tag`` if the SOURCE_IMAGE revision + label does not match. :func:`provenance_verdict` is the PURE verdict logic + that mirrors the hook's comparison (unit-tested in isolation). + +Both layers share ONE anchor — :func:`validated_revision` — so the build stamp (A) +and the expected revision (B) can never diverge. + +This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily +``qg.checks.is_self_hosting_repo``; it never imports ``stage_engine`` / +``self_deploy``. Every public helper honours a strict **never-raise** contract and +is **fail-closed** on any doubt (missing label, empty SHA, docker/ssh/inspect +error) -> treated as a mismatch, never promoted "on faith". +""" + +import logging +import os +import shlex +import subprocess + +from .config import settings + +logger = logging.getLogger("orchestrator.image_freshness") + +# The OCI-standard label key carrying the build commit (Dockerfile stamps it). +REVISION_LABEL = "org.opencontainers.image.revision" + +# Bounded timeouts so a hung git/docker/ssh never wedges the monitor-thread. +_GIT_TIMEOUT = 30 +_INSPECT_TIMEOUT = 30 +# The remote rebuild (docker build + compose recreate + health + staging_check) is +# the slow path; keep it generous but bounded (mirrors the merge-gate re-test order). +_REBUILD_TIMEOUT = 1200 + +# Explicit STAGING target for the --build-staging rebuild (Strategy A). These mirror +# the hook's staging-safe defaults but are passed EXPLICITLY so a future change to the +# hook defaults can never silently retarget the self-rebuild at prod (8500) — the whole +# path builds/recreates STAGING ONLY (AC-9, review P2). Never the prod 8500 target. +_STAGING_SERVICE = "orchestrator-staging" +_STAGING_PORT = 8501 +_STAGING_COMPOSE_PROFILE = "staging" + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors self_deploy_applies / _merge_gate_applies) +# --------------------------------------------------------------------------- +def image_freshness_applies(repo: str) -> bool: + """Whether the staging-image provenance feature (A + B) is REAL for this repo. + + Mirrors the ORCH-35 / ORCH-43 / ORCH-36 conditional rollout: + * ``image_freshness_enabled=False`` -> always False (single kill-switch for + the WHOLE feature; legacy ORCH-36 BUILD-ONCE behaviour for everyone). + * ``image_freshness_repos`` (CSV) non-empty -> real only for listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``). + Never raises. + """ + try: + if not settings.image_freshness_enabled: + return False + raw = (settings.image_freshness_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + # Lazy import keeps this module a leaf (avoids importing qg at module load). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("image_freshness_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# The validated-commit anchor (single source for both A and B) +# --------------------------------------------------------------------------- +def validated_revision(repo: str, branch: str) -> str: + """Return the SHA of the VALIDATED commit = ``git rev-parse HEAD`` in the task + worktree AFTER the merge-gate (post auto-rebase + push --force-with-lease). + + This is exactly the tree the merge-gate re-tested green and that merges into + ``main``. It is the SINGLE anchor that feeds both the staging rebuild stamp (A) + and the expected revision passed to the hook (B), so the two layers cannot + disagree about "what commit prod must run". + + Fail-closed / never-raise (AC-3 / AC-8): a missing worktree or any git/OS error + returns ``""`` (an empty SHA, which downstream treats as a provenance mismatch), + never a propagated exception. + """ + from .git_worktree import get_worktree_path + + try: + wt = get_worktree_path(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("validated_revision: worktree error for %s/%s: %s", repo, branch, e) + return "" + if not os.path.isdir(wt): + logger.warning("validated_revision: no worktree at %s for %s/%s", wt, repo, branch) + return "" + try: + r = subprocess.run( + ["git", "-C", wt, "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=_GIT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("validated_revision: git error for %s/%s: %s", repo, branch, e) + return "" + if r.returncode != 0: + logger.warning( + "validated_revision: rev-parse rc=%s for %s/%s", r.returncode, repo, branch + ) + return "" + return (r.stdout or "").strip() + + +def expected_revision(repo: str, branch: str) -> str: + """The revision the hook must require (Strategy B), or ``""`` when the feature + is inactive for this repo. + + Returns :func:`validated_revision` ONLY when :func:`image_freshness_applies` + (so non-self / disabled callers get ``""`` -> the hook keeps its backward- + compatible "no provenance check" behaviour, no EXPECTED_REVISION env). The + config invariant (ADR-001) is that B is active iff A is active — both gated by + the SAME flag — so there is never a "B without A" deadlock. Never raises. + """ + try: + if not image_freshness_applies(repo): + return "" + return validated_revision(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("expected_revision error for %s/%s: %s", repo, branch, e) + return "" + + +# --------------------------------------------------------------------------- +# Pure provenance verdict (mirrors the hook's bash comparison — Strategy B) +# --------------------------------------------------------------------------- +def provenance_verdict(expected_sha: str, image_sha: str) -> tuple[bool, str]: + """Pure, deterministic provenance verdict (no I/O) — the Python mirror of the + hook's fail-closed comparison (Strategy B), unit-testable in isolation. + + Contract (AC-1 / AC-2 / AC-3, fail-closed): + * both non-empty AND equal -> ``(True, "provenance match: ")``. + * expected empty / image empty -> ``(False, "...")`` — fail-closed: a + missing expected SHA or an unlabelled image is NEVER treated as fresh. + * both non-empty but different -> ``(False, "provenance mismatch ...")``. + """ + exp = (expected_sha or "").strip() + img = (image_sha or "").strip() + if not exp: + return False, "provenance fail-closed: empty expected revision" + if not img: + return False, "provenance fail-closed: image has no revision label" + if exp == img: + return True, f"provenance match: {exp[:12]}" + return False, f"provenance mismatch: image {img[:12]} != expected {exp[:12]}" + + +def image_revision(image: str, ssh_target: str | None = None) -> str: + """Read an image's ``org.opencontainers.image.revision`` label via + ``docker image inspect``. Returns ``""`` on any error or when the label is + absent (fail-closed -> downstream treats it as a mismatch). + + ``docker`` lives on the HOST (the container ships only ``openssh-client git``), + so when ``ssh_target`` is given the inspect runs over ssh; otherwise it runs + locally (covers host-side callers and tests). Never raises (AC-8). + """ + fmt = '{{ index .Config.Labels "%s" }}' % REVISION_LABEL + local_cmd = ["docker", "image", "inspect", "--format", fmt, image] + if ssh_target: + remote = "docker image inspect --format " + shlex.quote(fmt) + " " + shlex.quote(image) + cmd = ["ssh", "-o", "StrictHostKeyChecking=no", ssh_target, remote] + else: + cmd = local_cmd + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=_INSPECT_TIMEOUT) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("image_revision: inspect error for %s: %s", image, e) + return "" + if r.returncode != 0: + logger.warning("image_revision: inspect rc=%s for %s", r.returncode, image) + return "" + out = (r.stdout or "").strip() + # `docker inspect` prints "" for a missing label key. + if out in ("", ""): + return "" + return out + + +# --------------------------------------------------------------------------- +# Staging rebuild from the validated commit (Strategy A) — host-side via the hook +# --------------------------------------------------------------------------- +def _ssh_target() -> str | None: + """ssh ``user@host`` for the host rebuild, or None when no host is configured + (tests / non-self contexts that mock this away).""" + host = (settings.deploy_ssh_host or "").strip() + if not host: + return None + user = (settings.deploy_ssh_user or "").strip() + return f"{user}@{host}" if user else host + + +def _host_worktree_path(repo: str, branch: str) -> str: + """The task worktree path AS SEEN FROM THE HOST (docker build context). + + The container path uses ``settings.worktrees_dir`` (under ``repos_dir``); the + host sees the same files under ``host_repos_dir``. Derive the host path by + swapping the mount prefix (mirrors ``self_deploy.host_state_dir``). + """ + from .git_worktree import get_worktree_path + + container_wt = get_worktree_path(repo, branch) + repos_dir = settings.repos_dir.rstrip("/") + host_repos_dir = settings.host_repos_dir.rstrip("/") + if container_wt.startswith(repos_dir): + return host_repos_dir + container_wt[len(repos_dir):] + return container_wt + + +def rebuild_staging_image(repo: str, branch: str, sha: str) -> tuple[bool, str]: + """Rebuild the staging image from the VALIDATED commit and recreate 8501 + (Strategy A) by invoking the host hook in ``--build-staging`` mode over ssh. + + The hook (``orchestrator-deploy-hook.sh --build-staging``) runs, on the host: + ``docker build --build-arg GIT_SHA= -t `` + -> ``docker compose --profile staging up -d --no-build orchestrator-staging`` + -> health-check 8501 + -> ``staging_check.py --mode stub`` against the FRESH 8501 (ADR-001 step 3, + AC-4: validate exactly the artefact later retagged to prod). + Same exit-code contract (0 = ok). This trades prod for staging ONLY (8501), + NEVER prod (8500) (AC-9): all build/recreate/validate targets are the staging + service — passed EXPLICITLY below, not left to hook defaults (review P2). + + Synchronous ssh is fine here (unlike Phase B): recreating staging does not kill + the prod worker running this code. Bounded by ``_REBUILD_TIMEOUT``. + + Returns ``(True, msg)`` on a healthy rebuild, else ``(False, reason)``. + Never raises (AC-8). + """ + target = _ssh_target() + if not target: + return False, "no ssh host configured for staging rebuild" + host_ctx = _host_worktree_path(repo, branch) + # Pass the STAGING target explicitly (service/port/profile/container), so the + # rebuild + recreate + staging_check can never drift onto the prod 8500 service + # even if the hook's defaults change (AC-9, review P2). STAGING_CONTAINER is the + # container staging_check is docker-exec'd inside (step 3b). + env_assignments = ( + f"GIT_SHA={shlex.quote(sha)} " + f"BUILD_CONTEXT={shlex.quote(host_ctx)} " + f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_source_image)} " + f"TARGET_SERVICE={shlex.quote(_STAGING_SERVICE)} " + f"TARGET_PORT={shlex.quote(str(_STAGING_PORT))} " + f"COMPOSE_PROFILE={shlex.quote(_STAGING_COMPOSE_PROFILE)} " + f"STAGING_CONTAINER={shlex.quote(_STAGING_SERVICE)}" + ) + inner = ( + f"cd {shlex.quote(settings.deploy_host_repo_path)} && " + f"{env_assignments} " + f"bash {shlex.quote(settings.deploy_hook_script)} --build-staging" + ) + cmd = ["ssh", "-o", "StrictHostKeyChecking=no", target, inner] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=_REBUILD_TIMEOUT) + except subprocess.TimeoutExpired: + return False, f"staging rebuild timeout after {_REBUILD_TIMEOUT}s" + except (subprocess.SubprocessError, OSError) as e: + return False, f"staging rebuild ssh error: {e}" + if r.returncode != 0: + detail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:] + return False, f"staging rebuild failed (rc={r.returncode}): {detail}" + logger.info("rebuild_staging_image: %s/%s rebuilt from %s and healthy", repo, branch, sha[:12]) + return True, f"staging rebuilt from {sha[:12]} and healthy" + + +# --------------------------------------------------------------------------- +# QG sub-check: check_staging_image_fresh (Strategy A liveness, AC-4/AC-6) +# --------------------------------------------------------------------------- +def check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-058 freshness sub-gate on the ``deploy-staging -> deploy`` edge. + + Deterministic, no LLM. Mirrors ``check_branch_mergeable`` (ORCH-043): + 1. Conditionality: ``image_freshness_enabled=False`` -> ``(True, "...disabled")``; + a repo the feature is not real for -> ``(True, "image-freshness N/A for ")``. + 2. Anchor: ``sha = validated_revision(repo, branch)``. Empty -> fail-closed + ``(False, ...)`` (AC-3): we never rebuild/promote without a known commit. + 3. Rebuild the staging image from that commit, recreate 8501, and run + ``staging_check.py --mode stub`` against the fresh 8501 (host hook). PASS -> + ``(True, ...)``: the artefact we just validated (build + e2e) is the exact + one that will be retagged to prod (AC-4, loop closed). FAIL -> ``(False, ...)`` + -> the engine rolls back to ``development`` (AC-2). + + Never-raise (AC-8): any internal error -> ``(False, "")``; an exception + never escapes into ``advance_stage``. Returns ``(True, "N/A")`` for non-self + repos so the deploy edge is unchanged for them (AC-5). + """ + try: + if not settings.image_freshness_enabled: + return True, "image-freshness disabled" + if not image_freshness_applies(repo): + return True, f"image-freshness N/A for {repo}" + + sha = validated_revision(repo, branch) + if not sha: + # Fail-closed: without the validated commit we cannot prove freshness. + return False, "cannot resolve validated revision (fail-closed)" + + ok, reason = rebuild_staging_image(repo, branch, sha) + if not ok: + return False, f"staging rebuild failed: {reason}" + return True, f"staging image fresh ({sha[:12]})" + except Exception as e: # noqa: BLE001 - never-raise contract + logger.error("check_staging_image_fresh error for %s/%s: %s", repo, branch, e) + return False, f"image-freshness error: {e}" diff --git a/src/qg/checks.py b/src/qg/checks.py index 78f5c81..ead2b95 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -702,6 +702,20 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b return False, f"merge-gate error: {e}" +def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-058 freshness sub-gate (Strategy A) on the deploy-staging -> deploy edge. + + Thin registry wrapper that delegates to ``image_freshness.check_staging_image_fresh`` + (rebuild the staging image from the validated commit + recreate 8501). The real + logic lives in ``src/image_freshness.py`` (leaf module, never-raise, fail-closed); + importing it lazily here avoids an import cycle (image_freshness imports + is_self_hosting_repo from this module). For non-self repos it returns + ``(True, "N/A")`` so the deploy edge is unchanged for them (AC-5). + """ + from ..image_freshness import check_staging_image_fresh + return check_staging_image_fresh(repo, work_item_id, branch) + + # Registry for dynamic lookup by name QG_CHECKS = { "check_analysis_approved": check_analysis_approved, @@ -715,4 +729,5 @@ QG_CHECKS = { "check_deploy_status": check_deploy_status, "check_staging_status": check_staging_status, "check_branch_mergeable": check_branch_mergeable, + "check_staging_image_fresh": _check_staging_image_fresh, } diff --git a/src/self_deploy.py b/src/self_deploy.py index 989679a..17a14a7 100644 --- a/src/self_deploy.py +++ b/src/self_deploy.py @@ -230,7 +230,17 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li Build-once (BR-6): ``SOURCE_IMAGE=`` makes the hook retag the staging-validated image to the prod tag instead of rebuilding (no ``docker build``). The exit-code contract of the hook is untouched. + + Provenance guard (ORCH-058, Strategy B): when the image-freshness feature is + active for this repo, the VALIDATED commit SHA is passed as + ``EXPECTED_REVISION=`` so the hook fail-closes (``exit 1``) before + ``docker tag`` if SOURCE_IMAGE's revision label does not match — a stale image + can never be silently promoted. When inactive (non-self / kill-switch off) + ``expected_revision`` returns ``""`` and the env is omitted, keeping the hook's + backward-compatible "no provenance check" behaviour (AC-5 / AC-7). """ + from . import image_freshness + host_dir = host_state_dir(repo, work_item_id) result_sentinel = os.path.join(host_dir, RESULT) hook_log = os.path.join(host_dir, "hook.log") @@ -243,6 +253,9 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} " f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}" ) + expected_rev = image_freshness.expected_revision(repo, branch) + if expected_rev: + env_assignments += f" EXPECTED_REVISION={shlex.quote(expected_rev)}" inner = ( f"cd {shlex.quote(settings.deploy_host_repo_path)} && " f"{env_assignments} " diff --git a/src/stage_engine.py b/src/stage_engine.py index af1a3e4..c9bf7b2 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -271,6 +271,17 @@ def advance_stage( ): return result + # --- ORCH-058 freshness sub-gate (deploy-staging -> deploy edge) --- + # AFTER the merge-gate finalised the validated HEAD and BEFORE Phase A. + # Rebuilds the staging image from that validated commit + recreates 8501 + # so the artefact we validate is the exact one promoted to prod (AC-4). + # FAIL -> rollback to development (mirrors the merge-gate). Like the + # merge-gate it owns the outcome on intervention. + if _handle_image_freshness( + task_id, current_stage, repo, work_item_id, branch, agent, result + ): + return result + # --- ORCH-036 Phase A: request approve before the prod deploy --------- # On the deploy-staging -> deploy edge, AFTER a green check_staging_status # and the merge-gate, the self-hosting repo does NOT auto-launch a prod @@ -878,6 +889,83 @@ def _handle_merge_gate_rollback( ) +# --------------------------------------------------------------------------- +# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge +# --------------------------------------------------------------------------- +def _handle_image_freshness( + task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult +) -> bool: + """Run check_staging_image_fresh on the deploy-staging -> deploy edge (ORCH-058). + + Runs AFTER the merge-gate (validated HEAD finalised) and BEFORE Phase A. The + sub-check rebuilds the staging image from the validated commit + recreates 8501; + a green result means the artefact we validate is the exact one that will be + BUILD-ONCE retagged to prod (AC-4). + + Returns True if the gate INTERVENED (the caller must return without advancing): + * FAIL (stale / rebuild error / fail-closed) -> ROLLBACK to development + (+ developer retry, capped by MAX_DEVELOPER_RETRIES) and RELEASE the merge + lease (the merge-gate held it on its PASS). Mirrors the merge-gate rollback. + Returns False when the gate PASSED (fresh, or N/A for a non-self repo) so + advance_stage proceeds to Phase A. On a PASS the merge lease stays HELD until + the actual merge (released on PR-merged webhook / deploy->done / rollback). + """ + passed, reason = _run_qg("check_staging_image_fresh", repo, work_item_id, branch) + if passed: + logger.info(f"Task {task_id}: image-freshness passed ({reason})") + return False + + result.qg_name = "check_staging_image_fresh" + result.qg_passed = False + result.qg_reason = reason + + update_task_stage(task_id, "development") + notify_stage_change(task_id, current_stage, "development") + plane_notify_stage(work_item_id, current_stage, "development") + result.rolled_back_to = "development" + set_issue_in_progress(work_item_id) + # The merge-gate held the lease on its PASS; freshness failed before the merge, + # so release it (holder-aware no-op if a different task already owns it). + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 - defensive + logger.warning(f"Task {task_id}: merge-lease release on image-freshness fail failed: {e}") + notify_qg_failure(task_id, current_stage, "check_staging_image_fresh", reason) + plane_add_comment( + work_item_id, + f"❌ Staging-образ не свеж ({reason}). Откат на development. " + f"Developer нужен для фикса.", + author="deployer", + ) + retry_count = _developer_retry_count(task_id) + if retry_count < MAX_DEVELOPER_RETRIES: + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: development\nNote: Staging image freshness failed " + f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). " + f"Причина: {reason}." + ) + new_job = enqueue_job("developer", repo, task_desc, task_id=task_id) + result.enqueued_agent = "developer" + result.enqueued_job_id = new_job + logger.info( + f"Task {task_id}: image-freshness FAILED, enqueued developer (job_id={new_job})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after " + f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " + f"Manual intervention needed." + ) + result.alerted = True + logger.error( + f"Task {task_id}: image-freshness FAILED, rolled back deploy-staging -> " + f"development ({reason})" + ) + return True + + # --------------------------------------------------------------------------- # ORCH-036: executable self-deploy (Phase A/B/C) # --------------------------------------------------------------------------- diff --git a/tests/test_config.py b/tests/test_config.py index 012de48..b751be4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -115,3 +115,30 @@ def test_reconcile_settings_env_override(monkeypatch): assert s.reconcile_grace_default_s == 900 assert s.reconcile_grace_overrides_json == '{"development": 300}' assert s.reconcile_notify_unblock is False + + +# --------------------------------------------------------------------------- +# ORCH-058 / TC-13: image-freshness settings defaults + env override. +# --------------------------------------------------------------------------- +_FRESH_ENV = ( + "ORCH_IMAGE_FRESHNESS_ENABLED", + "ORCH_IMAGE_FRESHNESS_REPOS", +) + + +def test_image_freshness_settings_defaults(monkeypatch): + """TC-13 / AC-9: kill-switch ON by default, empty CSV (self-hosting only).""" + for name in _FRESH_ENV: + monkeypatch.delenv(name, raising=False) + s = Settings() + assert s.image_freshness_enabled is True + assert s.image_freshness_repos == "" + + +def test_image_freshness_settings_env_override(monkeypatch): + """TC-13 / AC-9: each field is read from its ORCH_* env var.""" + monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_ENABLED", "false") + monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_REPOS", "orchestrator,enduro-trails") + s = Settings() + assert s.image_freshness_enabled is False + assert s.image_freshness_repos == "orchestrator,enduro-trails" diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index ee91ebd..146a8e4 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -101,7 +101,8 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, - "check_branch_mergeable": _pass}, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _pass}, ) # Spy: the deploy launcher must never run on the staging->deploy edge. initiate = MagicMock() diff --git a/tests/test_deploy_build_once.py b/tests/test_deploy_build_once.py index 1d797a0..a9de36c 100644 --- a/tests/test_deploy_build_once.py +++ b/tests/test_deploy_build_once.py @@ -39,9 +39,56 @@ def test_tc14_hook_retag_branch_present(): assert 'SOURCE_IMAGE="${SOURCE_IMAGE:-}"' in text # Build-once retag branch present; the hook never runs `docker build`. assert 'docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"' in text - # No EXECUTABLE `docker build` line (comments mentioning it are fine). + # No EXECUTABLE `docker build` line on the PROD path (comments are fine). + # ORCH-058: the only build allowed is the staging-freshness rebuild, + # which is explicitly tagged with `--build-arg GIT_SHA` (Strategy A). + # Executable lines only: drop comments and `log "..."` strings that merely + # mention "docker build" in human-readable diagnostics. exec_lines = [ ln.strip() for ln in text.splitlines() - if ln.strip() and not ln.strip().startswith("#") + if ln.strip() + and not ln.strip().startswith("#") + and not ln.strip().startswith("log ") ] - assert not any("docker build" in ln for ln in exec_lines) + for ln in exec_lines: + if "docker build" in ln: + assert "--build-arg GIT_SHA" in ln, ( + f"unexpected docker build on prod retag path: {ln}" + ) + + +# --------------------------------------------------------------------------- +# ORCH-058 TC-06: build_deploy_command threads EXPECTED_REVISION (Strategy B) +# --------------------------------------------------------------------------- +def test_tc06_deploy_command_passes_expected_revision(monkeypatch): + """When image-freshness is active, the prod hook receives EXPECTED_REVISION.""" + from src import image_freshness + monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin") + monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154") + monkeypatch.setattr( + self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging" + ) + monkeypatch.setattr( + image_freshness, "expected_revision", lambda repo, branch: "abc123def456" + ) + + cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x") + remote = cmd[-1] + + assert "EXPECTED_REVISION=abc123def456" in remote + + +def test_tc06_no_expected_revision_when_inactive(monkeypatch): + """When image-freshness resolves to no SHA, EXPECTED_REVISION is omitted.""" + from src import image_freshness + monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin") + monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154") + monkeypatch.setattr( + self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging" + ) + monkeypatch.setattr(image_freshness, "expected_revision", lambda repo, branch: "") + + cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x") + remote = cmd[-1] + + assert "EXPECTED_REVISION=" not in remote diff --git a/tests/test_deploy_hook_mapping.py b/tests/test_deploy_hook_mapping.py index e40d806..122a626 100644 --- a/tests/test_deploy_hook_mapping.py +++ b/tests/test_deploy_hook_mapping.py @@ -27,6 +27,12 @@ def test_tc03_exit2_rollback_also_failed_maps_to_failed(): assert map_exit_code_to_status(2) == "FAILED" +def test_tc09_provenance_fail_closed_exit1_maps_to_failed(): + """ORCH-058 TC-09: the Strategy-B hook fail-close uses `exit 1`; that must map + to FAILED so the existing БАГ-8 rollback path triggers (prod never left stale).""" + assert map_exit_code_to_status(1) == "FAILED" + + def test_other_exit_codes_map_to_failed(): for code in (3, 127, 255, -1): assert map_exit_code_to_status(code) == "FAILED" diff --git a/tests/test_deploy_hook_provenance.py b/tests/test_deploy_hook_provenance.py new file mode 100644 index 0000000..c742763 --- /dev/null +++ b/tests/test_deploy_hook_provenance.py @@ -0,0 +1,159 @@ +"""ORCH-058 TC-07/08: static + caller-contract guarantees of the provenance plumbing. + +These assert the *shape* of the deploy artefacts that can't be unit-tested by +running them (they shell out to docker/ssh on the host): + + * TC-07 — the deploy hook fail-closes BEFORE `docker tag` when the staging + image's git-revision label != EXPECTED_REVISION (exit 1), and the + new `--build-staging` rebuild mode (a) stamps GIT_SHA into the image, + (b) uses $BUILD_CONTEXT as the build context, (c) recreates 8501 + + health-checks, (d) runs staging_check against the FRESH image + (Strategy A step 3, AC-4), and (e) never recomputes GIT_SHA from $REPO. + * TC-08 — the Dockerfile declares `ARG GIT_SHA` and stamps it into the + `org.opencontainers.image.revision` OCI label (the anchor B reads). + * TC-09 — the caller↔hook contract: `rebuild_staging_image` invokes the hook + in `--build-staging` mode with BUILD_CONTEXT=, + GIT_SHA=, and an EXPLICIT staging target (never prod). +""" + +import pathlib + +_ROOT = pathlib.Path(__file__).resolve().parents[1] +_HOOK = _ROOT / "scripts" / "orchestrator-deploy-hook.sh" +_DOCKERFILE = _ROOT / "Dockerfile" + + +# --------------------------------------------------------------------------- +# TC-07: hook fail-closed provenance guard + --build-staging rebuild mode +# --------------------------------------------------------------------------- +def test_tc07_hook_has_fail_closed_provenance_guard(): + text = _HOOK.read_text(encoding="utf-8") + # The label key the hook inspects must be the OCI revision label. + assert 'REVISION_LABEL="org.opencontainers.image.revision"' in text + # EXPECTED_REVISION is read (default unset -> backward compatible). + assert 'EXPECTED_REVISION="${EXPECTED_REVISION:-}"' in text + # The guard must inspect the source image's label and normalise . + assert "docker image inspect --format" in text + assert '""' in text + # Fail-closed: empty OR mismatch -> abort with exit 1. + assert '-z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION"' in text + + +def test_tc07_provenance_guard_precedes_docker_tag(): + """The fail-closed `exit 1` must sit BEFORE the `docker tag` retag line.""" + text = _HOOK.read_text(encoding="utf-8") + guard = text.index("$EXPECTED_REVISION") + retag = text.index('docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"') + assert guard < retag, "provenance guard must run before the prod retag" + + +def test_tc07_build_staging_mode_stamps_git_sha(): + text = _HOOK.read_text(encoding="utf-8") + # The new Strategy-A rebuild mode exists and is keyed on --build-staging. + assert '"${1:-}" == "--build-staging"' in text + # It rebuilds the staging image stamping the validated commit as a build-arg. + assert 'docker build --build-arg GIT_SHA="$GIT_SHA"' in text + + +def test_tc07_build_staging_uses_build_context_and_recreates_8501(): + """The rebuild must use $BUILD_CONTEXT as the docker build context and recreate + the staging service with a health-check (not a bare build).""" + text = _HOOK.read_text(encoding="utf-8") + # $BUILD_CONTEXT is the build context of the rebuild (validated worktree). + assert 'docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT"' in text + # Recreate the staging service on the fresh image (no-build) + health-check. + assert 'up -d --no-build "$TARGET_SERVICE"' in text + assert 'health_check 10 6 "build-staging-health"' in text + + +def test_tc07_build_staging_does_not_recompute_git_sha_from_repo(): + """Regression guard (root cause of the silent-stale-promote class): the + --build-staging mode must NOT derive GIT_SHA itself from the prod $REPO clone — + it must consume the GIT_SHA passed in by the caller (the validated commit).""" + text = _HOOK.read_text(encoding="utf-8") + # Anchor on the actual block guard (not the header comment mentions). + after = text[text.index('"${1:-}" == "--build-staging"'):] + assert 'GIT_SHA="${GIT_SHA:-}"' in after + assert "git rev-parse" not in after, "GIT_SHA must come from the caller, not the prod clone" + + +def test_tc07_build_staging_runs_staging_check_against_fresh_image(): + """Strategy A step 3 (ADR-001, AC-4): after recreate+health, the FRESH image is + validated by staging_check.py (not health-only). This is the P1 the reviewer + flagged: validate exactly the artefact later retagged to prod.""" + text = _HOOK.read_text(encoding="utf-8") + # Anchor on the actual block guard (not the header comment mentions). + after = text[text.index('"${1:-}" == "--build-staging"'):] + # staging_check is invoked, inside the staging container, --mode stub by default. + assert "staging_check.py" in after + assert 'docker exec "$STAGING_CONTAINER"' in after + assert '--mode "$STAGING_CHECK_MODE"' in after + assert 'STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"' in after + # The staging_check run must come AFTER the health-check (health gates readiness). + assert after.index('health_check 10 6 "build-staging-health"') < after.index("staging_check.py") + + +# --------------------------------------------------------------------------- +# TC-08: Dockerfile stamps the OCI revision label from a build-arg +# --------------------------------------------------------------------------- +def test_tc08_dockerfile_stamps_revision_label(): + text = _DOCKERFILE.read_text(encoding="utf-8") + assert "ARG GIT_SHA" in text + assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text + + +# --------------------------------------------------------------------------- +# TC-09: caller↔hook contract — rebuild_staging_image builds the right command +# --------------------------------------------------------------------------- +def test_tc09_rebuild_staging_image_passes_validated_context_and_staging_target(monkeypatch): + """`rebuild_staging_image` must invoke the hook `--build-staging` over ssh with + BUILD_CONTEXT=, GIT_SHA=, and an EXPLICIT staging + target (service/port/profile/container) — never the prod 8500 target. The absence + of this contract test is what hid the earlier P0s (review P2).""" + import src.image_freshness as imgf + + captured = {} + + class _FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def _fake_run(cmd, *a, **kw): + captured["cmd"] = cmd + return _FakeCompleted() + + monkeypatch.setattr(imgf, "_ssh_target", lambda: "slin@host") + monkeypatch.setattr(imgf, "_host_worktree_path", + lambda repo, branch: "/home/slin/repos/_wt/orchestrator/feature_X") + monkeypatch.setattr(imgf.subprocess, "run", _fake_run) + + ok, msg = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123def456") + assert ok, msg + + cmd = captured["cmd"] + assert cmd[0] == "ssh" + inner = cmd[-1] # the remote shell command string + # Validated commit + validated worktree as build context. + assert "GIT_SHA=abc123def456" in inner + assert "BUILD_CONTEXT=/home/slin/repos/_wt/orchestrator/feature_X" in inner + # Explicit STAGING target — never the prod 8500 service/port. + assert "TARGET_SERVICE=orchestrator-staging" in inner + assert "TARGET_PORT=8501" in inner + assert "COMPOSE_PROFILE=staging" in inner + assert "STAGING_CONTAINER=orchestrator-staging" in inner + assert "orchestrator-orchestrator-staging" in inner # staging TARGET_IMAGE + assert "--build-staging" in inner + # Hard safety: the prod service/port must NOT leak into the staging rebuild. + assert "TARGET_PORT=8500" not in inner + assert "TARGET_SERVICE=orchestrator " not in inner + + +def test_tc09_rebuild_staging_image_no_ssh_host_fails_closed(monkeypatch): + """No ssh host configured -> never-raise, fail-closed (False), no command run.""" + import src.image_freshness as imgf + + monkeypatch.setattr(imgf, "_ssh_target", lambda: None) + ok, reason = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123") + assert ok is False + assert "ssh host" in reason diff --git a/tests/test_image_freshness.py b/tests/test_image_freshness.py new file mode 100644 index 0000000..6fef54c --- /dev/null +++ b/tests/test_image_freshness.py @@ -0,0 +1,171 @@ +"""ORCH-058 TC-01..05: staging-image provenance helpers (src/image_freshness.py). + +Covers the INV-FRESH building blocks in isolation: + * TC-01/02/03 — the PURE provenance verdict (match / mismatch / fail-closed). + * TC-04 — never-raise: docker/ssh/git errors -> safe verdict, no exception. + * TC-05 — conditionality: non-self repo = no-op (N/A); self repo = real. +""" + +import os +import subprocess + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from src import image_freshness as imf # noqa: E402 + + +# --------------------------------------------------------------------------- +# TC-01: matching revisions -> fresh (PASS) +# --------------------------------------------------------------------------- +def test_tc01_matching_revisions_are_fresh(): + ok, reason = imf.provenance_verdict("abc123def456", "abc123def456") + assert ok is True + assert "match" in reason.lower() + + +# --------------------------------------------------------------------------- +# TC-02: differing revisions -> NOT fresh (input for fail-fast) +# --------------------------------------------------------------------------- +def test_tc02_differing_revisions_are_not_fresh(): + ok, reason = imf.provenance_verdict("aaaaaaaaaaaa", "bbbbbbbbbbbb") + assert ok is False + assert "mismatch" in reason.lower() + + +# --------------------------------------------------------------------------- +# TC-03: fail-closed — empty label OR empty expected -> never "fresh by default" +# --------------------------------------------------------------------------- +def test_tc03_empty_image_label_fails_closed(): + ok, reason = imf.provenance_verdict("abc123", "") + assert ok is False + assert "fail-closed" in reason.lower() + + +def test_tc03_empty_expected_revision_fails_closed(): + ok, reason = imf.provenance_verdict("", "abc123") + assert ok is False + assert "fail-closed" in reason.lower() + + +def test_tc03_both_empty_fails_closed(): + ok, _ = imf.provenance_verdict("", "") + assert ok is False + + +# --------------------------------------------------------------------------- +# TC-04: never-raise on docker/ssh/inspect/git errors -> safe verdict +# --------------------------------------------------------------------------- +def test_tc04_image_revision_inspect_error_returns_empty(monkeypatch): + def _boom(*a, **k): + raise OSError("docker not found") + monkeypatch.setattr(imf.subprocess, "run", _boom) + # Never raises; fail-closed empty -> downstream provenance mismatch. + assert imf.image_revision("orchestrator-orchestrator-staging") == "" + + +def test_tc04_image_revision_nonzero_rc_returns_empty(monkeypatch): + monkeypatch.setattr( + imf.subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess(a, 1, stdout="", stderr="no such image"), + ) + assert imf.image_revision("missing-image") == "" + + +def test_tc04_image_revision_no_value_label_returns_empty(monkeypatch): + # `docker inspect` prints "" when the label key is absent. + monkeypatch.setattr( + imf.subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="\n", stderr=""), + ) + assert imf.image_revision("unlabelled-image") == "" + + +def test_tc04_validated_revision_missing_worktree_returns_empty(monkeypatch, tmp_path): + # No worktree on disk -> fail-closed empty SHA, never raises. + monkeypatch.setattr(imf.settings, "worktrees_dir", str(tmp_path / "nope")) + monkeypatch.setattr(imf.settings, "repos_dir", str(tmp_path / "nope")) + assert imf.validated_revision("orchestrator", "feature/ORCH-058-x") == "" + + +def test_tc04_check_staging_image_fresh_never_raises(monkeypatch): + # Self repo + enabled, but rebuild blows up -> caught -> safe (False) verdict. + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + monkeypatch.setattr(imf, "validated_revision", lambda r, b: "deadbeef") + + def _boom(*a, **k): + raise RuntimeError("ssh exploded") + monkeypatch.setattr(imf, "rebuild_staging_image", _boom) + ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x") + assert ok is False + assert "error" in reason.lower() + + +# --------------------------------------------------------------------------- +# TC-05: conditionality (self-hosting only) +# --------------------------------------------------------------------------- +def test_tc05_applies_only_to_self_hosting_by_default(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + assert imf.image_freshness_applies("orchestrator") is True + assert imf.image_freshness_applies("enduro-trails") is False + + +def test_tc05_applies_respects_repos_csv(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "enduro-trails") + assert imf.image_freshness_applies("enduro-trails") is True + # CSV is authoritative: orchestrator not listed -> not real. + assert imf.image_freshness_applies("orchestrator") is False + + +def test_tc05_kill_switch_disables_for_everyone(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", False) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + assert imf.image_freshness_applies("orchestrator") is False + + +def test_tc05_check_is_noop_for_non_self_repo(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + ok, reason = imf.check_staging_image_fresh("enduro-trails", "ET-001", "feature/ET-001-x") + assert ok is True + assert "N/A" in reason + + +def test_tc05_check_disabled_is_pass(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", False) + ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x") + assert ok is True + assert "disabled" in reason.lower() + + +def test_tc05_check_real_for_self_repo_rebuilds(monkeypatch): + # Self repo + enabled: validated commit resolved + rebuild OK -> fresh PASS. + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + monkeypatch.setattr(imf, "validated_revision", lambda r, b: "abc123def456") + monkeypatch.setattr(imf, "rebuild_staging_image", lambda r, b, s: (True, "healthy")) + ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x") + assert ok is True + assert "abc123def456"[:12] in reason + + +def test_tc05_check_fail_closed_when_no_validated_revision(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + monkeypatch.setattr(imf, "validated_revision", lambda r, b: "") + ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x") + assert ok is False + assert "fail-closed" in reason.lower() + + +def test_tc05_check_fails_when_rebuild_fails(monkeypatch): + monkeypatch.setattr(imf.settings, "image_freshness_enabled", True) + monkeypatch.setattr(imf.settings, "image_freshness_repos", "") + monkeypatch.setattr(imf, "validated_revision", lambda r, b: "abc123def456") + monkeypatch.setattr(imf, "rebuild_staging_image", lambda r, b, s: (False, "build error")) + ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x") + assert ok is False + assert "rebuild failed" in reason.lower() diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 71ee2d0..5270bbc 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -29,6 +29,7 @@ _EXPECTED_QGS = { "check_deploy_status", "check_staging_status", "check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge) + "check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge) } diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index 89229b5..f229141 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -832,7 +832,8 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, - "check_branch_mergeable": _pass}, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _pass}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", branch="feature/ORCH-043-x") @@ -992,6 +993,114 @@ class TestMergeGate: assert _stage(task_id) == "deploy" +class TestImageFreshnessGate: + """ORCH-058 TC-10/11: the image-freshness sub-gate on the deploy-staging -> + deploy edge. It runs AFTER staging-status + merge-gate, BEFORE Phase A.""" + + def _jobs_full(self): + conn = get_db() + rows = conn.execute( + "SELECT agent, task_content FROM jobs ORDER BY id" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + def test_tc10_stale_image_fails_fast_and_rolls_back(self, monkeypatch): + """TC-10 / AC-1/AC-4: staging status + merge-gate green but the staging + image is STALE -> fail-fast: rollback to development, developer re-queued, + prod NEVER reached (no advance to deploy).""" + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _fail( + "staging rebuild failed: health FAILED")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058", + branch="feature/ORCH-058-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-058", + "feature/ORCH-058-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" # never reached deploy + jobs = self._jobs_full() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "developer" + # The rollback task_desc carries the freshness reason for the developer. + assert "staging rebuild failed" in jobs[0]["task_content"] + + def test_tc10_stale_rollback_respects_max_retries(self, monkeypatch): + """AC-1: image-freshness rollback is capped by MAX_DEVELOPER_RETRIES — + 4th attempt -> block + alert, no new developer job.""" + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _fail("provenance mismatch")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058", + branch="feature/ORCH-058-x") + _add_developer_runs(task_id, 3) # already at the cap + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-058", + "feature/ORCH-058-x", finished_agent="deployer", + ) + assert res.rolled_back_to == "development" + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + assert _jobs() == [] # no developer job past the cap + + def test_tc11_fresh_image_advances_to_deploy(self, monkeypatch): + """TC-11 / AC-1/AC-4: all three sub-checks green -> advance to deploy, + deployer enqueued, NO rollback (happy path).""" + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _pass}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058", + branch="feature/ORCH-058-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-058", + "feature/ORCH-058-x", finished_agent="deployer", + ) + assert res.advanced is True + assert res.to_stage == "deploy" + assert _stage(task_id) == "deploy" + assert res.rolled_back_to is None + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "deployer" + + def test_tc11_non_self_repo_skips_freshness_gate(self, monkeypatch): + """Regression: for a non-self repo the REAL freshness gate is a no-op + (N/A), so deploy-staging -> deploy advances exactly as before ORCH-058.""" + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass}, + ) # check_staging_image_fresh left REAL -> N/A for enduro-trails + task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099", + branch="feature/ET-099-x") + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ET-099", + "feature/ET-099-x", finished_agent="deployer", + ) + assert res.advanced is True + assert _stage(task_id) == "deploy" + + class TestDelegation: def test_launcher_calls_engine(self): from src.agents.launcher import AgentLauncher