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:
13
.env.example
13
.env.example
@@ -72,6 +72,19 @@ ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
|
||||
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
|
||||
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
|
||||
|
||||
# ORCH-058: staging-image provenance before the BUILD-ONCE prod retag (INV-FRESH).
|
||||
# Guarantees the staging image promoted to prod is the EXACT artefact rebuilt from the
|
||||
# validated commit — two layers, self-hosting only:
|
||||
# A (liveness): QG sub-check `check_staging_image_fresh` on the deploy-staging->deploy
|
||||
# edge rebuilds orchestrator-orchestrator-staging from the validated commit + recreates
|
||||
# 8501; FAIL -> rollback to development. (builds/recreate STAGING only, never prod.)
|
||||
# B (safety): the Dockerfile stamps `org.opencontainers.image.revision`; the prod hook
|
||||
# fail-closes (exit 1) before `docker tag` if SOURCE_IMAGE's label != EXPECTED_REVISION.
|
||||
# ENABLED -> single kill-switch for A+B as a WHOLE (never "B without A"); false -> legacy.
|
||||
# REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting (orchestrator).
|
||||
ORCH_IMAGE_FRESHNESS_ENABLED=true
|
||||
ORCH_IMAGE_FRESHNESS_REPOS=
|
||||
|
||||
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon
|
||||
# replays a missed stage transition through the SAME gates/handlers a webhook would,
|
||||
# fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).*
|
||||
|
||||
77
docs/architecture/adr/adr-0008-staging-image-provenance.md
Normal file
77
docs/architecture/adr/adr-0008-staging-image-provenance.md
Normal 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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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` в рамках задачи.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
7
docs/work-items/ORCH-058/00-business-request.md
Normal file
7
docs/work-items/ORCH-058/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Self-deploy: retag берёт устаревший staging-образ (риск тихого регресса)
|
||||
|
||||
Work Item ID: ORCH-058
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
87
docs/work-items/ORCH-058/01-brd.md
Normal file
87
docs/work-items/ORCH-058/01-brd.md
Normal 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`
|
||||
126
docs/work-items/ORCH-058/02-trz.md
Normal file
126
docs/work-items/ORCH-058/02-trz.md
Normal 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, строки ~262–288). **Реестр `STAGE_TRANSITIONS` не меняется.**
|
||||
- `.openclaw/agents/deployer.md` — шаги стадии `deploy-staging` (если выбран rebuild-перед-валидацией).
|
||||
- `docker-compose.yml` — опц. build-args/labels для staging-сервиса (если стратегия rebuild).
|
||||
|
||||
## 4. Требуемые изменения — стратегия A (пересборка из HEAD main перед валидацией)
|
||||
|
||||
A1. Перед прогоном `staging_check.py` стадия `deploy-staging` для `orchestrator` пересобирает
|
||||
образ `orchestrator-orchestrator-staging` из провалидированного коммита (worktree ветки
|
||||
после merge-gate rebase) и пересоздаёт контейнер 8501 на свежем образе.
|
||||
A2. `staging_check.py` гоняется против свежего контейнера; на `SUCCESS` ровно ЭТОТ образ
|
||||
становится `SOURCE_IMAGE` для прод-retag (loop closed).
|
||||
A3. Детерминированно (без LLM в критическом пути): сборку/recreate выполняет код стадии или
|
||||
host-хук в staging-режиме, не агент-деплойер «руками».
|
||||
A4. Безопасность: операция трогает ТОЛЬКО staging (8501), НИКОГДА прод (8500).
|
||||
|
||||
## 5. Требуемые изменения — стратегия B (fail-fast по провенансу образа)
|
||||
|
||||
B1. `Dockerfile`: добавить лейбл происхождения, напр.
|
||||
`LABEL org.opencontainers.image.revision=$GIT_SHA` через `ARG GIT_SHA` (build-arg).
|
||||
B2. Сборка staging-образа (ручная или из стратегии A) проставляет `GIT_SHA` = коммит сборки.
|
||||
B3. `src/self_deploy.py::build_deploy_command`: вычислить ожидаемый SHA провалидированного
|
||||
коммита и пробросить в команду хука новым env (напр. `EXPECTED_REVISION=<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`
|
||||
~268–288). 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.
|
||||
71
docs/work-items/ORCH-058/03-acceptance-criteria.md
Normal file
71
docs/work-items/ORCH-058/03-acceptance-criteria.md
Normal 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: любой тест из плана красный или регрессия существующих.
|
||||
124
docs/work-items/ORCH-058/04-test-plan.yaml
Normal file
124
docs/work-items/ORCH-058/04-test-plan.yaml
Normal 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
|
||||
@@ -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).
|
||||
71
docs/work-items/ORCH-058/07-infra-requirements.md
Normal file
71
docs/work-items/ORCH-058/07-infra-requirements.md
Normal 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 п.1–2: passwd-запись uid 1000,
|
||||
ключ смонтирован, `ORCH_DEPLOY_*` префиксы).
|
||||
- На хосте под `slin` доступны `docker build` и `docker compose --profile staging`
|
||||
(recreate 8501). Группа docker (`group_add: "999"` / host-доступ к `docker.sock`) — уже
|
||||
настроено.
|
||||
- **Build-context = host-путь worktree** валидированной ветки
|
||||
(`/home/slin/repos/_wt/<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).
|
||||
16
docs/work-items/ORCH-058/10-tech-risks.md
Normal file
16
docs/work-items/ORCH-058/10-tech-risks.md
Normal 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. |
|
||||
@@ -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
|
||||
|
||||
@@ -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
333
src/image_freshness.py
Normal 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}"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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} "
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
159
tests/test_deploy_hook_provenance.py
Normal file
159
tests/test_deploy_hook_provenance.py
Normal 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
|
||||
171
tests/test_image_freshness.py
Normal file
171
tests/test_image_freshness.py
Normal 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()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user