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:
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user