Files
HY2XS_flamy/orchestrator/test/commit-ordering.test.ts
T
founder 330a63b050 fix(v1): сделать надёжным нижний слой отката, а не только его запуск
Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
2026-08-30 19:32:43 +05:00

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);
});
});