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

163 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.

# Приёмка Task 3 / B-2 и Задача 4
Базовая ревизия: `main` @ `b11c277` (PR #12, squash merge, 21 файл, 1113/172).
Предшествующие документы: `smm-improvement-plan-2026-07-30.md`, `smm-task-2-2026-07-31.md`, `smm-task2-blockB-review-2026-08-03.md`, `smm-task-3-2026-08-03.md`.
---
## 0. Приёмка B-2: **принято**
Проверял по `origin/main`, а не по отчёту. Все семь контрольных сумм сходятся, CI 14/14 на PR #12, `PATCH_VERIFICATION.txt` подтверждает применимость патча к чистой базе.
| Требование задачи 3 | Проверка в `main` @ `b11c277` |
|---|---|
| B2-1 фоновый сервис, старт + интервал | `Program.cs:63``AddHostedService<LinkReconciliationBackgroundService>`; `do { RunOnceAsync } while (timer.WaitForNextTickAsync)` — первый проход немедленный ✅ |
| `Control__LinkReconciliationSeconds` | `ControlOptions.cs:27` (=300), валидация `Program.cs:49` (30…3600), `appsettings.json:13`, `control.env` в bootstrap ✅ |
| Проход по всем действующим политикам | `ReconcileAllAsync` через `ListEffectiveLinksAsync`, не по одному Node ✅ |
| Порядок блокировок сохранён | sorted node locks → per-Link gate, новых классов не введено ✅ |
| `link.reapplied` / `link.orphan-removed` | `LinkService.cs:309` ✅ |
| Единая логика сходимости | `ConvergeAsync` — используется create/disable/reconnect/full/TTL/retry. Три копии сведены к одной ✅ |
| B2-2 «таблицы нет» ≠ «правил ноль» | helper: exit 79 + точный маркер `mesh.firewall-unavailable` в stderr, `LC_ALL=C`; типизированное исключение; проход прерывается; агрегированное событие; bounded backoff; событие восстановления `mesh.firewall-available` ✅ |
| B2-3 маркер внеочередной реконсиляции | `firewall-restore` и `mesh-enable` публикуют маркер атомарно под общим root-flock с UUID-поколением; helper потребляет **только точное поколение** ✅ |
| B2-4 тесты | `LinkReconciliationTests.cs` (338 строк), контрактные проверки в bootstrap-тесте, интеграционный тест последовательности вызовов helper ✅ |
| Acceptance: firewall-restore без перезапуска Node | новый шаг `[8/12]` с retry-циклом по фактическому статусу **и** реальной связности ✅ |
| M1 `GetLinkAsync`, L1 CR/LF | оба исправлены ✅ (в `REPORT.md` метки M1 и L1 перепутаны местами — сами правки на месте) |
| M2, M4, M5 | явно перенесены в B-3 с обоснованием в `docs/roadmap.md` — это и требовалось по DoD ✅ |
| `docs/linux-bootstrap.md` | приведён в соответствие с реализацией ✅ |
Отдельно отмечу два места, где сделано **лучше**, чем было в задании:
- Я просил просто маркер. Реализовано поколение-UUID под общим `flock` с потреблением ровно прочитанного поколения — то есть закрыта гонка «пока шёл проход, пришёл новый запрос», о которой в задании не было ни слова.
- Маркер обходит только регулярный интервал, но **не** обходит backoff недоступного firewall. Это правильное разделение, и оно покрыто тестом.
Независимый review в этот раз проводился против полного текста задачи, а не только дифа — рекомендация из прошлого документа принята и сработала: его находки (маркер против throttle, слепое удаление чужого поколения, выход acceptance по первому несовпадению, баннер при нуле SSH-профилей, локализованный stderr `nft`) — это ровно тот класс дефектов, который в прошлый раз никто не искал.
**Статус блока:** `merged / verified, physical acceptance pending`. Формулировка в отчёте корректна, B6 не объявлен закрытым. Это правильно и так и должно остаться до прогона на реальной тройке.
---
## 1. Находки по B-2 (не блокирующие merge, в работу)
### N1 — MEDIUM. Незавершаемый маркер даёт полный проход каждые 30 секунд бесконечно
`LinkReconciliationBackgroundService.RunOnceAsync`:
```csharp
if (requestGeneration is null && _nextRegularAt is not null && now < _nextRegularAt) return null;
...
else { _nextRegularAt = now + interval;
if (requestGeneration is not null && result.Failed == 0)
await applier.CompleteReconciliationAsync(requestGeneration, ct); }
```
Маркер потребляется только при `Failed == 0`, а наличие маркера полностью снимает регулярный throttle. `_backoffUntil` взводится **только** для `FirewallUnavailable`, обычные отказы под него не попадают.
Следствие: одна политика, которая падает стабильно и не связана с firewall — например Node удалён из `nodes.tsv`, и `lookup_node_ip` возвращает 78, — навсегда удерживает маркер. Полный проход начинает выполняться на каждом poll-тике (`min(interval, 30)` = 30 с) без ограничения, каждый проход — до двух `sudo`+`nft` на каждую подходящую политику. При полусотне Link это сотня привилегированных вызовов каждые полминуты на неопределённый срок.
**Сделать:** применить ограниченный backoff и к marker-пути. Например: счётчик неуспешных prompt-проходов, после 34 попыток маркер перестаёт снимать регулярный throttle, в журнал пишется одно предупреждение с идентификаторами упавших политик. Тест: политика, падающая всегда, при висящем маркере не должна вызывать более N проходов за интервал.
### N2 — MEDIUM. Orphan-правило для полностью сошедшейся Disabled-политики не обнаруживается
```csharp
private static bool IsEligibleForFullReconciliation(LinkPolicy link)
=> link.DesiredState == "Active"
|| (link.DesiredState == "Disabled" && link.ActualState != "Disabled");
```
Политика в состоянии `Disabled/Disabled` из полного прохода исключена — то есть если accept-правило с её комментарием окажется в цепочке, никто этого не заметит. А это ровно инвариант kill switch.
**Это моя недоработка в постановке, а не отклонение исполнителя** — фильтр в задаче 3 сформулирован мной дословно так же. Отмечаю, чтобы не потерялось.
Реалистичные пути появления такого правила: восстановление сохранённого ruleset `nft -f` поверх, частично применённый backup, обрыв между успешным `link-connect` и записью в БД, отказ на середине `link.orphan-removed`.
**Правильное решение — развернуть направление прохода.** Сейчас идём БД → факт, по одной политике, двумя вызовами helper на каждую. Нужно наоборот: один вызов `link-list`, возвращающий **все** правила цепочки с комментарием `smm:*`, и сверка множеств:
- правило есть, политики в БД нет либо `DesiredState=Disabled` → удалить, `link.orphan-removed`;
- политика `DesiredState=Active`, правила нет → применить, `link.reapplied`;
- совпало → ничего.
Это одновременно закрывает N2, снимает зависимость от `IsEligibleForFullReconciliation`, и превращает стоимость прохода из `2N` привилегированных вызовов в один. `link-list` был в исходной постановке Block B (пункт B1) и до сих пор не реализован — сейчас есть только пер-линковый `link-status`.
Связано с M4: пока нет retention для завершённых Disabled-политик, обход БД→факт растёт неограниченно. Обход факт→БД от этого не зависит.
### N3 — LOW. На Hub без `mesh-init` предупреждение каждые 30 секунд
`install_control` создаёт `$STATE_DIR` и `$STATE_DIR/backups`, но не `$STATE_DIR/mesh`; каталог появляется только в `mesh_init` / `reserve_node_address`. Helper в `reconcile_status` делает `exec 9>"$RECONCILE_LOCK"` в несуществующем каталоге — под `set -e` это отказ. В `RunOnceAsync` он ловится и пишется `LogWarning`, то есть в journal попадает предупреждение каждые 30 секунд до первого `mesh-init`.
**Сделать:** helper возвращает `complete`, если каталог mesh отсутствует (Mesh ещё не инициализирован — запросов быть не может), либо создаёт каталог при первом обращении.
### N4 — LOW. Отсутствующий бинарь `nft` неотличим от отсутствующей таблицы
`inspect_firewall` берёт объединённый вывод `2>&1` и классифицирует по подстроке `No such file or directory`. Если `/usr/sbin/nft` не установлен, эту же строку выдаёт сам bash — и ситуация классифицируется как `mesh.firewall-unavailable` (79). Поведение fail-closed и мутаций не будет, так что дефект не опасный, но оператор увидит «Mesh firewall не загружен» вместо «nftables не установлен».
**Сделать:** явная проверка `[[ -x /usr/sbin/nft ]]` с отдельным сообщением до вызова.
### N5 — LOW. Форматирование в `three-server-mesh.sh`
В блоке `probe_factual_status` / `expect_factual_status` перемешаны отступы 4 и 2 пробела, закрывающая `}` вынесена внутрь тела, определение `expect_factual_status() {` смещено на два пробела. `bash -n` проходит, поведение верное, но остальной файл написан с отступом в 2 пробела. Похоже на тот же артефакт инструмента, что в прошлый раз дал `(char)13`. Переформатировать.
### N6 — nit. `REPORT.md` меняет местами метки M1 и L1
Написано «M1 (CR/LF compaction) и L1 (direct Link lookup)»; в задаче M1 — это `GetLinkAsync`, L1 — CR/LF. Обе правки сделаны, перепутаны только подписи.
---
## 2. Задача 4
### Блок B-3 (первым)
Переносится из roadmap плюс находки выше.
| ID | Что | Приоритет |
|---|---|---|
| N2 + B1 | `link-list` в policy-helper и разворот прохода реконсиляции на факт → БД; удаление orphan-правил независимо от состояния в БД; один привилегированный вызов на проход вместо `2N` | высокий |
| N1 | Ограниченный backoff для marker-пути; тест на политику, падающую всегда | высокий |
| M2 | Развести `LinkReconciliationResult` на `Examined` / `Converged` / `Failed`; сейчас «1 реконсилирован» при нуле мутаций | средний |
| M4 | Фильтр по умолчанию на Links-странице («действующие + расхождения», с переключателем) и/или retention завершённых Disabled-политик в `ControlMaintenance` | средний |
| M5 | Типизировать «Node зарезервирован, но не активирован в mesh» — сейчас `lookup_node_ip` даёт отказ применения и Link уходит в `Failed` на каждом проходе | средний |
| N3 | Helper возвращает `complete` при отсутствующем каталоге mesh | низкий |
| N4 | Явная проверка наличия `nft` с отдельным сообщением | низкий |
| N5 | Переформатировать `three-server-mesh.sh` | низкий |
**Критерий выхода B-3:** проход реконсиляции обнаруживает и снимает accept-правило, которому не соответствует ни одна действующая политика, **включая случай `Disabled/Disabled`**; стабильно падающая политика не вызывает более одного прохода за интервал; Links-страница остаётся читаемой при сотнях исторических политик.
### Блок C — доверенная поставка
Без изменений относительно `smm-task-2-2026-07-31.md`, раздел 3, и `smm-task-3-2026-08-03.md`, раздел 3:
- **C1** manifest v2: хэши всех артефактов, версии Control/Agent/helper/Desktop, минимальные совместимые версии, `helper_protocol`.
- **C2** подпись (`cosign sign-blob` keyless либо `minisign`); публичный ключ/issuer вшит константой в bootstrap и Desktop, не скачивается вместе с релизом; отсутствие инструмента проверки — отказ, не предупреждение.
- **C3** `verify_archive` сверяет хэш с manifest, а не с соседним `.sha256`; действие `verify-manifest`; отказ `update-*` при несовместимых версиях; `PROGRAM_VERSION` из тега вместо `0.2.0-dev`.
- **C4** пиннинг Actions по commit SHA, `dependabot.yml` (github-actions + nuget), сужение `permissions: contents: write` до job'а публикации, SBOM в артефакты.
- **C5** негативные тесты в CI: изменённый байт архива, подменённый хэш в manifest, manifest без подписи — все три отвергаются.
### 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
```
Пока он не выполнен, B6 и вся Этап 3 roadmap остаются открытыми, независимо от того, сколько раз CI будет зелёным. Сейчас harness готов и включает шаг `firewall-restore` без перезапуска Node — то есть сценарий, ради которого делались Block B и B-2, впервые проверяем по-настоящему. Это единственный пункт списка, который не может закрыть исполнитель.
---
## 3. Порядок
1. **B-3**, приоритет по таблице: N2+B1 и N1 в одном PR (они оба про сервис реконсиляции), M2/M4/M5 вторым, N3/N4/N5 попутно.
2. **Physical acceptance** — как только выданы topology inputs. По результату закрыть B6 и соответствующие пункты Этапа 3 и 5 roadmap.
3. **Блок C**.
## 4. Процесс — работает, оставить
Практика «независимый review получает полный текст задачи, а не только диф» подтвердилась: пять находок этого цикла относятся к классу, который в прошлый раз не искали вовсе. Сохранить и для B-3, и для Блока C — для Блока C особенно, там корректность дифа и корректность модели угроз расходятся ещё сильнее.
Формулировка статуса `merged / verified, physical acceptance pending` вместо «выполнено» — тоже оставить.