Files
HY2XS_flamy/orchestrator/test/rollback-mandatory.test.ts
T
founder 4e7f54b9ff fix(orchestrator): сделать staged firewall guard транзакционным
Снятие автоматического отката firewall было утверждением, а не фактом:

    systemctl stop <unit>.timer <unit>.service || true
    -> "firewall rollback timer disarmed"
    -> phase=installed

Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть
прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true`
нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop`
возвращает 5 — законный исход, неотличимый от успеха.

Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall
был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний
ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall,
который операция же и заменила.

Оба закрываются маркером /run/hy2xs/rollback/<op>/auto-rollback-fired, который
rollback-скрипт создаёт первым действием. Инвариант стал детерминированным:

    маркер отсутствует И timer/service inactive => можно фиксировать успех

Остальное в том же проходе:

- auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт.
  Прежний держался на склейке соседних кавычек и на том, что op-id не содержит
  пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером;
- скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном
  восстановлении. Состояние nftables.service он сознательно не трогает:
  ExecStop у него делает `nft flush ruleset`;
- smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного,
  принадлежность entrypoint и загруженность таблицы inet hy2xs;
- откат восстанавливает enabled/active nftables.service — стадиями, идущими до
  применения ruleset;
- остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом
  с `|| true` внутри;
- `*.candidate` больше не остаются на диске навсегда;
- стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по
  группе;
- ключ операции считается одной функцией: install писал в маркер сырой
  ISO-timestamp, и путь /run/hy2xs/rollback/<op_id> из runbook не существовал.
2026-08-30 22:54:57 +05:00

294 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", () => {
// Проверяется вложенность вызовов, а не форматирование: markPhase обязан
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
// редактор.
const wrapped = reconfigureSource.match(
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/
);
expect(wrapped).not.toBeNull();
});
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([]);
});
});