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:
@@ -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 по инварианту рантайма, а не по соглашению.**
|
||||
|
||||
Reference in New Issue
Block a user