Files
HY2XS_flamy/orchestrator/test/operation-lock.test.ts
T
founder d72550e11f 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 — снимать его на месте означало бы риск снять
живой. Непонятое содержимое не снимается автоматически: оно не доказывает
отсутствие операции, и сомнение трактуется в пользу отказа.
2026-08-30 22:59:50 +05:00

309 lines
14 KiB
TypeScript

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");
});
});