diff --git a/.env.example b/.env.example index 40423d9..37f8fa3 100644 --- a/.env.example +++ b/.env.example @@ -199,3 +199,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3 ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5 ORCH_POST_DEPLOY_AUTO_ROLLBACK=false ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500 + +# ── QG-0 entry validation (ORCH-069) ────────────────────────────────────────── +# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char +# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the +# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully +# degrades to 200 (the process never crashes on startup). +ORCH_QG0_TITLE_MAX=200 diff --git a/.env.staging.example b/.env.staging.example index f3af589..722ed25 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0 DEPLOY_SSH_USER=slin DEPLOY_SSH_HOST=127.0.0.1 DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh + +# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200. +ORCH_QG0_TITLE_MAX=200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5361f..2692a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Конфигурируемый верхний лимит длины заголовка QG-0 (`ORCH_QG0_TITLE_MAX`, дефолт 200)** (ORCH-069): хардкод `if len(name) > 80` во входной валидации `_qg0_errors` (`src/webhooks/plane.py`) вынесен в настраиваемый параметр `Settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). Лимит 80 был гигиеническим, а не структурным (slug режется независимо `[:30]`, `tasks.title TEXT` без ограничения), поэтому валидные заголовки 81–200 символов отклонялись на входе без бизнес-причины. Лимит читается из `settings.qg0_title_max` динамически на каждый вызов (тесты патчат значение), текст ошибки подставляет актуальное число; граница строгая (`len > limit` → FAIL, `len == limit` → PASS). **Graceful-деградация (AC-3, self-hosting safety):** пустое/нечисловое значение env не роняет процесс на старте — `field_validator(mode="before")` `_qg0_title_max_default` в `src/config.py` перехватывает сырое env ДО `int`-парсинга pydantic и при невалидном/пустом входе возвращает дефолт 200 (never-raise), гася `ValidationError`. Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят (AC-7). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (QG-0 — inline-валидация входа, не зарегистрированный stage-gate), схема БД, slug-логика `[:30]`, нижние лимиты (`< 5` title, `< 20` description), soft-QG-0 поведение (warning на `work_item.created`), API. ADR `docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md`. Документация: `.env.example`, `.env.staging.example`. Тесты: `tests/test_qg0_title_limit.py`. - **Merge-в-`main` + пост-деплой верификация как обязательное условие `done` (фикс «фантомного merge»)** (ORCH-071): задача могла дойти до `done`, хотя ветка фактически НЕ влита в `main` («фантомный merge») — конвейер рапортовал успех без реального состояния репозитория. Введён под-гейт ребра `deploy → done`: единственная точка перехода `advance_stage` теперь гейтится `_handle_merge_verify` (`src/stage_engine.py`), который покрывает ВСЕ пути финализации (finalizer Phase C, reconciler F-1, job-reaper). Добавлены детерминированный merge-актор и пост-деплой верификатор (`src/merge_gate.py`): merge выполняется ТОЛЬКО через PR-merge API (без push/force-push, INV-4) в restart-surviving Phase C, верификация подтверждает фактическое слияние в `main` прежде чем разрешить переход в `done`. Раскат условный и снабжён kill-switch (`src/config.py`, `src/main.py`, по образцу условности ORCH-35/43/58), never-raise контракты соблюдены. Документация: глобальный `docs/architecture/adr/adr-0013-merge-verify-gate.md`, детальный `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` (D1–D9), раздел в `docs/architecture/README.md`, runbook постмортема `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки + критерий «фантом подтверждён» + remediation). Тесты: `tests/test_merge_actor.py`, `tests/test_merge_verify.py`, `tests/test_deploy_finalizer_merge_gate.py`, `tests/test_deploy_restart_merge_recovery.py`, `tests/test_qg_checks.py`, `tests/test_stages.py`. - **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `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). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Выделенный статус-триггер прод-деплоя «Confirm Deploy»** (ORCH-059): жест запуска прод-деплоя отделён от человеческого гейта одобрения. Раньше один Plane-статус `Approved` был перегружен: на `analysis` он работал как человеческий гейт BRD (`check_analysis_approved`), а на `deploy` — молча триггерил Фазу B прод-деплоя ORCH-036 (`advance_stage(deploy, finished_agent=None) → _handle_self_deploy_phase_b → detached host-рестарт прод-контейнера 8500`). Привычный жест approve = групповой self-hosting риск (прод обслуживает ВСЕ проекты из одного инстанса). ORCH-059 вводит отдельный логический статус `confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на `deploy`; `Approved` остаётся исключительно гейтом конвейера. Четыре точечные правки в трёх модулях: (1) `src/plane_sync.py` — маппинг `"Confirm Deploy" → "confirm_deploy"` в `_PLANE_NAME_TO_KEY`; ключ намеренно НЕ добавлен в `_DEFAULT_STATES` (нет UUID для enduro/fallback) → **fail-closed**: для проекта ORCH резолвится из живого Plane API (`get_project_states(orch)["confirm_deploy"]` → реальный UUID), для сред без статуса (enduro / недоступный API / доска без статуса) ключ просто отсутствует, доступ через `.get("confirm_deploy")` → `None`, без `KeyError`. (2) `src/webhooks/plane.py` — `handle_issue_updated` ДО ветки `approved` добавляет fail-closed-ветку `confirm_state = proj_states.get("confirm_deploy"); if confirm_state and new_state == confirm_state: handle_confirm_deploy(...)`; новый `handle_confirm_deploy` резолвит задачу, гард `stage == "deploy"` (иначе no-op с логом — защищает прочие гейты от случайного триггера), иначе → `_try_advance_stage(..., confirm_deploy=True)`. `handle_verdict(approved=True)` не изменён (продолжает звать `_try_advance_stage` с дефолтным `confirm_deploy=False`). (3) `src/stage_engine.py` — `advance_stage` получил keyword-only параметр `confirm_deploy: bool = False` (обратносовместимо: все существующие вызовы из launcher/reconciler/finalizer передают `finished_agent`); блок Фазы B теперь **всегда возвращается рано** для `deploy + finished_agent is None` self-hosting, но `_handle_self_deploy_phase_b` вызывается ТОЛЬКО при `confirm_deploy=True`, иначе (обычный `Approved`) — детерминированный **no-op** (`result.note = "approved-on-deploy-noop"`): возврат ДО блока Quality Gate → `check_deploy_status` не запускается → нет ложного отката БАГ-8 (вердикта ещё нет, R-2). (4) CTA Фазы A (`_handle_self_deploy_phase_a`) — Plane-коммент и Telegram просят перевести задачу в статус «Confirm Deploy» (а не «Approved»). Следствие для reconciler F-1 на `deploy` (ORCH-053): попадает в no-op-ветку вместо неявного запуска Фазы B → прод-деплой нельзя инициировать автоматически, только явным человеческим «Confirm Deploy» (усиление safety). Условность как ORCH-35/36 (реально только для `self_deploy.self_deploy_applies("orchestrator")`; прочие репо — прежний синхронный ssh-деплой агентом, статус не нужен и не влияет). Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-код-контракт хука (0/1/2), Фазы A/C, merge-gate, terminal-sync, схема БД (статусы — на стороне Plane; restart-safe состояние деплоя — существующие sentinel-файлы ORCH-036). Эксплуатационное предусловие: в Plane-проекте ORCH создать статус доски «Confirm Deploy» (точное имя, регистр) + сброс кэша состояний — `docs/work-items/ORCH-059/07-infra-requirements.md`. До создания статуса прод-деплой через approve не запустится (желаемое fail-closed-поведение). ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет триггер Фазы B относительно adr-0007). Тесты: `tests/test_plane_states.py`, `tests/test_plane_confirm_deploy.py`, `tests/test_stage_engine_phase_b.py`, `tests/test_stage_engine_phase_a_cta.py`, `tests/test_confirm_deploy_integration.py`, `tests/test_deploy_approve.py` (обновлён под новый триггер). diff --git a/src/config.py b/src/config.py index b9ad1e3..6f36681 100644 --- a/src/config.py +++ b/src/config.py @@ -1,3 +1,4 @@ +from pydantic import field_validator from pydantic_settings import BaseSettings @@ -407,6 +408,24 @@ class Settings(BaseSettings): # Неизвестное/пустое значение трактуется как edit (см. notifications). tracker_mode: str = "edit" + # ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char + # cap was a hygiene limit, not structural (slug is cut to [:30] independently, + # DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default + # 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). + qg0_title_max: int = 200 + + @field_validator("qg0_title_max", mode="before") + @classmethod + def _qg0_title_max_default(cls, v): + # Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the + # process must not crash on startup. Never raises (self-hosting safety). + try: + if v is None or (isinstance(v, str) and v.strip() == ""): + return 200 + return int(v) + except (TypeError, ValueError): + return 200 + class Config: env_prefix = "ORCH_" env_file = ".env" diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 875f54a..4bdaf0c 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list: errors = [] if not name or len(name) < 5: errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") - if len(name) > 80: - errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + if len(name) > settings.qg0_title_max: + errors.append( + f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 " + f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)" + ) if not description or len(description.strip()) < 20: errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") diff --git a/tests/test_qg0_title_limit.py b/tests/test_qg0_title_limit.py new file mode 100644 index 0000000..44b2962 --- /dev/null +++ b/tests/test_qg0_title_limit.py @@ -0,0 +1,117 @@ +"""ORCH-069: unit tests for the configurable QG-0 title-length limit. + +Covers `_qg0_errors` (src/webhooks/plane.py) reading the upper title limit +dynamically from `settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, default 200), +plus the graceful env-degradation field-validator on `Settings`. + +The tests patch `src.config.settings.qg0_title_max` (the same object imported into +`src.webhooks.plane`) and assert boundary behaviour and error texts. For env-driven +cases a FRESH `Settings()` instance is created locally, since the module-level +singleton is built once on import. +""" + +import re + +import pytest + +from src.config import Settings, settings +from src.webhooks.plane import _qg0_errors + +VALID_DESCRIPTION = "x" * 30 # >= 20 chars, always passes the description check + + +def _title_length_error(errors): + """Return the title length-limit error string, or None if absent. + + The short-title error ('нужно >= 5') and the description error are excluded; + only the 'too long' title error is matched (it contains 'максимум'). + """ + for e in errors: + if "Title" in e and "максимум" in e: + return e + return None + + +# --- AC-1: default limit 200, boundary at 201 ------------------------------ + +def test_tc01_default_limit_200_boundary_pass(monkeypatch): + """TC-01: title of exactly 200 chars -> no title length error (PASS).""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 200, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc02_default_limit_200_boundary_fail(monkeypatch): + """TC-02: title of 201 chars -> length error mentioning '200'.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 201, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "200" in err + + +# --- AC-2: configurable limit 120, boundary at 121 ------------------------- + +def test_tc03_custom_limit_120_boundary_pass(monkeypatch): + """TC-03: with limit 120, a 120-char title passes.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 120, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc04_custom_limit_120_boundary_fail(monkeypatch): + """TC-04: with limit 120, a 121-char title fails; text mentions 120 not 80.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 121, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "120" in err + assert "80" not in err + + +# --- AC-3: graceful handling of invalid/empty env -------------------------- + +def test_tc05_graceful_non_numeric_env(monkeypatch): + """TC-05: non-numeric env -> Settings() does not raise, limit == 200.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "abc") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc06_graceful_empty_env(monkeypatch): + """TC-06: empty-string env -> default 200, no exception.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc07_valid_numeric_env(monkeypatch): + """TC-07: valid numeric env -> the given value is applied (positive path).""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "150") + s = Settings() + assert s.qg0_title_max == 150 + + +# --- AC-4: lower limits unchanged ------------------------------------------ + +def test_tc08_short_title_still_errors(monkeypatch): + """TC-08: title < 5 chars still raises the short-title error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("abc", VALID_DESCRIPTION) + assert any("Title" in e and "нужно >= 5" in e for e in errors) + + +def test_tc09_short_description_still_errors(monkeypatch): + """TC-09: description < 20 chars still raises the short-description error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("Valid title", "short") + assert any("Description" in e for e in errors) + + +# --- AC-7: backward compatibility ------------------------------------------ + +def test_tc10_backward_compat_titles_81_to_200(monkeypatch): + """TC-10: a title previously rejected by the 80-char cap now passes at 200.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 100, VALID_DESCRIPTION) + assert _title_length_error(errors) is None