fix(orchestrator): сериализовать операции жизненного цикла

У оркестратора не было никакой блокировки операций: ни flock, ни mutex, ни
lockfile. Вся архитектура отката при этом опиралась на невысказанное допущение,
что в каждый момент выполняется ровно одна операция HY2XS.

install-state.json замком не является — это запись о состоянии, а не право на
изменение. Два одновременных reconfigure спокойно доходили до конца каждый
по-своему, и уникальные op-id не спасали: они разделяют резервные копии, но
production paths общие — /etc/hysteria/config.yaml, unit-файлы,
/etc/nftables.conf, install-state.json. Дальше любая из операций могла упасть и
"восстановить" состояние поверх изменений другой, отчитавшись при этом полным
успехом: со своим манифестом она действительно сверилась. Отдельно опасен
firewall: обе операции независимо взводят транзиентные rollback-юниты, и guard
одной способен снять правила другой.

Введён эксклюзивный замок /run/lock/hy2xs-orchestrator.lock через атомарное
создание с O_EXCL. Не flock(2): прямого биндинга в рантайме нет, а держать
замок подпроцессом означало бы сторожевой процесс на каждую операцию.

- install/reconfigure/repair берут замок как мутирующие;
- doctor тоже: диагностика в середине транзакции описывает промежуточное
  состояние и выдаёт бессмысленные ошибки;
- status и diagnostics collect замок НЕ берут — они нужны в том числе во время
  долгой операции, — но сообщают, что операция идёт;
- preflight-install отказывает сразу, до exec в install.sh.

Замок снимается в finally, а также на SIGINT/SIGTERM/SIGHUP и при выходе
процесса: обрыв SSH не имеет права заблокировать сервер до перезагрузки.
Замок мёртвого держателя переиспользуется, но только через увод файла
переименованием со сверкой nonce — снимать его на месте означало бы риск снять
живой. Непонятое содержимое не снимается автоматически: оно не доказывает
отсутствие операции, и сомнение трактуется в пользу отказа.
This commit is contained in:
2026-08-30 22:59:50 +05:00
parent 4e7f54b9ff
commit d72550e11f
4 changed files with 840 additions and 10 deletions
+49 -5
View File
@@ -5,6 +5,11 @@ import { doctor } from "./commands/doctor";
import { status } from "./commands/status"; import { status } from "./commands/status";
import { diagnosticsCollect } from "./commands/diagnostics"; import { diagnosticsCollect } from "./commands/diagnostics";
import { redactConfig, type RedactConfigFormat } from "./commands/redact-config"; import { redactConfig, type RedactConfigFormat } from "./commands/redact-config";
import {
assertNoOperationInProgress,
describeOperationInProgress,
withOperationLock
} from "./lib/operationLock";
import type { InstallOptions, ReconfigureOptions } from "./types/context"; import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never { function usage(): never {
@@ -19,6 +24,7 @@ function usage(): never {
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator redact-config --config <path> [--in-place | --out <path>] [--format auto|env|yaml]"); console.error(" hy2xs-orchestrator redact-config --config <path> [--in-place | --out <path>] [--format auto|env|yaml]");
console.error(" note: --skip-start is deprecated alias for --skip-service-start --skip-smoke"); console.error(" note: --skip-start is deprecated alias for --skip-service-start --skip-smoke");
console.error(" note: install/reconfigure/repair/doctor are serialized by /run/lock/hy2xs-orchestrator.lock");
process.exit(2); process.exit(2);
} }
@@ -259,14 +265,45 @@ function parsePreflightInstallOptions(args: string[]): InstallOptions {
return options; return options;
} }
/**
* Политика взаимного исключения операций собрана здесь целиком и намеренно.
*
* Замок берётся ДО входа в команду — то есть до того, как `doctor` и
* `preflight-install` включают read-only guard. Это не обход контракта, а его
* соблюдение: замок живёт в /run/lock, это ephemeral runtime state, а не
* persistent path, и `acquireOperationLock` дополнительно спрашивает разрешения
* у guard'а, чтобы перенос захвата внутрь читающей фазы отказал громко.
*
* Кто берёт эксклюзивный замок:
*
* install, reconfigure, repair — мутируют production paths;
* doctor — не мутирует, но обязан быть исключён против
* reconfigure: диагностика в середине
* транзакции описывает промежуточное
* состояние и выдаёт бессмысленные ошибки.
*
* Кто замок НЕ берёт:
*
* status, diagnostics collect — существуют в том числе для того, чтобы
* посмотреть на сервер во время долгой
* операции. Но сообщают, что операция идёт;
* preflight-install — читающая PHASE 0. Отказывает сразу, если
* операция уже выполняется: иначе install.sh
* потратил бы время на проверки и получил
* отказ уже после exec;
* redact-config — работает с файлом, а не с сервером.
*/
async function main(): Promise<void> { async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2); const [command, ...args] = Bun.argv.slice(2);
if (command === "preflight-install") { if (command === "preflight-install") {
await preflightInstall(parsePreflightInstallOptions(args)); const options = parsePreflightInstallOptions(args);
await assertNoOperationInProgress("install preflight");
await preflightInstall(options);
return; return;
} }
if (command === "install") { if (command === "install") {
await install(parseInstallOptions(args)); const options = parseInstallOptions(args);
await withOperationLock("install", () => install(options));
return; return;
} }
if (command === "reconfigure") { if (command === "reconfigure") {
@@ -275,17 +312,17 @@ async function main(): Promise<void> {
console.error("--allow-partial-state is only valid for `repair`"); console.error("--allow-partial-state is only valid for `repair`");
usage(); usage();
} }
await reconfigure(options); await withOperationLock("reconfigure", () => reconfigure(options));
return; return;
} }
if (command === "repair") { if (command === "repair") {
const options = parseReconfigureOptions(["--apply", ...args]); const options = parseReconfigureOptions(["--apply", ...args]);
await repair(options); await withOperationLock("repair", () => repair(options));
return; return;
} }
if (command === "doctor") { if (command === "doctor") {
const options = parseReconfigureOptions(["--dry-run", ...args]); const options = parseReconfigureOptions(["--dry-run", ...args]);
await doctor(options); await withOperationLock("doctor", () => doctor(options));
return; return;
} }
if (command === "status") { if (command === "status") {
@@ -297,6 +334,13 @@ async function main(): Promise<void> {
if (subcommand !== "collect") { if (subcommand !== "collect") {
usage(); usage();
} }
const inProgress = await describeOperationInProgress();
if (inProgress) {
console.error(
`[hy2xs] note: an HY2XS operation is in progress: ${inProgress}. ` +
"Собранный бандл описывает промежуточное состояние транзакции."
);
}
await diagnosticsCollect(parseCommonOptions(rest)); await diagnosticsCollect(parseCommonOptions(rest));
return; return;
} }
+15 -5
View File
@@ -5,6 +5,7 @@ import { runReadOnly } from "../lib/process";
import { getPlatformProfile } from "../platform/profile"; import { getPlatformProfile } from "../platform/profile";
import { detectFirewallEntrypointKind } from "../steps/firewall"; import { detectFirewallEntrypointKind } from "../steps/firewall";
import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState"; import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState";
import { describeOperationInProgress } from "../lib/operationLock";
async function unitState(unit: string): Promise<string> { async function unitState(unit: string): Promise<string> {
try { try {
@@ -52,6 +53,12 @@ export async function status(_options: CommonOptions): Promise<void> {
const rollbackGuardUnits = (await runReadOnly`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim(); const rollbackGuardUnits = (await runReadOnly`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim();
// Идущая операция обязана быть видна в отчёте: без неё оператор разбирает
// промежуточное состояние транзакции как окончательное — например, читает
// `phase: firewall_applied` как незавершённую установку, хотя это просто
// середина работающего сейчас reconfigure.
const operationInProgress = await describeOperationInProgress();
const hysteriaService = await unitState("hysteria-server"); const hysteriaService = await unitState("hysteria-server");
const adminService = await unitState("hy2xs-admin"); const adminService = await unitState("hy2xs-admin");
const firewall = await firewallState(); const firewall = await firewallState();
@@ -66,11 +73,13 @@ export async function status(_options: CommonOptions): Promise<void> {
const runtimeState = (hysteriaService === "active" && adminService === "active") const runtimeState = (hysteriaService === "active" && adminService === "active")
? (installStateEffective === "installed" ? "running" : "partial") ? (installStateEffective === "installed" ? "running" : "partial")
: "stopped"; : "stopped";
const humanStatus = runtimeState === "partial" const humanStatus = operationInProgress
? "Runtime services are active, but installation is not finalized because smoke checks failed." ? `An HY2XS operation is in progress (${operationInProgress}); the state below is a snapshot of an unfinished transaction.`
: (runtimeState === "running" : (runtimeState === "partial"
? "Runtime services are active and installation is finalized." ? "Runtime services are active, but installation is not finalized because smoke checks failed."
: "Runtime services are not fully active."); : (runtimeState === "running"
? "Runtime services are active and installation is finalized."
: "Runtime services are not fully active."));
const result = { const result = {
ts: new Date().toISOString(), ts: new Date().toISOString(),
@@ -88,6 +97,7 @@ export async function status(_options: CommonOptions): Promise<void> {
? (generationProblems.length === 0 ? "current" : "foreign") ? (generationProblems.length === 0 ? "current" : "foreign")
: "absent", : "absent",
install_state_generation_problems: generationProblems, install_state_generation_problems: generationProblems,
operation_in_progress: operationInProgress,
rollback_guard_active: rollbackGuardActive, rollback_guard_active: rollbackGuardActive,
rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [], rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [],
runtime_state: runtimeState, runtime_state: runtimeState,
+468
View File
@@ -0,0 +1,468 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { open, readFile, rename, unlink } from "node:fs/promises";
import { dirname } from "node:path";
import { assertMutationAllowed } from "./guard";
import { info } from "./log";
/**
* Взаимное исключение операций жизненного цикла HY2XS.
*
* Вся архитектура отката держится на невысказанном допущении:
*
* в каждый момент времени на сервере выполняется ровно одна операция HY2XS.
*
* Код его никак не обеспечивал. `install-state.json` — не замок: это запись о
* состоянии, а не право на изменение. Два одновременных `reconfigure` спокойно
* доходили до конца каждый по-своему:
*
* A: прочитал installed=true B: прочитал installed=true
* A: снял копию в op-A B: снял копию в op-B
* A: записал конфиг B: записал конфиг
* A: развернул юниты B: развернул юниты
* A: применил firewall B: применил firewall
*
* Уникальные op-id разделяют РЕЗЕРВНЫЕ КОПИИ, но не production paths: и конфиг,
* и юниты, и /etc/nftables.conf, и /var/lib/hy2xs/install-state.json — общие.
* Дальше любая из операций могла упасть и «восстановить» состояние поверх
* изменений другой, причём её откат отчитался бы полным успехом: с точки зрения
* своего манифеста он действительно восстановил всё, что копировал.
*
* Отдельно опасен firewall: обе операции независимо взводят транзиентные
* rollback-юниты, и guard одной операции способен снять правила другой.
*
* Почему не flock(2). Прямого биндинга в рантайме нет, а держать замок
* подпроцессом `flock` означало бы завести сторожевой процесс на каждую
* операцию. Здесь используется классический lockfile через O_EXCL: создание
* файла с флагом «отказать, если существует» атомарно на всех файловых
* системах, включая tmpfs, на которой живёт /run/lock.
*
* Асимметрия решений сознательная. Ложное «держатель жив» приводит к отказу,
* который оператор снимает одной командой. Ложное «держатель мёртв» пускает
* вторую операцию в те же production paths — то есть ровно то, ради чего
* замок существует. Поэтому сомнение всегда трактуется в пользу отказа.
*/
export const OPERATION_LOCK_PATH = "/run/lock/hy2xs-orchestrator.lock";
export type LockRecord = {
/** PID процесса-держателя. */
pid: number;
/** Команда оркестратора: install | reconfigure | repair | doctor. */
command: string;
startedAt: string;
/**
* Одноразовое значение операции.
*
* Нужно там, где PID недостаточен: снятие замка обязано убрать ИМЕННО свой
* файл, а не тот, который успел занять кто-то другой после переиспользования
* освободившегося замка.
*/
nonce: string;
};
export type LockHolder = LockRecord & { alive: boolean };
export class OperationInProgressError extends Error {
readonly holder: LockHolder;
constructor(message: string, holder: LockHolder) {
super(message);
this.name = "OperationInProgressError";
this.holder = holder;
}
}
export type OperationLock = {
readonly command: string;
readonly nonce: string;
release(): Promise<void>;
};
export type LockOptions = {
/** Путь замка. Переопределяется тестами; на сервере всегда OPERATION_LOCK_PATH. */
path?: string;
/** Проверка живости держателя. Переопределяется тестами. */
isProcessAlive?: (pid: number) => boolean;
/** PID текущего процесса. Переопределяется тестами. */
pid?: number;
};
export function renderLockRecord(record: LockRecord): string {
return `${JSON.stringify(record, null, 2)}\n`;
}
/**
* Разбор строгий: непонятый замок НЕ превращается в «замка нет».
*
* Возврат null означает «файл существует, но не является нашим замком», и
* вызывающий обязан решить это отдельно, а не считать путь свободным.
*/
export function parseLockRecord(raw: string): LockRecord | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0) {
return null;
}
if (typeof record.command !== "string" || record.command === "") {
return null;
}
if (typeof record.nonce !== "string" || record.nonce === "") {
return null;
}
return {
pid: record.pid,
command: record.command,
startedAt: typeof record.startedAt === "string" ? record.startedAt : "",
nonce: record.nonce
};
}
/**
* Жив ли держатель замка.
*
* На Linux решает наличие /proc/<pid>. Дополнительной сверки cmdline здесь
* намеренно нет: она превратила бы «оркестратор запущен не тем способом»
* (например, из исходников при отладке) в «держатель мёртв, замок можно
* забрать», то есть в разрешение на параллельную мутацию. Переиспользование
* PID теоретически возможно, но его цена — лишний отказ, а не потерянное
* взаимное исключение.
*/
export function isProcessAliveByDefault(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
if (process.platform === "linux" && existsSync("/proc")) {
return existsSync(`/proc/${pid}`);
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM означает «процесс есть, но он не наш» — это живой держатель.
return (error as { code?: string }).code === "EPERM";
}
}
function lockPath(options: LockOptions): string {
return options.path ?? OPERATION_LOCK_PATH;
}
function livenessProbe(options: LockOptions): (pid: number) => boolean {
return options.isProcessAlive ?? isProcessAliveByDefault;
}
/**
* Кто держит замок сейчас. Чтение, поэтому доступно и под read-only guard.
*
* Возвращает null, только если замка НЕТ. Нечитаемый или неразобранный файл
* описывается как держатель с pid 0 и command "unknown": «здесь что-то лежит»
* и «здесь ничего нет» — разные ответы.
*/
export async function readLockHolder(options: LockOptions = {}): Promise<LockHolder | null> {
const path = lockPath(options);
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") {
return null;
}
throw error;
}
const record = parseLockRecord(raw);
if (!record) {
return {
pid: 0,
command: "unknown",
startedAt: "",
nonce: "",
alive: false
};
}
return { ...record, alive: livenessProbe(options)(record.pid) };
}
export function describeLockHolder(holder: LockHolder): string {
const started = holder.startedAt ? `, started at ${holder.startedAt}` : "";
return `${holder.command} (pid ${holder.pid}${started})`;
}
/**
* Освобождает замок мёртвого держателя.
*
* Забирать замок «на месте» нельзя: между чтением записи и её удалением
* держатель мог смениться, и тогда `unlink` снял бы ЖИВОЙ замок. Поэтому файл
* сначала уводится в сторону переименованием, а затем проверяется, что уведён
* именно тот замок, который был признан мёртвым. Если нет — он возвращается на
* место ровно тем же содержимым, и операция отказывает.
*/
async function reclaimStaleLock(path: string, observed: LockRecord): Promise<boolean> {
const stealPath = `${path}.stale-${observed.nonce}`;
try {
await rename(path, stealPath);
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") {
// Замок уже снят кем-то другим: путь свободен, повторная попытка уместна.
return true;
}
throw error;
}
const stolen = parseLockRecord(await readFile(stealPath, "utf8").catch(() => ""));
if (stolen && stolen.nonce !== observed.nonce) {
await rename(stealPath, path);
return false;
}
await unlink(stealPath).catch(() => undefined);
return true;
}
/**
* Замки, которые держит ЭТОТ процесс.
*
* Нужны обработчикам завершения: снятие замка обязано пережить и штатный
* выход, и Ctrl+C, и SIGTERM от systemd, и обрыв SSH (SIGHUP). Без этого
* прерванная операция оставляла бы замок до перезагрузки.
*/
const heldLocks = new Map<string, string>();
let exitHandlersInstalled = false;
function releaseSync(path: string, nonce: string): void {
try {
const record = parseLockRecord(readFileSync(path, "utf8"));
if (record && record.nonce !== nonce) {
// Замок уже не наш: снимать его — значит открыть дорогу третьей операции.
return;
}
unlinkSync(path);
} catch {
// Замка уже нет — снимать нечего.
}
heldLocks.delete(path);
}
function installExitHandlers(): void {
if (exitHandlersInstalled) {
return;
}
exitHandlersInstalled = true;
process.on("exit", () => {
for (const [path, nonce] of heldLocks) {
releaseSync(path, nonce);
}
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
for (const [path, nonce] of heldLocks) {
releaseSync(path, nonce);
}
// Код возврата по соглашению: 128 + номер сигнала. Обработчик снимает
// замок и НЕ подменяет собой штатное завершение.
process.exit(signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 129);
});
}
}
function newNonce(): string {
return `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
}
async function writeLockFile(path: string, record: LockRecord): Promise<boolean> {
let handle;
try {
// "wx" — атомарное создание с отказом, если файл уже существует.
handle = await open(path, "wx", 0o644);
} catch (error) {
if ((error as { code?: string }).code === "EEXIST") {
return false;
}
throw error;
}
try {
await handle.writeFile(renderLockRecord(record), "utf8");
// Замок читают другие процессы, в том числе после внезапной перезагрузки
// держателя: содержимое обязано быть на носителе, а не в page cache.
await handle.sync();
} finally {
await handle.close();
}
return true;
}
/**
* Берёт эксклюзивный замок операции или отказывает.
*
* Отказ неблокирующий и намеренно быстрый: очередь из операций жизненного
* цикла — это не то, чего оператор ждёт от установщика. Он должен увидеть, что
* на сервере уже что-то выполняется, и решить сам.
*/
export async function acquireOperationLock(
command: string,
options: LockOptions = {}
): Promise<OperationLock> {
// Замок пишется в /run — это ephemeral runtime state, а не persistent path,
// и берётся ДО того, как команда включает read-only guard. Проверка стоит
// здесь, чтобы перенос захвата внутрь guard'а отказал громко, а не записал
// файл молча в фазе, которая обязана быть читающей.
assertMutationAllowed(`acquireOperationLock(${command})`);
const path = lockPath(options);
const pid = options.pid ?? process.pid;
const record: LockRecord = {
pid,
command,
startedAt: new Date().toISOString(),
nonce: newNonce()
};
mkdirSync(dirname(path), { recursive: true });
for (let attempt = 0; attempt < 2; attempt += 1) {
if (await writeLockFile(path, record)) {
installExitHandlers();
heldLocks.set(path, record.nonce);
info(`operation lock acquired: ${path} (${command}, pid ${pid})`);
return {
command,
nonce: record.nonce,
release: async () => {
releaseSync(path, record.nonce);
}
};
}
const holder = await readLockHolder(options);
if (!holder) {
// Замок исчез между отказом создания и чтением: пробуем ещё раз.
continue;
}
if (holder.alive) {
throw new OperationInProgressError(
`another HY2XS operation is already in progress: ${describeLockHolder(holder)}. ` +
`Дождитесь её завершения; замок: ${path}`,
holder
);
}
if (attempt > 0) {
throw new OperationInProgressError(
`operation lock ${path} is held by ${describeLockHolder(holder)}, which is no longer running, ` +
"но освободить его не удалось. Проверьте состояние сервера и при необходимости удалите замок вручную.",
holder
);
}
if (holder.pid === 0) {
throw new OperationInProgressError(
`operation lock ${path} exists but is not a valid HY2XS lock record. ` +
"Файл не снимается автоматически: непонятое содержимое не является доказательством того, " +
"что операции нет. Проверьте сервер и удалите замок вручную.",
holder
);
}
info(
`operation lock ${path} is held by ${describeLockHolder(holder)}, which is no longer running; reclaiming it`
);
if (!(await reclaimStaleLock(path, holder))) {
throw new OperationInProgressError(
`another HY2XS operation took the lock ${path} while a stale one was being reclaimed`,
holder
);
}
}
const holder = await readLockHolder(options);
throw new OperationInProgressError(
`failed to acquire the operation lock ${path}` +
(holder ? `: held by ${describeLockHolder(holder)}` : ""),
holder ?? { pid: 0, command: "unknown", startedAt: "", nonce: "", alive: false }
);
}
/** Замок снимается в finally: операция не имеет права оставить его за собой. */
export async function withOperationLock<T>(
command: string,
run: () => Promise<T>,
options: LockOptions = {}
): Promise<T> {
const lock = await acquireOperationLock(command, options);
try {
return await run();
} finally {
await lock.release();
}
}
/**
* Отказ до первой мутации для команд, которые сами замок не берут.
*
* PHASE 0 установки обязана отказать здесь, а не после `exec` в install.sh:
* иначе оператор получил бы отказ уже от `install`, потратив на проверки время
* и увидев его вторым сообщением вместо первого.
*/
export async function assertNoOperationInProgress(
what: string,
options: LockOptions = {}
): Promise<void> {
const holder = await readLockHolder(options);
if (!holder || !holder.alive) {
return;
}
throw new OperationInProgressError(
`${what} refused: another HY2XS operation is already in progress: ${describeLockHolder(holder)}`,
holder
);
}
/**
* Описание текущей операции для команд, которые ничего не меняют.
*
* `status` и `diagnostics` не берут замок сознательно: они существуют в том
* числе ради того, чтобы посмотреть на сервер во время долгой операции.
* Но показать, что операция идёт, они обязаны — иначе оператор будет разбирать
* промежуточное состояние транзакции как окончательное.
*/
export async function describeOperationInProgress(
options: LockOptions = {}
): Promise<string | null> {
const holder = await readLockHolder(options);
if (!holder || !holder.alive) {
return null;
}
return describeLockHolder(holder);
}
/**
* Только для тестов: сбрасывает учёт замков этого процесса.
*
* Учёт нужен обработчикам завершения и живёт на уровне модуля, поэтому между
* тестами он обязан обнуляться — иначе обработчик выхода попытается снять
* замок из временного каталога, которого уже нет.
*/
export function resetHeldLocksForTests(): void {
heldLocks.clear();
}
+308
View File
@@ -0,0 +1,308 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard";
import {
OperationInProgressError,
acquireOperationLock,
assertNoOperationInProgress,
describeOperationInProgress,
isProcessAliveByDefault,
parseLockRecord,
readLockHolder,
renderLockRecord,
resetHeldLocksForTests,
withOperationLock
} from "../src/lib/operationLock";
/**
* Взаимное исключение операций жизненного цикла.
*
* У проекта не было ни flock, ни mutex, ни какого-либо замка вообще, а
* install-state.json им не является: это запись о состоянии, а не право на
* изменение. Два одновременных reconfigure доходили до конца каждый по-своему,
* и уникальные op-id тут не спасали — они разделяют только резервные копии,
* тогда как /etc/hysteria/config.yaml, unit-файлы, /etc/nftables.conf и
* install-state.json общие. Дальше любая из операций могла упасть и
* «восстановить» состояние поверх изменений другой, отчитавшись полным успехом.
*
* Проверяемое здесь свойство — отказ происходит ДО первой мутации, а не в
* середине транзакции.
*/
let dir = "";
let lockFile = "";
const DEAD_PID = 424242;
const LIVE_PID = 4242;
/** Живость подменяется: тест не имеет права зависеть от реальных PID системы. */
function liveness(alivePids: readonly number[]) {
return (pid: number) => alivePids.includes(pid);
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "hy2xs-lock-"));
lockFile = join(dir, "hy2xs-orchestrator.lock");
resetHeldLocksForTests();
});
afterEach(async () => {
disableReadOnlyGuard();
resetHeldLocksForTests();
await rm(dir, { recursive: true, force: true });
});
describe("запись замка", () => {
test("разбор согласован с записью", () => {
const record = { pid: 17, command: "reconfigure", startedAt: "2026-08-30T10:00:00.000Z", nonce: "n1" };
expect(parseLockRecord(renderLockRecord(record))).toEqual(record);
});
// «Файл не разобрался» и «замка нет» — разные ответы. Трактовать первое как
// второе означало бы пустить вторую операцию по непонятному файлу.
test("непонятое содержимое не превращается в отсутствие замка", () => {
expect(parseLockRecord("не json")).toBeNull();
expect(parseLockRecord("[]")).toBeNull();
expect(parseLockRecord('{"pid":0,"command":"install","nonce":"n"}')).toBeNull();
expect(parseLockRecord('{"pid":7,"command":"","nonce":"n"}')).toBeNull();
expect(parseLockRecord('{"pid":7,"command":"install"}')).toBeNull();
});
});
describe("захват и освобождение", () => {
test("замок создаётся с описанием операции", async () => {
const lock = await acquireOperationLock("install", { path: lockFile, pid: LIVE_PID });
const record = parseLockRecord(readFileSync(lockFile, "utf8"));
expect(record?.pid).toBe(LIVE_PID);
expect(record?.command).toBe("install");
expect(record?.nonce).toBe(lock.nonce);
await lock.release();
expect(existsSync(lockFile)).toBe(false);
});
test("второй захват при живом держателе отказывает", async () => {
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) };
const first = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID });
await expect(
acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID + 1 })
).rejects.toThrow(OperationInProgressError);
await first.release();
});
test("отказ называет держателя, чтобы оператор понял, чего ждать", async () => {
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) };
const first = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID });
try {
await acquireOperationLock("doctor", { ...options, pid: LIVE_PID + 1 });
throw new Error("захват обязан был отказать");
} catch (error) {
expect(error).toBeInstanceOf(OperationInProgressError);
expect((error as Error).message).toContain("another HY2XS operation is already in progress");
expect((error as Error).message).toContain("reconfigure");
expect((error as Error).message).toContain(String(LIVE_PID));
}
await first.release();
});
test("после освобождения замок берётся снова", async () => {
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) };
const first = await acquireOperationLock("install", { ...options, pid: LIVE_PID });
await first.release();
const second = await acquireOperationLock("reconfigure", { ...options, pid: LIVE_PID });
expect(parseLockRecord(readFileSync(lockFile, "utf8"))?.command).toBe("reconfigure");
await second.release();
});
// Замок обязан сниматься и на отказе операции: иначе первая же неудачная
// установка заблокировала бы сервер до перезагрузки.
test("withOperationLock снимает замок и после отказа", async () => {
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]), pid: LIVE_PID };
await expect(
withOperationLock("install", async () => {
expect(existsSync(lockFile)).toBe(true);
throw new Error("операция упала");
}, options)
).rejects.toThrow("операция упала");
expect(existsSync(lockFile)).toBe(false);
});
});
describe("замок мёртвого держателя", () => {
test("переиспользуется, а не блокирует сервер навсегда", async () => {
writeFileSync(
lockFile,
renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" })
);
const lock = await acquireOperationLock("repair", {
path: lockFile,
pid: LIVE_PID,
isProcessAlive: liveness([LIVE_PID])
});
expect(parseLockRecord(readFileSync(lockFile, "utf8"))?.command).toBe("repair");
await lock.release();
});
test("временный файл переиспользования не остаётся на диске", async () => {
writeFileSync(
lockFile,
renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" })
);
const lock = await acquireOperationLock("repair", {
path: lockFile,
pid: LIVE_PID,
isProcessAlive: liveness([LIVE_PID])
});
await lock.release();
expect((await readdir(dir)).filter((name) => name.includes(".stale-"))).toEqual([]);
});
// Непонятый файл не является доказательством отсутствия операции, поэтому
// автоматически он не снимается: сомнение трактуется в пользу отказа.
test("непонятый замок не переиспользуется автоматически", async () => {
writeFileSync(lockFile, "мусор, а не замок\n");
await expect(
acquireOperationLock("install", {
path: lockFile,
pid: LIVE_PID,
isProcessAlive: liveness([LIVE_PID])
})
).rejects.toThrow(/is not a valid HY2XS lock record/);
expect(existsSync(lockFile)).toBe(true);
});
});
describe("наблюдение за замком", () => {
test("readLockHolder отличает отсутствие замка от нечитаемого", async () => {
expect(await readLockHolder({ path: lockFile })).toBeNull();
writeFileSync(lockFile, "мусор\n");
const holder = await readLockHolder({ path: lockFile });
expect(holder?.command).toBe("unknown");
expect(holder?.alive).toBe(false);
});
test("мёртвый держатель не считается идущей операцией", async () => {
writeFileSync(
lockFile,
renderLockRecord({ pid: DEAD_PID, command: "install", startedAt: "", nonce: "stale" })
);
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) };
expect(await describeOperationInProgress(options)).toBeNull();
await assertNoOperationInProgress("install preflight", options);
});
test("живой держатель останавливает PHASE 0 до первой проверки", async () => {
writeFileSync(
lockFile,
renderLockRecord({ pid: LIVE_PID, command: "reconfigure", startedAt: "", nonce: "live" })
);
const options = { path: lockFile, isProcessAlive: liveness([LIVE_PID]) };
expect(await describeOperationInProgress(options)).toContain("reconfigure");
await expect(assertNoOperationInProgress("install preflight", options)).rejects.toThrow(
OperationInProgressError
);
});
test("собственный процесс опознаётся как живой", () => {
expect(isProcessAliveByDefault(process.pid)).toBe(true);
expect(isProcessAliveByDefault(0)).toBe(false);
expect(isProcessAliveByDefault(-1)).toBe(false);
});
});
describe("политика замка в CLI", () => {
const cliSource = readFileSync(new URL("../src/cli.ts", import.meta.url), "utf8");
test("мутирующие команды выполняются под замком", () => {
for (const command of ["install", "reconfigure", "repair"] as const) {
expect(cliSource).toContain(`await withOperationLock("${command}", () => ${command}(options))`);
}
});
/**
* doctor не мутирует, но обязан быть исключён против reconfigure: диагностика
* в середине транзакции описывает промежуточное состояние и выдаёт
* бессмысленные ошибки по временным несоответствиям.
*/
test("doctor исключён против мутирующих операций", () => {
expect(cliSource).toContain('await withOperationLock("doctor", () => doctor(options))');
});
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды
// целиком, поэтому backupCurrentState и applyFirewall физически недостижимы,
// пока замок не взят.
test("команда не вызывается мимо замка", () => {
for (const call of ["await install(", "await reconfigure(", "await repair(", "await doctor("]) {
expect(cliSource).not.toContain(call);
}
});
test("PHASE 0 отказывает сразу, а не после exec", () => {
const assertAt = cliSource.indexOf('await assertNoOperationInProgress("install preflight")');
const preflightAt = cliSource.indexOf("await preflightInstall(options)");
expect(assertAt).toBeGreaterThan(-1);
expect(preflightAt).toBeGreaterThan(assertAt);
});
// status и diagnostics существуют в том числе для того, чтобы посмотреть на
// сервер во время долгой операции: замок они брать не имеют права.
test("наблюдающие команды замок не берут, но сообщают об операции", () => {
expect(cliSource).not.toContain('withOperationLock("status"');
expect(cliSource).not.toContain('withOperationLock("diagnostics"');
expect(cliSource).toContain("await describeOperationInProgress()");
});
test("status показывает идущую операцию в отчёте", () => {
const statusSource = readFileSync(new URL("../src/commands/status.ts", import.meta.url), "utf8");
expect(statusSource).toContain("operation_in_progress: operationInProgress");
});
});
describe("замок и read-only guard", () => {
/**
* Замок берётся ДО включения guard'а — так устроен cli.ts. Проверка внутри
* `acquireOperationLock` существует, чтобы перенос захвата внутрь читающей
* фазы отказал громко, а не записал файл молча.
*/
test("захват под read-only guard отказывает", async () => {
enableReadOnlyGuard("тест PHASE 0");
await expect(
acquireOperationLock("doctor", { path: lockFile, pid: LIVE_PID })
).rejects.toThrow(/read-only guard violation/);
expect(existsSync(lockFile)).toBe(false);
});
test("наблюдение за замком под guard'ом разрешено", async () => {
writeFileSync(
lockFile,
renderLockRecord({ pid: LIVE_PID, command: "install", startedAt: "", nonce: "live" })
);
enableReadOnlyGuard("тест PHASE 0");
expect(await describeOperationInProgress({ path: lockFile, isProcessAlive: liveness([LIVE_PID]) }))
.toContain("install");
});
});