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
+46 -7
View File
@@ -21,7 +21,12 @@ import { deployUi } from "../steps/ui";
import { installHysteria } from "../steps/hysteria";
import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd";
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
import {
applyFirewall,
cleanupFirewallRollback,
disarmFirewallRollback,
rollbackFirewallNow
} from "../steps/firewall";
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
import { diagnosticsCollect } from "./diagnostics";
@@ -42,6 +47,7 @@ type InstallPhase =
| "bootstrap_secret_written"
| "services_started"
| "smoke_running"
| "smoke_ok"
| "smoke_failed"
| "failed"
| "installed";
@@ -244,23 +250,33 @@ async function rollbackFailedInstall(
}
if (ownership.unitsTouched) {
// Без `|| true`, и это осознанно.
//
// Раньше каждая из этих команд глушила собственный код возврата, поэтому
// стадия физически не могла сообщить runRollbackStages, что systemctl
// ничего не сделал. Обещание «отказавшие стадии перечисляются» для них не
// выполнялось никогда.
//
// Непрерывность отката теперь обеспечивает стадийный раннер, а не
// подавление ошибок в каждой команде: отказ остановки не мешает
// выполниться disable, отказ disable — выполниться reset-failed.
stages.push(
{
name: "stop services",
run: async () => {
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`;
}
},
{
name: "disable services",
run: async () => {
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`;
}
},
{
name: "reset failed services",
run: async () => {
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`;
}
}
);
@@ -396,12 +412,35 @@ export async function install(options: InstallOptions): Promise<void> {
phase = "smoke_running";
await smoke(context);
stepDone("smoke checks");
step("finalize firewall rollback guard");
await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard");
await advanceInstallState(context, ownership, "smoke_ok");
phase = "smoke_ok";
// Порядок фиксации успеха.
//
// Раньше здесь стоял один вызов, который снимал таймер автоотката И удалял
// резервные копии firewall, — и стоял он ДО долговечной записи
// `phase: installed`. Отказ этой записи (ENOSPC, EIO, read-only ФС)
// приводил в обработчик ошибки, обязательный откат запускался и сообщал
// «no HY2XS rollback markers found»: единственные данные для отката были
// уничтожены секундой раньше.
//
// Теперь между снятием таймера и удалением копий стоит сама фиксация
// успеха, и до неё откат остаётся выполнимым.
step("disarm firewall rollback timer");
await disarmFirewallRollback(context);
stepDone("disarm firewall rollback timer");
step("mark install successful");
await advanceInstallState(context, ownership, "installed");
stepDone("mark install successful");
// Установка уже зафиксирована. Неудача уборки — мусор в /run, а не причина
// объявить успешную установку неуспешной.
try {
await cleanupFirewallRollback(context);
} catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const failureKind = classifyFailure(ownership, phase);
+209 -34
View File
@@ -14,7 +14,23 @@ import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd";
import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
import {
applyFirewall,
cleanupFirewallRollback,
disarmFirewallRollback,
rollbackFirewallNow
} from "../steps/firewall";
import {
BACKUP_ROOT,
backupDir,
backupFilePath,
backupManifestPath,
buildManifest,
parseManifest,
renderManifest,
storedNameFor,
type BackupManifest
} from "../lib/backupManifest";
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
import { runMutatingVisible } from "../lib/process";
@@ -76,8 +92,12 @@ export function classifyReconfigureFailure(ownership: ReconfigureOwnership): Rec
return "reconfigure_failed";
}
/**
* Ключ операции. Санитизируется так же, как в steps/firewall.ts: он служит
* ИМЕНЕМ КАТАЛОГА резервной копии, а не только полем в маркере.
*/
function operationKey(context: ReconfigureContext): string {
return context.installDate;
return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-");
}
function ownedPaths(context: ReconfigureContext): string[] {
@@ -114,38 +134,164 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l
await persistInstallState(record);
}
async function backupCurrentState(): Promise<void> {
await runMutatingVisible`mkdir -p /etc/hy2xs/backups`;
await runMutatingVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`;
/**
* Файлы, которые reconfigure/repair обязаны сохранить до первой мутации.
*
* Порядок групп задаёт и порядок восстановления: конфиг Hysteria, юниты,
* env-артефакты, файлы nftables.
*/
const BACKUP_GROUPS = [
{ stage: "restore hysteria config", paths: ["/etc/hysteria/config.yaml"] },
{
stage: "restore systemd units",
paths: ["/etc/systemd/system/hy2xs-admin.service", "/etc/systemd/system/hysteria-server.service"]
},
{ stage: "restore runtime env", paths: ["/etc/hy2xs/hy2xs.env", "/etc/hysteria/post-install.env"] },
{ stage: "restore nftables files", paths: ["/etc/nftables.conf", "/etc/nftables.d/hy2xs.nft"] }
] as const;
await runMutatingVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`;
await runMutatingVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`;
await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`;
await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`;
const BACKED_UP_PATHS = BACKUP_GROUPS.flatMap((group) => group.paths);
/**
* Снимает полную резервную копию текущей операции и ДОКАЗЫВАЕТ, что снял.
*
* Копия привязана к op-id: каталог /etc/hy2xs/backups/<op-id>/ и манифест в
* нём. Раньше копии всех операций лежали одним общим набором `*.bak`, и при
* неудачном копировании в операции B откат B восстанавливал файлы, сохранённые
* операцией A, — то есть возвращал сервер в более старое состояние и выглядел
* при этом успешным.
*
* Любая ошибка здесь — отказ ДО первой мутации: у reconfigure ещё ничего не
* изменено, и остановиться дешевле, чем начать менять систему без копии.
*/
async function backupCurrentState(context: ReconfigureContext): Promise<void> {
const opId = operationKey(context);
const dir = backupDir(opId);
await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}`;
await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}/files`;
const files: { path: string; present: boolean }[] = [];
for (const path of BACKED_UP_PATHS) {
const present = await fileExists(path);
files.push({ path, present });
if (!present) {
continue;
}
const target = backupFilePath(opId, storedNameFor(path));
await runMutatingVisible`cp -a ${path} ${target}`;
if (!(await fileExists(target))) {
throw new Error(
`резервная копия ${path} не создана: ${target} отсутствует. ` +
"Reconfigure остановлен до первой мутации: без полной копии откат недостоверен."
);
}
}
const manifest = buildManifest(opId, context.installDate, files);
await writeTextAtomic(backupManifestPath(opId), renderManifest(manifest), {
mode: 0o600,
owner: "root",
group: "root"
});
info(`backup of the current state is complete: ${dir}`);
}
async function rollbackCurrentState(): Promise<void> {
await runMutatingVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`;
await runMutatingVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`;
async function readBackupManifest(opId: string): Promise<BackupManifest> {
const path = backupManifestPath(opId);
if (!(await fileExists(path))) {
throw new Error(`манифест резервной копии не найден: ${path}`);
}
return parseManifest(await readText(path), opId);
}
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`;
await runMutatingVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`;
await runMutatingVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`;
await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
/**
* Стадии восстановления состояния до текущей операции.
*
* Каждая стадия независима и НЕ глушит собственные ошибки. Раньше здесь были
* `cp ... || true`, `nft -f ... || true`, `daemon-reload || true` и
* `restart ... || true`, поэтому высокоуровневая стадия «restore
* configuration» физически не могла попасть в список отказавших: откат всегда
* отчитывался успешным, что бы ни произошло.
*
* Непрерывность обеспечивает стадийный раннер, а не подавление ошибок.
*/
function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] {
const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry]));
const stages: RollbackStage[] = [];
await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
// `|| true` здесь по той же причине, что и у остальных команд отката: без
// него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то
// есть восстановленные из backups unit-файлы так и не были бы применены.
await runMutatingVisible`systemctl daemon-reload || true`;
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`;
for (const group of BACKUP_GROUPS) {
stages.push({
name: group.stage,
run: async () => {
for (const path of group.paths) {
const entry = byPath.get(path);
if (!entry) {
throw new Error(`манифест резервной копии не описывает ${path}`);
}
if (!entry.present) {
// Файла не существовало до операции: восстановление — удаление.
await runMutatingVisible`rm -f ${path}`;
continue;
}
await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`;
}
}
});
}
const entrypoint = byPath.get("/etc/nftables.conf");
stages.push({
name: "apply restored ruleset",
run: async () => {
if (entrypoint?.present) {
await runMutatingVisible`nft -f /etc/nftables.conf`;
return;
}
await runMutatingVisible`nft flush ruleset`;
}
});
stages.push({
name: "reload systemd units",
run: async () => {
await runMutatingVisible`systemctl daemon-reload`;
}
});
stages.push({
name: "restart services",
run: async () => {
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin`;
}
});
return stages;
}
async function rollbackCurrentState(context: ReconfigureContext): Promise<string[]> {
const opId = operationKey(context);
const manifest = await readBackupManifest(opId);
return await runRollbackStages(restoreStages(manifest, opId));
}
/**
* Убирает копии предыдущих операций после долговечной фиксации успеха.
*
* Копия текущей операции остаётся: это состояние «до последнего успешного
* reconfigure», ровно та же семантика, что была у прежнего единственного
* набора `*.bak`, но теперь без смешения операций. Рост каталога при этом
* ограничен — лишние каталоги удаляются.
*/
async function pruneOldBackups(context: ReconfigureContext): Promise<void> {
const opId = operationKey(context);
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type d ! -name ${opId} -exec rm -rf {} +`;
// Наследие общего каталога: одиночные *.bak и *.existed от прежней схемы.
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.bak' -delete`;
await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.existed' -delete`;
}
async function readInstallState(): Promise<InstallStateRecord | null> {
@@ -239,7 +385,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
}
step("backup");
await backupCurrentState();
await backupCurrentState(context);
stepDone("backup");
const ownership = newReconfigureOwnership();
@@ -277,15 +423,35 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
await smoke(context);
stepDone("smoke checks");
await markPhase(context, "smoke_ok");
step("finalize firewall rollback guard");
await cancelFirewallRollback(context);
stepDone("finalize firewall rollback guard");
// Порядок фиксации успеха — тот же, что и в install: снять таймер
// автоотката, ЗАФИКСИРОВАТЬ успех долговечно и только потом удалять данные
// для отката. Раньше единственный вызов снимал таймер и удалял резервные
// копии firewall до записи `installed`, поэтому отказ этой записи приводил
// в обработчик ошибки, где откатывать было уже нечем.
step("disarm firewall rollback timer");
await disarmFirewallRollback(context);
stepDone("disarm firewall rollback timer");
await markPhase(context, "installed");
const finalState = await readInstallState();
if (!finalState?.installed || finalState.phase !== "installed") {
throw new Error("deterministic state violation: reconfigure/repair finished without installed phase");
}
// Операция зафиксирована. Уборка — best effort: невозможность удалить
// временные данные не делает успешный reconfigure неуспешным.
for (const [what, cleanup] of [
["firewall rollback data", () => cleanupFirewallRollback(context)],
["previous state backups", () => pruneOldBackups(context)]
] as const) {
try {
await cleanup();
} catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
info(`cleanup of ${what} failed after a successful reconfigure: ${cleanupMessage}`);
}
}
} catch (error) {
info("reconfigure failed, rollback in progress");
const message = error instanceof Error ? error.message : String(error);
@@ -333,7 +499,16 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
stages.push({
name: "restore configuration",
run: async () => {
await rollbackCurrentState();
// rollbackCurrentState сама разбита на независимые стадии и возвращает
// список отказавших. Здесь он превращается в отказ внешней стадии,
// чтобы итоговая сводка отката не объявила восстановление успешным.
const restoreFailures = await rollbackCurrentState(context);
if (restoreFailures.length > 0) {
throw new Error(
`восстановление состояния выполнено не полностью: ${restoreFailures.join("; ")}. ` +
`Данные для ручного восстановления сохранены в ${backupDir(operationKey(context))}`
);
}
}
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Манифест резервной копии одной операции reconfigure/repair.
*
* Зачем он вообще нужен.
*
* Резервная копия раньше жила в общем каталоге /etc/hy2xs/backups одним
* набором `*.bak`, не привязанным к операции, и снималась так:
*
* cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak || true
*
* Из этого следовали две разные беды.
*
* 1. Отказ копирования игнорировался, и reconfigure шёл менять систему, не имея
* копии, на которую рассчитывает откат.
*
* 2. Копии от РАЗНЫХ операций смешивались. Если reconfigure A успешно записал
* config.yaml.bak, а у reconfigure B копирование упало, то B всё равно менял
* конфигурацию, а его откат восстанавливал файл, сохранённый операцией A. То
* есть сервер возвращался не в состояние «до B», а в более старое — и это
* выглядело как успешный откат.
*
* Манифест закрывает обе: он привязан к op-id и содержит ЯВНУЮ запись о каждом
* файле, включая отсутствовавшие. «Файла не было» становится записанным фактом,
* а не выводом из неудачи `cp`.
*/
export const BACKUP_ROOT = "/etc/hy2xs/backups";
export const BACKUP_MANIFEST_VERSION = 1;
export type BackupEntry = {
/** Путь на сервере, который эта запись описывает. */
path: string;
/** Существовал ли файл на момент снятия копии. */
present: boolean;
/** Имя файла внутри каталога копии; null, если файла не было. */
stored: string | null;
};
export type BackupManifest = {
version: number;
opId: string;
createdAt: string;
entries: BackupEntry[];
};
export function backupDir(opId: string): string {
return `${BACKUP_ROOT}/${opId}`;
}
export function backupManifestPath(opId: string): string {
return `${backupDir(opId)}/manifest.json`;
}
export function backupFilePath(opId: string, stored: string): string {
return `${backupDir(opId)}/files/${stored}`;
}
/**
* Имя файла внутри копии.
*
* Плоское, потому что каталог копии — не зеркало файловой системы, а набор
* артефактов; при этом имя обязано быть однозначным, иначе
* /etc/nftables.conf и /etc/hy2xs/nftables.conf легли бы в одно место.
*/
export function storedNameFor(path: string): string {
const normalized = path.replace(/^\/+/, "").replace(/[^A-Za-z0-9._-]+/g, "_");
return normalized || "unnamed";
}
export function buildManifest(
opId: string,
createdAt: string,
files: readonly { path: string; present: boolean }[]
): BackupManifest {
return {
version: BACKUP_MANIFEST_VERSION,
opId,
createdAt,
entries: files.map((file) => ({
path: file.path,
present: file.present,
stored: file.present ? storedNameFor(file.path) : null
}))
};
}
export function renderManifest(manifest: BackupManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
/**
* Разбор манифеста строгий.
*
* Откат по частично понятому манифесту опаснее отказа: «поле не разобралось,
* будем считать, что файла не было» означает удаление существующего файла.
*/
export function parseManifest(raw: string, expectedOpId: string): BackupManifest {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`манифест резервной копии не разбирается: ${error instanceof Error ? error.message : String(error)}`
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("манифест резервной копии не является объектом");
}
const record = parsed as Record<string, unknown>;
if (record.version !== BACKUP_MANIFEST_VERSION) {
throw new Error(
`неизвестная версия манифеста резервной копии: ${JSON.stringify(record.version)}, ожидалась ${BACKUP_MANIFEST_VERSION}`
);
}
if (record.opId !== expectedOpId) {
throw new Error(
`манифест относится к другой операции: ${JSON.stringify(record.opId)}, ожидалась ${JSON.stringify(expectedOpId)}`
);
}
if (!Array.isArray(record.entries)) {
throw new Error("в манифесте резервной копии нет списка файлов");
}
const entries: BackupEntry[] = record.entries.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error(`запись ${index} манифеста не является объектом`);
}
const entry = item as Record<string, unknown>;
if (typeof entry.path !== "string" || entry.path === "") {
throw new Error(`запись ${index} манифеста не содержит пути`);
}
if (typeof entry.present !== "boolean") {
throw new Error(`запись ${index} манифеста не сообщает, существовал ли файл`);
}
if (entry.present && (typeof entry.stored !== "string" || entry.stored === "")) {
throw new Error(`запись ${index} манифеста объявляет файл существующим, но не называет копию`);
}
return {
path: entry.path,
present: entry.present,
stored: entry.present ? (entry.stored as string) : null
};
});
return {
version: BACKUP_MANIFEST_VERSION,
opId: expectedOpId,
createdAt: typeof record.createdAt === "string" ? record.createdAt : "",
entries
};
}
+20 -1
View File
@@ -79,13 +79,32 @@ export async function ensureDir(
owner?: string;
group?: string;
}
): Promise<void> {
): Promise<{ created: boolean }> {
assertMutationAllowed(`ensureDir(${path})`);
const existed = await dirExists(path);
await mkdir(path, { recursive: true, mode: options.mode });
await chmod(path, options.mode);
if (options.owner && options.group) {
chownByName(path, options.owner, options.group);
}
// Долговечность самой записи каталога в РОДИТЕЛЕ.
//
// writeTextAtomic синхронизирует файл и каталог, в котором файл лежит. Но
// при первой установке /var/lib/hy2xs создаётся прямо сейчас, и запись
// «hy2xs» в /var/lib остаётся несинхронизированной. После потери питания
// возможно состояние, в котором install-state.json и его каталог были
// сброшены на носитель, а сам каталог из /var/lib исчез — то есть маркер
// восстановления пропал целиком.
//
// Синхронизируется только при фактическом создании: на каждом обновлении
// маркера каталог уже существует, и лишний fsync родителя ничего не даёт.
if (!existed) {
await syncDirectory(dirname(path));
}
return { created: !existed };
}
/**
+178 -27
View File
@@ -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);
}
+11
View File
@@ -135,6 +135,17 @@ describe("ensureDir", () => {
expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755");
});
// Долговечность записи каталога в РОДИТЕЛЕ имеет смысл только при
// фактическом создании, поэтому ensureDir обязана его различать: на каждом
// обновлении маркера каталог уже существует, и лишний fsync родителя ничего
// не даёт.
test("сообщает, был ли каталог создан", async () => {
const target = join(dir, "hy2xs");
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(true);
expect((await ensureDir(target, { mode: 0o755 })).created).toBe(false);
});
test("создание каталога проходит через read-only guard", async () => {
enableReadOnlyGuard("the read-only install preflight (PHASE 0)");
await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow(
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
BACKUP_MANIFEST_VERSION,
backupDir,
backupFilePath,
backupManifestPath,
buildManifest,
parseManifest,
renderManifest,
storedNameFor
} from "../src/lib/backupManifest";
/**
* Целостность резервной копии — предпосылка отката, а не его деталь.
*
* Два дефекта, которые здесь закрепляются.
*
* 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять
* систему, не имея копии, на которую рассчитывает откат.
*
* 2. Копии всех операций лежали одним общим набором `*.bak` в
* /etc/hy2xs/backups. Если у операции B копирование падало, её откат
* восстанавливал файл, сохранённый операцией A, — то есть возвращал сервер
* в более старое состояние и выглядел при этом успешным.
*
* Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о
* каждом файле, включая отсутствовавшие.
*/
function source(relativeToSrc: string): string {
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
}
describe("манифест резервной копии", () => {
test("копия каждой операции лежит в своём каталоге", () => {
expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z");
expect(backupDir("a")).not.toBe(backupDir("b"));
});
test("манифест и файлы адресуются внутри каталога операции", () => {
const opId = "op-1";
expect(backupManifestPath(opId)).toStartWith(backupDir(opId));
expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId));
});
// Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf
// не имеют права лечь в один файл копии.
test("разные пути дают разные имена файлов копии", () => {
const names = new Set(
[
"/etc/nftables.conf",
"/etc/hy2xs/nftables.conf",
"/etc/nftables.d/hy2xs.nft",
"/etc/hysteria/config.yaml",
"/etc/systemd/system/hy2xs-admin.service"
].map(storedNameFor)
);
expect(names.size).toBe(5);
});
test("имя файла копии не выходит за пределы каталога", () => {
for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) {
const stored = storedNameFor(path);
expect(stored).not.toContain("/");
expect(stored).not.toContain("../");
}
});
// Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`.
test("отсутствовавший файл записывается явно", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/nftables.d/hy2xs.nft", present: false }
]);
const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft");
expect(missing?.present).toBe(false);
expect(missing?.stored).toBeNull();
const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml");
expect(kept?.present).toBe(true);
expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml"));
});
test("манифест переживает сериализацию без потерь", () => {
const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [
{ path: "/etc/hysteria/config.yaml", present: true },
{ path: "/etc/hy2xs/hy2xs.env", present: false }
]);
expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest);
});
});
describe("разбор манифеста строгий", () => {
const valid = renderManifest(
buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }])
);
// Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого
// копия стала привязанной к op-id.
test("манифест другой операции отклоняется", () => {
expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/);
});
test("непонятная версия отклоняется", () => {
const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99');
expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/);
});
test("битый JSON отклоняется, а не считается пустой копией", () => {
expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/);
});
// «Поле не разобралось, будем считать, что файла не было» означает удаление
// существующего файла при откате. Поэтому отказ, а не умолчание.
test("запись без признака существования отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/);
});
test("существующий файл без имени копии отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/);
});
test("запись без пути отклоняется", () => {
const broken = JSON.stringify({
version: BACKUP_MANIFEST_VERSION,
opId: "op-1",
createdAt: "",
entries: [{ present: false }]
});
expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/);
});
});
describe("reconfigure: копия снимается строго и до первой мутации", () => {
const reconfigureSource = source("commands/reconfigure.ts");
test("копирование не глушит ошибки", () => {
const start = reconfigureSource.indexOf("async function backupCurrentState");
const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `копирование снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата
// одного лишь `cp`.
test("существование копии проверяется после копирования", () => {
expect(reconfigureSource).toContain("резервная копия");
expect(reconfigureSource).toContain("await fileExists(target)");
});
test("копия снимается до первой мутации системы", () => {
const backup = reconfigureSource.indexOf("await backupCurrentState(context)");
const generate = reconfigureSource.indexOf("await generateConfig(context)");
const firewall = reconfigureSource.indexOf("await applyFirewall(context)");
expect(backup).toBeGreaterThan(-1);
expect(backup).toBeLessThan(generate);
expect(backup).toBeLessThan(firewall);
});
test("откат читает манифест текущей операции, а не общий каталог", () => {
expect(reconfigureSource).toContain("readBackupManifest(opId)");
expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)");
// Прежняя схема общего набора *.bak не должна вернуться.
expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak");
// Решение «восстанавливать или удалять» принимается по манифесту, а не по
// маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups
// допустима и проверяется отдельно.
const restoreStart = reconfigureSource.indexOf("function restoreStages(");
const restoreBody = reconfigureSource.slice(
restoreStart,
reconfigureSource.indexOf("async function rollbackCurrentState")
);
expect(restoreBody).not.toContain(".existed");
expect(restoreBody).toContain("entry.present");
});
// Каталог копий не имеет права расти неограниченно, но уборка обязана идти
// ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и
// с firewall.
test("копии предыдущих операций убираются после фиксации успеха", () => {
const installed = reconfigureSource.indexOf('await markPhase(context, "installed")');
const prune = reconfigureSource.indexOf("pruneOldBackups(context)");
expect(installed).toBeGreaterThan(-1);
expect(prune).toBeGreaterThan(installed);
});
});
describe("firewall: копия снимается строго и переживает неудачный откат", () => {
const firewallSource = source("steps/firewall.ts");
test("копирование firewall не глушит ошибки", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"));
expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Маркер `prepared` означает «данные для отката существуют». Пока он
// ставился ДО копирования, это было неправдой.
test("маркер готовности ставится после проверенных копий", () => {
const start = firewallSource.indexOf("async function backupFirewallState");
const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments"));
const copy = body.indexOf("cp -a ${target.path}");
const verify = body.indexOf("await fileExists(backupPath)");
const marker = body.indexOf("touch ${rollbackMarker(opId)}");
expect(copy).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(copy);
expect(marker).toBeGreaterThan(verify);
});
test("восстановление firewall не глушит ошибки cp и nft", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const muted = body
.split(/\r?\n/)
.filter((line) => line.includes("runMutatingVisible`"))
.filter((line) => line.includes("|| true") || line.includes("2>/dev/null"))
// Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ
// восстановления, а нормальное состояние при firewallStagedApply=false.
.filter((line) => !line.includes("systemctl"));
expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]);
});
// Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по
// которым оператор мог бы поднять firewall вручную, уничтожены.
test("резервные копии удаляются только после успешного восстановления", () => {
const start = firewallSource.indexOf("export async function rollbackFirewallNow");
const body = firewallSource.slice(start);
const failures = body.indexOf("if (failures.length > 0)");
const preserved = body.indexOf("manual recovery data preserved at");
const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)");
expect(failures).toBeGreaterThan(-1);
expect(preserved).toBeGreaterThan(failures);
expect(cleanup).toBeGreaterThan(preserved);
});
});
+118
View File
@@ -0,0 +1,118 @@
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);
});
});
+33
View File
@@ -130,6 +130,39 @@ describe("read-only — инвариант doctor, а не свойство те
}
});
// Guard защищает ТОЛЬКО процесс оркестратора. Он физически не способен
// запретить побочный эффект, вызванный HTTP-запросом в другой процесс,
// поэтому граница «что doctor имеет право трогать по сети» держится здесь.
//
// Конкретный риск: успешная machine-auth реального пира заставляет админку
// выполнить UPDATE peer.last_connection_at, то есть диагностика изменила бы
// отображаемое «последнее подключение» bootstrap-пира. Проба с ВАЛИДНЫМ
// паролем поэтому ограничена режимом install; doctor работает в режиме
// reconfigure и до неё не доходит.
test("проба с валидными учётными данными выполняется только при install", () => {
const smokeSource = source("steps/smoke.ts");
const guard = smokeSource.indexOf('if (context.mode === "install")');
const validAuth = smokeSource.indexOf('"auth valid credentials"');
// Именно ЧТЕНИЕ значения пароля, а не проверка наличия строки в файле:
// последняя выполняется и в doctor и ничего наружу не отправляет.
const readsSecret = smokeSource.indexOf("cut -d= -f2-");
expect(guard).toBeGreaterThan(-1);
expect(validAuth).toBeGreaterThan(guard);
expect(readsSecret).toBeGreaterThan(guard);
// Ровно одна такая проба, и она внутри блока install: вторая, добавленная
// мимо условия, вернула бы запись в БД в диагностику.
expect(smokeSource.split('"auth valid credentials"').length - 1).toBe(1);
});
test("doctor работает в режиме, который не запускает пробу успешной авторизации", () => {
const doctorSource = source("commands/doctor.ts");
expect(doctorSource).toContain('mode: "reconfigure"');
expect(doctorSource).not.toContain('mode: "install"');
});
// Поведенческая часть: механизм, на который опирается doctor, действительно
// отказывает любой мутации и называет причину.
test("под guard'ом doctor любая мутация отказывает с его причиной", async () => {
+1 -1
View File
@@ -140,7 +140,7 @@ describe("классификация отказа reconfigure/repair", () => {
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)");
const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)");
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState()");
const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)");
const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)");
expect(catchAt).toBeGreaterThan(diagnosticsAt);
+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([]);
});
});