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:
@@ -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, откат — обязателен.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user