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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
2026-08-30 19:32:43 +05:00
parent df73459ea5
commit 330a63b050
17 changed files with 1534 additions and 98 deletions
+58 -9
View File
@@ -217,22 +217,71 @@ describe("reconfigure: откат обязателен после операци
// Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком.
test("порядок сохранён: сначала firewall, затем конфиги", () => {
const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const restore = reconfigureSource.indexOf("await rollbackCurrentState()");
const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const stages = reconfigureSource.indexOf("await runRollbackStages(stages)");
expect(firewall).toBeGreaterThan(-1);
expect(firewall).toBeLessThan(restore);
expect(restore).toBeLessThan(stages);
});
// Внутри самого восстановления конфигов дефект был тот же: единственная
// команда без `|| true` отменяла перезапуск сервисов строкой ниже.
test("ни одна команда восстановления конфигов не обрывает следующие", () => {
const start = reconfigureSource.indexOf("async function rollbackCurrentState");
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState"));
const offenders = body
// Инвариант СМЕНИЛСЯ, и это принципиально.
//
// Раньше здесь требовалось, чтобы каждая команда восстановления несла
// `|| true`: тогда это была единственная защита от того, что отказ одной
// команды оборвёт следующие. После появления runRollbackStages непрерывность
// обеспечивает раннер, а `|| true` стал ВРЕДЕН: он не даёт стадии сообщить,
// что восстановление на самом деле не выполнилось, и «restore configuration»
// никогда не попадала в список отказавших.
test("команды восстановления не глушат собственные ошибки", () => {
const start = reconfigureSource.indexOf("function restoreStages(");
const body = reconfigureSource.slice(
start,
reconfigureSource.indexOf("async function rollbackCurrentState")
);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]);
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
test("восстановление разбито на независимые стадии", () => {
for (const stage of [
"restore hysteria config",
"restore systemd units",
"restore runtime env",
"restore nftables files",
"apply restored ruleset",
"reload systemd units",
"restart services"
]) {
expect(reconfigureSource).toContain(`"${stage}"`);
}
});
// Неполное восстановление обязано доходить до итоговой сводки как отказ.
test("частичное восстановление не выдаётся за успешное", () => {
expect(reconfigureSource).toContain("restoreFailures.length > 0");
expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью");
});
});
describe("install: команды отката не глушат собственные ошибки", () => {
const installSource = source("commands/install.ts");
// После появления runRollbackStages `|| true` в стадиях отката перестал быть
// защитой и стал маскировкой: стадия не могла сообщить, что systemctl
// реально ничего не сделал, и обещание «отказавшие стадии перечисляются» для
// них не выполнялось никогда.
test("стадии остановки сервисов не подавляют код возврата", () => {
const start = installSource.indexOf("async function rollbackFailedInstall");
const body = installSource.slice(start, installSource.indexOf("export async function install"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true"));
expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]);
});
});