fix(v1): сделать надёжным нижний слой отката, а не только его запуск

Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
2026-08-30 19:32:43 +05:00
parent df73459ea5
commit 330a63b050
17 changed files with 1534 additions and 98 deletions
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
BACKUP_MANIFEST_VERSION,
backupDir,
backupFilePath,
backupManifestPath,
buildManifest,
parseManifest,
renderManifest,
storedNameFor
} from "../src/lib/backupManifest";
/**
* Целостность резервной копии — предпосылка отката, а не его деталь.
*
* Два дефекта, которые здесь закрепляются.
*
* 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять
* систему, не имея копии, на которую рассчитывает откат.
*
* 2. Копии всех операций лежали одним общим набором `*.bak` в
* /etc/hy2xs/backups. Если у операции B копирование падало, её откат
* восстанавливал файл, сохранённый операцией A, — то есть возвращал сервер
* в более старое состояние и выглядел при этом успешным.
*
* Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о
* каждом файле, включая отсутствовавшие.
*/
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
describe("манифест резервной копии", () => {
test("копия каждой операции лежит в своём каталоге", () => {
expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z");
expect(backupDir("a")).not.toBe(backupDir("b"));
});
test("манифест и файлы адресуются внутри каталога операции", () => {
const opId = "op-1";
expect(backupManifestPath(opId)).toStartWith(backupDir(opId));
expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId));
});
// Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf
// не имеют права лечь в один файл копии.
test("разные пути дают разные имена файлов копии", () => {
const names = new Set(
[
"/etc/nftables.conf",
"/etc/hy2xs/nftables.conf",
"/etc/nftables.d/hy2xs.nft",
"/etc/hysteria/config.yaml",
"/etc/systemd/system/hy2xs-admin.service"
].map(storedNameFor)
);
expect(names.size).toBe(5);
});
test("имя файла копии не выходит за пределы каталога", () => {
for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) {
const stored = storedNameFor(path);
expect(stored).not.toContain("/");
expect(stored).not.toContain("../");
}
});
// Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`.
test("отсутствовавший файл записывается явно", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/nftables.d/hy2xs.nft", present: false }
]);
const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft");
expect(missing?.present).toBe(false);
expect(missing?.stored).toBeNull();
const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml");
expect(kept?.present).toBe(true);
expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml"));
});
test("манифест переживает сериализацию без потерь", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/hy2xs/hy2xs.env", present: false }
]);
expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest);
});
});
describe("разбор манифеста строгий", () => {
const valid = renderManifest(
buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }])
);
// Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого
// копия стала привязанной к op-id.
test("манифест другой операции отклоняется", () => {
expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/);
});
test("непонятная версия отклоняется", () => {
const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99');
expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/);
});
test("битый JSON отклоняется, а не считается пустой копией", () => {
expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/);
});
// «Поле не разобралось, будем считать, что файла не было» означает удаление
// существующего файла при откате. Поэтому отказ, а не умолчание.
test("запись без признака существования отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/);
});
test("существующий файл без имени копии отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/);
});
test("запись без пути отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ present: false }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/);
});
});
describe("reconfigure: копия снимается строго и до первой мутации", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("копирование не глушит ошибки", () => {
const start = reconfigureSource.indexOf("async function backupCurrentState");
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest"));
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([]);
});
// Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата
// одного лишь `cp`.
test("существование копии проверяется после копирования", () => {
expect(reconfigureSource).toContain("резервная копия");
expect(reconfigureSource).toContain("await fileExists(target)");
});
test("копия снимается до первой мутации системы", () => {
const backup = reconfigureSource.indexOf("await backupCurrentState(context)");
const generate = reconfigureSource.indexOf("await generateConfig(context)");
const firewall = reconfigureSource.indexOf("await applyFirewall(context)");
expect(backup).toBeGreaterThan(-1);
expect(backup).toBeLessThan(generate);
expect(backup).toBeLessThan(firewall);
});
test("откат читает манифест текущей операции, а не общий каталог", () => {
expect(reconfigureSource).toContain("readBackupManifest(opId)");
expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)");
// Прежняя схема общего набора *.bak не должна вернуться.
expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak");
// Решение «восстанавливать или удалять» принимается по манифесту, а не по
// маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups
// допустима и проверяется отдельно.
const restoreStart = reconfigureSource.indexOf("function restoreStages(");
const restoreBody = reconfigureSource.slice(
restoreStart,
reconfigureSource.indexOf("async function rollbackCurrentState")
);
expect(restoreBody).not.toContain(".existed");
expect(restoreBody).toContain("entry.present");
});
// Каталог копий не имеет права расти неограниченно, но уборка обязана идти
// ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и
// с firewall.
test("копии предыдущих операций убираются после фиксации успеха", () => {
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
const prune = reconfigureSource.indexOf("pruneOldBackups(context)");
expect(installed).toBeGreaterThan(-1);
expect(prune).toBeGreaterThan(installed);
});
});
describe("firewall: копия снимается строго и переживает неудачный откат", () => {
const firewallSource = source("steps/firewall.ts");
test("копирование firewall не глушит ошибки", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Маркер `prepared` означает «данные для отката существуют». Пока он
// ставился ДО копирования, это было неправдой.
test("маркер готовности ставится после проверенных копий", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const copy = body.indexOf("cp -a ${target.path}");
const verify = body.indexOf("await fileExists(backupPath)");
const marker = body.indexOf("touch ${rollbackMarker(opId)}");
expect(copy).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(copy);
expect(marker).toBeGreaterThan(verify);
});
test("восстановление firewall не глушит ошибки cp и nft", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"))
// Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ
// восстановления, а нормальное состояние при firewallStagedApply=false.
.filter((line) => !line.includes("systemctl"));
expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по
// которым оператор мог бы поднять firewall вручную, уничтожены.
test("резервные копии удаляются только после успешного восстановления", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
expect(failures).toBeGreaterThan(-1);
expect(preserved).toBeGreaterThan(failures);
expect(cleanup).toBeGreaterThan(preserved);
});
});