76d78ac71f
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.
1. Отказ записи `auto-rollback-fired` оставался незамеченным.
Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.
`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.
Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.
2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.
Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:
A берёт замок -> применяет firewall -> вооружает guard на 45s
A аварийно умирает
B берёт замок и начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.
Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.
Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.
Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
632 lines
32 KiB
TypeScript
632 lines
32 KiB
TypeScript
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,
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Запускает скрипт кандидатом-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(firewallSource).toContain("systemd-run --unit ${`${unit}.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("барьер покоя между операциями", () => {
|
||
/**
|
||
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
|
||
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
|
||
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
|
||
* который возвращает прежний firewall уже посреди следующей операции.
|
||
*/
|
||
const body = firewallSource.slice(
|
||
firewallSource.indexOf("export async function assertNoPendingRollbackGuard"),
|
||
firewallSource.indexOf("function firewallRollbackIsInactive")
|
||
);
|
||
|
||
test("вооружённый guard предыдущей операции запрещает новую", () => {
|
||
expect(body).toContain("PendingRecoveryError");
|
||
expect(body).toContain("readUnitProperty(unit, \"ActiveState\")");
|
||
});
|
||
|
||
// `failed` и `inactive` — покой: guard уже отработал и больше ничего не
|
||
// сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он
|
||
// нужен для устранения последствий.
|
||
test("покоем считаются inactive и failed, а не только inactive", () => {
|
||
expect(firewallSource).toContain(
|
||
'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const'
|
||
);
|
||
});
|
||
|
||
test("отказ запроса к systemd не выдаётся за наличие guard", () => {
|
||
const listing = firewallSource.slice(
|
||
firewallSource.indexOf("export async function listRollbackGuardUnits"),
|
||
firewallSource.indexOf("export async function assertNoPendingRollbackGuard")
|
||
);
|
||
expect(listing).toContain("unable to list firewall rollback guard units");
|
||
expect(listing).toContain("return [];");
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|