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:
@@ -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 не существовал.
|
||||
|
||||
Reference in New Issue
Block a user