4e7f54b9ff
Снятие автоматического отката 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 не существовал.
119 lines
6.1 KiB
TypeScript
119 lines
6.1 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* Порядок фиксации успеха операции.
|
|
*
|
|
* Дефект, который здесь закрепляется, — commit ordering. Успешный install
|
|
* заканчивался так:
|
|
*
|
|
* smoke PASS
|
|
* -> cancelFirewallRollback() // снимала таймер И УДАЛЯЛА резервные копии
|
|
* -> advanceInstallState("installed")
|
|
*
|
|
* То есть единственные данные для отката firewall (marker, прежний
|
|
* nftables.conf, прежний hy2xs.nft) уничтожались ДО долговечной записи успеха.
|
|
* Если эта запись падала — ENOSPC, EIO, read-only ФС, — управление уходило в
|
|
* обработчик ошибки, обязательный откат честно запускался и сообщал:
|
|
*
|
|
* firewall rollback skipped: no HY2XS rollback markers found
|
|
*
|
|
* Откат нельзя было отменить, но откатывать ему было нечем. Причём падение
|
|
* записи маркера — ровно тот сценарий, который был специально сделан
|
|
* безопасным предыдущим патчем.
|
|
*
|
|
* Правильный порядок: снять таймер (копии остаются) -> зафиксировать успех
|
|
* долговечно -> и только потом убрать копии, best-effort.
|
|
*/
|
|
|
|
function source(relativeToSrc: string): string {
|
|
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
|
}
|
|
|
|
const firewallSource = source("steps/firewall.ts");
|
|
|
|
describe("снятие таймера и удаление копий — разные операции", () => {
|
|
test("firewall предоставляет disarm и cleanup по отдельности", () => {
|
|
expect(firewallSource).toContain("export async function disarmFirewallRollback");
|
|
expect(firewallSource).toContain("export async function cleanupFirewallRollback");
|
|
});
|
|
|
|
// Прежнее единое имя не должно вернуться: оно и означало «снять таймер и
|
|
// удалить копии одним действием».
|
|
test("объединённая cancelFirewallRollback не вернулась", () => {
|
|
expect(firewallSource).not.toContain("export async function cancelFirewallRollback");
|
|
for (const file of ["commands/install.ts", "commands/reconfigure.ts"]) {
|
|
expect(source(file)).not.toContain("cancelFirewallRollback");
|
|
}
|
|
});
|
|
|
|
test("disarm не удаляет резервные копии", () => {
|
|
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
|
|
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
|
|
expect(body).not.toContain("cleanupFirewallBackupFiles");
|
|
expect(body).toContain("stopRollbackGuard");
|
|
});
|
|
|
|
test("cleanup удаляет копии и ничего больше", () => {
|
|
const start = firewallSource.indexOf("export async function cleanupFirewallRollback");
|
|
const body = firewallSource.slice(start, firewallSource.indexOf("export async function rollbackFirewallNow"));
|
|
expect(body).toContain("cleanupFirewallBackupFiles");
|
|
});
|
|
});
|
|
|
|
describe("install: успех фиксируется до уничтожения данных отката", () => {
|
|
const installSource = source("commands/install.ts");
|
|
|
|
test("порядок: disarm -> durable installed -> cleanup", () => {
|
|
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
|
|
const installed = installSource.indexOf('await advanceInstallState(context, ownership, "installed")');
|
|
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
|
|
|
|
expect(disarm).toBeGreaterThan(-1);
|
|
expect(installed).toBeGreaterThan(disarm);
|
|
expect(cleanup).toBeGreaterThan(installed);
|
|
});
|
|
|
|
test("успешный smoke фиксируется отдельной фазой до снятия таймера", () => {
|
|
const smokeOk = installSource.indexOf('await advanceInstallState(context, ownership, "smoke_ok")');
|
|
const disarm = installSource.indexOf("await disarmFirewallRollback(context)");
|
|
|
|
expect(smokeOk).toBeGreaterThan(-1);
|
|
expect(smokeOk).toBeLessThan(disarm);
|
|
});
|
|
|
|
// Уборка после фиксации успеха — мусор в /run, а не причина объявить
|
|
// успешную установку неуспешной.
|
|
test("уборка после фиксации выполняется best-effort", () => {
|
|
const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)");
|
|
const tryAt = installSource.lastIndexOf("try {", cleanup);
|
|
const catchAt = installSource.indexOf("catch (cleanupError)", cleanup);
|
|
|
|
expect(tryAt).toBeGreaterThan(-1);
|
|
expect(tryAt).toBeLessThan(cleanup);
|
|
expect(catchAt).toBeGreaterThan(cleanup);
|
|
});
|
|
});
|
|
|
|
describe("reconfigure: тот же порядок фиксации", () => {
|
|
const reconfigureSource = source("commands/reconfigure.ts");
|
|
|
|
test("порядок: disarm -> durable installed -> cleanup", () => {
|
|
const disarm = reconfigureSource.indexOf("await disarmFirewallRollback(context)");
|
|
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
|
|
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
|
|
|
|
expect(disarm).toBeGreaterThan(-1);
|
|
expect(installed).toBeGreaterThan(disarm);
|
|
expect(cleanup).toBeGreaterThan(installed);
|
|
});
|
|
|
|
test("уборка после фиксации выполняется best-effort", () => {
|
|
expect(reconfigureSource).toContain("catch (cleanupError)");
|
|
const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)");
|
|
const catchAt = reconfigureSource.indexOf("catch (cleanupError)", cleanup);
|
|
expect(catchAt).toBeGreaterThan(cleanup);
|
|
});
|
|
});
|