From 76d78ac71fefdb2fa2ed97af640881d81abde138 Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 31 Aug 2026 03:30:14 +0500 Subject: [PATCH] =?UTF-8?q?fix(orchestrator):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=D0=B4=D0=B2=D0=B0=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=8B?= =?UTF-8?q?=D0=BA=D0=B5=20guard=20=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к гарантиям, ради которых эти механизмы вводились. 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) — там же. --- CHANGELOG.md | 39 ++++ README.md | 9 + docs/07-systemd-and-firewall.md | 28 +++ docs/11-testing-and-acceptance.md | 81 +++++++- docs/12-operations-and-troubleshooting.md | 27 +++ docs/13-production-runbook.md | 16 ++ orchestrator/src/cli.ts | 23 ++- orchestrator/src/lib/operationLock.ts | 30 +++ orchestrator/src/steps/firewall.ts | 179 ++++++++++++++-- orchestrator/test/firewall-guard.test.ts | 237 +++++++++++++++++++++- orchestrator/test/operation-lock.test.ts | 94 ++++++++- tools/build/lib/acceptance.sh | 87 ++++++++ 12 files changed, 820 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3238777..1d1e234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,45 @@ Hardening-проход перед релизом `1.0.0`. Основная те отказавших, но принцип «восстановить максимум» на уровне файлов не выполнялся. +- **Отказ записи маркера `auto-rollback-fired` оставался незамеченным.** + Инвариант фиксации — «маркера нет и юниты `inactive` ⇒ guard не сработал» — + верен только при дополнительном условии «guard способен записать маркер». + Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs + `/run`, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал + прежний firewall, завершался кодом 0, юнит уходил в `inactive`, маркера не + было — и операция фиксировала успех после реально сработавшего отката. + Теперь у факта срабатывания два независимых канала: маркер и отказ юнита. + + Отдельно: маркер создаётся `touch`, а не `: >file`. Двоеточие — special + builtin POSIX, и ошибка перенаправления на нём обязана завершить + неинтерактивный shell целиком; в dash, который на Debian и есть `/bin/sh`, + скрипт умер бы ДО восстановления firewall. + +- **Новая операция могла начаться, пока guard предыдущей ещё вооружён.** Замок + и guard вводились по отдельности и оставляли дыру на своём стыке. Замок + действует, пока жив процесс-держатель; guard — отдельный объект systemd, + который свой процесс переживает: + + ```text + A берёт замок -> применяет firewall -> вооружает guard на 45 секунд + A аварийно умирает + B берёт замок и начинает менять production paths + guard A срабатывает и возвращает firewall, который был ДО A + ``` + + Случай с `SIGTERM`/`SIGHUP` при этом хуже, чем `kill -9`: обработчик снимает + замок сам, поэтому проверка живости держателя не видит вообще ничего, а + таймер остаётся. Введён барьер покоя, через который проходит каждый захват + замка — и PHASE 0 установщика тоже. Условие старта стало «у предыдущей + операции не осталось исполнителей, способных изменить систему». + +- **Восстановление `UnitFileState` обещало точность, которой не давало.** + `systemctl enable --runtime` не удаляет постоянную ссылку, поэтому + «восстановление» состояния `enabled-runtime` оставляло юнит включённым в + обоих scope'ах. Теперь восстанавливаются `enabled` и `disabled` — состояния, + которые операция реально меняет, — а остальные явно называются оператору и + не трогаются. + ### Исправлено — целостность отката - **Данные для отката уничтожались до фиксации успеха.** Успешный install diff --git a/README.md b/README.md index 8fa0c5c..30a98f8 100644 --- a/README.md +++ b/README.md @@ -693,6 +693,15 @@ another HY2XS operation is already in progress: reconfigure (pid 4242, started a Замок снимается сам при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH. Если процесс был убит `kill -9`, следующая операция обнаружит мёртвого держателя и переиспользует замок самостоятельно. +Замка при этом недостаточно: он действует, пока жив процесс‑держатель, а rollback guard firewall — отдельный объект systemd, который свой процесс переживает. Аварийно умершая операция оставляет guard вооружённым, и он способен вернуть прежний firewall уже посреди следующей операции. Поэтому условие старта — не «предыдущая операция мертва», а «у неё не осталось исполнителей, способных изменить систему»: + +```text +previous HY2XS operation is no longer running, but its firewall rollback guard +is still armed: hy2xs-fw-rollback-.timer (active) +``` + +Ждать в этом случае нужно не дольше 45 секунд с момента применения firewall. + ## Реконфигурация После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run: diff --git a/docs/07-systemd-and-firewall.md b/docs/07-systemd-and-firewall.md index ea119d5..c6f354d 100644 --- a/docs/07-systemd-and-firewall.md +++ b/docs/07-systemd-and-firewall.md @@ -135,6 +135,13 @@ smoke: smoke на медленном, но исправном сервере м systemd, `stop` возвращает 5, и этот исход неотличим от успешного снятия взведённого таймера. +У факта срабатывания два независимых канала, и это не избыточность. Маркер — +обычный. Отказ юнита — аварийный: если записать маркер не удалось (заполненный +tmpfs `/run`, read-only ФС), скрипт поднимает код возврата, юнит уходит в +`failed`, а `failed` на пути фиксации успеха запрещён так же, как и маркер. +Без второго канала инвариант был бы верен лишь при дополнительном условии +«guard способен записать маркер», которого никто не гарантирует. + Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то @@ -142,6 +149,27 @@ systemd, `stop` возвращает 5, и этот исход неотличи active восстанавливает обычный откат в процессе оркестратора, где порядок стадий контролируется. +### Guard переживает свой процесс + +Guard — объект systemd, а не часть процесса оркестратора. Аварийно умершая +операция оставляет его вооружённым, и он способен вернуть прежний firewall уже +посреди **следующей** операции. Замок операций от этого не защищает: он +действует, пока жив процесс-держатель. + +Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у +предыдущей не осталось исполнителей, способных изменить систему». Каждый захват +замка проходит через барьер покоя: если хоть один `hy2xs-fw-rollback-*` находится +в состоянии `active`, `activating`, `deactivating` или `reloading`, операция +отказывает. + +`inactive` и `failed` считаются покоем: отработавший guard больше ничего не +сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот инструмент, +которым чинят последствия. + +Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер +сознательно не проверяет: незавершённый `systemctl restart` ничего не +откатывает, он лишь повторяет то, что новая операция сделает сама. + ### Проверка эффективного firewall `nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 45ccfb8..dbbe051 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -257,6 +257,18 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e` и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата, поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`; +- `rc` объявляется **до** создания маркера, а ранний выход возвращает его, а не + жёсткий `0`. Инвариант фиксации верен только при условии «guard способен + записать маркер»: пока `rc=0` стояло после, отказ записи (заполненный tmpfs + `/run`, read-only ФС) не влиял ни на что — скрипт восстанавливал прежний + firewall, завершался нулём, и операция фиксировала успех после реально + сработавшего отката. Теперь у факта два канала: маркер и отказ юнита; +- маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin POSIX, + и ошибка перенаправления на нём обязана завершить неинтерактивный shell + целиком, то есть в dash скрипт умер бы **до** восстановления firewall; +- поведенчески проверяется ранний путь скрипта — он заканчивается до первой + команды восстановления и потому безопасен для запуска: при доступном каталоге + маркер создаётся и выход нулевой, при недоступном — выход ненулевой; - скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и остановка сервиса стёрла бы только что восстановленные правила; - скрипт разбирается **настоящим** shell-парсером. Парсер принимается только @@ -294,6 +306,10 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся до включения guard, и проверка существует, чтобы перенос захвата внутрь читающей фазы отказал громко, а не записал файл молча; +- барьер покоя вызывается **дважды** — до захвата и уже под замком, — а отказ + второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт: + он защищает production paths, пока жив держатель, а rollback guard переживает + свой процесс; - политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor` вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают об идущей операции, а `preflight-install` отказывает до собственных проверок. @@ -1083,14 +1099,61 @@ reconfigure идёт -> diagnostics collect → в stderr есть note об идущей операции ``` -Отдельно проверяется, что замок не переживает своего держателя: +Отдельно проверяется, что замок не переживает своего держателя. **Важно:** +прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий +D1h, а не этот. -1. `reconfigure --apply` прерывается `Ctrl+C` — замок снят, следующий - `reconfigure` проходит; -2. процесс убивается `kill -9`, после чего следующая операция сообщает - `is held by … which is no longer running; reclaiming it` и продолжает; +1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation` — + замок снят, следующий `reconfigure` проходит; +2. процесс убивается `kill -9` на том же шаге, после чего следующая операция + сообщает `is held by … which is no longer running; reclaiming it` и + продолжает; 3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции. +## D1h. Аварийно умершая операция с вооружённым guard + +Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его +закрытия каждый из них по отдельности работал правильно, а вместе они +оставляли дыру. + +Замок защищает production paths, пока **жив процесс-держатель**. Rollback guard +firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому: + +```text +A берёт замок -> применяет firewall -> вооружает guard на 45 секунд +A аварийно умирает +B берёт замок (снятый обработчиком сигнала либо переиспользованный) +B начинает менять production paths +guard A срабатывает и возвращает firewall, который был ДО A +``` + +Уникальные `op-id` здесь не помогают: каталоги копий разные, а +`/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и ruleset в ядре — общие. + +Сценарий: + +1. `reconfigure --apply` доводится до появления в журнале + `firewall rollback guard armed`; +2. процесс убивается `kill -9` (замок остаётся устаревшим) — и, отдельным + прогоном, `kill -TERM` (замок снимается обработчиком, то есть его вообще не + будет; это и есть случай, который проверка живости держателя не ловит); +3. **до истечения 45 секунд** запускается `repair` или `reconfigure --apply`; +4. новая операция обязана отказать: + +```text +previous HY2XS operation is no longer running, but its firewall rollback guard +is still armed: hy2xs-fw-rollback-.timer (active) +``` + +5. отказ происходит **до** снятия резервной копии и до первой мутации; +6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине; +7. после срабатывания guard (`hy2xs-fw-rollback-*` больше не `active`) + `repair` проходит. + +Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну +операцию не задерживает, а `failed` от уже отработавшего guard **не** считается +непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия. + ## D1g. Успешная установка не оставляет следов транзакции Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка @@ -1204,6 +1267,11 @@ hy2xs-orchestrator doctor - `status`/`diagnostics` не блокируются и сообщают об идущей операции; - замок не переживает своего держателя. +4c. **Аварийная смерть с вооружённым guard** (сценарий D1h): + - новая операция отказывает, пока `hy2xs-fw-rollback-*` ещё активен, в том + числе когда замка не осталось вовсе; + - после срабатывания guard `repair` проходит. + 5. **Partial install + repair**: - состояние `install-state` фиксирует промежуточную фазу; - `repair` завершает граф до `installed=true`. @@ -1295,3 +1363,6 @@ hy2xs-orchestrator doctor 65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил 66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются 67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно +68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв» +69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает +70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index c359683..2611f82 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -197,6 +197,33 @@ operation lock … exists but is not a valid HY2XS lock record В отчёте `status` при этом появляется поле `operation_in_progress` — читайте состояние как снимок незавершённой транзакции, а не как итог. +### Операция отказывает: guard предыдущей операции ещё вооружён + +```text +previous HY2XS operation is no longer running, but its firewall rollback guard +is still armed: hy2xs-fw-rollback-.timer (active) +``` + +Предыдущая операция умерла аварийно **после** применения firewall. Её процесса +уже нет — замка может не быть тоже, — но rollback guard это отдельный объект +systemd, и он переживает свой процесс. Если начать новую операцию сейчас, guard +сработает посреди неё и вернёт firewall, существовавший до **предыдущей** +операции. + +Ничего делать не нужно, кроме как подождать: окно guard — 45 секунд с момента +применения firewall. + +```bash +# сколько ещё ждать и что именно висит +systemctl list-units --all 'hy2xs-fw-rollback-*' +hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package +``` + +Когда guard сработает, юнит перестанет быть `active`, и операция пройдёт. +Состояние `failed` у него покою не мешает: оно означает, что откат отработал +не полностью, и это как раз повод запустить `repair`, а не ждать дальше — +подробности в `journalctl -u 'hy2xs-fw-rollback-*'`. + ### Установка отказала с `firewall_guard_fired` ```text diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 6df2615..b10ef8a 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -161,6 +161,22 @@ SIGTERM от systemd и при обрыве SSH. Если процесс был operation lock … is held by install (pid 1234), which is no longer running; reclaiming it ``` +**Замка при этом недостаточно, и это важно.** Он действует, пока жив +процесс-держатель, а rollback guard firewall — отдельный объект systemd, +переживающий свой процесс. Аварийно умершая операция оставляет guard +вооружённым, и он способен вернуть прежний firewall уже посреди следующей +операции. Поэтому каждый захват замка проходит ещё и через барьер покоя: + +```text +previous HY2XS operation is no longer running, but its firewall rollback guard +is still armed: hy2xs-fw-rollback-.timer (active) +``` + +Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось +исполнителей, способных изменить систему». Ждать нужно не больше 45 секунд с +момента применения firewall; `failed` у guard покою не мешает и означает, что +пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`. + `/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае. Удалять файл руками нужно только если в нём оказалось непонятное содержимое: такой замок сознательно не переиспользуется автоматически — непонятый файл не diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index cb77f9e..5c56c85 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -10,6 +10,7 @@ import { describeOperationInProgress, withOperationLock } from "./lib/operationLock"; +import { assertNoPendingRollbackGuard } from "./steps/firewall"; import type { InstallOptions, ReconfigureOptions } from "./types/context"; function usage(): never { @@ -292,18 +293,32 @@ function parsePreflightInstallOptions(args: string[]): InstallOptions { * потратил бы время на проверки и получил * отказ уже после exec; * redact-config — работает с файлом, а не с сервером. + * + * Замка при этом НЕДОСТАТОЧНО, и это второе, что здесь собрано в одном месте. + * Замок защищает production paths, пока жив процесс-держатель, а rollback guard + * firewall — отдельный systemd-объект, переживающий свой процесс. Аварийно + * умершая операция оставляет вооружённый guard, который способен вернуть + * прежний firewall уже посреди следующей. Поэтому каждый захват замка проходит + * через барьер покоя, а не только через проверку живости держателя. */ +async function runLifecycleOperation(command: string, run: () => Promise): Promise { + return await withOperationLock(command, run, { barrier: assertNoPendingRollbackGuard }); +} + async function main(): Promise { const [command, ...args] = Bun.argv.slice(2); if (command === "preflight-install") { const options = parsePreflightInstallOptions(args); await assertNoOperationInProgress("install preflight"); + // PHASE 0 отказывает по тем же двум причинам, что и сама операция: чужой + // живой держатель замка и вооружённый guard предыдущей операции. + await assertNoPendingRollbackGuard(); await preflightInstall(options); return; } if (command === "install") { const options = parseInstallOptions(args); - await withOperationLock("install", () => install(options)); + await runLifecycleOperation("install", () => install(options)); return; } if (command === "reconfigure") { @@ -312,17 +327,17 @@ async function main(): Promise { console.error("--allow-partial-state is only valid for `repair`"); usage(); } - await withOperationLock("reconfigure", () => reconfigure(options)); + await runLifecycleOperation("reconfigure", () => reconfigure(options)); return; } if (command === "repair") { const options = parseReconfigureOptions(["--apply", ...args]); - await withOperationLock("repair", () => repair(options)); + await runLifecycleOperation("repair", () => repair(options)); return; } if (command === "doctor") { const options = parseReconfigureOptions(["--dry-run", ...args]); - await withOperationLock("doctor", () => doctor(options)); + await runLifecycleOperation("doctor", () => doctor(options)); return; } if (command === "status") { diff --git a/orchestrator/src/lib/operationLock.ts b/orchestrator/src/lib/operationLock.ts index 4ed73ad..1942dac 100644 --- a/orchestrator/src/lib/operationLock.ts +++ b/orchestrator/src/lib/operationLock.ts @@ -85,6 +85,20 @@ export type LockOptions = { isProcessAlive?: (pid: number) => boolean; /** PID текущего процесса. Переопределяется тестами. */ pid?: number; + /** + * Барьер покоя: проверка, что у предыдущей операции не осталось асинхронных + * исполнителей, способных изменить систему. + * + * Замок сам по себе такой гарантии не даёт и дать не может. Он защищает + * production paths, пока жив процесс-держатель, а rollback guard firewall — + * отдельный systemd-объект, который свой процесс переживает. Поэтому + * условие начала операции не «PID предыдущей мёртв», а «предыдущая больше не + * имеет исполнителей». + * + * Вызывается ДВАЖДЫ — до попытки захвата и сразу после успешного: между + * этими моментами умирающая предыдущая операция успевает вооружить guard. + */ + barrier?: () => Promise; }; export function renderLockRecord(record: LockRecord): string { @@ -337,12 +351,28 @@ export async function acquireOperationLock( nonce: newNonce() }; + // Барьер до захвата: отказать раньше, чем на сервере появится наш замок. + await options.barrier?.(); + mkdirSync(dirname(path), { recursive: true }); for (let attempt = 0; attempt < 2; attempt += 1) { if (await writeLockFile(path, record)) { installExitHandlers(); heldLocks.set(path, record.nonce); + + // И повторно — уже под замком. Окно между проверкой и захватом невелико, + // но именно в нём умирающая предыдущая операция успевает вооружить guard, + // а барьер существует ровно против этого. + if (options.barrier) { + try { + await options.barrier(); + } catch (error) { + releaseSync(path, record.nonce); + throw error; + } + } + info(`operation lock acquired: ${path} (${command}, pid ${pid})`); return { command, diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index 65573d3..6127519 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -71,12 +71,37 @@ export class FirewallGuardFiredError extends Error { } } +const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-"; + +/** + * Состояния, в которых guard ещё СПОСОБЕН изменить систему. + * + * `failed` и `inactive` сюда не входят намеренно. Guard, который уже отработал + * (успешно или нет), больше ничего не сделает, а отказавший юнит — это как раз + * повод запустить `repair`. Барьер, отказывающий по `failed`, блокировал бы + * ровно тот инструмент, которым чинят последствия. + */ +const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const; + +/** + * Предыдущая операция мертва, но её асинхронный исполнитель ещё жив. + * + * Отдельный тип, потому что это единственный отказ, который не про текущую + * операцию: она не сделала ничего плохого, ей просто нельзя начинать. + */ +export class PendingRecoveryError extends Error { + constructor(message: string) { + super(message); + this.name = "PendingRecoveryError"; + } +} + function rollbackRoot(opId: string): string { return `/run/hy2xs/rollback/${opId}`; } function rollbackUnit(opId: string): string { - return `hy2xs-fw-rollback-${opId}`; + return `${ROLLBACK_UNIT_PREFIX}${opId}`; } /** @@ -321,6 +346,26 @@ export async function detectFirewallEntrypointKind(): Promise guard не сработал + * + * верен только при дополнительном условии «guard способен записать маркер». + * Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный + * tmpfs /run, read-only ФС, ошибка ввода-вывода) не влиял ни на что: скрипт + * успешно восстанавливал прежний firewall и завершался кодом 0, юнит уходил + * в `inactive`, маркера не было — и операция фиксировала успех после + * РЕАЛЬНО сработавшего отката. + * + * Теперь у факта срабатывания два независимых канала: + * + * маркер — обычный; + * отказ юнита — аварийный, когда маркер записать не удалось. + * + * Второй работает потому, что на пути фиксации успеха допустим ровно один + * `ActiveState` — `inactive`, а `failed` фиксацию запрещает. + * * Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у * этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса * стёрла бы только что восстановленные правила — прямо противоположно задаче @@ -344,18 +389,38 @@ export function buildAutoRollbackScript(opId: string): string { root='${root}' +# rc объявляется ДО первой операции, включая создание маркера срабатывания. +# +# Иначе отказ записи маркера не влиял бы ни на что: скрипт успешно восстановил +# бы прежний firewall и завершился кодом 0, а операция, не увидев маркера и +# увидев inactive-юнит, зафиксировала бы успех после реально сработавшего +# отката. Отказ юнита — аварийный канал того же факта. +rc=0 + # Маркер срабатывания — первым действием, до любой проверки. Операция обязана # узнать, что guard сработал, даже если восстановление ниже не удалось. -mkdir -p "$root" -: >"$root/${AUTO_ROLLBACK_FIRED_MARKER}" +if ! mkdir -p "$root"; then + echo "hy2xs auto-rollback: failed to access the recovery root $root" >&2 + rc=1 +fi +# touch, а НЕ \`: >file\`. +# +# Двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана +# завершить неинтерактивный shell целиком. В dash, который на Debian и есть +# /bin/sh, это означало бы, что при недоступном /run скрипт умирает ДО +# восстановления firewall — то есть guard перестаёт делать ровно то, ради чего +# существует. touch — обычная внешняя команда, её код возврата просто +# возвращается в if. +if ! touch "$root/${AUTO_ROLLBACK_FIRED_MARKER}"; then + echo "hy2xs auto-rollback: failed to create the fired marker in $root" >&2 + rc=1 +fi if [ ! -f "$root/prepared" ]; then echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2 - exit 0 + exit "$rc" fi -rc=0 - # $1 — маркер существования, $2 — резервная копия, $3 — целевой путь. restore_file() { if [ -f "$1" ]; then @@ -571,6 +636,81 @@ export async function assertEffectiveFirewallIsOurs(context: RuntimeContext): Pr } } +/** + * Транзиентные юниты guard, которые сейчас известны systemd. + * + * Отказ самого запроса не считается доказательством наличия guard: без systemd + * не может быть и транзиентного таймера, а требование systemd живёт в + * preflight, где отказ будет понятнее и точнее. + */ +export async function listRollbackGuardUnits(): Promise { + let listed: string; + try { + listed = await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`; + } catch (error) { + info( + `unable to list firewall rollback guard units: ${error instanceof Error ? error.message : String(error)}` + ); + return []; + } + + return listed + .split("\n") + .map((line) => line.trim().split(/\s+/)[0] ?? "") + .filter((unit) => unit.startsWith(ROLLBACK_UNIT_PREFIX)); +} + +/** + * Барьер покоя: у предыдущей операции не осталось асинхронных исполнителей. + * + * Замок операций и rollback guard вводились по отдельности и по отдельности же + * оставляли дыру на своём стыке. Замок защищает production paths, пока ЖИВ + * процесс-держатель. Guard — это отдельный systemd-объект, который переживает + * свой процесс: + * + * A берёт замок -> применяет firewall -> взводит guard на 45s + * A аварийно умирает + * B берёт замок (либо снятый обработчиком сигнала, либо переиспользованный) + * B начинает менять production paths + * guard A срабатывает и возвращает firewall, который был ДО A + * + * Уникальные op-id здесь не помогают: каталоги копий разные, а + * /etc/nftables.conf, /etc/nftables.d/hy2xs.nft и ruleset в ядре — общие. + * + * Поэтому правильное условие для начала новой операции — не «PID предыдущей + * мёртв», а «у предыдущей не осталось исполнителей, способных изменить + * систему». Проверка обязательна при ЛЮБОМ захвате замка, а не только при + * переиспользовании устаревшего: обработчик сигналов снимает замок сам, и в + * этом случае stale-замка просто не будет, а таймер останется. + * + * Сознательно НЕ проверяются `hysteria-server`, `hy2xs-admin` и + * `nftables.service`: незавершённый `systemctl restart` ничего не откатывает, + * он лишь повторяет то, что новая операция сделает сама, а отказ по их + * переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен. + */ +export async function assertNoPendingRollbackGuard(): Promise { + const pending: string[] = []; + + for (const unit of await listRollbackGuardUnits()) { + const state = await readUnitProperty(unit, "ActiveState"); + if ((GUARD_PENDING_STATES as readonly string[]).includes(state)) { + pending.push(`${unit} (${state})`); + } + } + + if (pending.length === 0) { + return; + } + + throw new PendingRecoveryError( + "previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " + + `${pending.join(", ")}. ` + + "Такой guard способен вернуть прежний firewall уже посреди новой операции. " + + "Дождитесь его завершения (окно — 45 секунд с момента применения firewall) и повторите; " + + "состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`." + ); +} + function firewallRollbackIsInactive(context: RuntimeContext): boolean { return ( context.options.skipFirewall || @@ -801,25 +941,32 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise Boolean(candidate) && existsSync(candidate as string)); + + for (const candidate of candidates) { + rmSync(probeDir, { recursive: true, force: true }); + const result = spawnShell(candidate, probe); + if (result.exitCode === 7 && existsSync(join(probeDir, "ok"))) { + return candidate; + } + } + return null; +} + describe("скрипт автоматического отката firewall", () => { const script = buildAutoRollbackScript(OP_ID); test("маркер срабатывания создаётся ПЕРВЫМ действием", () => { - const marker = script.indexOf(`: >"$root/auto-rollback-fired"`); + const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`); const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`); @@ -105,12 +156,61 @@ describe("скрипт автоматического отката firewall", () // выполнения исчезают, и `systemctl stop` для них неотличим от успешного // снятия взведённого таймера. test("маркер создаётся даже когда восстанавливать нечего", () => { - const marker = script.indexOf(`: >"$root/auto-rollback-fired"`); + const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); const earlyExit = script.indexOf("nothing to restore"); expect(marker).toBeLessThan(earlyExit); }); + /** + * Отказ записи маркера обязан входить в учёт rc. + * + * Инвариант фиксации — «маркера нет и юниты inactive => guard не сработал» — + * верен только при дополнительном условии «guard способен записать маркер». + * Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs + * /run, read-only ФС) не влиял ни на что: скрипт успешно восстанавливал + * прежний firewall и завершался кодом 0, юнит уходил в inactive, маркера не + * было — и операция фиксировала успех после реально сработавшего отката. + */ + test("rc объявляется до создания маркера, а не после", () => { + const rcInit = script.indexOf("rc=0"); + const mkdir = script.indexOf('mkdir -p "$root"'); + const marker = script.indexOf(`touch "$root/auto-rollback-fired"`); + + expect(rcInit).toBeGreaterThan(-1); + expect(rcInit).toBeLessThan(mkdir); + expect(rcInit).toBeLessThan(marker); + }); + + test("невозможность записать маркер поднимает код возврата", () => { + expect(script).toContain("failed to access the recovery root"); + expect(script).toContain("failed to create the fired marker"); + }); + + /** + * Маркер создаётся `touch`, а не `: >file`. + * + * Двоеточие — special builtin POSIX: ошибка перенаправления на нём обязана + * завершить неинтерактивный shell целиком. На Debian /bin/sh — это dash, + * который так и делает, поэтому при недоступном /run скрипт умер бы ДО + * восстановления firewall — то есть guard перестал бы делать ровно то, ради + * чего существует. + */ + test("маркер создаётся обычной командой, а не special builtin", () => { + expect(script).toContain('touch "$root/auto-rollback-fired"'); + expect(script).not.toContain(': >"$root/auto-rollback-fired"'); + }); + + // Аварийный канал факта срабатывания: если маркер записать не удалось, юнит + // обязан уйти в failed, а `failed` на пути фиксации успеха запрещён. + test("ранний выход возвращает накопленный код, а не ноль", () => { + const earlyExit = script.indexOf("nothing to restore"); + const tail = script.slice(earlyExit); + + expect(tail).toContain('exit "$rc"'); + expect(script).not.toContain("exit 0"); + }); + test("ошибки не маскируются", () => { expect(script).not.toContain("|| true"); expect(script).not.toContain("2>/dev/null"); @@ -153,6 +253,75 @@ describe("скрипт автоматического отката firewall", () expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/); }); + /** + * Поведенческая проверка раннего пути скрипта. + * + * Запускается ТОЛЬКО ветка «prepared отсутствует»: она заканчивается до + * первой команды восстановления, поэтому ничего в /etc не трогает и + * безопасна на любой машине. Именно в этой ветке живёт исправленный дефект — + * раньше она возвращала жёсткий `exit 0` и теряла факт неудачной записи + * маркера. + * + * Строка `root=` подменяется на временный каталог: это единственное + * изменение, остальные сорок строк — ровно те, что уезжают на сервер. + */ + function runEarlyPath(sandbox: string, root: string): { exitCode: number; stderr: string } | null { + const shell = findShell(sandbox); + if (!shell) { + return null; + } + + const scriptPath = join(sandbox, "run.sh"); + writeFileSync( + scriptPath, + script.replace(/^root='.*'$/m, `root='${root.replace(/\\/g, "/")}'`) + ); + + const result = spawnShell(shell, scriptPath); + return { exitCode: result.exitCode, stderr: result.stderr.toString() }; + } + + test("при доступном /run маркер создаётся, а ранний выход успешен", () => { + const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); + try { + const root = join(sandbox, "rollback"); + const result = runEarlyPath(sandbox, root); + if (!result) { + console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); + return; + } + + expect(existsSync(join(root, "auto-rollback-fired"))).toBe(true); + expect(result.stderr).toContain("nothing to restore"); + expect(result.exitCode).toBe(0); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + + // Ключевой сценарий исправления: маркер записать не удалось, восстановления + // не было — юнит обязан уйти в failed, потому что это аварийный канал факта + // срабатывания, и `failed` запрещает фиксацию успеха. + test("при недоступном /run ранний выход возвращает отказ", () => { + const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-run-")); + try { + // Родитель — файл, поэтому ни mkdir, ни touch выполниться не могут. + const blocker = join(sandbox, "blocker"); + writeFileSync(blocker, "не каталог\n"); + + const result = runEarlyPath(sandbox, join(blocker, "rollback")); + if (!result) { + console.warn("shell is unavailable: skipping the behavioural check of the rollback script"); + return; + } + + expect(result.stderr).toContain("auto-rollback: failed"); + expect(result.exitCode).not.toBe(0); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } + }); + test("разбирается настоящим shell-парсером", () => { const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-")); try { @@ -288,6 +457,68 @@ describe("disarm доказывает снятие guard'а, а не сообщ }); }); +describe("барьер покоя между операциями", () => { + /** + * Стык двух защитных механизмов. Замок защищает production paths, пока жив + * процесс-держатель; rollback guard — отдельный systemd-объект, переживающий + * свой процесс. Аварийно умершая операция оставляет вооружённый guard, + * который возвращает прежний firewall уже посреди следующей операции. + */ + const body = firewallSource.slice( + firewallSource.indexOf("export async function assertNoPendingRollbackGuard"), + firewallSource.indexOf("function firewallRollbackIsInactive") + ); + + test("вооружённый guard предыдущей операции запрещает новую", () => { + expect(body).toContain("PendingRecoveryError"); + expect(body).toContain("readUnitProperty(unit, \"ActiveState\")"); + }); + + // `failed` и `inactive` — покой: guard уже отработал и больше ничего не + // сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он + // нужен для устранения последствий. + test("покоем считаются inactive и failed, а не только inactive", () => { + expect(firewallSource).toContain( + 'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const' + ); + }); + + test("отказ запроса к systemd не выдаётся за наличие guard", () => { + const listing = firewallSource.slice( + firewallSource.indexOf("export async function listRollbackGuardUnits"), + firewallSource.indexOf("export async function assertNoPendingRollbackGuard") + ); + expect(listing).toContain("unable to list firewall rollback guard units"); + expect(listing).toContain("return [];"); + }); + + test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => { + const cliSource = source("cli.ts"); + expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }"); + // Обработчик сигналов снимает замок сам, поэтому у прерванной операции + // stale-замка может не быть вовсе, а таймер останется. + expect(cliSource).toContain("await assertNoPendingRollbackGuard()"); + }); + + // Барьер вызывается дважды: между проверкой и захватом умирающая операция + // успевает вооружить guard. + test("барьер проверяется до и после захвата замка", () => { + const lockSource = source("lib/operationLock.ts"); + const start = lockSource.indexOf("export async function acquireOperationLock"); + const acquire = lockSource.slice(start); + + const before = acquire.indexOf("await options.barrier?.()"); + const write = acquire.indexOf("await writeLockFile(path, record)"); + const after = acquire.indexOf("await options.barrier()"); + + expect(before).toBeGreaterThan(-1); + expect(write).toBeGreaterThan(before); + expect(after).toBeGreaterThan(write); + // Отказ второй проверки не имеет права оставить замок за собой. + expect(acquire.slice(after)).toContain("releaseSync(path, record.nonce)"); + }); +}); + describe("сработавший guard запрещает фиксацию успеха", () => { function ownership(overrides: Record = {}) { return { diff --git a/orchestrator/test/operation-lock.test.ts b/orchestrator/test/operation-lock.test.ts index ad2a95a..bfd1e54 100644 --- a/orchestrator/test/operation-lock.test.ts +++ b/orchestrator/test/operation-lock.test.ts @@ -139,6 +139,86 @@ describe("захват и освобождение", () => { }); }); +describe("барьер покоя при захвате", () => { + /** + * Замок защищает production paths, пока жив процесс-держатель. Rollback guard + * firewall — отдельный systemd-объект, который свой процесс переживает, и + * способен вернуть прежний firewall уже посреди следующей операции. + */ + test("отказ барьера не оставляет замка на сервере", async () => { + await expect( + acquireOperationLock("reconfigure", { + path: lockFile, + pid: LIVE_PID, + barrier: async () => { + throw new Error("guard предыдущей операции всё ещё вооружён"); + } + }) + ).rejects.toThrow("guard предыдущей операции всё ещё вооружён"); + + expect(existsSync(lockFile)).toBe(false); + }); + + test("барьер проверяется до захвата, а не после него", async () => { + let lockExistedAtBarrier: boolean | null = null; + + await expect( + acquireOperationLock("install", { + path: lockFile, + pid: LIVE_PID, + barrier: async () => { + if (lockExistedAtBarrier === null) { + lockExistedAtBarrier = existsSync(lockFile); + } + throw new Error("pending guard"); + } + }) + ).rejects.toThrow("pending guard"); + + expect(lockExistedAtBarrier).toBe(false); + }); + + // Между первой проверкой и захватом умирающая предыдущая операция успевает + // вооружить guard, поэтому проверок две. + test("вторая проверка идёт уже под замком и снимает его при отказе", async () => { + let calls = 0; + const seenUnderLock: boolean[] = []; + + await expect( + acquireOperationLock("repair", { + path: lockFile, + pid: LIVE_PID, + barrier: async () => { + calls += 1; + seenUnderLock.push(existsSync(lockFile)); + if (calls === 2) { + throw new Error("guard вооружён между проверкой и захватом"); + } + } + }) + ).rejects.toThrow("guard вооружён между проверкой и захватом"); + + expect(calls).toBe(2); + expect(seenUnderLock).toEqual([false, true]); + expect(existsSync(lockFile)).toBe(false); + }); + + test("при спокойном барьере замок берётся обычным образом", async () => { + let calls = 0; + const lock = await acquireOperationLock("install", { + path: lockFile, + pid: LIVE_PID, + barrier: async () => { + calls += 1; + } + }); + + expect(calls).toBe(2); + expect(existsSync(lockFile)).toBe(true); + await lock.release(); + }); +}); + describe("замок мёртвого держателя", () => { test("переиспользуется, а не блокирует сервер навсегда", async () => { writeFileSync( @@ -235,7 +315,7 @@ describe("политика замка в CLI", () => { test("мутирующие команды выполняются под замком", () => { for (const command of ["install", "reconfigure", "repair"] as const) { - expect(cliSource).toContain(`await withOperationLock("${command}", () => ${command}(options))`); + expect(cliSource).toContain(`await runLifecycleOperation("${command}", () => ${command}(options))`); } }); @@ -245,7 +325,17 @@ describe("политика замка в CLI", () => { * бессмысленные ошибки по временным несоответствиям. */ test("doctor исключён против мутирующих операций", () => { - expect(cliSource).toContain('await withOperationLock("doctor", () => doctor(options))'); + expect(cliSource).toContain('await runLifecycleOperation("doctor", () => doctor(options))'); + }); + + // Замок и барьер покоя обязаны идти вместе: замок ничего не знает про + // systemd-таймер, переживший своего держателя. + test("захват замка всегда сопровождается барьером покоя", () => { + expect(cliSource).toContain("{ barrier: assertNoPendingRollbackGuard }"); + const direct = cliSource.split("withOperationLock(").length - 1; + // Единственное употребление — внутри runLifecycleOperation: иначе появился + // бы путь захвата замка мимо барьера. + expect(direct).toBe(1); }); // Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 6cba3ec..b5acf32 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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 не существовал.