Merge pull request 'feat(ORCH-058): staging-image provenance before BUILD-ONCE prod retag (INV-FRESH)' (#57) from feature/ORCH-058-self-deploy-retag-staging into main

This commit was merged in pull request #57.
This commit is contained in:
2026-06-07 13:04:07 +03:00
30 changed files with 1967 additions and 10 deletions

View File

@@ -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

View File

@@ -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`, `<no value>`/ошибка → пусто), `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=<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-файлы (`<repos_dir>/.deploy-state-<repo>/<work_item_id>/`), без миграции БД. Условность как 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: <wi> <stage> разблокирована (потерян 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 <target>` в догнанном 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`.

View File

@@ -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=<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.

View File

@@ -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-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без миграции БД.
Подробнее: [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=<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).*

View File

@@ -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=<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`.

View File

@@ -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`. Несовпадение / пустой лейбл (`<no value>`) / ошибка 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.

View File

@@ -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` в рамках задачи.

View File

@@ -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=<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 |

View File

@@ -0,0 +1,7 @@
# Business Request: Self-deploy: retag берёт устаревший staging-образ (риск тихого регресса)
Work Item ID: ORCH-058
## Description
TBD

View File

@@ -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`

View File

@@ -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, строки ~262288). **Реестр `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=<sha>`).
Новый 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`
~268288). FAIL → откат на `development` (как merge-gate). Реестр стадий неизменен —
это под-гейт ребра, не новая стадия.
- Если выбран чисто хуковый fail-fast (раздел 5) — под-гейт не нужен.
## 7. Изменения API
Нет. Эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) не меняются. Опц.: в снимок
`GET /queue` можно добавить диагностическое поле о свежести образа — НЕ обязательно.
## 8. Изменения схемы БД
Нет. Состояние deploy — sentinel-файлы (`.deploy-state-<repo>/<wi>/`, 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-<slug>.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.

View File

@@ -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: любой тест из плана красный или регрессия существующих.

View File

@@ -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=<sha>
в 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

View File

@@ -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=<sha>` и пересоздаёт контейнер 8501 на
свежем образе (`--no-build`).
3. Прогоняет `staging_check.py --mode stub` против свежего 8501.
Результат: ровно ЭТОТ образ (с лейблом `revision=<sha>`) становится `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=<sha> -t <TARGET_IMAGE> <BUILD_CONTEXT>` →
`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).

View File

@@ -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 п.12: 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/<repo>/<branch-slug>`), читаемый под `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=<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).

View File

@@ -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. |

View File

@@ -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" == "<no value>" ]]; 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

View File

@@ -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

333
src/image_freshness.py Normal file
View File

@@ -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: <sha12>")``.
* 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 "<no value>" for a missing label key.
if out in ("", "<no value>"):
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=<sha> -t <staging-image> <host-worktree>``
-> ``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 <repo>")``.
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, "<reason>")``; 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}"

View File

@@ -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,
}

View File

@@ -230,7 +230,17 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
Build-once (BR-6): ``SOURCE_IMAGE=<staging-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=<sha>`` 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} "

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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"

View File

@@ -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()

View File

@@ -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

View File

@@ -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"

View File

@@ -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=<host-worktree>,
GIT_SHA=<validated 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 <no value>.
assert "docker image inspect --format" in text
assert '"<no value>"' 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=<host-worktree>, GIT_SHA=<validated 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

View File

@@ -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 "<no value>" when the label key is absent.
monkeypatch.setattr(
imf.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="<no value>\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()

View File

@@ -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)
}

View File

@@ -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