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,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ensureDir, writeTextAtomic } from "../src/lib/fs";
|
||||
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard";
|
||||
import { persistInstallState } from "../src/lib/installStateWriter";
|
||||
import { buildInstallStateRecord } from "../src/lib/installState";
|
||||
|
||||
/**
|
||||
* install-state.json — это метаданные восстановления, и записывать их нужно так,
|
||||
* чтобы после отказа на сервере лежал ЛИБО прежний полный документ, ЛИБО новый
|
||||
* полный документ.
|
||||
*
|
||||
* Что было. install писал маркер через `writeText` — обычную перезапись на
|
||||
* месте. Она укорачивает файл до нуля и только потом наполняет, поэтому отказ
|
||||
* между этими моментами оставлял половину JSON. reconfigure писал тот же файл
|
||||
* атомарно, то есть у одного файла было две разные гарантии в зависимости от
|
||||
* того, какая команда его обновляла.
|
||||
*
|
||||
* Проверяется здесь именно наблюдаемое поведение: временный файл не выживает,
|
||||
* целевой никогда не остаётся обрезанным, права выставлены до подстановки.
|
||||
* Сам fsync проверить из пользовательского процесса нельзя — его наличие
|
||||
* закрепляет приёмка по исходнику.
|
||||
*/
|
||||
|
||||
const onPosix = process.platform !== "win32";
|
||||
|
||||
let dir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "hy2xs-atomic-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
disableReadOnlyGuard();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function leftovers(): Promise<string[]> {
|
||||
return (await readdir(dir)).filter((name) => name.includes(".tmp-"));
|
||||
}
|
||||
|
||||
describe("writeTextAtomic", () => {
|
||||
test("создаёт файл с объявленным содержимым", async () => {
|
||||
const target = join(dir, "install-state.json");
|
||||
await writeTextAtomic(target, '{"product":"hy2xs"}\n', { mode: 0o644 });
|
||||
|
||||
expect(await readFile(target, "utf8")).toBe('{"product":"hy2xs"}\n');
|
||||
});
|
||||
|
||||
test("не оставляет временных файлов после успешной записи", async () => {
|
||||
await writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 });
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
test("заменяет прежнее содержимое целиком, а не дописывает", async () => {
|
||||
const target = join(dir, "state.json");
|
||||
await writeFile(target, "старое длинное содержимое, которое должно исчезнуть целиком");
|
||||
await writeTextAtomic(target, "{}\n", { mode: 0o644 });
|
||||
|
||||
expect(await readFile(target, "utf8")).toBe("{}\n");
|
||||
});
|
||||
|
||||
// Ключевое отличие от перезаписи на месте: пока новая запись не удалась,
|
||||
// по целевому пути лежит ПРЕЖНИЙ полный документ, а не его обрезок.
|
||||
test("при отказе записи прежний файл остаётся нетронутым", async () => {
|
||||
const target = join(dir, "state.json");
|
||||
const previous = '{"product":"hy2xs","phase":"installed"}\n';
|
||||
await writeFile(target, previous);
|
||||
|
||||
// Каталог назначения исчезает между вызовами: создать временный файл негде,
|
||||
// то есть запись падает на самом раннем шаге.
|
||||
const missing = join(dir, "gone", "state.json");
|
||||
await expect(writeTextAtomic(missing, "{}\n", { mode: 0o644 })).rejects.toThrow();
|
||||
|
||||
expect(await readFile(target, "utf8")).toBe(previous);
|
||||
});
|
||||
|
||||
test("отказ подстановки не оставляет временный файл", async () => {
|
||||
// Цель — каталог: rename файла поверх непустого каталога не проходит.
|
||||
const target = join(dir, "state.json");
|
||||
await mkdir(target);
|
||||
await mkdir(join(target, "occupied"));
|
||||
|
||||
await expect(writeTextAtomic(target, "{}\n", { mode: 0o644 })).rejects.toThrow();
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
test.skipIf(!onPosix)("права выставляются точно, независимо от umask", async () => {
|
||||
const target = join(dir, "state.json");
|
||||
await writeTextAtomic(target, "{}\n", { mode: 0o600 });
|
||||
|
||||
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("600");
|
||||
});
|
||||
|
||||
test.skipIf(!onPosix)("режим 0644 маркера установки сохраняется", async () => {
|
||||
const target = join(dir, "install-state.json");
|
||||
await writeTextAtomic(target, "{}\n", { mode: 0o644 });
|
||||
|
||||
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("644");
|
||||
});
|
||||
|
||||
test("запись проходит через read-only guard", async () => {
|
||||
enableReadOnlyGuard("test phase");
|
||||
await expect(writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 })).rejects.toThrow(
|
||||
/read-only guard violation/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureDir", () => {
|
||||
test("создаёт недостающие уровни", async () => {
|
||||
const target = join(dir, "var", "lib", "hy2xs");
|
||||
await ensureDir(target, { mode: 0o755 });
|
||||
|
||||
expect((await stat(target)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test("повторный вызов не является ошибкой", async () => {
|
||||
const target = join(dir, "hy2xs");
|
||||
await ensureDir(target, { mode: 0o755 });
|
||||
await ensureDir(target, { mode: 0o755 });
|
||||
|
||||
expect((await stat(target)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
// mkdir не меняет права уже существующего каталога, поэтому «создать» и
|
||||
// «права такие, как объявлено» — два разных действия.
|
||||
test.skipIf(!onPosix)("права приводятся к объявленным у существующего каталога", async () => {
|
||||
const target = join(dir, "hy2xs");
|
||||
await mkdir(target, { mode: 0o700 });
|
||||
await ensureDir(target, { mode: 0o755 });
|
||||
|
||||
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755");
|
||||
});
|
||||
|
||||
test("создание каталога проходит через read-only guard", async () => {
|
||||
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
|
||||
await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow(
|
||||
/read-only guard violation.*PHASE 0/s
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistInstallState", () => {
|
||||
// Единственный писатель маркера обязан идти через guarded-примитивы: иначе
|
||||
// PHASE 0 смогла бы создать /var/lib/hy2xs, и «read-only» перестало бы быть
|
||||
// правдой ровно для того файла, по которому clean-host принимает решение.
|
||||
test("под read-only guard запись маркера невозможна", async () => {
|
||||
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
|
||||
const record = buildInstallStateRecord({
|
||||
productVersion: "1.0.0",
|
||||
buildId: "b",
|
||||
opId: "op",
|
||||
startedAt: "2026-08-30T00:00:00.000Z",
|
||||
phase: "preflight_ok",
|
||||
installed: false,
|
||||
ownedPaths: []
|
||||
});
|
||||
|
||||
await expect(persistInstallState(record)).rejects.toThrow(/read-only guard violation/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user