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 и позволяло «чинить» чужое состояние.
### Запись маркера долговечна и имеет ровно одного владельца
`install` и `reconfigure` пишут маркер через один и тот же
`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями:
`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно.
Слабейшая гарантия досталась команде, которая этот файл создаёт.
Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой
отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на
сервере половину документа:
```json
{
"product": "hy2xs",
"release_line":
```
Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий,
а clean-host — как присутствующий, причём хост к этому моменту уже изменён.
Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи:
```text
1. запись во временный файл в том же каталоге
2. права и владелец ← до подстановки: иначе есть окно,
в котором файл виден с чужими правами
3. fsync временного файла ← данные на носителе, а не в page cache
4. rename ← атомарная подстановка
5. fsync каталога ← сама запись каталога о новом имени
```
Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной
перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла.
Для метаданных восстановления это неприемлемо.
## Ownership и rollback
Операция ведёт учёт того, к чему она **могла прикоснуться**:
```text
stateWritten
stateTouched
depsTouched
filesystemTouched
uiTouched
@@ -189,12 +224,18 @@ servicesStarted
хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед
мутирующим вызовом**, а не после него.
`stateWritten` — полноценный участник классификации. `install-state.json`
`stateTouched` — полноценный участник классификации. `install-state.json`
пишется сразу после успешного preflight, до `installDeps`; пока он в
классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего
не изменено», rollback пропускался, а маркер оставался на хосте и ломал
следующую установку по clean-host контракту.
Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера —
три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на
диске. Пока флаг взводился **после** успешной записи, такой отказ давал
классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже
существующем `/var/lib/hy2xs/install-state.json`.
Классификация отказа строится **по этим флагам и фазе**, а не по тексту
сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего
preflight-ошибка со словом `nftables` приводила к откату чужого firewall.
@@ -202,13 +243,49 @@ preflight-ошибка со словом `nftables` приводила к отк
Инварианты rollback:
- `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в
него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом
него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом
случае system rollback не выполняется, `install-state.json` не пишется,
diagnostics-бандл не собирается (его сбор сам создал бы каталоги в
`/var/log/hy2xs`).
- `systemctl stop/disable` выполняется **только если текущая операция сама
развернула эти unit-файлы**.
### После операционного отказа откат выполняется целиком
Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`:
```text
запись состояния отказа → best effort
сбор диагностики → best effort
откат → обязателен
```
Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть
падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего.
Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил
управление наружу, и восстановление не выполнялось вовсе: применённый firewall и
развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто
раньше, для записи состояния — нет.
Второй инвариант — **стадии отката независимы**:
| Команда | Стадии |
| --- | --- |
| `install` | firewall → stop services → disable services → reset failed services |
| `reconfigure` | firewall → restore configuration |
Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет
упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все
следующие. В `reconfigure` это означало сервер одновременно с применённым
сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов —
то есть худший сценарий отказа лишался обеих половин восстановления сразу.
Стадии выполняются последовательно и в объявленном порядке; независимость
означает «отказ не прерывает остальные», а не «выполняется как попало».
Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная**
ошибка операции: проблема внутри отката — это дополнительная информация о том,
что осталось не восстановленным, а не замена диагноза.
## Инвариант публичного endpoint
`preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как
+90 -2
View File
@@ -153,7 +153,9 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
ошибки;
- `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая
`stateWritten`: записанный `install-state.json` уже делает хост изменённым;
`stateTouched`: записанный `install-state.json` уже делает хост изменённым;
- частично выполненная запись маркера (отказ на `chown` после успешного `write`)
тоже даёт post-apply: флаг взводится **до** записи, а не после неё;
- начатая (не обязательно завершённая) установка пакетов уже даёт
`fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал,
установщик заявил, что ничего не тронул»;
@@ -188,6 +190,47 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
- записываемый маркер всегда несёт идентификацию поколения;
- незавершённая установка подсказывает `repair --allow-partial-state`.
## A5a. Обязательный откат (unit)
`orchestrator/test/rollback-mandatory.test.ts` — поведение механизма проверяется
настоящим внедрением отказа в стадию, проводка команд к нему — разбором
исходника (поднять systemd и nftables в этой среде нельзя):
- при отказе первой стадии отката выполняются **все** последующие;
- отказавшие стадии перечисляются по именам и в порядке объявления;
- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная
ошибка операции, а не проблема внутри восстановления;
- не-`Error` причина (брошенная строка) не роняет откат;
- `persistFailureState` не пробрасывает отказ записи наружу — это и был P0:
падение записи маркера отменяло откат целиком;
- в обработчике ошибки `install` и `reconfigure` не осталось незащищённой записи
состояния (`advanceInstallState` / `markPhase` голым `await`);
- откат в обеих командах идёт через `runRollbackStages`, а не цепочкой `await`;
- внутри `rollbackCurrentState` ни одна команда не обрывает следующие: отказ
`systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть
восстановленные unit-файлы так и не применялись.
## A5b. Долговечная запись маркера (unit)
`orchestrator/test/atomic-write.test.ts`:
- содержимое заменяется целиком, а не дописывается поверх прежнего;
- при отказе записи по целевому пути остаётся **прежний полный** документ;
- временный файл не выживает ни при успехе, ни при отказе подстановки;
- права выставляются точно, независимо от umask (`0600`, `0644`);
- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir`
этого не делает, поэтому «создать» и «права такие, как объявлено» — два
разных действия;
- и запись, и создание каталога проходят через read-only guard;
- `persistInstallState` под guard'ом отказывает: единственный писатель маркера
обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать
`/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того
файла, по которому clean-host принимает решение.
Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из
пользовательского процесса их не наблюдать, а без них `rename()` даёт
атомарность видимости без долговечности.
## A6. Редактирование секретов (unit)
`orchestrator/test/redaction.test.ts`:
@@ -727,6 +770,48 @@ runtime-пакета выполнял `install.sh`, эти пути не при
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением.
Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту
обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs`
и заполнить его до отказа:
```bash
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
```
Сценарий:
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
взведён, правила применены);
2. следующий шаг ломается искусственно;
3. запись `phase: failed` в маркер падает по `ENOSPC`;
4. в журнале есть `failed to persist failure state, continuing with the
mandatory rollback`;
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
развёрнутые этой операцией юниты останавливаются и выключаются;
6. SSH остаётся доступным;
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла **исходная** ошибка операции, а не `ENOSPC`.
До исправления шаги 4–6 давали противоположный результат: бросок из записи
состояния уносил управление наружу, и сервер оставался с применённым firewall
неудавшейся установки.
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, удалить `/run/hy2xs/rollback/<op>` между применением
firewall и отказом), восстановление конфигов и остановка сервисов обязаны
выполниться всё равно.
## D1a. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
@@ -836,7 +921,7 @@ hy2xs-orchestrator doctor
5. `post-install.env` отражает фактическое deploy-состояние
6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт
7. оркестратор не требует standalone update / rollback / uninstall subcommands
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke
8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии
9. Telegram/access layer не требуется для прохождения install acceptance
10. отсутствует production path для port hopping
11. UI не запускается от root
@@ -879,3 +964,6 @@ hy2xs-orchestrator doctor
48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons`
49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height`
50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют
51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
+15
View File
@@ -70,6 +70,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
- rollback guard не должен отменяться до успешного smoke;
- для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause.
Откат после операционного отказа выполняется целиком и сам по себе не может
быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом
одной из своих стадий. Поэтому в журнале нужно читать две разные вещи:
| Строка в журнале | Что она означает |
| --- | --- |
| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` |
| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены |
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
проблему внутри отката: последняя — это информация о том, что осталось не
восстановленным, а не причина отказа.
## 9. Reconfigure flow
```bash
+76 -26
View File
@@ -1,13 +1,11 @@
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 { info, setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta";
import {
INSTALL_STATE_PATH,
REPAIR_HINT,
buildInstallStateRecord
} from "../lib/installState";
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
import { persistInstallState } from "../lib/installStateWriter";
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import {
ORCHESTRATOR_INSTALL_DIR,
@@ -62,8 +60,18 @@ type InstallPhase =
* "firewall", — недопустимо.
*/
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,
* symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они
@@ -92,7 +100,7 @@ type FailureKind =
function newOwnership(): OperationOwnership {
return {
stateWritten: false,
stateTouched: false,
bootstrapTouched: false,
depsTouched: false,
filesystemTouched: false,
@@ -145,9 +153,7 @@ async function writeInstallState(
repairHint: phase === "installed" ? undefined : REPAIR_HINT
});
await runMutatingVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644);
await runMutatingVisible`chown root:root ${INSTALL_STATE_PATH}`;
await persistInstallState(record);
}
async function advanceInstallState(
@@ -156,15 +162,18 @@ async function advanceInstallState(
phase: InstallPhase,
lastError = ""
): Promise<void> {
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
// stateTouched. Частично выполненная запись маркера — это уже изменение
// хоста, и классификация обязана исходить из «сюда мы могли влезть».
ownership.stateTouched = true;
await writeInstallState(context, phase, lastError);
ownership.stateWritten = true;
}
/**
* Классификация опирается на то, к чему операция уже могла прикоснуться.
* `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.depsTouched ||
ownership.bootstrapTouched ||
ownership.stateWritten
ownership.stateTouched
) {
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(
context: InstallContext,
@@ -217,18 +232,43 @@ async function rollbackFailedInstall(
// которого install-state сохраняется с repair_hint. Снести оркестратор при
// откате означало бы лишить оператора инструмента починки. Полная зачистка —
// это осознанное отдельное действие, tools/legacy/purge-v0.sh.
const stages: RollbackStage[] = [];
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");
return;
}
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
await runRollbackStages(stages);
}
export async function install(options: InstallOptions): Promise<void> {
@@ -373,11 +413,21 @@ export async function install(options: InstallOptions): Promise<void> {
throw error;
}
await advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
// Запись состояния отказа — best effort, откат — обязателен.
//
// На заполненном диске или read-only ФС падает и она: `mkdir`, запись файла
// и `chown` в /var/lib/hy2xs. Пока она стояла обычным await, её собственный
// отказ уносил управление из обработчика наружу, и до rollbackFailedInstall
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
// существует. Это тот же класс, что и с диагностикой ниже.
await persistFailureState(() =>
advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
)
);
// Диагностика — best effort, откат — обязателен.
+42 -9
View File
@@ -8,6 +8,8 @@ import {
buildInstallStateRecord,
type InstallStateRecord
} from "../lib/installState";
import { persistInstallState } from "../lib/installStateWriter";
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config";
@@ -105,11 +107,11 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l
repairHint: phase === "installed" ? undefined : REPAIR_HINT
});
await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
mode: 0o644,
owner: "root",
group: "root"
});
// Запись идёт тем же путём, что и в install: у маркера установки ровно один
// владелец записи, и гарантии у него не зависят от того, какая команда его
// обновляет. Каталог при этом обеспечивается здесь так же, как при установке:
// repair обязан работать и на хосте, где /var/lib/hy2xs потеряли вручную.
await persistInstallState(record);
}
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`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`;
}
@@ -284,7 +289,15 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
} catch (error) {
info("reconfigure failed, rollback in progress");
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, откат — обязателен.
//
@@ -301,10 +314,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
}
// Стадии отката независимы. Снятие firewall и возврат конфигов чинят разные
// половины неудачного прохода, и обе выполняются через `systemctl`, `cp` и
// `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой
// `await`, отказ первой отменял вторую: сервер оставался и с применённым
// сломанным firewall, и со сломанными конфигами одновременно.
const stages: RollbackStage[] = [];
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;
}
}
+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";
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(
path: string,
data: string,
options: {
mode: number;
owner: string;
group: string;
owner?: string;
group?: string;
}
): Promise<void> {
assertMutationAllowed(`writeTextAtomic(${path})`);
const dir = path.replace(/\/[^/]+$/, "") || ".";
const base = path.split("/").pop() || "tmp";
const dir = dirname(path);
const base = path.split(/[/\\]/).pop() || "tmp";
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], {
stdout: "pipe",
stderr: "pipe"
});
if (!chmodResult.success) {
throw new Error(`chmod failed for ${tmp}: ${chmodResult.stderr.toString()}`);
await rename(tmp, path);
renamed = true;
} finally {
if (!renamed) {
// Временный файл не имеет права пережить неудачную запись: каталог
// install-state читается диагностикой и purge как набор наших файлов.
await unlink(tmp).catch(() => undefined);
}
}
const chownResult = Bun.spawnSync(["chown", `${options.owner}:${options.group}`, tmp], {
stdout: "pipe",
stderr: "pipe"
});
if (!chownResult.success) {
throw new Error(`chown failed for ${tmp}: ${chownResult.stderr.toString()}`);
}
await syncDirectory(dir);
}
const mvResult = Bun.spawnSync(["mv", "-f", tmp, path], {
stdout: "pipe",
stderr: "pipe"
});
if (!mvResult.success) {
throw new Error(`atomic rename failed for ${path}: ${mvResult.stderr.toString()}`);
async function syncDirectory(dir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
const handle = await open(dir, "r");
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 от произвольного старого маркера.
*/
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";
@@ -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 {
return {
stateWritten: false,
stateTouched: false,
bootstrapTouched: false,
depsTouched: false,
filesystemTouched: false,
@@ -109,8 +109,21 @@ describe("классификация отказа установки", () => {
// объявлялось «на сервере ничего не изменено», rollback пропускался, а
// /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую
// установку по clean-host контракту.
test("записанный install-state сам по себе делает отказ post-apply", () => {
expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
test("тронутый install-state сам по себе делает отказ 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,
@@ -122,13 +135,13 @@ describe("классификация отказа установки", () => {
"fatal_post_apply"
);
expect(
classifyFailure(ownership({ stateWritten: true, bootstrapTouched: true }), "bootstrap_installed")
classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed")
).toBe("fatal_post_apply");
});
test("падение installDeps после записи состояния — post-apply", () => {
expect(
classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok")
classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok")
).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 \
|| fail "acceptance: rollback must never stop services it did not deploy"
log_step "Acceptance: a written install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал stateWritten, поэтому падение
log_step "Acceptance: a touched install-state already makes the failure post-apply"
# Регрессия: classifyFailure не учитывал маркер установки, поэтому падение
# apt-get объявлялось «на сервере ничего не изменено», rollback пропускался,
# а install-state.json оставался на хосте и ломал следующую установку.
grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \
|| fail "acceptance: classifyFailure must account for a written install-state"
grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \
|| 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 '
const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8");
const body = source.slice(source.indexOf("export function classifyFailure"));
const preApply = body.indexOf("return \"fatal_pre_apply\"");
const stateWritten = body.indexOf("ownership.stateWritten");
if (preApply < 0 || stateWritten < 0) {
const stateTouched = body.indexOf("ownership.stateTouched");
if (preApply < 0 || stateTouched < 0) {
throw new Error("could not locate classifyFailure branches");
}
if (stateWritten > preApply) {
throw new Error("stateWritten is checked after the fatal_pre_apply fallback");
if (stateTouched > preApply) {
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"
# Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить
@@ -791,6 +811,125 @@ run_single_owner_acceptance() {
}
' || 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"
! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: reconfigure must not classify failures by matching the error text"