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:
+49
-5
@@ -5,6 +5,11 @@ import { doctor } from "./commands/doctor";
|
||||
import { status } from "./commands/status";
|
||||
import { diagnosticsCollect } from "./commands/diagnostics";
|
||||
import { redactConfig, type RedactConfigFormat } from "./commands/redact-config";
|
||||
import {
|
||||
assertNoOperationInProgress,
|
||||
describeOperationInProgress,
|
||||
withOperationLock
|
||||
} from "./lib/operationLock";
|
||||
import type { InstallOptions, ReconfigureOptions } from "./types/context";
|
||||
|
||||
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 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: install/reconfigure/repair/doctor are serialized by /run/lock/hy2xs-orchestrator.lock");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -259,14 +265,45 @@ function parsePreflightInstallOptions(args: string[]): InstallOptions {
|
||||
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> {
|
||||
const [command, ...args] = Bun.argv.slice(2);
|
||||
if (command === "preflight-install") {
|
||||
await preflightInstall(parsePreflightInstallOptions(args));
|
||||
const options = parsePreflightInstallOptions(args);
|
||||
await assertNoOperationInProgress("install preflight");
|
||||
await preflightInstall(options);
|
||||
return;
|
||||
}
|
||||
if (command === "install") {
|
||||
await install(parseInstallOptions(args));
|
||||
const options = parseInstallOptions(args);
|
||||
await withOperationLock("install", () => install(options));
|
||||
return;
|
||||
}
|
||||
if (command === "reconfigure") {
|
||||
@@ -275,17 +312,17 @@ async function main(): Promise<void> {
|
||||
console.error("--allow-partial-state is only valid for `repair`");
|
||||
usage();
|
||||
}
|
||||
await reconfigure(options);
|
||||
await withOperationLock("reconfigure", () => reconfigure(options));
|
||||
return;
|
||||
}
|
||||
if (command === "repair") {
|
||||
const options = parseReconfigureOptions(["--apply", ...args]);
|
||||
await repair(options);
|
||||
await withOperationLock("repair", () => repair(options));
|
||||
return;
|
||||
}
|
||||
if (command === "doctor") {
|
||||
const options = parseReconfigureOptions(["--dry-run", ...args]);
|
||||
await doctor(options);
|
||||
await withOperationLock("doctor", () => doctor(options));
|
||||
return;
|
||||
}
|
||||
if (command === "status") {
|
||||
@@ -297,6 +334,13 @@ async function main(): Promise<void> {
|
||||
if (subcommand !== "collect") {
|
||||
usage();
|
||||
}
|
||||
const inProgress = await describeOperationInProgress();
|
||||
if (inProgress) {
|
||||
console.error(
|
||||
`[hy2xs] note: an HY2XS operation is in progress: ${inProgress}. ` +
|
||||
"Собранный бандл описывает промежуточное состояние транзакции."
|
||||
);
|
||||
}
|
||||
await diagnosticsCollect(parseCommonOptions(rest));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runReadOnly } from "../lib/process";
|
||||
import { getPlatformProfile } from "../platform/profile";
|
||||
import { detectFirewallEntrypointKind } from "../steps/firewall";
|
||||
import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState";
|
||||
import { describeOperationInProgress } from "../lib/operationLock";
|
||||
|
||||
async function unitState(unit: string): Promise<string> {
|
||||
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();
|
||||
|
||||
// Идущая операция обязана быть видна в отчёте: без неё оператор разбирает
|
||||
// промежуточное состояние транзакции как окончательное — например, читает
|
||||
// `phase: firewall_applied` как незавершённую установку, хотя это просто
|
||||
// середина работающего сейчас reconfigure.
|
||||
const operationInProgress = await describeOperationInProgress();
|
||||
|
||||
const hysteriaService = await unitState("hysteria-server");
|
||||
const adminService = await unitState("hy2xs-admin");
|
||||
const firewall = await firewallState();
|
||||
@@ -66,11 +73,13 @@ export async function status(_options: CommonOptions): Promise<void> {
|
||||
const runtimeState = (hysteriaService === "active" && adminService === "active")
|
||||
? (installStateEffective === "installed" ? "running" : "partial")
|
||||
: "stopped";
|
||||
const humanStatus = runtimeState === "partial"
|
||||
? "Runtime services are active, but installation is not finalized because smoke checks failed."
|
||||
: (runtimeState === "running"
|
||||
? "Runtime services are active and installation is finalized."
|
||||
: "Runtime services are not fully active.");
|
||||
const humanStatus = operationInProgress
|
||||
? `An HY2XS operation is in progress (${operationInProgress}); the state below is a snapshot of an unfinished transaction.`
|
||||
: (runtimeState === "partial"
|
||||
? "Runtime services are active, but installation is not finalized because smoke checks failed."
|
||||
: (runtimeState === "running"
|
||||
? "Runtime services are active and installation is finalized."
|
||||
: "Runtime services are not fully active."));
|
||||
|
||||
const result = {
|
||||
ts: new Date().toISOString(),
|
||||
@@ -88,6 +97,7 @@ export async function status(_options: CommonOptions): Promise<void> {
|
||||
? (generationProblems.length === 0 ? "current" : "foreign")
|
||||
: "absent",
|
||||
install_state_generation_problems: generationProblems,
|
||||
operation_in_progress: operationInProgress,
|
||||
rollback_guard_active: rollbackGuardActive,
|
||||
rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [],
|
||||
runtime_state: runtimeState,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user