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:
@@ -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