From edafd63c74a71ac6a8df7ad4ac02b023620d18a6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 2 Jun 2026 18:03:21 +0000 Subject: [PATCH] architect(ET): auto-commit from architect run_id=54 --- docs/work-items/ET-011/01-brd.md | 347 +++++++++++ docs/work-items/ET-014/02-trz.md | 587 ++++++++++++++++++ .../ET-014/03-acceptance-criteria.md | 230 +++++++ 3 files changed, 1164 insertions(+) create mode 100644 docs/work-items/ET-011/01-brd.md create mode 100644 docs/work-items/ET-014/02-trz.md create mode 100644 docs/work-items/ET-014/03-acceptance-criteria.md diff --git a/docs/work-items/ET-011/01-brd.md b/docs/work-items/ET-011/01-brd.md new file mode 100644 index 0000000..f85f589 --- /dev/null +++ b/docs/work-items/ET-011/01-brd.md @@ -0,0 +1,347 @@ +--- +type: brd +work_item_id: ET-011 +title: "BRD: Деплой и rollback без git checkout в shared /repos (S-2/S-3)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-010" + - "ET-013" +labels: + - "infra" + - "orchestrator" + - "deployer" + - "S-2" + - "S-3" +--- + +# BRD — ET-011: Деплой и rollback без git checkout в shared /repos (S-2/S-3) + +## 1. Цель + +Устранить из работы **deployer-агента** все git-операции, которые +мутируют **состояние рабочей копии** (HEAD, working tree, index) +shared-репозитория `/repos/enduro-trails`. Сейчас deployer-агент +выполняет `git checkout` (rollback к предыдущему тегу), а также +делает в shared-репозитории операции tagging-а и git-commit+push +deploy-лога / CHANGELOG'а — при появлении параллельных задач это +ломает рабочие копии других активных work item'ов +(см. `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные +ограничения» и `BUGFIXES_2026-06-02.md` § «Что НЕ входило»). + +ET-011 закрывает **S-2** (deploy/tag/changelog без mutating-checkout) и +**S-3** (rollback без mutating-checkout). Целевое состояние: +**ни одна команда deployer-агента не меняет HEAD/working-tree/index в +`/repos/enduro-trails`**; все мутации продуктового кода и тегов идут +либо через **Gitea API** (refs, теги, content), либо через **SSH-хук на +deploy-хосте** (`/home/slin/repos/enduro-trails` — это уже отдельная +рабочая копия на mva154, к ней shared-`/repos` ограничение не относится). + +ET-011 — **«deployer перестаёт трогать общую рабочую копию»**, новых +фич для пользователя не добавляет. + +## 2. Контекст + +### 2.1 Архитектура orchestrator (текущая) + +- Все агенты (analyst → architect → developer → reviewer → tester → + deployer) и QG `check_tests_local` работают в **общей рабочей копии** + `/repos/` внутри контейнера orchestrator + (см. `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные + ограничения»). +- launcher.py перед запуском каждого агента делает + `git fetch origin && git checkout ` в этой копии. +- После завершения агента launcher.py делает `git add docs/` (+ + `src/`/`tests/` для developer), `git commit`, `git push origin + ` — также в этой копии. +- Параллельная работа двух задач сейчас «приемлемо» работает только + потому, что задачи идут последовательно. F-2b (ET-010) и S-4 + (ET-013) меняют это. + +### 2.2 Что делает deployer сейчас (`.openclaw/agents/deployer.md`) + +| Шаг | Команды | Затрагивает working tree shared /repos? | +|-----|---------|------------------------------------------| +| 1. Merge PR | Gitea API `POST /pulls/{n}/merge` | Нет (remote-only) | +| 2. Tag | `git describe --tags --abbrev=0`, `git fetch origin main`, `git tag $NEW_TAG origin/main`, `git push origin $NEW_TAG` | **Refs изменяются (теги), working tree — нет**, но команды выполняются в shared-копии | +| 3. Deploy | `ssh slin@host bash /home/slin/bin/enduro-deploy-hook.sh` | Нет (SSH-хук на host) | +| 4. Healthcheck | `curl` | Нет | +| 5. Smoke | `curl` | Нет | +| 6. Rollback | **`git checkout $LAST_TAG`** в shared `/repos/enduro-trails` | **Да — меняет HEAD и working tree** | +| 7. Финализация | Запись `docs/work-items//14-deploy-log.md` + правка `CHANGELOG.md` + `Commit + push в main` | **Да — пишет файлы в working tree shared-копии; commit/push выполняет orchestrator post-run hook (`_monitor_agent`)** | + +### 2.3 Связанные исправления orchestrator + +- **S-1** (готово, см. `BUGFIXES_2026-06-02.md`): `check_tests_local` + делает `git checkout` в shared-репо — известное ограничение, + не входит в ET-011, закрывается S-4. +- **S-4 = ET-013** (в работе): git worktree per task — каждой задаче + отдельный worktree, после чего `check_tests_local` и любой агент + изолированы. +- **F-2b = ET-010** (в работе): очередь задач вместо in-process + daemon-потоков. После F-2b и S-4 несколько задач могут идти + параллельно безопасно — но **только если deployer перестанет + трогать общую копию `/repos`** (что и есть ET-011). + +ET-011 ортогонален S-4: даже когда worktree-per-task появится, у +deployer-а **нет своего worktree**, потому что финальные операции +(tag main, commit changelog в main, rollback main) не привязаны к +ветке задачи — они должны идти в main. Делать «worktree main для +deployer» возможно, но в архитектуре orchestrator это сложнее, чем +вынести их в API + SSH-хук. Поэтому ET-011 — самостоятельная задача. + +### 2.4 Pain-points текущего поведения + +1. **Rollback ломает чужие задачи** (S-3). `git checkout $LAST_TAG` + переводит HEAD shared-копии в detached на тег. Если в этот момент + другая задача (ET-012, ET-014…) ожидает агента на своей + feature-ветке, launcher сделает `git checkout ` поверх — + но между rollback'ом deployer-а и следующим запуском агента уже + мог отработать `check_tests_local` или webhook → ошибочные + проверки на коде из тега, а не из feature-ветки. Деплоер может + также сам успеть запушить «откат» в неправильную ветку, если + `_monitor_agent` commit+push сработает на detached HEAD (текущий + `_monitor_agent` делает `git checkout ` перед commit, но + если deployer уже сделал `git checkout $LAST_TAG`, то rollback + фактически **не зафиксирован нигде**, кроме detached HEAD, — он + просто исчезает при следующем checkout). +2. **Rollback фактически бесполезен** (S-3, regression). Реальный + код, который запущен в продакшне на test-сервере, лежит в + `/home/slin/repos/enduro-trails` (отдельная рабочая копия) и в + запущенном Docker-контейнере. Локальный `git checkout` в + `/repos/enduro-trails` контейнера orchestrator **не влияет** на + развёрнутый сервис. Сейчас «rollback» в deployer.md — иллюзия: + единственный реальный эффект — поломка соседних задач. +3. **Tag и changelog-commit в shared-копии** (S-2). При параллельных + задачах `git tag origin/main` сам по себе безопасен (ref-only), + но `git push origin $NEW_TAG` в shared-копии не страшен. Реальная + проблема — **запись `14-deploy-log.md` + правка `CHANGELOG.md`**: + эти файлы пишутся в working tree shared-копии, а затем + orchestrator-овский post-run commit+push отправляет их в + `agent_branch` (= ветка задачи, например, `feature/ET-011-…`), + что **неверно**: deploy-лог и CHANGELOG должны идти в `main` + через отдельный deploy-PR. Сейчас это делается вручную (см. + ET-009 deploy-log: создавалась ветка `deploy/ET-009-v0.0.2`, PR + #17, потом ещё PR #18 для дополнения), и шаги «Commit + push в + main» в deployer.md — нечестные, потому что main защищён. + +### 2.5 Существующая инфраструктура, которую можно использовать + +- **Gitea API** уже используется в шаге 1 (Merge PR) с токеном + `$ORCH_GITEA_TOKEN`. Эндпоинты, которые понадобятся: + - `POST /repos/{owner}/{repo}/tags` — создание тега на коммите + (без `git tag` локально); + - `POST /repos/{owner}/{repo}/branches` — создание новой ветки от + base-ветки/коммита (для deploy-PR); + - `GET /repos/{owner}/{repo}/contents/{filepath}?ref=` — + чтение текущего содержимого `CHANGELOG.md` в main; + - `POST /repos/{owner}/{repo}/contents/{filepath}` — создание + файла (`14-deploy-log.md`) в новой ветке; + - `PUT /repos/{owner}/{repo}/contents/{filepath}` — обновление + файла (`CHANGELOG.md`) с указанием SHA текущего содержимого; + - `POST /repos/{owner}/{repo}/pulls` — создание deploy-PR; + - `POST /repos/{owner}/{repo}/pulls/{n}/merge` — merge deploy-PR + (уже используется в шаге 1); + - `GET /repos/{owner}/{repo}/tags` или `GET .../releases/latest` — + получить предыдущий тег для rollback и для NEW_TAG расчёта (без + локального `git describe`). +- **SSH deploy-хук** `/home/slin/bin/enduro-deploy-hook.sh` уже есть и + используется в шаге 3. По договорённости в `runbook.md`, + ручной деплой выполняет `git pull origin main && docker compose up + -d app`. Хук скрипт делает то же самое (см. ET-009 deploy-log + §1.2: «`enduro-deploy-hook.sh` fails on its first `echo … >> $LOG` + с `set -e`. Currently deploys bypass the hook and run the steps + manually via SSH»). Нужно: **расширить deploy-хук параметром + `ref` (default: `main`), починить `$LOG`, и добавить + rollback-режим**, который делает `git fetch && git -c + advice.detachedHead=false checkout ` на хосте, не + трогая shared `/repos`. +- **Plane API** уже используется для статусов и комментариев + (`plane_add_comment`, `set_issue_blocked`). Можно не трогать. + +## 3. Scope + +### In scope + +| # | Функция | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| F-01 | Переписать `.openclaw/agents/deployer.md` так, чтобы **ни одна команда не делала `git checkout`, `git pull`, `git merge`, `git reset`, `git stash`, `git rebase`, `git revert` (с изменением working tree), `git restore` (с изменением working tree), `git apply`, `git am`** в `/repos/enduro-trails`. | +| F-02 | Tag для нового релиза (S-2) создаётся через **Gitea API** `POST /repos/admin/enduro-trails/tags` на коммите merge'а PR. Локальная команда `git tag … && git push origin ` запрещена. | +| F-03 | Получение предыдущего тега для расчёта `NEW_TAG` и для rollback идёт через **Gitea API** `GET /repos/admin/enduro-trails/tags?limit=1&sort=newest` (или `GET .../releases/latest`). Локальная команда `git describe` запрещена. | +| F-04 | Запись `docs/work-items//14-deploy-log.md` и обновление `CHANGELOG.md` (S-2) идёт через **Gitea API** в отдельной ветке `deploy/-vX.Y.Z`, после чего создаётся PR `deploy/-vX.Y.Z → main` и мерджится через API. Никаких локальных правок этих файлов в shared `/repos/enduro-trails`. | +| F-05 | Rollback (S-3): локальный `git checkout $LAST_TAG` удалён. Вместо него deployer вызывает SSH-хук `enduro-deploy-hook.sh --rollback --to ` на deploy-хосте. Хук делает `git fetch && git checkout ` в `/home/slin/repos/enduro-trails` (НЕ в shared `/repos`) и `docker compose up -d app`. | +| F-06 | `/home/slin/bin/enduro-deploy-hook.sh` расширен:
(a) принимает аргументы `--ref ` (default `main`) и `--rollback` (флаг, при котором ref интерпретируется как rollback-таргет — лог-сообщение другое, плюс отметка в `/var/log/enduro-trails/deploy-hook.log`);
(b) исправлен баг с `$LOG` (см. ET-009 deploy-log §1.2): создаёт `mkdir -p $(dirname $LOG)` и `touch $LOG` до первого `echo`, либо логирует в `/tmp/enduro-deploy-hook.log` с fallback при отсутствии прав на `/var/log/enduro-trails/`. | +| F-07 | Скрипт `scripts/enduro-deploy-hook.sh` живёт **в репозитории** (а не только на хосте) — копируется на хост через системный путь, version-controlled, тестируем. Текущий `/home/slin/bin/enduro-deploy-hook.sh` остаётся симлинком/копией версии из репо. (Если копирование на хост — отдельная операция, она документируется в `runbook.md`.) | +| F-08 | Smoke-тесты выполняются после rollback так же, как после deploy: 4-й и 5-й шаги в deployer.md выполняются **до и после** rollback'а, чтобы убедиться, что rollback восстановил работающее состояние. Если smoke после rollback тоже падает — Telegram-эскалация с пометкой `ROLLBACK FAILED`, статус задачи остаётся `deploy → development` (как сейчас в `launcher._monitor_agent` deployer-failure-branch). | +| F-09 | Шаг «получить ветку из `.task-deploy.md`» работает через **чтение файла** (`grep "^Branch:" .task-deploy.md`), без `git` команд. Это уже не git-операция, оставляем как есть. | +| F-10 | В `deployer.md` явный список **разрешённых git-операций в shared-копии**: только read-only `git log `, `git show `, `git rev-parse `, `git ls-remote`, `git cat-file`, `git diff ..` без `--apply`. Любые ref-only мутации (`git fetch` для обновления `origin/*`) тоже разрешены — они не меняют working tree. Запрещены все команды из F-01. | +| F-11 | Запрет проверяется автоматически: linter-скрипт `scripts/lint_deployer_prompt.py` парсит `.openclaw/agents/deployer.md` и падает с exit-1, если внутри ` ```bash` блоков встречаются регэкспы `git checkout`, `git pull`, `git merge`, `git reset`, `git revert`, `git restore`, `git stash`, `git rebase`, `git apply`, `git am`, `git tag\s+\w+\s+`, `git push\s+\w+\s+(refs/tags|v\d)`. Запуск линтера добавляется в `make lint`. | +| F-12 | Тест автономности: integration-тест в `tests/integration/test_deployer_prompt.py` гоняет линтер F-11 в pytest. Дополнительно проверяет, что deployer.md содержит обязательные секции: `## 1. Merge PR`, `## 2. Tag`, `## 3. Deploy`, `## 4. Healthcheck`, `## 5. Smoke`, `## 6. Rollback`, `## 7. Финализация`, и что в каждой секции нет запрещённых команд. | +| F-13 | Документация: добавить в `docs/operations/runbook.md` раздел `## Rollback`, описывающий:
(a) автоматический rollback через deployer (вызывается при smoke-fail);
(b) ручной rollback оператором (`ssh slin@mva154 'bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to vX.Y.Z'`);
(c) как узнать предыдущий тег (`curl -s http://localhost:3000/api/v1/repos/admin/enduro-trails/tags?limit=5`). | +| F-14 | Документация: обновить `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные ограничения», убрав S-2/S-3 из NOT-входило списка (или пометив как `[DONE in ET-011]`). Файл не входит в `enduro-trails`, но обновление делает не deployer, а владелец orchestrator-репо (этот пункт — out of scope для коммитов ET-011, но **зафиксирован как зависимый** в § 7). | +| F-15 | Smoke-проверка `make lint`/`make test` на финальной версии `deployer.md` зелёная, новый линтер F-11 проходит. | + +### Out of scope + +- **S-4 / ET-013 (git worktree per task)** — отдельная задача. ET-011 + не вводит worktree-инфру в orchestrator; deployer и без неё + становится безопасным для shared-копии. +- **F-2b / ET-010 (очередь задач)** — отдельная задача. ET-011 + ортогонален. +- **Изменения кода `src/api/`, `src/web/`, `tests/`** — продуктовый код + ET-011 не трогает. Меняется только `.openclaw/agents/deployer.md`, + `scripts/enduro-deploy-hook.sh` (новый или обновлённый), + `scripts/lint_deployer_prompt.py` (новый), `tests/integration/...` + (новые), `docs/operations/runbook.md`, `Makefile` (новая lint-цель). +- **Изменения `orchestrator/src/`** — out of scope, в ET-011 не + коммитятся (см. § 7). +- **Замена rollback-стратегии на blue/green или canary** — out of + scope. Стратегия остаётся: rollback = redeploy предыдущего тега на + хосте. +- **Перенос secrets** (`ORCH_GITEA_TOKEN`, `DEPLOY_SSH_USER` и т.д.) — + они уже доступны deployer-агенту, ET-011 не меняет способ их + доставки. +- **Покрытие edge-кейса «нет предыдущего тега» (первый деплой)** — + для первого деплоя rollback не применим (некуда откатывать). + deployer должен явно сообщить в Telegram «No previous tag, rollback + skipped» и пометить задачу как `deploy → development` (как сейчас). + Этот кейс остаётся как был, без специальной обработки. +- **GUI Plane-комментариев** — формат уже определён в существующем + коде launcher, ET-011 не меняет. + +## 4. Архитектура взаимодействия (high-level) + +``` +orchestrator + └─ launcher.py + └─ запускает deployer в /repos/enduro-trails + ├─ Шаг 1: Gitea API POST /pulls/{n}/merge ── remote + ├─ Шаг 2: Gitea API GET /tags + POST /tags ── remote (S-2) + ├─ Шаг 3: ssh slin@mva154 enduro-deploy-hook.sh ── на host + ├─ Шаг 4: curl healthcheck ── remote + ├─ Шаг 5: curl smoke ── remote + ├─ Шаг 6 (если fail): + │ ssh slin@mva154 enduro-deploy-hook.sh + │ --rollback --to ── на host (S-3) + │ curl smoke after rollback ── remote + └─ Шаг 7: Gitea API создать ветку deploy/-vX ── remote (S-2) + + Gitea API PUT /contents/14-deploy-log + + Gitea API PUT /contents/CHANGELOG + + Gitea API POST /pulls + merge + +Никаких локальных git-команд, мутирующих working tree /repos/enduro-trails. +``` + +## 5. Метрики успеха + +| Метрика | Критерий | +| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Линтер deployer.md | `python scripts/lint_deployer_prompt.py` exit-code 0 на финальной версии deployer.md. | +| Make-цель | `make lint` гоняет линтер F-11. На текущем (старом) deployer.md линтер падает; на новом — проходит. | +| Integration-тест | `pytest tests/integration/test_deployer_prompt.py -v` зелёный. | +| Существующие тесты | `pytest tests/` zero regressions относительно baseline до ET-011. | +| Ручной запуск deploy-хука с `--ref` | `ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh --ref main` exit-code 0, лог в `/var/log/enduro-trails/deploy-hook.log` (или fallback `/tmp/…`) содержит timestamp и ref. | +| Ручной запуск deploy-хука с `--rollback` | `ssh slin@mva154 bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to vX.Y.Z` exit-code 0, в `/home/slin/repos/enduro-trails` `git rev-parse HEAD == vX.Y.Z`, контейнер `enduro-trails-app-1` пересоздан, healthcheck отвечает 200. | +| Shared `/repos/enduro-trails` после deploy | После шагов 1–7 deployer-а в shared-копии `git rev-parse HEAD` остаётся таким же, как до запуска deployer-а; `git status` не показывает modified-файлов (специально для проверки в integration-тесте). | +| Shared `/repos/enduro-trails` после rollback | После шагов 1–6 (с rollback) shared-копия точно так же не изменилась: HEAD и working tree совпадают с pre-deployer состоянием. | +| Документация runbook.md | `docs/operations/runbook.md` содержит секции `## Deploy` (если её ещё нет) и `## Rollback` с примерами команд `ssh … --rollback --to vX.Y.Z`. | +| Документация архитектуры | `/repos/orchestrator/docs/ARCHITECTURE.md` — обновление помечено в § 7 как зависимое; ET-011 не закрывает этот пункт сам, но фиксирует follow-up. | +| Реальный deploy следующей задачи | После merge ET-011 первый же реальный deploy (например, ET-013 после её завершения) идёт через новый deployer.md, deploy-log выглядит как раньше, smoke PASS, shared-копия не меняется. | + +## 6. Риски + +| # | Риск | Вероятность | Влияние | Митигация | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R-1 | Gitea API `POST /repos/{owner}/{repo}/tags` не поддерживается в установленной версии Gitea (старая версия) → tag нельзя создать через API. | Низкая | Высокое | Pre-check на этапе TRZ/архитектора: `curl -s -X OPTIONS http://localhost:3000/api/v1/swagger` или прямой `POST` на dummy-тег. Если API недоступен — fallback на SSH к хосту с `git tag && git push` (но НЕ в shared `/repos`, а в `/home/slin/repos/enduro-trails`). | +| R-2 | Gitea API `PUT /contents/{filepath}` требует SHA текущего файла; race-condition при параллельной правке CHANGELOG.md другой задачей. | Низкая | Среднее | Перечитывать SHA перед PUT (читать `GET /contents/CHANGELOG.md?ref=main`, потом PUT). При 409 — повторить до 3 раз. Это стандартный паттерн Gitea/GitHub. | +| R-3 | Rollback-хук на хосте делает `git checkout ` в `/home/slin/repos/enduro-trails` — детачит HEAD. Если оператор потом руками сделает `git pull origin main` — состояние сольётся обратно. | Средняя | Низкое | Документировать в runbook: после rollback оператор должен сделать `git checkout main && git pull` только когда задача с фиксом готова и снова задеплоена. До этого `/home/slin/repos/enduro-trails` остаётся на rollback-теге. | +| R-4 | Smoke after-rollback пройдёт случайно (например, если nginx уже отдаёт правильный код) и deployer ошибочно решит, что всё ок — но на самом деле контейнер не пересоздан. | Низкая | Среднее | Smoke after-rollback включает проверку `docker exec enduro-trails-app-1 git rev-parse HEAD` через хук (хук возвращает в stdout текущий SHA контейнера), deployer сравнивает с ожидаемым `LAST_TAG`. | +| R-5 | Нет предыдущего тега (первый деплой) → шаг 6 не может выполнить rollback. | Средняя | Низкое | См. § 3 out-of-scope: deployer пишет в Telegram «No previous tag, rollback skipped», ставит задачу в `development` (как сейчас). Никаких изменений архитектуры. | +| R-6 | Линтер F-11 ложно срабатывает на строку «`git checkout`» в `## Запрещено`-секции (которая описывает, что нельзя). | Высокая | Низкое | Линтер парсит **только** ` ```bash`-блоки, не свободный текст. Markdown-парсинг через `markdown-it-py` или простой стейт-машиной по `^```` `bash$` / `^```` `$`. | +| R-7 | SSH к хосту падает (контейнер orchestrator потерял доступ) → deployer.shell вообще не может ни deploy, ни rollback сделать. | Низкая | Высокое | Это уже существующий risk (шаг 3 deploy так работает с e2bf99d). ET-011 не ухудшает. В Telegram-эскалации deployer пишет последние 200 строк stderr SSH. | +| R-8 | Rollback-хук с `set -e` падает на промежуточной команде (например, `docker compose pull` для тега, который локально уже есть) — статус не определён. | Средняя | Среднее | Хук пишет JSON-статус (`{"step": "...", "status": "ok|fail", "details": "..."}`) после каждой команды в stdout. deployer парсит final-JSON. На failure — Telegram + exit-1. | +| R-9 | Запись `14-deploy-log.md` через API создаёт коммиты от лица токена Gitea (`claude-bot` или `admin`), а не от `claude-bot`-Git. У текущих коммитов уже автор `claude-bot`-Gitea — никакой регрессии. | Низкая | Низкое | Token-owner = `admin` в Gitea; в API можно явно передавать `author.name`/`author.email` = `claude-bot@mva154.local`. См. swagger. | +| R-10 | `enduro-deploy-hook.sh` в репозитории и `/home/slin/bin/enduro-deploy-hook.sh` на хосте разойдутся (один обновили, другой нет). | Высокая | Среднее | runbook.md документирует команду синхронизации: `sudo cp /home/slin/repos/enduro-trails/scripts/enduro-deploy-hook.sh /home/slin/bin/ && sudo chmod 755 /home/slin/bin/enduro-deploy-hook.sh`. Архитектор может предложить cron-sync (out of scope ET-011). | +| R-11 | Параллельная задача делает `_monitor_agent` post-run commit прямо в shared `/repos/enduro-trails`, deployer одновременно делает API-операции в main — race-condition на refs (но не на working tree). | Низкая | Низкое | Refs-операции в Gitea сериализуются на стороне сервера; конфликт по PR/тегу проявится как 409, deployer перетранслирует. ET-011 принципиально устраняет working-tree race, ref-race решается S-4 (worktree). | +| R-12 | Старые версии deploy-логов и CHANGELOG'а уже зафиксированы в `main` (ET-008, ET-009) с командой «Commit + push в main», которая фактически делалась через `deploy/...` ветки. После ET-011 формат коммита не меняется. | Низкая | Низкое | Никаких изменений в формате коммитов. Меняется только способ создания (API вместо локальной shared-копии). | +| R-13 | Reviewer/CI принимает изменение deployer.md, но deployer впервые выполнит новый алгоритм только на следующем реальном deploy. Если в новом алгоритме баг — он проявится не сейчас, а на следующей задаче. | Средняя | Среднее | Integration-тест F-12 покрывает структурную целостность deployer.md. **Сухой прогон (dry-run)** — отдельная задача (не входит в ET-011, потому что deployer.md — это prompt, его сложно «протестировать» вне реального запуска агента). См. R-14. | +| R-14 | Невозможно полностью «оттестировать» deployer-prompt без живого Claude CLI + Gitea + SSH. Регрессия может быть найдена только при реальном deploy. | Высокая | Среднее | (а) Линтер + integration-test покрывают «нет запрещённых команд». (б) Раздел AC-09 требует ручной верификации deploy-хука на mva154. (в) deployer.md содержит детальные команды — copy-paste из BRD в TRZ обеспечивает консистентность. | + +## 7. Зависимости + +### 7.1 Файлы и артефакты `enduro-trails` (изменяемые в ET-011) + +| Путь | Тип изменения | Owner agent | +| ------------------------------------------------------------------- | ------------- | ------------ | +| `.openclaw/agents/deployer.md` | rewrite | developer | +| `scripts/enduro-deploy-hook.sh` | new | developer | +| `scripts/lint_deployer_prompt.py` | new | developer | +| `tests/integration/test_deployer_prompt.py` | new | developer | +| `tests/fixtures/deployer/deployer-bad-checkout.md` (anti-pattern) | new | developer | +| `tests/fixtures/deployer/deployer-good.md` (sanity-fixture) | new | developer | +| `Makefile` | edit | developer | +| `docs/operations/runbook.md` | edit | developer | +| `docs/work-items/ET-011/*` (BRD, TRZ, AC, test-plan, ADR) | new | analyst+arch | + +### 7.2 Изменения на инфраструктуре mva154 (out-of-repo) + +- `/home/slin/bin/enduro-deploy-hook.sh` обновляется/заменяется + копией из `scripts/enduro-deploy-hook.sh` (документируется в + runbook.md). Точная процедура копирования — задача deployer-агента + при первом deploy после merge ET-011 или ручная команда + оператора. **Не блокирующая для merge ET-011**, но **блокирующая + для первого использования нового deploy-flow**. +- Права на `/var/log/enduro-trails/`: см. ET-009 deploy-log §1.2 — + `chown slin:slin` или переключить логи в `/tmp/` (по выбору + архитектора в TRZ). Без этого хук падает на первой строке логирования. + +### 7.3 Изменения в `orchestrator` (out-of-repo) + +- `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные + ограничения» — пометить S-2/S-3 как done. +- `/repos/orchestrator/docs/BUGFIXES_2026-06-02.md` (или новый + BUGFIXES_2026-06-04.md) — раздел «ET-011 / S-2 / S-3». +- Эти правки делаются **не в рамках ET-011**, а отдельной задачей в + orchestrator-репо. ET-011 фиксирует follow-up в `13-test-report.md` + и `14-deploy-log.md`. + +### 7.4 Связи с другими work item + +- **ET-010 (F-2b)** — очередь задач. После ET-011 deployer станет + безопасным; параллельный запуск deployer-а и любого другого агента + больше не ломает shared-копию. +- **ET-013 (S-4)** — git worktree per task. ET-011 ортогонален; даже + без worktree-инфры deployer становится безопасным. +- **ET-009** (готово) — `14-deploy-log.md` §1.2 описывает текущие + баги deploy-хука; ET-011 их фиксирует (F-06). +- **ET-008** (готово) — ничего не меняет в pipeline gps-collector. +- **ADR-N (новый)** — архитектор фиксирует решение «деплой/rollback + без mutating-checkout в shared `/repos`», ссылается на текущее BRD. + +### 7.5 Внешние зависимости + +- **Gitea токен** `ORCH_GITEA_TOKEN` уже доступен deployer-агенту. +- **SSH-ключ** orchestrator → mva154 уже настроен (см. e2bf99d). +- **curl, ssh** уже доступны в контейнере orchestrator (используются + в шаге 3 и шагах 4/5 текущей deployer.md). +- **`docker compose`** на хосте уже работает (используется + `enduro-deploy-hook.sh`). + +## 8. Глоссарий + +| Термин | Определение | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Shared `/repos` | Bind-mount `/home/slin/repos:/repos` в контейнер orchestrator. Все агенты видят одну и ту же рабочую копию репозитория. | +| Working tree | Файловая копия репозитория, на которую влияют `git checkout`, `git pull`, `git reset --hard`, `git stash` и т.п. | +| Refs | Указатели в `.git/refs/`: ветки, теги, HEAD. Их могут менять `git tag`, `git push origin `, `git fetch` — это **не** меняет working tree. | +| Deploy host | mva154 (`/home/slin/repos/enduro-trails`) — отдельная рабочая копия, на которой реально запускается `docker compose`. | +| Mutating-checkout | Любая git-команда, меняющая HEAD/working tree/index в shared `/repos`. Запрещено для deployer-агента в рамках ET-011. | +| Read-only / ref-only | Git-команды, которые читают историю или меняют только refs (`git fetch`, `git log`, `git show`, `git ls-remote`). Разрешено. | +| S-2 | Подпроблема: deploy (tag, changelog, deploy-log) без mutating-checkout. Закрывается ET-011 (F-02, F-03, F-04). | +| S-3 | Подпроблема: rollback без mutating-checkout. Закрывается ET-011 (F-05, F-06, F-08). | +| S-4 | Отдельная задача (ET-013): git worktree per task. Не входит в ET-011. | +| F-2b | Отдельная задача (ET-010): очередь задач вместо in-process daemons. Не входит в ET-011. | diff --git a/docs/work-items/ET-014/02-trz.md b/docs/work-items/ET-014/02-trz.md new file mode 100644 index 0000000..450fa4d --- /dev/null +++ b/docs/work-items/ET-014/02-trz.md @@ -0,0 +1,587 @@ +--- +type: trz +work_item_id: ET-014 +title: "ТЗ: [M-7] Идемпотентность webhook (дедуп по delivery-id)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +related: + - "ET-010" +--- + +# ТЗ — ET-014: [M-7] Идемпотентность webhook (дедуп по delivery-id) + +## 1. Терминология + +- **Webhook** — входящий HTTP-запрос `POST /api/webhooks/`, + отправляемый внешней системой (Gitea, GPS-источник, оркестратор и т. п.). +- **Provider** — внешняя система-источник webhook'а; идентификатор в URL + и в `config/webhooks.yaml` (string без пробелов, snake_case). +- **Delivery-id** — уникальный идентификатор конкретной доставки события, + передаваемый отправителем в HTTP-заголовке. Каноническое имя заголовка + `X-Delivery-Id`; per-provider может быть другим (см. `webhooks.yaml`). +- **Replay** — повторная доставка того же события с тем же delivery-id. + Backend должен возвращать сохранённый ответ без повторного вызова + бизнес-логики. +- **Business-handler** — функция-обработчик, вызываемая только при + первичной доставке; пишет в БД, ставит задачи в очередь, шлёт уведомления. + В ET-014 поставляется только заглушка для тестового провайдера. +- **Dedup-store** — таблица `webhook_deliveries` в SQLite, где хранятся + все полученные delivery-id с их обработанными ответами и TTL. +- **TTL** — срок жизни записи в dedup-store. По умолчанию 30 дней + (per-provider override через `ttl_days`). +- **GC** — `purge_expired(conn)`, удаляющая записи с `expires_at < now()`. +- **Strict mode** — поведение по умолчанию: при отсутствии delivery-id + возвращать 400. +- **Lenient mode** — `derive_from_body: true` в конфиге провайдера — + fallback к `sha256(body)` если заголовка нет. + +## 2. Архитектурные опоры + +- FastAPI приложение в `src/api/main.py` уже подключает gps_tracks-роутер: + `app.include_router(gps_router)`. ET-014 повторяет паттерн. +- SQLite-БД `data/gps_tracks.sqlite` уже инициализируется через + `src/api/gps_tracks/db.py:open_db / init_db`. ET-014 пользуется тем же + файлом и добавляет одну таблицу через `migrations/webhook_001_init.sql`. +- Pydantic v2 для моделей (как в gps_tracks). +- Pytest для тестов (как везде в проекте). + +## 3. Требования + +### REQ-F-01 — Миграция БД + +Файл `migrations/webhook_001_init.sql`: + +```sql +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + provider TEXT NOT NULL, + delivery_id TEXT NOT NULL, + received_at TEXT NOT NULL, -- ISO 8601 UTC + expires_at TEXT NOT NULL, -- ISO 8601 UTC + status TEXT NOT NULL, -- 'pending' | 'ok' | 'error' | 'timeout' + response_status INTEGER, -- HTTP status саженный ответ + response_body TEXT, -- JSON-сериализованное тело ответа + request_body_hash TEXT, -- sha256 от body (для отладки) + signature_valid INTEGER, -- зарезервировано для M-8, NULL по умолчанию + PRIMARY KEY (provider, delivery_id) +); + +CREATE INDEX IF NOT EXISTS idx_webhook_expires ON webhook_deliveries(expires_at); +CREATE INDEX IF NOT EXISTS idx_webhook_received ON webhook_deliveries(received_at); +``` + +**Acceptance check.** После применения миграции: +```bash +sqlite3 data/gps_tracks.sqlite ".schema webhook_deliveries" +``` +выводит схему таблицы; повторное применение не падает. + +### REQ-F-02 — Конфиг `config/webhooks.yaml` + +Минимальный starter-конфиг: + +```yaml +providers: + - id: test + delivery_header: X-Delivery-Id + derive_from_body: false + ttl_days: 30 + secret_env: WEBHOOK_TEST_SECRET + description: "Технический endpoint для smoke-тестов идемпотентности" +``` + +Парсер конфига валидирует: +- `id` — `^[a-z][a-z0-9_]{1,30}$`, уникальный. +- `delivery_header` — non-empty string, формат `X-…`. +- `derive_from_body` — bool, default `false`. +- `ttl_days` — int ≥ 1, ≤ 365, default `30`. +- `secret_env` — optional; если задан — env-переменная с таким именем должна + быть установлена при старте процесса (FastAPI startup-event выбрасывает + RuntimeError если переменной нет и `secret_env` задан). + +### REQ-F-03 — Pydantic-модели в `src/api/webhooks/models.py` + +```python +from pydantic import BaseModel +from typing import Optional + +class ProviderConfig(BaseModel): + id: str + delivery_header: str = "X-Delivery-Id" + derive_from_body: bool = False + ttl_days: int = 30 + secret_env: Optional[str] = None + description: Optional[str] = None + +class WebhookConfig(BaseModel): + providers: list[ProviderConfig] + +class DeliveryRecord(BaseModel): + provider: str + delivery_id: str + received_at: str + expires_at: str + status: str + response_status: Optional[int] = None + response_body: Optional[str] = None + request_body_hash: Optional[str] = None + signature_valid: Optional[bool] = None +``` + +### REQ-F-04 — Конфиг-загрузчик в `src/api/webhooks/config.py` + +```python +from pathlib import Path +import yaml +from src.api.webhooks.models import WebhookConfig, ProviderConfig + +DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[3] / "config" / "webhooks.yaml" + +def load_webhook_config(path: Path | None = None) -> WebhookConfig: + path = path or DEFAULT_CONFIG_PATH + with open(path) as f: + raw = yaml.safe_load(f) + return WebhookConfig(**raw) + +def get_provider(cfg: WebhookConfig, provider_id: str) -> ProviderConfig: + for p in cfg.providers: + if p.id == provider_id: + return p + raise KeyError(f"Unknown webhook provider: {provider_id}") +``` + +### REQ-F-05 — Dedup-логика в `src/api/webhooks/dedup.py` + +Функции: + +```python +def record_delivery( + conn: sqlite3.Connection, + provider: str, + delivery_id: str, + ttl_days: int, + request_body_hash: str, +) -> tuple[bool, Optional[DeliveryRecord]]: + """ + Атомарная попытка вставить новую запись. + + Returns: + (True, None) — вставка успешна, это первичная доставка. + (False, rec) — запись уже существует, возвращаем существующую (для replay). + """ +``` + +```python +def mark_processed( + conn: sqlite3.Connection, + provider: str, + delivery_id: str, + response_status: int, + response_body: str, + final_status: str = "ok", # 'ok' | 'error' | 'timeout' +) -> None: + """Помечает запись как обработанную, сохраняет response.""" +``` + +```python +def get_stored_response( + conn: sqlite3.Connection, + provider: str, + delivery_id: str, +) -> Optional[DeliveryRecord]: + """Читает запись (для replay).""" +``` + +```python +def wait_for_processed( + conn: sqlite3.Connection, + provider: str, + delivery_id: str, + timeout_sec: float = 5.0, + poll_interval_sec: float = 0.1, +) -> Optional[DeliveryRecord]: + """ + Polling: ждёт пока status != 'pending'. Используется в UC-03. + Returns None при таймауте. + """ +``` + +```python +def purge_expired(conn: sqlite3.Connection) -> int: + """Удаляет записи с expires_at < now(). Возвращает количество удалённых.""" +``` + +**Атомарность вставки** реализуется так: + +```python +cur = conn.cursor() +now_iso = _now_iso() +expires_iso = _now_iso(plus_days=ttl_days) +cur.execute(""" + INSERT OR IGNORE INTO webhook_deliveries + (provider, delivery_id, received_at, expires_at, status, request_body_hash) + VALUES (?, ?, ?, ?, 'pending', ?) +""", (provider, delivery_id, now_iso, expires_iso, request_body_hash)) +conn.commit() +if cur.rowcount == 1: + return True, None +# rowcount == 0 — запись уже была +existing = get_stored_response(conn, provider, delivery_id) +return False, existing +``` + +### REQ-F-06 — FastAPI dependency `idempotent_webhook` в `endpoint.py` + +```python +def idempotent_webhook(provider_id: str): + """ + FastAPI dependency-factory: возвращает callable, который читает body и + delivery-id, выполняет дедупликацию, возвращает (is_replay, stored_response) + или (False, None) если это первичная доставка. + + При первичной доставке handler должен вызвать mark_processed(...) перед + возвратом ответа клиенту. + + Если delivery-id отсутствует: + - provider.derive_from_body=True → используется sha256(body)[:32] + - иначе → HTTPException(400) + """ +``` + +Использование в endpoint'е: + +```python +@router.post("/api/webhooks/{provider_id}") +async def webhook_handler( + provider_id: str, + request: Request, + dedup_ctx: WebhookDedupContext = Depends(idempotent_webhook(...)) +): + if dedup_ctx.is_replay: + return JSONResponse( + status_code=dedup_ctx.stored.response_status, + content=json.loads(dedup_ctx.stored.response_body) | {"replay": True}, + ) + # ... business logic ... + response = {"received": True, "delivery_id": dedup_ctx.delivery_id, "replay": False} + mark_processed(conn, provider_id, dedup_ctx.delivery_id, 200, json.dumps(response)) + return response +``` + +### REQ-F-07 — Endpoint `POST /api/webhooks/test` + +- Метод: POST. +- Принимает любой JSON body (Content-Type: application/json). +- Header `X-Delivery-Id` — обязателен (strict для `test` провайдера). +- Header `X-Webhook-Secret` — обязателен, сверяется с env `WEBHOOK_TEST_SECRET`. + При несовпадении → HTTP 401 `{"error": "invalid secret"}`. +- Ответ при первичной доставке: + ```json + {"received": true, "delivery_id": "", "replay": false} + ``` +- Ответ при replay: + ```json + {"received": true, "delivery_id": "", "replay": true} + ``` +- При отсутствии delivery-id → HTTP 400 `{"error": "missing X-Delivery-Id header"}`. +- При отсутствии env `WEBHOOK_TEST_SECRET` → процесс не стартует (RuntimeError в startup-event). + +### REQ-F-08 — Endpoint `GET /api/webhooks/health` + +Ответ: + +```json +{ + "status": "ok", + "deliveries_count": 1234, + "last_delivery_at": "2026-06-02T18:23:45Z", + "providers": ["test"], + "by_provider": { + "test": {"count": 1234, "replays_24h": 12, "last_at": "2026-06-02T18:23:45Z"} + }, + "expired_pending_gc": 0 +} +``` + +Все поля — read-only выборки из `webhook_deliveries`. SQL-запросы должны +использовать индексы (`idx_webhook_expires`, `idx_webhook_received`). + +### REQ-F-09 — Подключение в `src/api/main.py` + +Изменение единственное: + +```python +from src.api.webhooks.endpoint import create_webhook_router +webhook_router = create_webhook_router(GPS_TRACKS_DB_PATH) +app.include_router(webhook_router) +``` + +Дополнительно в startup-event (если ещё нет — создать): + +```python +@app.on_event("startup") +async def webhook_startup(): + # Применить миграцию + from src.api.webhooks.dedup import init_webhook_db, purge_expired + conn = open_db(GPS_TRACKS_DB_PATH) + init_webhook_db(conn) + purge_expired(conn) + conn.close() +``` + +`init_webhook_db` повторно применяет `migrations/webhook_001_init.sql` — +idempotent через `CREATE TABLE IF NOT EXISTS`. + +### REQ-F-10 — Скрипт GC `scripts/webhook_gc.py` + +```python +#!/usr/bin/env python3 +"""Удаляет просроченные webhook-deliveries. Запускается из cron.""" +import sys +from src.api.gps_tracks.db import open_db +from src.api.webhooks.dedup import purge_expired + +def main(): + db_path = sys.argv[1] if len(sys.argv) > 1 else "data/gps_tracks.sqlite" + conn = open_db(db_path) + n = purge_expired(conn) + conn.close() + print(f"purged {n} expired webhook deliveries") + +if __name__ == "__main__": + main() +``` + +Не подключается в cron автоматически в ET-014; запускается вручную или +оператором в `crontab` после успешного приёмки. + +### REQ-F-11 — Unit-тесты `tests/unit/test_webhook_dedup.py` + +- **UT-WH-01.** `init_webhook_db` на пустой in-memory SQLite создаёт таблицу + и индексы. Повторный вызов не падает. +- **UT-WH-02.** `record_delivery` на пустой БД вставляет запись, возвращает + `(True, None)`. В БД одна строка со `status='pending'`, корректным + `expires_at` (received_at + ttl_days). +- **UT-WH-03.** `record_delivery` второй раз с теми же `(provider, delivery_id)` + возвращает `(False, rec)`, где `rec.status='pending'` (или `ok`, если + уже обработана). В БД по-прежнему одна строка. +- **UT-WH-04.** `mark_processed` обновляет `status='ok'`, `response_status=200`, + `response_body='{"ok":true}'`. Поля `received_at` и `expires_at` не меняются. +- **UT-WH-05.** `get_stored_response` возвращает корректный + `DeliveryRecord` после `mark_processed`. +- **UT-WH-06.** `purge_expired` на БД с одной записью с `expires_at` в + прошлом удаляет её, возвращает 1. Если запись не просрочена — возвращает 0. +- **UT-WH-07.** `wait_for_processed` мгновенно возвращает запись со + `status='ok'`. На записи со `status='pending'` крутится до таймаута и + возвращает None. +- **UT-WH-08.** `record_delivery` с разными `provider` и одним и тем же + `delivery_id` — две независимые записи. +- **UT-WH-09.** **Race condition.** Два потока (`threading.Thread`) + одновременно вызывают `record_delivery(conn, "test", "abc")` на одной + и той же файловой БД (WAL). Один получает `(True, None)`, другой — + `(False, rec)`. В БД ровно одна строка. Тест запускается 50 раз в цикле. +- **UT-WH-10.** `load_webhook_config` парсит `config/webhooks.yaml`, + возвращает `WebhookConfig` с одним провайдером `test`. Невалидный + `id` (например `Test` с большой буквы) → ValidationError. +- **UT-WH-11.** `get_provider(cfg, "test")` возвращает ProviderConfig. + `get_provider(cfg, "missing")` → KeyError. + +### REQ-F-12 — Integration-тесты `tests/integration/test_webhook_endpoint.py` + +Используется `httpx.AsyncClient` + FastAPI TestClient + tmp SQLite БД. + +- **IT-WH-01.** POST `/api/webhooks/test` со всеми правильными + headers/body → HTTP 200, тело `{"received":true,"delivery_id":"<…>", + "replay":false}`. В БД появилась запись `status='ok'`. +- **IT-WH-02.** Повторный POST с тем же `X-Delivery-Id` → + HTTP 200, тело включает `"replay":true`, остальные поля совпадают с + первым ответом. В БД по-прежнему одна запись. +- **IT-WH-03.** POST без `X-Delivery-Id` → HTTP 400 `{"error":"missing + X-Delivery-Id header"}`. В БД ничего не появилось. +- **IT-WH-04.** POST без `X-Webhook-Secret` → HTTP 401 `{"error":"invalid + secret"}`. В БД ничего не появилось. +- **IT-WH-05.** POST с неверным `X-Webhook-Secret` → HTTP 401. В БД ничего. +- **IT-WH-06.** GET `/api/webhooks/health` после трёх IT-WH-01 → + `deliveries_count=3`, `by_provider.test.count=3`, `replays_24h=0`. +- **IT-WH-07.** После IT-WH-02 (один replay): GET `/api/webhooks/health` → + `replays_24h ≥ 1`. +- **IT-WH-08.** **Параллельная нагрузка.** 100 параллельных POST с одним + и тем же `delivery_id` через `asyncio.gather`. После завершения: + ровно 1 запись в БД, ровно 1 уникальный ответ-body (`replay=false`), + остальные 99 ответов имеют `replay=true`. Business-handler (заменён + на счётчик через monkeypatch) вызван **ровно 1 раз**. +- **IT-WH-09.** POST с `delivery_id` длиной > 256 символов → HTTP 400 + `{"error":"delivery_id too long"}`. В БД ничего. +- **IT-WH-10.** Неизвестный provider в URL (`/api/webhooks/unknown`) → + HTTP 404 `{"error":"unknown provider"}`. + +### REQ-F-13 — Performance-тест `tests/integration/test_webhook_perf.py` + +(маркер `@pytest.mark.perf`, не запускается в обычном CI) + +- **PT-WH-01.** Вставить 10⁶ записей в `webhook_deliveries` (батч-скрипт + в setup). Измерить p95 latency `record_delivery` (попытка вставки + существующего delivery-id) — ожидание ≤ 5 мс на mva154. +- **PT-WH-02.** 1000 RPS на `/api/webhooks/test` (через `locust` или + `httpx` бенч) с уникальными delivery-id 5 секунд — ошибок 0, p99 ≤ 200 мс. + +### REQ-F-14 — ADR-014 + +Файл `docs/work-items/ET-014/06-adr/ADR-014-webhook-idempotency.md`, +status `proposed`. Содержание (см. шаблон в `docs/architecture/adr/README.md`): + +- Контекст. +- Решение: header-first, body-hash fallback, TTL 30 дней, SQLite same-file + `gps_tracks.sqlite`. +- Альтернативы: Redis (отвергнуто — single-instance деплой), Postgres + (отвергнуто — преждевременная миграция), in-memory dict (отвергнуто — + потеря на рестарт). +- Последствия: при переходе на multi-instance — отдельный work item на + миграцию dedup-store. + +### REQ-F-15 — Гайд `15-webhook-howto.md` + +Шаблон в стиле «5 шагов, ≤ 30 минут»: +1. Добавить запись в `config/webhooks.yaml`. +2. Описать business-handler в `src/api/webhooks/handlers/.py`. +3. Зарегистрировать handler в `endpoint.py:create_webhook_router`. +4. Написать integration-тест. +5. Развернуть и проверить через `curl`. + +### REQ-F-16 — Логирование + +Использовать `logging.getLogger("webhook")` (новый logger). + +Формат записей: + +- Получение запроса: `INFO webhook provider= delivery_id= + body_hash= replay=`. +- Завершение обработки: `INFO webhook done provider= + delivery_id= status= response_status= + processing_ms=`. +- Race condition: `DEBUG webhook race provider= delivery_id= + waited_ms= resolved=`. +- GC: `INFO webhook gc purged=`. + +### REQ-F-17 — Безопасность + +- `X-Webhook-Secret` сравнивается через `hmac.compare_digest` (timing-safe). +- Env-переменные не логируются. В логах `delivery_id` обрезается до + первых 64 символов (защита от мусорных значений). +- `body` не пишется в БД целиком — только `sha256(body)[:64]` в поле + `request_body_hash`. Тело хранится в `response_body` (наш ответ + клиенту), а не входящее. + +### REQ-F-18 — Совместимость + +- Эндпоинт `/api/gps-tracks*` не меняется. +- Эндпоинт `/api/health` не меняется (отдельный `/api/webhooks/health`). +- Существующая БД `data/gps_tracks.sqlite` после применения миграции + остаётся читаемой существующими модулями (новая таблица не пересекается + с `tracks`, `pipeline_runs`). + +### REQ-F-19 — Документация work-item + +В `docs/work-items/ET-014/` после Анализа должны существовать: +- `01-brd.md` (этот work-item) +- `02-trz.md` (этот файл) +- `03-acceptance-criteria.md` +- `04-test-plan.yaml` + +После реализации: +- `06-adr/ADR-014-webhook-idempotency.md` +- `07-infra-requirements.md` +- `10-tech-risks.md` +- `12-review.md` +- `13-test-report.md` +- `14-deploy-log.md` +- `15-webhook-howto.md` + +### REQ-F-20 — Регрессионная защита + +- Все существующие тесты `tests/unit/` и `tests/integration/` для + gps_tracks должны проходить без изменений. +- БД-файл `gps_tracks.sqlite` после применения миграции должен + открываться существующими функциями `open_db`/`init_db` без ошибок. + +## 4. Не-функциональные требования + +### NFR-01 — Производительность + +- p95 на dedup-lookup (UNIQUE-индекс) на БД из 10⁶ записей — ≤ 5 мс. +- p99 на `/api/webhooks/test` под нагрузкой 1000 RPS — ≤ 200 мс + (без тяжёлого business-handler). + +### NFR-02 — Надёжность + +- При сбое процесса между `record_delivery` и `mark_processed` запись + остаётся в `status='pending'`. При следующем приходе того же + delivery-id обработчик увидит `pending` → запустит `wait_for_processed` + → таймаут 5 сек → HTTP 409. Это корректное поведение для отправителя + (ретрай). +- Lazy GC при startup-event не должно блокировать готовность сервиса + дольше 200 мс на типичной БД (≤ 100 000 записей). + +### NFR-03 — Размер БД + +Прирост `data/gps_tracks.sqlite` за 1 месяц при 100 deliveries/день и +TTL 30 дней — ≤ 5 MB (запас на отладку). + +### NFR-04 — Логирование + +Все записи логов — в одну стандартную stdout-цепочку процесса +uvicorn; никаких новых sinks. Поле `provider` всегда присутствует +для grep-фильтрации. + +### NFR-05 — Безопасность + +- `hmac.compare_digest` для секретов. +- defusedxml не нужен (XML не парсим). +- Bandit / ruff линтер не должен ругаться на новый код (стандартные + правила проекта). + +### NFR-06 — Совместимость + +- Python 3.12 (требование проекта). +- SQLite 3.35+ (`INSERT OR IGNORE` в стандартной форме доступен). +- FastAPI 0.110+ (Pydantic v2). + +## 5. План работ (для разработчика) + +1. **Подтверждение ASSUMPTION-1..4 у Owner’а** (через orchestrator). + Если хотя бы одно отвергнуто — STOP, BRD/TRZ переписывается. +2. Создать миграцию `migrations/webhook_001_init.sql` (REQ-F-01). +3. Создать `config/webhooks.yaml` со starter-конфигом (REQ-F-02). +4. Создать модуль `src/api/webhooks/`: + - `models.py` (REQ-F-03) + - `config.py` (REQ-F-04) + - `dedup.py` (REQ-F-05) + - `endpoint.py` (REQ-F-06, REQ-F-07, REQ-F-08) +5. Подключить роутер в `src/api/main.py` (REQ-F-09), добавить startup-event. +6. Создать `scripts/webhook_gc.py` (REQ-F-10). +7. Написать unit-тесты (REQ-F-11). +8. Написать integration-тесты (REQ-F-12). +9. (Опционально) написать performance-тест (REQ-F-13). +10. Прогнать `make lint && make test` локально. +11. Написать ADR-014 (REQ-F-14) и `15-webhook-howto.md` (REQ-F-15). +12. Code review → merge в feature-branch → CI → merge в main → deploy в test. +13. Smoke-проверка `curl` на test-среде по REQ-F-07 (Acceptance §AC-09 в `03-acceptance-criteria.md`). +14. Заполнить `14-deploy-log.md`. + +## 6. Открытые вопросы и решения по умолчанию + +| Вопрос | Решение по умолчанию | +| ------ | -------------------- | +| Где хранить dedup-store? | `data/gps_tracks.sqlite` (same-file). Env `WEBHOOK_DB_PATH` для override. | +| Strict или lenient mode по умолчанию? | **Strict.** Lenient включается per-provider. | +| Что делает worker-2 в UC-03 если worker-1 завис на > 5 сек? | HTTP 409 `{"error":"still processing","retry_after":5}`. | +| Нужен ли отдельный `webhooks.sqlite`? | Нет (см. ASSUMPTION-4). | +| Подпись webhook'ов в ET-014? | **Нет.** Отдельный work item `[M-8]`. Поле `signature_valid` зарезервировано в схеме (NULL). | +| Что делать с body? | Хэшируем (`sha256[:64]`), сохраняем только хэш. Body не пишем в БД. | +| Cron для GC? | Опциональный `scripts/webhook_gc.py`. В ET-014 в cron автоматически НЕ ставится; ставит оператор после приёмки. | +| Что делать, если business-handler упал? | `mark_processed(... status='error', response_status=500, response_body=...)`. На retry того же delivery-id возвращаем сохранённый 500. Это документировано как «idempotency includes errors» — отправитель должен поменять delivery-id, чтобы перезапустить. | diff --git a/docs/work-items/ET-014/03-acceptance-criteria.md b/docs/work-items/ET-014/03-acceptance-criteria.md new file mode 100644 index 0000000..7b7f1f5 --- /dev/null +++ b/docs/work-items/ET-014/03-acceptance-criteria.md @@ -0,0 +1,230 @@ +--- +type: acceptance-criteria +work_item_id: ET-014 +title: "Acceptance Criteria: [M-7] Идемпотентность webhook (дедуп по delivery-id)" +version: 1 +status: draft +created_at: 2026-06-02 +updated_at: 2026-06-02 +authors: + - "agent:analyst" +--- + +# Acceptance Criteria — ET-014 + +Все критерии — обязательные. Задача считается принятой, когда **каждый** +прошёл проверку в test-среде или в автоматическом CI. + +Формат — Gherkin (Given / When / Then). + +## AC-01 — Допущения подтверждены + +**Given** BRD `01-brd.md` с разделом §0 и `⚑ ASSUMPTION-1..4` +**When** Owner / архитектор оставил решение по каждому допущению +**Then** в `06-adr/ADR-014-webhook-idempotency.md` зафиксированы +финальные значения (где хранить dedup-store, TTL, strict/lenient +default), и `status: accepted`. + +## AC-02 — Миграция создаёт таблицу + +**Given** пустая БД `data/gps_tracks.sqlite` +**When** запущен FastAPI (startup-event применяет миграцию) +**Then**: +- таблица `webhook_deliveries` существует; +- PK — составной `(provider, delivery_id)`; +- индексы `idx_webhook_expires`, `idx_webhook_received` присутствуют; +- повторный startup не падает (idempotent миграция). + +```bash +sqlite3 data/gps_tracks.sqlite ".schema webhook_deliveries" +``` +выводит схему без ошибок. + +## AC-03 — Конфиг `webhooks.yaml` загружается + +**Given** файл `config/webhooks.yaml` со starter-конфигом +**When** `load_webhook_config()` вызвана при старте +**Then**: +- возвращает `WebhookConfig` с одним провайдером `test`; +- env `WEBHOOK_TEST_SECRET` проверена на наличие; если её нет — процесс не стартует (RuntimeError, exit code 1). + +## AC-04 — Endpoint `/api/webhooks/test` — первичная доставка + +**Given** запущен backend, env `WEBHOOK_TEST_SECRET=topsecret` +**When** клиент шлёт +``` +POST /api/webhooks/test +Headers: + X-Delivery-Id: aaa-111 + X-Webhook-Secret: topsecret + Content-Type: application/json +Body: {"event": "ping"} +``` +**Then**: +- HTTP 200; +- тело ответа `{"received": true, "delivery_id": "aaa-111", "replay": false}`; +- в `webhook_deliveries` есть строка с `provider='test'`, `delivery_id='aaa-111'`, `status='ok'`, `response_status=200`. + +## AC-05 — Replay (повторная доставка) возвращает сохранённый ответ + +**Given** успешная AC-04 (запись `aaa-111` в БД, `status='ok'`) +**When** клиент шлёт **тот же** запрос второй раз +**Then**: +- HTTP 200; +- тело ответа `{"received": true, "delivery_id": "aaa-111", "replay": true}`; +- в `webhook_deliveries` по-прежнему **одна** строка с `delivery_id='aaa-111'`; +- business-handler не вызывался повторно (счётчик через monkeypatch не вырос). + +## AC-06 — Strict mode: без `X-Delivery-Id` → 400 + +**Given** провайдер `test` с `derive_from_body: false` +**When** клиент шлёт POST без заголовка `X-Delivery-Id` +**Then**: +- HTTP 400; +- тело `{"error": "missing X-Delivery-Id header"}`; +- в `webhook_deliveries` записи не появилось. + +## AC-07 — Аутентификация по shared secret + +**Given** запущен backend +**When** клиент шлёт POST: + - без заголовка `X-Webhook-Secret` → HTTP 401, тело `{"error":"invalid secret"}`; + - с неверным `X-Webhook-Secret: wrong` → HTTP 401, то же тело. +**Then**: +- в `webhook_deliveries` записей не появилось; +- логи содержат `WARN webhook auth_failed provider=test`. + +## AC-08 — Гонка параллельных retry + +**Given** запущен backend +**When** 100 параллельных POST с одним и тем же `delivery_id=race-001` и одинаковым `X-Webhook-Secret` +**Then**: +- ровно 1 ответ имеет `"replay": false`; +- ровно 99 ответов имеют `"replay": true`; +- в `webhook_deliveries` ровно 1 строка с `delivery_id='race-001'`; +- business-handler (monkeypatched счётчик) вызван **ровно 1 раз**. + +## AC-09 — Замена провайдера в URL — 404 + +**Given** запущен backend, в конфиге только провайдер `test` +**When** клиент шлёт `POST /api/webhooks/unknown_xyz` со всеми правильными headers +**Then**: +- HTTP 404; +- тело `{"error":"unknown provider"}`; +- в БД ничего не записалось. + +## AC-10 — Health-эндпоинт работает + +**Given** в БД есть N успешных delivery (AC-04, AC-05) +**When** клиент шлёт `GET /api/webhooks/health` +**Then**: +- HTTP 200; +- поле `deliveries_count` равно N; +- поле `by_provider.test.count` равно N; +- поле `by_provider.test.replays_24h` ≥ 1 (благодаря AC-05); +- поле `last_delivery_at` — ISO timestamp ≤ 60 секунд назад; +- поле `expired_pending_gc` равно 0 (GC прошёл при startup). + +## AC-11 — TTL и GC + +**Given** в БД есть запись `delivery_id=expired-001` с `expires_at` в прошлом (вставлена через прямой SQL) +**When** вызвана `purge_expired(conn)` (через `scripts/webhook_gc.py data/gps_tracks.sqlite`) +**Then**: +- скрипт печатает `purged 1 expired webhook deliveries`; +- запись удалена; +- активные (не просроченные) записи остаются. + +## AC-12 — Unit-тесты зелёные + +**Given** ветка `feature/ET-014-…` +**When** CI выполняет `pytest tests/unit/test_webhook_dedup.py -v` +**Then** все UT-WH-01..UT-WH-11 проходят, exit-code 0. + +## AC-13 — Integration-тесты зелёные + +**Given** та же ветка +**When** CI выполняет `pytest tests/integration/test_webhook_endpoint.py -v` +**Then** все IT-WH-01..IT-WH-10 проходят, exit-code 0. + +## AC-14 — Performance-тест (опционально, маркер `perf`) + +**Given** БД с 10⁶ строк в `webhook_deliveries` (предзагрузка через `scripts/seed_webhook.py` или fixture) +**When** запущен `pytest tests/integration/test_webhook_perf.py -m perf -v` на mva154 +**Then**: +- PT-WH-01 (lookup p95) ≤ 5 мс; +- PT-WH-02 (1000 RPS, 5 секунд) — ошибок 0, p99 ≤ 200 мс. + +Не блокирующее: если PT не проходит — фиксируется в `10-tech-risks.md`, +архитектор решает, переписывать ли SQLite на Postgres. + +## AC-15 — Линтер чистый + +**Given** ветка +**When** CI выполняет `ruff check src/api/webhooks/ scripts/webhook_gc.py tests/unit/test_webhook_dedup.py tests/integration/test_webhook_endpoint.py` +**Then** exit-code 0, 0 warnings. + +## AC-16 — ADR-014 написан и accepted + +**Given** ветка +**When** ревью завершено +**Then**: +- файл `docs/work-items/ET-014/06-adr/ADR-014-webhook-idempotency.md` существует; +- YAML front-matter содержит `status: accepted` и `decision_date: 2026-06-…`; +- содержит секции «Контекст», «Решение», «Альтернативы», «Последствия». + +## AC-17 — How-to для новых провайдеров + +**Given** ветка +**When** разработчик-newcomer читает `docs/work-items/ET-014/15-webhook-howto.md` +**Then**: +- документ содержит ≤ 5 пронумерованных шагов; +- каждый шаг — ≤ 1 экран текста; +- даны рабочие code-snippets для шагов 1, 2, 3, 4, 5; +- ревьюер субъективно подтверждает: «по этому гайду я смогу добавить новый провайдер за 30 минут». + +## AC-18 — Регрессия не нарушена + +**Given** все существующие тесты ET-008, ET-009 +**When** CI выполняет `pytest tests/ -v --ignore=tests/integration/test_webhook_endpoint.py --ignore=tests/unit/test_webhook_dedup.py` +**Then** все ранее зелёные тесты остаются зелёными. + +И отдельно: +**When** запущен FastAPI с миграцией ET-014, и сделан запрос `GET /api/gps-tracks?bbox=37,55,38,56` +**Then** HTTP 200, ответ соответствует контракту ET-008. + +## AC-19 — Логи правильные + +**Given** запущен backend и сделан запрос AC-04 +**When** оператор смотрит stdout процесса uvicorn +**Then** видны строки: +- `INFO webhook provider=test delivery_id=aaa-111 body_hash=<…> replay=false` +- `INFO webhook done provider=test delivery_id=aaa-111 status=ok response_status=200 processing_ms=` + +После AC-05 — дополнительно строка с `replay=true`. + +## AC-20 — `/api/webhooks/test` защищён в проде + +**Given** деплой test-среды mva154 +**When** внешний клиент без знания `WEBHOOK_TEST_SECRET` пытается +```bash +curl -X POST https://openclaw.mva154.duckdns.org/enduro/api/webhooks/test \ + -H "X-Delivery-Id: probe" -H "Content-Type: application/json" -d '{}' +``` +**Then**: +- HTTP 401 `{"error":"invalid secret"}`; +- в логах backend `WARN webhook auth_failed provider=test`. + +## AC-21 — Документация work-item полная + +**Given** репо после слияния ET-014 +**When** проверка `docs/work-items/ET-014/` +**Then** существуют: +- `00-business-request.md` (или TBD-стаб) — `⚑` не блокирует приёмку, но фиксируется в `12-review.md` +- `01-brd.md` +- `02-trz.md` +- `03-acceptance-criteria.md` +- `04-test-plan.yaml` +- `06-adr/ADR-014-webhook-idempotency.md` (accepted) +- `15-webhook-howto.md` +- `13-test-report.md` (после Тестирования) +- `14-deploy-log.md` (после Деплоя)