fix(orchestrator): закрыть два остатка на стыке guard и замка операций

Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.

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) — там же.
This commit is contained in:
2026-08-31 03:30:14 +05:00
parent 0230f1ca99
commit 76d78ac71f
12 changed files with 820 additions and 30 deletions
+19 -4
View File
@@ -10,6 +10,7 @@ import {
describeOperationInProgress,
withOperationLock
} from "./lib/operationLock";
import { assertNoPendingRollbackGuard } from "./steps/firewall";
import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never {
@@ -292,18 +293,32 @@ function parsePreflightInstallOptions(args: string[]): InstallOptions {
* потратил бы время на проверки и получил
* отказ уже после exec;
* redact-config — работает с файлом, а не с сервером.
*
* Замка при этом НЕДОСТАТОЧНО, и это второе, что здесь собрано в одном месте.
* Замок защищает production paths, пока жив процесс-держатель, а rollback guard
* firewall — отдельный systemd-объект, переживающий свой процесс. Аварийно
* умершая операция оставляет вооружённый guard, который способен вернуть
* прежний firewall уже посреди следующей. Поэтому каждый захват замка проходит
* через барьер покоя, а не только через проверку живости держателя.
*/
async function runLifecycleOperation<T>(command: string, run: () => Promise<T>): Promise<T> {
return await withOperationLock(command, run, { barrier: assertNoPendingRollbackGuard });
}
async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2);
if (command === "preflight-install") {
const options = parsePreflightInstallOptions(args);
await assertNoOperationInProgress("install preflight");
// PHASE 0 отказывает по тем же двум причинам, что и сама операция: чужой
// живой держатель замка и вооружённый guard предыдущей операции.
await assertNoPendingRollbackGuard();
await preflightInstall(options);
return;
}
if (command === "install") {
const options = parseInstallOptions(args);
await withOperationLock("install", () => install(options));
await runLifecycleOperation("install", () => install(options));
return;
}
if (command === "reconfigure") {
@@ -312,17 +327,17 @@ async function main(): Promise<void> {
console.error("--allow-partial-state is only valid for `repair`");
usage();
}
await withOperationLock("reconfigure", () => reconfigure(options));
await runLifecycleOperation("reconfigure", () => reconfigure(options));
return;
}
if (command === "repair") {
const options = parseReconfigureOptions(["--apply", ...args]);
await withOperationLock("repair", () => repair(options));
await runLifecycleOperation("repair", () => repair(options));
return;
}
if (command === "doctor") {
const options = parseReconfigureOptions(["--dry-run", ...args]);
await withOperationLock("doctor", () => doctor(options));
await runLifecycleOperation("doctor", () => doctor(options));
return;
}
if (command === "status") {
+30
View File
@@ -85,6 +85,20 @@ export type LockOptions = {
isProcessAlive?: (pid: number) => boolean;
/** PID текущего процесса. Переопределяется тестами. */
pid?: number;
/**
* Барьер покоя: проверка, что у предыдущей операции не осталось асинхронных
* исполнителей, способных изменить систему.
*
* Замок сам по себе такой гарантии не даёт и дать не может. Он защищает
* production paths, пока жив процесс-держатель, а rollback guard firewall —
* отдельный systemd-объект, который свой процесс переживает. Поэтому
* условие начала операции не «PID предыдущей мёртв», а «предыдущая больше не
* имеет исполнителей».
*
* Вызывается ДВАЖДЫ — до попытки захвата и сразу после успешного: между
* этими моментами умирающая предыдущая операция успевает вооружить guard.
*/
barrier?: () => Promise<void>;
};
export function renderLockRecord(record: LockRecord): string {
@@ -337,12 +351,28 @@ export async function acquireOperationLock(
nonce: newNonce()
};
// Барьер до захвата: отказать раньше, чем на сервере появится наш замок.
await options.barrier?.();
mkdirSync(dirname(path), { recursive: true });
for (let attempt = 0; attempt < 2; attempt += 1) {
if (await writeLockFile(path, record)) {
installExitHandlers();
heldLocks.set(path, record.nonce);
// И повторно — уже под замком. Окно между проверкой и захватом невелико,
// но именно в нём умирающая предыдущая операция успевает вооружить guard,
// а барьер существует ровно против этого.
if (options.barrier) {
try {
await options.barrier();
} catch (error) {
releaseSync(path, record.nonce);
throw error;
}
}
info(`operation lock acquired: ${path} (${command}, pid ${pid})`);
return {
command,
+163 -16
View File
@@ -71,12 +71,37 @@ export class FirewallGuardFiredError extends Error {
}
}
const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-";
/**
* Состояния, в которых guard ещё СПОСОБЕН изменить систему.
*
* `failed` и `inactive` сюда не входят намеренно. Guard, который уже отработал
* (успешно или нет), больше ничего не сделает, а отказавший юнит — это как раз
* повод запустить `repair`. Барьер, отказывающий по `failed`, блокировал бы
* ровно тот инструмент, которым чинят последствия.
*/
const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const;
/**
* Предыдущая операция мертва, но её асинхронный исполнитель ещё жив.
*
* Отдельный тип, потому что это единственный отказ, который не про текущую
* операцию: она не сделала ничего плохого, ей просто нельзя начинать.
*/
export class PendingRecoveryError extends Error {
constructor(message: string) {
super(message);
this.name = "PendingRecoveryError";
}
}
function rollbackRoot(opId: string): string {
return `/run/hy2xs/rollback/${opId}`;
}
function rollbackUnit(opId: string): string {
return `hy2xs-fw-rollback-${opId}`;
return `${ROLLBACK_UNIT_PREFIX}${opId}`;
}
/**
@@ -321,6 +346,26 @@ export async function detectFirewallEntrypointKind(): Promise<FirewallEntrypoint
* оставалась успешная запись. Теперь каждая стадия независима, её отказ
* поднимает `rc`, и юнит честно уходит в `failed` с диагностикой в journal.
*
* 3. Создание маркера входит в учёт `rc`, и это не мелочь, а второе плечо
* инварианта фиксации. Инвариант
*
* маркер отсутствует И юниты inactive => guard не сработал
*
* верен только при дополнительном условии «guard способен записать маркер».
* Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный
* tmpfs /run, read-only ФС, ошибка ввода-вывода) не влиял ни на что: скрипт
* успешно восстанавливал прежний firewall и завершался кодом 0, юнит уходил
* в `inactive`, маркера не было — и операция фиксировала успех после
* РЕАЛЬНО сработавшего отката.
*
* Теперь у факта срабатывания два независимых канала:
*
* маркер — обычный;
* отказ юнита — аварийный, когда маркер записать не удалось.
*
* Второй работает потому, что на пути фиксации успеха допустим ровно один
* `ActiveState` — `inactive`, а `failed` фиксацию запрещает.
*
* Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у
* этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса
* стёрла бы только что восстановленные правила — прямо противоположно задаче
@@ -344,18 +389,38 @@ export function buildAutoRollbackScript(opId: string): string {
root='${root}'
# rc объявляется ДО первой операции, включая создание маркера срабатывания.
#
# Иначе отказ записи маркера не влиял бы ни на что: скрипт успешно восстановил
# бы прежний firewall и завершился кодом 0, а операция, не увидев маркера и
# увидев inactive-юнит, зафиксировала бы успех после реально сработавшего
# отката. Отказ юнита — аварийный канал того же факта.
rc=0
# Маркер срабатывания — первым действием, до любой проверки. Операция обязана
# узнать, что guard сработал, даже если восстановление ниже не удалось.
mkdir -p "$root"
: >"$root/${AUTO_ROLLBACK_FIRED_MARKER}"
if ! mkdir -p "$root"; then
echo "hy2xs auto-rollback: failed to access the recovery root $root" >&2
rc=1
fi
# touch, а НЕ \`: >file\`.
#
# Двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана
# завершить неинтерактивный shell целиком. В dash, который на Debian и есть
# /bin/sh, это означало бы, что при недоступном /run скрипт умирает ДО
# восстановления firewall — то есть guard перестаёт делать ровно то, ради чего
# существует. touch — обычная внешняя команда, её код возврата просто
# возвращается в if.
if ! touch "$root/${AUTO_ROLLBACK_FIRED_MARKER}"; then
echo "hy2xs auto-rollback: failed to create the fired marker in $root" >&2
rc=1
fi
if [ ! -f "$root/prepared" ]; then
echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2
exit 0
exit "$rc"
fi
rc=0
# $1 — маркер существования, $2 — резервная копия, $3 — целевой путь.
restore_file() {
if [ -f "$1" ]; then
@@ -571,6 +636,81 @@ export async function assertEffectiveFirewallIsOurs(context: RuntimeContext): Pr
}
}
/**
* Транзиентные юниты guard, которые сейчас известны systemd.
*
* Отказ самого запроса не считается доказательством наличия guard: без systemd
* не может быть и транзиентного таймера, а требование systemd живёт в
* preflight, где отказ будет понятнее и точнее.
*/
export async function listRollbackGuardUnits(): Promise<string[]> {
let listed: string;
try {
listed = await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`;
} catch (error) {
info(
`unable to list firewall rollback guard units: ${error instanceof Error ? error.message : String(error)}`
);
return [];
}
return listed
.split("\n")
.map((line) => line.trim().split(/\s+/)[0] ?? "")
.filter((unit) => unit.startsWith(ROLLBACK_UNIT_PREFIX));
}
/**
* Барьер покоя: у предыдущей операции не осталось асинхронных исполнителей.
*
* Замок операций и rollback guard вводились по отдельности и по отдельности же
* оставляли дыру на своём стыке. Замок защищает production paths, пока ЖИВ
* процесс-держатель. Guard — это отдельный systemd-объект, который переживает
* свой процесс:
*
* A берёт замок -> применяет firewall -> взводит guard на 45s
* A аварийно умирает
* B берёт замок (либо снятый обработчиком сигнала, либо переиспользованный)
* B начинает менять production paths
* guard A срабатывает и возвращает firewall, который был ДО A
*
* Уникальные op-id здесь не помогают: каталоги копий разные, а
* /etc/nftables.conf, /etc/nftables.d/hy2xs.nft и ruleset в ядре — общие.
*
* Поэтому правильное условие для начала новой операции — не «PID предыдущей
* мёртв», а «у предыдущей не осталось исполнителей, способных изменить
* систему». Проверка обязательна при ЛЮБОМ захвате замка, а не только при
* переиспользовании устаревшего: обработчик сигналов снимает замок сам, и в
* этом случае stale-замка просто не будет, а таймер останется.
*
* Сознательно НЕ проверяются `hysteria-server`, `hy2xs-admin` и
* `nftables.service`: незавершённый `systemctl restart` ничего не откатывает,
* он лишь повторяет то, что новая операция сделает сама, а отказ по их
* переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен.
*/
export async function assertNoPendingRollbackGuard(): Promise<void> {
const pending: string[] = [];
for (const unit of await listRollbackGuardUnits()) {
const state = await readUnitProperty(unit, "ActiveState");
if ((GUARD_PENDING_STATES as readonly string[]).includes(state)) {
pending.push(`${unit} (${state})`);
}
}
if (pending.length === 0) {
return;
}
throw new PendingRecoveryError(
"previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " +
`${pending.join(", ")}. ` +
"Такой guard способен вернуть прежний firewall уже посреди новой операции. " +
"Дождитесь его завершения (окно — 45 секунд с момента применения firewall) и повторите; " +
"состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`."
);
}
function firewallRollbackIsInactive(context: RuntimeContext): boolean {
return (
context.options.skipFirewall ||
@@ -801,25 +941,32 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
info("nftables.service state was not captured for this operation; unit file state is left as is");
return;
}
// Восстанавливается ровно то, что операция могла изменить, и ровно так,
// как это можно сделать достоверно.
//
// Единственная мутация этого юнита в applyFirewall — постоянный
// `systemctl enable --now nftables`. Её точная отмена существует для двух
// состояний: `enabled` (ничего менять не нужно) и `disabled` (убрать
// добавленную нами постоянную ссылку).
//
// Остальные состояния сознательно НЕ трогаются, и это исправление
// прежнего поведения, а не пропуск. `enable --runtime` не удаляет
// постоянную ссылку, поэтому «восстановление» `enabled-runtime` таким
// вызовом оставляло юнит включённым в обоих scope'ах — то есть обещало
// точность, которой не давало. `masked`/`masked-runtime` восстанавливать
// не нужно вовсе: на замаскированном юните `enable --now` отказывает, и
// операция падает, ничего не изменив.
switch (serviceState.unitFileState) {
case "enabled":
await runMutatingVisible`systemctl enable nftables`;
return;
case "enabled-runtime":
await runMutatingVisible`systemctl enable --runtime nftables`;
return;
case "disabled":
await runMutatingVisible`systemctl disable nftables`;
return;
case "masked":
case "masked-runtime":
await runMutatingVisible`systemctl mask nftables`;
return;
default:
// static/indirect/generated/transient/пусто: у таких юнитов
// enable/disable либо бессмысленны, либо отказывают.
info(
`nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is not restorable explicitly; skipped`
`nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is left as is: ` +
"точное восстановление этого состояния не гарантируется, а операция не могла его изменить"
);
}
}
+234 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { delimiter, dirname, join } from "node:path";
import { classifyFailure } from "../src/commands/install";
import {
FirewallGuardFiredError,
@@ -88,11 +88,62 @@ function findShellParser(sandbox: string): string | null {
return null;
}
/**
* Запускает скрипт кандидатом-shell.
*
* Каталог самого shell добавляется в PATH: Git for Windows кладёт bash и
* coreutils рядом, но при запуске напрямую из процесса PATH этого каталога не
* содержит, и `mkdir`/`touch` внутри скрипта оказываются не найдены. Это
* особенность окружения, а не скрипта, и тест не должен на неё падать.
*/
function spawnShell(shell: string, scriptPath: string) {
return Bun.spawnSync([shell, scriptPath], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, PATH: `${dirname(shell)}${delimiter}${process.env.PATH ?? ""}` }
});
}
/**
* Ищет shell, которому можно доверить ЗАПУСК скрипта.
*
* Проба самопроверяющая и намеренно строгая: кандидат обязан вернуть код
* возврата скрипта И создать файл, который тест затем видит на своей файловой
* системе. Одной проверки кода возврата мало — `C:\Windows\system32\bash.exe`
* это launcher WSL, он честно выполнит `exit 7`, но в совершенно другом
* пространстве имён путей, и поведенческий тест ниже проверял бы не скрипт.
*/
function findShell(sandbox: string): string | null {
const probeDir = join(sandbox, "probe-dir");
const probe = join(sandbox, "probe.sh");
writeFileSync(
probe,
`mkdir -p '${probeDir.replace(/\\/g, "/")}'\ntouch '${probeDir.replace(/\\/g, "/")}/ok'\nexit 7\n`
);
const candidates = [
Bun.which("dash"),
Bun.which("sh"),
Bun.which("bash"),
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
"E:\\Git\\usr\\bin\\bash.exe"
].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string));
for (const candidate of candidates) {
rmSync(probeDir, { recursive: true, force: true });
const result = spawnShell(candidate, probe);
if (result.exitCode === 7 && existsSync(join(probeDir, "ok"))) {
return candidate;
}
}
return null;
}
describe("скрипт автоматического отката firewall", () => {
const script = buildAutoRollbackScript(OP_ID);
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const marker = script.indexOf(`touch "$root/auto-rollback-fired"`);
const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`);
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
@@ -105,12 +156,61 @@ describe("скрипт автоматического отката firewall", ()
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
// снятия взведённого таймера.
test("маркер создаётся даже когда восстанавливать нечего", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const marker = script.indexOf(`touch "$root/auto-rollback-fired"`);
const earlyExit = script.indexOf("nothing to restore");
expect(marker).toBeLessThan(earlyExit);
});
/**
* Отказ записи маркера обязан входить в учёт rc.
*
* Инвариант фиксации — «маркера нет и юниты inactive => guard не сработал» —
* верен только при дополнительном условии «guard способен записать маркер».
* Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs
* /run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал
* прежний firewall и завершался кодом 0, юнит уходил в inactive, маркера не
* было — и операция фиксировала успех после реально сработавшего отката.
*/
test("rc объявляется до создания маркера, а не после", () => {
const rcInit = script.indexOf("rc=0");
const mkdir = script.indexOf('mkdir -p "$root"');
const marker = script.indexOf(`touch "$root/auto-rollback-fired"`);
expect(rcInit).toBeGreaterThan(-1);
expect(rcInit).toBeLessThan(mkdir);
expect(rcInit).toBeLessThan(marker);
});
test("невозможность записать маркер поднимает код возврата", () => {
expect(script).toContain("failed to access the recovery root");
expect(script).toContain("failed to create the fired marker");
});
/**
* Маркер создаётся `touch`, а не `: >file`.
*
* Двоеточие — special builtin POSIX: ошибка перенаправления на нём обязана
* завершить неинтерактивный shell целиком. На Debian /bin/sh — это dash,
* который так и делает, поэтому при недоступном /run скрипт умер бы ДО
* восстановления firewall — то есть guard перестал бы делать ровно то, ради
* чего существует.
*/
test("маркер создаётся обычной командой, а не special builtin", () => {
expect(script).toContain('touch "$root/auto-rollback-fired"');
expect(script).not.toContain(': >"$root/auto-rollback-fired"');
});
// Аварийный канал факта срабатывания: если маркер записать не удалось, юнит
// обязан уйти в failed, а `failed` на пути фиксации успеха запрещён.
test("ранний выход возвращает накопленный код, а не ноль", () => {
const earlyExit = script.indexOf("nothing to restore");
const tail = script.slice(earlyExit);
expect(tail).toContain('exit "$rc"');
expect(script).not.toContain("exit 0");
});
test("ошибки не маскируются", () => {
expect(script).not.toContain("|| true");
expect(script).not.toContain("2>/dev/null");
@@ -153,6 +253,75 @@ describe("скрипт автоматического отката firewall", ()
expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/);
});
/**
* Поведенческая проверка раннего пути скрипта.
*
* Запускается ТОЛЬКО ветка «prepared отсутствует»: она заканчивается до
* первой команды восстановления, поэтому ничего в /etc не трогает и
* безопасна на любой машине. Именно в этой ветке живёт исправленный дефект —
* раньше она возвращала жёсткий `exit 0` и теряла факт неудачной записи
* маркера.
*
* Строка `root=` подменяется на временный каталог: это единственное
* изменение, остальные сорок строк — ровно те, что уезжают на сервер.
*/
function runEarlyPath(sandbox: string, root: string): { exitCode: number; stderr: string } | null {
const shell = findShell(sandbox);
if (!shell) {
return null;
}
const scriptPath = join(sandbox, "run.sh");
writeFileSync(
scriptPath,
script.replace(/^root='.*'$/m, `root='${root.replace(/\\/g, "/")}'`)
);
const result = spawnShell(shell, scriptPath);
return { exitCode: result.exitCode, stderr: result.stderr.toString() };
}
test("при доступном /run маркер создаётся, а ранний выход успешен", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-"));
try {
const root = join(sandbox, "rollback");
const result = runEarlyPath(sandbox, root);
if (!result) {
console.warn("shell is unavailable: skipping the behavioural check of the rollback script");
return;
}
expect(existsSync(join(root, "auto-rollback-fired"))).toBe(true);
expect(result.stderr).toContain("nothing to restore");
expect(result.exitCode).toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
// Ключевой сценарий исправления: маркер записать не удалось, восстановления
// не было — юнит обязан уйти в failed, потому что это аварийный канал факта
// срабатывания, и `failed` запрещает фиксацию успеха.
test("при недоступном /run ранний выход возвращает отказ", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-"));
try {
// Родитель — файл, поэтому ни mkdir, ни touch выполниться не могут.
const blocker = join(sandbox, "blocker");
writeFileSync(blocker, "не каталог\n");
const result = runEarlyPath(sandbox, join(blocker, "rollback"));
if (!result) {
console.warn("shell is unavailable: skipping the behavioural check of the rollback script");
return;
}
expect(result.stderr).toContain("auto-rollback: failed");
expect(result.exitCode).not.toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
test("разбирается настоящим shell-парсером", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
try {
@@ -288,6 +457,68 @@ describe("disarm доказывает снятие guard'а, а не сообщ
});
});
describe("барьер покоя между операциями", () => {
/**
* Стык двух защитных механизмов. Замок защищает production paths, пока жив
* процесс-держатель; rollback guard — отдельный systemd-объект, переживающий
* свой процесс. Аварийно умершая операция оставляет вооружённый guard,
* который возвращает прежний firewall уже посреди следующей операции.
*/
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertNoPendingRollbackGuard"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
test("вооружённый guard предыдущей операции запрещает новую", () => {
expect(body).toContain("PendingRecoveryError");
expect(body).toContain("readUnitProperty(unit, \"ActiveState\")");
});
// `failed` и `inactive` — покой: guard уже отработал и больше ничего не
// сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он
// нужен для устранения последствий.
test("покоем считаются inactive и failed, а не только inactive", () => {
expect(firewallSource).toContain(
'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const'
);
});
test("отказ запроса к systemd не выдаётся за наличие guard", () => {
const listing = firewallSource.slice(
firewallSource.indexOf("export async function listRollbackGuardUnits"),
firewallSource.indexOf("export async function assertNoPendingRollbackGuard")
);
expect(listing).toContain("unable to list firewall rollback guard units");
expect(listing).toContain("return [];");
});
test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {
const cliSource = source("cli.ts");
expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }");
// Обработчик сигналов снимает замок сам, поэтому у прерванной операции
// stale-замка может не быть вовсе, а таймер останется.
expect(cliSource).toContain("await assertNoPendingRollbackGuard()");
});
// Барьер вызывается дважды: между проверкой и захватом умирающая операция
// успевает вооружить guard.
test("барьер проверяется до и после захвата замка", () => {
const lockSource = source("lib/operationLock.ts");
const start = lockSource.indexOf("export async function acquireOperationLock");
const acquire = lockSource.slice(start);
const before = acquire.indexOf("await options.barrier?.()");
const write = acquire.indexOf("await writeLockFile(path, record)");
const after = acquire.indexOf("await options.barrier()");
expect(before).toBeGreaterThan(-1);
expect(write).toBeGreaterThan(before);
expect(after).toBeGreaterThan(write);
// Отказ второй проверки не имеет права оставить замок за собой.
expect(acquire.slice(after)).toContain("releaseSync(path, record.nonce)");
});
});
describe("сработавший guard запрещает фиксацию успеха", () => {
function ownership(overrides: Record<string, boolean> = {}) {
return {
+92 -2
View File
@@ -139,6 +139,86 @@ describe("захват и освобождение", () => {
});
});
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(
@@ -235,7 +315,7 @@ describe("политика замка в CLI", () => {
test("мутирующие команды выполняются под замком", () => {
for (const command of ["install", "reconfigure", "repair"] as const) {
expect(cliSource).toContain(`await withOperationLock("${command}", () => ${command}(options))`);
expect(cliSource).toContain(`await runLifecycleOperation("${command}", () => ${command}(options))`);
}
});
@@ -245,7 +325,17 @@ describe("политика замка в CLI", () => {
* бессмысленные ошибки по временным несоответствиям.
*/
test("doctor исключён против мутирующих операций", () => {
expect(cliSource).toContain('await withOperationLock("doctor", () => doctor(options))');
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);
});
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды