fix(orchestrator): закрыть два остатка на стыке guard и замка операций
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.
1. Отказ записи `auto-rollback-fired` оставался незамеченным.
Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.
`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.
Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.
2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.
Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:
A берёт замок -> применяет firewall -> вооружает guard на 45s
A аварийно умирает
B берёт замок и начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.
Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.
Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.
Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
This commit is contained in:
@@ -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
|
- **Данные для отката уничтожались до фиксации успеха.** Успешный install
|
||||||
|
|||||||
@@ -693,6 +693,15 @@ another HY2XS operation is already in progress: reconfigure (pid 4242, started a
|
|||||||
|
|
||||||
Замок снимается сам при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH. Если процесс был убит `kill -9`, следующая операция обнаружит мёртвого держателя и переиспользует замок самостоятельно.
|
Замок снимается сам при любом завершении держателя, включая `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-<op-id>.timer (active)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ждать в этом случае нужно не дольше 45 секунд с момента применения firewall.
|
||||||
|
|
||||||
## Реконфигурация
|
## Реконфигурация
|
||||||
|
|
||||||
После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run:
|
После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run:
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ smoke: smoke на медленном, но исправном сервере м
|
|||||||
systemd, `stop` возвращает 5, и этот исход неотличим от успешного снятия
|
systemd, `stop` возвращает 5, и этот исход неотличим от успешного снятия
|
||||||
взведённого таймера.
|
взведённого таймера.
|
||||||
|
|
||||||
|
У факта срабатывания два независимых канала, и это не избыточность. Маркер —
|
||||||
|
обычный. Отказ юнита — аварийный: если записать маркер не удалось (заполненный
|
||||||
|
tmpfs `/run`, read-only ФС), скрипт поднимает код возврата, юнит уходит в
|
||||||
|
`failed`, а `failed` на пути фиксации успеха запрещён так же, как и маркер.
|
||||||
|
Без второго канала инвариант был бы верен лишь при дополнительном условии
|
||||||
|
«guard способен записать маркер», которого никто не гарантирует.
|
||||||
|
|
||||||
Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и
|
Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и
|
||||||
уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он
|
уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он
|
||||||
сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то
|
сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то
|
||||||
@@ -142,6 +149,27 @@ systemd, `stop` возвращает 5, и этот исход неотличи
|
|||||||
active восстанавливает обычный откат в процессе оркестратора, где порядок стадий
|
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
|
### Проверка эффективного firewall
|
||||||
|
|
||||||
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
|
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
|
||||||
|
|||||||
@@ -257,6 +257,18 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
||||||
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
||||||
поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`;
|
поэтому частичное восстановление уходит в `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`, и
|
- скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и
|
||||||
остановка сервиса стёрла бы только что восстановленные правила;
|
остановка сервиса стёрла бы только что восстановленные правила;
|
||||||
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
||||||
@@ -294,6 +306,10 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
|||||||
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
||||||
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
||||||
читающей фазы отказал громко, а не записал файл молча;
|
читающей фазы отказал громко, а не записал файл молча;
|
||||||
|
- барьер покоя вызывается **дважды** — до захвата и уже под замком, — а отказ
|
||||||
|
второй проверки снимает замок за собой. Замок сам по себе гарантии не даёт:
|
||||||
|
он защищает production paths, пока жив держатель, а rollback guard переживает
|
||||||
|
свой процесс;
|
||||||
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
||||||
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
||||||
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
||||||
@@ -1083,14 +1099,61 @@ reconfigure идёт -> diagnostics collect
|
|||||||
→ в stderr есть note об идущей операции
|
→ в stderr есть note об идущей операции
|
||||||
```
|
```
|
||||||
|
|
||||||
Отдельно проверяется, что замок не переживает своего держателя:
|
Отдельно проверяется, что замок не переживает своего держателя. **Важно:**
|
||||||
|
прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий
|
||||||
|
D1h, а не этот.
|
||||||
|
|
||||||
1. `reconfigure --apply` прерывается `Ctrl+C` — замок снят, следующий
|
1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation` —
|
||||||
`reconfigure` проходит;
|
замок снят, следующий `reconfigure` проходит;
|
||||||
2. процесс убивается `kill -9`, после чего следующая операция сообщает
|
2. процесс убивается `kill -9` на том же шаге, после чего следующая операция
|
||||||
`is held by … which is no longer running; reclaiming it` и продолжает;
|
сообщает `is held by … which is no longer running; reclaiming it` и
|
||||||
|
продолжает;
|
||||||
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
|
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-<op-id>.timer (active)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. отказ происходит **до** снятия резервной копии и до первой мутации;
|
||||||
|
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
|
||||||
|
7. после срабатывания guard (`hy2xs-fw-rollback-*` больше не `active`)
|
||||||
|
`repair` проходит.
|
||||||
|
|
||||||
|
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
|
||||||
|
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
|
||||||
|
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
|
||||||
|
|
||||||
## D1g. Успешная установка не оставляет следов транзакции
|
## D1g. Успешная установка не оставляет следов транзакции
|
||||||
|
|
||||||
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
||||||
@@ -1204,6 +1267,11 @@ hy2xs-orchestrator doctor
|
|||||||
- `status`/`diagnostics` не блокируются и сообщают об идущей операции;
|
- `status`/`diagnostics` не блокируются и сообщают об идущей операции;
|
||||||
- замок не переживает своего держателя.
|
- замок не переживает своего держателя.
|
||||||
|
|
||||||
|
4c. **Аварийная смерть с вооружённым guard** (сценарий D1h):
|
||||||
|
- новая операция отказывает, пока `hy2xs-fw-rollback-*` ещё активен, в том
|
||||||
|
числе когда замка не осталось вовсе;
|
||||||
|
- после срабатывания guard `repair` проходит.
|
||||||
|
|
||||||
5. **Partial install + repair**:
|
5. **Partial install + repair**:
|
||||||
- состояние `install-state` фиксирует промежуточную фазу;
|
- состояние `install-state` фиксирует промежуточную фазу;
|
||||||
- `repair` завершает граф до `installed=true`.
|
- `repair` завершает граф до `installed=true`.
|
||||||
@@ -1295,3 +1363,6 @@ hy2xs-orchestrator doctor
|
|||||||
65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил
|
65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил
|
||||||
66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются
|
66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются
|
||||||
67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
|
67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
|
||||||
|
68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв»
|
||||||
|
69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает
|
||||||
|
70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются
|
||||||
|
|||||||
@@ -197,6 +197,33 @@ operation lock … exists but is not a valid HY2XS lock record
|
|||||||
В отчёте `status` при этом появляется поле `operation_in_progress` — читайте
|
В отчёте `status` при этом появляется поле `operation_in_progress` — читайте
|
||||||
состояние как снимок незавершённой транзакции, а не как итог.
|
состояние как снимок незавершённой транзакции, а не как итог.
|
||||||
|
|
||||||
|
### Операция отказывает: guard предыдущей операции ещё вооружён
|
||||||
|
|
||||||
|
```text
|
||||||
|
previous HY2XS operation is no longer running, but its firewall rollback guard
|
||||||
|
is still armed: hy2xs-fw-rollback-<op-id>.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`
|
### Установка отказала с `firewall_guard_fired`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -161,6 +161,22 @@ SIGTERM от systemd и при обрыве SSH. Если процесс был
|
|||||||
operation lock … is held by install (pid 1234), which is no longer running; reclaiming it
|
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-<op-id>.timer (active)
|
||||||
|
```
|
||||||
|
|
||||||
|
Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось
|
||||||
|
исполнителей, способных изменить систему». Ждать нужно не больше 45 секунд с
|
||||||
|
момента применения firewall; `failed` у guard покою не мешает и означает, что
|
||||||
|
пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`.
|
||||||
|
|
||||||
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
||||||
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
||||||
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
||||||
|
|||||||
+19
-4
@@ -10,6 +10,7 @@ import {
|
|||||||
describeOperationInProgress,
|
describeOperationInProgress,
|
||||||
withOperationLock
|
withOperationLock
|
||||||
} from "./lib/operationLock";
|
} from "./lib/operationLock";
|
||||||
|
import { assertNoPendingRollbackGuard } from "./steps/firewall";
|
||||||
import type { InstallOptions, ReconfigureOptions } from "./types/context";
|
import type { InstallOptions, ReconfigureOptions } from "./types/context";
|
||||||
|
|
||||||
function usage(): never {
|
function usage(): never {
|
||||||
@@ -292,18 +293,32 @@ function parsePreflightInstallOptions(args: string[]): InstallOptions {
|
|||||||
* потратил бы время на проверки и получил
|
* потратил бы время на проверки и получил
|
||||||
* отказ уже после exec;
|
* отказ уже после exec;
|
||||||
* redact-config — работает с файлом, а не с сервером.
|
* redact-config — работает с файлом, а не с сервером.
|
||||||
|
*
|
||||||
|
* Замка при этом НЕДОСТАТОЧНО, и это второе, что здесь собрано в одном месте.
|
||||||
|
* Замок защищает production paths, пока жив процесс-держатель, а rollback guard
|
||||||
|
* firewall — отдельный systemd-объект, переживающий свой процесс. Аварийно
|
||||||
|
* умершая операция оставляет вооружённый guard, который способен вернуть
|
||||||
|
* прежний firewall уже посреди следующей. Поэтому каждый захват замка проходит
|
||||||
|
* через барьер покоя, а не только через проверку живости держателя.
|
||||||
*/
|
*/
|
||||||
|
async function runLifecycleOperation<T>(command: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
return await withOperationLock(command, run, { barrier: assertNoPendingRollbackGuard });
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const [command, ...args] = Bun.argv.slice(2);
|
const [command, ...args] = Bun.argv.slice(2);
|
||||||
if (command === "preflight-install") {
|
if (command === "preflight-install") {
|
||||||
const options = parsePreflightInstallOptions(args);
|
const options = parsePreflightInstallOptions(args);
|
||||||
await assertNoOperationInProgress("install preflight");
|
await assertNoOperationInProgress("install preflight");
|
||||||
|
// PHASE 0 отказывает по тем же двум причинам, что и сама операция: чужой
|
||||||
|
// живой держатель замка и вооружённый guard предыдущей операции.
|
||||||
|
await assertNoPendingRollbackGuard();
|
||||||
await preflightInstall(options);
|
await preflightInstall(options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "install") {
|
if (command === "install") {
|
||||||
const options = parseInstallOptions(args);
|
const options = parseInstallOptions(args);
|
||||||
await withOperationLock("install", () => install(options));
|
await runLifecycleOperation("install", () => install(options));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "reconfigure") {
|
if (command === "reconfigure") {
|
||||||
@@ -312,17 +327,17 @@ async function main(): Promise<void> {
|
|||||||
console.error("--allow-partial-state is only valid for `repair`");
|
console.error("--allow-partial-state is only valid for `repair`");
|
||||||
usage();
|
usage();
|
||||||
}
|
}
|
||||||
await withOperationLock("reconfigure", () => reconfigure(options));
|
await runLifecycleOperation("reconfigure", () => reconfigure(options));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "repair") {
|
if (command === "repair") {
|
||||||
const options = parseReconfigureOptions(["--apply", ...args]);
|
const options = parseReconfigureOptions(["--apply", ...args]);
|
||||||
await withOperationLock("repair", () => repair(options));
|
await runLifecycleOperation("repair", () => repair(options));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "doctor") {
|
if (command === "doctor") {
|
||||||
const options = parseReconfigureOptions(["--dry-run", ...args]);
|
const options = parseReconfigureOptions(["--dry-run", ...args]);
|
||||||
await withOperationLock("doctor", () => doctor(options));
|
await runLifecycleOperation("doctor", () => doctor(options));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "status") {
|
if (command === "status") {
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ export type LockOptions = {
|
|||||||
isProcessAlive?: (pid: number) => boolean;
|
isProcessAlive?: (pid: number) => boolean;
|
||||||
/** PID текущего процесса. Переопределяется тестами. */
|
/** PID текущего процесса. Переопределяется тестами. */
|
||||||
pid?: number;
|
pid?: number;
|
||||||
|
/**
|
||||||
|
* Барьер покоя: проверка, что у предыдущей операции не осталось асинхронных
|
||||||
|
* исполнителей, способных изменить систему.
|
||||||
|
*
|
||||||
|
* Замок сам по себе такой гарантии не даёт и дать не может. Он защищает
|
||||||
|
* production paths, пока жив процесс-держатель, а rollback guard firewall —
|
||||||
|
* отдельный systemd-объект, который свой процесс переживает. Поэтому
|
||||||
|
* условие начала операции не «PID предыдущей мёртв», а «предыдущая больше не
|
||||||
|
* имеет исполнителей».
|
||||||
|
*
|
||||||
|
* Вызывается ДВАЖДЫ — до попытки захвата и сразу после успешного: между
|
||||||
|
* этими моментами умирающая предыдущая операция успевает вооружить guard.
|
||||||
|
*/
|
||||||
|
barrier?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderLockRecord(record: LockRecord): string {
|
export function renderLockRecord(record: LockRecord): string {
|
||||||
@@ -337,12 +351,28 @@ export async function acquireOperationLock(
|
|||||||
nonce: newNonce()
|
nonce: newNonce()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Барьер до захвата: отказать раньше, чем на сервере появится наш замок.
|
||||||
|
await options.barrier?.();
|
||||||
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
if (await writeLockFile(path, record)) {
|
if (await writeLockFile(path, record)) {
|
||||||
installExitHandlers();
|
installExitHandlers();
|
||||||
heldLocks.set(path, record.nonce);
|
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})`);
|
info(`operation lock acquired: ${path} (${command}, pid ${pid})`);
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -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 {
|
function rollbackRoot(opId: string): string {
|
||||||
return `/run/hy2xs/rollback/${opId}`;
|
return `/run/hy2xs/rollback/${opId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rollbackUnit(opId: string): string {
|
function rollbackUnit(opId: string): string {
|
||||||
return `hy2xs-fw-rollback-${opId}`;
|
return `${ROLLBACK_UNIT_PREFIX}${opId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +346,26 @@ export async function detectFirewallEntrypointKind(): Promise<FirewallEntrypoint
|
|||||||
* оставалась успешная запись. Теперь каждая стадия независима, её отказ
|
* оставалась успешная запись. Теперь каждая стадия независима, её отказ
|
||||||
* поднимает `rc`, и юнит честно уходит в `failed` с диагностикой в journal.
|
* поднимает `rc`, и юнит честно уходит в `failed` с диагностикой в journal.
|
||||||
*
|
*
|
||||||
|
* 3. Создание маркера входит в учёт `rc`, и это не мелочь, а второе плечо
|
||||||
|
* инварианта фиксации. Инвариант
|
||||||
|
*
|
||||||
|
* маркер отсутствует И юниты inactive => guard не сработал
|
||||||
|
*
|
||||||
|
* верен только при дополнительном условии «guard способен записать маркер».
|
||||||
|
* Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный
|
||||||
|
* tmpfs /run, read-only ФС, ошибка ввода-вывода) не влиял ни на что: скрипт
|
||||||
|
* успешно восстанавливал прежний firewall и завершался кодом 0, юнит уходил
|
||||||
|
* в `inactive`, маркера не было — и операция фиксировала успех после
|
||||||
|
* РЕАЛЬНО сработавшего отката.
|
||||||
|
*
|
||||||
|
* Теперь у факта срабатывания два независимых канала:
|
||||||
|
*
|
||||||
|
* маркер — обычный;
|
||||||
|
* отказ юнита — аварийный, когда маркер записать не удалось.
|
||||||
|
*
|
||||||
|
* Второй работает потому, что на пути фиксации успеха допустим ровно один
|
||||||
|
* `ActiveState` — `inactive`, а `failed` фиксацию запрещает.
|
||||||
|
*
|
||||||
* Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у
|
* Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у
|
||||||
* этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса
|
* этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса
|
||||||
* стёрла бы только что восстановленные правила — прямо противоположно задаче
|
* стёрла бы только что восстановленные правила — прямо противоположно задаче
|
||||||
@@ -344,18 +389,38 @@ export function buildAutoRollbackScript(opId: string): string {
|
|||||||
|
|
||||||
root='${root}'
|
root='${root}'
|
||||||
|
|
||||||
|
# rc объявляется ДО первой операции, включая создание маркера срабатывания.
|
||||||
|
#
|
||||||
|
# Иначе отказ записи маркера не влиял бы ни на что: скрипт успешно восстановил
|
||||||
|
# бы прежний firewall и завершился кодом 0, а операция, не увидев маркера и
|
||||||
|
# увидев inactive-юнит, зафиксировала бы успех после реально сработавшего
|
||||||
|
# отката. Отказ юнита — аварийный канал того же факта.
|
||||||
|
rc=0
|
||||||
|
|
||||||
# Маркер срабатывания — первым действием, до любой проверки. Операция обязана
|
# Маркер срабатывания — первым действием, до любой проверки. Операция обязана
|
||||||
# узнать, что guard сработал, даже если восстановление ниже не удалось.
|
# узнать, что guard сработал, даже если восстановление ниже не удалось.
|
||||||
mkdir -p "$root"
|
if ! mkdir -p "$root"; then
|
||||||
: >"$root/${AUTO_ROLLBACK_FIRED_MARKER}"
|
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
|
if [ ! -f "$root/prepared" ]; then
|
||||||
echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2
|
echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2
|
||||||
exit 0
|
exit "$rc"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rc=0
|
|
||||||
|
|
||||||
# $1 — маркер существования, $2 — резервная копия, $3 — целевой путь.
|
# $1 — маркер существования, $2 — резервная копия, $3 — целевой путь.
|
||||||
restore_file() {
|
restore_file() {
|
||||||
if [ -f "$1" ]; then
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
function firewallRollbackIsInactive(context: RuntimeContext): boolean {
|
||||||
return (
|
return (
|
||||||
context.options.skipFirewall ||
|
context.options.skipFirewall ||
|
||||||
@@ -801,25 +941,32 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
|
|||||||
info("nftables.service state was not captured for this operation; unit file state is left as is");
|
info("nftables.service state was not captured for this operation; unit file state is left as is");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Восстанавливается ровно то, что операция могла изменить, и ровно так,
|
||||||
|
// как это можно сделать достоверно.
|
||||||
|
//
|
||||||
|
// Единственная мутация этого юнита в applyFirewall — постоянный
|
||||||
|
// `systemctl enable --now nftables`. Её точная отмена существует для двух
|
||||||
|
// состояний: `enabled` (ничего менять не нужно) и `disabled` (убрать
|
||||||
|
// добавленную нами постоянную ссылку).
|
||||||
|
//
|
||||||
|
// Остальные состояния сознательно НЕ трогаются, и это исправление
|
||||||
|
// прежнего поведения, а не пропуск. `enable --runtime` не удаляет
|
||||||
|
// постоянную ссылку, поэтому «восстановление» `enabled-runtime` таким
|
||||||
|
// вызовом оставляло юнит включённым в обоих scope'ах — то есть обещало
|
||||||
|
// точность, которой не давало. `masked`/`masked-runtime` восстанавливать
|
||||||
|
// не нужно вовсе: на замаскированном юните `enable --now` отказывает, и
|
||||||
|
// операция падает, ничего не изменив.
|
||||||
switch (serviceState.unitFileState) {
|
switch (serviceState.unitFileState) {
|
||||||
case "enabled":
|
case "enabled":
|
||||||
await runMutatingVisible`systemctl enable nftables`;
|
await runMutatingVisible`systemctl enable nftables`;
|
||||||
return;
|
return;
|
||||||
case "enabled-runtime":
|
|
||||||
await runMutatingVisible`systemctl enable --runtime nftables`;
|
|
||||||
return;
|
|
||||||
case "disabled":
|
case "disabled":
|
||||||
await runMutatingVisible`systemctl disable nftables`;
|
await runMutatingVisible`systemctl disable nftables`;
|
||||||
return;
|
return;
|
||||||
case "masked":
|
|
||||||
case "masked-runtime":
|
|
||||||
await runMutatingVisible`systemctl mask nftables`;
|
|
||||||
return;
|
|
||||||
default:
|
default:
|
||||||
// static/indirect/generated/transient/пусто: у таких юнитов
|
|
||||||
// enable/disable либо бессмысленны, либо отказывают.
|
|
||||||
info(
|
info(
|
||||||
`nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is not restorable explicitly; skipped`
|
`nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is left as is: ` +
|
||||||
|
"точное восстановление этого состояния не гарантируется, а операция не могла его изменить"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { delimiter, dirname, join } from "node:path";
|
||||||
import { classifyFailure } from "../src/commands/install";
|
import { classifyFailure } from "../src/commands/install";
|
||||||
import {
|
import {
|
||||||
FirewallGuardFiredError,
|
FirewallGuardFiredError,
|
||||||
@@ -88,11 +88,62 @@ function findShellParser(sandbox: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает скрипт кандидатом-shell.
|
||||||
|
*
|
||||||
|
* Каталог самого shell добавляется в PATH: Git for Windows кладёт bash и
|
||||||
|
* coreutils рядом, но при запуске напрямую из процесса PATH этого каталога не
|
||||||
|
* содержит, и `mkdir`/`touch` внутри скрипта оказываются не найдены. Это
|
||||||
|
* особенность окружения, а не скрипта, и тест не должен на неё падать.
|
||||||
|
*/
|
||||||
|
function spawnShell(shell: string, scriptPath: string) {
|
||||||
|
return Bun.spawnSync([shell, scriptPath], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...process.env, PATH: `${dirname(shell)}${delimiter}${process.env.PATH ?? ""}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет shell, которому можно доверить ЗАПУСК скрипта.
|
||||||
|
*
|
||||||
|
* Проба самопроверяющая и намеренно строгая: кандидат обязан вернуть код
|
||||||
|
* возврата скрипта И создать файл, который тест затем видит на своей файловой
|
||||||
|
* системе. Одной проверки кода возврата мало — `C:\Windows\system32\bash.exe`
|
||||||
|
* это launcher WSL, он честно выполнит `exit 7`, но в совершенно другом
|
||||||
|
* пространстве имён путей, и поведенческий тест ниже проверял бы не скрипт.
|
||||||
|
*/
|
||||||
|
function findShell(sandbox: string): string | null {
|
||||||
|
const probeDir = join(sandbox, "probe-dir");
|
||||||
|
const probe = join(sandbox, "probe.sh");
|
||||||
|
writeFileSync(
|
||||||
|
probe,
|
||||||
|
`mkdir -p '${probeDir.replace(/\\/g, "/")}'\ntouch '${probeDir.replace(/\\/g, "/")}/ok'\nexit 7\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
Bun.which("dash"),
|
||||||
|
Bun.which("sh"),
|
||||||
|
Bun.which("bash"),
|
||||||
|
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||||
|
"E:\\Git\\usr\\bin\\bash.exe"
|
||||||
|
].filter((candidate): candidate is string => 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", () => {
|
describe("скрипт автоматического отката firewall", () => {
|
||||||
const script = buildAutoRollbackScript(OP_ID);
|
const script = buildAutoRollbackScript(OP_ID);
|
||||||
|
|
||||||
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
|
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 prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`);
|
||||||
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
|
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
|
||||||
|
|
||||||
@@ -105,12 +156,61 @@ describe("скрипт автоматического отката firewall", ()
|
|||||||
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
|
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
|
||||||
// снятия взведённого таймера.
|
// снятия взведённого таймера.
|
||||||
test("маркер создаётся даже когда восстанавливать нечего", () => {
|
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");
|
const earlyExit = script.indexOf("nothing to restore");
|
||||||
|
|
||||||
expect(marker).toBeLessThan(earlyExit);
|
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("ошибки не маскируются", () => {
|
test("ошибки не маскируются", () => {
|
||||||
expect(script).not.toContain("|| true");
|
expect(script).not.toContain("|| true");
|
||||||
expect(script).not.toContain("2>/dev/null");
|
expect(script).not.toContain("2>/dev/null");
|
||||||
@@ -153,6 +253,75 @@ describe("скрипт автоматического отката firewall", ()
|
|||||||
expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/);
|
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-парсером", () => {
|
test("разбирается настоящим shell-парсером", () => {
|
||||||
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
|
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
|
||||||
try {
|
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 запрещает фиксацию успеха", () => {
|
describe("сработавший guard запрещает фиксацию успеха", () => {
|
||||||
function ownership(overrides: Record<string, boolean> = {}) {
|
function ownership(overrides: Record<string, boolean> = {}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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("замок мёртвого держателя", () => {
|
describe("замок мёртвого держателя", () => {
|
||||||
test("переиспользуется, а не блокирует сервер навсегда", async () => {
|
test("переиспользуется, а не блокирует сервер навсегда", async () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -235,7 +315,7 @@ describe("политика замка в CLI", () => {
|
|||||||
|
|
||||||
test("мутирующие команды выполняются под замком", () => {
|
test("мутирующие команды выполняются под замком", () => {
|
||||||
for (const command of ["install", "reconfigure", "repair"] as const) {
|
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 исключён против мутирующих операций", () => {
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды
|
// Отказ обязан произойти ДО первой мутации. Замок оборачивает вызов команды
|
||||||
|
|||||||
@@ -1344,6 +1344,25 @@ run_transaction_boundary_acceptance() {
|
|||||||
if (failures === 0 || failures !== raised) {
|
if (failures === 0 || failures !== raised) {
|
||||||
throw new Error("не каждый сообщённый отказ поднимает код возврата: " + 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`: остановка сервиса
|
// ExecStop у nftables.service делает `nft flush ruleset`: остановка сервиса
|
||||||
// внутри guard стёрла бы только что восстановленные правила.
|
// внутри guard стёрла бы только что восстановленные правила.
|
||||||
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
|
if (script.includes("systemctl")) throw new Error("guard трогает состояние nftables.service");
|
||||||
@@ -1478,6 +1497,74 @@ run_transaction_boundary_acceptance() {
|
|||||||
}
|
}
|
||||||
' || fail "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"
|
log_step "Acceptance: the operation key has a single source"
|
||||||
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
|
# install писал в маркер сырой ISO-timestamp с двоеточиями, а каталог отката
|
||||||
# назывался санитизированным ключом: путь из runbook не существовал.
|
# назывался санитизированным ключом: путь из runbook не существовал.
|
||||||
|
|||||||
Reference in New Issue
Block a user