server-monitor-manager/agents/_salvage-2026-08-18/smm-deliverables/Старые задачи/smm-task-3-2026-08-03.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

148 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Задача 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(...)` наравне с прочими (диапазон 303600, по умолчанию 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<LinkPolicy> 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`, а не как закрытые.