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
+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");
});