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:
@@ -2,6 +2,7 @@ import type { RuntimeContext } from "../types/context";
|
||||
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { fail, info } from "../lib/log";
|
||||
import { runMutatingVisible } from "../lib/process";
|
||||
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||
|
||||
type NftEntrypointKind =
|
||||
| "missing"
|
||||
@@ -41,6 +42,60 @@ async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
|
||||
await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Файлы firewall, которые операция обязана сохранить до первой мутации.
|
||||
*
|
||||
* Список явный: «скопировать всё, что найдём» и «доказать, что скопировали
|
||||
* именно то, что нужно» — разные утверждения, и rollback опирается на второе.
|
||||
*/
|
||||
const FIREWALL_BACKUP_TARGETS = [
|
||||
{ path: "/etc/nftables.conf", backup: "nftables.conf.bak", marker: "nftables.conf.existed" },
|
||||
{ path: "/etc/nftables.d/hy2xs.nft", backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" }
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял.
|
||||
*
|
||||
* Что было:
|
||||
*
|
||||
* cp -a /etc/nftables.conf <backup> 2>/dev/null || true
|
||||
*
|
||||
* то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права)
|
||||
* молча игнорировался. Дальше выставлялся маркер `prepared`, и операция
|
||||
* начинала переписывать firewall — уже НЕ имея резервной копии, на которую
|
||||
* рассчитывает откат. Предпосылка отката нарушалась в самом его основании.
|
||||
*
|
||||
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
|
||||
* означает «данные для отката существуют», и раньше это было неправдой.
|
||||
*/
|
||||
async function backupFirewallState(opId: string): Promise<void> {
|
||||
await ensureRollbackRoot(opId);
|
||||
|
||||
for (const target of FIREWALL_BACKUP_TARGETS) {
|
||||
const backupPath = rollbackBackup(target.backup, opId);
|
||||
const markerPath = rollbackBackup(target.marker, opId);
|
||||
|
||||
if (!(await fileExists(target.path))) {
|
||||
// Отсутствие файла — законное состояние, но оно обязано быть ЗАПИСАНО, а
|
||||
// не выведено из неудачи копирования: откат по этому маркеру решает,
|
||||
// восстанавливать файл или удалять его.
|
||||
await runMutatingVisible`rm -f ${markerPath} ${backupPath}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
await runMutatingVisible`cp -a ${target.path} ${backupPath}`;
|
||||
if (!(await fileExists(backupPath))) {
|
||||
fail(
|
||||
`firewall backup was not created for ${target.path}: ${backupPath} is missing. ` +
|
||||
"Отказ до первой мутации firewall: без резервной копии откат невозможен."
|
||||
);
|
||||
}
|
||||
await runMutatingVisible`printf 1 > ${markerPath}`;
|
||||
}
|
||||
|
||||
await runMutatingVisible`touch ${rollbackMarker(opId)}`;
|
||||
}
|
||||
|
||||
function stripNftComments(content: string): string {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
@@ -125,12 +180,9 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
||||
fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off");
|
||||
}
|
||||
|
||||
await ensureRollbackRoot(opId);
|
||||
await runMutatingVisible`touch ${rollbackMarker(opId)}`;
|
||||
await runMutatingVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`;
|
||||
await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`;
|
||||
await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`;
|
||||
await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`;
|
||||
// Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
|
||||
await backupFirewallState(opId);
|
||||
|
||||
await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600);
|
||||
await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
|
||||
|
||||
@@ -171,24 +223,90 @@ include "/etc/nftables.d/hy2xs.nft"
|
||||
info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks");
|
||||
}
|
||||
|
||||
export async function cancelFirewallRollback(context: RuntimeContext): Promise<void> {
|
||||
const opId = operationKey(context);
|
||||
if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.config.firewallStagedApply) {
|
||||
const unit = rollbackUnit(opId);
|
||||
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
|
||||
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
|
||||
}
|
||||
|
||||
await cleanupFirewallBackupFiles(opId);
|
||||
function firewallRollbackIsInactive(context: RuntimeContext): boolean {
|
||||
return (
|
||||
context.options.skipFirewall ||
|
||||
context.config.firewallMode === "off" ||
|
||||
context.config.firewallMode === "external"
|
||||
);
|
||||
}
|
||||
|
||||
async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise<void> {
|
||||
if (!context.config.firewallStagedApply) {
|
||||
return;
|
||||
}
|
||||
const unit = rollbackUnit(opId);
|
||||
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
|
||||
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает автоматический откат по таймеру, НО сохраняет резервные копии.
|
||||
*
|
||||
* Разделение на disarm и cleanup — исправление ошибки порядка фиксации.
|
||||
* Единая `cancelFirewallRollback` делала и то и другое, а вызывалась ДО
|
||||
* долговечной записи `phase: installed`. Получался разрыв:
|
||||
*
|
||||
* smoke PASS
|
||||
* -> таймер снят, резервные копии УДАЛЕНЫ
|
||||
* -> запись "installed" падает (ENOSPC/EIO/read-only)
|
||||
* -> catch -> обязательный откат
|
||||
* -> "no HY2XS rollback markers found"
|
||||
*
|
||||
* То есть ровно тот отказ записи маркера, который был специально сделан
|
||||
* безопасным, случался после уничтожения единственных данных для отката.
|
||||
* Откат запускался, но откатывать ему было нечем.
|
||||
*
|
||||
* Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё
|
||||
* ручное восстановление остаётся возможным.
|
||||
*/
|
||||
export async function disarmFirewallRollback(context: RuntimeContext): Promise<void> {
|
||||
if (firewallRollbackIsInactive(context)) {
|
||||
return;
|
||||
}
|
||||
await stopRollbackTimer(context, operationKey(context));
|
||||
info("firewall rollback timer disarmed; backups are kept until the installation is durably committed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет резервные копии firewall. Вызывается ТОЛЬКО после долговечной
|
||||
* фиксации успеха операции.
|
||||
*
|
||||
* Неудача здесь — мусор в /run, а не причина объявить успешную установку
|
||||
* неуспешной, поэтому вызывающий выполняет её best-effort.
|
||||
*/
|
||||
export async function cleanupFirewallRollback(context: RuntimeContext): Promise<void> {
|
||||
if (firewallRollbackIsInactive(context)) {
|
||||
return;
|
||||
}
|
||||
await cleanupFirewallBackupFiles(operationKey(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Немедленное восстановление firewall.
|
||||
*
|
||||
* Два правила, которых здесь раньше не было.
|
||||
*
|
||||
* 1. Ошибки восстановления НЕ скрываются. Было:
|
||||
*
|
||||
* cp <backup> /etc/nftables.conf 2>/dev/null || true
|
||||
* nft -f /etc/nftables.conf >/dev/null 2>&1 || true
|
||||
*
|
||||
* то есть неудача копирования или применения правил давала функции
|
||||
* завершиться успешно, и стадия отката отчитывалась как выполненная.
|
||||
*
|
||||
* 2. Резервные копии удаляются ТОЛЬКО после подтверждённого восстановления.
|
||||
* Было — безусловный `rm -rf` в конце: худшая комбинация, при которой
|
||||
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
|
||||
* поднять firewall вручную, уничтожены.
|
||||
*
|
||||
* Стадии независимы: файл nftables.conf и файл hy2xs.nft восстанавливаются
|
||||
* порознь, применение правил выполняется в любом случае — частично
|
||||
* восстановленный firewall лучше полностью отсутствующего.
|
||||
*/
|
||||
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
|
||||
const opId = operationKey(context);
|
||||
if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") {
|
||||
if (firewallRollbackIsInactive(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,14 +315,47 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.config.firewallStagedApply) {
|
||||
const unit = rollbackUnit(opId);
|
||||
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
|
||||
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
|
||||
await stopRollbackTimer(context, opId);
|
||||
|
||||
const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
|
||||
const stages: RollbackStage[] = [];
|
||||
|
||||
for (const target of FIREWALL_BACKUP_TARGETS) {
|
||||
const backupPath = rollbackBackup(target.backup, opId);
|
||||
const markerPath = rollbackBackup(target.marker, opId);
|
||||
|
||||
stages.push({
|
||||
name: `restore ${target.path}`,
|
||||
run: async () => {
|
||||
if (await fileExists(markerPath)) {
|
||||
await runMutatingVisible`cp -a ${backupPath} ${target.path}`;
|
||||
return;
|
||||
}
|
||||
// Файла не было до операции — восстановление означает его удаление.
|
||||
await runMutatingVisible`rm -f ${target.path}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stages.push({
|
||||
name: "apply restored ruleset",
|
||||
run: async () => {
|
||||
if (entrypointExisted) {
|
||||
await runMutatingVisible`nft -f /etc/nftables.conf`;
|
||||
return;
|
||||
}
|
||||
await runMutatingVisible`nft flush ruleset`;
|
||||
}
|
||||
});
|
||||
|
||||
const failures = await runRollbackStages(stages);
|
||||
|
||||
if (failures.length > 0) {
|
||||
info(
|
||||
`firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`;
|
||||
await runMutatingVisible`if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
|
||||
await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi`;
|
||||
await cleanupFirewallBackupFiles(opId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user