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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды
|
||||
|
||||
Reference in New Issue
Block a user