# Задача 3 — закрыть P0-2 по существу и перейти к доверенной поставке Базовая ревизия: `main` @ `89ef2fd` (PR #11, Block B merged). Предшествующие документы: `smm-improvement-plan-2026-07-30.md`, `smm-task-2-2026-07-31.md`, `smm-task2-blockB-review-2026-08-03.md`. --- ## 0. Результат перепроверки пакета Block B **Процессная часть — принято.** `SHA256SUMS` сходится по всем семи файлам, репорт репрезентативен, CI 14/14 SUCCESS на `1580ec7` (включая Debian 12/13 x64+arm64 systemd restart и Ubuntu 22.04/24.04 native VM), PR #11 смержен как `89ef2fd`, временный verifier удалён. Замечание по контрольным суммам из прошлого ревью закрыто. **Содержательная часть — не принято.** Проверял по `origin/main`, а не по отчёту. Диф совпадает с тем, что я ревьюил (551 против 550 additions), и главное замечание в него не вошло: | Проверка в `main` @ `89ef2fd` | Результат | |---|---| | Хостед-сервисы в `Program.cs:61-62` | только `LinkExpirationBackgroundService` и `ControlMaintenanceBackgroundService` | | Вызовы `ReconcileLinksForNodeAsync` | ровно один — `Program.cs:356`, обработчик heartbeat | | `Control__LinkReconciliationSeconds` | отсутствует | | `mesh.firewall-missing` / маркер от `firewall-restore` | отсутствуют | | `(char)13` в `CompactError` | `LinkService.cs:339`, на месте | | `ListLinksAsync().SingleOrDefault(...)` | `LinkService.cs:32`, `LinkService.cs:297` | Условие запуска реконсиляции не изменилось: ```csharp var reconnectThreshold = TimeSpan.FromSeconds(Math.Max(60, nextHeartbeatSeconds * 3)); // 90 с var requiresReconciliation = previousStatus != "Online" || previousLastSeenAt is null || now - previousLastSeenAt >= reconnectThreshold; ``` Следовательно, в `main` по-прежнему открыты сценарии, ради которых Block B и делался: 1. `ochenstarik-smm-emergency firewall-restore` — правила стёрты, **ни один Node не уходил в offline**, реконсиляция не запускается. В Desktop: «Расхождение: нет». Фактически трафик заблокирован. `docs/linux-bootstrap.md:111` продолжает обещать переприменение, которого нет. 2. Ручной `systemctl restart ochenstarik-smm-firewall`. 3. Перезапуск Hub с простоем < 90 с (перезапуск сервиса, быстрый reboot VM, `update-control`). 4. Link, оба участника которого не переподключались. **Критерий выхода B6 не выполнен. Block B остаётся открытым**, несмотря на merge. Раздел 1 этой задачи — его остаток, а не новая функциональность. Отдельно: physical acceptance не выполнялся ни разу за две задачи, потому что не выданы `HUB_SSH_HOST`, `HUB_SSH_USER`, `SOURCE_SSH_HOST`, `SOURCE_SSH_USER`, `HOME_WG_IP`, `SECOND_WG_IP`, `SSH_IDENTITY_FILE`. Это внешний блокер, который снимается только вашим решением. Пока он не снят, ни один критерий, формулируемый через фактическую связность, закрыть нельзя — CI это не заменяет. --- ## 1. Блок B-2 — остаток Block B (обязательный) ### B2-1. Фоновая реконсиляция, не привязанная к переподключению Node Новый `LinkReconciliationBackgroundService : BackgroundService`, регистрируется третьим в `Program.cs`. - Настройка `Control__LinkReconciliationSeconds` в `ControlOptions`, валидация в цепочке `.Validate(...)` наравне с прочими (диапазон 30…3600, по умолчанию 300), значение в `appsettings.json`. - Один проход при старте — после `store.InitializeAsync()`, до начала обслуживания запросов не обязателен, но первый тик должен быть немедленным, а не через интервал. - Проход идёт по **всем действующим политикам** (`DesiredState = Active`, либо `DesiredState = Disabled` при `ActualState != "Disabled"`), а не по Link конкретного Node. - Порядок блокировок сохранить существующий: node-локи (отсортированные) → per-Link gate. Никаких новых классов блокировок. - Актор — `system:reconcile`. - События: при фактическом восстановлении публиковать `link.reapplied`, при удалении правила без записи в БД — `link.orphan-removed`. Существующие `link.reconciling` / `link.active` / `link.disabled` / `link.failed` / `link.partial` сохранить. - Не чаще одного полного прохода за интервал независимо от внешних триггеров. **Обязательный сопутствующий рефакторинг.** Логика «прочитать текущее состояние → сравнить факт с desired → применить → проверить → сохранить» сейчас существует в трёх экземплярах: `CreateAsync`, `ReconcileLinksForNodeAsync`, `ConvergeDisabledCoreAsync`. Четвёртая копия в фоновом сервисе недопустима. Свести к одному методу вида ```csharp private async Task ConvergeAsync( LinkPolicy link, bool expectedConnected, string actor, CancellationToken ct) ``` и вызывать его из всех четырёх мест. ### B2-2. «Таблицы нет» ≠ «правил ноль» Сейчас при отсутствии таблицы `link-status` завершается кодом 78, исключение ловится поштучно, и каждая политика по очереди получает `Failed`/`Partial` с текстом ошибки helper'а. Оператор видит N разных ошибок вместо одной причины. 1. `deploy/ochenstarik-smm-policy-apply`: отличать «таблица/цепочка недоступна» от «правило не найдено» — отдельный exit code (например 79) и стабильный маркер в stderr. Сохранить fail-closed поведение: неизвестная ошибка `nft` по-прежнему не должна читаться как «правил нет». 2. `LinkPolicyApplier`: типизированное `MeshFirewallUnavailableException` по этому коду. 3. Сервис реконсиляции: при таком исключении — **прервать проход**, один раз опубликовать `mesh.firewall-unavailable`, перевести затронутые политики в `Partial` с единым кодом ошибки, повторить с backoff. Никаких массовых попыток применения в несуществующую таблицу. 4. Desktop: один баннер «Mesh firewall не загружен» вместо N строк с ошибками. ### B2-3. Маркер внеочередной реконсиляции 1. `ochenstarik-smm-emergency firewall-restore` и `mesh-enable` создают `/var/lib/ochenstarik-server-monitor-manager/mesh/reconcile-requested` (root, `0600`). 2. Фоновый сервис на каждом тике проверяет маркер, при наличии выполняет внеочередной проход и удаляет маркер **только после успешного завершения**. 3. Контрактная проверка в `tests/bootstrap/test-bootstrap-contract.sh` — как для остальных инвариантов emergency-команды. ### B2-4. Тесты - **Unit:** все правила стёрты (fake applier: `IsConnected == false` для всех), **ни одного heartbeat** → фоновый проход выполняет N connect и приводит `ActualState` к `Active`. Это тот самый тест, который ловит текущий пробел; без него блок не принимается. - **Unit:** правило существует, `DesiredState = Disabled` → disconnect + `link.orphan-removed`. - **Unit:** firewall unavailable → ноль мутаций, событие опубликовано один раз, затронутые в `Partial`. - **Unit:** два прохода подряд без изменений → ноль вызовов helper во втором. - **Integration** в стиле `LinkPolicyApplierIntegrationTests`: удалить маркер `connected`, выполнить проход, сверить точную последовательность вызовов helper. - **Acceptance** (`tests/acceptance/three-server-mesh.sh`): новый шаг — на Hub выполнить `emergency firewall-restore` (или `nft delete table` + `systemctl start ochenstarik-smm-firewall`) **без перезапуска Node**, выждать интервал реконсиляции, затем `expect_reachable "$HOME_WG_IP"`, `expect_blocked "$SECOND_WG_IP"` и `expect_factual_status` для обоих. ### B2-5. Критерий приёмки блока Правила nftables стёрты при полностью живых Node → связность по всем `DesiredState=Active` восстанавливается автоматически не позднее одного интервала реконсиляции; `Disabled` остаются заблокированными; правил без записи в БД не остаётся; расхождение видно в Desktop до момента устранения. Подтверждается unit-тестом **и** прогоном acceptance-скрипта на реальной тройке. --- ## 2. Блок B-3 — техдолг из ревью (в том же PR или следующем, до закрытия Block B) | ID | Что | Где | |---|---|---| | M1 | Опубликовать `GetLinkAsync(string id, CancellationToken)` — приватный `ReadLinkAsync(id)` уже есть — и заменить им поиск по id полным перечислением. Сейчас O(N) чтений и десериализаций **под захваченной блокировкой**, что ударит по нагрузочному тесту Этапа 5. | `ControlStore.cs:1460`; `LinkService.cs:32`, `297`; цикл в `ReconcileLinksForNodeAsync` | | M2 | `LinkReconciliationResult.Reconciled` теперь означает «рассмотрено», а не «приведено в порядок» (тест ожидает `(1, 0)` при нуле вызовов helper). Развести на `Examined` / `Converged` / `Failed`. | `LinkService.cs` | | M4 | Фильтр на Links-странице снят, retention для таблицы `links` нет (чистятся только `metric_samples`, `idempotency`, `audit`, токены) — страница превращается в журнал. Нужен фильтр по умолчанию «действующие + расхождения» с переключателем либо retention для завершённых Disabled-политик. | `MainPage.xaml.cs`, `ControlMaintenance.cs` | | M5 | Проба статуса для зарезервированного, но не активированного в mesh Node падает через `lookup_node_ip` и перебрасывает Link в `Failed` на каждом проходе. Отличать «Node ещё не активирован» от «helper сломан». | `ochenstarik-smm-policy-apply`, `LinkService` | | L1 | `Split([(char)13, '\n'], …)` вернуть к `Split(['\r', '\n'], …)` — это то же самое, но нечитаемо; похоже на артефакт инструмента. | `LinkService.cs:339` | M1 и L1 — дешёвые, делать сразу. M4 влияет на пригодность UI и требует решения о retention — вынести отдельным PR можно, но до объявления Block B закрытым. --- ## 3. Блок C — доверенная поставка (после закрытия B-2) Полное описание — `smm-task-2-2026-07-31.md`, раздел 3. Кратко: - **C1.** Manifest v2: хэши всех артефактов (bootstrap, оба `tar.gz`, MSIX), версии Control/Agent/helper/Desktop, минимальные совместимые версии, `helper_protocol`. - **C2.** Подпись (`cosign sign-blob` keyless либо `minisign`); публичный ключ/issuer **вшит константой** в bootstrap и Desktop, не скачивается вместе с релизом. Отсутствие инструмента проверки — отказ, не предупреждение. - **C3.** `verify_archive` сверяет хэш с manifest, а не с соседним `.sha256`; старый путь только под явным `SMM_ALLOW_UNSIGNED=1`. Новое действие `verify-manifest`. Отказ `update-*` при несовместимой паре версий. `PROGRAM_VERSION` подставляется из тега при упаковке вместо `0.2.0-dev`. - **C4.** Пиннинг GitHub Actions по commit SHA (сейчас плавающие `@v6`, `@v5`, `@v2`), `dependabot.yml` для `github-actions` и `nuget`, сужение `permissions: contents: write` до job'а публикации, SBOM в артефакты релиза. - **C5.** Негативные тесты в CI: изменённый байт в архиве, подменённый хэш в manifest без переподписи, manifest без подписи — все три отвергаются. --- ## 4. Порядок и Definition of Done **Порядок:** B2-1 → B2-2 → B2-3 → B2-4 (в одном PR, это единая функция) → B-3 (M1, L1 в том же PR; M2, M4, M5 — отдельным) → physical acceptance → Блок C. **DoD задачи 3:** - [ ] Фоновая реконсиляция работает при старте и по интервалу, не завися от переподключения Node - [ ] Unit-тест «правила стёрты, heartbeat не приходил → связность восстановлена» проходит - [ ] Недоступный mesh firewall даёт одно событие и одно понятное состояние, а не N ошибок - [ ] `emergency firewall-restore` приводит к внеочередной реконсиляции - [ ] Логика сходимости существует в одном экземпляре, а не в четырёх - [ ] `docs/linux-bootstrap.md:111` соответствует реализации - [ ] M1, L1 исправлены; по M2, M4, M5 принято решение (сделать либо явно перенести с обоснованием) - [ ] **Physical acceptance `three-server-mesh.sh` c `SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1` выполнен** — иначе B6 остаётся открытым - [ ] Далее: Блок C по разделу 3 --- ## 5. Процесс Две вещи, которые стоит изменить в способе работы, — обе подтвердились на Block B. **Независимому review отдавать текст задачи, а не только диф.** Оба review Block B были корректны по существу написанного кода и оба пропустили, что половина требований блока не реализована. Первый проверял «безопасно ли коммитить этот диф», второй — «закрыты ли две HIGH». Полноту относительно постановки не проверял никто, и блок ушёл в `main` с открытым основным критерием. **Критерий выхода блока не равен merge.** Block B помечен выполненным на основании зелёного CI и APPROVE, тогда как его собственный критерий B6 сформулирован через фактическую связность и не проверялся. Пока physical acceptance заблокирован отсутствием topology inputs, блоки, чьи критерии формулируются через факт, следует помечать как `merged, acceptance pending`, а не как закрытые.