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 не существовал.
This commit is contained in:
2026-08-30 22:54:57 +05:00
parent 330a63b050
commit 4e7f54b9ff
9 changed files with 1127 additions and 87 deletions
+21 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { preflight } from "../src/steps/preflight";
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
import { FirewallGuardFiredError } from "../src/steps/firewall";
import { baselineConfig, testContext } from "./fixtures";
/**
@@ -134,7 +135,26 @@ describe("классификация отказа reconfigure/repair", () => {
// классификация по тексту ошибки убрана, должен быть разрешён.
test("текст ошибки на классификацию не влияет", () => {
expect(reconfigureSource).not.toContain(".test(message)");
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership)");
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership, error)");
});
/**
* Сработавший guard классифицируется по ТИПУ ошибки.
*
* Разница с прежним разбором сообщения принципиальна, и проверяется она
* именно так: ошибка, в тексте которой есть все нужные слова, но у которой
* другой тип, обязана классифицироваться по владению, как и раньше.
*/
test("сработавший guard опознаётся по типу ошибки, а не по её тексту", () => {
const late = ownership({ firewallTouched: true, servicesRestarted: true });
expect(classifyReconfigureFailure(late, new FirewallGuardFiredError("guard"))).toBe(
"firewall_guard_fired"
);
expect(
classifyReconfigureFailure(late, new Error("automatic firewall rollback has already fired"))
).toBe("smoke_failed");
expect(classifyReconfigureFailure(late)).toBe("smoke_failed");
});
test("отказ диагностики не отменяет откат firewall и конфигов", () => {