330a63b050
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.
1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).
cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
откат честно запускался и сообщал "no HY2XS rollback markers found":
откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
который прошлый проход специально сделал безопасным.
Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
smoke_ok -> disarm -> durable installed -> cleanup best-effort.
2. Резервные копии снимались без доказательства.
И firewall, и reconfigure копировали как `cp ... || true`: отказ
игнорировался, операция шла менять систему без копии, на которую
рассчитывает откат. У firewall маркер prepared («данные для отката
существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
создания проверяется, маркер ставится после.
3. Копии reconfigure смешивались между операциями.
Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы операции A — сервер возвращался в более старое
состояние и это выглядело успешным откатом. Копия стала операционной:
/etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
включая проверку opId.
4. Ошибка восстановления скрывалась, и после неё копии удалялись.
rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
стадия успешна, данные для ручной починки уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением manual recovery data preserved at ...
5. Команды отката глушили собственный код возврата.
До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
после его появления стал маскировкой — стадия не могла сообщить, что
ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
стадий.
6. Долговечность записи каталога маркера.
writeTextAtomic синхронизирует файл и его каталог, но при первой установке
/var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
несинхронизированной. ensureDir сообщает о фактическом создании и
синхронизирует родителя только тогда.
Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.
Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
176 lines
8.5 KiB
TypeScript
176 lines
8.5 KiB
TypeScript
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");
|
|
});
|
|
|
|
// Долговечность записи каталога в РОДИТЕЛЕ имеет смысл только при
|
|
// фактическом создании, поэтому ensureDir обязана его различать: на каждом
|
|
// обновлении маркера каталог уже существует, и лишний fsync родителя ничего
|
|
// не даёт.
|
|
test("сообщает, был ли каталог создан", async () => {
|
|
const target = join(dir, "hy2xs");
|
|
|
|
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(true);
|
|
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(false);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|