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 ведёт **на этот сервер**. Так как
|
||||
|
||||
Reference in New Issue
Block a user