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

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

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

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

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

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

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

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

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

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

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

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

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
This commit is contained in:
2026-08-30 18:11:55 +05:00
parent 60a1aea85e
commit e84fdedc4b
13 changed files with 1124 additions and 80 deletions
+80 -3
View File
@@ -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 ведёт **на этот сервер**. Так как
+90 -2
View File
@@ -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` при уже созданном файле
+15
View File
@@ -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
+76 -26
View File
@@ -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,18 +232,43 @@ 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) {
await rollbackFirewallNow(context); stages.push({
name: "firewall",
run: async () => {
await rollbackFirewallNow(context);
}
});
} }
if (!ownership.unitsTouched) { if (ownership.unitsTouched) {
stages.push(
{
name: "stop services",
run: async () => {
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
}
},
{
name: "disable services",
run: async () => {
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`;
}
}
);
} else {
info("rollback: systemd units were not deployed by this operation, leaving services untouched"); info("rollback: systemd units were not deployed by this operation, leaving services untouched");
return;
} }
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; await runRollbackStages(stages);
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
} }
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
@@ -373,11 +413,21 @@ export async function install(options: InstallOptions): Promise<void> {
throw error; throw error;
} }
await advanceInstallState( // Запись состояния отказа — best effort, откат — обязателен.
context, //
ownership, // На заполненном диске или read-only ФС падает и она: `mkdir`, запись файла
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", // и `chown` в /var/lib/hy2xs. Пока она стояла обычным await, её собственный
`${failureKind}: ${message}` // отказ уносил управление из обработчика наружу, и до rollbackFailedInstall
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
// существует. Это тот же класс, что и с диагностикой ниже.
await persistFailureState(() =>
advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
)
); );
// Диагностика — best effort, откат — обязателен. // Диагностика — best effort, откат — обязателен.
+42 -9
View File
@@ -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) {
await rollbackFirewallNow(context); stages.push({
name: "firewall",
run: async () => {
await rollbackFirewallNow(context);
}
});
} }
await rollbackCurrentState();
stages.push({
name: "restore configuration",
run: async () => {
await rollbackCurrentState();
}
});
await runRollbackStages(stages);
throw error; throw error;
} }
} }
+114 -25
View File
@@ -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 {
// "wx" — отказ, если файл уже существует: имя случайное, и совпадение
// означало бы чужой файл, а не наш прошлый заход.
const handle = await open(tmp, "wx", options.mode);
try {
await handle.writeFile(data, "utf8");
// Режим при 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 chmodResult = Bun.spawnSync(["chmod", options.mode.toString(8), tmp], { await rename(tmp, path);
stdout: "pipe", renamed = true;
stderr: "pipe" } finally {
}); if (!renamed) {
if (!chmodResult.success) { // Временный файл не имеет права пережить неудачную запись: каталог
throw new Error(`chmod failed for ${tmp}: ${chmodResult.stderr.toString()}`); // install-state читается диагностикой и purge как набор наших файлов.
await unlink(tmp).catch(() => undefined);
}
} }
const chownResult = Bun.spawnSync(["chown", `${options.owner}:${options.group}`, tmp], { await syncDirectory(dir);
stdout: "pipe", }
stderr: "pipe"
});
if (!chownResult.success) {
throw new Error(`chown failed for ${tmp}: ${chownResult.stderr.toString()}`);
}
const mvResult = Bun.spawnSync(["mv", "-f", tmp, path], { async function syncDirectory(dir: string): Promise<void> {
stdout: "pipe", if (process.platform === "win32") {
stderr: "pipe" return;
}); }
if (!mvResult.success) { const handle = await open(dir, "r");
throw new Error(`atomic rename failed for ${path}: ${mvResult.stderr.toString()}`); try {
await handle.sync();
} finally {
await handle.close();
} }
} }
+3 -1
View File
@@ -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"
});
}
+101
View File
@@ -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)}`
);
}
}
+164
View File
@@ -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/);
});
});
+18 -5
View File
@@ -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([]);
});
});
+148 -9
View File
@@ -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"