fix(v1): сделать откат неотменяемым, а маркер установки — долговечным

Три дефекта одного класса в failure path install/reconfigure.

1. Запись состояния отказа отменяла откат.

   Обработчик ошибки первым делом писал в install-state фазу отказа обычным
   await и только потом откатывался. Эта запись — mkdir, write и chown в
   /var/lib/hy2xs, то есть она падает ровно там, где откат нужнее всего:
   заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил
   управление наружу, и обязательное восстановление не выполнялось вовсе —
   применённый firewall и развёрнутые сервисы оставались на сервере.

   Необязательная телеметрия состояния стояла перед обязательным
   восстановлением. Для диагностики это уже было закрыто, для записи
   состояния — нет.

2. Откат отменял сам себя.

   Он был написан цепочкой await, а каждая его стадия — systemctl, cp, rm -rf
   и nft, то есть умеет упасть сама. Отказ первой стадии отменял все
   последующие. В reconfigure это означало сервер одновременно с применённым
   сломанным firewall И без восстановленных из /etc/hy2xs/backups конфигов.
   Внутри rollbackCurrentState болезнь та же: единственная команда без
   `|| true` (systemctl daemon-reload) отменяла перезапуск сервисов строкой
   ниже, и восстановленные unit-файлы не применялись.

   Стадии стали независимыми: выполняются все, отказавшие перечисляются в
   журнале, наружу уходит исходная ошибка операции.

3. У маркера установки было два писателя с разными гарантиями.

   install перезаписывал файл на месте (writeText), reconfigure подставлял
   атомарно. Слабейшая гарантия досталась команде, которая этот файл создаёт.
   Перезапись на месте укорачивает файл до нуля и только потом наполняет:
   отказ между этими моментами оставляет половину JSON, который не
   разбирается — reconfigure видит его как отсутствующий, clean-host как
   присутствующий, а хост уже изменён.

   Атомарности при этом мало. rename() без fsync даёт атомарность видимости
   без долговечности: после потери питания ext4 штатно отдаёт по этому пути
   нулевой файл. Для метаданных восстановления это неприемлемо, поэтому
   порядок теперь: права/владелец -> fsync файла -> rename -> fsync каталога.

   Заодно ownership-флаг переименован в stateTouched и взводится ДО записи:
   отказ на chown после успешного write оставлял файл на диске при
   невзведённом флаге, то есть давал fatal_pre_apply («ничего не изменено»)
   при уже существующем маркере установки.

Тесты: rollback-mandatory.test.ts (внедрение отказа в стадию, проводка команд),
atomic-write.test.ts (замена целиком, прежний файл при отказе, отсутствие
временных файлов, права, guard). Приёмка сборки закрепляет порядок шагов
атомарной записи, отсутствие незащищённой записи состояния в обработчиках и
отсутствие отменяемых цепочек в откате.
This commit is contained in:
2026-08-30 18:11:55 +05:00
parent 60a1aea85e
commit e84fdedc4b
13 changed files with 1124 additions and 80 deletions
+76 -26
View File
@@ -1,13 +1,11 @@
import type { InstallContext, InstallOptions } from "../types/context";
import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs";
import { fileExists, readText, writeTextAtomic } from "../lib/fs";
import { runMutatingVisible } from "../lib/process";
import { info, setOperationContext, step, stepDone } from "../lib/log";
import { readPackageValue } from "../lib/packageMeta";
import {
INSTALL_STATE_PATH,
REPAIR_HINT,
buildInstallStateRecord
} from "../lib/installState";
import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState";
import { persistInstallState } from "../lib/installStateWriter";
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import {
ORCHESTRATOR_INSTALL_DIR,
@@ -62,8 +60,18 @@ type InstallPhase =
* "firewall", — недопустимо.
*/
type OperationOwnership = {
/** install-state.json уже создан: сам по себе делает хост изменённым. */
stateWritten: boolean;
/**
* К /var/lib/hy2xs уже могли прикоснуться: сам по себе созданный
* install-state.json делает хост изменённым.
*
* Флаг называется touched, а не written, по той же причине, что и остальные.
* Запись маркера — это три операции (`mkdir`, `write`, `chown`), и отказ
* последней оставляет файл на диске. Пока флаг взводился ПОСЛЕ успешной
* записи, такой отказ давал классификацию `fatal_pre_apply` — «на сервере
* ничего не изменено», — хотя /var/lib/hy2xs/install-state.json уже
* существовал и следующая чистая установка опознавала его как чужую.
*/
stateTouched: boolean;
/**
* Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs,
* symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они
@@ -92,7 +100,7 @@ type FailureKind =
function newOwnership(): OperationOwnership {
return {
stateWritten: false,
stateTouched: false,
bootstrapTouched: false,
depsTouched: false,
filesystemTouched: false,
@@ -145,9 +153,7 @@ async function writeInstallState(
repairHint: phase === "installed" ? undefined : REPAIR_HINT
});
await runMutatingVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644);
await runMutatingVisible`chown root:root ${INSTALL_STATE_PATH}`;
await persistInstallState(record);
}
async function advanceInstallState(
@@ -156,15 +162,18 @@ async function advanceInstallState(
phase: InstallPhase,
lastError = ""
): Promise<void> {
// Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к
// stateTouched. Частично выполненная запись маркера — это уже изменение
// хоста, и классификация обязана исходить из «сюда мы могли влезть».
ownership.stateTouched = true;
await writeInstallState(context, phase, lastError);
ownership.stateWritten = true;
}
/**
* Классификация опирается на то, к чему операция уже могла прикоснуться.
* `fatal_pre_apply` по определению означает «на сервере ничего не изменено»,
* поэтому в него нельзя попасть после ЛЮБОГО взведённого флага — включая
* `stateWritten`: записанный /var/lib/hy2xs/install-state.json это уже
* `stateTouched`: записанный /var/lib/hy2xs/install-state.json это уже
* изменение хоста, которое переживёт неудачную установку.
*
* Порядок веток — от самой поздней стадии к самой ранней: она точнее
@@ -190,7 +199,7 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha
ownership.filesystemTouched ||
ownership.depsTouched ||
ownership.bootstrapTouched ||
ownership.stateWritten
ownership.stateTouched
) {
return "fatal_post_apply";
}
@@ -201,6 +210,12 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha
* Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула
* текущая операция. Иначе неудачный запуск установщика на чужом сервере
* положил бы работающий сервис.
*
* Второй инвариант — стадии независимы. Снятие firewall и остановка сервисов
* чинят разные половины неудачной установки, и отказ первой не имеет права
* отменить вторую: `rollbackFirewallNow` выполняет `systemctl`, `cp` и
* `rm -rf`, то есть умеет упасть сам, а без остановки сервисов на хосте
* остаётся включённый в автозапуск hysteria-server от неудавшейся установки.
*/
async function rollbackFailedInstall(
context: InstallContext,
@@ -217,18 +232,43 @@ async function rollbackFailedInstall(
// которого install-state сохраняется с repair_hint. Снести оркестратор при
// откате означало бы лишить оператора инструмента починки. Полная зачистка —
// это осознанное отдельное действие, tools/legacy/purge-v0.sh.
const stages: RollbackStage[] = [];
if (ownership.firewallTouched) {
await rollbackFirewallNow(context);
stages.push({
name: "firewall",
run: async () => {
await rollbackFirewallNow(context);
}
});
}
if (!ownership.unitsTouched) {
if (ownership.unitsTouched) {
stages.push(
{
name: "stop services",
run: async () => {
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
}
},
{
name: "disable services",
run: async () => {
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
}
},
{
name: "reset failed services",
run: async () => {
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
}
}
);
} else {
info("rollback: systemd units were not deployed by this operation, leaving services untouched");
return;
}
await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
await runRollbackStages(stages);
}
export async function install(options: InstallOptions): Promise<void> {
@@ -373,11 +413,21 @@ export async function install(options: InstallOptions): Promise<void> {
throw error;
}
await advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
// Запись состояния отказа — best effort, откат — обязателен.
//
// На заполненном диске или read-only ФС падает и она: `mkdir`, запись файла
// и `chown` в /var/lib/hy2xs. Пока она стояла обычным await, её собственный
// отказ уносил управление из обработчика наружу, и до rollbackFailedInstall
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
// существует. Это тот же класс, что и с диагностикой ниже.
await persistFailureState(() =>
advanceInstallState(
context,
ownership,
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
`${failureKind}: ${message}`
)
);
// Диагностика — best effort, откат — обязателен.
+42 -9
View File
@@ -8,6 +8,8 @@ import {
buildInstallStateRecord,
type InstallStateRecord
} from "../lib/installState";
import { persistInstallState } from "../lib/installStateWriter";
import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config";
@@ -105,11 +107,11 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l
repairHint: phase === "installed" ? undefined : REPAIR_HINT
});
await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
mode: 0o644,
owner: "root",
group: "root"
});
// Запись идёт тем же путём, что и в install: у маркера установки ровно один
// владелец записи, и гарантии у него не зависят от того, какая команда его
// обновляет. Каталог при этом обеспечивается здесь так же, как при установке:
// repair обязан работать и на хосте, где /var/lib/hy2xs потеряли вручную.
await persistInstallState(record);
}
async function backupCurrentState(): Promise<void> {
@@ -139,7 +141,10 @@ async function rollbackCurrentState(): Promise<void> {
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`;
await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
await runMutatingVisible`systemctl daemon-reload`;
// `|| true` здесь по той же причине, что и у остальных команд отката: без
// него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то
// есть восстановленные из backups unit-файлы так и не были бы применены.
await runMutatingVisible`systemctl daemon-reload || true`;
await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`;
}
@@ -284,7 +289,15 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
} catch (error) {
info("reconfigure failed, rollback in progress");
const message = error instanceof Error ? error.message : String(error);
await markPhase(context, classifyReconfigureFailure(ownership), message);
// Запись состояния отказа — best effort, откат — обязателен.
//
// markPhase пишет в /var/lib/hy2xs и падает ровно там, где откат нужнее
// всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Пока она
// стояла обычным await, её отказ уносил управление наружу мимо обоих
// восстановлений — и снятия firewall, и возврата конфигов из
// /etc/hy2xs/backups.
await persistFailureState(() => markPhase(context, classifyReconfigureFailure(ownership), message));
// Диагностика — best effort, откат — обязателен.
//
@@ -301,10 +314,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`);
}
// Стадии отката независимы. Снятие firewall и возврат конфигов чинят разные
// половины неудачного прохода, и обе выполняются через `systemctl`, `cp` и
// `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой
// `await`, отказ первой отменял вторую: сервер оставался и с применённым
// сломанным firewall, и со сломанными конфигами одновременно.
const stages: RollbackStage[] = [];
if (ownership.firewallTouched) {
await rollbackFirewallNow(context);
stages.push({
name: "firewall",
run: async () => {
await rollbackFirewallNow(context);
}
});
}
await rollbackCurrentState();
stages.push({
name: "restore configuration",
run: async () => {
await rollbackCurrentState();
}
});
await runRollbackStages(stages);
throw error;
}
}
+114 -25
View File
@@ -1,4 +1,5 @@
import { stat } from "node:fs/promises";
import { chmod, mkdir, open, rename, stat, unlink } from "node:fs/promises";
import { dirname } from "node:path";
import { assertMutationAllowed } from "./guard";
async function statSafe(path: string): Promise<import("node:fs").Stats | null> {
@@ -44,44 +45,132 @@ export async function writeText(path: string, data: string, mode?: number): Prom
}
}
/**
* Смена владельца по ИМЕНИ пользователя и группы.
*
* Здесь остаётся внешний `chown`, а не `fs.chown`: последний принимает только
* числовые uid/gid, то есть потребовал бы собственного разбора /etc/passwd и
* /etc/group. Имена — часть контракта установки (`root:root`,
* `hysteria:hy2xs-admin`), и разрешать их обязана система.
*/
function chownByName(target: string, owner: string, group: string): void {
const result = Bun.spawnSync(["chown", `${owner}:${group}`, target], {
stdout: "pipe",
stderr: "pipe"
});
if (!result.success) {
throw new Error(`chown failed for ${target}: ${result.stderr.toString()}`);
}
}
/**
* Каталог с гарантированными правами и владельцем.
*
* Существует ровно ради install-state: `mkdir -p` не меняет права уже
* существующего каталога, а режим при создании ещё и маскируется umask, поэтому
* «создать, если нет» и «права такие, как объявлено» — два разных действия.
*
* Проходит через guard: PHASE 0 не имеет права создать даже пустой каталог.
*/
export async function ensureDir(
path: string,
options: {
mode: number;
owner?: string;
group?: string;
}
): Promise<void> {
assertMutationAllowed(`ensureDir(${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);
}
}
/**
* Запись файла, которая переживает потерю питания.
*
* Атомарность и долговечность — РАЗНЫЕ свойства, и раньше здесь было только
* первое. `Bun.write` + `mv` даёт атомарность видимости: читатель видит либо
* старое содержимое, либо новое, никогда половину. Но данные к моменту
* `rename()` живут в page cache, а сам `rename()` — в незасинхронизированном
* каталоге. После внезапной перезагрузки ext4 штатно отдаёт по этому пути
* нулевой файл или отсутствие файла вовсе.
*
* Для install-state.json это принципиально: он и есть метаданные
* восстановления. Оператор читает его первым, а `repair --allow-partial-state`
* принимает решения по его содержимому. Обрезанный до нуля маркер означает
* потерю единственного описания того, что установка успела сделать с сервером.
*
* Поэтому порядок здесь такой и никакой другой:
*
* 1. запись во временный файл рядом с целью (тот же каталог — rename обязан
* остаться внутри одной файловой системы);
* 2. права и владелец — ДО подстановки, иначе существует окно, в котором файл
* уже виден по целевому пути с чужими правами;
* 3. fsync временного файла — данные и метаданные на носителе;
* 4. rename — атомарная подстановка;
* 5. fsync каталога — сама запись каталога о новом имени.
*
* Шаг 5 на win32 пропускается: открыть каталог как файл там нельзя. Оркестратор
* на Windows не выполняется (assertPlatform требует Debian), а тесты обязаны
* идти и на машине разработчика.
*/
export async function writeTextAtomic(
path: string,
data: string,
options: {
mode: number;
owner: string;
group: string;
owner?: string;
group?: string;
}
): Promise<void> {
assertMutationAllowed(`writeTextAtomic(${path})`);
const dir = path.replace(/\/[^/]+$/, "") || ".";
const base = path.split("/").pop() || "tmp";
const dir = dirname(path);
const base = path.split(/[/\\]/).pop() || "tmp";
const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await Bun.write(tmp, data);
let renamed = false;
try {
// "wx" — отказ, если файл уже существует: имя случайное, и совпадение
// означало бы чужой файл, а не наш прошлый заход.
const handle = await open(tmp, "wx", options.mode);
try {
await handle.writeFile(data, "utf8");
// Режим при open маскируется umask, поэтому объявленные права
// выставляются явно — по дескриптору, а не по имени.
await handle.chmod(options.mode);
if (options.owner && options.group) {
chownByName(tmp, options.owner, options.group);
}
await handle.sync();
} finally {
await handle.close();
}
const chmodResult = Bun.spawnSync(["chmod", options.mode.toString(8), tmp], {
stdout: "pipe",
stderr: "pipe"
});
if (!chmodResult.success) {
throw new Error(`chmod failed for ${tmp}: ${chmodResult.stderr.toString()}`);
await rename(tmp, path);
renamed = true;
} finally {
if (!renamed) {
// Временный файл не имеет права пережить неудачную запись: каталог
// install-state читается диагностикой и purge как набор наших файлов.
await unlink(tmp).catch(() => undefined);
}
}
const chownResult = Bun.spawnSync(["chown", `${options.owner}:${options.group}`, tmp], {
stdout: "pipe",
stderr: "pipe"
});
if (!chownResult.success) {
throw new Error(`chown failed for ${tmp}: ${chownResult.stderr.toString()}`);
}
await syncDirectory(dir);
}
const mvResult = Bun.spawnSync(["mv", "-f", tmp, path], {
stdout: "pipe",
stderr: "pipe"
});
if (!mvResult.success) {
throw new Error(`atomic rename failed for ${path}: ${mvResult.stderr.toString()}`);
async function syncDirectory(dir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
const handle = await open(dir, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../config/profi
* Без них reconfigure/repair не отличают v1 от произвольного старого маркера.
*/
export const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
export const INSTALL_STATE_DIR = "/var/lib/hy2xs";
export const INSTALL_STATE_PATH = `${INSTALL_STATE_DIR}/install-state.json`;
export const HY2XS_PRODUCT_ID = "hy2xs";
@@ -0,0 +1,35 @@
import { ensureDir, writeTextAtomic } from "./fs";
import { INSTALL_STATE_DIR, INSTALL_STATE_PATH, type InstallStateRecord } from "./installState";
/**
* Единственный способ записать маркер установки на диск.
*
* Раньше их было два. `install` писал через `writeText` — обычная перезапись
* файла на месте, — а `reconfigure` через `writeTextAtomic`. Один и тот же файл,
* две разные гарантии, причём слабейшая досталась команде, которая создаёт этот
* файл впервые и после которой он и становится метаданными восстановления.
*
* Расхождение стоило дороже, чем выглядит. Перезапись на месте укорачивает файл
* до нуля и только потом наполняет: любой отказ между этими моментами — потеря
* питания, kill -9, ENOSPC — оставляет на сервере половину JSON:
*
* {
* "product": "hy2xs",
* "release_line":
*
* Такой маркер не разбирается, поэтому reconfigure/repair видят его как
* отсутствующий, а clean-host — как присутствующий. Установка при этом уже
* изменила хост.
*
* Модуль отделён от installState.ts намеренно: тот остаётся чистым (структура
* записи и проверка поколения) и разбирается юнит-тестами без файловой системы
* и подпроцессов.
*/
export async function persistInstallState(record: InstallStateRecord): Promise<void> {
await ensureDir(INSTALL_STATE_DIR, { mode: 0o755, owner: "root", group: "root" });
await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, {
mode: 0o644,
owner: "root",
group: "root"
});
}
+101
View File
@@ -0,0 +1,101 @@
import { info } from "./log";
/**
* Откат неудачной операции.
*
* Инвариант, ради которого существует модуль:
*
* после операционного отказа откат выполняется ЦЕЛИКОМ, и ни один его шаг
* не может отменить остальные.
*
* Что было. Откат был написан обычной цепочкой `await`:
*
* if (ownership.firewallTouched) {
* await rollbackFirewallNow(context);
* }
* await rollbackCurrentState();
*
* Каждый шаг отката — это `systemctl`, `cp`, `rm -rf` и `nft` через
* `runMutatingVisible`, который бросает на ненулевом коде возврата. То есть
* отказ ПЕРВОГО шага отменял все последующие. В reconfigure это означало
* буквально: сервер остаётся с применённым сломанным firewall И без
* восстановленных из /etc/hy2xs/backups конфигов — то есть худший сценарий
* отказа лишался обеих половин восстановления сразу.
*
* Это тот же класс ошибки, который уже закрыт для диагностики («диагностика —
* best effort, откат — обязателен»), просто применённый на уровень ниже: внутри
* самого отката шаги тоже не имеют права зависеть друг от друга.
*
* Порядок при этом сохраняется: шаги идут последовательно и в объявленном
* порядке. Независимость означает «отказ не прерывает», а не «выполняется
* как попало».
*/
export type RollbackStage = {
/** Имя для журнала: оператор читает его первым при разборе неудачи. */
name: string;
run: () => Promise<void>;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Выполняет ВСЕ стадии отката и возвращает список отказавших.
*
* Возврат, а не бросок: вызывающий обязан после отката пробросить ИСХОДНУЮ
* ошибку операции. Ошибка внутри отката — это дополнительная информация о том,
* что именно осталось не восстановленным, а не замена причины отказа.
*/
export async function runRollbackStages(stages: readonly RollbackStage[]): Promise<string[]> {
const failures: string[] = [];
for (const stage of stages) {
try {
await stage.run();
} catch (error) {
const message = errorMessage(error);
failures.push(`${stage.name}: ${message}`);
info(`rollback stage "${stage.name}" failed, continuing with the remaining stages: ${message}`);
}
}
if (failures.length === 0) {
info(`rollback completed: ${stages.length} stage(s) succeeded`);
return failures;
}
info(`rollback finished with ${failures.length} failed stage(s); manual recovery may be required:`);
for (const failure of failures) {
info(` - ${failure}`);
}
return failures;
}
/**
* Запись состояния отказа — best effort, ровно как сбор диагностики.
*
* Что было. И `install`, и `reconfigure` в обработчике ошибки первым делом
* писали в маркер фазу отказа обычным `await`, и только потом откатывались.
* Запись этого файла — это `mkdir`, `write` и `chown` в /var/lib/hy2xs, то есть
* она умеет упасть сама: заполненный диск, ФС в read-only, ошибка ввода-вывода.
*
* И падала она ровно в тех сценариях, ради которых откат и существует. Дальше
* бросок из обработчика уносил управление наружу, и обязательное
* восстановление — снятие применённого firewall, остановка развёрнутых
* сервисов, возврат конфигов — не выполнялось вовсе.
*
* То есть НЕОБЯЗАТЕЛЬНАЯ телеметрия состояния стояла перед ОБЯЗАТЕЛЬНЫМ
* восстановлением и умела его отменить. Состояние отказа полезно оператору, но
* оно описывает сервер, а откат его чинит; при выборе между «записать, что всё
* плохо» и «сделать, чтобы стало хорошо» продукт обязан выбирать второе.
*/
export async function persistFailureState(write: () => Promise<void>): Promise<void> {
try {
await write();
} catch (error) {
info(
`failed to persist failure state, continuing with the mandatory rollback: ${errorMessage(error)}`
);
}
}