# ТЗ: 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::::`; чужие правила в цепочке игнорировать и **никогда** не трогать. - Формат вывода — стабильный, машиночитаемый, по одной записи в строке: `sourcetargetprotocolport`. Порядок не гарантируется, дубликаты допустимы (одинаковый 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. Исполнитель закрыть этот пункт не может.