330a63b050
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его substrate этой надёжности не соответствует: откат
гарантированно запускался, но отдельные его шаги могли молча не выполнить
восстановление, отчитаться успехом и уничтожить резервную копию.
1. Данные для отката уничтожались ДО фиксации успеха (commit ordering).
cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии
firewall, а вызывалась до долговечной записи phase=installed. Отказ этой
записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный
откат честно запускался и сообщал "no HY2XS rollback markers found":
откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий,
который прошлый проход специально сделал безопасным.
Разделено на disarmFirewallRollback (снять таймер, копии оставить) и
cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure:
smoke_ok -> disarm -> durable installed -> cleanup best-effort.
2. Резервные копии снимались без доказательства.
И firewall, и reconfigure копировали как `cp ... || true`: отказ
игнорировался, операция шла менять систему без копии, на которую
рассчитывает откат. У firewall маркер prepared («данные для отката
существуют») выставлялся вообще ДО копирования. Копирование строгое, факт
создания проверяется, маркер ставится после.
3. Копии reconfigure смешивались между операциями.
Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у
операции B копирование падало, B всё равно менял систему, а его откат
восстанавливал файлы операции A — сервер возвращался в более старое
состояние и это выглядело успешным откатом. Копия стала операционной:
/etc/hy2xs/backups/<op-id>/ с манифестом, где отсутствие файла записано
явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
включая проверку opId.
4. Ошибка восстановления скрывалась, и после неё копии удалялись.
rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
стадия успешна, данные для ручной починки уничтожены. Теперь копии
удаляются только после подтверждённого успеха, иначе сохраняются с
сообщением manual recovery data preserved at ...
5. Команды отката глушили собственный код возврата.
До стадийного раннера `|| true` был единственной защитой от обрыва цепочки;
после его появления стал маскировкой — стадия не могла сообщить, что
ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых
стадий.
6. Долговечность записи каталога маркера.
writeTextAtomic синхронизирует файл и его каталог, но при первой установке
/var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась
несинхронизированной. ensureDir сообщает о фактическом создании и
синхронизирует родителя только тогда.
Отдельно про doctor. Утверждение аудита, что doctor вызывает
UpdatePeerLastConnectionAt через успешную machine-auth, кодом не
подтверждается: проба с действующим паролем ограничена `context.mode ===
"install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не
охранялся — добавлены тест и приёмка. Документация уточнена: guard действует
внутри процесса, а границу «что doctor шлёт по сети» держит состав проб;
единственный остающийся след — записи в журнале админки, и это сказано прямо.
Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации,
сохранение копий при неудачном восстановлении), commit-ordering.test.ts
(disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста,
закреплявших прежний инвариант «каждая команда отката несёт || true»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
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("stopRollbackTimer");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|