Задания, отчёты и патчи, лежавшие в 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>
19 KiB
ТЗ: 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.
Алгоритм прохода:
- Один
link-list→ множество фактических правил. - Один
ListEffectiveLinksAsync→ множество действующих политик. - Сверка множеств по ключу
(source, target, protocol, port):- правилу не соответствует ни одна политика с
DesiredState=Active(политики нет вовсе, либо онаDisabled) → удалить правило, событиеlink.orphan-removed, запись в audit; если политика в БД есть — привести еёActualStateкDisabled; - политика
DesiredState=Active, правила нет → применить, проверить фактом, событиеlink.reapplied; - совпало → мутаций нет,
ActualStateпри необходимости синхронизировать без вызова helper.
- правилу не соответствует ни одна политика с
- Дубликаты одного и того же правила схлопывать до одного при
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.envbootstrap, по образцу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. Тесты
Обязательные, без них блок не принимается:
- Accept-правило существует, соответствующая политика в состоянии
Disabled/Disabled→ проход удаляет правило и публикуетlink.orphan-removed. Это тот случай, который сегодня не обнаруживается вовсе. - Accept-правило существует, политики в БД нет ни в каком виде → правило удалено.
- Проход без расхождений выполняет ровно один привилегированный вызов (
link-list) и ноль мутаций. Проверять по счётчику вызовов фейкового applier либо по журналу вызовов в интеграционном тесте. - Правило с чужим comment в той же цепочке не трогается ни при каких условиях.
- Дубликат правила с одинаковым comment: при
Activeостаётся один, приDisabledне остаётся ни одного. - Политика, падающая всегда, при висящем маркере не вызывает более одного прохода за интервал после исчерпания попыток; маркер при этом не потребляется.
link-listпри отсутствующей таблице → exit 79, проход прерван, одно агрегированное событие, ноль мутаций, маркер сохранён.- 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 и прогон
SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh
Harness к этому готов и уже содержит сценарий firewall-restore без перезапуска Node. До прогона пункт «выполнить физический acceptance» Этапа 3 roadmap и критерий B6 остаются открытыми независимо от состояния CI. Исполнитель закрыть этот пункт не может.