Files
HY2XS_flamy/orchestrator/test/firewall-guard.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

391 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});