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:
@@ -166,12 +166,47 @@ machine token или пароль пира, а сообщение уходит
|
||||
поверх незавершённой установки. Разрешение не подразумевается: молчаливое
|
||||
согласие на произвольный partial marker и позволяло «чинить» чужое состояние.
|
||||
|
||||
### Запись маркера долговечна и имеет ровно одного владельца
|
||||
|
||||
`install` и `reconfigure` пишут маркер через один и тот же
|
||||
`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями:
|
||||
`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно.
|
||||
Слабейшая гарантия досталась команде, которая этот файл создаёт.
|
||||
|
||||
Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой
|
||||
отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на
|
||||
сервере половину документа:
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "hy2xs",
|
||||
"release_line":
|
||||
```
|
||||
|
||||
Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий,
|
||||
а clean-host — как присутствующий, причём хост к этому моменту уже изменён.
|
||||
|
||||
Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи:
|
||||
|
||||
```text
|
||||
1. запись во временный файл в том же каталоге
|
||||
2. права и владелец ← до подстановки: иначе есть окно,
|
||||
в котором файл виден с чужими правами
|
||||
3. fsync временного файла ← данные на носителе, а не в page cache
|
||||
4. rename ← атомарная подстановка
|
||||
5. fsync каталога ← сама запись каталога о новом имени
|
||||
```
|
||||
|
||||
Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной
|
||||
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
|
||||
Для метаданных восстановления это неприемлемо.
|
||||
|
||||
## Ownership и rollback
|
||||
|
||||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||||
|
||||
```text
|
||||
stateWritten
|
||||
stateTouched
|
||||
depsTouched
|
||||
filesystemTouched
|
||||
uiTouched
|
||||
@@ -189,12 +224,18 @@ servicesStarted
|
||||
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||||
мутирующим вызовом**, а не после него.
|
||||
|
||||
`stateWritten` — полноценный участник классификации. `install-state.json`
|
||||
`stateTouched` — полноценный участник классификации. `install-state.json`
|
||||
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||||
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||||
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||||
следующую установку по clean-host контракту.
|
||||
|
||||
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
|
||||
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
|
||||
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
|
||||
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
|
||||
существующем `/var/lib/hy2xs/install-state.json`.
|
||||
|
||||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||||
@@ -202,13 +243,49 @@ preflight-ошибка со словом `nftables` приводила к отк
|
||||
Инварианты rollback:
|
||||
|
||||
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||||
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
|
||||
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||
`/var/log/hy2xs`).
|
||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||
развернула эти unit-файлы**.
|
||||
|
||||
### После операционного отказа откат выполняется целиком
|
||||
|
||||
Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`:
|
||||
|
||||
```text
|
||||
запись состояния отказа → best effort
|
||||
сбор диагностики → best effort
|
||||
откат → обязателен
|
||||
```
|
||||
|
||||
Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть
|
||||
падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего.
|
||||
Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил
|
||||
управление наружу, и восстановление не выполнялось вовсе: применённый firewall и
|
||||
развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто
|
||||
раньше, для записи состояния — нет.
|
||||
|
||||
Второй инвариант — **стадии отката независимы**:
|
||||
|
||||
| Команда | Стадии |
|
||||
| --- | --- |
|
||||
| `install` | firewall → stop services → disable services → reset failed services |
|
||||
| `reconfigure` | firewall → restore configuration |
|
||||
|
||||
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
|
||||
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
|
||||
следующие. В `reconfigure` это означало сервер одновременно с применённым
|
||||
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
|
||||
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
|
||||
|
||||
Стадии выполняются последовательно и в объявленном порядке; независимость
|
||||
означает «отказ не прерывает остальные», а не «выполняется как попало».
|
||||
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
|
||||
ошибка операции: проблема внутри отката — это дополнительная информация о том,
|
||||
что осталось не восстановленным, а не замена диагноза.
|
||||
|
||||
## Инвариант публичного endpoint
|
||||
|
||||
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
|
||||
|
||||
@@ -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` при уже созданном файле
|
||||
|
||||
@@ -70,6 +70,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
||||
- rollback guard не должен отменяться до успешного smoke;
|
||||
- для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause.
|
||||
|
||||
Откат после операционного отказа выполняется целиком и сам по себе не может
|
||||
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
|
||||
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
|
||||
|
||||
| Строка в журнале | Что она означает |
|
||||
| --- | --- |
|
||||
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
|
||||
| `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` | восстановление отработало полностью |
|
||||
|
||||
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
|
||||
проблему внутри отката: последняя — это информация о том, что осталось не
|
||||
восстановленным, а не причина отказа.
|
||||
|
||||
## 9. Reconfigure flow
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user