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 и позволяло «чинить» чужое состояние.
|
согласие на произвольный 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
|
## Ownership и rollback
|
||||||
|
|
||||||
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
Операция ведёт учёт того, к чему она **могла прикоснуться**:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
stateWritten
|
stateTouched
|
||||||
depsTouched
|
depsTouched
|
||||||
filesystemTouched
|
filesystemTouched
|
||||||
uiTouched
|
uiTouched
|
||||||
@@ -189,12 +224,18 @@ servicesStarted
|
|||||||
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
|
||||||
мутирующим вызовом**, а не после него.
|
мутирующим вызовом**, а не после него.
|
||||||
|
|
||||||
`stateWritten` — полноценный участник классификации. `install-state.json`
|
`stateTouched` — полноценный участник классификации. `install-state.json`
|
||||||
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
пишется сразу после успешного preflight, до `installDeps`; пока он в
|
||||||
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
|
||||||
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
|
||||||
следующую установку по clean-host контракту.
|
следующую установку по clean-host контракту.
|
||||||
|
|
||||||
|
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
|
||||||
|
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
|
||||||
|
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
|
||||||
|
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
|
||||||
|
существующем `/var/lib/hy2xs/install-state.json`.
|
||||||
|
|
||||||
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
|
||||||
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
|
||||||
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
|
||||||
@@ -202,13 +243,49 @@ preflight-ошибка со словом `nftables` приводила к отк
|
|||||||
Инварианты rollback:
|
Инварианты rollback:
|
||||||
|
|
||||||
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
|
||||||
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
|
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
|
||||||
случае system rollback не выполняется, `install-state.json` не пишется,
|
случае system rollback не выполняется, `install-state.json` не пишется,
|
||||||
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
|
||||||
`/var/log/hy2xs`).
|
`/var/log/hy2xs`).
|
||||||
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
- `systemctl stop/disable` выполняется **только если текущая операция сама
|
||||||
развернула эти unit-файлы**.
|
развернула эти 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
|
## Инвариант публичного endpoint
|
||||||
|
|
||||||
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
|
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
||||||
ошибки;
|
ошибки;
|
||||||
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
|
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
|
||||||
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
|
`stateTouched`: записанный `install-state.json` уже делает хост изменённым;
|
||||||
|
- частично выполненная запись маркера (отказ на `chown` после успешного `write`)
|
||||||
|
тоже даёт post-apply: флаг взводится **до** записи, а не после неё;
|
||||||
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
- начатая (не обязательно завершённая) установка пакетов уже даёт
|
||||||
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
|
`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`.
|
- незавершённая установка подсказывает `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)
|
## A6. Редактирование секретов (unit)
|
||||||
|
|
||||||
`orchestrator/test/redaction.test.ts`:
|
`orchestrator/test/redaction.test.ts`:
|
||||||
@@ -727,6 +770,48 @@ runtime-пакета выполнял `install.sh`, эти пути не при
|
|||||||
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
|
порт, не ответил резолвер) объявлялся `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. Проход установки не спотыкается о собственный маркер
|
## D1a. Проход установки не спотыкается о собственный маркер
|
||||||
|
|
||||||
Проверяется на чистом хосте, обычной успешной установкой.
|
Проверяется на чистом хосте, обычной успешной установкой.
|
||||||
@@ -836,7 +921,7 @@ hy2xs-orchestrator doctor
|
|||||||
5. `post-install.env` отражает фактическое deploy-состояние
|
5. `post-install.env` отражает фактическое deploy-состояние
|
||||||
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
|
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
|
||||||
7. оркестратор не требует standalone update / rollback / uninstall subcommands
|
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
|
9. Telegram/access layer не требуется для прохождения install acceptance
|
||||||
10. отсутствует production path для port hopping
|
10. отсутствует production path для port hopping
|
||||||
11. UI не запускается от root
|
11. UI не запускается от root
|
||||||
@@ -879,3 +964,6 @@ hy2xs-orchestrator doctor
|
|||||||
48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons`
|
48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons`
|
||||||
49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height`
|
49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height`
|
||||||
50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
|
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;
|
- rollback guard не должен отменяться до успешного smoke;
|
||||||
- для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause.
|
- для 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
|
## 9. Reconfigure flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { InstallContext, InstallOptions } from "../types/context";
|
import type { InstallContext, InstallOptions } from "../types/context";
|
||||||
import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs";
|
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
|
||||||
import { runMutatingVisible } from "../lib/process";
|
import { runMutatingVisible } from "../lib/process";
|
||||||
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
import { info, setOperationContext, step, stepDone } from "../lib/log";
|
||||||
import { readPackageValue } from "../lib/packageMeta";
|
import { readPackageValue } from "../lib/packageMeta";
|
||||||
import {
|
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
|
||||||
INSTALL_STATE_PATH,
|
import { persistInstallState } from "../lib/installStateWriter";
|
||||||
REPAIR_HINT,
|
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||||
buildInstallStateRecord
|
|
||||||
} from "../lib/installState";
|
|
||||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||||
import {
|
import {
|
||||||
ORCHESTRATOR_INSTALL_DIR,
|
ORCHESTRATOR_INSTALL_DIR,
|
||||||
@@ -62,8 +60,18 @@ type InstallPhase =
|
|||||||
* "firewall", — недопустимо.
|
* "firewall", — недопустимо.
|
||||||
*/
|
*/
|
||||||
type OperationOwnership = {
|
type OperationOwnership = {
|
||||||
/** install-state.json уже создан: сам по себе делает хост изменённым. */
|
/**
|
||||||
stateWritten: boolean;
|
* К /var/lib/hy2xs уже могли прикоснуться: сам по себе созданный
|
||||||
|
* install-state.json делает хост изменённым.
|
||||||
|
*
|
||||||
|
* Флаг называется touched, а не written, по той же причине, что и остальные.
|
||||||
|
* Запись маркера — это три операции (`mkdir`, `write`, `chown`), и отказ
|
||||||
|
* последней оставляет файл на диске. Пока флаг взводился ПОСЛЕ успешной
|
||||||
|
* записи, такой отказ давал классификацию `fatal_pre_apply` — «на сервере
|
||||||
|
* ничего не изменено», — хотя /var/lib/hy2xs/install-state.json уже
|
||||||
|
* существовал и следующая чистая установка опознавала его как чужую.
|
||||||
|
*/
|
||||||
|
stateTouched: boolean;
|
||||||
/**
|
/**
|
||||||
* Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs,
|
* Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs,
|
||||||
* symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они
|
* symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они
|
||||||
@@ -92,7 +100,7 @@ type FailureKind =
|
|||||||
|
|
||||||
function newOwnership(): OperationOwnership {
|
function newOwnership(): OperationOwnership {
|
||||||
return {
|
return {
|
||||||
stateWritten: false,
|
stateTouched: false,
|
||||||
bootstrapTouched: false,
|
bootstrapTouched: false,
|
||||||
depsTouched: false,
|
depsTouched: false,
|
||||||
filesystemTouched: false,
|
filesystemTouched: false,
|
||||||
@@ -145,9 +153,7 @@ async function writeInstallState(
|
|||||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
||||||
});
|
});
|
||||||
|
|
||||||
await runMutatingVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
|
await persistInstallState(record);
|
||||||
await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644);
|
|
||||||
await runMutatingVisible`chown root:root ${INSTALL_STATE_PATH}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function advanceInstallState(
|
async function advanceInstallState(
|
||||||
@@ -156,15 +162,18 @@ async function advanceInstallState(
|
|||||||
phase: InstallPhase,
|
phase: InstallPhase,
|
||||||
lastError = ""
|
lastError = ""
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
|
||||||
|
// stateTouched. Частично выполненная запись маркера — это уже изменение
|
||||||
|
// хоста, и классификация обязана исходить из «сюда мы могли влезть».
|
||||||
|
ownership.stateTouched = true;
|
||||||
await writeInstallState(context, phase, lastError);
|
await writeInstallState(context, phase, lastError);
|
||||||
ownership.stateWritten = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Классификация опирается на то, к чему операция уже могла прикоснуться.
|
* Классификация опирается на то, к чему операция уже могла прикоснуться.
|
||||||
* `fatal_pre_apply` по определению означает «на сервере ничего не изменено»,
|
* `fatal_pre_apply` по определению означает «на сервере ничего не изменено»,
|
||||||
* поэтому в него нельзя попасть после ЛЮБОГО взведённого флага — включая
|
* поэтому в него нельзя попасть после ЛЮБОГО взведённого флага — включая
|
||||||
* `stateWritten`: записанный /var/lib/hy2xs/install-state.json это уже
|
* `stateTouched`: записанный /var/lib/hy2xs/install-state.json это уже
|
||||||
* изменение хоста, которое переживёт неудачную установку.
|
* изменение хоста, которое переживёт неудачную установку.
|
||||||
*
|
*
|
||||||
* Порядок веток — от самой поздней стадии к самой ранней: она точнее
|
* Порядок веток — от самой поздней стадии к самой ранней: она точнее
|
||||||
@@ -190,7 +199,7 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha
|
|||||||
ownership.filesystemTouched ||
|
ownership.filesystemTouched ||
|
||||||
ownership.depsTouched ||
|
ownership.depsTouched ||
|
||||||
ownership.bootstrapTouched ||
|
ownership.bootstrapTouched ||
|
||||||
ownership.stateWritten
|
ownership.stateTouched
|
||||||
) {
|
) {
|
||||||
return "fatal_post_apply";
|
return "fatal_post_apply";
|
||||||
}
|
}
|
||||||
@@ -201,6 +210,12 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha
|
|||||||
* Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула
|
* Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула
|
||||||
* текущая операция. Иначе неудачный запуск установщика на чужом сервере
|
* текущая операция. Иначе неудачный запуск установщика на чужом сервере
|
||||||
* положил бы работающий сервис.
|
* положил бы работающий сервис.
|
||||||
|
*
|
||||||
|
* Второй инвариант — стадии независимы. Снятие firewall и остановка сервисов
|
||||||
|
* чинят разные половины неудачной установки, и отказ первой не имеет права
|
||||||
|
* отменить вторую: `rollbackFirewallNow` выполняет `systemctl`, `cp` и
|
||||||
|
* `rm -rf`, то есть умеет упасть сам, а без остановки сервисов на хосте
|
||||||
|
* остаётся включённый в автозапуск hysteria-server от неудавшейся установки.
|
||||||
*/
|
*/
|
||||||
async function rollbackFailedInstall(
|
async function rollbackFailedInstall(
|
||||||
context: InstallContext,
|
context: InstallContext,
|
||||||
@@ -217,19 +232,44 @@ async function rollbackFailedInstall(
|
|||||||
// которого install-state сохраняется с repair_hint. Снести оркестратор при
|
// которого install-state сохраняется с repair_hint. Снести оркестратор при
|
||||||
// откате означало бы лишить оператора инструмента починки. Полная зачистка —
|
// откате означало бы лишить оператора инструмента починки. Полная зачистка —
|
||||||
// это осознанное отдельное действие, tools/legacy/purge-v0.sh.
|
// это осознанное отдельное действие, tools/legacy/purge-v0.sh.
|
||||||
|
const stages: RollbackStage[] = [];
|
||||||
|
|
||||||
if (ownership.firewallTouched) {
|
if (ownership.firewallTouched) {
|
||||||
|
stages.push({
|
||||||
|
name: "firewall",
|
||||||
|
run: async () => {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (!ownership.unitsTouched) {
|
|
||||||
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ownership.unitsTouched) {
|
||||||
|
stages.push(
|
||||||
|
{
|
||||||
|
name: "stop services",
|
||||||
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
|
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disable services",
|
||||||
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
|
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reset failed services",
|
||||||
|
run: async () => {
|
||||||
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
|
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
await runRollbackStages(stages);
|
||||||
|
}
|
||||||
|
|
||||||
export async function install(options: InstallOptions): Promise<void> {
|
export async function install(options: InstallOptions): Promise<void> {
|
||||||
setOperationContext(`install-${Date.now().toString(36)}`);
|
setOperationContext(`install-${Date.now().toString(36)}`);
|
||||||
@@ -373,11 +413,21 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await advanceInstallState(
|
// Запись состояния отказа — best effort, откат — обязателен.
|
||||||
|
//
|
||||||
|
// На заполненном диске или read-only ФС падает и она: `mkdir`, запись файла
|
||||||
|
// и `chown` в /var/lib/hy2xs. Пока она стояла обычным await, её собственный
|
||||||
|
// отказ уносил управление из обработчика наружу, и до rollbackFailedInstall
|
||||||
|
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
|
||||||
|
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
|
||||||
|
// существует. Это тот же класс, что и с диагностикой ниже.
|
||||||
|
await persistFailureState(() =>
|
||||||
|
advanceInstallState(
|
||||||
context,
|
context,
|
||||||
ownership,
|
ownership,
|
||||||
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
|
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
|
||||||
`${failureKind}: ${message}`
|
`${failureKind}: ${message}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
buildInstallStateRecord,
|
buildInstallStateRecord,
|
||||||
type InstallStateRecord
|
type InstallStateRecord
|
||||||
} from "../lib/installState";
|
} from "../lib/installState";
|
||||||
|
import { persistInstallState } from "../lib/installStateWriter";
|
||||||
|
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||||
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
|
||||||
import { preflight } from "../steps/preflight";
|
import { preflight } from "../steps/preflight";
|
||||||
import { generateConfig } from "../steps/config";
|
import { generateConfig } from "../steps/config";
|
||||||
@@ -105,11 +107,11 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l
|
|||||||
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
repairHint: phase === "installed" ? undefined : REPAIR_HINT
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
|
// Запись идёт тем же путём, что и в install: у маркера установки ровно один
|
||||||
mode: 0o644,
|
// владелец записи, и гарантии у него не зависят от того, какая команда его
|
||||||
owner: "root",
|
// обновляет. Каталог при этом обеспечивается здесь так же, как при установке:
|
||||||
group: "root"
|
// repair обязан работать и на хосте, где /var/lib/hy2xs потеряли вручную.
|
||||||
});
|
await persistInstallState(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backupCurrentState(): Promise<void> {
|
async function backupCurrentState(): Promise<void> {
|
||||||
@@ -139,7 +141,10 @@ async function rollbackCurrentState(): Promise<void> {
|
|||||||
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
|
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
|
||||||
|
|
||||||
await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
|
await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
|
||||||
await runMutatingVisible`systemctl daemon-reload`;
|
// `|| true` здесь по той же причине, что и у остальных команд отката: без
|
||||||
|
// него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то
|
||||||
|
// есть восстановленные из backups unit-файлы так и не были бы применены.
|
||||||
|
await runMutatingVisible`systemctl daemon-reload || true`;
|
||||||
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`;
|
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +289,15 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
info("reconfigure failed, rollback in progress");
|
info("reconfigure failed, rollback in progress");
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await markPhase(context, classifyReconfigureFailure(ownership), message);
|
|
||||||
|
// Запись состояния отказа — best effort, откат — обязателен.
|
||||||
|
//
|
||||||
|
// markPhase пишет в /var/lib/hy2xs и падает ровно там, где откат нужнее
|
||||||
|
// всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Пока она
|
||||||
|
// стояла обычным await, её отказ уносил управление наружу мимо обоих
|
||||||
|
// восстановлений — и снятия firewall, и возврата конфигов из
|
||||||
|
// /etc/hy2xs/backups.
|
||||||
|
await persistFailureState(() => markPhase(context, classifyReconfigureFailure(ownership), message));
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
//
|
//
|
||||||
@@ -301,10 +314,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Стадии отката независимы. Снятие firewall и возврат конфигов чинят разные
|
||||||
|
// половины неудачного прохода, и обе выполняются через `systemctl`, `cp` и
|
||||||
|
// `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой
|
||||||
|
// `await`, отказ первой отменял вторую: сервер оставался и с применённым
|
||||||
|
// сломанным firewall, и со сломанными конфигами одновременно.
|
||||||
|
const stages: RollbackStage[] = [];
|
||||||
|
|
||||||
if (ownership.firewallTouched) {
|
if (ownership.firewallTouched) {
|
||||||
|
stages.push({
|
||||||
|
name: "firewall",
|
||||||
|
run: async () => {
|
||||||
await rollbackFirewallNow(context);
|
await rollbackFirewallNow(context);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stages.push({
|
||||||
|
name: "restore configuration",
|
||||||
|
run: async () => {
|
||||||
await rollbackCurrentState();
|
await rollbackCurrentState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await runRollbackStages(stages);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-25
@@ -1,4 +1,5 @@
|
|||||||
import { stat } from "node:fs/promises";
|
import { chmod, mkdir, open, rename, stat, unlink } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import { assertMutationAllowed } from "./guard";
|
import { assertMutationAllowed } from "./guard";
|
||||||
|
|
||||||
async function statSafe(path: string): Promise<import("node:fs").Stats | null> {
|
async function statSafe(path: string): Promise<import("node:fs").Stats | null> {
|
||||||
@@ -44,44 +45,132 @@ export async function writeText(path: string, data: string, mode?: number): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Смена владельца по ИМЕНИ пользователя и группы.
|
||||||
|
*
|
||||||
|
* Здесь остаётся внешний `chown`, а не `fs.chown`: последний принимает только
|
||||||
|
* числовые uid/gid, то есть потребовал бы собственного разбора /etc/passwd и
|
||||||
|
* /etc/group. Имена — часть контракта установки (`root:root`,
|
||||||
|
* `hysteria:hy2xs-admin`), и разрешать их обязана система.
|
||||||
|
*/
|
||||||
|
function chownByName(target: string, owner: string, group: string): void {
|
||||||
|
const result = Bun.spawnSync(["chown", `${owner}:${group}`, target], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe"
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`chown failed for ${target}: ${result.stderr.toString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталог с гарантированными правами и владельцем.
|
||||||
|
*
|
||||||
|
* Существует ровно ради install-state: `mkdir -p` не меняет права уже
|
||||||
|
* существующего каталога, а режим при создании ещё и маскируется umask, поэтому
|
||||||
|
* «создать, если нет» и «права такие, как объявлено» — два разных действия.
|
||||||
|
*
|
||||||
|
* Проходит через guard: PHASE 0 не имеет права создать даже пустой каталог.
|
||||||
|
*/
|
||||||
|
export async function ensureDir(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
mode: number;
|
||||||
|
owner?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
assertMutationAllowed(`ensureDir(${path})`);
|
||||||
|
await mkdir(path, { recursive: true, mode: options.mode });
|
||||||
|
await chmod(path, options.mode);
|
||||||
|
if (options.owner && options.group) {
|
||||||
|
chownByName(path, options.owner, options.group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись файла, которая переживает потерю питания.
|
||||||
|
*
|
||||||
|
* Атомарность и долговечность — РАЗНЫЕ свойства, и раньше здесь было только
|
||||||
|
* первое. `Bun.write` + `mv` даёт атомарность видимости: читатель видит либо
|
||||||
|
* старое содержимое, либо новое, никогда половину. Но данные к моменту
|
||||||
|
* `rename()` живут в page cache, а сам `rename()` — в незасинхронизированном
|
||||||
|
* каталоге. После внезапной перезагрузки ext4 штатно отдаёт по этому пути
|
||||||
|
* нулевой файл или отсутствие файла вовсе.
|
||||||
|
*
|
||||||
|
* Для install-state.json это принципиально: он и есть метаданные
|
||||||
|
* восстановления. Оператор читает его первым, а `repair --allow-partial-state`
|
||||||
|
* принимает решения по его содержимому. Обрезанный до нуля маркер означает
|
||||||
|
* потерю единственного описания того, что установка успела сделать с сервером.
|
||||||
|
*
|
||||||
|
* Поэтому порядок здесь такой и никакой другой:
|
||||||
|
*
|
||||||
|
* 1. запись во временный файл рядом с целью (тот же каталог — rename обязан
|
||||||
|
* остаться внутри одной файловой системы);
|
||||||
|
* 2. права и владелец — ДО подстановки, иначе существует окно, в котором файл
|
||||||
|
* уже виден по целевому пути с чужими правами;
|
||||||
|
* 3. fsync временного файла — данные и метаданные на носителе;
|
||||||
|
* 4. rename — атомарная подстановка;
|
||||||
|
* 5. fsync каталога — сама запись каталога о новом имени.
|
||||||
|
*
|
||||||
|
* Шаг 5 на win32 пропускается: открыть каталог как файл там нельзя. Оркестратор
|
||||||
|
* на Windows не выполняется (assertPlatform требует Debian), а тесты обязаны
|
||||||
|
* идти и на машине разработчика.
|
||||||
|
*/
|
||||||
export async function writeTextAtomic(
|
export async function writeTextAtomic(
|
||||||
path: string,
|
path: string,
|
||||||
data: string,
|
data: string,
|
||||||
options: {
|
options: {
|
||||||
mode: number;
|
mode: number;
|
||||||
owner: string;
|
owner?: string;
|
||||||
group: string;
|
group?: string;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertMutationAllowed(`writeTextAtomic(${path})`);
|
assertMutationAllowed(`writeTextAtomic(${path})`);
|
||||||
const dir = path.replace(/\/[^/]+$/, "") || ".";
|
const dir = dirname(path);
|
||||||
const base = path.split("/").pop() || "tmp";
|
const base = path.split(/[/\\]/).pop() || "tmp";
|
||||||
const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
await Bun.write(tmp, data);
|
let renamed = false;
|
||||||
|
try {
|
||||||
const chmodResult = Bun.spawnSync(["chmod", options.mode.toString(8), tmp], {
|
// "wx" — отказ, если файл уже существует: имя случайное, и совпадение
|
||||||
stdout: "pipe",
|
// означало бы чужой файл, а не наш прошлый заход.
|
||||||
stderr: "pipe"
|
const handle = await open(tmp, "wx", options.mode);
|
||||||
});
|
try {
|
||||||
if (!chmodResult.success) {
|
await handle.writeFile(data, "utf8");
|
||||||
throw new Error(`chmod failed for ${tmp}: ${chmodResult.stderr.toString()}`);
|
// Режим при open маскируется umask, поэтому объявленные права
|
||||||
|
// выставляются явно — по дескриптору, а не по имени.
|
||||||
|
await handle.chmod(options.mode);
|
||||||
|
if (options.owner && options.group) {
|
||||||
|
chownByName(tmp, options.owner, options.group);
|
||||||
|
}
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const chownResult = Bun.spawnSync(["chown", `${options.owner}:${options.group}`, tmp], {
|
await rename(tmp, path);
|
||||||
stdout: "pipe",
|
renamed = true;
|
||||||
stderr: "pipe"
|
} finally {
|
||||||
});
|
if (!renamed) {
|
||||||
if (!chownResult.success) {
|
// Временный файл не имеет права пережить неудачную запись: каталог
|
||||||
throw new Error(`chown failed for ${tmp}: ${chownResult.stderr.toString()}`);
|
// install-state читается диагностикой и purge как набор наших файлов.
|
||||||
|
await unlink(tmp).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mvResult = Bun.spawnSync(["mv", "-f", tmp, path], {
|
await syncDirectory(dir);
|
||||||
stdout: "pipe",
|
}
|
||||||
stderr: "pipe"
|
|
||||||
});
|
async function syncDirectory(dir: string): Promise<void> {
|
||||||
if (!mvResult.success) {
|
if (process.platform === "win32") {
|
||||||
throw new Error(`atomic rename failed for ${path}: ${mvResult.stderr.toString()}`);
|
return;
|
||||||
|
}
|
||||||
|
const handle = await open(dir, "r");
|
||||||
|
try {
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../config/profi
|
|||||||
* Без них reconfigure/repair не отличают v1 от произвольного старого маркера.
|
* Без них reconfigure/repair не отличают v1 от произвольного старого маркера.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
|
export const INSTALL_STATE_DIR = "/var/lib/hy2xs";
|
||||||
|
|
||||||
|
export const INSTALL_STATE_PATH = `${INSTALL_STATE_DIR}/install-state.json`;
|
||||||
|
|
||||||
export const HY2XS_PRODUCT_ID = "hy2xs";
|
export const HY2XS_PRODUCT_ID = "hy2xs";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ensureDir, writeTextAtomic } from "./fs";
|
||||||
|
import { INSTALL_STATE_DIR, INSTALL_STATE_PATH, type InstallStateRecord } from "./installState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственный способ записать маркер установки на диск.
|
||||||
|
*
|
||||||
|
* Раньше их было два. `install` писал через `writeText` — обычная перезапись
|
||||||
|
* файла на месте, — а `reconfigure` через `writeTextAtomic`. Один и тот же файл,
|
||||||
|
* две разные гарантии, причём слабейшая досталась команде, которая создаёт этот
|
||||||
|
* файл впервые и после которой он и становится метаданными восстановления.
|
||||||
|
*
|
||||||
|
* Расхождение стоило дороже, чем выглядит. Перезапись на месте укорачивает файл
|
||||||
|
* до нуля и только потом наполняет: любой отказ между этими моментами — потеря
|
||||||
|
* питания, kill -9, ENOSPC — оставляет на сервере половину JSON:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "product": "hy2xs",
|
||||||
|
* "release_line":
|
||||||
|
*
|
||||||
|
* Такой маркер не разбирается, поэтому reconfigure/repair видят его как
|
||||||
|
* отсутствующий, а clean-host — как присутствующий. Установка при этом уже
|
||||||
|
* изменила хост.
|
||||||
|
*
|
||||||
|
* Модуль отделён от installState.ts намеренно: тот остаётся чистым (структура
|
||||||
|
* записи и проверка поколения) и разбирается юнит-тестами без файловой системы
|
||||||
|
* и подпроцессов.
|
||||||
|
*/
|
||||||
|
export async function persistInstallState(record: InstallStateRecord): Promise<void> {
|
||||||
|
await ensureDir(INSTALL_STATE_DIR, { mode: 0o755, owner: "root", group: "root" });
|
||||||
|
await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
|
||||||
|
mode: 0o644,
|
||||||
|
owner: "root",
|
||||||
|
group: "root"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { info } from "./log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Откат неудачной операции.
|
||||||
|
*
|
||||||
|
* Инвариант, ради которого существует модуль:
|
||||||
|
*
|
||||||
|
* после операционного отказа откат выполняется ЦЕЛИКОМ, и ни один его шаг
|
||||||
|
* не может отменить остальные.
|
||||||
|
*
|
||||||
|
* Что было. Откат был написан обычной цепочкой `await`:
|
||||||
|
*
|
||||||
|
* if (ownership.firewallTouched) {
|
||||||
|
* await rollbackFirewallNow(context);
|
||||||
|
* }
|
||||||
|
* await rollbackCurrentState();
|
||||||
|
*
|
||||||
|
* Каждый шаг отката — это `systemctl`, `cp`, `rm -rf` и `nft` через
|
||||||
|
* `runMutatingVisible`, который бросает на ненулевом коде возврата. То есть
|
||||||
|
* отказ ПЕРВОГО шага отменял все последующие. В reconfigure это означало
|
||||||
|
* буквально: сервер остаётся с применённым сломанным firewall И без
|
||||||
|
* восстановленных из /etc/hy2xs/backups конфигов — то есть худший сценарий
|
||||||
|
* отказа лишался обеих половин восстановления сразу.
|
||||||
|
*
|
||||||
|
* Это тот же класс ошибки, который уже закрыт для диагностики («диагностика —
|
||||||
|
* best effort, откат — обязателен»), просто применённый на уровень ниже: внутри
|
||||||
|
* самого отката шаги тоже не имеют права зависеть друг от друга.
|
||||||
|
*
|
||||||
|
* Порядок при этом сохраняется: шаги идут последовательно и в объявленном
|
||||||
|
* порядке. Независимость означает «отказ не прерывает», а не «выполняется
|
||||||
|
* как попало».
|
||||||
|
*/
|
||||||
|
export type RollbackStage = {
|
||||||
|
/** Имя для журнала: оператор читает его первым при разборе неудачи. */
|
||||||
|
name: string;
|
||||||
|
run: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет ВСЕ стадии отката и возвращает список отказавших.
|
||||||
|
*
|
||||||
|
* Возврат, а не бросок: вызывающий обязан после отката пробросить ИСХОДНУЮ
|
||||||
|
* ошибку операции. Ошибка внутри отката — это дополнительная информация о том,
|
||||||
|
* что именно осталось не восстановленным, а не замена причины отказа.
|
||||||
|
*/
|
||||||
|
export async function runRollbackStages(stages: readonly RollbackStage[]): Promise<string[]> {
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
try {
|
||||||
|
await stage.run();
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
failures.push(`${stage.name}: ${message}`);
|
||||||
|
info(`rollback stage "${stage.name}" failed, continuing with the remaining stages: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
info(`rollback completed: ${stages.length} stage(s) succeeded`);
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`rollback finished with ${failures.length} failed stage(s); manual recovery may be required:`);
|
||||||
|
for (const failure of failures) {
|
||||||
|
info(` - ${failure}`);
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись состояния отказа — best effort, ровно как сбор диагностики.
|
||||||
|
*
|
||||||
|
* Что было. И `install`, и `reconfigure` в обработчике ошибки первым делом
|
||||||
|
* писали в маркер фазу отказа обычным `await`, и только потом откатывались.
|
||||||
|
* Запись этого файла — это `mkdir`, `write` и `chown` в /var/lib/hy2xs, то есть
|
||||||
|
* она умеет упасть сама: заполненный диск, ФС в read-only, ошибка ввода-вывода.
|
||||||
|
*
|
||||||
|
* И падала она ровно в тех сценариях, ради которых откат и существует. Дальше
|
||||||
|
* бросок из обработчика уносил управление наружу, и обязательное
|
||||||
|
* восстановление — снятие применённого firewall, остановка развёрнутых
|
||||||
|
* сервисов, возврат конфигов — не выполнялось вовсе.
|
||||||
|
*
|
||||||
|
* То есть НЕОБЯЗАТЕЛЬНАЯ телеметрия состояния стояла перед ОБЯЗАТЕЛЬНЫМ
|
||||||
|
* восстановлением и умела его отменить. Состояние отказа полезно оператору, но
|
||||||
|
* оно описывает сервер, а откат его чинит; при выборе между «записать, что всё
|
||||||
|
* плохо» и «сделать, чтобы стало хорошо» продукт обязан выбирать второе.
|
||||||
|
*/
|
||||||
|
export async function persistFailureState(write: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await write();
|
||||||
|
} catch (error) {
|
||||||
|
info(
|
||||||
|
`failed to persist failure state, continuing with the mandatory rollback: ${errorMessage(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { ensureDir, writeTextAtomic } from "../src/lib/fs";
|
||||||
|
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard";
|
||||||
|
import { persistInstallState } from "../src/lib/installStateWriter";
|
||||||
|
import { buildInstallStateRecord } from "../src/lib/installState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* install-state.json — это метаданные восстановления, и записывать их нужно так,
|
||||||
|
* чтобы после отказа на сервере лежал ЛИБО прежний полный документ, ЛИБО новый
|
||||||
|
* полный документ.
|
||||||
|
*
|
||||||
|
* Что было. install писал маркер через `writeText` — обычную перезапись на
|
||||||
|
* месте. Она укорачивает файл до нуля и только потом наполняет, поэтому отказ
|
||||||
|
* между этими моментами оставлял половину JSON. reconfigure писал тот же файл
|
||||||
|
* атомарно, то есть у одного файла было две разные гарантии в зависимости от
|
||||||
|
* того, какая команда его обновляла.
|
||||||
|
*
|
||||||
|
* Проверяется здесь именно наблюдаемое поведение: временный файл не выживает,
|
||||||
|
* целевой никогда не остаётся обрезанным, права выставлены до подстановки.
|
||||||
|
* Сам fsync проверить из пользовательского процесса нельзя — его наличие
|
||||||
|
* закрепляет приёмка по исходнику.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const onPosix = process.platform !== "win32";
|
||||||
|
|
||||||
|
let dir = "";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "hy2xs-atomic-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
disableReadOnlyGuard();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function leftovers(): Promise<string[]> {
|
||||||
|
return (await readdir(dir)).filter((name) => name.includes(".tmp-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeTextAtomic", () => {
|
||||||
|
test("создаёт файл с объявленным содержимым", async () => {
|
||||||
|
const target = join(dir, "install-state.json");
|
||||||
|
await writeTextAtomic(target, '{"product":"hy2xs"}\n', { mode: 0o644 });
|
||||||
|
|
||||||
|
expect(await readFile(target, "utf8")).toBe('{"product":"hy2xs"}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("не оставляет временных файлов после успешной записи", async () => {
|
||||||
|
await writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 });
|
||||||
|
expect(await leftovers()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("заменяет прежнее содержимое целиком, а не дописывает", async () => {
|
||||||
|
const target = join(dir, "state.json");
|
||||||
|
await writeFile(target, "старое длинное содержимое, которое должно исчезнуть целиком");
|
||||||
|
await writeTextAtomic(target, "{}\n", { mode: 0o644 });
|
||||||
|
|
||||||
|
expect(await readFile(target, "utf8")).toBe("{}\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ключевое отличие от перезаписи на месте: пока новая запись не удалась,
|
||||||
|
// по целевому пути лежит ПРЕЖНИЙ полный документ, а не его обрезок.
|
||||||
|
test("при отказе записи прежний файл остаётся нетронутым", async () => {
|
||||||
|
const target = join(dir, "state.json");
|
||||||
|
const previous = '{"product":"hy2xs","phase":"installed"}\n';
|
||||||
|
await writeFile(target, previous);
|
||||||
|
|
||||||
|
// Каталог назначения исчезает между вызовами: создать временный файл негде,
|
||||||
|
// то есть запись падает на самом раннем шаге.
|
||||||
|
const missing = join(dir, "gone", "state.json");
|
||||||
|
await expect(writeTextAtomic(missing, "{}\n", { mode: 0o644 })).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await readFile(target, "utf8")).toBe(previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("отказ подстановки не оставляет временный файл", async () => {
|
||||||
|
// Цель — каталог: rename файла поверх непустого каталога не проходит.
|
||||||
|
const target = join(dir, "state.json");
|
||||||
|
await mkdir(target);
|
||||||
|
await mkdir(join(target, "occupied"));
|
||||||
|
|
||||||
|
await expect(writeTextAtomic(target, "{}\n", { mode: 0o644 })).rejects.toThrow();
|
||||||
|
expect(await leftovers()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!onPosix)("права выставляются точно, независимо от umask", async () => {
|
||||||
|
const target = join(dir, "state.json");
|
||||||
|
await writeTextAtomic(target, "{}\n", { mode: 0o600 });
|
||||||
|
|
||||||
|
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("600");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!onPosix)("режим 0644 маркера установки сохраняется", async () => {
|
||||||
|
const target = join(dir, "install-state.json");
|
||||||
|
await writeTextAtomic(target, "{}\n", { mode: 0o644 });
|
||||||
|
|
||||||
|
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("644");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("запись проходит через read-only guard", async () => {
|
||||||
|
enableReadOnlyGuard("test phase");
|
||||||
|
await expect(writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 })).rejects.toThrow(
|
||||||
|
/read-only guard violation/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureDir", () => {
|
||||||
|
test("создаёт недостающие уровни", async () => {
|
||||||
|
const target = join(dir, "var", "lib", "hy2xs");
|
||||||
|
await ensureDir(target, { mode: 0o755 });
|
||||||
|
|
||||||
|
expect((await stat(target)).isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("повторный вызов не является ошибкой", async () => {
|
||||||
|
const target = join(dir, "hy2xs");
|
||||||
|
await ensureDir(target, { mode: 0o755 });
|
||||||
|
await ensureDir(target, { mode: 0o755 });
|
||||||
|
|
||||||
|
expect((await stat(target)).isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// mkdir не меняет права уже существующего каталога, поэтому «создать» и
|
||||||
|
// «права такие, как объявлено» — два разных действия.
|
||||||
|
test.skipIf(!onPosix)("права приводятся к объявленным у существующего каталога", async () => {
|
||||||
|
const target = join(dir, "hy2xs");
|
||||||
|
await mkdir(target, { mode: 0o700 });
|
||||||
|
await ensureDir(target, { mode: 0o755 });
|
||||||
|
|
||||||
|
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("создание каталога проходит через read-only guard", async () => {
|
||||||
|
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
|
||||||
|
await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow(
|
||||||
|
/read-only guard violation.*PHASE 0/s
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("persistInstallState", () => {
|
||||||
|
// Единственный писатель маркера обязан идти через guarded-примитивы: иначе
|
||||||
|
// PHASE 0 смогла бы создать /var/lib/hy2xs, и «read-only» перестало бы быть
|
||||||
|
// правдой ровно для того файла, по которому clean-host принимает решение.
|
||||||
|
test("под read-only guard запись маркера невозможна", async () => {
|
||||||
|
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
|
||||||
|
const record = buildInstallStateRecord({
|
||||||
|
productVersion: "1.0.0",
|
||||||
|
buildId: "b",
|
||||||
|
opId: "op",
|
||||||
|
startedAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
phase: "preflight_ok",
|
||||||
|
installed: false,
|
||||||
|
ownedPaths: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(persistInstallState(record)).rejects.toThrow(/read-only guard violation/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ type Ownership = Parameters<typeof classifyFailure>[0];
|
|||||||
|
|
||||||
function ownership(overrides: Partial<Ownership> = {}): Ownership {
|
function ownership(overrides: Partial<Ownership> = {}): Ownership {
|
||||||
return {
|
return {
|
||||||
stateWritten: false,
|
stateTouched: false,
|
||||||
bootstrapTouched: false,
|
bootstrapTouched: false,
|
||||||
depsTouched: false,
|
depsTouched: false,
|
||||||
filesystemTouched: false,
|
filesystemTouched: false,
|
||||||
@@ -109,8 +109,21 @@ describe("классификация отказа установки", () => {
|
|||||||
// объявлялось «на сервере ничего не изменено», rollback пропускался, а
|
// объявлялось «на сервере ничего не изменено», rollback пропускался, а
|
||||||
// /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую
|
// /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую
|
||||||
// установку по clean-host контракту.
|
// установку по clean-host контракту.
|
||||||
test("записанный install-state сам по себе делает отказ post-apply", () => {
|
test("тронутый install-state сам по себе делает отказ post-apply", () => {
|
||||||
expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
|
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).toBe("fatal_post_apply");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вторая половина той же регрессии. Флаг назывался stateWritten и взводился
|
||||||
|
// ПОСЛЕ успешной записи, а запись маркера — три операции (mkdir, write,
|
||||||
|
// chown). Отказ на chown оставлял /var/lib/hy2xs/install-state.json на диске
|
||||||
|
// при невзведённом флаге, то есть давал «на сервере ничего не изменено» с
|
||||||
|
// уже существующим маркером установки.
|
||||||
|
test("частично выполненная запись маркера уже post-apply", () => {
|
||||||
|
// Ровно то состояние, которое оставляет упавший на chown advanceInstallState:
|
||||||
|
// флаг взведён, а фаза ещё preflight_ok.
|
||||||
|
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).not.toBe(
|
||||||
|
"fatal_pre_apply"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh,
|
// Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh,
|
||||||
@@ -122,13 +135,13 @@ describe("классификация отказа установки", () => {
|
|||||||
"fatal_post_apply"
|
"fatal_post_apply"
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
classifyFailure(ownership({ stateWritten: true, bootstrapTouched: true }), "bootstrap_installed")
|
classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed")
|
||||||
).toBe("fatal_post_apply");
|
).toBe("fatal_post_apply");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("падение installDeps после записи состояния — post-apply", () => {
|
test("падение installDeps после записи состояния — post-apply", () => {
|
||||||
expect(
|
expect(
|
||||||
classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok")
|
classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok")
|
||||||
).toBe("fatal_post_apply");
|
).toBe("fatal_post_apply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Откат после операционного отказа обязан выполниться ЦЕЛИКОМ.
|
||||||
|
*
|
||||||
|
* Здесь закрепляются два дефекта одного класса, оба найдены в failure path
|
||||||
|
* install и reconfigure:
|
||||||
|
*
|
||||||
|
* 1. запись состояния отказа стояла перед откатом обычным `await`, поэтому её
|
||||||
|
* собственное падение (заполненный диск, read-only ФС) отменяло откат
|
||||||
|
* целиком;
|
||||||
|
* 2. сам откат был цепочкой `await`, поэтому падение первой стадии отменяло
|
||||||
|
* все последующие. В reconfigure это означало сервер одновременно и с
|
||||||
|
* применённым сломанным firewall, и без восстановленных конфигов.
|
||||||
|
*
|
||||||
|
* Механизм проверяется поведением — настоящим внедрением отказа в стадию.
|
||||||
|
* Проводка команд к этому механизму проверяется разбором исходника: поднять
|
||||||
|
* настоящие systemd-юниты и nftables в этой среде нельзя, а утверждение при
|
||||||
|
* этом остаётся точным.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
|
||||||
|
// репозитория (сборка), и из orchestrator/ (разработчик).
|
||||||
|
function source(relativeToSrc: string): string {
|
||||||
|
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("стадии отката независимы", () => {
|
||||||
|
test("все стадии выполняются, когда все успешны", async () => {
|
||||||
|
const executed: string[] = [];
|
||||||
|
const failures = await runRollbackStages([
|
||||||
|
{ name: "firewall", run: async () => void executed.push("firewall") },
|
||||||
|
{ name: "services", run: async () => void executed.push("services") }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(executed).toEqual(["firewall", "services"]);
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ключевой инвариант патча: упавшая стадия не отменяет остальные.
|
||||||
|
test("падение первой стадии не отменяет последующие", async () => {
|
||||||
|
const executed: string[] = [];
|
||||||
|
const failures = await runRollbackStages([
|
||||||
|
{
|
||||||
|
name: "firewall",
|
||||||
|
run: async () => {
|
||||||
|
executed.push("firewall");
|
||||||
|
throw new Error("nft: command failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ name: "restore configuration", run: async () => void executed.push("restore") },
|
||||||
|
{ name: "stop services", run: async () => void executed.push("stop") }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(executed).toEqual(["firewall", "restore", "stop"]);
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]).toContain("firewall");
|
||||||
|
expect(failures[0]).toContain("nft: command failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("падение каждой стадии учитывается отдельно и в порядке объявления", async () => {
|
||||||
|
const failures = await runRollbackStages([
|
||||||
|
{
|
||||||
|
name: "firewall",
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("first");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ name: "healthy", run: async () => undefined },
|
||||||
|
{
|
||||||
|
name: "restore configuration",
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("second");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(failures).toHaveLength(2);
|
||||||
|
expect(failures[0]).toStartWith("firewall: ");
|
||||||
|
expect(failures[1]).toStartWith("restore configuration: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Причиной отказа операции остаётся исходная ошибка: откат сообщает о своих
|
||||||
|
// проблемах возвратом, а не броском, иначе бы он подменил собой диагноз.
|
||||||
|
test("откат не бросает даже при отказе всех стадий", async () => {
|
||||||
|
const failures = await runRollbackStages([
|
||||||
|
{
|
||||||
|
name: "a",
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "b",
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(failures).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("не-Error причина не роняет откат", async () => {
|
||||||
|
const failures = await runRollbackStages([
|
||||||
|
{
|
||||||
|
name: "weird",
|
||||||
|
run: async () => {
|
||||||
|
throw "строковая ошибка";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(failures[0]).toContain("строковая ошибка");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("пустой набор стадий допустим", async () => {
|
||||||
|
expect(await runRollbackStages([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("состояние отказа пишется best effort", () => {
|
||||||
|
test("успешная запись выполняется", async () => {
|
||||||
|
let written = false;
|
||||||
|
await persistFailureState(async () => {
|
||||||
|
written = true;
|
||||||
|
});
|
||||||
|
expect(written).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия: это и был P0. Падение записи маркера уносило управление наружу
|
||||||
|
// мимо обязательного отката.
|
||||||
|
test("падение записи не пробрасывается наружу", async () => {
|
||||||
|
await expect(
|
||||||
|
persistFailureState(async () => {
|
||||||
|
throw new Error("ENOSPC: no space left on device");
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install: откат обязателен после операционного отказа", () => {
|
||||||
|
const installSource = source("commands/install.ts");
|
||||||
|
|
||||||
|
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
||||||
|
expect(installSource).toContain("await persistFailureState(() =>");
|
||||||
|
const guarded = installSource.indexOf("await persistFailureState(() =>");
|
||||||
|
const advance = installSource.indexOf("advanceInstallState(", guarded);
|
||||||
|
// Обёрнута именно запись маркера, а не что-то другое рядом.
|
||||||
|
expect(advance).toBeGreaterThan(guarded);
|
||||||
|
expect(advance - guarded).toBeLessThan(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("состояние отказа пишется раньше отката, но не может его отменить", () => {
|
||||||
|
const guarded = installSource.indexOf("await persistFailureState(");
|
||||||
|
const rollback = installSource.indexOf("await rollbackFailedInstall(");
|
||||||
|
expect(guarded).toBeGreaterThan(-1);
|
||||||
|
expect(rollback).toBeGreaterThan(guarded);
|
||||||
|
});
|
||||||
|
|
||||||
|
// В обработчике не должно остаться ни одной незащищённой записи состояния:
|
||||||
|
// единственный голый advanceInstallState в catch и был дефектом.
|
||||||
|
test("в обработчике ошибки нет незащищённой записи маркера", () => {
|
||||||
|
const catchAt = installSource.indexOf("} catch (error) {");
|
||||||
|
expect(catchAt).toBeGreaterThan(-1);
|
||||||
|
const handler = installSource.slice(catchAt);
|
||||||
|
expect(handler).not.toContain("await advanceInstallState(");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("откат идёт через независимые стадии", () => {
|
||||||
|
expect(installSource).toContain("await runRollbackStages(stages)");
|
||||||
|
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
|
||||||
|
// именно они и образовывали отменяемую цепочку.
|
||||||
|
const start = installSource.indexOf("async function rollbackFailedInstall");
|
||||||
|
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
||||||
|
expect(body).toContain("await rollbackFirewallNow(context)");
|
||||||
|
// Вызов существует только внутри стадии.
|
||||||
|
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
|
||||||
|
const stageAt = body.lastIndexOf('name: "firewall"', firewallAt);
|
||||||
|
expect(stageAt).toBeGreaterThan(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("остановка сервисов остаётся отдельными стадиями", () => {
|
||||||
|
for (const stage of ["stop services", "disable services", "reset failed services"]) {
|
||||||
|
expect(installSource).toContain(`name: "${stage}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("чужие сервисы по-прежнему не трогаются", () => {
|
||||||
|
expect(installSource).toContain(
|
||||||
|
"systemd units were not deployed by this operation, leaving services untouched"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconfigure: откат обязателен после операционного отказа", () => {
|
||||||
|
const reconfigureSource = source("commands/reconfigure.ts");
|
||||||
|
|
||||||
|
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
||||||
|
expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("в обработчике ошибки нет незащищённого markPhase", () => {
|
||||||
|
const catchAt = reconfigureSource.indexOf("} catch (error) {");
|
||||||
|
expect(catchAt).toBeGreaterThan(-1);
|
||||||
|
const handler = reconfigureSource.slice(catchAt);
|
||||||
|
expect(handler).not.toContain("await markPhase(");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firewall и восстановление конфигов — независимые стадии", () => {
|
||||||
|
expect(reconfigureSource).toContain('name: "firewall"');
|
||||||
|
expect(reconfigureSource).toContain('name: "restore configuration"');
|
||||||
|
expect(reconfigureSource).toContain("await runRollbackStages(stages)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
|
||||||
|
test("порядок сохранён: сначала firewall, затем конфиги", () => {
|
||||||
|
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
|
||||||
|
const restore = reconfigureSource.indexOf("await rollbackCurrentState()");
|
||||||
|
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
||||||
|
expect(firewall).toBeGreaterThan(-1);
|
||||||
|
expect(firewall).toBeLessThan(restore);
|
||||||
|
expect(restore).toBeLessThan(stages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Внутри самого восстановления конфигов дефект был тот же: единственная
|
||||||
|
// команда без `|| true` отменяла перезапуск сервисов строкой ниже.
|
||||||
|
test("ни одна команда восстановления конфигов не обрывает следующие", () => {
|
||||||
|
const start = reconfigureSource.indexOf("async function rollbackCurrentState");
|
||||||
|
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState"));
|
||||||
|
const offenders = body
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.includes("runMutatingVisible`"))
|
||||||
|
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
|
||||||
|
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -307,24 +307,44 @@ run_clean_install_acceptance() {
|
|||||||
grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \
|
grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \
|
||||||
|| fail "acceptance: rollback must never stop services it did not deploy"
|
|| fail "acceptance: rollback must never stop services it did not deploy"
|
||||||
|
|
||||||
log_step "Acceptance: a written install-state already makes the failure post-apply"
|
log_step "Acceptance: a touched install-state already makes the failure post-apply"
|
||||||
# Регрессия: classifyFailure не учитывал stateWritten, поэтому падение
|
# Регрессия: classifyFailure не учитывал маркер установки, поэтому падение
|
||||||
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
|
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
|
||||||
# а install-state.json оставался на хосте и ломал следующую установку.
|
# а install-state.json оставался на хосте и ломал следующую установку.
|
||||||
grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|
grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \
|
||||||
|| fail "acceptance: classifyFailure must account for a written install-state"
|
|| fail "acceptance: classifyFailure must account for a touched install-state"
|
||||||
|
! grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|
||||||
|
|| fail "acceptance: stateWritten вернулся; флаг обязан называться stateTouched и взводиться до записи"
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
||||||
const body = source.slice(source.indexOf("export function classifyFailure"));
|
const body = source.slice(source.indexOf("export function classifyFailure"));
|
||||||
const preApply = body.indexOf("return \"fatal_pre_apply\"");
|
const preApply = body.indexOf("return \"fatal_pre_apply\"");
|
||||||
const stateWritten = body.indexOf("ownership.stateWritten");
|
const stateTouched = body.indexOf("ownership.stateTouched");
|
||||||
if (preApply < 0 || stateWritten < 0) {
|
if (preApply < 0 || stateTouched < 0) {
|
||||||
throw new Error("could not locate classifyFailure branches");
|
throw new Error("could not locate classifyFailure branches");
|
||||||
}
|
}
|
||||||
if (stateWritten > preApply) {
|
if (stateTouched > preApply) {
|
||||||
throw new Error("stateWritten is checked after the fatal_pre_apply fallback");
|
throw new Error("stateTouched is checked after the fatal_pre_apply fallback");
|
||||||
}
|
}
|
||||||
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written"
|
' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was touched"
|
||||||
|
|
||||||
|
log_step "Acceptance: the install-state flag is raised before the write, like every other one"
|
||||||
|
# Запись маркера — это mkdir, write и chown. Отказ последней оставляет файл на
|
||||||
|
# диске, поэтому флаг обязан отвечать на вопрос «сюда мы могли влезть», а не
|
||||||
|
# «запись удалась».
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
|
||||||
|
const start = source.indexOf("async function advanceInstallState");
|
||||||
|
if (start < 0) throw new Error("advanceInstallState is missing");
|
||||||
|
// Границей тела служит следующее объявление верхнего уровня.
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.search(/\n(export )?(async )?function /);
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
const flag = body.indexOf("ownership.stateTouched = true");
|
||||||
|
const write = body.indexOf("await writeInstallState(");
|
||||||
|
if (flag < 0 || write < 0) throw new Error("could not locate the flag and the write");
|
||||||
|
if (flag > write) throw new Error("stateTouched is raised after writeInstallState");
|
||||||
|
' || fail "acceptance: the install-state ownership flag must be raised before the write"
|
||||||
|
|
||||||
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
|
log_step "Acceptance: mutating ownership flags are raised before the step, not after"
|
||||||
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
|
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
|
||||||
@@ -791,6 +811,125 @@ run_single_owner_acceptance() {
|
|||||||
}
|
}
|
||||||
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
' || fail "acceptance: a diagnostics failure must never cancel the rollback"
|
||||||
|
|
||||||
|
log_step "Acceptance: persisting the failure state never blocks the rollback"
|
||||||
|
# Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше.
|
||||||
|
# Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она
|
||||||
|
# падает ровно на заполненном диске и read-only ФС — там, где откат нужнее
|
||||||
|
# всего. Пока она стояла обычным await, её отказ уносил управление наружу
|
||||||
|
# мимо снятия firewall и остановки развёрнутых сервисов.
|
||||||
|
[ -f orchestrator/src/lib/rollback.ts ] \
|
||||||
|
|| fail "acceptance: модуль обязательного отката отсутствует"
|
||||||
|
local rollback_command
|
||||||
|
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
|
||||||
|
grep -q 'persistFailureState(' "$rollback_command" \
|
||||||
|
|| fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort"
|
||||||
|
done
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
for (const [file, write] of [
|
||||||
|
["orchestrator/src/commands/install.ts", "await advanceInstallState("],
|
||||||
|
["orchestrator/src/commands/reconfigure.ts", "await markPhase("]
|
||||||
|
]) {
|
||||||
|
const source = fs.readFileSync(file, "utf8");
|
||||||
|
const handler = source.slice(source.indexOf("} catch (error) {"));
|
||||||
|
if (handler.length === 0) throw new Error("не найден обработчик ошибки в " + file);
|
||||||
|
if (handler.includes(write)) {
|
||||||
|
throw new Error("незащищённая запись состояния отказа в обработчике " + file);
|
||||||
|
}
|
||||||
|
const guarded = handler.indexOf("await persistFailureState(");
|
||||||
|
if (guarded < 0) throw new Error("состояние отказа не обёрнуто в " + file);
|
||||||
|
}
|
||||||
|
' || fail "acceptance: отказ записи состояния обязан продолжать откат, а не отменять его"
|
||||||
|
|
||||||
|
log_step "Acceptance: rollback stages are independent, not a cancellable chain"
|
||||||
|
# Каждая стадия отката — systemctl/cp/rm/nft, то есть умеет упасть сама.
|
||||||
|
# Цепочка `await` означала, что отказ первой отменяет все следующие: в
|
||||||
|
# reconfigure сервер оставался и с применённым сломанным firewall, и без
|
||||||
|
# восстановленных из /etc/hy2xs/backups конфигов одновременно.
|
||||||
|
grep -q 'export async function runRollbackStages' orchestrator/src/lib/rollback.ts \
|
||||||
|
|| fail "acceptance: у отката нет механизма независимых стадий"
|
||||||
|
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
|
||||||
|
grep -q 'await runRollbackStages(stages)' "$rollback_command" \
|
||||||
|
|| fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой"
|
||||||
|
done
|
||||||
|
# Внутри восстановления конфигов дефект был тот же: единственная команда без
|
||||||
|
# `|| true` отменяла перезапуск сервисов строкой ниже.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
|
||||||
|
const start = source.indexOf("async function rollbackCurrentState");
|
||||||
|
if (start < 0) throw new Error("rollbackCurrentState отсутствует");
|
||||||
|
const rest = source.slice(start + 1);
|
||||||
|
const end = rest.search(/\n(export )?(async )?function /);
|
||||||
|
const body = end < 0 ? rest : rest.slice(0, end);
|
||||||
|
const offenders = body.split(/\r?\n/)
|
||||||
|
.filter((line) => line.includes("runMutatingVisible`"))
|
||||||
|
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
|
||||||
|
if (offenders.length) {
|
||||||
|
throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n"));
|
||||||
|
}
|
||||||
|
' || fail "acceptance: ни одна команда отката не имеет права отменить остальные"
|
||||||
|
|
||||||
|
log_step "Acceptance: the install-state marker has exactly one durable writer"
|
||||||
|
# Раньше writeText в install и writeTextAtomic в reconfigure давали одному
|
||||||
|
# файлу две разные гарантии, причём слабейшую — команде, которая его создаёт.
|
||||||
|
[ -f orchestrator/src/lib/installStateWriter.ts ] \
|
||||||
|
|| fail "acceptance: модуль записи маркера установки отсутствует"
|
||||||
|
for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do
|
||||||
|
grep -q 'await persistInstallState(record)' "$rollback_command" \
|
||||||
|
|| fail "acceptance: $rollback_command пишет маркер установки мимо единственного писателя"
|
||||||
|
! grep -q 'writeText(INSTALL_STATE_PATH' "$rollback_command" \
|
||||||
|
|| fail "acceptance: неатомарная перезапись маркера установки вернулась в $rollback_command"
|
||||||
|
done
|
||||||
|
# Долговечность, а не только атомарность: rename без fsync после потери
|
||||||
|
# питания штатно отдаёт нулевой файл, а маркер — это метаданные восстановления.
|
||||||
|
grep -q 'await handle.sync()' orchestrator/src/lib/fs.ts \
|
||||||
|
|| fail "acceptance: временный файл подставляется без fsync данных"
|
||||||
|
grep -q 'async function syncDirectory' orchestrator/src/lib/fs.ts \
|
||||||
|
|| fail "acceptance: каталог не синхронизируется после подстановки"
|
||||||
|
# Владелец обязан выставляться ДО подстановки: иначе существует окно, в
|
||||||
|
# котором файл уже виден по целевому пути с чужими правами.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
|
||||||
|
const start = source.indexOf("export async function writeTextAtomic");
|
||||||
|
if (start < 0) throw new Error("writeTextAtomic отсутствует");
|
||||||
|
const body = source.slice(start);
|
||||||
|
const chown = body.indexOf("chownByName(tmp");
|
||||||
|
const sync = body.indexOf("await handle.sync()");
|
||||||
|
const rename = body.indexOf("await rename(tmp, path)");
|
||||||
|
if (chown < 0 || sync < 0 || rename < 0) throw new Error("не найдены шаги атомарной записи");
|
||||||
|
if (!(chown < sync && sync < rename)) {
|
||||||
|
throw new Error("порядок обязан быть: права/владелец -> fsync -> rename");
|
||||||
|
}
|
||||||
|
' || fail "acceptance: атомарная запись обязана выставлять владельца и синхронизировать до подстановки"
|
||||||
|
# Каждый production-вызов обязан объявлять владельца явно: параметр
|
||||||
|
# необязателен только ради тестов, которые пишут во временный каталог.
|
||||||
|
"$BUN_BIN" -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const offenders = [];
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) { walk(full); continue; }
|
||||||
|
if (!entry.name.endsWith(".ts")) continue;
|
||||||
|
const source = fs.readFileSync(full, "utf8");
|
||||||
|
// Ищутся ВЫЗОВЫ, а не объявление: у самой функции параметр owner
|
||||||
|
// необязателен, потому что тесты пишут во временный каталог, где
|
||||||
|
// выставить root:root нельзя.
|
||||||
|
let at = source.indexOf("await writeTextAtomic(");
|
||||||
|
while (at >= 0) {
|
||||||
|
const call = source.slice(at, at + 400);
|
||||||
|
if (!call.includes("owner:")) offenders.push(full);
|
||||||
|
at = source.indexOf("await writeTextAtomic(", at + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk("orchestrator/src");
|
||||||
|
if (offenders.length) {
|
||||||
|
throw new Error("вызовы без владельца: " + [...new Set(offenders)].join(", "));
|
||||||
|
}
|
||||||
|
' || fail "acceptance: production-запись обязана объявлять владельца файла"
|
||||||
|
|
||||||
log_step "Acceptance: reconfigure classifies by ownership, not by message text"
|
log_step "Acceptance: reconfigure classifies by ownership, not by message text"
|
||||||
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|
||||||
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
|
|| fail "acceptance: reconfigure must not classify failures by matching the error text"
|
||||||
|
|||||||
Reference in New Issue
Block a user