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:
@@ -210,6 +210,41 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
|
||||
восстановленные unit-файлы так и не применялись.
|
||||
|
||||
## A5c. Целостность резервных копий (unit)
|
||||
|
||||
`orchestrator/test/backup-integrity.test.ts`:
|
||||
|
||||
- копия каждой операции адресуется своим каталогом, разные `op-id` не
|
||||
пересекаются;
|
||||
- разные пути дают разные имена файлов копии, и имя не выходит за пределы
|
||||
каталога;
|
||||
- отсутствовавший файл записан **явно** (`present: false`), а не выведен из
|
||||
неудачи `cp`;
|
||||
- манифест переживает сериализацию без потерь;
|
||||
- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON,
|
||||
запись без пути, без признака существования или без имени копии —
|
||||
отклоняются. «Поле не разобралось, будем считать, что файла не было» означало
|
||||
бы удаление существующего файла при откате;
|
||||
- копирование в `reconfigure` и в `firewall` не глушит ошибки, факт создания
|
||||
копии проверяется, а копия снимается **до** первой мутации;
|
||||
- маркер готовности firewall (`prepared`) ставится после проверенных копий;
|
||||
- восстановление firewall не глушит ошибки `cp`/`nft`, а резервные копии
|
||||
удаляются только после подтверждённого успеха — иначе сохраняются вместе с
|
||||
сообщением `manual recovery data preserved at …`.
|
||||
|
||||
## A5d. Порядок фиксации успеха (unit)
|
||||
|
||||
`orchestrator/test/commit-ordering.test.ts`:
|
||||
|
||||
- снятие таймера автоотката и удаление резервных копий — **разные** операции
|
||||
(`disarmFirewallRollback` / `cleanupFirewallRollback`), объединённая
|
||||
`cancelFirewallRollback` не вернулась ни в один вызов;
|
||||
- `disarm` не удаляет копии;
|
||||
- порядок в `install` и `reconfigure` одинаков: `disarm` → долговечная запись
|
||||
`installed` → `cleanup`;
|
||||
- успешный smoke фиксируется отдельной фазой до снятия таймера;
|
||||
- уборка после точки фиксации выполняется best-effort.
|
||||
|
||||
## A5b. Долговечная запись маркера (unit)
|
||||
|
||||
`orchestrator/test/atomic-write.test.ts`:
|
||||
@@ -227,6 +262,11 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
|
||||
файла, по которому clean-host принимает решение.
|
||||
|
||||
- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель
|
||||
синхронизируется только при создании, иначе долговечность записи `hy2xs` в
|
||||
`/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть
|
||||
весь каталог вместе с маркером.
|
||||
|
||||
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
|
||||
пользовательского процесса их не наблюдать, а без них `rename()` даёт
|
||||
атомарность видимости без долговечности.
|
||||
@@ -823,15 +863,29 @@ runtime-пакета выполнял `install.sh`, эти пути не при
|
||||
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
|
||||
больше не стоит перед восстановлением.
|
||||
|
||||
Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту
|
||||
обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs`
|
||||
и заполнить его до отказа:
|
||||
**Тайминг здесь — часть сценария, и его легко испортить.**
|
||||
|
||||
`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
|
||||
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
|
||||
другой путь — обычный `fatal_post_apply` на ранней стадии.
|
||||
|
||||
Порядок строго такой:
|
||||
|
||||
1. запустить установку и дождаться в журнале `step=firewall status=done`;
|
||||
2. **только теперь**, во втором терминале:
|
||||
|
||||
```bash
|
||||
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
|
||||
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
|
||||
```
|
||||
|
||||
3. вызвать искусственный отказ следующего шага установки.
|
||||
|
||||
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
|
||||
через отказ на более длинном шаге (`smoke`), где времени заметно больше:
|
||||
дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из
|
||||
сервисов, чтобы smoke не сошёлся.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
|
||||
@@ -856,9 +910,51 @@ dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
|
||||
отменялись разом.
|
||||
|
||||
Дополнительно проверяется независимость стадий: если сделать неработоспособной
|
||||
первую стадию (например, удалить `/run/hy2xs/rollback/<op>` между применением
|
||||
firewall и отказом), восстановление конфигов и остановка сервисов обязаны
|
||||
выполниться всё равно.
|
||||
первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
|
||||
`chattr +i` между применением firewall и отказом), восстановление конфигов и
|
||||
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
|
||||
`rollback stage "…" failed, continuing with the remaining stages` и итоговое
|
||||
`rollback finished with N failed stage(s)`.
|
||||
|
||||
## D1c. Данные отката переживают отказ фиксации успеха
|
||||
|
||||
Проверяется на чистом хосте. Это второй сценарий того же класса, но на
|
||||
противоположном конце операции: отказывает не промежуточный шаг, а **запись
|
||||
успеха**.
|
||||
|
||||
1. установка доходит до успешного `smoke`, в маркере появляется
|
||||
`phase: smoke_ok`;
|
||||
2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же
|
||||
`tmpfs`, смонтированный по появлению `step=smoke checks status=done`);
|
||||
3. запись `phase: installed` падает;
|
||||
4. `/run/hy2xs/rollback/<op>/prepared` и обе резервные копии firewall **всё ещё
|
||||
существуют** — это и есть проверяемое свойство;
|
||||
5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему
|
||||
содержимому, развёрнутые юниты останавливаются;
|
||||
6. в журнале **нет** строки `no HY2XS rollback markers found`.
|
||||
|
||||
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
|
||||
удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому
|
||||
откат запускался, но откатывать ему было нечем.
|
||||
|
||||
Обратная проверка — успешный путь: после нормально завершённой установки
|
||||
`/run/hy2xs/rollback/` пуст, а `phase: installed` записан.
|
||||
|
||||
## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
|
||||
|
||||
Проверяется на рабочей установке.
|
||||
|
||||
1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или
|
||||
заполненный `tmpfs`);
|
||||
2. запускается `reconfigure --apply`;
|
||||
3. операция отказывает на шаге `backup` с сообщением про несозданную копию;
|
||||
4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не
|
||||
изменены**, сервисы не перезапускались.
|
||||
|
||||
Отдельно проверяется привязка копии к операции: после успешного `reconfigure`
|
||||
в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с
|
||||
`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с
|
||||
`"present": false`.
|
||||
|
||||
## D1a. Проход установки не спотыкается о собственный маркер
|
||||
|
||||
@@ -1017,3 +1113,8 @@ hy2xs-orchestrator doctor
|
||||
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
|
||||
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
|
||||
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`
|
||||
56. данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью `phase: installed`
|
||||
57. резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется
|
||||
58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
|
||||
59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
|
||||
60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install`
|
||||
|
||||
Reference in New Issue
Block a user