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([]); }); });