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:
@@ -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` при уже созданном файле
|
||||
|
||||
Reference in New Issue
Block a user