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
+87
View File
@@ -1344,6 +1344,25 @@ run_transaction_boundary_acceptance() {
if (failures === 0 || failures !== raised) {
throw new Error("не каждый сообщённый отказ поднимает код возврата: " + failures + " != " + raised);
}
// Создание маркера обязано входить в учёт rc. Инвариант фиксации
// "маркера нет и юниты inactive => guard не сработал" верен только при
// условии, что guard способен маркер записать. Пока rc=0 стояло ПОСЛЕ
// создания маркера, отказ записи не влиял ни на что: скрипт восстанавливал
// firewall, завершался нулём, и операция фиксировала успех после реально
// сработавшего отката.
const rcInit = script.indexOf("rc=0");
if (rcInit < 0 || rcInit > marker) throw new Error("rc объявляется после создания маркера");
if (script.includes("exit 0")) throw new Error("ранний выход теряет накопленный код возврата");
if (!script.includes("exit \"$rc\"")) throw new Error("ранний выход не возвращает rc");
// touch, а не `: >file`: двоеточие — special builtin POSIX, и ошибка
// перенаправления на нём обязана завершить неинтерактивный shell целиком.
// В dash, который на Debian и есть /bin/sh, скрипт умер бы ДО
// восстановления firewall.
if (script.includes(": >\"$root/auto-rollback-fired\"")) {
throw new Error("маркер создаётся перенаправлением на special builtin");
}
// ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса
// внутри guard стёрла бы только что восстановленные правила.
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
@@ -1478,6 +1497,74 @@ run_transaction_boundary_acceptance() {
}
' || fail "acceptance: жизненный цикл замка операций нарушен"
log_step "Acceptance: a lifecycle operation waits for the previous one to become quiescent"
# Стык двух защитных механизмов. Замок защищает production paths, пока жив
# процесс-держатель; rollback guard firewall — отдельный systemd-объект,
# переживающий свой процесс. Аварийно умершая операция оставляла вооружённый
# guard, который возвращал прежний firewall уже посреди следующей операции.
# Уникальные op-id тут не помогают: каталоги копий разные, а
# /etc/nftables.conf и ruleset в ядре общие.
grep -q 'export async function assertNoPendingRollbackGuard' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
"$BUN_BIN" -e '
const fs = require("node:fs");
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
if (!cli.includes("{ barrier: assertNoPendingRollbackGuard }")) {
throw new Error("захват замка идёт без барьера покоя");
}
// Единственное употребление withOperationLock — внутри
// runLifecycleOperation: иначе появился бы путь захвата мимо барьера.
const direct = cli.split("withOperationLock(").length - 1;
if (direct !== 1) throw new Error("замок берётся мимо runLifecycleOperation: " + direct + " употреблений");
if (!cli.includes("await assertNoPendingRollbackGuard()")) {
throw new Error("PHASE 0 не проверяет вооружённый guard предыдущей операции");
}
// Барьер обязан вызываться ДО захвата и ещё раз ПОСЛЕ: между этими
// моментами умирающая предыдущая операция успевает вооружить guard.
const lock = fs.readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
const acquire = lock.slice(lock.indexOf("export async function acquireOperationLock"));
const before = acquire.indexOf("await options.barrier?.()");
const write = acquire.indexOf("await writeLockFile(path, record)");
const after = acquire.indexOf("await options.barrier()");
if (before < 0 || write < 0 || after < 0) throw new Error("барьер проверяется не с обеих сторон захвата");
if (!(before < write && write < after)) throw new Error("порядок обязан быть барьер -> захват -> барьер");
if (!acquire.slice(after).includes("releaseSync(path, record.nonce)")) {
throw new Error("отказ второй проверки оставляет замок за собой");
}
// Покой — это inactive и failed: отработавший guard больше ничего не
// сделает, а отказ по failed заблокировал бы repair, которым чинят
// последствия.
const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
if (!firewall.includes("const GUARD_PENDING_STATES = [\"active\", \"activating\", \"deactivating\", \"reloading\"] as const")) {
throw new Error("набор непокойных состояний guard изменился без пересмотра барьера");
}
' || fail "acceptance: барьер покоя между операциями нарушен"
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
# enabled-runtime таким вызовом обещало точность, которой не давало.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
const body = source.slice(source.indexOf("restore nftables.service unit file state"));
const end = body.indexOf("restore nftables.service inactive state");
const stage = body.slice(0, end);
for (const guessed of ["enable --runtime", "systemctl mask"]) {
if (stage.includes(guessed)) {
throw new Error("восстановление UnitFileState снова обещает недостижимую точность: " + guessed);
}
}
if (!stage.includes("case \"enabled\":") || !stage.includes("case \"disabled\":")) {
throw new Error("не восстанавливаются состояния, которые операция реально меняет");
}
if (!stage.includes("is left as is")) {
throw new Error("невосстановимое состояние не называется оператору явно");
}
' || fail "acceptance: восстановление состояния nftables.service нарушает свой контракт"
log_step "Acceptance: the operation key has a single source"
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
# назывался санитизированным ключом: путь из runbook не существовал.