firewall guard: барьер покоя fail-closed и явный контракт транзиентного таймера

Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах
принимал за доказательство отсутствие наблюдения.

- отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []`
  введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/
  unknown и отдельным типом отказа GuardStateUnknownError;
- покой перечисляется белым списком (inactive, failed): maintenance,
  refreshing и любое незнакомое состояние systemd блокируют операцию;
- у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min
  превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер
  дополнительно опознаёт SubState=elapsed у *.timer как покой;
- команда взведения строится чистой buildArmGuardArgv и выполняется новым
  runMutatingArgv без shell, поэтому её контракт проверяется значением, а не
  грепом по исходнику;
- status перестал листить guard-юниты своей копией кода: без --plain, с
  `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил
  барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown;
- purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке.

Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe:
прежние проверки грепом по тексту функции пережили инверсию смысла - строка
`return [];` была на месте, а решение стало неверным.

Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному
окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий
PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф.
This commit is contained in:
2026-08-31 16:44:20 +05:00
parent 76d78ac71f
commit 2259f7c847
14 changed files with 1115 additions and 118 deletions
+75 -10
View File
@@ -380,9 +380,15 @@ run_clean_install_acceptance() {
|| fail "acceptance: process.ts must expose an explicit mutating runner"
! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \
|| fail "acceptance: the pre-split runner names must not come back"
local unguarded_runner
unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$unguarded_runner" -ge 4 ] \
# Порог считается от числа мутирующих раннеров, а не задан константой: новый
# раннер обязан приносить с собой и проверку guard'а, а не проходить под
# запасом, оставленным предыдущей правкой.
local mutating_runners guarded_runners
mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)"
guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)"
[ "$mutating_runners" -ge 5 ] \
|| fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)"
[ "$guarded_runners" -gt "$mutating_runners" ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
@@ -413,7 +419,7 @@ run_clean_install_acceptance() {
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const calls = source.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("//"))
.filter((line) => /\brunMutating[A-Za-z]*`/.test(line));
.filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line));
if (calls.length !== 1) {
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
}
@@ -1421,7 +1427,7 @@ run_transaction_boundary_acceptance() {
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
}
// Проверка выполняется и в doctor, то есть под read-only guard.
if (/runMutating[A-Za-z]*`/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему");
' || fail "acceptance: проверка эффективного firewall нарушает свой контракт"
log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation"
@@ -1508,6 +1514,11 @@ run_transaction_boundary_acceptance() {
|| fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла"
grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа"
# «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и
# оператору по ним нужны разные действия: подождать окно отката либо чинить
# systemd. Один тип на оба означал бы, что различие существует только в тексте.
grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \
|| fail "acceptance: у недоказуемого состояния guard'а нет собственного типа"
"$BUN_BIN" -e '
const fs = require("node:fs");
const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8");
@@ -1535,15 +1546,69 @@ run_transaction_boundary_acceptance() {
throw new Error("отказ второй проверки оставляет замок за собой");
}
// Покой — это inactive и failed: отработавший guard больше ничего не
// сделает, а отказ по failed заблокировал бы repair, которым чинят
// последствия.
// Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое
// состояние, которого автор не назвал, — включая те, которых он не знал:
// systemd 257 знает maintenance и refreshing помимо перечислявшихся.
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 изменился без пересмотра барьера");
if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) {
throw new Error("политика покоя guard изменилась без пересмотра барьера");
}
if (firewall.includes("GUARD_PENDING_STATES")) {
throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем");
}
// Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя.
// Прежний код возвращал пустой список, то есть принимал невозможность
// получить доказательство за положительный результат.
const inspectStart = firewall.indexOf("export async function inspectRollbackGuard");
const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard");
if (inspectStart < 0 || inspectEnd < inspectStart) {
throw new Error("нет единого наблюдателя состояния guard перед барьером");
}
const inspect = firewall.slice(inspectStart, inspectEnd);
if (inspect.includes("return [];")) {
throw new Error("барьер снова fail-open при отказе systemctl");
}
for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) {
if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome);
}
// Второй копии листинга быть не должно: status расходился с барьером по
// --plain, по `|| true` и по трактовке failed-юнита.
const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8");
if (status.includes("list-units")) {
throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя");
}
if (!status.includes("await inspectRollbackGuard()")) {
throw new Error("status не берёт состояние guard у общего наблюдателя");
}
' || fail "acceptance: барьер покоя между операциями нарушен"
log_step "Acceptance: the transient guard timer states its window and its cleanup"
# `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает
# себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard,
# про который README и docs говорят «45 секунд», без явного значения имел
# контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от
# выгрузки отработавшего таймера зависит право барьера считать его отсутствие
# покоем, и держать этот инвариант на чужом умолчании нельзя.
"$BUN_BIN" -e '
const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts");
const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z");
for (const expected of [
"--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service",
"--on-active=45s",
"--timer-property=RemainAfterElapse=no",
"--timer-property=AccuracySec=1s"
]) {
if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected);
}
// Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой
// команде не участвует, поэтому единственная защита — отказ.
let rejected = false;
try { buildArmGuardArgv("op id"); } catch { rejected = true; }
if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard");
' || fail "acceptance: контракт транзиентного таймера guard нарушен"
log_step "Acceptance: nftables.service restore claims only what it can guarantee"
# `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление"
# enabled-runtime таким вызовом обещало точность, которой не давало.