server-monitor-manager/agents/_salvage-2026-08-18/smm-deliverables/Старые задачи/smm-task-B3-spec-2026-08-03.md
Ochenstarik 23eb3f5233 chore(agents): разбор рабочих папок с диска на 2026-08-18
Задания, отчёты и патчи, лежавшие в C:\Users\Ochenstarik\projects и в
домашней папке, перенесены в agents/. Разложено по агентам там, где имя
файла позволяло определить автора; остальное — в _salvage-2026-08-18/
и разбирается вручную.

Патчи в notes/salvage-2026-08-18/ — незакоммиченная работа из брошенных
рабочих копий: она существовала только на диске.

Тяжёлое (релизные архивы, инсталляторы, наборы данных) в репозиторий не
попало: оно лежит рядом, в Agent_projects/_archive и Agent_projects/_data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:19:54 +07:00

163 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ТЗ: Block B-3 — реконсиляция от факта и техдолг Link-политик
Репозиторий: `ochenstarik-ui/server-monitor-manager`
База: `main` @ `b11c277ac7f79a18670932eca4622982d9ff48e0`
Предшествующие блоки: B (PR #11), B-2 (PR #12) — оба merged, physical acceptance pending.
Этот документ самодостаточен. Всё, что нужно исполнителю, здесь; предыдущие документы читать не обязательно.
---
## 1. Контекст
Control уже умеет приводить Link-политики к желаемому состоянию: есть `LinkReconciliationBackgroundService` (немедленный проход при старте + периодический по `Control__LinkReconciliationSeconds`), единый `ConvergeAsync`, типизированное состояние `mesh.firewall-unavailable` с backoff и generation-маркер внеочередной реконсиляции от `ochenstarik-smm-emergency`.
Проход устроен **от базы к факту**: берётся список действующих политик, и для каждой вызывается helper — `link-status`, затем при расхождении `link-connect`/`link-disconnect`, затем повторный `link-status`. Отсюда два системных ограничения, которые и закрывает этот блок.
---
## 2. Scope
### B3-1 — `link-list` в policy-helper (высокий приоритет)
Новое действие в `deploy/ochenstarik-smm-policy-apply`, возвращающее **все** правила управляемой цепочки одним вызовом.
- Арность: ровно 1 аргумент (`link-list`), проверяется до всего остального, как у `reconcile-status`.
- Источник: та же цепочка `inet ochenstarik_smm links`, через существующий `inspect_firewall` — то есть недоступная таблица по-прежнему даёт exit 79 и точный маркер `mesh.firewall-unavailable` в stderr, а неизвестная ошибка `nft` остаётся fail-closed с exit 78.
- Разбирать только правила с комментарием вида `smm:<source>:<target>:<proto>:<port>`; чужие правила в цепочке игнорировать и **никогда** не трогать.
- Формат вывода — стабильный, машиночитаемый, по одной записи в строке: `source<TAB>target<TAB>protocol<TAB>port`. Порядок не гарантируется, дубликаты допустимы (одинаковый comment может встретиться несколько раз — это тоже подлежит вычистке).
- Пустая цепочка — пустой вывод и exit 0. Это отличается от «таблицы нет» (exit 79).
- Значения из comment валидировать теми же паттернами, что и аргументы действий (`node_pattern`, `tcp|udp`, порт 1…65535). Строка, не прошедшая валидацию, не выводится, но фиксируется в stderr как диагностика — она означает, что кто-то подделал comment.
- Режим `SMM_POLICY_TESTING=1` поддержать по образцу существующих действий, чтобы контрактный тест работал без root и без nftables.
`sudoers` менять не нужно — шаблон `$POLICY_HELPER *` уже покрывает новое действие.
### B3-2 — развернуть проход реконсиляции на факт → база (высокий приоритет)
`LinkService.ReconcileAllAsync` переписать так, чтобы источником истины о факте был один вызов `link-list`, а не `2N` вызовов `link-status`.
Алгоритм прохода:
1. Один `link-list` → множество фактических правил.
2. Один `ListEffectiveLinksAsync` → множество действующих политик.
3. Сверка множеств по ключу `(source, target, protocol, port)`:
- **правилу не соответствует ни одна политика с `DesiredState=Active`** (политики нет вовсе, либо она `Disabled`) → удалить правило, событие `link.orphan-removed`, запись в audit; если политика в БД есть — привести её `ActualState` к `Disabled`;
- **политика `DesiredState=Active`, правила нет** → применить, проверить фактом, событие `link.reapplied`;
- **совпало** → мутаций нет, `ActualState` при необходимости синхронизировать без вызова helper.
4. Дубликаты одного и того же правила схлопывать до одного при `Active` и удалять полностью при `Disabled`.
Требования:
- **Снять ограничение `IsEligibleForFullReconciliation`.** Сейчас политика в состоянии `Disabled/Disabled` из прохода исключена, поэтому accept-правило для полностью отключённой политики не обнаруживается никогда — а это инвариант kill switch. После разворота прохода фильтр по состоянию в БД больше не нужен: orphan находится по факту, независимо от того, что записано в базе.
- Порядок блокировок сохранить существующий: отсортированные node-локи → per-Link gate. Новых классов блокировок не вводить.
- Единый `ConvergeAsync` остаётся единственной реализацией сходимости; отдельной копии логики для нового прохода быть не должно.
- Поведение при `mesh.firewall-unavailable` не меняется: проход прерывается, публикуется одно агрегированное событие, работает bounded backoff, маркер не потребляется.
- Стоимость прохода без расхождений: **один** привилегированный вызов. Это проверяемое требование, см. тесты.
### B3-3 — ограниченный backoff для marker-пути (высокий приоритет)
В `LinkReconciliationBackgroundService` наличие маркера полностью снимает регулярный throttle (`_nextRegularAt`), а сам маркер потребляется только при `Failed == 0`. `_backoffUntil` взводится лишь для `FirewallUnavailable`. Следствие: одна стабильно падающая политика — например Node, выпавший из `nodes.tsv`, где `lookup_node_ip` даёт exit 78, — навсегда удерживает маркер, и полный проход начинает выполняться на каждом poll-тике (30 с) без ограничения.
Сделать:
- счётчик неуспешных prompt-проходов подряд; после 3 попыток маркер перестаёт снимать регулярный throttle и проходы возвращаются к обычному интервалу;
- одно предупреждение в журнал при переходе в это состояние, с идентификаторами упавших политик, без повторов на каждом тике;
- счётчик сбрасывается при первом проходе с `Failed == 0` (тогда же маркер потребляется штатно);
- маркер по-прежнему **не** потребляется, пока проход не завершился без отказов.
### B3-4 — M2: семантика результата реконсиляции (средний)
`LinkReconciliationResult.Reconciled` сейчас означает «рассмотрено», а не «приведено в порядок»: тест ожидает `(1, 0)` при нуле вызовов helper. Развести на три поля — `Examined`, `Converged`, `Failed` — и обновить всех потребителей, включая журнал фонового сервиса и `Program.cs`. `LinkFullReconciliationResult` привести к той же схеме.
### B3-5 — M4: Links-страница и retention (средний)
Фильтр на странице Links снят в Block B, retention для таблицы `links` нет (в `ControlMaintenance` чистятся только `metric_samples`, `idempotency`, `audit` и токены). Страница превращается в журнал всех политик за всё время.
Сделать **оба**:
- Desktop: фильтр по умолчанию «действующие + расхождения» с явным переключателем «показать историю»; счётчики в заголовке считать по отображаемому набору и подписывать однозначно.
- Control: retention для завершённых политик — `DesiredState=Disabled` и `ActualState=Disabled`, старше настраиваемого срока; новая настройка в `ControlOptions` с валидацией, значение в `appsettings.json` и в `control.env` bootstrap, по образцу `LinkReconciliationSeconds`. Действующие политики и политики с расхождением не удалять никогда.
### B3-6 — M5: не активированный в mesh Node (средний)
`lookup_node_ip` не различает «Node зарезервирован, но `peer-add` ещё не выполнен» и «helper сломан»: в обоих случаях exit 78, и политика уходит в `Failed` на каждом проходе. Ввести отдельный код возврата и типизированное состояние: политика к неактивированному Node не является отказом применения, это ожидаемое промежуточное состояние. В Desktop показывать его отдельной формулировкой, а не ошибкой.
### B3-7 — мелочи (низкий)
- Helper: `reconcile-status` возвращает `complete`, если каталог `/var/lib/ochenstarik-server-monitor-manager/mesh` отсутствует. Сейчас `exec 9>"$RECONCILE_LOCK"` в несуществующем каталоге падает под `set -e`, и на Hub, где `mesh-init` ещё не выполнялся, Control пишет предупреждение каждые 30 секунд.
- Helper: явная проверка `[[ -x /usr/sbin/nft ]]` с отдельным сообщением. Сейчас отсутствующий бинарь даёт ту же строку `No such file or directory`, что и отсутствующая таблица, и классифицируется как `mesh.firewall-unavailable`.
- `tests/acceptance/three-server-mesh.sh`: переформатировать блок `probe_factual_status` / `expect_factual_status` — перемешаны отступы 4 и 2 пробела, закрывающая скобка внесена внутрь тела. Поведение верное, `bash -n` проходит; вопрос читаемости.
---
## 3. Тесты
**Обязательные, без них блок не принимается:**
1. Accept-правило существует, соответствующая политика в состоянии `Disabled/Disabled` → проход удаляет правило и публикует `link.orphan-removed`. Это тот случай, который сегодня не обнаруживается вовсе.
2. Accept-правило существует, политики в БД нет ни в каком виде → правило удалено.
3. Проход без расхождений выполняет **ровно один** привилегированный вызов (`link-list`) и ноль мутаций. Проверять по счётчику вызовов фейкового applier либо по журналу вызовов в интеграционном тесте.
4. Правило с чужим comment в той же цепочке не трогается ни при каких условиях.
5. Дубликат правила с одинаковым comment: при `Active` остаётся один, при `Disabled` не остаётся ни одного.
6. Политика, падающая всегда, при висящем маркере не вызывает более одного прохода за интервал после исчерпания попыток; маркер при этом не потребляется.
7. `link-list` при отсутствующей таблице → exit 79, проход прерван, одно агрегированное событие, ноль мутаций, маркер сохранён.
8. Retention удаляет завершённую Disabled-политику старше срока и **не** удаляет действующую и политику с расхождением.
**Контрактные (`tests/bootstrap/test-bootstrap-contract.sh`):** арность `link-list`, формат вывода на фикстуре, игнорирование чужого comment, отказ на подделанном comment, `reconcile-status` при отсутствующем каталоге mesh.
**Acceptance (`tests/acceptance/three-server-mesh.sh`):** новый шаг под `SMM_ACCEPT_RESTORE=1` — вручную добавить на Hub accept-правило для отключённой политики (`link-connect` через helper напрямую), дождаться прохода, убедиться, что правило снято и `expect_blocked` для соответствующего Node выполняется. Шаг ставить рядом с существующим шагом firewall-restore.
---
## 4. Definition of Done
- [ ] Проход реконсиляции обнаруживает и снимает accept-правило, которому не соответствует действующая политика `Active`, **включая случай `Disabled/Disabled`**
- [ ] `IsEligibleForFullReconciliation` удалён, а не расширен
- [ ] Проход без расхождений — один привилегированный вызов; подтверждено тестом
- [ ] Чужие правила в цепочке не затрагиваются; подтверждено тестом
- [ ] Стабильно падающая политика не вызывает более одного прохода за интервал
- [ ] `Examined` / `Converged` / `Failed` разведены, потребители обновлены
- [ ] Links-страница читаема при сотнях исторических политик; retention настраивается и не трогает действующие политики
- [ ] Неактивированный в mesh Node не отображается как ошибка применения политики
- [ ] Логика сходимости существует в одном экземпляре
- [ ] Порядок блокировок не изменён, новых классов не введено
- [ ] B3-7 закрыты
- [ ] `docs/roadmap.md` обновлён: B-3 отмечен, M2/M4/M5 сняты из отложенных
- [ ] CI зелёный на PR
---
## 5. Вне scope
- Блок C (подписанный manifest, совместимость версий, пиннинг Actions) — отдельная задача после B-3.
- Провиженинг, роль Monitor в bootstrap, MVVM-рефакторинг Desktop, локализация — задачи следующего уровня.
- Physical acceptance — внешний блокер, см. раздел 7.
---
## 6. Формат сдачи
Как в предыдущих блоках:
- `REPORT.md` — статус, база, коммиты, PR, ссылки, что реализовано, что отложено и почему;
- `TEST_EVIDENCE.md` — фактические результаты прогонов и честный список невыполненного;
- патч и/или zip изменённых файлов;
- `CI_CHECKS.txt` — имена, длительности и URL всех проверок;
- `INDEPENDENT_REVIEW.md`;
- `SHA256SUMS` — считать **последним действием**, после всех правок отчётов, и включать все файлы пакета.
Независимому review передавать **полный текст этого ТЗ**, а не только диф. На двух предыдущих блоках это дало находки того класса, который при diff-only ревью не ищется вовсе: пропущенные требования, а не ошибки в написанном коде.
Статус блока по итогам merge формулировать как `merged / verified`, а не «выполнено», если хоть один критерий выражен через фактическую связность и не проверялся на реальной топологии.
---
## 7. Внешний блокер
Physical acceptance не выполнялся ни разу за три блока. Требуются `HUB_SSH_HOST`, `HUB_SSH_USER`, `SOURCE_SSH_HOST`, `SOURCE_SSH_USER`, `HOME_WG_IP`, `SECOND_WG_IP`, `SSH_IDENTITY_FILE` и прогон
```bash
SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh
```
Harness к этому готов и уже содержит сценарий `firewall-restore` без перезапуска Node. До прогона пункт «выполнить физический acceptance» Этапа 3 roadmap и критерий B6 остаются открытыми независимо от состояния CI. Исполнитель закрыть этот пункт не может.