From d72550e11f4c1c7e87e7541255bdd479b4c99396 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 30 Aug 2026 22:59:50 +0500 Subject: [PATCH] =?UTF-8?q?fix(orchestrator):=20=D1=81=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B6=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=86=D0=B8=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У оркестратора не было никакой блокировки операций: ни 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 — снимать его на месте означало бы риск снять живой. Непонятое содержимое не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа. --- orchestrator/src/cli.ts | 54 ++- orchestrator/src/commands/status.ts | 20 +- orchestrator/src/lib/operationLock.ts | 468 +++++++++++++++++++++++ orchestrator/test/operation-lock.test.ts | 308 +++++++++++++++ 4 files changed, 840 insertions(+), 10 deletions(-) create mode 100644 orchestrator/src/lib/operationLock.ts create mode 100644 orchestrator/test/operation-lock.test.ts diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index 3bf7e90..cb77f9e 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -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 [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator redact-config --config [--in-place | --out ] [--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 { 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 { 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 { 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; } diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts index 179a2a9..ebab9dd 100644 --- a/orchestrator/src/commands/status.ts +++ b/orchestrator/src/commands/status.ts @@ -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 { try { @@ -52,6 +53,12 @@ export async function status(_options: CommonOptions): Promise { 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 { 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 { ? (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, diff --git a/orchestrator/src/lib/operationLock.ts b/orchestrator/src/lib/operationLock.ts new file mode 100644 index 0000000..4ed73ad --- /dev/null +++ b/orchestrator/src/lib/operationLock.ts @@ -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; +}; + +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; + 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/. Дополнительной сверки 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 { + 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 { + 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(); +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 { + 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 { + // Замок пишется в /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( + command: string, + run: () => Promise, + options: LockOptions = {} +): Promise { + 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 { + 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 { + const holder = await readLockHolder(options); + if (!holder || !holder.alive) { + return null; + } + return describeLockHolder(holder); +} + +/** + * Только для тестов: сбрасывает учёт замков этого процесса. + * + * Учёт нужен обработчикам завершения и живёт на уровне модуля, поэтому между + * тестами он обязан обнуляться — иначе обработчик выхода попытается снять + * замок из временного каталога, которого уже нет. + */ +export function resetHeldLocksForTests(): void { + heldLocks.clear(); +} diff --git a/orchestrator/test/operation-lock.test.ts b/orchestrator/test/operation-lock.test.ts new file mode 100644 index 0000000..ad2a95a --- /dev/null +++ b/orchestrator/test/operation-lock.test.ts @@ -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"); + }); +});