76d78ac71f
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.
1. Отказ записи `auto-rollback-fired` оставался незамеченным.
Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.
`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.
Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.
2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.
Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:
A берёт замок -> применяет firewall -> вооружает guard на 45s
A аварийно умирает
B берёт замок и начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.
Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.
Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.
Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
399 lines
17 KiB
TypeScript
399 lines
17 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("барьер покоя при захвате", () => {
|
|
/**
|
|
* Замок защищает production paths, пока жив процесс-держатель. Rollback guard
|
|
* firewall — отдельный systemd-объект, который свой процесс переживает, и
|
|
* способен вернуть прежний firewall уже посреди следующей операции.
|
|
*/
|
|
test("отказ барьера не оставляет замка на сервере", async () => {
|
|
await expect(
|
|
acquireOperationLock("reconfigure", {
|
|
path: lockFile,
|
|
pid: LIVE_PID,
|
|
barrier: async () => {
|
|
throw new Error("guard предыдущей операции всё ещё вооружён");
|
|
}
|
|
})
|
|
).rejects.toThrow("guard предыдущей операции всё ещё вооружён");
|
|
|
|
expect(existsSync(lockFile)).toBe(false);
|
|
});
|
|
|
|
test("барьер проверяется до захвата, а не после него", async () => {
|
|
let lockExistedAtBarrier: boolean | null = null;
|
|
|
|
await expect(
|
|
acquireOperationLock("install", {
|
|
path: lockFile,
|
|
pid: LIVE_PID,
|
|
barrier: async () => {
|
|
if (lockExistedAtBarrier === null) {
|
|
lockExistedAtBarrier = existsSync(lockFile);
|
|
}
|
|
throw new Error("pending guard");
|
|
}
|
|
})
|
|
).rejects.toThrow("pending guard");
|
|
|
|
expect(lockExistedAtBarrier).toBe(false);
|
|
});
|
|
|
|
// Между первой проверкой и захватом умирающая предыдущая операция успевает
|
|
// вооружить guard, поэтому проверок две.
|
|
test("вторая проверка идёт уже под замком и снимает его при отказе", async () => {
|
|
let calls = 0;
|
|
const seenUnderLock: boolean[] = [];
|
|
|
|
await expect(
|
|
acquireOperationLock("repair", {
|
|
path: lockFile,
|
|
pid: LIVE_PID,
|
|
barrier: async () => {
|
|
calls += 1;
|
|
seenUnderLock.push(existsSync(lockFile));
|
|
if (calls === 2) {
|
|
throw new Error("guard вооружён между проверкой и захватом");
|
|
}
|
|
}
|
|
})
|
|
).rejects.toThrow("guard вооружён между проверкой и захватом");
|
|
|
|
expect(calls).toBe(2);
|
|
expect(seenUnderLock).toEqual([false, true]);
|
|
expect(existsSync(lockFile)).toBe(false);
|
|
});
|
|
|
|
test("при спокойном барьере замок берётся обычным образом", async () => {
|
|
let calls = 0;
|
|
const lock = await acquireOperationLock("install", {
|
|
path: lockFile,
|
|
pid: LIVE_PID,
|
|
barrier: async () => {
|
|
calls += 1;
|
|
}
|
|
});
|
|
|
|
expect(calls).toBe(2);
|
|
expect(existsSync(lockFile)).toBe(true);
|
|
await lock.release();
|
|
});
|
|
});
|
|
|
|
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 runLifecycleOperation("${command}", () => ${command}(options))`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* doctor не мутирует, но обязан быть исключён против reconfigure: диагностика
|
|
* в середине транзакции описывает промежуточное состояние и выдаёт
|
|
* бессмысленные ошибки по временным несоответствиям.
|
|
*/
|
|
test("doctor исключён против мутирующих операций", () => {
|
|
expect(cliSource).toContain('await runLifecycleOperation("doctor", () => doctor(options))');
|
|
});
|
|
|
|
// Замок и барьер покоя обязаны идти вместе: замок ничего не знает про
|
|
// systemd-таймер, переживший своего держателя.
|
|
test("захват замка всегда сопровождается барьером покоя", () => {
|
|
expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }");
|
|
const direct = cliSource.split("withOperationLock(").length - 1;
|
|
// Единственное употребление — внутри runLifecycleOperation: иначе появился
|
|
// бы путь захвата замка мимо барьера.
|
|
expect(direct).toBe(1);
|
|
});
|
|
|
|
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды
|
|
// целиком, поэтому 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");
|
|
});
|
|
});
|