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