0230f1ca99
systemd-run пропускает голое имя через unit_name_mangle_with_suffix, который сначала проверяет, не заканчивается ли оно уже известным типом юнита. Ключ операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть содержит точку, и корректность имени зависела бы от того, что `.789Z` случайно не совпало ни с одним типом systemd. Имя передаётся с явным суффиксом `.service`: systemd-run берёт его как есть и создаёт рядом одноимённый `.timer`, на который и рассчитывают снятие guard и status.
401 lines
20 KiB
TypeScript
401 lines
20 KiB
TypeScript
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'
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Имя транзиентного юнита передаётся с явным суффиксом. Голое имя 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("сработавший 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);
|
||
});
|
||
});
|