d72550e11f
У оркестратора не было никакой блокировки операций: ни 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 — снимать его на месте означало бы риск снять живой. Непонятое содержимое не снимается автоматически: оно не доказывает отсутствие операции, и сомнение трактуется в пользу отказа.
309 lines
14 KiB
TypeScript
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");
|
|
});
|
|
});
|