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:
2026-08-30 18:11:55 +05:00
parent 60a1aea85e
commit e84fdedc4b
13 changed files with 1124 additions and 80 deletions
+164
View File
@@ -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/);
});
});
+18 -5
View File
@@ -15,7 +15,7 @@ type Ownership = Parameters<typeof classifyFailure>[0];
function ownership(overrides: Partial<Ownership> = {}): Ownership {
return {
stateWritten: false,
stateTouched: false,
bootstrapTouched: false,
depsTouched: false,
filesystemTouched: false,
@@ -109,8 +109,21 @@ describe("классификация отказа установки", () => {
// объявлялось «на сервере ничего не изменено», rollback пропускался, а
// /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую
// установку по clean-host контракту.
test("записанный install-state сам по себе делает отказ post-apply", () => {
expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply");
test("тронутый install-state сам по себе делает отказ post-apply", () => {
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).toBe("fatal_post_apply");
});
// Вторая половина той же регрессии. Флаг назывался stateWritten и взводился
// ПОСЛЕ успешной записи, а запись маркера — три операции (mkdir, write,
// chown). Отказ на chown оставлял /var/lib/hy2xs/install-state.json на диске
// при невзведённом флаге, то есть давал «на сервере ничего не изменено» с
// уже существующим маркером установки.
test("частично выполненная запись маркера уже post-apply", () => {
// Ровно то состояние, которое оставляет упавший на chown advanceInstallState:
// флаг взведён, а фаза ещё preflight_ok.
expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).not.toBe(
"fatal_pre_apply"
);
});
// Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh,
@@ -122,13 +135,13 @@ describe("классификация отказа установки", () => {
"fatal_post_apply"
);
expect(
classifyFailure(ownership({ stateWritten: true, bootstrapTouched: true }), "bootstrap_installed")
classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed")
).toBe("fatal_post_apply");
});
test("падение installDeps после записи состояния — post-apply", () => {
expect(
classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok")
classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok")
).toBe("fatal_post_apply");
});
@@ -0,0 +1,238 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { persistFailureState, runRollbackStages } from "../src/lib/rollback";
/**
* Откат после операционного отказа обязан выполниться ЦЕЛИКОМ.
*
* Здесь закрепляются два дефекта одного класса, оба найдены в failure path
* install и reconfigure:
*
* 1. запись состояния отказа стояла перед откатом обычным `await`, поэтому её
* собственное падение (заполненный диск, read-only ФС) отменяло откат
* целиком;
* 2. сам откат был цепочкой `await`, поэтому падение первой стадии отменяло
* все последующие. В reconfigure это означало сервер одновременно и с
* применённым сломанным firewall, и без восстановленных конфигов.
*
* Механизм проверяется поведением — настоящим внедрением отказа в стадию.
* Проводка команд к этому механизму проверяется разбором исходника: поднять
* настоящие systemd-юниты и nftables в этой среде нельзя, а утверждение при
* этом остаётся точным.
*/
// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня
// репозитория (сборка), и из orchestrator/ (разработчик).
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
describe("стадии отката независимы", () => {
test("все стадии выполняются, когда все успешны", async () => {
const executed: string[] = [];
const failures = await runRollbackStages([
{ name: "firewall", run: async () => void executed.push("firewall") },
{ name: "services", run: async () => void executed.push("services") }
]);
expect(executed).toEqual(["firewall", "services"]);
expect(failures).toEqual([]);
});
// Ключевой инвариант патча: упавшая стадия не отменяет остальные.
test("падение первой стадии не отменяет последующие", async () => {
const executed: string[] = [];
const failures = await runRollbackStages([
{
name: "firewall",
run: async () => {
executed.push("firewall");
throw new Error("nft: command failed");
}
},
{ name: "restore configuration", run: async () => void executed.push("restore") },
{ name: "stop services", run: async () => void executed.push("stop") }
]);
expect(executed).toEqual(["firewall", "restore", "stop"]);
expect(failures).toHaveLength(1);
expect(failures[0]).toContain("firewall");
expect(failures[0]).toContain("nft: command failed");
});
test("падение каждой стадии учитывается отдельно и в порядке объявления", async () => {
const failures = await runRollbackStages([
{
name: "firewall",
run: async () => {
throw new Error("first");
}
},
{ name: "healthy", run: async () => undefined },
{
name: "restore configuration",
run: async () => {
throw new Error("second");
}
}
]);
expect(failures).toHaveLength(2);
expect(failures[0]).toStartWith("firewall: ");
expect(failures[1]).toStartWith("restore configuration: ");
});
// Причиной отказа операции остаётся исходная ошибка: откат сообщает о своих
// проблемах возвратом, а не броском, иначе бы он подменил собой диагноз.
test("откат не бросает даже при отказе всех стадий", async () => {
const failures = await runRollbackStages([
{
name: "a",
run: async () => {
throw new Error("boom");
}
},
{
name: "b",
run: async () => {
throw new Error("boom");
}
}
]);
expect(failures).toHaveLength(2);
});
test("не-Error причина не роняет откат", async () => {
const failures = await runRollbackStages([
{
name: "weird",
run: async () => {
throw "строковая ошибка";
}
}
]);
expect(failures[0]).toContain("строковая ошибка");
});
test("пустой набор стадий допустим", async () => {
expect(await runRollbackStages([])).toEqual([]);
});
});
describe("состояние отказа пишется best effort", () => {
test("успешная запись выполняется", async () => {
let written = false;
await persistFailureState(async () => {
written = true;
});
expect(written).toBe(true);
});
// Регрессия: это и был P0. Падение записи маркера уносило управление наружу
// мимо обязательного отката.
test("падение записи не пробрасывается наружу", async () => {
await expect(
persistFailureState(async () => {
throw new Error("ENOSPC: no space left on device");
})
).resolves.toBeUndefined();
});
});
describe("install: откат обязателен после операционного отказа", () => {
const installSource = source("commands/install.ts");
test("запись состояния отказа обёрнута в persistFailureState", () => {
expect(installSource).toContain("await persistFailureState(() =>");
const guarded = installSource.indexOf("await persistFailureState(() =>");
const advance = installSource.indexOf("advanceInstallState(", guarded);
// Обёрнута именно запись маркера, а не что-то другое рядом.
expect(advance).toBeGreaterThan(guarded);
expect(advance - guarded).toBeLessThan(80);
});
test("состояние отказа пишется раньше отката, но не может его отменить", () => {
const guarded = installSource.indexOf("await persistFailureState(");
const rollback = installSource.indexOf("await rollbackFailedInstall(");
expect(guarded).toBeGreaterThan(-1);
expect(rollback).toBeGreaterThan(guarded);
});
// В обработчике не должно остаться ни одной незащищённой записи состояния:
// единственный голый advanceInstallState в catch и был дефектом.
test("в обработчике ошибки нет незащищённой записи маркера", () => {
const catchAt = installSource.indexOf("} catch (error) {");
expect(catchAt).toBeGreaterThan(-1);
const handler = installSource.slice(catchAt);
expect(handler).not.toContain("await advanceInstallState(");
});
test("откат идёт через независимые стадии", () => {
expect(installSource).toContain("await runRollbackStages(stages)");
// Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно:
// именно они и образовывали отменяемую цепочку.
const start = installSource.indexOf("async function rollbackFailedInstall");
const body = installSource.slice(start, installSource.indexOf("export async function install"));
expect(body).toContain("await rollbackFirewallNow(context)");
// Вызов существует только внутри стадии.
const firewallAt = body.indexOf("await rollbackFirewallNow(context)");
const stageAt = body.lastIndexOf('name: "firewall"', firewallAt);
expect(stageAt).toBeGreaterThan(-1);
});
test("остановка сервисов остаётся отдельными стадиями", () => {
for (const stage of ["stop services", "disable services", "reset failed services"]) {
expect(installSource).toContain(`name: "${stage}"`);
}
});
test("чужие сервисы по-прежнему не трогаются", () => {
expect(installSource).toContain(
"systemd units were not deployed by this operation, leaving services untouched"
);
});
});
describe("reconfigure: откат обязателен после операционного отказа", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("запись состояния отказа обёрнута в persistFailureState", () => {
expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context");
});
test("в обработчике ошибки нет незащищённого markPhase", () => {
const catchAt = reconfigureSource.indexOf("} catch (error) {");
expect(catchAt).toBeGreaterThan(-1);
const handler = reconfigureSource.slice(catchAt);
expect(handler).not.toContain("await markPhase(");
});
test("firewall и восстановление конфигов — независимые стадии", () => {
expect(reconfigureSource).toContain('name: "firewall"');
expect(reconfigureSource).toContain('name: "restore configuration"');
expect(reconfigureSource).toContain("await runRollbackStages(stages)");
});
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
test("порядок сохранён: сначала firewall, затем конфиги", () => {
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const restore = reconfigureSource.indexOf("await rollbackCurrentState()");
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
expect(firewall).toBeGreaterThan(-1);
expect(firewall).toBeLessThan(restore);
expect(restore).toBeLessThan(stages);
});
// Внутри самого восстановления конфигов дефект был тот же: единственная
// команда без `|| true` отменяла перезапуск сервисов строкой ниже.
test("ни одна команда восстановления конфигов не обрывает следующие", () => {
const start = reconfigureSource.indexOf("async function rollbackCurrentState");
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState"));
const offenders = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
});
});