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:
+132
-13
@@ -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 давали одному
|
||||
|
||||
Reference in New Issue
Block a user