Files
HY2XS_flamy/orchestrator/test/firewall-guard.test.ts
T
founder 2259f7c847 firewall guard: барьер покоя fail-closed и явный контракт транзиентного таймера
Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах
принимал за доказательство отсутствие наблюдения.

- отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []`
  введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/
  unknown и отдельным типом отказа GuardStateUnknownError;
- покой перечисляется белым списком (inactive, failed): maintenance,
  refreshing и любое незнакомое состояние systemd блокируют операцию;
- у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min
  превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер
  дополнительно опознаёт SubState=elapsed у *.timer как покой;
- команда взведения строится чистой buildArmGuardArgv и выполняется новым
  runMutatingArgv без shell, поэтому её контракт проверяется значением, а не
  грепом по исходнику;
- status перестал листить guard-юниты своей копией кода: без --plain, с
  `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил
  барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown;
- purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке.

Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe:
прежние проверки грепом по тексту функции пережили инверсию смысла - строка
`return [];` была на месте, а решение стало неверным.

Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному
окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий
PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф.
2026-08-31 16:44:20 +05:00

852 lines
43 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 { delimiter, dirname, join } from "node:path";
import { classifyFailure } from "../src/commands/install";
import {
FirewallGuardFiredError,
GuardStateUnknownError,
OperationBarrierError,
PendingRecoveryError,
type SystemdUnitProbe,
assertNoPendingRollbackGuard,
buildArmGuardArgv,
buildAutoRollbackScript,
describeGuardUnits,
guardUnitIsQuiescent,
inspectRollbackGuard,
operationKeyFor,
parseNftablesServiceState,
parseRollbackGuardUnitNames,
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;
}
/**
* Запускает скрипт кандидатом-shell.
*
* Каталог самого shell добавляется в PATH: Git for Windows кладёт bash и
* coreutils рядом, но при запуске напрямую из процесса PATH этого каталога не
* содержит, и `mkdir`/`touch` внутри скрипта оказываются не найдены. Это
* особенность окружения, а не скрипта, и тест не должен на неё падать.
*/
function spawnShell(shell: string, scriptPath: string) {
return Bun.spawnSync([shell, scriptPath], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, PATH: `${dirname(shell)}${delimiter}${process.env.PATH ?? ""}` }
});
}
/**
* Ищет shell, которому можно доверить ЗАПУСК скрипта.
*
* Проба самопроверяющая и намеренно строгая: кандидат обязан вернуть код
* возврата скрипта И создать файл, который тест затем видит на своей файловой
* системе. Одной проверки кода возврата мало — `C:\Windows\system32\bash.exe`
* это launcher WSL, он честно выполнит `exit 7`, но в совершенно другом
* пространстве имён путей, и поведенческий тест ниже проверял бы не скрипт.
*/
function findShell(sandbox: string): string | null {
const probeDir = join(sandbox, "probe-dir");
const probe = join(sandbox, "probe.sh");
writeFileSync(
probe,
`mkdir -p '${probeDir.replace(/\\/g, "/")}'\ntouch '${probeDir.replace(/\\/g, "/")}/ok'\nexit 7\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) {
rmSync(probeDir, { recursive: true, force: true });
const result = spawnShell(candidate, probe);
if (result.exitCode === 7 && existsSync(join(probeDir, "ok"))) {
return candidate;
}
}
return null;
}
describe("скрипт автоматического отката firewall", () => {
const script = buildAutoRollbackScript(OP_ID);
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
const marker = script.indexOf(`touch "$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(`touch "$root/auto-rollback-fired"`);
const earlyExit = script.indexOf("nothing to restore");
expect(marker).toBeLessThan(earlyExit);
});
/**
* Отказ записи маркера обязан входить в учёт rc.
*
* Инвариант фиксации — «маркера нет и юниты inactive => guard не сработал» —
* верен только при дополнительном условии «guard способен записать маркер».
* Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs
* /run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал
* прежний firewall и завершался кодом 0, юнит уходил в inactive, маркера не
* было — и операция фиксировала успех после реально сработавшего отката.
*/
test("rc объявляется до создания маркера, а не после", () => {
const rcInit = script.indexOf("rc=0");
const mkdir = script.indexOf('mkdir -p "$root"');
const marker = script.indexOf(`touch "$root/auto-rollback-fired"`);
expect(rcInit).toBeGreaterThan(-1);
expect(rcInit).toBeLessThan(mkdir);
expect(rcInit).toBeLessThan(marker);
});
test("невозможность записать маркер поднимает код возврата", () => {
expect(script).toContain("failed to access the recovery root");
expect(script).toContain("failed to create the fired marker");
});
/**
* Маркер создаётся `touch`, а не `: >file`.
*
* Двоеточие — special builtin POSIX: ошибка перенаправления на нём обязана
* завершить неинтерактивный shell целиком. На Debian /bin/sh — это dash,
* который так и делает, поэтому при недоступном /run скрипт умер бы ДО
* восстановления firewall — то есть guard перестал бы делать ровно то, ради
* чего существует.
*/
test("маркер создаётся обычной командой, а не special builtin", () => {
expect(script).toContain('touch "$root/auto-rollback-fired"');
expect(script).not.toContain(': >"$root/auto-rollback-fired"');
});
// Аварийный канал факта срабатывания: если маркер записать не удалось, юнит
// обязан уйти в failed, а `failed` на пути фиксации успеха запрещён.
test("ранний выход возвращает накопленный код, а не ноль", () => {
const earlyExit = script.indexOf("nothing to restore");
const tail = script.slice(earlyExit);
expect(tail).toContain('exit "$rc"');
expect(script).not.toContain("exit 0");
});
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/);
});
/**
* Поведенческая проверка раннего пути скрипта.
*
* Запускается ТОЛЬКО ветка «prepared отсутствует»: она заканчивается до
* первой команды восстановления, поэтому ничего в /etc не трогает и
* безопасна на любой машине. Именно в этой ветке живёт исправленный дефект —
* раньше она возвращала жёсткий `exit 0` и теряла факт неудачной записи
* маркера.
*
* Строка `root=` подменяется на временный каталог: это единственное
* изменение, остальные сорок строк — ровно те, что уезжают на сервер.
*/
function runEarlyPath(sandbox: string, root: string): { exitCode: number; stderr: string } | null {
const shell = findShell(sandbox);
if (!shell) {
return null;
}
const scriptPath = join(sandbox, "run.sh");
writeFileSync(
scriptPath,
script.replace(/^root='.*'$/m, `root='${root.replace(/\\/g, "/")}'`)
);
const result = spawnShell(shell, scriptPath);
return { exitCode: result.exitCode, stderr: result.stderr.toString() };
}
test("при доступном /run маркер создаётся, а ранний выход успешен", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-"));
try {
const root = join(sandbox, "rollback");
const result = runEarlyPath(sandbox, root);
if (!result) {
console.warn("shell is unavailable: skipping the behavioural check of the rollback script");
return;
}
expect(existsSync(join(root, "auto-rollback-fired"))).toBe(true);
expect(result.stderr).toContain("nothing to restore");
expect(result.exitCode).toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
// Ключевой сценарий исправления: маркер записать не удалось, восстановления
// не было — юнит обязан уйти в failed, потому что это аварийный канал факта
// срабатывания, и `failed` запрещает фиксацию успеха.
test("при недоступном /run ранний выход возвращает отказ", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-"));
try {
// Родитель — файл, поэтому ни mkdir, ни touch выполниться не могут.
const blocker = join(sandbox, "blocker");
writeFileSync(blocker, "не каталог\n");
const result = runEarlyPath(sandbox, join(blocker, "rollback"));
if (!result) {
console.warn("shell is unavailable: skipping the behavioural check of the rollback script");
return;
}
expect(result.stderr).toContain("auto-rollback: failed");
expect(result.exitCode).not.toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
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'
);
});
/**
* Имя транзиентного юнита передаётся с явным суффиксом. Голое имя systemd-run
* пропускает через мангling, который смотрит, не заканчивается ли оно уже
* известным типом юнита; ключ операции содержит точку (`...T12-34-56.789Z`),
* и корректность зависела бы от того, что `.789Z` ни с чем не совпало.
*/
test("имя юнита guard'а не зависит от мангления systemd", () => {
expect(buildArmGuardArgv(OP_ID)).toContain(`--unit=hy2xs-fw-rollback-${OP_ID}.service`);
});
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'а задаёт свойства таймера явно", () => {
/**
* У этой команды есть контракт, от которого зависят два чужих утверждения, и
* оба до сих пор проверялись грепом по исходнику.
*
* `AccuracySec` — обещанное оператору окно. `systemd.timer` разрешает себе
* сработать в интервале `[цель; цель + AccuracySec]`, а умолчание — `1min`.
* То есть guard, про который README, docs и текст отказа барьера говорят «45
* секунд», по контракту systemd мог сработать через 105.
*
* `RemainAfterElapse` — право барьера считать исчезновение юнита покоем.
* systemd-run выставляет `false` сам, но инвариант, который держится на чужом
* умолчании, нигде не записан и ничем не проверяется.
*/
const argv = buildArmGuardArgv(OP_ID);
test("окно отката задано вместе с точностью таймера", () => {
expect(argv).toContain("--on-active=45s");
expect(argv).toContain("--timer-property=AccuracySec=1s");
});
test("отработавший таймер обязан выгрузиться", () => {
expect(argv).toContain("--timer-property=RemainAfterElapse=no");
});
test("команда собрана целиком, а не по кускам", () => {
expect(argv).toEqual([
"systemd-run",
`--unit=hy2xs-fw-rollback-${OP_ID}.service`,
"--on-active=45s",
"--timer-property=RemainAfterElapse=no",
"--timer-property=AccuracySec=1s",
"/bin/sh",
`/run/hy2xs/rollback/${OP_ID}/auto-rollback.sh`
]);
});
// Ключ операции подставляется в имя systemd-юнита и в путь скрипта. Без shell
// квотирование не спасает — спасает отказ.
test("небезопасный ключ операции отвергается до запуска", () => {
expect(() => buildArmGuardArgv("op id")).toThrow(/unsafe operation key/);
expect(() => buildArmGuardArgv("op'; rm -rf /")).toThrow(/unsafe operation key/);
});
// Готовый argv уходит в exec как есть: shell в этой команде не участвует.
test("взведение идёт без shell", () => {
const arm = firewallSource.slice(
firewallSource.indexOf("async function armRollbackGuard"),
firewallSource.indexOf("export async function applyFirewall")
);
expect(arm).toContain("runMutatingArgv(buildArmGuardArgv(opId))");
expect(arm).not.toMatch(/runMutatingVisible`/);
});
});
describe("барьер покоя между операциями", () => {
/**
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
* который возвращает прежний firewall уже посреди следующей операции.
*
* Проверки здесь поведенческие. Прежние сверяли ТЕКСТ функции, и именно
* поэтому пропустили инверсию смысла: тест утверждал, что в теле есть
* `return [];`, строка была на месте, а решение при этом стало неверным.
*/
const TIMER = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.timer";
const SERVICE = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.service";
/** Probe, отвечающий заранее заданными состояниями. */
function probeWith(states: Record<string, { ActiveState: string; SubState: string }>): SystemdUnitProbe {
return {
async listGuardUnits() {
return Object.keys(states)
.map((unit) => `${unit} loaded active running HY2XS firewall rollback guard`)
.join("\n");
},
async showProperties(unit) {
const state = states[unit];
if (!state) {
throw new Error(`unexpected unit: ${unit}`);
}
return { ...state };
}
};
}
const failingProbe: SystemdUnitProbe = {
async listGuardUnits(): Promise<string> {
throw new Error("Failed to connect to bus: No such file or directory");
},
async showProperties(): Promise<Record<string, string>> {
throw new Error("Failed to connect to bus: No such file or directory");
}
};
test("guard'ов нет — операция разрешена", async () => {
const inspection = await inspectRollbackGuard(probeWith({}));
expect(inspection.kind).toBe("quiescent");
await expect(assertNoPendingRollbackGuard(probeWith({}))).resolves.toBeUndefined();
});
test("взведённый таймер предыдущей операции запрещает новую", async () => {
const probe = probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } });
const inspection = await inspectRollbackGuard(probe);
expect(inspection.kind).toBe("pending");
const error = await assertNoPendingRollbackGuard(probe).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(PendingRecoveryError);
expect((error as Error).message).toContain(TIMER);
expect((error as Error).message).toContain("active/waiting");
});
test("выполняющийся прямо сейчас откат — тоже непокой", async () => {
const probe = probeWith({
[TIMER]: { ActiveState: "active", SubState: "running" },
[SERVICE]: { ActiveState: "activating", SubState: "start" }
});
await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(PendingRecoveryError);
});
/**
* Отказ запроса к systemd — ОТСУТСТВИЕ наблюдения, а не наблюдение покоя.
*
* Прежний код возвращал пустой список и тем самым принимал невозможность
* получить доказательство за положительный результат:
*
* systemd жив, старый таймер взведён
* -> systemctl временно отказывает
* -> список пуст -> барьер считает систему спокойной
* -> новая операция меняет firewall, старый таймер срабатывает поверх
*/
test("отказ запроса к systemd запрещает операцию, а не разрешает её", async () => {
const inspection = await inspectRollbackGuard(failingProbe);
expect(inspection.kind).toBe("unknown");
const error = await assertNoPendingRollbackGuard(failingProbe).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(GuardStateUnknownError);
expect((error as Error).message).toContain("refusing to start a lifecycle operation");
});
test("отказ на одном юните тоже делает картину неполной", async () => {
const probe: SystemdUnitProbe = {
async listGuardUnits() {
return `${TIMER} loaded active waiting guard\n${SERVICE} loaded inactive dead guard`;
},
async showProperties(unit) {
if (unit === SERVICE) {
throw new Error("Connection timed out");
}
return { ActiveState: "inactive", SubState: "dead" };
}
};
await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(GuardStateUnknownError);
});
// Обе причины отказа — про то, что операцию нельзя начинать, и вызывающий
// вправе не различать их по конкретному типу.
test("оба отказа барьера имеют общего предка", async () => {
await expect(assertNoPendingRollbackGuard(failingProbe)).rejects.toBeInstanceOf(OperationBarrierError);
await expect(
assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } }))
).rejects.toBeInstanceOf(OperationBarrierError);
});
// Отработавший guard больше ничего не сделает. Отказ по `failed` заблокировал
// бы `repair` ровно тогда, когда им чинят последствия.
test("покой — это inactive и failed", async () => {
await expect(
assertNoPendingRollbackGuard(
probeWith({
[TIMER]: { ActiveState: "inactive", SubState: "dead" },
[SERVICE]: { ActiveState: "failed", SubState: "failed" }
})
)
).resolves.toBeUndefined();
});
/**
* Политика покоя — белый список, а не чёрный.
*
* Прежняя перечисляла непокойные состояния, то есть объявляла безопасным
* любое, которого автор не назвал. systemd 257 знает `maintenance` и
* `refreshing` помимо перечисленных, и список может пополниться снова.
*/
test("незнакомое состояние systemd блокирует операцию, а не проходит молча", async () => {
for (const activeState of ["maintenance", "refreshing", "some-future-state"]) {
await expect(
assertNoPendingRollbackGuard(probeWith({ [SERVICE]: { ActiveState: activeState, SubState: "x" } }))
).rejects.toBeInstanceOf(PendingRecoveryError);
}
});
/**
* Отработавший таймер не должен блокировать операцию навсегда.
*
* `TIMER_ELAPSED` в systemd отображается в `UNIT_ACTIVE` так же, как
* `TIMER_WAITING`, — различает их только SubState. У наших guard'ов такого не
* бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не
* должен зависеть от того, чем именно создан таймер.
*/
test("таймер в elapsed — покой, а не вечная блокировка", async () => {
await expect(
assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "elapsed" } }))
).resolves.toBeUndefined();
});
// Исключение узкое: у сервиса тот же SubState ничего не значит.
test("исключение по SubState действует только для таймера", async () => {
expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "elapsed" })).toBe(true);
expect(guardUnitIsQuiescent({ unit: SERVICE, activeState: "active", subState: "elapsed" })).toBe(false);
expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "waiting" })).toBe(false);
});
/**
* Разбор вывода `systemctl list-units`.
*
* У юнита в состоянии `failed` первой колонкой идёт маркер `●`, поэтому
* «имя юнита — первое поле строки» теряло бы именно аварийно сработавший
* guard. Ищется первое поле, начинающееся с префикса.
*/
test("имя юнита находится и при маркере failed в первой колонке", () => {
const listed = [
`${TIMER} loaded active waiting HY2XS firewall rollback guard`,
`${SERVICE} loaded failed failed HY2XS firewall rollback guard`,
"",
"hysteria-server.service loaded active running Hysteria"
].join("\n");
expect(parseRollbackGuardUnitNames(listed)).toEqual([TIMER, SERVICE]);
});
test("посторонние юниты и пустой вывод не попадают в разбор", () => {
expect(parseRollbackGuardUnitNames("")).toEqual([]);
expect(parseRollbackGuardUnitNames("nftables.service loaded active exited nftables\n")).toEqual([]);
});
test("описание юнита называет и состояние, и подсостояние", () => {
expect(describeGuardUnits([{ unit: TIMER, activeState: "active", subState: "waiting" }])).toBe(
`${TIMER} (active/waiting)`
);
});
test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {
const cliSource = source("cli.ts");
expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }");
// Обработчик сигналов снимает замок сам, поэтому у прерванной операции
// stale-замка может не быть вовсе, а таймер останется.
expect(cliSource).toContain("await assertNoPendingRollbackGuard()");
});
// Барьер вызывается дважды: между проверкой и захватом умирающая операция
// успевает вооружить guard.
test("барьер проверяется до и после захвата замка", () => {
const lockSource = source("lib/operationLock.ts");
const start = lockSource.indexOf("export async function acquireOperationLock");
const acquire = lockSource.slice(start);
const before = acquire.indexOf("await options.barrier?.()");
const write = acquire.indexOf("await writeLockFile(path, record)");
const after = acquire.indexOf("await options.barrier()");
expect(before).toBeGreaterThan(-1);
expect(write).toBeGreaterThan(before);
expect(after).toBeGreaterThan(write);
// Отказ второй проверки не имеет права оставить замок за собой.
expect(acquire.slice(after)).toContain("releaseSync(path, record.nonce)");
});
});
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);
});
});