Files
orchestrator/docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md

14 KiB
Raw Blame History

ADR-001: Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)

Work Item: ORCH-036 Stage: architecture Автор: architect Дата: 2026-06-06

Статус

Accepted

Контекст

Стадия deploy сейчас «бумажная»: deployer-агент (LLM) пишет в 14-deploy-log.md deploy_status: SUCCESS|FAILED, а гейт check_deploy_status (src/qg/checks.py:464) парсит этот вердикт и двигает deploy → done. Реального docker-деплоя нет (прод катается руками). BRD ORCH-36 требует превратить стадию в РЕАЛЬНЫЙ самодеплой с обязательным ручным approve, build-once и авто-rollback (BR-1…BR-10).

Три твёрдых ограничения, разведанных в коде, определяют дизайн:

  1. Self-restart (BR-2). Прод-контейнер orchestrator (8500) — ОДИН на все проекты, и в нём же исполняется deployer. docker compose up -d orchestrator из контейнера убьёт процесс агента/воркера на середине. Реальный рестарт обязан делать ВНЕШНИЙ процесс на хосте, переживающий гибель контейнера.
  2. Status-only verdict model. Комментарии Plane НЕ управляют конвейером — механизм :approved:/:rejected: был удалён (src/webhooks/plane.py:544, bug-3 «echo self-hit»). Единственный человеческий гейт — смена статуса Plane на Approved (handle_verdict_try_advance_stageadvance_stage).
  3. Гонка чтения гейта. Так как реальный рестарт асинхронный и убивает контейнер, check_deploy_status нельзя выполнять на выходе агента — вердикта ещё нет; его преждевременное чтение → ложный FAILED → ложный откат.

Контракты, которые НЕ меняются (BR-9, AC-10): STAGE_TRANSITIONS, check_deploy_status / _parse_deploy_status (frontmatter only), откат БАГ-8 (deploy → development), terminal-sync deploy → done, merge-gate (ORCH-43), exit-code-контракт хука (0/1/2).

Решение

Деплой стадии deploy для self-hosting (orchestrator) разбивается на три фазы, оркеструемые детерминированным кодом (без LLM в критическом пути self-restart). Для НЕ-self репо (enduro-trails и пр.) поведение НЕ меняется — прежний синхронный ssh-деплой агентом.

Условность по репо

Вся новая логика гейтится is_self_hosting_repo(repo) (как ORCH-35). Не-self репо идут существующим путём: deployer-агент на стадии deploy делает ssh-деплой синхронно, пишет 14-deploy-log.md, гейт срабатывает на выходе агента.

Фаза A — запрос approve (вход в deploy)

В advance_stage на ребре deploy-staging → deploy (ПОСЛЕ зелёного check_staging_status и merge-gate ORCH-43), для self-hosting + deploy_require_ manual_approve=true:

  • НЕ ставить в очередь прод-deployer (перехватить штатный enqueue_job(get_agent_for_stage("deploy-staging")));
  • выставить issue в approval-pending статус (паттерн set_issue_in_review), написать Plane-коммент «approve для прод-деплоя» + Telegram (BR-5);
  • записать restart-safe маркер approve-requested (sentinel-файл, см. ниже).

Задача остаётся на стадии deploy и ждёт человека. STAGE_TRANSITIONS не меняется.

При deploy_require_manual_approve=false (вне объёма, флаг НЕ выключается в ORCH-36 — AC-12) Фаза A сразу переходит к Фазе B без человеческого гейта. Структурная ветка закладывается, но дефолт true.

Фаза B — инициация деплоя (смена статуса Plane → Approved)

Человек ставит issue в Approved. handle_verdict(approved=True)_try_advance_stageadvance_stage(current_stage="deploy", finished_agent=None). Новая ветка-перехват в advance_stage:

  • условие: current_stage=="deploy" И finished_agent is None (человеческий путь) И self-hosting И approve-флаг И маркер initiated ОТСУТСТВУЕТ;
  • действие: запустить внешний detached host-процесс (см. ниже) и поставить в очередь детерминированный finalizer-job с задержкой; записать маркер initiated (идемпотентность: повторный Approved не запускает деплой дважды); Plane-коммент «прод-деплой стартовал» + Telegram (BR-5);
  • вернуться БЕЗ advance (НЕ запускать check_deploy_status — вердикта ещё нет).

Дискриминатор finished_agent разводит Фазу B (человек, None) и Фазу C (finalizer, "deployer"), поэтому повторное использование advance_stage безопасно.

Фаза C — фиксация вердикта (детерминированный finalizer)

Finalizer-job (claim'ится воркером уже в НОВОМ контейнере после рестарта):

  • читает sentinel result (exit-code хука, записан host-процессом);
  • если result ещё нет и бюджет попыток не исчерпан → defer (повторный finalizer-job с available_at_delay_s, как merge-gate defer); бюджет считается из jobs (LIKE '%deploy-finalize%', restart-safe);
  • если result есть → маппинг exit-code → deploy_status (детерминированный, unit-тестируемый): 0 → SUCCESS, 1|2|иное → FAILED; записать 14-deploy-log.md (frontmatter deploy_status:), смержить в main (паттерн лога), затем вызвать advance_stage(current_stage="deploy", finished_agent="deployer");
  • далее срабатывают СУЩЕСТВУЮЩИЕ контракты: SUCCESS → terminal-sync deploy → done
    • release merge-lease; FAILED → откат БАГ-8 deploy → development + set_issue_blocked + Plane/Telegram (BR-3, AC-4). _parse_deploy_status НЕ меняется.

Механизм detached-запуска: ssh + setsid

Выбор: ssh на хост (slin@DEPLOY_SSH_HOST) с setsid-detached исполнением хука. Обоснование: ssh-ключи уже смонтированы (INFRA P-2), не-self репо уже деплоятся по ssh (единый путь), хук живёт на хосте и под slin имеет полный доступ к docker вне контейнера → переживает рестарт 8500 (BR-2). setsid/nohup + redirect отвязывает удалённый процесс от ssh-канала, чтобы он пережил гибель ssh-клиента при рестарте контейнера. Отвергнуто: вызов через docker.sock изнутри контейнера = ровно мина «убей себя на середине вызова».

Эскиз (точная сборка — за разработчиком):

ssh -o StrictHostKeyChecking=no slin@$DEPLOY_SSH_HOST \
  "setsid bash -c 'cd /home/slin/repos/orchestrator && \
     SOURCE_IMAGE=orchestrator-orchestrator-staging \
     TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
     TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE= \
     PREV_IMAGE_FILE=.deploy-prev-image-prod \
     bash scripts/orchestrator-deploy-hook.sh --deploy; \
     echo \$? > <result-sentinel>' >> <hook.log> 2>&1 </dev/null &"

ssh-команда возвращается сразу; remote-процесс detached. Запись sentinel result делает обёртка (echo $? > result), а НЕ хук — контракт хука нетронут.

Build-once (BR-6, AC-7)

Прод обязан подняться на ОБРАЗЕ, прошедшем staging (а не на пересборке). Решение: расширить хук опциональным SOURCE_IMAGE (обратно совместимо: не задан → текущее поведение). При заданном SOURCE_IMAGE хук ПЕРЕД up -d --no-build делает docker tag $SOURCE_IMAGE $TARGET_IMAGE. Для прод-self: SOURCE_IMAGE=orchestrator-orchestrator-stagingTARGET_IMAGE=orchestrator-orchestrator. Это единственное допустимое изменение хука; exit-code-контракт и дефолтное staging-поведение не меняются. git pull хука обновляет рабочее дерево хоста для будущих сборок, но РАЗВЁРНУТЫЙ артефакт = перетегированный staging-образ.

Restart-safe состояние: sentinel-файлы (без миграции БД)

По образцу merge-lease (<repos_dir>/.merge-lease-<repo>.json) состояние деплоя хранится в файлах под <repos_dir>/.deploy-state-<repo>/<work_item_id>/ (вне git, видны и хосту, и контейнеру через mount /home/slin/repos ↔ /repos):

  • approve-requested — Фаза A выполнена;
  • initiated — Фаза B запущена (idempotency-guard);
  • result — exit-code хука (пишет host-обёртка). Бюджет finalize-defer считается из jobs (restart-safe), новых таблиц/колонок НЕТ (TRZ §4).

Последствия

Плюсы

  • deploy_status: SUCCESS становится ДОКАЗАННЫМ (реальный health-ok хука), не декларацией LLM (BR-1).
  • Self-restart безопасен: рестарт 8500 делает внешний host-процесс; орк себя не убивает (BR-2). Вердикт фиксирует НОВЫЙ контейнер после рестарта.
  • Критический путь self-restart детерминирован (без LLM) — главный выигрыш по безопасности self-hosting; зеркалит детерминизм merge-gate ORCH-43.
  • Approve вписан в существующую status-only модель — restart-safe, аудируемо в Plane, идемпотентно (маркер initiated).
  • Гонка чтения гейта закрыта: гейт читает РЕАЛЬНЫЙ итог через finalizer-defer.
  • Build-once гарантирует «что тестировали — то в проде».
  • Нетронуты: STAGE_TRANSITIONS, реестр QG, _parse_deploy_status, БАГ-8, terminal-sync, merge-gate, контракт хука (exit-code).

Минусы / ограничения

  • Вводится новый детерминированный job-handler в очереди (reserved-agent deploy-finalizer, не-LLM) — расширение dispatch воркера/лаунчера. Контейнированное, но это новая под-компонента → задача помечается arch:major-change.
  • Перехваты в advance_stage усложняют стадию deploy (три ветки по finished_agent/маркерам). Требуется аккуратное покрытие тестами (TC-04…TC-09).
  • Build-once зависит от того, что deploy-staging оставил валидный образ orchestrator-orchestrator-staging; при rebase merge-gate возможен дрейф образ↔main (см. 10-tech-risks R-3).
  • Approve = смена статуса Plane на Approved; человек должен понимать, что на стадии deploy Approved означает «деплой в прод» (документируется в deployer.md и INFRA.md).

Что обязан сделать developer

  1. src/config.py: deploy_require_manual_approve: bool = True + прод-параметры хука/ssh + deploy_finalize_delay_s / deploy_finalize_max_attempts.
  2. src/stage_engine.py: перехваты Фазы A/B + ветка finalizer (Фаза C через advance_stage(..., finished_agent="deployer")).
  3. Очередь: reserved-agent deploy-finalizer (детерминированный handler: read-result | defer | map+write+advance). Маппинг exit→status — отдельная чистая функция (unit TC-01/02/03).
  4. scripts/orchestrator-deploy-hook.sh: опциональный SOURCE_IMAGE retag (обратно совместимо) + прод PREV_IMAGE_FILE.
  5. Уведомления (Plane+Telegram) на initiate/success/rollback (BR-5).
  6. Документация: deployer.md, INFRA.md, DEPLOY_HOOK.md, CHANGELOG.md.
  7. Отладка — только на staging-цели хука; прод 8500 в разработке не трогать.

Связанные решения

  • Глобальный ADR: docs/architecture/adr/adr-0007-executable-self-deploy.md.
  • ORCH-35 staging-gate (adr-0003), ORCH-43 merge-gate (adr-0006), ORCH-21 auto-rollback, ORCH-34 хук, ORCH-40 run-as-host-uid (adr-0005).