fix(v1): сделать откат неотменяемым, а маркер установки — долговечным

Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
This commit is contained in:
2026-08-30 18:11:55 +05:00
parent 60a1aea85e
commit e84fdedc4b
13 changed files with 1124 additions and 80 deletions
+90 -2
View File
@@ -153,7 +153,9 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
ошибки;
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
`stateTouched`: записанный `install-state.json` уже делает хост изменённым;
- частично выполненная запись маркера (отказ на `chown` после успешного `write`)
тоже даёт post-apply: флаг взводится **до** записи, а не после неё;
- начатая (не обязательно завершённая) установка пакетов уже даёт
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
установщик заявил, что ничего не тронул»;
@@ -188,6 +190,47 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
- записываемый маркер всегда несёт идентификацию поколения;
- незавершённая установка подсказывает `repair --allow-partial-state`.
## A5a. Обязательный откат (unit)
`orchestrator/test/rollback-mandatory.test.ts` — поведение механизма проверяется
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
исходника (поднять systemd и nftables в этой среде нельзя):
- при отказе первой стадии отката выполняются **все** последующие;
- отказавшие стадии перечисляются по именам и в порядке объявления;
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная
ошибка операции, а не проблема внутри восстановления;
- не-`Error` причина (брошенная строка) не роняет откат;
- `persistFailureState` не пробрасывает отказ записи наружу — это и был P0:
падение записи маркера отменяло откат целиком;
- в обработчике ошибки `install` и `reconfigure` не осталось незащищённой записи
состояния (`advanceInstallState` / `markPhase` голым `await`);
- откат в обеих командах идёт через `runRollbackStages`, а не цепочкой `await`;
- внутри `rollbackCurrentState` ни одна команда не обрывает следующие: отказ
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
восстановленные unit-файлы так и не применялись.
## A5b. Долговечная запись маркера (unit)
`orchestrator/test/atomic-write.test.ts`:
- содержимое заменяется целиком, а не дописывается поверх прежнего;
- при отказе записи по целевому пути остаётся **прежний полный** документ;
- временный файл не выживает ни при успехе, ни при отказе подстановки;
- права выставляются точно, независимо от umask (`0600`, `0644`);
- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir`
этого не делает, поэтому «создать» и «права такие, как объявлено» — два
разных действия;
- и запись, и создание каталога проходят через read-only guard;
- `persistInstallState` под guard'ом отказывает: единственный писатель маркера
обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
файла, по которому clean-host принимает решение.
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них `rename()` даёт
атомарность видимости без долговечности.
## A6. Редактирование секретов (unit)
`orchestrator/test/redaction.test.ts`:
@@ -727,6 +770,48 @@ runtime-пакета выполнял `install.sh`, эти пути не при
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением.
Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту
обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs`
и заполнить его до отказа:
```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
```
Сценарий:
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
взведён, правила применены);
2. следующий шаг ломается искусственно;
3. запись `phase: failed` в маркер падает по `ENOSPC`;
4. в журнале есть `failed to persist failure state, continuing with the
mandatory rollback`;
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
развёрнутые этой операцией юниты останавливаются и выключаются;
6. SSH остаётся доступным;
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла **исходная** ошибка операции, а не `ENOSPC`.
До исправления шаги 4–6 давали противоположный результат: бросок из записи
состояния уносил управление наружу, и сервер оставался с применённым firewall
неудавшейся установки.
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, удалить `/run/hy2xs/rollback/<op>` между применением
firewall и отказом), восстановление конфигов и остановка сервисов обязаны
выполниться всё равно.
## D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
@@ -836,7 +921,7 @@ hy2xs-orchestrator doctor
5. `post-install.env` отражает фактическое deploy-состояние
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
7. оркестратор не требует standalone update / rollback / uninstall subcommands
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
9. Telegram/access layer не требуется для прохождения install acceptance
10. отсутствует production path для port hopping
11. UI не запускается от root
@@ -879,3 +964,6 @@ hy2xs-orchestrator doctor
48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons`
49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height`
50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле