fix(v1): сделать надёжным нижний слой отката, а не только его запуск

Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.

1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).

   cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
   firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
   записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
   откат честно запускался и сообщал "no HY2XS rollback markers found":
   откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
   который прошлый проход специально сделал безопасным.

   Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
   cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
   smoke_ok -> disarm -> durable installed -> cleanup best-effort.

2. Резервные копии снимались без доказательства.

   И firewall, и reconfigure копировали как `cp ... || true`: отказ
   игнорировался, операция шла менять систему без копии, на которую
   рассчитывает откат. У firewall маркер prepared («данные для отката
   существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
   создания проверяется, маркер ставится после.

3. Копии reconfigure смешивались между операциями.

   Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
   операции B копирование падало, B всё равно менял систему, а его откат
   восстанавливал файлы операции A — сервер возвращался в более старое
   состояние и это выглядело успешным откатом. Копия стала операционной:
   /etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
   явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
   включая проверку opId.

4. Ошибка восстановления скрывалась, и после неё копии удалялись.

   rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
   удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
   стадия успешна, данные для ручной починки уничтожены. Теперь копии
   удаляются только после подтверждённого успеха, иначе сохраняются с
   сообщением manual recovery data preserved at ...

5. Команды отката глушили собственный код возврата.

   До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
   после его появления стал маскировкой — стадия не могла сообщить, что
   ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
   стадий.

6. Долговечность записи каталога маркера.

   writeTextAtomic синхронизирует файл и его каталог, но при первой установке
   /var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
   несинхронизированной. ensureDir сообщает о фактическом создании и
   синхронизирует родителя только тогда.

Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.

Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
2026-08-30 19:32:43 +05:00
parent df73459ea5
commit 330a63b050
17 changed files with 1534 additions and 98 deletions
+61
View File
@@ -27,6 +27,67 @@ Hardening-проход перед релизом `1.0.0`. Основная те
проверявшие не то, что обещали, и два свойства, которые были описаны, но не
обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`.
Пятый проход — нижний слой того же механизма. Верхнеуровневый откат стал
надёжным раньше, чем его storage/firewall substrate: откат гарантированно
запускался, но отдельные его шаги могли молча не выполнить восстановление,
отчитаться успехом и уничтожить резервную копию.
### Исправлено — целостность отката
- **Данные для отката уничтожались до фиксации успеха.** Успешный install
заканчивался вызовом, который снимал таймер автоотката **и удалял резервные
копии firewall**, — и стоял этот вызов ДО долговечной записи
`phase: installed`. Если запись падала (`ENOSPC`, `EIO`, read-only ФС),
управление уходило в обработчик ошибки, обязательный откат честно запускался
и сообщал `no HY2XS rollback markers found`. Откат нельзя было отменить, но
откатывать ему было нечем — причём отказ записи маркера это ровно тот
сценарий, который был специально сделан безопасным прошлым проходом.
Операция разделена на `disarmFirewallRollback` (снять таймер, копии
оставить) и `cleanupFirewallRollback` (удалить копии). Порядок теперь:
`smoke_ok` → disarm → durable `installed` → cleanup best-effort. То же в
`reconfigure`.
- **Резервные копии снимались без доказательства.** И firewall, и
`reconfigure` копировали файлы как `cp ... || true`, поэтому отказ
копирования игнорировался, а операция начинала менять систему, не имея
копии, на которую рассчитывает откат. У firewall маркер `prepared`
(«данные для отката существуют») выставлялся вообще до копирования.
Копирование стало строгим, факт создания проверяется, маркер ставится после.
- **Копии `reconfigure` смешивались между операциями.** Они лежали одним общим
набором `*.bak` в `/etc/hy2xs/backups`, не привязанным к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы, сохранённые операцией A: сервер возвращался не в
состояние «до B», а в более старое — и это выглядело успешным откатом.
Копия стала операционной: `/etc/hy2xs/backups/<op-id>/` с манифестом, где
отсутствие файла — записанный факт (`"present": false`), а не вывод из
неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`.
- **Ошибка восстановления скрывалась, а копии после неё удалялись.**
`rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем
безусловно удаляла `/run/hy2xs/rollback/<op>`. Худшая возможная комбинация:
неудача восстановления не видна, стадия отчитывается успешной, а данные, по
которым оператор мог бы поднять firewall вручную, уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением `manual recovery data preserved at …`.
- **Команды отката глушили собственный код возврата.** `systemctl stop`,
`disable`, `reset-failed`, `cp`, `nft -f`, `daemon-reload`, `restart` — все
несли `|| true`. До появления стадийного раннера это была единственная защита
от обрыва цепочки; после его появления — маскировка: стадия физически не
могла сообщить, что ничего не сделала, и обещание «отказавшие стадии
перечисляются» для них не выполнялось никогда. `|| true` убран,
непрерывность обеспечивает раннер, `rollbackCurrentState` разбита на семь
независимых стадий.
- **Долговечность записи каталога маркера.** `writeTextAtomic` синхронизирует
файл и каталог, в котором файл лежит, но при первой установке
`/var/lib/hy2xs` создаётся тут же, и запись «hy2xs» в `/var/lib` оставалась
несинхронизированной: после потери питания мог исчезнуть весь каталог вместе
с маркером. `ensureDir` сообщает о фактическом создании и синхронизирует
родителя только тогда.
### Изменено — свойства, ставшие инвариантами
- **`doctor` read-only по инварианту рантайма, а не по соглашению.**