Compare commits
1 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
| 01684a89df |
24
.env.example
24
.env.example
@@ -116,27 +116,3 @@ ORCH_RECONCILE_GRACE_DEFAULT_S=600
|
||||
ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
|
||||
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
|
||||
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
|
||||
|
||||
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
|
||||
# terminal deploy->done transition for an applicable repo, a reserved-agent job
|
||||
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a
|
||||
# window and reacts to a degradation the restart-time health-check missed (class
|
||||
# "green deploy, red prod", precedent ET-8). State is in sentinel files
|
||||
# (.post-deploy-state-<repo>/<wi>/), no DB migration.
|
||||
# MONITOR_ENABLED -> global kill-switch; false -> pipeline is 1:1 as before ORCH-021.
|
||||
# REPOS -> CSV of repos where monitoring is REAL; empty -> only self-hosting.
|
||||
# WINDOW_S -> observation window length (~15 min).
|
||||
# INTERVAL_S -> seconds between probe ticks.
|
||||
# FAIL_THRESHOLD -> N CONSECUTIVE health failures -> DEGRADED.
|
||||
# 5XX_THRESHOLD -> window 5xx ratio above this -> DEGRADED.
|
||||
# AUTO_ROLLBACK -> allow auto-rollback; acts ONLY for non-self repos. Self-hosting
|
||||
# is ALWAYS ALERT_ONLY (a tick NEVER restarts the prod container).
|
||||
# BASE_URL -> base URL of the observed prod instance.
|
||||
ORCH_POST_DEPLOY_MONITOR_ENABLED=true
|
||||
ORCH_POST_DEPLOY_REPOS=
|
||||
ORCH_POST_DEPLOY_WINDOW_S=900
|
||||
ORCH_POST_DEPLOY_INTERVAL_S=30
|
||||
ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
|
||||
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
|
||||
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
|
||||
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -47,7 +47,7 @@ created → analysis → architecture → development → review → testing →
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||
|
||||
## Артефакты задачи (`docs/work-items/<plane-id>/`)
|
||||
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021).
|
||||
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
|
||||
|
||||
## Правила для агентов
|
||||
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -20,13 +20,15 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/ ./src/
|
||||
# ORCH-021: do NOT `COPY data/ ./data/`. `data/` is gitignored (SQLite DB dir) and
|
||||
# is provided at runtime as a bind-mount volume (`./data:/app/data`, see
|
||||
# docker-compose.yml) which shadows anything baked into the image — so the COPY was
|
||||
# dead weight. Worse, the ORCH-058 staging rebuild (`check_staging_image_fresh`)
|
||||
# builds with the task *worktree* as the docker build context; a fresh worktree never
|
||||
# contains the untracked `data/`, so `COPY data/` failed `docker build` with exit 1
|
||||
# and bounced the task off `deploy-staging`. We just ensure the mountpoint exists.
|
||||
# ORCH-061: do NOT `COPY data/ ./data/`. `data/` is gitignored (runtime SQLite DB
|
||||
# + backups), so it is ABSENT in every git worktree. The staging-image rebuild of
|
||||
# ORCH-058 (`check_staging_image_fresh` / hook `--build-staging`) uses the task
|
||||
# WORKTREE as the build context, where `data/` does not exist -> `COPY data/`
|
||||
# fails the build (rc=1) -> deploy-staging rolls back to development (the loop this
|
||||
# task fixes). It is also pointless: the DB always arrives via the compose bind
|
||||
# mount (`./data:/app/data` prod, `./data/staging:/app/data` staging), which
|
||||
# overrides anything baked in (and baking the host DB into the image leaks stale
|
||||
# state). Just ensure the mount target exists; sqlite creates the .db file.
|
||||
RUN mkdir -p /app/data
|
||||
ENV PYTHONPATH=/app
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]
|
||||
|
||||
@@ -91,42 +91,6 @@ 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`.
|
||||
|
||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
|
||||
деградация через минуты под трафиком, health `200 ok`, фича сломана). ORCH-021 продлевает
|
||||
ответственность **ЗА** `done`: для применимого репо после терминального перехода армится
|
||||
наблюдение окна `post_deploy_window_s` (~15 мин) с интервалом `post_deploy_interval_s`;
|
||||
деградация фиксируется по детерминированным порогам, при подтверждении — реакция.
|
||||
|
||||
Механизм — **reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`, НЕ
|
||||
стадия и НЕ daemon): арм в `advance_stage` в блоке `next_stage == "done"`
|
||||
(`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность); тик перехватывается в
|
||||
`launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос →
|
||||
append в `series` → классификация → перепостановка с задержкой ИЛИ реакция+артефакт+`done`).
|
||||
Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise): `post_deploy_applies`,
|
||||
`probe_signals` (`/health` 200+`{"status":"ok"}` + доля 5xx на `/status`,`/queue`),
|
||||
`classify` (HEALTHY|DEGRADED — главный предмет юнит-тестов), `decide_action`,
|
||||
sentinel-state, `write_post_deploy_log`.
|
||||
- **Пороги (BR-3):** `DEGRADED` ⇔ `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов
|
||||
health ИЛИ доля 5xx `> post_deploy_5xx_threshold`; одиночный глюк → HEALTHY (нет ложных
|
||||
откатов).
|
||||
- **Реакция:** self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY` (Telegram+Plane, ручной
|
||||
approve; тик НИКОГДА не откатывает/рестартит прод-контейнер); не-self +
|
||||
`post_deploy_auto_rollback=true` → хук `--rollback` (`0→ROLLBACK_OK`,
|
||||
`1/2→ROLLBACK_FAILED`+алерт); дефолт → `ALERT_ONLY`.
|
||||
- **Артефакт** `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/
|
||||
`action_taken`/…) — машиночитаемо для петли уроков ORCH-8; best-effort.
|
||||
- **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец `reconcile`).
|
||||
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync,
|
||||
merge-gate, exit-коды хука (0/1/2), схема БД — НЕ меняются. Restart-safe (sentinel
|
||||
`.post-deploy-state-<repo>/<wi>/` + jobs-очередь). Kill-switch
|
||||
`post_deploy_monitor_enabled`, область `post_deploy_repos` (пусто → self-hosting).
|
||||
Условность как ORCH-35/36/43/58.
|
||||
|
||||
Подробнее: [adr-0010](adr/adr-0010-post-deploy-monitor.md), детально —
|
||||
`docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`.
|
||||
|
||||
### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано)
|
||||
BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод
|
||||
**без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет:
|
||||
@@ -154,6 +118,14 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
|
||||
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
|
||||
|
||||
**Инвариант build-context (ORCH-061):** staging-rebuild собирает образ из **git-воркти**
|
||||
задачи, а воркти содержит только git-tracked файлы. Поэтому `Dockerfile` НЕ должен
|
||||
`COPY` ни одного gitignore-пути — иначе `docker build` падает (rc=1) и `deploy-staging`
|
||||
зацикливается на откате в `development`. В частности `data/` (рантайм-БД + бэкапы)
|
||||
gitignore'нут и приходит исключительно через compose bind-mount (`./data:/app/data`),
|
||||
поэтому образ лишь создаёт каталог монтирования (`RUN mkdir -p /app/data`), а не копирует
|
||||
его. Гард — `tests/test_dockerfile_worktree_buildable.py`.
|
||||
|
||||
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
|
||||
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
|
||||
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
|
||||
@@ -233,7 +205,7 @@ never-raise на единицу работы; тишина при синхрон
|
||||
|--------|------|----------|
|
||||
| GET | `/health` | health check |
|
||||
| GET | `/status` | активные задачи (stage != done) |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs |
|
||||
| POST | `/webhook/plane` | Plane webhook |
|
||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||
|
||||
@@ -247,4 +219,4 @@ never-raise на единицу работы; тишина при синхрон
|
||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
||||
|
||||
---
|
||||
*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата).*
|
||||
*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled).*
|
||||
|
||||
@@ -15,12 +15,11 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 |
|
||||
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
|
||||
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
|
||||
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0010`).
|
||||
> свободный номер (текущий максимум — `0009`).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# adr-0010: Post-deploy мониторинг прода + реакция на деградацию
|
||||
|
||||
- **Статус:** proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-021
|
||||
- **Метка:** `arch:major-change` (новая под-компонента + новый reserved-agent job-kind)
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`
|
||||
|
||||
## Контекст
|
||||
Конвейер заканчивается на `deploy → done`: `check_deploy_status` видит
|
||||
`deploy_status: SUCCESS` → terminal-sync (Plane → Done, release merge-lease), и
|
||||
оркестратор **забывает про прод**. «Успех» сегодня = health-check в момент рестарта
|
||||
(~60с окно в `orchestrator-deploy-hook.sh`). Класс инцидентов «зелёный деплой, красный
|
||||
прод» (прецедент **ET-8**): деградация проявляется через минуты под боевым трафиком,
|
||||
health отвечает `200 ok`, фича сломана. Для self-hosting опасно вдвойне — сломанный
|
||||
прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
|
||||
|
||||
## Решение
|
||||
Продлить ответственность конвейера **ЗА** `done`: после терминального перехода для
|
||||
применимого репо армится пост-деплой наблюдение окна `post_deploy_window_s` (дефолт
|
||||
~15 мин) с интервалом `post_deploy_interval_s`; деградация фиксируется по
|
||||
**детерминированным порогам**, при подтверждении выполняется реакция.
|
||||
|
||||
**Механизм — reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`,
|
||||
ORCH-36), НЕ отдельная стадия и НЕ daemon-поток:
|
||||
- **Арм:** в `stage_engine.advance_stage`, в блоке `next_stage == "done"`, при
|
||||
`post_deploy.post_deploy_applies(repo)` → `post_deploy.arm_monitor(...)` (sentinel
|
||||
`armed` = идемпотентность, первый job через `enqueue_job(available_at_delay_s=...)`).
|
||||
- **Тик:** `launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО
|
||||
`_spawn` → `stage_engine.run_post_deploy_monitor(job)`: один опрос сигналов, append в
|
||||
персистентный `series`, классификация; HEALTHY и окно не истекло → перепостановка с
|
||||
задержкой; иначе → реакция + артефакт + `mark_done`.
|
||||
- **Чистая логика — новый leaf-модуль `src/post_deploy.py`** (never-raise, по образцу
|
||||
`self_deploy.py`/`staging_verdict.py`): `post_deploy_applies`, `probe_signals`
|
||||
(опрос `/health` + доля 5xx на `/status`,`/queue`), `classify` (HEALTHY|DEGRADED —
|
||||
главный предмет юнит-тестов), `decide_action` (NONE|ROLLBACK|ALERT_ONLY с учётом
|
||||
self-hosting), sentinel-state хелперы, `write_post_deploy_log`.
|
||||
|
||||
**Сигналы и пороги (детерминированно, AC-3…AC-6):** `DEGRADED` ⇔ `≥
|
||||
post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health ИЛИ доля 5xx на окне `>
|
||||
post_deploy_5xx_threshold`. Одиночный глюк < порога → HEALTHY (нет ложных откатов).
|
||||
|
||||
**Реакция (BR-4/BR-5):**
|
||||
- **Self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY`:** громкий Telegram + Plane,
|
||||
запрос ручного approve отката. Тик НИКОГДА не откатывает/рестартит прод-контейнер
|
||||
(структурный инвариант). Откат прод-орка, если оператор решит, — только detached
|
||||
host-процесс (`self_deploy.initiate_deploy`), вне тика (MVP).
|
||||
- **Не-self + `post_deploy_auto_rollback=True`:** хук `--rollback` с прод-env; exit
|
||||
`0 → ROLLBACK_OK`, `1/2 → ROLLBACK_FAILED` + громкий алерт.
|
||||
- Дефолт (`auto_rollback=False`) → `ALERT_ONLY`.
|
||||
|
||||
**Артефакт `16-post-deploy-log.md`** (новый) с YAML-frontmatter (`post_deploy_status`,
|
||||
`action_taken`, `window_s`, `checks_total/failed`) — машиночитаемо для петли уроков
|
||||
ORCH-8; best-effort. **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец
|
||||
`reconcile.status()`).
|
||||
|
||||
## Альтернативы
|
||||
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия опросов в памяти не
|
||||
restart-safe (а деплой орка = рестарт); restart-safe-вариант требует тех же sentinel,
|
||||
reserved-agent проще и уже имеет проверенную jobs+sentinel машинерию.
|
||||
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет `STAGE_TRANSITIONS`/
|
||||
`QG_CHECKS`, ломает семантику терминального `done`; наблюдение принципиально ПОСЛЕ
|
||||
`done`.
|
||||
- **Авто-rollback прод-орка из тика** — отклонён (self-hosting safety): групповой риск;
|
||||
контейнер не откатит себя надёжно. Self → alert + ручной approve (как ORCH-54).
|
||||
- **Колонка в `tasks`** — отклонён: миграция на проде; sentinel-файлы restart-safe
|
||||
(как ORCH-36/53/58).
|
||||
|
||||
## Последствия
|
||||
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация =
|
||||
сигнал для ORCH-8.
|
||||
- Реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`), контракт `check_deploy_status`,
|
||||
terminal-sync, merge-gate, exit-code-контракт хука, схема БД — **не меняются**.
|
||||
- Дефолты безопасны: kill-switch on, auto-rollback off, self только alert.
|
||||
- Ограничение: монитор self бежит внутри наблюдаемого прода — полностью wedged
|
||||
контейнер = пропущенный тик/алерт (known MVP gap; внешний watchdog — follow-up).
|
||||
- Self-hosting: тик не рестартит/не роняет прод-контейнер; kill-switch
|
||||
`post_deploy_monitor_enabled` обязателен; поэтапный раскат через `post_deploy_repos`.
|
||||
|
||||
## Связи
|
||||
adr-0007-executable-self-deploy (ORCH-36 — sentinel/detached-host/finalizer образец,
|
||||
`map_exit_code_to_status`), adr-0007-reconciler (ORCH-53 — daemon/`status()` образец,
|
||||
отклонён как основной механизм), adr-0006 (merge-gate — условность/флаги раската),
|
||||
adr-0003 (staging-gate — образец условности), adr-0008 (provenance — `.deploy-prev-image`/
|
||||
хук-откат). Прецедент ET-8. Будущее: ORCH-8 (петля уроков), ORCH-54 (полный авто).
|
||||
@@ -1,7 +0,0 @@
|
||||
# Business Request: [★ высокий] Post-deploy мониторинг прода + авто-rollback при деградации
|
||||
|
||||
Work Item ID: ORCH-021
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
@@ -1,88 +0,0 @@
|
||||
# BRD — ORCH-021: Post-deploy мониторинг прода + авто-rollback при деградации
|
||||
|
||||
Work Item: ORCH-021
|
||||
Приоритет: высокий (★)
|
||||
Источник: предложение Стрим, одобрено Славой (2026-06-04)
|
||||
Стадия: analysis
|
||||
|
||||
## 1. Проблема (Why)
|
||||
|
||||
Сейчас конвейер заканчивается на `deploy → done`: как только `check_deploy_status`
|
||||
видит `deploy_status: SUCCESS`, задача закрывается и оркестратор **забывает про прод**.
|
||||
«Успех» деплоя сегодня означает только то, что health-check в момент рестарта
|
||||
прошёл (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60 секунд.
|
||||
|
||||
**Прямой урок ET-8:** деплой отрапортовал SUCCESS, а на проде фича не работала.
|
||||
Класс инцидентов — «зелёный деплой, красный прод»:
|
||||
- деградация проявляется через минуты, а не в первые 60с (прогрев кэшей, фоновые
|
||||
миграции, отложенные запросы, утечки, рост 5xx под реальным трафиком);
|
||||
- health-эндпоинт отвечает `200 ok`, но ключевая функциональность сломана;
|
||||
- регресс виден только под боевым трафиком, которого нет в момент рестарта.
|
||||
|
||||
После закрытия задачи никакого пригляда за продом нет — деградацию замечает человек
|
||||
постфактум. Для self-hosting это особенно опасно: сломанный прод-орк (8500) обслуживает
|
||||
ВСЕ проекты (enduro-trails) из общего инстанса.
|
||||
|
||||
## 2. Цель (What)
|
||||
|
||||
Продлить ответственность конвейера за прод **после** `deploy → done`: в течение
|
||||
заданного окна наблюдать ключевые сигналы здоровья прода и при доказанной деградации
|
||||
выполнить реакцию (откат на предыдущий образ или громкий алерт с запросом ручного
|
||||
отката). Закрыть класс «зелёный деплой, красный прод».
|
||||
|
||||
Механизм частичного отката уже есть: `do_rollback()` и режим `--rollback` в
|
||||
`scripts/orchestrator-deploy-hook.sh` умеют вернуть предыдущий образ из
|
||||
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`), который сохраняется при каждом деплое.
|
||||
Задача — построить **наблюдение поверх** этого и привязать решение к измеримым порогам.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Owner (Слава)** — принимает риск авто-отката прода; получает алерты.
|
||||
- **Стрим** — инициатор; потребитель сигнала деградации для петли уроков (ORCH-8).
|
||||
- **Другие проекты (enduro-trails)** — косвенно: устойчивость общего инстанса.
|
||||
|
||||
## 4. Бизнес-требования
|
||||
|
||||
| # | Требование | Приоритет |
|
||||
|---|------------|-----------|
|
||||
| BR-1 | После `deploy → done` прод наблюдается в течение конфигурируемого окна (дефолт ~15 мин), а не забывается. | Must |
|
||||
| BR-2 | Деградация определяется по **детерминированным измеримым сигналам**: периодический `/health` (HTTP 200 + `{"status":"ok"}`) и доля HTTP 5xx на ключевых эндпоинтах (`/status`, `/queue`). | Must |
|
||||
| BR-3 | Деградация фиксируется только по **порогам** (N последовательных провалов / окно), а не по разовому сетевому глюку — чтобы не было ложных откатов. | Must |
|
||||
| BR-4 | При подтверждённой деградации система выполняет реакцию: **авто-rollback** на `.deploy-prev-image-prod` (через существующий хук `--rollback`) **либо** громкий алерт с запросом ручного отката — в зависимости от политики репозитория. | Must |
|
||||
| BR-5 | **Self-hosting safety:** для самого `orchestrator` авто-откат прода = рестарт инструмента, обслуживающего все проекты. По умолчанию для self-hosting реакция — **алерт + ручной approve отката** (по образцу deploy Phase A/B), НЕ автоматический откат. Для не-self репозиториев допустим авто-откат. | Must |
|
||||
| BR-6 | Любой исход (наблюдение начато, деградация, откат, откат-провал, окно завершилось чисто) уведомляется в Telegram и комментарием в Plane; результат наблюдения фиксируется артефактом. | Must |
|
||||
| BR-7 | Мониторинг — **restart-safe**: рестарт оркестратора (в т.ч. сам деплой) не теряет и не задваивает наблюдение. Идемпотентность по образцу reconciler / deploy-finalizer. | Must |
|
||||
| BR-8 | Глобальный kill-switch (env-флаг) и список репозиториев, на которые распространяется фича (по образцу `merge_gate_enabled` / `image_freshness_enabled` / `self_deploy_repos`). Выключенный флаг = прежнее поведение (наблюдения нет). | Must |
|
||||
| BR-9 | Наблюдаемость: текущее состояние пост-деплой наблюдения отражается в `GET /queue` (по образцу блока `reconcile`). | Should |
|
||||
| BR-10 | Сигнал деградации пригоден для будущей петли уроков (ORCH-8): фиксируется в артефакте/логе в машиночитаемом виде. | Should |
|
||||
| BR-11 | Доменный smoke результата фичи (проверка, что конкретная фича реально работает) — желателен, но выносится в follow-up; MVP ограничивается health + 5xx. | Could |
|
||||
|
||||
## 5. Вне рамок (Out of scope)
|
||||
- Полноценная система метрик/APM (Prometheus, дашборды) — фича опирается на уже
|
||||
существующие HTTP-эндпоинты, не вводит сбор метрик.
|
||||
- Универсальный доменный smoke для произвольной фичи (BR-11 — follow-up).
|
||||
- Полностью автоматический откат прод-орка без участия человека (противоречит
|
||||
self-hosting safety; отдельная задача при наборе доверия, аналогично ORCH-54 для deploy).
|
||||
- Изменение момента вердикта `deploy_status` / контракта `check_deploy_status`
|
||||
(наблюдение происходит ПОСЛЕ `done`, не заменяет deploy-gate).
|
||||
|
||||
## 6. Связи
|
||||
- **ET-8** — прецедент «deploy SUCCESS, прод не работает». Обоснование задачи.
|
||||
- **ORCH-36** (`docs/architecture/adr/adr-0007-executable-self-deploy.md`) — Phase A/B/C
|
||||
исполняемого самодеплоя; пост-деплой наблюдение продлевает ответственность ЗА `done`,
|
||||
переиспользует sentinel-паттерн и detached-host-процесс для self-rollback.
|
||||
- **ORCH-53** (`src/reconciler.py`) — каноничный паттерн фонового daemon-потока
|
||||
(watchdog), запускаемого в `main.lifespan`; образец для пост-деплой наблюдателя.
|
||||
- **ORCH-58** — `.deploy-prev-image` и хук-механика отката, на которые опирается реакция.
|
||||
- **ORCH-8** — деградация прода = сигнал для петли уроков (BR-10).
|
||||
- **ORCH-12** — фича может оформиться как пост-deploy стадия ИЛИ как watchdog (решение
|
||||
архитектора, см. §7).
|
||||
|
||||
## 7. Открытые архитектурные вопросы (для архитектора, НЕ решаются в анализе)
|
||||
1. **Где живёт наблюдение:** отдельная пост-deploy стадия конвейера vs фоновый
|
||||
watchdog-daemon (по образцу `reconciler`) vs reserved-agent job (по образцу
|
||||
`deploy-finalizer`). Анализ задаёт требования (BR-1, BR-7), выбор механизма — за архитектором.
|
||||
2. **Механизм self-rollback для self-hosting:** откат прод-орка требует detached
|
||||
host-процесса (контейнер не может надёжно откатить себя, умирая) — переиспользовать
|
||||
ли `self_deploy.initiate_deploy` / хук `--rollback`.
|
||||
3. Точные пороги и веса сигналов (BR-3) — анализ предлагает дефолты (см. AC), архитектор
|
||||
фиксирует реализацию.
|
||||
@@ -1,165 +0,0 @@
|
||||
# ТЗ — ORCH-021: Post-deploy мониторинг прода + авто-rollback
|
||||
|
||||
Work Item: ORCH-021
|
||||
Стадия: analysis → (architecture)
|
||||
|
||||
> Документ описывает ТРЕБОВАНИЯ к изменениям и НАЗЫВАЕТ задействованные модули.
|
||||
> Выбор механизма (стадия vs watchdog vs reserved-agent) и точная реализация —
|
||||
> зона архитектора (см. BRD §7). Здесь фиксируется, ЧТО должно измениться и КАКИЕ
|
||||
> контракты НЕЛЬЗЯ ломать.
|
||||
|
||||
## 1. Контекст в коде (как есть сейчас)
|
||||
|
||||
- Конвейер заканчивается в `src/stages.py`: `deploy → done`, gate `check_deploy_status`.
|
||||
Терминальный переход `deploy → done` исполняется в `src/stage_engine.py::advance_stage`
|
||||
(блок «Terminal sync», `set_issue_done`, release merge-lease). После этого ничего
|
||||
не наблюдает за продом.
|
||||
- `scripts/orchestrator-deploy-hook.sh` уже умеет:
|
||||
- `health_check(max_attempts, sleep, label)` — опрос `http://localhost:$TARGET_PORT/health`
|
||||
с проверкой `"status":"ok"`;
|
||||
- `do_rollback()` — retag `PREV_IMAGE_FILE` → `TARGET_IMAGE` + рестарт + пост-rollback
|
||||
health-check; коды возврата 0 (ок) / 1 (нет prev-образа) / 2 (rollback тоже упал);
|
||||
- режим `--rollback` (ручной откат);
|
||||
- при обычном деплое сохраняет `PREV_IMG` в `PREV_IMAGE_FILE`
|
||||
(`.deploy-prev-image-prod` для прода, см. `settings.deploy_prod_prev_image_file`).
|
||||
- Self-deploy прода идёт через detached host-процесс: `src/self_deploy.py`
|
||||
(`build_deploy_command`, `initiate_deploy`, sentinel-маркеры под
|
||||
`.deploy-state-<repo>/<wi>/`, `read_result`, `map_exit_code_to_status`).
|
||||
- Фоновый daemon-паттерн: `src/reconciler.py` (`threading.Thread(daemon=True)` +
|
||||
`threading.Event`, старт/стоп в `src/main.py::lifespan` после `worker.start()` /
|
||||
перед `worker.stop()`, `status()` в `GET /queue`).
|
||||
- Reserved-agent (детерминированный no-LLM job) паттерн: `deploy-finalizer` —
|
||||
перехват в `src/agents/launcher.py::launch_job` ДО `_spawn`, исполнение
|
||||
`stage_engine.run_deploy_finalizer`, отложенная постановка через
|
||||
`enqueue_job(..., available_at_delay_s=...)`.
|
||||
- Условность self-hosting: `src/qg/checks.py::is_self_hosting_repo`,
|
||||
`src/self_deploy.py::self_deploy_applies` (флаг + CSV-репо; пусто → только `orchestrator`).
|
||||
- Наблюдаемые эндпоинты прода (`src/main.py`): `GET /health`, `GET /status`, `GET /queue`.
|
||||
- API БД: `src/db.py::enqueue_job` (с `available_at_delay_s`), `get_db`,
|
||||
`update_task_stage`, `get_active_tasks_for_reconcile`.
|
||||
|
||||
## 2. Требуемые изменения
|
||||
|
||||
### 2.1. Новый leaf-модуль чистой логики наблюдения — `src/post_deploy.py` (новый)
|
||||
Контракт **never-raise** (по образцу `self_deploy.py` / `staging_verdict.py`).
|
||||
Чистые, юнит-тестируемые функции:
|
||||
- **Опрос сигналов:** функция, опрашивающая `/health` и ключевые эндпоинты
|
||||
(`/status`, `/queue`) прод-инстанса (base-url из config), возвращающая структуру
|
||||
с результатами (код ответа, ok-флаг, доля 5xx). Сеть/таймаут → консервативный
|
||||
результат, не исключение.
|
||||
- **Классификация деградации** (чистая, без сети): на вход — серия результатов
|
||||
опросов; на выход — вердикт `HEALTHY | DEGRADED` по порогам (BR-3):
|
||||
`≥ post_deploy_fail_threshold` последовательных провалов health ИЛИ доля 5xx
|
||||
выше `post_deploy_5xx_threshold` на окне. Эта функция — основной предмет
|
||||
юнит-тестов (детерминированная, как `compute_staging_verdict` в ORCH-061).
|
||||
- **Решение о реакции** (чистая): по `(repo, вердикт, политика)` → одно из
|
||||
`NONE | ROLLBACK | ALERT_ONLY`, с учётом self-hosting (BR-5).
|
||||
- **Запись артефакта** результата наблюдения (см. §2.5), best-effort.
|
||||
- Условность: хелпер `post_deploy_applies(repo)` (флаг + CSV-репо, пусто →
|
||||
только self-hosting), по образцу `self_deploy_applies` / `_merge_gate_applies`.
|
||||
|
||||
### 2.2. Оркестрация наблюдения (механизм — выбор архитектора)
|
||||
Требования к механизму (независимо от выбора стадия/watchdog/reserved-agent):
|
||||
- запускается ПОСЛЕ перехода `deploy → done` для применимого репозитория (BR-1);
|
||||
- наблюдает окно `post_deploy_window_s` с интервалом `post_deploy_interval_s`;
|
||||
- **restart-safe и идемпотентен** (BR-7): состояние наблюдения — в sentinel-файлах
|
||||
(по образцу `.deploy-state-<repo>/<wi>/`, напр. маркеры `monitor-started` /
|
||||
`monitor-done`) ИЛИ через отложенные `enqueue_job(available_at_delay_s=...)`;
|
||||
повторный старт не задваивает наблюдение и не теряет его при рестарте;
|
||||
- по итогу вызывает «Решение о реакции» из `src/post_deploy.py` и исполняет реакцию (§2.3).
|
||||
|
||||
Кандидатные точки интеграции (на выбор архитектора, см. BRD §7):
|
||||
- хук в `stage_engine.advance_stage` в блоке `next_stage == "done"` — арм наблюдения;
|
||||
- reserved-agent `post-deploy-monitor` (расширение `launcher.launch_job` ДО `_spawn`,
|
||||
как `deploy-finalizer`), с само-перепостановкой через `available_at_delay_s`;
|
||||
- отдельный daemon-поток `PostDeployWatcher` (как `Reconciler`), старт/стоп в `main.lifespan`.
|
||||
|
||||
### 2.3. Реакция на деградацию
|
||||
- **Не-self репозитории / политика auto:** вызвать существующий хук в режиме отката
|
||||
(`scripts/orchestrator-deploy-hook.sh --rollback` с прод-параметрами окружения,
|
||||
как в `self_deploy.build_deploy_command`, но action=`--rollback`). Маппинг
|
||||
exit-code хука (0/1/2) в исход переиспользует логику `self_deploy.map_exit_code_to_status`
|
||||
по смыслу (0 → откат успешен; 1/2 → откат не выполнен/провалился → громкий алерт).
|
||||
- **Self-hosting (`orchestrator`) по умолчанию (BR-5):** НЕ откатывать автоматически.
|
||||
Сформировать громкий алерт (Telegram + Plane-коммент) и запросить ручной approve
|
||||
отката (по образцу deploy Phase A — статус Plane / Telegram CTA). Откат самого
|
||||
прод-орка, если выполняется, — только через detached host-процесс (нельзя надёжно
|
||||
откатить контейнер, который при этом умирает; переиспользовать механику
|
||||
`self_deploy.initiate_deploy`).
|
||||
- Команда отката для self НЕ должна ронять прод-контейнер в рамках обычного тика
|
||||
наблюдения (CLAUDE.md: не ронять/не рестартить прод-контейнер вне явного действия).
|
||||
|
||||
### 2.4. Конфигурация — `src/config.py` (расширение `Settings`)
|
||||
Добавить (env-префикс `ORCH_`, дефолты безопасные):
|
||||
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8).
|
||||
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting
|
||||
(по образцу `self_deploy_repos` / `merge_gate_repos` / `image_freshness_repos`).
|
||||
- `post_deploy_window_s: int = 900` — длина окна наблюдения (дефолт ~15 мин, BR-1).
|
||||
- `post_deploy_interval_s: int = 30` — интервал между опросами.
|
||||
- `post_deploy_fail_threshold: int = 3` — N последовательных провалов health → DEGRADED.
|
||||
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx на окне → DEGRADED.
|
||||
- `post_deploy_auto_rollback: bool = False` — глобально разрешён ли авто-откат;
|
||||
при `True` действует для не-self репо; для self всегда требует approve (BR-5).
|
||||
- `post_deploy_base_url: str = "http://localhost:8500"` — base-url наблюдаемого прода.
|
||||
- `post_deploy_target` параметры отката — переиспользовать существующие
|
||||
`deploy_prod_*` (service/port/image/prev_image_file), новых дублей не вводить.
|
||||
|
||||
### 2.5. Артефакт задачи — `16-post-deploy-log.md` (новый)
|
||||
В `docs/work-items/<plane-id>/`. YAML-frontmatter (машиночитаемо, канон гейтов;
|
||||
для будущей петли уроков BR-10):
|
||||
```
|
||||
---
|
||||
post_deploy_status: HEALTHY | DEGRADED
|
||||
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
|
||||
work_item: <plane-id>
|
||||
window_s: <int>
|
||||
checks_total: <int>
|
||||
checks_failed: <int>
|
||||
---
|
||||
```
|
||||
Тело — человекочитаемая сводка опросов. Записывается best-effort (по образцу
|
||||
`self_deploy.write_deploy_log`); отсутствие файла не должно ничего ронять.
|
||||
> Артефакт `16-post-deploy-log.md` добавить в перечень артефактов в `CLAUDE.md`
|
||||
> и таблицу/описание в `docs/architecture/README.md` (golden-source, в том же PR).
|
||||
|
||||
### 2.6. Наблюдаемость — `GET /queue` (`src/main.py`) (BR-9)
|
||||
Добавить блок `post_deploy` со снимком состояния (enabled, window, активные
|
||||
наблюдения, последний исход) — по образцу блока `reconcile` (метод `status()`).
|
||||
|
||||
### 2.7. Изменения схемы БД
|
||||
**Не требуются.** Состояние наблюдения — sentinel-файлы (restart-safe, без миграции,
|
||||
по образцу ORCH-36) и/или отложенные jobs. Если архитектор выберет колонку в `tasks`
|
||||
для отметки наблюдения — потребуется миграция; предпочтительно избежать (как ORCH-36/53/58).
|
||||
|
||||
### 2.8. Новые QG checks
|
||||
**Не требуются.** Наблюдение происходит ПОСЛЕ `done` и не является gate'ом стадии;
|
||||
реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются (если архитектор НЕ выберет
|
||||
вариант «отдельная пост-deploy стадия» — тогда потребуется новая стадия+gate, что
|
||||
надо явно отразить в ADR; по умолчанию предпочтителен вариант без изменения реестров).
|
||||
|
||||
## 3. Инварианты (НЕ ломать)
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракт `check_deploy_status` /
|
||||
`_parse_deploy_status`, момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync
|
||||
`deploy → done`, merge-gate, exit-code-контракт хука (0/1/2) — без изменений.
|
||||
- Контракт хука: дефолты STAGING-безопасны; прод-параметры приходят только через env.
|
||||
- Условность как ORCH-35/36/43/58: реально для `orchestrator`/listed-repos, прочие — no-op.
|
||||
- Never-raise: ошибка в наблюдении не роняет worker / lifespan / конвейер других проектов.
|
||||
- Self-hosting: тик наблюдения НИКОГДА не рестартит прод-контейнер сам по себе (BR-5).
|
||||
|
||||
## 4. Задействованные модули (сводка)
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/post_deploy.py` | **новый** — чистая логика опроса/классификации/решения/артефакта, never-raise |
|
||||
| `src/config.py` | +параметры `post_deploy_*` (kill-switch, окно, пороги, политика) |
|
||||
| `src/stage_engine.py` и/или `src/agents/launcher.py` и/или `src/main.py` | арм/исполнение наблюдения (точка — за архитектором) |
|
||||
| `scripts/orchestrator-deploy-hook.sh` | переиспользуется (`--rollback`); правки — только если откат self требует отдельной ветки (за архитектором) |
|
||||
| `src/main.py` | блок `post_deploy` в `GET /queue` (BR-9); возможный старт daemon в `lifespan` |
|
||||
| `docs/work-items/<id>/16-post-deploy-log.md` | **новый** артефакт |
|
||||
| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | обновить (golden-source, в том же PR) |
|
||||
| ADR | `docs/work-items/ORCH-021/06-adr/ADR-001-*.md` (+ возможный сквозной `adr/adr-00NN`) |
|
||||
|
||||
## 5. Артефакты по pipeline, которые должны появиться/обновиться
|
||||
- `16-post-deploy-log.md` (новый, машиночитаемый frontmatter).
|
||||
- Обновлённые `CLAUDE.md` (перечень артефактов), `docs/architecture/README.md`
|
||||
(описание пост-деплой наблюдения), `CHANGELOG.md`.
|
||||
- ADR work-item (`06-adr/`) с зафиксированным выбором механизма и порогов.
|
||||
@@ -1,106 +0,0 @@
|
||||
# Критерии приёмки — ORCH-021
|
||||
|
||||
Work Item: ORCH-021
|
||||
Формат: каждый критерий имеет чёткое условие PASS/FAIL и проверяется тестом
|
||||
из `04-test-plan.yaml`.
|
||||
|
||||
## Наблюдение и сигналы
|
||||
|
||||
### AC-1 — наблюдение армится после deploy→done
|
||||
- **PASS:** для применимого репозитория после терминального перехода `deploy → done`
|
||||
пост-деплой наблюдение инициируется (создаётся sentinel/отложенный job/запись в watcher).
|
||||
- **FAIL:** переход `deploy → done` не приводит к старту наблюдения.
|
||||
|
||||
### AC-2 — наблюдение НЕ армится для неприменимых репо
|
||||
- **PASS:** для репозитория вне области (не self-hosting и не в `post_deploy_repos`)
|
||||
`post_deploy_applies(repo)` → False; наблюдение не стартует; конвейер не меняется.
|
||||
- **FAIL:** наблюдение стартует для неприменимого репо.
|
||||
|
||||
### AC-3 — классификация HEALTHY
|
||||
- **PASS:** серия опросов без провалов (или провалов меньше `post_deploy_fail_threshold`
|
||||
и доля 5xx ниже `post_deploy_5xx_threshold`) → вердикт `HEALTHY`.
|
||||
- **FAIL:** при здоровых сигналах возвращается `DEGRADED`.
|
||||
|
||||
### AC-4 — классификация DEGRADED по порогу провалов health
|
||||
- **PASS:** `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health → `DEGRADED`.
|
||||
- **FAIL:** порог достигнут, но вердикт не `DEGRADED`.
|
||||
|
||||
### AC-5 — классификация DEGRADED по доле 5xx
|
||||
- **PASS:** доля 5xx на окне выше `post_deploy_5xx_threshold` → `DEGRADED`,
|
||||
даже если `/health` отвечает 200.
|
||||
- **FAIL:** превышение порога 5xx не даёт `DEGRADED`.
|
||||
|
||||
### AC-6 — устойчивость к разовому глюку (нет ложного срабатывания)
|
||||
- **PASS:** одиночный провал (1 < `post_deploy_fail_threshold`) с последующим
|
||||
восстановлением → итог `HEALTHY`, реакции нет.
|
||||
- **FAIL:** одиночный разовый провал приводит к `DEGRADED`/откату.
|
||||
|
||||
## Реакция
|
||||
|
||||
### AC-7 — авто-rollback для не-self репо при политике auto
|
||||
- **PASS:** при `post_deploy_auto_rollback=True` и НЕ-self репо вердикт `DEGRADED`
|
||||
приводит к вызову отката (хук `--rollback` с прод-параметрами); `action_taken`
|
||||
фиксируется как `ROLLBACK_OK`/`ROLLBACK_FAILED` по exit-code.
|
||||
- **FAIL:** откат не вызывается, либо вызывается с staging-дефолтами, либо роняет прод напрямую.
|
||||
|
||||
### AC-8 — self-hosting НЕ откатывается автоматически (safety)
|
||||
- **PASS:** для `orchestrator` вердикт `DEGRADED` НЕ приводит к автоматическому
|
||||
откату/рестарту прод-контейнера в тике наблюдения; вместо этого формируется
|
||||
громкий алерт + запрос ручного approve (`action_taken: ALERT_ONLY`).
|
||||
- **FAIL:** тик наблюдения автоматически откатывает/рестартит прод-орк.
|
||||
|
||||
### AC-9 — откат-провал эскалируется
|
||||
- **PASS:** если откат вызван и вернул код 1/2 (нет prev-образа / откат тоже упал) →
|
||||
`action_taken: ROLLBACK_FAILED` + громкий Telegram-алерт о необходимости ручного вмешательства.
|
||||
- **FAIL:** провал отката проглатывается тихо.
|
||||
|
||||
## Конфигурация и совместимость
|
||||
|
||||
### AC-10 — kill-switch выключает фичу
|
||||
- **PASS:** `post_deploy_monitor_enabled=False` → наблюдение не армится ни для кого;
|
||||
поведение конвейера 1:1 как до ORCH-021.
|
||||
- **FAIL:** при выключенном флаге наблюдение всё равно работает.
|
||||
|
||||
### AC-11 — пороги/окно конфигурируемы через env
|
||||
- **PASS:** `post_deploy_window_s`, `post_deploy_interval_s`, `post_deploy_fail_threshold`,
|
||||
`post_deploy_5xx_threshold` читаются из `Settings` (env `ORCH_*`) и влияют на поведение.
|
||||
- **FAIL:** значения захардкожены.
|
||||
|
||||
### AC-12 — реестры и схема БД не изменены
|
||||
- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, контракт `check_deploy_status` и схема
|
||||
таблиц БД не изменены (если архитектор не вводит явно новую стадию — тогда это
|
||||
отражено в ADR и тестах). Существующие тесты deploy/staging/merge-gate зелёные.
|
||||
- **FAIL:** молча сломан какой-либо существующий контракт/тест.
|
||||
|
||||
## Наблюдаемость, артефакт, идемпотентность
|
||||
|
||||
### AC-13 — артефакт 16-post-deploy-log.md с машиночитаемым frontmatter
|
||||
- **PASS:** по итогу наблюдения пишется `16-post-deploy-log.md` с валидным YAML-frontmatter
|
||||
(`post_deploy_status`, `action_taken`); запись best-effort (её отсутствие ничего не роняет).
|
||||
- **FAIL:** артефакт не пишется или frontmatter невалиден/непарсится.
|
||||
|
||||
### AC-14 — наблюдаемость в /queue
|
||||
- **PASS:** `GET /queue` содержит блок `post_deploy` со снимком состояния (enabled,
|
||||
window, активные/последний исход).
|
||||
- **FAIL:** состояние наблюдения нигде не видно.
|
||||
|
||||
### AC-15 — идемпотентность / restart-safe
|
||||
- **PASS:** повторный арм для той же задачи (двойной webhook / рестарт оркестратора)
|
||||
не создаёт второе параллельное наблюдение и не теряет уже идущее.
|
||||
- **FAIL:** дублируется наблюдение или теряется при рестарте.
|
||||
|
||||
### AC-16 — never-raise
|
||||
- **PASS:** любая ошибка опроса/сети/файлов/классификации логируется и НЕ роняет
|
||||
worker / lifespan / конвейер других проектов.
|
||||
- **FAIL:** исключение из наблюдения всплывает и ломает обслуживание других проектов.
|
||||
|
||||
### AC-17 — уведомления
|
||||
- **PASS:** ключевые события (наблюдение начато, DEGRADED, откат/алерт, чистое
|
||||
завершение окна) уведомляются в Telegram и/или Plane-комментарием.
|
||||
- **FAIL:** деградация/откат происходят молча.
|
||||
|
||||
### AC-18 — документация обновлена (golden-source)
|
||||
- **PASS:** в том же PR обновлены `CLAUDE.md` (артефакт `16-post-deploy-log.md`),
|
||||
`docs/architecture/README.md` (описание пост-деплой наблюдения), `CHANGELOG.md`,
|
||||
и заведён ADR work-item.
|
||||
- **FAIL:** функционал есть, документация не обновлена (reviewer → REQUEST_CHANGES).
|
||||
@@ -1,163 +0,0 @@
|
||||
work_item: ORCH-021
|
||||
description: >
|
||||
Тест-план пост-деплой мониторинга прода + авто-rollback. Упор на детерминированную
|
||||
чистую логику классификации/решения (юнит, без сети/LLM) и на интеграцию
|
||||
армирования наблюдения после deploy->done. Сетевые опросы и хук-вызовы мокируются.
|
||||
Имена модулей/функций — целевые (src/post_deploy.py); архитектор уточняет точную
|
||||
сигнатуру, тесты адаптируются под ADR.
|
||||
|
||||
tests:
|
||||
# --- Классификация деградации (чистая логика, ядро) ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "HEALTHY: серия опросов без провалов (< порога) -> вердикт HEALTHY"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "DEGRADED: N последовательных провалов health (== fail_threshold) -> DEGRADED"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "DEGRADED по 5xx: доля 5xx выше порога при health=200 -> DEGRADED"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "Нет ложного срабатывания: одиночный провал (1 < threshold) + восстановление -> HEALTHY"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-6]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Пороги читаются из Settings (env ORCH_*), изменение порога меняет вердикт на тех же данных"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-11]
|
||||
expected: PASS
|
||||
|
||||
# --- Решение о реакции (чистая логика + self-hosting safety) ---
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Решение: не-self репо + auto_rollback=True + DEGRADED -> ROLLBACK"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Решение self-hosting: orchestrator + DEGRADED -> ALERT_ONLY (НИКОГДА не авто-rollback)"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-8]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Решение: HEALTHY -> NONE (реакции нет) для любого репо"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
# --- Условность / kill-switch ---
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "post_deploy_applies: пусто в repos -> True только для orchestrator, False для enduro-trails"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "kill-switch: post_deploy_monitor_enabled=False -> applies()=False для всех; наблюдение не армится"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-10]
|
||||
expected: PASS
|
||||
|
||||
# --- Маппинг exit-code отката -> исход ---
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Откат exit 0 -> action_taken=ROLLBACK_OK"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Откат exit 1/2 (нет prev-образа / откат упал) -> ROLLBACK_FAILED + эскалация-алерт"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-9]
|
||||
expected: PASS
|
||||
|
||||
# --- Артефакт ---
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "16-post-deploy-log.md пишется с валидным YAML-frontmatter (post_deploy_status/action_taken), парсится yaml.safe_load"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-13]
|
||||
expected: PASS
|
||||
|
||||
# --- never-raise ---
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "Опрос при сетевой ошибке/таймауте -> консервативный результат (провал-как-down), исключение НЕ всплывает"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-16]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Ошибка записи артефакта (нет каталога/IO) -> логируется, функция возвращает False, не raise"
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-16, AC-13]
|
||||
expected: PASS
|
||||
|
||||
# --- Интеграция: армирование после deploy->done ---
|
||||
- id: TC-16
|
||||
type: integration
|
||||
description: "advance_stage deploy->done для orchestrator армит наблюдение (sentinel/job создан); для enduro-trails — нет"
|
||||
module: tests/test_post_deploy_integration.py
|
||||
covers: [AC-1, AC-2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: integration
|
||||
description: "Идемпотентность: повторный арм той же задачи (двойной webhook) не создаёт второе наблюдение"
|
||||
module: tests/test_post_deploy_integration.py
|
||||
covers: [AC-15]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "Полный цикл DEGRADED -> для не-self вызывается откат (хук замокан), пишется лог, шлётся уведомление"
|
||||
module: tests/test_post_deploy_integration.py
|
||||
covers: [AC-7, AC-13, AC-17]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "Self-hosting DEGRADED: тик НЕ вызывает рестарт/откат прод-контейнера, формирует алерт+approve-запрос"
|
||||
module: tests/test_post_deploy_integration.py
|
||||
covers: [AC-8, AC-17]
|
||||
expected: PASS
|
||||
|
||||
# --- Наблюдаемость и обратная совместимость ---
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "GET /queue содержит блок post_deploy со снимком состояния"
|
||||
module: tests/test_post_deploy_integration.py
|
||||
covers: [AC-14]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: integration
|
||||
description: "Регресс: существующие тесты deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS и QG_CHECKS не изменены"
|
||||
module: tests/test_stages.py
|
||||
covers: [AC-12]
|
||||
expected: PASS
|
||||
@@ -1,212 +0,0 @@
|
||||
# ADR-001 (ORCH-021): Post-deploy мониторинг прода + реакция на деградацию
|
||||
|
||||
## Статус
|
||||
Proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`.
|
||||
Сквозной индексный ADR: `docs/architecture/adr/adr-0010-post-deploy-monitor.md`.
|
||||
Помечено `arch:major-change` (новая под-компонента + новый reserved-agent job-kind).
|
||||
|
||||
## Контекст
|
||||
Конвейер заканчивается на `deploy → done` (`check_deploy_status` видит
|
||||
`deploy_status: SUCCESS` → terminal-sync, Plane → Done, release merge-lease). После
|
||||
этого оркестратор **забывает про прод**. «Успех» сегодня = прохождение health-check
|
||||
в момент рестарта (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60с.
|
||||
|
||||
Класс инцидентов «зелёный деплой, красный прод» (прецедент **ET-8**): деградация
|
||||
проявляется через минуты под боевым трафиком (прогрев кэшей, фоновые миграции,
|
||||
утечки, рост 5xx), health отвечает `200 ok`, но фича сломана. Для self-hosting это
|
||||
критично: сломанный прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
|
||||
|
||||
BRD/ТЗ задают требования (BR-1…BR-11, AC-1…AC-18) и оставляют архитектору **три
|
||||
открытых вопроса** (BRD §7): (1) где живёт наблюдение — стадия / watchdog-daemon /
|
||||
reserved-agent job; (2) механизм self-rollback; (3) пороги/веса сигналов.
|
||||
|
||||
Существующие переиспользуемые механики:
|
||||
- **deploy-finalizer** (ORCH-36, `stage_engine.run_deploy_finalizer` + перехват в
|
||||
`launcher.launch_job` ДО `_spawn`) — детерминированный no-LLM reserved-agent job,
|
||||
само-перепостановка через `enqueue_job(available_at_delay_s=...)`, defer-budget,
|
||||
restart-safe (jobs-очередь + sentinel-файлы `.deploy-state-<repo>/<wi>/`).
|
||||
- **self_deploy.py** — sentinel-state хелперы (`write_marker`/`has_marker`/
|
||||
`read_result`/`clear_state`), detached host-процесс (`build_deploy_command`/
|
||||
`initiate_deploy`: ssh + setsid), `map_exit_code_to_status`, `self_deploy_applies`.
|
||||
- **reconciler.py** — daemon-поток + `status()` в `GET /queue`.
|
||||
- **хук `--rollback`** (`do_rollback`): retag `PREV_IMAGE_FILE` → `TARGET_IMAGE` +
|
||||
рестарт + health, коды 0 / 1 (нет prev-образа) / 2 (rollback тоже упал).
|
||||
- **Условность** ORCH-35/36/43/58: `is_self_hosting_repo`, флаг + CSV-репо.
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. Механизм наблюдения — reserved-agent job `post-deploy-monitor` (Вариант B)
|
||||
Наблюдение реализуется как **детерминированный no-LLM reserved-agent job**, точная
|
||||
калька **deploy-finalizer**. Один «тик» наблюдения = один job: он делает ОДИН опрос
|
||||
сигналов, обновляет персистентные счётчики в sentinel-файлах, классифицирует и либо
|
||||
**перепостанавливает себя** с задержкой `post_deploy_interval_s` (окно не истекло и
|
||||
ещё не DEGRADED), либо завершает наблюдение (DEGRADED → реакция; либо окно истекло →
|
||||
HEALTHY). Это «watchdog поверх очереди»: между тиками job не выполняется (он
|
||||
запланирован в будущем через `available_at_delay_s`), worker свободен для других
|
||||
проектов — ровно как defer у finalizer.
|
||||
|
||||
**Почему НЕ daemon-watchdog (Вариант A, как reconciler):** daemon тикает глобально, а
|
||||
не per-task; серию опросов (последовательные провалы health, доля 5xx на окне) пришлось
|
||||
бы держать в памяти → теряется/двоится при рестарте (а сам деплой орка = рестарт). Чтобы
|
||||
сделать daemon restart-safe, всё равно нужны персистентные per-task счётчики в sentinel —
|
||||
тогда reserved-agent проще и уже имеет проверенную restart-safe машинерию (jobs-очередь
|
||||
+ `requeue_running_jobs` + sentinels). Per-task жизненный цикл естественно ложится на
|
||||
job-цепочку, а не на глобальный sweep.
|
||||
|
||||
**Почему НЕ отдельная пост-deploy стадия (Вариант C):** меняет `STAGE_TRANSITIONS` +
|
||||
реестр `QG_CHECKS` (нарушает AC-12, ТЗ §2.8 — явно непредпочтительно); ломает семантику
|
||||
`deploy → done` как терминального перехода (Plane уже Done). Наблюдение происходит
|
||||
**ПОСЛЕ** `done` — «продление ответственности ЗА done», а не новая стадия конвейера.
|
||||
|
||||
### 2. Арм наблюдения — хук в terminal-блоке `advance_stage`
|
||||
В `stage_engine.advance_stage`, в существующем блоке `next_stage == "done"` (после
|
||||
`set_issue_done` и `release_merge_lease`), добавляется арм:
|
||||
```
|
||||
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
|
||||
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
|
||||
```
|
||||
`arm_monitor` (never-raise): если sentinel `armed` отсутствует → создаёт state-dir,
|
||||
пишет `armed` (идемпотентность, по образцу `INITIATED`), инициализирует `series`-файл,
|
||||
ставит первый `post-deploy-monitor` job через `enqueue_job(available_at_delay_s=
|
||||
post_deploy_interval_s)`. Если `armed` уже есть → no-op (двойной webhook / reconciler
|
||||
F-1 / finalizer Phase C могут довести `done` повторно — AC-15). Выключенный
|
||||
kill-switch / неприменимый репо → `post_deploy_applies` False → арма нет (AC-2/AC-10).
|
||||
|
||||
### 3. Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise)
|
||||
По образцу `self_deploy.py` / `staging_verdict.py`. Импортирует только config (+lazy
|
||||
`qg.checks.is_self_hosting_repo`), НЕ импортирует `stage_engine`/`launcher`. Функции:
|
||||
- **`post_deploy_applies(repo) -> bool`** — флаг `post_deploy_monitor_enabled` +
|
||||
CSV `post_deploy_repos` (пусто → только self-hosting). Калька `self_deploy_applies`.
|
||||
- **`probe_signals(base_url) -> ProbeResult`** — один опрос: `GET /health` (HTTP 200 +
|
||||
`{"status":"ok"}`) и ключевые эндпоинты `/status`, `/queue` (учёт доли 5xx).
|
||||
Сеть/таймаут → консервативный «провал»-результат, не исключение.
|
||||
- **`classify(series, fail_threshold, 5xx_threshold) -> "HEALTHY"|"DEGRADED"`** —
|
||||
чистая, без сети, **главный предмет юнит-тестов** (детерминированная, как
|
||||
`compute_staging_verdict`): `DEGRADED` если `≥ fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ
|
||||
провалов health (AC-4) ИЛИ доля 5xx на окне `> 5xx_threshold` (AC-5). Иначе
|
||||
`HEALTHY` (одиночный провал < порога с восстановлением → HEALTHY, AC-3/AC-6).
|
||||
- **`decide_action(repo, verdict) -> "NONE"|"ROLLBACK"|"ALERT_ONLY"`** — чистая:
|
||||
`HEALTHY → NONE`; `DEGRADED` + self-hosting → `ALERT_ONLY` (BR-5/AC-8, ВСЕГДА);
|
||||
`DEGRADED` + не-self + `post_deploy_auto_rollback=True` → `ROLLBACK`; иначе →
|
||||
`ALERT_ONLY`.
|
||||
- **Sentinel-state хелперы** (state-dir `.post-deploy-state-<repo>/<wi>/`, по образцу
|
||||
`self_deploy._state_dir`): `armed`, `series` (JSON-список результатов опросов,
|
||||
append каждый тик — restart-safe счётчики), `done`. `read_series`/`append_probe`/
|
||||
`mark_done`/`has_marker` — never-raise.
|
||||
- **`write_post_deploy_log(...)`** — артефакт `16-post-deploy-log.md`, best-effort
|
||||
(по образцу `self_deploy.write_deploy_log`).
|
||||
- **`build_rollback_command(repo)`** — argv хука `--rollback` с прод-env (как
|
||||
`build_deploy_command`, но action=`--rollback`; переиспользует `deploy_prod_*`).
|
||||
|
||||
### 4. Исполнение тика — `stage_engine.run_post_deploy_monitor(job)` + перехват в launcher
|
||||
По образцу `run_deploy_finalizer` / `_run_deploy_finalizer_job`:
|
||||
`launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО `_spawn` →
|
||||
`stage_engine.run_post_deploy_monitor(job)`. Алгоритм тика (never-raise):
|
||||
1. `mark_done` уже стоит → no-op (AC-15, защита от дубля).
|
||||
2. `probe = post_deploy.probe_signals(base_url)`; `append_probe(series, probe)`.
|
||||
3. `verdict = classify(series, ...)`.
|
||||
4. **Если `HEALTHY` и окно не истекло** (число тиков < `window_s/interval_s`) →
|
||||
перепостановка `post-deploy-monitor` через `available_at_delay_s=interval_s`
|
||||
(как finalizer defer; счётчик тиков — из jobs-очереди/`series`, restart-safe).
|
||||
5. **Если `HEALTHY` и окно истекло** → исход `NONE`, `write_post_deploy_log(HEALTHY,
|
||||
NONE)`, `mark_done`, нотификация «окно завершилось чисто» (BR-6/AC-17).
|
||||
6. **Если `DEGRADED`** → `action = decide_action(...)`; исполнить реакцию (§5),
|
||||
`write_post_deploy_log`, `mark_done`, нотификации.
|
||||
|
||||
`mark_done` + sentinel `armed` дают идемпотентность; jobs-очередь +
|
||||
`requeue_running_jobs` + `series` дают restart-safe (AC-15). Бюджет тиков bounded
|
||||
(`window_s/interval_s`) — анти-livelock, как `deploy_finalize_max_attempts`.
|
||||
|
||||
### 5. Реакция на деградацию
|
||||
- **Self-hosting (`orchestrator`), всегда (BR-5/AC-8):** `ALERT_ONLY`. НЕ откатывать
|
||||
и НЕ рестартить прод-контейнер в тике. Громкий Telegram + Plane-коммент с запросом
|
||||
ручного approve отката (по образцу deploy Phase A CTA). `action_taken: ALERT_ONLY`.
|
||||
Откат самого прод-орка (если оператор решит) — ТОЛЬКО через detached host-процесс
|
||||
(контейнер не откатит себя, умирая); переиспользуется механика
|
||||
`self_deploy.initiate_deploy`, но в MVP она вне тика наблюдения (ручной approve →
|
||||
отдельный путь, как ORCH-54 для авто-deploy). Тик self НИКОГДА не запускает хук
|
||||
`--rollback` (структурный инвариант).
|
||||
- **Не-self + `post_deploy_auto_rollback=True` (AC-7):** вызвать хук `--rollback` с
|
||||
прод-env (`build_rollback_command`). Маппинг exit-code по смыслу
|
||||
`map_exit_code_to_status`: `0 → ROLLBACK_OK`; `1/2 → ROLLBACK_FAILED` + громкий
|
||||
Telegram о необходимости ручного вмешательства (AC-9). Целевой контейнер не есть
|
||||
orchestrator → его рестарт безопасен для конвейера.
|
||||
- **Не-self + auto_rollback=False (дефолт):** `ALERT_ONLY`.
|
||||
|
||||
### 6. Артефакт `16-post-deploy-log.md` (новый, машиночитаемый)
|
||||
YAML-frontmatter (канон гейтов; для петли уроков ORCH-8, BR-10):
|
||||
```
|
||||
---
|
||||
post_deploy_status: HEALTHY | DEGRADED
|
||||
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
|
||||
work_item: <plane-id>
|
||||
window_s: <int>
|
||||
checks_total: <int>
|
||||
checks_failed: <int>
|
||||
---
|
||||
```
|
||||
Тело — человекочитаемая сводка опросов. Best-effort (отсутствие файла ничего не роняет,
|
||||
AC-13). **Не** читается ни одним гейтом — наблюдение происходит после `done`.
|
||||
|
||||
### 7. Конфигурация — `src/config.py` (env-префикс `ORCH_`)
|
||||
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8/AC-10).
|
||||
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting.
|
||||
- `post_deploy_window_s: int = 900` — окно наблюдения (~15 мин, BR-1).
|
||||
- `post_deploy_interval_s: int = 30` — интервал опросов.
|
||||
- `post_deploy_fail_threshold: int = 3` — N послед. провалов health → DEGRADED.
|
||||
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx → DEGRADED.
|
||||
- `post_deploy_auto_rollback: bool = False` — глоб. разрешение авто-отката (для self
|
||||
всегда требует approve, BR-5).
|
||||
- `post_deploy_base_url: str = "http://localhost:8500"` — наблюдаемый прод.
|
||||
- Параметры отката — переиспользовать существующие `deploy_prod_*` (новых дублей нет).
|
||||
|
||||
### 8. Наблюдаемость — блок `post_deploy` в `GET /queue` (BR-9/AC-14)
|
||||
По образцу блока `reconcile` (метод `status()`): `enabled`, `window_s`, `interval_s`,
|
||||
активные наблюдения (по sentinel `armed` без `done`), последний исход
|
||||
(`post_deploy_status`/`action_taken`). Best-effort, never-raise.
|
||||
|
||||
### Инварианты (НЕ меняются)
|
||||
`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`,
|
||||
момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync `deploy → done`, merge-gate,
|
||||
exit-code-контракт хука (0/1/2), схема БД. Условность как ORCH-35/36/43/58. Never-raise
|
||||
во всём наблюдении (AC-16). Тик self НИКОГДА не рестартит прод-контейнер (AC-8).
|
||||
|
||||
## Альтернативы
|
||||
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия в памяти не
|
||||
restart-safe; restart-safe-вариант требует тех же sentinel-счётчиков → reserved-agent
|
||||
проще и уже проверен.
|
||||
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет реестры (AC-12), ломает
|
||||
семантику терминального `done`; наблюдение принципиально ПОСЛЕ `done`.
|
||||
- **Авто-rollback прод-орка из тика** — отклонён (BR-5): контейнер не откатит себя
|
||||
надёжно; групповой риск для всех проектов. Self → только ALERT + ручной approve.
|
||||
- **Новая колонка в `tasks` для отметки наблюдения** — отклонён: миграция на проде
|
||||
(риск, как в adr-0007); sentinel-файлы достаточны и restart-safe (как ORCH-36/53/58).
|
||||
- **Прометей/APM** — вне рамок (BR out-of-scope): опираемся на существующие
|
||||
HTTP-эндпоинты, не вводим сбор метрик.
|
||||
|
||||
## Последствия
|
||||
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация —
|
||||
машиночитаемый сигнал для петли уроков (ORCH-8).
|
||||
- Плюс: максимальное переиспользование проверенной finalizer/sentinel/hook-машинерии;
|
||||
нулевая миграция БД; реестры не тронуты; дефолты безопасны (auto-rollback off, self
|
||||
только alert).
|
||||
- Минус/ограничение: монитор self бежит ВНУТРИ наблюдаемого прод-контейнера — если
|
||||
контейнер полностью wedged, worker может не выполнить тик и алерта не будет (gap).
|
||||
Это known limitation MVP; внешний независимый watchdog — follow-up (вне рамок).
|
||||
- Минус: каждый тик на короткое время занимает single-worker (`max_concurrency=1`);
|
||||
митигируется коротким опросом (~секунды) и `interval_s` между тиками (defer не держит
|
||||
worker), как finalizer.
|
||||
- Доменный smoke результата фичи (BR-11) — follow-up; MVP = health + 5xx.
|
||||
|
||||
## Связи
|
||||
- **ET-8** — обоснование (deploy SUCCESS, прод не работает).
|
||||
- **adr-0007-executable-self-deploy** (ORCH-36) — sentinel-паттерн, detached
|
||||
host-процесс, `map_exit_code_to_status`, deploy-finalizer reserved-agent (образец).
|
||||
- **adr-0007-reconciler** (ORCH-53) — daemon/`status()` образец (рассмотрен и отклонён
|
||||
как основной механизм; `status()`-снимок в `/queue` переиспользуется).
|
||||
- **adr-0006-merge-gate** / **adr-0003-staging-gate** — образец условности и флагов
|
||||
раската (`*_enabled` + `*_repos`).
|
||||
- **adr-0008-staging-image-provenance** — `.deploy-prev-image` / хук-механика отката.
|
||||
- **ORCH-8** — петля уроков (потребитель `16-post-deploy-log.md`).
|
||||
- **ORCH-54** — будущий полный авто (включая авто-approve отката self), по аналогии
|
||||
с авто-deploy.
|
||||
@@ -1,56 +0,0 @@
|
||||
# 07 — Инфраструктурные требования (ORCH-021)
|
||||
|
||||
> Топология НЕ меняется. Фича опирается на уже существующие HTTP-эндпоинты прода и
|
||||
> существующий деплой-хук. Этот документ фиксирует, какие инфра-предпосылки должны
|
||||
> выполняться, чтобы наблюдение и реакция работали.
|
||||
|
||||
## 1. Топология — без изменений
|
||||
- Прод `orchestrator` (8500), staging `orchestrator-staging` (8501), один сервер
|
||||
mva154 (см. `docs/operations/INFRA.md`). Новых контейнеров/портов/сервисов нет.
|
||||
- Наблюдение — внутрипроцессный reserved-agent job в worker'е прод-контейнера.
|
||||
Daemon-потоков не добавляется (в отличие от reconciler).
|
||||
|
||||
## 2. Наблюдаемый прод — HTTP-эндпоинты
|
||||
- Монитор опрашивает `post_deploy_base_url` (дефолт `http://localhost:8500`):
|
||||
- `GET /health` → ожидается HTTP 200 + тело `{"status":"ok"}` (BR-2);
|
||||
- `GET /status`, `GET /queue` → учёт доли HTTP 5xx (BR-2).
|
||||
- Эндпоинты уже существуют (`src/main.py`). Новых эндпоинтов фича НЕ вводит
|
||||
(out-of-scope APM/метрики).
|
||||
- Для self-hosting `base_url=localhost:8500` означает: монитор бьёт по собственному
|
||||
контейнеру. Это допустимо для MVP (см. риск R-1 в `10-tech-risks.md`).
|
||||
|
||||
## 3. Деплой-хук `--rollback` — предпосылки реакции
|
||||
- Реакция ROLLBACK (только не-self + `post_deploy_auto_rollback=True`) вызывает
|
||||
`scripts/orchestrator-deploy-hook.sh --rollback` с прод-env (переиспользуются
|
||||
`deploy_prod_*`: `TARGET_SERVICE`/`TARGET_PORT`/`TARGET_IMAGE`/`COMPOSE_PROFILE`/
|
||||
`PREV_IMAGE_FILE`), по образцу `self_deploy.build_deploy_command`.
|
||||
- Предпосылка: при штатном деплое хук сохраняет предыдущий образ в
|
||||
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`). Без снимка → хук вернёт exit 1
|
||||
(«нет prev-образа») → `ROLLBACK_FAILED` + алерт (AC-9). Контракт exit-кодов хука
|
||||
(0/1/2) НЕ меняется.
|
||||
- **Self-hosting:** откат прод-орка хуком в тике ЗАПРЕЩЁН (контейнер не откатит себя,
|
||||
умирая). Если оператор по алерту решит откатить — только detached host-процесс
|
||||
(ssh + setsid, механика `self_deploy.initiate_deploy`), как у Phase B самодеплоя.
|
||||
Предпосылки для detached-пути (ssh-доступ host, shared-mount state-dir) уже
|
||||
выполнены для ORCH-36; в MVP detached-откат self вне тика наблюдения.
|
||||
|
||||
## 4. Restart-safe состояние — shared mount
|
||||
- Состояние наблюдения — sentinel-файлы под `.post-deploy-state-<repo>/<wi>/`
|
||||
(`armed`, `series`, `done`) на том же mount `settings.repos_dir`, что и
|
||||
`.deploy-state-*` (ORCH-36). Миграции БД нет (см. `08-data-requirements.md`).
|
||||
- `requeue_running_jobs` (ORCH-1) восстанавливает claimed `post-deploy-monitor` job
|
||||
после рестарта; `series` хранит счётчики опросов → наблюдение продолжается
|
||||
с того же места (BR-7/AC-15).
|
||||
|
||||
## 5. Конфигурация окружения (env `ORCH_*`)
|
||||
Новые ключи (дефолты безопасны, в `.env`/`.env.staging` по необходимости):
|
||||
`post_deploy_monitor_enabled` (kill-switch, дефолт true), `post_deploy_repos` (CSV,
|
||||
пусто → self-hosting), `post_deploy_window_s` (900), `post_deploy_interval_s` (30),
|
||||
`post_deploy_fail_threshold` (3), `post_deploy_5xx_threshold` (0.5),
|
||||
`post_deploy_auto_rollback` (false), `post_deploy_base_url` (localhost:8500).
|
||||
Параметры отката — существующие `deploy_prod_*`, новых дублей не вводить.
|
||||
|
||||
## 6. Чего НЕ требуется
|
||||
- Новых контейнеров, портов, сетевых правил, секретов.
|
||||
- Prometheus / Grafana / APM (out-of-scope).
|
||||
- Изменений compose-топологии или деплой-пути не-self репо.
|
||||
@@ -1,40 +0,0 @@
|
||||
# 08 — Требования к данным / схеме БД (ORCH-021)
|
||||
|
||||
## Вывод: миграция БД НЕ требуется
|
||||
Состояние наблюдения хранится в **sentinel-файлах** (restart-safe, без миграции —
|
||||
по образцу ORCH-36/53/58), а не в таблицах. Реестры и схема не меняются (AC-12).
|
||||
|
||||
## 1. Существующие таблицы — без изменений
|
||||
- `events`, `tasks`, `agent_runs`, `jobs` — структура не меняется.
|
||||
- В `tasks` НЕ вводится колонка статуса/окна наблюдения (намеренно — миграция на
|
||||
проде = риск, как обосновано в adr-0007; альтернатива отклонена в ADR-001 §Альтернативы).
|
||||
|
||||
## 2. Очередь `jobs` — переиспользование, без схемы
|
||||
- `post-deploy-monitor` — новый **job-kind** (значение в существующей колонке
|
||||
`agent`/`task_content`), НЕ новая колонка. Ставится через существующий
|
||||
`enqueue_job(..., available_at_delay_s=...)` (ORCH-1).
|
||||
- Счётчик тиков/деферов восстанавливается из jobs-очереди (как
|
||||
`_deploy_finalize_defer_count` считает по `task_content LIKE`), restart-safe.
|
||||
|
||||
## 3. Sentinel-состояние (файлы, не БД)
|
||||
State-dir `.post-deploy-state-<repo>/<work_item_id>/` на `settings.repos_dir`
|
||||
(по образцу `.deploy-state-*`):
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `armed` | наблюдение заармлено (идемпотентность арма; калька `INITIATED`) |
|
||||
| `series` | JSON-список результатов опросов (счётчики health-fail / 5xx; restart-safe) |
|
||||
| `done` | наблюдение завершено (защита от повторной обработки) |
|
||||
|
||||
Все обращения — never-raise (по образцу `self_deploy.has_marker`/`write_marker`/
|
||||
`read_result`). Отсутствие/битость файла → консервативный фоллбэк, не исключение.
|
||||
|
||||
## 4. Артефакт `16-post-deploy-log.md` — файл репозитория, не БД
|
||||
Машиночитаемый YAML-frontmatter (`post_deploy_status`, `action_taken`, `window_s`,
|
||||
`checks_total`, `checks_failed`) пишется best-effort в `docs/work-items/<id>/`; в БД
|
||||
не реплицируется. Источник для петли уроков ORCH-8 (BR-10).
|
||||
|
||||
## 5. Очистка состояния
|
||||
По завершении окна / реакции `done`-маркер ставится; state-dir можно чистить
|
||||
best-effort (по образцу `self_deploy.clear_state`) — необязательно для корректности,
|
||||
но желательно для гигиены. Stale-`armed` без `done` после краха → виден в `/queue`
|
||||
как «активное наблюдение» и доигрывается восстановленным job'ом.
|
||||
@@ -1,20 +0,0 @@
|
||||
# 10 — Технические риски (ORCH-021)
|
||||
|
||||
| # | Риск | Вероятн. | Влияние | Митигация |
|
||||
|---|------|----------|---------|-----------|
|
||||
| R-1 | **Монитор self бежит внутри наблюдаемого прода.** Полностью wedged прод-контейнер → worker не выполнит тик → деградация не замечена, алерта нет. | Сред. | Высок. | Known MVP limitation (зафиксировано в ADR-001 §Последствия). Health в момент рестарта (хук) + reconciler ловят часть случаев. Внешний независимый watchdog — follow-up (вне рамок). |
|
||||
| R-2 | **Ложный авто-rollback** по сетевому глюку. | Низк. | Высок. | Пороги по N ПОСЛЕДОВАТЕЛЬНЫХ провалов + доля 5xx на окне (BR-3/AC-6), а не разовый провал. Self ВСЕГДА `ALERT_ONLY` (BR-5). `auto_rollback=False` по умолчанию. |
|
||||
| R-3 | **Авто-rollback прод-орка убивает инструмент всех проектов.** | Низк. | Критич. | Структурный инвариант: тик self НИКОГДА не откатывает/рестартит прод-контейнер (AC-8). Self → только alert + ручной approve. Откат self — только detached host-процесс вне тика. |
|
||||
| R-4 | **Нет prev-образа** при ROLLBACK → откат невозможен. | Сред. | Сред. | Хук возвращает exit 1 → `ROLLBACK_FAILED` + громкий алерт (AC-9), деградация не проглатывается тихо. |
|
||||
| R-5 | **Дубль/потеря наблюдения** при двойном webhook / рестарте. | Сред. | Сред. | Идемпотентность: sentinel `armed` (арм-гард) + `done` (защита от повторной обработки) + restart-safe jobs-очередь + `series` (AC-15). По образцу finalizer. |
|
||||
| R-6 | **Исключение в наблюдении роняет worker / конвейер других проектов.** | Низк. | Высок. | Контракт never-raise во всём `post_deploy.py` и `run_post_deploy_monitor` (AC-16), по образцу `self_deploy`/`staging_verdict`. |
|
||||
| R-7 | **Тик занимает single-worker** (`max_concurrency=1`) → задержка других задач. | Низк. | Низк. | Опрос короткий (~секунды), между тиками job не выполняется (defer через `available_at_delay_s`) — worker свободен, как у finalizer. Окно bounded (`window_s/interval_s`). |
|
||||
| R-8 | **Скрытое изменение контракта** (реестры/гейты/exit-коды/схема). | Низк. | Высок. | Инвариант: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_deploy_status`/terminal-sync/merge-gate/exit-коды/схема БД НЕ меняются (AC-12). Существующие тесты deploy/staging/merge-gate должны остаться зелёными. |
|
||||
| R-9 | **5xx на `/queue`/`/status` из-за самого монитора** (рекурсивная нагрузка). | Низк. | Низк. | Интервал `post_deploy_interval_s` (30с) — низкая частота; опрос лёгкий GET. |
|
||||
| R-10 | **Артефакт `16-post-deploy-log.md` не пишется / невалиден** → петля уроков без данных. | Низк. | Низк. | Best-effort запись с валидным frontmatter (AC-13); отсутствие файла ничего не роняет. Парсинг — defensive. |
|
||||
|
||||
## Эскалация
|
||||
- Изменение помечено `arch:major-change` (новая под-компонента `src/post_deploy.py`
|
||||
+ новый reserved-agent job-kind `post-deploy-monitor`).
|
||||
- R-1 (gap наблюдения для wedged self-контейнера) — кандидат на отдельную задачу
|
||||
(внешний watchdog), вне рамок ORCH-021.
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-021
|
||||
verdict: APPROVED
|
||||
version: 2
|
||||
---
|
||||
|
||||
# Review ORCH-021 — Post-deploy мониторинг прода + реакция на деградацию
|
||||
|
||||
## Summary
|
||||
Реализация продлевает ответственность конвейера ЗА терминальный переход
|
||||
`deploy → done`, закрывая класс инцидентов «зелёный деплой, красный прод» (ET-8).
|
||||
Механизм — детерминированный reserved-agent job `post-deploy-monitor` (вариант B
|
||||
из ADR-001, точная калька `deploy-finalizer`): арм в `stage_engine.advance_stage`
|
||||
(блок `next_stage == "done"`), один тик = один job (перехват в
|
||||
`launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor`),
|
||||
чистая логика в новом leaf-модуле `src/post_deploy.py` (never-raise).
|
||||
|
||||
Проверены все четыре оси. Реализация соответствует ТЗ (`02-trz.md`), ADR-001 и
|
||||
глобальному adr-0010, удовлетворяет всем критериям приёмки AC-1…AC-18.
|
||||
Документация (golden-source) обновлена в том же PR. Регрессов нет.
|
||||
|
||||
## Соответствие ТЗ
|
||||
- §2.1 `src/post_deploy.py` (leaf, never-raise): `post_deploy_applies`,
|
||||
`probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт,
|
||||
`build_rollback_command` — все на месте. ✅
|
||||
- §2.2 Оркестрация: арм в terminal-блоке + reserved-agent тик с
|
||||
само-перепостановкой через `available_at_delay_s`; restart-safe (sentinel
|
||||
`armed`/`series`/`done` + jobs-очередь). ✅
|
||||
- §2.3 Реакция: non-self+auto → хук `--rollback` (синхронно, целевой ≠ orch);
|
||||
self-hosting → ВСЕГДА `ALERT_ONLY`. ✅
|
||||
- §2.4 Конфигурация: все `post_deploy_*` в `src/config.py`, дефолты безопасны
|
||||
(kill-switch on, auto-rollback off), параметры отката переиспользуют
|
||||
`deploy_prod_*`. ✅
|
||||
- §2.5 Артефакт `16-post-deploy-log.md` с машиночитаемым frontmatter,
|
||||
best-effort. ✅
|
||||
- §2.6 Блок `post_deploy` в `GET /queue`. ✅
|
||||
- §2.7/§2.8/§3 Инварианты: `STAGE_TRANSITIONS`, `QG_CHECKS`,
|
||||
`check_deploy_status`, terminal-sync, merge-gate, exit-code-контракт хука,
|
||||
схема БД — не тронуты (подтверждено зелёным полным прогоном). ✅
|
||||
|
||||
## Соответствие ADR
|
||||
Реализация 1:1 повторяет ADR-001: механизм (reserved-agent, не стадия/не daemon),
|
||||
точки интеграции, пороги BR-3, политика реакции BR-5 (self never auto-rollback —
|
||||
структурный инвариант в `decide_action` + отсутствие вызова `run_rollback` на
|
||||
ALERT_ONLY). Нарушений глобальных ADR не выявлено.
|
||||
|
||||
## Качество кода
|
||||
- Контракт never-raise выдержан во всех публичных функциях и в каждой ветке
|
||||
`run_post_deploy_monitor`; launcher оборачивает тик в доп. guard (AC-16).
|
||||
- `classify` fail-safe → HEALTHY на мусорном входе (ложный DEGRADED опаснее).
|
||||
- Docstrings содержательные, со ссылками на AC/BR.
|
||||
- Условность раската по образцу ORCH-35/36/43/58 (флаг + CSV-репо).
|
||||
|
||||
## Тесты
|
||||
30 тестов ORCH-021 (`tests/test_post_deploy.py`,
|
||||
`tests/test_post_deploy_integration.py`) — содержательные, покрывают
|
||||
классификацию (AC-3..6), self-hosting safety (TC-19 явно проверяет, что хук
|
||||
`--rollback` НЕ вызывается для self — AC-8), idempotency двойного арма (AC-15),
|
||||
kill-switch/условность (AC-2/10/11), exit-code маппинг (AC-9), frontmatter
|
||||
артефакта (AC-13), never-raise (AC-16), `/queue` (AC-14). Полный прогон
|
||||
`pytest tests/` — **701 passed** (регрессов нет, AC-12).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] `run_post_deploy_monitor`: в ветке `ALERT_ONLY` для **не-self** репо при
|
||||
`post_deploy_auto_rollback=false` текст алерта упоминает «авто-rollback для
|
||||
self-hosting запрещён (BR-5)», что для не-self случая формулировка не совсем
|
||||
точна (косметика сообщения; на поведение не влияет).
|
||||
- [ ] `write_post_deploy_log` коммитит/пушит артефакт в ветку задачи, которая к
|
||||
моменту наблюдения уже слита/может быть удалена — артефакт может не попасть в
|
||||
`main`. Контракт best-effort соблюдён (never-raise, ничего не роняет); как
|
||||
улучшение наблюдаемости — рассмотреть запись лог-артефакта отдельным путём.
|
||||
|
||||
## Документация
|
||||
Обновлено в том же PR (golden-source, AC-18 — PASS):
|
||||
- `CLAUDE.md` — `16-post-deploy-log.md` добавлен в перечень артефактов;
|
||||
- `docs/architecture/README.md` — раздел «Post-deploy наблюдение прода» + блок
|
||||
`post_deploy` в таблице API `/queue`;
|
||||
- `docs/architecture/adr/adr-0010-post-deploy-monitor.md` — новый сквозной ADR;
|
||||
- `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` — детальный ADR;
|
||||
- `CHANGELOG.md` — запись в `Added` (+ fix Dockerfile `COPY data/`);
|
||||
- `README.md` / `.env.example` — все `ORCH_POST_DEPLOY_*` env задокументированы.
|
||||
|
||||
Изменение `src/` сопровождено обновлением документации — правило CLAUDE.md №2/№6
|
||||
выполнено.
|
||||
|
||||
## Вердикт
|
||||
Только P3 (nice-to-have) findings, блокеров и must-fix нет → **APPROVED**.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-021
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-021
|
||||
|
||||
Post-deploy наблюдение прода + реакция на деградацию (reserved-agent job
|
||||
`post-deploy-monitor`, leaf-модуль `src/post_deploy.py`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (asyncio mode=AUTO, anyio 4.13.0)
|
||||
- Ветка: feature/ORCH-021-post-deploy-rollback
|
||||
- Дата: 2026-06-07
|
||||
|
||||
## Прогон
|
||||
- `pytest tests/ -v --tb=short` → **701 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче).
|
||||
- Целевые модули `tests/test_post_deploy.py` + `tests/test_post_deploy_integration.py` → **30 passed**.
|
||||
|
||||
## Smoke-test (read-only, прод 8500)
|
||||
`curl` в окружении недоступен — опрос через `python urllib` (read-only, прод-контейнер не трогается).
|
||||
|
||||
| Эндпоинт | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
|
||||
| `GET /status` | 200, активная задача ORCH-021 на стадии `testing` |
|
||||
| `GET /queue` | 200, counts/resilience/reconcile присутствуют |
|
||||
|
||||
> Примечание: блок `post_deploy` в **живом** `/queue` отсутствует — это ожидаемо: прод
|
||||
> сейчас работает на коде ДО ORCH-021 (задача ещё не задеплоена, стадия testing).
|
||||
> Наличие блока (AC-14) проверяется интеграционным тестом TC-20 против кода ветки → PASS.
|
||||
> Smoke-проверка подтверждает живость окружения, не версию ветки.
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Покрывает AC | Тест-функция | Результат |
|
||||
|-------|----------|--------------|--------------|-----------|
|
||||
| TC-01 | HEALTHY: серия без провалов < порога | AC-3 | test_tc01_healthy_no_failures | PASS |
|
||||
| TC-02 | DEGRADED: N посл. провалов health == threshold | AC-4 | test_tc02_degraded_consecutive_health_failures | PASS |
|
||||
| TC-03 | DEGRADED по 5xx при health=200 | AC-5 | test_tc03_degraded_by_5xx_ratio_even_when_health_200 | PASS |
|
||||
| TC-04 | Нет ложного срабатывания: одиночный глюк + восстановление | AC-6 | test_tc04_no_false_trip_single_glitch_then_recovery | PASS |
|
||||
| TC-05 | Пороги из Settings меняют вердикт на тех же данных | AC-11 | test_tc05_thresholds_change_verdict_on_same_data, test_classify_uses_settings_thresholds | PASS |
|
||||
| TC-06 | не-self + auto_rollback=True + DEGRADED → ROLLBACK | AC-7 | test_tc06_nonself_auto_rollback_degraded_rolls_back | PASS |
|
||||
| TC-07 | self-hosting + DEGRADED → ALERT_ONLY (никогда не авто-rollback) | AC-8 | test_tc07_self_hosting_degraded_never_rolls_back | PASS |
|
||||
| TC-08 | HEALTHY → NONE для любого репо | AC-3 | test_tc08_healthy_means_none_for_any_repo, test_nonself_default_policy_alert_only | PASS |
|
||||
| TC-09 | post_deploy_applies: пусто → только orchestrator | AC-2 | test_tc09_applies_empty_repos_only_self_hosting, test_tc09_applies_explicit_repos_csv | PASS |
|
||||
| TC-10 | kill-switch: monitor_enabled=False → applies()=False для всех | AC-10 | test_tc10_kill_switch_disables_for_everyone | PASS |
|
||||
| TC-11 | Откат exit 0 → ROLLBACK_OK | AC-7 | test_tc11_rollback_exit0_is_ok | PASS |
|
||||
| TC-12 | Откат exit 1/2 → ROLLBACK_FAILED + эскалация | AC-9 | test_tc12_rollback_exit_nonzero_is_failed | PASS |
|
||||
| TC-13 | 16-post-deploy-log.md: валидный YAML-frontmatter | AC-13 | test_tc13_log_frontmatter_parses | PASS |
|
||||
| TC-14 | Опрос при сетевой ошибке → консервативный, не raise | AC-16 | test_tc14_probe_network_error_is_conservative_not_raise, test_tc14_classify_junk_input_swallowed | PASS |
|
||||
| TC-15 | Ошибка записи артефакта → False, не raise | AC-16, AC-13 | test_tc15_write_log_no_worktree_returns_false | PASS |
|
||||
| TC-16 | advance_stage deploy→done армит наблюдение (self), не армит (non-self) | AC-1, AC-2 | test_tc16_arm_for_self_hosting, test_tc16_no_arm_for_nonself, test_tc16_no_arm_when_kill_switch_off | PASS |
|
||||
| TC-17 | Идемпотентность: повторный арм не задваивает | AC-15 | test_tc17_double_arm_is_noop | PASS |
|
||||
| TC-18 | Полный цикл DEGRADED → не-self откат + лог + уведомление | AC-7, AC-13, AC-17 | test_tc18_degraded_nonself_rolls_back | PASS |
|
||||
| TC-19 | Self-hosting DEGRADED → НЕ рестарт/откат, алерт+approve | AC-8, AC-17 | test_tc19_degraded_self_hosting_alert_only | PASS |
|
||||
| TC-20 | GET /queue содержит блок post_deploy | AC-14 | test_tc20_queue_block_present | PASS |
|
||||
| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 701) | PASS |
|
||||
|
||||
Доп. тесты ветки (не из плана, подтверждают контракты): `test_series_append_and_read_roundtrip`,
|
||||
`test_mark_done_idempotency_marker`, `test_healthy_tick_requeues_without_finishing`,
|
||||
`test_finished_window_tick_is_noop` — все PASS.
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
AC-1…AC-18 — все покрыты прошедшими тестами (см. таблицу). AC-12 (реестры/схема БД
|
||||
не изменены) дополнительно подтверждён зелёным полным регрессом 701 теста, включая
|
||||
deploy/staging/merge-gate/reconciler. AC-18 (документация) — вне scope прогона тестов,
|
||||
подтверждён ревью (12-review.md, verdict APPROVED).
|
||||
|
||||
## Вывод pytest (хвост)
|
||||
```
|
||||
======================= 701 passed, 1 warning in 12.71s ========================
|
||||
```
|
||||
```
|
||||
======================== 30 passed, 1 warning in 0.58s =========================
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (701) зелёный, smoke прод-эндпоинтов
|
||||
OK (окружение живо). Существующие контракты не сломаны. Задача готова к стадии deploy-staging.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-07T14:37:33Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
|
||||
|
||||
Run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001)
|
||||
via the Docker Engine API over the mounted socket (`docker` CLI is not installed
|
||||
in the prod-agent container; `network_mode: host` + group `999` allow direct
|
||||
socket access):
|
||||
|
||||
```
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
```
|
||||
RESULT: 8/10 checks PASS
|
||||
REAL failed : none
|
||||
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
|
||||
tolerance: staging_infra_tolerance_enabled=True
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
- **Block A (SMOKE):** A1 `/health` 200 ok, A2 `/queue` 200, A3 `ORCH_STAGING=true` — all PASS.
|
||||
- **Block B (ACCESS):** B4 Plane sandbox, B5 Gitea `orchestrator-sandbox` (push=true),
|
||||
B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
|
||||
- **Block C (E2E, stub):** C7 create issue in SANDBOX, C8 trigger pipeline via
|
||||
`/webhook/plane` — PASS. C9a/C9b FAILED but are sandbox-infra checks (bot accounts
|
||||
not members of the SANDBOX Plane project) — **waived** per ORCH-061; not a pipeline
|
||||
regression. Cleanup deleted the test Plane issue (HTTP 204).
|
||||
|
||||
All REAL pipeline checks are green; the only failures are the two known
|
||||
sandbox-infra checks, which the verdict tolerates (`staging_infra_tolerance_enabled=true`).
|
||||
The script exited 0 → advance.
|
||||
@@ -12,6 +12,7 @@ Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting)
|
||||
| **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). |
|
||||
| **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. |
|
||||
| **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). |
|
||||
| **R-9** (realized) | Та же петля `deploy-staging → development` по ВТОРОЙ причине: `docker build` staging-образа падает (rc=1), т.к. `Dockerfile` `COPY data/ ./data/` ссылается на gitignore-каталог, отсутствующий в build-context воркти. Всплыло, когда waiver C9a/C9b впервые пропустил конвейер до пересборки образа (`check_staging_image_fresh`, ORCH-058). | — (произошло) | Высокое | `COPY data/ ./data/` → `RUN mkdir -p /app/data`. `data/` приходит через compose bind-mount, в образ запекать нечего. Инвариант: `Dockerfile` не `COPY` gitignore-путей (иначе сборка из воркти ломается). Гард — `tests/test_dockerfile_worktree_buildable.py`. |
|
||||
|
||||
## Контрактные инварианты (не нарушать)
|
||||
- `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений.
|
||||
|
||||
@@ -249,11 +249,6 @@ class AgentLauncher:
|
||||
"""
|
||||
if job.get("agent") == "deploy-finalizer":
|
||||
return self._run_deploy_finalizer_job(job)
|
||||
# ORCH-021: the reserved-agent `post-deploy-monitor` is also a
|
||||
# DETERMINISTIC (no-LLM) tick — intercept it BEFORE _spawn and run one
|
||||
# observation tick synchronously. Returns None (no agent_run row).
|
||||
if job.get("agent") == "post-deploy-monitor":
|
||||
return self._run_post_deploy_monitor_job(job)
|
||||
return self._spawn(
|
||||
job["agent"],
|
||||
job["repo"],
|
||||
@@ -283,27 +278,6 @@ class AgentLauncher:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _run_post_deploy_monitor_job(self, job: dict):
|
||||
"""ORCH-021: run one deterministic post-deploy monitor tick for a job.
|
||||
|
||||
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
|
||||
done/failed here. The tick never-raises, but we guard anyway so a monitor
|
||||
fault can never wedge the worker / starve other projects (AC-16).
|
||||
"""
|
||||
from ..db import mark_job
|
||||
from .. import stage_engine
|
||||
try:
|
||||
stage_engine.run_post_deploy_monitor(job)
|
||||
mark_job(job["id"], "done")
|
||||
logger.info(f"post-deploy-monitor job {job['id']} done")
|
||||
except Exception as e:
|
||||
logger.error(f"post-deploy-monitor job {job['id']} failed: {e}")
|
||||
try:
|
||||
mark_job(job["id"], "failed", error=f"post-deploy-monitor error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _spawn(self, agent: str, repo: str, task_content: str = None,
|
||||
task_id: int = None, job_id: int = None) -> int:
|
||||
"""Shared spawn implementation for launch() and launch_job().
|
||||
|
||||
@@ -265,37 +265,6 @@ class Settings(BaseSettings):
|
||||
reconcile_notify_unblock: bool = True
|
||||
reconcile_skip_blocked_enabled: bool = True
|
||||
|
||||
# ORCH-021: post-deploy production monitoring + degradation reaction. After
|
||||
# the terminal deploy->done transition for an applicable repo, a reserved-agent
|
||||
# `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod
|
||||
# over a window and reacts to a degradation the restart-time health-check
|
||||
# missed (class "green deploy, red prod", precedent ET-8). State is in sentinel
|
||||
# files (.post-deploy-state-<repo>/<wi>/), no DB migration. See
|
||||
# docs/architecture/adr/adr-0010-post-deploy-monitor.md.
|
||||
# post_deploy_monitor_enabled -> global kill-switch (BR-8); False -> the
|
||||
# pipeline is 1:1 as before ORCH-021 (no arm).
|
||||
# post_deploy_repos -> CSV of repos where monitoring is REAL; empty
|
||||
# -> only the self-hosting repo (orchestrator).
|
||||
# Mirrors self_deploy_repos / merge_gate_repos.
|
||||
# post_deploy_window_s -> observation window length (~15 min, BR-1).
|
||||
# post_deploy_interval_s -> seconds between probe ticks.
|
||||
# post_deploy_fail_threshold -> N CONSECUTIVE health failures -> DEGRADED.
|
||||
# post_deploy_5xx_threshold -> window 5xx ratio above this -> DEGRADED.
|
||||
# post_deploy_auto_rollback -> globally allow auto-rollback; True acts ONLY
|
||||
# for non-self repos. For self-hosting the
|
||||
# reaction is ALWAYS ALERT_ONLY (BR-5) — a tick
|
||||
# NEVER restarts the prod orchestrator container.
|
||||
# post_deploy_base_url -> base URL of the observed prod instance.
|
||||
# Rollback target params reuse the existing deploy_prod_* settings (no dupes).
|
||||
post_deploy_monitor_enabled: bool = True
|
||||
post_deploy_repos: str = ""
|
||||
post_deploy_window_s: int = 900
|
||||
post_deploy_interval_s: int = 30
|
||||
post_deploy_fail_threshold: int = 3
|
||||
post_deploy_5xx_threshold: float = 0.5
|
||||
post_deploy_auto_rollback: bool = False
|
||||
post_deploy_base_url: str = "http://localhost:8500"
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
@@ -123,13 +123,11 @@ async def queue():
|
||||
from .db import job_status_counts, recent_jobs
|
||||
from .queue_worker import worker
|
||||
from .reconciler import reconciler
|
||||
from . import post_deploy
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
"max_concurrency": worker.max_concurrency,
|
||||
"poll_interval": worker.poll_interval,
|
||||
"resilience": worker.status(),
|
||||
"reconcile": reconciler.status(),
|
||||
"post_deploy": post_deploy.status(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
"""Post-deploy production monitoring + degradation reaction (ORCH-021).
|
||||
|
||||
The pipeline used to end at ``deploy -> done`` and then **forget about prod**:
|
||||
"success" meant the health-check passed at restart (~60s window in
|
||||
``scripts/orchestrator-deploy-hook.sh``). The class of incidents "green deploy,
|
||||
red prod" (precedent ET-8 — degradation appears minutes later under real
|
||||
traffic; ``/health`` answers ``200 ok`` while the feature is broken) was never
|
||||
caught. ORCH-021 extends responsibility **PAST** ``done``: after the terminal
|
||||
transition for an applicable repo we arm an observation window
|
||||
(``post_deploy_window_s`` ~15 min, interval ``post_deploy_interval_s``);
|
||||
degradation is detected by deterministic thresholds and, when confirmed,
|
||||
triggers a reaction.
|
||||
|
||||
The observation mechanism (ADR-001 §1, Variant B) is a **reserved-agent job**
|
||||
``post-deploy-monitor`` — a deterministic, no-LLM job modelled exactly on
|
||||
``deploy-finalizer``. One "tick" == one job: it does ONE probe, appends to a
|
||||
persisted ``series`` file, classifies, and either re-queues itself with a delay
|
||||
(``available_at_delay_s``) or finishes (DEGRADED -> reaction; or window expired
|
||||
-> HEALTHY). Between ticks no job runs (it is scheduled in the future), so the
|
||||
single worker stays free for other projects — exactly like the finalizer defer.
|
||||
|
||||
This module is a **leaf** (mirrors ``self_deploy.py`` / ``staging_verdict.py``):
|
||||
it imports only config (and lazily ``qg.checks.is_self_hosting_repo``), never
|
||||
``stage_engine`` / ``launcher`` — the orchestration that needs those lives in
|
||||
``stage_engine.run_post_deploy_monitor``. Every public helper honours a
|
||||
**never-raise** contract so a monitoring hiccup can never crash the worker /
|
||||
lifespan / the pipeline of other projects (AC-16).
|
||||
|
||||
Restart-safe state lives in sentinel files under
|
||||
``<repos_dir>/.post-deploy-state-<repo>/<work_item_id>/`` (mirrors the
|
||||
deploy-state pattern, no DB migration — ТЗ §2.7):
|
||||
* ``armed`` — monitoring armed for this work item (idempotency-guard, AC-15);
|
||||
* ``series`` — JSON list of probe results (restart-safe streak/5xx counters);
|
||||
* ``done`` — monitoring finished (anti-dupe, AC-15).
|
||||
|
||||
Self-hosting safety (BR-5 / AC-8): a monitor tick NEVER auto-rolls-back or
|
||||
restarts the prod ``orchestrator`` container — for ``orchestrator`` the reaction
|
||||
is ALWAYS ``ALERT_ONLY`` (loud Telegram + Plane, manual approve).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.post_deploy")
|
||||
|
||||
# Sentinel marker filenames (see module docstring).
|
||||
ARMED = "armed"
|
||||
SERIES = "series"
|
||||
DONE = "done"
|
||||
|
||||
# Verdicts (classify).
|
||||
HEALTHY = "HEALTHY"
|
||||
DEGRADED = "DEGRADED"
|
||||
|
||||
# Reaction decisions (decide_action).
|
||||
NONE = "NONE"
|
||||
ROLLBACK = "ROLLBACK"
|
||||
ALERT_ONLY = "ALERT_ONLY"
|
||||
|
||||
# action_taken values written to the artefact frontmatter.
|
||||
ROLLBACK_OK = "ROLLBACK_OK"
|
||||
ROLLBACK_FAILED = "ROLLBACK_FAILED"
|
||||
|
||||
# The 5xx-monitored endpoints (besides /health, whose 200+ok is its own signal).
|
||||
_FIVEXX_ENDPOINTS = ("/status", "/queue")
|
||||
|
||||
_PROBE_TIMEOUT = 5
|
||||
_SSH_TIMEOUT = 60
|
||||
_GIT_TIMEOUT = 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors self_deploy_applies / _merge_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def post_deploy_applies(repo: str) -> bool:
|
||||
"""Whether post-deploy monitoring is REAL for this repo (AC-2 / AC-10).
|
||||
|
||||
Mirrors the ORCH-35/36/43/58 conditional rollout:
|
||||
* ``post_deploy_monitor_enabled=False`` -> always False (global
|
||||
kill-switch); the pipeline is 1:1 as before ORCH-021 (AC-10).
|
||||
* ``post_deploy_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.post_deploy_monitor_enabled:
|
||||
return False
|
||||
raw = (settings.post_deploy_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 (avoid importing qg at load time).
|
||||
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("post_deploy_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal probe (one tick)
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class ProbeResult:
|
||||
"""Outcome of ONE probe tick (JSON-serialisable via ``as_dict``).
|
||||
|
||||
``health_ok`` — ``/health`` answered HTTP 200 with ``{"status": "ok"}``.
|
||||
``total`` — number of 5xx-monitored endpoints probed (``/status``,
|
||||
``/queue``) — the denominator of the window 5xx ratio.
|
||||
``fivexx`` — how many of those returned 5xx (or were unreachable, which
|
||||
is conservatively counted as a server failure).
|
||||
``detail`` — human-readable note (logs / artefact body).
|
||||
"""
|
||||
|
||||
health_ok: bool
|
||||
total: int
|
||||
fivexx: int
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"health_ok": bool(self.health_ok),
|
||||
"total": int(self.total),
|
||||
"fivexx": int(self.fivexx),
|
||||
"detail": str(self.detail),
|
||||
}
|
||||
|
||||
|
||||
def _http_status(url: str) -> tuple[int, str]:
|
||||
"""GET ``url`` -> (http_code, body). Network/timeout -> (0, "").
|
||||
|
||||
Never raises. ``urllib`` raises ``HTTPError`` for >=400 responses; we treat
|
||||
that as a real status code (so a 5xx is observed, not swallowed).
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=_PROBE_TIMEOUT) as resp: # noqa: S310
|
||||
body = resp.read(4096).decode("utf-8", "replace")
|
||||
return int(getattr(resp, "status", resp.getcode())), body
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = e.read(4096).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
return int(e.code), body
|
||||
except Exception as e: # noqa: BLE001 - URLError / socket timeout / anything
|
||||
logger.warning("post_deploy probe error for %s: %s", url, e)
|
||||
return 0, ""
|
||||
|
||||
|
||||
def probe_signals(base_url: str) -> ProbeResult:
|
||||
"""Probe ``/health`` + the key endpoints of the prod instance ONCE (AC-16).
|
||||
|
||||
``/health`` is healthy iff HTTP 200 AND the body parses to
|
||||
``{"status": "ok"}``. ``/status`` and ``/queue`` contribute to the window
|
||||
5xx ratio: an HTTP 5xx OR an unreachable endpoint (network error / timeout,
|
||||
code 0) is counted as a failure (conservative — a down server is bad). A
|
||||
network failure yields a conservative "failed" probe, NEVER an exception
|
||||
(TC-14).
|
||||
"""
|
||||
base = (base_url or "").rstrip("/")
|
||||
# --- /health: the primary liveness signal ---
|
||||
code, body = _http_status(base + "/health")
|
||||
health_ok = False
|
||||
if code == 200:
|
||||
try:
|
||||
health_ok = json.loads(body).get("status") == "ok"
|
||||
except Exception:
|
||||
health_ok = False
|
||||
# --- /status, /queue: 5xx ratio over the window ---
|
||||
total = 0
|
||||
fivexx = 0
|
||||
for ep in _FIVEXX_ENDPOINTS:
|
||||
total += 1
|
||||
ep_code, _ = _http_status(base + ep)
|
||||
if ep_code == 0 or 500 <= ep_code <= 599:
|
||||
fivexx += 1
|
||||
detail = f"health={code}({'ok' if health_ok else 'bad'}) 5xx={fivexx}/{total}"
|
||||
return ProbeResult(health_ok=health_ok, total=total, fivexx=fivexx, detail=detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classification (pure, no I/O — the MAIN unit-test subject, like
|
||||
# compute_staging_verdict in ORCH-061)
|
||||
# ---------------------------------------------------------------------------
|
||||
def classify(series, fail_threshold: int, fivexx_threshold: float) -> str:
|
||||
"""Fold a probe series into ``HEALTHY`` | ``DEGRADED`` (deterministic, pure).
|
||||
|
||||
``series`` — iterable of probe dicts (``{"health_ok", "total", "fivexx"}``),
|
||||
as persisted by :func:`append_probe`.
|
||||
|
||||
Decision (BR-3 / AC-3..AC-6):
|
||||
* ``>= fail_threshold`` CONSECUTIVE health failures -> ``DEGRADED`` (AC-4);
|
||||
* window 5xx ratio ``sum(fivexx)/sum(total)`` strictly ``> fivexx_threshold``
|
||||
-> ``DEGRADED`` even if ``/health`` answers 200 (AC-5);
|
||||
* otherwise ``HEALTHY`` — a single glitch below the threshold that recovers
|
||||
does NOT trip (AC-3 / AC-6, no false rollback).
|
||||
|
||||
Never raises: on malformed input it returns ``HEALTHY`` (fail-SAFE — a false
|
||||
``DEGRADED`` would trigger an unwanted rollback, the worse outcome).
|
||||
"""
|
||||
try:
|
||||
# Non-list input is malformed -> fail-safe HEALTHY (never a false rollback).
|
||||
if not isinstance(series, (list, tuple)):
|
||||
return HEALTHY
|
||||
# Longest run of consecutive health failures.
|
||||
streak = 0
|
||||
best = 0
|
||||
total = 0
|
||||
fivexx = 0
|
||||
for row in series:
|
||||
# A non-dict row is malformed: skip it (do NOT count it as a failure,
|
||||
# which could fabricate a DEGRADED streak from garbage).
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
ok = bool(row.get("health_ok"))
|
||||
total += int(row.get("total") or 0)
|
||||
fivexx += int(row.get("fivexx") or 0)
|
||||
if ok:
|
||||
streak = 0
|
||||
else:
|
||||
streak += 1
|
||||
if streak > best:
|
||||
best = streak
|
||||
if best >= int(fail_threshold):
|
||||
return DEGRADED
|
||||
if total > 0 and (fivexx / total) > float(fivexx_threshold):
|
||||
return DEGRADED
|
||||
return HEALTHY
|
||||
except Exception as e: # noqa: BLE001 - never-raise; fail-safe to HEALTHY
|
||||
logger.warning("post_deploy classify error: %s", e)
|
||||
return HEALTHY
|
||||
|
||||
|
||||
def decide_action(repo: str, verdict: str) -> str:
|
||||
"""Decide the reaction for ``(repo, verdict)`` (pure, BR-5 / AC-7 / AC-8).
|
||||
|
||||
* ``HEALTHY`` -> ``NONE`` (no reaction, any repo);
|
||||
* ``DEGRADED`` + self-hosting -> ``ALERT_ONLY`` (ALWAYS — the tick
|
||||
NEVER auto-rolls-back / restarts the prod orchestrator container, AC-8);
|
||||
* ``DEGRADED`` + non-self + ``post_deploy_auto_rollback=True`` -> ``ROLLBACK``;
|
||||
* ``DEGRADED`` + non-self + auto_rollback False (default) -> ``ALERT_ONLY``.
|
||||
|
||||
Never raises: on doubt returns ``ALERT_ONLY`` (never an unexpected rollback).
|
||||
"""
|
||||
try:
|
||||
if verdict != DEGRADED:
|
||||
return NONE
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
if is_self_hosting_repo(repo):
|
||||
return ALERT_ONLY # BR-5: self-hosting is NEVER auto-rolled-back
|
||||
if settings.post_deploy_auto_rollback:
|
||||
return ROLLBACK
|
||||
return ALERT_ONLY
|
||||
except Exception as e: # noqa: BLE001 - never-raise; safe default
|
||||
logger.warning("post_deploy decide_action error for %s: %s", repo, e)
|
||||
return ALERT_ONLY
|
||||
|
||||
|
||||
def map_rollback_exit_code(exit_code) -> str:
|
||||
"""Map a ``--rollback`` hook exit-code to an ``action_taken`` (pure, AC-9).
|
||||
|
||||
Hook exit-code contract (unchanged, 0/1/2):
|
||||
* ``0`` -> ``ROLLBACK_OK`` (rollback proven healthy);
|
||||
* ``1`` (no prev image), ``2`` (rollback also failed), anything else, or a
|
||||
non-int/None -> ``ROLLBACK_FAILED`` (fail-closed -> loud escalation).
|
||||
"""
|
||||
try:
|
||||
code = int(exit_code)
|
||||
except (TypeError, ValueError):
|
||||
return ROLLBACK_FAILED
|
||||
return ROLLBACK_OK if code == 0 else ROLLBACK_FAILED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentinel state (restart-safe, no DB migration — ТЗ §2.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _state_dir(base: str, repo: str, work_item_id: str | None) -> str:
|
||||
return os.path.join(base, f".post-deploy-state-{repo}", (work_item_id or "_"))
|
||||
|
||||
|
||||
def state_dir(repo: str, work_item_id: str | None) -> str:
|
||||
"""State dir as seen from the container (``settings.repos_dir`` mount)."""
|
||||
return _state_dir(settings.repos_dir, repo, work_item_id)
|
||||
|
||||
|
||||
def host_state_dir(repo: str, work_item_id: str | None) -> str:
|
||||
"""State dir as seen from the HOST (``settings.host_repos_dir``).
|
||||
|
||||
Same physical directory as :func:`state_dir` via the shared mount; the host
|
||||
path is what we embed in an ssh command if a host-side helper needs it.
|
||||
"""
|
||||
return _state_dir(settings.host_repos_dir, repo, work_item_id)
|
||||
|
||||
|
||||
def marker_path(repo: str, work_item_id: str | None, name: str) -> str:
|
||||
return os.path.join(state_dir(repo, work_item_id), name)
|
||||
|
||||
|
||||
def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
|
||||
"""True iff the named sentinel exists. Never raises."""
|
||||
try:
|
||||
return os.path.isfile(marker_path(repo, work_item_id, name))
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
|
||||
return False
|
||||
|
||||
|
||||
def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool:
|
||||
"""Create/overwrite a sentinel (best-effort). Returns True on success."""
|
||||
try:
|
||||
d = state_dir(repo, work_item_id)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
|
||||
f.write(str(content))
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
|
||||
return False
|
||||
|
||||
|
||||
def mark_done(repo: str, work_item_id: str | None) -> bool:
|
||||
"""Mark monitoring finished for this work item (anti-dupe, AC-15)."""
|
||||
return write_marker(repo, work_item_id, DONE, "done")
|
||||
|
||||
|
||||
def read_series(repo: str, work_item_id: str | None) -> list:
|
||||
"""Read the persisted probe series (JSON list). Missing/corrupt -> ``[]``.
|
||||
|
||||
Never raises — restart-safe streak/5xx counters survive a container restart.
|
||||
"""
|
||||
p = marker_path(repo, work_item_id, SERIES)
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception as e: # noqa: BLE001 - never-raise; corrupt -> empty
|
||||
logger.warning("read_series error for %s/%s: %s", repo, work_item_id, e)
|
||||
return []
|
||||
|
||||
|
||||
def append_probe(repo: str, work_item_id: str | None, probe: ProbeResult) -> list:
|
||||
"""Append a probe to the persisted series and return the new list.
|
||||
|
||||
Best-effort (a write error logs and returns the in-memory list so the tick
|
||||
still classifies). Never raises.
|
||||
"""
|
||||
series = read_series(repo, work_item_id)
|
||||
try:
|
||||
series.append(probe.as_dict() if isinstance(probe, ProbeResult) else dict(probe))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("append_probe coerce error for %s/%s: %s", repo, work_item_id, e)
|
||||
return series
|
||||
try:
|
||||
d = state_dir(repo, work_item_id)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
|
||||
json.dump(series, f)
|
||||
except OSError as e:
|
||||
logger.warning("append_probe write error for %s/%s: %s", repo, work_item_id, e)
|
||||
return series
|
||||
|
||||
|
||||
def arm_monitor(repo: str, work_item_id: str | None, branch: str, task_id: int) -> bool:
|
||||
"""Arm post-deploy monitoring after ``deploy -> done`` (AC-1 / AC-15).
|
||||
|
||||
Idempotent: if the ``armed`` sentinel already exists this is a no-op (a double
|
||||
webhook / reconciler F-1 / finalizer Phase C can drive ``done`` more than once,
|
||||
AC-15). Otherwise creates the state dir, writes ``armed`` + an empty ``series``,
|
||||
and enqueues the FIRST ``post-deploy-monitor`` job with a delay of one interval
|
||||
(so the prod has settled before the first probe). Returns True iff it armed a
|
||||
NEW monitor. Never raises — the caller (terminal block of ``advance_stage``)
|
||||
must never be crashed by a monitoring hiccup.
|
||||
"""
|
||||
try:
|
||||
if has_marker(repo, work_item_id, ARMED):
|
||||
logger.info("arm_monitor: already armed for %s/%s (no-op)", repo, work_item_id)
|
||||
return False
|
||||
write_marker(repo, work_item_id, ARMED, "armed")
|
||||
# Initialise an empty series so read_series is well-defined from tick 1.
|
||||
try:
|
||||
d = state_dir(repo, work_item_id)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
|
||||
json.dump([], f)
|
||||
except OSError as e:
|
||||
logger.warning("arm_monitor: series init error for %s/%s: %s", repo, work_item_id, e)
|
||||
# Lazy import keeps this module a leaf (db is a low-level dependency).
|
||||
from .db import enqueue_job
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: post-deploy\nNote: post-deploy monitor tick 1 "
|
||||
f"(window {settings.post_deploy_window_s}s, interval "
|
||||
f"{settings.post_deploy_interval_s}s)."
|
||||
)
|
||||
job_id = enqueue_job(
|
||||
"post-deploy-monitor", repo, task_desc, task_id=task_id,
|
||||
available_at_delay_s=settings.post_deploy_interval_s,
|
||||
)
|
||||
logger.info(
|
||||
"arm_monitor: armed post-deploy monitor for %s/%s (job_id=%s)",
|
||||
repo, work_item_id, job_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.error("arm_monitor error for %s/%s: %s", repo, work_item_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def max_ticks() -> int:
|
||||
"""Bounded tick budget for the window (anti-livelock, like
|
||||
``deploy_finalize_max_attempts``): ``window_s // interval_s`` (>= 1)."""
|
||||
try:
|
||||
interval = max(1, int(settings.post_deploy_interval_s))
|
||||
return max(1, int(settings.post_deploy_window_s) // interval)
|
||||
except Exception: # noqa: BLE001 - never-raise
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback command (non-self repos only; reuses deploy_prod_* env — ТЗ §2.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_rollback_command(repo: str) -> list[str]:
|
||||
"""Build the ssh argv that runs the deploy hook in ``--rollback`` mode.
|
||||
|
||||
Mirrors ``self_deploy.build_deploy_command`` (same prod-env, INFRA P-2 ssh
|
||||
target) but the action is ``--rollback`` and the call is SYNCHRONOUS (the
|
||||
target container is NOT the orchestrator, so it is safe to wait for the hook
|
||||
exit-code directly — no detached setsid wrapper, no ``result`` sentinel).
|
||||
Reuses the existing ``deploy_prod_*`` settings; no new duplicate config.
|
||||
"""
|
||||
env_assignments = (
|
||||
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
|
||||
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
|
||||
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} "
|
||||
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
|
||||
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
|
||||
)
|
||||
inner = (
|
||||
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
|
||||
f"{env_assignments} "
|
||||
f"bash {shlex.quote(settings.deploy_hook_script)} --rollback"
|
||||
)
|
||||
user = (settings.deploy_ssh_user or "").strip()
|
||||
host = (settings.deploy_ssh_host or "").strip()
|
||||
target = f"{user}@{host}" if user else host
|
||||
return ["ssh", "-o", "StrictHostKeyChecking=no", target, inner]
|
||||
|
||||
|
||||
def run_rollback(repo: str) -> tuple[int, str]:
|
||||
"""Run the ``--rollback`` hook synchronously. Returns ``(exit_code, detail)``.
|
||||
|
||||
Never raises: an ssh launch error / timeout maps to a non-zero exit-code so
|
||||
the caller records ``ROLLBACK_FAILED`` and escalates (AC-9). NEVER used for
|
||||
the self-hosting repo (``decide_action`` returns ``ALERT_ONLY`` there) — the
|
||||
structural guard against a tick restarting the prod orchestrator (AC-8).
|
||||
"""
|
||||
cmd = build_rollback_command(repo)
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 2, "rollback ssh timeout"
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return 2, f"rollback ssh error: {e}"
|
||||
detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200]
|
||||
return int(r.returncode), detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artefact 16-post-deploy-log.md (machine-readable frontmatter — ТЗ §2.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_post_deploy_log(
|
||||
work_item_id: str,
|
||||
status: str,
|
||||
action_taken: str,
|
||||
window_s: int,
|
||||
checks_total: int,
|
||||
checks_failed: int,
|
||||
body_extra: str = "",
|
||||
) -> str:
|
||||
"""Render a 16-post-deploy-log.md body. Only the YAML-frontmatter is machine
|
||||
read (canon of gates; the loop-of-lessons ORCH-8 consumes it, BR-10). The
|
||||
body is informational. Parseable by ``yaml.safe_load`` (AC-13).
|
||||
"""
|
||||
return (
|
||||
"---\n"
|
||||
f"post_deploy_status: {status}\n"
|
||||
f"action_taken: {action_taken}\n"
|
||||
f"work_item: {work_item_id}\n"
|
||||
f"window_s: {int(window_s)}\n"
|
||||
f"checks_total: {int(checks_total)}\n"
|
||||
f"checks_failed: {int(checks_failed)}\n"
|
||||
"---\n\n"
|
||||
"# Post-deploy log — ORCH-021 post-deploy monitor\n\n"
|
||||
f"Наблюдение прода завершено: `post_deploy_status: {status}`, "
|
||||
f"`action_taken: {action_taken}`.\n\n"
|
||||
f"Окно наблюдения: {int(window_s)}s; опросов всего: {int(checks_total)}, "
|
||||
f"из них с провалом: {int(checks_failed)}.\n"
|
||||
f"{body_extra}"
|
||||
)
|
||||
|
||||
|
||||
def write_post_deploy_log(
|
||||
repo: str,
|
||||
work_item_id: str,
|
||||
branch: str,
|
||||
status: str,
|
||||
action_taken: str,
|
||||
window_s: int,
|
||||
checks_total: int,
|
||||
checks_failed: int,
|
||||
body_extra: str = "",
|
||||
) -> bool:
|
||||
"""Write 16-post-deploy-log.md into the task worktree and best-effort
|
||||
commit+push it. Returns True iff the file was written. Never raises — the
|
||||
artefact is best-effort, its absence rolls nothing back (AC-13 / TC-15).
|
||||
"""
|
||||
from .git_worktree import get_worktree_path
|
||||
|
||||
rel = f"docs/work-items/{work_item_id}/16-post-deploy-log.md"
|
||||
try:
|
||||
wt = get_worktree_path(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error("write_post_deploy_log: worktree error for %s/%s: %s", repo, branch, e)
|
||||
return False
|
||||
|
||||
path = os.path.join(wt, rel)
|
||||
content = build_post_deploy_log(
|
||||
work_item_id, status, action_taken, window_s, checks_total, checks_failed, body_extra
|
||||
)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
logger.error("write_post_deploy_log: write error at %s: %s", path, e)
|
||||
return False
|
||||
|
||||
git_env = {
|
||||
**os.environ,
|
||||
"HOME": "/home/slin",
|
||||
"GIT_AUTHOR_NAME": "post-deploy-monitor",
|
||||
"GIT_AUTHOR_EMAIL": "post-deploy-monitor@mva154.local",
|
||||
"GIT_COMMITTER_NAME": "post-deploy-monitor",
|
||||
"GIT_COMMITTER_EMAIL": "post-deploy-monitor@mva154.local",
|
||||
}
|
||||
try:
|
||||
subprocess.run(["git", "-C", wt, "add", rel],
|
||||
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
|
||||
commit = subprocess.run(
|
||||
["git", "-C", wt, "commit", "-m",
|
||||
f"docs(ORCH-021): post-deploy {status}/{action_taken} for {work_item_id}"],
|
||||
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
|
||||
)
|
||||
if commit.returncode == 0:
|
||||
subprocess.run(["git", "-C", wt, "push", "origin", branch],
|
||||
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("write_post_deploy_log: git commit/push best-effort failed: %s", e)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (BR-9 / AC-14)
|
||||
# ---------------------------------------------------------------------------
|
||||
def status() -> dict:
|
||||
"""Post-deploy snapshot for /queue observability. Never raises.
|
||||
|
||||
``active`` — work items with an ``armed`` sentinel but no ``done`` yet (a
|
||||
monitoring window in flight). ``last_outcome`` — best-effort last finished
|
||||
window read from the most-recent ``done`` state dir's series length.
|
||||
"""
|
||||
snap = {
|
||||
"enabled": False,
|
||||
"window_s": None,
|
||||
"interval_s": None,
|
||||
"repos": "",
|
||||
"active": [],
|
||||
"active_count": 0,
|
||||
}
|
||||
try:
|
||||
snap["enabled"] = bool(settings.post_deploy_monitor_enabled)
|
||||
snap["window_s"] = int(settings.post_deploy_window_s)
|
||||
snap["interval_s"] = int(settings.post_deploy_interval_s)
|
||||
snap["repos"] = settings.post_deploy_repos or ""
|
||||
pattern = os.path.join(settings.repos_dir, ".post-deploy-state-*", "*")
|
||||
active: list[str] = []
|
||||
for d in glob.glob(pattern):
|
||||
try:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
if os.path.isfile(os.path.join(d, ARMED)) and not os.path.isfile(
|
||||
os.path.join(d, DONE)
|
||||
):
|
||||
active.append(os.path.basename(d))
|
||||
except Exception: # noqa: BLE001 - skip one dir
|
||||
continue
|
||||
snap["active"] = sorted(active)
|
||||
snap["active_count"] = len(active)
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("post_deploy status snapshot error: %s", e)
|
||||
return snap
|
||||
@@ -37,7 +37,6 @@ from .review_parse import extract_review_findings, extract_test_failures
|
||||
from .qg.checks import QG_CHECKS
|
||||
from . import merge_gate
|
||||
from . import self_deploy
|
||||
from . import post_deploy
|
||||
from .notifications import (
|
||||
notify_stage_change,
|
||||
notify_qg_failure,
|
||||
@@ -353,17 +352,6 @@ def advance_stage(
|
||||
except Exception as e: # noqa: BLE001 - defensive
|
||||
logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}")
|
||||
|
||||
# ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends
|
||||
# beyond the restart-time health-check to catch the "green deploy, red prod"
|
||||
# class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a
|
||||
# double webhook / reconciler / finalizer re-driving `done` never doubles it
|
||||
# and non-applicable repos are untouched. never-raise (arm_monitor + guard).
|
||||
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
|
||||
try:
|
||||
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
|
||||
except Exception as e: # noqa: BLE001 - monitoring must never crash done
|
||||
logger.warning(f"Task {task_id}: post-deploy arm failed: {e}")
|
||||
|
||||
# --- Launch the next agent (ORCH-4 fix: current_stage, not next) -----
|
||||
next_agent = get_agent_for_stage(current_stage)
|
||||
if next_agent:
|
||||
@@ -1188,139 +1176,3 @@ def run_deploy_finalizer(job: dict):
|
||||
branch=branch,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
|
||||
def run_post_deploy_monitor(job: dict):
|
||||
"""ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM).
|
||||
|
||||
A deterministic tick modelled on ``run_deploy_finalizer``: it does ONE probe
|
||||
of the prod instance, appends to the persisted ``series`` (restart-safe
|
||||
streak/5xx counters), classifies, and then either RE-QUEUES itself with a
|
||||
delay (window not over and still HEALTHY) or FINISHES the window (DEGRADED ->
|
||||
reaction; window expired -> HEALTHY). Observation happens entirely AFTER the
|
||||
terminal ``done`` — it never touches ``STAGE_TRANSITIONS`` / ``QG_CHECKS`` and
|
||||
never restarts the prod orchestrator container itself (AC-8 / AC-12).
|
||||
|
||||
never-raise into the caller (the launcher marks the job done/failed); each
|
||||
branch is individually defensive.
|
||||
"""
|
||||
task_id = job.get("task_id")
|
||||
repo = job.get("repo")
|
||||
try:
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.error(f"post-deploy-monitor: db error for task_id={task_id}: {e}")
|
||||
return
|
||||
if not row:
|
||||
logger.error(f"post-deploy-monitor: no task row for task_id={task_id}")
|
||||
return
|
||||
work_item_id, branch = row[0], row[1]
|
||||
|
||||
# AC-15: a finished window is a no-op (defends against a duplicate job).
|
||||
if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE):
|
||||
logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)")
|
||||
return
|
||||
|
||||
# One probe -> append -> classify (restart-safe via the persisted series).
|
||||
probe = post_deploy.probe_signals(settings.post_deploy_base_url)
|
||||
series = post_deploy.append_probe(repo, work_item_id, probe)
|
||||
verdict = post_deploy.classify(
|
||||
series,
|
||||
settings.post_deploy_fail_threshold,
|
||||
settings.post_deploy_5xx_threshold,
|
||||
)
|
||||
ticks = len(series)
|
||||
budget = post_deploy.max_ticks()
|
||||
logger.info(
|
||||
f"post-deploy-monitor: {work_item_id} tick {ticks}/{budget} "
|
||||
f"probe=[{probe.detail}] verdict={verdict}"
|
||||
)
|
||||
|
||||
# HEALTHY and window not exhausted -> defer the next tick (worker stays free).
|
||||
if verdict == post_deploy.HEALTHY and ticks < budget:
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: post-deploy\nNote: post-deploy monitor tick {ticks + 1} "
|
||||
f"(healthy so far; re-poll after {settings.post_deploy_interval_s}s)."
|
||||
)
|
||||
enqueue_job(
|
||||
"post-deploy-monitor", repo, task_desc, task_id=task_id,
|
||||
available_at_delay_s=settings.post_deploy_interval_s,
|
||||
)
|
||||
return
|
||||
|
||||
checks_total = ticks
|
||||
checks_failed = sum(1 for r in series if not r.get("health_ok"))
|
||||
|
||||
# HEALTHY and window exhausted -> clean finish (BR-6 / AC-17).
|
||||
if verdict == post_deploy.HEALTHY:
|
||||
post_deploy.write_post_deploy_log(
|
||||
repo, work_item_id, branch, post_deploy.HEALTHY, post_deploy.NONE,
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
)
|
||||
post_deploy.mark_done(repo, work_item_id)
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"✅ {work_item_id}: пост-деплой окно завершено чисто "
|
||||
f"(HEALTHY, {checks_total} опросов).",
|
||||
)
|
||||
return
|
||||
|
||||
# DEGRADED -> decide + execute the reaction (§5), write artefact, finish.
|
||||
action = post_deploy.decide_action(repo, verdict)
|
||||
action_taken = post_deploy.ALERT_ONLY
|
||||
if action == post_deploy.ROLLBACK:
|
||||
# Non-self repo + auto policy: run the --rollback hook synchronously (the
|
||||
# target is NOT the orchestrator, so its restart is safe for the pipeline).
|
||||
exit_code, detail = post_deploy.run_rollback(repo)
|
||||
action_taken = post_deploy.map_rollback_exit_code(exit_code)
|
||||
if action_taken == post_deploy.ROLLBACK_OK:
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"⚠️ {work_item_id}: пост-деплой DEGRADED -> авто-rollback выполнен "
|
||||
f"(exit {exit_code}).",
|
||||
)
|
||||
else:
|
||||
# AC-9: a failed rollback escalates loudly for manual intervention.
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"🚨 {work_item_id}: пост-деплой DEGRADED -> авто-rollback ПРОВАЛИЛСЯ "
|
||||
f"(exit {exit_code}: {detail}). Нужно ручное вмешательство.",
|
||||
)
|
||||
else:
|
||||
# ALERT_ONLY: self-hosting ALWAYS lands here — the tick NEVER auto-rolls-back
|
||||
# or restarts the prod orchestrator container (BR-5 / AC-8). Loud alert +
|
||||
# manual-approve request (mirrors deploy Phase A CTA).
|
||||
action_taken = post_deploy.ALERT_ONLY
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"🚨 {work_item_id}: пост-деплой DEGRADED ({checks_failed}/{checks_total} "
|
||||
f"провалов). Требуется ручной approve отката — авто-rollback для "
|
||||
f"self-hosting запрещён (BR-5).",
|
||||
)
|
||||
|
||||
post_deploy.write_post_deploy_log(
|
||||
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
)
|
||||
post_deploy.mark_done(repo, work_item_id)
|
||||
|
||||
|
||||
def _notify_post_deploy(work_item_id: str, message: str) -> None:
|
||||
"""Best-effort Telegram + Plane notification for a post-deploy event (AC-17).
|
||||
|
||||
Never raises — a notification failure must not wedge the monitor tick.
|
||||
"""
|
||||
try:
|
||||
send_telegram(message)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy notify telegram failed for {work_item_id}: {e}")
|
||||
if work_item_id:
|
||||
try:
|
||||
plane_add_comment(work_item_id, message, author="deployer")
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}")
|
||||
|
||||
@@ -102,31 +102,6 @@ def test_tc08_dockerfile_stamps_revision_label():
|
||||
assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08b (ORCH-021 regression): the Dockerfile must not COPY a gitignored path.
|
||||
# The ORCH-058 staging rebuild builds with the task *worktree* as the docker build
|
||||
# context. A fresh worktree contains only tracked files, so any `COPY <gitignored>`
|
||||
# (notably `data/`, the SQLite dir) makes `docker build` fail with exit 1 and bounces
|
||||
# the task off `deploy-staging`. `data/` is a runtime bind-mount volume anyway, so it
|
||||
# must never be a COPY source.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08b_dockerfile_does_not_copy_gitignored_data_dir():
|
||||
text = _DOCKERFILE.read_text(encoding="utf-8")
|
||||
gitignore = (_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||
# Precondition: `data/` really is gitignored (the build context will not have it).
|
||||
assert "data/" in [ln.strip() for ln in gitignore]
|
||||
# The Dockerfile must not COPY it (would break the worktree-context staging build).
|
||||
copy_sources = [
|
||||
line.split()[1]
|
||||
for line in text.splitlines()
|
||||
if line.strip().upper().startswith("COPY") and len(line.split()) >= 3
|
||||
]
|
||||
assert "data/" not in copy_sources, (
|
||||
"Dockerfile must not `COPY data/` — it's gitignored and absent from the "
|
||||
"worktree build context used by the ORCH-058 staging rebuild (exit 1)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09: caller↔hook contract — rebuild_staging_image builds the right command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -90,10 +90,6 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
|
||||
# Spy the merge-lease release to confirm the terminal-sync still frees it.
|
||||
release = MagicMock()
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release)
|
||||
# ORCH-021 arms an orthogonal post-deploy-monitor reserved job at deploy->done
|
||||
# for the self-hosting repo; disable it here so this test stays focused on the
|
||||
# ORCH-036 terminal-sync contract (no PIPELINE agent launched leaving deploy).
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
|
||||
90
tests/test_dockerfile_worktree_buildable.py
Normal file
90
tests/test_dockerfile_worktree_buildable.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""ORCH-061 regression: the image must build from a git WORKTREE context.
|
||||
|
||||
The staging-image rebuild of ORCH-058 (``check_staging_image_fresh`` / the deploy
|
||||
hook's ``--build-staging`` mode) uses the task **worktree** as the ``docker build``
|
||||
context. A git worktree only contains git-TRACKED files, so any ``COPY`` of a
|
||||
gitignored path makes ``docker build`` fail (rc=1) -> ``deploy-staging`` rolls back
|
||||
to ``development`` (the exact loop ORCH-061 fixes).
|
||||
|
||||
The concrete regression: ``COPY data/ ./data/`` referenced ``data/`` which is
|
||||
gitignored (runtime SQLite DB + backups) and therefore absent in every worktree.
|
||||
At runtime ``data/`` always arrives via the compose bind mount
|
||||
(``./data:/app/data`` / ``./data/staging:/app/data``), so baking it in was both
|
||||
build-breaking and pointless.
|
||||
|
||||
These tests guard the invariant statically (no docker required): the Dockerfile
|
||||
must not ``COPY`` a path that ``.gitignore`` excludes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCKERFILE = REPO_ROOT / "Dockerfile"
|
||||
GITIGNORE = REPO_ROOT / ".gitignore"
|
||||
|
||||
|
||||
def _dockerfile_copy_sources() -> list[str]:
|
||||
"""Source paths from every ``COPY <src...> <dst>`` line in the Dockerfile.
|
||||
|
||||
``--from`` (multi-stage / build-context) COPYs are skipped — they do not read
|
||||
the worktree build context. The last token on a COPY line is the destination.
|
||||
"""
|
||||
sources: list[str] = []
|
||||
for raw in DOCKERFILE.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line.upper().startswith("COPY "):
|
||||
continue
|
||||
if "--from" in line:
|
||||
continue
|
||||
tokens = line.split()[1:] # drop the COPY keyword
|
||||
tokens = [t for t in tokens if not t.startswith("--")]
|
||||
if len(tokens) >= 2:
|
||||
sources.extend(tokens[:-1]) # all but the destination
|
||||
return sources
|
||||
|
||||
|
||||
def _gitignored_dirs() -> set[str]:
|
||||
"""Top-level directory names excluded by ``.gitignore`` (e.g. ``data``)."""
|
||||
dirs: set[str] = set()
|
||||
for raw in GITIGNORE.read_text().splitlines():
|
||||
entry = raw.strip()
|
||||
if not entry or entry.startswith("#"):
|
||||
continue
|
||||
entry = entry.rstrip("/")
|
||||
# only care about simple top-level dir patterns (no globs / nested paths)
|
||||
if entry and "/" not in entry and "*" not in entry:
|
||||
dirs.add(entry)
|
||||
return dirs
|
||||
|
||||
|
||||
def test_dockerfile_does_not_copy_gitignored_data():
|
||||
"""``data/`` (gitignored runtime dir) must never be a Dockerfile COPY source."""
|
||||
copy_sources = _dockerfile_copy_sources()
|
||||
offending = [s for s in copy_sources if s.rstrip("/") == "data"]
|
||||
assert not offending, (
|
||||
"Dockerfile COPYs gitignored 'data/' -> build fails from a worktree "
|
||||
f"context (rc=1). Offending COPY sources: {offending}. "
|
||||
"Use `RUN mkdir -p /app/data` and rely on the compose bind mount instead."
|
||||
)
|
||||
|
||||
|
||||
def test_dockerfile_copies_only_git_tracked_sources():
|
||||
"""No Dockerfile COPY source may be a gitignored top-level directory."""
|
||||
gitignored = _gitignored_dirs()
|
||||
copy_sources = [s.rstrip("/") for s in _dockerfile_copy_sources()]
|
||||
leaking = sorted(set(copy_sources) & gitignored)
|
||||
assert not leaking, (
|
||||
"Dockerfile COPYs gitignored path(s) absent from git worktrees: "
|
||||
f"{leaking}. The staging rebuild (ORCH-058) builds from the worktree and "
|
||||
"will fail (rc=1)."
|
||||
)
|
||||
|
||||
|
||||
def test_data_dir_mount_target_is_created():
|
||||
"""The image must create the /app/data mount target (no COPY dependency)."""
|
||||
text = DOCKERFILE.read_text()
|
||||
assert re.search(r"mkdir\s+-p\s+/app/data", text), (
|
||||
"Dockerfile must `RUN mkdir -p /app/data` so the compose bind-mount "
|
||||
"target exists without depending on a (gitignored) host data/ dir."
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""ORCH-021 unit tests — post-deploy monitor pure logic (TC-01..TC-15).
|
||||
|
||||
The deterministic, network-free core (classification + reaction decision +
|
||||
exit-code mapping + artefact frontmatter + never-raise) of ``src/post_deploy.py``.
|
||||
Network probes and the rollback hook are exercised via mocks; the classifier is
|
||||
the main subject (mirrors compute_staging_verdict in ORCH-061).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Isolate the settings singleton onto a tmp repos_dir BEFORE importing the module.
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import post_deploy # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _probe(health_ok=True, total=2, fivexx=0):
|
||||
return {"health_ok": health_ok, "total": total, "fivexx": fivexx}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_state(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01..TC-05 — classification (the core)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_healthy_no_failures():
|
||||
series = [_probe() for _ in range(5)]
|
||||
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
|
||||
|
||||
|
||||
def test_tc02_degraded_consecutive_health_failures():
|
||||
# Exactly fail_threshold consecutive failures -> DEGRADED (>= contract).
|
||||
series = [_probe(health_ok=False) for _ in range(3)]
|
||||
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "DEGRADED"
|
||||
|
||||
|
||||
def test_tc03_degraded_by_5xx_ratio_even_when_health_200():
|
||||
# /health stays 200 (health_ok True) but the 5xx ratio is above threshold.
|
||||
series = [_probe(health_ok=True, total=2, fivexx=2) for _ in range(3)]
|
||||
assert post_deploy.classify(series, fail_threshold=10, fivexx_threshold=0.5) == "DEGRADED"
|
||||
|
||||
|
||||
def test_tc04_no_false_trip_single_glitch_then_recovery():
|
||||
# One isolated failure (1 < threshold) surrounded by healthy probes -> HEALTHY.
|
||||
series = [_probe(), _probe(health_ok=False), _probe(), _probe()]
|
||||
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
|
||||
|
||||
|
||||
def test_tc05_thresholds_change_verdict_on_same_data():
|
||||
# Same data, different threshold flips the verdict (AC-11): two consecutive fails.
|
||||
series = [_probe(health_ok=False), _probe(health_ok=False)]
|
||||
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
|
||||
assert post_deploy.classify(series, fail_threshold=2, fivexx_threshold=0.5) == "DEGRADED"
|
||||
|
||||
|
||||
def test_classify_uses_settings_thresholds(monkeypatch):
|
||||
# The tick reads thresholds from Settings (env ORCH_*) — verify the wiring point.
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 2)
|
||||
series = [_probe(health_ok=False), _probe(health_ok=False)]
|
||||
assert post_deploy.classify(
|
||||
series,
|
||||
post_deploy.settings.post_deploy_fail_threshold,
|
||||
post_deploy.settings.post_deploy_5xx_threshold,
|
||||
) == "DEGRADED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06..TC-08 — reaction decision (self-hosting safety)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_nonself_auto_rollback_degraded_rolls_back(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
|
||||
assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ROLLBACK"
|
||||
|
||||
|
||||
def test_tc07_self_hosting_degraded_never_rolls_back(monkeypatch):
|
||||
# orchestrator (self-hosting) is ALWAYS ALERT_ONLY, even with auto_rollback on.
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
|
||||
assert post_deploy.decide_action("orchestrator", "DEGRADED") == "ALERT_ONLY"
|
||||
|
||||
|
||||
def test_tc08_healthy_means_none_for_any_repo():
|
||||
assert post_deploy.decide_action("orchestrator", "HEALTHY") == "NONE"
|
||||
assert post_deploy.decide_action("enduro-trails", "HEALTHY") == "NONE"
|
||||
|
||||
|
||||
def test_nonself_default_policy_alert_only(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", False)
|
||||
assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ALERT_ONLY"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09..TC-10 — conditionality / kill-switch
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_applies_empty_repos_only_self_hosting(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
|
||||
assert post_deploy.post_deploy_applies("orchestrator") is True
|
||||
assert post_deploy.post_deploy_applies("enduro-trails") is False
|
||||
|
||||
|
||||
def test_tc09_applies_explicit_repos_csv(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails")
|
||||
assert post_deploy.post_deploy_applies("enduro-trails") is True
|
||||
assert post_deploy.post_deploy_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc10_kill_switch_disables_for_everyone(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False)
|
||||
assert post_deploy.post_deploy_applies("orchestrator") is False
|
||||
assert post_deploy.post_deploy_applies("enduro-trails") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11..TC-12 — rollback exit-code mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_rollback_exit0_is_ok():
|
||||
assert post_deploy.map_rollback_exit_code(0) == "ROLLBACK_OK"
|
||||
|
||||
|
||||
def test_tc12_rollback_exit_nonzero_is_failed():
|
||||
assert post_deploy.map_rollback_exit_code(1) == "ROLLBACK_FAILED"
|
||||
assert post_deploy.map_rollback_exit_code(2) == "ROLLBACK_FAILED"
|
||||
assert post_deploy.map_rollback_exit_code(None) == "ROLLBACK_FAILED"
|
||||
assert post_deploy.map_rollback_exit_code("garbage") == "ROLLBACK_FAILED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 — artefact frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_log_frontmatter_parses():
|
||||
body = post_deploy.build_post_deploy_log(
|
||||
"ORCH-021", "DEGRADED", "ALERT_ONLY", 900, 12, 4
|
||||
)
|
||||
assert body.startswith("---\n")
|
||||
fm = body.split("---", 2)[1]
|
||||
data = yaml.safe_load(fm)
|
||||
assert data["post_deploy_status"] == "DEGRADED"
|
||||
assert data["action_taken"] == "ALERT_ONLY"
|
||||
assert data["work_item"] == "ORCH-021"
|
||||
assert data["window_s"] == 900
|
||||
assert data["checks_total"] == 12
|
||||
assert data["checks_failed"] == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14..TC-15 — never-raise
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_probe_network_error_is_conservative_not_raise(monkeypatch):
|
||||
# urlopen raises on every call -> health bad + monitored endpoints counted as
|
||||
# 5xx, but NO exception propagates (the helper swallows and reports code 0).
|
||||
def boom(*a, **k):
|
||||
raise OSError("network down")
|
||||
|
||||
monkeypatch.setattr(post_deploy.urllib.request, "urlopen", boom)
|
||||
res = post_deploy.probe_signals("http://localhost:8500")
|
||||
assert res.health_ok is False
|
||||
assert res.total == 2
|
||||
assert res.fivexx == 2 # unreachable endpoints counted as failures
|
||||
|
||||
|
||||
def test_tc14_classify_junk_input_swallowed():
|
||||
# If classify gets junk it must not raise (fail-safe to HEALTHY).
|
||||
assert post_deploy.classify("not-a-list", 3, 0.5) == "HEALTHY"
|
||||
assert post_deploy.classify([{"bad": "row"}], 3, 0.5) == "HEALTHY"
|
||||
assert post_deploy.classify(None, 3, 0.5) == "HEALTHY"
|
||||
|
||||
|
||||
def test_tc15_write_log_no_worktree_returns_false(monkeypatch):
|
||||
# get_worktree_path raises -> write returns False, no exception (best-effort).
|
||||
def boom(repo, branch):
|
||||
raise FileNotFoundError("no worktree")
|
||||
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", boom)
|
||||
ok = post_deploy.write_post_deploy_log(
|
||||
"nope-repo", "ORCH-021", "feature/x", "HEALTHY", "NONE", 900, 3, 0
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentinel state restart-safe counters
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_series_append_and_read_roundtrip():
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(False, 2, 1, "x"))
|
||||
post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(True, 2, 0, "y"))
|
||||
series = post_deploy.read_series("orchestrator", "ORCH-021")
|
||||
assert len(series) == 2
|
||||
assert series[0]["health_ok"] is False
|
||||
assert series[1]["health_ok"] is True
|
||||
|
||||
|
||||
def test_mark_done_idempotency_marker():
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is False
|
||||
post_deploy.mark_done("orchestrator", "ORCH-021")
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is True
|
||||
@@ -1,259 +0,0 @@
|
||||
"""ORCH-021 integration tests — arming + tick orchestration (TC-16..TC-20).
|
||||
|
||||
Exercises the wiring in ``stage_engine`` (arm on deploy->done,
|
||||
``run_post_deploy_monitor`` tick + reaction) and the ``/queue`` observability
|
||||
block, with the network probe and the rollback hook mocked. Mirrors the
|
||||
test_deploy_terminal_sync.py harness.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_post_deploy.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import stage_engine # noqa: E402
|
||||
from src import post_deploy # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
# State sentinels live under the tmp repos_dir (container view).
|
||||
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||
monkeypatch.setattr(stage_engine.settings, "repos_dir", str(tmp_path))
|
||||
# The artefact write is best-effort; stub it so no worktree is needed.
|
||||
monkeypatch.setattr(post_deploy, "write_post_deploy_log", MagicMock(return_value=True))
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence_side_effects(monkeypatch):
|
||||
for name in (
|
||||
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
|
||||
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||
"set_issue_blocked", "set_issue_done",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
|
||||
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-021-x", wi="ORCH-021"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def _jobs(agent=None):
|
||||
conn = get_db()
|
||||
if agent:
|
||||
rows = conn.execute(
|
||||
"SELECT agent FROM jobs WHERE agent=? ORDER BY id", (agent,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _drive_deploy_to_done(monkeypatch, task_id, repo="orchestrator",
|
||||
branch="feature/ORCH-021-x", wi="ORCH-021"):
|
||||
"""Advance a deploy-stage task to done through the real terminal block."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
return stage_engine.advance_stage(
|
||||
task_id=task_id, current_stage="deploy", repo=repo,
|
||||
work_item_id=wi, branch=branch, finished_agent="deployer",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 — arm on deploy->done (applicable repo only)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_arm_for_self_hosting(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
|
||||
task_id = _make_task("deploy")
|
||||
_drive_deploy_to_done(monkeypatch, task_id)
|
||||
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED)
|
||||
assert "post-deploy-monitor" in _jobs("post-deploy-monitor")
|
||||
|
||||
|
||||
def test_tc16_no_arm_for_nonself(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
|
||||
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-9", wi="ET-9")
|
||||
_drive_deploy_to_done(monkeypatch, task_id, repo="enduro-trails",
|
||||
branch="feature/ET-9", wi="ET-9")
|
||||
|
||||
assert not post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.ARMED)
|
||||
assert _jobs("post-deploy-monitor") == []
|
||||
|
||||
|
||||
def test_tc16_no_arm_when_kill_switch_off(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False)
|
||||
task_id = _make_task("deploy")
|
||||
_drive_deploy_to_done(monkeypatch, task_id)
|
||||
assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED)
|
||||
assert _jobs("post-deploy-monitor") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 — idempotent arm (double webhook)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_double_arm_is_noop(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
armed1 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1)
|
||||
armed2 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1)
|
||||
assert armed1 is True
|
||||
assert armed2 is False
|
||||
# Exactly ONE monitor job enqueued despite two arm calls.
|
||||
assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 — DEGRADED -> non-self auto-rollback (hook mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_degraded_nonself_rolls_back(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails")
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 tick
|
||||
# Probe reports unhealthy.
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
|
||||
)
|
||||
rollback = MagicMock(return_value=(0, "ok"))
|
||||
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
|
||||
notify = MagicMock()
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify)
|
||||
logspy = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy)
|
||||
|
||||
task_id = _make_task("done", repo="enduro-trails", branch="feature/ET-9", wi="ET-9")
|
||||
post_deploy.write_marker("enduro-trails", "ET-9", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
|
||||
rollback.assert_called_once_with("enduro-trails")
|
||||
assert post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.DONE)
|
||||
# Artefact written with ROLLBACK_OK; a notification was sent.
|
||||
args = logspy.call_args[0]
|
||||
assert "DEGRADED" in args
|
||||
assert "ROLLBACK_OK" in args
|
||||
assert notify.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-19 — self-hosting DEGRADED never rolls back, alerts instead
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc19_degraded_self_hosting_alert_only(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
|
||||
)
|
||||
# Rollback hook MUST NOT be called for self-hosting (AC-8 structural invariant).
|
||||
rollback = MagicMock(return_value=(0, "ok"))
|
||||
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
|
||||
notify = MagicMock()
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify)
|
||||
logspy = MagicMock(return_value=True)
|
||||
monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy)
|
||||
|
||||
task_id = _make_task("done")
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
|
||||
rollback.assert_not_called()
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
args = logspy.call_args[0]
|
||||
assert "DEGRADED" in args
|
||||
assert "ALERT_ONLY" in args
|
||||
assert notify.called
|
||||
|
||||
|
||||
def test_healthy_tick_requeues_without_finishing(monkeypatch):
|
||||
# HEALTHY and window not exhausted -> re-queue, do NOT mark done.
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 90)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=3
|
||||
monkeypatch.setattr(
|
||||
post_deploy, "probe_signals",
|
||||
lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"),
|
||||
)
|
||||
task_id = _make_task("done")
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
# A follow-up tick job was enqueued.
|
||||
assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"]
|
||||
|
||||
|
||||
def test_finished_window_tick_is_noop(monkeypatch):
|
||||
# AC-15: a tick after the window is done is a no-op (no new job, no re-probe).
|
||||
probe = MagicMock()
|
||||
monkeypatch.setattr(post_deploy, "probe_signals", probe)
|
||||
task_id = _make_task("done")
|
||||
post_deploy.mark_done("orchestrator", "ORCH-021")
|
||||
stage_engine.run_post_deploy_monitor(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 9, "agent": "post-deploy-monitor"}
|
||||
)
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-20 — /queue observability block
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc20_queue_block_present(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
|
||||
snap = post_deploy.status()
|
||||
assert snap["enabled"] is True
|
||||
assert snap["window_s"] == post_deploy.settings.post_deploy_window_s
|
||||
assert "ORCH-021" in snap["active"]
|
||||
assert snap["active_count"] >= 1
|
||||
# A finished window drops out of "active".
|
||||
post_deploy.mark_done("orchestrator", "ORCH-021")
|
||||
snap2 = post_deploy.status()
|
||||
assert "ORCH-021" not in snap2["active"]
|
||||
Reference in New Issue
Block a user