Задания, отчёты и патчи, лежавшие в 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>
163 lines
19 KiB
Markdown
163 lines
19 KiB
Markdown
# ТЗ: 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. Исполнитель закрыть этот пункт не может.
|