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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
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(context)");
|
|
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
|
|
expect(firewall).toBeGreaterThan(-1);
|
|
expect(firewall).toBeLessThan(restore);
|
|
expect(restore).toBeLessThan(stages);
|
|
});
|
|
|
|
// Инвариант СМЕНИЛСЯ, и это принципиально.
|
|
//
|
|
// Раньше здесь требовалось, чтобы каждая команда восстановления несла
|
|
// `|| true`: тогда это была единственная защита от того, что отказ одной
|
|
// команды оборвёт следующие. После появления runRollbackStages непрерывность
|
|
// обеспечивает раннер, а `|| true` стал ВРЕДЕН: он не даёт стадии сообщить,
|
|
// что восстановление на самом деле не выполнилось, и «restore configuration»
|
|
// никогда не попадала в список отказавших.
|
|
test("команды восстановления не глушат собственные ошибки", () => {
|
|
const start = reconfigureSource.indexOf("function restoreStages(");
|
|
const body = reconfigureSource.slice(
|
|
start,
|
|
reconfigureSource.indexOf("async function rollbackCurrentState")
|
|
);
|
|
const muted = body
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.includes("runMutatingVisible`"))
|
|
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
|
|
|
|
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
|
|
});
|
|
|
|
test("восстановление разбито на независимые стадии", () => {
|
|
for (const stage of [
|
|
"restore hysteria config",
|
|
"restore systemd units",
|
|
"restore runtime env",
|
|
"restore nftables files",
|
|
"apply restored ruleset",
|
|
"reload systemd units",
|
|
"restart services"
|
|
]) {
|
|
expect(reconfigureSource).toContain(`"${stage}"`);
|
|
}
|
|
});
|
|
|
|
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
|
|
test("частичное восстановление не выдаётся за успешное", () => {
|
|
expect(reconfigureSource).toContain("restoreFailures.length > 0");
|
|
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
|
|
});
|
|
});
|
|
|
|
describe("install: команды отката не глушат собственные ошибки", () => {
|
|
const installSource = source("commands/install.ts");
|
|
|
|
// После появления runRollbackStages `|| true` в стадиях отката перестал быть
|
|
// защитой и стал маскировкой: стадия не могла сообщить, что systemctl
|
|
// реально ничего не сделал, и обещание «отказавшие стадии перечисляются» для
|
|
// них не выполнялось никогда.
|
|
test("стадии остановки сервисов не подавляют код возврата", () => {
|
|
const start = installSource.indexOf("async function rollbackFailedInstall");
|
|
const body = installSource.slice(start, installSource.indexOf("export async function install"));
|
|
const muted = body
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.includes("runMutatingVisible`"))
|
|
.filter((line) => line.includes("|| true"));
|
|
|
|
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
|
|
});
|
|
});
|