build(acceptance): закрепить границы транзакции гейтами сборки
Инварианты, добавленные двумя предыдущими коммитами, обязаны жить в сборке, а не в намерении. - скан заглушённых ошибок отката расширен на остановку rollback guard: именно этот диапазон он не покрывал, поэтому `systemctl stop ... || true` прожил дольше всех остальных `|| true` в откате. Заодно покрыт rollbackFirewallNow целиком — правая граница null означает "до конца файла"; - скрипт автоотката проверяется как текст: маркер срабатывания первым действием, отсутствие маскировки, накопление rc, отказ трогать nftables.service; - снятие guard обязано проверять маркер с обеих сторон остановки и подтверждаться ActiveState, а не кодом возврата systemctl; - сработавший guard обязан иметь собственный тип ошибки и собственную причину отказа, и классифицироваться по типу, а не по тексту; - smoke обязан сверять эффективный firewall, а не только разбирать файл, и делать это read-only раннерами: та же проверка выполняется в doctor; - порядок стадий отката firewall проверяется явно: ExecStop у nftables.service делает `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти до применения ruleset; - политика замка операций: кто берёт, кто не берёт, снятие в finally и по SIGINT/SIGTERM/SIGHUP, безопасное переиспользование замка мёртвого держателя; - формула ключа операции обязана существовать в одном файле.
This commit is contained in:
@@ -559,6 +559,7 @@ run_clean_install_acceptance() {
|
||||
! grep -q 'purge-v0' "$package_dir/install.sh" \
|
||||
|| fail "acceptance: the installer must never run destructive cleanup on its own"
|
||||
|
||||
run_transaction_boundary_acceptance
|
||||
run_single_owner_acceptance "$package_dir"
|
||||
run_secret_channel_acceptance
|
||||
run_atomic_import_acceptance
|
||||
@@ -983,12 +984,20 @@ run_single_owner_acceptance() {
|
||||
["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"],
|
||||
["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"],
|
||||
["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"],
|
||||
["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "function stripNftComments"]
|
||||
["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "async function readNftablesServiceState"],
|
||||
// Остановка rollback guard — та же категория. Скан её не покрывал,
|
||||
// поэтому `systemctl stop ... || true` внутри stopRollbackTimer прожил
|
||||
// дольше всех остальных заглушённых ошибок отката.
|
||||
["orchestrator/src/steps/firewall.ts", "async function stopRollbackGuard", "export async function disarmFirewallRollback"],
|
||||
// null как правая граница означает «до конца файла»: rollbackFirewallNow
|
||||
// объявлена последней, и придумывать ей искусственный якорь означало бы
|
||||
// ломать скан при каждой перестановке функций.
|
||||
["orchestrator/src/steps/firewall.ts", "export async function rollbackFirewallNow", null]
|
||||
];
|
||||
for (const [file, from, to] of checks) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const start = source.indexOf(from);
|
||||
const end = source.indexOf(to);
|
||||
const end = to === null ? source.length : source.indexOf(to);
|
||||
if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file);
|
||||
const muted = source.slice(start, end).split(/\r?\n/)
|
||||
.filter((line) => line.includes("runMutatingVisible`"))
|
||||
@@ -1294,6 +1303,196 @@ run_atomic_import_acceptance() {
|
||||
done
|
||||
}
|
||||
|
||||
# Границы транзакции операции: снятие guard'а и взаимное исключение операций.
|
||||
run_transaction_boundary_acceptance() {
|
||||
log_step "Acceptance: disarming the firewall guard is proven, not announced"
|
||||
# Снятие автоматического отката было утверждением, а не фактом:
|
||||
#
|
||||
# systemctl stop <unit>.timer <unit>.service || true
|
||||
# -> "firewall rollback timer disarmed"
|
||||
# -> phase=installed
|
||||
#
|
||||
# Отказ остановки стирался, и взведённый таймер мог вернуть прежний firewall
|
||||
# уже после долговечной записи успеха.
|
||||
grep -q 'export function buildAutoRollbackScript' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: скрипт автоотката снова собирается на месте вместо отдельной проверяемой функции"
|
||||
grep -q 'auto-rollback-fired' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: маркер срабатывания guard'а отсутствует"
|
||||
! grep -q 'stopRollbackTimer' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: вернулась stopRollbackTimer, снимавшая guard без доказательства"
|
||||
grep -q 'export async function runMutatingStatus' orchestrator/src/lib/process.ts \
|
||||
|| fail "acceptance: нет раннера, возвращающего код возврата вместо броска; снятие guard'а снова требует '|| true'"
|
||||
|
||||
# Скрипт автоотката проверяется как ТЕКСТ: скан заглушённых ошибок смотрит
|
||||
# только на строки с runMutatingVisible и до содержимого скрипта не достаёт.
|
||||
"$BUN_BIN" -e '
|
||||
const { buildAutoRollbackScript } = await import("./orchestrator/src/steps/firewall.ts");
|
||||
const script = buildAutoRollbackScript("2026-01-01T00-00-00.000Z");
|
||||
|
||||
const marker = script.indexOf("auto-rollback-fired");
|
||||
const prepared = script.indexOf("$root/prepared");
|
||||
if (marker < 0 || prepared < 0) throw new Error("в скрипте нет маркера срабатывания или проверки prepared");
|
||||
if (marker > prepared) throw new Error("маркер срабатывания создаётся не первым действием");
|
||||
|
||||
for (const masked of ["|| true", "2>/dev/null", ">/dev/null 2>&1"]) {
|
||||
if (script.includes(masked)) throw new Error("скрипт автоотката снова маскирует ошибки: " + masked);
|
||||
}
|
||||
if (!script.includes("exit \"$rc\"")) throw new Error("скрипт автоотката не возвращает накопленный код");
|
||||
|
||||
const failures = script.split("auto-rollback: failed").length - 1;
|
||||
const raised = script.split("rc=1").length - 1;
|
||||
if (failures === 0 || failures !== raised) {
|
||||
throw new Error("не каждый сообщённый отказ поднимает код возврата: " + failures + " != " + raised);
|
||||
}
|
||||
// ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса
|
||||
// внутри guard стёрла бы только что восстановленные правила.
|
||||
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
|
||||
' || fail "acceptance: скрипт автоматического отката firewall нарушает свой контракт"
|
||||
|
||||
# Снятие guard'а обязано опираться на наблюдаемое состояние юнитов и на
|
||||
# маркер, а не на код возврата systemctl: для уже отработавшего транзиентного
|
||||
# юнита `systemctl stop` возвращает 5 — исход, неотличимый от успеха.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
|
||||
const start = source.indexOf("async function stopRollbackGuard");
|
||||
const end = source.indexOf("export async function disarmFirewallRollback");
|
||||
if (start < 0 || end < 0) throw new Error("не найдена функция снятия guard'"'"'а");
|
||||
const body = source.slice(start, end);
|
||||
const before = body.indexOf("before stopping the rollback guard");
|
||||
const stop = body.indexOf("runMutatingStatus`systemctl stop");
|
||||
const after = body.indexOf("after stopping the rollback guard");
|
||||
if (before < 0 || stop < 0 || after < 0) throw new Error("маркер проверяется не с обеих сторон остановки");
|
||||
if (!(before < stop && stop < after)) throw new Error("порядок обязан быть маркер -> stop -> маркер");
|
||||
if (!body.includes("readUnitProperty(target, \"ActiveState\")")) {
|
||||
throw new Error("снятие guard'"'"'а не подтверждается состоянием юнитов");
|
||||
}
|
||||
if (!source.includes("const GUARD_STOPPED_STATES_FOR_COMMIT = [\"inactive\"] as const")) {
|
||||
throw new Error("на пути фиксации успеха допустимо не только inactive");
|
||||
}
|
||||
' || fail "acceptance: снятие guard'а обязано быть доказуемым"
|
||||
|
||||
log_step "Acceptance: a fired guard forbids the durable commit"
|
||||
# Классификация по ТИПУ ошибки, а не по её тексту: разбор сообщения
|
||||
# регулярным выражением уже убирали и из install, и из reconfigure.
|
||||
grep -q 'export class FirewallGuardFiredError' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: сработавший guard не имеет собственного типа ошибки"
|
||||
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/install.ts \
|
||||
|| fail "acceptance: install не отличает сработавший guard от прочих отказов"
|
||||
grep -q 'error instanceof FirewallGuardFiredError' orchestrator/src/commands/reconfigure.ts \
|
||||
|| fail "acceptance: reconfigure не отличает сработавший guard от прочих отказов"
|
||||
grep -q 'classifyFailure(ownership, phase, error)' orchestrator/src/commands/install.ts \
|
||||
|| fail "acceptance: классификация отказа install не получает саму ошибку"
|
||||
|
||||
log_step "Acceptance: smoke verifies the effective firewall, not only its syntax"
|
||||
# `nft -c` разбирает текущий файл, каким бы он ни был. Откатившийся прежний
|
||||
# ruleset проходил эту проверку зелёным.
|
||||
grep -q 'assertEffectiveFirewallIsOurs' orchestrator/src/steps/smoke.ts \
|
||||
|| fail "acceptance: smoke не сверяет эффективный firewall с конфигурацией операции"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
|
||||
const syntax = source.indexOf("nft -c -f /etc/nftables.conf");
|
||||
const semantic = source.indexOf("assertEffectiveFirewallIsOurs(context)");
|
||||
if (syntax < 0 || semantic < 0) throw new Error("не найдены проверки firewall в smoke");
|
||||
if (semantic < syntax) throw new Error("семантическая проверка идёт раньше разбора файла");
|
||||
const firewall = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
|
||||
const start = firewall.indexOf("export async function assertEffectiveFirewallIsOurs");
|
||||
const end = firewall.indexOf("function firewallRollbackIsInactive");
|
||||
const body = firewall.slice(start, end);
|
||||
for (const claim of ["await renderHy2xsNft(context)", "hy2xs-managed", "nft list table inet hy2xs"]) {
|
||||
if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim);
|
||||
}
|
||||
// Проверка выполняется и в doctor, то есть под read-only guard.
|
||||
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"
|
||||
grep -q 'async function cleanupFirewallCandidates' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: candidate-файлы firewall снова остаются на диске навсегда"
|
||||
grep -q 'renderNftablesServiceState' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: состояние nftables.service не сохраняется и не восстанавливается откатом"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8");
|
||||
const body = source.slice(source.indexOf("export async function rollbackFirewallNow"));
|
||||
const unitFile = body.indexOf("restore nftables.service unit file state");
|
||||
const inactive = body.indexOf("restore nftables.service inactive state");
|
||||
const ruleset = body.indexOf("apply restored ruleset");
|
||||
if (unitFile < 0 || inactive < 0 || ruleset < 0) throw new Error("не найдены стадии восстановления сервиса");
|
||||
// ExecStop у nftables.service делает `nft flush ruleset`, поэтому
|
||||
// восстановление состояния сервиса обязано идти ДО применения ruleset.
|
||||
if (!(unitFile < inactive && inactive < ruleset)) {
|
||||
throw new Error("остановка сервиса идёт после применения ruleset и сотрёт его");
|
||||
}
|
||||
' || fail "acceptance: порядок восстановления firewall стирает восстановленные правила"
|
||||
|
||||
log_step "Acceptance: lifecycle operations are serialized by an exclusive lock"
|
||||
# install-state.json замком не является: два одновременных reconfigure писали
|
||||
# одни и те же production paths, а уникальные op-id разделяли только копии.
|
||||
[ -f orchestrator/src/lib/operationLock.ts ] \
|
||||
|| fail "acceptance: модуль взаимного исключения операций отсутствует"
|
||||
grep -q '/run/lock/hy2xs-orchestrator.lock' orchestrator/src/lib/operationLock.ts \
|
||||
|| fail "acceptance: замок операций не объявлен в /run/lock"
|
||||
grep -q 'assertMutationAllowed' orchestrator/src/lib/operationLock.ts \
|
||||
|| fail "acceptance: захват замка не спрашивает разрешения у read-only guard'а"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/cli.ts", "utf8");
|
||||
for (const command of ["install", "reconfigure", "repair", "doctor"]) {
|
||||
if (!source.includes("await withOperationLock(\"" + command + "\", () => " + command + "(options))")) {
|
||||
throw new Error("команда выполняется без замка: " + command);
|
||||
}
|
||||
if (source.includes("await " + command + "(options)")) {
|
||||
throw new Error("команда вызывается мимо замка: " + command);
|
||||
}
|
||||
}
|
||||
// Отказ обязан произойти до первой проверки PHASE 0, а не после exec.
|
||||
const assertAt = source.indexOf("await assertNoOperationInProgress(\"install preflight\")");
|
||||
const preflightAt = source.indexOf("await preflightInstall(options)");
|
||||
if (assertAt < 0 || preflightAt < 0) throw new Error("PHASE 0 не проверяет идущую операцию");
|
||||
if (assertAt > preflightAt) throw new Error("PHASE 0 проверяет замок после собственных проверок");
|
||||
// status и diagnostics нужны в том числе во время долгой операции.
|
||||
for (const observer of ["status", "diagnostics"]) {
|
||||
if (source.includes("withOperationLock(\"" + observer + "\"")) {
|
||||
throw new Error("наблюдающая команда берёт эксклюзивный замок: " + observer);
|
||||
}
|
||||
}
|
||||
if (!source.includes("await describeOperationInProgress()")) {
|
||||
throw new Error("наблюдающие команды не сообщают об идущей операции");
|
||||
}
|
||||
' || fail "acceptance: политика взаимного исключения операций нарушена"
|
||||
|
||||
# Замок обязан сниматься при любом завершении: обрыв SSH (SIGHUP) не имеет
|
||||
# права заблокировать сервер до перезагрузки.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/lib/operationLock.ts", "utf8");
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
if (!source.includes(signal)) throw new Error("замок не снимается по сигналу " + signal);
|
||||
}
|
||||
if (!source.includes("process.on(\"exit\"")) throw new Error("замок не снимается при выходе процесса");
|
||||
if (!source.includes("} finally {")) throw new Error("замок снимается не в finally");
|
||||
// Снятие замка мёртвого держателя обязано идти через увод файла со сверкой
|
||||
// nonce: unlink на месте способен снять уже живой замок.
|
||||
if (!source.includes("async function reclaimStaleLock")) {
|
||||
throw new Error("нет безопасного переиспользования замка мёртвого держателя");
|
||||
}
|
||||
if (!source.includes("stolen.nonce !== observed.nonce")) {
|
||||
throw new Error("переиспользование замка не сверяет, что уводит именно мёртвый замок");
|
||||
}
|
||||
' || fail "acceptance: жизненный цикл замка операций нарушен"
|
||||
|
||||
log_step "Acceptance: the operation key has a single source"
|
||||
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
|
||||
# назывался санитизированным ключом: путь из runbook не существовал.
|
||||
grep -q 'export function operationKeyFor' orchestrator/src/steps/firewall.ts \
|
||||
|| fail "acceptance: ключ операции не объявлен единственной функцией"
|
||||
grep -q 'opId: operationKeyFor(context.installDate)' orchestrator/src/commands/install.ts \
|
||||
|| fail "acceptance: install снова пишет в маркер собственный вариант ключа операции"
|
||||
# Формула живёт ровно в одном файле — там, где объявлена operationKeyFor.
|
||||
local key_formula
|
||||
key_formula="$(grep -rl 'installDate.replace(' orchestrator/src \
|
||||
| grep -v 'orchestrator/src/steps/firewall.ts' || true)"
|
||||
[ -z "$key_formula" ] \
|
||||
|| fail "acceptance: формула ключа операции продублирована в: $key_formula"
|
||||
}
|
||||
|
||||
# Compatibility-слой предыдущего поколения не должен пережить 1.0.
|
||||
run_legacy_account_acceptance() {
|
||||
log_step "Acceptance: the legacy account layer is gone from the runtime"
|
||||
|
||||
Reference in New Issue
Block a user