Files
HY2XS_flamy/orchestrator/src/lib/installStateWriter.ts
T
founder e84fdedc4b 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). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
2026-08-30 18:11:55 +05:00

36 lines
2.1 KiB
TypeScript

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