architect(ET): auto-commit from architect run_id=54
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped

This commit is contained in:
2026-06-02 18:03:21 +00:00
parent 297fd0a8d7
commit edafd63c74
3 changed files with 1164 additions and 0 deletions

View File

@@ -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/<repo>` внутри контейнера orchestrator
(см. `/repos/orchestrator/docs/ARCHITECTURE.md` § «Известные
ограничения»).
- launcher.py перед запуском каждого агента делает
`git fetch origin && git checkout <agent_branch>` в этой копии.
- После завершения агента launcher.py делает `git add docs/` (+
`src/`/`tests/` для developer), `git commit`, `git push origin
<agent_branch>` — также в этой копии.
- Параллельная работа двух задач сейчас «приемлемо» работает только
потому, что задачи идут последовательно. 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/<WID>/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 <branch>` поверх —
но между rollback'ом deployer-а и следующим запуском агента уже
мог отработать `check_tests_local` или webhook → ошибочные
проверки на коде из тега, а не из feature-ветки. Деплоер может
также сам успеть запушить «откат» в неправильную ветку, если
`_monitor_agent` commit+push сработает на detached HEAD (текущий
`_monitor_agent` делает `git checkout <branch>` перед 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=<branch>`
чтение текущего содержимого `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 <previous-tag>` на хосте, не
трогая 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 <tag>` запрещена. |
| 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/<WID>/14-deploy-log.md` и обновление `CHANGELOG.md` (S-2) идёт через **Gitea API** в отдельной ветке `deploy/<WID>-vX.Y.Z`, после чего создаётся PR `deploy/<WID>-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 <LAST_TAG>` на deploy-хосте. Хук делает `git fetch && git checkout <LAST_TAG>` в `/home/slin/repos/enduro-trails` (НЕ в shared `/repos`) и `docker compose up -d app`. |
| F-06 | `/home/slin/bin/enduro-deploy-hook.sh` расширен: <br>(a) принимает аргументы `--ref <tag-or-branch>` (default `main`) и `--rollback` (флаг, при котором ref интерпретируется как rollback-таргет — лог-сообщение другое, плюс отметка в `/var/log/enduro-trails/deploy-hook.log`); <br>(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 <ref>`, `git show <ref>`, `git rev-parse <ref>`, `git ls-remote`, `git cat-file`, `git diff <ref-A>..<ref-B>` без `--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`, описывающий: <br>(a) автоматический rollback через deployer (вызывается при smoke-fail); <br>(b) ручной rollback оператором (`ssh slin@mva154 'bash /home/slin/bin/enduro-deploy-hook.sh --rollback --to vX.Y.Z'`); <br>(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 <LAST_TAG> ── на host (S-3)
│ curl smoke after rollback ── remote
└─ Шаг 7: Gitea API создать ветку deploy/<WID>-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 | После шагов 17 deployer-а в shared-копии `git rev-parse HEAD` остаётся таким же, как до запуска deployer-а; `git status` не показывает modified-файлов (специально для проверки в integration-тесте). |
| Shared `/repos/enduro-trails` после rollback | После шагов 16 (с 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 <tag>` в `/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 <tag>`, `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. |

View File

@@ -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/<provider>`,
отправляемый внешней системой (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": "<uuid>", "replay": false}
```
- Ответ при replay:
```json
{"received": true, "delivery_id": "<uuid>", "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/<provider>.py`.
3. Зарегистрировать handler в `endpoint.py:create_webhook_router`.
4. Написать integration-тест.
5. Развернуть и проверить через `curl`.
### REQ-F-16 — Логирование
Использовать `logging.getLogger("webhook")` (новый logger).
Формат записей:
- Получение запроса: `INFO webhook provider=<id> delivery_id=<id>
body_hash=<sha256[:12]> replay=<bool>`.
- Завершение обработки: `INFO webhook done provider=<id>
delivery_id=<id> status=<ok|error|timeout> response_status=<int>
processing_ms=<int>`.
- Race condition: `DEBUG webhook race provider=<id> delivery_id=<id>
waited_ms=<int> resolved=<bool>`.
- GC: `INFO webhook gc purged=<N>`.
### 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, чтобы перезапустить. |

View File

@@ -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=<int>`
После 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` (после Деплоя)