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:
@@ -201,6 +201,14 @@ machine token или пароль пира, а сообщение уходит
|
||||
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
|
||||
Для метаданных восстановления это неприемлемо.
|
||||
|
||||
Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs`
|
||||
создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть
|
||||
долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены
|
||||
на носитель, а каталог из родителя исчез — то есть маркер пропал целиком.
|
||||
Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при
|
||||
создании синхронизирует родителя. На последующих обновлениях маркера каталог уже
|
||||
существует, и лишний `fsync` родителя не выполняется.
|
||||
|
||||
## Ownership и rollback
|
||||
|
||||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||||
@@ -286,6 +294,98 @@ preflight-ошибка со словом `nftables` приводила к отк
|
||||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||||
что осталось не восстановленным, а не замена диагноза.
|
||||
|
||||
Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно
|
||||
тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой
|
||||
команде, стадия физически не могла сообщить, что восстановление не выполнилось:
|
||||
`cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль
|
||||
при любом исходе, и «restore configuration» никогда не попадала в список
|
||||
отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода
|
||||
возврата после его появления стало не защитой, а маскировкой.
|
||||
|
||||
### Порядок фиксации успеха
|
||||
|
||||
Данные, по которым выполняется откат, обязаны пережить долговечную запись
|
||||
успеха:
|
||||
|
||||
```text
|
||||
smoke PASS
|
||||
↓
|
||||
durable phase = smoke_ok
|
||||
↓
|
||||
disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ
|
||||
↓
|
||||
durable phase = installed ← точка фиксации
|
||||
↓
|
||||
cleanup резервных копий ← best effort
|
||||
```
|
||||
|
||||
Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до**
|
||||
записи `installed`. Отсюда следовал разрыв:
|
||||
|
||||
```text
|
||||
smoke PASS
|
||||
→ таймер снят, резервные копии УДАЛЕНЫ
|
||||
→ запись "installed" падает (ENOSPC / EIO / read-only ФС)
|
||||
→ обработчик ошибки → обязательный откат
|
||||
→ "firewall rollback skipped: no HY2XS rollback markers found"
|
||||
```
|
||||
|
||||
То есть ровно тот отказ записи маркера, который был специально сделан
|
||||
безопасным, случался после уничтожения единственных данных для отката: откат
|
||||
запускался, но откатывать ему было нечем.
|
||||
|
||||
Уборка после точки фиксации выполняется best-effort намеренно: невозможность
|
||||
удалить временные данные в `/run` — мусор, а не причина объявить успешную
|
||||
установку неуспешной.
|
||||
|
||||
### Резервные копии: строгие и привязанные к операции
|
||||
|
||||
Две отдельные гарантии, которых раньше не было ни у firewall, ни у
|
||||
`reconfigure`.
|
||||
|
||||
**Копия обязана существовать до первой мутации.** Копирование выполнялось как
|
||||
`cp ... || true`, поэтому отказ (заполненный `/run`, ошибка ввода-вывода, права)
|
||||
игнорировался, а операция шла менять систему, не имея того, на что рассчитывает
|
||||
откат. Теперь копирование строгое, факт создания проверяется, а маркер
|
||||
готовности `prepared` ставится **после** проверенных копий, а не до них.
|
||||
|
||||
**Копия принадлежит конкретной операции.** `reconfigure` хранил копии всех
|
||||
операций одним общим набором `*.bak` в `/etc/hy2xs/backups`. Отсюда сценарий:
|
||||
|
||||
```text
|
||||
reconfigure A → config.yaml.bak создан
|
||||
reconfigure B → создание копии упало, ошибка скрыта
|
||||
→ B меняет конфигурацию
|
||||
→ B падает → откат восстанавливает копию, снятую операцией A
|
||||
```
|
||||
|
||||
Сервер возвращался не в состояние «до B», а в более старое — и это выглядело
|
||||
успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups/<op-id>/` с
|
||||
манифестом:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"opId": "2026-08-30T10-00-00.000Z",
|
||||
"entries": [
|
||||
{ "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" },
|
||||
{ "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Отсутствие файла — **записанный факт**, а не вывод из неудачи `cp`: по этому
|
||||
полю откат решает, восстанавливать файл или удалять его. Разбор манифеста
|
||||
строгий, включая проверку `opId`: восстановление по частично понятому манифесту
|
||||
или по копии чужой операции опаснее отказа.
|
||||
|
||||
**Артефакты восстановления удаляются только после подтверждённого
|
||||
восстановления.** `rollbackFirewallNow` раньше скрывала ошибки `cp` и `nft`, а
|
||||
затем безусловно удаляла копии — худшая комбинация, при которой неудача
|
||||
восстановления не видна, а данные для ручной починки уничтожены. Теперь при
|
||||
любом отказе стадии копии сохраняются, и в журнале появляется
|
||||
`manual recovery data preserved at …`.
|
||||
|
||||
## Инвариант публичного endpoint
|
||||
|
||||
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
|
||||
|
||||
Reference in New Issue
Block a user