Files
HY2XS_flamy/orchestrator/test/backup-integrity.test.ts
founder 330a63b050 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
2026-08-30 19:32:43 +05:00

266 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});