server-monitor-manager/agents/_salvage-2026-08-18/panel-control/Task3-B3-2026-08-04/B3_SPEC.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

19 KiB
Raw Blame History

ТЗ: 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

Новое действие в 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 привести к той же схеме.

Фильтр на странице 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 и прогон

SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh

Harness к этому готов и уже содержит сценарий firewall-restore без перезапуска Node. До прогона пункт «выполнить физический acceptance» Этапа 3 roadmap и критерий B6 остаются открытыми независимо от состояния CI. Исполнитель закрыть этот пункт не может.