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
+132 -13
View File
@@ -969,22 +969,141 @@ run_single_owner_acceptance() {
grep -q 'await runRollbackStages(stages)' "$rollback_command" \
|| fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой"
done
# Внутри восстановления конфигов дефект был тот же: единственная команда без
# `|| true` отменяла перезапуск сервисов строкой ниже.
# Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки.
#
# Инвариант здесь обратный тому, что стоял раньше. До появления
# runRollbackStages каждая команда несла `|| true` — это была единственная
# защита от того, что отказ одной оборвёт следующие. Теперь непрерывность
# обеспечивает стадийный раннер, а `|| true` стал маскировкой: стадия не
# могла сообщить, что восстановление на самом деле не выполнилось, и
# «restore configuration» никогда не попадала в список отказавших.
"$BUN_BIN" -e '
const fs = require("node:fs");
const checks = [
["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"],
["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"],
["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"],
["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "function stripNftComments"]
];
for (const [file, from, to] of checks) {
const source = fs.readFileSync(file, "utf8");
const start = source.indexOf(from);
const end = source.indexOf(to);
if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file);
const muted = source.slice(start, end).split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
if (muted.length) {
throw new Error(from + " в " + file + " скрывает ошибки:\n" + muted.join("\n"));
}
}
' || fail "acceptance: команды отката и резервного копирования обязаны сообщать о своих отказах"
log_step "Acceptance: rollback artifacts outlive the durable commit"
# Ошибка порядка фиксации. cancelFirewallRollback снимала таймер И удаляла
# резервные копии, а вызывалась ДО долговечной записи `phase: installed`.
# Отказ этой записи приводил в обработчик ошибки, обязательный откат честно
# запускался и сообщал «no HY2XS rollback markers found»: откатывать было
# уже нечем.
grep -q 'export async function disarmFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий"
grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию"
! grep -rq 'cancelFirewallRollback' orchestrator/src \
|| fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха"
"$BUN_BIN" -e '
const fs = require("node:fs");
for (const [file, installed] of [
["orchestrator/src/commands/install.ts", "await advanceInstallState(context, ownership, \"installed\")"],
["orchestrator/src/commands/reconfigure.ts", "await markPhase(context, \"installed\")"]
]) {
const source = fs.readFileSync(file, "utf8");
const disarm = source.indexOf("disarmFirewallRollback(context)");
const commit = source.indexOf(installed);
const cleanup = source.indexOf("cleanupFirewallRollback(context)");
if (disarm < 0 || commit < 0 || cleanup < 0) throw new Error("не найдены шаги фиксации в " + file);
if (!(disarm < commit && commit < cleanup)) {
throw new Error("порядок обязан быть disarm -> durable installed -> cleanup в " + file);
}
}
' || fail "acceptance: данные отката обязаны переживать долговечную фиксацию успеха"
# Восстановление firewall не имеет права удалить копии, не восстановив.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("export async function rollbackFirewallNow"));
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
if (failures < 0 || preserved < 0 || cleanup < 0) throw new Error("не найдены ветки восстановления");
if (!(failures < preserved && preserved < cleanup)) {
throw new Error("резервные копии удаляются раньше проверки успеха восстановления");
}
' || fail "acceptance: копии удаляются только после подтверждённого восстановления firewall"
log_step "Acceptance: reconfigure backups are operation-scoped and proven"
# Копии всех операций лежали одним общим набором *.bak. При неудачном
# копировании в операции B её откат восстанавливал файлы, сохранённые
# операцией A, — возвращал сервер в более старое состояние и выглядел
# успешным.
[ -f orchestrator/src/lib/backupManifest.ts ] \
|| fail "acceptance: манифест резервной копии отсутствует"
grep -q 'backupDir(opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: копия reconfigure снова не привязана к операции"
grep -q 'parseManifest(await readText(path), opId)' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: откат читает копию без проверки принадлежности операции"
! grep -qF '/etc/hy2xs/backups/config.yaml.bak' orchestrator/src/commands/reconfigure.ts \
|| fail "acceptance: вернулся общий набор *.bak, смешивающий операции"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8");
const start = source.indexOf("async function rollbackCurrentState");
if (start < 0) throw new Error("rollbackCurrentState отсутствует");
const rest = source.slice(start + 1);
const end = rest.search(/\n(export )?(async )?function /);
const body = end < 0 ? rest : rest.slice(0, end);
const offenders = body.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => !line.includes("|| true") && !line.includes("; fi`"));
if (offenders.length) {
throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n"));
const backup = source.indexOf("await backupCurrentState(context)");
for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) {
const at = source.indexOf(mutation);
if (at < 0) throw new Error("не найден шаг " + mutation);
if (backup > at) throw new Error("копия снимается после мутации " + mutation);
}
' || fail "acceptance: ни одна команда отката не имеет права отменить остальные"
const verify = source.indexOf("await fileExists(target)");
if (verify < 0) throw new Error("создание копии не проверяется");
' || fail "acceptance: полная проверенная копия обязана предшествовать первой мутации"
log_step "Acceptance: the install-state directory entry is durable too"
# writeTextAtomic синхронизирует файл и его каталог. Но при первой установке
# /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib
# остаётся несинхронизированной: после потери питания мог исчезнуть весь
# каталог вместе с маркером.
grep -q 'return { created: !existed }' orchestrator/src/lib/fs.ts \
|| fail "acceptance: ensureDir не сообщает о фактическом создании каталога"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8");
const start = source.indexOf("export async function ensureDir");
const body = source.slice(start, source.indexOf("export async function writeTextAtomic"));
if (!/if \(!existed\) \{\s*\n\s*await syncDirectory\(dirname\(path\)\)/.test(body)) {
throw new Error("родительский каталог не синхронизируется при создании");
}
' || fail "acceptance: создание каталога маркера обязано быть долговечным"
log_step "Acceptance: doctor never triggers a peer state write over HTTP"
# Guard защищает только процесс оркестратора и не способен запретить побочный
# эффект в другом процессе. Успешная machine-auth заставляет админку
# выполнить UPDATE peer.last_connection_at, поэтому проба с ВАЛИДНЫМ паролем
# ограничена режимом install, а doctor работает в режиме reconfigure.
"$BUN_BIN" -e '
const fs = require("node:fs");
const smoke = fs.readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const guard = smoke.indexOf("if (context.mode === \"install\")");
const validAuth = smoke.indexOf("\"auth valid credentials\"");
const readsSecret = smoke.indexOf("cut -d= -f2-");
if (guard < 0 || validAuth < 0 || readsSecret < 0) throw new Error("не найдена проба успешной авторизации");
if (validAuth < guard || readsSecret < guard) {
throw new Error("проба с валидными учётными данными вышла за пределы режима install");
}
if (smoke.split("\"auth valid credentials\"").length - 1 !== 1) {
throw new Error("проб успешной авторизации больше одной");
}
const doctor = fs.readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
if (!doctor.includes("mode: \"reconfigure\"") || doctor.includes("mode: \"install\"")) {
throw new Error("doctor работает в режиме, который запускает пробу успешной авторизации");
}
' || fail "acceptance: диагностика не имеет права менять состояние пира через HTTP"
log_step "Acceptance: the install-state marker has exactly one durable writer"
# Раньше writeText в install и writeTextAtomic в reconfigure давали одному