e84fdedc4b
Три дефекта одного класса в 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). Приёмка сборки закрепляет порядок шагов атомарной записи, отсутствие незащищённой записи состояния в обработчиках и отсутствие отменяемых цепочек в откате.
36 lines
2.1 KiB
TypeScript
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"
|
|
});
|
|
}
|