Files
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

119 lines
6.1 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Порядок фиксации успеха операции.
*
* Дефект, который здесь закрепляется, — commit ordering. Успешный install
* заканчивался так:
*
* smoke PASS
* -> cancelFirewallRollback() // снимала таймер И УДАЛЯЛА резервные копии
* -> advanceInstallState("installed")
*
* То есть единственные данные для отката firewall (marker, прежний
* nftables.conf, прежний hy2xs.nft) уничтожались ДО долговечной записи успеха.
* Если эта запись падала — ENOSPC, EIO, read-only ФС, — управление уходило в
* обработчик ошибки, обязательный откат честно запускался и сообщал:
*
* firewall rollback skipped: no HY2XS rollback markers found
*
* Откат нельзя было отменить, но откатывать ему было нечем. Причём падение
* записи маркера — ровно тот сценарий, который был специально сделан
* безопасным предыдущим патчем.
*
* Правильный порядок: снять таймер (копии остаются) -> зафиксировать успех
* долговечно -> и только потом убрать копии, best-effort.
*/
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
const firewallSource = source("steps/firewall.ts");
describe("снятие таймера и удаление копий — разные операции", () => {
test("firewall предоставляет disarm и cleanup по отдельности", () => {
expect(firewallSource).toContain("export async function disarmFirewallRollback");
expect(firewallSource).toContain("export async function cleanupFirewallRollback");
});
// Прежнее единое имя не должно вернуться: оно и означало «снять таймер и
// удалить копии одним действием».
test("объединённая cancelFirewallRollback не вернулась", () => {
expect(firewallSource).not.toContain("export async function cancelFirewallRollback");
for (const file of ["commands/install.ts", "commands/reconfigure.ts"]) {
expect(source(file)).not.toContain("cancelFirewallRollback");
}
});
test("disarm не удаляет резервные копии", () => {
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
expect(body).not.toContain("cleanupFirewallBackupFiles");
expect(body).toContain("stopRollbackGuard");
});
test("cleanup удаляет копии и ничего больше", () => {
const start = firewallSource.indexOf("export async function cleanupFirewallRollback");
const body = firewallSource.slice(start, firewallSource.indexOf("export async function rollbackFirewallNow"));
expect(body).toContain("cleanupFirewallBackupFiles");
});
});
describe("install: успех фиксируется до уничтожения данных отката", () => {
const installSource = source("commands/install.ts");
test("порядок: disarm -> durable installed -> cleanup", () => {
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
const installed = installSource.indexOf('await advanceInstallState(context, ownership, "installed")');
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
expect(disarm).toBeGreaterThan(-1);
expect(installed).toBeGreaterThan(disarm);
expect(cleanup).toBeGreaterThan(installed);
});
test("успешный smoke фиксируется отдельной фазой до снятия таймера", () => {
const smokeOk = installSource.indexOf('await advanceInstallState(context, ownership, "smoke_ok")');
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
expect(smokeOk).toBeGreaterThan(-1);
expect(smokeOk).toBeLessThan(disarm);
});
// Уборка после фиксации успеха — мусор в /run, а не причина объявить
// успешную установку неуспешной.
test("уборка после фиксации выполняется best-effort", () => {
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
const tryAt = installSource.lastIndexOf("try {", cleanup);
const catchAt = installSource.indexOf("catch (cleanupError)", cleanup);
expect(tryAt).toBeGreaterThan(-1);
expect(tryAt).toBeLessThan(cleanup);
expect(catchAt).toBeGreaterThan(cleanup);
});
});
describe("reconfigure: тот же порядок фиксации", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("порядок: disarm -> durable installed -> cleanup", () => {
const disarm = reconfigureSource.indexOf("await disarmFirewallRollback(context)");
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
expect(disarm).toBeGreaterThan(-1);
expect(installed).toBeGreaterThan(disarm);
expect(cleanup).toBeGreaterThan(installed);
});
test("уборка после фиксации выполняется best-effort", () => {
expect(reconfigureSource).toContain("catch (cleanupError)");
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
const catchAt = reconfigureSource.indexOf("catch (cleanupError)", cleanup);
expect(catchAt).toBeGreaterThan(cleanup);
});
});