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
+100
View File
@@ -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 ведёт **на этот сервер**. Так как
+107 -6
View File
@@ -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`
+26
View File
@@ -270,6 +270,32 @@ hy2xs-admin`, и диагностика подозрения на проблем
auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и
синтаксис `nft` проверяются полностью.
### Где проходит граница read-only
Guard действует внутри процесса оркестратора. Он не способен запретить
побочный эффект, который вызвал бы HTTP-запрос в **другом** процессе, поэтому
эта половина границы держится не им, а составом проб.
Существенный случай — machine-auth. Успешная авторизация пира заставляет админку
выполнить `UPDATE peer.last_connection_at`, то есть диагностика изменила бы
отображаемое «последнее подключение» у `bootstrap-admin-peer`. Поэтому проба с
**действующим** паролем выполняется только в режиме `install`; `doctor` работает
в режиме `reconfigure` и до неё не доходит. Полный happy-path авторизации
проверяют установка и E2E, а не диагностика.
`doctor` отправляет только пробы, которые заведомо не проходят авторизацию
(отсутствующий machine token, неверные учётные данные, некорректный тип поля) и
читающие запросы (`/healthz`, `trafficStats /online`). Ни одна из них не
изменяет данные.
Честная формулировка гарантии:
> `doctor` не изменяет конфигурацию, состояние сервисов, firewall и данные.
Единственный след, который он оставляет, — записи в журнале админки: пробы
проходят через обычный обработчик логирования, как любой запрос. Это не
состояние системы, но и не «совсем ничего», поэтому сказано прямо.
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
+15
View File
@@ -80,6 +80,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
Резервные копии не удаляются, пока восстановление не подтверждено, и переживают
долговечную запись `phase: installed`. Поэтому при разборе неудачи всегда
осмысленно посмотреть:
```bash
ls -la /run/hy2xs/rollback/ # копии firewall текущей операции
ls -la /etc/hy2xs/backups/ # копия состояния до последнего reconfigure
cat /etc/hy2xs/backups/*/manifest.json
```
Манифест прямо говорит, какие файлы существовали до операции, а какие нет:
запись `"present": false` означает, что откат обязан был файл **удалить**, а не
восстановить.
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
проблему внутри отката: последняя — это информация о том, что осталось не