Compare commits
10 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbb7ef0bb | ||
|
|
e07ee9e574 | ||
| 8cdb9f194a | |||
| cb3bdd9c7a | |||
|
|
04233cb3c8 | ||
|
|
85ecf50926 | ||
| 30b6187c73 | |||
| 44db94e462 | |||
| 4f24f96169 | |||
| 2d20da295e |
21
.env.example
21
.env.example
@@ -146,6 +146,27 @@ ORCH_REAPER_MAX_RUNNING_S=3600
|
||||
ORCH_REAPER_FINALIZE_GRACE_S=300
|
||||
ORCH_LEASE_RECLAIM_ENABLED=true
|
||||
|
||||
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
|
||||
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates. Deterministic
|
||||
# (no LLM): gitleaks (offline secret-scan, pinned Go binary in the image) + pip-audit
|
||||
# (OSV/PyPI CVE audit). Verdict in the versioned 17-security-report.md frontmatter;
|
||||
# FAIL -> rollback to development + developer-retry (cap 3). See ADR-001.
|
||||
# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-022.
|
||||
# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting.
|
||||
# DEP_BLOCK_SEVERITY -> CVE severity that BLOCKS (CRITICAL>HIGH>MEDIUM>LOW); below /
|
||||
# UNKNOWN -> warning only (anti-loop).
|
||||
# SCAN_TIMEOUT_S -> per external scanner call timeout.
|
||||
# DEP_AUDIT_FAIL_CLOSED -> strict mode: unreachable CVE feed -> FAIL instead of the
|
||||
# default fail-open + warning (anti-loop). Default false.
|
||||
# SECRETS_BLOCK -> a found secret blocks (always true by default; the offline
|
||||
# secrets guarantee is unconditional).
|
||||
ORCH_SECURITY_GATE_ENABLED=true
|
||||
ORCH_SECURITY_GATE_REPOS=
|
||||
ORCH_SECURITY_DEP_BLOCK_SEVERITY=HIGH
|
||||
ORCH_SECURITY_SCAN_TIMEOUT_S=300
|
||||
ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false
|
||||
ORCH_SECURITY_SECRETS_BLOCK=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
|
||||
|
||||
38
.gitleaks.toml
Normal file
38
.gitleaks.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
# gitleaks config — ORCH-022 security-gate (secret-scanning).
|
||||
#
|
||||
# Versioned in the repo root (07-infra I-4 / BR-13): rules + an allowlist of
|
||||
# known-safe matches are reviewed as code. The security-gate (src/security_gate.py)
|
||||
# passes this file via `--config` when present. gitleaks runs OFFLINE (local rules)
|
||||
# so the "a secret always blocks" guarantee (BR-2) never depends on the network.
|
||||
#
|
||||
# Strategy: extend the built-in ruleset (broad coverage, maintained upstream) and
|
||||
# only ADD a narrow allowlist for placeholders / fixtures that are intentionally
|
||||
# fake (e.g. .env.example dummy values, test fixtures). Keep the allowlist tight —
|
||||
# an over-broad allowlist silently re-opens the leak it was meant to bless.
|
||||
|
||||
title = "orchestrator gitleaks config"
|
||||
|
||||
[extend]
|
||||
# Start from gitleaks' maintained default ruleset.
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Known-safe, intentionally non-secret matches (placeholders + fixtures)."
|
||||
|
||||
# Files that legitimately contain placeholder/dummy secret-shaped values:
|
||||
# * .env.example — the committed canon of env vars with DUMMY values (CLAUDE.md §8;
|
||||
# real secrets live only in the host .env / .env.staging, never in git).
|
||||
# * tests/ — fixtures may embed fake tokens to exercise the scanner itself (TC-03).
|
||||
# * .gitleaks.toml — this file (avoid self-matching example patterns below).
|
||||
paths = [
|
||||
'''(^|/)\.env\.example$''',
|
||||
'''(^|/)tests/''',
|
||||
'''(^|/)\.gitleaks\.toml$''',
|
||||
]
|
||||
|
||||
# Generic placeholder tokens used in docs / examples that are NOT real secrets.
|
||||
regexes = [
|
||||
'''(?i)(your[-_]?(token|key|secret|password)[-_]?here)''',
|
||||
'''(?i)(changeme|dummy|example|placeholder|xxxxx+)''',
|
||||
'''(?i)<[a-z0-9_-]+>''',
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -38,19 +38,16 @@ created → analysis → architecture → development → review → testing →
|
||||
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
|
||||
```
|
||||
|
||||
## Статусная модель Plane (ORCH-066) — индикация ≠ управление
|
||||
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
|
||||
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
|
||||
- Work items: `docs/work-items/<plane-id>/`
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_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`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022).
|
||||
|
||||
## Правила для агентов
|
||||
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -8,9 +8,28 @@ FROM python:3.12-slim
|
||||
ARG GIT_SHA=""
|
||||
LABEL org.opencontainers.image.revision=$GIT_SHA
|
||||
WORKDIR /app
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssh-client git curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it.
|
||||
RUN git config --system --add safe.directory '*'
|
||||
# ORCH-022: pinned gitleaks static Go binary for the offline secret-scan sub-gate
|
||||
# (07-infra I-1). Baked into the image (NOT a pip package): the gate runs INSIDE the
|
||||
# orchestrator container over a per-task worktree. Pinned release => deterministic
|
||||
# rules; gitleaks needs no network so the "a secret always blocks" guarantee (BR-2)
|
||||
# is independent of internet access. Multi-arch aware (amd64/arm64).
|
||||
ARG GITLEAKS_VERSION=8.18.4
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) gl_arch="x64" ;; \
|
||||
arm64) gl_arch="arm64" ;; \
|
||||
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL -o /tmp/gitleaks.tar.gz \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz"; \
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks; \
|
||||
chmod +x /usr/local/bin/gitleaks; \
|
||||
rm -f /tmp/gitleaks.tar.gz; \
|
||||
gitleaks version
|
||||
# ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base
|
||||
# image has no passwd entry for uid 1000 -> ssh/whoami fail with
|
||||
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh
|
||||
|
||||
@@ -36,7 +36,7 @@ created → analysis → architecture → development → review → testing →
|
||||
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
|
||||
| done | — | — | — |
|
||||
|
||||
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058).
|
||||
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022).
|
||||
|
||||
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||
|
||||
@@ -155,6 +155,38 @@ 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`.
|
||||
|
||||
### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — реализовано)
|
||||
Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/
|
||||
приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну
|
||||
задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный
|
||||
(без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, рядом с merge-gate
|
||||
(ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди edge-под-гейтов
|
||||
(ДО merge-gate). Паттерн соседей: leaf `src/security_gate.py` (never-raise) + тонкая обёртка
|
||||
`check_security_gate` в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
|
||||
`STAGE_TRANSITIONS` и схема БД — **без изменений**.
|
||||
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
|
||||
аллоулиста `.gitleaks.toml` → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
|
||||
не зависит от сети (безусловна).
|
||||
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
|
||||
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
|
||||
громкий warning** (анти-петля ORCH-061; флаг `security_dep_audit_fail_closed` для строгого
|
||||
режима). best-effort при доступности фида.
|
||||
- **ПЕРВЫМ, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки ДО rebase
|
||||
не «обвиняет» задачу в CVE из обновившегося `main`; до захвата merge-lease → при FAIL lease
|
||||
освобождать не нужно.
|
||||
- **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/
|
||||
`deps_blocking`/`deps_warning`/`deps_audit_degraded`); вердикт читается ТОЛЬКО из
|
||||
frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает: единый
|
||||
источник истины), negative-токен авторитетен, битый/нет → fail-closed.
|
||||
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
|
||||
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
|
||||
- **Условность как ORCH-35/43/58:** `security_gate_enabled` + `security_gate_repos` (пусто →
|
||||
только self-hosting); never-raise; таймаут `security_scan_timeout_s`; гейт не деплоит/не
|
||||
рестартит прод. v1 — Python-only; SAST/мульти-стек — follow-up (BR-14).
|
||||
|
||||
Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально —
|
||||
`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`.
|
||||
|
||||
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
|
||||
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
|
||||
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
|
||||
@@ -249,46 +281,6 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
|
||||
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
|
||||
|
||||
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
|
||||
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
|
||||
конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно.
|
||||
ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B**
|
||||
(Plane-индикация: `src/plane_sync.py` + точки простановки в `src/stage_engine.py`/
|
||||
`src/webhooks/plane.py`/`src/reconciler.py`) и **не трогая слой A** (`STAGE_TRANSITIONS`,
|
||||
инвариант). Статус — индикация, не управление (вердикты по-прежнему из YAML-frontmatter):
|
||||
```
|
||||
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
|
||||
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
|
||||
Monitoring after Deploy → Done
|
||||
```
|
||||
`[...]` = человеческий вход-триггер; остальное ставит орк.
|
||||
- **6 новых логических ключей** (`to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/
|
||||
`deploying`/`monitoring`) в `_PLANE_NAME_TO_KEY` (резолв по имени) + `_DEFAULT_STATES`.
|
||||
`To Analyse` заменяет `In Progress` как вход-триггер (старт + resume аналитика из Needs
|
||||
Input; fork «старт vs resume» по `get_task_by_plane_id`+`has_active_job_for_task` —
|
||||
сохранён). Стадии: analysis→`Analysis`, review→`Code-Review` (`_STAGE_TO_STATE_KEY`).
|
||||
- **Self-deploy фазы:** Phase A → `Awaiting Deploy` (разгружает `In Review`), Phase B →
|
||||
`Deploying`, Phase C/terminal-sync (self) → `Monitoring after Deploy` (НЕ `Done` сразу);
|
||||
post-deploy monitor (ORCH-021): HEALTHY-окно → `Done`, DEGRADED → `Blocked` (тик
|
||||
по-прежнему НИКОГДА не рестартит прод — ALERT_ONLY). Не-self репо: `deploy → Done` как
|
||||
сейчас (terminal-sync разводится по `post_deploy.post_deploy_applies`).
|
||||
- **Fail-closed (project-relative alias-fallback):** отсутствующий новый статус в проекте
|
||||
деградирует на **собственный базовый UUID того же проекта** (`to_analyse/analysis→in_progress`,
|
||||
`code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`,
|
||||
`monitoring→done`) — индикация откатывается к текущей, конвейер не ломается, PATCH валиден
|
||||
даже при частичной конфигурации. Enduro (статусы не создаются) → строго прежнее поведение.
|
||||
Усиленный паттерн ORCH-059 AC-7.
|
||||
- **Reconciler:** F-2 триггер `in_progress`→`to_analyse`; Guard 2 skip-set расширен
|
||||
активными ожиданиями (`awaiting_deploy`/`deploying`/`monitoring`) с **вычитанием базовых
|
||||
рабочих статусов** — на enduro (алиасы схлопнуты) нулевой регресс, на orchestrator skip
|
||||
реальных ожиданий (BR-13).
|
||||
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, exit-коды хука,
|
||||
merge-gate, `Confirm Deploy`, механизм `Needs Input` (analyst-only), схема БД — без
|
||||
изменений. Без нового kill-switch (раскат гейтится созданием Plane-статусов оператором).
|
||||
Инфра-предусловие — `docs/work-items/ORCH-066/07-infra-requirements.md`.
|
||||
|
||||
Подробнее: `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`.
|
||||
|
||||
## Откаты
|
||||
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
||||
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
||||
@@ -346,4 +338,4 @@ Monitoring after Deploy → Done
|
||||
Схема БД, потоки данных, 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-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.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-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).*
|
||||
|
||||
63
docs/architecture/adr/adr-0012-security-gate.md
Normal file
63
docs/architecture/adr/adr-0012-security-gate.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# adr-0012: Security-гейт — secret-scanning + dependency audit перед мержем
|
||||
|
||||
- **Статус:** proposed
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-022
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`
|
||||
|
||||
## Контекст
|
||||
Оркестратор автономен: `developer` пишет код без человека-фильтра. Перед слиянием ветки в
|
||||
`main` нет проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую
|
||||
зависимость (CVE). Для self-hosting один общий прод-инстанс обслуживает все проекты с общей
|
||||
БД — секрет/CVE через одну задачу попадает в прод всех (CLAUDE.md §self-hosting, §8). Фактический
|
||||
мерж PR в `main` делает `deployer` в начале стадии `deploy`.
|
||||
|
||||
## Решение
|
||||
Детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**,
|
||||
рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди
|
||||
edge-под-гейтов (ДО merge-gate). `STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавлен
|
||||
`check_security_gate`. Паттерн — как у соседей: leaf-модуль `src/security_gate.py`
|
||||
(never-raise) + тонкая обёртка в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
|
||||
|
||||
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
|
||||
аллоулиста (`.gitleaks.toml`) → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
|
||||
не зависит от сети.
|
||||
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
|
||||
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
|
||||
громкий warning** (анти-петля; флаг `security_dep_audit_fail_closed` для строгого режима).
|
||||
- **ПЕРВЫМ на ребре, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки
|
||||
ДО rebase не «обвиняет» задачу в CVE, притащенной обновившимся `main` (анти-петля
|
||||
ORCH-061); до захвата merge-lease → при FAIL lease освобождать не нужно.
|
||||
- **Артефакт `17-security-report.md`** с YAML-frontmatter (`security_status`,
|
||||
`secrets_found`, `deps_blocking`, `deps_warning`, `deps_audit_degraded`); вердикт читается
|
||||
ТОЛЬКО из frontmatter (канон), negative-токен авторитетен; битый/нет → fail-closed.
|
||||
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
|
||||
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
|
||||
- **Условность (как ORCH-35/43/58):** `security_gate_enabled` + `security_gate_repos`; пусто
|
||||
→ реально только self-hosting (`orchestrator`), прочие репо — no-op pass.
|
||||
- **never-raise**, таймаут `security_scan_timeout_s`, гейт не деплоит/не рестартит прод.
|
||||
|
||||
## Альтернативы
|
||||
- **Вариант R (review-стадия):** diff может разойтись с мержем в `main`; merge-edge — последняя
|
||||
страховка. Отклонено.
|
||||
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
|
||||
выражаются статусом коммита; коуплинг с раннером. Отклонено для v1 (точка расширения).
|
||||
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как в ORCH-043).
|
||||
Отклонено.
|
||||
- **fail-closed dep-audit / аудит после rebase:** ложные откаты → петля. Отклонено.
|
||||
- **Новая колонка retry в БД:** не нужна (переиспользуем `_developer_retry_count`).
|
||||
|
||||
## Последствия
|
||||
- Класс «тихо влитый секрет/CVE» закрыт: секреты — безусловно (offline), CVE — best-effort при
|
||||
доступности фида. Самоприменение CLAUDE.md §8 без человека.
|
||||
- Плата: ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); внешние инструменты
|
||||
(gitleaks в образе, pip-audit в зависимостях); время скана на каждом прогоне (ограничено
|
||||
таймаутом); v1 — Python-only (SAST/мульти-стек — follow-up WI).
|
||||
- Сквозное изменение (новый QG + edge-под-гейт) → `arch:major-change`; прод-деплой ORCH-022 —
|
||||
строго через staging-гейт (8501), без рестарта прод-контейнера.
|
||||
|
||||
## Связи
|
||||
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
|
||||
условность/never-raise/fail-closed), adr-0003 (условный гейт / `is_self_hosting_repo`),
|
||||
adr-0009 (анти-петля ложных FAIL, ORCH-061), ORCH-046 (дословный reason в `task_desc`),
|
||||
ORCH-9/15 (мульти-стек — будущая зависимость), ORCH-2 (worktree-изоляция).
|
||||
7
docs/work-items/ORCH-022/00-business-request.md
Normal file
7
docs/work-items/ORCH-022/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: [★ высокий] Security-гейт: secret-scanning + аудит зависимостей перед мержем
|
||||
|
||||
Work Item ID: ORCH-022
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
150
docs/work-items/ORCH-022/01-brd.md
Normal file
150
docs/work-items/ORCH-022/01-brd.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 01 — BRD: Security-гейт (secret-scanning + аудит зависимостей перед мержем)
|
||||
|
||||
Work Item: **ORCH-022**
|
||||
Приоритет: **★ высокий**
|
||||
Источник: предложение Стрим, одобрено Славой (2026-06-04).
|
||||
Стадия: analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-проблема
|
||||
|
||||
Оркестратор — автономная мульти-агентная система: агенты (`developer`) пишут код
|
||||
**без человека-фильтра по умолчанию**. Перед мержем в `main` сейчас нет проверки на:
|
||||
|
||||
- **утёкший секрет** — закоммиченный API-ключ / токен / пароль / приватный ключ;
|
||||
- **дырявую зависимость** — пакет с известной CVE;
|
||||
- (опционально) **базовую уязвимость кода** — типовой SAST-паттерн.
|
||||
|
||||
Для автономной системы это критично: ошибку, которую в обычной команде «выловили бы
|
||||
глазами на ревью», здесь поймать некому. Утёкший в `git`-историю ключ или уязвимая
|
||||
зависимость может уехать в прод и обслуживать **все** проекты (общий инстанс,
|
||||
self-hosting).
|
||||
|
||||
### Прецеденты / связки
|
||||
- **PR #18** (`check_ci_green`: красный CI → возврат на `development`) — задаёт целевой
|
||||
паттерн поведения красного гейта. Security-гейт должен вести себя так же.
|
||||
- **Управление секретами** (CLAUDE.md §8): секреты живут только в `.env`/`.env.staging`
|
||||
на хосте, канон — `.env.example`. Гейт — это автоматический страж этого правила.
|
||||
|
||||
---
|
||||
|
||||
## 2. Цель
|
||||
|
||||
Ввести **security-гейт перед слиянием ветки задачи в `main`**, который детерминированно
|
||||
(без LLM) проверяет diff/ветку на секреты и уязвимые зависимости и **блокирует
|
||||
продвижение** при нарушении порогов: красный security-гейт → **возврат на `development`**
|
||||
(developer-retry, как красный CI / merge-gate), задача **не уезжает в прод**.
|
||||
|
||||
### Бизнес-ценность
|
||||
- Структурно невозможно «тихо» влить секрет или известную CVE в прод автономной системы.
|
||||
- Самоприменение правила CLAUDE.md §8 (секреты не в гит) без участия человека.
|
||||
- Расширяет уже выстроенную линию автономных страховок (CI-гейт, merge-gate ORCH-043,
|
||||
staging-провенанс ORCH-058, post-deploy ORCH-021).
|
||||
|
||||
---
|
||||
|
||||
## 3. Объём (Scope)
|
||||
|
||||
### 3.1 В объёме (v1) — **предположение по умолчанию (A1)**
|
||||
1. **Secret-scanning** — обязательный минимум гейта. Поиск закоммиченных секретов
|
||||
в ветке задачи / её diff относительно `main`.
|
||||
2. **Dependency audit** — аудит зависимостей проекта на известные CVE.
|
||||
3. **Машиночитаемый артефакт-вердикт** security-гейта (YAML-frontmatter — канон гейтов).
|
||||
4. **Поведение красного гейта** = откат на `development` + developer-retry (cap
|
||||
`MAX_DEVELOPER_RETRIES = 3`), наблюдаемость (Telegram + Plane-коммент).
|
||||
5. **Условный раскат** (kill-switch + scope репозиториев), **never-raise**,
|
||||
self-hosting (`orchestrator`) — первым.
|
||||
|
||||
### 3.2 Вне объёма (v1) — **предположение (A2), отдельные WI**
|
||||
- **SAST (semgrep)** — вынесен в follow-up WI: шумнее, требует policy-тюнинга правил;
|
||||
гейт проектируется с точкой расширения под него, но в v1 не включается.
|
||||
- **Полноценный мульти-стек** (JS/npm, Android) — см. A3 ниже; в v1 целевой стек —
|
||||
Python (сам оркестратор). Связь с ORCH-9/15 фиксируется как зависимость на будущее.
|
||||
- Ретроспективное сканирование уже существующей истории `main` (гейт смотрит вперёд —
|
||||
ветку перед мержем, не чистит прошлое).
|
||||
- Управление аллоулистом ложных срабатываний через UI/Plane (в v1 — файл в репозитории).
|
||||
|
||||
### 3.3 Зафиксированные предположения по умолчанию
|
||||
> ⚠️ Интерактивный опрос Owner на стадии анализа не дал ответа; ниже —
|
||||
> **дефолты по конвенциям проекта**. Любой из них Owner/архитектор может переопределить
|
||||
> (для A4 предусмотрены конфиг-флаги порогов).
|
||||
|
||||
- **A1 (объём сканеров v1):** secret-scanning + dependency-audit. SAST отложен.
|
||||
- **A2 (SAST):** отложен в отдельный WI; гейт оставляет точку расширения.
|
||||
- **A3 (стек):** **Python-only сначала**, реально только для self-hosting
|
||||
(`is_self_hosting_repo` / scope-CSV), как ORCH-35/43/58. Прочие репо — no-op pass.
|
||||
Мульти-стек (детект стека по репо) — отдельный WI.
|
||||
- **A4 (пороги):** **секреты — всегда блок**; **зависимости — блок на HIGH/CRITICAL,
|
||||
warning на MEDIUM/LOW**. Пороги вынесены в конфиг (переопределяемы без редеплоя кода).
|
||||
|
||||
---
|
||||
|
||||
## 4. Заинтересованные стороны
|
||||
| Роль | Интерес |
|
||||
|------|---------|
|
||||
| Owner (Слава) | Прод-безопасность автономного конвейера; контроль порогов и раската. |
|
||||
| Стрим | Инициатор; снижение риска утечки/уязвимости в автономном режиме. |
|
||||
| Агент `developer` | Получает понятную причину красного гейта → быстрый фикс. |
|
||||
| Агент `reviewer` | Гейт снимает с него непосильную задачу «глазами ловить ключи». |
|
||||
| Все проекты на инстансе | Общий прод не должен получить секрет/CVE через одну задачу. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Бизнес-требования
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|----|-----------|-----------|
|
||||
| BR-1 | Перед слиянием ветки задачи в `main` обязателен security-гейт (секреты + аудит зависимостей). | MUST |
|
||||
| BR-2 | Найден секрет (порог A4) → гейт **красный** → откат на `development`, в прод не уходит. | MUST |
|
||||
| BR-3 | Уязвимость зависимости уровня блокировки (порог A4) → гейт **красный** → откат на `development`. | MUST |
|
||||
| BR-4 | Уязвимость ниже порога блокировки → **warning**, продвижение не блокируется, но фиксируется в артефакте. | MUST |
|
||||
| BR-5 | Красный гейт ведёт себя как красный CI / merge-gate: откат на `development` + developer-retry (cap 3), затем эскалация (Telegram + Plane Blocked). | MUST |
|
||||
| BR-6 | Вердикт гейта — **машиночитаемый** (YAML-frontmatter артефакта), читается гейтом ТОЛЬКО из frontmatter (канон проекта), не из прозы. | MUST |
|
||||
| BR-7 | Гейт **детерминированный, без LLM** в критическом пути (как merge-gate / image-freshness). | MUST |
|
||||
| BR-8 | Гейт **never-raise**: внутренняя ошибка не роняет `advance_stage` и не вешает конвейер всех проектов. | MUST |
|
||||
| BR-9 | Условный раскат: глобальный kill-switch + scope-CSV репозиториев; пусто → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. | MUST |
|
||||
| BR-10 | Пороги блокировки конфигурируемы (env-флаги, без редеплоя кода). | SHOULD |
|
||||
| BR-11 | Наблюдаемость: причина блокировки видна (Telegram + Plane-коммент + артефакт); проход — без шума. | MUST |
|
||||
| BR-12 | Документация (CLAUDE.md «Артефакты задачи», `docs/architecture/README.md` таблица гейтов, CHANGELOG, ADR) обновлена в том же PR. | MUST |
|
||||
| BR-13 | Аллоулист ложных срабатываний (заведомо-безопасные совпадения, напр. в `.env.example`, фикстуры тестов) поддерживается версионируемым файлом в репозитории. | SHOULD |
|
||||
| BR-14 | Точка расширения под SAST и мульти-стек заложена, но в v1 не активна (A2/A3). | SHOULD |
|
||||
|
||||
---
|
||||
|
||||
## 6. Ограничения и риски (бизнес-уровень)
|
||||
- **Self-hosting:** гейт исполняется внутри инстанса, который правит сам себя. Запрет на
|
||||
рестарт/падение прод-контейнера в рамках задачи (CLAUDE.md §self-hosting) сохраняется —
|
||||
гейт ничего не деплоит и не рестартит, только читает/сканирует.
|
||||
- **Ложные срабатывания** (false positives) могут зациклить откат `→ development`
|
||||
(прецедент ORCH-061 со staging-петлёй). Митигировано: cap retry=3 + аллоулист (BR-13)
|
||||
+ конфигурируемые пороги (BR-10) + kill-switch (BR-9).
|
||||
- **Внешние БД уязвимостей** (CVE-фиды) — сетевая зависимость; недоступность фида не
|
||||
должна давать ложный красный (см. AC: degrade-поведение при недоступности фида —
|
||||
решение порога «fail-open vs fail-closed для аудита» закрепляется в acceptance + ADR).
|
||||
- **Стоимость/время** сканирования добавляется к каждому прогону задачи — должно быть
|
||||
ограничено таймаутом (как merge-retest).
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерий успеха (бизнес)
|
||||
Ветка с подсаженным тестовым секретом и/или зависимостью с известной CRITICAL-CVE
|
||||
**не может** дойти до `main`/прода: гейт краснеет, задача откатывается на `development`
|
||||
с понятной причиной. Чистая ветка проходит гейт без задержек и без шума. Для не-self
|
||||
репозиториев конвейер не меняется (no-op). Прод-контейнер не рестартится гейтом.
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы (для архитектора / Owner)
|
||||
1. **Размещение гейта** (решение архитектора): (а) на стадии `review`, либо (б) отдельный
|
||||
под-гейт перед мержем на ребре `deploy-staging → deploy` (где уже живёт merge-gate
|
||||
ORCH-043 / image-freshness ORCH-058). Требование BRD — «перед слиянием в `main`»;
|
||||
обе опции его удовлетворяют. См. 02-trz §4.
|
||||
2. **Где запускается сканер**: новый job в `.gitea/workflows/ci.yml` (тогда вердикт может
|
||||
течь через существующий `check_ci_green`) **или** отдельный QG-чек/под-гейт в `src/qg`.
|
||||
Решение — архитектор (02-trz фиксирует требования к обоим путям).
|
||||
3. **Аудит зависимостей при недоступном CVE-фиде:** fail-open (warning) или fail-closed
|
||||
(блок)? Дефолт-предложение — **fail-open с громким warning** (не плодить ложные
|
||||
завороты), закрепить в ADR.
|
||||
4. **Выбор конкретных инструментов** (gitleaks vs trufflehog; pip-audit vs trivy) —
|
||||
технологическое решение архитектора; BRD фиксирует только функцию.
|
||||
175
docs/work-items/ORCH-022/02-trz.md
Normal file
175
docs/work-items/ORCH-022/02-trz.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# 02 — ТЗ: Security-гейт (secret-scanning + dependency audit)
|
||||
|
||||
Work Item: **ORCH-022** · Стадия: analysis · См. `01-brd.md`, `03-acceptance-criteria.md`.
|
||||
|
||||
> **Граница ответственности аналитика.** Ниже — *функциональные требования и точки
|
||||
> касания* кода. Выбор размещения гейта в пайплайне, конкретных инструментов и схемы
|
||||
> модулей — **решение архитектора** (см. §4 и `01-brd.md` §8). ТЗ фиксирует требования к
|
||||
> любому из допустимых вариантов и инварианты, которые нельзя нарушать.
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст кода (как есть)
|
||||
|
||||
- **Стадии:** `src/stages.py::STAGE_TRANSITIONS` — линейный конвейер
|
||||
`… review → testing → deploy-staging → deploy → done`. Фактический merge ветки в
|
||||
`main` делает агент `deployer` **в начале стадии `deploy`** (CLAUDE/README).
|
||||
- **Quality Gates:** `src/qg/checks.py` — реестр `QG_CHECKS` (имя → функция), сигнатуры
|
||||
диспетчеризуются в `src/stage_engine.py::_run_qg`.
|
||||
- **Существующий паттерн «красный гейт → возврат developer»:**
|
||||
`check_ci_green` (PR #18) и rollback-ветки в
|
||||
`stage_engine._handle_qg_failure_rollbacks` (откат на `development`, developer-retry,
|
||||
cap `MAX_DEVELOPER_RETRIES = 3`, затем `set_issue_blocked` + Telegram).
|
||||
- **Эталонный паттерн детерминированного под-гейта на ребре** (без LLM, never-raise,
|
||||
условный раскат, откат на `development`):
|
||||
- merge-gate **ORCH-043** — `src/merge_gate.py` + `check_branch_mergeable` +
|
||||
`stage_engine._handle_merge_gate` (ребро `deploy-staging → deploy`);
|
||||
- image-freshness **ORCH-058** — `src/image_freshness.py` + `_check_staging_image_fresh`
|
||||
+ `stage_engine._handle_image_freshness` (то же ребро).
|
||||
Оба: leaf-модуль с чистой логикой (never-raise) + тонкая обёртка в `QG_CHECKS` +
|
||||
врезка-обработчик в `advance_stage`, kill-switch `*_enabled` + scope `*_repos`,
|
||||
реально только для self-hosting при пустом scope.
|
||||
- **CI:** `.gitea/workflows/ci.yml` — один job `test` (pytest) на `self-hosted` раннере,
|
||||
push в `feature/**` и PR в `main`. `check_ci_green` читает комбинированный статус
|
||||
коммита из Gitea API.
|
||||
- **Артефакты задачи** нумерованы до `16-post-deploy-log.md`.
|
||||
- **Зависимости Python:** `requirements.txt` (корень репо).
|
||||
|
||||
---
|
||||
|
||||
## 2. Функциональные требования к реализации
|
||||
|
||||
### FR-1. Secret-scanning ветки перед мержем
|
||||
- Сканировать ветку задачи / её diff относительно `origin/main` на секреты
|
||||
(ключи, токены, пароли, приватные ключи).
|
||||
- **Любой** подтверждённый секрет (не из аллоулиста) → вердикт **FAIL** (порог A4: секреты
|
||||
всегда блокируют).
|
||||
- Инструмент (gitleaks / trufflehog) — выбор архитектора. Должен запускаться offline-/
|
||||
детерминированно (без LLM) и иметь конфиг правил/аллоулиста в репозитории.
|
||||
|
||||
### FR-2. Dependency audit
|
||||
- Аудит зависимостей целевого стека на известные CVE. Для Python — манифест
|
||||
`requirements.txt` (инструмент pip-audit / trivy — выбор архитектора).
|
||||
- Классификация по severity. **Порог блокировки (A4, конфигурируемо BR-10):**
|
||||
- `CRITICAL`, `HIGH` → вклад в **FAIL**;
|
||||
- `MEDIUM`, `LOW` → **warning** (фиксируется в артефакте, не блокирует).
|
||||
- Недоступность CVE-фида: degrade-поведение по решению ADR (дефолт-предложение —
|
||||
fail-open + громкий warning, чтобы не плодить ложные завороты). Поведение должно быть
|
||||
детерминированным и протестированным.
|
||||
|
||||
### FR-3. Машиночитаемый артефакт-вердикт
|
||||
- Гейт порождает артефакт security-отчёта с **YAML-frontmatter**, напр.:
|
||||
```
|
||||
---
|
||||
security_status: PASS # PASS | FAIL
|
||||
secrets_found: 0
|
||||
deps_blocking: 0 # число уязвимостей уровня блокировки
|
||||
deps_warning: 2
|
||||
---
|
||||
```
|
||||
Имя артефакта — предложение: **`17-security-report.md`** (следующий свободный номер;
|
||||
финализирует архитектор). Тело — человекочитаемый список находок.
|
||||
- Вердикт читается гейтом **ТОЛЬКО из frontmatter** (канон проекта: «машинные вердикты —
|
||||
строго YAML-frontmatter, никогда проза»), по образцу `_parse_deploy_status` /
|
||||
`_parse_staging_status` / `check_reviewer_verdict`. Negative-токен (FAIL) авторитетен.
|
||||
- Отсутствие/битый frontmatter → `(False, reason)` (fail-closed на чтении вердикта,
|
||||
как у существующих парсеров).
|
||||
|
||||
### FR-4. Поведение красного гейта (откат)
|
||||
- `security_status: FAIL` → откат на `development` + enqueue `developer`, по образцу
|
||||
`_handle_qg_failure_rollbacks` (merge-gate-ветка — точный шаблон):
|
||||
- cap `MAX_DEVELOPER_RETRIES` (3); при исчерпании — `set_issue_blocked` + Telegram-алерт;
|
||||
- `task_desc` для developer несёт **дословную причину** (какие секреты/CVE), по образцу
|
||||
ORCH-046 (встраивание must-fix в `task_desc`), а не только ссылку на артефакт;
|
||||
- Plane-коммент + `notify_qg_failure` (наблюдаемость BR-11).
|
||||
|
||||
### FR-5. Условный раскат (как ORCH-35/43/58)
|
||||
- Глобальный kill-switch `security_gate_enabled` (env `ORCH_SECURITY_GATE_ENABLED`,
|
||||
дефолт по согласованию; рекомендуется `true` с safety-net, как у соседних фич).
|
||||
- Scope `security_gate_repos` (CSV); пусто → реально только `is_self_hosting_repo(repo)`
|
||||
(`orchestrator`). Прочие репо → `(True, "security-gate N/A for <repo>")` (мгновенный pass).
|
||||
- Отдельные пороги-флаги (A4/BR-10): напр. `security_dep_block_severity`
|
||||
(`HIGH` по умолчанию), при желании `security_secrets_block` (`true`).
|
||||
|
||||
### FR-6. never-raise
|
||||
- Любая внутренняя ошибка гейта (сбой сканера, отсутствие бинаря, таймаут) →
|
||||
`(False, "<reason>")` **без** проброса исключения в `advance_stage`. Контракт —
|
||||
как у `check_branch_mergeable` (внешний + внутренний guard).
|
||||
- Таймаут сканирования ограничен (по образцу `merge_retest_timeout_s`).
|
||||
|
||||
### FR-7. Наблюдаемость
|
||||
- Блокировка → Telegram + Plane-коммент (BR-11). Проход → лог-строка, без шумных
|
||||
нотификаций (по образцу merge-gate pass).
|
||||
- Желательно: краткий снимок в `GET /queue` (опционально, по образцу блоков `reconcile`/
|
||||
`reaper`/`post_deploy`) — на усмотрение архитектора.
|
||||
|
||||
---
|
||||
|
||||
## 3. Задействованные модули `src/` (точки касания)
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/security_gate.py` (**новый leaf-модуль**) | Чистая логика гейта: запуск сканеров, классификация по severity, применение порогов/аллоулиста, формирование вердикта + парсер frontmatter. **never-raise.** По образцу `src/merge_gate.py` / `src/image_freshness.py` / `src/post_deploy.py`. |
|
||||
| `src/qg/checks.py` | Новый чек `check_security_gate` (тонкая обёртка над `security_gate`, ленивый импорт во избежание циклов) + регистрация в `QG_CHECKS`. Условность (kill-switch/scope/self-hosting) — как `check_branch_mergeable` / `_check_staging_image_fresh`. |
|
||||
| `src/stage_engine.py` | Врезка-обработчик `_handle_security_gate(...)` по образцу `_handle_merge_gate` / `_handle_image_freshness`: вызов в `advance_stage` на выбранном архитектором ребре; FAIL → откат на `development` (FR-4); never-raise. **`STAGE_TRANSITIONS` НЕ меняется**, если выбран вариант «под-гейт ребра». |
|
||||
| `src/config.py` | Новые настройки: `security_gate_enabled`, `security_gate_repos`, `security_dep_block_severity`, `security_scan_timeout_s` (+ при необходимости пути к бинарям/конфигам сканеров). С docstring-комментариями по образцу ORCH-043/058. |
|
||||
| `.gitea/workflows/ci.yml` | **Если** архитектор выберет CI-путь: новый job `security` (secret-scan + dep-audit), влияющий на комбинированный статус коммита (тогда срабатывает `check_ci_green`-паттерн PR #18). Иначе — не трогается. |
|
||||
| `requirements.txt` / Dockerfile | Установка выбранных сканеров (если они Python-пакеты — в `requirements.txt`; если бинари — в Dockerfile/раннер). |
|
||||
| Конфиг сканера + аллоулист | Версионируемые файлы в репозитории (напр. `.gitleaks.toml` / аллоулист) — BR-13. |
|
||||
| `.openclaw/agents/developer.md` | (Если нужно) краткая инструкция developer'у про устранение security-находок при заворотах. |
|
||||
|
||||
> Если выбран вариант «гейт на стадии `review`» — врезка делается в соответствующую
|
||||
> ветку `advance_stage`/обработчик ревью вместо ребра `deploy-staging → deploy`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Размещение в пайплайне — варианты для архитектора
|
||||
|
||||
Требование BRD: **«перед слиянием ветки в `main`»**. Допустимы (выбор + обоснование — в ADR):
|
||||
|
||||
- **Вариант R (review):** security-проверка на стадии `review` (раньше отлов, дешевле
|
||||
откат — задача ещё близко к development). Минус: дальше по конвейеру `main` может уйти
|
||||
вперёд (но это закрывает merge-gate).
|
||||
- **Вариант M (merge-edge, рекомендуемый к рассмотрению):** под-гейт на ребре
|
||||
`deploy-staging → deploy`, рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058) —
|
||||
непосредственно перед фактическим мержем `deployer`'ом. Плюс: единое место «последней
|
||||
страховки перед main», переиспользование готового паттерна врезки/отката/lease.
|
||||
- **Вариант C (CI-job):** добавить job в `ci.yml`; вердикт течёт через `check_ci_green`.
|
||||
Плюс: меньше нового кода в движке. Минус: пороги/severity-логика и артефакт-вердикт
|
||||
сложнее выразить только статусом коммита.
|
||||
|
||||
ТЗ не предписывает вариант; реализация обязана сохранить инварианты §6.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения API
|
||||
- Новых HTTP-endpoint'ов **не требуется**.
|
||||
- Допустимо (опционально, FR-7): расширить ответ `GET /queue` блоком `security`
|
||||
(counts/last_run) — по образцу блоков `reconcile`/`reaper`/`post_deploy`. Не обязательно.
|
||||
|
||||
## 6. Изменения схемы БД
|
||||
- **Не требуется.** Состояние гейта — артефакт-файл + (при необходимости) sentinel-файлы,
|
||||
по образцу merge-lease / deploy-state / post-deploy-state. Миграций БД нет.
|
||||
- Если архитектор сочтёт нужным считать security-retry отдельно от developer-retry —
|
||||
предпочесть подсчёт по `jobs`/`agent_runs` (как `_developer_retry_count` /
|
||||
`_merge_defer_count`), без новых колонок.
|
||||
|
||||
## 7. Инварианты (НЕ нарушать)
|
||||
1. `STAGE_TRANSITIONS` и реестр `QG_CHECKS` остаются консистентными; при варианте
|
||||
«под-гейт ребра» — `STAGE_TRANSITIONS` не меняется (триггер — то же событие стадии).
|
||||
2. Машинный вердикт — только из YAML-frontmatter, не из прозы.
|
||||
3. never-raise: гейт никогда не пробрасывает исключение в `advance_stage`.
|
||||
4. Условность как ORCH-35/43/58: не-self репо при пустом scope не затрагиваются (no-op).
|
||||
5. Гейт **не деплоит и не рестартит** прод-контейнер (self-hosting safety).
|
||||
6. Откат и retry-счётчик developer не ломаются (cap=3, затем эскалация).
|
||||
7. Документация (CLAUDE.md, README, CHANGELOG, ADR) обновлена в том же PR (BR-12).
|
||||
|
||||
## 8. Артефакты pipeline, создаваемые/обновляемые
|
||||
- **Новый:** `docs/work-items/ORCH-022/17-security-report.md` (имя финализирует архитектор)
|
||||
с `security_status:`-frontmatter (FR-3) — порождается гейтом per-task.
|
||||
- **ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-<slug>.md` (решение: размещение,
|
||||
инструменты, degrade-поведение фида, пороги). При сквозном влиянии — global ADR в
|
||||
`docs/architecture/adr/`.
|
||||
- **Обновить:** `CLAUDE.md` (раздел «Артефакты задачи» — добавить 17-…),
|
||||
`docs/architecture/README.md` (таблица гейтов + реестр `QG_CHECKS` + новый раздел),
|
||||
`CHANGELOG.md`, `.env.example` (новые `ORCH_SECURITY_*`).
|
||||
140
docs/work-items/ORCH-022/03-acceptance-criteria.md
Normal file
140
docs/work-items/ORCH-022/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 03 — Критерии приёмки: Security-гейт (ORCH-022)
|
||||
|
||||
Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Привязка к
|
||||
`01-brd.md` (BR-*) и `02-trz.md` (FR-*).
|
||||
|
||||
---
|
||||
|
||||
## A. Secret-scanning (FR-1, BR-1/BR-2)
|
||||
|
||||
### AC-1 — Подсаженный секрет блокирует гейт
|
||||
- **PASS:** ветка с тестовым секретом (напр. фиктивный AWS-ключ формата `AKIA…` вне
|
||||
аллоулиста) → `security_status: FAIL`; гейт возвращает `(False, reason)`, причина
|
||||
называет секрет/файл.
|
||||
- **FAIL:** секрет не обнаружен ИЛИ гейт зелёный при наличии секрета.
|
||||
|
||||
### AC-2 — Чистая ветка проходит
|
||||
- **PASS:** ветка без секретов → `security_status: PASS`; `secrets_found: 0`;
|
||||
гейт возвращает `(True, …)`.
|
||||
- **FAIL:** ложное срабатывание (FAIL на чистой ветке).
|
||||
|
||||
### AC-3 — Аллоулист подавляет заведомо-безопасное (BR-13)
|
||||
- **PASS:** совпадение, явно занесённое в версионируемый аллоулист (напр. плейсхолдер в
|
||||
`.env.example` / фикстура теста), **не** даёт FAIL.
|
||||
- **FAIL:** аллоулист игнорируется и даёт ложный FAIL.
|
||||
|
||||
---
|
||||
|
||||
## B. Dependency audit (FR-2, BR-3/BR-4)
|
||||
|
||||
### AC-4 — CVE уровня блокировки краснит гейт
|
||||
- **PASS:** зависимость с известной `CRITICAL`/`HIGH` CVE (при пороге по умолчанию
|
||||
`HIGH`) → вклад в `security_status: FAIL`; `deps_blocking >= 1`.
|
||||
- **FAIL:** блокирующая уязвимость не приводит к FAIL.
|
||||
|
||||
### AC-5 — Низкая severity = warning, не блок
|
||||
- **PASS:** только `MEDIUM`/`LOW` уязвимости → `security_status: PASS`, при этом
|
||||
`deps_warning >= 1` и находки перечислены в теле артефакта.
|
||||
- **FAIL:** `MEDIUM`/`LOW` блокирует продвижение.
|
||||
|
||||
### AC-6 — Порог блокировки конфигурируем (BR-10)
|
||||
- **PASS:** при `ORCH_SECURITY_DEP_BLOCK_SEVERITY=CRITICAL` та же `HIGH`-уязвимость
|
||||
становится warning (не блок); при `=HIGH` — блок. Поведение детерминированно
|
||||
определяется флагом.
|
||||
- **FAIL:** флаг не влияет на классификацию.
|
||||
|
||||
### AC-7 — Degrade при недоступном CVE-фиде
|
||||
- **PASS:** недоступность фида обрабатывается по решению ADR детерминированно и
|
||||
протестированно (дефолт: fail-open + громкий warning, гейт не краснеет ложно).
|
||||
- **FAIL:** недоступность фида даёт неконтролируемый красный/исключение.
|
||||
|
||||
---
|
||||
|
||||
## C. Вердикт и артефакт (FR-3, BR-6)
|
||||
|
||||
### AC-8 — Машинный вердикт только из frontmatter
|
||||
- **PASS:** вердикт читается ТОЛЬКО из YAML-frontmatter `17-security-report.md`; проза с
|
||||
«PASS»/«FAIL» в теле не влияет на решение. Negative-токен (FAIL) авторитетен.
|
||||
- **FAIL:** вердикт извлекается из тела/прозы.
|
||||
|
||||
### AC-9 — Битый/отсутствующий frontmatter → fail-closed на чтении
|
||||
- **PASS:** нет frontmatter / битый YAML / нет поля `security_status` → `(False, reason)`
|
||||
(как `_parse_deploy_status`/`check_reviewer_verdict`).
|
||||
- **FAIL:** битый артефакт трактуется как PASS.
|
||||
|
||||
### AC-10 — Артефакт создаётся с корректными полями
|
||||
- **PASS:** после прогона существует `17-security-report.md` с валидным frontmatter
|
||||
(`security_status`, `secrets_found`, `deps_blocking`, `deps_warning`) и телом-списком.
|
||||
- **FAIL:** артефакт не создан/без машинных полей.
|
||||
|
||||
---
|
||||
|
||||
## D. Откат и retry (FR-4, BR-5)
|
||||
|
||||
### AC-11 — Красный гейт → откат на development + developer-retry
|
||||
- **PASS:** `FAIL` → стадия задачи становится `development`, enqueue `developer`,
|
||||
Plane-коммент + `notify_qg_failure`; счётчик developer-retry растёт.
|
||||
- **FAIL:** при FAIL задача продвигается дальше / не откатывается.
|
||||
|
||||
### AC-12 — task_desc несёт дословную причину (ORCH-046-паттерн)
|
||||
- **PASS:** `task_desc` для перезапущенного developer содержит конкретику находок
|
||||
(какие секреты/CVE), а не только ссылку на артефакт.
|
||||
- **FAIL:** developer получает только ссылку без сути.
|
||||
|
||||
### AC-13 — Cap retry и эскалация
|
||||
- **PASS:** после `MAX_DEVELOPER_RETRIES` (3) безуспешных фиксов — `set_issue_blocked` +
|
||||
Telegram-алерт; бесконечного отскока нет.
|
||||
- **FAIL:** откат зацикливается без cap/эскалации.
|
||||
|
||||
---
|
||||
|
||||
## E. Условный раскат и устойчивость (FR-5/FR-6, BR-8/BR-9)
|
||||
|
||||
### AC-14 — Не-self репозиторий = no-op pass
|
||||
- **PASS:** для repo, не входящего в scope и не self-hosting → гейт возвращает
|
||||
`(True, "security-gate N/A for <repo>")` мгновенно, конвейер такого репо не меняется.
|
||||
- **FAIL:** гейт реально запускается/блокирует чужой репо при пустом scope.
|
||||
|
||||
### AC-15 — Kill-switch отключает гейт
|
||||
- **PASS:** `ORCH_SECURITY_GATE_ENABLED=false` → гейт — no-op pass (`(True, …)`),
|
||||
поведение конвейера 1:1 как до ORCH-022.
|
||||
- **FAIL:** при выключенном флаге гейт всё ещё блокирует.
|
||||
|
||||
### AC-16 — never-raise
|
||||
- **PASS:** искусственный сбой (нет бинаря сканера / таймаут / исключение внутри) →
|
||||
`(False, reason)` без проброса исключения; `advance_stage` не падает, конвейер других
|
||||
задач/проектов не встаёт.
|
||||
- **FAIL:** внутренняя ошибка пробрасывается/вешает движок.
|
||||
|
||||
### AC-17 — Таймаут ограничен
|
||||
- **PASS:** сканирование, превысившее `ORCH_SECURITY_SCAN_TIMEOUT_S`, корректно
|
||||
прерывается → детерминированный вердикт (по политике degrade), без зависания.
|
||||
- **FAIL:** сканер висит без таймаута.
|
||||
|
||||
---
|
||||
|
||||
## F. Инварианты и интеграция (BR-7/BR-12, TRZ §7)
|
||||
|
||||
### AC-18 — STAGE_TRANSITIONS/QG_CHECKS консистентны
|
||||
- **PASS:** при варианте «под-гейт ребра» `STAGE_TRANSITIONS` не изменён; новый чек
|
||||
зарегистрирован в `QG_CHECKS`; `_run_qg` корректно его диспетчеризует. Все
|
||||
существующие тесты гейтов/стадий зелёные.
|
||||
- **FAIL:** сломан реестр/переходы/существующие тесты.
|
||||
|
||||
### AC-19 — Гейт не деплоит/не рестартит прод
|
||||
- **PASS:** код гейта не вызывает деплой-хук/рестарт прод-контейнера; только
|
||||
чтение/сканирование.
|
||||
- **FAIL:** гейт инициирует рестарт/деплой.
|
||||
|
||||
### AC-20 — Документация обновлена в том же PR (BR-12)
|
||||
- **PASS:** обновлены `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md`
|
||||
(таблица гейтов + реестр QG + раздел ORCH-022), `CHANGELOG.md`, `.env.example`
|
||||
(`ORCH_SECURITY_*`); заведён ADR `06-adr/ADR-001-*`.
|
||||
- **FAIL:** функционал есть, документация/ADR не обновлены → reviewer обязан
|
||||
REQUEST_CHANGES (CLAUDE.md §6).
|
||||
|
||||
### AC-21 — End-to-end на тестовой задаче
|
||||
- **PASS:** прогон на self-hosting-репо: грязная ветка (секрет/CVE) → откат на
|
||||
`development`; после фикса чистая ветка → гейт зелёный → конвейер идёт дальше; прод не
|
||||
затронут в процессе.
|
||||
- **FAIL:** любой шаг E2E не воспроизводится.
|
||||
126
docs/work-items/ORCH-022/04-test-plan.yaml
Normal file
126
docs/work-items/ORCH-022/04-test-plan.yaml
Normal file
@@ -0,0 +1,126 @@
|
||||
work_item: ORCH-022
|
||||
title: "Security-гейт: secret-scanning + dependency audit перед мержем"
|
||||
notes: >
|
||||
План тестов для security-гейта. Чистая логика выносится в leaf-модуль
|
||||
src/security_gate.py (never-raise) — основной предмет unit-тестов (по образцу
|
||||
tests для merge_gate / image_freshness / post_deploy / staging_verdict).
|
||||
Интеграция врезки в advance_stage и условный раскат — integration-тесты.
|
||||
Имена модулей тестов финализирует разработчик/архитектор по факту реализации.
|
||||
|
||||
tests:
|
||||
# --- Secret-scanning (FR-1 / AC-1..AC-3) ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Подсаженный тестовый секрет в diff -> вердикт FAIL, secrets_found>=1, причина называет находку."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Чистая ветка без секретов -> вердикт PASS, secrets_found=0."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Совпадение из аллоулиста (плейсхолдер .env.example / фикстура) НЕ даёт FAIL."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Dependency audit + пороги (FR-2 / AC-4..AC-7) ---
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "CVE уровня HIGH/CRITICAL при пороге HIGH -> вклад в FAIL, deps_blocking>=1."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Только MEDIUM/LOW уязвимости -> PASS, deps_warning>=1, находки в теле артефакта."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Конфиг порога: severity=CRITICAL делает HIGH-CVE warning; severity=HIGH делает её блоком."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Недоступный CVE-фид -> детерминированный degrade по политике ADR (дефолт fail-open + warning), без исключения и без ложного FAIL."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Вердикт / парсер frontmatter (FR-3 / AC-8..AC-10) ---
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Вердикт читается ТОЛЬКО из YAML-frontmatter; проза PASS/FAIL в теле не влияет; negative-токен авторитетен."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Нет frontmatter / битый YAML / нет поля security_status -> (False, reason) (fail-closed на чтении)."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "Артефакт 17-security-report.md создаётся с валидным frontmatter (security_status, secrets_found, deps_blocking, deps_warning) и телом-списком."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- never-raise / таймаут / условность (FR-5/FR-6 / AC-14..AC-17) ---
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Отсутствие бинаря сканера / внутреннее исключение -> (False, reason), исключение не пробрасывается (never-raise)."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Превышение ORCH_SECURITY_SCAN_TIMEOUT_S -> корректное прерывание и детерминированный вердикт, без зависания."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "check_security_gate: не-self репо при пустом scope -> (True, 'security-gate N/A for <repo>') мгновенно."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "check_security_gate: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True)."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Новый чек зарегистрирован в QG_CHECKS и корректно диспетчеризуется _run_qg."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
|
||||
# --- Откат / retry в stage_engine (FR-4 / AC-11..AC-13) ---
|
||||
- id: TC-16
|
||||
type: integration
|
||||
description: "security_status FAIL -> advance_stage откатывает на development, enqueue developer, Plane-коммент + notify_qg_failure."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-17
|
||||
type: integration
|
||||
description: "task_desc перезапущенного developer содержит дословную причину находок (ORCH-046-паттерн), не только ссылку."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "После MAX_DEVELOPER_RETRIES (3) -> set_issue_blocked + Telegram-алерт; бесконечного отскока нет."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "security_status PASS -> advance_stage продвигает конвейер штатно (без отката, без шумных нотификаций)."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Инварианты / интеграция (BR-7/BR-12 / AC-18..AC-19) ---
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "При варианте 'под-гейт ребра' STAGE_TRANSITIONS не изменён; существующие тесты стадий/гейтов остаются зелёными."
|
||||
module: tests/test_stages.py
|
||||
expected: PASS
|
||||
- id: TC-21
|
||||
type: integration
|
||||
description: "Гейт не вызывает деплой-хук/рестарт прод-контейнера (self-hosting safety)."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
235
docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md
Normal file
235
docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# ADR-001: Security-гейт — secret-scanning + dependency audit перед мержем
|
||||
|
||||
- **Статус:** Accepted (proposed → принято архитектором ORCH-022)
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-022
|
||||
- **Связанный global ADR:** `docs/architecture/adr/adr-0012-security-gate.md`
|
||||
- **Источники:** `01-brd.md` (BR-1..BR-14), `02-trz.md` (FR-1..FR-7, §4 варианты, §7 инварианты),
|
||||
`03-acceptance-criteria.md` (AC-1..AC-21).
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Оркестратор автономен: `developer`-агент пишет код без человека-фильтра. Перед слиянием
|
||||
ветки задачи в `main` нет автоматической проверки на утёкший секрет (ключ/токен/пароль/
|
||||
приватный ключ) и на уязвимую зависимость (известная CVE). Для self-hosting это особенно
|
||||
опасно: один общий прод-инстанс обслуживает все проекты с общей БД — секрет или CVE,
|
||||
просочившийся через одну задачу, попадает в прод всех проектов (CLAUDE.md §self-hosting, §8).
|
||||
|
||||
Конвейер уже содержит линию детерминированных страховок на ребре `deploy-staging → deploy`
|
||||
(непосредственно перед фактическим мержем PR в `main`, который делает `deployer` в начале
|
||||
стадии `deploy`):
|
||||
|
||||
- **merge-gate** (ORCH-043, `check_branch_mergeable`) — догон `main` + re-test + сериализация;
|
||||
- **image-freshness** (ORCH-058, `check_staging_image_fresh`) — провенанс staging-образа.
|
||||
|
||||
Оба построены по одному паттерну: **leaf-модуль чистой логики (never-raise) + тонкая обёртка
|
||||
в `QG_CHECKS` + врезка-обработчик `_handle_*` в `advance_stage`**, с условным раскатом
|
||||
(`*_enabled` + `*_repos`, реально только для self-hosting при пустом scope) и откатом на
|
||||
`development` с developer-retry (cap `MAX_DEVELOPER_RETRIES = 3`).
|
||||
|
||||
Открытые вопросы BRD §8 / TRZ §4, требующие решения архитектора:
|
||||
1. Размещение гейта в пайплайне (review / merge-edge / CI-job).
|
||||
2. Где запускается сканер (CI-job через `check_ci_green` / отдельный QG-чек).
|
||||
3. Degrade при недоступном CVE-фиде (fail-open / fail-closed).
|
||||
4. Выбор инструментов (gitleaks/trufflehog; pip-audit/trivy).
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1. Размещение — Вариант M (под-гейт ребра `deploy-staging → deploy`), ПЕРВЫМ среди edge-под-гейтов
|
||||
|
||||
Security-гейт реализуется как **детерминированный под-гейт того же ребра**
|
||||
`deploy-staging → deploy`, что merge-gate и image-freshness, и исполняется **ПЕРВЫМ** —
|
||||
**ДО** merge-gate. `STAGE_TRANSITIONS` **не меняется** (триггер — то же событие «staging-
|
||||
deployer завершился»; инвариант TRZ §7.1).
|
||||
|
||||
Порядок врезок в `advance_stage` (блок `current_stage == "deploy-staging"`):
|
||||
|
||||
```
|
||||
check_staging_status (PASS, существующий QG стадии)
|
||||
→ security-gate (НОВЫЙ, _handle_security_gate) ← первым
|
||||
→ merge-gate (_handle_merge_gate)
|
||||
→ image-freshness (_handle_image_freshness)
|
||||
→ Phase A (self-deploy approve)
|
||||
```
|
||||
|
||||
**Почему merge-edge, а не review (Вариант R):**
|
||||
- BRD-требование «перед слиянием в `main`» удовлетворяют оба, но на review-стадии diff
|
||||
может разойтись с тем, что реально вольётся в `main` (параллельная задача двигает `main`
|
||||
вперёд между review и merge). Merge-edge — последняя точка перед фактическим мержем.
|
||||
- Переиспользуется готовая машинерия отката/retry/нотификаций edge-под-гейтов
|
||||
(минимальный blast-radius, инвариант TRZ §7).
|
||||
|
||||
**Почему ПЕРВЫМ (до merge-gate), а не после image-freshness:**
|
||||
- **Дёшево фейлить.** merge-gate (rebase + re-test, минуты) и image-freshness (docker
|
||||
rebuild, до 1200с) — дорогие. Нет смысла гонять их на ветке с секретом/CVE.
|
||||
- **Корректность для секретов.** Секрет живёт в собственных коммитах ветки;
|
||||
rebase онто `main` его не добавляет и не убирает → скан диапазона `origin/main..HEAD`
|
||||
до rebase ловит ровно те коммиты, что попадут в `main`.
|
||||
- **Анти-петля для зависимостей.** Аудит ветки **до** rebase оценивает то, что вносит
|
||||
ИМЕННО эта задача (её `requirements.txt`/diff), а не уязвимость, которую притащил в
|
||||
ветку обновившийся `main`. Аудит после rebase «обвинял» бы задачу в чужой (main'овой)
|
||||
CVE → ложный откат `→ development` → петля (прецедент ORCH-061). Скан до rebase этого
|
||||
избегает.
|
||||
- **Проще, чем image-freshness.** Гейт исполняется ДО захвата merge-lease → при FAIL
|
||||
**lease освобождать не нужно** (в отличие от `_handle_image_freshness`). Чистый откат.
|
||||
|
||||
**Почему не CI-job (Вариант C):** пороги severity, warning-vs-block, аллоулист и
|
||||
машиночитаемый артефакт-вердикт плохо выражаются одним статусом коммита Gitea; путь
|
||||
коуплится с CI-раннером. Отклонено для v1; оставлено как точка расширения (BR-14).
|
||||
|
||||
### Р-2. Инструменты
|
||||
|
||||
- **Secret-scanning — `gitleaks`.** Полностью **offline** (без сетевого фида → гарантия
|
||||
«секрет всегда блокирует» не зависит от сети, BR-2), один статический бинарь,
|
||||
детерминированный, конфиг + аллоулист в репо (`.gitleaks.toml`, BR-13), поддержка
|
||||
`--log-opts="origin/main..HEAD"` (скан диапазона), JSON-отчёт, exit-code контракт
|
||||
(0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента). Бинарь устанавливается в
|
||||
`Dockerfile` (Go-бинарь, не pip-пакет) — см. `07-infra-requirements.md`.
|
||||
- **Dependency audit — `pip-audit`.** Python-native (v1-стек — сам оркестратор, Python),
|
||||
читает `requirements.txt`, источник advisory — OSV/PyPI, JSON-выход, ставится через
|
||||
`requirements.txt`. trivy/trufflehog отклонены как тяжелее/контейнер-ориентированные для
|
||||
v1-цели «Python-only» (A3).
|
||||
|
||||
Конкретные инструменты — деталь реализации; контракт гейта (вход: repo/branch/wi,
|
||||
выход: `(bool, reason)` + артефакт) от них не зависит, заменяемы за leaf-модулем.
|
||||
|
||||
### Р-3. Degrade при недоступном CVE-фиде — **fail-open + громкий warning** (дефолт)
|
||||
|
||||
`pip-audit` требует сети (OSV/PyPI advisory DB). Недоступность фида **по умолчанию**:
|
||||
- **fail-open**: dep-audit не даёт FAIL по причине недоступности фида (иначе — ложные
|
||||
откаты `→ development` → петля при сетевых проблемах прод-инстанса, прецедент ORCH-061);
|
||||
- **громко**: в артефакте `deps_audit_degraded: true`, лог `logger.warning`, Telegram-алерт.
|
||||
- **Секреты не деградируют:** gitleaks offline → гарантия BR-2 безусловна даже при
|
||||
отсутствии сети. Деградирует ТОЛЬКО dep-audit.
|
||||
- **Конфигурируемо:** флаг `security_dep_audit_fail_closed` (дефолт `false`) позволяет
|
||||
Owner'у переключить на fail-closed (недоступность фида → FAIL) без редеплоя кода.
|
||||
|
||||
Это разделяет две гарантии: «нет секрета в прод» — **безусловная**; «нет известной CVE» —
|
||||
**best-effort при доступности фида**. Закреплено в acceptance (AC-7).
|
||||
|
||||
### Р-4. Пороги классификации (A4, BR-10)
|
||||
|
||||
- **Секреты:** любой подтверждённый (не из аллоулиста) секрет → **вклад в FAIL** (всегда
|
||||
блок; флаг `security_secrets_block`, дефолт `true`).
|
||||
- **Зависимости:** severity ≥ `security_dep_block_severity` (дефолт `HIGH`) → **вклад в
|
||||
FAIL** (`deps_blocking`); ниже порога (`MEDIUM`/`LOW`) → **warning** (`deps_warning`,
|
||||
не блокирует, фиксируется в теле).
|
||||
- **Severity = UNKNOWN** (OSV/advisory без CVSS — частый случай pip-audit): трактуется как
|
||||
**ниже порога → warning**, никогда не авто-блок (анти-петля). Логируется.
|
||||
|
||||
### Р-5. Артефакт и вердикт (FR-3, BR-6, канон проекта)
|
||||
|
||||
- Новый артефакт **`17-security-report.md`** (следующий свободный номер; финализировано).
|
||||
- YAML-frontmatter:
|
||||
```
|
||||
---
|
||||
security_status: PASS # PASS | FAIL
|
||||
secrets_found: 0
|
||||
deps_blocking: 0
|
||||
deps_warning: 2
|
||||
deps_audit_degraded: false
|
||||
---
|
||||
```
|
||||
Тело — человекочитаемый список находок (секреты: файл/правило/маскированное совпадение;
|
||||
CVE: пакет/версия/идентификатор/severity).
|
||||
- **Единый источник истины:** гейт вычисляет находки → пишет артефакт → **читает вердикт
|
||||
обратно через `parse_security_status(content)`** (frontmatter-парсер по образцу
|
||||
`_parse_deploy_status`/`_parse_staging_status`) → возвращает этот вердикт. Так возвращаемый
|
||||
`(bool, reason)` гарантированно == frontmatter артефакта (канон «машинный вердикт — только
|
||||
из YAML-frontmatter, никогда из прозы», AC-8). Negative-токен (`FAIL`) авторитетен.
|
||||
- Битый/отсутствующий frontmatter / нет поля `security_status` → `(False, reason)` —
|
||||
fail-closed на чтении вердикта (AC-9).
|
||||
|
||||
### Р-6. Поведение красного гейта (FR-4, BR-5)
|
||||
|
||||
`security_status: FAIL` → врезка `_handle_security_gate` (по образцу
|
||||
`_handle_image_freshness`, но БЕЗ работы с lease — гейт до его захвата):
|
||||
- `update_task_stage(development)` + `enqueue_job("developer", …)`;
|
||||
- retry-счётчик — **существующий** `_developer_retry_count` (общий с merge/freshness;
|
||||
без новой колонки, TRZ §6); cap `MAX_DEVELOPER_RETRIES = 3` → при исчерпании
|
||||
`set_issue_blocked` + Telegram;
|
||||
- `task_desc` несёт **дословную причину** (какие секреты/файлы, какие пакеты/CVE/severity)
|
||||
по образцу ORCH-046 — не только ссылку на артефакт (AC-12);
|
||||
- `notify_qg_failure` + Plane-коммент (наблюдаемость BR-11).
|
||||
|
||||
PASS → `return False` из обработчика → `advance_stage` идёт к merge-gate (тишина, без шума).
|
||||
|
||||
### Р-7. Условный раскат и устойчивость (FR-5/FR-6)
|
||||
|
||||
- `check_security_gate(repo, work_item_id, branch)` в `QG_CHECKS`; обёртка делегирует в
|
||||
`src/security_gate.py` (ленивый импорт во избежание цикла — по образцу
|
||||
`_check_staging_image_fresh`).
|
||||
- Условность: `security_gate_enabled=False` → `(True, "security-gate disabled")`;
|
||||
`security_gate_repos` (CSV) пусто → реально только `is_self_hosting_repo` → прочие репо
|
||||
`(True, "security-gate N/A for <repo>")` (AC-14/AC-15).
|
||||
- **never-raise** (двойной guard как `check_branch_mergeable`): любая ошибка (нет бинаря,
|
||||
таймаут, исключение) → `(False, reason)`, исключение не уходит в `advance_stage` (AC-16).
|
||||
- Таймаут сканирования `security_scan_timeout_s` (дефолт 300) на каждый внешний вызов
|
||||
(`subprocess … timeout=`) — превышение → детерминированный degrade-вердикт (AC-17).
|
||||
|
||||
### Р-8. Self-hosting safety (инвариант TRZ §7.5, AC-19)
|
||||
|
||||
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
|
||||
деплой-хук, не рестартит и не трогает прод-контейнер (8500/8501).
|
||||
|
||||
---
|
||||
|
||||
## Точки касания (для developer; reviewer проверяет полноту — AC-20)
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/security_gate.py` (**новый leaf**) | `security_gate_applies`, `scan_secrets`, `audit_dependencies`, `classify_severity`, `compute_verdict`, `write_security_report`, `parse_security_status`, `check_security_gate`. never-raise, fail-closed на чтении вердикта. По образцу `image_freshness.py`. |
|
||||
| `src/qg/checks.py` | `check_security_gate` (тонкая обёртка, ленивый импорт) + регистрация в `QG_CHECKS`. |
|
||||
| `src/stage_engine.py` | `_handle_security_gate(...)` + врезка ПЕРВОЙ в блоке `current_stage == "deploy-staging"` (до `_handle_merge_gate`). FAIL → откат на `development`. never-raise. **`STAGE_TRANSITIONS` НЕ меняется.** |
|
||||
| `src/config.py` | `security_gate_enabled` (True), `security_gate_repos` (""), `security_dep_block_severity` ("HIGH"), `security_scan_timeout_s` (300), `security_dep_audit_fail_closed` (False), `security_secrets_block` (True) — с docstring по образцу ORCH-043/058. |
|
||||
| `Dockerfile` | Установка `gitleaks` (release-бинарь). |
|
||||
| `requirements.txt` | `pip-audit`. |
|
||||
| `.gitleaks.toml` (**новый, корень репо**) | Конфиг правил + аллоулист (`.env.example`-плейсхолдеры, тест-фикстуры) — BR-13. |
|
||||
| `.openclaw/agents/developer.md` | (Опц.) краткая инструкция про устранение security-находок при заворотах. |
|
||||
| `tests/` | `test_security_gate.py`, `test_qg_security.py`, `test_stage_engine_security_gate.py` (см. `04-test-plan.yaml`). |
|
||||
| **Документация** | `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` (таблица гейтов + реестр QG + раздел), `CHANGELOG.md`, `.env.example` (`ORCH_SECURITY_*`), global `adr-0012`. |
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
- **Вариант R (review-стадия):** раньше/дешевле, но diff может разойтись с тем, что
|
||||
вольётся в `main`; merge-edge уже закрывает «последнюю страховку».
|
||||
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
|
||||
выражаются статусом коммита; коуплинг с CI-раннером. → точка расширения BR-14.
|
||||
- **fail-closed dep-audit по умолчанию:** ложные откаты при сетевых сбоях → петля. →
|
||||
только опционально через флаг.
|
||||
- **Аудит после rebase (как анкер image-freshness):** обвиняет задачу в CVE из `main` →
|
||||
петля. → скан ветки ДО merge-gate.
|
||||
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как
|
||||
отклонено в ORCH-043). → под-гейт ребра.
|
||||
- **Новая колонка retry в БД:** не нужна — переиспользуем `_developer_retry_count`.
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы.** Структурно невозможно тихо влить секрет (безусловно) или известную CVE
|
||||
(best-effort) в `main`/прод автономной системы. Самоприменение CLAUDE.md §8. Минимальный
|
||||
blast-radius: `STAGE_TRANSITIONS`/схема БД не меняются, переиспользован готовый паттерн.
|
||||
|
||||
**Минусы / плата.** Ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`).
|
||||
Добавлены внешние инструменты (gitleaks-бинарь в образ, pip-audit в зависимости). Время
|
||||
сканирования добавляется к каждому прогону (ограничено таймаутом). Dep-audit best-effort
|
||||
при сетевых сбоях (осознанный компромисс против петли). v1 — Python-only (A3); мульти-стек
|
||||
и SAST — follow-up WI (BR-14).
|
||||
|
||||
**Раскат.** Сквозное изменение конвейера (новый QG + новый edge-под-гейт) → лейбл
|
||||
`arch:major-change`. Прод-деплой ORCH-022 — строго через staging-гейт (8501), без рестарта
|
||||
прод-контейнера в рамках задачи (self-hosting safety).
|
||||
|
||||
## Связи
|
||||
|
||||
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
|
||||
условность/never-raise/fail-closed), adr-0003 (`is_self_hosting_repo` — образец условности),
|
||||
adr-0009/ORCH-061 (анти-петля ложных FAIL), ORCH-046 (дословный reason в `task_desc`),
|
||||
ORCH-9/15 (мульти-стек — будущая зависимость).
|
||||
56
docs/work-items/ORCH-022/07-infra-requirements.md
Normal file
56
docs/work-items/ORCH-022/07-infra-requirements.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 07 — Инфраструктурные требования: Security-гейт (ORCH-022)
|
||||
|
||||
См. `06-adr/ADR-001-security-gate.md` (Р-2, Р-3, Р-8). Топология не меняется (один сервер
|
||||
mva154, Docker Compose). Новые требования — только инструменты сканирования и сетевой доступ
|
||||
к CVE-фиду.
|
||||
|
||||
## I-1. Бинарь `gitleaks` в образе
|
||||
- **Что:** статический Go-бинарь `gitleaks` (secret-scanning), устанавливается в `Dockerfile`
|
||||
(НЕ pip-пакет). Зафиксировать версию (pinned release) для детерминизма.
|
||||
- **Почему в образе, а не на хосте:** гейт исполняется внутри контейнера оркестратора
|
||||
(`advance_stage`); сканируется per-task worktree, смонтированный в контейнер.
|
||||
- **Оффлайн:** gitleaks не требует сети (правила локальны) → гарантия «секрет всегда
|
||||
блокирует» (BR-2) не зависит от доступности интернета.
|
||||
- **Контракт exit-кодов:** 0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента
|
||||
(≥2 → never-raise degrade-вердикт гейта).
|
||||
|
||||
## I-2. `pip-audit` в зависимостях
|
||||
- **Что:** Python-пакет `pip-audit` (dependency audit), добавляется в `requirements.txt`
|
||||
(pinned-версия).
|
||||
- **Источник advisory:** OSV / PyPI advisory DB — **требует сетевого доступа** (исходящий
|
||||
HTTPS к OSV/PyPI).
|
||||
- **Цель v1:** аудит `requirements.txt` корня репо (Python-стек, A3). Мульти-стек — follow-up.
|
||||
|
||||
## I-3. Сетевой доступ к CVE-фиду (degrade-политика)
|
||||
- **Требование:** исходящий HTTPS из прод-контейнера к OSV/PyPI advisory.
|
||||
- **При недоступности (Р-3):** **fail-open + громкий warning** по умолчанию — dep-audit не
|
||||
краснит гейт из-за сетевого сбоя (анти-петля ORCH-061); фиксируется
|
||||
`deps_audit_degraded: true` + Telegram + лог. Флаг `security_dep_audit_fail_closed`
|
||||
(дефолт `false`) — для перевода в строгий режим без редеплоя кода.
|
||||
- **Секреты не зависят от сети** (I-1) — критическая гарантия безусловна.
|
||||
|
||||
## I-4. Конфиг-файлы в репозитории (версионируемые, BR-13)
|
||||
- `.gitleaks.toml` (корень репо): правила + аллоулист заведомо-безопасных совпадений
|
||||
(плейсхолдеры `.env.example`, тест-фикстуры). Версионируется, ревьюится как код.
|
||||
|
||||
## I-5. Env-флаги (`.env.example` + хост `.env`/`.env.staging`)
|
||||
| Переменная | Дефолт | Назначение |
|
||||
|------------|--------|-----------|
|
||||
| `ORCH_SECURITY_GATE_ENABLED` | `true` | глобальный kill-switch |
|
||||
| `ORCH_SECURITY_GATE_REPOS` | `` (пусто) | CSV scope; пусто → только self-hosting |
|
||||
| `ORCH_SECURITY_DEP_BLOCK_SEVERITY` | `HIGH` | порог блокировки зависимостей |
|
||||
| `ORCH_SECURITY_SCAN_TIMEOUT_S` | `300` | таймаут каждого внешнего вызова сканера |
|
||||
| `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` | `false` | строгий режим при недоступном фиде |
|
||||
| `ORCH_SECURITY_SECRETS_BLOCK` | `true` | секреты блокируют (всегда по дефолту) |
|
||||
|
||||
Секреты-значения в гит НЕ коммитятся (CLAUDE.md §8) — только дефолты в `.env.example`.
|
||||
|
||||
## I-6. Ресурсы и тайминги
|
||||
- Время сканирования добавляется к каждому прогону задачи на ребре `deploy-staging → deploy`,
|
||||
ограничено `ORCH_SECURITY_SCAN_TIMEOUT_S` (по образцу `merge_retest_timeout_s`).
|
||||
- Гейт исполняется ДО merge-gate/image-freshness (дёшево фейлить до дорогих rebase/rebuild).
|
||||
|
||||
## I-7. Self-hosting safety (инвариант)
|
||||
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
|
||||
деплой-хук, не рестартит/не трогает прод-контейнер (8500/8501). Прод-деплой ORCH-022 — строго
|
||||
через staging-гейт (8501).
|
||||
26
docs/work-items/ORCH-022/08-data-requirements.md
Normal file
26
docs/work-items/ORCH-022/08-data-requirements.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 08 — Требования к схеме БД: Security-гейт (ORCH-022)
|
||||
|
||||
## Решение: схема БД НЕ меняется
|
||||
|
||||
Миграций нет. Обоснование (соответствует TRZ §6 и паттерну edge-под-гейтов ORCH-043/058):
|
||||
|
||||
1. **Вердикт гейта — артефакт-файл** `17-security-report.md` (YAML-frontmatter), как
|
||||
`14-deploy-log.md` / `15-staging-log.md`. Не хранится в БД.
|
||||
2. **Состояние/идемпотентность** — детерминированная пересборка вердикта при каждом тике
|
||||
(гейт чистый, без долгоживущего состояния между прогонами); sentinel-файлы НЕ требуются
|
||||
(в отличие от deploy-state/post-deploy-state — там асинхронный self-restart).
|
||||
3. **Retry-счётчик** — переиспользуется существующий `_developer_retry_count(task_id)`
|
||||
(подсчёт по `jobs`/`agent_runs`), общий с merge-gate/image-freshness. **Новой колонки
|
||||
`security_retry` НЕ вводим** (TRZ §6: предпочесть подсчёт по `jobs`/`agent_runs`). Это
|
||||
корректно: security-FAIL, как merge/freshness-FAIL, откатывает на `development` и
|
||||
запускает developer — он и есть единица retry; общий cap=3 защищает от петли.
|
||||
|
||||
## Используемые существующие таблицы (без изменений)
|
||||
- `tasks` — стадия задачи (`update_task_stage` при откате на `development`).
|
||||
- `jobs` — enqueue `developer` при FAIL; основа `_developer_retry_count`.
|
||||
- `agent_runs` — usage/duration; основа подсчёта retry.
|
||||
|
||||
## Что НЕ делаем
|
||||
- Не добавляем таблицу findings/CVE-журнала (история находок — в артефактах per-task; петля
|
||||
уроков ORCH-8 читает артефакт).
|
||||
- Не добавляем колонок в `tasks`/`jobs`.
|
||||
16
docs/work-items/ORCH-022/10-tech-risks.md
Normal file
16
docs/work-items/ORCH-022/10-tech-risks.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 10 — Технические риски: Security-гейт (ORCH-022)
|
||||
|
||||
| ID | Риск | Вероятность / Влияние | Митигация (заложена в ADR-001) |
|
||||
|----|------|----------------------|-------------------------------|
|
||||
| R-1 | **Ложные срабатывания → петля отката** `→ development` (прецедент ORCH-061 staging-loop). | Средн. / Выс. | Аллоулист `.gitleaks.toml` (BR-13); cap `MAX_DEVELOPER_RETRIES=3` → эскалация (`set_issue_blocked`+Telegram); конфигурируемый порог severity; kill-switch; UNKNOWN-severity → warning, не блок. |
|
||||
| R-2 | **Недоступность CVE-фида** даёт ложный красный/исключение. | Средн. / Выс. | fail-open + громкий warning по умолчанию (Р-3); `deps_audit_degraded:true`; флаг `security_dep_audit_fail_closed` для строгого режима. Секреты offline → не затронуты. |
|
||||
| R-3 | **Скан вешает worker-слот** (зависший gitleaks/pip-audit) → стоит конвейер всех проектов (общий инстанс, `max_concurrency`). | Низк. / Выс. | `security_scan_timeout_s` (300) на каждый внешний вызов; never-raise degrade-вердикт; гейт ПЕРВЫМ на ребре (фейлит до дорогих rebase/rebuild). |
|
||||
| R-4 | **Исключение гейта роняет `advance_stage`** → встаёт движок. | Низк. / Выс. | Двойной never-raise guard (внешний+внутренний) как `check_branch_mergeable`; AC-16/TC-11. |
|
||||
| R-5 | **Скан после rebase обвиняет задачу в CVE из `main`** → петля. | — (устранён дизайном) | Гейт исполняется ДО merge-gate (скан ветки до rebase); Р-1. |
|
||||
| R-6 | **Отсутствие бинаря `gitleaks` в образе** (забыт в Dockerfile) → гейт всегда degrade. | Низк. / Средн. | Установка в Dockerfile (I-1), pinned-версия; TC-11 (нет бинаря → `(False,reason)`, never-raise); проверяется на staging (8501) до прода. |
|
||||
| R-7 | **pip-audit без severity (UNKNOWN)** → либо ложный блок, либо пропуск. | Средн. / Средн. | UNKNOWN → warning (не блок), логируется; осознанный анти-петля компромисс; ужесточение — follow-up. |
|
||||
| R-8 | **Self-hosting: гейт трогает прод** (рестарт/деплой). | — (запрещено дизайном) | Гейт только читает/сканирует; AC-19/TC-21; прод-деплой ORCH-022 — через staging-гейт. |
|
||||
| R-9 | **Drift вердикта vs артефакта** (возврат ≠ frontmatter). | Низк. / Средн. | Единый источник: гейт пишет артефакт → читает обратно через `parse_security_status` → возвращает (Р-5); AC-8. |
|
||||
| R-10 | **Регресс существующих гейтов/стадий** (сломан `QG_CHECKS`/`STAGE_TRANSITIONS`). | Низк. / Выс. | `STAGE_TRANSITIONS` не меняется; новый чек — аддитивно в реестр; полный прогон `tests/` (TC-20); staging-гейт перед прод. |
|
||||
| R-11 | **v1 Python-only** — секреты/CVE в не-Python стеке (JS/Android) не ловятся. | — (вне scope v1, A3) | Условность scope; точка расширения мульти-стек/SAST (BR-14); зависимость ORCH-9/15 зафиксирована. |
|
||||
| R-12 | **Стоимость времени** на каждом прогоне задачи. | Низк. / Низк. | Таймаут; гейт первым (ранний выход); только self-hosting по умолчанию. |
|
||||
74
docs/work-items/ORCH-022/12-review.md
Normal file
74
docs/work-items/ORCH-022/12-review.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-022
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-022
|
||||
|
||||
## Summary
|
||||
Security-гейт (secret-scanning `gitleaks` + dependency audit `pip-audit`) реализован как
|
||||
детерминированный под-гейт ребра `deploy-staging → deploy`, исполняемый ПЕРВЫМ среди
|
||||
edge-под-гейтов — в точности по ADR-001 (Вариант M) и эталонному паттерну соседей
|
||||
(merge-gate ORCH-043 / image-freshness ORCH-058): leaf-модуль `src/security_gate.py`
|
||||
(never-raise) + тонкая обёртка `check_security_gate` в `QG_CHECKS` (lazy-import, нет цикла)
|
||||
+ врезка `_handle_security_gate` ПЕРВОЙ в блоке `current_stage == "deploy-staging"`.
|
||||
`STAGE_TRANSITIONS` и схема БД не тронуты. Все 772 теста зелёные (25 из них —
|
||||
security-специфичные: `test_security_gate.py`, `test_qg_security.py`,
|
||||
`test_stage_engine_security_gate.py`). Документация обновлена полностью и в этом же PR.
|
||||
|
||||
### Соответствие ТЗ (02-trz)
|
||||
- FR-1 secret-scan offline `origin/main..HEAD`, любой секрет вне аллоулиста → FAIL ✓
|
||||
- FR-2 dep-audit по severity (`HIGH` дефолт), MEDIUM/LOW/UNKNOWN → warning ✓
|
||||
- FR-3 машинный вердикт ТОЛЬКО из frontmatter `17-security-report.md`, negative-токен
|
||||
авторитетен, write→read-back (единый источник истины) ✓
|
||||
- FR-4 FAIL → откат на `development` + developer-retry (cap 3) + `task_desc` с дословными
|
||||
находками (ORCH-046) ✓
|
||||
- FR-5 условность `security_gate_enabled` / `security_gate_repos` (пусто → self-hosting) ✓
|
||||
- FR-6 never-raise + таймаут `security_scan_timeout_s` ✓
|
||||
- FR-7 наблюдаемость (Telegram при degraded/FAIL, лог при PASS) ✓
|
||||
- §6 без миграций БД, §7 инварианты соблюдены (STAGE_TRANSITIONS/QG_CHECKS консистентны,
|
||||
gate не деплоит/не рестартит прод) ✓
|
||||
|
||||
### Соответствие ADR (06-adr/ADR-001 + global adr-0012)
|
||||
Р-1 (размещение ПЕРВЫМ, до merge-gate, до захвата merge-lease → lease не освобождается),
|
||||
Р-2 (gitleaks pinned Go-бинарь в Dockerfile, pip-audit в requirements), Р-3 (fail-open
|
||||
degrade + флаг `security_dep_audit_fail_closed`), Р-4 (пороги, UNKNOWN→warning), Р-5
|
||||
(артефакт + read-back), Р-6 (откат/cap/эскалация), Р-7 (lazy-import, double-guard
|
||||
never-raise), Р-8 (self-hosting safety) — все реализованы как описано.
|
||||
|
||||
### Критерии приёмки (03)
|
||||
AC-1..AC-21 покрыты тестами TC-01..TC-21 (incl. rollback TC-16, verbatim task_desc TC-17,
|
||||
cap+blocked TC-18, PASS-advance TC-19, no-deploy-on-FAIL TC-21). AC-20 (документация) —
|
||||
подтверждён ниже.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- Глобальный `docs/architecture/adr/adr-0012-security-gate.md` помечен `Статус: proposed`,
|
||||
тогда как per-WI `06-adr/ADR-001` — `Accepted`. Косметическая рассинхронизация статуса,
|
||||
на функциональность/гейты не влияет.
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR (AC-20, CLAUDE.md §6 соблюдён):
|
||||
- `CLAUDE.md` — раздел «Артефакты задачи» (добавлен `17-security-report.md`) + строка о
|
||||
машинных вердиктах (`security_status:`).
|
||||
- `docs/architecture/README.md` — реестр `QG_CHECKS` (`check_security_gate (ORCH-022)`),
|
||||
новый раздел «Security-гейт …», статусная сноска внизу.
|
||||
- `docs/architecture/adr/adr-0012-security-gate.md` — новый global ADR (+ per-WI ADR-001).
|
||||
- `CHANGELOG.md` — подробная запись в `[Unreleased] / Added`.
|
||||
- `.env.example` — все шесть `ORCH_SECURITY_*` с комментариями.
|
||||
- `Dockerfile` (pinned gitleaks), `requirements.txt` (pip-audit), `.gitleaks.toml` (корень,
|
||||
правила + аллоулист) — инфраструктура версионирована.
|
||||
|
||||
Статус: документация = golden source — синхронна с кодом. Замечаний нет.
|
||||
76
docs/work-items/ORCH-022/13-test-report.md
Normal file
76
docs/work-items/ORCH-022/13-test-report.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-022
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-022
|
||||
|
||||
Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) как под-гейт
|
||||
ребра `deploy-staging → deploy`.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-07
|
||||
- Ветка: `feature/ORCH-022-security-secret-scanning`
|
||||
- Review verdict: APPROVED (`12-review.md`)
|
||||
|
||||
## Smoke test API (prod 8500, self-hosting — не трогаем контейнер)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
|
||||
| `GET /status` | OK (active task ORCH-022 в stage=testing виден) |
|
||||
| `GET /queue` | OK (counts/resilience/reconcile/reaper/post_deploy присутствуют) |
|
||||
|
||||
## Результаты (привязка к 04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | Секрет в diff → FAIL, secrets_found>=1, причина называет находку | test_security_gate.py::test_tc01_secret_in_diff_fails | PASS |
|
||||
| TC-02 | Чистая ветка → PASS, secrets_found=0 | test_tc02_clean_branch_passes | PASS |
|
||||
| TC-03 | Аллоулист подавляет заведомо-безопасное | test_tc03_allowlisted_match_does_not_fail | PASS |
|
||||
| TC-04 | HIGH/CRITICAL CVE при пороге HIGH → FAIL, deps_blocking>=1 | test_tc04_high_cve_at_high_threshold_blocks | PASS |
|
||||
| TC-05 | Только MEDIUM/LOW → PASS, deps_warning>=1 | test_tc05_only_medium_low_warns_passes | PASS |
|
||||
| TC-06 | Конфиг порога severity влияет на классификацию | test_tc06_threshold_config_changes_classification | PASS |
|
||||
| TC-07 | Недоступный фид → детерминированный degrade (fail-open default / fail-closed strict) | test_tc07_degraded_feed_failopen_default_failclosed_strict | PASS |
|
||||
| TC-08 | Вердикт ТОЛЬКО из frontmatter; negative-токен авторитетен | test_tc08_verdict_only_from_frontmatter | PASS |
|
||||
| TC-09 | Нет/битый frontmatter → (False, reason) fail-closed | test_tc09_missing_or_broken_frontmatter_failclosed | PASS |
|
||||
| TC-10 | Артефакт 17-security-report.md с валидным frontmatter + телом | test_tc10_artifact_has_valid_frontmatter_and_body | PASS |
|
||||
| TC-11 | Нет бинаря / исключение → (False, reason), never-raise | test_tc11_missing_binary_failclosed_never_raises | PASS |
|
||||
| TC-12 | Таймаут → детерминированный fail-closed, без зависания | test_tc12_timeout_is_deterministic_failclosed | PASS |
|
||||
| TC-13 | Не-self репо при пустом scope → (True, N/A) мгновенно | test_qg_security.py::test_tc13_non_self_repo_empty_scope_is_na | PASS |
|
||||
| TC-14 | ORCH_SECURITY_GATE_ENABLED=false → no-op pass | test_tc14_disabled_is_noop_pass | PASS |
|
||||
| TC-15 | Зарегистрирован в QG_CHECKS и диспетчеризуется _run_qg | test_tc15_registered_in_qg_checks / test_tc15_dispatched_by_run_qg | PASS |
|
||||
| TC-16 | FAIL → откат на development, enqueue developer, notify_qg_failure | test_stage_engine_security_gate.py::test_tc16_fail_rolls_back_and_enqueues_developer | PASS |
|
||||
| TC-17 | task_desc несёт дословную причину (ORCH-046) | test_tc17_task_desc_has_verbatim_findings | PASS |
|
||||
| TC-18 | После MAX_DEVELOPER_RETRIES (3) → set_issue_blocked + Telegram | test_tc18_retry_cap_blocks_and_alerts | PASS |
|
||||
| TC-19 | PASS → штатное продвижение конвейера | test_tc19_pass_advances_normally | PASS |
|
||||
| TC-20 | STAGE_TRANSITIONS не изменён; тесты стадий зелёные | tests/test_stages.py (полный прогон) | PASS |
|
||||
| TC-21 | Гейт не вызывает деплой-хук/рестарт прод (self-hosting safety) | test_tc21_fail_never_triggers_deploy | PASS |
|
||||
|
||||
Все 21 TC покрыты и зелёные. Соответствие критериям приёмки (03-acceptance-criteria):
|
||||
AC-1..AC-21 закрыты соответствующими TC (AC-N ↔ TC-N для N=1..21; AC-20 «документация»
|
||||
подтверждён в review 12-review.md).
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
### Security-специфичные тесты (25 шт.)
|
||||
```
|
||||
tests/test_security_gate.py ............... (15)
|
||||
tests/test_qg_security.py ...... (6)
|
||||
tests/test_stage_engine_security_gate.py ..... (5)
|
||||
======================== 25 passed, 1 warning in 0.49s =========================
|
||||
```
|
||||
|
||||
### Полный регресс
|
||||
```
|
||||
======================= 772 passed, 1 warning in 14.70s ========================
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-022,
|
||||
существовал до задачи.)
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс 772/772 зелёный, 25 security-тестов покрывают все 21 TC
|
||||
плана и AC-1..AC-21, smoke-тесты API прод-инстанса OK. Прод-контейнер в процессе
|
||||
тестирования не затронут (тесты офлайн/изолированы). Задача готова к стадии deploy-staging.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-066
|
||||
work_item: ORCH-022
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
post_deploy_status: HEALTHY
|
||||
action_taken: NONE
|
||||
work_item: ORCH-066
|
||||
work_item: ORCH-022
|
||||
window_s: 900
|
||||
checks_total: 30
|
||||
checks_failed: 0
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-07T19:19:25Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
|
||||
|
||||
Canonical run inside the `orchestrator-staging` container (ORCH-048, ADR-001):
|
||||
`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 in orchestrator-sandbox), C9b (analyst job enqueued)
|
||||
|
||||
All REAL pipeline checks (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
|
||||
C7/C8) are green. The two failing checks are sandbox-infra-only (SANDBOX bot accounts
|
||||
not members of the SANDBOX Plane project) and were waived per ORCH-061. Exit code 0.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
tolerance: staging_infra_tolerance_enabled=True
|
||||
@@ -1,7 +0,0 @@
|
||||
# Business Request: [высокий] Статусная модель Plane: осмысленные статусы этапов
|
||||
|
||||
Work Item ID: ORCH-066
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
@@ -1,110 +0,0 @@
|
||||
# 01 — Business Requirements Document (BRD)
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
**Заголовок:** [высокий] Статусная модель Plane: осмысленные статусы этапов
|
||||
**Стадия:** analysis
|
||||
**Автор:** Analyst
|
||||
**Дата:** 2026-06-07
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Статусная модель Plane оркестратора имеет **семантические перегрузки**: один и тот
|
||||
же Plane-статус используется для несовместимых смыслов, из-за чего:
|
||||
|
||||
- оператор не понимает, на каком реально этапе стоит задача (доска нечитаема);
|
||||
- повышается риск ошибки оператора (например, неверный ручной перевод статуса);
|
||||
- `In Progress` одновременно означает «человек запускает конвейер», «идёт анализ»,
|
||||
«идёт прод-деплой» и «возврат из Needs Input» — четыре разных смысла на одном статусе.
|
||||
|
||||
Уже частично исправлено: ORCH-059 ввёл отдельный статус для подтверждения деплоя
|
||||
(`Confirm Deploy`), разгрузив перегруженный `Approved`. ORCH-066 завершает наведение
|
||||
порядка по **утверждённой Owner** статусной модели.
|
||||
|
||||
### Два слоя (критично различать)
|
||||
|
||||
| Слой | Что это | Источник | Трогаем? |
|
||||
|------|---------|----------|----------|
|
||||
| **A** | `STAGE_TRANSITIONS` — внутренняя машина стадий (`created→analysis→…→done`) | `src/stages.py` | **НЕТ (инвариант)** |
|
||||
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки в `src/stage_engine.py` / `src/webhooks/plane.py` | **ДА** |
|
||||
|
||||
ORCH-066 меняет **только слой B** и точки, где код вручную проставляет Plane-статусы.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевая статусная модель (решение Owner)
|
||||
|
||||
```
|
||||
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
|
||||
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
|
||||
Monitoring after Deploy → Done
|
||||
```
|
||||
|
||||
- `[...]` = **действие человека** (вход-триггер).
|
||||
- Остальное ставит **орк** (индикация).
|
||||
|
||||
### Ветки (нелинейные исходы)
|
||||
- **Rejected** — откат на предыдущую стадию (человек).
|
||||
- **Needs Input** — ТОЛЬКО аналитик (НЕ расширять на других агентов).
|
||||
- **Blocked** — затык / фейл деплоя / деградация прода.
|
||||
- **Cancelled** — человек решил не делать задачу (валидный выход из In Review).
|
||||
|
||||
---
|
||||
|
||||
## 3. Бизнес-требования
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|----|------------|-----------|
|
||||
| **BR-1** | Каждый этап конвейера показывается на доске Plane осмысленным статусом (To Analyse / Analysis / Code-Review / Awaiting Deploy / Deploying / Monitoring after Deploy). | Must |
|
||||
| **BR-2** | `To Analyse` — единый человеческий вход: (а) старт нового конвейера, (б) resume/relaunch аналитика при возврате из Needs Input. Заменяет роль `In Progress` как входа-триггера. | Must |
|
||||
| **BR-3** | Стадия `analysis` индицируется отдельным статусом `Analysis` (орк ставит при старте/relaunch аналитика), а не `In Progress`. | Must |
|
||||
| **BR-4** | Стадия `review` индицируется Plane-статусом `Code-Review` (переименование `Review`). | Must |
|
||||
| **BR-5** | Self-deploy Phase A (approval-pending) ставит `Awaiting Deploy` вместо `In Review`. | Must |
|
||||
| **BR-6** | Self-deploy Phase B (старт прод-деплоя) ставит `Deploying`. | Must |
|
||||
| **BR-7** | Self-deploy Phase C (health-OK финализация) ставит `Monitoring after Deploy` (НЕ `Done` сразу). | Must |
|
||||
| **BR-8** | Post-deploy monitor (ORCH-021): чистое закрытие окна (HEALTHY) → `Done`; UNHEALTHY/деградация → `Blocked`. | Must |
|
||||
| **BR-9** | `In Review` разгрузить: оставить ТОЛЬКО за approve-pending артефактов конвейера (BRD/ревью). Выходы: `Approved` (вперёд), `Rejected` (откат), `Cancelled` (человек отменил). | Must |
|
||||
| **BR-10** | `Needs Input` — БЕЗ ИЗМЕНЕНИЙ. Остаётся только у аналитика (`01-questions.md` → `set_issue_needs_input`). Механизм не трогать. | Must |
|
||||
| **BR-11** | Возврат аналитика из Needs Input выполняется через `To Analyse` (а НЕ через `In Progress`). Логика fork «старт vs resume» (по наличию task + active-job) сохраняется. | Must (грабли R1) |
|
||||
| **BR-12** | **Fail-closed:** отсутствие нового статуса в проекте (enduro / Plane API down / fallback `_DEFAULT_STATES`) НЕ приводит к падению; поведение остаётся backward-compatible (паттерн ORCH-059 AC-7). | Must |
|
||||
| **BR-13** | Reconciler не «оживляет» активные ожидания (`Awaiting Deploy` / `Deploying` / `Monitoring after Deploy`) как зависшие задачи (Guard 2 skip-list). | Must |
|
||||
| **BR-14** | Документация (golden source) обновлена в том же PR: `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md`, ADR per-work-item. | Must |
|
||||
|
||||
---
|
||||
|
||||
## 4. Границы (Out of Scope / НЕ трогать)
|
||||
|
||||
- `STAGE_TRANSITIONS` (`src/stages.py`) — машина стадий, инвариант.
|
||||
- `QG_CHECKS`, `check_deploy_status`, exit-коды хука (0/1/2), merge-gate, схема БД.
|
||||
- `Confirm Deploy` (уже работает, ORCH-059).
|
||||
- Механизм `Needs Input` (analyst-only) — не расширять, не менять.
|
||||
- Поведение прод-деплоя **не-self** репозиториев (enduro-trails): для них терминальный
|
||||
переход остаётся `deploy → Done` как сейчас (Monitoring after Deploy не применяется —
|
||||
post-deploy monitor армится только для self-hosting).
|
||||
- Автоматический approve / авто-rollback self-hosting (ORCH-54 / ORCH-021 политика
|
||||
ALERT_ONLY) — не меняется.
|
||||
|
||||
---
|
||||
|
||||
## 5. Инфра-предусловие (вне кода, делает оператор)
|
||||
|
||||
Новые Plane-статусы в проекте **ORCH** создаёт оператор через Plane API **ДО** эксплуатации:
|
||||
`To Analyse`, `Analysis`, `Code-Review`, `Awaiting Deploy`, `Deploying`,
|
||||
`Monitoring after Deploy` (`Confirm Deploy` уже есть).
|
||||
|
||||
Резолвер (`_PLANE_NAME_TO_KEY` + `get_project_states`) подхватывает их **по имени** с
|
||||
**fail-closed fallback** на `_DEFAULT_STATES` (см. BR-12). Документируется в
|
||||
`07-infra-requirements.md` (создаёт архитектор) и в `docs/operations/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
- Plane показывает осмысленные статусы на каждом этапе.
|
||||
- Возврат аналитика из Needs Input работает через `To Analyse`.
|
||||
- Phase A → `Awaiting Deploy`, Phase B → `Deploying`, Phase C → `Monitoring after Deploy`,
|
||||
окно HEALTHY → `Done`, фейл → `Blocked`.
|
||||
- `STAGE_TRANSITIONS` не изменён.
|
||||
- `pytest tests/ -q` — зелёный. Fail-closed покрыт тестами.
|
||||
- Документация обновлена.
|
||||
@@ -1,178 +0,0 @@
|
||||
# 02 — Техническое задание (ТЗ)
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
**Стадия:** analysis → (вход для architecture)
|
||||
**Автор:** Analyst
|
||||
|
||||
> ТЗ фиксирует ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые точки кода. Конкретную архитектуру
|
||||
> резолвера (точные имена ключей/функций) финализирует архитектор в ADR. Ниже —
|
||||
> опорный контракт, согласованный с бизнес-запросом Owner.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в задаче |
|
||||
|--------|---------------|
|
||||
| `src/plane_sync.py` | **Ядро изменений (слой B):** реестр логических статусов (`_DEFAULT_STATES`), `_PLANE_NAME_TO_KEY`, маппинг стадия→статус (`_STAGE_TO_STATE_KEY`, `STAGE_VISIBILITY_STATE`), хелперы `set_issue_*`. |
|
||||
| `src/webhooks/plane.py` | Маршрутизация входящего статуса (`handle_issue_updated`): `To Analyse` → `handle_status_start` (старт **или** resume). |
|
||||
| `src/stage_engine.py` | Точки ручной простановки статуса: analyst-flow (`Analysis`/`Needs Input`/`In Review`), Phase A (`Awaiting Deploy`), Phase B (`Deploying`), Phase C → `Monitoring after Deploy`, post-deploy monitor → `Done`/`Blocked`. |
|
||||
| `src/reconciler.py` | F-2 запрос статусов (`To Analyse` в список), Guard 2 skip-list (активные ожидания). |
|
||||
| `src/stages.py` | **НЕ менять** (инвариант слоя A). Используется только для чтения переходов. |
|
||||
| `src/config.py` | (При необходимости) kill-switch для новой статусной модели — на усмотрение архитектора (см. §6). |
|
||||
|
||||
---
|
||||
|
||||
## 2. Изменения статусной модели (слой B)
|
||||
|
||||
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
|
||||
|
||||
Ввести новые **логические ключи** и их имена в `_PLANE_NAME_TO_KEY`:
|
||||
|
||||
| Логический ключ | Plane name | Назначение |
|
||||
|-----------------|-----------|------------|
|
||||
| `to_analyse` | `To Analyse` | Вход-триггер (старт + resume аналитика). |
|
||||
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
|
||||
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review`. |
|
||||
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
|
||||
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
|
||||
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
|
||||
|
||||
Сохранить существующие: `backlog`, `todo`, `in_progress` (backward-compat), `needs_input`,
|
||||
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `testing`,
|
||||
`approved`, `rejected`. `Cancelled` уже присутствует в `_PLANE_NAME_TO_KEY`.
|
||||
|
||||
### 2.2. Fail-closed резолюция (КРИТИЧНО — BR-12)
|
||||
|
||||
`get_project_states()` после резолва по API делает `setdefault(k, v)` из `_DEFAULT_STATES`.
|
||||
Чтобы отсутствие нового статуса в проекте (enduro / Plane down / частичная конфигурация)
|
||||
**не ломало** конвейер, новые логические ключи в `_DEFAULT_STATES` должны
|
||||
**алиаситься на существующие UUID** (degrade-to-current):
|
||||
|
||||
| Новый ключ | Default-алиас (UUID) | Деградированное поведение |
|
||||
|------------|----------------------|---------------------------|
|
||||
| `to_analyse` | = `in_progress` | enduro/старый проект: `In Progress` по-прежнему триггерит старт/resume. |
|
||||
| `analysis` | = `in_progress` | analysis показывается как `In Progress` (как сейчас). |
|
||||
| `code_review` | = `review` | review показывается как `Review` (как сейчас). |
|
||||
| `awaiting_deploy` | = `in_review` | Phase A показывается как `In Review` (как сейчас). |
|
||||
| `deploying` | = `in_progress` | Phase B показывается как `In Progress` (как сейчас). |
|
||||
| `monitoring` | = `done` | Phase C показывается как `Done` (как сейчас); монитор затем держит Done / флипает Blocked. |
|
||||
|
||||
> Эффект: если оператор НЕ создал новый статус — система работает строго как до ORCH-066
|
||||
> (никаких падений, никаких 404 от Plane PATCH). Если создал — резолвится по имени и
|
||||
> используется новый UUID. Это ровно паттерн ORCH-059 AC-7.
|
||||
|
||||
### 2.3. Маппинг стадия → статус
|
||||
|
||||
`src/plane_sync.py`:
|
||||
- `_STAGE_TO_STATE_KEY`: `analysis` → `analysis` (было `in_progress`); `review` → `code_review`
|
||||
(было `review`). `deploy` остаётся (управляется Phase A/B/C напрямую, не через
|
||||
`update_issue_state`). `created`/`architecture`/`development`/`testing`/`done` — без изменений.
|
||||
- `STAGE_VISIBILITY_STATE`: `review` → `code_review` (было `review`). Добавить
|
||||
`analysis` → `analysis`, если индикация analysis ставится через `set_issue_stage_state`
|
||||
(решает архитектор; альтернатива — отдельный хелпер `set_issue_analysis`).
|
||||
- Сохранить совместимость `STAGE_TO_STATE` / `PLANE_STATES` алиасов (импортируются тестами).
|
||||
|
||||
### 2.4. Точки простановки статуса
|
||||
|
||||
| Место (файл:симв.) | Сейчас | Должно стать |
|
||||
|--------------------|--------|--------------|
|
||||
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` (с fail-closed: при алиасе совпадает с `in_progress`) → `handle_status_start` |
|
||||
| `webhooks/plane.py` `start_pipeline` (старт) | статус остаётся `In Progress` | при старте/enqueue analyst орк ставит `Analysis` |
|
||||
| `webhooks/plane.py` `handle_status_start` (resume из Needs Input) | relaunch на `In Progress`-триггере | relaunch на `To Analyse`-триггере; при relaunch орк ставит `Analysis`. Fork «старт vs resume» (по `get_task_by_plane_id` + `has_active_job_for_task`) — **сохранить как есть.** |
|
||||
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | оставить `In Review` (BR-9: In Review только за approve-pending конвейера) ✔ без изменений |
|
||||
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) |
|
||||
| `stage_engine.py` `_handle_self_deploy_phase_a` | `set_issue_in_review` | `Awaiting Deploy` (`set_issue_awaiting_deploy` или аналог) |
|
||||
| `stage_engine.py` `_handle_self_deploy_phase_b` | (статус не меняет) | `Deploying` |
|
||||
| `stage_engine.py` advance `deploy → done` (terminal-sync, строка ~338) | `set_issue_done` для всех | **self-hosting:** `Monitoring after Deploy` (перед/вместо арма монитора); **не-self:** `Done` как сейчас |
|
||||
| `stage_engine.py` `run_post_deploy_monitor` (HEALTHY, окно закрыто) | пишет лог + коммент, статус Plane НЕ трогает (остаётся Done) | `Done` (явно) |
|
||||
| `stage_engine.py` `run_post_deploy_monitor` (DEGRADED) | пишет лог + alert | `Blocked` |
|
||||
|
||||
> **Замечание по terminal-sync (важно для архитектора):** сейчас `advance_stage` на
|
||||
> `next_stage == "done"` вызывает `set_issue_done` безусловно (строка ~338), затем армит
|
||||
> post-deploy monitor для self-hosting (~361). Нужно развести: для репо, где
|
||||
> `post_deploy.post_deploy_applies(repo)` истинно (self-hosting) — ставить `Monitoring
|
||||
> after Deploy` вместо `Done`, и переложить простановку `Done`/`Blocked` на финал
|
||||
> монитора (`run_post_deploy_monitor`). Для прочих репо — `Done` как сейчас.
|
||||
|
||||
### 2.5. Новые хелперы `src/plane_sync.py`
|
||||
|
||||
Добавить тонкие обёртки по образцу `set_issue_in_review` (резолв per-project UUID +
|
||||
`_set_issue_state_direct`), never-raise при отсутствии issue:
|
||||
- `set_issue_analysis(work_item_id, project_id=None)`
|
||||
- `set_issue_code_review(...)` (или через `set_issue_stage_state("review")`)
|
||||
- `set_issue_awaiting_deploy(...)`
|
||||
- `set_issue_deploying(...)`
|
||||
- `set_issue_monitoring(...)`
|
||||
|
||||
(Точный набор/именование — на усмотрение архитектора; контракт: per-project резолв +
|
||||
fail-closed.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Изменения reconciler (`src/reconciler.py`)
|
||||
|
||||
- **F-2** `_reconcile_plane_project`: добавить `to_analyse` в список запрашиваемых
|
||||
статусов (`list_issues_by_state([... , to_analyse])`) и в `_reconcile_plane_issue`
|
||||
маршрутизировать `new_state == to_analyse` → `handle_status_start` (старт при `task is
|
||||
None`, resume при существующем task без active-job — логика уже в `handle_status_start`).
|
||||
Сохранить обработку `approved`/`rejected`. При fail-closed алиасе `to_analyse==in_progress`
|
||||
поведение не дублируется (один и тот же UUID).
|
||||
- **Guard 2** `_is_blocked_or_needs_input` (F-1 skip): расширить skip-множество активными
|
||||
ожиданиями — `awaiting_deploy`, `deploying`, `monitoring` — чтобы реконсилер НЕ
|
||||
«оживлял» их как зависшие (BR-13). Имя метода/семантику можно обобщить
|
||||
(«human-or-active-wait»), флаг `reconcile_skip_blocked_enabled` продолжает управлять
|
||||
этим networked-чеком.
|
||||
|
||||
> Примечание: F-1 и так не тронет Phase A (`check_deploy_status` red → silent),
|
||||
> Deploying (active finalizer job), Monitoring (стадия `done`). Guard 2 — явная
|
||||
> defense-in-depth по требованию Owner.
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения API / эндпоинтов
|
||||
|
||||
**Нет** новых HTTP-эндпоинтов. `GET /queue` / `GET /status` — без изменений контракта
|
||||
(статусы Plane там не отражаются). Изменения только во внешней индикации Plane (PATCH
|
||||
issue state — существующий механизм).
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
**Нет.** `tasks` не хранит Plane-статус (источник истины — стадия в БД + Plane API).
|
||||
Миграции не требуются.
|
||||
|
||||
---
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
|
||||
**Нет.** `QG_CHECKS` не расширяется. Статусы — индикация, не управление (канон:
|
||||
машинные вердикты читаются из YAML-frontmatter артефактов, не из Plane-статуса).
|
||||
|
||||
Опционально (на усмотрение архитектора): единый kill-switch новой статусной модели
|
||||
(env-флаг) для безопасного раската, по образцу `staging_infra_tolerance_enabled` /
|
||||
`reconcile_skip_blocked_enabled`. Не обязателен, т.к. fail-closed алиасинг (§2.2) уже даёт
|
||||
backward-compatible деградацию.
|
||||
|
||||
---
|
||||
|
||||
## 7. Артефакты pipeline, создаваемые/обновляемые
|
||||
|
||||
- `06-adr/ADR-001-plane-status-model.md` — архитектор (решение по резолверу,
|
||||
алиасингу, разводке terminal-sync).
|
||||
- `07-infra-requirements.md` — архитектор (список Plane-статусов для ручного создания
|
||||
оператором + Plane API инструкция).
|
||||
- Документация (golden source, тот же PR): `CLAUDE.md` (секция статусной модели),
|
||||
`docs/architecture/README.md` (секция статусов рядом с ORCH-036/ORCH-021),
|
||||
`CHANGELOG.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Инварианты (проверяемые)
|
||||
|
||||
- `src/stages.py` `STAGE_TRANSITIONS` — байт-в-байт без изменений.
|
||||
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate,
|
||||
схема БД, `Confirm Deploy`, механизм `Needs Input` — без изменений.
|
||||
- Все новые `set_issue_*` / резолв — never-raise (Plane down ⇒ degrade, не crash).
|
||||
- Поведение enduro (не-self) и его терминальный `Done` — без регресса.
|
||||
@@ -1,71 +0,0 @@
|
||||
# 03 — Критерии приёмки (Acceptance Criteria)
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
|
||||
Каждый критерий — чёткое условие PASS/FAIL. Покрытие тестами — см. `04-test-plan.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Группа A — Вход и стадия анализа
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-1** | `To Analyse` запускает конвейер | Перевод issue без task в `To Analyse` → `handle_status_start` → `start_pipeline` (создаётся task, ветка, enqueue analyst). | Не запускается / запускается на другом статусе. |
|
||||
| **AC-2** | `To Analyse` делает resume аналитика из Needs Input | Существующий task без active-job + перевод в `To Analyse` → relaunch агента текущей стадии (analyst читает свежие комменты). Fork «старт vs resume» определяется по `get_task_by_plane_id` + `has_active_job_for_task` (как раньше). | Создаётся второй task / двойной запуск / resume не происходит. |
|
||||
| **AC-3** | Стадия `analysis` индицируется статусом `Analysis` | При старте/relaunch аналитика орк ставит `Analysis`. | Остаётся `In Progress` (при наличии статуса `Analysis` в проекте). |
|
||||
| **AC-4** | Busy-guard сохранён | `To Analyse` при существующем active-job для task → НЕ relaunch (no double launch). | Двойной запуск агента. |
|
||||
|
||||
## Группа B — Code-Review
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-5** | Стадия `review` индицируется `Code-Review` | Вход в стадию `review` → Plane-статус `Code-Review`. | Остаётся `Review` (при наличии нового статуса). |
|
||||
|
||||
## Группа C — Self-deploy фазы
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-6** | Phase A → `Awaiting Deploy` | `_handle_self_deploy_phase_a` ставит `Awaiting Deploy` (не `In Review`). | Ставит `In Review` (при наличии нового статуса). |
|
||||
| **AC-7** | Phase B → `Deploying` | `_handle_self_deploy_phase_b` при успешном `initiate_deploy` ставит `Deploying`. | Статус не меняется / иной. |
|
||||
| **AC-8** | Phase C → `Monitoring after Deploy` (self) | Финализатор SUCCESS для self-hosting → статус `Monitoring after Deploy`, НЕ `Done` сразу. | Ставит `Done` немедленно (для self-hosting). |
|
||||
| **AC-9** | Не-self deploy → `Done` без регресса | Для не-self репо (`post_deploy_applies==False`) терминальный `deploy → done` ставит `Done` как сейчас. | Не-self репо получает `Monitoring after Deploy` / иной регресс. |
|
||||
|
||||
## Группа D — Post-deploy monitor
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-10** | Чистое окно → `Done` | `run_post_deploy_monitor` HEALTHY + окно исчерпано → статус `Done`. | Остаётся `Monitoring after Deploy` / иной. |
|
||||
| **AC-11** | Деградация → `Blocked` | `run_post_deploy_monitor` DEGRADED → статус `Blocked` (+ существующий ALERT_ONLY для self). | Остаётся в Monitoring / ставит Done. |
|
||||
| **AC-12** | Self-hosting монитор не рестартит прод | Тик НИКОГДА не рестартит/откатывает прод-контейнер (ORCH-021 BR-5 сохранён). | Тик трогает прод-контейнер. |
|
||||
|
||||
## Группа E — In Review / Needs Input / ветки
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-13** | `In Review` только за approve-pending конвейера | `In Review` ставится лишь для approve артефактов (analyst BRD/ревью), не для Phase A. | Phase A / иные стадии ставят `In Review`. |
|
||||
| **AC-14** | `Needs Input` без изменений | Поведение `set_issue_needs_input` (analyst `01-questions.md`) идентично прежнему; не расширено на других агентов. | Механизм изменён / расширен. |
|
||||
| **AC-15** | `Cancelled` — валидный выход из In Review без действий конвейера | Перевод в `Cancelled` → орк не выполняет advance/rollback (индикация, не управление). | Орк совершает действие конвейера на `Cancelled`. |
|
||||
|
||||
## Группа F — Fail-closed (КРИТИЧНО)
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-16** | Отсутствие нового статуса не ломает конвейер | Проект без новых статусов (enduro/частичный/Plane down) → `get_project_states` отдаёт default-алиасы; все `set_issue_*`/триггеры работают backward-compatible, без исключений и без 404 PATCH. | Падение / необработанное исключение / зависание задачи. |
|
||||
| **AC-17** | enduro `In Progress` по-прежнему стартует конвейер | Через `to_analyse`-алиас (= `in_progress` UUID) перевод enduro-issue в `In Progress` запускает старт/resume. | enduro-старт сломан. |
|
||||
| **AC-18** | Резолв по имени | При наличии статуса в проекте по `name` (`_PLANE_NAME_TO_KEY`) используется его UUID, а не default-алиас. | Используется неверный UUID. |
|
||||
|
||||
## Группа G — Reconciler
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-19** | F-2 реконсилирует `To Analyse` | `_reconcile_plane_project` запрашивает `to_analyse` и маршрутизирует к `handle_status_start` (старт/resume при потерянном webhook). | `To Analyse`-старты не реконсилируются. |
|
||||
| **AC-20** | Guard 2 skip активных ожиданий | Задачи в `Awaiting Deploy` / `Deploying` / `Monitoring after Deploy` НЕ «оживляются» F-1 как зависшие. | Реконсилер advance'ит активное ожидание. |
|
||||
|
||||
## Группа H — Инварианты и документация
|
||||
|
||||
| ID | Критерий | PASS | FAIL |
|
||||
|----|----------|------|------|
|
||||
| **AC-21** | `STAGE_TRANSITIONS` не изменён | `src/stages.py` `STAGE_TRANSITIONS` идентичен (diff пуст). | Любое изменение слоя A. |
|
||||
| **AC-22** | Реестры/контракты не изменены | `QG_CHECKS`, `check_deploy_status`, exit-коды хука, merge-gate, схема БД, `Confirm Deploy` — без изменений. | Любое изменение перечисленного. |
|
||||
| **AC-23** | Тесты зелёные | `pytest tests/ -q` проходит полностью; новые fail-closed тесты присутствуют и зелёные. | Любой красный тест. |
|
||||
| **AC-24** | Документация обновлена (golden source) | `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` обновлены; заведён `06-adr/ADR-001-*`. | Любой из артефактов не обновлён. |
|
||||
@@ -1,184 +0,0 @@
|
||||
work_item: ORCH-066
|
||||
description: >
|
||||
Тест-план статусной модели Plane (слой B). Покрывает осмысленные статусы этапов,
|
||||
возврат аналитика через To Analyse, фазы self-deploy, post-deploy monitor,
|
||||
fail-closed деградацию и reconciler. Слой A (STAGE_TRANSITIONS) проверяется на
|
||||
неизменность. Все тесты — pytest; Plane API мокается (httpx), как в существующих
|
||||
tests/test_plane_*.py / tests/test_orch10_states.py.
|
||||
|
||||
tests:
|
||||
# --- Группа A: вход и стадия анализа ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "To Analyse без существующего task -> handle_status_start -> start_pipeline (старт конвейера)."
|
||||
module: tests/test_status_trigger.py
|
||||
covers: [AC-1]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: integration
|
||||
description: "To Analyse при существующем task без active-job -> relaunch агента стадии (resume из Needs Input), новый task НЕ создаётся."
|
||||
module: tests/test_plane_to_analyse_resume.py
|
||||
covers: [AC-2, BR-11]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Старт/relaunch аналитика ставит Plane-статус Analysis (а не In Progress) при наличии статуса в проекте."
|
||||
module: tests/test_plane_status_model.py
|
||||
covers: [AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "To Analyse при существующем task с active-job -> НЕ relaunch (busy-guard)."
|
||||
module: tests/test_plane_to_analyse_resume.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа B: Code-Review ---
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Вход в стадию review -> Plane-статус Code-Review (маппинг _STAGE_TO_STATE_KEY / STAGE_VISIBILITY_STATE)."
|
||||
module: tests/test_plane_status_model.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа C: self-deploy фазы ---
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "_handle_self_deploy_phase_a ставит Awaiting Deploy (не In Review)."
|
||||
module: tests/test_deploy_approve.py
|
||||
covers: [AC-6, AC-13]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "_handle_self_deploy_phase_b при успешном initiate_deploy ставит Deploying."
|
||||
module: tests/test_deploy_approve.py
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: "Phase C (finalizer SUCCESS) для self-hosting ставит Monitoring after Deploy, НЕ Done; армит post-deploy monitor."
|
||||
module: tests/test_deploy_terminal_sync.py
|
||||
covers: [AC-8]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: "Не-self репо: deploy->done ставит Done (без регресса, Monitoring не применяется)."
|
||||
module: tests/test_deploy_terminal_sync.py
|
||||
covers: [AC-9]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа D: post-deploy monitor ---
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "run_post_deploy_monitor HEALTHY + окно исчерпано -> Plane-статус Done."
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-10]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "run_post_deploy_monitor DEGRADED -> Plane-статус Blocked (+ ALERT_ONLY для self)."
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-11]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Self-hosting тик НЕ рестартит/не откатывает прод-контейнер (ORCH-021 BR-5 сохранён)."
|
||||
module: tests/test_post_deploy.py
|
||||
covers: [AC-12]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа E: In Review / Needs Input / Cancelled ---
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "In Review ставится только за approve-pending конвейера (analyst BRD ready), не Phase A."
|
||||
module: tests/test_analyst_status_only_regression.py
|
||||
covers: [AC-13]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "set_issue_needs_input (analyst 01-questions.md) поведение идентично прежнему; не расширено на других агентов."
|
||||
module: tests/test_plane_status_model.py
|
||||
covers: [AC-14, BR-10]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Перевод в Cancelled -> handle_issue_updated не выполняет advance/rollback (индикация, не управление)."
|
||||
module: tests/test_plane_webhook.py
|
||||
covers: [AC-15]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа F: fail-closed (критично) ---
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "Проект без новых статусов: get_project_states отдаёт default-алиасы (to_analyse=in_progress, code_review=review, awaiting_deploy=in_review, monitoring=done); исключений нет."
|
||||
module: tests/test_plane_status_failclosed.py
|
||||
covers: [AC-16, BR-12]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "Plane API down -> get_project_states fallback на _DEFAULT_STATES; set_issue_* never-raise."
|
||||
module: tests/test_plane_status_failclosed.py
|
||||
covers: [AC-16]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "enduro In Progress по-прежнему стартует конвейер через to_analyse-алиас."
|
||||
module: tests/test_plane_status_failclosed.py
|
||||
covers: [AC-17]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "Резолв по имени: при наличии статуса в проекте используется его UUID, а не default-алиас."
|
||||
module: tests/test_orch10_states.py
|
||||
covers: [AC-18]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа G: reconciler ---
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "F-2 _reconcile_plane_project запрашивает to_analyse и маршрутизирует к handle_status_start (потерянный webhook старта/resume)."
|
||||
module: tests/test_reconciler_plane.py
|
||||
covers: [AC-19]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "Guard 2: задачи в Awaiting Deploy / Deploying / Monitoring after Deploy НЕ оживляются F-1 как зависшие."
|
||||
module: tests/test_reconciler.py
|
||||
covers: [AC-20, BR-13]
|
||||
expected: PASS
|
||||
|
||||
# --- Группа H: инварианты ---
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "STAGE_TRANSITIONS не изменён (явная проверка ключей/значений слоя A)."
|
||||
module: tests/test_plane_status_model.py
|
||||
covers: [AC-21]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-23
|
||||
type: unit
|
||||
description: "QG_CHECKS реестр и check_deploy_status контракты не изменены."
|
||||
module: tests/test_plane_status_model.py
|
||||
covers: [AC-22]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-24
|
||||
type: integration
|
||||
description: "Полный прогон pytest tests/ -q зелёный (регрессия)."
|
||||
module: tests/
|
||||
covers: [AC-23]
|
||||
expected: PASS
|
||||
@@ -1,287 +0,0 @@
|
||||
# ADR-001: Осмысленная статусная модель Plane (слой B)
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
**Стадия:** architecture
|
||||
**Автор:** Architect
|
||||
**Дата:** 2026-06-07
|
||||
**Статус:** Accepted
|
||||
|
||||
> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на
|
||||
> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`).
|
||||
> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`,
|
||||
> риски — `10-tech-risks.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Plane-доска оркестратора семантически перегружена: `In Progress` одновременно
|
||||
означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат
|
||||
из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного
|
||||
перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`;
|
||||
ORCH-066 завершает наведение порядка по утверждённой Owner модели.
|
||||
|
||||
**Жёсткое разделение двух слоёв (инвариант проекта):**
|
||||
|
||||
| Слой | Что | Источник | ORCH-066 |
|
||||
|------|-----|----------|----------|
|
||||
| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** |
|
||||
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** |
|
||||
|
||||
Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только
|
||||
из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена
|
||||
Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих
|
||||
триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами).
|
||||
|
||||
Целевая модель Owner:
|
||||
|
||||
```
|
||||
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
|
||||
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
|
||||
Monitoring after Deploy → Done
|
||||
```
|
||||
`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация).
|
||||
Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл
|
||||
деплоя/деградация), **Cancelled** (человек отменил задачу).
|
||||
|
||||
---
|
||||
|
||||
## 2. Решение
|
||||
|
||||
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
|
||||
|
||||
Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из
|
||||
Plane API):
|
||||
|
||||
| Логический ключ | Plane name | Назначение |
|
||||
|-----------------|-----------|------------|
|
||||
| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. |
|
||||
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
|
||||
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. |
|
||||
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
|
||||
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
|
||||
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
|
||||
|
||||
Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`,
|
||||
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`,
|
||||
`testing`, `approved`, `rejected`. `Cancelled` уже присутствует.
|
||||
|
||||
### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12)
|
||||
|
||||
ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное
|
||||
уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые
|
||||
статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH
|
||||
422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой
|
||||
UUID.
|
||||
|
||||
**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:**
|
||||
|
||||
1. Резолв по имени из Plane API (как сейчас).
|
||||
2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его
|
||||
**базового ключа из этого же проекта**:
|
||||
|
||||
```python
|
||||
_STATE_ALIAS_FALLBACK = {
|
||||
"to_analyse": "in_progress",
|
||||
"analysis": "in_progress",
|
||||
"code_review": "review",
|
||||
"awaiting_deploy": "in_review",
|
||||
"deploying": "in_progress",
|
||||
"monitoring": "done",
|
||||
}
|
||||
# после резолва по имени, ДО _DEFAULT_STATES.setdefault:
|
||||
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
|
||||
if new_key not in resolved and resolved.get(base_key):
|
||||
resolved[new_key] = resolved[base_key]
|
||||
```
|
||||
3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где
|
||||
API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал
|
||||
запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового
|
||||
ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал
|
||||
`KeyError`.
|
||||
|
||||
**Эффект деградации:**
|
||||
|
||||
| Сценарий | Поведение |
|
||||
|----------|-----------|
|
||||
| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). |
|
||||
| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. |
|
||||
| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). |
|
||||
| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. |
|
||||
|
||||
Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и
|
||||
`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не
|
||||
пробрасывается) — индикация деградирует, слой A не затрагивается.
|
||||
|
||||
### 2.3. Маппинг стадия → статус
|
||||
|
||||
- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`):
|
||||
`analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`).
|
||||
`deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без
|
||||
изменений.
|
||||
- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis`
|
||||
(для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется).
|
||||
- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`,
|
||||
`review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это
|
||||
алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют.
|
||||
|
||||
### 2.4. Новые хелперы `src/plane_sync.py`
|
||||
|
||||
Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`,
|
||||
never-raise):
|
||||
|
||||
- `set_issue_analysis(work_item_id, project_id=None)`
|
||||
- `set_issue_code_review(work_item_id, project_id=None)`
|
||||
- `set_issue_awaiting_deploy(work_item_id, project_id=None)`
|
||||
- `set_issue_deploying(work_item_id, project_id=None)`
|
||||
- `set_issue_monitoring(work_item_id, project_id=None)`
|
||||
|
||||
`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не
|
||||
кидает `KeyError`.
|
||||
|
||||
### 2.5. Точки простановки статуса (разводка)
|
||||
|
||||
| Файл:место | Сейчас | Должно стать | AC |
|
||||
|------------|--------|--------------|----|
|
||||
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 |
|
||||
| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 |
|
||||
| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 |
|
||||
| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
|
||||
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 |
|
||||
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 |
|
||||
| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
|
||||
| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 |
|
||||
| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 |
|
||||
| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 |
|
||||
| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 |
|
||||
| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 |
|
||||
|
||||
**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт
|
||||
`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor.
|
||||
Разводим по `post_deploy.post_deploy_applies(repo)`:
|
||||
|
||||
```python
|
||||
if next_stage == "done" and work_item_id:
|
||||
if post_deploy.post_deploy_applies(repo):
|
||||
set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу
|
||||
else:
|
||||
set_issue_done(work_item_id) # не-self: терминальный Done как сейчас
|
||||
# арм монитора (существующий блок ~361) — без изменений
|
||||
```
|
||||
Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`.
|
||||
При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем
|
||||
монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему.
|
||||
|
||||
**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку —
|
||||
**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер
|
||||
(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над
|
||||
контейнером.
|
||||
|
||||
**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только
|
||||
на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action».
|
||||
Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим
|
||||
кодом.
|
||||
|
||||
### 2.6. Reconciler (`src/reconciler.py`)
|
||||
|
||||
- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в
|
||||
списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`)
|
||||
и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` →
|
||||
`handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение
|
||||
идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19.
|
||||
- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями
|
||||
`awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro
|
||||
(КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в
|
||||
skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться
|
||||
F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда
|
||||
они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы):
|
||||
|
||||
```python
|
||||
base_working = {states.get(k) for k in (
|
||||
"backlog","todo","in_progress","in_review","review",
|
||||
"architecture","development","testing","approved","rejected","done")}
|
||||
extra_waits = {states.get("awaiting_deploy"),
|
||||
states.get("deploying"),
|
||||
states.get("monitoring")} - base_working - {None}
|
||||
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
|
||||
return cur in skip_set
|
||||
```
|
||||
Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator
|
||||
(отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до
|
||||
«human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот
|
||||
networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status`
|
||||
red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия
|
||||
`done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner.
|
||||
|
||||
### 2.7. Без kill-switch
|
||||
|
||||
Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится
|
||||
**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2)
|
||||
держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это
|
||||
проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает
|
||||
флаг как опциональный — сознательно отказываемся.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Затронутые модули (карта изменений)
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. |
|
||||
| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. |
|
||||
| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. |
|
||||
| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. |
|
||||
| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). |
|
||||
| `src/config.py` | Без изменений (kill-switch не вводится). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Инварианты (проверяемые, AC-21/AC-22)
|
||||
|
||||
- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт).
|
||||
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2),
|
||||
merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений.
|
||||
- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений.
|
||||
- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений.
|
||||
- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API).
|
||||
- Все новые `set_issue_*` / резолв — never-raise.
|
||||
- Не-self (enduro) терминальный `deploy → Done` — без регресса.
|
||||
|
||||
---
|
||||
|
||||
## 5. Последствия
|
||||
|
||||
**Плюсы**
|
||||
- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены
|
||||
от индикации.
|
||||
- `In Progress` разгружен: больше не «всё подряд».
|
||||
- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию,
|
||||
ни конвейер.
|
||||
- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting).
|
||||
- Нет нового флага/таблицы → меньше движущихся частей.
|
||||
|
||||
**Минусы / ограничения**
|
||||
- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до
|
||||
этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`).
|
||||
- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен
|
||||
`reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск).
|
||||
- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой
|
||||
extra; orchestrator → три статуса).
|
||||
|
||||
**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина
|
||||
стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501)
|
||||
до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта.
|
||||
|
||||
---
|
||||
|
||||
## 6. Альтернативы (отклонены)
|
||||
|
||||
- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной
|
||||
конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative
|
||||
alias-fallback (§2.2).
|
||||
- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт
|
||||
естественный гейт раската (§2.7).
|
||||
- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия +
|
||||
живой Plane API. Нарушило бы инвариант «без лишних зависимостей».
|
||||
- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A);
|
||||
статусы — индикация, отделены от машины стадий.
|
||||
@@ -1,96 +0,0 @@
|
||||
# 07 — Требования к инфраструктуре
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
**Автор:** Architect
|
||||
**Дата:** 2026-06-07
|
||||
|
||||
> ORCH-066 не меняет топологию (контейнеры/порты/сеть — без изменений, см.
|
||||
> `docs/operations/INFRA.md`). Единственное инфра-действие — создание новых
|
||||
> Plane-статусов в проекте **ORCH** руками оператора через Plane API. Это
|
||||
> **предусловие эксплуатации**, не часть кодового PR.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что нужно сделать оператору (ДО эксплуатации новой модели)
|
||||
|
||||
Создать в Plane-проекте **ORCH** следующие статусы (states) с точными именами —
|
||||
резолвер сопоставляет их по `name` (`_PLANE_NAME_TO_KEY`):
|
||||
|
||||
| Plane name (точно) | Логический ключ | Группа Plane (рекомендуемая) | Назначение |
|
||||
|--------------------|-----------------|------------------------------|------------|
|
||||
| `To Analyse` | `to_analyse` | unstarted / started | Человеческий вход: старт конвейера + resume аналитика из Needs Input. |
|
||||
| `Analysis` | `analysis` | started | Индикация стадии анализа. |
|
||||
| `Code-Review` | `code_review` | started | Индикация стадии review. |
|
||||
| `Awaiting Deploy` | `awaiting_deploy` | started | Phase A: ожидание ручного approve на прод-деплой. |
|
||||
| `Deploying` | `deploying` | started | Phase B: идёт прод-деплой. |
|
||||
| `Monitoring after Deploy` | `monitoring` | started | Phase C / окно пост-деплой наблюдения. |
|
||||
|
||||
`Confirm Deploy` (ORCH-059) и базовые статусы (`Backlog`, `Todo`, `In Progress`,
|
||||
`Architecture`, `Development`, `Review`, `Testing`, `Approved`, `Rejected`, `Done`,
|
||||
`Cancelled`, `Needs Input`, `In Review`, `Blocked`) уже существуют — **не трогать**.
|
||||
|
||||
> ⚠️ **Точность имён критична.** Резолв идёт по строковому `name`. Опечатка/иной регистр
|
||||
> → статус не сопоставится → ключ деградирует на собственный базовый UUID проекта
|
||||
> (alias-fallback, ADR §2.2): индикация откатится к старому статусу, но конвейер
|
||||
> продолжит работать. Дефис в `Code-Review` — обязателен.
|
||||
|
||||
---
|
||||
|
||||
## 2. Plane API — как создать статус
|
||||
|
||||
Эндпоинт (как в `src/plane_sync.py`, `PLANE_BASE = {plane_api_url}/api/v1`):
|
||||
|
||||
```
|
||||
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
|
||||
Headers: X-API-Key: <PLANE_API_TOKEN> (или соответствующий бот-токен с правами)
|
||||
Body (JSON):
|
||||
{ "name": "To Analyse", "group": "started", "color": "#3f76ff" }
|
||||
```
|
||||
|
||||
Повторить для каждого имени из таблицы §1. `group` влияет только на колонку доски;
|
||||
оркестратор `group` не читает (резолв строго по `name`). `color` — на вкус оператора.
|
||||
|
||||
Проверка после создания:
|
||||
|
||||
```
|
||||
GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
|
||||
```
|
||||
В ответе должны присутствовать все 6 имён.
|
||||
|
||||
---
|
||||
|
||||
## 3. Сброс кэша статусов (важно)
|
||||
|
||||
`get_project_states` кэширует резолв per-process (`_STATES_CACHE`). После создания
|
||||
статусов оркестратор подхватит их **только** после сброса кэша:
|
||||
|
||||
- штатно — `plane_sync.reload_project_states(project_id)` (или рестарт процесса);
|
||||
- на **staging** (8501) — безопасный рестарт песочницы;
|
||||
- на **прод** (8500) — **НЕ рестартить контейнер ради этого** в рамках задачи
|
||||
(self-hosting: общий контейнер всех проектов). Кэш заполняется при первом обращении к
|
||||
проекту; если статусы созданы ДО первого PATCH в цикле новой версии — отдельный сброс не
|
||||
нужен. Если созданы позже — дождаться штатного цикла обновления/деплоя орка.
|
||||
|
||||
---
|
||||
|
||||
## 4. Порядок раската (рекомендация)
|
||||
|
||||
1. Слить кодовый PR ORCH-066 через `deploy-staging` (8501).
|
||||
2. Создать 6 статусов в проекте ORCH (§1–§2).
|
||||
3. Сбросить кэш / поднять staging, прогнать sandbox-задачу — убедиться, что доска
|
||||
показывает `Analysis` / `Code-Review` / `Awaiting Deploy` / `Deploying` /
|
||||
`Monitoring after Deploy` / `Done` на соответствующих этапах.
|
||||
4. Прод-деплой орка штатным self-deploy (Phase A → approve → Phase B/C).
|
||||
|
||||
**До шага 2** система работает строго как до ORCH-066 (alias-fallback) — раскат
|
||||
безопасно обратим: не создавать/удалить статусы = откат индикации к старой модели,
|
||||
без изменения кода.
|
||||
|
||||
---
|
||||
|
||||
## 5. Что НЕ требуется
|
||||
|
||||
- Никаких изменений docker-compose, портов, сети, томов, `.env`/`.env.staging`.
|
||||
- Никаких миграций БД (`tasks` не хранит Plane-статус).
|
||||
- Никаких изменений в проекте **enduro-trails** — там новые статусы не создаются;
|
||||
alias-fallback держит прежнюю индикацию (`In Progress`/`Review`/`Done`).
|
||||
@@ -1,31 +0,0 @@
|
||||
# 10 — Технические риски
|
||||
|
||||
**Work Item:** ORCH-066
|
||||
**Автор:** Architect
|
||||
**Дата:** 2026-06-07
|
||||
|
||||
Риски слоя B (Plane-индикация). Слой A (`STAGE_TRANSITIONS`/гейты) не затрагивается, поэтому
|
||||
класс «сломали конвейер» структурно исключён — худший исход любого риска ниже = неверная
|
||||
**индикация**, не остановка конвейера.
|
||||
|
||||
| ID | Риск | Вероятность | Влияние | Митигация |
|
||||
|----|------|-------------|---------|-----------|
|
||||
| **R1** | Частичная конфигурация: оператор создал не все 6 статусов в ORCH → отсутствующий ключ деградирует. Наивный статический enduro-UUID дал бы невалидный `state` (PATCH 422) на orchestrator-issue. | Средняя | Средн. | **Project-relative alias-fallback** (ADR §2.2): отсутствующий ключ → собственный базовый UUID проекта → PATCH валиден, индикация откатывается к текущему статусу. Покрыть тестом partial-config. |
|
||||
| **R2** | Enduro-регресс через Guard 2: новые ключи алиасятся на `in_progress`/`in_review`/`done`; наивное добавление в skip-set заставит F-1 пропускать enduro `In Progress`/`Done` → сломанная реконсиляция (ORCH-053/060). | Средняя | Высок. | **Subtraction базовых рабочих статусов** (ADR §2.6): `extra_waits -= base_working`. На enduro (алиасы схлопнуты) `extra_waits == {}` → нулевой регресс. Тест: enduro-алиас не добавляет skip, orchestrator-distinct добавляет. |
|
||||
| **R3** | Двойной триггер старта: F-2 reconciler и webhook оба маршрутизируют `to_analyse`; при алиасе `to_analyse == in_progress` возможен повтор. | Низкая | Низк. | `list_issues_by_state` дедуплицирует UUID через `set`; active-job guard + atomic create-claim в `handle_status_start` (`get_task_by_plane_id` + `has_active_job_for_task`) — без двойного старта (AC-4). Сохранить fork как есть. |
|
||||
| **R4** | Кэш статусов: после создания статусов `_STATES_CACHE` отдаёт старый резолв до сброса → доска не обновляется. | Средняя | Низк. | `reload_project_states()` / рестарт **staging**. Документировано в `07-infra-requirements.md §3`. Прод-рестарт ради кэша — запрещён (self-hosting). |
|
||||
| **R5** | Опечатка в имени статуса оператором (`Code Review` без дефиса и т.п.) → ключ не резолвится. | Средняя | Низк. | Резолв по точному `name`; при промахе — alias-fallback (деградация, не падение). Точные имена и проверка в `07-infra-requirements.md §1–2`. |
|
||||
| **R6** | Terminal-sync split: ошибка ветвления `post_deploy_applies` → enduro получает `Monitoring after Deploy` вместо `Done` (регресс AC-9) или self уходит в `Done` минуя окно (AC-8). | Низкая | Средн. | Единый источник условности — `post_deploy.post_deploy_applies(repo)` (та же функция, что армит монитор). Тесты AC-8 (self→monitoring) и AC-9 (не-self→done). |
|
||||
| **R7** | Phase B: `set_issue_deploying` поставлен до фактического старта детача → ложная индикация при провале `initiate_deploy`. | Низкая | Низк. | Ставить `set_issue_deploying` **после** успешного `initiate_deploy` и записи `INITIATED` marker (ADR §2.5); провал `initiate_deploy` оставляет `Awaiting Deploy` + просьбу повторить approve. |
|
||||
| **R8** | Post-deploy DEGRADED → `set_issue_blocked` ошибочно трактуется как «действие над продом». | Низкая | Высок.(если) | `set_issue_blocked` — только Plane-PATCH. Тик остаётся `ALERT_ONLY`, НИКОГДА не рестартит/откатывает прод-контейнер (AC-12, ORCH-021 BR-5). Явный тест: self DEGRADED не трогает контейнер. |
|
||||
| **R9** | Plane API недоступен в момент простановки статуса → PATCH падает. | Низкая | Низк. | Все `set_issue_*`/`_set_issue_state_direct` — never-raise (логируют, не пробрасывают). Индикация пропускается, слой A не затронут. |
|
||||
| **R10** | Регресс на тестах, читающих `STAGE_TO_STATE`/`PLANE_STATES` конкретные UUID. | Низкая | Низк. | Новые ключи в `_DEFAULT_STATES` = алиасы на те же in_progress/review/done UUID → значения байт-в-байт; `STAGE_TO_STATE` analysis/review остаются прежними UUID (ADR §2.3). |
|
||||
| **R11** | Self-hosting: выкладка орка минуя staging. | Низкая | Высок. | Обязательный `deploy-staging` гейт (8501); прод не рестартить вне штатного self-deploy. Раскат обратим (не создавать статусы = старое поведение). |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Все риски снижаемы в рамках принятой архитектуры; ни один не способен остановить конвейер
|
||||
(слой A инвариантен). Два ключевых требуют аккуратной реализации и обязательных тестов:
|
||||
**R1** (project-relative alias-fallback) и **R2** (Guard-2 anti-regress subtraction) —
|
||||
оба зафиксированы в ADR §2.2 и §2.6 как явные контракты. Эскалации `arch:major-change` не
|
||||
требуется: изменение локализовано в слое B, без новых компонентов/стадий/QG/миграций.
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-066
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-066
|
||||
|
||||
## Summary
|
||||
Осмысленная статусная модель Plane (слой B — индикация). Реализация затрагивает
|
||||
строго слой B (`src/plane_sync.py`, точки простановки в `src/stage_engine.py` /
|
||||
`src/webhooks/plane.py` / `src/reconciler.py`) и **не трогает слой A**
|
||||
(`src/stages.py::STAGE_TRANSITIONS` — diff пуст). Все 4 оси проверки (ТЗ, ADR,
|
||||
качество кода, тесты) и проверка документации — пройдены. `pytest tests/ -q`:
|
||||
**774 passed**. Вердикт — **APPROVED**.
|
||||
|
||||
## Соответствие ТЗ (02-trz.md)
|
||||
- §2.1 — 6 новых логических ключей в `_PLANE_NAME_TO_KEY` + `_DEFAULT_STATES`. ✔
|
||||
- §2.2 — fail-closed резолюция (BR-12). ✔ (реализована усиленная project-relative
|
||||
версия — см. ADR ниже).
|
||||
- §2.3 — `_STAGE_TO_STATE_KEY` (analysis→analysis, review→code_review),
|
||||
`STAGE_VISIBILITY_STATE`, legacy `STAGE_TO_STATE` (UUID байт-в-байт прежние). ✔
|
||||
- §2.4 — точки простановки разведены (handle_issue_updated триггер `to_analyse`,
|
||||
start_pipeline/resume → Analysis, Phase A → Awaiting Deploy, Phase B → Deploying,
|
||||
terminal-sync split, post-deploy HEALTHY→Done / DEGRADED→Blocked,
|
||||
rollback@analysis → Analysis). ✔
|
||||
- §2.5 — 5 новых never-raise хелперов `set_issue_*`. ✔
|
||||
- §3 — reconciler F-2 триггер `to_analyse` (+ resume-ветка), Guard 2 skip-set с
|
||||
вычитанием base_working. ✔
|
||||
- §4/§5/§6 — нет новых эндпоинтов, нет миграций БД, `QG_CHECKS` не расширен. ✔
|
||||
|
||||
## Соответствие ADR (06-adr/ADR-001)
|
||||
- §2.2 project-relative alias-fallback (`_STATE_ALIAS_FALLBACK`, применён ДО
|
||||
`_DEFAULT_STATES.setdefault`) — реализован точно по контракту, деградация на
|
||||
собственный базовый UUID проекта, PATCH остаётся валидным на частичной
|
||||
конфигурации. ✔
|
||||
- §2.5 terminal-sync split по `post_deploy.post_deploy_applies(repo)` — реализован
|
||||
как в ADR (self → Monitoring, не-self → Done). ✔
|
||||
- §2.6 Guard 2 анти-регресс (extra_waits − base_working − {None}) — реализован
|
||||
дословно, enduro-алиасы схлопываются → нулевой регресс. ✔
|
||||
- §2.7 без kill-switch — config.py не изменён (diff пуст). ✔
|
||||
|
||||
## Качество кода
|
||||
- Все новые `set_issue_*` следуют образцу `set_issue_in_review` (per-project резолв
|
||||
+ `_set_issue_state_direct`), контракт never-raise сохранён, есть docstrings. ✔
|
||||
- Post-deploy/terminal-sync простановки обёрнуты в try/except с warning-логом
|
||||
(never break the tick). ✔
|
||||
- Переменные в scope корректны (`work_item_id` определён до всех новых вызовов в
|
||||
`start_pipeline`/`handle_status_start`/stage_engine). ✔
|
||||
- AC-12 соблюдён: `set_issue_blocked` в DEGRADED-ветке — только индикация, тик
|
||||
прод-контейнер не трогает. ✔
|
||||
|
||||
## Качество тестов
|
||||
- Содержательные, не тривиальные: `test_plane_status_failclosed.py`
|
||||
(TC-16/17/18 — partial project, API down, never-raise сеттеров, enduro alias
|
||||
старт), `test_plane_to_analyse_resume.py`, `test_plane_status_model.py`,
|
||||
`test_deploy_terminal_sync.py` (self/не-self split), `test_post_deploy_integration.py`,
|
||||
`test_reconciler*.py` (F-2 to_analyse + Guard 2). ✔
|
||||
|
||||
## Инварианты (AC-21/AC-22)
|
||||
- `src/stages.py` — diff 0 строк (STAGE_TRANSITIONS байт-в-байт). ✔
|
||||
- `src/qg/checks.py` — diff 0 строк (QG_CHECKS, check_deploy_status). ✔
|
||||
- `src/config.py` — diff 0 строк. ✔
|
||||
- Схема БД — без миграций. ✔
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR (golden source соблюдён):
|
||||
- `CLAUDE.md` — добавлена секция «Статусная модель Plane (ORCH-066)». ✔
|
||||
- `docs/architecture/README.md` — секция «Осмысленная статусная модель Plane
|
||||
(ORCH-066)» + обновлён статусный footer. ✔
|
||||
- `CHANGELOG.md` — подробная запись в [Unreleased]/Added. ✔
|
||||
- `06-adr/ADR-001-plane-status-model.md` — заведён. ✔
|
||||
- `07-infra-requirements.md` — присутствует (инфра-предусловие: 6 Plane-статусов
|
||||
создаёт оператор). ✔
|
||||
|
||||
Изменения `src/` полностью отражены в документации → требование
|
||||
«документация обновлена при изменении src/» выполнено.
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-066
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-066
|
||||
|
||||
Осмысленная статусная модель Plane (слой B — индикация). Прогон полного регресса +
|
||||
покрытие тест-плана `04-test-plan.yaml` + проверка инвариантов слоя A.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-066-plane
|
||||
- Дата: 2026-06-07
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Покрывает | Описание | Модуль | Результат |
|
||||
|-------|-----------|----------|--------|-----------|
|
||||
| TC-01 | AC-1 | To Analyse без task → start_pipeline | test_status_trigger.py | PASS |
|
||||
| TC-02 | AC-2,BR-11 | To Analyse resume аналитика, без двойного task | test_plane_to_analyse_resume.py | PASS |
|
||||
| TC-03 | AC-3 | Старт/relaunch → статус Analysis | test_plane_status_model.py | PASS |
|
||||
| TC-04 | AC-4 | Busy-guard: active-job → не relaunch | test_plane_to_analyse_resume.py | PASS |
|
||||
| TC-05 | AC-5 | review → статус Code-Review | test_plane_status_model.py | PASS |
|
||||
| TC-06 | AC-6,AC-13 | Phase A → Awaiting Deploy (не In Review) | test_deploy_approve.py | PASS |
|
||||
| TC-07 | AC-7 | Phase B → Deploying | test_deploy_approve.py | PASS |
|
||||
| TC-08 | AC-8 | Phase C self → Monitoring after Deploy | test_deploy_terminal_sync.py | PASS |
|
||||
| TC-09 | AC-9 | Не-self deploy→done → Done (без регресса) | test_deploy_terminal_sync.py | PASS |
|
||||
| TC-10 | AC-10 | Post-deploy HEALTHY → Done | test_post_deploy.py | PASS |
|
||||
| TC-11 | AC-11 | Post-deploy DEGRADED → Blocked | test_post_deploy.py | PASS |
|
||||
| TC-12 | AC-12 | Self-тик не рестартит прод | test_post_deploy.py | PASS |
|
||||
| TC-13 | AC-13 | In Review только за approve-pending | test_analyst_status_only_regression.py | PASS |
|
||||
| TC-14 | AC-14,BR-10 | Needs Input без изменений | test_plane_status_model.py | PASS |
|
||||
| TC-15 | AC-15 | Cancelled → нет действий конвейера | test_plane_webhook.py | PASS |
|
||||
| TC-16 | AC-16,BR-12 | Fail-closed default-алиасы, нет исключений | test_plane_status_failclosed.py | PASS |
|
||||
| TC-17 | AC-16 | Plane API down → fallback, never-raise | test_plane_status_failclosed.py | PASS |
|
||||
| TC-18 | AC-17 | enduro In Progress стартует через алиас | test_plane_status_failclosed.py | PASS |
|
||||
| TC-19 | AC-18 | Резолв по имени → корректный UUID | test_orch10_states.py | PASS |
|
||||
| TC-20 | AC-19 | F-2 реконсилирует To Analyse | test_reconciler_plane.py | PASS |
|
||||
| TC-21 | AC-20,BR-13 | Guard 2 skip активных ожиданий | test_reconciler.py | PASS |
|
||||
| TC-22 | AC-21 | STAGE_TRANSITIONS не изменён | test_plane_status_model.py | PASS |
|
||||
| TC-23 | AC-22 | QG_CHECKS/check_deploy_status не изменены | test_plane_status_model.py | PASS |
|
||||
| TC-24 | AC-23 | Полный регресс pytest зелёный | tests/ | PASS |
|
||||
|
||||
Все 24 тест-кейса — PASS.
|
||||
|
||||
## Инварианты слоя A (AC-21 / AC-22)
|
||||
Diff против `origin/main` (merge-base `4815e378`):
|
||||
- `src/stages.py` (STAGE_TRANSITIONS) — diff пуст ✔
|
||||
- `src/qg/checks.py` (QG_CHECKS, check_deploy_status) — diff пуст ✔
|
||||
- `src/config.py` (без kill-switch) — diff пуст ✔
|
||||
|
||||
## Smoke test API (TestClient — прод-контейнер 8500 не трогался)
|
||||
> `curl` в окружении недоступен; smoke прогнан через FastAPI TestClient (lifespan),
|
||||
> без рестарта/обращения к прод-контейнеру (self-hosting safety).
|
||||
|
||||
| Endpoint | Статус | Тело (фрагмент) |
|
||||
|----------|--------|-----------------|
|
||||
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | 200 | `{"active_tasks":[...]}` |
|
||||
| GET /queue | 200 | `{"counts":{...},"max_concurrency":1,...}` |
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
======================= 774 passed, 1 warning in 17.68s ========================
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в src/config.py, предсуществующий,
|
||||
не связан с ORCH-066)
|
||||
|
||||
Прогон по модулям тест-плана: `117 passed` (ORCH-066-специфичные файлы).
|
||||
|
||||
## Итог
|
||||
PASS — все тесты зелёные (774 passed), все 24 TC покрыты, инварианты слоя A
|
||||
сохранены (diff пуст), smoke-эндпоинты отвечают 200. Review-вердикт APPROVED.
|
||||
Задача готова к переходу на стадию deploy-staging.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-07T22:01:57Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
|
||||
run canonically via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`.
|
||||
|
||||
**Result: 8/10 checks PASS — exit code 0 (advance).**
|
||||
|
||||
All REAL (pipeline) checks are green. The two failing checks are the known
|
||||
SANDBOX_INFRA-only checks C9a/C9b (sandbox branch / analyst-job — depend on
|
||||
SANDBOX bot accounts being project members, not on the pipeline), which are
|
||||
waived under ORCH-061 since every REAL check passed.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Check breakdown
|
||||
|
||||
| Block | Check | Result |
|
||||
|-------|-------|--------|
|
||||
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
|
||||
| A SMOKE | A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS |
|
||||
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
|
||||
| B ACCESS | B4 Plane: sandbox project accessible | PASS |
|
||||
| B ACCESS | B5 Gitea: orchestrator-sandbox accessible, push=true | PASS |
|
||||
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
|
||||
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
|
||||
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
|
||||
| C E2E | C9a Branch appears in orchestrator-sandbox | FAIL (waived — sandbox-infra) |
|
||||
| C E2E | C9b Analyst job enqueued in staging queue | FAIL (waived — sandbox-infra) |
|
||||
|
||||
CLEANUP completed: test Plane issue deleted (HTTP 204); no branch to delete.
|
||||
@@ -4,3 +4,8 @@ pydantic-settings==2.5.0
|
||||
httpx==0.27.0
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.23.8
|
||||
# ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the
|
||||
# network at scan time -> an unreachable feed degrades fail-open + warning by
|
||||
# default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go
|
||||
# binary baked into the Dockerfile, NOT a pip package.
|
||||
pip-audit==2.7.3
|
||||
|
||||
@@ -219,6 +219,36 @@ class Settings(BaseSettings):
|
||||
image_freshness_enabled: bool = True
|
||||
image_freshness_repos: str = ""
|
||||
|
||||
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
|
||||
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates (cheap to
|
||||
# fail before the expensive rebase/rebuild). Deterministic (no LLM): gitleaks
|
||||
# (offline secret-scan) + pip-audit (OSV/PyPI dependency audit), verdict in the
|
||||
# versioned 17-security-report.md frontmatter; FAIL -> rollback to development +
|
||||
# developer-retry (cap MAX_DEVELOPER_RETRIES). See ADR-001-security-gate.md.
|
||||
# security_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as
|
||||
# before ORCH-022 for everyone. Env
|
||||
# ORCH_SECURITY_GATE_ENABLED.
|
||||
# security_gate_repos -> CSV of repos where the gate is REAL; empty ->
|
||||
# only the self-hosting repo (orchestrator).
|
||||
# Mirrors merge_gate_repos / image_freshness_repos.
|
||||
# security_dep_block_severity -> CVE severity threshold that BLOCKS (CRITICAL >
|
||||
# HIGH > MEDIUM > LOW); below it / UNKNOWN -> a
|
||||
# warning only (anti-loop ADR-001 Р-4).
|
||||
# security_scan_timeout_s -> per external scanner call timeout (mirrors
|
||||
# merge_retest_timeout_s).
|
||||
# security_dep_audit_fail_closed -> strict mode: an unreachable CVE feed -> FAIL
|
||||
# instead of the default fail-open + warning
|
||||
# (Р-3). Default False (anti-loop ORCH-061).
|
||||
# security_secrets_block -> a found secret blocks (always True by default;
|
||||
# the offline secrets guarantee is unconditional,
|
||||
# BR-2).
|
||||
security_gate_enabled: bool = True
|
||||
security_gate_repos: str = ""
|
||||
security_dep_block_severity: str = "HIGH"
|
||||
security_scan_timeout_s: int = 300
|
||||
security_dep_audit_fail_closed: bool = False
|
||||
security_secrets_block: bool = True
|
||||
|
||||
# ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite.
|
||||
# The self-hosting deploy-staging stage looped because scripts/staging_check.py
|
||||
# exited non-zero on ANY failed check, so two infra-only failures (sandbox bot
|
||||
|
||||
@@ -107,19 +107,6 @@ _DEFAULT_STATES = {
|
||||
# Feature 2 (verdict statuses) — Approved / Rejected.
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
# ORCH-066 (meaningful Plane status model, layer B): six new logical keys.
|
||||
# Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key
|
||||
# (see _STATE_ALIAS_FALLBACK) so a project without these statuses created
|
||||
# (enduro / Plane down / partial config) degrades to the current behaviour
|
||||
# instead of producing an invalid PATCH state. The project-relative
|
||||
# alias-fallback in get_project_states() overrides these with the *project's
|
||||
# own* base UUID on the success path; these defaults are the last resort.
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review
|
||||
"awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review
|
||||
"deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
|
||||
"monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done
|
||||
}
|
||||
|
||||
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
|
||||
@@ -141,29 +128,6 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
|
||||
"Needs Input": "needs_input",
|
||||
"In Review": "in_review",
|
||||
"Blocked": "blocked",
|
||||
# ORCH-066: meaningful per-stage / human-input statuses (layer B).
|
||||
"To Analyse": "to_analyse",
|
||||
"Analysis": "analysis",
|
||||
"Code-Review": "code_review",
|
||||
"Awaiting Deploy": "awaiting_deploy",
|
||||
"Deploying": "deploying",
|
||||
"Monitoring after Deploy": "monitoring",
|
||||
}
|
||||
|
||||
# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys.
|
||||
# After resolving states by name from the Plane API, any NEW key the project did
|
||||
# not define degrades to the UUID of its BASE key **from the same project** — so
|
||||
# the indication falls back to the current status and the PATCH stays valid even
|
||||
# for a partially-configured project. Enduro (none of the new statuses created)
|
||||
# collapses every new key onto its base, i.e. strictly the pre-ORCH-066
|
||||
# behaviour. Strengthened ORCH-059 AC-7 pattern.
|
||||
_STATE_ALIAS_FALLBACK: dict[str, str] = {
|
||||
"to_analyse": "in_progress",
|
||||
"analysis": "in_progress",
|
||||
"code_review": "review",
|
||||
"awaiting_deploy": "in_review",
|
||||
"deploying": "in_progress",
|
||||
"monitoring": "done",
|
||||
}
|
||||
|
||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||
@@ -211,16 +175,6 @@ def get_project_states(project_id: str) -> dict[str, str]:
|
||||
if not resolved:
|
||||
raise ValueError("no recognisable states in API response")
|
||||
|
||||
# ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the
|
||||
# project did not define, reuse the UUID of its BASE key FROM THIS SAME
|
||||
# PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH
|
||||
# state on a partially-configured orchestrator project). Runs BEFORE the
|
||||
# _DEFAULT_STATES.setdefault below so a project's own base UUID wins over
|
||||
# the static enduro default.
|
||||
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
|
||||
if new_key not in resolved and resolved.get(base_key):
|
||||
resolved[new_key] = resolved[base_key]
|
||||
|
||||
# Fill any missing keys from _DEFAULT_STATES so callers always get a
|
||||
# complete mapping (defensive against partial Plane configs).
|
||||
for k, v in _DEFAULT_STATES.items():
|
||||
@@ -256,16 +210,14 @@ def reload_project_states(project_id: str = None) -> None:
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
|
||||
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
|
||||
# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked
|
||||
# remain higher priority and are set explicitly elsewhere — do NOT override them
|
||||
# from here.
|
||||
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
||||
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
||||
# in_progress until done. Needs Input / In Review / Blocked remain higher
|
||||
# priority and are set explicitly elsewhere — do NOT override them from here.
|
||||
STAGE_VISIBILITY_STATE = {
|
||||
"analysis": "analysis", # ORCH-066: analysis stage -> Analysis status
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "code_review", # ORCH-066: review stage -> Code-Review status
|
||||
"review": "review",
|
||||
"testing": "testing",
|
||||
}
|
||||
|
||||
@@ -273,27 +225,22 @@ STAGE_VISIBILITY_STATE = {
|
||||
# update_issue_state now calls stage_to_state() instead of looking up here.
|
||||
STAGE_TO_STATE = {
|
||||
"created": _DEFAULT_STATES["todo"],
|
||||
# ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias
|
||||
# the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers /
|
||||
# tests that compare against concrete UUIDs see byte-identical values.
|
||||
"analysis": _DEFAULT_STATES["analysis"],
|
||||
"analysis": _DEFAULT_STATES["in_progress"],
|
||||
"architecture": _DEFAULT_STATES["architecture"],
|
||||
"development": _DEFAULT_STATES["development"],
|
||||
"review": _DEFAULT_STATES["code_review"],
|
||||
"review": _DEFAULT_STATES["review"],
|
||||
"testing": _DEFAULT_STATES["testing"],
|
||||
"deploy": _DEFAULT_STATES["in_progress"],
|
||||
"done": _DEFAULT_STATES["done"],
|
||||
}
|
||||
|
||||
# Map orchestrator stage -> logical state key (project-independent).
|
||||
# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review).
|
||||
# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state).
|
||||
_STAGE_TO_STATE_KEY = {
|
||||
"created": "todo",
|
||||
"analysis": "analysis",
|
||||
"analysis": "in_progress",
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "code_review",
|
||||
"review": "review",
|
||||
"testing": "testing",
|
||||
"deploy": "in_progress",
|
||||
"done": "done",
|
||||
@@ -628,58 +575,6 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_analysis(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Analysis' — analyst is working (start / resume).
|
||||
|
||||
Degrades to the project's In Progress UUID when the 'Analysis' status is not
|
||||
created (alias-fallback). never-raise (via _set_issue_state_direct).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["analysis"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_code_review(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Code-Review' — review stage indication.
|
||||
|
||||
Degrades to the project's Review UUID when 'Code-Review' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["code_review"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending.
|
||||
|
||||
Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["awaiting_deploy"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_deploying(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight.
|
||||
|
||||
Degrades to the project's In Progress UUID when 'Deploying' is not created.
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["deploying"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_monitoring(work_item_id: str, project_id: str = None):
|
||||
"""ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open.
|
||||
|
||||
Degrades to the project's Done UUID when 'Monitoring after Deploy' is not
|
||||
created (so the board shows Done, exactly as before ORCH-066).
|
||||
"""
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["monitoring"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
"""Feature 3: move the issue to the board status for a pipeline stage.
|
||||
|
||||
|
||||
@@ -716,6 +716,23 @@ def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tup
|
||||
return check_staging_image_fresh(repo, work_item_id, branch)
|
||||
|
||||
|
||||
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
|
||||
"""ORCH-022 security sub-gate (secret-scan + dependency audit) on the
|
||||
deploy-staging -> deploy edge, run FIRST (before merge-gate / image-freshness).
|
||||
|
||||
Thin registry wrapper that delegates to ``security_gate.check_security_gate``
|
||||
(gitleaks offline + pip-audit, write/read-back ``17-security-report.md``). The
|
||||
real logic lives in ``src/security_gate.py`` (leaf module, never-raise,
|
||||
fail-closed on secrets, fail-open degrade for the dep-audit feed); importing it
|
||||
lazily here avoids an import cycle (security_gate imports is_self_hosting_repo
|
||||
from this module). For non-self repos with an empty scope it returns
|
||||
``(True, "security-gate N/A for <repo>")`` so the deploy edge is unchanged for
|
||||
them (AC-13/TC-13).
|
||||
"""
|
||||
from ..security_gate import check_security_gate as _impl
|
||||
return _impl(repo, work_item_id, branch)
|
||||
|
||||
|
||||
# Registry for dynamic lookup by name
|
||||
QG_CHECKS = {
|
||||
"check_analysis_approved": check_analysis_approved,
|
||||
@@ -730,4 +747,5 @@ QG_CHECKS = {
|
||||
"check_staging_status": check_staging_status,
|
||||
"check_branch_mergeable": check_branch_mergeable,
|
||||
"check_staging_image_fresh": _check_staging_image_fresh,
|
||||
"check_security_gate": check_security_gate,
|
||||
}
|
||||
|
||||
@@ -193,22 +193,12 @@ class Reconciler:
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
|
||||
def _is_blocked_or_needs_input(self, task: dict) -> bool:
|
||||
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
|
||||
an active orchestrator wait that F-1 must not "revive"?
|
||||
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
|
||||
|
||||
Variant A (no schema migration): resolve the task's Plane project, fetch
|
||||
the issue's current state uuid and compare against a skip-set. ``tasks``
|
||||
has no status column, so the live Plane state is the source of truth.
|
||||
|
||||
Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the
|
||||
ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``,
|
||||
BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto
|
||||
``in_review`` / ``in_progress`` / ``done`` on a project that did not create
|
||||
them. Adding them verbatim would make F-1 wrongly skip enduro
|
||||
In Progress / Done tasks (regression of ORCH-053/060). So they are
|
||||
included ONLY when DISTINCT from the project's base working statuses
|
||||
(i.e. actually created as separate statuses): enduro collapses them to {}
|
||||
-> zero regress; orchestrator keeps three real statuses -> BR-13.
|
||||
the issue's current state uuid and compare against the project's
|
||||
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
|
||||
the live Plane state is the source of truth.
|
||||
|
||||
**Never-raise, conservative fallback.** Any error / unresolved project /
|
||||
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
|
||||
@@ -229,22 +219,7 @@ class Reconciler:
|
||||
cur = fetch_issue_state(issue_id, pid)
|
||||
if cur is None:
|
||||
return True # Plane unreachable / no state -> conservative skip
|
||||
# ORCH-066 BR-13: active orchestrator waits, minus base working
|
||||
# statuses so aliased (enduro) keys never widen the skip-set.
|
||||
base_working = {
|
||||
states.get(k) for k in (
|
||||
"backlog", "todo", "in_progress", "in_review", "review",
|
||||
"architecture", "development", "testing",
|
||||
"approved", "rejected", "done",
|
||||
)
|
||||
}
|
||||
extra_waits = {
|
||||
states.get("awaiting_deploy"),
|
||||
states.get("deploying"),
|
||||
states.get("monitoring"),
|
||||
} - base_working - {None}
|
||||
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
|
||||
return cur in skip_set
|
||||
return cur in {states.get("blocked"), states.get("needs_input")}
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(
|
||||
f"reconciler Guard 2: blocked-check failed for task "
|
||||
@@ -266,19 +241,15 @@ class Reconciler:
|
||||
def _reconcile_plane_project(self, proj) -> None:
|
||||
pid = proj.plane_project_id
|
||||
# Resolve the actionable state uuids per-project (never hardcode).
|
||||
# ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was
|
||||
# In Progress). On a project without that status, `to_analyse` aliases to
|
||||
# the project's own `in_progress` UUID, so enduro behaviour is identical
|
||||
# (and `list_issues_by_state` deduplicates the uuid via its internal set).
|
||||
states = get_project_states(pid)
|
||||
to_analyse = states["to_analyse"]
|
||||
in_progress = states["in_progress"]
|
||||
approved = states["approved"]
|
||||
rejected = states["rejected"]
|
||||
issues = list_issues_by_state(pid, [to_analyse, approved, rejected])
|
||||
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
|
||||
for issue in issues:
|
||||
try:
|
||||
self._reconcile_plane_issue(
|
||||
issue, pid, to_analyse, approved, rejected
|
||||
issue, pid, in_progress, approved, rejected
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - isolate one issue's failure
|
||||
logger.error(
|
||||
@@ -287,7 +258,7 @@ class Reconciler:
|
||||
|
||||
def _reconcile_plane_issue(
|
||||
self, issue: dict, project_id: str,
|
||||
to_analyse: str, approved: str, rejected: str,
|
||||
in_progress: str, approved: str, rejected: str,
|
||||
) -> None:
|
||||
issue_id = str(issue.get("id") or "")
|
||||
if not issue_id:
|
||||
@@ -317,16 +288,10 @@ class Reconciler:
|
||||
"description_stripped": issue.get("description_stripped", ""),
|
||||
}
|
||||
|
||||
if new_state == to_analyse and task is None:
|
||||
# To Analyse without a task -> start the pipeline (lost start webhook).
|
||||
if new_state == in_progress and task is None:
|
||||
# In Progress without a task -> start the pipeline (lost start webhook).
|
||||
self._dispatch(handle_status_start, issue_data, project_id)
|
||||
self._note_unblock(issue_id, "analysis")
|
||||
elif new_state == to_analyse and task is not None:
|
||||
# To Analyse with an existing (idle) task -> resume the analyst from
|
||||
# Needs Input (lost resume webhook). handle_status_start applies its
|
||||
# own busy-guard / start-vs-resume fork.
|
||||
self._dispatch(handle_status_start, issue_data, project_id)
|
||||
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
|
||||
elif new_state == approved and task is not None:
|
||||
# Approved but the stage never advanced -> replay the verdict.
|
||||
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
|
||||
|
||||
689
src/security_gate.py
Normal file
689
src/security_gate.py
Normal file
@@ -0,0 +1,689 @@
|
||||
"""Security-gate core (ORCH-022): secret-scanning + dependency audit before merge.
|
||||
|
||||
Background
|
||||
----------
|
||||
The orchestrator is autonomous: the ``developer`` agent writes code with no human
|
||||
filter. Before a task branch merges into ``main`` there was no automatic check for a
|
||||
leaked secret (key / token / password / private key) or a vulnerable dependency
|
||||
(known CVE). For the self-hosting ``orchestrator`` repo this is acute: one shared
|
||||
prod instance serves every project from a shared DB, so a secret or CVE that slips
|
||||
through one task lands in the prod of all projects (CLAUDE.md §self-hosting, §8).
|
||||
|
||||
This module provides the deterministic (no-LLM) primitives that the quality-gate
|
||||
``check_security_gate`` (src/qg/checks.py) composes on the ``deploy-staging ->
|
||||
deploy`` edge, **FIRST** among the edge sub-gates (BEFORE the merge-gate and
|
||||
image-freshness), immediately before the deployer merges the PR (ADR-001 Р-1):
|
||||
|
||||
* ``scan_secrets`` -> run ``gitleaks`` over ``origin/main..HEAD`` (offline).
|
||||
* ``audit_dependencies`` -> run ``pip-audit`` over ``requirements.txt`` (OSV/PyPI).
|
||||
* ``classify_severity`` -> pure: map a CVE severity to block / warning.
|
||||
* ``compute_verdict`` -> pure: combine findings + thresholds -> the artefact
|
||||
frontmatter fields + a human-readable reason.
|
||||
* ``write_security_report`` / ``parse_security_status`` -> write the
|
||||
``17-security-report.md`` artefact and read its machine verdict back (single
|
||||
source of truth: the gate returns exactly the frontmatter it wrote, AC-8).
|
||||
* ``check_security_gate`` -> the orchestrating entry the QG wrapper delegates to.
|
||||
|
||||
Invariants (ADR-001 §7, never broken):
|
||||
* **Secrets are unconditional** (BR-2): gitleaks is fully offline, so the "a
|
||||
secret always blocks" guarantee does not depend on the network. A secret-scan
|
||||
TOOL error is **fail-closed** (we cannot prove "no secret" -> FAIL).
|
||||
* **Dependency audit is best-effort** (Р-3): an unreachable CVE feed degrades
|
||||
**fail-open + a loud warning** by default (anti-loop, precedent ORCH-061);
|
||||
``security_dep_audit_fail_closed`` flips it to strict.
|
||||
* **never-raise**: any internal error -> ``(False, "<reason>")``; an exception
|
||||
never escapes into ``advance_stage`` (AC-16).
|
||||
* **Self-hosting safety** (AC-19): the gate only reads / scans / writes the
|
||||
artefact. It never calls the deploy hook and never restarts the prod container.
|
||||
|
||||
This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily
|
||||
``qg.checks.is_self_hosting_repo`` / ``notifications``; it never imports
|
||||
``stage_engine``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import settings
|
||||
from .git_worktree import ensure_worktree, get_worktree_path
|
||||
|
||||
logger = logging.getLogger("orchestrator.security_gate")
|
||||
|
||||
# Bounded git timeout so a hung fetch never wedges the monitor-thread running the
|
||||
# gate (the scan timeout itself comes from settings.security_scan_timeout_s).
|
||||
_GIT_TIMEOUT = 60
|
||||
|
||||
# Severity ranking for the dependency block threshold. UNKNOWN / unrecognised is
|
||||
# intentionally absent -> classified as "warning" (anti-loop, ADR-001 Р-4).
|
||||
_SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result containers (plain dataclasses, easy to build in tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class SecretScanResult:
|
||||
"""Outcome of :func:`scan_secrets`.
|
||||
|
||||
status:
|
||||
* ``"clean"`` -> no secret found.
|
||||
* ``"found"`` -> ``findings`` lists the confirmed (non-allowlisted) secrets.
|
||||
* ``"error"`` -> the scanner could not run (missing binary / timeout / rc>=2);
|
||||
treated as **fail-closed** by :func:`compute_verdict` (BR-2).
|
||||
"""
|
||||
|
||||
status: str = "clean"
|
||||
findings: list = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepAuditResult:
|
||||
"""Outcome of :func:`audit_dependencies`.
|
||||
|
||||
status:
|
||||
* ``"ok"`` -> the audit ran; ``findings`` may be empty or non-empty.
|
||||
* ``"degraded"`` -> the CVE feed was unreachable / the tool failed; **fail-open**
|
||||
by default (ADR-001 Р-3), surfaced as ``deps_audit_degraded: true``.
|
||||
"""
|
||||
|
||||
status: str = "ok"
|
||||
findings: list = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors _merge_gate_applies / image_freshness_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def security_gate_applies(repo: str) -> bool:
|
||||
"""Whether the security-gate is REAL for this repo (conditional rollout).
|
||||
|
||||
Mirrors the ORCH-35 / ORCH-43 / ORCH-58 pattern:
|
||||
* ``security_gate_enabled=False`` -> always False (kill-switch; pipeline is
|
||||
1:1 as before ORCH-022 for everyone).
|
||||
* ``security_gate_repos`` (CSV) non-empty -> real only for the listed repos.
|
||||
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
|
||||
Never raises (AC-16): any error -> False (the safe no-op default).
|
||||
"""
|
||||
try:
|
||||
if not settings.security_gate_enabled:
|
||||
return False
|
||||
raw = (settings.security_gate_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 (no qg import at module load).
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
return is_self_hosting_repo(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("security_gate_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret-scanning (gitleaks, offline) — FR-1 / AC-1..AC-3
|
||||
# ---------------------------------------------------------------------------
|
||||
def _gitleaks_config_path(worktree: str) -> str | None:
|
||||
"""Versioned ``.gitleaks.toml`` at the repo root (BR-13), or None if absent."""
|
||||
cfg = os.path.join(worktree, ".gitleaks.toml")
|
||||
return cfg if os.path.isfile(cfg) else None
|
||||
|
||||
|
||||
def _mask(secret: str) -> str:
|
||||
"""Mask a matched secret so the artefact never re-leaks it verbatim."""
|
||||
s = (secret or "").strip()
|
||||
if len(s) <= 8:
|
||||
return "****"
|
||||
return f"{s[:4]}…{s[-2:]}"
|
||||
|
||||
|
||||
def parse_gitleaks_report(text: str) -> list:
|
||||
"""Pure parser for the gitleaks JSON report -> a list of finding dicts.
|
||||
|
||||
Each finding: ``{"file", "rule", "line", "match"}`` (the match is MASKED).
|
||||
Tolerates an empty / non-JSON / non-list body (returns ``[]``); never raises.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text or "[]")
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"file": item.get("File") or item.get("file") or "?",
|
||||
"rule": item.get("RuleID") or item.get("Description") or "secret",
|
||||
"line": item.get("StartLine") or item.get("startLine") or 0,
|
||||
"match": _mask(item.get("Secret") or item.get("Match") or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def scan_secrets(repo: str, branch: str) -> SecretScanResult:
|
||||
"""Scan ``origin/main..HEAD`` of the task branch for secrets with ``gitleaks``.
|
||||
|
||||
Offline (BR-2): gitleaks rules are local, so the "a secret always blocks"
|
||||
guarantee never depends on the network. Scanning the ``origin/main..HEAD``
|
||||
range covers exactly the commits this task adds (and that will land in
|
||||
``main``), and — because it runs BEFORE the merge-gate rebase — does not blame
|
||||
the task for a secret introduced by a parallel update of ``main`` (ADR-001 Р-1).
|
||||
|
||||
Exit-code contract (07-infra-requirements.md I-1): 0 = clean, 1 = secrets
|
||||
found, >=2 = tool error. A tool error / missing binary / timeout -> ``"error"``
|
||||
(fail-closed downstream). Never raises (AC-16).
|
||||
"""
|
||||
try:
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
return SecretScanResult(status="error", detail=f"worktree error: {e}")
|
||||
|
||||
# Refresh origin/main so the origin/main..HEAD range is meaningful. Best-effort:
|
||||
# a fetch failure does not abort the scan (gitleaks still scans whatever range
|
||||
# it can resolve); the scan itself is the security-critical step.
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", wt, "fetch", "origin", "main"],
|
||||
capture_output=True, timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("scan_secrets: fetch origin/main failed for %s/%s: %s", repo, branch, e)
|
||||
|
||||
report_path = os.path.join(wt, ".gitleaks-report.json")
|
||||
cmd = [
|
||||
"gitleaks", "detect",
|
||||
"--source", wt,
|
||||
"--log-opts", "origin/main..HEAD",
|
||||
"--report-format", "json",
|
||||
"--report-path", report_path,
|
||||
"--exit-code", "1",
|
||||
"--no-banner",
|
||||
]
|
||||
cfg = _gitleaks_config_path(wt)
|
||||
if cfg:
|
||||
cmd += ["--config", cfg]
|
||||
|
||||
timeout = settings.security_scan_timeout_s
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return SecretScanResult(status="error", detail=f"gitleaks timeout after {timeout}s")
|
||||
except FileNotFoundError:
|
||||
# Missing binary -> fail-closed (we cannot prove the branch is secret-free).
|
||||
return SecretScanResult(status="error", detail="gitleaks binary not found")
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return SecretScanResult(status="error", detail=f"gitleaks error: {e}")
|
||||
finally:
|
||||
# The report file is transient scratch inside the worktree; remove it after
|
||||
# reading so it is never committed/scanned on a later pass.
|
||||
report_text = ""
|
||||
try:
|
||||
if os.path.isfile(report_path):
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
report_text = f.read()
|
||||
os.remove(report_path)
|
||||
except OSError:
|
||||
report_text = ""
|
||||
|
||||
if r.returncode == 0:
|
||||
return SecretScanResult(status="clean", detail="no secrets found")
|
||||
if r.returncode == 1:
|
||||
findings = parse_gitleaks_report(report_text) or parse_gitleaks_report(r.stdout)
|
||||
if not findings:
|
||||
# rc=1 with no parseable findings -> still treat as found (fail-closed).
|
||||
findings = [{"file": "?", "rule": "secret", "line": 0, "match": "****"}]
|
||||
return SecretScanResult(
|
||||
status="found", findings=findings, detail=f"{len(findings)} secret(s) found"
|
||||
)
|
||||
# rc >= 2 (or any other) -> tool error -> fail-closed.
|
||||
tail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:]
|
||||
return SecretScanResult(status="error", detail=f"gitleaks rc={r.returncode}: {tail}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency audit (pip-audit, OSV/PyPI) — FR-2 / AC-4..AC-7
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_pip_audit_report(text: str) -> list:
|
||||
"""Pure parser for the ``pip-audit -f json`` report -> a list of finding dicts.
|
||||
|
||||
Each finding: ``{"package", "version", "id", "severity", "fix"}``. pip-audit's
|
||||
default JSON rarely carries a CVSS severity (OSV advisories often omit it), so a
|
||||
missing severity is reported as ``"UNKNOWN"`` (classified as a warning, never an
|
||||
auto-block — ADR-001 Р-4 anti-loop). Tolerates both the modern
|
||||
``{"dependencies": [...]}`` shape and a bare list; never raises.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if isinstance(data, dict):
|
||||
deps = data.get("dependencies", data.get("vulnerabilities", []))
|
||||
elif isinstance(data, list):
|
||||
deps = data
|
||||
else:
|
||||
return []
|
||||
out = []
|
||||
for dep in deps or []:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
name = dep.get("name") or dep.get("package") or "?"
|
||||
version = dep.get("version") or "?"
|
||||
for v in dep.get("vulns", dep.get("vulnerabilities", [])) or []:
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
sev = _extract_severity(v)
|
||||
fix = v.get("fix_versions") or v.get("fixed_in") or []
|
||||
aliases = v.get("aliases") or []
|
||||
vuln_id = v.get("id") or (aliases[0] if aliases else "?")
|
||||
out.append(
|
||||
{
|
||||
"package": name,
|
||||
"version": version,
|
||||
"id": vuln_id,
|
||||
"severity": sev,
|
||||
"fix": ", ".join(fix) if isinstance(fix, list) else str(fix),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_severity(vuln: dict) -> str:
|
||||
"""Best-effort severity extraction from a pip-audit vuln record -> UPPER token.
|
||||
|
||||
pip-audit JSON may carry severity in different shapes depending on the advisory
|
||||
source; when none is present we return ``"UNKNOWN"`` (warning, never a block).
|
||||
"""
|
||||
raw = vuln.get("severity")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip().upper()
|
||||
if isinstance(raw, list) and raw:
|
||||
first = raw[0]
|
||||
if isinstance(first, dict):
|
||||
val = first.get("severity") or first.get("score") or first.get("type")
|
||||
if val:
|
||||
return str(val).strip().upper()
|
||||
elif first:
|
||||
return str(first).strip().upper()
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def audit_dependencies(repo: str, branch: str) -> DepAuditResult:
|
||||
"""Audit the branch's ``requirements.txt`` for known CVEs with ``pip-audit``.
|
||||
|
||||
The advisory source is OSV/PyPI -> it needs the network. Per ADR-001 Р-3 an
|
||||
unreachable feed / tool failure degrades **fail-open** by default (status
|
||||
``"degraded"``), so a transient network problem on the prod instance never
|
||||
produces a false rollback loop (precedent ORCH-061). The ``"degraded"`` state
|
||||
is surfaced loudly (``deps_audit_degraded: true`` + warning log + Telegram).
|
||||
|
||||
Returns a :class:`DepAuditResult`. Never raises (AC-16).
|
||||
"""
|
||||
try:
|
||||
wt = get_worktree_path(repo, branch)
|
||||
if not os.path.isdir(wt):
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
return DepAuditResult(status="degraded", detail=f"worktree error: {e}")
|
||||
|
||||
req = os.path.join(wt, "requirements.txt")
|
||||
if not os.path.isfile(req):
|
||||
# Python-only v1 (A3): no manifest -> nothing to audit (not a degrade).
|
||||
return DepAuditResult(status="ok", detail="no requirements.txt to audit")
|
||||
|
||||
cmd = ["pip-audit", "-r", req, "-f", "json", "--progress-spinner", "off"]
|
||||
timeout = settings.security_scan_timeout_s
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit timeout after {timeout}s")
|
||||
except FileNotFoundError:
|
||||
# Missing binary -> degrade (dep-audit is best-effort, not unconditional).
|
||||
return DepAuditResult(status="degraded", detail="pip-audit binary not found")
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit error: {e}")
|
||||
|
||||
# pip-audit exits 0 (no vulns) or 1 (vulns found) with valid JSON on stdout. A
|
||||
# network/feed error produces non-JSON output (and often a non-zero rc) -> if
|
||||
# we cannot parse the JSON we degrade fail-open rather than block falsely.
|
||||
out = (r.stdout or "").strip()
|
||||
if not out:
|
||||
if r.returncode == 0:
|
||||
return DepAuditResult(status="ok", detail="no vulnerabilities")
|
||||
tail = (r.stderr or "").strip()[-200:]
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit no output (rc={r.returncode}): {tail}")
|
||||
try:
|
||||
json.loads(out)
|
||||
except ValueError:
|
||||
tail = (r.stderr or "").strip()[-200:]
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit feed unavailable: {tail}")
|
||||
|
||||
findings = parse_pip_audit_report(out)
|
||||
return DepAuditResult(status="ok", findings=findings, detail=f"{len(findings)} vuln(s)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure classification + verdict (FR-2/FR-3/Р-4) — the core of the unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def classify_severity(severity: str, block_threshold: str) -> str:
|
||||
"""Pure: classify a CVE severity against the block threshold -> token.
|
||||
|
||||
Returns ``"block"`` when ``severity >= block_threshold`` in CRITICAL > HIGH >
|
||||
MEDIUM > LOW order, else ``"warning"``. An UNKNOWN / unrecognised severity is
|
||||
ALWAYS ``"warning"`` (never an auto-block — anti-loop, ADR-001 Р-4). Never
|
||||
raises.
|
||||
"""
|
||||
sev = (severity or "").upper().strip()
|
||||
thr = (block_threshold or "HIGH").upper().strip()
|
||||
sev_rank = _SEVERITY_ORDER.get(sev)
|
||||
thr_rank = _SEVERITY_ORDER.get(thr, _SEVERITY_ORDER["HIGH"])
|
||||
if sev_rank is None:
|
||||
return "warning"
|
||||
return "block" if sev_rank >= thr_rank else "warning"
|
||||
|
||||
|
||||
def compute_verdict(
|
||||
secret_result: SecretScanResult,
|
||||
dep_result: DepAuditResult,
|
||||
*,
|
||||
secrets_block: bool,
|
||||
dep_block_severity: str,
|
||||
dep_fail_closed: bool,
|
||||
) -> dict:
|
||||
"""Pure: combine scan results + thresholds into the artefact's machine fields.
|
||||
|
||||
Returns a dict with the frontmatter fields (``security_status``,
|
||||
``secrets_found``, ``deps_blocking``, ``deps_warning``, ``deps_audit_degraded``),
|
||||
a one-line ``reason`` summary, and the categorised finding lists for the body.
|
||||
|
||||
Decision (ADR-001 Р-4):
|
||||
* secret-scan ERROR -> FAIL (fail-closed; BR-2 secrets guarantee is unconditional).
|
||||
* any secret found AND ``secrets_block`` -> FAIL.
|
||||
* any dependency at/over ``dep_block_severity`` -> FAIL (``deps_blocking``).
|
||||
* MEDIUM/LOW/UNKNOWN deps -> warning only (``deps_warning``), never block.
|
||||
* feed degraded -> warning by default; FAIL only when ``dep_fail_closed``.
|
||||
Never raises.
|
||||
"""
|
||||
secret_scan_error = secret_result.status == "error"
|
||||
secret_findings = list(secret_result.findings) if secret_result.status == "found" else []
|
||||
secrets_found = len(secret_findings)
|
||||
|
||||
deps_audit_degraded = dep_result.status == "degraded"
|
||||
blocking_findings = []
|
||||
warning_findings = []
|
||||
for f in dep_result.findings or []:
|
||||
if classify_severity(f.get("severity", "UNKNOWN"), dep_block_severity) == "block":
|
||||
blocking_findings.append(f)
|
||||
else:
|
||||
warning_findings.append(f)
|
||||
|
||||
reasons = []
|
||||
fail = False
|
||||
if secret_scan_error:
|
||||
fail = True
|
||||
reasons.append(f"secret scan error (fail-closed): {secret_result.detail}")
|
||||
if secrets_block and secrets_found > 0:
|
||||
fail = True
|
||||
names = ", ".join(
|
||||
f"{x.get('rule')} in {x.get('file')}:{x.get('line')}" for x in secret_findings
|
||||
)
|
||||
reasons.append(f"{secrets_found} secret(s): {names}")
|
||||
if blocking_findings:
|
||||
fail = True
|
||||
names = ", ".join(
|
||||
f"{x.get('package')} {x.get('version')} {x.get('id')} ({x.get('severity')})"
|
||||
for x in blocking_findings
|
||||
)
|
||||
reasons.append(f"{len(blocking_findings)} blocking CVE(s): {names}")
|
||||
if deps_audit_degraded and dep_fail_closed:
|
||||
fail = True
|
||||
reasons.append(f"dep-audit feed unavailable (fail-closed): {dep_result.detail}")
|
||||
|
||||
status = "FAIL" if fail else "PASS"
|
||||
if reasons:
|
||||
reason = "; ".join(reasons)
|
||||
else:
|
||||
extra = " (dep-audit degraded — warning only)" if deps_audit_degraded else ""
|
||||
reason = f"clean: {secrets_found} secrets, {len(blocking_findings)} blocking CVE(s){extra}"
|
||||
|
||||
return {
|
||||
"security_status": status,
|
||||
"secrets_found": secrets_found,
|
||||
"secret_scan_error": secret_scan_error,
|
||||
"deps_blocking": len(blocking_findings),
|
||||
"deps_warning": len(warning_findings),
|
||||
"deps_audit_degraded": deps_audit_degraded,
|
||||
"reason": reason,
|
||||
"secret_findings": secret_findings,
|
||||
"blocking_findings": blocking_findings,
|
||||
"warning_findings": warning_findings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artefact: write the report, read the machine verdict back (FR-3 / AC-8..AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _report_rel(work_item_id: str) -> str:
|
||||
return f"docs/work-items/{work_item_id}/17-security-report.md"
|
||||
|
||||
|
||||
def _report_path(repo: str, work_item_id: str, branch: str) -> str:
|
||||
"""Absolute path of 17-security-report.md inside the task worktree."""
|
||||
try:
|
||||
wt = get_worktree_path(repo, branch)
|
||||
if not os.path.isdir(wt):
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception: # noqa: BLE001 - never-raise; fall back to shared clone
|
||||
wt = os.path.join(settings.repos_dir, repo)
|
||||
return os.path.join(wt, _report_rel(work_item_id))
|
||||
|
||||
|
||||
def _bool_yaml(v: bool) -> str:
|
||||
return "true" if v else "false"
|
||||
|
||||
|
||||
def render_security_report(work_item_id: str, fields: dict) -> str:
|
||||
"""Pure: render the 17-security-report.md content (frontmatter + body) from the
|
||||
fields produced by :func:`compute_verdict`. Never raises."""
|
||||
def _secret_lines():
|
||||
items = fields.get("secret_findings") or []
|
||||
if not items:
|
||||
return "- None"
|
||||
return "\n".join(
|
||||
f"- `{x.get('file')}:{x.get('line')}` — {x.get('rule')} (match `{x.get('match')}`)"
|
||||
for x in items
|
||||
)
|
||||
|
||||
def _dep_lines(key):
|
||||
items = fields.get(key) or []
|
||||
if not items:
|
||||
return "- None"
|
||||
return "\n".join(
|
||||
f"- `{x.get('package')}=={x.get('version')}` — {x.get('id')} "
|
||||
f"severity={x.get('severity')} fix={x.get('fix') or 'n/a'}"
|
||||
for x in items
|
||||
)
|
||||
|
||||
return (
|
||||
"---\n"
|
||||
f"security_status: {fields.get('security_status', 'FAIL')}\n"
|
||||
f"secrets_found: {int(fields.get('secrets_found', 0))}\n"
|
||||
f"deps_blocking: {int(fields.get('deps_blocking', 0))}\n"
|
||||
f"deps_warning: {int(fields.get('deps_warning', 0))}\n"
|
||||
f"deps_audit_degraded: {_bool_yaml(bool(fields.get('deps_audit_degraded', False)))}\n"
|
||||
"---\n"
|
||||
f"# Security Report — {work_item_id}\n\n"
|
||||
"Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + "
|
||||
"dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.\n\n"
|
||||
"## Verdict\n"
|
||||
f"{fields.get('reason', '')}\n\n"
|
||||
"## Secrets\n"
|
||||
f"{_secret_lines()}\n\n"
|
||||
"## Dependencies (blocking)\n"
|
||||
f"{_dep_lines('blocking_findings')}\n\n"
|
||||
"## Dependencies (warning)\n"
|
||||
f"{_dep_lines('warning_findings')}\n"
|
||||
)
|
||||
|
||||
|
||||
def write_security_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str:
|
||||
"""Write 17-security-report.md into the task worktree; return its path.
|
||||
|
||||
Best-effort/never-raise: a write error is logged and the path is still returned
|
||||
(the caller's read-back then fails closed). The artefact body is human-readable;
|
||||
the machine verdict lives ONLY in the YAML frontmatter (canon)."""
|
||||
path = _report_path(repo, work_item_id, branch)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(render_security_report(work_item_id, fields))
|
||||
except OSError as e:
|
||||
logger.error("write_security_report error for %s/%s: %s", repo, work_item_id, e)
|
||||
return path
|
||||
|
||||
|
||||
def parse_security_status(content: str) -> tuple[bool, str]:
|
||||
"""Map a 17-security-report.md body to a quality-gate verdict by reading ONLY
|
||||
the machine-readable ``security_status:`` YAML frontmatter — never the prose.
|
||||
|
||||
Mirrors ``_parse_deploy_status`` / ``_parse_staging_status`` (canon: machine
|
||||
verdict only from frontmatter, AC-8). The negative token (FAIL) is authoritative
|
||||
(checked first). Returns:
|
||||
* ``security_status: PASS`` -> ``(True, "Security status: PASS")``
|
||||
* ``security_status: FAIL`` -> ``(False, "Security status: FAIL")``
|
||||
* missing field / no frontmatter / bad YAML -> ``(False, <reason>)`` (fail-closed
|
||||
on the verdict read, AC-9).
|
||||
"""
|
||||
import yaml
|
||||
|
||||
status = None
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML frontmatter in security report: {e}"
|
||||
if isinstance(fm, dict):
|
||||
status = str(fm.get("security_status", "")).upper().strip()
|
||||
if status == "FAIL":
|
||||
return False, "Security status: FAIL"
|
||||
if status == "PASS":
|
||||
return True, "Security status: PASS"
|
||||
return False, f"No machine-readable security_status in frontmatter (got: {status!r})"
|
||||
|
||||
|
||||
def extract_security_findings(report_path: str) -> str:
|
||||
"""ORCH-046: best-effort verbatim excerpt of the report's finding sections for
|
||||
embedding into the developer's ``task_desc`` on a rollback.
|
||||
|
||||
Pulls the ``## Verdict`` + ``## Secrets`` + ``## Dependencies (blocking)``
|
||||
sections so the developer sees the must-fix substance directly (not just a
|
||||
link). Contract «never raise»: any error / missing file -> ``""`` (the caller
|
||||
then falls back to the reason + link). Mirrors ``review_parse`` defensiveness.
|
||||
"""
|
||||
try:
|
||||
if not os.path.isfile(report_path):
|
||||
return ""
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Drop the frontmatter; keep the human body.
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
content = parts[2]
|
||||
wanted = ("## Verdict", "## Secrets", "## Dependencies (blocking)")
|
||||
lines = content.splitlines()
|
||||
out = []
|
||||
keep = False
|
||||
for ln in lines:
|
||||
if ln.startswith("## "):
|
||||
keep = any(ln.startswith(w) for w in wanted)
|
||||
if keep:
|
||||
out.append(ln)
|
||||
excerpt = "\n".join(out).strip()
|
||||
return excerpt[:1500]
|
||||
except Exception as e: # noqa: BLE001 - never-raise (ORCH-046 defensive)
|
||||
logger.warning("extract_security_findings error for %s: %s", report_path, e)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrating entry — delegated to by qg.checks.check_security_gate
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
|
||||
"""ORCH-022 security-gate on the deploy-staging -> deploy edge, run FIRST.
|
||||
|
||||
Deterministic, no LLM. Algorithm (ADR-001 Р-1/Р-5):
|
||||
1. Conditionality: ``security_gate_enabled=False`` -> ``(True, "...disabled")``;
|
||||
a repo the gate is not real for -> ``(True, "security-gate N/A for <repo>")``.
|
||||
2. ``scan_secrets`` (offline) + ``audit_dependencies`` (best-effort).
|
||||
3. ``compute_verdict`` -> write ``17-security-report.md`` -> read the verdict
|
||||
BACK via ``parse_security_status`` (single source of truth: the returned
|
||||
verdict == the artefact frontmatter, AC-8).
|
||||
4. FAIL -> ``(False, reason)`` (engine rolls back to ``development``); PASS ->
|
||||
``(True, reason)`` (engine proceeds to the merge-gate).
|
||||
|
||||
A degraded dep-audit on a PASS is surfaced loudly (Telegram + log) without
|
||||
failing the gate (ADR-001 Р-3). Never-raise (AC-16): any internal error ->
|
||||
``(False, "<reason>")``; an exception never escapes into ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
if not settings.security_gate_enabled:
|
||||
return True, "security-gate disabled"
|
||||
if not security_gate_applies(repo):
|
||||
return True, f"security-gate N/A for {repo}"
|
||||
|
||||
secret_result = scan_secrets(repo, branch)
|
||||
dep_result = audit_dependencies(repo, branch)
|
||||
fields = compute_verdict(
|
||||
secret_result,
|
||||
dep_result,
|
||||
secrets_block=settings.security_secrets_block,
|
||||
dep_block_severity=settings.security_dep_block_severity,
|
||||
dep_fail_closed=settings.security_dep_audit_fail_closed,
|
||||
)
|
||||
|
||||
path = write_security_report(repo, work_item_id, branch, fields)
|
||||
|
||||
# Read the machine verdict back from the artefact we just wrote — so the
|
||||
# returned (bool, reason) is guaranteed == the YAML frontmatter (AC-8).
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
return False, f"cannot read security report (fail-closed): {e}"
|
||||
ok, _verdict = parse_security_status(content)
|
||||
|
||||
# Surface a degraded dep-audit loudly even when the gate passes (Р-3 / BR-11).
|
||||
if fields.get("deps_audit_degraded"):
|
||||
logger.warning(
|
||||
"security-gate %s/%s: dep-audit DEGRADED (fail-%s): %s",
|
||||
repo, work_item_id,
|
||||
"closed" if settings.security_dep_audit_fail_closed else "open",
|
||||
dep_result.detail,
|
||||
)
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"⚠️ {work_item_id}: dep-audit недоступен фид CVE "
|
||||
f"({dep_result.detail}). "
|
||||
+ ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed
|
||||
else "Гейт fail-open → warning (секреты проверены оффлайн).")
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - telegram best-effort
|
||||
logger.warning("security-gate degraded telegram failed: %s", e)
|
||||
|
||||
if ok:
|
||||
logger.info("security-gate passed for %s/%s: %s", repo, work_item_id, fields["reason"])
|
||||
return True, f"security clean ({fields['reason']})"
|
||||
return False, fields["reason"]
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract (AC-16)
|
||||
logger.error("check_security_gate error for %s/%s: %s", repo, branch, e)
|
||||
return False, f"security-gate error: {e}"
|
||||
@@ -34,6 +34,7 @@ from .db import get_db, update_task_stage, enqueue_job
|
||||
from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
|
||||
from .git_worktree import get_worktree_path
|
||||
from .review_parse import extract_review_findings, extract_test_failures
|
||||
from .security_gate import extract_security_findings
|
||||
from .qg.checks import QG_CHECKS
|
||||
from . import merge_gate
|
||||
from . import self_deploy
|
||||
@@ -53,10 +54,6 @@ from .plane_sync import (
|
||||
set_issue_in_progress,
|
||||
set_issue_blocked,
|
||||
set_issue_done,
|
||||
set_issue_analysis,
|
||||
set_issue_awaiting_deploy,
|
||||
set_issue_deploying,
|
||||
set_issue_monitoring,
|
||||
)
|
||||
from .config import settings
|
||||
|
||||
@@ -281,6 +278,18 @@ def advance_stage(
|
||||
# event. If it intervenes (defer on busy-lock, or rollback on conflict /
|
||||
# red re-test) it owns the outcome and we return without advancing.
|
||||
if current_stage == "deploy-staging":
|
||||
# --- ORCH-022 security sub-gate (deploy-staging -> deploy edge) -----
|
||||
# Run FIRST among the edge sub-gates (BEFORE the merge-gate and the
|
||||
# image-freshness rebuild): it is cheap (read-only scan) and we want to
|
||||
# fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic:
|
||||
# gitleaks (offline secret-scan) + pip-audit (CVE audit). FAIL -> rollback
|
||||
# to development + developer-retry (cap MAX_DEVELOPER_RETRIES). It owns
|
||||
# the outcome on intervention (mirrors the merge-gate / image-freshness).
|
||||
if _handle_security_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result
|
||||
):
|
||||
return result
|
||||
|
||||
if _handle_merge_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result
|
||||
):
|
||||
@@ -339,28 +348,14 @@ def advance_stage(
|
||||
# here, so explicitly drive the Plane issue into the terminal Done state
|
||||
# (PLANE_STATES['done'] — mapping unchanged) in addition to the
|
||||
# stage-change comment above.
|
||||
# ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy
|
||||
# monitoring applies. For self-hosting (post_deploy_applies==True) the
|
||||
# task enters a `Monitoring after Deploy` window, NOT terminal Done yet —
|
||||
# the monitor finalises Done/Blocked (run_post_deploy_monitor). For
|
||||
# non-self repos the behaviour is unchanged: terminal Done immediately.
|
||||
# Where the `Monitoring after Deploy` status is absent, set_issue_monitoring
|
||||
# degrades to the project's Done UUID -> identical to today.
|
||||
if next_stage == "done" and work_item_id:
|
||||
try:
|
||||
if post_deploy.post_deploy_applies(repo):
|
||||
set_issue_monitoring(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done (self), Plane state -> "
|
||||
f"Monitoring after Deploy (post-deploy window)"
|
||||
)
|
||||
else:
|
||||
set_issue_done(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done, Plane state forced to Done"
|
||||
)
|
||||
set_issue_done(work_item_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy->done, Plane state forced to Done"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}")
|
||||
logger.error(f"Task {task_id}: failed to set Plane Done: {e}")
|
||||
|
||||
# ORCH-043: the merge has landed (deploy->done). Release the merge lease as
|
||||
# a backstop in case the PR-merged webhook was lost (holder-aware no-op if a
|
||||
@@ -684,9 +679,7 @@ def _handle_qg_failure_rollbacks(
|
||||
notify_stage_change(task_id, current_stage, "analysis")
|
||||
plane_notify_stage(work_item_id, current_stage, "analysis")
|
||||
result.rolled_back_to = "analysis"
|
||||
# ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis`
|
||||
# (degrades to In Progress where the status is not created).
|
||||
set_issue_analysis(work_item_id)
|
||||
set_issue_in_progress(work_item_id)
|
||||
with open(conflict_path, "r") as cf:
|
||||
conflict_text = cf.read()[:500]
|
||||
plane_add_comment(
|
||||
@@ -931,6 +924,93 @@ def _handle_merge_gate_rollback(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-022: security sub-gate (secret-scan + dependency audit) on the
|
||||
# deploy-staging -> deploy edge
|
||||
# ---------------------------------------------------------------------------
|
||||
def _handle_security_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""Run check_security_gate on the deploy-staging -> deploy edge (ORCH-022).
|
||||
|
||||
Runs FIRST among the edge sub-gates — BEFORE the merge-gate and the
|
||||
image-freshness rebuild — because it is a cheap read-only scan and we want to
|
||||
fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic (no LLM):
|
||||
gitleaks (offline secret-scan, fail-closed) + pip-audit (CVE audit, fail-open
|
||||
degrade). The machine verdict lives in 17-security-report.md frontmatter.
|
||||
|
||||
Returns True if the gate INTERVENED (the caller must return without advancing):
|
||||
* FAIL (secret found / blocking CVE / fail-closed) -> ROLLBACK to development
|
||||
(+ developer retry, capped by MAX_DEVELOPER_RETRIES). No merge-lease release
|
||||
here: the security-gate runs BEFORE the merge-gate, so the lease is not held
|
||||
yet (distinct from the image-freshness rollback). The verbatim findings are
|
||||
embedded into the developer's task_desc (ORCH-046 pattern, TC-17).
|
||||
Returns False when the gate PASSED (clean, or N/A for a non-self repo with an
|
||||
empty scope) so advance_stage proceeds to the merge-gate.
|
||||
"""
|
||||
passed, reason = _run_qg("check_security_gate", repo, work_item_id, branch)
|
||||
if passed:
|
||||
logger.info(f"Task {task_id}: security-gate passed ({reason})")
|
||||
return False
|
||||
|
||||
result.qg_name = "check_security_gate"
|
||||
result.qg_passed = False
|
||||
result.qg_reason = reason
|
||||
|
||||
update_task_stage(task_id, "development")
|
||||
notify_stage_change(task_id, current_stage, "development")
|
||||
plane_notify_stage(work_item_id, current_stage, "development")
|
||||
result.rolled_back_to = "development"
|
||||
set_issue_in_progress(work_item_id)
|
||||
notify_qg_failure(task_id, current_stage, "check_security_gate", reason)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"❌ Security-гейт провален ({reason}). Откат на development. "
|
||||
f"Developer нужен для фикса (секреты/уязвимые зависимости).",
|
||||
author="deployer",
|
||||
)
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
# ORCH-046: embed the verbatim findings into task_desc so the developer
|
||||
# agent sees the must-fix substance directly (not just a link).
|
||||
# extract_security_findings never raises; "" -> graceful link-only fallback.
|
||||
report_ref = f"docs/work-items/{work_item_id}/17-security-report.md"
|
||||
report_path = os.path.join(get_worktree_path(repo, branch), report_ref)
|
||||
findings = extract_security_findings(report_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Security-гейт провален "
|
||||
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
|
||||
f"Причина: {reason}."
|
||||
)
|
||||
if findings:
|
||||
task_desc = (
|
||||
f"{head}\nFindings:\n{findings}\n"
|
||||
f"Полный контекст: {report_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix findings in {report_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
logger.info(
|
||||
f"Task {task_id}: security-gate FAILED, enqueued developer (job_id={new_job})"
|
||||
)
|
||||
else:
|
||||
set_issue_blocked(work_item_id)
|
||||
send_telegram(
|
||||
f"\U0001f6a8 {work_item_id}: Security-гейт still failing after "
|
||||
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
|
||||
f"Manual intervention needed."
|
||||
)
|
||||
result.alerted = True
|
||||
logger.error(
|
||||
f"Task {task_id}: security-gate FAILED, rolled back deploy-staging -> "
|
||||
f"development ({reason})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1029,11 +1109,7 @@ def _handle_self_deploy_phase_a(
|
||||
result.note = "self-deploy-approval-pending"
|
||||
|
||||
if work_item_id:
|
||||
# ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`,
|
||||
# which discharges `In Review` of the deploy-approval meaning (In Review
|
||||
# stays for analyst BRD/review approve-pending only). Degrades to In Review
|
||||
# where the status is not created.
|
||||
set_issue_awaiting_deploy(work_item_id)
|
||||
set_issue_in_review(work_item_id)
|
||||
# ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before
|
||||
# arming a fresh approve. A prior FAILED pass clears on rollback, but clearing
|
||||
# here too guarantees the entry to every new prod-deploy pass starts clean
|
||||
@@ -1093,10 +1169,6 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
|
||||
self_deploy.write_marker(
|
||||
repo, work_item_id, self_deploy.INITIATED, content=str(time.time())
|
||||
)
|
||||
# ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying`
|
||||
# (degrades to In Progress where the status is not created).
|
||||
if work_item_id:
|
||||
set_issue_deploying(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
|
||||
@@ -1291,12 +1363,6 @@ def run_post_deploy_monitor(job: dict):
|
||||
settings.post_deploy_window_s, checks_total, checks_failed,
|
||||
)
|
||||
post_deploy.mark_done(repo, work_item_id)
|
||||
# ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done.
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_done(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}")
|
||||
_notify_post_deploy(
|
||||
work_item_id,
|
||||
f"✅ {work_item_id}: пост-деплой окно завершено чисто "
|
||||
@@ -1337,15 +1403,6 @@ def run_post_deploy_monitor(job: dict):
|
||||
f"self-hosting запрещён (BR-5).",
|
||||
)
|
||||
|
||||
# ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for
|
||||
# manual intervention. This is INDICATION ONLY — the tick NEVER restarts /
|
||||
# rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5).
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_blocked(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
|
||||
|
||||
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,
|
||||
|
||||
@@ -147,15 +147,10 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
||||
return
|
||||
|
||||
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
|
||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the
|
||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
||||
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
||||
# ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point),
|
||||
# which discharges `In Progress` of its overloaded "start the pipeline"
|
||||
# meaning. Fail-closed: on a project without the `To Analyse` status,
|
||||
# `to_analyse` aliases to the project's own `in_progress` UUID, so moving an
|
||||
# enduro issue to In Progress still triggers start/resume (AC-17).
|
||||
proj_states = get_project_states(project_id)
|
||||
if new_state == proj_states["to_analyse"]:
|
||||
if new_state == proj_states["in_progress"]:
|
||||
await handle_status_start(data, project_id)
|
||||
elif new_state == proj_states["approved"]:
|
||||
await handle_verdict(data, project_id, approved=True)
|
||||
@@ -240,14 +235,9 @@ async def handle_status_start(data: dict, project_id: str = ""):
|
||||
)
|
||||
job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id)
|
||||
logger.info(
|
||||
f"Task {task_id}: returned to To Analyse (Needs Input answered), "
|
||||
f"Task {task_id}: returned to In Progress (Needs Input answered), "
|
||||
f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})"
|
||||
)
|
||||
# ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is
|
||||
# re-indicated as `Analysis`; other stages keep their own indication.
|
||||
if current_stage == "analysis":
|
||||
from ..plane_sync import set_issue_analysis as _set_analysis
|
||||
_set_analysis(work_item_id)
|
||||
try:
|
||||
_add_comment(
|
||||
work_item_id,
|
||||
@@ -548,10 +538,6 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
)
|
||||
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
|
||||
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
|
||||
# ORCH-066 (AC-3): indicate the analysis stage with the dedicated
|
||||
# `Analysis` status (degrades to In Progress where it is not created).
|
||||
from ..plane_sync import set_issue_analysis as _set_analysis
|
||||
_set_analysis(work_item_id, plane_project_id)
|
||||
# Post start comment to Plane
|
||||
from ..plane_sync import add_comment as _add_comment
|
||||
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
|
||||
@@ -593,11 +579,9 @@ async def _rollback_stage(
|
||||
(via the existing rollback notify + an enqueue of the prev-stage agent).
|
||||
"""
|
||||
if current_stage == "analysis":
|
||||
# Already in analysis — just relaunch analyst with rejection reason.
|
||||
# ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the
|
||||
# status is not created).
|
||||
from ..plane_sync import set_issue_analysis
|
||||
set_issue_analysis(work_item_id)
|
||||
# Already in analysis — just relaunch analyst with rejection reason
|
||||
from ..plane_sync import set_issue_in_progress
|
||||
set_issue_in_progress(work_item_id)
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "
|
||||
|
||||
@@ -235,6 +235,7 @@ def test_tc19_qg_checks_registry_unchanged():
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable",
|
||||
"check_staging_image_fresh",
|
||||
"check_security_gate",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -104,6 +101,7 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -130,9 +128,6 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
|
||||
assert _jobs() == []
|
||||
# The restart-safe approve-requested marker was written.
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
|
||||
# ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`.
|
||||
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036")
|
||||
stage_engine.set_issue_in_review.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,8 +152,6 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
|
||||
# The finalizer was enqueued.
|
||||
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
|
||||
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
|
||||
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
|
||||
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036")
|
||||
|
||||
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
|
||||
res2 = advance_stage(
|
||||
|
||||
@@ -45,9 +45,6 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -109,56 +106,3 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
|
||||
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
|
||||
# No agent is launched leaving deploy (terminal).
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy,
|
||||
# NOT terminal Done. The post-deploy monitor finalises.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch):
|
||||
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
# post_deploy applies for the self-hosting repo with the monitor enabled.
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
|
||||
# arm_monitor is orthogonal; stub it so this test stays on the status contract.
|
||||
monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True))
|
||||
|
||||
task_id = _make_task("deploy")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
|
||||
)
|
||||
|
||||
assert _stage(task_id) == "done"
|
||||
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
|
||||
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036")
|
||||
stage_engine.set_issue_done.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_non_self_deploy_done_sets_done(monkeypatch):
|
||||
self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
|
||||
# Monitor enabled, but the empty CSV means it applies ONLY to the self repo;
|
||||
# a non-self repo therefore takes the unchanged terminal-Done path.
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
|
||||
|
||||
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042")
|
||||
stage_engine.run_deploy_finalizer(
|
||||
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"}
|
||||
)
|
||||
|
||||
assert _stage(task_id) == "done"
|
||||
stage_engine.set_issue_done.assert_called_once_with("ET-042")
|
||||
stage_engine.set_issue_monitoring.assert_not_called()
|
||||
|
||||
@@ -40,15 +40,11 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
# ORCH-066: To Analyse is the start trigger; with the status absent it
|
||||
# aliases to in_progress (the real get_project_states fallback).
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
},
|
||||
|
||||
@@ -460,59 +460,3 @@ def test_default_states_et_values():
|
||||
assert ps._DEFAULT_STATES[key] == expected, (
|
||||
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the
|
||||
# new statuses, get_project_states must use its OWN UUID, not the default alias.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc19_name_resolution_beats_alias():
|
||||
"""A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' /
|
||||
'Deploying' / 'Monitoring after Deploy' resolves each to its own project
|
||||
UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID."""
|
||||
import src.plane_sync as ps
|
||||
|
||||
new_uuids = {
|
||||
"Analysis": "11111111-0000-0000-0000-000000000001",
|
||||
"Code-Review": "11111111-0000-0000-0000-000000000002",
|
||||
"Awaiting Deploy": "11111111-0000-0000-0000-000000000003",
|
||||
"Deploying": "11111111-0000-0000-0000-000000000004",
|
||||
"Monitoring after Deploy": "11111111-0000-0000-0000-000000000005",
|
||||
"To Analyse": "11111111-0000-0000-0000-000000000006",
|
||||
}
|
||||
# Start from the full ORCH base set, then add the dedicated new statuses.
|
||||
results = _make_states_response(ORCH_STATES)["results"]
|
||||
results += [{"id": uid, "name": name} for name, uid in new_uuids.items()]
|
||||
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response({"results": results})
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
|
||||
# Each new key resolved to the project's OWN UUID, not the base-key alias.
|
||||
assert states["analysis"] == new_uuids["Analysis"]
|
||||
assert states["code_review"] == new_uuids["Code-Review"]
|
||||
assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"]
|
||||
assert states["deploying"] == new_uuids["Deploying"]
|
||||
assert states["monitoring"] == new_uuids["Monitoring after Deploy"]
|
||||
assert states["to_analyse"] == new_uuids["To Analyse"]
|
||||
# Sanity: they are NOT the aliased base UUIDs.
|
||||
assert states["analysis"] != states["in_progress"]
|
||||
assert states["code_review"] != states["review"]
|
||||
assert states["awaiting_deploy"] != states["in_review"]
|
||||
|
||||
|
||||
def test_orch066_tc19_missing_new_status_aliases_to_project_base():
|
||||
"""BR-12: a project WITHOUT the new statuses degrades each new key to its OWN
|
||||
base UUID (not a foreign enduro UUID) — keeping the PATCH state valid."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
|
||||
# No dedicated new statuses -> alias to THIS project's base UUIDs.
|
||||
assert states["analysis"] == ORCH_STATES["in_progress"]
|
||||
assert states["to_analyse"] == ORCH_STATES["in_progress"]
|
||||
assert states["code_review"] == ORCH_STATES["review"]
|
||||
assert states["awaiting_deploy"] == ORCH_STATES["in_review"]
|
||||
assert states["deploying"] == ORCH_STATES["in_progress"]
|
||||
assert states["monitoring"] == ORCH_STATES["done"]
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the
|
||||
pipeline when the 6 Plane statuses are absent or Plane is unreachable.
|
||||
|
||||
* TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new
|
||||
logical key to its OWN base UUID (to_analyse=in_progress, code_review=review,
|
||||
awaiting_deploy=in_review, monitoring=done); no exception.
|
||||
* TC-17 (AC-16) — Plane API down -> get_project_states falls back to
|
||||
_DEFAULT_STATES; every set_issue_* helper is never-raise.
|
||||
* TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through
|
||||
the to_analyse alias (= in_progress UUID).
|
||||
|
||||
httpx is mocked; no network.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import plane_sync as PS # noqa: E402
|
||||
|
||||
ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
|
||||
# An enduro-style states response: the 6 ORCH-066 statuses are NOT created.
|
||||
_ENDURO_BASE = {
|
||||
"Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u",
|
||||
"Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u",
|
||||
"Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u",
|
||||
"Blocked": "blk-u",
|
||||
}
|
||||
|
||||
|
||||
def _states_response(name_to_uuid):
|
||||
return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]}
|
||||
|
||||
|
||||
def _fake_resp(data, status=200):
|
||||
m = MagicMock()
|
||||
m.status_code = status
|
||||
m.json.return_value = data
|
||||
m.raise_for_status.return_value = None
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache():
|
||||
PS.reload_project_states()
|
||||
yield
|
||||
PS.reload_project_states()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_partial_project_aliases_to_base_uuids():
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
|
||||
states = PS.get_project_states(ENDURO_PROJECT_ID)
|
||||
|
||||
# The new keys degrade to THIS project's base UUIDs (not foreign defaults).
|
||||
assert states["to_analyse"] == states["in_progress"] == "ip-u"
|
||||
assert states["analysis"] == "ip-u"
|
||||
assert states["code_review"] == states["review"] == "review-u"
|
||||
assert states["awaiting_deploy"] == states["in_review"] == "inrev-u"
|
||||
assert states["deploying"] == "ip-u"
|
||||
assert states["monitoring"] == states["done"] == "done-u"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_api_down_falls_back_to_defaults():
|
||||
with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")):
|
||||
states = PS.get_project_states(ENDURO_PROJECT_ID)
|
||||
assert states is PS._DEFAULT_STATES
|
||||
# All new keys exist in the defaults (so callers never KeyError).
|
||||
for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy",
|
||||
"deploying", "monitoring"):
|
||||
assert k in states
|
||||
|
||||
|
||||
def test_tc17_set_issue_helpers_never_raise_when_issue_missing():
|
||||
# find_issue_id returns None (issue not in Plane) -> helpers log + return,
|
||||
# they must NOT raise. Covers every ORCH-066 setter.
|
||||
setters = [
|
||||
PS.set_issue_analysis, PS.set_issue_code_review,
|
||||
PS.set_issue_awaiting_deploy, PS.set_issue_deploying,
|
||||
PS.set_issue_monitoring,
|
||||
]
|
||||
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
|
||||
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
|
||||
patch("src.plane_sync.find_issue_id", return_value=None), \
|
||||
patch("src.plane_sync.httpx.patch") as mock_patch:
|
||||
for setter in setters:
|
||||
setter("ET-1") # must not raise
|
||||
# No PATCH issued because the issue could not be resolved.
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
|
||||
def test_tc17_set_issue_helpers_never_raise_when_patch_errors():
|
||||
# The PATCH itself blows up -> _set_issue_state_direct swallows it.
|
||||
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
|
||||
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
|
||||
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \
|
||||
patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")):
|
||||
PS.set_issue_monitoring("ET-1") # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_tc18_enduro_in_progress_still_starts_via_alias():
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
|
||||
with patch("src.plane_sync.httpx.get") as mock_get, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
|
||||
# enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u).
|
||||
data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}}
|
||||
await handle_issue_updated(data, ENDURO_PROJECT_ID)
|
||||
|
||||
mock_start.assert_called_once()
|
||||
mock_verdict.assert_not_called()
|
||||
@@ -1,151 +0,0 @@
|
||||
"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage.
|
||||
|
||||
These tests pin the layer-B behaviour WITHOUT touching layer A (the stage
|
||||
machine). httpx is mocked; no network.
|
||||
|
||||
* TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress.
|
||||
* TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`.
|
||||
* TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input).
|
||||
* TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin).
|
||||
* TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from unittest.mock import patch, MagicMock # noqa: E402
|
||||
|
||||
from src import plane_sync as PS # noqa: E402
|
||||
|
||||
|
||||
# A per-project state map that DEFINES the new ORCH-066 statuses with distinct
|
||||
# UUIDs, so we can prove the dedicated status (not the base alias) is used.
|
||||
_STATES_WITH_NEW = {
|
||||
"in_progress": "ip-uuid",
|
||||
"review": "review-uuid",
|
||||
"in_review": "inrev-uuid",
|
||||
"needs_input": "ni-uuid",
|
||||
"done": "done-uuid",
|
||||
"analysis": "analysis-uuid",
|
||||
"code_review": "codereview-uuid",
|
||||
"awaiting_deploy": "awaiting-uuid",
|
||||
"deploying": "deploying-uuid",
|
||||
"monitoring": "monitoring-uuid",
|
||||
}
|
||||
|
||||
|
||||
def _patch_resolve(states):
|
||||
"""Patch find_issue_id + _resolve_project_id + get_project_states so a
|
||||
set_issue_* helper reaches the PATCH with a known per-project state map."""
|
||||
return (
|
||||
patch("src.plane_sync.httpx.patch"),
|
||||
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"),
|
||||
patch("src.plane_sync._resolve_project_id", return_value="proj-1"),
|
||||
patch("src.plane_sync.get_project_states", return_value=states),
|
||||
)
|
||||
|
||||
|
||||
def _run_setter(setter, states):
|
||||
p_patch, p_find, p_res, p_states = _patch_resolve(states)
|
||||
with p_patch as mock_patch, p_find, p_res, p_states:
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
setter("ET-1")
|
||||
return mock_patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 (AC-3): analyst start/resume indicates Analysis.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_set_issue_analysis_patches_analysis_uuid():
|
||||
mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW)
|
||||
# The dedicated Analysis UUID is used (NOT the in_progress base alias).
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid"
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"]
|
||||
|
||||
|
||||
def test_tc03_analysis_aliases_in_progress_when_absent():
|
||||
# A project without the Analysis status -> get_project_states already aliased
|
||||
# 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully.
|
||||
aliased = dict(_STATES_WITH_NEW)
|
||||
aliased["analysis"] = aliased["in_progress"]
|
||||
mock_patch = _run_setter(PS.set_issue_analysis, aliased)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 (AC-5): the review stage indicates Code-Review.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_review_stage_maps_to_code_review():
|
||||
# Both the stage->state-key map and the stage-visibility map point review at
|
||||
# the new code_review logical key (layer B only).
|
||||
assert PS._STAGE_TO_STATE_KEY["review"] == "code_review"
|
||||
assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review"
|
||||
|
||||
|
||||
def test_tc05_set_issue_stage_state_review_patches_code_review_uuid():
|
||||
p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW)
|
||||
with p_patch as mock_patch, p_find, p_res, p_states:
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
PS.set_issue_stage_state("ET-1", "review")
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
||||
|
||||
|
||||
def test_tc05_set_issue_code_review_helper_patches_code_review_uuid():
|
||||
mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-14): Needs Input behaviour unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_needs_input_unchanged():
|
||||
mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW)
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes
|
||||
# ONLY layer B — the machine must not move.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc22_stage_transitions_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
assert STAGE_TRANSITIONS == {
|
||||
"created": {"next": "analysis", "agent": "analyst", "qg": None},
|
||||
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
|
||||
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
||||
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
||||
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
||||
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
||||
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
||||
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
||||
"done": {"next": None, "agent": None, "qg": None},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc23_qg_checks_registry_unchanged():
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(QG_CHECKS.keys()) == {
|
||||
"check_analysis_approved", "check_analysis_complete", "check_architecture_done",
|
||||
"check_ci_green", "check_review_approved", "check_tests_passed",
|
||||
"check_reviewer_verdict", "check_tests_local", "check_deploy_status",
|
||||
"check_staging_status", "check_branch_mergeable", "check_staging_image_fresh",
|
||||
}
|
||||
|
||||
|
||||
def test_tc23_check_deploy_status_signature_unchanged():
|
||||
import inspect
|
||||
from src.qg.checks import check_deploy_status, QG_CHECKS
|
||||
# Registry still points at the same callable.
|
||||
assert QG_CHECKS["check_deploy_status"] is check_deploy_status
|
||||
# (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact.
|
||||
params = list(inspect.signature(check_deploy_status).parameters)
|
||||
assert params == ["repo", "work_item_id", "branch"]
|
||||
@@ -1,114 +0,0 @@
|
||||
"""ORCH-066: To Analyse resume semantics (F-1 status-only model).
|
||||
|
||||
`handle_status_start` forks on (existing task?) + (active job?):
|
||||
|
||||
* TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse ->
|
||||
RELAUNCH the current stage's agent (the analyst resumes from Needs Input);
|
||||
NO second task is created; the issue is re-indicated `Analysis`.
|
||||
* TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse ->
|
||||
busy-guard: NO relaunch (no double launch).
|
||||
|
||||
handle_status_start is exercised directly; enqueue_job + Plane side-effects are
|
||||
mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.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 patch, AsyncMock, MagicMock # noqa: E402
|
||||
|
||||
import src.db as _db # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.webhooks.plane import handle_status_start # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails",
|
||||
branch="feature/ET-001-x", wi="ET-001"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(plane_id, wi, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _count(plane_id):
|
||||
conn = get_db()
|
||||
n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0]
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate():
|
||||
_make_task("resume-1", stage="analysis")
|
||||
data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
||||
|
||||
with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \
|
||||
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.plane_sync.add_comment", MagicMock()), \
|
||||
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
||||
await handle_status_start(data, "proj-1")
|
||||
|
||||
# No new pipeline start (it is a resume, not a fresh task).
|
||||
mock_start.assert_not_called()
|
||||
assert _count("resume-1") == 1 # NO duplicate task
|
||||
# The current stage's agent (analyst) was relaunched exactly once.
|
||||
assert mock_enqueue.call_count == 1
|
||||
assert mock_enqueue.call_args.args[0] == "analyst"
|
||||
# AC-3: the resumed analysis stage is re-indicated as Analysis.
|
||||
mock_analysis.assert_called_once_with("ET-001")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_tc04_to_analyse_with_active_job_does_not_relaunch():
|
||||
tid = _make_task("resume-2", stage="analysis")
|
||||
# Seed an active (queued) job so has_active_job_for_task reports busy.
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')",
|
||||
("analyst", "enduro-trails", tid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}}
|
||||
with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \
|
||||
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.plane_sync.add_comment", MagicMock()), \
|
||||
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
|
||||
await handle_status_start(data, "proj-1")
|
||||
|
||||
mock_start.assert_not_called()
|
||||
mock_enqueue.assert_not_called() # busy-guard held: NO double launch
|
||||
mock_analysis.assert_not_called()
|
||||
assert _count("resume-2") == 1
|
||||
@@ -47,18 +47,13 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
# ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress.
|
||||
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -224,38 +219,3 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher)
|
||||
assert rows["o1"] == "ORCH-001"
|
||||
assert rows["o2"] == "ORCH-002"
|
||||
assert rows["e1"] == "ET-001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator
|
||||
# performs NO advance/rollback (indication, not control).
|
||||
# ---------------------------------------------------------------------------
|
||||
@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane.launcher")
|
||||
def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict):
|
||||
cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"]
|
||||
resp = client.post(
|
||||
"/webhook/plane",
|
||||
json={
|
||||
"event": "issue",
|
||||
"action": "updated",
|
||||
"data": {
|
||||
"id": "cancel-1",
|
||||
"name": "A cancelled work item",
|
||||
"description_stripped": "This is a sufficiently long description.",
|
||||
"project": ORCH_PLANE_ID,
|
||||
"state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Neither the start nor the verdict (advance/rollback) handler ran.
|
||||
mock_start.assert_not_called()
|
||||
mock_verdict.assert_not_called()
|
||||
mock_launcher.launch.assert_not_called()
|
||||
# No task created off a Cancelled transition.
|
||||
conn = get_db()
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone()
|
||||
conn.close()
|
||||
assert task is None
|
||||
|
||||
@@ -47,9 +47,6 @@ def silence_side_effects(monkeypatch):
|
||||
"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",
|
||||
# ORCH-066 status setters.
|
||||
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
|
||||
"set_issue_monitoring",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
@@ -245,81 +242,6 @@ def test_finished_window_tick_is_noop(monkeypatch):
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc10_clean_window_close_sets_done(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1
|
||||
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"}
|
||||
)
|
||||
# Clean window close -> terminal Done indicated on Plane; window marked done.
|
||||
stage_engine.set_issue_done.assert_called_once_with("ORCH-021")
|
||||
stage_engine.set_issue_blocked.assert_not_called()
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
# No follow-up tick once the window closed.
|
||||
assert _jobs("post-deploy-monitor") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc11_degraded_sets_blocked(monkeypatch):
|
||||
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", 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"),
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
|
||||
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"}
|
||||
)
|
||||
# DEGRADED -> Blocked indication (NOT Done); window finalised.
|
||||
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021")
|
||||
stage_engine.set_issue_done.assert_not_called()
|
||||
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod —
|
||||
# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_orch066_tc12_self_tick_never_restarts_prod(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"),
|
||||
)
|
||||
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
|
||||
# The rollback hook (the only restart-capable path) MUST stay untouched for self.
|
||||
rollback = MagicMock(return_value=(0, "ok"))
|
||||
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
|
||||
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() # never restarts/rolls back the prod self-container
|
||||
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-20 — /queue observability block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,7 @@ _EXPECTED_QGS = {
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge)
|
||||
"check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge)
|
||||
"check_security_gate", # ORCH-022 security sub-gate (same edge, run FIRST)
|
||||
}
|
||||
|
||||
|
||||
|
||||
113
tests/test_qg_security.py
Normal file
113
tests/test_qg_security.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ORCH-022 / TC-13..TC-15: the security-gate QG wrapper + registry wiring.
|
||||
|
||||
Covers the thin ``check_security_gate`` registry wrapper in src/qg/checks.py (its
|
||||
conditionality fast-paths) and that the new check is registered + dispatched by
|
||||
``_run_qg``. The deterministic core (scan / verdict / frontmatter) is covered in
|
||||
tests/test_security_gate.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import security_gate as sg # noqa: E402
|
||||
from src.qg import checks as qg # noqa: E402
|
||||
from src.qg.checks import QG_CHECKS, check_security_gate # noqa: E402
|
||||
|
||||
_WI = "ORCH-022"
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 — non-self repo with empty scope -> N/A fast pass (no scanner run).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_non_self_repo_empty_scope_is_na(monkeypatch):
|
||||
"""TC-13: a non-self repo with an empty scope -> (True, 'security-gate N/A
|
||||
for <repo>') immediately, WITHOUT invoking the scanners."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", True)
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "")
|
||||
|
||||
called = {"scan": False}
|
||||
|
||||
def _should_not_run(*a, **k):
|
||||
called["scan"] = True
|
||||
raise AssertionError("scanner must not run for an N/A repo")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _should_not_run)
|
||||
monkeypatch.setattr(sg, "audit_dependencies", _should_not_run)
|
||||
|
||||
ok, reason = check_security_gate("enduro-trails", _WI, _BRANCH)
|
||||
assert ok is True
|
||||
assert "N/A" in reason
|
||||
assert "enduro-trails" in reason
|
||||
assert called["scan"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 — kill-switch disabled -> no-op pass.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_disabled_is_noop_pass(monkeypatch):
|
||||
"""TC-14: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True), scanners untouched."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", False)
|
||||
|
||||
def _should_not_run(*a, **k):
|
||||
raise AssertionError("scanner must not run when the gate is disabled")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _should_not_run)
|
||||
monkeypatch.setattr(sg, "audit_dependencies", _should_not_run)
|
||||
|
||||
ok, reason = check_security_gate("orchestrator", _WI, _BRANCH)
|
||||
assert ok is True
|
||||
assert "disabled" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 — registered in QG_CHECKS + dispatched by _run_qg.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_registered_in_qg_checks():
|
||||
"""TC-15a: the new check is registered and callable."""
|
||||
assert "check_security_gate" in QG_CHECKS
|
||||
assert QG_CHECKS["check_security_gate"] is check_security_gate
|
||||
assert callable(QG_CHECKS["check_security_gate"])
|
||||
|
||||
|
||||
def test_tc15_dispatched_by_run_qg(monkeypatch):
|
||||
"""TC-15b: _run_qg routes 'check_security_gate' with the (repo, work_item_id,
|
||||
branch) signature to the registered wrapper."""
|
||||
from src import stage_engine
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake(repo, work_item_id, branch):
|
||||
captured["args"] = (repo, work_item_id, branch)
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setitem(stage_engine.QG_CHECKS, "check_security_gate", _fake)
|
||||
passed, reason = stage_engine._run_qg("check_security_gate", "orchestrator", _WI, _BRANCH)
|
||||
assert passed is True
|
||||
assert captured["args"] == ("orchestrator", _WI, _BRANCH)
|
||||
|
||||
|
||||
def test_security_gate_applies_scope(monkeypatch):
|
||||
"""Conditionality matrix mirrors merge_gate_applies / image_freshness_applies."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", True)
|
||||
# Empty scope -> only the self-hosting repo.
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "")
|
||||
assert sg.security_gate_applies("orchestrator") is True
|
||||
assert sg.security_gate_applies("enduro-trails") is False
|
||||
# Explicit CSV scope -> only the listed repos (case-insensitive).
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "enduro-trails, foo")
|
||||
assert sg.security_gate_applies("enduro-trails") is True
|
||||
assert sg.security_gate_applies("orchestrator") is False
|
||||
# Kill-switch wins over everything.
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", False)
|
||||
assert sg.security_gate_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_qg_wrapper_delegates(monkeypatch):
|
||||
"""The QG wrapper delegates to security_gate.check_security_gate verbatim."""
|
||||
monkeypatch.setattr(sg, "check_security_gate", lambda r, w, b: (False, "delegated FAIL"))
|
||||
ok, reason = check_security_gate("orchestrator", _WI, _BRANCH)
|
||||
assert ok is False
|
||||
assert reason == "delegated FAIL"
|
||||
@@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch):
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc060_09_f2_does_not_replay_blocked(monkeypatch):
|
||||
states = {
|
||||
"in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ",
|
||||
"in_progress": "IP", "approved": "AP", "rejected": "RJ",
|
||||
"blocked": "BL", "needs_input": "NI",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
@@ -680,67 +680,3 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch):
|
||||
|
||||
assert _stage_of(blocked) == "review" # Guard 2 muted
|
||||
assert _stage_of(escalated) == "development" # Guard 1 still skips
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits
|
||||
# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are
|
||||
# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _guard2(monkeypatch, states, cur_state):
|
||||
"""Drive _is_blocked_or_needs_input with a chosen project state map + the
|
||||
issue's current Plane state uuid."""
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_states",
|
||||
MagicMock(return_value=states))
|
||||
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=cur_state))
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "get_project_by_repo",
|
||||
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.settings, "reconcile_skip_blocked_enabled", True
|
||||
)
|
||||
task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"}
|
||||
return Reconciler()._is_blocked_or_needs_input(task)
|
||||
|
||||
|
||||
# orchestrator has the three new statuses as DISTINCT UUIDs.
|
||||
_DISTINCT_STATES = {
|
||||
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
|
||||
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
|
||||
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
|
||||
"blocked": "blocked-u", "needs_input": "ni-u",
|
||||
"awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u",
|
||||
}
|
||||
|
||||
|
||||
def test_tc21_guard2_skips_distinct_active_waits(monkeypatch):
|
||||
# Each active-wait status (distinct UUID) -> skipped (not revived).
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True
|
||||
# Explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True
|
||||
# A normal working state is NOT skipped (gets reconciled).
|
||||
assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False
|
||||
|
||||
|
||||
def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
|
||||
# enduro: the new keys alias onto base working statuses -> they must NOT make
|
||||
# F-1 skip a genuinely In Progress / In Review / Done task (anti-regress).
|
||||
aliased = {
|
||||
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
|
||||
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
|
||||
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
|
||||
"blocked": "blocked-u", "needs_input": "ni-u",
|
||||
# aliased onto base UUIDs (project did not create dedicated statuses).
|
||||
"awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u",
|
||||
}
|
||||
# In Progress / In Review / Done are base working states -> NOT skipped.
|
||||
assert _guard2(monkeypatch, aliased, "ip-u") is False
|
||||
assert _guard2(monkeypatch, aliased, "inrev-u") is False
|
||||
assert _guard2(monkeypatch, aliased, "done-u") is False
|
||||
# The explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, aliased, "blocked-u") is True
|
||||
|
||||
@@ -59,9 +59,6 @@ def single_project(monkeypatch):
|
||||
reconciler_mod, "get_project_states",
|
||||
lambda pid: {
|
||||
"in_progress": _IN_PROGRESS,
|
||||
# ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this
|
||||
# project -> aliases in_progress (real get_project_states fallback).
|
||||
"to_analyse": _IN_PROGRESS,
|
||||
"approved": _APPROVED,
|
||||
"rejected": _REJECTED,
|
||||
},
|
||||
@@ -117,46 +114,6 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje
|
||||
verdict.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it
|
||||
# to handle_status_start (a lost start/resume webhook is recovered).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch):
|
||||
_TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "get_project_states",
|
||||
lambda pid: {
|
||||
"in_progress": _IN_PROGRESS,
|
||||
"to_analyse": _TO_ANALYSE, # dedicated status created
|
||||
"approved": _APPROVED,
|
||||
"rejected": _REJECTED,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.projects, "PROJECTS",
|
||||
[SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails",
|
||||
work_item_prefix="ET")],
|
||||
)
|
||||
start, verdict = _patch_handlers(monkeypatch)
|
||||
|
||||
polled = {}
|
||||
|
||||
def fake_list(pid, states):
|
||||
polled["states"] = list(states)
|
||||
return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS,
|
||||
"name": "Lost start"}]
|
||||
|
||||
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
|
||||
|
||||
Reconciler().reconcile_plane_once()
|
||||
|
||||
# The To Analyse UUID is in the polled set and routed to start (not verdict).
|
||||
assert _TO_ANALYSE in polled["states"]
|
||||
assert start.call_count == 1
|
||||
assert start.call_args.args[0]["id"] == "iss-ta"
|
||||
verdict.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12: Approved with an existing task, no active job -> handle_verdict(True).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,10 +279,7 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
|
||||
|
||||
def fake_states(pid):
|
||||
states_calls.append(pid)
|
||||
return {
|
||||
"in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS,
|
||||
"approved": _APPROVED, "rejected": _REJECTED,
|
||||
}
|
||||
return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED}
|
||||
|
||||
def fake_issues(pid, states):
|
||||
issues_calls.append((pid, tuple(states)))
|
||||
|
||||
324
tests/test_security_gate.py
Normal file
324
tests/test_security_gate.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""ORCH-022 / TC-01..TC-12: the security-gate leaf module (src/security_gate.py).
|
||||
|
||||
These exercise the DETERMINISTIC core: the pure classifier / verdict / frontmatter
|
||||
helpers (no binaries needed) plus scan_secrets / audit_dependencies with the
|
||||
external scanners (gitleaks / pip-audit) mocked at subprocess.run. The integration
|
||||
of the gate into advance_stage is covered in tests/test_stage_engine_security_gate.py;
|
||||
the QG registry wiring in tests/test_qg_security.py.
|
||||
|
||||
Contract under test (ADR-001 §7):
|
||||
* secrets are UNCONDITIONAL + offline -> a found secret blocks; a tool error is
|
||||
fail-closed (FAIL);
|
||||
* dependency audit is best-effort -> blocking only at/over the severity threshold;
|
||||
UNKNOWN / below-threshold -> warning; an unreachable feed degrades fail-open +
|
||||
warning by default, fail-closed only when configured;
|
||||
* the machine verdict lives ONLY in the YAML frontmatter (read-back == written);
|
||||
* never-raise: any internal error -> (False, reason), no exception escapes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import security_gate as sg # noqa: E402
|
||||
|
||||
_REPO = "orchestrator"
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
_WI = "ORCH-022"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builders for the result containers (no binaries needed).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _clean_secret():
|
||||
return sg.SecretScanResult(status="clean", detail="no secrets found")
|
||||
|
||||
|
||||
def _found_secret(n=1):
|
||||
findings = [
|
||||
{"file": "src/config.py", "rule": "generic-api-key", "line": 12 + i, "match": "abcd…yz"}
|
||||
for i in range(n)
|
||||
]
|
||||
return sg.SecretScanResult(status="found", findings=findings, detail=f"{n} secret(s)")
|
||||
|
||||
|
||||
def _ok_deps(findings=None):
|
||||
return sg.DepAuditResult(status="ok", findings=findings or [], detail="ok")
|
||||
|
||||
|
||||
def _degraded_deps():
|
||||
return sg.DepAuditResult(status="degraded", detail="pip-audit feed unavailable")
|
||||
|
||||
|
||||
def _verdict(secret, dep, *, secrets_block=True, dep_block_severity="HIGH", dep_fail_closed=False):
|
||||
return sg.compute_verdict(
|
||||
secret, dep,
|
||||
secrets_block=secrets_block,
|
||||
dep_block_severity=dep_block_severity,
|
||||
dep_fail_closed=dep_fail_closed,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 / TC-02 / TC-03 — secret-scanning (FR-1 / AC-1..AC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_secret_in_diff_fails():
|
||||
"""TC-01: a planted secret -> FAIL, secrets_found>=1, reason names the finding."""
|
||||
fields = _verdict(_found_secret(1), _ok_deps())
|
||||
assert fields["security_status"] == "FAIL"
|
||||
assert fields["secrets_found"] >= 1
|
||||
# The reason must name the finding substance (rule + file), not just "FAIL".
|
||||
assert "generic-api-key" in fields["reason"]
|
||||
assert "src/config.py" in fields["reason"]
|
||||
|
||||
|
||||
def test_tc02_clean_branch_passes():
|
||||
"""TC-02: a clean branch -> PASS, secrets_found=0."""
|
||||
fields = _verdict(_clean_secret(), _ok_deps())
|
||||
assert fields["security_status"] == "PASS"
|
||||
assert fields["secrets_found"] == 0
|
||||
assert fields["deps_blocking"] == 0
|
||||
|
||||
|
||||
def test_tc03_allowlisted_match_does_not_fail(monkeypatch, tmp_path):
|
||||
"""TC-03: an allowlisted match (placeholder / fixture) is filtered by gitleaks
|
||||
(rc=0) -> scan_secrets reports clean -> PASS. The allowlist lives in the
|
||||
versioned .gitleaks.toml; here we assert the gate honours gitleaks' rc=0."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
# `git fetch` and `gitleaks detect` both routed here; both "succeed clean".
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _fake_run)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "clean"
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "PASS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04..TC-07 — dependency audit + thresholds (FR-2 / AC-4..AC-7)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_high_cve_at_high_threshold_blocks():
|
||||
"""TC-04: a HIGH/CRITICAL CVE at threshold HIGH -> FAIL, deps_blocking>=1."""
|
||||
deps = _ok_deps([
|
||||
{"package": "requests", "version": "2.0.0", "id": "CVE-1", "severity": "HIGH", "fix": "2.1"},
|
||||
{"package": "urllib3", "version": "1.0.0", "id": "CVE-2", "severity": "CRITICAL", "fix": "1.1"},
|
||||
])
|
||||
fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert fields["security_status"] == "FAIL"
|
||||
assert fields["deps_blocking"] >= 1
|
||||
assert "CVE-1" in fields["reason"] or "CVE-2" in fields["reason"]
|
||||
|
||||
|
||||
def test_tc05_only_medium_low_warns_passes():
|
||||
"""TC-05: only MEDIUM/LOW vulns -> PASS, deps_warning>=1, findings in the body."""
|
||||
deps = _ok_deps([
|
||||
{"package": "jinja2", "version": "2.0", "id": "CVE-M", "severity": "MEDIUM", "fix": "2.1"},
|
||||
{"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""},
|
||||
])
|
||||
fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert fields["security_status"] == "PASS"
|
||||
assert fields["deps_warning"] >= 1
|
||||
assert fields["deps_blocking"] == 0
|
||||
body = sg.render_security_report(_WI, fields)
|
||||
assert "CVE-M" in body and "CVE-L" in body
|
||||
|
||||
|
||||
def test_tc06_threshold_config_changes_classification():
|
||||
"""TC-06: severity=CRITICAL makes a HIGH CVE a warning; severity=HIGH blocks it."""
|
||||
assert sg.classify_severity("HIGH", "CRITICAL") == "warning"
|
||||
assert sg.classify_severity("HIGH", "HIGH") == "block"
|
||||
assert sg.classify_severity("CRITICAL", "CRITICAL") == "block"
|
||||
# UNKNOWN is ALWAYS a warning, never an auto-block (anti-loop, Р-4).
|
||||
assert sg.classify_severity("UNKNOWN", "LOW") == "warning"
|
||||
assert sg.classify_severity("", "HIGH") == "warning"
|
||||
|
||||
deps = _ok_deps([
|
||||
{"package": "x", "version": "1", "id": "CVE-H", "severity": "HIGH", "fix": ""},
|
||||
])
|
||||
at_critical = _verdict(_clean_secret(), deps, dep_block_severity="CRITICAL")
|
||||
at_high = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert at_critical["security_status"] == "PASS"
|
||||
assert at_critical["deps_warning"] == 1
|
||||
assert at_high["security_status"] == "FAIL"
|
||||
assert at_high["deps_blocking"] == 1
|
||||
|
||||
|
||||
def test_tc07_degraded_feed_failopen_default_failclosed_strict():
|
||||
"""TC-07: an unreachable CVE feed degrades fail-open + warning by default (no
|
||||
exception, no false FAIL); fail-closed -> FAIL only when configured."""
|
||||
default = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=False)
|
||||
assert default["security_status"] == "PASS"
|
||||
assert default["deps_audit_degraded"] is True
|
||||
|
||||
strict = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=True)
|
||||
assert strict["security_status"] == "FAIL"
|
||||
assert strict["deps_audit_degraded"] is True
|
||||
assert "fail-closed" in strict["reason"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08..TC-10 — verdict / frontmatter parser + artefact (FR-3 / AC-8..AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_verdict_only_from_frontmatter():
|
||||
"""TC-08: the verdict is read ONLY from the YAML frontmatter; prose in the body
|
||||
does not influence it; the negative (FAIL) token is authoritative."""
|
||||
# Frontmatter PASS but body screams FAIL -> still PASS (prose ignored).
|
||||
pass_fm = (
|
||||
"---\nsecurity_status: PASS\nsecrets_found: 0\n---\n"
|
||||
"# Report\nThis build totally FAILED everything, FAIL FAIL.\n"
|
||||
)
|
||||
ok, reason = sg.parse_security_status(pass_fm)
|
||||
assert ok is True
|
||||
assert "PASS" in reason
|
||||
|
||||
# Frontmatter FAIL but body says PASS -> FAIL (negative token authoritative).
|
||||
fail_fm = "---\nsecurity_status: FAIL\n---\nEverything PASS, looks great!\n"
|
||||
ok, reason = sg.parse_security_status(fail_fm)
|
||||
assert ok is False
|
||||
assert "FAIL" in reason
|
||||
|
||||
|
||||
def test_tc09_missing_or_broken_frontmatter_failclosed():
|
||||
"""TC-09: no frontmatter / broken YAML / missing field -> (False, reason)."""
|
||||
# No frontmatter at all.
|
||||
ok, reason = sg.parse_security_status("# Just a body, no frontmatter\nPASS\n")
|
||||
assert ok is False and reason
|
||||
|
||||
# Frontmatter present but no security_status field.
|
||||
ok, reason = sg.parse_security_status("---\nother: 1\n---\nbody\n")
|
||||
assert ok is False
|
||||
|
||||
# Broken YAML in the frontmatter.
|
||||
ok, reason = sg.parse_security_status("---\nsecurity_status: : : [bad\n---\nbody\n")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_tc10_artifact_has_valid_frontmatter_and_body(tmp_path, monkeypatch):
|
||||
"""TC-10: 17-security-report.md is written with valid frontmatter (all machine
|
||||
fields) and a body listing the findings; read-back == the written verdict."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt))
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
deps = _ok_deps([
|
||||
{"package": "requests", "version": "2.0", "id": "CVE-X", "severity": "HIGH", "fix": "2.1"},
|
||||
{"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""},
|
||||
])
|
||||
fields = _verdict(_found_secret(1), deps, dep_block_severity="HIGH")
|
||||
path = sg.write_security_report(_REPO, _WI, _BRANCH, fields)
|
||||
assert os.path.isfile(path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Frontmatter carries every machine field.
|
||||
for key in ("security_status", "secrets_found", "deps_blocking", "deps_warning",
|
||||
"deps_audit_degraded"):
|
||||
assert f"{key}:" in content
|
||||
# Body lists findings.
|
||||
assert "CVE-X" in content and "CVE-L" in content
|
||||
# Read-back agrees with the computed status (single source of truth, AC-8).
|
||||
ok, _ = sg.parse_security_status(content)
|
||||
assert ok is (fields["security_status"] == "PASS")
|
||||
assert ok is False # this fixture is a FAIL (secret + HIGH CVE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 / TC-12 — never-raise / timeout (FR-5/FR-6 / AC-14..AC-17)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_missing_binary_failclosed_never_raises(monkeypatch, tmp_path):
|
||||
"""TC-11: a missing scanner binary / internal exception -> error -> FAIL
|
||||
(fail-closed for secrets), and the exception never propagates."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _raise_fnf(cmd, **kwargs):
|
||||
# git fetch ok, gitleaks missing.
|
||||
if cmd[:1] == ["git"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
raise FileNotFoundError("gitleaks")
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _raise_fnf)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "error"
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "FAIL" # fail-closed, BR-2
|
||||
assert "fail-closed" in fields["reason"]
|
||||
|
||||
# check_security_gate as a whole never raises even if everything explodes.
|
||||
monkeypatch.setattr(sg, "security_gate_applies", lambda r: True)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _boom)
|
||||
ok, reason = sg.check_security_gate(_REPO, _WI, _BRANCH)
|
||||
assert ok is False
|
||||
assert "error" in reason.lower()
|
||||
|
||||
|
||||
def test_tc12_timeout_is_deterministic_failclosed(monkeypatch, tmp_path):
|
||||
"""TC-12: exceeding the scan timeout -> a deterministic error verdict, no hang."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _timeout(cmd, **kwargs):
|
||||
if cmd[:1] == ["git"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 1))
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _timeout)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "error"
|
||||
assert "timeout" in res.detail.lower()
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "FAIL"
|
||||
|
||||
# pip-audit timeout -> degrade (best-effort), not a hard error.
|
||||
monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt))
|
||||
(wt / "requirements.txt").write_text("requests==2.0\n")
|
||||
dep = sg.audit_dependencies(_REPO, _BRANCH)
|
||||
assert dep.status == "degraded"
|
||||
assert "timeout" in dep.detail.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser robustness (supports the above; pure, never raises)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_gitleaks_report_tolerant():
|
||||
assert sg.parse_gitleaks_report("") == []
|
||||
assert sg.parse_gitleaks_report("not json") == []
|
||||
assert sg.parse_gitleaks_report("{}") == []
|
||||
parsed = sg.parse_gitleaks_report(
|
||||
'[{"File":"a.py","RuleID":"key","StartLine":3,"Secret":"supersecretvalue"}]'
|
||||
)
|
||||
assert parsed[0]["file"] == "a.py"
|
||||
assert parsed[0]["rule"] == "key"
|
||||
# The secret value is masked, never re-leaked verbatim.
|
||||
assert "supersecretvalue" not in parsed[0]["match"]
|
||||
|
||||
|
||||
def test_parse_pip_audit_report_tolerant():
|
||||
assert sg.parse_pip_audit_report("") == []
|
||||
assert sg.parse_pip_audit_report("garbage") == []
|
||||
doc = (
|
||||
'{"dependencies":[{"name":"requests","version":"2.0",'
|
||||
'"vulns":[{"id":"CVE-1","severity":"HIGH","fix_versions":["2.1"]}]}]}'
|
||||
)
|
||||
parsed = sg.parse_pip_audit_report(doc)
|
||||
assert parsed[0]["package"] == "requests"
|
||||
assert parsed[0]["severity"] == "HIGH"
|
||||
# Missing severity -> UNKNOWN.
|
||||
doc2 = '{"dependencies":[{"name":"x","version":"1","vulns":[{"id":"CVE-2"}]}]}'
|
||||
assert sg.parse_pip_audit_report(doc2)[0]["severity"] == "UNKNOWN"
|
||||
@@ -832,6 +832,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -856,6 +857,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30)
|
||||
@@ -883,6 +885,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3)
|
||||
@@ -916,6 +919,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -939,6 +943,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -962,6 +967,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -1014,6 +1020,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _fail(
|
||||
"staging rebuild failed: health FAILED")},
|
||||
@@ -1041,6 +1048,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _fail("provenance mismatch")},
|
||||
)
|
||||
@@ -1064,6 +1072,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -1089,6 +1098,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass},
|
||||
) # check_staging_image_fresh left REAL -> N/A for enduro-trails
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099",
|
||||
@@ -1160,6 +1170,7 @@ class TestStagingInfraTolerance:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -1232,6 +1243,7 @@ class TestStagingInfraTolerance:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass,
|
||||
"check_deploy_status": _pass},
|
||||
|
||||
270
tests/test_stage_engine_security_gate.py
Normal file
270
tests/test_stage_engine_security_gate.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""ORCH-022 / TC-16..TC-19, TC-21: the security sub-gate wired into advance_stage.
|
||||
|
||||
These are integration tests over src.stage_engine.advance_stage on the
|
||||
deploy-staging -> deploy edge. The security verdict is injected by patching the
|
||||
QG_CHECKS registry entry (the leaf scanner logic is unit-tested in
|
||||
tests/test_security_gate.py), so we exercise the ENGINE behaviour:
|
||||
* FAIL -> rollback to development + enqueue developer + Plane comment + notify;
|
||||
* the rollback task_desc carries the verbatim findings (ORCH-046 pattern);
|
||||
* after MAX_DEVELOPER_RETRIES -> set_issue_blocked + Telegram, no bounce;
|
||||
* PASS -> the pipeline advances normally (no rollback, no noisy notify);
|
||||
* self-hosting safety: a FAIL never calls the deploy hook / restarts prod.
|
||||
|
||||
Network/Plane/Telegram side effects are mocked at the src.stage_engine level.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_security_gate.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.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures (mirror tests/test_stage_engine.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
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, branch=_BRANCH, wi="ORCH-022"):
|
||||
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 _stage(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _jobs():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _job_contents():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _add_developer_runs(task_id, n):
|
||||
conn = get_db()
|
||||
for _ in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')",
|
||||
(task_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _fail(reason):
|
||||
def _f(*a, **k):
|
||||
return (False, reason)
|
||||
return _f
|
||||
|
||||
|
||||
def _qg_with_security(monkeypatch, security_result):
|
||||
"""Patch QG_CHECKS so every gate passes EXCEPT the security gate, which returns
|
||||
``security_result``. Keeps the deploy-staging edge reachable (check_staging_status
|
||||
passes) and isolates the security verdict under test."""
|
||||
patched = {k: _pass for k in stage_engine.QG_CHECKS}
|
||||
patched["check_security_gate"] = security_result
|
||||
monkeypatch.setattr(stage_engine, "QG_CHECKS", patched)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 — FAIL -> rollback to development + enqueue developer + notify.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_fail_rolls_back_and_enqueues_developer(monkeypatch):
|
||||
"""TC-16: security_status FAIL -> rollback deploy-staging -> development,
|
||||
enqueue developer, Plane comment + notify_qg_failure."""
|
||||
_qg_with_security(monkeypatch, _fail("2 secret(s): aws-key in src/x.py:3"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.advanced is False
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
jobs = _jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "developer"
|
||||
assert res.qg_name == "check_security_gate"
|
||||
# The deployer-authored Plane comment + the QG-failure notification fired.
|
||||
assert stage_engine.plane_add_comment.called
|
||||
assert stage_engine.notify_qg_failure.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 — the rollback task_desc carries the verbatim findings (ORCH-046).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_task_desc_has_verbatim_findings(monkeypatch, tmp_path):
|
||||
"""TC-17: the re-launched developer's task_desc embeds the verbatim finding
|
||||
substance (not just a link), following the ORCH-046 pattern."""
|
||||
reason = "2 secret(s): aws-access-key in src/config.py:12"
|
||||
_qg_with_security(monkeypatch, _fail(reason))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
# Isolate the worktree base under tmp_path so this test never touches the real
|
||||
# shared /repos/_wt host path (PermissionError in CI). Mirrors the pattern in
|
||||
# tests/test_git_worktree.py / test_merge_gate.py.
|
||||
from src import git_worktree
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
|
||||
|
||||
# Seed a real 17-security-report.md in the worktree so extract_security_findings
|
||||
# has a verbatim body to excerpt.
|
||||
wt = stage_engine.get_worktree_path("enduro-trails", _BRANCH)
|
||||
report_dir = os.path.join(wt, "docs", "work-items", "ORCH-022")
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
with open(os.path.join(report_dir, "17-security-report.md"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"---\nsecurity_status: FAIL\nsecrets_found: 1\n---\n"
|
||||
"# Security Report — ORCH-022\n\n"
|
||||
"## Verdict\n1 secret(s): aws-access-key in src/config.py:12\n\n"
|
||||
"## Secrets\n- `src/config.py:12` — aws-access-key (match `AKIA…YZ`)\n\n"
|
||||
"## Dependencies (blocking)\n- None\n"
|
||||
)
|
||||
|
||||
advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# The verbatim reason AND the excerpted finding line are present.
|
||||
assert "aws-access-key in src/config.py:12" in desc
|
||||
assert "src/config.py:12" in desc
|
||||
# Plus the link to the full artefact.
|
||||
assert "17-security-report.md" in desc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 — after MAX_DEVELOPER_RETRIES -> block + Telegram, no bounce.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_retry_cap_blocks_and_alerts(monkeypatch):
|
||||
"""TC-18: after MAX_DEVELOPER_RETRIES developer attempts -> set_issue_blocked +
|
||||
Telegram alert; no infinite bounce (no new developer job)."""
|
||||
_qg_with_security(monkeypatch, _fail("blocking CVE"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
_add_developer_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.rolled_back_to == "development"
|
||||
assert res.alerted is True
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
# No further developer job past the cap.
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-19 — PASS -> the pipeline advances normally.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc19_pass_advances_normally(monkeypatch):
|
||||
"""TC-19: security_status PASS -> advance deploy-staging -> deploy with the
|
||||
deployer launched, no rollback, no QG-failure notification."""
|
||||
_qg_with_security(monkeypatch, lambda *a, **k: (True, "security clean"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.advanced is True
|
||||
assert res.to_stage == "deploy"
|
||||
assert _stage(task_id) == "deploy"
|
||||
assert res.rolled_back_to is None
|
||||
# No noisy QG-failure notification on the happy path.
|
||||
assert not stage_engine.notify_qg_failure.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-21 — self-hosting safety: a FAIL never deploys / restarts prod.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc21_fail_never_triggers_deploy(monkeypatch):
|
||||
"""TC-21: on a security FAIL the gate only rolls back + enqueues developer; it
|
||||
never calls the deploy hook / restarts the prod container (self-hosting safety)."""
|
||||
_qg_with_security(monkeypatch, _fail("secret found"))
|
||||
# Spy on the self-deploy entrypoints — none must be invoked on a FAIL.
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", MagicMock())
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "self_deploy_applies", MagicMock(return_value=True))
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.rolled_back_to == "development"
|
||||
# The security FAIL returns BEFORE the self-deploy block -> no deploy initiated.
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
# Only the developer is re-enqueued; no deployer job.
|
||||
jobs = _jobs()
|
||||
assert all(j["agent"] == "developer" for j in jobs)
|
||||
@@ -68,18 +68,10 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p
|
||||
@patch("src.plane_sync.httpx.patch")
|
||||
@patch("src.plane_sync.find_issue_id", return_value="issue-uuid")
|
||||
@patch("src.plane_sync._resolve_project_id", return_value="proj-1")
|
||||
def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch):
|
||||
# ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes.
|
||||
# deploy still has no board status here (driven by Phase A/B/C) -> no-op.
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.return_value = None
|
||||
mock_patch.return_value = resp
|
||||
|
||||
def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch):
|
||||
# analysis has no dedicated board status -> no PATCH at all.
|
||||
PS.set_issue_stage_state("ET-1", "analysis")
|
||||
# analysis aliases in_progress when the Analysis status is absent.
|
||||
assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"]
|
||||
|
||||
mock_patch.reset_mock()
|
||||
mock_patch.assert_not_called()
|
||||
PS.set_issue_stage_state("ET-1", "deploy")
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user