From 50ec4d97171cec67f73f1022f49776a1e22c8948 Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 31 Aug 2026 01:25:29 +0500 Subject: [PATCH] =?UTF-8?q?build(acceptance):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инварианты, добавленные двумя предыдущими коммитами, обязаны жить в сборке, а не в намерении. - скан заглушённых ошибок отката расширен на остановку 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, безопасное переиспользование замка мёртвого держателя; - формула ключа операции обязана существовать в одном файле. --- tools/build/lib/acceptance.sh | 203 +++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 7c31574..6cba3ec 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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 .timer .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"