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
+1 -1
View File
@@ -52,7 +52,7 @@ describe("снятие таймера и удаление копий — раз
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("stopRollbackTimer");
expect(body).toContain("stopRollbackGuard");
});
test("cleanup удаляет копии и ничего больше", () => {
+390
View File
@@ -0,0 +1,390 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { classifyFailure } from "../src/commands/install";
import {
FirewallGuardFiredError,
buildAutoRollbackScript,
operationKeyFor,
parseNftablesServiceState,
renderNftablesEntrypoint,
renderNftablesServiceState
} from "../src/steps/firewall";
/**
* Транзакционность staged firewall guard.
*
* Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он
* тоньше первого. Первый был про то, что данные для отката уничтожались до
* фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное
* утверждение.
*
* systemctl stop <unit>.timer <unit>.service || true
* -> "firewall rollback timer disarmed"
* -> phase=installed
*
* Между «мы думаем, что guard снят» и «guard действительно снят» не было ни
* одной проверки. Отказ остановки стирался через `|| true`, и взведённый таймер
* мог вернуть прежний firewall уже ПОСЛЕ того, как установка объявлена
* успешной и долговечно записана.
*
* Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
* Окно 45 секунд заведомо короче худшего случая smoke (retry-бюджеты дают
* заметно больше), а единственной проверкой firewall в smoke был `nft -c` —
* разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset
* проходил её зелёным.
*
* Оба закрываются одним фактом: маркером `auto-rollback-fired`, который
* rollback-скрипт создаёт первым действием. Дальше инвариант становится
* детерминированным:
*
* маркер отсутствует И юниты inactive => можно фиксировать успех
*/
function source(relativeToSrc: string): string {
return readFileSync(new URL(`../src/${relativeToSrc}`, import.meta.url), "utf8");
}
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
const firewallSource = source("steps/firewall.ts");
const smokeSource = source("steps/smoke.ts");
const OP_ID = "2026-08-30T12-34-56.789Z";
/**
* Ищет shell, которому можно доверить разбор скрипта.
*
* Кандидат принимается только после ДВУСТОРОННЕЙ проверки: он обязан принять
* заведомо корректный скрипт и отвергнуть заведомо сломанный. Односторонней
* проверки мало — `C:\Windows\system32\bash.exe` без установленного WSL
* «проваливает» любой файл одинаково, и тест на таком парсере либо всегда
* зелёный, либо всегда красный, но в обоих случаях ничего не проверяет.
*/
function findShellParser(sandbox: string): string | null {
const good = join(sandbox, "good.sh");
const bad = join(sandbox, "bad.sh");
writeFileSync(good, "true\n");
writeFileSync(bad, "if then fi else\n");
const candidates = [
Bun.which("dash"),
Bun.which("sh"),
Bun.which("bash"),
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
"E:\\Git\\usr\\bin\\bash.exe"
].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string));
for (const candidate of candidates) {
const accepts = Bun.spawnSync([candidate, "-n", good], { stdout: "pipe", stderr: "pipe" });
const rejects = Bun.spawnSync([candidate, "-n", bad], { stdout: "pipe", stderr: "pipe" });
if (accepts.exitCode === 0 && rejects.exitCode !== 0) {
return candidate;
}
}
return null;
}
describe("скрипт автоматического отката firewall", () => {
const script = buildAutoRollbackScript(OP_ID);
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`);
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
expect(marker).toBeGreaterThan(-1);
expect(prepared).toBeGreaterThan(marker);
expect(firstRestore).toBeGreaterThan(prepared);
});
// Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
// снятия взведённого таймера.
test("маркер создаётся даже когда восстанавливать нечего", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const earlyExit = script.indexOf("nothing to restore");
expect(marker).toBeLessThan(earlyExit);
});
test("ошибки не маскируются", () => {
expect(script).not.toContain("|| true");
expect(script).not.toContain("2>/dev/null");
expect(script).not.toContain(">/dev/null 2>&1");
});
// set -e прервал бы восстановление на первой отказавшей части, а задача
// guard'а — восстановить ВСЁ, что получится.
test("непрерывность обеспечивается rc, а не set -e", () => {
expect(script).not.toMatch(/^set -e/m);
expect(script).toContain("rc=0");
expect(script).toContain('exit "$rc"');
});
test("каждый сообщённый отказ поднимает код возврата", () => {
expect(occurrences(script, "rc=1")).toBe(occurrences(script, "auto-rollback: failed"));
expect(occurrences(script, "rc=1")).toBeGreaterThan(0);
});
/**
* На Debian у nftables.service `ExecStop=/usr/sbin/nft flush ruleset`.
* Остановка сервиса внутри guard'а стёрла бы только что восстановленные
* правила — то есть сделала бы ровно противоположное задаче.
*/
test("состояние nftables.service guard не трогает", () => {
expect(script).not.toContain("systemctl");
});
test("пути привязаны к каталогу этой операции", () => {
expect(script).toContain(`root='/run/hy2xs/rollback/${OP_ID}'`);
expect(buildAutoRollbackScript("2026-09-01T00-00-00.000Z")).not.toContain(OP_ID);
});
// Ключ операции служит именем каталога, именем systemd-юнита и подставляется
// в текст скрипта. Прежний однострочник держался на том, что в нём не бывает
// пробелов; теперь это проверяемое утверждение, а не допущение.
test("небезопасный ключ операции отвергается", () => {
expect(() => buildAutoRollbackScript("op id")).toThrow(/unsafe operation key/);
expect(() => buildAutoRollbackScript("op'; rm -rf /")).toThrow(/unsafe operation key/);
expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/);
});
test("разбирается настоящим shell-парсером", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
try {
const parser = findShellParser(sandbox);
if (!parser) {
// Сборка идёт на Linux, где парсер есть всегда; на машине разработчика
// его может не быть, и это не повод объявлять скрипт корректным.
console.warn("shell parser is unavailable: skipping the syntax check of the rollback script");
return;
}
const scriptPath = join(sandbox, "auto-rollback.sh");
writeFileSync(scriptPath, script);
const parsed = Bun.spawnSync([parser, "-n", scriptPath], { stdout: "pipe", stderr: "pipe" });
expect(parsed.stderr.toString().trim()).toBe("");
expect(parsed.exitCode).toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
describe("ключ операции один на все её артефакты", () => {
// Раньше формула была записана дважды, и копии разошлись: install писал в
// маркер сырой ISO-timestamp с двоеточиями, а каталог отката назывался
// санитизированным ключом. Runbook предлагает оператору открыть
// /run/hy2xs/rollback/<op_id> — по значению из маркера такого пути не было.
test("двоеточия ISO-времени заменяются", () => {
expect(operationKeyFor("2026-08-30T12:34:56.789Z")).toBe("2026-08-30T12-34-56.789Z");
});
test("install и reconfigure берут ключ из одного места", () => {
expect(source("commands/install.ts")).toContain("opId: operationKeyFor(context.installDate)");
expect(source("commands/reconfigure.ts")).toContain("return operationKeyFor(context.installDate)");
});
});
describe("состояние nftables.service переживает откат", () => {
test("запись и разбор согласованы", () => {
const state = { unitFileState: "disabled", activeState: "inactive" };
expect(parseNftablesServiceState(renderNftablesServiceState(state))).toEqual(state);
});
test("непонятый файл не притворяется состоянием", () => {
expect(parseNftablesServiceState("")).toBeNull();
expect(parseNftablesServiceState("мусор без разделителя\n")).toBeNull();
});
test("частичное состояние разбирается, а не отбрасывается", () => {
expect(parseNftablesServiceState("unit_file_state=enabled\n")).toEqual({
unitFileState: "enabled",
activeState: ""
});
});
test("состояние снимается до первой мутации firewall", () => {
const backup = firewallSource.indexOf("async function backupFirewallState");
const capture = firewallSource.indexOf("nftablesServiceStatePath(opId)", backup);
const prepared = firewallSource.indexOf("touch ${rollbackMarker(opId)}", backup);
expect(capture).toBeGreaterThan(backup);
expect(prepared).toBeGreaterThan(capture);
});
/**
* Порядок стадий отката. `systemctl stop nftables` выполняет
* `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти
* ДО применения ruleset — иначе остановка стёрла бы восстановленные правила.
*/
test("состояние сервиса восстанавливается до применения ruleset", () => {
const body = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
const unitFile = body.indexOf('name: "restore nftables.service unit file state"');
const inactive = body.indexOf('name: "restore nftables.service inactive state"');
const ruleset = body.indexOf('name: "apply restored ruleset"');
expect(unitFile).toBeGreaterThan(-1);
expect(inactive).toBeGreaterThan(unitFile);
expect(ruleset).toBeGreaterThan(inactive);
});
});
describe("disarm доказывает снятие guard'а, а не сообщает о нём", () => {
const body = firewallSource.slice(
firewallSource.indexOf("async function stopRollbackGuard"),
firewallSource.indexOf("export async function disarmFirewallRollback")
);
test("остановка guard'а больше не глушит свой код возврата", () => {
expect(body).not.toContain("|| true");
expect(body).toContain("runMutatingStatus`systemctl stop");
});
test("маркер срабатывания проверяется до и после остановки", () => {
const before = body.indexOf("before stopping the rollback guard");
const stop = body.indexOf("runMutatingStatus`systemctl stop");
const after = body.indexOf("after stopping the rollback guard");
expect(before).toBeGreaterThan(-1);
expect(stop).toBeGreaterThan(before);
expect(after).toBeGreaterThan(stop);
});
test("снятие подтверждается наблюдаемым состоянием юнитов", () => {
expect(body).toContain('readUnitProperty(target, "ActiveState")');
expect(body).toContain("`${unit}.timer`");
expect(body).toContain("`${unit}.service`");
});
// На пути фиксации успеха допустимо ровно одно состояние. `failed` там
// означает сработавший и упавший guard, то есть запрет на фиксацию.
test("на пути фиксации допустим только inactive", () => {
expect(firewallSource).toContain('const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const');
expect(firewallSource).toContain(
'const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const'
);
});
test("остановка guard'а стала стадией отката с отчётом", () => {
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
expect(rollback).toContain('name: "stop firewall rollback guard"');
expect(rollback).toContain("assertNotFired: false");
});
});
describe("сработавший guard запрещает фиксацию успеха", () => {
function ownership(overrides: Record<string, boolean> = {}) {
return {
stateTouched: true,
bootstrapTouched: true,
depsTouched: true,
filesystemTouched: true,
uiTouched: true,
hysteriaTouched: true,
configTouched: true,
unitsTouched: true,
firewallTouched: true,
postInstallTouched: true,
bootstrapSecretTouched: true,
servicesStarted: true,
...overrides
};
}
test("install классифицирует его отдельной причиной отказа", () => {
expect(classifyFailure(ownership(), "smoke_ok", new FirewallGuardFiredError("guard"))).toBe(
"firewall_guard_fired"
);
});
// Классификация по ТИПУ, а не по тексту: ошибка с теми же словами, но другим
// типом обязана классифицироваться по владению, как и прежде.
test("похожий текст чужой ошибки на классификацию не влияет", () => {
expect(
classifyFailure(ownership(), "smoke_ok", new Error("automatic firewall rollback has already fired"))
).toBe("service_start_failure");
});
test("маркер отказа получает собственную фазу", () => {
const installSource = source("commands/install.ts");
expect(installSource).toContain('? "firewall_guard_fired"');
expect(source("commands/reconfigure.ts")).toContain('return "firewall_guard_fired"');
});
});
describe("эффективный firewall проверяется семантически", () => {
test("smoke сверяет firewall, а не только разбирает файл", () => {
const syntax = smokeSource.indexOf("nft -c -f /etc/nftables.conf");
const semantic = smokeSource.indexOf("assertEffectiveFirewallIsOurs(context)");
expect(syntax).toBeGreaterThan(-1);
expect(semantic).toBeGreaterThan(syntax);
});
test("сверяется отрендеренный фрагмент, entrypoint и загруженная таблица", () => {
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
expect(body).toContain("await renderHy2xsNft(context)");
expect(body).toContain("effective !== expected");
expect(body).toContain('entrypointKind !== "hy2xs-managed"');
expect(body).toContain("nft list table inet hy2xs");
});
// Проверка обязана оставаться наблюдением: она выполняется и в doctor,
// который целиком идёт под read-only guard.
test("проверка выполняется read-only раннером", () => {
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
expect(body).not.toMatch(/runMutating[A-Za-z]*`/);
});
test("рендер фрагмента объявлен один раз и переиспользуется", () => {
expect(occurrences(firewallSource, "templates/nftables/hy2xs.nft.tpl")).toBe(1);
});
});
describe("candidate-файлы не переживают операцию", () => {
test("успешный apply убирает оба", () => {
expect(firewallSource).toContain(
"runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`"
);
const apply = firewallSource.slice(
firewallSource.indexOf("export async function applyFirewall"),
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs")
);
expect(apply).toContain("await cleanupFirewallCandidates()");
});
test("откат убирает их best-effort и не срывается на этом", () => {
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
const cleanup = rollback.indexOf("await cleanupFirewallCandidates()");
const guarded = rollback.indexOf("catch (candidateError)");
expect(cleanup).toBeGreaterThan(-1);
expect(guarded).toBeGreaterThan(cleanup);
});
});
describe("entrypoint nftables", () => {
test("объявляет владение HY2XS и подключает переданный фрагмент", () => {
const rendered = renderNftablesEntrypoint("/etc/nftables.d/hy2xs.nft");
expect(rendered).toContain("HY2XS-MANAGED");
expect(rendered).toContain("flush ruleset");
expect(rendered).toContain('include "/etc/nftables.d/hy2xs.nft"');
});
test("candidate и production entrypoint строятся одной функцией", () => {
expect(occurrences(firewallSource, "HY2XS-MANAGED: root nftables entrypoint")).toBe(1);
});
});
+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 и конфигов", () => {
+7 -1
View File
@@ -198,7 +198,13 @@ describe("reconfigure: откат обязателен после операци
const reconfigureSource = source("commands/reconfigure.ts");
test("запись состояния отказа обёрнута в persistFailureState", () => {
expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context");
// Проверяется вложенность вызовов, а не форматирование: markPhase обязан
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
// редактор.
const wrapped = reconfigureSource.match(
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/
);
expect(wrapped).not.toBeNull();
});
test("в обработчике ошибки нет незащищённого markPhase", () => {